mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
9dedba0e47
commit
3dba323aab
12 files changed
+648
-103
No files matched your search
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) :
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}));
|
||||
|
||||
@@ -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 != "",
|
||||
|
||||
@@ -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(), " "))
|
||||
}
|
||||
Reference in new issue
Block a user