Merge branch 'claude/ajean-loki-container-fork-aep9r2'

This commit is contained in:
Claude committed 2026-08-15 12:53:56 +00:00
commit 07ce51f2ab
4 files changed
+143 -2

No files matched your search

+9
View File
@@ -50,9 +50,18 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ARG PLAYWRIGHT=1 ARG PLAYWRIGHT=1
ARG PLAYWRIGHT_VERSION=latest ARG PLAYWRIGHT_VERSION=latest
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers 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 \ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
npm i -g playwright@${PLAYWRIGHT_VERSION} \ npm i -g playwright@${PLAYWRIGHT_VERSION} \
&& playwright install --with-deps chromium \ && 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/* ; \ && rm -rf /root/.npm /var/lib/apt/lists/* ; \
fi fi
+62 -2
View File
@@ -16,6 +16,7 @@ package loki
import ( import (
"context" "context"
"encoding/base64"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@@ -69,8 +70,11 @@ func webScreenshotTool() Tool {
Name: "web_screenshot", Name: "web_screenshot",
// Description tenue au plus court : les schémas d'outils partent dans // Description tenue au plus court : les schémas d'outils partent dans
// CHAQUE requête et le préambule a un budget (TestSystemPromptStaysLean). // 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. " + 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{ Parameters: map[string]any{
"type": "object", "type": "object",
"properties": map[string]any{ "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. // safeSlug réduit un hôte à un nom de fichier sûr.
var slugRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) var slugRe = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
@@ -147,7 +204,10 @@ func toolWebScreenshot(args map[string]any) string {
cmdArgs := []string{"screenshot", "--browser", "chromium", cmdArgs := []string{"screenshot", "--browser", "chromium",
"--viewport-size", fmt.Sprintf("%d,800", width), "--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 { if fullPage {
cmdArgs = append(cmdArgs, "--full-page") cmdArgs = append(cmdArgs, "--full-page")
} }
+59
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "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 // Une capture unique plus lourde que le plafond en OCTETS ne doit pas s'effacer
// elle-même : il ne resterait alors rien à montrer. // elle-même : il ne resterait alors rien à montrer.
func TestPruneCapturesNEffacePasUneCaptureUniqueTropLourde(t *testing.T) { func TestPruneCapturesNEffacePasUneCaptureUniqueTropLourde(t *testing.T) {
+13
View File
@@ -1076,6 +1076,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result} toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
messages = append(messages, toolMsg) messages = append(messages, toolMsg)
extra = append(extra, 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 : // 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 // une boucle d'outils peut à elle seule remplir la fenêtre (résultats