mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-12 01:37:06 +02:00
L'amont (AJEAN 0.15) donne à l'IA le contrôle d'un navigateur ; Loki embarquait déjà un Chromium pour `web_screenshot` sans jamais le piloter. Contrôle du navigateur (computer_use.go, computer_cdp.go, browser_grid.go, portés d'AJEAN) : l'IA ouvre une page en CDP, en reçoit les éléments interactifs NUMÉROTÉS et agit par numéro (browser_open/snapshot/find/click/ type/key/scroll). Aucune vision requise — un petit modèle texte s'en sort. Avec un projecteur chargé s'ajoutent browser_screenshot (image quadrillée) et browser_click_xy. Interrupteur dédié (Réglages, `loki computer`, /api/computer), sous le mode agent : cliquer et taper dans une page sont des actions réelles, même niveau de confiance que bash. Adaptation au conteneur : chromePath cherche D'ABORD le Chromium de Playwright (PLAYWRIGHT_BROWSERS_PATH, /opt/pw-browsers), le seul navigateur de l'image — sinon la fonctionnalité se déclarait absente là où le navigateur est présent. LOKI_CHROME force un chemin, LOKI_CU_HEADFUL ouvre une vraie fenêtre. Préparation des images (web_upload_orient.go, porté d'AJEAN) : l'orientation EXIF est cuite dans les pixels — le projecteur l'ignore et voyait les photos de téléphone couchées — et le grand côté ramené sous 1568 px. Appliquée aux pièces jointes, à see_image et aux captures. Dédup des appels d'outils : bash, bash_bg, bash_tail, see_image et les browser_* ne sont plus court-circuités sur un appel identique. Relancer la même commande après avoir modifié un fichier est légitime, et un outil qui porte une image dans un message à part renvoyait « [déjà fait] » SANS l'image — le modèle tournait en boucle. Mode code : le builder ne publie plus de lui-même (pas de commit/push/reset/ rebase ni de redémarrage de service sans demande explicite), reprise de la leçon d'AJEAN 0.15.4 ; l'inspection en lecture seule reste encouragée. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
405 lines
16 KiB
Go
405 lines
16 KiB
Go
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
|
|
}
|