From 24ff2f0a258decacd5d7f565e831d8ac1b89ebe9 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Fri, 7 Aug 2026 17:39:46 +0200 Subject: [PATCH] Deux services nommes ajean-engine et ajean-ui, une seule porte web - ajean.service -> ajean-engine, ajean-link.service -> ajean-ui - 'ajean web' est desormais l'unique serveur d'interface : il ouvre aussi le tunnel quand un jeton est enregistre (une seule conversation, plus de conflit sur :8090 ni de fils divergents) - 'ajean link' ne gere plus que le compte ; 'ajean ui' pilote le service - l'installateur Linux pose les deux unites et les deux regles sudoers - 'app' sort de l'aide --- README.md | 40 ++--- RELEASE_NOTES.md | 12 +- internal/ajean/cli_app.go | 2 +- internal/ajean/relay_link.go | 241 +++++++++++----------------- internal/ajean/run.go | 25 +-- internal/ajean/sys_install_linux.go | 66 ++++++-- internal/ajean/sys_update.go | 6 +- internal/ajean/web_link_api.go | 20 +-- internal/ajean/web_server.go | 17 ++ 9 files changed, 220 insertions(+), 209 deletions(-) diff --git a/README.md b/README.md index cd6679b..0437fec 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Aucune dépendance à l'exécution, aucun flag CMake à retenir, aucun conteneur **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. -**Un service, pas un script.** `ajean install` écrit l'unité systemd, la règle sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas). +**Des services, pas des scripts.** `ajean install` écrit les deux unités systemd, les règles sudoers et les dossiers. Ensuite `start` / `stop` / `status` / `logs`. Windows et macOS ont leurs équivalents natifs (voir plus bas). **Plusieurs modèles, un clic.** Les presets gardent chacun leur configuration complète ; basculer de l'un à l'autre recharge le modèle sans toucher à un fichier. Les `.gguf` peuvent vivre sur n'importe quel disque. @@ -61,48 +61,50 @@ Nécessite `git` et `cmake`, plus le toolkit de l'accélérateur (CUDA, ROCm…) ```bash ajean edit # régler MODEL=/chemin/vers/le-modele.gguf -ajean start +ajean start # démarre le moteur (ajean-engine) ajean test # vérifier que le modèle répond -ajean web # http://:8090 +ajean ui start # démarre l'interface (ajean-ui) sur http://:8090 ``` +AJEAN tourne en **deux services** : `ajean-engine`, qui exécute le modèle, et +`ajean-ui`, qui sert l'interface web, le tunnel d'accès distant et l'endpoint +OpenAI. Les séparer permet de redémarrer l'interface — ce qui est instantané — +sans recharger des dizaines de gigaoctets de modèle. + ## Commandes ``` -Application : - app lance l'UI web + ouvre le navigateur (auto au double-clic) - web [PORT] UI web seule (défaut :8090) - chat [prompt-système] chat dans le terminal - -Service : +Moteur (ajean-engine) : start | stop | restart gérer le service status | logs état / logs en direct enable | disable démarrage au boot edit éditer la configuration dans $EDITOR + switch [N] changer de preset (presets/) test | bench [N] vérifier que le modèle répond / mesurer les tok/s vram | gpu [index…] VRAM / choix des GPU (gpu all = tous) set-api-key [clé] protéger le moteur d'inférence (Bearer) + llamacpp install|update|status + +Interface (ajean-ui) : + ui [start|stop|restart|status] piloter le service d'interface + web [PORT] servir l'interface au premier plan (défaut :8090) set-web-key [clé] protéger l'API de pilotage Capacités de l'IA : + chat [prompt-système] chat dans le terminal agent [on|off|status] active TOUS les outils (terminal, fichiers, mémoire) memory [off|ondemand|always] mode mémoire internet [on|off|engine |url |key ] accès web -Presets et moteur : - switch [N] changer de preset (presets/) - llamacpp install|update|status - Accès distant (ajean.link) : - link enregistre le token et démarre le lien - link start|restart|stop gérer le service de lien + link enregistre le jeton et ouvre le tunnel link code code d'appairage (10 min, usage unique) link status|logout Installation : install | uninstall update [--check] mise à jour depuis les releases GitHub - version + where | version ``` ## Configuration @@ -141,7 +143,7 @@ La clé API (`ajean set-api-key`) est rangée hors de la configuration, afin de |----------|------|--------| | `AJEAN_HOME` | racine des données | `/etc/ajean`, `%ProgramData%\ajean` | | `AJEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — | -| `AJEAN_SERVICE` | nom de l'unité systemd | `ajean` | +| `AJEAN_SERVICE` | nom de l'unité du moteur | `ajean-engine` | | `HF_TOKEN` | token Hugging Face pour les modèles privés | — | | `AJEAN_DL_CONNS` | connexions parallèles au téléchargement | — | | `EDITOR` | éditeur pour `ajean edit` | `nano` / `notepad` | @@ -232,7 +234,7 @@ ajean link # token fourni sur ajean.link ajean link code # code d'appairage à saisir dans le portail ``` -Le lien tourne comme un service : la commande rend la main, la connexion continue en tâche de fond. Inutile de lancer `ajean web`, l'interface est servie dans le tunnel. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI. +Le tunnel n'est pas un service à part : il est ouvert par `ajean-ui`, le service qui sert déjà l'interface, dès qu'un jeton est enregistré. Un seul process sert donc l'interface locale et l'accès distant — c'est ce qui garantit **une seule conversation**, identique sur les deux. Le portail donne accès à l'interface du serveur avec un chat chiffré, à la gestion de plusieurs machines, et en option à un endpoint compatible OpenAI. Il s'agit d'un service optionnel et payant ; tout le reste d'AJEAN est et restera open source et gratuit. @@ -255,7 +257,7 @@ Le VPS effectue un simple **passthrough SNI** : le TLS est terminé sur la machi ## API de pilotage -`ajean web` expose une API HTTP pour piloter AJEAN à distance. À protéger avant toute exposition : +Le service d'interface expose une API HTTP pour piloter AJEAN à distance. À protéger avant toute exposition : ```bash ajean set-web-key # génère une clé diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 14dc299..d69c6c0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -14,10 +14,20 @@ Restent des fichiers ceux qui sont faits pour être lus, édités et sauvegardé `$AJEAN_HOME` contient exactement `backends/`, `bin/`, `presets/`, `memory/`, `models/`, `workspace/`, plus la base. `configs/` devient `presets/`, `MEMORY/` devient `memory/`, et les `.gguf` ont enfin leur `models/` au lieu d'être posés à la racine. +## Deux services qui portent enfin leur nom + +`ajean.service` et `ajean-link.service` deviennent **`ajean-engine`** et **`ajean-ui`**. Le premier exécute le modèle, le second sert l'interface web, le tunnel d'accès distant et l'endpoint OpenAI. Le nom « link » cachait l'essentiel : ce service est d'abord le serveur web. + +Surtout, il n'y a plus **qu'une seule façon** de servir l'interface. Avant, `ajean web` et `ajean link serve` savaient tous deux le faire, ce qui posait un piège permanent : lancer les deux, c'était un conflit sur le port 8090 et surtout deux fils de conversation qui divergeaient — la conversation vit en mémoire, deux process qui la servent finissent par s'écraser l'un l'autre. La consigne « ne jamais lancer `ajean web` » circulait comme une règle à retenir ; elle n'existait que parce que le code offrait deux portes pour la même pièce. + +Désormais `ajean web` est cette porte unique : il sert l'interface et, si un jeton de liaison est enregistré, ouvre le tunnel dans le même process. `ajean link` ne s'occupe plus que du compte — jeton, code d'appairage, état — et `ajean ui start|stop|restart|status` pilote le service. `ajean link serve`, `link start`, `link stop` et `link restart` disparaissent. + +Séparer les deux services garde son intérêt : redémarrer l'interface est instantané, alors que redémarrer le moteur recharge des dizaines de gigaoctets. + ## Ce qui a été retiré Tout le code écrit pour ménager les installations « jean » : la migration du dossier de données et ses reprises après échec, la migration de l'agencement système (unités, `/etc/default`, réécriture des chemins), l'élévation Windows qu'elle demandait, la résolution du nom d'unité réellement installée, la reprise des fichiers PID et des skills, les alias `jean` posés à l'installation, les variables `JEAN_*` lues en second. -La CLI perd ses alias hérités — `skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama` — et son aide est réorganisée. Chaque commande a désormais un seul nom. +La CLI perd ses alias hérités — `skills`, `machine`, `tools`, `web-access`, `mem`, `upgrade`, `self-update`, `paths`, `llama` — et son aide est réorganisée autour des deux services. Chaque commande a désormais un seul nom. `app` quitte l'aide : c'est le comportement du double-clic, qu'on n'atteint pas en tapant son nom. Les releases ne publient plus qu'un jeu de binaires, `ajean--`. La double publication qui accompagnait la transition n'a plus d'objet. diff --git a/internal/ajean/cli_app.go b/internal/ajean/cli_app.go index cb0d71a..9b74f44 100644 --- a/internal/ajean/cli_app.go +++ b/internal/ajean/cli_app.go @@ -56,7 +56,7 @@ func cmdApp(args []string) error { // Worker détaché rescapé (version antérieure, autre copie de l'app) : il // possède SA propre conversation et écrase la nôtre. On le supprime avant // de prendre la main. - killForeignLinkWorker() + killForeignUIWorker() startAppLink(appWebMux) }() diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 2fbaeaf..022e905 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -71,11 +71,16 @@ func relayURL() string { return defaultRelayURL } -// linkUnitName est l'unité qui exécute le worker « ajean link serve ». -const linkUnitName = "ajean-link" +// uiUnitName est l'unité qui exécute « ajean web » : l'UI locale, le tunnel du +// relais et l'endpoint OpenAI, dans un seul et même process. +const uiUnitName = "ajean-ui" -func linkServiceName() string { return linkUnitName } +func uiServiceName() string { return uiUnitName } +// cmdLink ne gère QUE le compte d'accès distant : le jeton, l'appairage, l'état. +// Le tunnel n'est plus un service à part — il est ouvert par « ajean web » dès +// qu'un jeton est enregistré. Piloter le tunnel, c'est donc piloter le service +// d'interface (voir cmdUI). func cmdLink(args []string) error { sub := "" if len(args) > 0 { @@ -83,40 +88,29 @@ func cmdLink(args []string) error { } switch sub { case "": - // `ajean link` seul : afficher l'aide des sous-commandes (NE démarre pas — - // éviter de prendre un mot pour un token et d'écraser le vrai). + // `ajean link` seul : afficher l'aide (NE démarre rien — éviter de prendre + // un mot pour un jeton et d'écraser le vrai). printLinkHelp() return nil - case "serve": - // Le worker réel (boucle de connexion au relais), pendant de `ajean serve`. - // C'est ce que lance l'unité systemd ; il NE doit PAS rendre la main. - return runLinkForeground() - case "start": - return startLink(false) case "status": tok := readLinkToken() if tok == "" { - fmt.Println(yellow("[info]") + " aucun token enregistré — lance: ajean link ") + fmt.Println(yellow("[info]") + " aucun jeton enregistré — lance: ajean link ") return nil } - fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL()) - if linkServiceActive() { - fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName()) + fmt.Printf("%s jeton enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL()) + if uiServiceActive() { + fmt.Printf("%s service %s actif — le tunnel est ouvert\n", green("[ok]"), uiServiceName()) } else { - fmt.Printf("%s service %s: arrêté (ajean link pour démarrer)\n", yellow("[info]"), linkServiceName()) + fmt.Printf("%s service %s arrêté — pas de tunnel (ajean ui start)\n", yellow("[info]"), uiServiceName()) } return nil case "logout": - _ = removeLinkToken() - fmt.Println(green("[ok]") + " token supprimé") - return nil - case "stop": - return linkServiceCtl("stop") - case "restart": - if err := linkServiceCtl("restart"); err != nil { + if err := removeLinkToken(); err != nil { return err } - return linkPrintIdentity() + fmt.Println(green("[ok]") + " jeton supprimé — « ajean ui restart » pour fermer le tunnel") + return nil case "code": code, err := newPairCode() if err != nil { @@ -126,9 +120,9 @@ func cmdLink(args []string) error { return nil } - // Un argument restant n'est traité comme TOKEN que s'il en a la forme (`jl_…`). + // Un argument restant n'est traité comme JETON que s'il en a la forme (`jl_…`). // Sinon c'est une faute de frappe / sous-commande inconnue : on REFUSE, sans - // jamais écraser le token enregistré (le bug qui rendait le serveur injoignable). + // jamais écraser le jeton enregistré (le bug qui rendait le serveur injoignable). if !strings.HasPrefix(sub, "jl_") { fmt.Fprintf(os.Stderr, "%s sous-commande inconnue : %q\n\n", yellow("[link]"), sub) printLinkHelp() @@ -137,46 +131,52 @@ func cmdLink(args []string) error { if err := saveLinkToken(strings.TrimSpace(sub)); err != nil { return err } - // Nouveau token → on (re)démarre le worker pour qu'il le prenne en compte. - return startLink(true) -} - -// startLink démarre (ou redémarre si force) le service de lien puis affiche -// l'identité (empreinte + code). Si le service tourne déjà et qu'on ne force pas, -// on le signale au lieu d'un faux « démarré ». -func startLink(force bool) error { - if readLinkToken() == "" { - return fmt.Errorf("aucun token. Usage: ajean link (token fourni à l'achat sur la boutique)") - } - switch { - case force: - if err := linkServiceCtl("restart"); err != nil { - return err - } - case linkServiceActive(): - fmt.Printf("%s service %s déjà en cours — « ajean link restart » pour le relancer\n", yellow("[info]"), linkServiceName()) - default: - if err := linkServiceCtl("start"); err != nil { - return err - } + // Nouveau jeton : le service d'interface doit le relire pour ouvrir le tunnel. + if err := uiServiceCtl("restart"); err != nil { + return err } return linkPrintIdentity() } +// cmdUI pilote le service d'interface — « ajean web » en arrière-plan : l'UI +// locale, le tunnel du relais et l'endpoint OpenAI, servis par le MÊME process. +// C'est ce qui garantit une conversation unique, identique en local et à distance. +func cmdUI(args []string) error { + action := "status" + if len(args) > 0 && args[0] != "" { + action = args[0] + } + switch action { + case "start", "stop", "restart": + return uiServiceCtl(action) + case "status": + if uiServiceActive() { + fmt.Printf("%s service %s : actif\n", green("[ok]"), uiServiceName()) + } else { + fmt.Printf("%s service %s : arrêté\n", yellow("[info]"), uiServiceName()) + } + if readLinkToken() != "" { + fmt.Printf(" accès distant : jeton enregistré — le tunnel s'ouvre avec le service\n") + } + return nil + default: + return fmt.Errorf("usage: ajean ui [start|stop|restart|status]") + } +} + // printLinkHelp liste les sous-commandes de `ajean link`. func printLinkHelp() { fmt.Print(`ajean link — accès distant via le relais ajean.link Usage : - ajean link enregistre le token (1re fois / pour le changer) et démarre le lien - ajean link start démarre le lien en arrière-plan (service) - ajean link restart redémarre le service de lien - ajean link stop arrête le service de lien - ajean link status état du token et du service + ajean link enregistre le jeton (1re fois / pour le changer) et ouvre le tunnel + ajean link status état du jeton et du tunnel ajean link code génère un code d'appairage (valable 10 min, à usage unique) - ajean link logout oublie le token enregistré + ajean link logout oublie le jeton enregistré -Le token est fourni sur ajean.link. « ajean link » seul affiche cette aide. +Le jeton est fourni sur ajean.link. Le tunnel est ouvert par le service +d'interface (« ajean ui ») dès qu'un jeton est enregistré : il n'y a pas de +service séparé pour l'accès distant. `) } @@ -197,50 +197,6 @@ func linkPrintIdentity() error { return nil } -// runLinkForeground exécute la boucle de connexion au relais (le worker supervisé -// par systemd). Reconnexion automatique avec backoff. Ne rend jamais la main. -func runLinkForeground() error { - token := readLinkToken() - if token == "" { - return fmt.Errorf("aucun token. Usage: ajean link ") - } - fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL()) - fmt.Printf(" (UI AJEAN + endpoint OpenAI servis dans le tunnel — pas besoin de 'ajean web')\n") - if fp := e2eFingerprint(); fp != "" { - fmt.Printf("%s empreinte E2E : %s (code d'appairage : ajean link code)\n", green("[e2e]"), bold(fp)) - } - - // UNE seule instance du mux web → UNE seule conversation en mémoire, PARTAGÉE - // entre l'UI locale (:8090, ajean.n27.fr) et le tunnel (app.ajean.link). Avant, - // `ajean web` et `ajean link` étaient deux process distincts avec chacun leur - // historique → confusion. Désormais le process ajean-link est l'unique - // propriétaire de la conversation et sert les DEUX surfaces. - webMux := newWebMux() - go serveLocalWebMux(webMux) - // On construit le handler une seule fois ; il est servi à travers chaque tunnel. - handler := newLinkHandler(webMux) - // Front TLS de l'accès OpenAI public. Toujours prêt ; c'est le drapeau - // oaiPublicEnabled (piloté par l'UI, lu en direct au démux) qui autorise ou non - // le trafic. Cert obtenu à la demande via TLS-ALPN-01 à travers le tunnel. - oaiTLS := oaiTLSConfig() - fmt.Printf("%s front OpenAI public prêt (activation en direct via l'UI ; état: %v)\n", green("[oai]"), oaiPublicEnabled()) - - backoff := time.Second - for { - err := runLinkSession(token, handler, oaiTLS) - if err != nil { - fmt.Printf("%s lien perdu: %v — reconnexion dans %s\n", yellow("[link]"), err, backoff) - } - time.Sleep(backoff) - if backoff < 30*time.Second { - backoff *= 2 - if backoff > 30*time.Second { - backoff = 30 * time.Second - } - } - } -} - // --------------------------------------------------------------------------- // Tunnel DANS le process de l'app (macOS/Windows). // @@ -262,7 +218,7 @@ var appLink struct { } // appOwnsLink : vrai dans le process de l'app de bureau, qui pilote le tunnel -// en interne. linkServiceCtl s'y adapte pour ne PAS lancer de worker concurrent. +// en interne. uiServiceCtl s'y adapte pour ne PAS lancer de worker concurrent. // appWebMux est le mux servi par l'app — réutilisé pour le tunnel afin que les // deux surfaces partagent la même conversation. var ( @@ -327,45 +283,30 @@ func appLinkRunning() bool { return appLink.running } -// killForeignLinkWorker tue un worker de lien détaché encore en vie (laissé par +// killForeignUIWorker tue un worker de lien détaché encore en vie (laissé par // une version antérieure ou une autre copie de l'app). Indispensable AVANT que // l'app ne prenne la main : sinon deux process servent la même conversation et // les fils divergent entre l'UI locale et app.ajean.link. -func killForeignLinkWorker() { - if pid := linkUserPID(); pid > 0 { +func killForeignUIWorker() { + if pid := uiUserPID(); pid > 0 { killTree(pid) - _ = os.Remove(linkPIDPath()) + _ = os.Remove(uiPIDPath()) } } -// serveLocalWebMux sert l'UI web locale sur :8090 avec le mux fourni — le MÊME -// que celui exposé dans le tunnel. Le process ajean-link devient ainsi l'unique -// propriétaire de la conversation, partagée entre l'accès local et l'accès -// distant. Remplace le `ajean web` autonome (à ne plus lancer séparément). -func serveLocalWebMux(mux *http.ServeMux) { - addr := "0.0.0.0:8090" - ln, err := net.Listen("tcp", addr) - if err != nil { - fmt.Printf("%s UI locale non démarrée (%s occupé ? tue le vieux « ajean web ») : %v\n", yellow("[web]"), addr, err) - return - } - fmt.Printf("%s UI locale sur http://%s (même conversation que le tunnel)\n", green("[web]"), addr) - _ = http.Serve(ln, mux) -} - -// linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, +// uiPIDPath / uiLogPath : suivi du worker de lien hors systemd (macOS, // Windows), où AJEAN est une app de bureau lancée sans droits root. -func linkPIDPath() string { return filepath.Join(AjeanHome(), linkUnitName+".pid") } -func linkLogPath() string { return filepath.Join(AjeanHome(), linkUnitName+".log") } +func uiPIDPath() string { return filepath.Join(AjeanHome(), uiUnitName+".pid") } +func uiLogPath() string { return filepath.Join(AjeanHome(), uiUnitName+".log") } -// linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est +// uiServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est // l'unité systemd ajean-link (avec sudo non interactif si on n'est pas root) ; // ailleurs — macOS et Windows, où il n'y a ni systemd ni droits root — on lance // « ajean link serve » en processus détaché suivi par un fichier PID, comme le // fait déjà le service principal. Sans ça, l'accès distant restait définitivement // « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne // composait jamais le tunnel. -func linkServiceCtl(action string) error { +func uiServiceCtl(action string) error { if runtime.GOOS != "linux" { // Dans l'app de bureau, le tunnel tourne DANS ce process (conversation // unique) : on ne lance surtout pas un worker séparé. @@ -375,7 +316,7 @@ func linkServiceCtl(action string) error { stopAppLink() case "start", "restart": stopAppLink() - killForeignLinkWorker() + killForeignUIWorker() startAppLink(appWebMux) if !appLinkRunning() { return fmt.Errorf("tunnel non démarré (clé de liaison absente ?)") @@ -385,43 +326,43 @@ func linkServiceCtl(action string) error { } return nil } - return linkUserSvcCtl(action) + return uiUserSvcCtl(action) } bin, pre := "systemctl", []string{} if os.Geteuid() != 0 { bin, pre = "sudo", []string{"-n", "systemctl"} } - cmd := exec.Command(bin, append(pre, action, linkServiceName())...) + cmd := exec.Command(bin, append(pre, action, uiServiceName())...) cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr if err := cmd.Run(); err != nil { - return fmt.Errorf("systemctl %s %s: %w", action, linkServiceName(), err) + return fmt.Errorf("systemctl %s %s: %w", action, uiServiceName(), err) } switch action { case "start", "restart": - fmt.Printf("%s service %s %s\n", green("[ok]"), linkServiceName(), action+"é") + fmt.Printf("%s service %s %s\n", green("[ok]"), uiServiceName(), action+"é") case "stop": - fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName()) + fmt.Printf("%s service %s arrêté\n", green("[ok]"), uiServiceName()) } return nil } -// linkServiceActive indique si le worker de lien tourne (unité systemd sous +// uiServiceActive indique si le worker de lien tourne (unité systemd sous // Linux, processus suivi par fichier PID ailleurs). -func linkServiceActive() bool { +func uiServiceActive() bool { if runtime.GOOS != "linux" { if appOwnsLink { return appLinkRunning() } - return linkUserPID() > 0 + return uiUserPID() > 0 } - out, _ := exec.Command("systemctl", "is-active", linkServiceName()).Output() + out, _ := exec.Command("systemctl", "is-active", uiServiceName()).Output() return strings.TrimSpace(string(out)) == "active" } -// linkUserPID renvoie le PID du worker de lien s'il tourne vraiment, 0 sinon +// uiUserPID renvoie le PID du worker de lien s'il tourne vraiment, 0 sinon // (fichier absent, illisible, ou process mort → on nettoie le fichier obsolète). -func linkUserPID() int { - b, err := os.ReadFile(linkPIDPath()) +func uiUserPID() int { + b, err := os.ReadFile(uiPIDPath()) if err != nil { return 0 } @@ -435,24 +376,24 @@ func linkUserPID() int { return pid } -// linkUserSvcCtl : équivalent de systemctl pour les OS sans systemd. -func linkUserSvcCtl(action string) error { +// uiUserSvcCtl : équivalent de systemctl pour les OS sans systemd. +func uiUserSvcCtl(action string) error { switch action { case "stop", "restart": - if pid := linkUserPID(); pid > 0 { + if pid := uiUserPID(); pid > 0 { killTree(pid) for i := 0; i < 30 && pidAlive(pid); i++ { time.Sleep(100 * time.Millisecond) } } - _ = os.Remove(linkPIDPath()) + _ = os.Remove(uiPIDPath()) if action == "stop" { - fmt.Printf("%s service %s arrêté\n", green("[ok]"), linkServiceName()) + fmt.Printf("%s service %s arrêté\n", green("[ok]"), uiServiceName()) return nil } case "start": - if linkUserPID() > 0 { - fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), linkServiceName()) + if uiUserPID() > 0 { + fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), uiServiceName()) return nil } default: @@ -469,9 +410,9 @@ func linkUserSvcCtl(action string) error { if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { return err } - logf, err := os.OpenFile(linkLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + logf, err := os.OpenFile(uiLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { - return fmt.Errorf("ouverture du log %s: %w", linkLogPath(), err) + return fmt.Errorf("ouverture du log %s: %w", uiLogPath(), err) } defer logf.Close() @@ -483,7 +424,7 @@ func linkUserSvcCtl(action string) error { pid := cmd.Process.Pid // PID + binaire d'origine : la 2e ligne dit QUELLE copie de l'app a lancé ce // worker — précieux pour diagnostiquer un process rescapé d'une autre version. - if err := os.WriteFile(linkPIDPath(), []byte(strconv.Itoa(pid)+"\n"+self+"\n"), 0o644); err != nil { + if err := os.WriteFile(uiPIDPath(), []byte(strconv.Itoa(pid)+"\n"+self+"\n"), 0o644); err != nil { return fmt.Errorf("écriture du PID: %w", err) } _ = cmd.Process.Release() @@ -493,10 +434,10 @@ func linkUserSvcCtl(action string) error { // « en ligne » sur un process déjà mort. time.Sleep(1500 * time.Millisecond) if !pidAlive(pid) { - _ = os.Remove(linkPIDPath()) - return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", linkLogPath()) + _ = os.Remove(uiPIDPath()) + return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", uiLogPath()) } - fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), linkServiceName(), pid) + fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), uiServiceName(), pid) return nil } diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 2d2eb1d..deff654 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -64,6 +64,8 @@ func Main() { mustExit(cmdWeb(args)) case "link": mustExit(cmdLink(args)) + case "ui": + mustExit(cmdUI(args)) case "oai": mustExit(cmdOAI(args)) case "agent": @@ -108,10 +110,10 @@ func printHelp() { Usage: ajean [args] -Application: - app lance l'UI web + ouvre le navigateur (auto au double-clic du binaire) +AJEAN tourne en deux services : ajean-engine (le modèle) et ajean-ui +(l'interface web, le tunnel d'accès distant et l'endpoint OpenAI). -Service: +Moteur (ajean-engine) : start | stop | restart gérer le service status | logs état / logs en direct enable | disable auto-démarrage au boot @@ -121,22 +123,24 @@ Service: vram utilisation GPU/VRAM (nvidia-smi) gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer + +Interface (ajean-ui) : + ui [start|stop|restart|status] pilote le service d'interface + web [PORT] sert l'interface au premier plan (défaut :8090) — + ouvre aussi le tunnel si un jeton est enregistré set-web-key [clé] protéger l'API de pilotage; vide = générer, "" = retirer Interaction: chat [system-prompt] chat terminal streamé - web [PORT] UI web (défaut :8090) — chat + presets + mode agent agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire) memory [off|ondemand|always|status] mode mémoire de l'IA internet [on|off|status|engine |url |key ] accès web de l'IA (moteur intégré ou serveur Crawl4AI) Accès distant (ajean.link) : - link enregistre le token et démarre le lien au relais - link start | restart | stop pilote le service de lien + link enregistre le jeton et ouvre le tunnel link code code d'appairage (10 min, usage unique) pour le portail - link status | logout état du lien / oublier le token - link serve worker au premier plan (lancé par ajean-link.service) + link status | logout état du jeton / l'oublier Backend llama.cpp : llamacpp install clone + compile llama.cpp (CUDA/ROCm/Metal/CPU), pointe BIN dessus @@ -203,12 +207,13 @@ func memoryDir() string { return filepath.Join(AjeanHome(), "memory") } func modelsDir() string { return filepath.Join(AjeanHome(), "models") } func workspaceDir() string { return filepath.Join(AjeanHome(), "workspace") } -// serviceName est le nom de l'unité qui exécute llama-server. +// serviceName est le nom de l'unité qui exécute llama-server. Son pendant est +// uiUnitName (« ajean-ui »), qui sert l'interface et le tunnel. func serviceName() string { if n := os.Getenv("AJEAN_SERVICE"); n != "" { return n } - return "ajean" + return "ajean-engine" } // Color helpers (ANSI). Disabled when stdout is not a TTY. diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index f29b82c..3d69cf1 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -11,10 +11,34 @@ import ( "strings" ) -const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by ajean). +// sudoersTemplate autorise l'utilisateur à piloter UNE unité sans mot de passe. +// Posé pour les deux unités : l'interface, qui tourne sans privilèges, doit +// pouvoir redémarrer le moteur (bascule de preset) comme elle-même (nouveau +// jeton de liaison). +const sudoersTemplate = `# Permet à %s de piloter l'unité %s sans mot de passe (posé par ajean install). %s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s ` +// uiUnitTemplate — le service d'interface : UI web locale, tunnel du relais et +// endpoint OpenAI, servis par un seul process (donc une seule conversation). +// Champs formatés : User, WorkingDirectory, ExecStart. +const uiUnitTemplate = `[Unit] +Description=AJEAN interface web + accès distant +After=network-online.target %s.service +Wants=network-online.target + +[Service] +Type=simple +User=%s +WorkingDirectory=%s +ExecStart=%s +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +` + const serviceUnitTemplate = `[Unit] Description=AJEAN llama.cpp server After=network.target @@ -109,21 +133,29 @@ func cmdInstall(args []string) error { } fmt.Printf(" %s /etc/default/ajean\n", green("✓")) - // 4. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed) - unit := fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve") - unitPath := "/etc/systemd/system/" + svc + ".service" - if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { - return err + // 4. Les deux unités : le moteur (exec llama-server) et l'interface. + units := map[string]string{ + svc: fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve"), + uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, installedExePath()+" web"), + } + for name, body := range units { + path := "/etc/systemd/system/" + name + ".service" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), path) } - fmt.Printf(" %s %s\n", green("✓"), unitPath) - // 5. Sudoers drop-in - sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) - sudoersPath := "/etc/sudoers.d/ajean-" + svc - if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { - return err + // 5. Sudoers : une règle par unité, pour que l'interface (sans privilèges) + // puisse redémarrer le moteur ET elle-même. + for _, name := range []string{svc, uiUnitName} { + sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name, targetUser, name, name, name, name, name) + path := "/etc/sudoers.d/ajean-" + name + if err := os.WriteFile(path, []byte(sudoers), 0o440); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), path) } - fmt.Printf(" %s %s\n", green("✓"), sudoersPath) // 6. chown AJEAN_HOME contents to target user chown(ajeanHome, u) @@ -155,11 +187,15 @@ func cmdUninstall(args []string) error { keepData = true } } - _ = exec.Command("systemctl", "stop", svc).Run() - _ = exec.Command("systemctl", "disable", svc).Run() + for _, name := range []string{uiUnitName, svc} { + _ = exec.Command("systemctl", "stop", name).Run() + _ = exec.Command("systemctl", "disable", name).Run() + } for _, p := range []string{ "/etc/systemd/system/" + svc + ".service", + "/etc/systemd/system/" + uiUnitName + ".service", "/etc/sudoers.d/ajean-" + svc, + "/etc/sudoers.d/ajean-" + uiUnitName, "/etc/default/ajean", installedExePath(), } { diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index 5fd8c88..a9428bf 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -440,16 +440,16 @@ func restartAfterUpdate() (bool, string) { if runtime.GOOS == "windows" { return scheduleAppRestart() } - if runtime.GOOS != "linux" || !linkServiceActive() { + if runtime.GOOS != "linux" || !uiServiceActive() { return false, "" } go func() { time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client // --no-block : on enregistre le job puis on rend la main ; systemd exécute le // stop/start même si ce process (et le client systemctl) sont tués entre-temps. - _ = exec.Command("systemctl", "--no-block", "restart", linkServiceName()).Run() + _ = exec.Command("systemctl", "--no-block", "restart", uiServiceName()).Run() }() - return true, "Service " + linkServiceName() + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)." + return true, "Service " + uiServiceName() + " redémarré automatiquement — la page va se reconnecter seule (le modèle n'est pas rechargé)." } func restartHintText() string { diff --git a/internal/ajean/web_link_api.go b/internal/ajean/web_link_api.go index fe0f7ca..4cce3e5 100644 --- a/internal/ajean/web_link_api.go +++ b/internal/ajean/web_link_api.go @@ -27,7 +27,7 @@ func handleLinkStatus(w http.ResponseWriter, r *http.Request) { tok := readLinkToken() sendJSON(w, http.StatusOK, map[string]any{ "linked": tok != "", - "active": linkServiceActive(), + "active": uiServiceActive(), "machineURL": remoteServerURL(), "fingerprint": e2eFingerprint(), }) @@ -47,17 +47,17 @@ func handleLinkConnect(w http.ResponseWriter, r *http.Request) { sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) return } - // (re)démarre le worker pour qu'il prenne la nouvelle clé. Sur une machine sans - // systemd (dev/Windows), le token est quand même enregistré : on renvoie l'info - // mais on signale que le service n'a pas pu démarrer ici. + // Redémarre le service d'interface pour qu'il relise la clé et ouvre le + // tunnel. Sur une machine sans systemd (dev/Windows), la clé est quand même + // enregistrée : on renvoie l'info en signalant que le service n'a pas démarré. svcErr := "" - if err := startLink(true); err != nil { + if err := uiServiceCtl("restart"); err != nil { svcErr = err.Error() } sendJSON(w, http.StatusOK, map[string]any{ "ok": true, "linked": true, - "active": linkServiceActive(), + "active": uiServiceActive(), "machineURL": remoteServerURL(), "serviceErr": svcErr, }) @@ -71,17 +71,17 @@ func handleLinkStart(w http.ResponseWriter, r *http.Request) { sendJSON(w, http.StatusBadRequest, map[string]any{"error": "aucune clé de liaison enregistrée"}) return } - if err := linkServiceCtl("restart"); err != nil { - sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "active": linkServiceActive()}) + if err := uiServiceCtl("restart"); err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "active": uiServiceActive()}) return } - sendJSON(w, http.StatusOK, map[string]any{"ok": true, "active": linkServiceActive()}) + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "active": uiServiceActive()}) } // handleLinkDisconnect (POST /api/link/disconnect) : arrête le service et oublie // la clé (équivalent `ajean link stop` + `ajean link logout`). func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) { - _ = linkServiceCtl("stop") + _ = uiServiceCtl("stop") _ = removeLinkToken() sendJSON(w, http.StatusOK, map[string]any{"ok": true, "linked": false, "active": false}) } diff --git a/internal/ajean/web_server.go b/internal/ajean/web_server.go index 245f8e5..c94123f 100644 --- a/internal/ajean/web_server.go +++ b/internal/ajean/web_server.go @@ -52,6 +52,23 @@ func cmdWeb(args []string) error { } else { fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]")) } + + // Accès distant : le tunnel vers le relais est ouvert ICI, dans le process qui + // sert déjà l'UI, et avec le MÊME mux. C'est la condition d'une conversation + // unique — elle vit en mémoire (voir chat_conversation.go), donc deux process + // qui la servent, c'est deux fils qui divergent et s'écrasent l'un l'autre. + // Sans jeton enregistré, startAppLink ne fait rien : l'UI locale suffit. + appOwnsLink = true + appWebMux = mux + if readLinkToken() != "" { + killForeignUIWorker() // rescapé d'une version antérieure ou d'une autre copie + fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL()) + if fp := e2eFingerprint(); fp != "" { + fmt.Printf("%s empreinte E2E : %s (code d'appairage : ajean link code)\n", green("[e2e]"), bold(fp)) + } + fmt.Printf("%s front OpenAI public prêt (activation en direct via l'UI ; état: %v)\n", green("[oai]"), oaiPublicEnabled()) + startAppLink(mux) + } return http.Serve(ln, mux) }