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}