Chat : 20 derniers échanges au chargement, et le toucher revient sur iOS

Repris d'AJEAN 0.15.7.

HISTORIQUE PAGINÉ
Rouvrir une longue conversation d'agent rejouait TOUT le journal : des Mo
à travers le tunnel, et des milliers de bulles construites avant
d'afficher la moindre chose. Le client annonce désormais `tail` : au
chargement (et à l'ouverture d'une autre discussion), le serveur ne rejoue
que les 20 derniers échanges, précédés de {history_more: N}. Un bouton en
tête du fil redemande le rejeu complet en gardant la position de lecture.
Un client qui n'envoie pas `tail` (app relais…) reçoit tout, comme avant.

Écart avec l'amont : les états que le client garde et que la partie
masquée avait posés — mode Chat/Code, critères du mode Code — sont réémis
à la coupe (historyHead), ainsi que ctx_used à l'ouverture d'une
discussion. Sans ça, une discussion en mode Code rouverte s'affichait en
mode Chat, et la jauge de contexte à zéro.

iOS : LES TAPS PERDUS PENDANT UNE RÉPONSE
iOS annule un appui si la position de défilement change pendant qu'il a
lieu. Le fil se recalant en bas à chaque token, presque chaque tap tombait
dessus — y compris sur stop. Le suivi est suspendu pendant un contact (et
350 ms après), scrollTop n'est plus réécrit quand on est déjà en bas, et
stop agit dès l'appui sur écran tactile (anti-doublon pour le clic qui
suit).

Vérifié dans le navigateur sur 23 échanges : 20 affichés + « afficher les
3 échanges précédents », puis rejeu complet à position de lecture égale.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:07:56 +02:00
1 parent 51c791268d
commit f54c304019
8 files changed
+356 -15

No files matched your search

+97 -2
View File
@@ -967,11 +967,76 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
return out
}
// Subscribe diffuse les événements au client via emit : d'abord un REPLAY coalescé
// historyTailDefault : échanges rejoués à l'ouverture d'une session (reset) pour
// un client qui gère la pagination de l'historique.
const historyTailDefault = 20
// historyCut calcule où couper le rejeu pour ne garder que les `tail` derniers
// échanges (un échange commence à un événement `user`). Renvoie le Seq à partir
// duquel rejouer (exclu) et le nombre d'échanges masqués ; (0, 0) = tout
// rejouer. Repris d'AJEAN 0.15.7.
func historyCut(log []LogEvent, tail int) (int, int) {
if tail <= 0 {
return 0, 0
}
seen := 0
for i := len(log) - 1; i >= 0; i-- {
if _, ok := log[i].Delta["user"]; !ok {
continue
}
seen++
if seen == tail {
hidden := 0
for _, ev := range log[:i] {
if _, ok := ev.Delta["user"]; ok {
hidden++
}
}
if hidden == 0 {
return 0, 0
}
return log[i].Seq - 1, hidden
}
}
return 0, 0
}
// historyHead : événements qui annoncent un historique tronqué. Les états que
// les échanges masqués avaient posés et que le client garde (mode Chat/Code,
// critères du mode Code) sont réémis tels qu'ils étaient à la coupe — sans eux,
// une discussion en mode Code rouverte s'afficherait en mode Chat.
func historyHead(log []LogEvent, cut, hidden int) []map[string]any {
out := []map[string]any{{"history_more": hidden}}
var mode, crit map[string]any
for _, ev := range log {
if ev.Seq > cut {
break
}
if _, ok := ev.Delta["mode"]; ok {
mode = ev.Delta
}
if _, ok := ev.Delta["criteria"]; ok {
crit = ev.Delta
}
}
for _, d := range []map[string]any{mode, crit} {
if d != nil {
out = append(out, d)
}
}
return out
}
// Subscribe : abonnement sans pagination (rejeu complet).
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
c.SubscribeTail(ctx, from, -1, emit)
}
// SubscribeTail diffuse les événements au client via emit : d'abord un REPLAY coalescé
// 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) {
func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, emit func(map[string]any) bool) {
// Réveille les attentes de cond quand la connexion se ferme.
go func() {
<-ctx.Done()
@@ -1006,6 +1071,20 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
from = 0
}
// Chargement initial d'un client paginé : on ne rejoue que la fin du fil,
// précédée de {history_more: N}. Sur un long fil d'agent, tout rejouer
// envoyait des Mo et bâtissait des milliers de bulles avant d'afficher quoi
// que ce soit.
if from == 0 && tail > 0 {
if cut, hidden := historyCut(snapshot, tail); hidden > 0 {
from = cut
for _, ev := range historyHead(snapshot, cut, hidden) {
if !emit(ev) {
return
}
}
}
}
last := from
for _, ev := range coalesceReplay(snapshot, from) {
if ctx.Err() != nil {
@@ -1051,10 +1130,26 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
if c.epoch != epoch { // reset → on ordonne au client de nettoyer et on repart
epoch = c.epoch
last = 0
// Session ouverte (fil complet qui suit) chez un client paginé : on
// saute directement aux derniers échanges.
var head []map[string]any
if tail >= 0 {
if cut, hidden := historyCut(c.Log, historyTailDefault); hidden > 0 {
last = cut
// ctx_used vit dans le journal des échanges masqués : on le
// donne tel qu'il est maintenant, sinon la jauge resterait à 0.
head = append(historyHead(c.Log, cut, hidden), map[string]any{"ctx_used": c.CtxUsed})
}
}
c.mu.Unlock()
if !emit(map[string]any{"reset": true}) {
return
}
for _, ev := range head {
if !emit(ev) {
return
}
}
c.mu.Lock()
continue
}
+80
View File
@@ -0,0 +1,80 @@
package loki
import (
"context"
"testing"
)
// historyCut ne garde que les N derniers échanges (un échange commence à `user`).
func TestHistoryCut(t *testing.T) {
var log []LogEvent
seq := 0
add := func(d map[string]any) { seq++; log = append(log, LogEvent{Seq: seq, Delta: d}) }
for i := 0; i < 5; i++ {
add(map[string]any{"user": "q"})
add(map[string]any{"content": "r"})
add(map[string]any{"turn_done": true})
}
// 5 échanges, seq 1..15 ; les 2 derniers commencent aux seq 10 et 13.
if cut, hidden := historyCut(log, 2); cut != 9 || hidden != 3 {
t.Fatalf("tail=2 : cut=%d hidden=%d, attendu 9 et 3", cut, hidden)
}
if cut, hidden := historyCut(log, 5); cut != 0 || hidden != 0 {
t.Fatalf("tail=5 (tout tient) : cut=%d hidden=%d, attendu 0 et 0", cut, hidden)
}
if cut, hidden := historyCut(log, 0); cut != 0 || hidden != 0 {
t.Fatalf("tail=0 : pas de coupure attendue (cut=%d hidden=%d)", cut, hidden)
}
// Les événements rejoués après la coupure commencent bien au 4e échange.
evs := coalesceReplay(log, 9)
if u, _ := evs[0]["user"].(string); u != "q" || evs[0]["seq"] != 10 {
t.Fatalf("premier événement rejoué inattendu : %v", evs[0])
}
}
// La coupe réémet le mode (Chat/Code) posé dans la partie masquée.
func TestHistoryHeadCarriesMode(t *testing.T) {
log := []LogEvent{
{Seq: 1, Delta: map[string]any{"mode": "code"}},
{Seq: 2, Delta: map[string]any{"user": "q1"}},
{Seq: 3, Delta: map[string]any{"user": "q2"}},
}
head := historyHead(log, 2, 1)
if len(head) != 2 || head[0]["history_more"] != 1 || head[1]["mode"] != "code" {
t.Fatalf("en-tête inattendu : %v", head)
}
}
// Un client paginé ne reçoit au chargement que les derniers échanges, précédés
// de {history_more} ; un ancien client (tail < 0) reçoit tout.
func TestSubscribeTailPaginates(t *testing.T) {
c := newTestConv()
for i := 0; i < 5; i++ {
c.appendDelta(c.epoch, map[string]any{"user": "q"})
c.appendDelta(c.epoch, map[string]any{"content": "r"})
}
collect := func(tail int) (more any, users int) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
c.SubscribeTail(ctx, 0, tail, func(m map[string]any) bool {
if v, ok := m["history_more"]; ok {
more = v
}
if _, ok := m["user"]; ok {
users++
}
if _, ok := m["caught_up"]; ok {
cancel()
return false
}
return true
})
return
}
if more, users := collect(2); more != 3 || users != 2 {
t.Fatalf("tail=2 : history_more=%v, %d échanges rejoués (attendu 3 et 2)", more, users)
}
if more, users := collect(-1); more != nil || users != 5 {
t.Fatalf("ancien client : history_more=%v, %d échanges (attendu rien et 5)", more, users)
}
}
+85 -6
View File
@@ -2238,6 +2238,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
.md-report table{border-collapse:collapse;margin:.5em 0}
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
/* Historique paginé : bouton « échanges précédents » en tête du fil. */
#chat .history-more{display:flex;justify-content:center;padding:6px 0 14px}
#chat .history-more button{font:inherit;font-size:12px;color:var(--dim);background:var(--row-bg);
border:1px solid var(--border);border-radius:999px;padding:6px 14px;cursor:pointer;
transition:background .15s,color .15s,border-color .15s}
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
#chat .history-more button:disabled{opacity:.6;cursor:default}
</style></head><body>
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -6402,15 +6410,32 @@ addEventListener('DOMContentLoaded', ()=>{
if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c);
});
// iOS ANNULE un appui si la position de défilement change pendant qu'il a lieu.
// Le fil se recalant en bas à chaque token pendant la génération, presque chaque
// tap tombait dessus : plus rien ne répondait (pas même stop) jusqu'à la fin de
// la réponse. On suspend donc le suivi le temps du contact, et on rattrape juste
// après (TOUCH_GRACE). Repris d'AJEAN 0.15.7.
let TOUCHING = false, _touchEnd = 0;
const TOUCH_GRACE = 350;
(function(){
const on = ()=>{ TOUCHING = true; };
const off = ()=>{ TOUCHING = false; _touchEnd = performance.now(); if(stickyBottom) setTimeout(()=>scrollMaybe(), TOUCH_GRACE + 20); };
document.addEventListener('touchstart', on, {passive:true, capture:true});
document.addEventListener('touchend', off, {passive:true, capture:true});
document.addEventListener('touchcancel', off, {passive:true, capture:true});
})();
function scrollMaybe(){
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
if(REPLAYING) return;
if(stickyBottom){
if(stickyBottom && !TOUCHING && performance.now() - _touchEnd >= TOUCH_GRACE){
const c = chatEl();
c.scrollTop = c.scrollHeight;
const target = c.scrollHeight - c.clientHeight;
// N'écrire que si l'on n'y est pas déjà : sinon on relance pour rien la
// machinerie de défilement d'iOS, qui continue de voler les taps.
if(Math.abs(c.scrollTop - target) > 1) c.scrollTop = c.scrollHeight;
}
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
}
@@ -6949,6 +6974,13 @@ 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;
// Historique paginé (AJEAN 0.15.7) : au chargement, le serveur ne rejoue que les
// HIST_TAIL derniers échanges et annonce le reste ({history_more: N}). Le bouton
// en haut du fil redemande alors un rejeu complet (HIST_FULL) en gardant la
// position de lecture (HIST_RESTORE = distance au bas du fil, réappliquée au
// caught_up).
const HIST_TAIL=20;
let HIST_FULL=false, HIST_RESTORE=null;
// 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
@@ -7361,7 +7393,15 @@ function handleDelta(d){
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
if(REPLAYING && HIST_RESTORE!==null){
// Historique complet demandé : on garde la position de lecture (distance au
// bas du fil) au lieu de sauter en bas, et on la recale une fois la mise en
// page stabilisée (le rejeu pose ses blocs de façon différée).
REPLAYING=false; syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1';
const keep=HIST_RESTORE; HIST_RESTORE=null; stickyBottom=false;
const put=()=>{ c.scrollTop=Math.max(0, c.scrollHeight-keep); };
put(); requestAnimationFrame(()=>requestAnimationFrame(put)); setTimeout(put, 80); setTimeout(put, 250);
} else if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
@@ -7374,7 +7414,8 @@ function handleDelta(d){
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; }
if(d.reset!==undefined){ HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -7502,7 +7543,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,tail:HIST_FULL?0:HIST_TAIL}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
@@ -7527,7 +7568,45 @@ async function connectStream(){
}
// Interrompt la génération en cours côté serveur (la goroutine détachée est
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
// _stopAt : anti-doublon, le bouton réagit à l'APPUI sur écran tactile (voir plus
// bas) puis le clic qui suit éventuellement ne doit pas relancer.
let _stopAt = 0;
function stopGen(){
const now = Date.now(); if(now - _stopAt < 800) return; _stopAt = now;
jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop');
}
// Sur écran tactile, stop agit dès l'appui (pointerdown) : le « clic » complet
// peut être annulé par iOS si le fil défile au même moment — or c'est justement
// quand l'IA écrit qu'on veut l'arrêter vite. Repris d'AJEAN 0.15.7.
document.addEventListener('DOMContentLoaded', ()=>{
const st=document.getElementById('stop'); if(!st) return;
st.addEventListener('pointerdown', (e)=>{ if(e.pointerType!=='mouse'){ e.preventDefault(); stopGen(); } });
});
// Bouton « échanges précédents » posé en tête du fil quand le serveur n'a rejoué
// que la fin de la conversation.
function showHistoryMore(n){
const chat=chatEl(); if(!chat || !n) return;
let box=chat.querySelector('.history-more');
if(!box){ box=document.createElement('div'); box.className='history-more'; chat.insertBefore(box, chat.firstChild); }
box.innerHTML='';
const b=document.createElement('button'); b.type='button';
b.textContent = n===1 ? 'afficher l\'échange précédent' : 'afficher les '+n+' échanges précédents';
b.onclick=loadFullHistory;
box.appendChild(b);
}
// Rejeu COMPLET de la conversation vive, en gardant ce qu'on lisait à l'écran :
// on vide le fil, on coupe le flux, et la boucle connectStream se reconnecte
// depuis le début sans pagination (tail=0).
function loadFullHistory(){
const chat=chatEl(); if(!chat) return;
const box=chat.querySelector('.history-more'); if(box){ const b=box.querySelector('button'); if(b){ b.disabled=true; b.textContent='chargement…'; } }
HIST_RESTORE=chat.scrollHeight-chat.scrollTop;
HIST_FULL=true;
chat.innerHTML=''; newTurn();
smoothReset(); cancelRender();
lastSeq=0; REPLAYING=true;
if(streamAbort){ try{ streamAbort.abort(); }catch(e){} }
}
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
+19 -2
View File
@@ -35,15 +35,32 @@ addEventListener('DOMContentLoaded', ()=>{
if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c);
});
// iOS ANNULE un appui si la position de défilement change pendant qu'il a lieu.
// Le fil se recalant en bas à chaque token pendant la génération, presque chaque
// tap tombait dessus : plus rien ne répondait (pas même stop) jusqu'à la fin de
// la réponse. On suspend donc le suivi le temps du contact, et on rattrape juste
// après (TOUCH_GRACE). Repris d'AJEAN 0.15.7.
let TOUCHING = false, _touchEnd = 0;
const TOUCH_GRACE = 350;
(function(){
const on = ()=>{ TOUCHING = true; };
const off = ()=>{ TOUCHING = false; _touchEnd = performance.now(); if(stickyBottom) setTimeout(()=>scrollMaybe(), TOUCH_GRACE + 20); };
document.addEventListener('touchstart', on, {passive:true, capture:true});
document.addEventListener('touchend', off, {passive:true, capture:true});
document.addEventListener('touchcancel', off, {passive:true, capture:true});
})();
function scrollMaybe(){
// Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque
// événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût
// quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait
// une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}.
if(REPLAYING) return;
if(stickyBottom){
if(stickyBottom && !TOUCHING && performance.now() - _touchEnd >= TOUCH_GRACE){
const c = chatEl();
c.scrollTop = c.scrollHeight;
const target = c.scrollHeight - c.clientHeight;
// N'écrire que si l'on n'y est pas déjà : sinon on relance pour rien la
// machinerie de défilement d'iOS, qui continue de voler les taps.
if(Math.abs(c.scrollTop - target) > 1) c.scrollTop = c.scrollHeight;
}
document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom);
}
+58 -4
View File
@@ -4,6 +4,13 @@
// 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;
// Historique paginé (AJEAN 0.15.7) : au chargement, le serveur ne rejoue que les
// HIST_TAIL derniers échanges et annonce le reste ({history_more: N}). Le bouton
// en haut du fil redemande alors un rejeu complet (HIST_FULL) en gardant la
// position de lecture (HIST_RESTORE = distance au bas du fil, réappliquée au
// caught_up).
const HIST_TAIL=20;
let HIST_FULL=false, HIST_RESTORE=null;
// 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
@@ -416,7 +423,15 @@ function handleDelta(d){
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
if(REPLAYING && HIST_RESTORE!==null){
// Historique complet demandé : on garde la position de lecture (distance au
// bas du fil) au lieu de sauter en bas, et on la recale une fois la mise en
// page stabilisée (le rejeu pose ses blocs de façon différée).
REPLAYING=false; syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1';
const keep=HIST_RESTORE; HIST_RESTORE=null; stickyBottom=false;
const put=()=>{ c.scrollTop=Math.max(0, c.scrollHeight-keep); };
put(); requestAnimationFrame(()=>requestAnimationFrame(put)); setTimeout(put, 80); setTimeout(put, 250);
} else if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
@@ -429,7 +444,8 @@ function handleDelta(d){
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; }
if(d.reset!==undefined){ HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -557,7 +573,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,tail:HIST_FULL?0:HIST_TAIL}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
@@ -582,7 +598,45 @@ async function connectStream(){
}
// Interrompt la génération en cours côté serveur (la goroutine détachée est
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
// _stopAt : anti-doublon, le bouton réagit à l'APPUI sur écran tactile (voir plus
// bas) puis le clic qui suit éventuellement ne doit pas relancer.
let _stopAt = 0;
function stopGen(){
const now = Date.now(); if(now - _stopAt < 800) return; _stopAt = now;
jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop');
}
// Sur écran tactile, stop agit dès l'appui (pointerdown) : le « clic » complet
// peut être annulé par iOS si le fil défile au même moment — or c'est justement
// quand l'IA écrit qu'on veut l'arrêter vite. Repris d'AJEAN 0.15.7.
document.addEventListener('DOMContentLoaded', ()=>{
const st=document.getElementById('stop'); if(!st) return;
st.addEventListener('pointerdown', (e)=>{ if(e.pointerType!=='mouse'){ e.preventDefault(); stopGen(); } });
});
// Bouton « échanges précédents » posé en tête du fil quand le serveur n'a rejoué
// que la fin de la conversation.
function showHistoryMore(n){
const chat=chatEl(); if(!chat || !n) return;
let box=chat.querySelector('.history-more');
if(!box){ box=document.createElement('div'); box.className='history-more'; chat.insertBefore(box, chat.firstChild); }
box.innerHTML='';
const b=document.createElement('button'); b.type='button';
b.textContent = n===1 ? 'afficher l\'échange précédent' : 'afficher les '+n+' échanges précédents';
b.onclick=loadFullHistory;
box.appendChild(b);
}
// Rejeu COMPLET de la conversation vive, en gardant ce qu'on lisait à l'écran :
// on vide le fil, on coupe le flux, et la boucle connectStream se reconnecte
// depuis le début sans pagination (tail=0).
function loadFullHistory(){
const chat=chatEl(); if(!chat) return;
const box=chat.querySelector('.history-more'); if(box){ const b=box.querySelector('button'); if(b){ b.disabled=true; b.textContent='chargement…'; } }
HIST_RESTORE=chat.scrollHeight-chat.scrollTop;
HIST_FULL=true;
chat.innerHTML=''; newTurn();
smoothReset(); cancelRender();
lastSeq=0; REPLAYING=true;
if(streamAbort){ try{ streamAbort.abort(); }catch(e){} }
}
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
+8
View File
@@ -2203,3 +2203,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
.md-report table{border-collapse:collapse;margin:.5em 0}
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
/* Historique paginé : bouton « échanges précédents » en tête du fil. */
#chat .history-more{display:flex;justify-content:center;padding:6px 0 14px}
#chat .history-more button{font:inherit;font-size:12px;color:var(--dim);background:var(--row-bg);
border:1px solid var(--border);border-radius:999px;padding:6px 14px;cursor:pointer;
transition:background .15s,color .15s,border-color .15s}
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
#chat .history-more button:disabled{opacity:.6;cursor:default}
+4
View File
@@ -1345,6 +1345,10 @@ 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"`
// Tail (flux d'abonnement) : nombre de derniers ÉCHANGES à rejouer au
// chargement ; 0 = tout. nil = ancien client (app relais…), qui ne sait pas
// afficher le bouton « échanges précédents » : il reçoit toujours tout.
Tail *int `json:"tail"`
// 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
+5 -1
View File
@@ -84,7 +84,11 @@ 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)
tail := -1
if body.Tail != nil {
tail = *body.Tail
}
conv.SubscribeTail(ctx, body.From, tail, emit)
}
// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse