From 00b2350fb9da40a0e2e59a14148e43e3303d4367 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 13:19:37 +0000 Subject: [PATCH] =?UTF-8?q?Navigateur=20pilot=C3=A9,=20images=20redress?= =?UTF-8?q?=C3=A9es=20:=20reprises=20d'AJEAN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY --- NOTICE.md | 8 + README.md | 22 +- internal/loki/agents/builder.md | 1 + internal/loki/browser_grid.go | 129 ++++ internal/loki/chat_screenshot.go | 9 +- internal/loki/chat_tools.go | 3 + internal/loki/chat_vision_tool.go | 20 +- internal/loki/computer_cdp.go | 895 +++++++++++++++++++++++ internal/loki/computer_cdp_smoke_test.go | 422 +++++++++++ internal/loki/computer_dedup_test.go | 22 + internal/loki/computer_use.go | 404 ++++++++++ internal/loki/llm_client.go | 60 +- internal/loki/run.go | 3 + internal/loki/ui/index.html | 42 +- internal/loki/ui/src/index.tmpl.html | 12 + internal/loki/ui/src/js/06-settings.js | 30 +- internal/loki/web_api.go | 31 + internal/loki/web_server.go | 1 + internal/loki/web_upload.go | 12 +- internal/loki/web_upload_orient.go | 267 +++++++ internal/loki/web_upload_orient_test.go | 129 ++++ 21 files changed, 2503 insertions(+), 19 deletions(-) create mode 100644 internal/loki/browser_grid.go create mode 100644 internal/loki/computer_cdp.go create mode 100644 internal/loki/computer_cdp_smoke_test.go create mode 100644 internal/loki/computer_dedup_test.go create mode 100644 internal/loki/computer_use.go create mode 100644 internal/loki/web_upload_orient.go create mode 100644 internal/loki/web_upload_orient_test.go diff --git a/NOTICE.md b/NOTICE.md index 77cd63e..0a1f0fa 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -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 diff --git a/README.md b/README.md index 54e687b..71fbb84 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/loki/agents/builder.md b/internal/loki/agents/builder.md index 4af611b..e1426b6 100644 --- a/internal/loki/agents/builder.md +++ b/internal/loki/agents/builder.md @@ -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. diff --git a/internal/loki/browser_grid.go b/internal/loki/browser_grid.go new file mode 100644 index 0000000..62cd861 --- /dev/null +++ b/internal/loki/browser_grid.go @@ -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}) + } +} diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go index 2159b35..785f730 100644 --- a/internal/loki/chat_screenshot.go +++ b/internal/loki/chat_screenshot.go @@ -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 } diff --git a/internal/loki/chat_tools.go b/internal/loki/chat_tools.go index 46581ab..fbf9037 100644 --- a/internal/loki/chat_tools.go +++ b/internal/loki/chat_tools.go @@ -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) diff --git a/internal/loki/chat_vision_tool.go b/internal/loki/chat_vision_tool.go index 3fe2c8d..36b818c 100644 --- a/internal/loki/chat_vision_tool.go +++ b/internal/loki/chat_vision_tool.go @@ -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, }} } diff --git a/internal/loki/computer_cdp.go b/internal/loki/computer_cdp.go new file mode 100644 index 0000000..1b82b19 --- /dev/null +++ b/internal/loki/computer_cdp.go @@ -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- 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-, 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=") + } + // 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