From 199d7f1e9911b5b84680b740b2861c2b87da58e1 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 2 Oct 2026 23:21:18 +0200 Subject: [PATCH] Chat : compactage sans redite, file multi-appareils, bascule par identifiant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/loki/chat_compact.go | 17 ++++++++++++- internal/loki/chat_compact_test.go | 27 ++++++++++++++++++++ internal/loki/chat_conversation.go | 21 ++++++++++++++++ internal/loki/ui/index.html | 21 +++++++++++++--- internal/loki/ui/src/js/06-settings.js | 2 +- internal/loki/ui/src/js/07-models.js | 5 ++-- internal/loki/ui/src/js/09-stream.js | 11 ++++++++ internal/loki/ui/src/js/18-shell.js | 3 ++- internal/loki/web_api.go | 31 +++++++++++++++++++---- internal/loki/web_switch_test.go | 35 ++++++++++++++++++++++++++ 10 files changed, 159 insertions(+), 14 deletions(-) create mode 100644 internal/loki/web_switch_test.go diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index 0057264..e40f3a3 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -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 } diff --git a/internal/loki/chat_compact_test.go b/internal/loki/chat_compact_test.go index 82438e9..5a34159 100644 --- a/internal/loki/chat_compact_test.go +++ b/internal/loki/chat_compact_test.go @@ -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) + } +} diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index e4c4b4b..b143e42 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -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, diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 2e2c621..ef6b20b 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -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); diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js index d7c9730..eb8ce0b 100644 --- a/internal/loki/ui/src/js/06-settings.js +++ b/internal/loki/ui/src/js/06-settings.js @@ -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 diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index 3f2c551..8fa51f1 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -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 diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index 43a0ea3..2899a83 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -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 diff --git a/internal/loki/ui/src/js/18-shell.js b/internal/loki/ui/src/js/18-shell.js index 3ca5fb8..306f2cd 100644 --- a/internal/loki/ui/src/js/18-shell.js +++ b/internal/loki/ui/src/js/18-shell.js @@ -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); diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 4c78204..8d83ed9 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -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 diff --git a/internal/loki/web_switch_test.go b/internal/loki/web_switch_test.go new file mode 100644 index 0000000..b9cc9fb --- /dev/null +++ b/internal/loki/web_switch_test.go @@ -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) + } +}