diff --git a/README.md b/README.md index 0f02690..7645fb8 100644 --- a/README.md +++ b/README.md @@ -318,6 +318,11 @@ Ajoutées par ce fork : → maximale) et rejoue le message sans rien perdre de l'historique — une fois, puis la traduction est retenue pour ce modèle. La liste est grisée quand le raisonnement est coupé pour ce modèle. +- **Ouverture rapide des longues discussions** : le serveur ne rejoue que la + **fin** du journal (les 4000 derniers événements). Un fil de plusieurs + centaines de tours s'affichait en quelques secondes et laissait autant de + bulles dans le DOM à traîner ensuite. Rien n'est effacé : un bandeau en tête + dit combien d'événements sont masqués et **charge le début** d'un clic. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index a0ae96d..586b82b 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -952,7 +952,18 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any { // de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement. // Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle, // continue indépendamment. emit renvoie false si l'écriture échoue (client parti). -func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) { +// replayTailMax borne le nombre d'événements REJOUÉS au chargement d'une +// discussion. Ce n'est pas une troncature du journal (rien n'est effacé) : le +// début reste sur disque et le client le demande d'un clic (full=true). +// +// Une discussion de plusieurs centaines de tours rejouait des dizaines de +// milliers d'événements à chaque ouverture d'onglet : quelques secondes de voile +// de chargement, et autant de bulles dans le DOM que le navigateur devait ensuite +// traîner à chaque rendu. On rejoue la fin — ce qu'on vient lire — et on annonce +// combien d'événements sont restés en arrière. +const replayTailMax = 4000 + +func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit func(map[string]any) bool) { // Réveille les attentes de cond quand la connexion se ferme. go func() { <-ctx.Done() @@ -988,7 +999,19 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st from = 0 } last := from - for _, ev := range coalesceReplay(snapshot, from) { + events := coalesceReplay(snapshot, from) + // Replay borné : on ne garde que la fin, et on DIT au client combien + // d'événements manquent devant (il affiche « charger le début »). Jamais + // borné sur une reprise de flux (from > 0) : là, le client a déjà tout le + // début à l'écran et attend la SUITE. + if !full && from == 0 && len(events) > replayTailMax { + hidden := len(events) - replayTailMax + events = events[hidden:] + if !emit(map[string]any{"replay_truncated": hidden}) { + return + } + } + for _, ev := range events { if ctx.Err() != nil { return } diff --git a/internal/loki/chat_conversation_test.go b/internal/loki/chat_conversation_test.go index 1f9b0a2..b75abc4 100644 --- a/internal/loki/chat_conversation_test.go +++ b/internal/loki/chat_conversation_test.go @@ -27,7 +27,7 @@ func TestSubscribeReplayAndLive(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() got := make(chan int, 32) - go c.Subscribe(ctx, 0, func(m map[string]any) bool { + go c.Subscribe(ctx, 0, false, func(m map[string]any) bool { if s, ok := m["seq"].(int); ok { got <- s } @@ -51,7 +51,7 @@ func TestSubscribeFromOffset(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() got := make(chan int, 8) - go c.Subscribe(ctx, 1, func(m map[string]any) bool { + go c.Subscribe(ctx, 1, false, func(m map[string]any) bool { if s, ok := m["seq"].(int); ok { got <- s } @@ -68,7 +68,7 @@ func TestResetNotifiesSubscribers(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() resetSeen := make(chan bool, 4) - go c.Subscribe(ctx, 0, func(m map[string]any) bool { + go c.Subscribe(ctx, 0, false, func(m map[string]any) bool { if _, ok := m["reset"]; ok { resetSeen <- true } @@ -202,7 +202,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) { } { ctx, cancel := context.WithCancel(context.Background()) got := make(chan int, 8) - go c.Subscribe(ctx, tc.from, func(m map[string]any) bool { + go c.Subscribe(ctx, tc.from, false, func(m map[string]any) bool { if s, ok := m["seq"].(int); ok { got <- s } @@ -216,7 +216,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() got := make(chan int, 8) - go c.Subscribe(ctx, 502, func(m map[string]any) bool { + go c.Subscribe(ctx, 502, false, func(m map[string]any) bool { if s, ok := m["seq"].(int); ok { got <- s } @@ -289,7 +289,7 @@ func TestSubscribeStaleFromCrossSession(t *testing.T) { var got []map[string]any done := make(chan struct{}) go func() { - c.Subscribe(ctx, 9999, func(ev map[string]any) bool { + c.Subscribe(ctx, 9999, false, func(ev map[string]any) bool { got = append(got, ev) if ev["caught_up"] != nil { cancel() @@ -309,3 +309,50 @@ func TestSubscribeStaleFromCrossSession(t *testing.T) { t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen) } } + +// Replay borné : une discussion très longue ne rejoue que sa fin, annonce +// combien d'événements manquent, et rend TOUT si le client le demande. +func TestReplayTailIsBoundedUnlessFull(t *testing.T) { + c := &Conversation{} + c.cond = sync.NewCond(&c.mu) + for i := 0; i < replayTailMax+50; i++ { + c.Log = append(c.Log, LogEvent{Seq: i + 1, Delta: map[string]any{"user": "m"}}) + } + + collect := func(full bool) (events int, hidden int) { + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + defer close(done) + c.Subscribe(ctx, 0, full, func(m map[string]any) bool { + if h, ok := m["replay_truncated"].(int); ok { + hidden = h + return true + } + if m["caught_up"] != nil { + cancel() + return false + } + if _, ok := m["pad"]; ok { + return true + } + events++ + return true + }) + }() + <-done + cancel() + return + } + + events, hidden := collect(false) + if hidden != 50 { + t.Fatalf("50 événements auraient dû être annoncés masqués, obtenu %d", hidden) + } + if events > replayTailMax { + t.Fatalf("replay borné dépassé : %d événements rejoués", events) + } + if full, h := collect(true); full <= replayTailMax || h != 0 { + t.Fatalf("full=true doit tout rejouer sans bandeau, obtenu %d événements / %d masqués", full, h) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 0f1cf31..dbd45a8 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -478,6 +478,13 @@ button:disabled{opacity:.5;cursor:not-allowed} .diff-count{margin-left:8px;letter-spacing:0;text-transform:none} .diff-count .a{color:var(--add)} .diff-count .d{color:var(--del)} +/* Bandeau « début de la discussion masqué » : le serveur ne rejoue que la fin + d'un très long fil (replayTailMax). Discret, en tête du fil, avec le bouton + qui va chercher le reste. */ +.replay-banner{margin:4px auto 10px;padding:6px 10px;font-size:11px;color:var(--dim);background:var(--panel);border:1px solid var(--border);border-radius:3px;text-align:center;max-width:680px} +.replay-banner .replay-more{padding:2px 8px;font-size:11px;background:none;border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer} +.replay-banner .replay-more:hover{color:var(--accent);border-color:var(--accent)} +.replay-banner .replay-more:disabled{cursor:default;opacity:.6} .msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px} /* Résultat d'outil tronqué : « voir plus » et « copier » réunis dans une barre ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre @@ -6883,6 +6890,10 @@ function autoGrow(ta){ // suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté // serveur) ; se reconnecter rejoue tout le fil, détails compris. let lastSeq=0, streamAbort=null; +// REPLAY_FULL : demander le journal ENTIER au prochain abonnement. Le serveur +// ne rejoue par défaut que la fin d'une longue discussion (voir replayTailMax) ; +// le bandeau « charger le début » rouvre le flux avec ce drapeau. +let REPLAY_FULL=false; // Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout // aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la // frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand @@ -7282,10 +7293,44 @@ function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoot // bulle d'outil) doit voir le texte complet. function settleBlocks(){ smoothSnap(); flushRender(); } +// showReplayBanner affiche, en tête du fil, le bandeau « début masqué ». Un clic +// coupe le flux et se réabonne depuis zéro en demandant TOUT le journal. +function showReplayBanner(hidden){ + const c=chatEl(); if(!c) return; + let b=document.getElementById('replay-banner'); + if(!b){ + b=document.createElement('div'); + b.id='replay-banner'; b.className='replay-banner'; + c.insertBefore(b, c.firstChild); + } + b.textContent=''; + const txt=document.createElement('span'); + txt.textContent='Début de la discussion masqué ('+hidden+' événements) — '; + const btn=document.createElement('button'); + btn.type='button'; btn.className='replay-more'; btn.textContent='charger le début'; + btn.onclick=()=>{ btn.disabled=true; btn.textContent='chargement…'; loadFullReplay(); }; + b.appendChild(txt); b.appendChild(btn); +} + +// loadFullReplay relance l'abonnement depuis le début, journal entier. On repart +// d'un fil VIDE : rejouer par-dessus l'existant ferait doublon. +function loadFullReplay(){ + REPLAY_FULL=true; lastSeq=0; REPLAYING=true; + if(streamAbort) streamAbort.abort(); + const c=chatEl(); if(c){ c.innerHTML=''; c.style.opacity='0'; } + PENDING=null; newTurn(); + setChatLoading('chargement du début de la conversation…'); + connectStream(); +} + // Traite UN événement du flux — même sémantique que l'ancien switch inline, mais // piloté par le serveur et rejouable à l'identique. function handleDelta(d){ if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq; + // Replay borné : le serveur a sauté le début d'une longue discussion. On + // l'annonce en tête du fil, avec le bouton qui va le chercher. Rien n'est + // perdu : le journal complet est sur le disque. + if(typeof d.replay_truncated==='number'){ showReplayBanner(d.replay_truncated); return; } // Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son // `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas // pendant le rejeu, où les `ts` sont vieux de plusieurs heures. @@ -7436,7 +7481,7 @@ async function connectStream(){ while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); } streamAbort=new AbortController(); try{ - const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal}); + const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq, full:REPLAY_FULL}),signal:streamAbort.signal}); if(REPLAYING) setChatLoading('chargement de la conversation…'); const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; while(true){ diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index d06fed8..f7ed946 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -4,6 +4,10 @@ // suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté // serveur) ; se reconnecter rejoue tout le fil, détails compris. let lastSeq=0, streamAbort=null; +// REPLAY_FULL : demander le journal ENTIER au prochain abonnement. Le serveur +// ne rejoue par défaut que la fin d'une longue discussion (voir replayTailMax) ; +// le bandeau « charger le début » rouvre le flux avec ce drapeau. +let REPLAY_FULL=false; // Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout // aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la // frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand @@ -403,10 +407,44 @@ function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoot // bulle d'outil) doit voir le texte complet. function settleBlocks(){ smoothSnap(); flushRender(); } +// showReplayBanner affiche, en tête du fil, le bandeau « début masqué ». Un clic +// coupe le flux et se réabonne depuis zéro en demandant TOUT le journal. +function showReplayBanner(hidden){ + const c=chatEl(); if(!c) return; + let b=document.getElementById('replay-banner'); + if(!b){ + b=document.createElement('div'); + b.id='replay-banner'; b.className='replay-banner'; + c.insertBefore(b, c.firstChild); + } + b.textContent=''; + const txt=document.createElement('span'); + txt.textContent='Début de la discussion masqué ('+hidden+' événements) — '; + const btn=document.createElement('button'); + btn.type='button'; btn.className='replay-more'; btn.textContent='charger le début'; + btn.onclick=()=>{ btn.disabled=true; btn.textContent='chargement…'; loadFullReplay(); }; + b.appendChild(txt); b.appendChild(btn); +} + +// loadFullReplay relance l'abonnement depuis le début, journal entier. On repart +// d'un fil VIDE : rejouer par-dessus l'existant ferait doublon. +function loadFullReplay(){ + REPLAY_FULL=true; lastSeq=0; REPLAYING=true; + if(streamAbort) streamAbort.abort(); + const c=chatEl(); if(c){ c.innerHTML=''; c.style.opacity='0'; } + PENDING=null; newTurn(); + setChatLoading('chargement du début de la conversation…'); + connectStream(); +} + // Traite UN événement du flux — même sémantique que l'ancien switch inline, mais // piloté par le serveur et rejouable à l'identique. function handleDelta(d){ if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq; + // Replay borné : le serveur a sauté le début d'une longue discussion. On + // l'annonce en tête du fil, avec le bouton qui va le chercher. Rien n'est + // perdu : le journal complet est sur le disque. + if(typeof d.replay_truncated==='number'){ showReplayBanner(d.replay_truncated); return; } // Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son // `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas // pendant le rejeu, où les `ts` sont vieux de plusieurs heures. @@ -557,7 +595,7 @@ async function connectStream(){ while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); } streamAbort=new AbortController(); try{ - const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal}); + const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq, full:REPLAY_FULL}),signal:streamAbort.signal}); if(REPLAYING) setChatLoading('chargement de la conversation…'); const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; while(true){ diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 7e88602..6128a48 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -443,6 +443,13 @@ button:disabled{opacity:.5;cursor:not-allowed} .diff-count{margin-left:8px;letter-spacing:0;text-transform:none} .diff-count .a{color:var(--add)} .diff-count .d{color:var(--del)} +/* Bandeau « début de la discussion masqué » : le serveur ne rejoue que la fin + d'un très long fil (replayTailMax). Discret, en tête du fil, avec le bouton + qui va chercher le reste. */ +.replay-banner{margin:4px auto 10px;padding:6px 10px;font-size:11px;color:var(--dim);background:var(--panel);border:1px solid var(--border);border-radius:3px;text-align:center;max-width:680px} +.replay-banner .replay-more{padding:2px 8px;font-size:11px;background:none;border:1px solid var(--border);color:var(--dim);border-radius:3px;cursor:pointer} +.replay-banner .replay-more:hover{color:var(--accent);border-color:var(--accent)} +.replay-banner .replay-more:disabled{cursor:default;opacity:.6} .msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px} /* Résultat d'outil tronqué : « voir plus » et « copier » réunis dans une barre ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 99244f7..efb492e 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -1349,6 +1349,12 @@ type chatReq struct { // vu par le client (le flux d'abonnement rejoue Log[From:] puis suit le direct). Message string `json:"message"` From int `json:"from"` + // Full = rejouer TOUT le journal de la discussion. Par défaut le replay est + // BORNÉ à sa fin (voir replayTailMax) : une discussion de plusieurs centaines + // de tours mettait des secondes à s'afficher et laissait autant de bulles + // dans le DOM. Le bouton « charger le début » de l'interface se réabonne + // avec full=true. + Full bool `json:"full"` // Files = chemins relatifs des fichiers déposés juste avant par // /api/chat/upload ("uploads/rapport.pdf"). Ils sont annoncés au modèle en // tête du message (voir attachNote) ; le contenu, lui, reste sur le disque et diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go index 12e7b11..98520b7 100644 --- a/internal/loki/web_chat.go +++ b/internal/loki/web_chat.go @@ -84,7 +84,7 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun // goroutine détachée — fermer le navigateur n'arrête donc plus rien. Partagé par // handleChat (clair) et handleE2EChat (chiffré). func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) { - conv.Subscribe(ctx, body.From, emit) + conv.Subscribe(ctx, body.From, body.Full, emit) } // handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse