mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Longues discussions : ne rejouer que la fin, charger le début d'un clic
Inspiré du fenêtrage du fil d'OpenFox (2.0.151+). À chaque ouverture d'onglet, le serveur rejouait TOUT le journal d'affichage : sur une discussion de plusieurs centaines de tours, des dizaines de milliers d'événements traversaient le flux, puis autant de bulles s'installaient dans le DOM que le navigateur devait traîner à chaque rendu. Le replay initial est désormais borné à ses 4000 derniers événements. Rien n'est tronqué sur le disque : le serveur annonce combien d'événements sont restés en arrière, l'interface l'affiche en tête du fil et le bouton « charger le début » se réabonne en demandant le journal entier. Jamais borné sur une reprise de flux (from > 0) : là, le client a déjà le début à l'écran et attend la suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
This commit is contained in:
8 files changed
+182
-11
No files matched your search
@@ -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/<id>/`) : les pièces jointes
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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){
|
||||
|
||||
@@ -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){
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user