Chat : compactage sans redite, file multi-appareils, bascule par identifiant

Repris d'AJEAN 0.17.4.

- Compactage : la dernière demande du torse n'est plus réinjectée quand la
  queue contient déjà un message utilisateur (le modèle répondait une
  seconde fois à une vieille question), ni quand le résumé a échoué (elle
  est déjà dans le torse dégraissé et réapparaissait après ses propres
  résultats d'outils).
- Le texte écrit avant un appel d'outil n'est plus rangé deux fois dans
  l'historique du modèle (message tool_calls ET réponse finale) : du
  contexte gaspillé à chaque tour d'outil.
- Deux appareils qui envoient en même temps : le second message part en
  file au lieu d'un 409. Une tâche planifiée garde le refus.
- Un raisonnement qui reprend après du texte de réponse ouvre une nouvelle
  bulle sous la réponse au lieu de remplir l'ancienne, repliée au-dessus.
- Bascule de preset par identifiant : le numéro seul visait un autre
  preset quand la liste avait bougé sur un autre appareil. Le numéro reste
  accepté.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:21:18 +02:00
1 parent 7f8c5d360f
commit 199d7f1e99
10 files changed
+159 -14

No files matched your search

+16 -1
View File
@@ -408,8 +408,23 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// On réinjecte donc textuellement la dernière vraie demande du torse, juste
// avant la queue (les résultats d'outils qu'elle a produits la suivent, comme
// dans l'historique d'origine). Le torse reste entièrement compactable.
//
// Seulement quand c'est nécessaire (AJEAN 0.17.4), sinon deux bugs :
// - la queue contient déjà un message `user` : la dernière demande du torse a
// DÉJÀ reçu sa réponse, la réinjecter juste avant la nouvelle faisait
// répondre le modèle une seconde fois à une vieille question ;
// - repli sans résumé (mid = torse dégraissé) : ce message y est déjà, il se
// retrouvait en double, cette fois APRÈS ses propres résultats d'outils.
tailHasUser := false
for _, m := range msgs[tailStart:] {
if m.Role == "user" {
tailHasUser = true
break
}
}
summarized := err == nil && !summaryLooksEmpty(summary)
var pending []Message
for i := len(torso) - 1; i >= 0; i-- {
for i := len(torso) - 1; i >= 0 && summarized && !tailHasUser; i-- {
if torso[i].Role != "user" {
continue
}
+27
View File
@@ -177,3 +177,30 @@ func TestEstimateTokensGrows(t *testing.T) {
t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
}
}
// Repli sans résumé (moteur absent) : la demande en cours est déjà dans le
// torse dégraissé, elle ne doit pas être réinjectée une seconde fois — elle
// apparaissait APRÈS ses propres résultats d'outils (AJEAN 0.17.4).
func TestCompactSansResumeNeDoublePasLaDemande(t *testing.T) {
testHome(t)
page := func(n int) Message {
return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
}
msgs := []Message{
um("première question de la conversation"), am("ok"),
um("cherche les horaires du train pour Lyon"),
}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), page(i))
}
out, _ := compactMessages(t.Context(), msgs, Caps{})
n := 0
for _, m := range out {
if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
n++
}
}
if n != 1 {
t.Fatalf("demande en cours présente %d fois après compactage, attendu 1", n)
}
}
+21
View File
@@ -3,6 +3,7 @@ package loki
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"sort"
@@ -568,6 +569,21 @@ func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps
}
c.mu.Unlock()
err := c.StartTurn(text, files, caps, temperature)
if errors.Is(err, ErrBusy) {
// Un autre tour a démarré entre le test ci-dessus et StartTurn (deux
// appareils qui envoient en même temps) : en file plutôt qu'un refus
// (AJEAN 0.17.4). Une tâche planifiée garde le refus : sa fin ne dépile rien.
c.mu.Lock()
if c.Generating && c.runningTaskName == "" {
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
c.mu.Unlock()
// Ce tour a pu finir entre-temps : sans relance, le message attendrait
// le prochain envoi.
c.startQueuedIfAny()
return true, nil
}
c.mu.Unlock()
}
if err != nil {
// Refusé (modèle pas prêt, tâche en cours) : le client pourra renvoyer
// ce même message, son cid ne doit pas rester marqué comme reçu.
@@ -774,6 +790,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
case ev.ToolUsed != nil:
// Le texte écrit AVANT cet appel d'outil est déjà rangé dans le message
// assistant porteur des tool_calls (extra). Le garder aussi dans la
// réponse finale le doublait dans l'historique vu par le modèle — du
// contexte gaspillé à chaque tour d'outil (AJEAN 0.17.4).
content.Reset()
tu := map[string]any{
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
+17 -4
View File
@@ -4325,7 +4325,7 @@ async function loadPresets(){
const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name);
row.onclick=()=>switchTo(i+1, x.name, x.id);
const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
@@ -4931,14 +4931,15 @@ async function runBenchUI(){
loadPresets();
}
}
async function switchTo(n,name){
async function switchTo(n,name,id){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
toast('switching…');
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
pendingPreset = n; loadPresets();
const r=await jpost('/api/switch',{n:n});
// id d'abord : le numéro seul peut viser un autre preset si la liste a bougé.
const r=await jpost('/api/switch',id?{id:id}:{n:n});
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
toast('switched');
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
@@ -7534,6 +7535,17 @@ function handleDelta(d){
return; }
if(d.reasoning_content){
killTyping('reasoning');
// Raisonnement qui reprend APRÈS du texte de réponse (modèle qui repense en
// cours de réponse, reprise après coupure) : il allait dans l'ANCIENNE bulle,
// déjà repliée au-dessus de la réponse. On en ouvre une nouvelle sous la
// réponse : l'ordre affiché suit l'ordre reçu (AJEAN 0.17.4).
// Réponse encore vide (simple saut de ligne avant la réflexion) : on retire
// la bulle vide et le raisonnement continue dans la sienne.
if(T.contentEl){
if((T.fullContent||'').trim()){ settleBlocks(); T.reasonEl=null; }
else { smoothReset(); cancelRender(); T.contentEl.remove(); }
T.contentEl=null;
}
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
@@ -9592,7 +9604,7 @@ function onModelSwitch(sel){
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
// pendant les quelques secondes du redémarrage.
switchTo(n, name);
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
}
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
@@ -9670,6 +9682,7 @@ function paintModelSwitch(){
MS_PRESETS.forEach((p, i) => {
const o = document.createElement('option');
o.value = String(i + 1);
o.dataset.id = p.id || '';
o.textContent = p.name;
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
g.appendChild(o);
+1 -1
View File
@@ -56,7 +56,7 @@ async function loadPresets(){
const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name);
row.onclick=()=>switchTo(i+1, x.name, x.id);
const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
+3 -2
View File
@@ -41,14 +41,15 @@ async function runBenchUI(){
loadPresets();
}
}
async function switchTo(n,name){
async function switchTo(n,name,id){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
toast('switching…');
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
pendingPreset = n; loadPresets();
const r=await jpost('/api/switch',{n:n});
// id d'abord : le numéro seul peut viser un autre preset si la liste a bougé.
const r=await jpost('/api/switch',id?{id:id}:{n:n});
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
toast('switched');
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
+11
View File
@@ -560,6 +560,17 @@ function handleDelta(d){
return; }
if(d.reasoning_content){
killTyping('reasoning');
// Raisonnement qui reprend APRÈS du texte de réponse (modèle qui repense en
// cours de réponse, reprise après coupure) : il allait dans l'ANCIENNE bulle,
// déjà repliée au-dessus de la réponse. On en ouvre une nouvelle sous la
// réponse : l'ordre affiché suit l'ordre reçu (AJEAN 0.17.4).
// Réponse encore vide (simple saut de ligne avant la réflexion) : on retire
// la bulle vide et le raisonnement continue dans la sienne.
if(T.contentEl){
if((T.fullContent||'').trim()){ settleBlocks(); T.reasonEl=null; }
else { smoothReset(); cancelRender(); T.contentEl.remove(); }
T.contentEl=null;
}
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
+2 -1
View File
@@ -65,7 +65,7 @@ function onModelSwitch(sel){
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
// annulée) : on ne touche pas à la sélection ici, sinon l'affichage mentirait
// pendant les quelques secondes du redémarrage.
switchTo(n, name);
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
}
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
@@ -143,6 +143,7 @@ function paintModelSwitch(){
MS_PRESETS.forEach((p, i) => {
const o = document.createElement('option');
o.value = String(i + 1);
o.dataset.id = p.id || '';
o.textContent = p.name;
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
g.appendChild(o);
+26 -5
View File
@@ -1219,8 +1219,14 @@ func handleModelUse(w http.ResponseWriter, r *http.Request) {
}
func handleSwitch(w http.ResponseWriter, r *http.Request) {
// id = identité stable du preset (nom de fichier). Le numéro seul est
// dangereux : si la liste a changé depuis l'affichage (réordonnée, preset
// ajouté ou supprimé depuis un autre appareil), la même position désigne un
// AUTRE preset, et on chargeait le mauvais (AJEAN 0.17.4). n reste accepté
// pour les anciens clients.
var req struct {
N int `json:"n"`
N int `json:"n"`
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
@@ -1231,11 +1237,26 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
if req.N < 1 || req.N > len(list) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"})
return
var target Preset
if id := strings.TrimSpace(req.ID); id != "" {
found := false
for _, p := range list {
if p.ID == id {
target, found = p, true
break
}
}
if !found {
sendJSON(w, 404, map[string]any{"ok": false, "error": "preset introuvable (supprimé ou renommé ?)"})
return
}
} else {
if req.N < 1 || req.N > len(list) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"})
return
}
target = list[req.N-1]
}
target := list[req.N-1]
// On écrit config.env TOUT DE SUITE (c'est lui qui décide du preset actif),
// puis on répond — le redémarrage du service part en arrière-plan. Passer par
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
+35
View File
@@ -0,0 +1,35 @@
package loki
import (
"net/http/httptest"
"strings"
"testing"
)
// La bascule vise le preset par son id, pas par sa position : une liste
// réordonnée sur un autre appareil faisait charger un AUTRE preset. Presets
// externes ici : la bascule n'a alors aucun moteur local à redémarrer.
func TestBasculeParIdentifiant(t *testing.T) {
testHome(t)
if _, err := SavePreset("", "Alpha", externalPresetContent("https://a.example/v1", "modele-a", "", "", false)); err != nil {
t.Fatal(err)
}
idB, err := SavePreset("", "Beta", externalPresetContent("https://b.example/v1", "modele-b", "", "", false))
if err != nil {
t.Fatal(err)
}
post := func(body string) int {
w := httptest.NewRecorder()
handleSwitch(w, httptest.NewRequest("POST", "/api/switch", strings.NewReader(body)))
return w.Code
}
if code := post(`{"id":"` + idB + `","n":1}`); code != 200 {
t.Fatalf("bascule par id : code %d", code)
}
if got := ReadConfig()[extKeyModel]; got != "modele-b" {
t.Fatalf("preset chargé = %q, attendu celui de l'id (modele-b) et non la position 1", got)
}
if code := post(`{"id":"inexistant"}`); code != 404 {
t.Fatalf("id inconnu : code %d, attendu 404", code)
}
}