mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-21 22:18:13 +02:00
Navigateur piloté, images redressées : reprises d'AJEAN
L'amont (AJEAN 0.15) donne à l'IA le contrôle d'un navigateur ; Loki embarquait déjà un Chromium pour `web_screenshot` sans jamais le piloter. Contrôle du navigateur (computer_use.go, computer_cdp.go, browser_grid.go, portés d'AJEAN) : l'IA ouvre une page en CDP, en reçoit les éléments interactifs NUMÉROTÉS et agit par numéro (browser_open/snapshot/find/click/ type/key/scroll). Aucune vision requise — un petit modèle texte s'en sort. Avec un projecteur chargé s'ajoutent browser_screenshot (image quadrillée) et browser_click_xy. Interrupteur dédié (Réglages, `loki computer`, /api/computer), sous le mode agent : cliquer et taper dans une page sont des actions réelles, même niveau de confiance que bash. Adaptation au conteneur : chromePath cherche D'ABORD le Chromium de Playwright (PLAYWRIGHT_BROWSERS_PATH, /opt/pw-browsers), le seul navigateur de l'image — sinon la fonctionnalité se déclarait absente là où le navigateur est présent. LOKI_CHROME force un chemin, LOKI_CU_HEADFUL ouvre une vraie fenêtre. Préparation des images (web_upload_orient.go, porté d'AJEAN) : l'orientation EXIF est cuite dans les pixels — le projecteur l'ignore et voyait les photos de téléphone couchées — et le grand côté ramené sous 1568 px. Appliquée aux pièces jointes, à see_image et aux captures. Dédup des appels d'outils : bash, bash_bg, bash_tail, see_image et les browser_* ne sont plus court-circuités sur un appel identique. Relancer la même commande après avoir modifié un fichier est légitime, et un outil qui porte une image dans un message à part renvoyait « [déjà fait] » SANS l'image — le modèle tournait en boucle. Mode code : le builder ne publie plus de lui-même (pas de commit/push/reset/ rebase ni de redémarrage de service sans demande explicite), reprise de la leçon d'AJEAN 0.15.4 ; l'inspection en lecture seule reste encouragée. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
This commit is contained in:
21 files changed
+2503
-19
No files matched your search
@@ -0,0 +1,404 @@
|
||||
package loki
|
||||
|
||||
// computer_use.go — le « computer use » : donner à Jean la capacité d'agir sur
|
||||
// une interface graphique de la machine hôte. Étape 1 : le WEB, via un Chromium
|
||||
// piloté en CDP (computer_cdp.go). Le modèle lit l'arbre d'accessibilité (des
|
||||
// éléments interactifs NUMÉROTÉS, façon set-of-marks) et agit par numéro —
|
||||
// aucune vision requise, ce qui marche même avec de petits modèles texte à
|
||||
// faible contexte. browser_screenshot n'est proposé que si la vision est active, pour
|
||||
// les cas sans a11y (canvas, jeux).
|
||||
//
|
||||
// C'est un axe de capacité indépendant, sous le mode agent (comme l'accès
|
||||
// internet) : un outil qui pilote un navigateur exécute des actions réelles, donc
|
||||
// même niveau de confiance que bash. Interrupteur : clé bkState "computer".
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func computerUseEnabled() bool { return getBool(bkState, "computer") }
|
||||
|
||||
func setComputerUseEnabled(on bool) error { return putBool(bkState, "computer", on) }
|
||||
|
||||
// cuMaxOutput borne ce qu'un appel d'outil computer use injecte dans le contexte
|
||||
// (aligné sur le web et le shell : 8000).
|
||||
const cuMaxOutput = 8000
|
||||
|
||||
func capCUOutput(s string) string {
|
||||
if r := []rune(s); len(r) > cuMaxOutput {
|
||||
return string(r[:cuMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── schémas d'outils (annoncés au modèle) ───────────────────────────────────
|
||||
|
||||
func cuOpenTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_open",
|
||||
Description: "Open a URL in the controlled browser and return the page's interactive elements, each numbered [n]. Start here. Pass 'back' or 'forward' instead of a URL to move through history.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"url": map[string]any{"type": "string", "description": "URL to open, or 'back' / 'forward'"}},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuSnapshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_snapshot",
|
||||
Description: "Re-read the current page's interactive elements (numbered). Use it after the page changes to get fresh [n] refs.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click",
|
||||
Description: "Click the element with the given number from the latest snapshot. Returns the page state afterwards.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"ref": map[string]any{"type": "integer", "description": "Element number [n]"}},
|
||||
"required": []string{"ref"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuTypeTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_type",
|
||||
Description: "Type text into a field. ALWAYS give 'ref' (the field's number) so it targets the right field and REPLACES its current content — no need to browser_click the field first. For a dropdown/select (role 'combobox'), pass the option's text and it picks that option directly (don't click it open). Without a ref it types into whatever is focused, which is unreliable.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to type"},
|
||||
"ref": map[string]any{"type": "integer", "description": "Optional: field number [n] to focus first"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuKeyTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_key",
|
||||
Description: "Press a special key: enter, tab, backspace, escape, delete, up, down, left, right, home, end, pageup, pagedown.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"key": map[string]any{"type": "string", "description": "Key name"}},
|
||||
"required": []string{"key"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuFindTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_find",
|
||||
Description: "Find interactive elements ANYWHERE on the page (not just the visible part) whose label contains the text, and scroll the first into view. Use it to locate an off-screen link or button (e.g. 'créer un compte', 'panier') instead of scrolling blindly or guessing URLs.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"text": map[string]any{"type": "string", "description": "Text to look for in element labels"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScrollTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_scroll",
|
||||
Description: "Scroll the page (up, down, left, right), then return the now-visible interactive elements.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"direction": map[string]any{"type": "string", "description": "up, down, left or right"}},
|
||||
"required": []string{"direction"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickXYTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click_xy",
|
||||
Description: fmt.Sprintf("Click at pixel coordinates (x from the left, y from the top) read off the latest browser_screenshot — %dx%d pixels, 1:1 with the page, with a coordinate grid drawn every 100px to read x/y precisely. Use it ONLY for something the numbered elements can't reach: a cookie/consent banner inside a cross-origin iframe, a canvas, a map. Otherwise always prefer browser_click(ref).", cuViewW, cuViewH),
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"x": map[string]any{"type": "number", "description": "pixels from the left edge"},
|
||||
"y": map[string]any{"type": "number", "description": "pixels from the top edge"},
|
||||
},
|
||||
"required": []string{"x", "y"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScreenshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_screenshot",
|
||||
Description: "Capture the current page as an image. It is shown to the user immediately AND saved to your working folder; the result gives the filename to link with Markdown if they want the file. Use it to see a visual layout the numbered elements don't convey, or when the user asks for a screenshot — never hunt the disk for it.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
// computerUseTools est la liste annoncée au modèle quand le computer use est
|
||||
// actif. browser_screenshot n'y figure que si la vision est réellement active.
|
||||
func computerUseTools() []Tool {
|
||||
tools := []Tool{cuOpenTool(), cuSnapshotTool(), cuFindTool(), cuClickTool(), cuTypeTool(), cuKeyTool(), cuScrollTool()}
|
||||
// Vision active : la capture d'écran ET le clic par coordonnées (qui n'a de sens
|
||||
// que si le modèle peut lire l'image pour viser).
|
||||
if visionEnabled() {
|
||||
tools = append(tools, cuScreenshotTool(), cuClickXYTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// cuPromptLine est la consigne d'usage ajoutée au prompt système quand le
|
||||
// computer use est actif (l'ordre d'appel que les schémas isolés ne disent pas).
|
||||
func cuPromptLine() string {
|
||||
line := "\nComputer use (a browser you control on this machine): browser_open a URL, then act on the NUMBERED elements — browser_click(ref), browser_type(text, ref), browser_key, browser_scroll. Each action returns the fresh numbered elements; after the page changes, trust the new numbers, not old ones. The list shows only what's in view: to reach an off-screen link or button (e.g. 'créer un compte'), use browser_find(text) to jump straight to it rather than scrolling blindly or, worse, guessing URLs — inventing paths mostly 404s. If a page shows an error (404, 'introuvable'), browser_open goes 'back' or try another link. Prefer the numbered elements over screenshots."
|
||||
if visionEnabled() {
|
||||
line += " Use browser_screenshot when the numbers aren't enough (canvas, visual layout). If a button is visible but NOT in the numbered list (e.g. a cookie banner inside an iframe), browser_screenshot then browser_click_xy(x,y) at its pixel position."
|
||||
}
|
||||
line += "\n"
|
||||
return line
|
||||
}
|
||||
|
||||
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
|
||||
|
||||
func toolCUOpen(args map[string]any) string {
|
||||
url, _ := args["url"].(string)
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return "[erreur] url manquante"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.navigate(url); err != nil {
|
||||
return "[erreur] navigation : " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUSnapshot() string {
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUFind(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "[erreur] texte de recherche manquant"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
res, err := s.find(text)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func toolCUClick(args map[string]any) string {
|
||||
ref, ok := intArg(args, "ref")
|
||||
if !ok {
|
||||
return "[erreur] ref manquant (numéro de l'élément)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickRef(ref); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] cliqué [%d]\n\n%s", ref, snap)
|
||||
}
|
||||
|
||||
func toolCUType(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if ref, ok := intArg(args, "ref"); ok {
|
||||
if err := s.typeInto(ref, text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
} else if err := s.typeText(text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] saisi « %s »\n\n%s", text, snap)
|
||||
}
|
||||
|
||||
func toolCUKey(args map[string]any) string {
|
||||
key, _ := args["key"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.pressKey(key); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] touche %s\n\n%s", key, snap)
|
||||
}
|
||||
|
||||
func toolCUClickXY(args map[string]any) string {
|
||||
x, okx := floatArg(args, "x")
|
||||
y, oky := floatArg(args, "y")
|
||||
if !okx || !oky {
|
||||
return "[erreur] coordonnées x et y requises (en pixels du screenshot)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickXY(x, y); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] clic en (%.0f, %.0f)\n\n%s", x, y, snap)
|
||||
}
|
||||
|
||||
func toolCUScroll(args map[string]any) string {
|
||||
dir, _ := args["direction"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.scroll(dir); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return snap
|
||||
}
|
||||
|
||||
// toolCUScreenshot renvoie (accusé texte, partie image_url) comme see_image :
|
||||
// l'image est réinjectée dans un message utilisateur multimodal par l'appelant.
|
||||
//
|
||||
// La capture PLEINE RÉSOLUTION est aussi ENREGISTRÉE dans le dossier de travail :
|
||||
// sans ça, le modèle qui reçoit « envoie-moi une capture » partait chercher un
|
||||
// PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour
|
||||
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
|
||||
func toolCUScreenshot() (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
// Enregistre le PNG plein format dans le workspace (chemin RELATIF renvoyé au
|
||||
// modèle : un lien Markdown [capture](nom.png) est servi par /api/chat/file).
|
||||
saved := ""
|
||||
name := "capture-" + time.Now().Format("20060102-150405") + ".png"
|
||||
if ws := agentWorkspace(); ws != "" {
|
||||
if err := os.WriteFile(filepath.Join(ws, name), png, 0o644); err == nil {
|
||||
saved = name
|
||||
}
|
||||
}
|
||||
// L'image du MODÈLE porte une GRILLE de coordonnées (lignes graduées tous les
|
||||
// 100 px) pour qu'il lise x/y avec précision ; celle sauvegardée pour l'utilisateur
|
||||
// (plus haut, `png`) reste PROPRE, sans grille.
|
||||
b, mime := prepareImageForModel(overlayGrid(png), "image/png")
|
||||
// ⚠️ TOI (le modèle) vois l'image ci-dessous, mais l'utilisateur NE la voit PAS
|
||||
// automatiquement dans le chat. Pour la lui afficher, il faut l'insérer en IMAGE
|
||||
// Markdown  — un simple lien [texte](fichier) ne fait qu'un bouton
|
||||
// de téléchargement, pas d'aperçu.
|
||||
msg := fmt.Sprintf("[ok] capture prise (tu la vois ci-dessous, %dx%d px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille.", cuViewW, cuViewH)
|
||||
if saved != "" {
|
||||
msg += " Enregistrée sous « " + saved + " ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : . Un simple lien [capture](" + saved + ") ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci."
|
||||
}
|
||||
return msg, imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// intArg extrait un entier d'un argument d'outil (le JSON décode les nombres en
|
||||
// float64).
|
||||
func intArg(args map[string]any, key string) (int, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// floatArg extrait un nombre d'un argument d'outil (coordonnées de clic).
|
||||
func floatArg(args map[string]any, key string) (float64, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ─── CLI : loki computer [on|off|status] ────────────────────────────────────
|
||||
|
||||
func cmdComputer(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
if err := setComputerUseEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := " contrôle du navigateur activé — l'IA pilote un navigateur (browser_open/browser_click/browser_type/…) si le mode agent est actif"
|
||||
if chromePath() == "" {
|
||||
msg += "\n" + red("[!]") + " aucun Chrome/Chromium/Edge détecté — installe-le ou définis LOKI_CHROME=<chemin>"
|
||||
}
|
||||
fmt.Println(green("[ok]") + msg)
|
||||
case "off":
|
||||
if err := setComputerUseEnabled(false); err != nil {
|
||||
return err
|
||||
}
|
||||
cdpShutdown()
|
||||
fmt.Println(green("[ok]") + " contrôle du navigateur désactivé")
|
||||
case "", "status":
|
||||
state := dim("off")
|
||||
if computerUseEnabled() {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Contrôle du navigateur"), state)
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
fmt.Printf(" navigateur : %s — installe Chrome/Chromium/Edge ou LOKI_CHROME=<chemin>\n", red("introuvable"))
|
||||
} else {
|
||||
fmt.Printf(" navigateur : %s\n", bold(bin))
|
||||
}
|
||||
fmt.Printf(" outils : browser_open, browser_snapshot, browser_find, browser_click, browser_type, browser_key, browser_scroll")
|
||||
if visionEnabled() {
|
||||
fmt.Printf(", browser_screenshot, browser_click_xy")
|
||||
}
|
||||
fmt.Println()
|
||||
default:
|
||||
return fmt.Errorf("usage: loki computer [on|off|status]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user