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
This commit is contained in:
nathaninline committed 2026-08-07 17:39:46 +02:00
1 parent 47c27499ae
commit 24ff2f0a25
9 files changed
+220 -209

No files matched your search

+21 -19
View File
@@ -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://<hôte>:8090
ajean ui start # démarre l'interface (ajean-ui) sur http://<hôte>: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 <go|crawl4ai>|url <url>|key <clé>] accès web
Presets et moteur :
switch [N] changer de preset (presets/)
llamacpp install|update|status
Accès distant (ajean.link) :
link <token> enregistre le token et démarre le lien
link start|restart|stop gérer le service de lien
link <token> 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> # 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é
+11 -1
View File
@@ -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-<os>-<arch>`. La double publication qui accompagnait la transition n'a plus d'objet.
+1 -1
View File
@@ -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)
}()
+91 -150
View File
@@ -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 <token>")
fmt.Println(yellow("[info]") + " aucun jeton enregistré — lance: ajean link <token>")
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> (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 <token> 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 <token> 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 <token>")
}
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
}
+15 -10
View File
@@ -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 <commande> [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 <go|crawl4ai>|url <url>|key <clé>]
accès web de l'IA (moteur intégré ou serveur Crawl4AI)
Accès distant (ajean.link) :
link <token> enregistre le token et démarre le lien au relais
link start | restart | stop pilote le service de lien
link <token> 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.
+51 -15
View File
@@ -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(),
} {
+3 -3
View File
@@ -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 {
+10 -10
View File
@@ -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})
}
+17
View File
@@ -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)
}