mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Merge branch 'claude/ajean-loki-container-fork-aep9r2'
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