mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-14 18:58:43 +02:00
Fil long : retour au replay complet avant la reprise du fenêtrage par échanges
Annule c32fd40. Le replay borné aux 4000 derniers événements coupait au
milieu d'un tour, perdait l'état du mode Code (critères, jauge de contexte)
porté par la partie masquée et s'imposait à tous les clients. Le fenêtrage
par échanges repris d'AJEAN (commit suivant) coupe aux frontières d'échange
et renvoie cet état en tête.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
04137411fa
commit
51c791268d
8 files changed
+11
-182
No files matched your search
@@ -335,11 +335,6 @@ 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/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -971,18 +971,7 @@ 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).
|
||||
// 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) {
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
|
||||
// Réveille les attentes de cond quand la connexion se ferme.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
@@ -1018,19 +1007,7 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit
|
||||
from = 0
|
||||
}
|
||||
last := 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 {
|
||||
for _, ev := range coalesceReplay(snapshot, from) {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, 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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 1, 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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, 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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, tc.from, 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, false, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 502, 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, false, func(ev map[string]any) bool {
|
||||
c.Subscribe(ctx, 9999, func(ev map[string]any) bool {
|
||||
got = append(got, ev)
|
||||
if ev["caught_up"] != nil {
|
||||
cancel()
|
||||
@@ -309,50 +309,3 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -478,13 +478,6 @@ 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
|
||||
@@ -6956,10 +6949,6 @@ 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
|
||||
@@ -7359,44 +7348,10 @@ 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.
|
||||
@@ -7547,7 +7502,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, full:REPLAY_FULL}),signal:streamAbort.signal});
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
|
||||
if(REPLAYING) setChatLoading('chargement de la conversation…');
|
||||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
|
||||
@@ -4,10 +4,6 @@
|
||||
// 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
|
||||
@@ -407,44 +403,10 @@ 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.
|
||||
@@ -595,7 +557,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, full:REPLAY_FULL}),signal:streamAbort.signal});
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
|
||||
if(REPLAYING) setChatLoading('chargement de la conversation…');
|
||||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
|
||||
@@ -443,13 +443,6 @@ 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
|
||||
|
||||
@@ -1345,12 +1345,6 @@ 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
|
||||
|
||||
@@ -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, body.Full, emit)
|
||||
conv.Subscribe(ctx, body.From, emit)
|
||||
}
|
||||
|
||||
// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse
|
||||
|
||||
Reference in new issue
Block a user