mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
Lot 45 : outils d'image (see_image, web_screenshot) tenus par la façade
La façade exécute les deux outils qui montrent une image au modèle (src/imagetools.rs, port de chat_vision_tool.go, chat_screenshot.go et prepareImageForModel) : préparation de l'image comme les pièces jointes (orientation EXIF, grand côté sous 1568 px, mêmes pixels), sonde du moteur (/props authentifié, gardée 10 s ; preset externe : sa case), see_image aux textes du Go, web_screenshot par Playwright (adresses locales quand internet est coupé, mêmes arguments, erreur résumée, capture rangée dans le dossier de la discussion, ménage à 20 captures et 40 Mo, relayée au modèle s'il voit les images). Le Go garde les siens pour ses sous-agents et tâches IA. Parité : journal tiré du Go avec ses fichiers, un faux moteur et un faux Playwright (tests/parity_images/images.json), rejoué par tests/imagetools.rs ; un tour de la façade sans passer par le Go (tests/lot6.rs) ; tests unitaires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
11 files changed
+2090
No files matched your search
@@ -0,0 +1,116 @@
|
||||
# Lot 45 — Outils d'image de l'agent dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
Deux outils de l'agent montrent une image au modèle :
|
||||
|
||||
- `see_image` charge un fichier image du disque dans la vision du
|
||||
modèle ;
|
||||
- `web_screenshot` photographie une page rendue par Playwright, la range
|
||||
dans le dossier de la discussion et donne à l'agent la ligne markdown
|
||||
qui la montre à l'utilisateur.
|
||||
|
||||
La façade tient la conversation par défaut, mais ces deux outils passaient
|
||||
encore par le Go (`/internal/v1/tool/exec`), qui renvoyait l'image à
|
||||
relayer dans `vision_img`.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/imagetools.rs` porte trois fichiers Go :
|
||||
|
||||
- `chat_vision_tool.go` (`see_image`) ;
|
||||
- `chat_screenshot.go` (`web_screenshot`) ;
|
||||
- `prepareImageForModel` (`web_upload_orient.go`).
|
||||
|
||||
Ce qui est porté :
|
||||
|
||||
- **la préparation de l'image**, la même que pour les pièces jointes :
|
||||
- orientation EXIF d'une JPEG redressée dans les pixels ;
|
||||
- grand côté ramené sous 1568 px (mêmes pixels que le Go) ;
|
||||
- PNG réencodée en PNG, le reste en JPEG qualité 88 ;
|
||||
- rien à faire, ou image que la bibliothèque standard du Go ne décode
|
||||
pas (WebP, BMP, PNG cassée) : les octets d'origine ;
|
||||
- **la sonde du moteur** (`engineSeesImages`) :
|
||||
- preset externe : sa case « vision » ;
|
||||
- sinon `GET /props` du moteur local, authentifié par la clé de
|
||||
complétion, gardé 10 s ;
|
||||
- appelée en dernier, après toutes les vérifications du fichier ;
|
||||
- **`see_image`**, mêmes textes :
|
||||
- vision coupée, poste distant, chemin manquant ;
|
||||
- format non reconnu, fichier introuvable, dossier ;
|
||||
- trop lourde (12 Mo au plus) ;
|
||||
- moteur aveugle, fichier vide ;
|
||||
- chemin relatif au dossier de travail de l'agent, ou absolu ;
|
||||
- **`web_screenshot`**, mêmes textes :
|
||||
- Playwright absent, url manquante ou invalide ;
|
||||
- internet coupé : seule une adresse locale passe (`localhost`,
|
||||
`127.0.0.1`, `::1`, `0.0.0.0`, `*.localhost`, casse comprise comme
|
||||
le Go) ;
|
||||
- mêmes arguments de Playwright (viewport, page entière, attente,
|
||||
90 s au plus) ;
|
||||
- erreur résumée par ses trois dernières lignes non vides ;
|
||||
- aucune image ou image vide refusée ;
|
||||
- capture rangée dans `captures/` du dossier de la discussion, nommée
|
||||
par l'hôte et l'heure locale ;
|
||||
- ménage : 20 captures et 40 Mo au plus, la nouvelle reste ;
|
||||
- relayée au modèle (« Voici la capture demandée. ») seulement si la
|
||||
vision est active et que le moteur voit les images.
|
||||
|
||||
`ChatShared.vision` porte la sonde du processus ; `GoTurnHost::exec_tool`
|
||||
exécute les deux outils :
|
||||
|
||||
- `see_image` prend le dossier de travail et le poste visé du contexte
|
||||
des outils (lot 7) ; contexte indisponible : le Go prend le relais ;
|
||||
- `web_screenshot` range ses captures dans le dossier de la discussion
|
||||
principale, sous le dossier de travail de l'agent, et les montre par
|
||||
`ToolOutcome.image`.
|
||||
|
||||
Rien ne change côté Go : ces outils n'ont pas d'état partagé, et le Go
|
||||
garde les siens pour ses sous-agents et ses tâches IA.
|
||||
|
||||
## Écarts connus
|
||||
|
||||
- Encodeurs PNG et JPEG : mêmes pixels, octets différents.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_image_tools_test.go` (Linux) tire du Go un journal
|
||||
(`tests/parity_images/images.json`) avec ses fichiers (en base64), un faux
|
||||
moteur (`/props` authentifié, vision réglée par étape, sondes comptées) et
|
||||
un faux Playwright (script qui note ses arguments et produit l'image, rien,
|
||||
un fichier vide ou une erreur).
|
||||
|
||||
Chaque étape porte :
|
||||
|
||||
- le résultat, avec le texte relayé au modèle ;
|
||||
- l'image relayée :
|
||||
- identique à un fichier : son nom ;
|
||||
- réencodée : son type et ses dimensions ;
|
||||
- en PNG : cinq pixels ;
|
||||
- la capture montrée, les arguments de Playwright, le nombre de sondes ;
|
||||
- le dossier des captures.
|
||||
|
||||
Le journal couvre :
|
||||
|
||||
- `see_image` :
|
||||
- vision coupée ;
|
||||
- chemin vide, pas une image, introuvable, dossier, trop lourde ;
|
||||
- moteur aveugle, fichier vide ;
|
||||
- onze images : PNG, JPEG, GIF, WebP, BMP, PNG cassée, JPEG tournée,
|
||||
grandes images réduites en largeur et en hauteur, sous-dossier,
|
||||
chemin absolu ;
|
||||
- `web_screenshot` :
|
||||
- url manquante ou invalide, internet coupé ;
|
||||
- capture locale sans vision, capture vue par le modèle ;
|
||||
- page entière réduite ;
|
||||
- vision mais moteur aveugle ;
|
||||
- Playwright en échec, image vide, aucune image ;
|
||||
- ménage des captures.
|
||||
|
||||
`tests/imagetools.rs` le rejoue contre le port. D'autres tests
|
||||
complètent :
|
||||
|
||||
- `tests/lot6.rs` (`outils_image_par_la_facade`) : un tour de la façade
|
||||
appelle les deux outils sans passer par le Go ;
|
||||
- tests unitaires : types d'image, noms de capture, adresses locales,
|
||||
dernières lignes, préparation, ménage, refus avant Playwright.
|
||||
@@ -0,0 +1,344 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parity_image_tools_test.go — journal de référence des outils d'image de
|
||||
// l'agent (lot 45 de la migration Rust) : see_image et web_screenshot, avec la
|
||||
// préparation des images (orientation EXIF, grand côté sous 1568 px) et la
|
||||
// sonde « le moteur voit-il les images » (faux /props).
|
||||
//
|
||||
// Une image laissée telle quelle est comparée à l'octet (empreinte) ; une
|
||||
// image réencodée, par ses dimensions et son type (l'encodeur diffère), et
|
||||
// une PNG réduite par quelques pixels (même réduction des deux côtés).
|
||||
// web_screenshot passe par un faux Playwright qui range ses arguments et
|
||||
// écrit l'image demandée par le scénario. Rangé dans
|
||||
// rust/loki-rs/tests/parity_images/images.json ; tests/imagetools.rs le
|
||||
// rejoue contre le port.
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilsImage
|
||||
|
||||
const parityImagesFile = "../../rust/loki-rs/tests/parity_images/images.json"
|
||||
|
||||
// parityFakePlaywright : écrit le fichier « image » de son dossier à la
|
||||
// place de la capture (dernier argument), ou échoue selon « mode ».
|
||||
const parityFakePlaywright = `#!/bin/sh
|
||||
d=$(dirname "$0")
|
||||
printf '%s\n' "$@" > "$d/args"
|
||||
for a in "$@"; do out="$a"; done
|
||||
mode=$(cat "$d/mode" 2>/dev/null)
|
||||
case "$mode" in
|
||||
echec) echo "Launching chromium" ; echo "" ; echo "page.goto: net::ERR_NAME_NOT_RESOLVED at https://x" >&2 ; echo "Call log:" >&2 ; echo " - navigating" >&2 ; exit 1 ;;
|
||||
vide) : > "$out" ;;
|
||||
rien) ;;
|
||||
*) cat "$d/image" > "$out" ;;
|
||||
esac
|
||||
`
|
||||
|
||||
type parityImageStep struct {
|
||||
Name string `json:"name"`
|
||||
Vision bool `json:"vision,omitempty"`
|
||||
Sees bool `json:"sees,omitempty"` // le faux /props annonce la vision
|
||||
Mode string `json:"mode,omitempty"` // faux Playwright
|
||||
Image string `json:"image,omitempty"` // fichier rendu par le faux Playwright
|
||||
Internet bool `json:"internet,omitempty"`
|
||||
Tool string `json:"tool"`
|
||||
Args map[string]any `json:"args"`
|
||||
Label string `json:"label"`
|
||||
Result string `json:"result"`
|
||||
Part map[string]any `json:"part,omitempty"` // image relayée
|
||||
Shown string `json:"shown,omitempty"` // web_screenshot : la capture montrée dans le fil
|
||||
PWArgs []string `json:"pw_args,omitempty"`
|
||||
Probes int `json:"probes"`
|
||||
Captures []string `json:"captures,omitempty"`
|
||||
}
|
||||
|
||||
var parityImgStampRe = regexp.MustCompile(`[0-9]{8}-[0-9]{6}`)
|
||||
|
||||
// parityImageFiles : les images du dossier de travail.
|
||||
func parityImageFiles() map[string][]byte {
|
||||
grad := func(w, h int) *image.NRGBA {
|
||||
img := image.NewNRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
img.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 255 / w), G: uint8(y * 255 / h), B: uint8((x + y) % 256), A: 255})
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
enc := func(img image.Image, kind string) []byte {
|
||||
var b bytes.Buffer
|
||||
if kind == "png" {
|
||||
_ = png.Encode(&b, img)
|
||||
} else {
|
||||
_ = jpeg.Encode(&b, img, &jpeg.Options{Quality: 90})
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
// JPEG avec un tag EXIF Orientation=6 (photo de téléphone tenue debout).
|
||||
withExif := func(j []byte, orient byte) []byte {
|
||||
tiff := []byte{'M', 'M', 0, 0x2A, 0, 0, 0, 8, 0, 1, 0x01, 0x12, 0, 3, 0, 0, 0, 1, 0, orient, 0, 0, 0, 0, 0, 0}
|
||||
payload := append([]byte("Exif\x00\x00"), tiff...)
|
||||
seg := []byte{0xFF, 0xE1, byte((len(payload) + 2) >> 8), byte(len(payload) + 2)}
|
||||
seg = append(seg, payload...)
|
||||
return append(append(append([]byte{}, j[:2]...), seg...), j[2:]...)
|
||||
}
|
||||
return map[string][]byte{
|
||||
"petite.png": enc(grad(10, 8), "png"),
|
||||
"grande.png": enc(grad(2000, 1000), "png"),
|
||||
"haute.png": enc(grad(300, 1700), "png"),
|
||||
"photo.jpg": enc(grad(40, 20), "jpeg"),
|
||||
"tournee.jpg": withExif(enc(grad(40, 20), "jpeg"), 6),
|
||||
"grande.jpeg": enc(grad(1800, 900), "jpeg"),
|
||||
"anim.gif": []byte("GIF89a\x01\x00\x01\x00\x80\x00\x00\x00\x00\x00\xff\xff\xff!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;"),
|
||||
"image.webp": []byte("RIFF\x10\x00\x00\x00WEBPVP8 faux"),
|
||||
"image.BMP": []byte("BM pas vraiment une image"),
|
||||
"cassee.png": []byte("\x89PNG\r\n\x1a\nabîmé"),
|
||||
"texte.txt": []byte("pas une image"),
|
||||
"vide.png": {},
|
||||
"capture.jpg": enc(grad(1280, 800), "jpeg"),
|
||||
"pleine.jpg": enc(grad(1280, 3000), "jpeg"),
|
||||
"capture.png": enc(grad(64, 40), "png"),
|
||||
"sous/tres.png": enc(grad(3, 3), "png"),
|
||||
}
|
||||
}
|
||||
|
||||
// parityPartFacts : ce qu'on compare d'une image relayée.
|
||||
func parityPartFacts(t *testing.T, part map[string]any, raw map[string][]byte) map[string]any {
|
||||
u, _ := part["image_url"].(map[string]any)["url"].(string)
|
||||
i := strings.Index(u, ";base64,")
|
||||
mime := strings.TrimPrefix(u[:i], "data:")
|
||||
b, err := base64.StdEncoding.DecodeString(u[i+8:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]any{"mime": mime}
|
||||
for name, r := range raw {
|
||||
if bytes.Equal(r, b) {
|
||||
out["same_as"] = name // laissée telle quelle : à l'octet
|
||||
return out
|
||||
}
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(b))
|
||||
if err != nil {
|
||||
h := sha256.Sum256(b)
|
||||
out["sha"] = hex.EncodeToString(h[:8])
|
||||
return out
|
||||
}
|
||||
bd := img.Bounds()
|
||||
out["w"], out["h"] = bd.Dx(), bd.Dy()
|
||||
if mime == "image/png" {
|
||||
px := map[string]string{}
|
||||
for _, p := range [][2]int{{0, 0}, {1, 1}, {100, 50}, {bd.Dx() - 1, bd.Dy() - 1}, {bd.Dx() / 2, bd.Dy() / 3}} {
|
||||
c := color.NRGBAModel.Convert(img.At(p[0], p[1])).(color.NRGBA)
|
||||
px[fmt.Sprintf("%d,%d", p[0], p[1])] = fmt.Sprintf("%d,%d,%d,%d", c.R, c.G, c.B, c.A)
|
||||
}
|
||||
out["px"] = px
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestPariteOutilsImage(t *testing.T) {
|
||||
testHome(t)
|
||||
root := t.TempDir()
|
||||
files := filepath.Join(root, "fichiers")
|
||||
raw := parityImageFiles()
|
||||
for name, b := range raw {
|
||||
p := filepath.Join(files, name)
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
if err := os.WriteFile(p, b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(files, "dossier.png"), 0o755)
|
||||
lourd, _ := os.Create(filepath.Join(files, "lourd.png"))
|
||||
_ = lourd.Truncate(13 << 20)
|
||||
lourd.Close()
|
||||
setTaskWorkspace(files)
|
||||
t.Cleanup(func() { setTaskWorkspace("") })
|
||||
|
||||
// Faux moteur : /props, authentifié.
|
||||
var mu sync.Mutex
|
||||
sees, probes := false, 0
|
||||
eng := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if r.URL.Path != "/props" || r.Header.Get("Authorization") != "Bearer cle-moteur" {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
probes++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"modalities": map[string]any{"vision": sees}})
|
||||
}))
|
||||
defer eng.Close()
|
||||
_ = SetConfigKey("PORT", strings.TrimPrefix(eng.URL, "http://127.0.0.1:"))
|
||||
_ = putStr(bkState, "api_key", "cle-moteur")
|
||||
|
||||
// Faux Playwright, seul « playwright » du PATH.
|
||||
pwDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(pwDir, "playwright"), []byte(parityFakePlaywright), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", pwDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
conv := convEnsureActive()
|
||||
capDir, _ := captureDirFor(conv)
|
||||
|
||||
var steps []parityImageStep
|
||||
run := func(st parityImageStep) {
|
||||
if st.Vision {
|
||||
_ = SetConfigKey("MMPROJ", "vision.gguf")
|
||||
} else {
|
||||
_ = SetConfigKey("MMPROJ", "")
|
||||
}
|
||||
mu.Lock()
|
||||
sees, probes = st.Sees, 0
|
||||
mu.Unlock()
|
||||
visionProbeMu.Lock()
|
||||
visionProbeAt = time.Time{}
|
||||
visionProbeMu.Unlock()
|
||||
_ = os.WriteFile(filepath.Join(pwDir, "mode"), []byte(st.Mode), 0o644)
|
||||
if st.Image != "" {
|
||||
_ = os.WriteFile(filepath.Join(pwDir, "image"), raw[st.Image], 0o644)
|
||||
}
|
||||
_ = os.Remove(filepath.Join(pwDir, "args"))
|
||||
args := map[string]any{}
|
||||
b, _ := json.Marshal(st.Args)
|
||||
_ = json.Unmarshal([]byte(strings.ReplaceAll(string(b), "{{FICHIERS}}", files)), &args)
|
||||
label := toolCallLabel(st.Tool, args)
|
||||
st.Label = strings.ReplaceAll(label, files, "{{FICHIERS}}")
|
||||
res := execTool(t.Context(), st.Tool, args, label, Caps{Agent: true, Internet: st.Internet})
|
||||
image, _, relay := toolStepExtras(st.Tool, label, res)
|
||||
st.Result = strings.ReplaceAll(parityImgStampRe.ReplaceAllString(res.Result, "{{T}}"), files, "{{FICHIERS}}")
|
||||
st.Shown = parityImgStampRe.ReplaceAllString(image, "{{T}}")
|
||||
var part map[string]any
|
||||
if relay != nil {
|
||||
parts, _ := json.Marshal(relay.Content)
|
||||
var ps []map[string]any
|
||||
_ = json.Unmarshal(parts, &ps)
|
||||
for _, p := range ps {
|
||||
if p["type"] == "image_url" {
|
||||
part = p
|
||||
}
|
||||
if p["type"] == "text" {
|
||||
st.Result += "\n[relais] " + strings.ReplaceAll(fmt.Sprint(p["text"]), files, "{{FICHIERS}}")
|
||||
}
|
||||
}
|
||||
}
|
||||
if part != nil {
|
||||
st.Part = parityPartFacts(t, part, raw)
|
||||
}
|
||||
if b, err := os.ReadFile(filepath.Join(pwDir, "args")); err == nil {
|
||||
for _, l := range strings.Split(strings.TrimRight(string(b), "\n"), "\n") {
|
||||
st.PWArgs = append(st.PWArgs, parityImgStampRe.ReplaceAllString(strings.ReplaceAll(l, capDir, "{{CAPTURES}}"), "{{T}}"))
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
st.Probes = probes
|
||||
mu.Unlock()
|
||||
if st.Tool == "web_screenshot" {
|
||||
entries, _ := os.ReadDir(capDir)
|
||||
st.Captures = []string{}
|
||||
for _, e := range entries {
|
||||
st.Captures = append(st.Captures, parityImgStampRe.ReplaceAllString(e.Name(), "{{T}}"))
|
||||
}
|
||||
sort.Strings(st.Captures)
|
||||
}
|
||||
if st.Args == nil {
|
||||
st.Args = map[string]any{}
|
||||
}
|
||||
steps = append(steps, st)
|
||||
}
|
||||
a := func(kv ...any) map[string]any {
|
||||
m := map[string]any{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i].(string)] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// see_image.
|
||||
run(parityImageStep{Name: "sans vision", Tool: "see_image", Args: a("file", "petite.png")})
|
||||
run(parityImageStep{Name: "chemin manquant", Vision: true, Tool: "see_image", Args: a("file", " ")})
|
||||
run(parityImageStep{Name: "pas une image", Vision: true, Tool: "see_image", Args: a("file", "texte.txt")})
|
||||
run(parityImageStep{Name: "introuvable", Vision: true, Tool: "see_image", Args: a("file", "absente.png")})
|
||||
run(parityImageStep{Name: "dossier", Vision: true, Tool: "see_image", Args: a("file", "dossier.png")})
|
||||
run(parityImageStep{Name: "trop lourde", Vision: true, Tool: "see_image", Args: a("file", "lourd.png")})
|
||||
run(parityImageStep{Name: "le moteur ne voit pas", Vision: true, Tool: "see_image", Args: a("file", "petite.png")})
|
||||
run(parityImageStep{Name: "vide", Vision: true, Sees: true, Tool: "see_image", Args: a("file", "vide.png")})
|
||||
for _, f := range []string{"petite.png", "grande.png", "haute.png", "photo.jpg", "tournee.jpg", "grande.jpeg", "anim.gif", "image.webp", "image.BMP", "cassee.png", "sous/tres.png"} {
|
||||
run(parityImageStep{Name: "voir " + f, Vision: true, Sees: true, Tool: "see_image", Args: a("file", f)})
|
||||
}
|
||||
run(parityImageStep{Name: "chemin absolu", Vision: true, Sees: true, Tool: "see_image", Args: a("file", "{{FICHIERS}}/petite.png")})
|
||||
|
||||
// web_screenshot.
|
||||
run(parityImageStep{Name: "capture sans url", Tool: "web_screenshot", Args: a()})
|
||||
run(parityImageStep{Name: "url invalide", Tool: "web_screenshot", Args: a("url", "ftp://x")})
|
||||
run(parityImageStep{Name: "internet coupé", Tool: "web_screenshot", Args: a("url", "https://exemple.test/")})
|
||||
run(parityImageStep{Name: "capture locale sans vision", Image: "capture.jpg", Tool: "web_screenshot", Args: a("url", "http://localhost:3000/app?x=1")})
|
||||
run(parityImageStep{Name: "capture vue par le modèle", Vision: true, Sees: true, Internet: true, Image: "capture.jpg",
|
||||
Tool: "web_screenshot", Args: a("url", "https://www.exemple.test/page#ancre", "width", 1600.0)})
|
||||
run(parityImageStep{Name: "page entière réduite", Vision: true, Sees: true, Internet: true, Image: "pleine.jpg",
|
||||
Tool: "web_screenshot", Args: a("url", "https://pleine.test/", "full_page", true, "width", 100.0)})
|
||||
run(parityImageStep{Name: "vision mais moteur aveugle", Vision: true, Internet: true, Image: "capture.png",
|
||||
Tool: "web_screenshot", Args: a("url", "https://aveugle.test/")})
|
||||
run(parityImageStep{Name: "Playwright en échec", Internet: true, Mode: "echec", Tool: "web_screenshot", Args: a("url", "https://x.test/")})
|
||||
run(parityImageStep{Name: "image vide", Internet: true, Mode: "vide", Tool: "web_screenshot", Args: a("url", "https://vide.test/")})
|
||||
run(parityImageStep{Name: "aucune image", Internet: true, Mode: "rien", Tool: "web_screenshot", Args: a("url", "https://rien.test/")})
|
||||
// Ménage : 22 vieilles captures, plus la nouvelle → 20.
|
||||
old := time.Now().Add(-time.Hour)
|
||||
for i := 0; i < 22; i++ {
|
||||
p := filepath.Join(capDir, fmt.Sprintf("vieille-%02d.jpg", i))
|
||||
_ = os.WriteFile(p, []byte("x"), 0o644)
|
||||
mt := old.Add(time.Duration(i) * time.Second)
|
||||
_ = os.Chtimes(p, mt, mt)
|
||||
}
|
||||
run(parityImageStep{Name: "ménage des captures", Internet: true, Image: "capture.png", Tool: "web_screenshot", Args: a("url", "http://127.0.0.1:8080/")})
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(map[string]any{"files": raw, "steps": steps}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.Bytes()
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(parityImagesFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(parityImagesFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(parityImagesFile)
|
||||
if err != nil {
|
||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("les outils d'image ne produisent plus le journal rangé dans %s.\n"+
|
||||
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityImagesFile)
|
||||
}
|
||||
}
|
||||
@@ -1325,6 +1325,7 @@ impl ChatShared {
|
||||
web: Default::default(),
|
||||
jobs: crate::tools::jobs::global(),
|
||||
browser: crate::browser::global(),
|
||||
vision: Default::default(),
|
||||
go_version: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,6 +358,8 @@ pub struct ChatShared {
|
||||
pub jobs: Arc<crate::tools::jobs::BgJobs>,
|
||||
/// Le navigateur piloté (lot 44).
|
||||
pub browser: Arc<crate::browser::Browser>,
|
||||
/// La sonde « le moteur voit-il les images » (lot 45).
|
||||
pub vision: Arc<crate::imagetools::VisionProbe>,
|
||||
/// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue
|
||||
/// une fois.
|
||||
pub go_version: tokio::sync::OnceCell<String>,
|
||||
@@ -716,6 +718,74 @@ impl TurnHost for GoTurnHost {
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
// Outils d'image (lot 45) : see_image dans le dossier de la
|
||||
// discussion, web_screenshot par Playwright ; l'image relayée au
|
||||
// modèle s'il la voit.
|
||||
if name == "see_image" || name == "web_screenshot" {
|
||||
let cfg = self.shared.store.config().await;
|
||||
let vision = crate::system::vision_enabled(&cfg);
|
||||
if name == "see_image" {
|
||||
let st = crate::tools::GoToolState {
|
||||
store: self.shared.store.clone(),
|
||||
caps: self.caps.clone(),
|
||||
};
|
||||
match crate::tools::ToolState::context(&st).await {
|
||||
Ok(ctx) => {
|
||||
let sees = self.shared.vision.sees(&self.shared.store, &cfg);
|
||||
let (result, img) = crate::imagetools::see_image(
|
||||
label,
|
||||
&ctx.cwd,
|
||||
&ctx.target,
|
||||
vision,
|
||||
sees,
|
||||
)
|
||||
.await;
|
||||
return ToolOutcome {
|
||||
result,
|
||||
relay: img.map(|i| see_image_message(label, i)),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[outils] contexte indisponible, see_image par le Go : {e}")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let home = self.shared.images.home();
|
||||
let ws = tokio::task::spawn_blocking(move || {
|
||||
let env = std::env::var(crate::paths::WORKSPACE_ENV).ok();
|
||||
crate::paths::agent_workspace(&home, env.as_deref())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let conv_dir = crate::convfiles::conv_dir(&ws, &self.compact.main_conv);
|
||||
let internet = self.caps.get("Internet").and_then(Value::as_bool) == Some(true);
|
||||
let pw = crate::paths::look_path("playwright").unwrap_or_default();
|
||||
let shot = crate::imagetools::web_screenshot(
|
||||
args,
|
||||
&pw,
|
||||
Path::new(&conv_dir),
|
||||
internet,
|
||||
&crate::imagetools::local_stamp(),
|
||||
)
|
||||
.await;
|
||||
let relay = if !shot.rel.is_empty()
|
||||
&& vision
|
||||
&& self.shared.vision.sees(&self.shared.store, &cfg).await
|
||||
{
|
||||
crate::imagetools::screenshot_message(&shot.abs)
|
||||
.and_then(|m| serde_json::from_value::<Message>(m).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return ToolOutcome {
|
||||
result: shot.result,
|
||||
image: shot.rel,
|
||||
relay,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
}
|
||||
// Navigateur piloté (lot 44) : la session est ici ; aperçu de la page
|
||||
// après une action qui la change (jamais après une erreur), capture
|
||||
// relayée au modèle.
|
||||
|
||||
@@ -145,6 +145,17 @@ fn safe_conv_id(id: &str) -> String {
|
||||
id.to_string()
|
||||
}
|
||||
|
||||
/// `convDirFor` : le dossier d'une discussion sous la racine de travail (la
|
||||
/// racine elle-même pour un identifiant douteux).
|
||||
pub fn conv_dir(root: &str, id: &str) -> String {
|
||||
let id = safe_conv_id(id);
|
||||
if id.is_empty() {
|
||||
root.to_string()
|
||||
} else {
|
||||
go_join(&[root, CONV_FILES_ROOT, &id])
|
||||
}
|
||||
}
|
||||
|
||||
/// `exifOrientation` : le tag Orientation (1..8) d'un JPEG, 0 sinon.
|
||||
pub fn exif_orientation(b: &[u8]) -> i32 {
|
||||
if b.len() < 4 || b[0] != 0xFF || b[1] != 0xD8 {
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
//! Outils d'image de l'agent (lot 45) — port de `chat_vision_tool.go`
|
||||
//! (`see_image`), de `chat_screenshot.go` (`web_screenshot`, Playwright) et
|
||||
//! de `prepareImageForModel` (`web_upload_orient.go`).
|
||||
//!
|
||||
//! `see_image` charge une image du disque dans la vision du modèle ;
|
||||
//! `web_screenshot` photographie une page rendue par Playwright, la range
|
||||
//! dans le dossier de la discussion et la montre au modèle s'il voit les
|
||||
//! images. Les deux passent par la même préparation que les pièces jointes :
|
||||
//! orientation EXIF redressée, grand côté ramené sous 1568 px (mêmes pixels
|
||||
//! que le Go ; l'encodeur, lui, diffère).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use base64::Engine as _;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::convfiles::{Rgba, apply_orientation, exif_orientation, scale_down_to_max};
|
||||
use crate::store::{BK_STATE, Store};
|
||||
|
||||
/// `maxImageDim`.
|
||||
pub const MAX_IMAGE_DIM: usize = 1568;
|
||||
/// `imageJPEGQuality`.
|
||||
const JPEG_QUALITY: u8 = 88;
|
||||
/// `maxVisionBytes`.
|
||||
pub const MAX_VISION_BYTES: i64 = 12 << 20;
|
||||
/// `screenshotTimeout`.
|
||||
const SCREENSHOT_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
/// `maxCaptureFiles`, `maxCaptureBytes`.
|
||||
const MAX_CAPTURE_FILES: usize = 20;
|
||||
const MAX_CAPTURE_BYTES: u64 = 40 << 20;
|
||||
|
||||
/// `imageMimes` : les images qu'on sait montrer au modèle.
|
||||
pub fn image_mime(name: &str) -> &'static str {
|
||||
let ext = Path::new(name)
|
||||
.extension()
|
||||
.map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
|
||||
.unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
".png" => "image/png",
|
||||
".jpg" | ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".bmp" => "image/bmp",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
/// `prepareImageForModel` : orientation cuite dans les pixels, grand côté
|
||||
/// sous [`MAX_IMAGE_DIM`] ; rien à faire ou image indécodable : les octets
|
||||
/// d'origine.
|
||||
pub fn prepare_image_for_model(raw: &[u8], mime: &str) -> (Vec<u8>, String) {
|
||||
let orient = if mime == "image/jpeg" {
|
||||
exif_orientation(raw)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let need_orient = (2..=8).contains(&orient);
|
||||
// Les formats que décode la bibliothèque standard du Go.
|
||||
let fmt = match image::guess_format(raw) {
|
||||
Ok(f @ (image::ImageFormat::Png | image::ImageFormat::Jpeg | image::ImageFormat::Gif)) => f,
|
||||
_ => return (raw.to_vec(), mime.to_string()),
|
||||
};
|
||||
let Ok(src) = image::load_from_memory_with_format(raw, fmt) else {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
};
|
||||
let (w, h) = (src.width() as usize, src.height() as usize);
|
||||
let need_resize = w > MAX_IMAGE_DIM || h > MAX_IMAGE_DIM;
|
||||
if !need_orient && !need_resize {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
}
|
||||
let mut img = Rgba {
|
||||
w,
|
||||
h,
|
||||
pix: src.to_rgba8().into_raw(),
|
||||
};
|
||||
if need_orient {
|
||||
img = apply_orientation(&img, orient);
|
||||
}
|
||||
if img.w > MAX_IMAGE_DIM || img.h > MAX_IMAGE_DIM {
|
||||
img = scale_down_to_max(&img, MAX_IMAGE_DIM);
|
||||
}
|
||||
let Some(buf) = image::RgbaImage::from_raw(img.w as u32, img.h as u32, img.pix) else {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
};
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
if mime == "image/png" {
|
||||
if buf.write_to(&mut out, image::ImageFormat::Png).is_err() {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
}
|
||||
return (out.into_inner(), "image/png".into());
|
||||
}
|
||||
let enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, JPEG_QUALITY);
|
||||
let rgb = image::DynamicImage::ImageRgba8(buf).to_rgb8();
|
||||
if rgb.write_with_encoder(enc).is_err() {
|
||||
return (raw.to_vec(), mime.to_string());
|
||||
}
|
||||
(out.into_inner(), "image/jpeg".into())
|
||||
}
|
||||
|
||||
/// `imageURLPart`.
|
||||
pub fn image_url_part(b: &[u8], mime: &str) -> Value {
|
||||
json!({
|
||||
"type": "image_url",
|
||||
"image_url": { "url": format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(b)) },
|
||||
})
|
||||
}
|
||||
|
||||
/// `engineSeesImages` : le moteur en service accepte-t-il des images ?
|
||||
/// Sonde `/props` (authentifiée), gardée 10 s ; preset externe : sa case.
|
||||
#[derive(Default)]
|
||||
pub struct VisionProbe {
|
||||
last: tokio::sync::Mutex<Option<(Instant, bool)>>,
|
||||
}
|
||||
|
||||
impl VisionProbe {
|
||||
pub async fn sees(&self, store: &Store, cfg: &crate::serve::Cfg) -> bool {
|
||||
if crate::serve::is_external(cfg) {
|
||||
return crate::system::vision_enabled(cfg);
|
||||
}
|
||||
let mut last = self.last.lock().await;
|
||||
if let Some((at, seen)) = *last
|
||||
&& at.elapsed() < Duration::from_secs(10)
|
||||
{
|
||||
return seen;
|
||||
}
|
||||
*last = Some((Instant::now(), false));
|
||||
crate::chat::engine::install_crypto();
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
let url = format!("http://localhost:{}/props", crate::engstatus::llm_port(cfg));
|
||||
let mut rq = client.get(url);
|
||||
let key = store.get_str(BK_STATE, "api_key").await;
|
||||
if !key.is_empty() {
|
||||
rq = rq.header("Authorization", format!("Bearer {key}"));
|
||||
}
|
||||
let Ok(resp) = rq.send().await else {
|
||||
return false;
|
||||
};
|
||||
if resp.status().as_u16() != 200 {
|
||||
return false;
|
||||
}
|
||||
let v: Value = match resp.bytes().await {
|
||||
Ok(b) => serde_json::from_slice(&b).unwrap_or_default(),
|
||||
Err(_) => return false,
|
||||
};
|
||||
let seen = v["modalities"]["vision"].as_bool().unwrap_or(false);
|
||||
*last = Some((Instant::now(), seen));
|
||||
seen
|
||||
}
|
||||
|
||||
/// Oublie la dernière sonde (tests).
|
||||
pub async fn reset(&self) {
|
||||
*self.last.lock().await = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// `toolSeeImage` : (accusé, partie image). `cwd` et `target` : le contexte
|
||||
/// des outils ; `sees` : la sonde du moteur, appelée en dernier.
|
||||
pub async fn see_image(
|
||||
path: &str,
|
||||
cwd: &Path,
|
||||
target: &str,
|
||||
vision: bool,
|
||||
sees: impl std::future::Future<Output = bool>,
|
||||
) -> (String, Option<Value>) {
|
||||
let err = |m: String| (m, None);
|
||||
if !vision {
|
||||
return err("[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image".into());
|
||||
}
|
||||
if !target.is_empty() {
|
||||
return err("[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)".into());
|
||||
}
|
||||
if path.is_empty() {
|
||||
return err("[erreur] chemin de fichier manquant".into());
|
||||
}
|
||||
let abs = crate::tools::policy::resolve_agent_path(cwd, path);
|
||||
let mime = image_mime(&abs.to_string_lossy());
|
||||
if mime.is_empty() {
|
||||
return err(format!(
|
||||
"[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : {path}"
|
||||
));
|
||||
}
|
||||
let st = match std::fs::metadata(&abs) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return err(format!("[erreur] fichier introuvable : {path}")),
|
||||
};
|
||||
if st.is_dir() {
|
||||
return err(format!("[erreur] c'est un dossier, pas une image : {path}"));
|
||||
}
|
||||
if st.len() as i64 > MAX_VISION_BYTES {
|
||||
return err(format!(
|
||||
"[erreur] image trop lourde ({}, max {}) : {path}",
|
||||
crate::downloads::human_bytes(st.len() as i64),
|
||||
crate::downloads::human_bytes(MAX_VISION_BYTES)
|
||||
));
|
||||
}
|
||||
if !sees.await {
|
||||
return err(format!(
|
||||
"[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir {path}"
|
||||
));
|
||||
}
|
||||
let b = match std::fs::read(&abs) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return err(format!(
|
||||
"[erreur] lecture impossible : {}",
|
||||
crate::paths::os_err("open", &abs.to_string_lossy(), &e)
|
||||
));
|
||||
}
|
||||
};
|
||||
if b.is_empty() {
|
||||
return err(format!("[erreur] fichier vide : {path}"));
|
||||
}
|
||||
let (b, mime) = prepare_image_for_model(&b, mime);
|
||||
let base = abs
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
(
|
||||
format!("[ok] image chargée : {base}"),
|
||||
Some(image_url_part(&b, &mime)),
|
||||
)
|
||||
}
|
||||
|
||||
// --- web_screenshot -------------------------------------------------------------
|
||||
|
||||
/// Le résultat de `web_screenshot` : le texte, et la capture rangée
|
||||
/// (chemin relatif à la discussion, et absolu).
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct Shot {
|
||||
pub result: String,
|
||||
pub rel: String,
|
||||
pub abs: PathBuf,
|
||||
}
|
||||
|
||||
/// `safeSlug`.
|
||||
pub fn safe_slug(s: &str) -> String {
|
||||
let re = regex::Regex::new(r"[^a-zA-Z0-9._-]+").expect("regex");
|
||||
let s = re.replace_all(s, "-");
|
||||
let s = s.trim_matches(['-', '.']);
|
||||
let s: String = s.chars().take(40).collect();
|
||||
if s.is_empty() { "page".into() } else { s }
|
||||
}
|
||||
|
||||
/// `isLocalURL`. Le nom d'hôte tel qu'écrit (`url.Hostname()` du Go
|
||||
/// garde la casse : `LOCALHOST` n'est pas local).
|
||||
pub fn is_local_url(raw: &str) -> bool {
|
||||
if url::Url::parse(raw).is_err() {
|
||||
return false;
|
||||
}
|
||||
let host = go_hostname(raw);
|
||||
matches!(host, "localhost" | "127.0.0.1" | "::1" | "0.0.0.0") || host.ends_with(".localhost")
|
||||
}
|
||||
|
||||
/// `url.Hostname()` : l'hôte de l'autorité, sans identifiants ni port,
|
||||
/// sans crochets pour une IPv6.
|
||||
fn go_hostname(raw: &str) -> &str {
|
||||
let Some(i) = raw.find("://") else {
|
||||
return "";
|
||||
};
|
||||
let rest = &raw[i + 3..];
|
||||
let auth = &rest[..rest.find(['/', '?', '#']).unwrap_or(rest.len())];
|
||||
let hp = auth.rfind('@').map_or(auth, |j| &auth[j + 1..]);
|
||||
if let Some(v6) = hp.strip_prefix('[') {
|
||||
return v6.find(']').map_or(v6, |j| &v6[..j]);
|
||||
}
|
||||
match hp.rfind(':') {
|
||||
Some(c) if hp[c + 1..].bytes().all(|b| b.is_ascii_digit()) => &hp[..c],
|
||||
_ => hp,
|
||||
}
|
||||
}
|
||||
|
||||
/// `lastLines` : les n dernières lignes non vides, jointes par « · ».
|
||||
pub fn last_lines(s: &str, n: usize) -> String {
|
||||
let keep: Vec<&str> = s.split('\n').filter(|l| !l.trim().is_empty()).collect();
|
||||
keep[keep.len().saturating_sub(n)..].join(" · ")
|
||||
}
|
||||
|
||||
/// `pruneCaptures` : les plus anciennes captures partent tant que le
|
||||
/// dossier dépasse 20 fichiers ou 40 Mo ; celle qui vient d'être prise reste.
|
||||
pub fn prune_captures(dir: &Path, keep: &Path) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut shots: Vec<(PathBuf, SystemTime, u64)> = Vec::new();
|
||||
let mut total = 0u64;
|
||||
for e in rd.flatten() {
|
||||
let Ok(m) = e.metadata() else { continue };
|
||||
if m.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let p = e.path();
|
||||
total += m.len();
|
||||
if p == keep {
|
||||
continue;
|
||||
}
|
||||
shots.push((p, m.modified().unwrap_or(SystemTime::UNIX_EPOCH), m.len()));
|
||||
}
|
||||
shots.sort_by_key(|s| s.1);
|
||||
// Tous les fichiers comptent, celle qui reste comprise.
|
||||
let mut nb = shots.len() + usize::from(!keep.as_os_str().is_empty());
|
||||
for (p, _, size) in shots {
|
||||
if nb <= MAX_CAPTURE_FILES && total <= MAX_CAPTURE_BYTES {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_file(&p).is_ok() {
|
||||
total -= size;
|
||||
nb -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `toolWebScreenshot`. `playwright` : le binaire (vide : absent) ;
|
||||
/// `conv_dir` : le dossier de la discussion ; `internet` : l'accès est-il
|
||||
/// ouvert ; `stamp` : l'heure du nom de fichier.
|
||||
pub async fn web_screenshot(
|
||||
args: &Map<String, Value>,
|
||||
playwright: &str,
|
||||
conv_dir: &Path,
|
||||
internet: bool,
|
||||
stamp: &str,
|
||||
) -> Shot {
|
||||
let fail = |m: &str| Shot {
|
||||
result: m.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
if playwright.is_empty() {
|
||||
return fail(
|
||||
"[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0).",
|
||||
);
|
||||
}
|
||||
let url = args
|
||||
.get("url")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if url.is_empty() {
|
||||
return fail("[erreur] url manquante");
|
||||
}
|
||||
if !url.starts_with("http://") && !url.starts_with("https://") {
|
||||
return fail("[erreur] url invalide : elle doit commencer par http:// ou https://");
|
||||
}
|
||||
if !internet && !is_local_url(&url) {
|
||||
return fail(
|
||||
"[refusé] L'accès internet est coupé : la capture ne marche que sur une adresse locale (localhost / 127.0.0.1), par exemple ton serveur de dev. Active l'accès internet pour le web.",
|
||||
);
|
||||
}
|
||||
let mut width = 1280i64;
|
||||
if let Some(v) = args.get("width").and_then(Value::as_f64)
|
||||
&& (320.0..=3840.0).contains(&v)
|
||||
{
|
||||
width = v as i64;
|
||||
}
|
||||
let full_page = args.get("full_page").and_then(Value::as_bool) == Some(true);
|
||||
let dir = conv_dir.join("captures");
|
||||
if let Err(e) = crate::paths::go_mkdir_all(&dir) {
|
||||
return fail(&format!("[erreur] création du dossier de captures : {e}"));
|
||||
}
|
||||
let mut host = url.as_str();
|
||||
if let Some(i) = host.find("://") {
|
||||
host = &host[i + 3..];
|
||||
}
|
||||
if let Some(i) = host.find(['/', '?', '#']) {
|
||||
host = &host[..i];
|
||||
}
|
||||
let host = host.to_string();
|
||||
let name = format!("{}-{stamp}.jpg", safe_slug(&host));
|
||||
let out = dir.join(&name);
|
||||
let mut cmd_args: Vec<String> = [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
&format!("{width},800"),
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
]
|
||||
.map(String::from)
|
||||
.to_vec();
|
||||
if full_page {
|
||||
cmd_args.push("--full-page".into());
|
||||
}
|
||||
cmd_args.push(url.clone());
|
||||
cmd_args.push(out.to_string_lossy().into_owned());
|
||||
let run = tokio::process::Command::new(playwright)
|
||||
.args(&cmd_args)
|
||||
.current_dir(&dir)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.kill_on_drop(true)
|
||||
.output();
|
||||
match tokio::time::timeout(SCREENSHOT_TIMEOUT, run).await {
|
||||
Err(_) => return fail("[erreur] la page n'a pas fini de charger en 90 s"),
|
||||
// Lancement impossible : le Go ne rend que la fin de la sortie, vide.
|
||||
Ok(Err(_)) => return fail("[erreur] capture impossible : "),
|
||||
Ok(Ok(o)) if !o.status.success() => {
|
||||
let mut combined = o.stdout.clone();
|
||||
combined.extend_from_slice(&o.stderr);
|
||||
return fail(&format!(
|
||||
"[erreur] capture impossible : {}",
|
||||
last_lines(&crate::system::go_lossy(&combined), 3).trim()
|
||||
));
|
||||
}
|
||||
Ok(Ok(_)) => {}
|
||||
}
|
||||
let size = match std::fs::metadata(&out) {
|
||||
Ok(m) if m.len() > 0 => m.len(),
|
||||
_ => return fail("[erreur] Playwright n'a produit aucune image"),
|
||||
};
|
||||
prune_captures(&dir, &out);
|
||||
let rel = format!("captures/{name}");
|
||||
Shot {
|
||||
result: format!(
|
||||
"Capture enregistrée ({rel}, {} Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n",
|
||||
size / 1024
|
||||
),
|
||||
rel,
|
||||
abs: out,
|
||||
}
|
||||
}
|
||||
|
||||
/// `screenshotImageMessage` : le message qui porte la capture au modèle.
|
||||
pub fn screenshot_message(abs: &Path) -> Option<Value> {
|
||||
let mime = image_mime(&abs.to_string_lossy());
|
||||
if mime.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let b = std::fs::read(abs).ok().filter(|b| !b.is_empty())?;
|
||||
let (b, mime) = prepare_image_for_model(&b, mime);
|
||||
Some(json!({
|
||||
"role": "user",
|
||||
"content": [
|
||||
{ "type": "text", "text": "Voici la capture demandée." },
|
||||
image_url_part(&b, &mime),
|
||||
],
|
||||
}))
|
||||
}
|
||||
|
||||
/// `time.Now().Format("20060102-150405")`, heure locale.
|
||||
pub fn local_stamp() -> String {
|
||||
let sec = SystemTime::now()
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or_default();
|
||||
let c = crate::tz::Location::local().civil(sec);
|
||||
format!(
|
||||
"{:04}{:02}{:02}-{:02}{:02}{:02}",
|
||||
c.year, c.month, c.day, c.hour, c.minute, c.second
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Tests unitaires des outils d'image (lot 45) ; la parité avec le Go est
|
||||
//! dans `tests/imagetools.rs`.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn types_d_image() {
|
||||
assert_eq!(image_mime("a.PNG"), "image/png");
|
||||
assert_eq!(image_mime("/x/b.jpeg"), "image/jpeg");
|
||||
assert_eq!(image_mime("c.Jpg"), "image/jpeg");
|
||||
assert_eq!(image_mime("d.gif"), "image/gif");
|
||||
assert_eq!(image_mime("e.webp"), "image/webp");
|
||||
assert_eq!(image_mime("f.bmp"), "image/bmp");
|
||||
assert_eq!(image_mime("g.svg"), "");
|
||||
assert_eq!(image_mime("png"), "");
|
||||
assert_eq!(image_mime(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noms_de_capture() {
|
||||
assert_eq!(safe_slug("localhost:3000"), "localhost-3000");
|
||||
assert_eq!(safe_slug("www.exemple.test"), "www.exemple.test");
|
||||
assert_eq!(safe_slug("..--a b--.."), "a-b");
|
||||
assert_eq!(safe_slug("été"), "t");
|
||||
assert_eq!(safe_slug("::"), "page");
|
||||
assert_eq!(safe_slug(""), "page");
|
||||
assert_eq!(safe_slug(&"x".repeat(60)).len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adresses_locales() {
|
||||
for u in [
|
||||
"http://localhost:3000/app",
|
||||
"http://127.0.0.1/",
|
||||
"http://[::1]:8080/",
|
||||
"http://0.0.0.0:5173",
|
||||
"https://app.localhost/",
|
||||
"http://moi:secret@localhost/",
|
||||
] {
|
||||
assert!(is_local_url(u), "{u}");
|
||||
}
|
||||
for u in [
|
||||
"https://exemple.test/",
|
||||
"http://localhost.exemple.test/",
|
||||
"http://LOCALHOST/",
|
||||
"http://127.0.0.2/",
|
||||
"pas une url",
|
||||
"http://localhost:abc/",
|
||||
] {
|
||||
assert!(!is_local_url(u), "{u}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dernieres_lignes() {
|
||||
assert_eq!(last_lines("a\n\nb\n \nc\nd\n", 3), "b · c · d");
|
||||
assert_eq!(last_lines("seule", 6), "seule");
|
||||
assert_eq!(last_lines("", 6), "");
|
||||
assert_eq!(last_lines("\n \n", 6), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_preparee() {
|
||||
// Petite PNG : rien à faire, les octets d'origine.
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(10, 5, image::Rgba([1, 2, 3, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prepare_image_for_model(&png, "image/png"),
|
||||
(png.clone(), "image/png".into())
|
||||
);
|
||||
// Indécodable : telle quelle.
|
||||
assert_eq!(
|
||||
prepare_image_for_model(b"pas une image", "image/png"),
|
||||
(b"pas une image".to_vec(), "image/png".into())
|
||||
);
|
||||
// Trop grande : réduite sous 1568 px, en PNG.
|
||||
let mut big = Vec::new();
|
||||
image::RgbaImage::from_pixel(3136, 100, image::Rgba([9, 9, 9, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut big), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let (b, mime) = prepare_image_for_model(&big, "image/png");
|
||||
assert_eq!(mime, "image/png");
|
||||
let img = image::load_from_memory(&b).unwrap();
|
||||
assert_eq!((img.width(), img.height()), (1568, 50));
|
||||
let part = image_url_part(b"ab", "image/gif");
|
||||
assert_eq!(part["image_url"]["url"], "data:image/gif;base64,YWI=");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menage_des_captures() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let base = SystemTime::now() - Duration::from_secs(1000);
|
||||
for i in 0..25u64 {
|
||||
let p = d.path().join(format!("c{i:02}.jpg"));
|
||||
std::fs::write(&p, "x").unwrap();
|
||||
std::fs::File::options()
|
||||
.write(true)
|
||||
.open(&p)
|
||||
.unwrap()
|
||||
.set_modified(base + Duration::from_secs(i))
|
||||
.unwrap();
|
||||
}
|
||||
// La plus ancienne est celle qu'on vient de prendre : elle reste.
|
||||
let keep = d.path().join("c00.jpg");
|
||||
prune_captures(d.path(), &keep);
|
||||
let mut left: Vec<String> = std::fs::read_dir(d.path())
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
left.sort();
|
||||
assert_eq!(left.len(), 20);
|
||||
assert_eq!(left[0], "c00.jpg");
|
||||
assert_eq!(left[1], "c06.jpg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn capture_refusee_avant_playwright() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let args = |v: Value| serde_json::from_value::<Map<String, Value>>(v).unwrap();
|
||||
let s = web_screenshot(&args(json!({"url": "http://x/"})), "", d.path(), true, "t").await;
|
||||
assert!(
|
||||
s.result.contains("Playwright n'est pas installé"),
|
||||
"{}",
|
||||
s.result
|
||||
);
|
||||
let s = web_screenshot(&args(json!({"url": 3})), "/bin/false", d.path(), true, "t").await;
|
||||
assert_eq!(s.result, "[erreur] url manquante");
|
||||
assert!(s.rel.is_empty());
|
||||
assert!(!d.path().join("captures").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn voir_sans_poste_distant() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let (r, img) = see_image("a.png", d.path(), "nœud", true, async { true }).await;
|
||||
assert!(r.contains("poste distant"), "{r}");
|
||||
assert!(img.is_none());
|
||||
let (r, _) = see_image("", d.path(), "", true, async { true }).await;
|
||||
assert_eq!(r, "[erreur] chemin de fichier manquant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horodatage_local() {
|
||||
let s = local_stamp();
|
||||
assert_eq!(s.len(), 15);
|
||||
assert_eq!(&s[8..9], "-");
|
||||
}
|
||||
@@ -29,6 +29,7 @@ pub mod engstatus;
|
||||
pub mod gguf;
|
||||
pub mod gojson;
|
||||
pub mod hf;
|
||||
pub mod imagetools;
|
||||
pub mod mcp;
|
||||
pub mod memory;
|
||||
pub mod models;
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Lot 45 : outils d'image de l'agent (`see_image`, `web_screenshot`)
|
||||
//! tenus par la façade, rejoués sur le journal du Go
|
||||
//! (`internal/loki/parity_image_tools_test.go` →
|
||||
//! `tests/parity_images/images.json`) : mêmes fichiers, même faux moteur
|
||||
//! (`/props`), même faux Playwright. Une image laissée telle quelle se
|
||||
//! compare à l'octet ; une image réencodée par ses dimensions et son type,
|
||||
//! et une PNG réduite par ses pixels.
|
||||
#![cfg(target_os = "linux")]
|
||||
|
||||
mod common;
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine as _;
|
||||
use bytes::Bytes;
|
||||
use common::fakego;
|
||||
use http_body_util::Full;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use loki_rs::imagetools::{self, VisionProbe};
|
||||
use loki_rs::store::Store;
|
||||
use regex::Regex;
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
const FAKE_PLAYWRIGHT: &str = r#"#!/bin/sh
|
||||
d=$(dirname "$0")
|
||||
printf '%s\n' "$@" > "$d/args"
|
||||
for a in "$@"; do out="$a"; done
|
||||
mode=$(cat "$d/mode" 2>/dev/null)
|
||||
case "$mode" in
|
||||
echec) echo "Launching chromium" ; echo "" ; echo "page.goto: net::ERR_NAME_NOT_RESOLVED at https://x" >&2 ; echo "Call log:" >&2 ; echo " - navigating" >&2 ; exit 1 ;;
|
||||
vide) : > "$out" ;;
|
||||
rien) ;;
|
||||
*) cat "$d/image" > "$out" ;;
|
||||
esac
|
||||
"#;
|
||||
|
||||
/// Le faux moteur : `/props` authentifié, la vision réglée par le test.
|
||||
async fn fake_engine(sees: Arc<Mutex<(bool, u32)>>) -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((st, _)) = l.accept().await {
|
||||
let sees = sees.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
|
||||
let sees = sees.clone();
|
||||
async move {
|
||||
let ok = req.uri().path() == "/props"
|
||||
&& req
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
== Some("Bearer cle-moteur");
|
||||
let mut r = Response::new(Full::new(Bytes::new()));
|
||||
if ok {
|
||||
let mut s = sees.lock().unwrap();
|
||||
s.1 += 1;
|
||||
r = Response::new(Full::new(Bytes::from(
|
||||
json!({ "modalities": { "vision": s.0 } }).to_string(),
|
||||
)));
|
||||
} else {
|
||||
*r.status_mut() = hyper::StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
Ok::<_, Infallible>(r)
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(st), svc)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
/// `parityPartFacts`.
|
||||
fn part_facts(part: &Value, raw: &Map<String, Value>) -> Value {
|
||||
let u = part["image_url"]["url"].as_str().unwrap();
|
||||
let i = u.find(";base64,").unwrap();
|
||||
let mime = u[..i].trim_start_matches("data:");
|
||||
let b = base64::engine::general_purpose::STANDARD
|
||||
.decode(&u[i + 8..])
|
||||
.unwrap();
|
||||
let mut out = Map::new();
|
||||
out.insert("mime".into(), json!(mime));
|
||||
for (name, r) in raw {
|
||||
let rb = base64::engine::general_purpose::STANDARD
|
||||
.decode(r.as_str().unwrap_or(""))
|
||||
.unwrap();
|
||||
if rb == b {
|
||||
out.insert("same_as".into(), json!(name));
|
||||
return Value::Object(out);
|
||||
}
|
||||
}
|
||||
let Ok(img) = image::load_from_memory(&b) else {
|
||||
panic!("image relayée illisible");
|
||||
};
|
||||
let img = img.to_rgba8();
|
||||
let (w, h) = img.dimensions();
|
||||
out.insert("w".into(), json!(w));
|
||||
out.insert("h".into(), json!(h));
|
||||
if mime == "image/png" {
|
||||
let mut px = Map::new();
|
||||
for (x, y) in [(0, 0), (1, 1), (100, 50), (w - 1, h - 1), (w / 2, h / 3)] {
|
||||
let p = img.get_pixel(x, y).0;
|
||||
px.insert(
|
||||
format!("{x},{y}"),
|
||||
json!(format!("{},{},{},{}", p[0], p[1], p[2], p[3])),
|
||||
);
|
||||
}
|
||||
out.insert("px".into(), Value::Object(px));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn outils_image_comme_le_go() {
|
||||
let fx: Value =
|
||||
serde_json::from_str(include_str!("parity_images/images.json")).expect("journal");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let files = tmp.path().join("fichiers");
|
||||
let raw = fx["files"].as_object().unwrap();
|
||||
for (name, b) in raw {
|
||||
let p = files.join(name);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&p,
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(b.as_str().unwrap_or(""))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(files.join("dossier.png")).unwrap();
|
||||
std::fs::File::create(files.join("lourd.png"))
|
||||
.unwrap()
|
||||
.set_len(13 << 20)
|
||||
.unwrap();
|
||||
let files_s = files.to_string_lossy().into_owned();
|
||||
|
||||
let sees = Arc::new(Mutex::new((false, 0u32)));
|
||||
let port = fake_engine(sees.clone()).await;
|
||||
let (_, go, _stop) = fakego::start(tmp.path().join("home")).await;
|
||||
let backend = go.backend.lock().unwrap().unwrap();
|
||||
let store = Store::new(backend, fakego::JETON.into());
|
||||
go.set("state", "api_key", "cle-moteur");
|
||||
let mut cfg = std::collections::HashMap::new();
|
||||
cfg.insert("PORT".to_string(), port.to_string());
|
||||
let probe = VisionProbe::default();
|
||||
|
||||
let pw_dir = tmp.path().join("pw");
|
||||
std::fs::create_dir_all(&pw_dir).unwrap();
|
||||
let pw = pw_dir.join("playwright");
|
||||
std::fs::write(&pw, FAKE_PLAYWRIGHT).unwrap();
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
std::fs::set_permissions(&pw, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let conv_dir = tmp.path().join("conv");
|
||||
let cap_dir = conv_dir.join("captures");
|
||||
let stamp_re = Regex::new(r"[0-9]{8}-[0-9]{6}").unwrap();
|
||||
|
||||
for (n, st) in fx["steps"].as_array().unwrap().iter().enumerate() {
|
||||
let name = st["name"].as_str().unwrap();
|
||||
*sees.lock().unwrap() = (st["sees"] == true, 0);
|
||||
probe.reset().await;
|
||||
std::fs::write(pw_dir.join("mode"), st["mode"].as_str().unwrap_or("")).unwrap();
|
||||
if let Some(img) = st["image"].as_str() {
|
||||
let b = base64::engine::general_purpose::STANDARD
|
||||
.decode(raw[img].as_str().unwrap())
|
||||
.unwrap();
|
||||
std::fs::write(pw_dir.join("image"), b).unwrap();
|
||||
}
|
||||
let _ = std::fs::remove_file(pw_dir.join("args"));
|
||||
if name == "ménage des captures" {
|
||||
let old = std::time::SystemTime::now() - std::time::Duration::from_secs(3600);
|
||||
for i in 0..22 {
|
||||
let p = cap_dir.join(format!("vieille-{i:02}.jpg"));
|
||||
std::fs::write(&p, "x").unwrap();
|
||||
let f = std::fs::File::options().write(true).open(&p).unwrap();
|
||||
f.set_modified(old + std::time::Duration::from_secs(i))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
let args: Map<String, Value> =
|
||||
serde_json::from_str(&st["args"].to_string().replace("{{FICHIERS}}", &files_s))
|
||||
.unwrap();
|
||||
let label = st["label"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.replace("{{FICHIERS}}", &files_s);
|
||||
let vision = st["vision"] == true;
|
||||
let (mut result, part, shown) = match st["tool"].as_str().unwrap() {
|
||||
"see_image" => {
|
||||
let (r, img) =
|
||||
imagetools::see_image(&label, &files, "", vision, probe.sees(&store, &cfg))
|
||||
.await;
|
||||
let relay = img.map(|i| loki_rs::chat::host::see_image_message(&label, i));
|
||||
(r, relay, String::new())
|
||||
}
|
||||
_ => {
|
||||
let stamp = format!("20260101-1200{:02}", n % 60);
|
||||
let shot = imagetools::web_screenshot(
|
||||
&args,
|
||||
&pw.to_string_lossy(),
|
||||
&conv_dir,
|
||||
st["internet"] == true,
|
||||
&stamp,
|
||||
)
|
||||
.await;
|
||||
let relay = if !shot.rel.is_empty() && vision && probe.sees(&store, &cfg).await {
|
||||
imagetools::screenshot_message(&shot.abs)
|
||||
.and_then(|m| serde_json::from_value(m).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
(shot.result, relay, shot.rel)
|
||||
}
|
||||
};
|
||||
let mut facts = None;
|
||||
if let Some(m) = &relay_parts(&part) {
|
||||
for p in m {
|
||||
if p["type"] == "image_url" {
|
||||
facts = Some(part_facts(p, raw));
|
||||
}
|
||||
if p["type"] == "text" {
|
||||
result += &format!("\n[relais] {}", p["text"].as_str().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
let mask = |s: &str| {
|
||||
stamp_re
|
||||
.replace_all(&s.replace(&files_s, "{{FICHIERS}}"), "{{T}}")
|
||||
.into_owned()
|
||||
};
|
||||
assert_eq!(mask(&result), st["result"].as_str().unwrap(), "{name}");
|
||||
assert_eq!(facts, st.get("part").cloned(), "{name} : image relayée");
|
||||
assert_eq!(
|
||||
mask(&shown),
|
||||
st["shown"].as_str().unwrap_or(""),
|
||||
"{name} : capture montrée"
|
||||
);
|
||||
assert_eq!(
|
||||
sees.lock().unwrap().1 as u64,
|
||||
st["probes"].as_u64().unwrap(),
|
||||
"{name} : sondes du moteur"
|
||||
);
|
||||
let pw_args: Vec<String> = std::fs::read_to_string(pw_dir.join("args"))
|
||||
.map(|b| {
|
||||
b.trim_end_matches('\n')
|
||||
.split('\n')
|
||||
.map(|l| {
|
||||
mask(&l.replace(&cap_dir.to_string_lossy().into_owned(), "{{CAPTURES}}"))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
assert_eq!(
|
||||
json!(pw_args),
|
||||
st.get("pw_args").cloned().unwrap_or(json!([])),
|
||||
"{name} : Playwright"
|
||||
);
|
||||
if st["tool"] == "web_screenshot" {
|
||||
let mut caps: Vec<String> = std::fs::read_dir(&cap_dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| mask(&e.file_name().to_string_lossy()))
|
||||
.collect();
|
||||
caps.sort();
|
||||
assert_eq!(
|
||||
json!(caps),
|
||||
st.get("captures").cloned().unwrap_or(json!([])),
|
||||
"{name} : dossier des captures"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Les parties d'un message relayé.
|
||||
fn relay_parts(m: &Option<loki_rs::chat::types::Message>) -> Option<Vec<Value>> {
|
||||
let m = m.as_ref()?;
|
||||
m.content.as_ref()?.as_array().cloned()
|
||||
}
|
||||
@@ -674,6 +674,7 @@ async fn tour_complet_par_le_go() {
|
||||
web: Default::default(),
|
||||
jobs: Default::default(),
|
||||
browser: Default::default(),
|
||||
vision: Default::default(),
|
||||
go_version: Default::default(),
|
||||
});
|
||||
let cfg = std::collections::HashMap::new();
|
||||
@@ -2842,6 +2843,46 @@ async fn job_arriere_plan_par_la_facade() {
|
||||
assert_eq!(jobs.running(), 0);
|
||||
}
|
||||
|
||||
/// Lot 45 : `see_image` et `web_screenshot` sont exécutés par la façade
|
||||
/// (rien par le Go) : sans projecteur, l'image n'est pas lue ; une capture
|
||||
/// sans url est refusée avant Playwright.
|
||||
#[tokio::test]
|
||||
async fn outils_image_par_la_facade() {
|
||||
let calls = sse(&[
|
||||
serde_json::json!({"choices":[{"delta":{"tool_calls":[
|
||||
{"index":0,"id":"v1","type":"function","function":{"name":"see_image","arguments":"{\"path\":\"photo.png\"}"}},
|
||||
{"index":1,"id":"v2","type":"function","function":{"name":"web_screenshot","arguments":"{}"}}
|
||||
]}}]}),
|
||||
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
|
||||
]);
|
||||
let (url, seen) = fake_engine(vec![calls, answer("vu")]).await;
|
||||
let ws = tempfile::tempdir().unwrap();
|
||||
std::fs::write(ws.path().join("photo.png"), b"x").unwrap();
|
||||
let (svc, go, _stop, _t) = service_with(Some(&url), |_| {}).await;
|
||||
*go.tool_ctx.lock().unwrap() = Some(serde_json::json!({
|
||||
"cwd": ws.path(), "memory_dir": ws.path().join("mem"), "target": ""
|
||||
}));
|
||||
svc.send(serde_json::json!({"message": "regarde", "cid": "v"}))
|
||||
.await
|
||||
.unwrap();
|
||||
until_done(&svc, 1).await;
|
||||
assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log);
|
||||
let seen = seen.lock().unwrap().clone();
|
||||
let tools: Vec<String> = seen[1].1["messages"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|m| m["role"] == "tool")
|
||||
.map(|m| m["content"].as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(tools.len(), 2, "{tools:?}");
|
||||
assert!(tools[0].contains("la vision n'est pas active"), "{tools:?}");
|
||||
assert!(
|
||||
tools[1] == "[erreur] url manquante" || tools[1].contains("Playwright n'est pas installé"),
|
||||
"{tools:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Lot 44 : le navigateur piloté est celui de la façade. Un tour qui ouvre
|
||||
/// une page l'exécute chez elle (rien par le Go), l'aperçu est servi par
|
||||
/// /api/chat/cushot, et le Go passe par /internal/facade/v1/browser.
|
||||
|
||||
@@ -0,0 +1,614 @@
|
||||
{
|
||||
"files": {
|
||||
"anim.gif": "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==",
|
||||
"capture.jpg": "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 truncated
|
||||
"capture.png": "iVBORw0KGgoAAAANSUhEUgAAAEAAAAAoCAIAAADBrGu+AAAAZ0lEQVR4nOzS0QmAMBBEwQsG7b9ji9iPJTpwBdywb8/MNWsffPeaOfg+AHj6T/x8geMBEmoDLAAQAiQEEAIk1AZYACAESAggBEioDbAAQAiQEEAIkFAbYAGAECAhgBAgoe69AQAA//86MgyTWSqBGAAAAABJRU5ErkJggg==",
|
||||
"cassee.png": "iVBORw0KGgphYsOubcOp",
|
||||
"grande.jpeg": "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 truncated
|
||||
"grande.png": "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 truncated
|
||||
"haute.png": "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 truncated
|
||||
"image.BMP": "Qk0gcGFzIHZyYWltZW50IHVuZSBpbWFnZQ==",
|
||||
"image.webp": "UklGRhAAAABXRUJQVlA4IGZhdXg=",
|
||||
"petite.png": "iVBORw0KGgoAAAANSUhEUgAAAAoAAAAICAIAAABPmPnhAAAAJUlEQVR4nGJhYGCQZGCUYmDESrIwyDMyMOBELAwKQ1QaEAAA//9xYwI0YqlbiQAAAABJRU5ErkJggg==",
|
||||
"photo.jpg": "/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABQAKAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APgLTfh10/dfpXWab8Oun7r9K93034ddP3X6V1em/Drp+6/SvyqrxF/ePkcj4x2948I034c9P3X6V1em/Dnp+6/Svd9N+HXT91+ldXpvw56fuv0rxqvEX94/oTI+MdvePCNN+HXT91+laf8Awrr/AKZfpX0fpvw56fuv0rT/AOFdf9Mv0rypcRa/EfueC4x/dL3jiNN0S04/d11em6Jacfu6w9N7V1em9q/P6tSfc/xoyOrU095m7puiWnH7uur03QrTj93WHpvaus03tXjVak+5/QmR1amnvM29N0K04/d1p/2Faf8APOmab2rSryp1J33P3LBVqnsV7zP/2Q==",
|
||||
"pleine.jpg": "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 truncated
|
||||
"sous/tres.png": "iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAH0lEQVR4nGJhYGAIZWAMZWBkYQhlZGAAIQQLEAAA//8aWAFpD3kcfgAAAABJRU5ErkJggg==",
|
||||
"texte.txt": "cGFzIHVuZSBpbWFnZQ==",
|
||||
"tournee.jpg": "/9j/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIABQAKAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APgLTfh10/dfpXWab8Oun7r9K93034ddP3X6V1em/Drp+6/SvyqrxF/ePkcj4x2948I034c9P3X6V1em/Dnp+6/Svd9N+HXT91+ldXpvw56fuv0rxqvEX94/oTI+MdvePCNN+HXT91+laf8Awrr/AKZfpX0fpvw56fuv0rT/AOFdf9Mv0rypcRa/EfueC4x/dL3jiNN0S04/d11em6Jacfu6w9N7V1em9q/P6tSfc/xoyOrU095m7puiWnH7uur03QrTj93WHpvaus03tXjVak+5/QmR1amnvM29N0K04/d1p/2Faf8APOmab2rSryp1J33P3LBVqnsV7zP/2Q==",
|
||||
"vide.png": ""
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"name": "sans vision",
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "petite.png"
|
||||
},
|
||||
"label": "petite.png",
|
||||
"result": "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image",
|
||||
"probes": 0
|
||||
},
|
||||
{
|
||||
"name": "chemin manquant",
|
||||
"vision": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": " "
|
||||
},
|
||||
"label": " ",
|
||||
"result": "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : ",
|
||||
"probes": 0
|
||||
},
|
||||
{
|
||||
"name": "pas une image",
|
||||
"vision": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "texte.txt"
|
||||
},
|
||||
"label": "texte.txt",
|
||||
"result": "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : texte.txt",
|
||||
"probes": 0
|
||||
},
|
||||
{
|
||||
"name": "introuvable",
|
||||
"vision": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "absente.png"
|
||||
},
|
||||
"label": "absente.png",
|
||||
"result": "[erreur] fichier introuvable : absente.png",
|
||||
"probes": 0
|
||||
},
|
||||
{
|
||||
"name": "dossier",
|
||||
"vision": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "dossier.png"
|
||||
},
|
||||
"label": "dossier.png",
|
||||
"result": "[erreur] c'est un dossier, pas une image : dossier.png",
|
||||
"probes": 0
|
||||
},
|
||||
{
|
||||
"name": "trop lourde",
|
||||
"vision": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "lourd.png"
|
||||
},
|
||||
"label": "lourd.png",
|
||||
"result": "[erreur] image trop lourde (13 Mo, max 12 Mo) : lourd.png",
|
||||
"probes": 0
|
||||
},
|
||||
{
|
||||
"name": "le moteur ne voit pas",
|
||||
"vision": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "petite.png"
|
||||
},
|
||||
"label": "petite.png",
|
||||
"result": "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir petite.png",
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "vide",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "vide.png"
|
||||
},
|
||||
"label": "vide.png",
|
||||
"result": "[erreur] fichier vide : vide.png",
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir petite.png",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "petite.png"
|
||||
},
|
||||
"label": "petite.png",
|
||||
"result": "[ok] image chargée : petite.png\n[relais] Image demandée (petite.png) :",
|
||||
"part": {
|
||||
"mime": "image/png",
|
||||
"same_as": "petite.png"
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir grande.png",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "grande.png"
|
||||
},
|
||||
"label": "grande.png",
|
||||
"result": "[ok] image chargée : grande.png\n[relais] Image demandée (grande.png) :",
|
||||
"part": {
|
||||
"h": 784,
|
||||
"mime": "image/png",
|
||||
"px": {
|
||||
"0,0": "0,0,0,255",
|
||||
"1,1": "0,0,2,255",
|
||||
"100,50": "16,16,190,255",
|
||||
"1567,783": "254,254,181,255",
|
||||
"784,261": "127,84,52,255"
|
||||
},
|
||||
"w": 1568
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir haute.png",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "haute.png"
|
||||
},
|
||||
"label": "haute.png",
|
||||
"result": "[ok] image chargée : haute.png\n[relais] Image demandée (haute.png) :",
|
||||
"part": {
|
||||
"h": 1568,
|
||||
"mime": "image/png",
|
||||
"px": {
|
||||
"0,0": "0,0,0,255",
|
||||
"1,1": "0,0,2,255",
|
||||
"100,50": "91,8,162,255",
|
||||
"138,522": "126,84,202,255",
|
||||
"276,1567": "253,254,205,255"
|
||||
},
|
||||
"w": 277
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir photo.jpg",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "photo.jpg"
|
||||
},
|
||||
"label": "photo.jpg",
|
||||
"result": "[ok] image chargée : photo.jpg\n[relais] Image demandée (photo.jpg) :",
|
||||
"part": {
|
||||
"mime": "image/jpeg",
|
||||
"same_as": "photo.jpg"
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir tournee.jpg",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "tournee.jpg"
|
||||
},
|
||||
"label": "tournee.jpg",
|
||||
"result": "[ok] image chargée : tournee.jpg\n[relais] Image demandée (tournee.jpg) :",
|
||||
"part": {
|
||||
"h": 40,
|
||||
"mime": "image/jpeg",
|
||||
"w": 20
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir grande.jpeg",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "grande.jpeg"
|
||||
},
|
||||
"label": "grande.jpeg",
|
||||
"result": "[ok] image chargée : grande.jpeg\n[relais] Image demandée (grande.jpeg) :",
|
||||
"part": {
|
||||
"h": 784,
|
||||
"mime": "image/jpeg",
|
||||
"w": 1568
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir anim.gif",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "anim.gif"
|
||||
},
|
||||
"label": "anim.gif",
|
||||
"result": "[ok] image chargée : anim.gif\n[relais] Image demandée (anim.gif) :",
|
||||
"part": {
|
||||
"mime": "image/gif",
|
||||
"same_as": "anim.gif"
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir image.webp",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "image.webp"
|
||||
},
|
||||
"label": "image.webp",
|
||||
"result": "[ok] image chargée : image.webp\n[relais] Image demandée (image.webp) :",
|
||||
"part": {
|
||||
"mime": "image/webp",
|
||||
"same_as": "image.webp"
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir image.BMP",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "image.BMP"
|
||||
},
|
||||
"label": "image.BMP",
|
||||
"result": "[ok] image chargée : image.BMP\n[relais] Image demandée (image.BMP) :",
|
||||
"part": {
|
||||
"mime": "image/bmp",
|
||||
"same_as": "image.BMP"
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir cassee.png",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "cassee.png"
|
||||
},
|
||||
"label": "cassee.png",
|
||||
"result": "[ok] image chargée : cassee.png\n[relais] Image demandée (cassee.png) :",
|
||||
"part": {
|
||||
"mime": "image/png",
|
||||
"same_as": "cassee.png"
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "voir sous/tres.png",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "sous/tres.png"
|
||||
},
|
||||
"label": "sous/tres.png",
|
||||
"result": "[ok] image chargée : tres.png\n[relais] Image demandée (sous/tres.png) :",
|
||||
"part": {
|
||||
"mime": "image/png",
|
||||
"same_as": "sous/tres.png"
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "chemin absolu",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"tool": "see_image",
|
||||
"args": {
|
||||
"file": "{{FICHIERS}}/petite.png"
|
||||
},
|
||||
"label": "{{FICHIERS}}/petite.png",
|
||||
"result": "[ok] image chargée : petite.png\n[relais] Image demandée ({{FICHIERS}}/petite.png) :",
|
||||
"part": {
|
||||
"mime": "image/png",
|
||||
"same_as": "petite.png"
|
||||
},
|
||||
"probes": 1
|
||||
},
|
||||
{
|
||||
"name": "capture sans url",
|
||||
"tool": "web_screenshot",
|
||||
"args": {},
|
||||
"label": "",
|
||||
"result": "[erreur] url manquante",
|
||||
"probes": 0
|
||||
},
|
||||
{
|
||||
"name": "url invalide",
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"url": "ftp://x"
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] url invalide : elle doit commencer par http:// ou https://",
|
||||
"probes": 0
|
||||
},
|
||||
{
|
||||
"name": "internet coupé",
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"url": "https://exemple.test/"
|
||||
},
|
||||
"label": "",
|
||||
"result": "[refusé] L'accès internet est coupé : la capture ne marche que sur une adresse locale (localhost / 127.0.0.1), par exemple ton serveur de dev. Active l'accès internet pour le web.",
|
||||
"probes": 0
|
||||
},
|
||||
{
|
||||
"name": "capture locale sans vision",
|
||||
"image": "capture.jpg",
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"url": "http://localhost:3000/app?x=1"
|
||||
},
|
||||
"label": "",
|
||||
"result": "Capture enregistrée (captures/localhost-3000-{{T}}.jpg, 74 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n",
|
||||
"shown": "captures/localhost-3000-{{T}}.jpg",
|
||||
"pw_args": [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
"1280,800",
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
"http://localhost:3000/app?x=1",
|
||||
"{{CAPTURES}}/localhost-3000-{{T}}.jpg"
|
||||
],
|
||||
"probes": 0,
|
||||
"captures": [
|
||||
"localhost-3000-{{T}}.jpg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "capture vue par le modèle",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"image": "capture.jpg",
|
||||
"internet": true,
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"url": "https://www.exemple.test/page#ancre",
|
||||
"width": 1600
|
||||
},
|
||||
"label": "",
|
||||
"result": "Capture enregistrée (captures/www.exemple.test-{{T}}.jpg, 74 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n\n[relais] Voici la capture demandée.",
|
||||
"part": {
|
||||
"mime": "image/jpeg",
|
||||
"same_as": "capture.jpg"
|
||||
},
|
||||
"shown": "captures/www.exemple.test-{{T}}.jpg",
|
||||
"pw_args": [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
"1600,800",
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
"https://www.exemple.test/page#ancre",
|
||||
"{{CAPTURES}}/www.exemple.test-{{T}}.jpg"
|
||||
],
|
||||
"probes": 1,
|
||||
"captures": [
|
||||
"localhost-3000-{{T}}.jpg",
|
||||
"www.exemple.test-{{T}}.jpg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "page entière réduite",
|
||||
"vision": true,
|
||||
"sees": true,
|
||||
"image": "pleine.jpg",
|
||||
"internet": true,
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"full_page": true,
|
||||
"url": "https://pleine.test/",
|
||||
"width": 100
|
||||
},
|
||||
"label": "",
|
||||
"result": "Capture enregistrée (captures/pleine.test-{{T}}.jpg, 253 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n\n[relais] Voici la capture demandée.",
|
||||
"part": {
|
||||
"h": 1568,
|
||||
"mime": "image/jpeg",
|
||||
"w": 669
|
||||
},
|
||||
"shown": "captures/pleine.test-{{T}}.jpg",
|
||||
"pw_args": [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
"1280,800",
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
"--full-page",
|
||||
"https://pleine.test/",
|
||||
"{{CAPTURES}}/pleine.test-{{T}}.jpg"
|
||||
],
|
||||
"probes": 1,
|
||||
"captures": [
|
||||
"localhost-3000-{{T}}.jpg",
|
||||
"pleine.test-{{T}}.jpg",
|
||||
"www.exemple.test-{{T}}.jpg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "vision mais moteur aveugle",
|
||||
"vision": true,
|
||||
"image": "capture.png",
|
||||
"internet": true,
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"url": "https://aveugle.test/"
|
||||
},
|
||||
"label": "",
|
||||
"result": "Capture enregistrée (captures/aveugle.test-{{T}}.jpg, 0 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n",
|
||||
"shown": "captures/aveugle.test-{{T}}.jpg",
|
||||
"pw_args": [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
"1280,800",
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
"https://aveugle.test/",
|
||||
"{{CAPTURES}}/aveugle.test-{{T}}.jpg"
|
||||
],
|
||||
"probes": 1,
|
||||
"captures": [
|
||||
"aveugle.test-{{T}}.jpg",
|
||||
"localhost-3000-{{T}}.jpg",
|
||||
"pleine.test-{{T}}.jpg",
|
||||
"www.exemple.test-{{T}}.jpg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Playwright en échec",
|
||||
"mode": "echec",
|
||||
"internet": true,
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"url": "https://x.test/"
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] capture impossible : page.goto: net::ERR_NAME_NOT_RESOLVED at https://x · Call log: · - navigating",
|
||||
"pw_args": [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
"1280,800",
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
"https://x.test/",
|
||||
"{{CAPTURES}}/x.test-{{T}}.jpg"
|
||||
],
|
||||
"probes": 0,
|
||||
"captures": [
|
||||
"aveugle.test-{{T}}.jpg",
|
||||
"localhost-3000-{{T}}.jpg",
|
||||
"pleine.test-{{T}}.jpg",
|
||||
"www.exemple.test-{{T}}.jpg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "image vide",
|
||||
"mode": "vide",
|
||||
"internet": true,
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"url": "https://vide.test/"
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] Playwright n'a produit aucune image",
|
||||
"pw_args": [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
"1280,800",
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
"https://vide.test/",
|
||||
"{{CAPTURES}}/vide.test-{{T}}.jpg"
|
||||
],
|
||||
"probes": 0,
|
||||
"captures": [
|
||||
"aveugle.test-{{T}}.jpg",
|
||||
"localhost-3000-{{T}}.jpg",
|
||||
"pleine.test-{{T}}.jpg",
|
||||
"vide.test-{{T}}.jpg",
|
||||
"www.exemple.test-{{T}}.jpg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "aucune image",
|
||||
"mode": "rien",
|
||||
"internet": true,
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"url": "https://rien.test/"
|
||||
},
|
||||
"label": "",
|
||||
"result": "[erreur] Playwright n'a produit aucune image",
|
||||
"pw_args": [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
"1280,800",
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
"https://rien.test/",
|
||||
"{{CAPTURES}}/rien.test-{{T}}.jpg"
|
||||
],
|
||||
"probes": 0,
|
||||
"captures": [
|
||||
"aveugle.test-{{T}}.jpg",
|
||||
"localhost-3000-{{T}}.jpg",
|
||||
"pleine.test-{{T}}.jpg",
|
||||
"vide.test-{{T}}.jpg",
|
||||
"www.exemple.test-{{T}}.jpg"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "ménage des captures",
|
||||
"image": "capture.png",
|
||||
"internet": true,
|
||||
"tool": "web_screenshot",
|
||||
"args": {
|
||||
"url": "http://127.0.0.1:8080/"
|
||||
},
|
||||
"label": "",
|
||||
"result": "Capture enregistrée (captures/127.0.0.1-8080-{{T}}.jpg, 0 Ko).\nPour la montrer à l'utilisateur, recopie TELLE QUELLE cette ligne markdown dans ta réponse :\n",
|
||||
"shown": "captures/127.0.0.1-8080-{{T}}.jpg",
|
||||
"pw_args": [
|
||||
"screenshot",
|
||||
"--browser",
|
||||
"chromium",
|
||||
"--viewport-size",
|
||||
"1280,800",
|
||||
"--wait-for-timeout",
|
||||
"3500",
|
||||
"http://127.0.0.1:8080/",
|
||||
"{{CAPTURES}}/127.0.0.1-8080-{{T}}.jpg"
|
||||
],
|
||||
"probes": 0,
|
||||
"captures": [
|
||||
"127.0.0.1-8080-{{T}}.jpg",
|
||||
"aveugle.test-{{T}}.jpg",
|
||||
"localhost-3000-{{T}}.jpg",
|
||||
"pleine.test-{{T}}.jpg",
|
||||
"vide.test-{{T}}.jpg",
|
||||
"vieille-08.jpg",
|
||||
"vieille-09.jpg",
|
||||
"vieille-10.jpg",
|
||||
"vieille-11.jpg",
|
||||
"vieille-12.jpg",
|
||||
"vieille-13.jpg",
|
||||
"vieille-14.jpg",
|
||||
"vieille-15.jpg",
|
||||
"vieille-16.jpg",
|
||||
"vieille-17.jpg",
|
||||
"vieille-18.jpg",
|
||||
"vieille-19.jpg",
|
||||
"vieille-20.jpg",
|
||||
"vieille-21.jpg",
|
||||
"www.exemple.test-{{T}}.jpg"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user