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';