Files
Loki/internal/loki/chat_internet_tools.go
MichaelandClaude Opus 5.5 5016396998 Repris d'AJEAN : recherche d'images, aperçu du navigateur, vignettes, liens d'images
- web_images (amont v0.17.7) : recherche d'images DuckDuckGo, pendant de
  web_search ; les images du web s'affichent dans le fil avec la loupe, sans
  Referer, et disparaissent si le site les refuse. Schéma minimal pour tenir
  le budget du préambule.
- Aperçu en direct du navigateur piloté (cu_autoshot.go) : une capture après
  chaque action browser_* qui change la page, gardée en RAM, jamais envoyée au
  modèle, retirée du journal en fin de tour ; une carte dans le fil, en fondu.
- Vignettes calculées par le serveur (?thumb=, amont v0.17.9) : ~560 px au
  lieu de l'original (7 Ko au lieu de 550 Ko sur une capture), chargées deux
  par deux avec un nouvel essai ; la loupe ouvre l'original.
- Un lien vers une image ([graphique](graph.png)) l'affiche au lieu d'un
  bouton de téléchargement.
- Chrome du computer use tué avec Loki sous Linux (Pdeathsig), même en cas
  d'arrêt brutal : plus de navigateur orphelin dans le conteneur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:30:05 +02:00

375 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// chat_internet_tools.go — les 4 outils web exposés au modèle (web_search,
// web_open, web_read, web_grep) + la sous-commande `loki internet`.
package loki
import (
"fmt"
"regexp"
"strings"
)
func webSearchTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_search",
Description: "Recherche web (DuckDuckGo) → liste classée de {title, url, snippet}. " +
"Pour une question sans URL ou une information récente. Enchaîner avec web_open + web_read sur le meilleur résultat.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Requête"},
"limit": map[string]any{"type": "integer", "description": "Défaut 8, max 20"},
},
"required": []string{"query"},
},
}}
}
// webOpenTool : le schéma DÉPEND du moteur web actif.
//
// Le moteur intégré n'a pas de DOM vivant : actions / wait_for / dismiss_popups
// seraient acceptés puis ignorés en silence. Les déclarer quand même reviendrait
// à mentir au modèle — il croirait pouvoir déplier une section ou fermer un
// bandeau, constaterait que rien ne change, et réessaierait en boucle (le failure
// mode classique, cf. le garde-fou anti-boucle de chat_agent.go). On ne déclare
// donc que ce que le moteur sait réellement faire, et on annonce la limite du JS
// dans la description pour que le modèle change de source au lieu d'insister.
func webOpenTool() Tool {
desc := "Récupère une URL et renvoie SEULEMENT ses métadonnées (taille, nb de lignes, plan des titres), " +
"PAS le contenu. Toujours l'appeler avant de lire ; mis en cache 10 min pour les web_read/web_grep suivants."
props := map[string]any{
"url": map[string]any{"type": "string", "description": "URL complète"},
"refresh": map[string]any{"type": "boolean", "description": "Ignore le cache. Défaut false."},
}
if webEngine() == engineGo {
desc += " ⚠ Ce moteur ne lit que le HTML servi, SANS JavaScript : une page rendue côté client " +
"ressort vide. Dans ce cas ne réessaie pas la même URL, change de source."
} else {
props["actions"] = map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "Snippets JS à exécuter sur la page AVANT extraction (déplier des sections, cliquer 'voir plus', etc.)."}
props["dismiss_popups"] = map[string]any{"type": "boolean", "description": "Ferme auto les bandeaux cookies/overlays. Défaut true."}
props["wait_for"] = map[string]any{"type": "string", "description": "Sélecteur CSS ou expr JS à attendre après les actions."}
}
return Tool{Type: "function", Function: ToolFunction{
Name: "web_open",
Description: desc,
Parameters: map[string]any{
"type": "object",
"properties": props,
"required": []string{"url"},
},
}}
}
func webReadTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_read",
Description: "Lit une plage de lignes d'une URL déjà ouverte avec web_open. Lignes 1-indexées, préfixées par leur numéro.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "URL ouverte avec web_open"},
"offset": map[string]any{"type": "integer", "description": "Ligne de départ (défaut 1)"},
"limit": map[string]any{"type": "integer", "description": "Défaut 80, max 500"},
},
"required": []string{"url"},
},
}}
}
func webGrepTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "web_grep",
Description: "Recherche regex dans une URL déjà ouverte avec web_open → lignes correspondantes, " +
"avec contexte et numéros. Idéal sur une page longue dont on connaît un mot-clé.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "URL ouverte avec web_open"},
"pattern": map[string]any{"type": "string", "description": "Regex (insensible à la casse)"},
"context": map[string]any{"type": "integer", "description": "Lignes de contexte. Défaut 2."},
"max_matches": map[string]any{"type": "integer", "description": "Plafond. Défaut 30."},
},
"required": []string{"url", "pattern"},
},
}}
}
// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
// webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme
// toolMaxOutput (8000) pour le shell et mcpOutputCap pour MCP. Sans ce
// plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000
// caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui
// déclenchait des compactages en cascade au milieu du raisonnement.
const webMaxOutput = 8000
// capWebOutput tronque en gardant le DÉBUT (contrairement au shell, où c'est la
// fin qui porte l'info) et dit au modèle comment lire la suite proprement.
func capWebOutput(s string) string {
if r := []rune(s); len(r) > webMaxOutput {
return string(r[:webMaxOutput]) +
"\n…[tronqué : réponse trop longue. Relis par tranches avec web_read(offset, limit) ou cible avec web_grep.]"
}
return s
}
func toolWebSearch(args map[string]any) string {
query, _ := args["query"].(string)
limit := 8
if v, ok := args["limit"].(float64); ok {
limit = int(v)
}
if limit < 1 {
limit = 1
}
if limit > 20 {
limit = 20
}
results, err := duckduckgoSearch(query, limit)
if err != nil {
return "❌ Recherche échouée : " + err.Error()
}
if len(results) == 0 {
return fmt.Sprintf("Aucun résultat pour « %s »", query)
}
var b strings.Builder
fmt.Fprintf(&b, "# Recherche : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results))
for i, r := range results {
fmt.Fprintf(&b, "%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)
}
return strings.TrimRight(b.String(), "\n")
}
func toolWebOpen(args map[string]any) string {
u, _ := args["url"].(string)
opts := fetchOptions{dismissPopups: true}
if v, ok := args["refresh"].(bool); ok {
opts.force = v
}
if v, ok := args["dismiss_popups"].(bool); ok {
opts.dismissPopups = v
}
if v, ok := args["wait_for"].(string); ok {
opts.waitFor = v
}
if arr, ok := args["actions"].([]any); ok {
for _, a := range arr {
if s, ok := a.(string); ok {
opts.actions = append(opts.actions, s)
}
}
}
entry, err := getPage(u, opts)
if err != nil {
return "❌ " + err.Error()
}
total := len(entry.lines)
chars := total
for _, l := range entry.lines {
chars += len(l)
}
return fmt.Sprintf("# Ouvert : %s\nTotal : %d lignes, %s (%d caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n%s\n```",
entry.url, total, formatBytes(chars), chars, extractOutline(entry.lines))
}
func toolWebRead(args map[string]any) string {
u, _ := args["url"].(string)
entry := findCached(u)
if entry == nil {
return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
}
total := len(entry.lines)
offset := 1
if v, ok := args["offset"].(float64); ok {
offset = int(v)
}
if offset < 1 {
offset = 1
}
limit := 80
if v, ok := args["limit"].(float64); ok {
limit = int(v)
}
if limit < 1 {
limit = 1
}
if limit > 500 {
limit = 500
}
start := offset - 1
if start > total {
start = total
}
end := start + limit
if end > total {
end = total
}
slice := entry.lines[start:end]
remaining := total - end
tail := " (fin de page)"
if remaining > 0 {
tail = fmt.Sprintf(" (%d de plus en dessous)", remaining)
}
return fmt.Sprintf("# %s\nLignes %d–%d sur %d%s\n\n```\n%s\n```",
entry.url, offset, end, total, tail, formatLines(slice, offset))
}
func toolWebGrep(args map[string]any) string {
u, _ := args["url"].(string)
pattern, _ := args["pattern"].(string)
entry := findCached(u)
if entry == nil {
return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
}
re, err := regexp.Compile("(?i)" + pattern)
if err != nil {
return "❌ Regex invalide : " + err.Error()
}
ctx := 2
if v, ok := args["context"].(float64); ok {
ctx = int(v)
}
if ctx < 0 {
ctx = 0
}
maxMatches := 30
if v, ok := args["max_matches"].(float64); ok {
maxMatches = int(v)
}
if maxMatches < 1 {
maxMatches = 1
}
lines := entry.lines
var matchIdx []int
for i := 0; i < len(lines) && len(matchIdx) < maxMatches; i++ {
if re.MatchString(lines[i]) {
matchIdx = append(matchIdx, i)
}
}
if len(matchIdx) == 0 {
return fmt.Sprintf("# %s\nAucun match pour /%s/i", entry.url, pattern)
}
// Fusionne les fenêtres de contexte qui se chevauchent.
type rng struct{ s, e int }
var ranges []rng
for _, i := range matchIdx {
s := i - ctx
if s < 0 {
s = 0
}
e := i + ctx
if e > len(lines)-1 {
e = len(lines) - 1
}
if n := len(ranges); n > 0 && s <= ranges[n-1].e+1 {
if e > ranges[n-1].e {
ranges[n-1].e = e
}
} else {
ranges = append(ranges, rng{s, e})
}
}
var blocks []string
for _, r := range ranges {
blocks = append(blocks, "```\n"+formatLines(lines[r.s:r.e+1], r.s+1)+"\n```")
}
capped := ""
if len(matchIdx) == maxMatches {
capped = fmt.Sprintf(" (plafonné à %d)", maxMatches)
}
return fmt.Sprintf("# %s\n%d match(es) pour /%s/i%s\n\n%s",
entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n"))
}
// ─── CLI : loki internet [on|off|status|engine|url|key] ────────────────────
func cmdInternet(args []string) error {
sub := ""
if len(args) > 0 {
sub = args[0]
}
switch sub {
case "on":
// Le moteur intégré ne demande aucun réglage ; Crawl4AI exige un serveur.
if webEngine() == engineCrawl && crawl4aiURL() == "" {
return fmt.Errorf("configure d'abord l'URL : loki internet url <url> (ou bascule sur le moteur intégré : loki internet engine go)")
}
if err := setInternetEnabled(true); err != nil {
return err
}
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_images/web_open/web_read/web_grep (si le mode agent est actif)")
case "off":
if err := setInternetEnabled(false); err != nil {
return err
}
fmt.Println(green("[ok]") + " accès internet désactivé")
case "url":
if len(args) < 2 {
return fmt.Errorf("usage: loki internet url <url> (ex: http://localhost:11235)")
}
u := strings.TrimRight(strings.TrimSpace(args[1]), "/")
if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
return err
}
reachMu.Lock()
reachURL = "" // invalide le cache de reachability
reachMu.Unlock()
fmt.Printf("%s serveur Crawl4AI : %s\n", green("[ok]"), bold(u))
case "key":
if len(args) < 2 {
return fmt.Errorf("usage: loki internet key <clé> (vide pour l'enlever : loki internet key \"\")")
}
k := strings.TrimSpace(args[1])
if err := writeCrawlKey(k); err != nil {
return err
}
reachMu.Lock()
reachURL = ""
reachMu.Unlock()
if k == "" {
fmt.Println(green("[ok]") + " clé Crawl4AI retirée")
} else {
fmt.Println(green("[ok]") + " clé Crawl4AI enregistrée")
}
case "engine":
if len(args) < 2 {
return fmt.Errorf("usage: loki internet engine <go|crawl4ai>")
}
e := strings.ToLower(strings.TrimSpace(args[1]))
if err := setWebEngine(e); err != nil {
return err
}
if e == engineGo {
fmt.Println(green("[ok]") + " moteur intégré — aucune installation requise (pas de rendu JavaScript)")
} else {
fmt.Println(green("[ok]") + " moteur Crawl4AI — configure le serveur : loki internet url <url>")
}
case "", "status", "list":
state := dim("off")
if internetEnabled() {
state = green("on")
}
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
if webEngine() == engineGo {
fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
return nil
}
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
u := crawl4aiURL()
if u == "" {
fmt.Printf(" serveur : %s — configure : loki internet url <url>\n", dim("(non configuré)"))
return nil
}
reach := red("injoignable")
if crawlReachable() {
reach = green("joignable")
}
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
default:
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
}
return nil
}