mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
L'amont (AJEAN 0.15) donne à l'IA le contrôle d'un navigateur ; Loki embarquait déjà un Chromium pour `web_screenshot` sans jamais le piloter. Contrôle du navigateur (computer_use.go, computer_cdp.go, browser_grid.go, portés d'AJEAN) : l'IA ouvre une page en CDP, en reçoit les éléments interactifs NUMÉROTÉS et agit par numéro (browser_open/snapshot/find/click/ type/key/scroll). Aucune vision requise — un petit modèle texte s'en sort. Avec un projecteur chargé s'ajoutent browser_screenshot (image quadrillée) et browser_click_xy. Interrupteur dédié (Réglages, `loki computer`, /api/computer), sous le mode agent : cliquer et taper dans une page sont des actions réelles, même niveau de confiance que bash. Adaptation au conteneur : chromePath cherche D'ABORD le Chromium de Playwright (PLAYWRIGHT_BROWSERS_PATH, /opt/pw-browsers), le seul navigateur de l'image — sinon la fonctionnalité se déclarait absente là où le navigateur est présent. LOKI_CHROME force un chemin, LOKI_CU_HEADFUL ouvre une vraie fenêtre. Préparation des images (web_upload_orient.go, porté d'AJEAN) : l'orientation EXIF est cuite dans les pixels — le projecteur l'ignore et voyait les photos de téléphone couchées — et le grand côté ramené sous 1568 px. Appliquée aux pièces jointes, à see_image et aux captures. Dédup des appels d'outils : bash, bash_bg, bash_tail, see_image et les browser_* ne sont plus court-circuités sur un appel identique. Relancer la même commande après avoir modifié un fichier est légitime, et un outil qui porte une image dans un message à part renvoyait « [déjà fait] » SANS l'image — le modèle tournait en boucle. Mode code : le builder ne publie plus de lui-même (pas de commit/push/reset/ rebase ni de redémarrage de service sans demande explicite), reprise de la leçon d'AJEAN 0.15.4 ; l'inspection en lecture seule reste encouragée. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
130 lines
4.4 KiB
Go
130 lines
4.4 KiB
Go
package loki
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"image"
|
|
"image/color"
|
|
"image/jpeg"
|
|
"testing"
|
|
)
|
|
|
|
// buildExifJPEG fabrique un JPEG minimal portant un tag EXIF Orientation donné,
|
|
// pour tester le parseur sans dépendre d'un fichier binaire.
|
|
func buildExifJPEG(t *testing.T, orient uint16, img image.Image) []byte {
|
|
t.Helper()
|
|
// Bloc TIFF (little-endian) avec un unique IFD0 : 1 entrée = Orientation.
|
|
var tiff bytes.Buffer
|
|
tiff.WriteString("II")
|
|
binary.Write(&tiff, binary.LittleEndian, uint16(0x002A))
|
|
binary.Write(&tiff, binary.LittleEndian, uint32(8)) // IFD0 juste après l'en-tête
|
|
binary.Write(&tiff, binary.LittleEndian, uint16(1)) // 1 entrée
|
|
binary.Write(&tiff, binary.LittleEndian, uint16(0x0112))
|
|
binary.Write(&tiff, binary.LittleEndian, uint16(3)) // type SHORT
|
|
binary.Write(&tiff, binary.LittleEndian, uint32(1)) // count
|
|
binary.Write(&tiff, binary.LittleEndian, orient)
|
|
binary.Write(&tiff, binary.LittleEndian, uint16(0)) // padding du champ valeur
|
|
binary.Write(&tiff, binary.LittleEndian, uint32(0)) // next IFD = 0
|
|
|
|
exif := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
|
|
|
|
var out bytes.Buffer
|
|
out.Write([]byte{0xFF, 0xD8}) // SOI
|
|
// APP1
|
|
out.Write([]byte{0xFF, 0xE1})
|
|
binary.Write(&out, binary.BigEndian, uint16(len(exif)+2))
|
|
out.Write(exif)
|
|
// Corps JPEG réel (sans son propre SOI, qu'on saute).
|
|
var body bytes.Buffer
|
|
if err := jpeg.Encode(&body, img, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out.Write(body.Bytes()[2:]) // saute le SOI dupliqué
|
|
return out.Bytes()
|
|
}
|
|
|
|
func TestExifOrientationParsing(t *testing.T) {
|
|
img := image.NewNRGBA(image.Rect(0, 0, 4, 2))
|
|
for _, want := range []int{1, 3, 6, 8} {
|
|
b := buildExifJPEG(t, uint16(want), img)
|
|
if got := exifOrientation(b); got != want {
|
|
t.Errorf("orientation %d : lu %d", want, got)
|
|
}
|
|
}
|
|
// Un JPEG sans EXIF renvoie 0.
|
|
var plain bytes.Buffer
|
|
if err := jpeg.Encode(&plain, img, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := exifOrientation(plain.Bytes()); got != 0 {
|
|
t.Errorf("sans EXIF : attendu 0, lu %d", got)
|
|
}
|
|
}
|
|
|
|
func TestApplyOrientationDimensions(t *testing.T) {
|
|
// 4 de large, 2 de haut. Une rotation d'un quart (6, 8) doit échanger les axes.
|
|
src := image.NewNRGBA(image.Rect(0, 0, 4, 2))
|
|
if d := applyOrientation(src, 6).Bounds(); d.Dx() != 2 || d.Dy() != 4 {
|
|
t.Errorf("orient 6 : %dx%d, attendu 2x4", d.Dx(), d.Dy())
|
|
}
|
|
if d := applyOrientation(src, 3).Bounds(); d.Dx() != 4 || d.Dy() != 2 {
|
|
t.Errorf("orient 3 : %dx%d, attendu 4x2", d.Dx(), d.Dy())
|
|
}
|
|
}
|
|
|
|
func TestScaleDownToMax(t *testing.T) {
|
|
// Grande image paysage : le plus grand côté doit tomber à maxImageDim, ratio
|
|
// conservé, et une image déjà petite ne bouge pas.
|
|
big := image.NewNRGBA(image.Rect(0, 0, 4000, 2000))
|
|
d := scaleDownToMax(big, maxImageDim).Bounds()
|
|
if d.Dx() != maxImageDim {
|
|
t.Errorf("grand côté : %d, attendu %d", d.Dx(), maxImageDim)
|
|
}
|
|
if d.Dy() != maxImageDim/2 {
|
|
t.Errorf("petit côté : %d, attendu %d (ratio 2:1)", d.Dy(), maxImageDim/2)
|
|
}
|
|
small := image.NewNRGBA(image.Rect(0, 0, 100, 80))
|
|
if sd := scaleDownToMax(small, maxImageDim).Bounds(); sd.Dx() != 100 || sd.Dy() != 80 {
|
|
t.Errorf("image petite modifiée : %dx%d", sd.Dx(), sd.Dy())
|
|
}
|
|
}
|
|
|
|
func TestPrepareImageForModelResizesJPEG(t *testing.T) {
|
|
// Un JPEG plus grand que maxImageDim doit être réencodé plus petit ; ses octets
|
|
// changent donc forcément.
|
|
big := image.NewNRGBA(image.Rect(0, 0, 3000, 1500))
|
|
var raw bytes.Buffer
|
|
if err := jpeg.Encode(&raw, big, nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, mime := prepareImageForModel(raw.Bytes(), "image/jpeg")
|
|
if mime != "image/jpeg" {
|
|
t.Fatalf("mime = %s", mime)
|
|
}
|
|
img, _, err := image.Decode(bytes.NewReader(out))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if img.Bounds().Dx() != maxImageDim {
|
|
t.Errorf("largeur après préparation : %d, attendu %d", img.Bounds().Dx(), maxImageDim)
|
|
}
|
|
}
|
|
|
|
func TestApplyOrientationRotate90(t *testing.T) {
|
|
// Pixel repère en haut-gauche du capteur ; en orientation 6 (90° horaire) il
|
|
// doit se retrouver en haut-droite de l'image redressée.
|
|
src := image.NewNRGBA(image.Rect(0, 0, 3, 2))
|
|
for y := 0; y < 2; y++ {
|
|
for x := 0; x < 3; x++ {
|
|
src.Set(x, y, color.NRGBA{0, 0, 0, 255})
|
|
}
|
|
}
|
|
src.Set(0, 0, color.NRGBA{255, 0, 0, 255})
|
|
dst := applyOrientation(src, 6)
|
|
// dst est 2 de large, 3 de haut ; le repère va en (dw-1, 0).
|
|
r := color.NRGBAModel.Convert(dst.At(dst.Bounds().Dx()-1, 0)).(color.NRGBA)
|
|
if r.R != 255 {
|
|
t.Errorf("orient 6 : repère mal placé, got %+v", r)
|
|
}
|
|
}
|