mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Navigateur piloté, images redressées : reprises d'AJEAN
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
This commit is contained in:
21 files changed
+2503
-19
No files matched your search
@@ -28,6 +28,14 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
|
||||
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales.
|
||||
|
||||
## Reprises depuis l'amont après le fork
|
||||
|
||||
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
|
||||
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
|
||||
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
|
||||
présent dans l'image) et la **préparation des images** envoyées au modèle
|
||||
(`web_upload_orient.go` — orientation EXIF et redimensionnement).
|
||||
|
||||
## Services externes
|
||||
|
||||
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
|
||||
|
||||
@@ -122,6 +122,8 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_HOME` | données (volume) | `/data` |
|
||||
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
|
||||
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
|
||||
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
|
||||
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
@@ -175,11 +177,29 @@ Héritées d'AJEAN :
|
||||
|
||||
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
|
||||
conversations. La **vision** demande un modèle multimodal *et* son projecteur
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle). Toute image
|
||||
envoyée au modèle (pièce jointe, `see_image`, capture) est d'abord
|
||||
**redressée** — l'orientation EXIF d'une photo de téléphone est cuite dans
|
||||
les pixels, sinon le projecteur, qui ignore l'EXIF, la voyait couchée — et
|
||||
**ramenée sous 1568 px** de grand côté, taille au-delà de laquelle le base64
|
||||
grossit sans rien apprendre de plus au modèle.
|
||||
- **Mémoire persistante** (`memory off|ondemand|always`).
|
||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Contrôle du navigateur** (`computer on`, ou *Réglages → Contrôle du
|
||||
navigateur*) : l'IA **pilote** un Chromium — celui de Playwright, déjà dans
|
||||
l'image — par le protocole DevTools. Elle ouvre une page, en reçoit les
|
||||
éléments interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit
|
||||
par numéro : `browser_open`, `browser_snapshot`, `browser_find`,
|
||||
`browser_click`, `browser_type`, `browser_key`, `browser_scroll`. Aucune
|
||||
vision requise — ça marche avec de petits modèles texte. Avec un projecteur
|
||||
`mmproj` chargé s'ajoutent `browser_screenshot` (image quadrillée tous les
|
||||
100 px) et `browser_click_xy`, pour ce que l'arbre d'accessibilité ne montre
|
||||
pas (canvas, bandeau en iframe). Ce sont des **actions réelles** sur le web :
|
||||
l'interrupteur est distinct de l'accès internet et n'agit qu'en **mode
|
||||
agent**, au même niveau de confiance que `bash`. La session navigateur est
|
||||
unique et réutilisée entre les appels ; couper l'interrupteur la ferme.
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
|
||||
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
|
||||
|
||||
@@ -13,3 +13,4 @@ Rules:
|
||||
- You may NOT mark a criterion passed — the verification pass does that.
|
||||
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
|
||||
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
|
||||
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
|
||||
@@ -0,0 +1,129 @@
|
||||
package loki
|
||||
|
||||
// browser_grid.go — surimpose une GRILLE DE COORDONNÉES sur le screenshot envoyé
|
||||
// au MODÈLE (pas sur celui sauvegardé pour l'utilisateur). Un petit modèle vise
|
||||
// mal au pixel près ; avec des lignes graduées tous les 100 px et des étiquettes
|
||||
// chiffrées, il lit x/y précisément et browser_click_xy tombe juste du premier
|
||||
// coup (bandeaux cookies en iframe, canvas, jeux…). Sans dépendance externe : la
|
||||
// police de chiffres est un bitmap 3×5 codé ici.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const gridStep = 100 // espacement des lignes, en pixels
|
||||
|
||||
// digits5x3 : bitmap 5 lignes × 3 colonnes pour chaque chiffre (# = pixel plein).
|
||||
var digits5x3 = map[rune][5]string{
|
||||
'0': {"###", "# #", "# #", "# #", "###"},
|
||||
'1': {" # ", "## ", " # ", " # ", "###"},
|
||||
'2': {"###", " #", "###", "# ", "###"},
|
||||
'3': {"###", " #", "###", " #", "###"},
|
||||
'4': {"# #", "# #", "###", " #", " #"},
|
||||
'5': {"###", "# ", "###", " #", "###"},
|
||||
'6': {"###", "# ", "###", "# #", "###"},
|
||||
'7': {"###", " #", " #", " #", " #"},
|
||||
'8': {"###", "# #", "###", "# #", "###"},
|
||||
'9': {"###", "# #", "###", " #", "###"},
|
||||
}
|
||||
|
||||
// overlayGrid décode un PNG, y dessine la grille + les graduations, et renvoie le
|
||||
// PNG résultant. En cas d'erreur (image illisible), renvoie l'entrée inchangée.
|
||||
func overlayGrid(raw []byte) []byte {
|
||||
src, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
b := src.Bounds()
|
||||
img := image.NewRGBA(b)
|
||||
draw.Draw(img, b, src, b.Min, draw.Src)
|
||||
|
||||
line := color.RGBA{255, 0, 200, 130} // magenta translucide pour les lignes
|
||||
labelBG := color.RGBA{0, 0, 0, 200} // fond sombre derrière les chiffres
|
||||
labelFG := color.RGBA{255, 255, 0, 255} // chiffres jaunes (contraste sur toute page)
|
||||
w, h := b.Dx(), b.Dy()
|
||||
|
||||
// Lignes verticales + étiquette x en haut.
|
||||
for x := gridStep; x < w; x += gridStep {
|
||||
for y := 0; y < h; y++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+x+2, b.Min.Y+2, strconv.Itoa(x), labelFG, labelBG)
|
||||
}
|
||||
// Lignes horizontales + étiquette y à gauche.
|
||||
for y := gridStep; y < h; y += gridStep {
|
||||
for x := 0; x < w; x++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+2, b.Min.Y+y+2, strconv.Itoa(y), labelFG, labelBG)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return raw
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// blend applique une couleur semi-transparente (alpha) sur un pixel.
|
||||
func blend(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if !(image.Pt(x, y).In(img.Bounds())) {
|
||||
return
|
||||
}
|
||||
a := float64(c.A) / 255
|
||||
o := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(float64(c.R)*a + float64(o.R)*(1-a)),
|
||||
G: uint8(float64(c.G)*a + float64(o.G)*(1-a)),
|
||||
B: uint8(float64(c.B)*a + float64(o.B)*(1-a)),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
|
||||
// drawLabel écrit un nombre (police 3×5, échelle ×2) avec un fond sombre pour
|
||||
// rester lisible sur n'importe quel fond.
|
||||
func drawLabel(img *image.RGBA, x, y int, s string, fg, bg color.RGBA) {
|
||||
const sc = 2
|
||||
glyphW, glyphH, gap := 3*sc, 5*sc, sc
|
||||
boxW := len(s)*(glyphW+gap) + gap
|
||||
boxH := glyphH + 2*gap
|
||||
// Fond.
|
||||
for dy := -gap; dy < boxH-gap; dy++ {
|
||||
for dx := -gap; dx < boxW-gap; dx++ {
|
||||
setOpaque(img, x+dx, y+dy, bg)
|
||||
}
|
||||
}
|
||||
// Chiffres.
|
||||
cx := x
|
||||
for _, r := range s {
|
||||
g, ok := digits5x3[r]
|
||||
if !ok {
|
||||
cx += glyphW + gap
|
||||
continue
|
||||
}
|
||||
for row := 0; row < 5; row++ {
|
||||
for col := 0; col < 3; col++ {
|
||||
if g[row][col] != '#' {
|
||||
continue
|
||||
}
|
||||
for iy := 0; iy < sc; iy++ {
|
||||
for ix := 0; ix < sc; ix++ {
|
||||
setOpaque(img, cx+col*sc+ix, y+row*sc+iy, fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += glyphW + gap
|
||||
}
|
||||
}
|
||||
|
||||
func setOpaque(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if image.Pt(x, y).In(img.Bounds()) {
|
||||
img.SetRGBA(x, y, color.RGBA{c.R, c.G, c.B, 255})
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -213,11 +212,13 @@ func screenshotImageMessage(relPath string) (Message, bool) {
|
||||
if err != nil || len(b) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
// Redimensionnement sous maxImageDim avant l'envoi : une capture de page
|
||||
// fait facilement 1500×4000 px, dont le base64 pèse plus que ce que le
|
||||
// projecteur regarde (voir prepareImageForModel, web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
}},
|
||||
imageURLPart(b, mime),
|
||||
}}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,9 @@ func baseSystemPrompt(caps Caps) string {
|
||||
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\n")
|
||||
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
|
||||
}
|
||||
if caps.Agent && caps.ComputerUse {
|
||||
b.WriteString(cuPromptLine())
|
||||
}
|
||||
if caps.Agent {
|
||||
if line := mcpPromptLine(); line != "" {
|
||||
b.WriteString(line)
|
||||
|
||||
@@ -80,7 +80,17 @@ func toolSeeImage(path string) (string, map[string]any) {
|
||||
if len(b) == 0 {
|
||||
return "[erreur] fichier vide : " + path, nil
|
||||
}
|
||||
return "[ok] image chargée : " + filepath.Base(abs), map[string]any{
|
||||
// Même préparation que les pièces jointes : orientation EXIF redressée et
|
||||
// grand côté ramené sous maxImageDim (voir web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return "[ok] image chargée : " + filepath.Base(abs), imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// imageURLPart construit la partie multimodale {type:image_url, image_url:{url}}
|
||||
// à partir d'octets image déjà préparés — le SEUL format qu'un llama-server
|
||||
// --mmproj comprend. Partagé par see_image, web_screenshot et browser_screenshot.
|
||||
func imageURLPart(b []byte, mime string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
@@ -93,8 +103,14 @@ func toolSeeImage(path string) (string, map[string]any) {
|
||||
// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne
|
||||
// dirait pas LAQUELLE rouvrir.
|
||||
func seeImageMessage(label string, img map[string]any) Message {
|
||||
// Sans parenthèses vides : browser_screenshot n'a pas d'argument (label=""),
|
||||
// là où see_image porte le chemin du fichier.
|
||||
text := "Image :"
|
||||
if label != "" {
|
||||
text = "Image demandée (" + label + ") :"
|
||||
}
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Image demandée (" + label + ") :"},
|
||||
{"type": "text", "text": text},
|
||||
img,
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,895 @@
|
||||
package loki
|
||||
|
||||
// computer_cdp.go — pilote un Chromium/Chrome LOCAL via le protocole DevTools
|
||||
// (CDP), en Go pur (websocket coder/websocket déjà présent), sans dépendance
|
||||
// lourde type chromedp/playwright. C'est le moteur « web » du computer use :
|
||||
// ouvrir une URL, lire l'arbre d'accessibilité (éléments interactifs numérotés,
|
||||
// façon set-of-marks), cliquer/taper/défiler, et — à la demande — capturer un
|
||||
// screenshot pour la vision.
|
||||
//
|
||||
// Choix d'archi : le conteneur Loki embarque déjà un Chromium (celui de
|
||||
// Playwright, cf. Dockerfile) ; on le pilote en CDP. Une seule session navigateur
|
||||
// est maintenue (lazy) et réutilisée entre les appels d'outils, comme le cache
|
||||
// web de chat_internet.go.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
var (
|
||||
cdpMu sync.Mutex
|
||||
cdpCur *cdpSession
|
||||
)
|
||||
|
||||
// Taille FIXE du viewport piloté (et donc du screenshot). Communiquée au modèle
|
||||
// pour que ses coordonnées browser_click_xy lues sur l'image tombent juste.
|
||||
const (
|
||||
cuViewW = 1280
|
||||
cuViewH = 800
|
||||
)
|
||||
|
||||
// cdpSession détient le process Chrome, la connexion websocket CDP vers la cible
|
||||
// « page », et le multiplexage requête/réponse (un lecteur en tâche de fond
|
||||
// distribue les réponses aux appels en attente par id).
|
||||
type cdpSession struct {
|
||||
cmd *exec.Cmd
|
||||
userDir string
|
||||
ws *websocket.Conn
|
||||
wctx context.Context
|
||||
wcancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[int]chan cdpMessage
|
||||
|
||||
netMu sync.Mutex
|
||||
inflight int // requêtes réseau en cours (pour l'attente d'inactivité)
|
||||
lastNet time.Time // dernier événement réseau
|
||||
|
||||
lastSnap string // dernier snapshot renvoyé, pour dé-dupliquer les états identiques
|
||||
}
|
||||
|
||||
type cdpMessage struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *cdpError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type cdpError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ─── découverte du binaire navigateur ────────────────────────────────────────
|
||||
|
||||
// chromePath localise un binaire Chromium/Chrome/Edge exploitable. LOKI_CHROME
|
||||
// force un chemin ; sinon on cherche D'ABORD le Chromium de Playwright (celui
|
||||
// qu'embarque l'image Docker, sous PLAYWRIGHT_BROWSERS_PATH), puis les
|
||||
// emplacements standards par OS, puis le PATH.
|
||||
//
|
||||
// L'ordre compte : dans le conteneur, AUCUN chromium n'est installé par le
|
||||
// gestionnaire de paquets — le seul navigateur présent est celui que
|
||||
// `playwright install chromium` a déposé dans /opt/pw-browsers. Sans cette
|
||||
// recherche, le pilotage de navigateur serait déclaré indisponible dans l'image
|
||||
// même qui embarque un navigateur (voir Dockerfile, ARG PLAYWRIGHT).
|
||||
func chromePath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("LOKI_CHROME")); p != "" {
|
||||
return p
|
||||
}
|
||||
if p := playwrightChromium(); p != "" {
|
||||
return p
|
||||
}
|
||||
var cands []string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)"), os.Getenv("LocalAppData")} {
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
cands = append(cands,
|
||||
filepath.Join(base, `Google\Chrome\Application\chrome.exe`),
|
||||
filepath.Join(base, `Microsoft\Edge\Application\msedge.exe`),
|
||||
filepath.Join(base, `Chromium\Application\chrome.exe`),
|
||||
)
|
||||
}
|
||||
case "darwin":
|
||||
cands = []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
}
|
||||
default:
|
||||
for _, n := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range cands {
|
||||
if c != "" {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"chrome", "google-chrome", "chromium"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// playwrightChromium renvoie le binaire Chromium déposé par Playwright, ou ""
|
||||
// s'il n'y en a pas. Playwright range ses navigateurs sous
|
||||
// PLAYWRIGHT_BROWSERS_PATH (l'image Docker fixe /opt/pw-browsers) ou, à défaut,
|
||||
// dans le cache utilisateur, en un dossier chromium-<build> par version.
|
||||
//
|
||||
// On prend la version la PLUS RÉCENTE (les builds sont numérotés, tri croissant
|
||||
// sur le nom) : une image reconstruite laisse parfois l'ancien dossier derrière
|
||||
// elle, et lancer un vieux Chromium sur le CDP d'aujourd'hui, c'est chercher
|
||||
// des pannes sans raison. `chrome` d'abord, `headless_shell` en repli : ce
|
||||
// dernier ne sait pas ouvrir de fenêtre (LOKI_CU_HEADFUL serait sans effet)
|
||||
// mais pilote très bien une page en headless.
|
||||
func playwrightChromium() string {
|
||||
var roots []string
|
||||
if p := strings.TrimSpace(os.Getenv("PLAYWRIGHT_BROWSERS_PATH")); p != "" && p != "0" {
|
||||
roots = append(roots, p)
|
||||
}
|
||||
roots = append(roots, "/opt/pw-browsers")
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
roots = append(roots, filepath.Join(home, "AppData", "Local", "ms-playwright"))
|
||||
case "darwin":
|
||||
roots = append(roots, filepath.Join(home, "Library", "Caches", "ms-playwright"))
|
||||
default:
|
||||
roots = append(roots, filepath.Join(home, ".cache", "ms-playwright"))
|
||||
}
|
||||
}
|
||||
// Chemin du binaire À L'INTÉRIEUR d'un dossier chromium-<build>, par OS.
|
||||
var rels [][]string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
rels = [][]string{{"chrome-win", "chrome.exe"}, {"chrome-win", "headless_shell.exe"}}
|
||||
case "darwin":
|
||||
rels = [][]string{{"chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"}, {"chrome-mac", "headless_shell"}}
|
||||
default:
|
||||
rels = [][]string{{"chrome-linux", "chrome"}, {"chrome-linux", "headless_shell"}}
|
||||
}
|
||||
for _, root := range roots {
|
||||
dirs, err := filepath.Glob(filepath.Join(root, "chromium*-*"))
|
||||
if err != nil || len(dirs) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
for _, rel := range rels {
|
||||
p := filepath.Join(append([]string{dirs[i]}, rel...)...)
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── lancement + connexion ───────────────────────────────────────────────────
|
||||
|
||||
func cdpLaunch() (*cdpSession, error) {
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
return nil, fmt.Errorf("navigateur Chromium/Chrome/Edge introuvable — installe-le ou définis LOKI_CHROME=<chemin du binaire>")
|
||||
}
|
||||
// Balaye les profils temporaires abandonnés (Chrome tué sans close() lors d'un
|
||||
// redémarrage du service) : sinon /tmp se remplit de dossiers loki-cu-* au fil
|
||||
// des restarts.
|
||||
sweepStaleCUDirs()
|
||||
userDir, err := os.MkdirTemp("", "loki-cu-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Headless par défaut (marche sur le serveur sans écran) ; LOKI_CU_HEADFUL=1
|
||||
// ouvre une vraie fenêtre visible sur une machine de bureau.
|
||||
args := []string{
|
||||
"--remote-debugging-port=0",
|
||||
"--user-data-dir=" + userDir,
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-gpu", "--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-features=Translate,BackForwardCache",
|
||||
// Indispensables sur un serveur headless sous un compte non-root : sans
|
||||
// --no-sandbox Chrome plante au lancement (namespaces refusés), et /dev/shm
|
||||
// y est souvent minuscule → --disable-dev-shm-usage évite les crash onglet.
|
||||
"--no-sandbox", "--disable-dev-shm-usage",
|
||||
"--window-size=1280,900",
|
||||
"about:blank",
|
||||
}
|
||||
if os.Getenv("LOKI_CU_HEADFUL") == "" {
|
||||
args = append([]string{"--headless=new"}, args...)
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
// Chrome écrit le port de debug réel dans DevToolsActivePort (ligne 1).
|
||||
port := ""
|
||||
portFile := filepath.Join(userDir, "DevToolsActivePort")
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(portFile); err == nil {
|
||||
if line := strings.SplitN(strings.TrimSpace(string(b)), "\n", 2)[0]; line != "" {
|
||||
port = line
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if port == "" {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, fmt.Errorf("Chrome n'a pas exposé son port de debug (DevToolsActivePort)")
|
||||
}
|
||||
wsURL, err := cdpPageWS(port)
|
||||
if err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
c.SetReadLimit(-1) // les screenshots dépassent la limite par défaut (32 Kio)
|
||||
s := &cdpSession{cmd: cmd, userDir: userDir, ws: c, wctx: ctx, wcancel: cancel, pending: map[int]chan cdpMessage{}}
|
||||
go s.readLoop()
|
||||
_, _ = s.call("Page.enable", nil)
|
||||
_, _ = s.call("Runtime.enable", nil)
|
||||
_, _ = s.call("DOM.enable", nil)
|
||||
_, _ = s.call("Network.enable", nil) // pour l'attente d'inactivité réseau (settle)
|
||||
// Viewport DÉTERMINISTE : sans ça, headless donne une taille bizarre (mesuré :
|
||||
// 1264x749) que le modèle interprétait mal pour browser_click_xy (il visait trop
|
||||
// bas). On force exactement cuViewW x cuViewH, DPR 1 → le screenshot fait cette
|
||||
// taille, et les coordonnées lues dessus = coordonnées de clic, 1:1.
|
||||
_, _ = s.call("Emulation.setDeviceMetricsOverride", map[string]any{
|
||||
"width": cuViewW, "height": cuViewH, "deviceScaleFactor": 1, "mobile": false,
|
||||
})
|
||||
// Garder toute navigation dans le MÊME onglet : le pilote n'est attaché qu'à une
|
||||
// cible « page ». Un lien target=_blank ou un window.open ouvrait un onglet que
|
||||
// le pilote ne voyait pas → l'IA croyait que « rien ne se passe » et bouclait
|
||||
// (vécu sur le test IONOS « Devenez client »). Ce script, réévalué au début de
|
||||
// CHAQUE document, neutralise les nouveaux onglets.
|
||||
_, _ = s.call("Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": cdpSameTabJS})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpSameTabJS force les navigations « nouvel onglet » à rester dans l'onglet
|
||||
// courant (window.open redirigé, target=_blank retiré au clic).
|
||||
const cdpSameTabJS = `(function(){try{
|
||||
window.open=function(u){if(u)location.href=u;return window;};
|
||||
document.addEventListener('click',function(e){
|
||||
var a=e.target&&e.target.closest&&e.target.closest('a[target]');
|
||||
if(a)a.removeAttribute('target');
|
||||
},true);
|
||||
}catch(e){}})();`
|
||||
|
||||
// cdpPageWS interroge l'API HTTP de debug pour trouver l'URL websocket d'une
|
||||
// cible de type « page ».
|
||||
func cdpPageWS(port string) (string, error) {
|
||||
cl := &http.Client{Timeout: 5 * time.Second}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := cl.Get("http://127.0.0.1:" + port + "/json")
|
||||
if err == nil {
|
||||
var targets []struct {
|
||||
Type string `json:"type"`
|
||||
WS string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&targets)
|
||||
resp.Body.Close()
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && t.WS != "" {
|
||||
return t.WS, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
return "", fmt.Errorf("aucune cible 'page' exposée par Chrome")
|
||||
}
|
||||
|
||||
func (s *cdpSession) readLoop() {
|
||||
for {
|
||||
_, data, err := s.ws.Read(s.wctx)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
for _, ch := range s.pending {
|
||||
close(ch)
|
||||
}
|
||||
s.pending = map[int]chan cdpMessage{}
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
var m cdpMessage
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
continue
|
||||
}
|
||||
if m.ID != 0 {
|
||||
s.mu.Lock()
|
||||
ch := s.pending[m.ID]
|
||||
delete(s.pending, m.ID)
|
||||
s.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Événements réseau : on suit le nombre de requêtes en vol pour que settle()
|
||||
// puisse attendre l'inactivité réseau (fin du chargement AJAX).
|
||||
switch m.Method {
|
||||
case "Network.requestWillBeSent":
|
||||
s.netMu.Lock()
|
||||
s.inflight++
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
case "Network.loadingFinished", "Network.loadingFailed":
|
||||
s.netMu.Lock()
|
||||
if s.inflight > 0 {
|
||||
s.inflight--
|
||||
}
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call envoie une commande CDP et attend sa réponse (borné à 30 s).
|
||||
func (s *cdpSession) call(method string, params map[string]any) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
ch := make(chan cdpMessage, 1)
|
||||
s.pending[id] = ch
|
||||
s.mu.Unlock()
|
||||
|
||||
payload := map[string]any{"id": id, "method": method}
|
||||
if params != nil {
|
||||
payload["params"] = params
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
wctx, cancel := context.WithTimeout(s.wctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if err := s.ws.Write(wctx, websocket.MessageText, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case m, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connexion CDP fermée")
|
||||
}
|
||||
if m.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", method, m.Error.Message)
|
||||
}
|
||||
return m.Result, nil
|
||||
case <-wctx.Done():
|
||||
return nil, fmt.Errorf("%s: délai dépassé", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.ws != nil {
|
||||
_ = s.ws.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
if s.wcancel != nil {
|
||||
s.wcancel()
|
||||
}
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
if s.userDir != "" {
|
||||
os.RemoveAll(s.userDir)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpGet renvoie la session courante, en (re)lançant Chrome au besoin. Un ping
|
||||
// léger valide qu'une session existante répond encore.
|
||||
func cdpGet() (*cdpSession, error) {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
if _, err := cdpCur.call("Runtime.evaluate", map[string]any{"expression": "1", "returnByValue": true}); err == nil {
|
||||
return cdpCur, nil
|
||||
}
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
s, err := cdpLaunch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdpCur = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpShutdown ferme la session navigateur si elle existe (appelé quand on coupe
|
||||
// le computer use, pour ne pas laisser un Chrome headless traîner).
|
||||
// sweepStaleCUDirs supprime les profils Chrome temporaires (loki-cu-*) de plus
|
||||
// d'une heure laissés dans le dossier temp — best-effort, silencieux.
|
||||
func sweepStaleCUDirs() {
|
||||
entries, err := os.ReadDir(os.TempDir())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.HasPrefix(e.Name(), "loki-cu-") {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
os.RemoveAll(filepath.Join(os.TempDir(), e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cdpShutdown() {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── évaluation JS ───────────────────────────────────────────────────────────
|
||||
|
||||
// cdpEval exécute une expression JS dans la page et renvoie sa valeur (JSON brut).
|
||||
func (s *cdpSession) eval(expr string) (json.RawMessage, error) {
|
||||
res, err := s.call("Runtime.evaluate", map[string]any{
|
||||
"expression": expr,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Result struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
} `json:"result"`
|
||||
ExceptionDetails *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"exceptionDetails"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &r)
|
||||
if r.ExceptionDetails != nil {
|
||||
return nil, fmt.Errorf("JS: %s", r.ExceptionDetails.Text)
|
||||
}
|
||||
return r.Result.Value, nil
|
||||
}
|
||||
|
||||
// ─── snapshot d'accessibilité (set-of-marks) ─────────────────────────────────
|
||||
|
||||
// cdpHelpersJS : fonctions partagées par le snapshot et browser_find, préfixées au
|
||||
// script évalué. Points clés :
|
||||
// - ajActionable ne garde que les éléments RÉELLEMENT cliquables/saisissables :
|
||||
// on écarte les conteneurs (list, listitem, banner, navigation, menu, region,
|
||||
// div/span muets…) qui polluaient la liste et embrouillaient le modèle.
|
||||
// - ajName enrichit un champ de formulaire avec son <label> (« Prénom » plutôt
|
||||
// que « firstName »), et NE RÉVÈLE PAS la valeur d'un champ mot de passe
|
||||
// (le secret ne doit pas repartir dans le contexte/l'export).
|
||||
const cdpHelpersJS = `
|
||||
function ajRole(el){var r=el.getAttribute('role');if(r)return r;var t=el.tagName.toLowerCase();if(t==='a')return 'link';if(t==='button')return 'button';if(t==='select')return 'combobox';if(t==='textarea')return 'textbox';if(t==='input'){var ty=(el.type||'text').toLowerCase();var m={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',reset:'button',file:'button',range:'slider',password:'password',search:'searchbox'};return m[ty]||'textbox';}return t;}
|
||||
function ajName(el){var t=el.tagName.toLowerCase();var lbl=el.getAttribute('aria-label')||'';if(!lbl){var lb=el.getAttribute('aria-labelledby');if(lb){var e2=document.getElementById(lb.split(' ')[0]);if(e2)lbl=e2.innerText||'';}}if(!lbl&&el.labels&&el.labels.length)lbl=el.labels[0].innerText||'';if(!lbl)lbl=el.getAttribute('placeholder')||el.getAttribute('title')||el.getAttribute('alt')||'';var val='';if((t==='input'||t==='textarea')&&el.type!=='password'&&el.type!=='hidden')val=el.value||'';var n=val?(lbl?(lbl+' = '+val):val):lbl;if(!n&&t!=='input'&&t!=='select'&&t!=='textarea')n=el.innerText||'';if(!n){var c=el.querySelector('img[alt],[aria-label],[title],svg title');if(c)n=c.getAttribute&&(c.getAttribute('alt')||c.getAttribute('aria-label')||c.getAttribute('title'))||c.textContent||'';}if(!n)n=el.getAttribute('name')||el.getAttribute('value')||'';return (n||'').replace(/\s+/g,' ').trim().slice(0,100);}
|
||||
function ajActionable(el){var t=el.tagName.toLowerCase();if(t==='a')return !!el.getAttribute('href');if(t==='button'||t==='input'||t==='select'||t==='textarea'||t==='summary')return el.type!=='hidden';if(el.isContentEditable)return true;if(el.getAttribute('onclick'))return true;var r=(el.getAttribute('role')||'').toLowerCase();return {button:1,link:1,checkbox:1,radio:1,tab:1,menuitem:1,menuitemcheckbox:1,menuitemradio:1,option:1,'switch':1,combobox:1,textbox:1,searchbox:1,slider:1,spinbutton:1}[r]===1;}
|
||||
function ajVis(el){var r=el.getBoundingClientRect();if(r.width<3||r.height<3)return false;var s=getComputedStyle(el);if(s.visibility==='hidden'||s.display==='none'||s.opacity==='0')return false;if(r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)return false;return true;}
|
||||
var ajSel='a[href],button,input,select,textarea,[role],[onclick],[tabindex],summary,[contenteditable="true"]';
|
||||
// Traversée du SHADOW DOM : les bandeaux cookies et composants web (usercentrics,
|
||||
// cookiebot…) vivent dans des shadow roots que querySelectorAll ne voit pas → le
|
||||
// modèle ne pouvait ni les lister ni les cliquer (test Lady Sushi). ajQueryAll /
|
||||
// ajByRef / ajClear descendent récursivement dans chaque shadowRoot ouvert.
|
||||
function ajQueryAll(){var out=[];function w(r){var e=r.querySelectorAll(ajSel);for(var i=0;i<e.length;i++)out.push(e[i]);var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);return out;}
|
||||
function ajByRef(ref){function f(r){var e=r.querySelector('[data-loki-ref="'+ref+'"]');if(e)return e;var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++)if(a[i].shadowRoot){var x=f(a[i].shadowRoot);if(x)return x;}return null;}return f(document);}
|
||||
function ajClear(){function w(r){var o=r.querySelectorAll('[data-loki-ref]');for(var i=0;i<o.length;i++)o[i].removeAttribute('data-loki-ref');var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);}
|
||||
`
|
||||
|
||||
// cdpSnapshotJS marque chaque élément ACTIONNABLE et VISIBLE d'un attribut
|
||||
// data-loki-ref numéroté, mémorise le centre de chacun dans window.__lokiRefs,
|
||||
// et renvoie {title,url,refs,above,below}. Compact volontairement.
|
||||
const cdpSnapshotJS = cdpHelpersJS + `(function(){
|
||||
ajClear();
|
||||
var els=ajQueryAll().filter(function(el){return ajActionable(el)&&ajVis(el);});
|
||||
var map={},refs=[],i=0;
|
||||
for(var k=0;k<els.length&&i<200;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
i++;var r=el.getBoundingClientRect();map[i]={x:r.left+r.width/2,y:r.top+r.height/2};
|
||||
el.setAttribute('data-loki-ref',String(i));
|
||||
refs.push('['+i+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
window.__lokiRefs=map;
|
||||
var de=document.documentElement;
|
||||
var sh=Math.max(de.scrollHeight,document.body?document.body.scrollHeight:0);
|
||||
return {title:document.title||'',url:location.href,refs:refs,
|
||||
above:Math.round(scrollY),below:Math.round(Math.max(0,sh-scrollY-innerHeight))};
|
||||
})()`
|
||||
|
||||
type cuSnap struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Refs []string `json:"refs"`
|
||||
Above int `json:"above"` // pixels défilables au-dessus de la vue
|
||||
Below int `json:"below"` // pixels défilables en dessous de la vue
|
||||
}
|
||||
|
||||
// snapshot lit l'arbre interactif courant et le formate pour le modèle.
|
||||
func (s *cdpSession) snapshot() (string, error) {
|
||||
val, err := s.eval(cdpSnapshotJS)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var snap cuSnap
|
||||
if err := json.Unmarshal(val, &snap); err != nil {
|
||||
return "", fmt.Errorf("snapshot illisible: %v", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n%s\n\n", snap.Title, snap.URL)
|
||||
if len(snap.Refs) == 0 {
|
||||
b.WriteString("(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%d élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n", len(snap.Refs))
|
||||
b.WriteString(strings.Join(snap.Refs, "\n"))
|
||||
}
|
||||
// Indice de défilement : la liste ne couvre QUE la partie visible. Sans ce
|
||||
// repère, le modèle croit la page épuisée après un scroll (3 éléments restants)
|
||||
// et se met à deviner des URLs au lieu de défiler (vécu sur le test IONOS).
|
||||
if snap.Below > 20 || snap.Above > 20 {
|
||||
b.WriteString("\n\n")
|
||||
if snap.Below > 20 {
|
||||
fmt.Fprintf(&b, "↓ encore ~%d px sous la vue — browser_scroll(down) pour révéler d'autres éléments. ", snap.Below)
|
||||
}
|
||||
if snap.Above > 20 {
|
||||
fmt.Fprintf(&b, "↑ ~%d px au-dessus — browser_scroll(up) pour y revenir.", snap.Above)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), " "), nil
|
||||
}
|
||||
|
||||
// snapshotDedup renvoie le snapshot courant, ou une ligne courte s'il est
|
||||
// RIGOUREUSEMENT identique au dernier renvoyé (clic sans effet, ré-ouverture de
|
||||
// la même page…). Évite de re-lister 20+ éléments pour rien — les numéros restent
|
||||
// valides puisque le DOM n'a pas changé. Économie de contexte pour petits modèles.
|
||||
func (s *cdpSession) snapshotDedup() (string, error) {
|
||||
full, err := s.snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if full == s.lastSnap {
|
||||
return "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", nil
|
||||
}
|
||||
s.lastSnap = full
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// cdpFindJS cherche, sur TOUTE la page (pas seulement le viewport), les éléments
|
||||
// interactifs dont le rôle ou le nom contient la requête, leur pose un
|
||||
// data-loki-ref numéroté, fait défiler le premier au centre, et renvoie la liste.
|
||||
// Sert à localiser un lien/bouton hors écran sans défiler à l'aveugle.
|
||||
// browser_find est NON DESTRUCTIF (contrairement au snapshot) : il n'efface PAS les
|
||||
// data-loki-ref existants — sinon les numéros du dernier snapshot deviendraient
|
||||
// invalides et un browser_click ensuite échouerait (« introuvable », vécu au test #11).
|
||||
// Il RÉUTILISE le ref d'un élément déjà numéroté, et n'attribue un NOUVEAU numéro
|
||||
// (au-delà du max courant) qu'aux éléments hors écran encore sans ref.
|
||||
const cdpFindJS = `function(q){
|
||||
q=(q||'').toLowerCase();
|
||||
var pool=ajQueryAll();
|
||||
var maxRef=0;for(var m=0;m<pool.length;m++){var v=parseInt(pool[m].getAttribute('data-loki-ref'),10)||0;if(v>maxRef)maxRef=v;}
|
||||
var els=pool.filter(ajActionable);
|
||||
var refs=[],count=0,first=null;
|
||||
for(var k=0;k<els.length&&count<60;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
if((rl+' '+nm).toLowerCase().indexOf(q)<0)continue;
|
||||
var st=getComputedStyle(el);if(st.display==='none'||st.visibility==='hidden')continue;
|
||||
var r=el.getBoundingClientRect();if(r.width<1&&r.height<1)continue;
|
||||
var ref=el.getAttribute('data-loki-ref');
|
||||
if(!ref){maxRef++;ref=String(maxRef);el.setAttribute('data-loki-ref',ref);}
|
||||
if(!first)first=el;count++;
|
||||
refs.push('['+ref+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
if(first)first.scrollIntoView({block:'center'});
|
||||
return {refs:refs};
|
||||
}`
|
||||
|
||||
func (s *cdpSession) find(query string) (string, error) {
|
||||
qb, _ := json.Marshal(query)
|
||||
val, err := s.eval(cdpHelpersJS + "(" + cdpFindJS + ")(" + string(qb) + ")")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var r struct {
|
||||
Refs []string `json:"refs"`
|
||||
}
|
||||
if err := json.Unmarshal(val, &r); err != nil {
|
||||
return "", fmt.Errorf("recherche illisible: %v", err)
|
||||
}
|
||||
if len(r.Refs) == 0 {
|
||||
return fmt.Sprintf("Aucun élément contenant « %s » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien).", query), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d élément(s) contenant « %s » (le 1er est amené dans la vue) — clique par numéro :\n", len(r.Refs), query)
|
||||
b.WriteString(strings.Join(r.Refs, "\n"))
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ─── actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// waitLoad attend (au mieux) que la page ait fini de charger après une navigation,
|
||||
// puis que le DOM se STABILISE (contenu rendu en AJAX après readyState=complete).
|
||||
func (s *cdpSession) waitLoad() {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
val, err := s.eval(`document.readyState`)
|
||||
if err == nil && strings.Trim(string(val), `"`) == "complete" {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
s.settle()
|
||||
}
|
||||
|
||||
// settle attend l'INACTIVITÉ RÉSEAU : sur une SPA, readyState passe à « complete »
|
||||
// avant que le contenu chargé en AJAX (résultats de recherche, étape de tunnel…)
|
||||
// ne soit arrivé. On rend la main dès qu'il n'y a plus de requête en vol depuis
|
||||
// ~600 ms, borné à 6 s. Ça supprime le « bash sleep » que le modèle bricolait
|
||||
// avant un browser_snapshot (vécu au test IONOS #10).
|
||||
func (s *cdpSession) settle() {
|
||||
const quiet = 600 * time.Millisecond
|
||||
deadline := time.Now().Add(6 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.netMu.Lock()
|
||||
inflight := s.inflight
|
||||
since := time.Since(s.lastNet)
|
||||
s.netMu.Unlock()
|
||||
if inflight <= 0 && since >= quiet {
|
||||
return
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) navigate(url string) error {
|
||||
// Toute navigation = nouvelle page : on oublie le dernier snapshot pour que le
|
||||
// browser_open qui suit renvoie la page COMPLÈTE, jamais « (page inchangée) »
|
||||
// (piège quand la session persiste d'un test à l'autre, vécu sur Lady Sushi).
|
||||
s.lastSnap = ""
|
||||
// « back » / « forward » : navigation dans l'historique (récupération d'erreur),
|
||||
// pas une URL — sinon on en ferait « https://back ».
|
||||
switch strings.ToLower(strings.TrimSpace(url)) {
|
||||
case "back":
|
||||
_, err := s.eval("history.back();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
case "forward":
|
||||
_, err := s.eval("history.forward();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(url, "://") {
|
||||
url = "https://" + url
|
||||
}
|
||||
res, err := s.call("Page.navigate", map[string]any{"url": url})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Page.navigate signale l'échec réseau dans errorText (DNS, connexion refusée…) ;
|
||||
// et une page d'erreur Chrome a une location « chrome-error:// ». Dans les deux
|
||||
// cas on renvoie une erreur EXPLICITE plutôt qu'un faux snapshot « Actualiser »
|
||||
// que le modèle re-tentait en boucle (vécu : account.ionos.com ré-ouvert 2×).
|
||||
var nr struct {
|
||||
ErrorText string `json:"errorText"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &nr)
|
||||
s.waitLoad()
|
||||
loc, _ := s.eval("location.href")
|
||||
// ERR_ABORTED est bénin (redirection/téléchargement) : on ne le traite pas comme
|
||||
// un échec. Le signal fiable est une location « chrome-error:// ».
|
||||
failed := strings.Contains(string(loc), "chrome-error")
|
||||
if nr.ErrorText != "" && nr.ErrorText != "net::ERR_ABORTED" {
|
||||
failed = true
|
||||
}
|
||||
if failed {
|
||||
reason := nr.ErrorText
|
||||
if reason == "" {
|
||||
reason = "page injoignable"
|
||||
}
|
||||
return fmt.Errorf("chargement de %s échoué (%s) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", url, reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refCenter renvoie les coordonnées viewport (fraîches) d'un élément marqué, en
|
||||
// le faisant d'abord défiler au centre.
|
||||
func (s *cdpSession) refCenter(ref int) (float64, float64, error) {
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(){var el=ajByRef(%d);if(!el){return null;}el.scrollIntoView({block:'center',inline:'center'});var r=el.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`, ref)
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if string(val) == "null" || len(val) == 0 {
|
||||
return 0, 0, fmt.Errorf("élément [%d] introuvable — refais un browser_snapshot (la page a peut-être changé)", ref)
|
||||
}
|
||||
var p struct{ X, Y float64 }
|
||||
if err := json.Unmarshal(val, &p); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return p.X, p.Y, nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) clickRef(ref int) error {
|
||||
x, y, err := s.refCenter(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clickXY clique à des coordonnées pixel du VIEWPORT (lues sur le screenshot).
|
||||
// Seul moyen d'atteindre ce que le DOM ne voit pas : un bandeau de consentement
|
||||
// dans un iframe cross-origin, un canvas, une carte (vécu sur Lady Sushi).
|
||||
func (s *cdpSession) clickXY(x, y float64) error {
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) typeText(text string) error {
|
||||
_, err := s.call("Input.insertText", map[string]any{"text": text})
|
||||
return err
|
||||
}
|
||||
|
||||
// typeInto écrit dans le champ [ref]. Cas spécial des listes déroulantes NATIVES
|
||||
// (<select>, rôle « combobox ») : leur menu s'ouvre en natif hors du DOM, donc
|
||||
// ses options ne sont pas cliquables dans le snapshot (galère du test IONOS #14).
|
||||
// Ici on SÉLECTIONNE directement l'option dont le texte contient la valeur, et on
|
||||
// émet input/change. Sinon on focus+sélectionne le contenu et on laisse insertText
|
||||
// remplacer (comportement texte normal).
|
||||
func (s *cdpSession) typeInto(ref int, text string) error {
|
||||
vb, _ := json.Marshal(text)
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(ref,val){
|
||||
var el=ajByRef(ref);
|
||||
if(!el)return 'NOTFOUND';
|
||||
el.scrollIntoView({block:'center'});
|
||||
if(el.tagName.toLowerCase()==='select'){
|
||||
var o=el.options,lv=val.toLowerCase(),k=-1;
|
||||
for(var i=0;i<o.length;i++){var tx=(o[i].text||'').toLowerCase();if(tx===lv||tx.indexOf(lv)>=0||(o[i].value||'').toLowerCase()===lv){k=i;break;}}
|
||||
if(k<0)return 'NOOPT';
|
||||
el.selectedIndex=k;el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
return 'SELECT';
|
||||
}
|
||||
el.focus();
|
||||
try{if(typeof el.select==='function')el.select();else if(window.getSelection){var r=document.createRange();r.selectNodeContents(el);var s=getSelection();s.removeAllRanges();s.addRange(r);}}catch(e){}
|
||||
return 'TEXT';
|
||||
})(%d,%s)`, ref, string(vb))
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.Trim(string(val), `"`) {
|
||||
case "NOTFOUND":
|
||||
return fmt.Errorf("champ [%d] introuvable — refais un browser_snapshot", ref)
|
||||
case "NOOPT":
|
||||
return fmt.Errorf("aucune option de la liste [%d] ne correspond à « %s » — fais un browser_screenshot pour voir les choix", ref, text)
|
||||
case "SELECT":
|
||||
return nil // liste déroulante réglée, rien à taper
|
||||
default: // TEXT
|
||||
return s.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpKeys mappe les touches nommées vers leur description CDP.
|
||||
var cdpKeys = map[string]struct {
|
||||
key, code string
|
||||
vk int
|
||||
}{
|
||||
"enter": {"Enter", "Enter", 13},
|
||||
"tab": {"Tab", "Tab", 9},
|
||||
"backspace": {"Backspace", "Backspace", 8},
|
||||
"escape": {"Escape", "Escape", 27},
|
||||
"esc": {"Escape", "Escape", 27},
|
||||
"delete": {"Delete", "Delete", 46},
|
||||
"up": {"ArrowUp", "ArrowUp", 38},
|
||||
"down": {"ArrowDown", "ArrowDown", 40},
|
||||
"left": {"ArrowLeft", "ArrowLeft", 37},
|
||||
"right": {"ArrowRight", "ArrowRight", 39},
|
||||
"home": {"Home", "Home", 36},
|
||||
"end": {"End", "End", 35},
|
||||
"pageup": {"PageUp", "PageUp", 33},
|
||||
"pagedown": {"PageDown", "PageDown", 34},
|
||||
}
|
||||
|
||||
func (s *cdpSession) pressKey(name string) error {
|
||||
k, ok := cdpKeys[strings.ToLower(strings.TrimSpace(name))]
|
||||
if !ok {
|
||||
return fmt.Errorf("touche inconnue « %s » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown", name)
|
||||
}
|
||||
for _, typ := range []string{"rawKeyDown", "keyUp"} {
|
||||
if _, err := s.call("Input.dispatchKeyEvent", map[string]any{
|
||||
"type": typ, "key": k.key, "code": k.code,
|
||||
"windowsVirtualKeyCode": k.vk, "nativeVirtualKeyCode": k.vk,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) scroll(dir string) error {
|
||||
dy, dx := 0, 0
|
||||
switch strings.ToLower(strings.TrimSpace(dir)) {
|
||||
case "down", "":
|
||||
dy = 600
|
||||
case "up":
|
||||
dy = -600
|
||||
case "right":
|
||||
dx = 600
|
||||
case "left":
|
||||
dx = -600
|
||||
default:
|
||||
return fmt.Errorf("direction inconnue « %s » (up, down, left, right)", dir)
|
||||
}
|
||||
_, err := s.eval(fmt.Sprintf("window.scrollBy(%d,%d);'ok'", dx, dy))
|
||||
if err == nil {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// screenshot capture le viewport en PNG (octets décodés).
|
||||
func (s *cdpSession) screenshot() ([]byte, error) {
|
||||
res, err := s.call("Page.captureScreenshot", map[string]any{"format": "png"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(r.Data)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCDPSmoke pilote un vrai Chrome de bout en bout : lancement, navigation,
|
||||
// snapshot d'accessibilité, click, screenshot. Coûteux et dépendant d'un
|
||||
// navigateur installé → ne tourne QUE si LOKI_CU_SMOKE=1.
|
||||
func TestCDPSmoke(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if !strings.Contains(strings.ToLower(snap), "example") {
|
||||
t.Errorf("titre 'example' attendu dans le snapshot, absent")
|
||||
}
|
||||
if !strings.Contains(snap, "link") {
|
||||
t.Errorf("le lien 'More information' devrait apparaître comme [n] link")
|
||||
}
|
||||
// browser_find doit retrouver le lien par son texte.
|
||||
found, err := s.find("more")
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(found), "link") {
|
||||
t.Errorf("browser_find('more') devrait trouver le lien, got: %s", found)
|
||||
}
|
||||
// Le screenshot doit produire des octets PNG non vides.
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
t.Fatalf("screenshot: %v", err)
|
||||
}
|
||||
if len(png) < 100 {
|
||||
t.Errorf("screenshot suspicieusement petit: %d octets", len(png))
|
||||
}
|
||||
t.Logf("screenshot: %d octets", len(png))
|
||||
}
|
||||
|
||||
// TestCDPTypeReplacesField vérifie le fix du test IONOS #4 : browser_type(ref) cible le
|
||||
// BON champ et REMPLACE son contenu, même avec deux champs et une valeur existante.
|
||||
func TestCDPTypeReplacesField(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Deux champs texte injectés dans la page courante ; le 2e contient « ancien ».
|
||||
if _, err := s.eval(`document.body.innerHTML='<input id=a placeholder=prenom><input id=b value=ancien>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Retrouve le numéro du 2e champ (celui à remplacer).
|
||||
val, err := s.eval(`document.querySelector('#b').getAttribute('data-loki-ref')`)
|
||||
if err != nil {
|
||||
t.Fatalf("eval ref: %v", err)
|
||||
}
|
||||
ref, _ := strconv.Atoi(strings.Trim(string(val), `"`))
|
||||
if ref == 0 {
|
||||
t.Fatalf("pas de ref sur #b (snapshot: %s)", val)
|
||||
}
|
||||
if err := s.typeInto(ref, "nouveau"); err != nil {
|
||||
t.Fatalf("typeInto: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#b').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "nouveau" {
|
||||
t.Errorf("champ #b = %q, attendu \"nouveau\" (remplacement raté)", v)
|
||||
}
|
||||
// Le premier champ ne doit PAS avoir été touché.
|
||||
got2, _ := s.eval(`document.querySelector('#a').value`)
|
||||
if v := strings.Trim(string(got2), `"`); v != "" {
|
||||
t.Errorf("champ #a = %q, attendu vide (mauvais ciblage)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotHygiene vérifie que le snapshot (1) masque la valeur d'un champ
|
||||
// mot de passe et (2) ne liste pas les conteneurs non-actionnables (un <ul>/<li>).
|
||||
func TestCDPSnapshotHygiene(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
inject := `document.body.innerHTML='` +
|
||||
`<label for=pw>Mot de passe</label><input id=pw type=password value=SECRET123>` +
|
||||
`<label for=em>Email</label><input id=em type=text value="a@b.co">` +
|
||||
`<ul><li>bloc conteneur</li></ul>` +
|
||||
`<a href="#x">un lien</a>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if strings.Contains(snap, "SECRET123") {
|
||||
t.Error("la valeur du champ mot de passe ne doit PAS apparaître dans le snapshot")
|
||||
}
|
||||
if strings.Contains(snap, "bloc conteneur") {
|
||||
t.Error("un <li> conteneur ne doit pas figurer dans la liste des éléments actionnables")
|
||||
}
|
||||
if !strings.Contains(snap, "un lien") {
|
||||
t.Error("le lien actionnable devrait figurer dans le snapshot")
|
||||
}
|
||||
// Le champ mot de passe lui-même reste listé (par son label), juste sans valeur.
|
||||
if !strings.Contains(strings.ToLower(snap), "mot de passe") {
|
||||
t.Error("le champ mot de passe devrait apparaître par son label")
|
||||
}
|
||||
// Un champ texte non-secret DOIT montrer sa valeur (observabilité du formulaire).
|
||||
if !strings.Contains(snap, "a@b.co") {
|
||||
t.Error("la valeur d'un champ texte non-secret devrait apparaître dans le snapshot")
|
||||
}
|
||||
// Un <input type=text> doit être annoncé « textbox », pas « text » (qui ressemble
|
||||
// à du texte statique et faisait croire au modèle que c'était une étiquette).
|
||||
if !strings.Contains(snap, "textbox") {
|
||||
t.Error("un champ de saisie devrait avoir le rôle 'textbox'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPRefUniqueness reproduit le bug du test IONOS #9 : un snapshot doit
|
||||
// EFFACER les anciens data-loki-ref, sinon deux éléments partagent un numéro et
|
||||
// browser_type/browser_click visent le mauvais champ (querySelector prend le 1er du DOM).
|
||||
func TestCDPRefUniqueness(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>A</a><a href=#>B</a><a href=#>C</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Simule un attribut périmé laissé par un snapshot antérieur sur un AUTRE élément.
|
||||
if _, err := s.eval(`document.querySelectorAll('a')[2].setAttribute('data-loki-ref','1');'ok'`); err != nil {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
// Un nouveau snapshot doit repartir propre : chaque numéro sur UN SEUL élément.
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot2: %v", err)
|
||||
}
|
||||
dup, _ := s.eval(`document.querySelectorAll('[data-loki-ref="1"]').length`)
|
||||
if strings.TrimSpace(string(dup)) != "1" {
|
||||
t.Errorf("data-loki-ref=\"1\" devrait être unique, trouvé %s occurrence(s)", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSettleWaitsAsync vérifie que settle() attend le contenu injecté en
|
||||
// asynchrone (cas du test #10 où le modèle devait « bash sleep » avant snapshot).
|
||||
func TestCDPSettleWaitsAsync(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Page réelle (réseau réel), puis on déclenche un fetch AJAX qui n'ajoute son
|
||||
// lien qu'une fois la requête terminée. settle() doit attendre l'inactivité
|
||||
// réseau et donc voir le lien.
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
if _, err := s.eval(`fetch(location.href,{cache:'no-store'}).then(function(){var a=document.createElement('a');a.href='#';a.textContent='AjaxDone';document.body.appendChild(a);});'ok'`); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond) // laisse requestWillBeSent s'enregistrer
|
||||
s.settle() // doit attendre la fin du fetch
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "AjaxDone") {
|
||||
t.Errorf("settle() aurait dû attendre la fin du fetch AJAX; snapshot:\n%s", snap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPTypeSelectsOption vérifie le fix du test #14 : browser_type sur un <select>
|
||||
// choisit l'option par son texte au lieu de taper des caractères.
|
||||
func TestCDPTypeSelectsOption(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<select id=civ><option value="">--</option><option>Madame</option><option>Monsieur</option><option>Autre</option></select>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
ref, _ := s.eval(`+document.querySelector('#civ').getAttribute('data-loki-ref')`)
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(string(ref)))
|
||||
if n == 0 {
|
||||
t.Fatalf("pas de ref sur le select (%s)", ref)
|
||||
}
|
||||
if err := s.typeInto(n, "Monsieur"); err != nil {
|
||||
t.Fatalf("typeInto select: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#civ').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "Monsieur" {
|
||||
t.Errorf("le select devrait valoir « Monsieur », got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotDedup vérifie qu'un état identique renvoie une ligne courte au
|
||||
// lieu de re-lister tous les éléments (économie de contexte, test #12).
|
||||
func TestCDPSnapshotDedup(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Un lien</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
first, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup1: %v", err)
|
||||
}
|
||||
if !strings.Contains(first, "Un lien") {
|
||||
t.Fatalf("1er snapshot devrait lister le lien, got: %s", first)
|
||||
}
|
||||
second, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup2: %v", err)
|
||||
}
|
||||
if strings.Contains(second, "Un lien") || !strings.Contains(second, "inchang") {
|
||||
t.Errorf("2e snapshot identique devrait être compact (inchangé), got: %s", second)
|
||||
}
|
||||
// Après un vrai changement, on redonne la liste complète.
|
||||
if _, err := s.eval(`document.body.innerHTML+='<a href=#>Autre</a>';'ok'`); err != nil {
|
||||
t.Fatalf("change: %v", err)
|
||||
}
|
||||
third, _ := s.snapshotDedup()
|
||||
if !strings.Contains(third, "Autre") {
|
||||
t.Errorf("après changement, le snapshot complet devrait revenir, got: %s", third)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPFindKeepsRefs vérifie le fix du test #11 : browser_find ne doit PAS effacer
|
||||
// les data-loki-ref du dernier snapshot (sinon un browser_click ensuite échoue).
|
||||
func TestCDPFindKeepsRefs(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Alpha</a><a href=#>Beta</a><a href=#>Connexion</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
before, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
// Recherche d'un AUTRE élément : ne doit pas toucher au ref de « Connexion ».
|
||||
if _, err := s.find("Alpha"); err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
after, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
if string(before) != string(after) || strings.Trim(string(after), `"`) == "" {
|
||||
t.Errorf("browser_find a modifié/effacé le ref de « Connexion » : avant=%s après=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClickXY vérifie le clic par coordonnées (pour iframe cross-origin/canvas).
|
||||
func TestCDPClickXY(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un bouton à une position connue qui note son propre clic.
|
||||
inject := `document.body.innerHTML='<button id=b style="position:fixed;left:40px;top:30px;width:120px;height:40px">X</button>';` +
|
||||
`window.__hit=false;document.getElementById('b').addEventListener('click',function(){window.__hit=true;});'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if err := s.clickXY(100, 50); err != nil { // dans le rectangle du bouton
|
||||
t.Fatalf("clickXY: %v", err)
|
||||
}
|
||||
hit, _ := s.eval(`window.__hit`)
|
||||
if strings.TrimSpace(string(hit)) != "true" {
|
||||
t.Errorf("le clic par coordonnées aurait dû atteindre le bouton, got %s", hit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPShadowDOM vérifie le fix du test Lady Sushi : les éléments d'un shadow
|
||||
// DOM (bandeaux cookies, web components) sont vus par le snapshot ET cliquables.
|
||||
func TestCDPShadowDOM(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un host avec un bouton « TOUT REFUSER » DANS un shadow root ouvert.
|
||||
inject := `document.body.innerHTML='<div id=host></div>';` +
|
||||
`var sr=document.getElementById('host').attachShadow({mode:'open'});` +
|
||||
`sr.innerHTML='<button id=b>TOUT REFUSER</button>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "TOUT REFUSER") {
|
||||
t.Fatalf("le bouton du shadow DOM devrait apparaître dans le snapshot; got:\n%s", snap)
|
||||
}
|
||||
// browser_find doit aussi le trouver, et browser_click le viser (via ajByRef).
|
||||
found, err := s.find("refuser")
|
||||
if err != nil || !strings.Contains(strings.ToLower(found), "refuser") {
|
||||
t.Errorf("find n'a pas trouvé le bouton shadow: %v / %s", err, found)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPNavigateError vérifie qu'une URL injoignable renvoie une ERREUR claire
|
||||
// (et pas un faux snapshot avec un bouton « Actualiser » que le modèle re-tente).
|
||||
func TestCDPNavigateError(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
err = s.navigate("https://nonexistent-host-loki-test-99999.invalid")
|
||||
if err == nil {
|
||||
t.Error("une URL injoignable devrait renvoyer une erreur, pas nil")
|
||||
} else {
|
||||
t.Logf("erreur attendue: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestDedupableTool verrouille le fix du test IONOS : les outils à état vivant /
|
||||
// à image (cu_*, see_image, bash) ne doivent JAMAIS être dédupliqués, sinon un
|
||||
// second appel identique renvoie « [déjà fait] » sans rejouer l'action ni
|
||||
// l'image, et l'IA boucle.
|
||||
func TestDedupableTool(t *testing.T) {
|
||||
never := []string{"bash", "see_image", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_key", "browser_scroll", "browser_screenshot"}
|
||||
for _, n := range never {
|
||||
if dedupableTool(n) {
|
||||
t.Errorf("%s ne doit PAS être déduplicable", n)
|
||||
}
|
||||
}
|
||||
always := []string{"write", "edit", "mem_add", "web_open"}
|
||||
for _, n := range always {
|
||||
if !dedupableTool(n) {
|
||||
t.Errorf("%s devrait rester déduplicable", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package loki
|
||||
|
||||
// computer_use.go — le « computer use » : donner à Jean la capacité d'agir sur
|
||||
// une interface graphique de la machine hôte. Étape 1 : le WEB, via un Chromium
|
||||
// piloté en CDP (computer_cdp.go). Le modèle lit l'arbre d'accessibilité (des
|
||||
// éléments interactifs NUMÉROTÉS, façon set-of-marks) et agit par numéro —
|
||||
// aucune vision requise, ce qui marche même avec de petits modèles texte à
|
||||
// faible contexte. browser_screenshot n'est proposé que si la vision est active, pour
|
||||
// les cas sans a11y (canvas, jeux).
|
||||
//
|
||||
// C'est un axe de capacité indépendant, sous le mode agent (comme l'accès
|
||||
// internet) : un outil qui pilote un navigateur exécute des actions réelles, donc
|
||||
// même niveau de confiance que bash. Interrupteur : clé bkState "computer".
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func computerUseEnabled() bool { return getBool(bkState, "computer") }
|
||||
|
||||
func setComputerUseEnabled(on bool) error { return putBool(bkState, "computer", on) }
|
||||
|
||||
// cuMaxOutput borne ce qu'un appel d'outil computer use injecte dans le contexte
|
||||
// (aligné sur le web et le shell : 8000).
|
||||
const cuMaxOutput = 8000
|
||||
|
||||
func capCUOutput(s string) string {
|
||||
if r := []rune(s); len(r) > cuMaxOutput {
|
||||
return string(r[:cuMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── schémas d'outils (annoncés au modèle) ───────────────────────────────────
|
||||
|
||||
func cuOpenTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_open",
|
||||
Description: "Open a URL in the controlled browser and return the page's interactive elements, each numbered [n]. Start here. Pass 'back' or 'forward' instead of a URL to move through history.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"url": map[string]any{"type": "string", "description": "URL to open, or 'back' / 'forward'"}},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuSnapshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_snapshot",
|
||||
Description: "Re-read the current page's interactive elements (numbered). Use it after the page changes to get fresh [n] refs.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click",
|
||||
Description: "Click the element with the given number from the latest snapshot. Returns the page state afterwards.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"ref": map[string]any{"type": "integer", "description": "Element number [n]"}},
|
||||
"required": []string{"ref"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuTypeTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_type",
|
||||
Description: "Type text into a field. ALWAYS give 'ref' (the field's number) so it targets the right field and REPLACES its current content — no need to browser_click the field first. For a dropdown/select (role 'combobox'), pass the option's text and it picks that option directly (don't click it open). Without a ref it types into whatever is focused, which is unreliable.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to type"},
|
||||
"ref": map[string]any{"type": "integer", "description": "Optional: field number [n] to focus first"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuKeyTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_key",
|
||||
Description: "Press a special key: enter, tab, backspace, escape, delete, up, down, left, right, home, end, pageup, pagedown.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"key": map[string]any{"type": "string", "description": "Key name"}},
|
||||
"required": []string{"key"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuFindTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_find",
|
||||
Description: "Find interactive elements ANYWHERE on the page (not just the visible part) whose label contains the text, and scroll the first into view. Use it to locate an off-screen link or button (e.g. 'créer un compte', 'panier') instead of scrolling blindly or guessing URLs.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"text": map[string]any{"type": "string", "description": "Text to look for in element labels"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScrollTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_scroll",
|
||||
Description: "Scroll the page (up, down, left, right), then return the now-visible interactive elements.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"direction": map[string]any{"type": "string", "description": "up, down, left or right"}},
|
||||
"required": []string{"direction"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickXYTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click_xy",
|
||||
Description: fmt.Sprintf("Click at pixel coordinates (x from the left, y from the top) read off the latest browser_screenshot — %dx%d pixels, 1:1 with the page, with a coordinate grid drawn every 100px to read x/y precisely. Use it ONLY for something the numbered elements can't reach: a cookie/consent banner inside a cross-origin iframe, a canvas, a map. Otherwise always prefer browser_click(ref).", cuViewW, cuViewH),
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"x": map[string]any{"type": "number", "description": "pixels from the left edge"},
|
||||
"y": map[string]any{"type": "number", "description": "pixels from the top edge"},
|
||||
},
|
||||
"required": []string{"x", "y"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScreenshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_screenshot",
|
||||
Description: "Capture the current page as an image. It is shown to the user immediately AND saved to your working folder; the result gives the filename to link with Markdown if they want the file. Use it to see a visual layout the numbered elements don't convey, or when the user asks for a screenshot — never hunt the disk for it.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
// computerUseTools est la liste annoncée au modèle quand le computer use est
|
||||
// actif. browser_screenshot n'y figure que si la vision est réellement active.
|
||||
func computerUseTools() []Tool {
|
||||
tools := []Tool{cuOpenTool(), cuSnapshotTool(), cuFindTool(), cuClickTool(), cuTypeTool(), cuKeyTool(), cuScrollTool()}
|
||||
// Vision active : la capture d'écran ET le clic par coordonnées (qui n'a de sens
|
||||
// que si le modèle peut lire l'image pour viser).
|
||||
if visionEnabled() {
|
||||
tools = append(tools, cuScreenshotTool(), cuClickXYTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// cuPromptLine est la consigne d'usage ajoutée au prompt système quand le
|
||||
// computer use est actif (l'ordre d'appel que les schémas isolés ne disent pas).
|
||||
func cuPromptLine() string {
|
||||
line := "\nComputer use (a browser you control on this machine): browser_open a URL, then act on the NUMBERED elements — browser_click(ref), browser_type(text, ref), browser_key, browser_scroll. Each action returns the fresh numbered elements; after the page changes, trust the new numbers, not old ones. The list shows only what's in view: to reach an off-screen link or button (e.g. 'créer un compte'), use browser_find(text) to jump straight to it rather than scrolling blindly or, worse, guessing URLs — inventing paths mostly 404s. If a page shows an error (404, 'introuvable'), browser_open goes 'back' or try another link. Prefer the numbered elements over screenshots."
|
||||
if visionEnabled() {
|
||||
line += " Use browser_screenshot when the numbers aren't enough (canvas, visual layout). If a button is visible but NOT in the numbered list (e.g. a cookie banner inside an iframe), browser_screenshot then browser_click_xy(x,y) at its pixel position."
|
||||
}
|
||||
line += "\n"
|
||||
return line
|
||||
}
|
||||
|
||||
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
|
||||
|
||||
func toolCUOpen(args map[string]any) string {
|
||||
url, _ := args["url"].(string)
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return "[erreur] url manquante"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.navigate(url); err != nil {
|
||||
return "[erreur] navigation : " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUSnapshot() string {
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUFind(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "[erreur] texte de recherche manquant"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
res, err := s.find(text)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func toolCUClick(args map[string]any) string {
|
||||
ref, ok := intArg(args, "ref")
|
||||
if !ok {
|
||||
return "[erreur] ref manquant (numéro de l'élément)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickRef(ref); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] cliqué [%d]\n\n%s", ref, snap)
|
||||
}
|
||||
|
||||
func toolCUType(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if ref, ok := intArg(args, "ref"); ok {
|
||||
if err := s.typeInto(ref, text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
} else if err := s.typeText(text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] saisi « %s »\n\n%s", text, snap)
|
||||
}
|
||||
|
||||
func toolCUKey(args map[string]any) string {
|
||||
key, _ := args["key"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.pressKey(key); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] touche %s\n\n%s", key, snap)
|
||||
}
|
||||
|
||||
func toolCUClickXY(args map[string]any) string {
|
||||
x, okx := floatArg(args, "x")
|
||||
y, oky := floatArg(args, "y")
|
||||
if !okx || !oky {
|
||||
return "[erreur] coordonnées x et y requises (en pixels du screenshot)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickXY(x, y); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] clic en (%.0f, %.0f)\n\n%s", x, y, snap)
|
||||
}
|
||||
|
||||
func toolCUScroll(args map[string]any) string {
|
||||
dir, _ := args["direction"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.scroll(dir); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return snap
|
||||
}
|
||||
|
||||
// toolCUScreenshot renvoie (accusé texte, partie image_url) comme see_image :
|
||||
// l'image est réinjectée dans un message utilisateur multimodal par l'appelant.
|
||||
//
|
||||
// La capture PLEINE RÉSOLUTION est aussi ENREGISTRÉE dans le dossier de travail :
|
||||
// sans ça, le modèle qui reçoit « envoie-moi une capture » partait chercher un
|
||||
// PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour
|
||||
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
|
||||
func toolCUScreenshot() (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
// Enregistre le PNG plein format dans le workspace (chemin RELATIF renvoyé au
|
||||
// modèle : un lien Markdown [capture](nom.png) est servi par /api/chat/file).
|
||||
saved := ""
|
||||
name := "capture-" + time.Now().Format("20060102-150405") + ".png"
|
||||
if ws := agentWorkspace(); ws != "" {
|
||||
if err := os.WriteFile(filepath.Join(ws, name), png, 0o644); err == nil {
|
||||
saved = name
|
||||
}
|
||||
}
|
||||
// L'image du MODÈLE porte une GRILLE de coordonnées (lignes graduées tous les
|
||||
// 100 px) pour qu'il lise x/y avec précision ; celle sauvegardée pour l'utilisateur
|
||||
// (plus haut, `png`) reste PROPRE, sans grille.
|
||||
b, mime := prepareImageForModel(overlayGrid(png), "image/png")
|
||||
// ⚠️ TOI (le modèle) vois l'image ci-dessous, mais l'utilisateur NE la voit PAS
|
||||
// automatiquement dans le chat. Pour la lui afficher, il faut l'insérer en IMAGE
|
||||
// Markdown  — un simple lien [texte](fichier) ne fait qu'un bouton
|
||||
// de téléchargement, pas d'aperçu.
|
||||
msg := fmt.Sprintf("[ok] capture prise (tu la vois ci-dessous, %dx%d px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille.", cuViewW, cuViewH)
|
||||
if saved != "" {
|
||||
msg += " Enregistrée sous « " + saved + " ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : . Un simple lien [capture](" + saved + ") ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci."
|
||||
}
|
||||
return msg, imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// intArg extrait un entier d'un argument d'outil (le JSON décode les nombres en
|
||||
// float64).
|
||||
func intArg(args map[string]any, key string) (int, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// floatArg extrait un nombre d'un argument d'outil (coordonnées de clic).
|
||||
func floatArg(args map[string]any, key string) (float64, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ─── CLI : loki computer [on|off|status] ────────────────────────────────────
|
||||
|
||||
func cmdComputer(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
if err := setComputerUseEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := " contrôle du navigateur activé — l'IA pilote un navigateur (browser_open/browser_click/browser_type/…) si le mode agent est actif"
|
||||
if chromePath() == "" {
|
||||
msg += "\n" + red("[!]") + " aucun Chrome/Chromium/Edge détecté — installe-le ou définis LOKI_CHROME=<chemin>"
|
||||
}
|
||||
fmt.Println(green("[ok]") + msg)
|
||||
case "off":
|
||||
if err := setComputerUseEnabled(false); err != nil {
|
||||
return err
|
||||
}
|
||||
cdpShutdown()
|
||||
fmt.Println(green("[ok]") + " contrôle du navigateur désactivé")
|
||||
case "", "status":
|
||||
state := dim("off")
|
||||
if computerUseEnabled() {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Contrôle du navigateur"), state)
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
fmt.Printf(" navigateur : %s — installe Chrome/Chromium/Edge ou LOKI_CHROME=<chemin>\n", red("introuvable"))
|
||||
} else {
|
||||
fmt.Printf(" navigateur : %s\n", bold(bin))
|
||||
}
|
||||
fmt.Printf(" outils : browser_open, browser_snapshot, browser_find, browser_click, browser_type, browser_key, browser_scroll")
|
||||
if visionEnabled() {
|
||||
fmt.Printf(", browser_screenshot, browser_click_xy")
|
||||
}
|
||||
fmt.Println()
|
||||
default:
|
||||
return fmt.Errorf("usage: loki computer [on|off|status]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -202,6 +202,9 @@ type Caps struct {
|
||||
// "verifier" (passe de vérification, seule autorisée à marquer un critère
|
||||
// passed), "explorer", "code-reviewer". Voir code_roles.go.
|
||||
Role string
|
||||
// ComputerUse = pilotage du navigateur du conteneur (outils browser_*).
|
||||
// Requiert aussi Agent (mêmes actions réelles que bash). Voir computer_use.go.
|
||||
ComputerUse bool
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
@@ -219,7 +222,7 @@ func globalCaps() Caps {
|
||||
if !agent {
|
||||
return Caps{Agent: false, Internet: false, Mem: MemOff}
|
||||
}
|
||||
return Caps{Agent: true, Internet: internetEnabled() && crawlReachable(), Mem: memMode()}
|
||||
return Caps{Agent: true, Internet: internetEnabled() && crawlReachable(), Mem: memMode(), ComputerUse: computerUseEnabled()}
|
||||
}
|
||||
|
||||
// InjectSkills prepends context system messages to msgs: the decisive-agent
|
||||
@@ -369,6 +372,14 @@ func EnabledTools(caps Caps) []Tool {
|
||||
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
|
||||
tools = append(tools, webScreenshotTool())
|
||||
}
|
||||
// Pilotage de navigateur (browser_*) : l'IA ouvre une page, lit ses éléments
|
||||
// interactifs NUMÉROTÉS et agit par numéro. Ce sont des actions réelles sur
|
||||
// le web (cliquer, taper, valider un formulaire) : même niveau de confiance
|
||||
// que bash, donc mode agent requis, plus son propre interrupteur (Réglages).
|
||||
// Voir computer_use.go.
|
||||
if caps.Agent && caps.ComputerUse {
|
||||
tools = append(tools, computerUseTools()...)
|
||||
}
|
||||
// Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
|
||||
// Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
|
||||
// agent. La découverte est paresseuse et cachée (voir mcp_client.go).
|
||||
@@ -458,6 +469,33 @@ func shownResult(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// dedupableTool indique si un appel RIGOUREUSEMENT identique (même outil, mêmes
|
||||
// arguments) doit être court-circuité au lieu d'être rejoué. Vrai pour les outils
|
||||
// où un ré-appel identique n'a pas de sens ou produit une fausse erreur (rejouer
|
||||
// une écriture déjà appliquée → « old introuvable »).
|
||||
//
|
||||
// FAUX pour bash : relancer la MÊME commande est un usage courant et légitime —
|
||||
// l'IA crée un fichier, le lance, le modifie, puis le RELANCE avec la même ligne ;
|
||||
// le résultat change parce que le fichier a changé. La dédup la bloquait par un
|
||||
// « [déjà fait] » exaspérant. bash a des effets de bord : on le laisse toujours
|
||||
// s'exécuter. Même raison pour bash_bg / bash_tail (suivre un job, c'est relire
|
||||
// la même sortie qui, elle, avance).
|
||||
//
|
||||
// FAUX aussi pour see_image et tout le pilotage de navigateur (browser_*) :
|
||||
// - leur résultat dépend de l'ÉTAT VIVANT de la page (un browser_snapshot sans
|
||||
// argument a forcément la même clé de dédup à chaque appel, alors que la page
|
||||
// a changé) — les dédupliquer fige l'IA sur un vieux cliché ;
|
||||
// - surtout, browser_screenshot et see_image portent leur IMAGE dans un message
|
||||
// à part (visionImg) que le chemin de dédup ne rejoue PAS : l'IA recevrait
|
||||
// « [déjà fait] » SANS l'image et tournerait en boucle.
|
||||
func dedupableTool(name string) bool {
|
||||
switch name {
|
||||
case "bash", "bash_bg", "bash_tail", "see_image":
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(name, "browser_")
|
||||
}
|
||||
|
||||
// repeatedCallResult construit ce qu'on renvoie quand le modèle redemande un
|
||||
// appel RIGOUREUSEMENT identique (même outil, mêmes arguments) dans le même tour.
|
||||
// L'appel n'est jamais rejoué — on répond depuis le résultat mémorisé.
|
||||
@@ -1228,7 +1266,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// même écriture ; la rejouer produisait une fausse erreur (« old
|
||||
// introuvable », puisque le remplacement est déjà fait).
|
||||
callKey := tc.Function.Name + "\x00" + tc.Function.Arguments
|
||||
if prev, seen := doneCalls[callKey]; seen {
|
||||
if prev, seen := doneCalls[callKey]; seen && dedupableTool(tc.Function.Name) {
|
||||
repeatCount[callKey]++
|
||||
result = repeatedCallResult(prev, repeatCount[callKey])
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true}})
|
||||
@@ -1381,6 +1419,24 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = capWebOutput(toolWebGrep(args))
|
||||
case "web_screenshot":
|
||||
result = toolWebScreenshot(args, caps)
|
||||
case "browser_open":
|
||||
result = capCUOutput(toolCUOpen(args))
|
||||
case "browser_snapshot":
|
||||
result = capCUOutput(toolCUSnapshot())
|
||||
case "browser_find":
|
||||
result = capCUOutput(toolCUFind(args))
|
||||
case "browser_click":
|
||||
result = capCUOutput(toolCUClick(args))
|
||||
case "browser_click_xy":
|
||||
result = capCUOutput(toolCUClickXY(args))
|
||||
case "browser_type":
|
||||
result = capCUOutput(toolCUType(args))
|
||||
case "browser_key":
|
||||
result = capCUOutput(toolCUKey(args))
|
||||
case "browser_scroll":
|
||||
result = capCUOutput(toolCUScroll(args))
|
||||
case "browser_screenshot":
|
||||
result, visionImg = toolCUScreenshot()
|
||||
default:
|
||||
if isMCPTool(tc.Function.Name) {
|
||||
result = mcpCall(tc.Function.Name, args)
|
||||
|
||||
@@ -84,6 +84,8 @@ func Main() {
|
||||
mustExit(cmdOAI(args))
|
||||
case "agent":
|
||||
mustExit(cmdAgent(args))
|
||||
case "computer":
|
||||
mustExit(cmdComputer(args))
|
||||
case "internet":
|
||||
mustExit(cmdInternet(args))
|
||||
case "memory":
|
||||
@@ -156,6 +158,7 @@ Interaction:
|
||||
memory [off|ondemand|always|status] mode mémoire de l'IA
|
||||
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]
|
||||
accès web de l'IA (moteur intégré ou serveur Crawl4AI)
|
||||
computer [on|off|status] laisse l'IA piloter un navigateur (browser_open/click/type…)
|
||||
|
||||
Accès distant (ajean.link) :
|
||||
link <token> enregistre le jeton et ouvre le tunnel
|
||||
|
||||
@@ -2621,6 +2621,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pilotage de navigateur (computer use) : axe de capacité à part, sous le
|
||||
mode agent comme l'accès internet — un outil qui clique et tape dans une
|
||||
page fait des actions RÉELLES, même niveau de confiance que bash. -->
|
||||
<div id="computer-block" class="gated-group">
|
||||
<div class="subhead">Contrôle du navigateur<span id="computer-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Donne à l'IA le contrôle d'un <b>navigateur</b> dans le conteneur (outils <b>browser_open / browser_find / browser_click / browser_type / browser_key / browser_scroll</b>). L'IA lit les éléments <b>numérotés</b> de la page et agit par numéro — aucune vision requise, ça marche avec de petits modèles. <code>browser_screenshot</code> s'ajoute si la vision est active. Utilise le Chromium de Playwright embarqué dans l'image ; à défaut, <code>LOKI_CHROME=<chemin></code>. Actif seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="computer-toggle" onchange="toggleComputer()"><span class="slider"></span></span> laisser l'IA piloter un navigateur</label>
|
||||
<div class="rowbody">
|
||||
<div id="computer-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-block" class="gated-group">
|
||||
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
|
||||
<div class="slist">
|
||||
@@ -4274,7 +4286,7 @@ async function loadAgent(){
|
||||
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
|
||||
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
|
||||
function setAgentGate(on){
|
||||
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
|
||||
['mem-block','net-block','computer-block','mcp-block','param-block'].forEach(id=>{
|
||||
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
|
||||
});
|
||||
}
|
||||
@@ -4383,6 +4395,32 @@ function renderInternet(s){
|
||||
else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; }
|
||||
}
|
||||
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
|
||||
// Contrôle du navigateur (outils browser_*). Le serveur dit s'il a trouvé un
|
||||
// binaire Chromium : sans lui, les outils ne sont pas annoncés au modèle, et
|
||||
// l'interrupteur ne servirait qu'à promettre ce qui n'existe pas.
|
||||
function renderComputer(s){
|
||||
const tg=document.getElementById('computer-toggle');
|
||||
if(tg) tg.checked = !!s.enabled;
|
||||
// La pastille ne redit pas l'interrupteur : elle ne signale que l'anomalie
|
||||
// (actif mais aucun navigateur trouvé).
|
||||
if(s.enabled && !s.browser_ok) setBadge('computer-badge','warn','sans navigateur');
|
||||
else setBadge('computer-badge', null);
|
||||
const st=document.getElementById('computer-status');
|
||||
if(!st) return;
|
||||
if(!s.enabled){
|
||||
st.textContent = "inactif : l'IA n'a aucun outil de navigateur" + (s.browser_ok ? '.' : " (aucun navigateur détecté dans le conteneur).");
|
||||
st.style.color='';
|
||||
return;
|
||||
}
|
||||
if(!s.browser_ok){ st.innerHTML = '⚠ aucun Chromium détecté — image bâtie avec PLAYWRIGHT=0 ? sinon définis LOKI_CHROME=<chemin du binaire>'; return; }
|
||||
st.innerHTML = '<span style="color:var(--accent)">✓</span> navigateur prêt, outils de contrôle actifs'
|
||||
+ (s.vision ? '' : ' — vision inactive : browser_screenshot indisponible');
|
||||
}
|
||||
async function loadComputer(){ renderComputer(await jget('/api/computer')); }
|
||||
async function toggleComputer(){
|
||||
const on=document.getElementById('computer-toggle').checked;
|
||||
renderComputer(await jpost('/api/computer',{on}));
|
||||
}
|
||||
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
|
||||
let OAI_KEY='', OAI_REVEAL=false;
|
||||
async function copyText(txt, msg){
|
||||
@@ -4614,7 +4652,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
|
||||
@@ -434,6 +434,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pilotage de navigateur (computer use) : axe de capacité à part, sous le
|
||||
mode agent comme l'accès internet — un outil qui clique et tape dans une
|
||||
page fait des actions RÉELLES, même niveau de confiance que bash. -->
|
||||
<div id="computer-block" class="gated-group">
|
||||
<div class="subhead">Contrôle du navigateur<span id="computer-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Donne à l'IA le contrôle d'un <b>navigateur</b> dans le conteneur (outils <b>browser_open / browser_find / browser_click / browser_type / browser_key / browser_scroll</b>). L'IA lit les éléments <b>numérotés</b> de la page et agit par numéro — aucune vision requise, ça marche avec de petits modèles. <code>browser_screenshot</code> s'ajoute si la vision est active. Utilise le Chromium de Playwright embarqué dans l'image ; à défaut, <code>LOKI_CHROME=<chemin></code>. Actif seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="computer-toggle" onchange="toggleComputer()"><span class="slider"></span></span> laisser l'IA piloter un navigateur</label>
|
||||
<div class="rowbody">
|
||||
<div id="computer-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-block" class="gated-group">
|
||||
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
|
||||
<div class="slist">
|
||||
|
||||
@@ -133,7 +133,7 @@ async function loadAgent(){
|
||||
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
|
||||
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
|
||||
function setAgentGate(on){
|
||||
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
|
||||
['mem-block','net-block','computer-block','mcp-block','param-block'].forEach(id=>{
|
||||
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
|
||||
});
|
||||
}
|
||||
@@ -242,6 +242,32 @@ function renderInternet(s){
|
||||
else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; }
|
||||
}
|
||||
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
|
||||
// Contrôle du navigateur (outils browser_*). Le serveur dit s'il a trouvé un
|
||||
// binaire Chromium : sans lui, les outils ne sont pas annoncés au modèle, et
|
||||
// l'interrupteur ne servirait qu'à promettre ce qui n'existe pas.
|
||||
function renderComputer(s){
|
||||
const tg=document.getElementById('computer-toggle');
|
||||
if(tg) tg.checked = !!s.enabled;
|
||||
// La pastille ne redit pas l'interrupteur : elle ne signale que l'anomalie
|
||||
// (actif mais aucun navigateur trouvé).
|
||||
if(s.enabled && !s.browser_ok) setBadge('computer-badge','warn','sans navigateur');
|
||||
else setBadge('computer-badge', null);
|
||||
const st=document.getElementById('computer-status');
|
||||
if(!st) return;
|
||||
if(!s.enabled){
|
||||
st.textContent = "inactif : l'IA n'a aucun outil de navigateur" + (s.browser_ok ? '.' : " (aucun navigateur détecté dans le conteneur).");
|
||||
st.style.color='';
|
||||
return;
|
||||
}
|
||||
if(!s.browser_ok){ st.innerHTML = '⚠ aucun Chromium détecté — image bâtie avec PLAYWRIGHT=0 ? sinon définis LOKI_CHROME=<chemin du binaire>'; return; }
|
||||
st.innerHTML = '<span style="color:var(--accent)">✓</span> navigateur prêt, outils de contrôle actifs'
|
||||
+ (s.vision ? '' : ' — vision inactive : browser_screenshot indisponible');
|
||||
}
|
||||
async function loadComputer(){ renderComputer(await jget('/api/computer')); }
|
||||
async function toggleComputer(){
|
||||
const on=document.getElementById('computer-toggle').checked;
|
||||
renderComputer(await jpost('/api/computer',{on}));
|
||||
}
|
||||
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
|
||||
let OAI_KEY='', OAI_REVEAL=false;
|
||||
async function copyText(txt, msg){
|
||||
@@ -473,7 +499,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -698,6 +698,37 @@ func localIP() string {
|
||||
return "localhost"
|
||||
}
|
||||
|
||||
// handleComputer pilote le pilotage de navigateur (outils browser_*).
|
||||
//
|
||||
// GET → {enabled, browser, browser_ok, vision}
|
||||
// POST {on} → active/désactive (et ferme le navigateur quand on coupe).
|
||||
func handleComputer(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := setComputerUseEnabled(req.On); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Couper l'interrupteur ferme le navigateur : le laisser tourner
|
||||
// garderait un Chromium (et son profil temporaire) sur les bras alors
|
||||
// que plus aucun outil ne peut s'en servir.
|
||||
if !req.On {
|
||||
cdpShutdown()
|
||||
}
|
||||
}
|
||||
bin := chromePath()
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"enabled": computerUseEnabled(),
|
||||
"browser": bin,
|
||||
"browser_ok": bin != "",
|
||||
"vision": visionEnabled(),
|
||||
})
|
||||
}
|
||||
|
||||
func handleInternet(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
|
||||
@@ -245,6 +245,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/node/target", handleNodeTarget) // choisit la machine cible de l'agent
|
||||
api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte
|
||||
api("/api/internet", handleInternet)
|
||||
api("/api/computer", handleComputer) // pilotage de navigateur (outils browser_*)
|
||||
api("/api/mcp", handleMCP)
|
||||
api("/api/mcp/catalog", handleMCPCatalog)
|
||||
api("/api/mcp/save", handleMCPSave)
|
||||
|
||||
@@ -215,12 +215,12 @@ func userMessageContent(files []attachInfo, prompt string) any {
|
||||
textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier
|
||||
continue
|
||||
}
|
||||
imgParts = append(imgParts, map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
},
|
||||
})
|
||||
// Photo prise au téléphone : l'orientation vit dans un tag EXIF que le
|
||||
// projecteur ignore (il voit les pixels bruts, donc l'image couchée), et
|
||||
// une image de plusieurs milliers de pixels ne fait que gonfler le base64
|
||||
// — voir prepareImageForModel (web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
imgParts = append(imgParts, imageURLPart(b, mime))
|
||||
}
|
||||
if len(imgParts) == 0 {
|
||||
return attachNote(files) + prompt
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
// web_upload_orient.go — préparation des images avant envoi au modèle :
|
||||
// redressement de l'orientation EXIF ET redimensionnement des images trop
|
||||
// grandes.
|
||||
//
|
||||
// Orientation : les photos prises au téléphone sont presque toujours
|
||||
// enregistrées dans l'orientation NATIVE du capteur (souvent paysage), avec un
|
||||
// tag EXIF Orientation qui dit au visualiseur de les tourner à l'affichage. Les
|
||||
// navigateurs et apps photo respectent ce tag, donc l'utilisateur voit l'image
|
||||
// droite ; mais le projecteur multimodal (mmproj) de llama.cpp IGNORE l'EXIF et
|
||||
// reçoit les pixels bruts. Le modèle voyait alors l'image tournée de 90°.
|
||||
//
|
||||
// Redimensionnement : une image de plusieurs milliers de pixels de côté est
|
||||
// inutilement lourde — le projecteur la redécoupe de toute façon à sa propre
|
||||
// résolution (quelques centaines de pixels par tuile). L'envoyer en pleine
|
||||
// définition ne fait que gonfler le base64 transféré et les tokens visuels, sans
|
||||
// rien apprendre de plus au modèle. On plafonne donc le plus grand côté à
|
||||
// maxImageDim : c'est ce que font les fournisseurs d'API vision (~1568 px), et
|
||||
// c'est visuellement sans perte à l'échelle où le modèle regarde. Les PNG
|
||||
// restent réencodés en PNG (sans perte, utile pour les captures d'écran à
|
||||
// texte) ; le reste part en JPEG.
|
||||
//
|
||||
// On CUIT donc orientation et taille dans les pixels : décoder, redresser,
|
||||
// redimensionner, réencoder. Repli systématique sur les octets bruts en cas de
|
||||
// pépin : préparer une image ne doit jamais faire échouer un envoi.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"math"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxImageDim plafonne le plus grand côté d'une image envoyée au modèle.
|
||||
maxImageDim = 1568
|
||||
// imageJPEGQuality : qualité de réencodage JPEG (photos). Assez haut pour
|
||||
// rester net à l'œil, assez bas pour couper franchement le poids du base64.
|
||||
imageJPEGQuality = 88
|
||||
)
|
||||
|
||||
// prepareImageForModel prend les octets d'une image et son type MIME, et renvoie
|
||||
// une version prête pour la vision : orientation EXIF cuite dans les pixels et
|
||||
// grand côté ramené sous maxImageDim. Si rien n'est à faire (bon format, pas de
|
||||
// tag, image déjà petite) ou si le décodage échoue (WEBP/BMP hors stdlib, octets
|
||||
// abîmés), elle renvoie les octets d'origine inchangés — jamais d'échec.
|
||||
func prepareImageForModel(raw []byte, mime string) ([]byte, string) {
|
||||
orient := 0
|
||||
if mime == "image/jpeg" {
|
||||
orient = exifOrientation(raw)
|
||||
}
|
||||
needOrient := orient >= 2 && orient <= 8
|
||||
|
||||
src, _, err := image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return raw, mime // format non décodable ici (webp/bmp…) : laisser tel quel
|
||||
}
|
||||
b := src.Bounds()
|
||||
needResize := b.Dx() > maxImageDim || b.Dy() > maxImageDim
|
||||
if !needOrient && !needResize {
|
||||
return raw, mime // rien à changer : préserver les octets d'origine
|
||||
}
|
||||
|
||||
img := src
|
||||
if needOrient {
|
||||
img = applyOrientation(img, orient)
|
||||
}
|
||||
if img.Bounds().Dx() > maxImageDim || img.Bounds().Dy() > maxImageDim {
|
||||
img = scaleDownToMax(img, maxImageDim)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if mime == "image/png" {
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return raw, mime
|
||||
}
|
||||
return buf.Bytes(), "image/png"
|
||||
}
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: imageJPEGQuality}); err != nil {
|
||||
return raw, mime
|
||||
}
|
||||
return buf.Bytes(), "image/jpeg"
|
||||
}
|
||||
|
||||
// toNRGBA renvoie une vue *image.NRGBA de src (sans copie s'il l'est déjà), pour
|
||||
// un accès rapide aux octets de pixels dans scaleDownToMax.
|
||||
func toNRGBA(src image.Image) *image.NRGBA {
|
||||
if n, ok := src.(*image.NRGBA); ok {
|
||||
return n
|
||||
}
|
||||
b := src.Bounds()
|
||||
n := image.NewNRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
|
||||
draw.Draw(n, n.Bounds(), src, b.Min, draw.Src)
|
||||
return n
|
||||
}
|
||||
|
||||
// scaleDownToMax réduit une image pour que son plus grand côté vaille au plus
|
||||
// maxDim, en conservant le ratio. Moyennage par aire (box filter) : chaque pixel
|
||||
// de destination est la moyenne des pixels source qu'il recouvre — le meilleur
|
||||
// filtre simple pour une RÉDUCTION (pas de crénelage ni de halo), sans
|
||||
// dépendance externe.
|
||||
func scaleDownToMax(src image.Image, maxDim int) image.Image {
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxDim && h <= maxDim {
|
||||
return src
|
||||
}
|
||||
scale := float64(maxDim) / float64(w)
|
||||
if h > w {
|
||||
scale = float64(maxDim) / float64(h)
|
||||
}
|
||||
nw := int(math.Round(float64(w) * scale))
|
||||
nh := int(math.Round(float64(h) * scale))
|
||||
if nw < 1 {
|
||||
nw = 1
|
||||
}
|
||||
if nh < 1 {
|
||||
nh = 1
|
||||
}
|
||||
s := toNRGBA(src)
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, nw, nh))
|
||||
for dy := 0; dy < nh; dy++ {
|
||||
sy0 := dy * h / nh
|
||||
sy1 := (dy + 1) * h / nh
|
||||
if sy1 <= sy0 {
|
||||
sy1 = sy0 + 1
|
||||
}
|
||||
for dx := 0; dx < nw; dx++ {
|
||||
sx0 := dx * w / nw
|
||||
sx1 := (dx + 1) * w / nw
|
||||
if sx1 <= sx0 {
|
||||
sx1 = sx0 + 1
|
||||
}
|
||||
var r, g, bl, a, cnt uint64
|
||||
for sy := sy0; sy < sy1; sy++ {
|
||||
for sx := sx0; sx < sx1; sx++ {
|
||||
i := s.PixOffset(s.Rect.Min.X+sx, s.Rect.Min.Y+sy)
|
||||
r += uint64(s.Pix[i])
|
||||
g += uint64(s.Pix[i+1])
|
||||
bl += uint64(s.Pix[i+2])
|
||||
a += uint64(s.Pix[i+3])
|
||||
cnt++
|
||||
}
|
||||
}
|
||||
di := dst.PixOffset(dx, dy)
|
||||
dst.Pix[di] = byte(r / cnt)
|
||||
dst.Pix[di+1] = byte(g / cnt)
|
||||
dst.Pix[di+2] = byte(bl / cnt)
|
||||
dst.Pix[di+3] = byte(a / cnt)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// exifOrientation renvoie la valeur du tag EXIF Orientation (1..8) d'un JPEG, ou
|
||||
// 0 si absent/illisible. On lit à la main le segment APP1 pour éviter une
|
||||
// dépendance : la structure est simple et le tag vit dans l'IFD0.
|
||||
func exifOrientation(b []byte) int {
|
||||
// En-tête JPEG : SOI = 0xFFD8.
|
||||
if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
|
||||
return 0
|
||||
}
|
||||
pos := 2
|
||||
for pos+4 <= len(b) {
|
||||
if b[pos] != 0xFF {
|
||||
return 0 // désynchronisé
|
||||
}
|
||||
marker := b[pos+1]
|
||||
// SOS (début des données image) ou EOI : plus aucun segment de métadonnées.
|
||||
if marker == 0xDA || marker == 0xD9 {
|
||||
return 0
|
||||
}
|
||||
segLen := int(binary.BigEndian.Uint16(b[pos+2 : pos+4]))
|
||||
if segLen < 2 || pos+2+segLen > len(b) {
|
||||
return 0
|
||||
}
|
||||
seg := b[pos+4 : pos+2+segLen]
|
||||
// APP1 = 0xFFE1, préfixé "Exif\0\0" pour les données EXIF.
|
||||
if marker == 0xE1 && len(seg) >= 6 && string(seg[:6]) == "Exif\x00\x00" {
|
||||
return exifOrientationFromTIFF(seg[6:])
|
||||
}
|
||||
pos += 2 + segLen
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// exifOrientationFromTIFF lit l'orientation dans le bloc TIFF d'un segment EXIF.
|
||||
func exifOrientationFromTIFF(t []byte) int {
|
||||
if len(t) < 8 {
|
||||
return 0
|
||||
}
|
||||
var bo binary.ByteOrder
|
||||
switch string(t[:2]) {
|
||||
case "II":
|
||||
bo = binary.LittleEndian
|
||||
case "MM":
|
||||
bo = binary.BigEndian
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
if bo.Uint16(t[2:4]) != 0x002A {
|
||||
return 0
|
||||
}
|
||||
ifdOff := int(bo.Uint32(t[4:8]))
|
||||
if ifdOff < 8 || ifdOff+2 > len(t) {
|
||||
return 0
|
||||
}
|
||||
n := int(bo.Uint16(t[ifdOff : ifdOff+2]))
|
||||
entry := ifdOff + 2
|
||||
for i := 0; i < n; i++ {
|
||||
if entry+12 > len(t) {
|
||||
return 0
|
||||
}
|
||||
tag := bo.Uint16(t[entry : entry+2])
|
||||
if tag == 0x0112 { // Orientation
|
||||
// Type SHORT : la valeur tient dans les 2 premiers octets du champ valeur
|
||||
// (offset entry+8).
|
||||
return int(bo.Uint16(t[entry+8 : entry+10]))
|
||||
}
|
||||
entry += 12
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// applyOrientation renvoie une nouvelle image où l'orientation EXIF a été cuite
|
||||
// dans les pixels. Les 8 cas du tag combinent rotations (multiples de 90°) et
|
||||
// miroirs.
|
||||
func applyOrientation(src image.Image, orient int) image.Image {
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
// Les orientations 5..8 échangent largeur et hauteur (rotation d'un quart).
|
||||
swap := orient >= 5
|
||||
dw, dh := w, h
|
||||
if swap {
|
||||
dw, dh = h, w
|
||||
}
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, dw, dh))
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
c := src.At(b.Min.X+x, b.Min.Y+y)
|
||||
var nx, ny int
|
||||
switch orient {
|
||||
case 2: // miroir horizontal
|
||||
nx, ny = w-1-x, y
|
||||
case 3: // 180°
|
||||
nx, ny = w-1-x, h-1-y
|
||||
case 4: // miroir vertical
|
||||
nx, ny = x, h-1-y
|
||||
case 5: // transpose (diagonale principale)
|
||||
nx, ny = y, x
|
||||
case 6: // 90° horaire
|
||||
nx, ny = h-1-y, x
|
||||
case 7: // transverse (diagonale secondaire)
|
||||
nx, ny = h-1-y, w-1-x
|
||||
case 8: // 90° anti-horaire
|
||||
nx, ny = y, w-1-x
|
||||
default:
|
||||
nx, ny = x, y
|
||||
}
|
||||
dst.Set(nx, ny, c)
|
||||
}
|
||||
}
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user