From 3dba323aab96b1ce6959423f72ff0480ee47e2e4 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 5 Aug 2026 11:48:45 +0200 Subject: [PATCH] v0.7.8 : l'acces internet sans rien installer Moteur web integre en pur Go (http + readability + html-to-markdown), selectionnable par WEB_ENGINE. Crawl4AI reste disponible pour le rendu JavaScript et reste le defaut des installations qui l'ont deja configure. Le schema de web_open n'annonce plus actions/wait_for/dismiss_popups avec le moteur integre, et une page riche en HTML mais sans texte est diagnostiquee comme rendue en JavaScript, pour que le modele change de source au lieu de rouvrir la meme URL en boucle. --- README.md | 23 +- RELEASE_NOTES.md | 39 +-- go.mod | 17 +- go.sum | 46 ++- internal/ajean/chat_internet.go | 30 +- internal/ajean/chat_internet_tools.go | 73 +++-- internal/ajean/run.go | 5 +- internal/ajean/ui/index.html | 59 ++-- internal/ajean/ui/src/index.tmpl.html | 37 ++- internal/ajean/ui/src/js/06-settings.js | 22 +- internal/ajean/web_api.go | 10 + internal/ajean/web_fetch_go.go | 390 ++++++++++++++++++++++++ 12 files changed, 648 insertions(+), 103 deletions(-) create mode 100644 internal/ajean/web_fetch_go.go diff --git a/README.md b/README.md index 51d92e7..7483e50 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur | terminal | exécute une commande (bash sous Unix, `cmd.exe` sous Windows) | | write / edit | écrit un fichier, ou le modifie par remplacement exact | | mem_* | mémoire Markdown persistante entre les sessions | -| web_* | recherche et lecture de pages, via Crawl4AI | +| web_* | recherche et lecture de pages | | mcp__* | les outils des serveurs MCP configurés | **Le matériel et le moteur, gérés pour vous.** `ajean llamacpp install` clone et compile llama.cpp avec les bons flags pour *cette* machine : CUDA (capacité de calcul détectée par GPU, donc le multi-GPU fonctionne), ROCm, Metal, Vulkan, ou repli CPU. `ajean llamacpp update` récupère le dernier commit, arrête le service le temps de recompiler, puis le redémarre. @@ -87,7 +87,7 @@ Service : Capacités de l'IA : agent [on|off|status] active TOUS les outils (terminal, fichiers, mémoire) memory [off|ondemand|always] mode mémoire - internet [on|off|url |key ] accès web via Crawl4AI + internet [on|off|engine |url |key ] accès web Presets et moteur : switch [N] changer de preset (configs/) @@ -156,16 +156,27 @@ ajean memory off # mémoire coupée ### Accès internet -Par défaut, l'IA n'a pas accès au web. En branchant un serveur [Crawl4AI](https://github.com/unclecode/crawl4ai), elle gagne `web_search` (DuckDuckGo), `web_open`, `web_read` et `web_grep`. **AJEAN ne fournit pas ce serveur, il s'y branche :** +Par défaut, l'IA n'a pas accès au web. Une fois activé, elle gagne `web_search` (DuckDuckGo), `web_open`, `web_read` et `web_grep`. Deux moteurs sont disponibles. + +**Moteur intégré (défaut)** — inclus dans le binaire, rien à installer : ```bash -docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest -ajean internet url http://localhost:11235 ajean internet on ajean internet status ``` -Les outils web ne sont proposés au modèle que si le mode agent est actif, l'accès internet activé **et** le serveur joignable. Sinon ils n'existent pas, et le modèle ne peut donc pas les inventer. +Il récupère les pages en HTTP, en extrait le contenu (Readability) et le convertit en markdown. Il n'exécute pas le JavaScript : une page entièrement rendue côté client ressort vide. Docs, articles, blogs, Wikipédia, GitHub et forums passent sans problème. + +**Moteur Crawl4AI** — un serveur [Crawl4AI](https://github.com/unclecode/crawl4ai) que vous hébergez, avec Chromium headless, donc rendu JavaScript complet. **AJEAN ne fournit pas ce serveur, il s'y branche :** + +```bash +docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest +ajean internet engine crawl4ai +ajean internet url http://localhost:11235 +ajean internet on +``` + +Les outils web ne sont proposés au modèle que si le mode agent est actif, l'accès internet activé **et** — avec Crawl4AI — le serveur joignable. Sinon ils n'existent pas, et le modèle ne peut donc pas les inventer. ### Serveurs MCP diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0cd8945..3e3d4eb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,34 +1,31 @@ -Cette version répare la recherche sur internet. Quand une recherche devenait longue, l'IA tournait en rond : elle rouvrait sans fin la même page, repartait de zéro, ou répondait soudain à une question posée bien plus tôt. Ce n'était pas le modèle, c'était le compactage du contexte qui effaçait exactement ce dont elle avait besoin. +Cette version donne à l'IA l'accès à internet sans rien installer. Jusqu'ici il fallait héberger un serveur Crawl4AI — un conteneur Docker, un Chromium, quelques centaines de mégaoctets — avant que l'IA puisse chercher ou lire quoi que ce soit sur le web. C'était une barrière que peu de gens franchissaient. AJEAN embarque maintenant son propre moteur web. -## L'IA ne repart plus de zéro au milieu d'une recherche +## Internet fonctionne dès l'installation -Quand la conversation devient trop longue pour la fenêtre de contexte, AJEAN la compacte : les vieux tours sont remplacés par un résumé. Ce résumé était fabriqué à partir d'un historique dont on avait d'abord effacé tous les résultats d'outils. Autrement dit, chaque page web lue avait déjà disparu quand le résumé était écrit. Le résumé ne pouvait donc contenir aucune des informations trouvées, seulement la trace que des recherches avaient eu lieu. +Le nouveau moteur est écrit en Go et vit dans le binaire. Il n'y a rien à télécharger, rien à lancer, aucune adresse de serveur à renseigner : vous activez l'accès internet, et l'IA dispose de `web_search`, `web_open`, `web_read` et `web_grep`. -Résultat vu de l'extérieur : l'IA relançait les mêmes recherches en boucle, sans jamais aboutir. +Il récupère les pages, en extrait le contenu réel — en écartant les menus, les bandeaux et les pieds de page — puis le convertit en texte lisible. La recherche passe par DuckDuckGo, dont les résultats sont désormais lus directement dans la structure de la page plutôt que devinés dans du texte reformaté : les titres, les adresses et les extraits sont plus fiables qu'avant. -Le résumé est maintenant écrit à partir des vraies pages lues. On lui demande explicitement de conserver les informations récoltées (faits, chiffres, dates, adresses consultées) et de dire où en est le travail : ce qui est répondu, ce qui manque, la prochaine étape. +## Crawl4AI reste disponible pour les pages en JavaScript -## La question en cours ne se perd plus +Le moteur intégré lit les pages telles que le serveur les envoie, sans exécuter leur JavaScript. C'est sans conséquence pour la documentation, les articles, les blogs, Wikipédia, les dépôts de code ou les forums, qui représentent l'essentiel de ce qu'une IA a besoin de consulter. En revanche, une application web dont le contenu n'apparaît qu'une fois le JavaScript exécuté restera illisible. -Pendant une recherche, un tour peut enchaîner des dizaines d'appels d'outils sans le moindre message de votre part. Le compactage protégeait les tours récents et le tout premier message de la conversation, mais votre demande en cours, elle, se retrouvait au milieu, et disparaissait dans le résumé. +Pour ces cas-là, le moteur Crawl4AI est toujours là, avec son navigateur complet. Le choix se fait dans le panneau « Accès internet » de l'interface, ou en ligne de commande : -L'IA ne voyait alors plus qu'une seule question explicite : la première de la conversation. Et elle y répondait, en abandonnant la recherche en cours. Votre demande du moment est désormais toujours conservée telle quelle. +``` +ajean internet engine go +ajean internet engine crawl4ai +``` -## Une même page n'est plus rouverte en boucle +Les installations qui utilisaient déjà un serveur Crawl4AI le gardent : rien ne change pour elles tant qu'elles n'ont pas choisi l'autre moteur. -Un appel d'outil rigoureusement identique n'était déjà pas rejoué, mais l'avertissement était collé à la fin du contenu renvoyé, noyé après plusieurs milliers de caractères. L'IA voyait le contenu, pas l'avertissement, et recommençait. +## L'IA sait maintenant quand une page lui est inaccessible -L'avertissement passe en tête, et à partir de la deuxième redemande le contenu n'est plus renvoyé du tout : redemander la même page ne rapporte plus rien et ne consomme plus de contexte. Le tour n'est jamais interrompu pour autant, une recherche qui enchaîne légitimement beaucoup d'appels reste possible. +Une page vide ne dit rien de ce qui s'est passé. L'IA en concluait qu'elle avait mal lu, et rouvrait la même adresse encore et encore. -De même, le message qui remplace un vieux résultat d'outil disait seulement qu'il avait été effacé, ce qui se lisait comme une invitation à retélécharger la page. Il dit maintenant clairement de ne pas le faire. +Quand une page arrive bien mais ne contient presque aucun texte, AJEAN reconnaît la signature d'un contenu affiché par JavaScript et l'explique à l'IA, en lui demandant de chercher ailleurs plutôt que d'insister. Les pages courtes mais légitimes, elles, continuent d'être lues normalement. -## Les pages web ne saturent plus le contexte - -La lecture d'une page n'avait aucune limite de taille, là où le terminal et les outils MCP en avaient une. Une seule lecture pouvait injecter 25 000 caractères d'un coup et remplir la fenêtre à elle seule, ce qui déclenchait les compactages en cascade décrits plus haut. La lecture est maintenant bornée, avec une indication de comment lire la suite par tranches. - -## Le compteur de tokens des outils dit enfin la vérité - -Sous chaque appel d'outil, l'étiquette « ~N tok » affichait le plus souvent la même valeur, autour de 1004, quelle que soit la page. Ce n'était pas la taille de la page mais celle du plafond appliqué à l'affichage. Ce plafond est aligné sur ce que voit réellement le modèle : les valeurs affichées varient donc désormais, et correspondent au coût réel. +Dans le même esprit, l'IA ne se voit plus proposer d'agir sur une page — dérouler une section, fermer un bandeau, attendre un élément — quand le moteur intégré est actif, puisqu'il ne peut pas le faire. Elle ne perd plus de temps à essayer. ## Mise à jour @@ -37,7 +34,3 @@ ajean update ``` Ou le bouton de l'interface. - -Les binaires restent publiés sous leurs deux noms, `ajean-*` et `jean-*`, le temps que les installations existantes basculent. - -L'icône de la barre de menus macOS, introduite en 0.6.10, n'a toujours pas été vérifiée sur une vraie machine. diff --git a/go.mod b/go.mod index 4d7ff1d..8dc79e7 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,20 @@ module github.com/nathaninline/ajean go 1.25.0 require ( + codeberg.org/readeck/go-readability/v2 v2.1.2 + github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 github.com/caddyserver/certmagic v0.25.4 github.com/coder/websocket v1.8.15 github.com/getlantern/systray v1.2.2 github.com/hashicorp/yamux v0.1.2 + github.com/modelcontextprotocol/go-sdk v1.6.1 golang.org/x/mod v0.35.0 + golang.org/x/net v0.55.0 ) require ( + github.com/JohannesKaufmann/dom v0.3.1 // indirect + github.com/andybalholm/cascadia v1.3.4 // indirect github.com/caddyserver/zerossl v0.1.5 // indirect github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect @@ -18,13 +24,15 @@ require ( github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect + github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect github.com/go-stack/stack v1.8.0 // indirect + github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/google/jsonschema-go v0.4.3 // indirect + github.com/itlightning/dateparse v0.2.1 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/libdns/libdns v1.1.1 // indirect github.com/mholt/acmez/v3 v3.1.6 // indirect github.com/miekg/dns v1.1.72 // indirect - github.com/modelcontextprotocol/go-sdk v1.6.1 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect @@ -33,11 +41,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/net v0.53.0 // indirect + golang.org/x/crypto v0.51.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect ) diff --git a/go.sum b/go.sum index d5652f5..6e85cf1 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,13 @@ code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE= code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM= +codeberg.org/readeck/go-readability/v2 v2.1.2 h1:JBrdyYJBRPMBbodLM1b5KxCSDH+JqCkGcuVRD7ICBAw= +codeberg.org/readeck/go-readability/v2 v2.1.2/go.mod h1:Ut31sW4osSrJPR3T8eQslMh4+jbwimXqn0w0ReCT+PU= +github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0= +github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA= +github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg= +github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM= github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg= github.com/caddyserver/certmagic v0.25.4/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA= github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE= @@ -25,14 +33,22 @@ github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sTho github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w= +github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM= github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8= github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns= +github.com/itlightning/dateparse v0.2.1 h1:AB0NJTyI0HYcerEUMovKZOiQVBg1mBPxgAnWQwzLP6g= +github.com/itlightning/dateparse v0.2.1/go.mod h1:xHlmL8lT0L9JIBlaKotRwsoDYpKJskXpiU9ZwbbSkNA= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU= @@ -53,17 +69,23 @@ github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgF github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= +github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= @@ -78,22 +100,22 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= diff --git a/internal/ajean/chat_internet.go b/internal/ajean/chat_internet.go index 0d9c947..90011f6 100644 --- a/internal/ajean/chat_internet.go +++ b/internal/ajean/chat_internet.go @@ -87,9 +87,12 @@ func crawlAuth(req *http.Request) { } // internetEnabled : accès internet actif = drapeau .internet_enabled présent ET -// une URL Crawl4AI configurée. Même modèle que agentEnabled() (chat_agent.go). +// un moteur web utilisable. Même modèle que agentEnabled() (chat_agent.go). +// +// Avec le moteur intégré (webEngine() == engineGo) il n'y a rien à configurer : +// le drapeau suffit. Avec Crawl4AI il faut en plus une URL de serveur. func internetEnabled() bool { - if crawl4aiURL() == "" { + if webEngine() == engineCrawl && crawl4aiURL() == "" { return false } _, err := os.Stat(internetFlag()) @@ -124,6 +127,12 @@ var ( const reachTTL = 30 * time.Second func crawlReachable() bool { + if webEngine() == engineGo { + // Moteur intégré : rien à joindre, il tourne dans ce process. On ne + // teste pas la connectivité internet ici — un ping à chaque tour de + // chat coûterait plus cher que l'échec de la requête réelle. + return true + } base := crawl4aiURL() if base == "" { return false @@ -432,10 +441,20 @@ func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) { if opts.waitFor != "" || len(opts.actions) > 0 { pageTimeout = 45000 } - md, err := runCrwl(u, crwlOptions{jsCode: jsCode, waitFor: opts.waitFor, pageTimeoutMs: pageTimeout}) + var md string + var err error + if webEngine() == engineGo { + // Moteur intégré : pas de DOM vivant, donc jsCode / waitFor / actions + // sont sans objet (voir web_fetch_go.go). + md, err = goFetchMarkdown(u) + } else { + md, err = runCrwl(u, crwlOptions{jsCode: jsCode, waitFor: opts.waitFor, pageTimeoutMs: pageTimeout}) + } if err != nil { return nil, err } + // Le diagnostic « probablement du JavaScript » est posé par goFetchMarkdown, + // qui seul connaît la taille du HTML brut. Ici on n'attrape que le cas trivial. if strings.TrimSpace(md) == "" { return nil, fmt.Errorf("page vide") } @@ -534,6 +553,11 @@ func decodeUddg(raw string) string { } func duckduckgoSearch(query string, limit int) ([]searchResult, error) { + if webEngine() == engineGo { + // Le moteur intégré lit la structure HTML des résultats plutôt que de + // reparser un markdown intermédiaire. + return goSearch(query, limit) + } searchURL := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query) md, err := runCrwl(searchURL, crwlOptions{rawMarkdown: true, pageTimeoutMs: 30000}) if err != nil { diff --git a/internal/ajean/chat_internet_tools.go b/internal/ajean/chat_internet_tools.go index bf92f47..cccf4cb 100644 --- a/internal/ajean/chat_internet_tools.go +++ b/internal/ajean/chat_internet_tools.go @@ -25,23 +25,40 @@ func webSearchTool() Tool { }} } +// 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 les métadonnées (taille, nb de lignes, plan des titres). " + + "Ne renvoie PAS le contenu. Toujours appeler ceci d'abord avant de lire. Résultat en cache 10 min " + + "— les web_read / web_grep suivants le réutilisent." + props := map[string]any{ + "url": map[string]any{"type": "string", "description": "URL complète à récupérer"}, + "refresh": map[string]any{"type": "boolean", "description": "Ignore le cache et re-fetch. Défaut false."}, + } + if webEngine() == engineGo { + desc += " ⚠ Ce moteur lit le HTML servi SANS exécuter le JavaScript : une page rendue " + + "entièrement côté client ressortira vide. Dans ce cas, ne réessaie pas la même URL — " + + "cherche une autre source (doc officielle, dépôt, article)." + } 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: "Récupère une URL et renvoie SEULEMENT les métadonnées (taille, nb de lignes, plan des titres). " + - "Ne renvoie PAS le contenu. Toujours appeler ceci d'abord avant de lire. Résultat en cache 10 min " + - "— les web_read / web_grep suivants le réutilisent.", + Name: "web_open", + Description: desc, Parameters: map[string]any{ - "type": "object", - "properties": map[string]any{ - "url": map[string]any{"type": "string", "description": "URL complète à récupérer"}, - "refresh": map[string]any{"type": "boolean", "description": "Ignore le cache et re-fetch. Défaut false."}, - "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.)."}, - "dismiss_popups": map[string]any{"type": "boolean", "description": "Ferme auto les bandeaux cookies/overlays. Défaut true."}, - "wait_for": map[string]any{"type": "string", "description": "Sélecteur CSS ou expr JS à attendre après les actions."}, - }, - "required": []string{"url"}, + "type": "object", + "properties": props, + "required": []string{"url"}, }, }} } @@ -268,7 +285,7 @@ func toolWebGrep(args map[string]any) string { entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n")) } -// ─── CLI : ajean internet [on|off|status|url ] ────────────────────────── +// ─── CLI : ajean internet [on|off|status|engine|url|key] ──────────────────── func cmdInternet(args []string) error { sub := "" @@ -277,8 +294,9 @@ func cmdInternet(args []string) error { } switch sub { case "on": - if crawl4aiURL() == "" { - return fmt.Errorf("configure d'abord l'URL : ajean internet url ") + // 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 : ajean internet url (ou bascule sur le moteur intégré : ajean internet engine go)") } if err := setInternetEnabled(true); err != nil { return err @@ -317,12 +335,31 @@ func cmdInternet(args []string) error { } else { fmt.Println(green("[ok]") + " clé Crawl4AI enregistrée") } + case "engine": + if len(args) < 2 { + return fmt.Errorf("usage: ajean 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 : ajean 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 : ajean internet url \n", dim("(non configuré)")) @@ -335,7 +372,7 @@ func cmdInternet(args []string) error { 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: ajean internet [on|off|status|url ]") + return fmt.Errorf("usage: ajean internet [on|off|status|engine |url |key ]") } return nil } diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 4a31fae..6ccfe65 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.7.7" +const Version = "0.7.8" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { @@ -140,7 +140,8 @@ Presets: Interaction: chat [system-prompt] chat terminal streamé web [PORT] UI web (défaut :8090) — chat + presets + mode agent - internet [on|off|status|url |key ] accès web de l'IA via un serveur Crawl4AI (web_search/open/read/grep) + internet [on|off|status|engine |url |key ] + accès web de l'IA (web_search/open/read/grep) — moteur intégré ou serveur Crawl4AI memory [off|ondemand|always|status] mode mémoire de l'IA (off / sur demande / auto) Accès distant (ajean.link) : diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index a0bde28..a67711f 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -916,22 +916,33 @@ button:hover{border-color:var(--dim);color:var(--text)}
-
Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep via un serveur Crawl4AI que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest). Actifs seulement si le mode agent est actif et que le serveur répond.
+
Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep. Le moteur intégré ne demande aucune installation. Le moteur Crawl4AI exige un serveur que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest) mais exécute le JavaScript des pages. Actifs seulement si le mode agent est actif.
- +
+ +
+ -
- +
+
+ +
+ +
+ + +
+
enregistré automatiquement en quittant le champ
+
- -
- - -
-
enregistré automatiquement en quittant le champ
-
@@ -1895,15 +1906,21 @@ async function setMemMode(){ renderMemModeDesc(r.mode||mode); } // Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte. -let internetOn=false; +let internetOn=false, webEngine='go'; function renderInternet(s){ internetOn = !!s.enabled; + webEngine = s.engine || 'go'; document.getElementById('internet-toggle').checked = internetOn; + const sel=document.getElementById('web-engine'); + if(sel && document.activeElement!==sel) sel.value = webEngine; + // Les réglages Crawl4AI n'ont aucun sens avec le moteur intégré. + const cf=document.getElementById('crawl-fields'); + if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none'; if(document.activeElement !== document.getElementById('crawl-url')) document.getElementById('crawl-url').value = s.url || ''; // Actif mais serveur injoignable : la pastille le dit (sinon l'UI ment — les // outils web ne sont pas proposés au modèle dans ce cas). - if(internetOn && s.url && !s.reachable) setBadge('internet-badge','warn','injoignable'); + if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable'); else setBadge('internet-badge', internetOn, internetOn?'actif':'inactif'); // Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée), // on indique seulement si une clé est enregistrée. @@ -1916,7 +1933,8 @@ function renderInternet(s){ if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)'; } const st=document.getElementById('internet-status'); - if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } + if(webEngine!=='crawl4ai'){ st.innerHTML='✓ moteur intégré — aucune installation requise (pas de rendu JavaScript)'; } + else if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } else if(s.reachable){ st.innerHTML='✓ serveur joignable — outils web actifs'; } else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; } } @@ -1989,9 +2007,16 @@ async function apiKeySet(){ async function toggleInternet(){ const on=document.getElementById('internet-toggle').checked; const url=document.getElementById('crawl-url').value.trim(); - if(on && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; } + // Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a + // besoin d'aucun réglage : on active directement. + if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; } renderInternet(await jpost('/api/internet',{enabled:on, url})); } +async function saveWebEngine(){ + const engine=document.getElementById('web-engine').value; + renderInternet(await jpost('/api/internet',{engine})); + toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné'); +} async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index 78cc5ad..9f14db2 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -88,22 +88,33 @@
-
Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep via un serveur Crawl4AI que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest). Actifs seulement si le mode agent est actif et que le serveur répond.
+
Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep. Le moteur intégré ne demande aucune installation. Le moteur Crawl4AI exige un serveur que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest) mais exécute le JavaScript des pages. Actifs seulement si le mode agent est actif.
- +
+ +
+ -
- +
+
+ +
+ +
+ + +
+
enregistré automatiquement en quittant le champ
+
- -
- - -
-
enregistré automatiquement en quittant le champ
-
diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index f80a319..b308c6d 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -132,15 +132,21 @@ async function setMemMode(){ renderMemModeDesc(r.mode||mode); } // Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte. -let internetOn=false; +let internetOn=false, webEngine='go'; function renderInternet(s){ internetOn = !!s.enabled; + webEngine = s.engine || 'go'; document.getElementById('internet-toggle').checked = internetOn; + const sel=document.getElementById('web-engine'); + if(sel && document.activeElement!==sel) sel.value = webEngine; + // Les réglages Crawl4AI n'ont aucun sens avec le moteur intégré. + const cf=document.getElementById('crawl-fields'); + if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none'; if(document.activeElement !== document.getElementById('crawl-url')) document.getElementById('crawl-url').value = s.url || ''; // Actif mais serveur injoignable : la pastille le dit (sinon l'UI ment — les // outils web ne sont pas proposés au modèle dans ce cas). - if(internetOn && s.url && !s.reachable) setBadge('internet-badge','warn','injoignable'); + if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable'); else setBadge('internet-badge', internetOn, internetOn?'actif':'inactif'); // Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée), // on indique seulement si une clé est enregistrée. @@ -153,7 +159,8 @@ function renderInternet(s){ if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)'; } const st=document.getElementById('internet-status'); - if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } + if(webEngine!=='crawl4ai'){ st.innerHTML='✓ moteur intégré — aucune installation requise (pas de rendu JavaScript)'; } + else if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } else if(s.reachable){ st.innerHTML='✓ serveur joignable — outils web actifs'; } else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; } } @@ -226,9 +233,16 @@ async function apiKeySet(){ async function toggleInternet(){ const on=document.getElementById('internet-toggle').checked; const url=document.getElementById('crawl-url').value.trim(); - if(on && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; } + // Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a + // besoin d'aucun réglage : on active directement. + if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; } renderInternet(await jpost('/api/internet',{enabled:on, url})); } +async function saveWebEngine(){ + const engine=document.getElementById('web-engine').value; + renderInternet(await jpost('/api/internet',{engine})); + toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné'); +} async function saveCrawlUrl(){ const url=document.getElementById('crawl-url').value.trim(); renderInternet(await jpost('/api/internet',{url})); diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index fd0b98c..7583f5e 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -582,8 +582,17 @@ func handleInternet(w http.ResponseWriter, r *http.Request) { Enabled *bool `json:"enabled"` URL *string `json:"url"` Key *string `json:"key"` + Engine *string `json:"engine"` } _ = json.NewDecoder(r.Body).Decode(&req) + // Moteur web : "go" (intégré, rien à installer) ou "crawl4ai" (serveur + // externe, rendu JavaScript). + if req.Engine != nil { + if err := setWebEngine(strings.ToLower(strings.TrimSpace(*req.Engine))); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + } // Clé d'accès au serveur Crawl4AI : chaîne vide = on l'enlève. if req.Key != nil { if err := writeCrawlKey(strings.TrimSpace(*req.Key)); err != nil { @@ -622,6 +631,7 @@ func handleInternet(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{ "ok": true, "enabled": internetEnabled(), + "engine": webEngine(), "url": crawl4aiURL(), "reachable": crawlReachable(), "key_set": key != "", diff --git a/internal/ajean/web_fetch_go.go b/internal/ajean/web_fetch_go.go new file mode 100644 index 0000000..ac4ca7e --- /dev/null +++ b/internal/ajean/web_fetch_go.go @@ -0,0 +1,390 @@ +package ajean + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + readability "codeberg.org/readeck/go-readability/v2" + htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" + "github.com/JohannesKaufmann/html-to-markdown/v2/converter" + "golang.org/x/net/html" + "golang.org/x/net/html/charset" +) + +// Moteur web INTÉGRÉ (pur Go) — alternative à Crawl4AI, sans rien à installer. +// +// Crawl4AI = Chromium headless piloté par Playwright : il exécute le JS de la +// page avant d'en extraire le markdown. C'est puissant mais ça demande à +// l'utilisateur de faire tourner un serveur Python/Docker — rédhibitoire pour +// un utilisateur non technique. +// +// Ce moteur-ci remplace UNIQUEMENT l'étape « URL → markdown » (runCrwl) par du +// Go pur : +// +// http.Get → readability (port Go de Readability de Firefox, qui vire nav, +// pubs, pieds de page) → html-to-markdown +// +// Ce qu'on perd : le rendu JavaScript. Une page 100 % React sans SSR ressort +// vide, et les options js_code / wait_for / actions n'ont plus de sens (pas de +// DOM vivant). En pratique, les sources que l'IA lit — docs, articles, blogs, +// Wikipédia, GitHub, forums — sont servies en HTML et passent très bien. +// +// Le choix se fait par la clé de config WEB_ENGINE ("go" ou "crawl4ai"). Voir +// webEngine(). Crawl4AI reste entièrement supporté. + +// ─── sélection du moteur ──────────────────────────────────────────────────── + +const ( + engineGo = "go" + engineCrawl = "crawl4ai" +) + +// webEngine renvoie le moteur web actif : engineGo ou engineCrawl. +// +// Par défaut on prend le moteur intégré. L'exception est l'installation qui a +// DÉJÀ une URL Crawl4AI configurée sans avoir jamais choisi de moteur : elle +// tournait sur Crawl4AI, on ne change pas son comportement dans son dos. +func webEngine() string { + switch strings.ToLower(strings.TrimSpace(ReadConfig()["WEB_ENGINE"])) { + case engineGo: + return engineGo + case engineCrawl: + return engineCrawl + } + if crawl4aiURL() != "" { + return engineCrawl + } + return engineGo +} + +// setWebEngine enregistre le moteur web choisi et invalide le cache de +// reachability (l'état affiché dépend du moteur). +func setWebEngine(engine string) error { + if engine != engineGo && engine != engineCrawl { + return fmt.Errorf("moteur web inconnu : %q", engine) + } + if err := SetConfigKey("WEB_ENGINE", engine); err != nil { + return err + } + reachMu.Lock() + reachURL = "" + reachMu.Unlock() + return nil +} + +// ─── récupération HTTP ────────────────────────────────────────────────────── + +// Un vrai User-Agent : beaucoup de sites servent une page dégradée (ou un 403) +// aux clients qui s'annoncent comme des robots. +const goFetchUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + +const ( + goFetchTimeout = 30 * time.Second + goFetchMaxBytes = 8 << 20 // 8 Mo : au-delà ce n'est plus une page à lire +) + +// goHTTPClient : client dédié au moteur web. Timeout global et plafond de +// redirections. On ne touche pas à http.DefaultClient (utilisé ailleurs). +var goHTTPClient = &http.Client{ + Timeout: goFetchTimeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("trop de redirections") + } + return nil + }, +} + +// goFetch effectue le GET et renvoie le corps (plafonné), le Content-Type et +// l'URL finale après redirections. +func goFetch(ctx context.Context, target string) (body []byte, contentType, finalURL string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, "", "", err + } + req.Header.Set("User-Agent", goFetchUA) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7") + req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8") + // Pas d'Accept-Encoding manuel : le transport gère gzip tout seul. + + resp, err := goHTTPClient.Do(req) + if err != nil { + return nil, "", "", fmt.Errorf("échec de la requête : %v", err) + } + defer resp.Body.Close() + + b, err := io.ReadAll(io.LimitReader(resp.Body, goFetchMaxBytes)) + if err != nil { + return nil, "", "", fmt.Errorf("lecture interrompue : %v", err) + } + if resp.StatusCode >= 400 { + return nil, "", "", fmt.Errorf("HTTP %d sur %s", resp.StatusCode, target) + } + return b, resp.Header.Get("Content-Type"), resp.Request.URL.String(), nil +} + +// goParseHTML décode le corps selon le charset annoncé (ou détecté) puis le +// parse en arbre DOM. +func goParseHTML(body []byte, contentType string) (*html.Node, error) { + r, err := charset.NewReader(strings.NewReader(string(body)), contentType) + if err != nil { + // Charset inconnu : on tente en l'état plutôt que d'abandonner. + r = strings.NewReader(string(body)) + } + return html.Parse(r) +} + +// ─── URL → markdown ───────────────────────────────────────────────────────── + +// goFetchMarkdown est l'équivalent pur Go de runCrwl : elle récupère une URL et +// en renvoie le contenu en markdown. +// +// Les contenus non-HTML (texte brut, markdown, JSON…) sont renvoyés tels quels : +// c'est le cas des README GitHub, vers lesquels normalizeCrawlURL redirige. +func goFetchMarkdown(target string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) + defer cancel() + + body, contentType, finalURL, err := goFetch(ctx, target) + if err != nil { + return "", err + } + + ct := strings.ToLower(contentType) + isHTML := strings.Contains(ct, "html") || strings.Contains(ct, "xml") + if ct == "" { + // Certains serveurs n'annoncent rien : on devine sur le contenu. + isHTML = strings.Contains(strings.ToLower(string(body[:min(len(body), 1024)])), " jsShellHTMLMin { + return "", fmt.Errorf("page récupérée (%s de HTML) mais seulement %d caractères de texte : "+ + "elle est très probablement rendue en JavaScript, ce que le moteur web intégré n'exécute pas. "+ + "Ne réessaie PAS cette URL — cherche une autre source (documentation officielle, dépôt du projet, "+ + "article qui en parle)", formatBytes(len(body)), len(strings.TrimSpace(md))) + } + return md, nil +} + +// Seuils du diagnostic « rendu JavaScript » (voir goFetchMarkdown). +const ( + jsShellTextMax = 200 // texte extrait en dessous duquel il n'y a rien à lire + jsShellHTMLMin = 15000 // HTML au-dessus duquel « rien à lire » devient suspect +) + +// goArticleMarkdown applique Readability puis convertit le résultat. Renvoie du +// markdown vide (sans erreur) si la page n'a pas de contenu d'article isolable. +func goArticleMarkdown(doc *html.Node, base *url.URL) (md, title string) { + // Readability mute l'arbre qu'on lui donne ; on lui passe une copie pour + // pouvoir retomber sur le document complet en cas d'échec. + clone, err := goCloneDoc(doc) + if err != nil { + return "", "" + } + art, err := readability.FromDocument(clone, base) + if err != nil || art.Node == nil { + return "", "" + } + out, err := goConvert(art.Node, baseString(base)) + if err != nil { + return "", "" + } + return out, strings.TrimSpace(art.Title()) +} + +// goConvert rend un nœud HTML en markdown, en réécrivant les liens et images +// relatifs en URLs absolues (sinon l'IA ne peut pas les rouvrir). +func goConvert(node *html.Node, baseURL string) (string, error) { + opts := []converter.ConvertOptionFunc{} + if baseURL != "" { + opts = append(opts, converter.WithDomain(baseURL)) + } + b, err := htmltomarkdown.ConvertNode(node, opts...) + if err != nil { + return "", fmt.Errorf("conversion markdown : %v", err) + } + return string(b), nil +} + +// goCloneDoc duplique un document HTML en le re-parsant depuis son rendu. +func goCloneDoc(doc *html.Node) (*html.Node, error) { + var sb strings.Builder + if err := html.Render(&sb, doc); err != nil { + return nil, err + } + return html.Parse(strings.NewReader(sb.String())) +} + +func baseString(u *url.URL) string { + if u == nil { + return "" + } + return u.Scheme + "://" + u.Host +} + +// ─── recherche DuckDuckGo (parsing HTML direct) ───────────────────────────── + +// goSearch interroge le point d'entrée HTML de DuckDuckGo et lit les résultats +// directement dans le DOM. +// +// Le chemin Crawl4AI passe par une conversion en markdown avant de reparser le +// texte à coups de regex (duckduckgoSearch) ; ici on lit la structure réelle +// (.result__a, .result__snippet), ce qui est nettement plus robuste. +func goSearch(query string, limit int) ([]searchResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) + defer cancel() + + target := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query) + body, contentType, _, err := goFetch(ctx, target) + if err != nil { + return nil, err + } + if strings.Contains(string(body), "anomaly-modal") || strings.Contains(string(body), "anomaly.js") { + return nil, fmt.Errorf("DuckDuckGo a renvoyé un défi anti-bot") + } + doc, err := goParseHTML(body, contentType) + if err != nil { + return nil, fmt.Errorf("HTML illisible : %v", err) + } + + var results []searchResult + seen := map[string]bool{} + for _, block := range goFindByClass(doc, "result") { + if len(results) >= limit { + break + } + link := goFirstByClass(block, "result__a") + if link == nil { + continue + } + href := decodeUddg(goAttr(link, "href")) + title := goText(link) + if title == "" || href == "" || strings.Contains(href, "duckduckgo.com") || seen[href] { + continue + } + snippet := "" + if s := goFirstByClass(block, "result__snippet"); s != nil { + snippet = goText(s) + } + seen[href] = true + results = append(results, searchResult{Title: title, URL: href, Snippet: snippet}) + } + return results, nil +} + +// ─── petits helpers DOM (x/net/html, sans sélecteur CSS) ──────────────────── + +func goHasClass(n *html.Node, class string) bool { + if n.Type != html.ElementNode { + return false + } + for _, f := range strings.Fields(goAttr(n, "class")) { + if f == class { + return true + } + } + return false +} + +func goAttr(n *html.Node, name string) string { + if n == nil { + return "" + } + for _, a := range n.Attr { + if a.Key == name { + return a.Val + } + } + return "" +} + +// goFindByClass renvoie tous les nœuds portant la classe, sans descendre dans +// un nœud déjà retenu (les résultats DDG ne s'imbriquent pas). +func goFindByClass(root *html.Node, class string) []*html.Node { + var out []*html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if goHasClass(n, class) { + out = append(out, n) + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + return out +} + +func goFirstByClass(root *html.Node, class string) *html.Node { + var found *html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if found != nil { + return + } + if goHasClass(n, class) { + found = n + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + return found +} + +// goText concatène le texte d'un sous-arbre, espaces normalisés. +func goText(n *html.Node) string { + var sb strings.Builder + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.TextNode { + sb.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return strings.TrimSpace(wsRe.ReplaceAllString(sb.String(), " ")) +}