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:
Claude committed 2026-10-16 06:36:08 +00:00
1 parent fe51bdeee9
commit db1f20c0a4
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.
+344
View File
@@ -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)
}
}
+1
View File
@@ -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(),
}
}
+70
View File
@@ -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.
+11
View File
@@ -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 {
+456
View File
@@ -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![capture de {host}](/api/chat/image?path={rel})",
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;
+150
View File
@@ -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], "-");
}
+1
View File
@@ -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;
+286
View File
@@ -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()
}
+41
View File
@@ -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": "/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIAyAFAAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APytVKeFp6pUipxXpU6ZnzDFTmpFTinqnNPVOK9SnTHzDQtPVaeFqRU5r1KdMfMMCVIqc05U4qQJzXqU6Y+Yaq08JT1SpAlepTphzDFSnqnFPVOakVOK9OnTHzDVTmnqlPVOaeqV6lOmPmGhKeqU8JUipzXqU6Y+YYqcVIqc05U4qUJzXqU6YcwxUp4WnqlPCcV6lOmPmGqnNPVOKeqc1IqcV6lOmPmGheaeqU8JUipXqU6Y+YYEp6pzT1TipFTmvUp0x8wxU4qQJzT1TipAlepTpj5hipTwtPVKeEr1KdMOYaqc1g6/r/kBrW1b970eQfw+w9/5fXo7xBr3kbrW1b970eRT932Hv/L69OYVK/nLxF8Rfq/PkmST9/apUX2e8Yv+b+aX2dl713H28Hhb/vKi9EMCU8JT1TipAlfy9TpnvcwxUqRU6U5UqRU6V6lOmPmGqlPVKeqU8JXqU6Y+YaFp6pTwlSKnNepTphzDFTpUirTlSpVSvUp0x8wxUp4SnqlPCV6lOmPmGqlSKnSnKnNSKnFepTpj5hqpT1WnqlPVK9SnTHzDQtPVKeEqRU5r06dMfMMVOBUipzT1TinhK9SnTDmGhaeEp6pTwnFepTpj5hqpzUipxT1TmnqnFepTpj5hqpT1SnhKeqV6lOmPmGhakVOacE4qRU5r1KdMOYYqcVIFp6pxTwlepTpj5hqpWB4g1/yA1rat+96PIP4fYe/8vr0f4g1/yC1rat+96SSA/d9h7/y+vTl1Tiv5y8RfEX6vz5Jkk/f2qVF9nvGL/m/ml9nZe9dx9vB4W9qlT5IYE5qRUp4Snqlfy9Tpnvcw0LTwtPVOKeqdK9SnTHzDVSpFTpTlSpFTpXqU6Y+YaqU8LT1SnqlepTpj5hqpT1SnqnSpFSvUp0w5hip0qRUp6p0p4TmvUp0x8w0JT1TpT1SpFTpXqU6Y+YYqVIqdKeqU9Ur06dMfMNVKeq08JT1SvVp0w5hqp0qRUpyp0qRUr06dMfMNVOlPCU8JTwlepTpj5hipUip0p6pUip0r1KdMfMMVakVKeqU8JxXqU6Y+YaEp6pTwlPVK9SnTHzDVTpUipTlTpUipXqU6YcwxUrB8Qa/5G61tW/e9HkX+D2Hv79vr0f4g1/yN1rat+96PIP4PYe/8vr05YJxX85+IniL9X58kySfv7VKi+z3jF/zfzS+zsveu4+3g8Lf95UXohoTmnqnFPCc1IqV/LtOme9zDQnNPVKeEp4SvUp0x8w0LT1TmnhKkVOlepTpj5hipxUgWnKlSBK9SnTDmGKlSKlPVKeqV6lOmPmGqnNSKvFOVOlSKlepTpj5hoSnqlPCU9Ur06dMfMMVakVKeqVIqdK9SnTHzDFSpAtPVKeFr1KdMfMNC08JTwlPVK9SnTDmGqnSpFWnKnSpFSvUp0x8w0JT1SnhOKeEr1KdMfMMVKlVOlOVKkVOlepTpj5hipUgWnqlPVOK9SnTDmGhaeqU9U6VIqV6lOmPmGKnSsHxBr/kbrW1b970eQfw+w9/5fXo7xBr/ANn3Wtq373o8gP3fYe/8vr05cJX85+IviL9X58kySfv7VKi+z3jF/wA380vs7L3ruPt4PC3tUqfJDFSpAtPVKeqcV/LtOme9zDVTmpFTinqnNPVOK9SnTHzDQtPVaeFp6pzXqU6Y+YYEqVU5pypxUipzXqU6Y+YYq1IEp6pTwlepTphzDFSpFTinqnNPVOK9SnTHzDVTmpFTinqnNPVK9SnTHzDAtSKlPCU9U5r1KdMfMNVOKkVOacqcVIEr1KdMOYYqVIFp6pTwnFepTpj5hqpzT1TinqnNSKnFenTpj5hirzUipTwlPVK9SnTHzDAlSKnNOVOKlVOa9SnTHzDFTipAnNOVOKkCV6lOmPmGKlSBaeEp4SvTp0w5hqpzWBr+v+QGtbVv3vR5B/D7D3/l9ej/ABBr3kbrW1b970eRT932Hv8Ay+vTl1Sv508RfEX6vz5Jkk/f2qVF9nvGL/m/ml9nZe9dx9vB4W/7yovRHx8E5qRU4pwTmpFTivBp0z/MDmGhKeqU8JT1SvUp0x8wxUqRUpwSpFTpXqU6Ycw1UqQJTlSpAtenTpj5hoSnqlPVKeqV6lOmPmGqnSpFSnKnSpFSvUp0x8wwJUipzTglSKlepTpj5hipUqp0pypUip0r1KdMOYYqVIEpypUgSvUp0x8w0JzT1SnqlPVK9SnTHzDVTpUipTlTpUipXqU6Y+YYEqRUpwTipAlepTpj5hqpUip0pypUip0r06dMOYaqU8JxTlSpFTivVp0x8w1U6Vga/r3kb7W1b970eQH7vsPf+X16P8Qa/wCRutbVv3vSSQH7vsPf+X16cuqV/OXiL4i/V+fJMkn7+1Sovs94xf8AN/NL7Oy967j7eDwt7VKi06IYEqQJTgnFSBK/l6nTPe5hqpT1TinKlSBOK9SnTHzDVTmpFTinKnNSKnFepTpj5hgSpFSnBKkVK9SnTDmGBOKlVOaVU4qUJzXqU6Y+YYqcVIEpypUgSvUp0x8w1U5p6pxTlTmpFTivUp0x8w1U5qRUpypzUipXp06Y+YYEqRU5pwSpFTmvUp0x8w1U4qQJzTlTipAnNepTphzDFSpAlOVKkCV6lOmPmGqnNPVOKeqc09U4r1KdMfMIEp6pTglSKlepTpj5hipUipzTgnFSKnNepTphzDVTipAlOVOKkCV6dOmPmGKlYPiDXvIDWtq373o8gP3fYe/8vr0d4g1/yC1rat+96PIv8PsPf+X16cuqV/OfiL4i/V+fJMkn7+1Sovs94xf8380vs7L3ruPt4PC3tUqfJDVTpUipTlTpUipX8vU6Z73MMVKkCU5U4qQJXqU6Y+YYqVIqdKcqVIqdK9SnTHzDVSpFSnKlSKlepTpj5hgSpFTmnBKkVOa9SnTDmGqnSpAlOVKkVK9OnTHzDAlSBKcqVIEr1adMfMNVOaeqcU9U5p6pXp06Y+Yaqc1IqU4JzUipXqU6YcwwJUipzTgtSKnNepTpj5hqpxUgTmnKnFSBK9SnTHzDFSpAlOVKkCcV6lOmPmGqnNSKlOVOakVOK9SnTHzDAlSKlOCVIqV6lOmPmGKnFSqnNKE4qVU5r1KdMOYYqcVg+INf8jda2rfvejyD+D2Hv/L69HeINf8AI3Wtq373o8gP3PYe/wDL69OXCcV/OfiL4i/V+fJMkn7+1Sovs94xf8380vs7L3ruPt4PC3tUqLTohqpzT1TinqnNPVOK/l2nTPe5hoSpFSnBKkVK9SnTHzDFSpFTpTlTipFTpXqU6Y+YaqVIqdKcqVIqdK9SnTDmGKlSKlOVKkVK9SnTHzDFTpUipTlTpUipXqU6Y+YaqdKkVOacqdKkVK9SnTHzDFSpFTpTlSpFTpXp06Y+YaqVIqU5UqRUr1KdMfMMCc1IqU4JUipXqU6Ycw1U6VIqUqp0qVUr1KdMfMMCVIFpwSpAlepTpj5hipUip0pypUip0r1KdMfMNVKkVKcqVIE4r1KdMOYYEqRUpwSpFSvUp0x8w1U6Vg+INf8As+61tW/e9HkH8PsPf+X16O8Qa95Aa1tW/e9HkB+77D3/AJfXpy4Sv5y8RfEX6vz5Jkk/f2qVF9nvGL/m/ml9nZe9dx9vB4W9qlT5IYqVIE4pypUgTiv5ep0z3uYaE5p6pxTwnNPVOK9SnTHzDQlSKlOCVIEr1KdMfMMVKkVKcEqRU6V6lOmPmGqlSBKcqVIEr1KdMOYYqVIqU5UqRUr1KdMfMNVOlPVKeqdKeqV6lOmPmGhKkVOacEqRUr1KdMfMMVKkVOlOVKkVOlenTphzDVSpAlOVKkCV6lOmPmGBKkVKcEqRUr1KdMfMNVOlSKlOVOlSKlepTpj5hgSpFSnBOKkCV6lOmPmGKlSKnSnKlSKnSvUp0x8w1UqQJxTlSnqnFepTphzDVTpWDr+veRvtbVv3vR5Afu+w9/5fXo7xBr/kbrW1b970kkB+77D3/l9enLqlfzn4i+Iv1fnyTJJ+/tUqL7PeMX/N/NL7Oy967j7eDwt7VKi06IaEqQJTgnFSBK/l2nTPe5j4+CVIqU4LUgSv6Yp0z/KPmGBOKkVOlOVKkVOlepTgPmGKlSKnSnqlSKlepTphzDFSnqlPC1Iq16lOmPmGKnSpFSnKnSpFSvTp0x8w1U6VIqU4LUgWvUp0x8wxUqRU6U5UqRULine 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": "/9j/2wCEAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRQBAwQEBQQFCQUFCRQNCw0UFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFP/AABEIC7gFAAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/APysAqRRSKtPC16dOmTcVRUiihV5p6rXq06Y7iqKkUUgWnqtenSpBcUCpFFIF4qRV5r1KdMdxVFPAoVakC16lKkO4KKkVaRVqRV4r1KdMdwUVIooVeaeq8V6lKkFxVFSKKQJUirzXqU6Y7gq1IopFXipFXmvUp0x3FUU9RQq08LXp06Y7iqKkUUirUirxXq06Y7iqKeooC809Vr06dMdxVFSKKQLUirzXqU6YXBRUiikVeKlC16lOmO4KKeooVaeq16lOmO4oFYPiDxB9n3Wtq373o8g/h9h7/y+vRPEGv8AkFrW1f8Ae9HkH8HsPf8Al9enLqtfzn4i+In1fnyTJJ+/tUqL7PeMX/N/NL7Oy967j7eDwl/3lRadEKBT1FAWnhK/l6nTPeuKq1IopFWpFXpXp0qQXBRUiihVp6rXq06Y7iqKeooC1Iq16dKkO4KKkUUir0qRVr1KdMdxVFPUUKtPC16lKkO4qipFFIq1Iq9K9SnTHcAKkUUBaeFr1KdMLiqKkApAtSKvNepTpjuCipFFIq1IFr1KdMdxVFPUUKtPC16dOmO4qipFFIq81Iq8V6tOmFwUVIooVaeq16dKkO4qipFFIFqRV5r1KdMdwUVIooVaeEr1KVIdxVFYHiDxB5G61tW/e9JJB/D7D3/l9eh4g1/yN1rat+96PKP4PYe/8vr05dV4r+c/EXxF+r8+SZJP39qlRfZ7xi/5v5pfZ2XvXcfbweEvapU26IAKkAoC809Vr+XaVI964qinqKAtPVa9SnTHcUCpFFIq1Iq9K9SnTC4KKkAoVaeq16lOmO4qinqKFWpFWvUp0x3BRUiihV6U9Vr1KdMdxVFPUUKtPVelepTphcVRUiikVakVelepTpjuCipFFCrT1WvUp0x3FAqRRSKvSpFWvTp0x3BRUgFCrT1WvVp0x3FVaeooVakVelenSpDuCipFFCrTwterTphcVRT1WgJT1WvTpUh3FUVIopFXpUirXqU6Y7gorB8QeIPI3Wtq373o8g/h9h7/AMvr0PEGv+QGtbV/3vSSQfw+w9/5fXpywWv5z8RPET6vz5Jkk/f2qVF9nvGL/m/ml9nZe9dx9vB4S/7yotOiFAqRRSBeakVeK/l6nTPeuCipFFAWnqtepSpBcVRT1FCrUirzXqU6Y7goqRRSKvFSBa9SnTHcFFSKtCrTwtenTpjuKoqRRSKtSKtepTpjuCipFFAWnqtenSpDuKq1IopAtSKvSvVp0wuCipFFIq1IFr06VIdxVFPAoVaeq16tOmO4qipFFIq9KkVa9OlSHcUCnqKAtPC16lOmFxVWpFFIq1Iq9K9SnTHcAKkUUKtPCV6lOmO4qinqtAWnqtepTpjuKorB1/xD5G61tW/e9HkH8PsPf+X16J4g1/yN1rat+96PIP4fYe/8vr05cLX85+IviL9X58kySfv7VKi+z3jF/wA380vs7L3ruPt4PCXtUqbdEKBT1FCrTwtfy9TpnvXFAqRRSKvNSKtenTpjuCipFFAWnqterTphcUCnqKAvFSKvNenTpjuCipAKRVqQLXq06Y7iqKeq0KtPVeK9OnTHcVRUiihV5p6rXq06YXBRUiigJT1XmvTpUh3FVakUUirxUirzXqU6Y7goqRRSKtSBa9SlSHcVRT1FCrT1XivUp0x3FUVIooC809Vr1KVIdwUVIopAlSKvNepTphcVRUiikVeKkC16dOmO4KKkUUKtPVa9SnTHcUCsDxB4g+z7rW1b970eQfw+w9/5fXoeINf8gta2r/vejyD+D2Hv/L69OXVa/nPxF8Rfq/PkmST9/apUX2e8Yv8Am/ml9nZe9dx9vB4S/wC8qLToj46AqRRQBUirXi0qR/mJcFFPUUqinqK9OnTHcFFSKKFFSKK9WnTC4iipFFKq09RXp06Y7goqRRQoqRRXqU6Y7iKKkUUKKkUV6lOmO4KtPUUqipFFepTpjuIoqRRQoqRRXqUqQ7gop6rSqKeor1KdMLgoqQChRUiivUpUh3EUVIooAqRRXqU6Y7gBT1FKoqRRXqUqQ7iKKkUUAVIor06dMLgop6ilUU8CvVp0x3BRWDr/AIh8jfa2rfvejyD+H2Hv/L69DxB4g8jda2rfvejyD+H2Hv8Ay+vTl1Ffzl4i+Iv1fnyTJJ+/tUqL7PeMX/N/NL7Oy967j7eDwl7VKi9ECinqtKop6iv5ep0z3rgBUiigCpFFepTpjuIoqRRQoqRRXqU6Y7gq1IooAp6ivTp0x3ACpFFCipAK9WnTC4KKeBQoqRRXp06Y7goqRRQoqQCvVp0x3ACnqKFFSKK9OlSHcFWpFFAFSKK9SnTC4iipFFCipFFepSpDuCinqtKop6ivUp0x3BRUiihRUiivUp0x3BRTwKFFSKK9OnTHcFFSKKFFSKK9WnTHcRRUiihVqRRXp06YXBRWB4g8QfZ91rat+96PIP4fYe/8vr0XxB4g+z7rW1b970kkH8PsPf8Al9enLqK/nPxF8Rfq/PkmST9/apUX2e8Yv+b+aX2dl713H28HhL2qVFp0QiipAKFFSKK/l2nTPfuAFPVaUCnqK9WnTC4KKkUUKKkUV6dKkO4iipFFCipFFerTphcFWpFFCipFFenSpDuIoqRRQoqRRXqU6Y7gop6ilUU9RXqUqQ7goqRRQq1Ior1KdMdxFFSKKVRT1FepTpjuCipFFCipFWvUp0wuIoqRRQoqQCvUp0x3ACnqKVRUiivUp0x3EUVIooAqRRXqU6Y7gBT1FKop6ivTp0wuCipAKFFSKK9WnTHcRRWD4g8QeRutbVv3vSSQfw+w9/5fXoeIPEHkbrS1b970kkH8PsPf+X16cuor+cvEXxE+r8+SZJP39qlRfZ7xi/5v5pfZ2XvXcfbweEvapUXogUVIooUVIq1/L1Ome9cQCpFFAFSKK9SlSHcFFPUUqinqK9SnTHcAKkUUKtSKK9SlSHcFFPAoAqRRXqU6YXBRUiihRUgFenTpjuIoqQChRUiivUp0x3BRT1FKBT1FepTpjuAFSKKFWpFFepTphcFFPUUqinqK9SnTHcFFSKKFFSKtepTpjuCinqKFFSKK9SnTHcFFSKKFFPAr06VIdwUVIooUVIor1adMdwVaeopVFPUV6dKkFwUVg+IPEHkbrW1b970eQfw+w9/5fXoeIPEHkbrW1b970eQfw+w9/wCX16cuor+c/EXxF+r8+SZJP39qlRfZ7xi/5v5pfZ2XvXcfbweEvapUWnRAop6ilUU8Cv5epUj37gBUiigCpFWvUp0wuIoqRRQoqRRXqUqQ7gop6ilUVIor06dMLiKKkUUKtSKK9WnTHcFFPUUqinqK9OnTHcFFSKKFFSKK9WnTHcRVqRRSqKeor06dMdwUVIooUVIor1adMdxFFSKtCipFFenTphcFFPApVFPUV6lOmO4KKkUUAVIor1KdMdwAp6ilUU9RXqU6Y7goqRRQq1Ior1KVILiKKkUUKKkUV6dOmO4KKwNf8Q+RvtbVv3vR5B/D7D3/AJfXoviDxB5G61tW/e9HkH8PsPf+X16csor+c/EXxF+r8+SZJP39qlRfZ7xi/wCb+aX2dl713H28HhL2qVF6IFFSKtCipFFfy7TpnvXPjpVqRV4oAp6iv6cp0z/Ke4qrzUirxSKtSKK9SlSHcAlSKtCinqK9OnTHcFXipVXmkUVIq16tOmFwVakC0iipAK9OnTHcFWpFXihRUiivVp0x3ALzUirSAVIor06dMdwC1Iq80KKeor1KdMLiqvFSBaQCpFFepTplXBVqQLSKKkUV6lOmFxVXmnqvFCipFFepTpjuAWpFWkUVIor1KdMdwVakVeaFFPUV6lKkO4qrWD4g1/yN1rat+96PIv8AD7D3/l9eieIPEHkbrW1b950eQfw+w9/5fXpy6iv5y8RfEX6vz5Jkk/f2qVF9nvGL/m/ml9nZe9dx9vB4S9qlReiBVqRVoUU9RX8vUqR71xVXpUirSKKkAr1KdMdwVakC0gFSKK9SlSC4KtSKvShRT1FenTpjuKFqRVpFWpFFerTphcAtSKvNCinqK9OnTKuKq1IFpFFSKterTphcFWpAtIoqRRXp06Y7iqvNPVeKFFSKK9WnTHcFWpFWkUVIor06dMdwC1Iq80KKeor1KdMLiqtSBKRRUiivUp0x3BVqQLxQop4FepTpjuKq809VoUVIor1KVIdwC1IqLine 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![capture de localhost:3000](/api/chat/image?path=captures/localhost-3000-{{T}}.jpg)",
"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![capture de www.exemple.test](/api/chat/image?path=captures/www.exemple.test-{{T}}.jpg)\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![capture de pleine.test](/api/chat/image?path=captures/pleine.test-{{T}}.jpg)\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![capture de aveugle.test](/api/chat/image?path=captures/aveugle.test-{{T}}.jpg)",
"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![capture de 127.0.0.1:8080](/api/chat/image?path=captures/127.0.0.1-8080-{{T}}.jpg)",
"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"
]
}
]
}