From 1360eb1943561b795fa9b81e86ac065755e109cb Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 30 Jul 2026 10:21:09 +0200 Subject: [PATCH] v0.6.4 : serveurs MCP connectes en parallele et prechauffes, vitesse de generation de retour, retouches du menu MCP : les serveurs actifs se connectent en parallele au lieu de la file indienne (quatre serveurs npx n'additionnent plus leurs demarrages) et sont prechauffes au lancement du service, donc le premier message ne paie plus le handshake. Une session est publiee dans le pool avant d'aboutir : deux appelants simultanes partagent la meme tentative au lieu de lancer deux process. Interface : la vitesse de generation (prefill/decode) reapparait sous chaque reponse, en fondu, avec un interrupteur pour la masquer ; infobulles " ? " qui n'etaient plus rognees ; espace superflu en haut des cartes du mode agent ; pastilles d'etat redessinees et plaquees a droite ; moteurs renommes en llama.cpp precompile / compile / personnalise ; badge conseille lisible en sombre ; section Service fondue dans Moteur ; plus qu'un enregistrement automatique pour l'acces internet. Documentation : le README dit desormais que Crawl4AI est un serveur a heberger soi-meme, avec la commande Docker. --- README.md | 8 + cmd/jean/resource_windows_amd64.syso | Bin 8340 -> 8340 bytes cmd/jean/resource_windows_arm64.syso | Bin 8340 -> 8340 bytes cmd/jean/versioninfo.json | 6 +- internal/jean/mcp_client.go | 98 ++++++++--- internal/jean/mcp_client_test.go | 40 +++++ internal/jean/run.go | 2 +- internal/jean/ui/index.html | 193 ++++++++++++++++------ internal/jean/ui/src/index.tmpl.html | 42 ++--- internal/jean/ui/src/js/01-theme.js | 1 + internal/jean/ui/src/js/03-ui-kit.js | 9 + internal/jean/ui/src/js/05-status.js | 10 +- internal/jean/ui/src/js/06-settings.js | 10 +- internal/jean/ui/src/js/07-models.js | 10 +- internal/jean/ui/src/js/08-chat-render.js | 18 ++ internal/jean/ui/src/js/09-stream.js | 10 +- internal/jean/ui/src/js/10-llamacpp.js | 19 ++- internal/jean/ui/src/js/12-mcp.js | 6 +- internal/jean/ui/src/js/13-remote.js | 4 +- internal/jean/ui/src/styles.css | 54 +++++- internal/jean/web_prefs.go | 1 + internal/jean/web_server.go | 3 + 22 files changed, 409 insertions(+), 135 deletions(-) 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 5c5d047f7329fc03789d6fd3d6a825a5b9a1cbe9..9cdc0eaf7e910b80c1360db0ea8a440695097c36 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^e0~-SfuxwVA5oKaD*=#8n%m}1<<=GhlguVvg delta 36 ncmbQ@IK^>;lnf^`0~-SfFmG0t5oKaD-fSrs%m}1<<=GhlgnI_u diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 604bb92724a2323bbf737d092b73db764820af34..cb06bd56e4bc5f756c18e4b28257190c62ca6561 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^e0~-SfuxwVA5oKaD*=#8n%m}1<<=GhlguVvg delta 36 ncmbQ@IK^>;lnf^`0~-SfFmG0t5oKaD-fSrs%m}1<<=GhlgnI_u 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
- - + +