diff --git a/README.md b/README.md index 460ecdd..b01343c 100644 --- a/README.md +++ b/README.md @@ -220,6 +220,14 @@ La clé est stockée dans `$JEAN_HOME/.web_key`, distincte de `.api_key` (pilota Par défaut l'IA n'a pas accès au web. En branchant un serveur [Crawl4AI](https://github.com/unclecode/crawl4ai) (Chrome headless), elle gagne quatre outils : `web_search` (DuckDuckGo), `web_open` (récupère une page → métadonnées + plan), `web_read` (lit une plage de lignes) et `web_grep` (regex dans une page ouverte). +**AJEAN ne fournit pas le serveur Crawl4AI, il s'y branche** — c'est à toi de l'héberger (sur la même machine ou ailleurs), typiquement en Docker : + +```bash +docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest +``` + +Puis côté AJEAN : + ```bash jean internet url http://localhost:11235 # URL de ton serveur Crawl4AI jean internet on # active l'accès web diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 5c5d047..9cdc0ea 100644 Binary files a/cmd/jean/resource_windows_amd64.syso and b/cmd/jean/resource_windows_amd64.syso differ diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 604bb92..cb06bd5 100644 Binary files a/cmd/jean/resource_windows_arm64.syso and b/cmd/jean/resource_windows_arm64.syso differ diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 999a700..739d384 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 3, + "Patch": 4, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 3, + "Patch": 4, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.3", + "ProductVersion": "0.6.4", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/mcp_client.go b/internal/jean/mcp_client.go index dd46c75..c1e271d 100644 --- a/internal/jean/mcp_client.go +++ b/internal/jean/mcp_client.go @@ -18,10 +18,15 @@ import ( // Pool de sessions MCP + exposition des outils au moteur de chat. // -// Une session est établie paresseusement au premier besoin (premier tour de chat -// avec le mode agent actif) et gardée ouverte pour les tours suivants. Un serveur -// stdio garde donc son process vivant entre les messages — c'est voulu : -// desktop-commander & co ont un coût de démarrage non négligeable. +// Une session est gardée ouverte entre les tours de chat : un serveur stdio garde +// son process vivant d'un message à l'autre — c'est voulu, desktop-commander & co +// ont un coût de démarrage non négligeable. +// +// Les connexions sont établies EN PARALLÈLE (mcpEnsureAll) : en séquence, quatre +// serveurs `npx` faisaient attendre la somme de leurs démarrages (~20 s), là où +// les autres clients MCP paient le plus lent. Elles sont aussi pré-chauffées au +// démarrage du service web/link (MCPPrewarm) pour que le premier message ne paie +// pas le handshake. // // Le namespacing des outils suit la convention de l'écosystème : // mcp____. On maintient un registre nom-exposé → (serveur, outil) @@ -39,12 +44,17 @@ const ( ) // mcpSession est une connexion vivante à un serveur MCP. +// +// L'entrée est publiée dans le pool AVANT que la connexion aboutisse, pour qu'un +// second appelant se mette en attente au lieu de lancer un deuxième process. Les +// champs sess/tools/err ne doivent donc être lus qu'une fois `ready` fermé. type mcpSession struct { name string cfg MCPServerConfig sess *mcpsdk.ClientSession tools []*mcpsdk.Tool - err error // dernière erreur de connexion/liste, pour l'UI + err error // dernière erreur de connexion/liste, pour l'UI + ready chan struct{} // fermé quand la tentative de connexion est terminée } // mcpManager détient le pool de sessions. @@ -79,6 +89,9 @@ func mcpInvalidate(name string) { } } mcpMgr.mu.Unlock() + // Une session encore en cours de connexion a sess == nil : c'est sa goroutine + // qui refermera ce qu'elle vient d'ouvrir, en constatant qu'elle n'est plus + // dans le pool. if s != nil && s.sess != nil { _ = s.sess.Close() } @@ -145,16 +158,22 @@ func mcpConnect(ctx context.Context, name string, cfg MCPServerConfig) (*mcpsdk. // ensure renvoie une session vivante pour un serveur activé, en la (re)créant si // besoin. Doit être appelé sans détenir mgr.mu (il gère le lock lui-même). +// +// L'entrée est réservée dans le pool avant la connexion : deux appelants +// simultanés (le pré-chauffage et un tour de chat, par exemple) partagent la même +// tentative au lieu de lancer deux process stdio. func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession { m.mu.Lock() if s, ok := m.sessions[name]; ok { m.mu.Unlock() + <-s.ready // connexion peut-être encore en cours : on attend son issue return s } + s := &mcpSession{name: name, cfg: cfg, ready: make(chan struct{})} + m.sessions[name] = s m.mu.Unlock() // Connexion hors-lock (peut être lente). - s := &mcpSession{name: name, cfg: cfg} ctx, cancel := context.WithTimeout(context.Background(), mcpConnectTimeout) defer cancel() sess, err := mcpConnect(ctx, name, cfg) @@ -171,28 +190,63 @@ func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession { } m.mu.Lock() - // Un autre appelant a pu connecter entretemps : garder la première. - if existing, ok := m.sessions[name]; ok { - m.mu.Unlock() - if s.sess != nil { - _ = s.sess.Close() + // La session a pu être invalidée pendant la connexion (changement de config, + // arrêt du service) : dans ce cas on ne la republie pas, on referme. + stale := m.sessions[name] != s + if !stale { + // (Re)peuple le registre pour ce serveur. + for exposed, ref := range m.registry { + if ref.server == name { + delete(m.registry, exposed) + } } - return existing - } - m.sessions[name] = s - // (Re)peuple le registre pour ce serveur. - for exposed, ref := range m.registry { - if ref.server == name { - delete(m.registry, exposed) + for _, t := range s.tools { + m.registry[mcpExposedName(name, t.Name)] = mcpToolRef{server: name, tool: t.Name} } } - for _, t := range s.tools { - m.registry[mcpExposedName(name, t.Name)] = mcpToolRef{server: name, tool: t.Name} - } m.mu.Unlock() + close(s.ready) + if stale && s.sess != nil { + _ = s.sess.Close() + s.sess = nil + } return s } +// mcpEnsureAll connecte en parallèle tous les serveurs activés et attend qu'ils +// soient tous fixés (connectés ou en erreur). C'est ce qui évite d'additionner +// les temps de démarrage : le coût total est celui du serveur le plus lent. +func mcpEnsureAll(servers map[string]MCPServerConfig) { + var wg sync.WaitGroup + for _, name := range sortedServerNames(servers) { + cfg := servers[name] + if !cfg.Enabled || cfg.Transport() == "" { + continue + } + wg.Add(1) + go func(name string, cfg MCPServerConfig) { + defer wg.Done() + mcpMgr.ensure(name, cfg) + }(name, cfg) + } + wg.Wait() +} + +// MCPPrewarm établit les sessions MCP en tâche de fond, au démarrage du service +// web/link. Sans ça le handshake était payé par le premier message de chat, ce +// qui donnait l'impression d'une IA très lente à démarrer. Sans mode agent les +// outils MCP ne sont de toute façon pas exposés : inutile de lancer les process. +func MCPPrewarm() { + if !agentEnabled() { + return + } + servers, err := LoadMCPConfig() + if err != nil || len(servers) == 0 { + return + } + go mcpEnsureAll(servers) +} + var mcpSanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) // mcpSanitize rend une chaîne compatible avec les noms d'outils (^[A-Za-z0-9_-]+$). @@ -213,6 +267,7 @@ func mcpTools() []Tool { if err != nil || len(servers) == 0 { return nil } + mcpEnsureAll(servers) // connexions en parallèle, puis lecture du pool (instantanée) var out []Tool for _, name := range sortedServerNames(servers) { cfg := servers[name] @@ -419,6 +474,7 @@ func MCPStatus() ([]MCPServerStatus, error) { if err != nil { return nil, err } + mcpEnsureAll(servers) // en parallèle : le panneau MCP de l'UI n'attend plus la somme var out []MCPServerStatus for _, name := range sortedServerNames(servers) { cfg := servers[name] diff --git a/internal/jean/mcp_client_test.go b/internal/jean/mcp_client_test.go index cac8f78..152c968 100644 --- a/internal/jean/mcp_client_test.go +++ b/internal/jean/mcp_client_test.go @@ -3,9 +3,49 @@ package jean import ( "os/exec" "strings" + "sync" "testing" ) +// TestMCPEnsureDeduplicatesConcurrentConnects vérifie que N appelants simultanés +// (pré-chauffage + tour de chat + panneau MCP de l'UI) partagent une seule +// tentative de connexion : sans ça, connecter en parallèle multiplierait les +// process stdio lancés pour un même serveur. +func TestMCPEnsureDeduplicatesConcurrentConnects(t *testing.T) { + t.Setenv("JEAN_HOME", t.TempDir()) + t.Cleanup(mcpCloseAll) + + // Commande inexistante : la connexion échoue, mais l'entrée du pool est bien + // partagée — c'est ce qu'on teste. + cfg := MCPServerConfig{Command: "jean-binaire-inexistant-pour-test", Enabled: true} + + const callers = 8 + var wg sync.WaitGroup + got := make([]*mcpSession, callers) + for i := range got { + wg.Add(1) + go func(i int) { + defer wg.Done() + got[i] = mcpMgr.ensure("bidon", cfg) + }(i) + } + wg.Wait() + + for i, s := range got { + if s != got[0] { + t.Fatalf("appelant %d a obtenu une session différente : %p vs %p", i, s, got[0]) + } + select { + case <-s.ready: + default: + t.Fatalf("appelant %d a reçu une session pas encore prête", i) + } + } + if got[0].err == nil { + t.Fatal("une commande inexistante devrait produire une erreur de connexion") + } +} + // TestMCPEndToEnd connecte le serveur MCP de référence (@modelcontextprotocol/ // server-everything via npx), vérifie la découverte d'outils namespacés puis un // appel réel (echo). Skippé si npx est absent (CI sans Node). diff --git a/internal/jean/run.go b/internal/jean/run.go index 5389fbc..8b75ca0 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.3" +const Version = "0.6.4" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index d2ce372..c3c9303 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -22,7 +22,7 @@ garde `display-mode` en second pour les PWA Android/desktop à manifeste. --> - + @@ -82,20 +82,21 @@
-
Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep via un serveur Crawl4AI. Actifs seulement si le serveur répond.
+
Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep via un serveur Crawl4AI que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest). Actifs seulement si le mode agent est actif et que le serveur répond.
+
-
-
+
enregistré automatiquement en quittant le champ
@@ -121,6 +122,7 @@ +
System prompt @@ -193,18 +195,18 @@
…
-
Version rapide conseillé
-
prête à l'emploi, installée en ~2 min
+
llama.cpp précompilé conseillé
+
binaire officiel téléchargé tel quel, prêt en ~2 min
-
Version optimisée
-
compilée pour votre machine, plus longue
+
llama.cpp compilé
+
compilé ici pour votre machine — plus rapide, plus long à installer
-
Backend personnalisé
-
forks llama.cpp pour modèles spéciaux (ternaire…)
+
llama.cpp personnalisé
+
un fork de llama.cpp pour modèles spéciaux (quant ternaire…)
@@ -213,14 +215,16 @@
▸ détails
-
-
Service + +
Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
-
- - - -
+
+ + + +
Actions @@ -413,8 +417,8 @@
Moteur
- - + +