diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 0bdc798..5b304f9 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -553,6 +553,9 @@ func (c *Conversation) Stop() { // le bump d'epoch réduit de toute façon au silence. func (c *Conversation) Reset() { c.Stop() + // Vider la discussion efface aussi ses captures : les messages qui les + // affichaient disparaissent, les fichiers n'auraient plus aucun lecteur. + dropConvCaptures(convEnsureActive()) c.mu.Lock() c.Messages = nil c.Log = nil diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go index 9a14de6..25ec324 100644 --- a/internal/loki/chat_screenshot.go +++ b/internal/loki/chat_screenshot.go @@ -3,7 +3,7 @@ package loki // chat_screenshot.go — outil web_screenshot : capture d'une page web RENDUE // (JavaScript exécuté) via le navigateur Chromium piloté par Playwright. // -// Indépendant de la vision : la capture est un fichier PNG écrit dans le dossier +// Indépendant de la vision : la capture est un fichier JPEG écrit dans le dossier // de travail, que l'UI affiche dans le fil (route /api/chat/image). Le modèle, // lui, ne la VOIT que si un projecteur multimodal est configuré (MMPROJ) — deux // mécanismes distincts qu'il ne faut pas confondre. Sans vision, l'agent @@ -21,13 +21,34 @@ import ( "os/exec" "path/filepath" "regexp" + "sort" "strings" "time" ) -// captureDir : sous-dossier du workspace où atterrissent les captures. +// captureDir : sous-dossier du workspace où atterrissent les captures. Elles +// sont rangées PAR DISCUSSION (captures//…) pour que supprimer une +// discussion supprime aussi ses images — sinon elles s'accumulaient sur le +// disque sans qu'aucun écran ne les mentionne plus. const captureDir = "captures" +// captureDirFor renvoie le dossier de captures d'une discussion (chemin absolu) +// et son préfixe relatif, celui qui sert dans les URLs d'affichage. +func captureDirFor(convID string) (abs, rel string) { + rel = captureDir + "/" + convID + return filepath.Join(agentWorkspace(), captureDir, convID), rel +} + +// dropConvCaptures supprime les captures d'une discussion. Appelé quand on la +// supprime ou qu'on la vide. Best-effort : un échec ne doit rien interrompre. +func dropConvCaptures(convID string) { + if convID == "" { + return + } + abs, _ := captureDirFor(convID) + _ = os.RemoveAll(abs) +} + // screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a // son propre délai interne, celui-ci est le garde-fou externe. const screenshotTimeout = 90 * time.Second @@ -48,13 +69,13 @@ func webScreenshotTool() Tool { Name: "web_screenshot", // Description tenue au plus court : les schémas d'outils partent dans // CHAQUE requête et le préambule a un budget (TestSystemPromptStaysLean). - Description: "Photographie une page web (PNG, JS exécuté) pour la MONTRER. " + + Description: "Photographie une page web (JS exécuté) pour la MONTRER. " + "La réponse donne la ligne markdown à recopier. Tu ne vois pas l'image.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "url": map[string]any{"type": "string", "description": "URL complète"}, - "full_page": map[string]any{"type": "boolean", "description": "Page entière. Défaut true."}, + "full_page": map[string]any{"type": "boolean", "description": "Page entière (lourd). Défaut false."}, }, "required": []string{"url"}, }, @@ -96,12 +117,16 @@ func toolWebScreenshot(args map[string]any) string { if v, ok := args["width"].(float64); ok && v >= 320 && v <= 3840 { width = int(v) } - fullPage := true + // Pleine page NON par défaut : un article long capturé en entier fait + // plusieurs milliers de pixels de haut, donc plusieurs Mo, alors que « montre-moi + // cette page » veut presque toujours dire le premier écran. Le modèle peut + // demander la page entière quand c'est vraiment le sujet. + fullPage := false if v, ok := args["full_page"].(bool); ok { fullPage = v } - dir := filepath.Join(agentWorkspace(), captureDir) + dir, relDir := captureDirFor(convEnsureActive()) if err := os.MkdirAll(dir, 0o755); err != nil { return "[erreur] création du dossier de captures : " + err.Error() } @@ -114,7 +139,10 @@ func toolWebScreenshot(args map[string]any) string { if i := strings.IndexAny(host, "/?#"); i >= 0 { host = host[:i] } - name := fmt.Sprintf("%s-%s.png", safeSlug(host), time.Now().Format("20060102-150405")) + // .jpg et non .png : Playwright déduit le format de l'extension, et sur une + // vraie page web (photos, dégradés) le JPEG pèse 3 à 10 fois moins. Le PNG ne + // gagne que sur les aplats — pas le cas courant ici. + name := fmt.Sprintf("%s-%s.jpg", safeSlug(host), time.Now().Format("20060102-150405")) out := filepath.Join(dir, name) cmdArgs := []string{"screenshot", "--browser", "chromium", @@ -141,7 +169,9 @@ func toolWebScreenshot(args map[string]any) string { return "[erreur] Playwright n'a produit aucune image" } - rel := captureDir + "/" + name + pruneCaptures(dir, out) + + rel := relDir + "/" + name // On rend au modèle la ligne EXACTE à recopier : lui laisser composer l'URL // d'affichage revient à lui faire inventer un chemin, donc une image cassée. return fmt.Sprintf("Capture enregistrée (%s, %d Ko).\n"+ @@ -150,6 +180,61 @@ func toolWebScreenshot(args map[string]any) string { rel, st.Size()/1024, host, rel) } +// Plafonds du dossier de captures. Sans ménage, chaque capture s'ajoute pour +// toujours dans /data — un volume que l'utilisateur n'inspecte jamais et qui +// finirait par saturer son cache SSD. +const ( + maxCaptureFiles = 20 + maxCaptureBytes = 40 << 20 // 40 Mo +) + +// pruneCaptures supprime les captures les plus ANCIENNES tant que le dossier +// dépasse l'un des deux plafonds. `keep` est la capture qui vient d'être prise : +// elle n'est JAMAIS supprimée, sinon une capture plus lourde que le plafond +// s'effacerait elle-même et le modèle renverrait un lien vers un fichier absent. +// Best-effort : une erreur d'E/S ne doit pas faire échouer une capture réussie. +func pruneCaptures(dir, keep string) { + ents, err := os.ReadDir(dir) + if err != nil { + return + } + type shot struct { + path string + mod time.Time + size int64 + } + var shots []shot + var total int64 + for _, e := range ents { + if e.IsDir() { + continue + } + fi, err := e.Info() + if err != nil { + continue + } + p := filepath.Join(dir, e.Name()) + total += fi.Size() + if p == keep { + continue // comptée dans le total, mais jamais candidate à la suppression + } + shots = append(shots, shot{p, fi.ModTime(), fi.Size()}) + } + // Plus ancienne en tête : c'est l'ordre de suppression. `nb` compte TOUS les + // fichiers (keep compris) pour que le plafond porte sur le dossier entier. + sort.Slice(shots, func(i, j int) bool { return shots[i].mod.Before(shots[j].mod) }) + nb := len(shots) + if keep != "" { + nb++ + } + for i := 0; i < len(shots) && (nb > maxCaptureFiles || total > maxCaptureBytes); i++ { + if os.Remove(shots[i].path) == nil { + total -= shots[i].size + nb-- + } + } +} + // lastLines garde les n dernières lignes non vides d'une sortie d'erreur — // Playwright est bavard, seule la fin porte la cause. func lastLines(s string, n int) string { diff --git a/internal/loki/chat_screenshot_test.go b/internal/loki/chat_screenshot_test.go new file mode 100644 index 0000000..fa09780 --- /dev/null +++ b/internal/loki/chat_screenshot_test.go @@ -0,0 +1,95 @@ +package loki + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" +) + +// Le dossier de captures ne doit pas croître sans fin — mais la capture qui +// vient d'être prise doit survivre au ménage, même seule et plus lourde que le +// plafond : sinon le modèle renvoie à l'utilisateur un lien vers un fichier que +// pruneCaptures a effacé dans la foulée. +func TestPruneCapturesGardeLaDerniere(t *testing.T) { + dir := t.TempDir() + + // 25 captures d'âges croissants, au-delà du plafond en NOMBRE. + var last string + for i := 0; i < 25; i++ { + p := filepath.Join(dir, fmt.Sprintf("shot-%02d.jpg", i)) + if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + // mtime croissant : shot-00 est la plus ancienne. + mt := time.Now().Add(time.Duration(i-25) * time.Minute) + if err := os.Chtimes(p, mt, mt); err != nil { + t.Fatal(err) + } + last = p + } + pruneCaptures(dir, last) + + ents, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(ents) != maxCaptureFiles { + t.Fatalf("%d fichiers restants, attendu %d", len(ents), maxCaptureFiles) + } + if _, err := os.Stat(last); err != nil { + t.Fatalf("la capture la plus récente a été supprimée : %v", err) + } + // Les plus anciennes doivent être parties, pas les récentes. + if _, err := os.Stat(filepath.Join(dir, "shot-00.jpg")); err == nil { + t.Fatal("la capture la plus ancienne aurait dû être supprimée") + } +} + +// Supprimer une discussion doit emporter ses captures : sans ça, des images que +// plus aucun message n'affiche restent sur le disque pour toujours. Et elle ne +// doit emporter QUE les siennes. +func TestConvDeleteSupprimeLesCaptures(t *testing.T) { + t.Setenv("LOKI_HOME", t.TempDir()) + + a := convEnsureActive() + b := convNew() // b devient active, a reste + + shot := func(id string) string { + dir, _ := captureDirFor(id) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "vue.jpg") + if err := os.WriteFile(p, []byte("jpeg"), 0o644); err != nil { + t.Fatal(err) + } + return p + } + shotA, shotB := shot(a), shot(b) + + if err := convDelete(a); err != nil { + t.Fatalf("suppression de la discussion : %v", err) + } + if _, err := os.Stat(shotA); err == nil { + t.Fatal("la capture de la discussion supprimée est toujours là") + } + if _, err := os.Stat(shotB); err != nil { + t.Fatalf("la capture d'une AUTRE discussion a été supprimée : %v", err) + } +} + +// Une capture unique plus lourde que le plafond en OCTETS ne doit pas s'effacer +// elle-même : il ne resterait alors rien à montrer. +func TestPruneCapturesNEffacePasUneCaptureUniqueTropLourde(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "enorme.jpg") + if err := os.WriteFile(p, make([]byte, maxCaptureBytes+1), 0o644); err != nil { + t.Fatal(err) + } + pruneCaptures(dir, p) + if _, err := os.Stat(p); err != nil { + t.Fatalf("la capture courante a été supprimée : %v", err) + } +} diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index ee5c03b..ba9ebae 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -211,6 +211,9 @@ func convDelete(id string) error { } convIndexSave(next) _ = putBytes(bkChat, convKey(id), nil) + // Les captures de cette discussion n'ont plus rien qui les référence : les + // garder occuperait le disque pour toujours. + dropConvCaptures(id) if id != getStr(bkChat, ckActive) { return nil }