Files
Loki/internal/loki/chat_screenshot.go
T
Loki 3ecbfe274e Captures : polices installées, et image réellement montrée au modèle
Deux défauts signalés à l'usage, tous deux réels.

1) Captures sans aucun texte. Le conteneur n'avait pas de polices : Chromium
   rendait images et aplats, mais pas un caractère. Ajout de Noto (écritures
   du monde, CJK, emoji), Liberation et DejaVu (substituts d'Arial/Times que
   réclament la plupart des sites), plus fc-cache. Le délai avant capture
   passe à 3,5 s : de nombreux sites chargent leurs polices en webfont et
   laissent le texte invisible le temps du téléchargement (font-display:
   block), ce qui produisait aussi des blocs vides.

2) « Je n'ai pas de vision » alors que le projecteur était configuré. Le
   modèle disait vrai deux fois : la description de l'outil lui affirmait
   « Tu ne vois pas l'image », et la capture ne lui était jamais transmise —
   un message  ne transporte que du texte.
   - la description SUIT désormais l'état du projecteur, comme web_open suit
     le moteur web choisi (même motif que l'amont) ;
   - la capture est relayée dans un message  multimodal (text +
     image_url en data URI), le format déjà utilisé par les pièces jointes.
     Sans projecteur, rien n'est envoyé : llama-server rejetterait l'image.

Vérifié : 359 polices dans l'image et texte lisible sur une capture réelle ;
tests sur la description et le relais selon MMPROJ, et sur l'extraction du
chemin de capture (si le texte de l'outil change, le relais casserait en
silence).
2026-08-15 12:53:54 +00:00

312 lines
11 KiB
Go

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 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
// photographie sans regarder : c'est utile pour TOI, pas pour lui.
//
// Playwright est installé dans l'image Docker (voir Dockerfile). Hors conteneur,
// ou si l'image a été bâtie avec PLAYWRIGHT=0, l'outil n'est pas déclaré du tout
// plutôt que d'être annoncé au modèle puis d'échouer — un outil qu'on annonce et
// qui ne marche pas déclenche des boucles de réessai.
import (
"context"
"encoding/base64"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// captureDir : sous-dossier du workspace où atterrissent les captures. Elles
// sont rangées PAR DISCUSSION (captures/<id>/…) 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
// playwrightBin renvoie le chemin du CLI Playwright, ou "" s'il est absent.
func playwrightBin() string {
p, err := exec.LookPath("playwright")
if err != nil {
return ""
}
return p
}
func screenshotAvailable() bool { return playwrightBin() != "" }
func webScreenshotTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
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).
// Elle DÉPEND de la vision : annoncer « tu ne vois pas l'image » à un
// modèle qui la reçoit ensuite le fait se contredire devant l'utilisateur
// (il refuse de décrire ce qu'il a pourtant sous les yeux).
Description: "Photographie une page web (JS exécuté) pour la MONTRER. " +
"La réponse donne la ligne markdown à recopier. " + screenshotVisionNote(),
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 (lourd). Défaut false."},
},
"required": []string{"url"},
},
}}
}
// screenshotVisionNote : ce que le modèle doit savoir de SA propre perception.
// Avec un projecteur configuré, la capture lui est réellement transmise (voir
// screenshotImageMessage) ; sans projecteur, il photographie sans regarder.
func screenshotVisionNote() string {
if visionEnabled() {
return "L'image t'est ensuite montrée : tu peux la décrire."
}
return "Tu ne vois pas l'image."
}
// screenshotImageMessage construit le message utilisateur qui PORTE la capture
// jusqu'au modèle. Le résultat d'un outil est un message `tool`, qui ne
// transporte que du texte : pour qu'un modèle multimodal voie l'image, elle doit
// arriver dans un message `user` au format OpenAI (partie text + partie
// image_url en data URI), le même que celui des pièces jointes.
//
// Renvoie ok=false quand la vision est absente ou le fichier illisible :
// llama-server rejette un contenu image sans --mmproj, donc mieux vaut ne rien
// envoyer que de faire échouer le tour.
func screenshotImageMessage(relPath string) (Message, bool) {
if !visionEnabled() {
return Message{}, false
}
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath))
mime := imageMime(abs)
if mime == "" {
return Message{}, false
}
b, err := os.ReadFile(abs)
if err != nil || len(b) == 0 {
return Message{}, false
}
return Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": "Voici la capture demandée."},
{"type": "image_url", "image_url": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
}},
}}, true
}
// capturedRelPath extrait le chemin de la capture du texte rendu par
// toolWebScreenshot, pour éviter de faire porter deux valeurs de retour à
// l'outil (le protocole n'en accepte qu'une, textuelle).
var capturedRe = regexp.MustCompile(`\(/api/chat/image\?path=([^)]+)\)`)
func capturedRelPath(toolResult string) string {
m := capturedRe.FindStringSubmatch(toolResult)
if len(m) != 2 {
return ""
}
return m[1]
}
// safeSlug réduit un hôte à un nom de fichier sûr.
var slugRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
func safeSlug(s string) string {
s = slugRe.ReplaceAllString(s, "-")
s = strings.Trim(s, "-.")
if r := []rune(s); len(r) > 40 {
s = string(r[:40])
}
if s == "" {
s = "page"
}
return s
}
func toolWebScreenshot(args map[string]any) string {
bin := playwrightBin()
if bin == "" {
return "[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0)."
}
url, _ := args["url"].(string)
url = strings.TrimSpace(url)
if url == "" {
return "[erreur] url manquante"
}
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return "[erreur] url invalide : elle doit commencer par http:// ou https://"
}
// width n'est plus déclaré dans le schéma (budget de préambule), mais reste
// honoré s'il arrive quand même : un modèle qui l'invente obtient le
// comportement attendu plutôt qu'un paramètre ignoré en silence.
width := 1280
if v, ok := args["width"].(float64); ok && v >= 320 && v <= 3840 {
width = int(v)
}
// 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, relDir := captureDirFor(convEnsureActive())
if err := os.MkdirAll(dir, 0o755); err != nil {
return "[erreur] création du dossier de captures : " + err.Error()
}
// Nom lisible et unique : hôte + horodatage. Deux captures de la même page
// ne s'écrasent donc pas, et le fil garde les deux états.
host := url
if i := strings.Index(host, "://"); i >= 0 {
host = host[i+3:]
}
if i := strings.IndexAny(host, "/?#"); i >= 0 {
host = host[:i]
}
// .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",
"--viewport-size", fmt.Sprintf("%d,800", width),
// 3,5 s : beaucoup de sites chargent leurs polices en webfont et laissent le
// texte INVISIBLE le temps du téléchargement (font-display: block). Capturer
// trop tôt donnait une page aux blocs vides, sans un mot.
"--wait-for-timeout", "3500"}
if fullPage {
cmdArgs = append(cmdArgs, "--full-page")
}
cmdArgs = append(cmdArgs, url, out)
ctx, cancel := context.WithTimeout(context.Background(), screenshotTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, bin, cmdArgs...)
cmd.Dir = dir
combined, err := cmd.CombinedOutput()
if err != nil {
if ctx.Err() == context.DeadlineExceeded {
return "[erreur] la page n'a pas fini de charger en 90 s"
}
return "[erreur] capture impossible : " + strings.TrimSpace(lastLines(string(combined), 3))
}
st, err := os.Stat(out)
if err != nil || st.Size() == 0 {
return "[erreur] Playwright n'a produit aucune image"
}
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"+
"Pour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n"+
"![capture de %s](/api/chat/image?path=%s)",
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 {
var keep []string
for _, l := range strings.Split(s, "\n") {
if strings.TrimSpace(l) != "" {
keep = append(keep, l)
}
}
if len(keep) > n {
keep = keep[len(keep)-n:]
}
return strings.Join(keep, " · ")
}