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:
Claude committed 2026-09-22 13:19:37 +00:00
1 parent b5904907e8
commit 00b2350fb9
21 files changed
+2503 -19

No files matched your search

+8
View File
@@ -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
+21 -1
View File
@@ -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
+1
View File
@@ -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.
+129
View File
@@ -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})
}
}
+5 -4
View File
@@ -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
}
+3
View File
@@ -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)
+18 -2
View File
@@ -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,
}}
}
+895
View File
@@ -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)
}
+422
View File
@@ -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)
}
}
+22
View File
@@ -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)
}
}
}
+404
View File
@@ -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 ![...](fichier) — 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 : ![capture](" + saved + "). 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
}
+58 -2
View File
@@ -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)
+3
View File
@@ -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
+40 -2
View File
@@ -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=&lt;chemin&gt;</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=&lt;chemin du binaire&gt;'; 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); }
+12
View File
@@ -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=&lt;chemin&gt;</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">
+28 -2
View File
@@ -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=&lt;chemin du binaire&gt;'; 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); }
+31
View File
@@ -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 {
+1
View File
@@ -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)
+6 -6
View File
@@ -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
+267
View File
@@ -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
}
+129
View File
@@ -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)
}
}