From c24472b91fda299bdcfc84cfbe01aebada672596 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Sun, 4 Oct 2026 15:12:19 +0200 Subject: [PATCH] =?UTF-8?q?Moteur=20:=20encart=20=C2=AB=20version=20recomm?= =?UTF-8?q?and=C3=A9e=20=C2=BB,=20retour=20=C3=A0=20la=20version=20pr?= =?UTF-8?q?=C3=A9c=C3=A9dente,=20garde=20du=20rendu=20du=20raisonnement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- README.md | 34 ++ internal/loki/chat_tplprobe.go | 133 ++++++- internal/loki/chat_tplprobe_test.go | 13 +- internal/loki/ui/index.html | 134 ++++++- internal/loki/ui/src/index.tmpl.html | 15 + internal/loki/ui/src/js/03-ui-kit.js | 3 +- internal/loki/ui/src/js/10-llamacpp.js | 111 +++++- internal/loki/ui/src/styles.css | 5 + internal/loki/web_engine.go | 55 ++- internal/loki/web_engine_reco.go | 476 +++++++++++++++++++++++++ internal/loki/web_engine_reco_test.go | 386 ++++++++++++++++++++ internal/loki/web_server.go | 12 +- 12 files changed, 1343 insertions(+), 34 deletions(-) create mode 100644 internal/loki/web_engine_reco.go create mode 100644 internal/loki/web_engine_reco_test.go diff --git a/README.md b/README.md index 4b23ee9..ce190a3 100644 --- a/README.md +++ b/README.md @@ -927,6 +927,40 @@ courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intac Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus. +**Version recommandée.** Quand le moteur officiel qui tourne est antérieur à +`b10864`, le panneau affiche un encart qui dit ce que la mise à jour apporte : +les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next) ne sont plus +évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise —, le MTP +rapide (graphes CUDA) arrive à `b11009` et le MTP de qwen4exp à `b11331`. Le +décodage spéculatif reste coupé tant que `SPEC` n'est pas posé. L'encart se +décide sans réseau, sur le build du moteur (jamais pour un llama.cpp compilé ou +un fork, dont le numéro ne prouve rien). Le bouton **Mettre à jour vers la +version recommandée** n'interroge le registre qu'au clic : il prend le dernier +build publié, vérifie qu'il atteint `b10864` et que son tag figé +(`server-cuda-bNNNNN`) existe bien, puis demande confirmation. Sans réseau, il +le dit et n'installe rien. Rien n'est jamais mis à jour d'office, ni au +démarrage. + +**Revenir à la version précédente.** Après une mise à jour, la version +téléchargée qui tournait juste avant est gardée (les plus anciennes partent, +~450 Mo chacune) ; le bouton **Revenir à la version précédente** y ramène sans +réseau, et la version quittée devient à son tour « la précédente ». + +**Le rendu du raisonnement.** Depuis `b10763`, llama-server garde par défaut la +réflexion des tours passés dans le gabarit (`preserve_reasoning`). Un gabarit +qui la retirait de lui-même (Qwen3.6, gabarits à `clear_thinking`) la rend alors +— vide, puisque Loki ne la renvoie pas sans `REASONING_ECHO` : le modèle +relirait un historique où il n'a jamais réfléchi. Avant de basculer vers un tel +moteur, Loki lit le gabarit chargé et le verdict de la sonde de gabarit ; si le +prompt rendu va changer et que `REASONING_PRESERVE` n'est pas posé, la +confirmation l'explique et propose de poser `REASONING_PRESERVE=off` dans le +preset actif (case cochée seulement quand le changement est établi : +sur un gabarit qui garde déjà la réflexion, comme Qwen3.8, `off` changerait à +son tour le rendu). L'utilisateur choisit. Après la bascule, le rendu de +quelques conversations synthétiques est comparé, via `/apply-template`, entre +l'ancien et le nouveau moteur une fois le modèle chargé : le panneau dit s'il +est identique, ou montre le premier écart. + Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io : `LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur. diff --git a/internal/loki/chat_tplprobe.go b/internal/loki/chat_tplprobe.go index 9cd98ee..3a89001 100644 --- a/internal/loki/chat_tplprobe.go +++ b/internal/loki/chat_tplprobe.go @@ -15,6 +15,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" ) // Sonde de gabarit : que fait le gabarit de chat du modèle chargé de ce qu'on @@ -176,8 +177,33 @@ var ( tplProbeRunning bool tplProbeTried time.Time tplProbeLogged string + // tplProbeShapeLast : la forme de la dernière requête sondée. C'est elle + // que rejoue la vérification du rendu autour d'une mise à jour du moteur + // (web_engine_reco.go) : mêmes outils, mêmes chat_template_kwargs que les + // vraies requêtes, sans rien garder de leur texte. + tplProbeShapeLast *tplShape ) +// tplLastShape : la forme de la dernière requête sondée, ou une requête nue +// (sans outils ni kwargs) si aucune complétion n'a encore eu lieu. +func tplLastShape() tplShape { + tplProbeMu.Lock() + defer tplProbeMu.Unlock() + if tplProbeShapeLast != nil { + return *tplProbeShapeLast + } + return newTplShape(nil, nil, "") +} + +// tplLocalProber : le prober du moteur local, comme celui de la sonde. +func tplLocalProber() tplProber { + return tplProber{ + base: fmt.Sprintf("http://localhost:%d", LLMPort()), + auth: authHeader, + client: &http.Client{Timeout: tplProbeTimeout}, + } +} + // tplProbeKick : appelé par runChat après une complétion COMPLÈTE du fil // principal, sur le moteur local, avec ce qu'elle a envoyé. Ne bloque jamais : // au plus une sonde en vol, au plus une vérification par minute (la plupart se @@ -239,11 +265,11 @@ func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult { if resolveChatEndpoint().External { return tplUnknownResult("preset externe : pas de sonde") } - p := tplProber{ - base: fmt.Sprintf("http://localhost:%d", LLMPort()), - auth: authHeader, - client: &http.Client{Timeout: tplProbeTimeout}, - } + tplProbeMu.Lock() + s := shape + tplProbeShapeLast = &s + tplProbeMu.Unlock() + p := tplLocalProber() if !p.healthy(ctx) { // Moteur absent ou en chargement : rien à conclure, rien à ranger. return tplUnknownResult("moteur pas prêt (/health)") @@ -561,3 +587,100 @@ func (p tplProber) probe(ctx context.Context, shape tplShape) tplProbeResult { r.cacheable = definitive return r } + +// --- Rendu comparé d'un moteur à l'autre ---------------------------------------- +// +// Une mise à jour du moteur peut changer le prompt rendu sans qu'aucun réglage +// de Loki ne bouge : depuis b10763, llama-server active preserve_reasoning par +// défaut, et un gabarit qui ne gardait pas la réflexion des tours passés +// (Qwen3.6) se met à la rendre — vide, puisque Loki ne la renvoie pas sans +// REASONING_ECHO. Le modèle relirait alors un historique où il n'a jamais +// réfléchi. tplRenderPrint fige le rendu de quelques conversations +// synthétiques ; le même relevé avant et après la bascule dit si le prompt a +// changé, et où. + +// tplRenderSep sépare les rendus dans l'empreinte ; le nom qui suit dit de +// quelle conversation vient un écart. +const tplRenderSep = "\n\x00--- " + +// tplRenderPrint : les rendus, concaténés, de trois conversations synthétiques +// sur la forme donnée — l'historique tel que Loki l'envoie par défaut (sans +// reasoning_content), le même avec reasoning_content (REASONING_ECHO), et un +// tour d'outil suivi d'une question, amorce de réponse comprise. Seul +// /apply-template est appelé : sans état, sans slot, sans effet sur le cache. +func tplRenderPrint(ctx context.Context, p tplProber, shape tplShape) (string, error) { + sys := tplMsg{Role: "system", Content: tplMarkSys} + userA := tplMsg{Role: "user", Content: tplMarkA} + userB := tplMsg{Role: "user", Content: tplMarkB} + name := shape.first + if name == "" { + name = "lookup" + } + convs := []struct { + name string + msgs []tplMsg + gen bool + }{ + {"historique sans raisonnement", []tplMsg{sys, userA, {Role: "assistant", Content: tplMarkC1}, userB}, true}, + {"historique avec raisonnement", []tplMsg{sys, userA, + {Role: "assistant", Content: tplMarkC1, ReasoningContent: tplMarkR1}, userB}, true}, + {"tour d'outil", []tplMsg{sys, userA, + {Role: "assistant", Content: tplMarkC1, + ToolCalls: []ToolCall{{ID: tplCallID, Type: "function", Function: ToolCallFunc{Name: name, Arguments: "{}"}}}}, + {Role: "tool", Content: tplMarkT1, ToolCallID: tplCallID}, userB}, true}, + } + var b strings.Builder + for _, c := range convs { + out, err := p.render(ctx, shape, c.msgs, c.gen) + if err != nil { + return "", fmt.Errorf("%s : %w", c.name, err) + } + b.WriteString(tplRenderSep + c.name + " ---\n") + b.WriteString(out) + } + return b.String(), nil +} + +// tplRenderDiff compare deux empreintes de tplRenderPrint. Identiques : true. +// Sinon, la conversation où elles divergent et un court extrait de part et +// d'autre du premier écart (texte synthétique : aucun contenu de discussion). +func tplRenderDiff(before, after string) (bool, string) { + if before == after { + return true, "" + } + i := 0 + for i < len(before) && i < len(after) && before[i] == after[i] { + i++ + } + section := "" + if k := strings.LastIndex(before[:i], tplRenderSep); k >= 0 { + rest := before[k+len(tplRenderSep):] + if e := strings.Index(rest, " ---"); e >= 0 { + section = rest[:e] + } + } + excerpt := func(s string) string { + from := max(0, i-40) + to := min(len(s), i+60) + // Bornes sur des débuts de caractère : un extrait ne coupe pas un rune. + for from > 0 && !utf8.RuneStart(s[from]) { + from-- + } + for to < len(s) && !utf8.RuneStart(s[to]) { + to++ + } + x := strings.NewReplacer("\n", "⏎", "\x00", "").Replace(s[from:to]) + if from > 0 { + x = "…" + x + } + if to < len(s) { + x += "…" + } + return x + } + d := fmt.Sprintf("avant « %s » / après « %s »", excerpt(before), excerpt(after)) + if section != "" { + d = section + " : " + d + } + return false, d +} diff --git a/internal/loki/chat_tplprobe_test.go b/internal/loki/chat_tplprobe_test.go index ae34c26..97bee65 100644 --- a/internal/loki/chat_tplprobe_test.go +++ b/internal/loki/chat_tplprobe_test.go @@ -33,6 +33,7 @@ func freshTplProbe(t *testing.T) { tplProbeRunning = false tplProbeTried = time.Time{} tplProbeLogged = "" + tplProbeShapeLast = nil tplProbeMu.Unlock() } reset() @@ -51,6 +52,8 @@ func freshTplProbe(t *testing.T) { type fakeTpl struct { mu sync.Mutex mode string + tpl string // texte du gabarit annoncé par /props (défaut : mode) + build string // build_info annoncé par /props (défaut : b7000-abc) key string // Bearer exigé hors /health applyStatus []int // statuts imposés aux prochains /apply-template (0 = rendu) applyBody string @@ -116,7 +119,15 @@ func (f *fakeTpl) handler(w http.ResponseWriter, r *http.Request) { } switch r.URL.Path { case "/props": - fmt.Fprintf(w, `{"build_info":"b7000-abc","model_path":"/models/Qwen3-8B-Q8_0.gguf","chat_template":%q}`, f.mode) + tpl := f.tpl + if tpl == "" { + tpl = f.mode + } + build := f.build + if build == "" { + build = "b7000-abc" + } + fmt.Fprintf(w, `{"build_info":%q,"model_path":"/models/Qwen3-8B-Q8_0.gguf","chat_template":%q}`, build, tpl) case "/apply-template": var body map[string]any _ = json.NewDecoder(r.Body).Decode(&body) diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 0615008..8f0069e 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1073,6 +1073,11 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} de build et un hash de commit se recopient et se comparent à l'œil, et « AAAA111 » n'est pas le commit « aaaa111 ». Le monospace vient de là aussi. */ .eng-ver{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--text)} +/* Encart « version recommandée » du panneau Moteur : un conseil, pas une alerte + — bordure discrète, liste serrée des gains. */ +.eng-reco{margin-top:8px;padding:8px 10px;border:1px solid var(--border);border-radius:4px;background:var(--panel)} +.eng-reco ul{margin:4px 0 4px 16px;padding:0} +.eng-reco li{margin:1px 0} .mcp-tools{font-size:10px;color:var(--accent)} .mcp-err{font-size:10px;color:var(--err)} .mcp-switch{flex:none} @@ -2772,6 +2777,20 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Moteur llama.cpp
+ + +
+
@@ -3947,7 +3967,8 @@ function _openAsk(kind, message, opts){ const chk=document.getElementById('ask-check'); chk.style.display = opts.check ? 'inline-flex' : 'none'; document.getElementById('ask-check-label').textContent = opts.check || ''; - document.getElementById('ask-check-input').checked = false; + // opts.checked : case cochée d'office (le choix sûr), toujours décochable. + document.getElementById('ask-check-input').checked = !!opts.checked; _askCheck=false; const cancel=document.getElementById('ask-cancel'), ok=document.getElementById('ask-ok'); cancel.style.display = kind==='alert' ? 'none' : ''; @@ -8413,26 +8434,80 @@ async function loadEngine(){ prebuilt:'précompilé', custom:'personnalisé'}[s.source] || ''; const repo = document.getElementById('eng-repo'); if(repo) repo.textContent = s.repo + ':' + (s.variant || 'server'); + // Version précédente (celle qui tournait avant la dernière bascule) : un + // bouton à elle, sans réseau. Les autres boutons ne la répètent pas. + const prev = s.previous || null; + const prevBtn = document.getElementById('eng-prev'); + if(prevBtn){ + prevBtn.style.display = prev ? '' : 'none'; + if(prev) prevBtn.textContent = 'Revenir à la version précédente' + + (prev.source === 'image' ? ' (moteur de l\'image' + (prev.build ? ', b'+prev.build : '') + ')' + : (prev.build ? ' (b'+prev.build+')' : '')); + } // Le retour arrière n'a de sens que si on n'est pas déjà dessus. const revert = document.getElementById('eng-revert'); - if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none'; + if(revert) revert.style.display = (s.image_bin && s.source !== 'image' && !(prev && prev.source === 'image')) ? '' : 'none'; // Versions téléchargées gardées à côté (la précédente survit à une mise à // jour) : revenir dessus ne demande ni réseau ni que le tag soit encore publié. const others = document.getElementById('eng-others'); - if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use).map(v=>{ + if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use && !(prev && prev.tag === v.tag)).map(v=>{ const tag = String(v.tag).replace(/[^A-Za-z0-9._-]/g,''); return ''; }).join(' '); // Build trop ancien pour les points de reprise des hybrides (voir // engineBuildNotice) : un avis, rien n'est changé d'office ici. + // L'encart « version recommandée » reprend l'avis : il le remplace quand il + // est affiché. const notice = document.getElementById('eng-notice'); if(notice){ notice.textContent = s.notice ? '⚠ '+s.notice : ''; - notice.style.display = s.notice ? '' : 'none'; + notice.style.display = (s.notice && !s.recommend) ? '' : 'none'; } + // Encart « version recommandée » : décidé côté serveur sans réseau ; le tag + // n'est choisi qu'au clic (engRecommended). + const reco = document.getElementById('eng-reco'); + if(reco){ + const r = s.recommend; + reco.style.display = r ? '' : 'none'; + if(r){ + document.getElementById('eng-reco-min').textContent = r.min; + document.getElementById('eng-reco-cur').textContent = r.current; + document.getElementById('eng-reco-gains').innerHTML = (r.gains||[]) + .map(g=>'
  • '+String(g).replace(/[<>&]/g,'')+'
  • ').join(''); + } + } + engRenderCheck(s.render_check); engSay(''); } +// Rendu du gabarit comparé avant/après la dernière bascule (web_engine_reco.go). +// « pending » : le nouveau moteur charge encore le modèle — on repasse plus +// tard tant que le panneau est ouvert. +let engRenderTimer = null; +function engRenderCheck(c){ + const el = document.getElementById('eng-render'); + if(!el) return; + const esc = x=>String(x||'').replace(/[<>&]/g,''); + if(!c){ el.style.display = 'none'; return; } + el.style.display = ''; + el.className = c.status === 'changed' ? '' : 'muted'; + if(c.status === 'pending'){ + el.innerHTML = '⏳ vérification du rendu du gabarit après '+esc(c.tag)+' (une fois le modèle chargé)…'; + if(!engRenderTimer) engRenderTimer = setTimeout(()=>{ + engRenderTimer = null; + const pane = document.getElementById('lc-details'); + if(pane && pane.offsetParent !== null) loadEngine(); + }, 10000); + } else if(c.status === 'same'){ + el.innerHTML = '✓ rendu du gabarit identique avant et après '+esc(c.tag); + } else if(c.status === 'changed'){ + el.innerHTML = '⚠ le prompt rendu a changé avec '+esc(c.tag)+' — '+esc(c.detail) + + '
    Si ce sont des blocs de réflexion vides dans l\'historique, REASONING_PRESERVE=off dans le preset rétablit le rendu d\'avant ; « Revenir à la version précédente » annule la mise à jour.'; + } else { + el.innerHTML = 'rendu du gabarit non vérifié après '+esc(c.tag)+(c.detail ? ' : '+esc(c.detail) : ''); + } +} + // engSay écrit sous la ligne d'état : c'est le résultat d'une vérification, qui // doit rester lisible après le toast (on ne se souvient pas d'un toast). function engSay(html, cls){ @@ -8458,17 +8533,58 @@ async function engCheck(){ } } -async function engUpdate(){ - if(!await askConfirm('Télécharger la dernière version de llama.cpp (~170 Mo) et l\'utiliser à la place du moteur de l\'image.\n\n' - + 'Le moteur est essayé avant d\'être adopté ; celui de l\'image reste intact. Le moteur redémarre à la fin — la génération en cours sera coupée.', - {title:'Mettre à jour le moteur', okText:'Mettre à jour'})) return; +async function engUpdate(){ return engPlanAndUpdate(false); } +async function engRecommended(){ return engPlanAndUpdate(true); } + +// engPlanAndUpdate : sur clic seulement. Le serveur choisit la version (la +// dernière publiée, ou la plus récente ≥ minimum recommandé) et dit si le +// prompt rendu va changer (preserve_reasoning par défaut depuis b10763) ; la +// confirmation montre les deux, et la case REASONING_PRESERVE=off laisse le +// choix à l'utilisateur — cochée d'office seulement quand le changement est +// établi, puisque sur un gabarit qui garde déjà la réflexion, off changerait +// à son tour le rendu. +async function engPlanAndUpdate(recommended){ + toast(recommended ? 'recherche de la version recommandée…' : 'recherche de la dernière version…'); + let p; + try{ p = await jpost('/api/engine/plan', {recommended}); }catch(_){ toast('erreur réseau'); return; } + if(!p.ok){ engSay('✗ '+String(p.error||'').replace(/[<>&]/g,'')); toast('erreur'); return; } + if(p.uptodate){ engSay('Moteur à jour ✓ (b'+p.build+')'); toast('moteur à jour ✓'); return; } + const what = p.build ? 'b'+p.build+' ('+p.tag+')' : p.tag; + let msg = 'Télécharger llama.cpp '+what+' (~170 Mo) et l\'utiliser à la place du moteur actuel' + + (p.current ? ' (b'+p.current+')' : '') + '.\n\n' + + 'Le moteur est essayé avant d\'être adopté ; ' + + (p.keeps ? 'la version actuelle ('+p.keeps+') est gardée' : 'celui de l\'image reste intact') + + ' pour revenir en arrière. Le moteur redémarre à la fin — la génération en cours sera coupée.'; + const pr = p.preserve || {risk:'no'}; + const opts = {title: recommended ? 'Version recommandée' : 'Mettre à jour le moteur', okText:'Mettre à jour'}; + if(pr.risk !== 'no'){ + msg += '\n\n⚠ Rendu du raisonnement : '+(pr.why||''); + opts.check = 'poser REASONING_PRESERVE=off (garde le rendu actuel de l\'historique)'; + opts.checked = !!pr.suggest; + } + if(!await askConfirm(msg, opts)) return; + const preserve_off = pr.risk !== 'no' && askChecked(); let r; - try{ r = await jpost('/api/engine/update', {}); }catch(_){ toast('erreur réseau'); return; } + try{ r = await jpost('/api/engine/update', {tag: p.tag, preserve_off}); }catch(_){ toast('erreur réseau'); return; } if(!r.ok){ toast('erreur : '+(r.error||'')); return; } openDetails('lc-details'); lcStartPolling(); } +// Retour au moteur d'avant la dernière bascule — sans réseau. +async function engRollback(){ + const prev = (engState && engState.previous) || null; + if(!prev) return; + const what = prev.source === 'image' ? 'le moteur de l\'image' : (prev.build ? 'b'+prev.build : (prev.tag || prev.bin)); + if(!await askConfirm('Revenir à la version précédente ('+what+'). Le moteur redémarre.', + {title:'Revenir à la version précédente', okText:'Revenir'})) return; + let r; + try{ r = await jpost('/api/engine/rollback', {}); }catch(_){ toast('erreur réseau'); return; } + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + toast('version précédente rétablie — redémarrage en cours'); + loadAll(); +} + // Bascule vers une version déjà installée — « image » = celle d'origine. async function engUse(tag){ const image = tag === 'image'; diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 4c863ec..89a7975 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -546,6 +546,20 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
    Moteur llama.cpp
    + + +
    +
    diff --git a/internal/loki/ui/src/js/03-ui-kit.js b/internal/loki/ui/src/js/03-ui-kit.js index 8d5e574..be35109 100644 --- a/internal/loki/ui/src/js/03-ui-kit.js +++ b/internal/loki/ui/src/js/03-ui-kit.js @@ -68,7 +68,8 @@ function _openAsk(kind, message, opts){ const chk=document.getElementById('ask-check'); chk.style.display = opts.check ? 'inline-flex' : 'none'; document.getElementById('ask-check-label').textContent = opts.check || ''; - document.getElementById('ask-check-input').checked = false; + // opts.checked : case cochée d'office (le choix sûr), toujours décochable. + document.getElementById('ask-check-input').checked = !!opts.checked; _askCheck=false; const cancel=document.getElementById('ask-cancel'), ok=document.getElementById('ask-ok'); cancel.style.display = kind==='alert' ? 'none' : ''; diff --git a/internal/loki/ui/src/js/10-llamacpp.js b/internal/loki/ui/src/js/10-llamacpp.js index fe29ecf..75c5dfa 100644 --- a/internal/loki/ui/src/js/10-llamacpp.js +++ b/internal/loki/ui/src/js/10-llamacpp.js @@ -137,26 +137,80 @@ async function loadEngine(){ prebuilt:'précompilé', custom:'personnalisé'}[s.source] || ''; const repo = document.getElementById('eng-repo'); if(repo) repo.textContent = s.repo + ':' + (s.variant || 'server'); + // Version précédente (celle qui tournait avant la dernière bascule) : un + // bouton à elle, sans réseau. Les autres boutons ne la répètent pas. + const prev = s.previous || null; + const prevBtn = document.getElementById('eng-prev'); + if(prevBtn){ + prevBtn.style.display = prev ? '' : 'none'; + if(prev) prevBtn.textContent = 'Revenir à la version précédente' + + (prev.source === 'image' ? ' (moteur de l\'image' + (prev.build ? ', b'+prev.build : '') + ')' + : (prev.build ? ' (b'+prev.build+')' : '')); + } // Le retour arrière n'a de sens que si on n'est pas déjà dessus. const revert = document.getElementById('eng-revert'); - if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none'; + if(revert) revert.style.display = (s.image_bin && s.source !== 'image' && !(prev && prev.source === 'image')) ? '' : 'none'; // Versions téléchargées gardées à côté (la précédente survit à une mise à // jour) : revenir dessus ne demande ni réseau ni que le tag soit encore publié. const others = document.getElementById('eng-others'); - if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use).map(v=>{ + if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use && !(prev && prev.tag === v.tag)).map(v=>{ const tag = String(v.tag).replace(/[^A-Za-z0-9._-]/g,''); return ''; }).join(' '); // Build trop ancien pour les points de reprise des hybrides (voir // engineBuildNotice) : un avis, rien n'est changé d'office ici. + // L'encart « version recommandée » reprend l'avis : il le remplace quand il + // est affiché. const notice = document.getElementById('eng-notice'); if(notice){ notice.textContent = s.notice ? '⚠ '+s.notice : ''; - notice.style.display = s.notice ? '' : 'none'; + notice.style.display = (s.notice && !s.recommend) ? '' : 'none'; } + // Encart « version recommandée » : décidé côté serveur sans réseau ; le tag + // n'est choisi qu'au clic (engRecommended). + const reco = document.getElementById('eng-reco'); + if(reco){ + const r = s.recommend; + reco.style.display = r ? '' : 'none'; + if(r){ + document.getElementById('eng-reco-min').textContent = r.min; + document.getElementById('eng-reco-cur').textContent = r.current; + document.getElementById('eng-reco-gains').innerHTML = (r.gains||[]) + .map(g=>'
  • '+String(g).replace(/[<>&]/g,'')+'
  • ').join(''); + } + } + engRenderCheck(s.render_check); engSay(''); } +// Rendu du gabarit comparé avant/après la dernière bascule (web_engine_reco.go). +// « pending » : le nouveau moteur charge encore le modèle — on repasse plus +// tard tant que le panneau est ouvert. +let engRenderTimer = null; +function engRenderCheck(c){ + const el = document.getElementById('eng-render'); + if(!el) return; + const esc = x=>String(x||'').replace(/[<>&]/g,''); + if(!c){ el.style.display = 'none'; return; } + el.style.display = ''; + el.className = c.status === 'changed' ? '' : 'muted'; + if(c.status === 'pending'){ + el.innerHTML = '⏳ vérification du rendu du gabarit après '+esc(c.tag)+' (une fois le modèle chargé)…'; + if(!engRenderTimer) engRenderTimer = setTimeout(()=>{ + engRenderTimer = null; + const pane = document.getElementById('lc-details'); + if(pane && pane.offsetParent !== null) loadEngine(); + }, 10000); + } else if(c.status === 'same'){ + el.innerHTML = '✓ rendu du gabarit identique avant et après '+esc(c.tag); + } else if(c.status === 'changed'){ + el.innerHTML = '⚠ le prompt rendu a changé avec '+esc(c.tag)+' — '+esc(c.detail) + + '
    Si ce sont des blocs de réflexion vides dans l\'historique, REASONING_PRESERVE=off dans le preset rétablit le rendu d\'avant ; « Revenir à la version précédente » annule la mise à jour.'; + } else { + el.innerHTML = 'rendu du gabarit non vérifié après '+esc(c.tag)+(c.detail ? ' : '+esc(c.detail) : ''); + } +} + // engSay écrit sous la ligne d'état : c'est le résultat d'une vérification, qui // doit rester lisible après le toast (on ne se souvient pas d'un toast). function engSay(html, cls){ @@ -182,17 +236,58 @@ async function engCheck(){ } } -async function engUpdate(){ - if(!await askConfirm('Télécharger la dernière version de llama.cpp (~170 Mo) et l\'utiliser à la place du moteur de l\'image.\n\n' - + 'Le moteur est essayé avant d\'être adopté ; celui de l\'image reste intact. Le moteur redémarre à la fin — la génération en cours sera coupée.', - {title:'Mettre à jour le moteur', okText:'Mettre à jour'})) return; +async function engUpdate(){ return engPlanAndUpdate(false); } +async function engRecommended(){ return engPlanAndUpdate(true); } + +// engPlanAndUpdate : sur clic seulement. Le serveur choisit la version (la +// dernière publiée, ou la plus récente ≥ minimum recommandé) et dit si le +// prompt rendu va changer (preserve_reasoning par défaut depuis b10763) ; la +// confirmation montre les deux, et la case REASONING_PRESERVE=off laisse le +// choix à l'utilisateur — cochée d'office seulement quand le changement est +// établi, puisque sur un gabarit qui garde déjà la réflexion, off changerait +// à son tour le rendu. +async function engPlanAndUpdate(recommended){ + toast(recommended ? 'recherche de la version recommandée…' : 'recherche de la dernière version…'); + let p; + try{ p = await jpost('/api/engine/plan', {recommended}); }catch(_){ toast('erreur réseau'); return; } + if(!p.ok){ engSay('✗ '+String(p.error||'').replace(/[<>&]/g,'')); toast('erreur'); return; } + if(p.uptodate){ engSay('Moteur à jour ✓ (b'+p.build+')'); toast('moteur à jour ✓'); return; } + const what = p.build ? 'b'+p.build+' ('+p.tag+')' : p.tag; + let msg = 'Télécharger llama.cpp '+what+' (~170 Mo) et l\'utiliser à la place du moteur actuel' + + (p.current ? ' (b'+p.current+')' : '') + '.\n\n' + + 'Le moteur est essayé avant d\'être adopté ; ' + + (p.keeps ? 'la version actuelle ('+p.keeps+') est gardée' : 'celui de l\'image reste intact') + + ' pour revenir en arrière. Le moteur redémarre à la fin — la génération en cours sera coupée.'; + const pr = p.preserve || {risk:'no'}; + const opts = {title: recommended ? 'Version recommandée' : 'Mettre à jour le moteur', okText:'Mettre à jour'}; + if(pr.risk !== 'no'){ + msg += '\n\n⚠ Rendu du raisonnement : '+(pr.why||''); + opts.check = 'poser REASONING_PRESERVE=off (garde le rendu actuel de l\'historique)'; + opts.checked = !!pr.suggest; + } + if(!await askConfirm(msg, opts)) return; + const preserve_off = pr.risk !== 'no' && askChecked(); let r; - try{ r = await jpost('/api/engine/update', {}); }catch(_){ toast('erreur réseau'); return; } + try{ r = await jpost('/api/engine/update', {tag: p.tag, preserve_off}); }catch(_){ toast('erreur réseau'); return; } if(!r.ok){ toast('erreur : '+(r.error||'')); return; } openDetails('lc-details'); lcStartPolling(); } +// Retour au moteur d'avant la dernière bascule — sans réseau. +async function engRollback(){ + const prev = (engState && engState.previous) || null; + if(!prev) return; + const what = prev.source === 'image' ? 'le moteur de l\'image' : (prev.build ? 'b'+prev.build : (prev.tag || prev.bin)); + if(!await askConfirm('Revenir à la version précédente ('+what+'). Le moteur redémarre.', + {title:'Revenir à la version précédente', okText:'Revenir'})) return; + let r; + try{ r = await jpost('/api/engine/rollback', {}); }catch(_){ toast('erreur réseau'); return; } + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + toast('version précédente rétablie — redémarrage en cours'); + loadAll(); +} + // Bascule vers une version déjà installée — « image » = celle d'origine. async function engUse(tag){ const image = tag === 'image'; diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 1a3071d..3219869 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -1038,6 +1038,11 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} de build et un hash de commit se recopient et se comparent à l'œil, et « AAAA111 » n'est pas le commit « aaaa111 ». Le monospace vient de là aussi. */ .eng-ver{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--text)} +/* Encart « version recommandée » du panneau Moteur : un conseil, pas une alerte + — bordure discrète, liste serrée des gains. */ +.eng-reco{margin-top:8px;padding:8px 10px;border:1px solid var(--border);border-radius:4px;background:var(--panel)} +.eng-reco ul{margin:4px 0 4px 16px;padding:0} +.eng-reco li{margin:1px 0} .mcp-tools{font-size:10px;color:var(--accent)} .mcp-err{font-size:10px;color:var(--err)} .mcp-switch{flex:none} diff --git a/internal/loki/web_engine.go b/internal/loki/web_engine.go index 1e0ab66..d51f4fb 100644 --- a/internal/loki/web_engine.go +++ b/internal/loki/web_engine.go @@ -8,6 +8,9 @@ // 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 @@ -15,12 +18,14 @@ 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 @@ -61,6 +66,12 @@ func handleEngineStatus(w http.ResponseWriter, r *http.Request) { // 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(), }) } @@ -92,17 +103,20 @@ func handleEngineCheck(w http.ResponseWriter, r *http.Request) { // 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"` + 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) }); err != nil { + if err := startLcJob("engine", func() { engineRunUpdate(tag, req.PreserveOff) }); err != nil { sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) return } @@ -118,7 +132,7 @@ func engineUnsupportedWhy() string { // 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) { +func engineRunUpdate(tag string, preserveOff bool) { variant := engineVariant() ancien := currentEngineBin() if tag == "" { @@ -152,15 +166,44 @@ func engineRunUpdate(tag string) { 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 } - if build, commit := engineBuildOf(bin); build > 0 { - lcAppend(fmt.Sprintf("moteur testé : build %d (%s)", build, commit)) + 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 + ")") } @@ -231,10 +274,12 @@ func handleEngineUse(w http.ResponseWriter, r *http.Request) { 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") } diff --git a/internal/loki/web_engine_reco.go b/internal/loki/web_engine_reco.go new file mode 100644 index 0000000..ce8b3d1 --- /dev/null +++ b/internal/loki/web_engine_reco.go @@ -0,0 +1,476 @@ +// web_engine_reco.go — « version recommandée » du panneau Moteur, retour à la +// version précédente, et garde du rendu des gabarits à travers une mise à jour. +// +// POST /api/engine/plan → choisit la version à installer (sur clic) et +// dit si le prompt rendu va changer +// POST /api/engine/rollback → revient au moteur d'avant la dernière bascule +// +// RIEN D'AUTOMATIQUE. Aucune de ces routes n'est appelée au démarrage ni par +// l'état du panneau : le registre n'est interrogé que sur un clic, et rien ne +// s'installe sans la confirmation qui suit. L'encart « version recommandée » +// lui-même se décide sans réseau, sur le build du moteur courant. +package loki + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +const ( + // enginePreserveDefaultBuild : premier build où llama-server active + // preserve_reasoning quand ni --reasoning-preserve ni + // --no-reasoning-preserve n'est passé (PR #28174). + enginePreserveDefaultBuild = 10763 + // engineQwen4MTPBuild : premier build qui sait la tête MTP de qwen4exp + // (PR #29761). Cité dans l'encart, jamais activé par Loki : SPEC reste off + // tant que l'utilisateur ne le pose pas. + engineQwen4MTPBuild = 11331 +) + +// --------------------------------------------------------------------------- +// Choix de la version recommandée +// --------------------------------------------------------------------------- + +// engineRecommendedTag : le tag figé le plus récent publié pour la variante, +// à condition qu'il atteigne engineMinRecommended et qu'il existe vraiment. +// +// Le registre n'offre pas de « plus récent au-dessus de N » ; lister ses +// milliers de tags pour le trouver serait lent et fragile. Le plus récent +// au-dessus du minimum, c'est le dernier publié — l'annotation du tag mouvant +// le nomme (engineOCILatest) — et on vérifie que son tag figé répond avant de +// le proposer : c'est lui qu'on installera, pas le tag mouvant, pour que la +// version confirmée soit la version installée. +func engineRecommendedTag(variant string) (int, string, error) { + build, tag, err := engineOCILatest(variant) + if err != nil { + return 0, "", fmt.Errorf("registre injoignable, aucune version choisie : %w", err) + } + if build == 0 { + // Sans numéro, impossible de promettre « au moins b10864 » : on ne + // propose pas un tag mouvant dont on ne sait pas ce qu'il contient. + return 0, "", fmt.Errorf("le registre n'annonce pas le build du tag « %s » : impossible de vérifier qu'il atteint b%d", variant, engineMinRecommended) + } + if build < engineMinRecommended { + return 0, "", fmt.Errorf("dernier build publié pour %s : b%d, antérieur à la version recommandée b%d", variant, build, engineMinRecommended) + } + if _, _, err := ociResolve(ociEngineRepo, tag); err != nil { + return 0, "", fmt.Errorf("%s annoncé mais introuvable sur le registre : %w", tag, err) + } + return build, tag, nil +} + +// engineRecommendation : l'encart du panneau, ou nil. Sans réseau : seulement +// le build de confiance du moteur courant (engineBuildTrust — jamais pour un +// compilé ou un fork), comparé au minimum recommandé. +func engineRecommendation(trusted int, supported bool) map[string]any { + if trusted <= 0 || trusted >= engineMinRecommended || !supported { + return nil + } + return map[string]any{ + "current": trusted, + "min": engineMinRecommended, + "gains": []string{ + fmt.Sprintf("points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) gardés : plus de ~8 k jetons recalculés à chaque reprise (b%d)", engineMinRecommended), + fmt.Sprintf("MTP rapide (graphes CUDA) à partir de b%d", specAutoMinBuild), + fmt.Sprintf("MTP de qwen4exp à partir de b%d", engineQwen4MTPBuild), + }, + } +} + +// --------------------------------------------------------------------------- +// Rendu du raisonnement : le défaut qui change à b10763 +// --------------------------------------------------------------------------- + +// preserveIn : ce qu'il faut savoir pour dire si la bascule change le prompt. +type preserveIn struct { + External bool // preset externe : le gabarit n'est pas rendu par ce moteur + Current int // build de confiance du moteur courant (0 = inconnu) + Target int // build visé (0 = inconnu) + Key string // REASONING_PRESERVE de la configuration active + Overridden string // EXTRA_ARGS ou LLAMA_ARG_* qui règlent déjà la question + Template string // gabarit chargé (/props) + TplKnown bool + Preserves tplTri // la sonde, sur le moteur courant +} + +// preserveRisk : le verdict, pour l'avertissement et la case à cocher. +type preserveRisk struct { + Risk string `json:"risk"` // "yes", "no" ou "unknown" + Why string `json:"why"` + // Suggest : cocher d'office REASONING_PRESERVE=off. Seulement quand le + // changement est établi : sur un gabarit qui garde de lui-même la réflexion + // (Qwen3.8), « off » changerait à son tour le rendu. + Suggest bool `json:"suggest"` +} + +// tplPreserveVars : les variables que llama-server pose dans le contexte du +// gabarit avec preserve_reasoning (caps_apply_preserve_reasoning). Un gabarit +// qui n'en lit aucune rend la même chose sur les deux moteurs. +var tplPreserveVars = []string{"preserve_thinking", "clear_thinking", "truncate_history_thinking", "preserve_reasoning"} + +// reasoningPreserveRisk : la bascule vers le build visé change-t-elle le prompt +// rendu ? Fonction pure. +func reasoningPreserveRisk(in preserveIn) preserveRisk { + no := func(why string) preserveRisk { return preserveRisk{Risk: "no", Why: why} } + switch { + case in.External: + return no("preset externe : son gabarit n'est pas rendu par ce moteur") + case in.Target > 0 && in.Target < enginePreserveDefaultBuild: + return no(fmt.Sprintf("b%d n'a pas encore preserve_reasoning par défaut (b%d)", in.Target, enginePreserveDefaultBuild)) + case in.Current >= enginePreserveDefaultBuild: + return no(fmt.Sprintf("le moteur courant (b%d) a déjà preserve_reasoning par défaut", in.Current)) + case in.Key != "": + return no("REASONING_PRESERVE=" + in.Key + " est posée : le rendu ne dépend pas du défaut du moteur") + case in.Overridden != "": + return no(in.Overridden + " règle déjà preserve_reasoning") + case !in.TplKnown: + return preserveRisk{Risk: "unknown", Why: "moteur arrêté ou gabarit illisible : impossible de vérifier. " + + "À partir de b" + fmt.Sprint(enginePreserveDefaultBuild) + ", un gabarit de type Qwen3.6 rend la réflexion des tours passés, " + + "vide puisque Loki ne la renvoie pas (sans REASONING_ECHO) ; REASONING_PRESERVE=off garde le rendu actuel. " + + "À l'inverse, sur un gabarit qui la garde déjà (Qwen3.8), off changerait le rendu."} + } + tpl := false + for _, v := range tplPreserveVars { + if strings.Contains(in.Template, v) { + tpl = true + break + } + } + switch { + case !tpl: + return no("le gabarit chargé ne lit pas preserve_thinking / clear_thinking : rendu inchangé") + case in.Preserves == tplYes: + return no("le gabarit garde déjà la réflexion des tours passés : rendu inchangé (ne pas poser off)") + case in.Preserves == tplNo: + return preserveRisk{Risk: "yes", Suggest: true, Why: fmt.Sprintf("le gabarit chargé retire la réflexion des tours passés ; "+ + "à partir de b%d le moteur la lui fait garder, et sans REASONING_ECHO Loki ne la renvoie pas : chaque tour passé "+ + "serait rendu avec un bloc de réflexion vide. REASONING_PRESERVE=off garde le rendu actuel.", enginePreserveDefaultBuild)} + } + return preserveRisk{Risk: "unknown", Why: "le gabarit lit preserve_thinking mais la sonde n'a pas conclu : " + + "REASONING_PRESERVE=off garde le rendu actuel s'il retire la réflexion des tours passés"} +} + +// preserveOverridden : EXTRA_ARGS ou variable d'environnement qui fixent déjà +// preserve_reasoning (mêmes gardes que reasoningPreserveArgs). Vide = aucune. +func preserveOverridden(cfg map[string]string) string { + extra := splitArgs(cfg["EXTRA_ARGS"]) + switch { + case hasAnyFlag(extra, "--reasoning-preserve", "--no-reasoning-preserve"): + return "EXTRA_ARGS" + case strings.Contains(strings.Join(flagValues(extra, "--chat-template-kwargs"), " "), "preserve_reasoning"): + return "--chat-template-kwargs" + case strings.TrimSpace(os.Getenv("LLAMA_ARG_REASONING_PRESERVE")) != "": + return "LLAMA_ARG_REASONING_PRESERVE" + case strings.Contains(os.Getenv("LLAMA_ARG_CHAT_TEMPLATE_KWARGS"), "preserve_reasoning"): + return "LLAMA_ARG_CHAT_TEMPLATE_KWARGS" + } + return "" +} + +// enginePreserveInputs : les lectures de reasoningPreserveRisk. Le moteur +// courant n'est interrogé qu'en lecture (/health, /props, /apply-template) : +// rien n'y touche au cache de la conversation. +func enginePreserveInputs(ctx context.Context, target int) preserveIn { + cfg := ReadConfig() + in := preserveIn{ + External: isExternalConfig(cfg), + Current: engineBuildTrusted(currentEngineBin()), + Target: target, + Key: strings.TrimSpace(cfg["REASONING_PRESERVE"]), + Overridden: preserveOverridden(cfg), + Preserves: tplUnknown, + } + if in.External || in.Key != "" || in.Overridden != "" || (target > 0 && target < enginePreserveDefaultBuild) { + return in // verdict déjà acquis : pas la peine d'interroger le moteur + } + p := tplLocalProber() + if !p.healthy(ctx) { + return in + } + props, err := p.props(ctx) + if err != nil { + return in + } + in.Template, in.TplKnown = props.ChatTemplate, props.ChatTemplate != "" + in.Preserves = tplProbeEnsure(ctx, tplLastShape()).PreservesHistory + return in +} + +// engineHelpHas : l'aide du binaire mentionne-t-elle ce drapeau ? Lancé avec +// ses bibliothèques à côté, comme le test à blanc : un moteur téléchargé n'a +// pas encore servi, binHelp ne le connaît pas. +func engineHelpHas(bin, flag string) bool { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + cmd := hideCmd(exec.CommandContext(ctx, bin, "--help")) + cmd.WaitDelay = 2 * time.Second + cmd.Env = libraryPathEnv(filepath.Dir(bin)) + out, _ := cmd.CombinedOutput() + return strings.Contains(string(out), flag) +} + +// enginePreserveOff pose REASONING_PRESERVE=off, choisi par l'utilisateur +// avant la bascule. Dans le preset actif s'il y en a un — sinon la prochaine +// bascule de preset l'effacerait sans un mot —, dans la configuration sinon. +// Rien si la clé est déjà posée : c'est un choix antérieur, on ne le contredit +// pas. +func enginePreserveOff(newBin string) (string, error) { + return enginePreserveOffIf(func() bool { return engineHelpHas(newBin, "--no-reasoning-preserve") }) +} + +// enginePreserveOffIf : le corps d'enginePreserveOff, l'aide du nouveau moteur +// passée en fonction (les tests n'ont pas de llama-server à lancer). +func enginePreserveOffIf(knowsFlag func() bool) (string, error) { + if v := strings.TrimSpace(ReadConfig()["REASONING_PRESERVE"]); v != "" { + return "REASONING_PRESERVE=" + v + " déjà posée : inchangée", nil + } + if !knowsFlag() { + return "", fmt.Errorf("le nouveau moteur ne connaît pas --no-reasoning-preserve") + } + if err := tuneGuard(); err != nil { + return "", err + } + if p, content, err := tuneActivePreset(); err == nil { + if _, _, err := SavePresetApplying(p.ID, p.Name, presetSetKey(content, "REASONING_PRESERVE", "off")); err != nil { + return "", err + } + return "REASONING_PRESERVE=off posée dans le preset « " + p.Name + " »", nil + } + if err := SetConfigKey("REASONING_PRESERVE", "off"); err != nil { + return "", err + } + return "REASONING_PRESERVE=off posée dans la configuration active (aucun preset ne lui correspond)", nil +} + +// --------------------------------------------------------------------------- +// Vérification du rendu après la bascule +// --------------------------------------------------------------------------- + +// engineRenderCheck : le dernier relevé avant/après une bascule de moteur. +type engineRenderCheck struct { + Status string `json:"status"` // pending, same, changed, unknown + Tag string `json:"tag"` + Detail string `json:"detail,omitempty"` + At time.Time `json:"at"` +} + +var ( + engineRenderMu sync.Mutex + engineRenderLast *engineRenderCheck + // engineRenderWait : délai laissé au nouveau moteur pour charger le modèle + // (un gros MoE en mmap peut demander plusieurs minutes). Variable pour les + // tests. + engineRenderWait = 20 * time.Minute + engineRenderPoll = 5 * time.Second +) + +func engineRenderSet(c engineRenderCheck) { + c.At = time.Now() + engineRenderMu.Lock() + engineRenderLast = &c + engineRenderMu.Unlock() + if c.Status != "pending" { + line := "[moteur] rendu du gabarit après " + c.Tag + " : " + c.Status + if c.Detail != "" { + line += " — " + c.Detail + } + fmt.Fprintln(os.Stderr, line) + } +} + +func engineRenderSnapshot() *engineRenderCheck { + engineRenderMu.Lock() + defer engineRenderMu.Unlock() + if engineRenderLast == nil { + return nil + } + c := *engineRenderLast + return &c +} + +// engineRenderRef : le rendu relevé sur le moteur qui tourne encore, la forme +// rejouée et le build qu'il annonce (/props). +type engineRenderRef struct { + text string + shape tplShape + build string +} + +// engineRenderBefore relève le rendu sur le moteur qui tourne encore. ok=false +// : moteur arrêté, preset externe ou rendu impossible — pas de comparaison. +func engineRenderBefore(ctx context.Context) (engineRenderRef, bool) { + ref := engineRenderRef{shape: tplLastShape()} + if externalActive() { + return ref, false + } + p := tplLocalProber() + if !p.healthy(ctx) { + return ref, false + } + if props, err := p.props(ctx); err == nil { + ref.build = props.BuildInfo + } + fp, err := tplRenderPrint(ctx, p, ref.shape) + ref.text = fp + return ref, err == nil +} + +// engineRenderAfter attend le nouveau moteur (modèle chargé) et compare. En +// tâche de fond : le job de mise à jour est fini, la bascule faite ; ceci ne +// fait que signaler. +// +// « Prêt » veut dire /health à 200 ET un build différent de celui relevé +// avant : un ancien moteur qui aurait survécu à l'arrêt répondrait aussi, et +// comparer l'ancien à lui-même conclurait à tort « identique ». +func engineRenderAfter(ref engineRenderRef, tag string) { + engineRenderSet(engineRenderCheck{Status: "pending", Tag: tag}) + ctx, cancel := context.WithTimeout(context.Background(), engineRenderWait) + defer cancel() + p := tplLocalProber() + ready := func() bool { + if !p.healthy(ctx) { + return false + } + if ref.build == "" { + return true + } + props, err := p.props(ctx) + return err == nil && props.BuildInfo != ref.build + } + for !ready() { + select { + case <-ctx.Done(): + engineRenderSet(engineRenderCheck{Status: "unknown", Tag: tag, Detail: "le nouveau moteur n'a pas répondu à temps : rendu non vérifié"}) + return + case <-time.After(engineRenderPoll): + } + } + after, err := tplRenderPrint(ctx, p, ref.shape) + if err != nil { + engineRenderSet(engineRenderCheck{Status: "unknown", Tag: tag, Detail: "rendu impossible sur le nouveau moteur : " + err.Error()}) + return + } + if same, d := tplRenderDiff(ref.text, after); same { + engineRenderSet(engineRenderCheck{Status: "same", Tag: tag}) + } else { + engineRenderSet(engineRenderCheck{Status: "changed", Tag: tag, Detail: d}) + } +} + +// --------------------------------------------------------------------------- +// Version précédente +// --------------------------------------------------------------------------- + +// enginePrevFile : le moteur d'avant la dernière bascule faite par le panneau. +// Un fichier à part, pas une clé de configuration : un preset n'a rien à en +// dire, et il ne doit pas changer l'empreinte de la configuration active. +func enginePrevFile() string { return filepath.Join(engineDir(), "previous-bin") } + +// engineRememberPrevious note le moteur qu'on quitte. Best-effort : sans ce +// fichier, le bouton « revenir » n'apparaît simplement pas. +func engineRememberPrevious(left, next string) { + if left == "" || samePath(left, next) { + return + } + _ = os.MkdirAll(engineDir(), 0o755) + _ = os.WriteFile(enginePrevFile(), []byte(left+"\n"), 0o644) +} + +// enginePrevious : le moteur précédent s'il est encore là et n'est pas celui +// qui tourne. Le ménage d'après mise à jour le garde (engineKeepAfterUpdate) ; +// une suppression à la main le fait disparaître, et le bouton avec. +func enginePrevious(cur string) map[string]any { + b, err := os.ReadFile(enginePrevFile()) + if err != nil { + return nil + } + bin := strings.TrimSpace(string(b)) + if bin == "" || !isFile(bin) || samePath(bin, cur) { + return nil + } + out := map[string]any{"bin": bin, "source": engineSource(bin)} + if tag := engineTagOf(bin); tag != "" { + out["tag"] = tag + out["build"] = engineTagBuild(tag) + } else { + out["build"] = engineBuildCached(bin) + } + return out +} + +// handleEnginePlan : sur clic, la version qu'on installerait et ce qu'elle +// changerait au prompt. {recommended:true} : la plus récente ≥ b10864 +// réellement publiée ; sinon la dernière publiée. Rien n'est téléchargé. +func handleEnginePlan(w http.ResponseWriter, r *http.Request) { + if !engineOCISupported() { + sendJSON(w, 200, map[string]any{"ok": false, "error": engineUnsupportedWhy()}) + return + } + var req struct { + Recommended bool `json:"recommended"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + variant := engineVariant() + var ( + build int + tag string + err error + ) + if req.Recommended { + build, tag, err = engineRecommendedTag(variant) + } else { + build, tag, err = engineOCILatest(variant) + } + if err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + cur := currentEngineBin() + curBuild, _ := engineBuildOf(cur) + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + sendJSON(w, 200, map[string]any{ + "ok": true, + "tag": tag, + "build": build, + "current": curBuild, + "uptodate": build > 0 && curBuild == build, + "preserve": reasoningPreserveRisk(enginePreserveInputs(ctx, build)), + // Ce qui restera pour revenir en arrière : la version téléchargée qui + // tourne (gardée par le ménage), ou le moteur de l'image. + "keeps": engineTagOf(cur), + }) +} + +// handleEngineRollback revient au moteur d'avant la dernière bascule. Aucun +// réseau. Le moteur quitté devient à son tour « le précédent » : deux clics +// ramènent où l'on était. +func handleEngineRollback(w http.ResponseWriter, r *http.Request) { + if tuneDenyHTTP(w) { + return + } + cur := currentEngineBin() + prev := enginePrevious(cur) + if prev == nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": "aucune version précédente conservée"}) + return + } + bin := prev["bin"].(string) + if err := SetConfigKey("BIN", bin); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + engineRememberPrevious(cur, bin) + if serviceIsActive() { + _ = serviceAction("restart") + } + sendJSON(w, 200, map[string]any{"ok": true, "bin": bin}) +} diff --git a/internal/loki/web_engine_reco_test.go b/internal/loki/web_engine_reco_test.go new file mode 100644 index 0000000..cbca1df --- /dev/null +++ b/internal/loki/web_engine_reco_test.go @@ -0,0 +1,386 @@ +package loki + +// Version recommandée du moteur, retour à la version précédente, et garde du +// rendu des gabarits à travers une mise à jour (web_engine_reco.go). +// +// Ce qui se joue : ne JAMAIS proposer une version qu'on n'a pas vue publiée, +// ne rien faire sans réseau d'autre que le dire, garder un chemin de retour +// sans réseau, et ne pas laisser une mise à jour changer en silence le prompt +// que voit le modèle. + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// Le cas nominal : le dernier build publié dépasse le minimum, et c'est son tag +// FIGÉ qui est proposé (celui qu'on installera), pas le tag mouvant. +func TestVersionRecommandeeChoixDuTag(t *testing.T) { + fauxRegistre(t, "b11351", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil) + build, tag, err := engineRecommendedTag("server-cuda") + if err != nil { + t.Fatal(err) + } + if build != 11351 || tag != "server-cuda-b11351" { + t.Errorf("build=%d tag=%q, attendu 11351 / server-cuda-b11351", build, tag) + } +} + +// Un registre dont le dernier build est encore sous le minimum, ou qui ne dit +// pas son numéro : rien n'est proposé, et l'erreur dit pourquoi. +func TestVersionRecommandeeRefusee(t *testing.T) { + for _, c := range []struct{ nom, version, attendu string }{ + {"trop ancienne", "b10700", "antérieur"}, + {"sans numéro", "", "n'annonce pas le build"}, + } { + t.Run(c.nom, func(t *testing.T) { + fauxRegistre(t, c.version, [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil) + _, tag, err := engineRecommendedTag("server-cuda") + if err == nil || !strings.Contains(err.Error(), c.attendu) { + t.Fatalf("tag=%q err=%v, attendu une erreur « %s »", tag, err, c.attendu) + } + }) + } +} + +// L'annotation du tag mouvant annonce un build dont le tag figé n'existe pas +// (encore) : on ne propose pas un tag introuvable. +func TestVersionRecommandeeTagFigeAbsent(t *testing.T) { + index, _ := json.Marshal(map[string]any{ + "schemaVersion": 2, + "layers": []map[string]any{{"digest": "sha256:x", "size": 1}}, + "annotations": map[string]string{"org.opencontainers.image.version": "b11400"}, + }) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/token"): + _, _ = w.Write([]byte(`{"token":"t"}`)) + case strings.HasSuffix(r.URL.Path, "/manifests/server-cuda"): + _, _ = w.Write(index) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + old := ociBase + ociBase = srv.URL + defer func() { ociBase = old }() + + if _, tag, err := engineRecommendedTag("server-cuda"); err == nil || !strings.Contains(err.Error(), "introuvable") { + t.Fatalf("tag=%q err=%v, attendu « introuvable »", tag, err) + } +} + +// Sans réseau : une erreur lisible, aucun tag, rien d'installé. +func TestVersionRecommandeeSansReseau(t *testing.T) { + testHome(t) + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + srv.Close() // port fermé : connexion refusée + old := ociBase + ociBase = srv.URL + defer func() { ociBase = old }() + + _, tag, err := engineRecommendedTag("server-cuda") + if err == nil || tag != "" || !strings.Contains(err.Error(), "registre injoignable") { + t.Fatalf("tag=%q err=%v, attendu « registre injoignable »", tag, err) + } + if got := engineInstalled(); len(got) != 0 { + t.Errorf("versions installées sans réseau : %v", got) + } +} + +// L'encart : seulement pour un build de confiance sous le minimum, là où les +// images officielles existent. Les gains cités nomment leurs builds. +func TestEncartVersionRecommandee(t *testing.T) { + r := engineRecommendation(10678, true) + if r == nil { + t.Fatal("pas d'encart pour b10678") + } + gains := strings.Join(r["gains"].([]string), "\n") + for _, want := range []string{"b10864", "b11009", "b11331", "points de reprise"} { + if !strings.Contains(gains, want) { + t.Errorf("gains sans « %s » : %s", want, gains) + } + } + for _, c := range []struct { + build int + ok bool + }{{engineMinRecommended, true}, {11351, true}, {0, true}, {10678, false}} { + if engineRecommendation(c.build, c.ok) != nil { + t.Errorf("encart pour build=%d supporté=%v", c.build, c.ok) + } + } +} + +// Retour arrière : la version quittée est notée, proposée tant qu'elle existe, +// et le retour fait de la version quittée à son tour « la précédente ». +func TestRetourVersionPrecedente(t *testing.T) { + testHome(t) + mk := func(tag string) string { + d := filepath.Join(engineDir(), tag) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(d, "llama-server") + if err := os.WriteFile(p, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + return p + } + ancien, nouveau := mk("server-cuda-b10678"), mk("server-cuda-b11351") + + if enginePrevious(nouveau) != nil { + t.Fatal("version précédente annoncée avant toute bascule") + } + // Le ménage d'après mise à jour garde bien l'ancienne (lot 1). + if keep := engineKeepAfterUpdate(nouveau, ancien, nil); !keep["server-cuda-b10678"] { + t.Fatalf("ménage : l'ancienne version n'est pas gardée (%v)", keep) + } + engineRememberPrevious(ancien, nouveau) + prev := enginePrevious(nouveau) + if prev == nil || prev["tag"] != "server-cuda-b10678" || prev["build"] != 10678 { + t.Fatalf("précédente = %v", prev) + } + // Ne jamais proposer de « revenir » sur le moteur qui tourne. + if enginePrevious(ancien) != nil { + t.Error("la version courante proposée comme précédente") + } + + // Le retour par la route : BIN revient, et la version quittée devient la + // précédente (deux clics ramènent où l'on était). + if err := SetConfigKey("BIN", nouveau); err != nil { + t.Fatal(err) + } + rec := httptest.NewRecorder() + handleEngineRollback(rec, httptest.NewRequest("POST", "/api/engine/rollback", strings.NewReader("{}"))) + if rec.Code != 200 { + t.Fatalf("rollback : HTTP %d %s", rec.Code, rec.Body.String()) + } + if got := ReadConfig()["BIN"]; !samePath(got, ancien) { + t.Fatalf("BIN = %q après retour, attendu %q", got, ancien) + } + if p := enginePrevious(ancien); p == nil || p["tag"] != "server-cuda-b11351" { + t.Errorf("après retour, précédente = %v", p) + } + + // Une version supprimée à la main : plus de bouton, et la route refuse. + if err := os.RemoveAll(filepath.Dir(nouveau)); err != nil { + t.Fatal(err) + } + if enginePrevious(ancien) != nil { + t.Error("version supprimée encore proposée") + } + rec = httptest.NewRecorder() + handleEngineRollback(rec, httptest.NewRequest("POST", "/api/engine/rollback", strings.NewReader("{}"))) + if rec.Code != 400 { + t.Errorf("rollback sans précédente : HTTP %d, attendu 400", rec.Code) + } +} + +// Le verdict « le prompt rendu va changer » : seulement quand c'est établi +// (gabarit qui lit preserve_thinking ET retire aujourd'hui la réflexion), et +// jamais de case cochée d'office ailleurs — sur Qwen3.8, off changerait le rendu. +func TestAvertissementRenduRaisonnement(t *testing.T) { + qwen36 := "{%- if preserve_thinking is defined and preserve_thinking %}…" + base := preserveIn{Current: 10678, Target: 11351, Template: qwen36, TplKnown: true, Preserves: tplNo} + for _, c := range []struct { + nom string + mod func(*preserveIn) + risk string + suggest bool + }{ + {"Qwen3.6 sur moteur ancien", func(*preserveIn) {}, "yes", true}, + {"Qwen3.8 garde déjà", func(in *preserveIn) { in.Preserves = tplYes }, "no", false}, + {"gabarit sans la variable", func(in *preserveIn) { in.Template = "{{ messages }}" }, "no", false}, + {"clear_thinking (GLM)", func(in *preserveIn) { in.Template = "{% if clear_thinking %}" }, "yes", true}, + {"sonde sans verdict", func(in *preserveIn) { in.Preserves = tplUnknown }, "unknown", false}, + {"moteur arrêté", func(in *preserveIn) { in.TplKnown, in.Template = false, "" }, "unknown", false}, + {"clé posée", func(in *preserveIn) { in.Key = "on" }, "no", false}, + {"EXTRA_ARGS", func(in *preserveIn) { in.Overridden = "EXTRA_ARGS" }, "no", false}, + {"cible avant b10763", func(in *preserveIn) { in.Target = 10700 }, "no", false}, + {"cible inconnue", func(in *preserveIn) { in.Target = 0 }, "yes", true}, + {"moteur courant déjà ≥ b10763", func(in *preserveIn) { in.Current = 10800 }, "no", false}, + {"preset externe", func(in *preserveIn) { in.External = true }, "no", false}, + } { + t.Run(c.nom, func(t *testing.T) { + in := base + c.mod(&in) + r := reasoningPreserveRisk(in) + if r.Risk != c.risk || r.Suggest != c.suggest || r.Why == "" { + t.Errorf("= %+v, attendu risk=%s suggest=%v", r, c.risk, c.suggest) + } + }) + } +} + +// Les réglages qui fixent déjà preserve_reasoning hors de la clé. +func TestPreserveDejaRegle(t *testing.T) { + t.Setenv("LLAMA_ARG_REASONING_PRESERVE", "") + t.Setenv("LLAMA_ARG_CHAT_TEMPLATE_KWARGS", "") + if got := preserveOverridden(map[string]string{"EXTRA_ARGS": "--no-reasoning-preserve -c 4096"}); got != "EXTRA_ARGS" { + t.Errorf("EXTRA_ARGS : %q", got) + } + if got := preserveOverridden(map[string]string{"EXTRA_ARGS": `--chat-template-kwargs {"preserve_reasoning":false}`}); got != "--chat-template-kwargs" { + t.Errorf("kwargs : %q", got) + } + if got := preserveOverridden(map[string]string{}); got != "" { + t.Errorf("rien de posé : %q", got) + } + t.Setenv("LLAMA_ARG_REASONING_PRESERVE", "0") + if got := preserveOverridden(map[string]string{}); got != "LLAMA_ARG_REASONING_PRESERVE" { + t.Errorf("variable : %q", got) + } +} + +// Les lectures du verdict sur un vrai (faux) moteur : gabarit et sonde. +func TestAvertissementRenduSurLeMoteur(t *testing.T) { + t.Setenv("LLAMA_ARG_REASONING_PRESERVE", "") + t.Setenv("LLAMA_ARG_CHAT_TEMPLATE_KWARGS", "") + for _, c := range []struct{ nom, mode, tpl, risk string }{ + {"retire la réflexion", "qwen3", "{% if preserve_thinking %}", "yes"}, + {"la garde déjà", "keep", "{% if preserve_thinking is not defined or preserve_thinking %}", "no"}, + {"ne lit pas la variable", "qwen3", "", "no"}, + } { + t.Run(c.nom, func(t *testing.T) { + testHome(t) + freshTplProbe(t) + (&fakeTpl{mode: c.mode, tpl: c.tpl}).start(t) + r := reasoningPreserveRisk(enginePreserveInputs(context.Background(), 11351)) + if r.Risk != c.risk { + t.Errorf("risk=%s (%s), attendu %s", r.Risk, r.Why, c.risk) + } + }) + } + t.Run("moteur arrêté", func(t *testing.T) { + testHome(t) + freshTplProbe(t) + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + u, _ := url.Parse(srv.URL) + srv.Close() + if err := SetConfigKey("PORT", u.Port()); err != nil { + t.Fatal(err) + } + if r := reasoningPreserveRisk(enginePreserveInputs(context.Background(), 11351)); r.Risk != "unknown" || r.Suggest { + t.Errorf("= %+v, attendu unknown sans case cochée", r) + } + }) +} + +// REASONING_PRESERVE=off, choisi par l'utilisateur : dans le preset actif (il +// survit aux bascules), jamais par-dessus une valeur déjà posée, et pas du tout +// sur un moteur qui ne connaît pas le drapeau. +func TestPoserPreserveOff(t *testing.T) { + testHome(t) + dir := presetsDir() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "qwen.env"), []byte("# NAME=Qwen\nMODEL=q.gguf\n"), 0o644); err != nil { + t.Fatal(err) + } + setConfig(t, "MODEL=q.gguf\n") + oui := func() bool { return true } + + if _, err := enginePreserveOffIf(func() bool { return false }); err == nil { + t.Fatal("posée alors que le moteur ne connaît pas --no-reasoning-preserve") + } + if ReadConfig()["REASONING_PRESERVE"] != "" { + t.Fatal("clé écrite malgré le refus") + } + if _, err := enginePreserveOffIf(oui); err != nil { + t.Fatal(err) + } + b, _ := os.ReadFile(filepath.Join(dir, "qwen.env")) + if parseEnv(string(b))["REASONING_PRESERVE"] != "off" || ReadConfig()["REASONING_PRESERVE"] != "off" { + t.Fatalf("preset=%q config=%q", b, ReadConfig()["REASONING_PRESERVE"]) + } + + // Déjà posée (à on) : on ne contredit pas un choix antérieur. + setConfig(t, "MODEL=autre.gguf\nREASONING_PRESERVE=on\n") + if msg, err := enginePreserveOffIf(oui); err != nil || ReadConfig()["REASONING_PRESERVE"] != "on" { + t.Fatalf("msg=%q err=%v valeur=%q", msg, err, ReadConfig()["REASONING_PRESERVE"]) + } + + // Aucun preset ne correspond : la configuration active. + setConfig(t, "MODEL=sans-preset.gguf\n") + if _, err := enginePreserveOffIf(oui); err != nil || ReadConfig()["REASONING_PRESERVE"] != "off" { + t.Fatalf("err=%v valeur=%q", err, ReadConfig()["REASONING_PRESERVE"]) + } +} + +// La comparaison des rendus : identique, ou le premier écart, avec la +// conversation où il se trouve. +func TestComparaisonDesRendus(t *testing.T) { + a := tplRenderSep + "historique sans raisonnement ---\n<|im_start|>assistant\nloki-sonde-reponse-c1<|im_end|>\n" + if same, d := tplRenderDiff(a, a); !same || d != "" { + t.Fatalf("identiques : same=%v d=%q", same, d) + } + b := strings.Replace(a, "assistant\n", "assistant\n\n\n\n\n", 1) + same, d := tplRenderDiff(a, b) + if same || !strings.Contains(d, "historique sans raisonnement") || !strings.Contains(d, "") { + t.Fatalf("same=%v d=%q", same, d) + } + // Un extrait ne coupe jamais un caractère. + _, d = tplRenderDiff(a+"é", a+"è") + if !strings.Contains(d, "é") || !strings.Contains(d, "è") { + t.Errorf("extrait coupé : %q", d) + } +} + +// Après la bascule : le rendu relevé sur le nouveau moteur est comparé à celui +// d'avant, et l'écart est signalé dans l'état du panneau. Tant que le moteur +// qui répond annonce l'ancien build, rien n'est comparé. +func TestVerificationDuRenduApresBascule(t *testing.T) { + testHome(t) + freshTplProbe(t) + oldPoll, oldWait := engineRenderPoll, engineRenderWait + engineRenderPoll = time.Millisecond + t.Cleanup(func() { + engineRenderPoll, engineRenderWait = oldPoll, oldWait + engineRenderMu.Lock() + engineRenderLast = nil + engineRenderMu.Unlock() + }) + f := &fakeTpl{mode: "qwen3", build: "b10678-old"} + f.start(t) + ref, ok := engineRenderBefore(context.Background()) + if !ok || ref.build != "b10678-old" { + t.Fatalf("rendu de référence : ok=%v build=%q", ok, ref.build) + } + set := func(mode, build string) { + f.mu.Lock() + f.mode, f.build = mode, build + f.mu.Unlock() + } + + // L'ancien moteur répond encore : pas de comparaison, « non vérifié ». + engineRenderWait = 50 * time.Millisecond + engineRenderAfter(ref, "server-cuda-b11351") + if c := engineRenderSnapshot(); c == nil || c.Status != "unknown" { + t.Fatalf("ancien moteur toujours là : %+v", c) + } + engineRenderWait = 10 * time.Second + + // Nouveau build, même gabarit : identique. + set("qwen3", "b11351-new") + engineRenderAfter(ref, "server-cuda-b11351") + if c := engineRenderSnapshot(); c == nil || c.Status != "same" { + t.Fatalf("même gabarit : %+v", c) + } + // Le moteur garde désormais la réflexion des tours passés : changé. + set("keep", "b11351-new") + engineRenderAfter(ref, "server-cuda-b11351") + c := engineRenderSnapshot() + if c == nil || c.Status != "changed" || !strings.Contains(c.Detail, "historique avec raisonnement") { + t.Fatalf("gabarit changé : %+v", c) + } + f.assertNoCompletion(t) +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index edd34e4..d8abf94 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -249,11 +249,13 @@ func newWebMux() *http.ServeMux { api("/api/llamacpp/use", handleLlamacppUse) // bascule BIN entre versions déjà installées // Moteur livré par l'image : mise à jour SANS reconstruire l'image, en // extrayant /app de l'image officielle llama.cpp (voir web_engine.go). - api("/api/engine", handleEngineStatus) // version qui tourne + versions installées - api("/api/engine/check", handleEngineCheck) // dernier build publié vs courant - api("/api/engine/update", handleEngineUpdate) // job : téléchargement + essai + bascule - api("/api/engine/use", handleEngineUse) // bascule vers une version installée (ou "image") - api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée + api("/api/engine", handleEngineStatus) // version qui tourne + versions installées + api("/api/engine/check", handleEngineCheck) // dernier build publié vs courant + api("/api/engine/update", handleEngineUpdate) // job : téléchargement + essai + bascule + api("/api/engine/use", handleEngineUse) // bascule vers une version installée (ou "image") + api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée + api("/api/engine/plan", handleEnginePlan) // sur clic : version visée + effet sur le rendu du gabarit + api("/api/engine/rollback", handleEngineRollback) // revient au moteur d'avant la dernière bascule api("/api/presets", handlePresets) api("/api/preset", handlePreset) api("/api/preset/save", handlePresetSave)