mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-14 18:58:43 +02:00
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:
1 parent
7f8c5d360f
commit
199d7f1e99
10 files changed
+159
-14
No files matched your search
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user