// 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 mcpMaxOutput (12000) 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 (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_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 (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 (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 ") } 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 ") } 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_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 \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_open, web_read, web_grep\n") default: return fmt.Errorf("usage: loki internet [on|off|status|engine |url |key ]") } return nil }