From f54c3040196b490cd67c35486e0a2de942c83482 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 28 Sep 2026 00:34:38 +0200 Subject: [PATCH] =?UTF-8?q?Chat=20:=2020=20derniers=20=C3=A9changes=20au?= =?UTF-8?q?=20chargement,=20et=20le=20toucher=20revient=20sur=20iOS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/loki/chat_conversation.go | 99 ++++++++++++++++++++++- internal/loki/chat_history_tail_test.go | 80 ++++++++++++++++++ internal/loki/ui/index.html | 91 +++++++++++++++++++-- internal/loki/ui/src/js/08-chat-render.js | 21 ++++- internal/loki/ui/src/js/09-stream.js | 62 +++++++++++++- internal/loki/ui/src/styles.css | 8 ++ internal/loki/web_api.go | 4 + internal/loki/web_chat.go | 6 +- 8 files changed, 356 insertions(+), 15 deletions(-) create mode 100644 internal/loki/chat_history_tail_test.go diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 55ee889..cd65e72 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -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 } diff --git a/internal/loki/chat_history_tail_test.go b/internal/loki/chat_history_tail_test.go new file mode 100644 index 0000000..bf246ab --- /dev/null +++ b/internal/loki/chat_history_tail_test.go @@ -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) + } +} diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 7993228..604cd2f 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -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}