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.
This commit is contained in:
nathaninline committed 2026-08-05 11:48:45 +02:00
1 parent 9dedba0e47
commit 3dba323aab
12 files changed
+648 -103

No files matched your search

+17 -6
View File
@@ -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 <url>|key <clé>] accès web via Crawl4AI
internet [on|off|engine <go|crawl4ai>|url <url>|key <clé>] 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
+16 -23
View File
@@ -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.
+12 -5
View File
@@ -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
)
+34 -12
View File
@@ -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=
+27 -3
View File
@@ -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 {
+55 -18
View File
@@ -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 <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 <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 <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 <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 : ajean 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_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 <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 <url>]")
return fmt.Errorf("usage: ajean internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
}
return nil
}
+3 -2
View File
@@ -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 <url>|key <clé>] accès web de l'IA via un serveur Crawl4AI (web_search/open/read/grep)
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]
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) :
+42 -17
View File
@@ -916,22 +916,33 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</div>
<div id="net-block" class="gated-block">
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b> via un serveur <b>Crawl4AI</b> que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>). Actifs seulement si le <b>mode agent</b> est actif et que le serveur répond.</span></span></div>
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
<!-- URL et clé s'enregistrent en quittant le champ (onchange) : deux boutons
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
JavaScript) · « crawl4ai » = serveur externe avec Chromium headless. -->
<div class="row" style="margin-top:6px">
<select id="web-engine" class="sctl" onchange="saveWebEngine()">
<option value="go">moteur intégré — rien à installer</option>
<option value="crawl4ai">serveur Crawl4AI — rendu JavaScript</option>
</select>
</div>
<!-- Réglages propres à Crawl4AI : masqués avec le moteur intégré.
URL et clé s'enregistrent en quittant le champ (onchange) : deux boutons
« enregistrer » côte à côte n'apportaient rien et brouillaient la carte. -->
<div class="row" style="margin-top:6px">
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
<div id="crawl-fields">
<div class="row" style="margin-top:6px">
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
</div>
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
qu'un indice sur les 4 derniers caractères. -->
<div class="row" style="margin-top:6px">
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
</div>
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
</div>
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
qu'un indice sur les 4 derniers caractères. -->
<div class="row" style="margin-top:6px">
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
</div>
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
<div id="mcp-block" class="gated-block">
@@ -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='<span style="color:var(--accent)">✓</span> 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='<span style="color:var(--accent)">✓</span> 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}));
+24 -13
View File
@@ -88,22 +88,33 @@
</div>
</div>
<div id="net-block" class="gated-block">
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b> via un serveur <b>Crawl4AI</b> que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>). Actifs seulement si le <b>mode agent</b> est actif et que le serveur répond.</span></span></div>
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
<!-- URL et clé s'enregistrent en quittant le champ (onchange) : deux boutons
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
JavaScript) · « crawl4ai » = serveur externe avec Chromium headless. -->
<div class="row" style="margin-top:6px">
<select id="web-engine" class="sctl" onchange="saveWebEngine()">
<option value="go">moteur intégré — rien à installer</option>
<option value="crawl4ai">serveur Crawl4AI — rendu JavaScript</option>
</select>
</div>
<!-- Réglages propres à Crawl4AI : masqués avec le moteur intégré.
URL et clé s'enregistrent en quittant le champ (onchange) : deux boutons
« enregistrer » côte à côte n'apportaient rien et brouillaient la carte. -->
<div class="row" style="margin-top:6px">
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
<div id="crawl-fields">
<div class="row" style="margin-top:6px">
<input id="crawl-url" type="text" placeholder="http://localhost:11235" class="sctl" onchange="saveCrawlUrl()">
</div>
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
qu'un indice sur les 4 derniers caractères. -->
<div class="row" style="margin-top:6px">
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
</div>
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
</div>
<!-- Clé d'accès du serveur Crawl4AI (facultative) : envoyée en
« Authorization: Bearer … ». Jamais relue par l'API, on n'affiche
qu'un indice sur les 4 derniers caractères. -->
<div class="row" style="margin-top:6px">
<input id="crawl-key" type="password" autocomplete="off" placeholder="clé API (si le serveur en exige une)" class="sctl" onchange="saveCrawlKey()">
<button id="crawl-key-clear" onclick="clearCrawlKey()" style="display:none">retirer</button>
</div>
<div class="muted" style="font-size:11px;margin-top:4px">enregistré automatiquement en quittant le champ</div>
<div id="crawl-key-state" class="muted" style="font-size:11px;margin-top:3px"></div>
<div id="internet-status" class="muted" style="font-size:11px;margin-top:4px"></div>
</div>
<div id="mcp-block" class="gated-block">
+18 -4
View File
@@ -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='<span style="color:var(--accent)">✓</span> 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='<span style="color:var(--accent)">✓</span> 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}));
+10
View File
@@ -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 != "",
+390
View File
@@ -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)])), "<html")
}
if !isHTML {
if strings.Contains(ct, "text/") || strings.Contains(ct, "json") || ct == "" {
return string(body), nil
}
return "", fmt.Errorf("type de contenu non lisible : %s", contentType)
}
doc, err := goParseHTML(body, contentType)
if err != nil {
return "", fmt.Errorf("HTML illisible : %v", err)
}
base, _ := url.Parse(finalURL)
md, title := goArticleMarkdown(doc, base)
if strings.TrimSpace(md) == "" {
// Readability n'a rien trouvé d'« article » (page d'accueil, listing,
// appli…). On convertit la page entière plutôt que de renvoyer vide.
md, err = goConvert(doc, finalURL)
if err != nil {
return "", err
}
}
if title != "" && !strings.HasPrefix(strings.TrimSpace(md), "# ") {
md = "# " + title + "\n\n" + md
}
// Diagnostic du rendu client. Beaucoup de HTML (scripts, bundles, templates)
// mais presque aucun texte extractible = coquille de SPA. Le rapport compte
// plus que la taille absolue : une vraie petite page (example.com : ~1 Ko de
// HTML, ~170 caractères de texte) n'est PAS suspecte, alors qu'une page de
// 300 Ko qui ne rend que 80 caractères l'est clairement.
//
// Sans ce message le modèle relançait la même URL en boucle (cf. le garde-fou
// anti-boucle de chat_agent.go) : on lui dit explicitement de changer de source.
if len(strings.TrimSpace(md)) < jsShellTextMax && len(body) > 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(), " "))
}