mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
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
345 lines
13 KiB
Go
345 lines
13 KiB
Go
//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)
|
|
}
|
|
}
|