From e12f07426f6861d7f0d92ff1435dc04935e39e19 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 28 Sep 2026 00:10:12 +0200 Subject: [PATCH] =?UTF-8?q?Diff=20:=20le=20compteur=20de=20lignes=20dit=20?= =?UTF-8?q?enfin=20la=20v=C3=A9rit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repris d'AJEAN 0.16.0 (chat_diff.go et son test, repris tels quels : le fichier n'avait pas bougé chez nous depuis la 0.13.5). - Un fichier de 500 lignes écrit par l'agent affichait « +500 » pendant la frappe puis retombait à « +120 » : le diff envoyé à l'UI est plafonné à 120 lignes, et l'UI recomptait sur la version tronquée. lineDiff et addedDiff rendent désormais les VRAIS totaux, calculés avant la coupe, et ils voyagent dans le journal (added/removed) — donc survivent au refresh. Repli sur l'ancien décompte pour les conversations d'avant. - Une retouche d'une ligne dans un bloc de plus de 400 lignes s'affichait en remplacement complet (le LCS n'y était pas tenté). Les lignes communes en tête et en queue sont écartées d'abord : on voit le vrai changement, avec trois lignes de contexte, et il n'est plus repoussé hors de la fenêtre. - Un contenu terminé par un saut de ligne ne compte plus une ligne de trop, côté serveur comme dans la bulle en cours de frappe (bodyLineCount). Le vérificateur du mode Code (code_verify.go) journalise les mêmes champs. Co-Authored-By: Claude Opus 5.5 --- internal/loki/chat_conversation.go | 2 + internal/loki/chat_diff.go | 107 +++++++++++++++++----- internal/loki/chat_diff_test.go | 68 ++++++++++++++ internal/loki/code_verify.go | 2 + internal/loki/llm_client.go | 14 ++- internal/loki/ui/index.html | 11 ++- internal/loki/ui/src/js/08-chat-render.js | 11 ++- 7 files changed, 182 insertions(+), 33 deletions(-) create mode 100644 internal/loki/chat_diff_test.go diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 586b82b..ca8bc5e 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -618,6 +618,8 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // pour que le diff soit encore là après un rafraîchissement. if len(ev.ToolUsed.Diff) > 0 { tu["diff"] = ev.ToolUsed.Diff + tu["added"] = ev.ToolUsed.Added + tu["removed"] = ev.ToolUsed.Removed } if ev.ToolUsed.Image != "" { tu["image"] = ev.ToolUsed.Image diff --git a/internal/loki/chat_diff.go b/internal/loki/chat_diff.go index c8b216d..3362cd1 100644 --- a/internal/loki/chat_diff.go +++ b/internal/loki/chat_diff.go @@ -5,12 +5,18 @@ package loki // envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en // vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec // des garde-fous pour ne jamais transformer un gros remplacement en pavé. +// +// Les lignes envoyées sont bornées (diffMaxShown), mais les compteurs +N / -N +// sont calculés AVANT la coupe et renvoyés à part : l'UI les comptait dans le +// diff tronqué, d'où un « +500 » pendant l'écriture qui retombait à « +120 ». import "strings" const ( - diffMaxLines = 400 // au-delà, on compare sans détail (trop gros / trop lent) - diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé) + diffMaxLines = 400 // au-delà (partie modifiée), on compare sans détail (LCS trop lente) + diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé) + diffContext = 3 // lignes identiques gardées autour d'un changement + diffOmitPrefix = "…" ) // DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+". @@ -19,26 +25,69 @@ type DiffLine struct { Text string `json:"text"` } -// lineDiff compare deux blocs de texte ligne à ligne. Le résultat garde les -// lignes communes comme contexte : c'est ce qui rend le changement lisible -// quand l'IA ne touche qu'un mot au milieu d'un paragraphe. -func lineDiff(oldText, newText string) []DiffLine { +// lineDiff compare deux blocs de texte ligne à ligne et renvoie les lignes à +// afficher (bornées) avec le nombre RÉEL de lignes ajoutées et retirées. +// +// Les lignes communes en tête et en queue sont écartées avant la LCS : un +// changement d'une ligne dans un gros bloc reste un +1 -1 lisible (au lieu d'un +// « tout retiré, tout ajouté ») et le changement n'est pas repoussé hors des +// lignes affichées par des centaines de lignes de contexte. +func lineDiff(oldText, newText string) (lines []DiffLine, added, removed int) { a := splitLines(oldText) b := splitLines(newText) - // Blocs énormes : on ne calcule pas la LCS (coût quadratique), on montre - // simplement l'ancien en retrait et le nouveau en ajout. - if len(a) > diffMaxLines || len(b) > diffMaxLines { - out := make([]DiffLine, 0, len(a)+len(b)) - for _, l := range a { - out = append(out, DiffLine{Op: "-", Text: l}) + pre := 0 + for pre < len(a) && pre < len(b) && a[pre] == b[pre] { + pre++ + } + suf := 0 + for suf < len(a)-pre && suf < len(b)-pre && a[len(a)-1-suf] == b[len(b)-1-suf] { + suf++ + } + ma, mb := a[pre:len(a)-suf], b[pre:len(b)-suf] + + var mid []DiffLine + if len(ma) > diffMaxLines || len(mb) > diffMaxLines { + // Partie modifiée énorme : pas de LCS (coût quadratique), l'ancien en + // retrait puis le nouveau en ajout. + mid = make([]DiffLine, 0, len(ma)+len(mb)) + for _, l := range ma { + mid = append(mid, DiffLine{Op: "-", Text: l}) } - for _, l := range b { - out = append(out, DiffLine{Op: "+", Text: l}) + for _, l := range mb { + mid = append(mid, DiffLine{Op: "+", Text: l}) + } + } else { + mid = lcsDiff(ma, mb) + } + for _, d := range mid { + switch d.Op { + case "+": + added++ + case "-": + removed++ } - return capLines(out) } - // LCS : table des longueurs, puis remontée. + // Contexte autour du changement : quelques lignes, le reste résumé. + var out []DiffLine + if pre > diffContext { + out = append(out, DiffLine{Op: " ", Text: omitted(pre - diffContext)}) + } + for _, l := range a[max(0, pre-diffContext):pre] { + out = append(out, DiffLine{Op: " ", Text: l}) + } + out = append(out, mid...) + for _, l := range a[len(a)-suf : len(a)-suf+min(suf, diffContext)] { + out = append(out, DiffLine{Op: " ", Text: l}) + } + if suf > diffContext { + out = append(out, DiffLine{Op: " ", Text: omitted(suf - diffContext)}) + } + return capLines(out), added, removed +} + +// lcsDiff : diff exact par plus longue sous-séquence commune. +func lcsDiff(a, b []string) []DiffLine { n, m := len(a), len(b) lcs := make([][]int, n+1) for i := range lcs { @@ -77,27 +126,37 @@ func lineDiff(oldText, newText string) []DiffLine { for ; j < m; j++ { out = append(out, DiffLine{Op: "+", Text: b[j]}) } - return capLines(out) + return out } -// addedDiff présente un contenu entièrement nouveau (création d'une page). -func addedDiff(text string) []DiffLine { - lines := splitLines(text) - out := make([]DiffLine, 0, len(lines)) - for _, l := range lines { +// addedDiff présente un contenu entièrement nouveau (création d'une page ou +// d'un fichier) : toutes ses lignes en ajout. +func addedDiff(text string) (lines []DiffLine, added int) { + ls := splitLines(text) + out := make([]DiffLine, 0, len(ls)) + for _, l := range ls { out = append(out, DiffLine{Op: "+", Text: l}) } - return capLines(out) + return capLines(out), len(ls) } func splitLines(s string) []string { s = strings.ReplaceAll(s, "\r\n", "\n") + // Un saut de ligne final termine la dernière ligne, il n'en ouvre pas une vide. + s = strings.TrimSuffix(s, "\n") if s == "" { return nil } return strings.Split(s, "\n") } +func omitted(n int) string { + if n == 1 { + return diffOmitPrefix + "(1 ligne identique)" + } + return diffOmitPrefix + "(" + itoa(n) + " lignes identiques)" +} + // capLines borne la taille envoyée à l'UI et signale ce qui a été coupé. func capLines(d []DiffLine) []DiffLine { if len(d) <= diffMaxShown { @@ -105,7 +164,7 @@ func capLines(d []DiffLine) []DiffLine { } cut := len(d) - diffMaxShown out := append([]DiffLine{}, d[:diffMaxShown]...) - return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"}) + return append(out, DiffLine{Op: " ", Text: diffOmitPrefix + "(" + itoa(cut) + " lignes de plus)"}) } func itoa(n int) string { diff --git a/internal/loki/chat_diff_test.go b/internal/loki/chat_diff_test.go new file mode 100644 index 0000000..20ff575 --- /dev/null +++ b/internal/loki/chat_diff_test.go @@ -0,0 +1,68 @@ +package loki + +import ( + "strings" + "testing" +) + +func numbered(n int, suffix string) string { + var b strings.Builder + for i := 1; i <= n; i++ { + b.WriteString("ligne " + itoa(i) + suffix + "\n") + } + return b.String() +} + +// Un fichier de 500 lignes : +500, même si l'UI n'en reçoit que 120. +func TestAddedDiffCountsBeyondCap(t *testing.T) { + lines, added := addedDiff(numbered(500, "")) + if added != 500 { + t.Fatalf("added = %d, attendu 500", added) + } + if len(lines) > diffMaxShown+1 { + t.Fatalf("%d lignes envoyées, plafond %d", len(lines), diffMaxShown) + } +} + +// Le saut de ligne final ne compte pas pour une ligne. +func TestAddedDiffTrailingNewline(t *testing.T) { + if _, n := addedDiff("a\nb\n"); n != 2 { + t.Fatalf("n = %d, attendu 2", n) + } + if _, n := addedDiff("a\nb"); n != 2 { + t.Fatalf("n = %d, attendu 2", n) + } +} + +// Une ligne changée au milieu d'un gros bloc : +1 -1, et le changement est +// bien dans les lignes affichées (pas noyé sous le contexte). +func TestLineDiffSmallChangeInBigBlock(t *testing.T) { + old := numbered(800, "") + neu := strings.Replace(old, "ligne 600\n", "ligne 600 modifiée\n", 1) + lines, add, del := lineDiff(old, neu) + if add != 1 || del != 1 { + t.Fatalf("+%d -%d, attendu +1 -1", add, del) + } + var sawAdd, sawDel bool + for _, l := range lines { + sawAdd = sawAdd || (l.Op == "+" && l.Text == "ligne 600 modifiée") + sawDel = sawDel || (l.Op == "-" && l.Text == "ligne 600") + } + if !sawAdd || !sawDel { + t.Fatalf("le changement n'est pas dans le diff affiché : %v", lines) + } + if len(lines) > 2*diffContext+4 { + t.Fatalf("trop de contexte : %d lignes", len(lines)) + } +} + +func TestLineDiffCountsAreExact(t *testing.T) { + _, add, del := lineDiff("a\nb\nc\n", "a\nx\ny\nc\n") + if add != 2 || del != 1 { + t.Fatalf("+%d -%d, attendu +2 -1", add, del) + } + _, add, del = lineDiff(numbered(300, ""), numbered(300, " bis")) + if add != 300 || del != 300 { + t.Fatalf("+%d -%d, attendu +300 -300", add, del) + } +} diff --git a/internal/loki/code_verify.go b/internal/loki/code_verify.go index 799b8fe..2edeec5 100644 --- a/internal/loki/code_verify.go +++ b/internal/loki/code_verify.go @@ -154,6 +154,8 @@ func (c *Conversation) forwardStream(ev StreamEvent, epoch int) { } if len(ev.ToolUsed.Diff) > 0 { tu["diff"] = ev.ToolUsed.Diff + tu["added"] = ev.ToolUsed.Added + tu["removed"] = ev.ToolUsed.Removed } if ev.ToolUsed.Image != "" { tu["image"] = ev.ToolUsed.Image diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 04def32..dfef05b 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -474,6 +474,9 @@ type ToolUsedEvent struct { // Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose // (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-). Diff []DiffLine + // Added / Removed : nombre RÉEL de lignes ajoutées / retirées. Diff est + // tronqué pour l'affichage, on ne peut donc pas recompter à partir de lui. + Added, Removed int // Image : chemin (relatif au dossier de travail) d'une image PRODUITE par // l'outil — aujourd'hui la capture de web_screenshot. L'UI l'affiche dans la // bulle de l'outil. Sans ça, la capture n'apparaissait QUE si le modèle @@ -1315,6 +1318,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // diff : rempli par les outils d'écriture (edit / mémoire) pour que // l'UI montre les lignes ajoutées et retirées. var diff []DiffLine + var diffAdd, diffDel int // vrais totaux (diff est tronqué pour l'UI) // visionImg : partie image_url rendue par see_image, réinjectée après // le résultat de l'outil (un message `tool` ne porte que du texte). var visionImg map[string]any @@ -1375,7 +1379,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps result = "[erreur] " + werr.Error() } else { result = fmt.Sprintf("[ok] page '%s' créée", label) - diff = addedDiff(content) + diff, diffAdd = addedDiff(content) } case "mem_edit": oldText, _ := args["old"].(string) @@ -1386,7 +1390,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps result = "[erreur] " + werr.Error() } else { result = fmt.Sprintf("[ok] page '%s' modifiée", label) - diff = lineDiff(oldText, newText) + diff, diffAdd, diffDel = lineDiff(oldText, newText) } case "write": content, _ := args["content"].(string) @@ -1399,7 +1403,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps } else { result = fileWrite(label, content) if !strings.HasPrefix(result, "[erreur]") { - diff = addedDiff(content) + diff, diffAdd = addedDiff(content) trackerNoteWrite(resolveAgentPath(label)) result += lspDiagBlock(resolveAgentPath(label), caps) } @@ -1415,7 +1419,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps result = fileEdit(label, oldText, newText) // Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé). if !strings.HasPrefix(result, "[erreur]") { - diff = lineDiff(oldText, newText) + diff, diffAdd, diffDel = lineDiff(oldText, newText) trackerNoteWrite(resolveAgentPath(label)) result += lspDiagBlock(resolveAgentPath(label), caps) } @@ -1520,7 +1524,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if tc.Function.Name == "web_screenshot" { shot = capturedRelPath(result) } - cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Image: shot}, result, tc.ID)}) + cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result, tc.ID)}) toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} messages = append(messages, toolMsg) extra = append(extra, toolMsg) diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index dbd45a8..e418c67 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -6560,6 +6560,9 @@ function hydrateImages(root){ } }); } +// Lignes d'un contenu en cours d'écriture, comptées comme côté serveur : un saut +// de ligne final termine la dernière ligne, il n'en ouvre pas une vide. +function bodyLineCount(s){ s=String(s).replace(/\r\n/g,'\n').replace(/\n$/,''); return s ? s.split('\n').length : 0; } // Render a tool call as its own conversation message: the command the model // wrote, then the response it got back. textContent keeps it injection-safe. function renderToolMsg(el, tu){ @@ -6608,9 +6611,13 @@ function renderToolMsg(el, tu){ setLabel(el, lbl); // Volume de l'écriture (final si le diff est là, provisoire pendant la frappe) // reporté sur l'étiquette, pour rester lisible bulle repliée. + // Les vrais totaux viennent du serveur (added/removed) : le diff lui-même est + // tronqué pour l'affichage, le recompter donnait « +120 » pour 500 lignes. + // Repli sur le décompte du diff pour les conversations d'avant ces champs. let add=0, del=0; - if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); } - else if(tu.body){ add=tu.body.split('\n').length; } + if(tu.added!=null || tu.removed!=null){ add=tu.added||0; del=tu.removed||0; } + else if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); } + else if(tu.body){ add=bodyLineCount(tu.body); } if(add||del) setLabelCounts(el, add, del); const body=bodyOf(el); body.innerHTML=''; const head=document.createElement('div'); head.className='tool-head'; diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js index 51cb282..369034e 100644 --- a/internal/loki/ui/src/js/08-chat-render.js +++ b/internal/loki/ui/src/js/08-chat-render.js @@ -245,6 +245,9 @@ function hydrateImages(root){ } }); } +// Lignes d'un contenu en cours d'écriture, comptées comme côté serveur : un saut +// de ligne final termine la dernière ligne, il n'en ouvre pas une vide. +function bodyLineCount(s){ s=String(s).replace(/\r\n/g,'\n').replace(/\n$/,''); return s ? s.split('\n').length : 0; } // Render a tool call as its own conversation message: the command the model // wrote, then the response it got back. textContent keeps it injection-safe. function renderToolMsg(el, tu){ @@ -293,9 +296,13 @@ function renderToolMsg(el, tu){ setLabel(el, lbl); // Volume de l'écriture (final si le diff est là, provisoire pendant la frappe) // reporté sur l'étiquette, pour rester lisible bulle repliée. + // Les vrais totaux viennent du serveur (added/removed) : le diff lui-même est + // tronqué pour l'affichage, le recompter donnait « +120 » pour 500 lignes. + // Repli sur le décompte du diff pour les conversations d'avant ces champs. let add=0, del=0; - if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); } - else if(tu.body){ add=tu.body.split('\n').length; } + if(tu.added!=null || tu.removed!=null){ add=tu.added||0; del=tu.removed||0; } + else if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); } + else if(tu.body){ add=bodyLineCount(tu.body); } if(add||del) setLabelCounts(el, add, del); const body=bodyOf(el); body.innerHTML=''; const head=document.createElement('div'); head.className='tool-head';