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:
Claude committed 2026-09-22 14:04:58 +00:00
1 parent 382c5dbdce
commit c32fd40f1a
8 files changed
+182 -11

No files matched your search

+5
View File
@@ -318,6 +318,11 @@ Ajoutées par ce fork :
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois, → 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 puis la traduction est retenue pour ce modèle. La liste est grisée quand le
raisonnement est coupé pour ce modè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 - **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. **Chaque discussion a son repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
+25 -2
View File
@@ -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. // 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, // 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). // 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. // Réveille les attentes de cond quand la connexion se ferme.
go func() { go func() {
<-ctx.Done() <-ctx.Done()
@@ -988,7 +999,19 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
from = 0 from = 0
} }
last := from 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 { if ctx.Err() != nil {
return return
} }
+53 -6
View File
@@ -27,7 +27,7 @@ func TestSubscribeReplayAndLive(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
got := make(chan int, 32) 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 { if s, ok := m["seq"].(int); ok {
got <- s got <- s
} }
@@ -51,7 +51,7 @@ func TestSubscribeFromOffset(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
got := make(chan int, 8) 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 { if s, ok := m["seq"].(int); ok {
got <- s got <- s
} }
@@ -68,7 +68,7 @@ func TestResetNotifiesSubscribers(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
resetSeen := make(chan bool, 4) 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 { if _, ok := m["reset"]; ok {
resetSeen <- true resetSeen <- true
} }
@@ -202,7 +202,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
} { } {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
got := make(chan int, 8) 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 { if s, ok := m["seq"].(int); ok {
got <- s got <- s
} }
@@ -216,7 +216,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
got := make(chan int, 8) 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 { if s, ok := m["seq"].(int); ok {
got <- s got <- s
} }
@@ -289,7 +289,7 @@ func TestSubscribeStaleFromCrossSession(t *testing.T) {
var got []map[string]any var got []map[string]any
done := make(chan struct{}) done := make(chan struct{})
go func() { 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) got = append(got, ev)
if ev["caught_up"] != nil { if ev["caught_up"] != nil {
cancel() 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) 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)
}
}
+46 -1
View File
@@ -478,6 +478,13 @@ button:disabled{opacity:.5;cursor:not-allowed}
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none} .diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
.diff-count .a{color:var(--add)} .diff-count .a{color:var(--add)}
.diff-count .d{color:var(--del)} .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} .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 /* 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 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é // 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. // serveur) ; se reconnecter rejoue tout le fil, détails compris.
let lastSeq=0, streamAbort=null; 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 // 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 // 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 // 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. // bulle d'outil) doit voir le texte complet.
function settleBlocks(){ smoothSnap(); flushRender(); } 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 // Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
// piloté par le serveur et rejouable à l'identique. // piloté par le serveur et rejouable à l'identique.
function handleDelta(d){ function handleDelta(d){
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq; 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 // 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 // `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. // 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)); } while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController(); streamAbort=new AbortController();
try{ 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…'); if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){ while(true){
+39 -1
View File
@@ -4,6 +4,10 @@
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté // 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. // serveur) ; se reconnecter rejoue tout le fil, détails compris.
let lastSeq=0, streamAbort=null; 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 // 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 // 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 // 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. // bulle d'outil) doit voir le texte complet.
function settleBlocks(){ smoothSnap(); flushRender(); } 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 // Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
// piloté par le serveur et rejouable à l'identique. // piloté par le serveur et rejouable à l'identique.
function handleDelta(d){ function handleDelta(d){
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq; 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 // 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 // `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. // 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)); } while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController(); streamAbort=new AbortController();
try{ 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…'); if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){ while(true){
+7
View File
@@ -443,6 +443,13 @@ button:disabled{opacity:.5;cursor:not-allowed}
.diff-count{margin-left:8px;letter-spacing:0;text-transform:none} .diff-count{margin-left:8px;letter-spacing:0;text-transform:none}
.diff-count .a{color:var(--add)} .diff-count .a{color:var(--add)}
.diff-count .d{color:var(--del)} .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} .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 /* 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 ancrée en bas à droite, au lieu d'une ligne séparée sous le bloc. Le pre
+6
View File
@@ -1349,6 +1349,12 @@ type chatReq struct {
// vu par le client (le flux d'abonnement rejoue Log[From:] puis suit le direct). // vu par le client (le flux d'abonnement rejoue Log[From:] puis suit le direct).
Message string `json:"message"` Message string `json:"message"`
From int `json:"from"` 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 // 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 // /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 // tête du message (voir attachNote) ; le contenu, lui, reste sur le disque et
+1 -1
View File
@@ -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 // goroutine détachée — fermer le navigateur n'arrête donc plus rien. Partagé par
// handleChat (clair) et handleE2EChat (chiffré). // handleChat (clair) et handleE2EChat (chiffré).
func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) { 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 // handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse