diff --git a/Dockerfile b/Dockerfile index d16ea83..f0b3417 100644 --- a/Dockerfile +++ b/Dockerfile @@ -50,9 +50,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ARG PLAYWRIGHT=1 ARG PLAYWRIGHT_VERSION=latest ENV PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers +# Les polices sont INDISPENSABLES : sans elles, Chromium rend les pages sans +# AUCUN texte — boutons et titres sortent vides, seuls les images et les aplats +# apparaissent. Noto couvre l'essentiel des écritures, Liberation fournit les +# substituts d'Arial/Times que réclament la plupart des sites. RUN if [ "$PLAYWRIGHT" = "1" ]; then \ npm i -g playwright@${PLAYWRIGHT_VERSION} \ && playwright install --with-deps chromium \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + fonts-liberation fonts-dejavu-core fonts-noto-core \ + fonts-noto-cjk fonts-noto-color-emoji \ + && fc-cache -f \ && rm -rf /root/.npm /var/lib/apt/lists/* ; \ fi diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go index 25ec324..b18025f 100644 --- a/internal/loki/chat_screenshot.go +++ b/internal/loki/chat_screenshot.go @@ -16,6 +16,7 @@ package loki import ( "context" + "encoding/base64" "fmt" "os" "os/exec" @@ -69,8 +70,11 @@ 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). + // 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. Tu ne vois pas l'image.", + "La réponse donne la ligne markdown à recopier. " + screenshotVisionNote(), Parameters: map[string]any{ "type": "object", "properties": map[string]any{ @@ -82,6 +86,59 @@ func webScreenshotTool() Tool { }} } +// 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._-]+`) @@ -147,7 +204,10 @@ func toolWebScreenshot(args map[string]any) string { cmdArgs := []string{"screenshot", "--browser", "chromium", "--viewport-size", fmt.Sprintf("%d,800", width), - "--wait-for-timeout", "2500"} + // 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") } diff --git a/internal/loki/chat_screenshot_test.go b/internal/loki/chat_screenshot_test.go index fa09780..c7276c1 100644 --- a/internal/loki/chat_screenshot_test.go +++ b/internal/loki/chat_screenshot_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "testing" "time" ) @@ -80,6 +81,64 @@ func TestConvDeleteSupprimeLesCaptures(t *testing.T) { } } +// Le modèle annonçait « je ne vois pas l'image » alors que la vision était +// activée : sa description d'outil le lui disait, et la capture ne lui était +// jamais transmise. Les deux doivent suivre l'état réel du projecteur. +func TestScreenshotSuitLEtatDeLaVision(t *testing.T) { + t.Setenv("LOKI_HOME", t.TempDir()) + + if got := screenshotVisionNote(); !strings.Contains(got, "ne vois pas") { + t.Fatalf("sans projecteur, la description doit annoncer l'absence de vision : %q", got) + } + // Une capture existe, mais sans projecteur elle ne doit PAS partir au modèle : + // llama-server rejette un contenu image sans --mmproj. + dir, rel := captureDirFor(convEnsureActive()) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + shot := filepath.Join(dir, "vue.jpg") + if err := os.WriteFile(shot, []byte("\xff\xd8\xff jpeg"), 0o644); err != nil { + t.Fatal(err) + } + relPath := rel + "/vue.jpg" + if _, ok := screenshotImageMessage(relPath); ok { + t.Fatal("image transmise au modèle alors qu'aucun projecteur n'est configuré") + } + + // Projecteur configuré → description ET transmission changent. + if err := SetConfigKey("MMPROJ", "mmproj-test.gguf"); err != nil { + t.Fatal(err) + } + if got := screenshotVisionNote(); strings.Contains(got, "ne vois pas") { + t.Fatalf("avec projecteur, la description ne doit plus nier la vision : %q", got) + } + msg, ok := screenshotImageMessage(relPath) + if !ok { + t.Fatal("image non transmise alors que le projecteur est configuré") + } + if msg.Role != "user" { + t.Fatalf("role %q : l'image doit voyager dans un message user, un message tool ne porte que du texte", msg.Role) + } + parts, _ := msg.Content.([]map[string]any) + if len(parts) != 2 || parts[1]["type"] != "image_url" { + t.Fatalf("contenu multimodal attendu (text + image_url), obtenu %#v", msg.Content) + } +} + +// Le chemin de la capture est extrait du texte rendu par l'outil : si le format +// de ce texte change, le relais vers le modèle casse en silence. +func TestCapturedRelPath(t *testing.T) { + res := "Capture enregistrée (captures/c1/x.jpg, 42 Ko).\n" + + "Pour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n" + + "![capture de exemple.com](/api/chat/image?path=captures/c1/x.jpg)" + if got := capturedRelPath(res); got != "captures/c1/x.jpg" { + t.Fatalf("chemin extrait %q", got) + } + if got := capturedRelPath("[erreur] capture impossible"); got != "" { + t.Fatalf("une erreur ne doit produire aucun chemin, obtenu %q", got) + } +} + // 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) { diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 8bfce42..66ee03f 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1076,6 +1076,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} messages = append(messages, toolMsg) extra = append(extra, toolMsg) + // Capture d'écran + vision active : on fait SUIVRE l'image elle-même + // dans un message `user`. Un message `tool` ne transporte que du + // texte, donc sans ce relais le modèle recevait le chemin du fichier + // et rien d'autre — il annonçait alors à l'utilisateur qu'il ne + // voyait pas l'image, alors que le projecteur était bien chargé. + if tc.Function.Name == "web_screenshot" { + if rel := capturedRelPath(result); rel != "" { + if imgMsg, ok := screenshotImageMessage(rel); ok { + messages = append(messages, imgMsg) + extra = append(extra, imgMsg) + } + } + } } // Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour : // une boucle d'outils peut à elle seule remplir la fenêtre (résultats