diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 1f82a02..f3c272c 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -216,6 +216,21 @@ func (c *Conversation) compactLogLocked() { bufKey, toks, ts0, seq0 = "", 0, 0, 0 } for _, ev := range c.Log { + // Outils : un appel s'écrit en de nombreux événements (annonce done=false, + // frappe du corps, streaming des arguments) jusqu'au done=true, qui porte + // déjà l'état FINAL (résultat + diff). Les intermédiaires ne servent qu'à + // l'affichage EN DIRECT ; les garder dans le journal le faisait enfler sur + // une conversation agentique (gros write, appels en chaîne) jusqu'à + // maxLogEvents → troncature des plus VIEUX événements, donc perte des + // premiers messages à l'affichage. On ne conserve donc que le done=true + // (même politique que coalesceReplay au replay/export). + if tu, ok := ev.Delta["tool_used"].(map[string]any); ok { + flush() + if done, _ := tu["done"].(bool); done { + out = append(out, ev) + } + continue + } key := textKey(ev.Delta) if key == "" { flush() @@ -777,11 +792,15 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any { continue } _, isTool := ev.Delta["tool_used"].(map[string]any) - // Tout événement NON-outil clôt une éventuelle bulle d'outil en attente, pour - // préserver l'ordre (l'outil non terminé s'affiche avant ce qui le suit). - if !isTool { - flushTool() - } + // ⚠️ On ne « flushe » PAS l'annonce d'outil en attente sur un événement + // non-outil. Un appel d'outil s'écrit dans le journal en DEUX temps : une + // annonce (done=false, juste « je vais lancer X ») puis le résultat + // (done=true). Si un événement non-outil (stats, un bout de raisonnement) + // se glisse ENTRE les deux, flusher ici émettait l'annonce PUIS le done → + // le même outil apparaissait DEUX fois (à l'export Markdown et au replay). + // On garde donc l'annonce en attente : le done la remplace (émis une seule + // fois), et si aucun done n'arrive jamais (outil interrompu par un stop), + // le flushTool final l'émet une seule fois, en fin de fil. // Delta texte ? (une seule clé content ou reasoning_content, valeur string) key := "" if s, ok := ev.Delta["content"].(string); ok { @@ -852,6 +871,17 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st snapshot := append([]LogEvent(nil), c.Log...) epoch := c.epoch c.mu.Unlock() + // ⚠️ Garde-fou anti-« premiers messages manquants au changement de session ». + // Le client se réabonne avec from=lastSeq (le dernier Seq qu'il a vu). Les Seq + // ne sont PAS globaux : ouvrir une session PLUS ANCIENNE charge un journal dont + // les Seq sont plus bas. Si le client garde un from élevé (hérité de la session + // qu'il quittait) et se reconnecte, coalesceReplay saute tout (Seq <= from) → + // la conversation ouverte apparaît tronquée, voire vide. En session normale, le + // client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il + // l'est, son curseur vient d'une autre session → on repart du début. + if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq { + from = 0 + } last := from for _, ev := range coalesceReplay(snapshot, from) { if ctx.Err() != nil { diff --git a/internal/loki/chat_conversation_test.go b/internal/loki/chat_conversation_test.go index 5173852..1f9b0a2 100644 --- a/internal/loki/chat_conversation_test.go +++ b/internal/loki/chat_conversation_test.go @@ -225,3 +225,87 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) { c.appendDelta(c.epoch, map[string]any{"content": "suite"}) waitSeq(t, got, 503) } + +// --- Régressions v0.12.7 : journal d'affichage (outils, replay, changement de session) --- + +// compactLogLocked doit coalescer les événements tool_used d'un même appel en un +// seul (le done=true, qui porte l'état final) : les intermédiaires (annonce, frappe +// du corps) ne servent qu'à l'affichage en direct et faisaient enfler le journal +// jusqu'à la troncature des premiers messages. +func TestCompactLogCoalesceTools(t *testing.T) { + c := newTestConv() + c.Log = []LogEvent{ + {Seq: 1, Delta: map[string]any{"user": "édite"}}, + {Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f"}}}, + {Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "typing": true, "body": "a"}}}, + {Seq: 4, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "result": "ok", "done": true}}}, + {Seq: 5, Delta: map[string]any{"content": "fini"}}, + } + c.mu.Lock() + c.compactLogLocked() + c.mu.Unlock() + nTool := 0 + for _, ev := range c.Log { + if tu, ok := ev.Delta["tool_used"].(map[string]any); ok { + nTool++ + if done, _ := tu["done"].(bool); !done { + t.Error("un tool_used non terminé a survécu à la compaction") + } + } + } + if nTool != 1 { + t.Fatalf("compactLogLocked garde %d tool_used au lieu de 1", nTool) + } +} + +// coalesceReplay ne doit émettre un outil qu'UNE fois même si un événement non-outil +// (stats, un bout de raisonnement) tombe entre l'annonce (done=false) et le done=true. +func TestCoalesceReplayNoDoubleTool(t *testing.T) { + events := []LogEvent{ + {Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls"}}}, + {Seq: 2, Delta: map[string]any{"stats": map[string]any{"gen_tokens": 3}}}, + {Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls", "result": "x", "done": true}}}, + } + n := 0 + for _, ev := range coalesceReplay(events, 0) { + if _, ok := ev["tool_used"]; ok { + n++ + } + } + if n != 1 { + t.Fatalf("outil émis %d fois au replay (attendu 1)", n) + } +} + +// Un abonné qui arrive avec un `from` SUPÉRIEUR au dernier Seq du journal (curseur +// hérité d'une autre session, plus récente) doit obtenir le fil COMPLET, pas rien : +// sans ce garde-fou, ouvrir une session plus ancienne la montrait tronquée/vide. +func TestSubscribeStaleFromCrossSession(t *testing.T) { + c := newTestConv() + c.appendDelta(c.epoch, map[string]any{"user": "premier message"}) + c.appendDelta(c.epoch, map[string]any{"content": "réponse"}) + // Le journal va jusqu'à Seq 2 ; on s'abonne avec from=9999 (curseur d'une autre session). + ctx, cancel := context.WithCancel(context.Background()) + var got []map[string]any + done := make(chan struct{}) + go func() { + c.Subscribe(ctx, 9999, func(ev map[string]any) bool { + got = append(got, ev) + if ev["caught_up"] != nil { + cancel() + } + return true + }) + close(done) + }() + <-done + seen := "" + for _, ev := range got { + if u, ok := ev["user"].(string); ok { + seen += u + } + } + if !strings.Contains(seen, "premier message") { + t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen) + } +} diff --git a/internal/loki/chat_export.go b/internal/loki/chat_export.go index af117a6..a4fd48f 100644 --- a/internal/loki/chat_export.go +++ b/internal/loki/chat_export.go @@ -103,12 +103,78 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) { Version: Version, ExportedAt: time.Now().Format(time.RFC3339), CtxUsed: ctxUsed, - Messages: msgs, + Messages: stripInlineImages(msgs), Log: filterLog(log, o), } return json.MarshalIndent(p, "", " ") } +// stripInlineImages remplace les images en base64 (data URI) portées par les +// messages multimodaux (vision : pièces jointes images + outil see_image) par un +// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait +// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown, +// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal +// d'affichage non plus (il ne porte pas les images). On ne touche donc qu'ici, et +// sur une COPIE : les messages appartiennent à la conversation vivante. +func stripInlineImages(msgs []Message) []Message { + out := make([]Message, len(msgs)) + for i, m := range msgs { + out[i] = m + out[i].Content = stripInlineImagesValue(m.Content) + } + return out +} + +func stripInlineImagesValue(v any) any { + // Une partie image (format OpenAI) : {"type":"image_url","image_url":{"url":"data:…"}}. + // On copie la partie et on élide l'URL data: en gardant l'en-tête (type MIME) lisible. + elide := func(part map[string]any) map[string]any { + cp := make(map[string]any, len(part)) + for k, val := range part { + cp[k] = val + } + if iu, ok := cp["image_url"].(map[string]any); ok { + c2 := make(map[string]any, len(iu)) + for k, val := range iu { + c2[k] = val + } + if url, ok := c2["url"].(string); ok && strings.HasPrefix(url, "data:") { + c2["url"] = elideDataURI(url) + } + cp["image_url"] = c2 + } + return cp + } + switch parts := v.(type) { + case []map[string]any: // message fraîchement produit + out := make([]map[string]any, len(parts)) + for i, p := range parts { + out[i] = elide(p) + } + return out + case []any: // message relu depuis la base (JSON) : []any de maps + out := make([]any, len(parts)) + for i, p := range parts { + if mp, ok := p.(map[string]any); ok { + out[i] = elide(mp) + } else { + out[i] = p + } + } + return out + } + return v // simple chaîne (cas courant) : rien à faire +} + +// elideDataURI garde l'en-tête « data:;base64, » (lisible) et remplace le +// corps base64 par sa taille, pour que l'export reste informatif sans le poids. +func elideDataURI(url string) string { + if i := strings.Index(url, ","); i >= 0 { + return url[:i+1] + fmt.Sprintf("<élidé — %s>", humanBytes(int64(len(url)-i-1))) + } + return "" +} + // filterLog applique au journal les MÊMES cases que le Markdown. Les deux // formats avaient auparavant des options distinctes (le JSON proposait un // obscur « journal d'affichage » à la place des trois autres) : personne ne diff --git a/internal/loki/chat_export_test.go b/internal/loki/chat_export_test.go index 6b4699f..7104ea7 100644 --- a/internal/loki/chat_export_test.go +++ b/internal/loki/chat_export_test.go @@ -258,3 +258,35 @@ func TestCmdExportEcritLeFichier(t *testing.T) { t.Fatal("option inconnue acceptée") } } + +// L'export JSON d'un message multimodal (vision) doit ÉLIDER le base64 des images : +// sinon le fichier pesait plusieurs Mo par image. Le Markdown, lui, n'a jamais porté +// les images (juste les noms de fichiers). +func TestExportJSONElideImages(t *testing.T) { + t.Cleanup(func() { conv.Reset() }) + big := strings.Repeat("A", 300000) + conv.mu.Lock() + conv.Messages = []Message{ + {Role: "user", Content: []map[string]any{ + {"type": "text", "text": "regarde"}, + {"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64," + big}}, + }}, + {Role: "assistant", Content: "vu"}, + } + conv.Log = []LogEvent{{Seq: 1, TS: 1, Delta: map[string]any{"user": "regarde"}}} + conv.Seq = 1 + conv.mu.Unlock() + b, err := conv.ExportJSON(defaultExportOpts()) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(b), big) { + t.Error("le base64 brut d'une image est encore présent dans le JSON") + } + if len(b) > 10000 { + t.Errorf("JSON toujours énorme malgré l'élision : %d octets", len(b)) + } + if !strings.Contains(string(b), "élidé") { + t.Error("marqueur d'élision absent du JSON") + } +}