mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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).
This commit is contained in:
4 files changed
+143
-2
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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" +
|
||||
""
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user