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. **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. **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 ```bash
ajean edit # régler MODEL=/chemin/vers/le-modele.gguf 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 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 ## Commandes
``` ```
Application : Moteur (ajean-engine) :
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 :
start | stop | restart gérer le service start | stop | restart gérer le service
status | logs état / logs en direct status | logs état / logs en direct
enable | disable démarrage au boot enable | disable démarrage au boot
edit éditer la configuration dans $EDITOR 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 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) vram | gpu [index…] VRAM / choix des GPU (gpu all = tous)
set-api-key [clé] protéger le moteur d'inférence (Bearer) 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 set-web-key [clé] protéger l'API de pilotage
Capacités de l'IA : 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) agent [on|off|status] active TOUS les outils (terminal, fichiers, mémoire)
memory [off|ondemand|always] mode mémoire memory [off|ondemand|always] mode mémoire
internet [on|off|engine <go|crawl4ai>|url <url>|key <clé>] accès web 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) : Accès distant (ajean.link) :
link <token> enregistre le token et démarre le lien link <token> enregistre le jeton et ouvre le tunnel
link start|restart|stop gérer le service de lien
link code code d'appairage (10 min, usage unique) link code code d'appairage (10 min, usage unique)
link status|logout link status|logout
Installation : Installation :
install | uninstall install | uninstall
update [--check] mise à jour depuis les releases GitHub update [--check] mise à jour depuis les releases GitHub
version where | version
``` ```
## Configuration ## 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_HOME` | racine des données | `/etc/ajean`, `%ProgramData%\ajean` |
| `AJEAN_MODEL_DIRS` | dossiers de modèles (séparés par `:`, `;` sous Windows) | — | | `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 | — | | `HF_TOKEN` | token Hugging Face pour les modèles privés | — |
| `AJEAN_DL_CONNS` | connexions parallèles au téléchargement | — | | `AJEAN_DL_CONNS` | connexions parallèles au téléchargement | — |
| `EDITOR` | éditeur pour `ajean edit` | `nano` / `notepad` | | `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 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. 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 ## 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 ```bash
ajean set-web-key # génère une clé 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. `$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é ## 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. 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. 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 // 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 // possède SA propre conversation et écrase la nôtre. On le supprime avant
// de prendre la main. // de prendre la main.
killForeignLinkWorker() killForeignUIWorker()
startAppLink(appWebMux) startAppLink(appWebMux)
}() }()
+91 -150
View File
@@ -71,11 +71,16 @@ func relayURL() string {
return defaultRelayURL return defaultRelayURL
} }
// linkUnitName est l'unité qui exécute le worker « ajean link serve ». // uiUnitName est l'unité qui exécute « ajean web » : l'UI locale, le tunnel du
const linkUnitName = "ajean-link" // 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 { func cmdLink(args []string) error {
sub := "" sub := ""
if len(args) > 0 { if len(args) > 0 {
@@ -83,40 +88,29 @@ func cmdLink(args []string) error {
} }
switch sub { switch sub {
case "": case "":
// `ajean link` seul : afficher l'aide des sous-commandes (NE démarre pas — // `ajean link` seul : afficher l'aide (NE démarre rien — éviter de prendre
// éviter de prendre un mot pour un token et d'écraser le vrai). // un mot pour un jeton et d'écraser le vrai).
printLinkHelp() printLinkHelp()
return nil 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": case "status":
tok := readLinkToken() tok := readLinkToken()
if tok == "" { 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 return nil
} }
fmt.Printf("%s token enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL()) fmt.Printf("%s jeton enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL())
if linkServiceActive() { if uiServiceActive() {
fmt.Printf("%s service %s: actif\n", green("[ok]"), linkServiceName()) fmt.Printf("%s service %s actif — le tunnel est ouvert\n", green("[ok]"), uiServiceName())
} else { } 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 return nil
case "logout": case "logout":
_ = removeLinkToken() if err := removeLinkToken(); err != nil {
fmt.Println(green("[ok]") + " token supprimé")
return nil
case "stop":
return linkServiceCtl("stop")
case "restart":
if err := linkServiceCtl("restart"); err != nil {
return err return err
} }
return linkPrintIdentity() fmt.Println(green("[ok]") + " jeton supprimé — « ajean ui restart » pour fermer le tunnel")
return nil
case "code": case "code":
code, err := newPairCode() code, err := newPairCode()
if err != nil { if err != nil {
@@ -126,9 +120,9 @@ func cmdLink(args []string) error {
return nil 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 // 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_") { if !strings.HasPrefix(sub, "jl_") {
fmt.Fprintf(os.Stderr, "%s sous-commande inconnue : %q\n\n", yellow("[link]"), sub) fmt.Fprintf(os.Stderr, "%s sous-commande inconnue : %q\n\n", yellow("[link]"), sub)
printLinkHelp() printLinkHelp()
@@ -137,46 +131,52 @@ func cmdLink(args []string) error {
if err := saveLinkToken(strings.TrimSpace(sub)); err != nil { if err := saveLinkToken(strings.TrimSpace(sub)); err != nil {
return err return err
} }
// Nouveau token → on (re)démarre le worker pour qu'il le prenne en compte. // Nouveau jeton : le service d'interface doit le relire pour ouvrir le tunnel.
return startLink(true) if err := uiServiceCtl("restart"); err != nil {
} return err
// 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
}
} }
return linkPrintIdentity() 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`. // printLinkHelp liste les sous-commandes de `ajean link`.
func printLinkHelp() { func printLinkHelp() {
fmt.Print(`ajean link — accès distant via le relais ajean.link fmt.Print(`ajean link — accès distant via le relais ajean.link
Usage : Usage :
ajean link <token> enregistre le token (1re fois / pour le changer) et démarre le lien ajean link <token> enregistre le jeton (1re fois / pour le changer) et ouvre le tunnel
ajean link start démarre le lien en arrière-plan (service) ajean link status état du jeton et du tunnel
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 code génère un code d'appairage (valable 10 min, à usage unique) 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 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). // 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 // 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 // appWebMux est le mux servi par l'app — réutilisé pour le tunnel afin que les
// deux surfaces partagent la même conversation. // deux surfaces partagent la même conversation.
var ( var (
@@ -327,45 +283,30 @@ func appLinkRunning() bool {
return appLink.running 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 // 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 // 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. // les fils divergent entre l'UI locale et app.ajean.link.
func killForeignLinkWorker() { func killForeignUIWorker() {
if pid := linkUserPID(); pid > 0 { if pid := uiUserPID(); pid > 0 {
killTree(pid) killTree(pid)
_ = os.Remove(linkPIDPath()) _ = os.Remove(uiPIDPath())
} }
} }
// serveLocalWebMux sert l'UI web locale sur :8090 avec le mux fourni — le MÊME // uiPIDPath / uiLogPath : suivi du worker de lien hors systemd (macOS,
// 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,
// Windows), où AJEAN est une app de bureau lancée sans droits root. // Windows), où AJEAN est une app de bureau lancée sans droits root.
func linkPIDPath() string { return filepath.Join(AjeanHome(), linkUnitName+".pid") } func uiPIDPath() string { return filepath.Join(AjeanHome(), uiUnitName+".pid") }
func linkLogPath() string { return filepath.Join(AjeanHome(), linkUnitName+".log") } 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) ; // 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 // 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 // « 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 // 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 // « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne
// composait jamais le tunnel. // composait jamais le tunnel.
func linkServiceCtl(action string) error { func uiServiceCtl(action string) error {
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
// Dans l'app de bureau, le tunnel tourne DANS ce process (conversation // Dans l'app de bureau, le tunnel tourne DANS ce process (conversation
// unique) : on ne lance surtout pas un worker séparé. // unique) : on ne lance surtout pas un worker séparé.
@@ -375,7 +316,7 @@ func linkServiceCtl(action string) error {
stopAppLink() stopAppLink()
case "start", "restart": case "start", "restart":
stopAppLink() stopAppLink()
killForeignLinkWorker() killForeignUIWorker()
startAppLink(appWebMux) startAppLink(appWebMux)
if !appLinkRunning() { if !appLinkRunning() {
return fmt.Errorf("tunnel non démarré (clé de liaison absente ?)") return fmt.Errorf("tunnel non démarré (clé de liaison absente ?)")
@@ -385,43 +326,43 @@ func linkServiceCtl(action string) error {
} }
return nil return nil
} }
return linkUserSvcCtl(action) return uiUserSvcCtl(action)
} }
bin, pre := "systemctl", []string{} bin, pre := "systemctl", []string{}
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
bin, pre = "sudo", []string{"-n", "systemctl"} 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 cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := cmd.Run(); err != nil { 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 { switch action {
case "start", "restart": 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": 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 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). // Linux, processus suivi par fichier PID ailleurs).
func linkServiceActive() bool { func uiServiceActive() bool {
if runtime.GOOS != "linux" { if runtime.GOOS != "linux" {
if appOwnsLink { if appOwnsLink {
return appLinkRunning() 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" 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). // (fichier absent, illisible, ou process mort → on nettoie le fichier obsolète).
func linkUserPID() int { func uiUserPID() int {
b, err := os.ReadFile(linkPIDPath()) b, err := os.ReadFile(uiPIDPath())
if err != nil { if err != nil {
return 0 return 0
} }
@@ -435,24 +376,24 @@ func linkUserPID() int {
return pid return pid
} }
// linkUserSvcCtl : équivalent de systemctl pour les OS sans systemd. // uiUserSvcCtl : équivalent de systemctl pour les OS sans systemd.
func linkUserSvcCtl(action string) error { func uiUserSvcCtl(action string) error {
switch action { switch action {
case "stop", "restart": case "stop", "restart":
if pid := linkUserPID(); pid > 0 { if pid := uiUserPID(); pid > 0 {
killTree(pid) killTree(pid)
for i := 0; i < 30 && pidAlive(pid); i++ { for i := 0; i < 30 && pidAlive(pid); i++ {
time.Sleep(100 * time.Millisecond) time.Sleep(100 * time.Millisecond)
} }
} }
_ = os.Remove(linkPIDPath()) _ = os.Remove(uiPIDPath())
if action == "stop" { 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 return nil
} }
case "start": case "start":
if linkUserPID() > 0 { if uiUserPID() > 0 {
fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), linkServiceName()) fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), uiServiceName())
return nil return nil
} }
default: default:
@@ -469,9 +410,9 @@ func linkUserSvcCtl(action string) error {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err 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 { 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() defer logf.Close()
@@ -483,7 +424,7 @@ func linkUserSvcCtl(action string) error {
pid := cmd.Process.Pid pid := cmd.Process.Pid
// PID + binaire d'origine : la 2e ligne dit QUELLE copie de l'app a lancé ce // 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. // 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) return fmt.Errorf("écriture du PID: %w", err)
} }
_ = cmd.Process.Release() _ = cmd.Process.Release()
@@ -493,10 +434,10 @@ func linkUserSvcCtl(action string) error {
// « en ligne » sur un process déjà mort. // « en ligne » sur un process déjà mort.
time.Sleep(1500 * time.Millisecond) time.Sleep(1500 * time.Millisecond)
if !pidAlive(pid) { if !pidAlive(pid) {
_ = os.Remove(linkPIDPath()) _ = os.Remove(uiPIDPath())
return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", linkLogPath()) 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 return nil
} }
+15 -10
View File
@@ -64,6 +64,8 @@ func Main() {
mustExit(cmdWeb(args)) mustExit(cmdWeb(args))
case "link": case "link":
mustExit(cmdLink(args)) mustExit(cmdLink(args))
case "ui":
mustExit(cmdUI(args))
case "oai": case "oai":
mustExit(cmdOAI(args)) mustExit(cmdOAI(args))
case "agent": case "agent":
@@ -108,10 +110,10 @@ func printHelp() {
Usage: ajean <commande> [args] Usage: ajean <commande> [args]
Application: AJEAN tourne en deux services : ajean-engine (le modèle) et ajean-ui
app lance l'UI web + ouvre le navigateur (auto au double-clic du binaire) (l'interface web, le tunnel d'accès distant et l'endpoint OpenAI).
Service: Moteur (ajean-engine) :
start | stop | restart gérer le service start | stop | restart gérer le service
status | logs état / logs en direct status | logs état / logs en direct
enable | disable auto-démarrage au boot enable | disable auto-démarrage au boot
@@ -121,22 +123,24 @@ Service:
vram utilisation GPU/VRAM (nvidia-smi) vram utilisation GPU/VRAM (nvidia-smi)
gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) 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 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 set-web-key [clé] protéger l'API de pilotage; vide = générer, "" = retirer
Interaction: Interaction:
chat [system-prompt] chat terminal streamé 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) agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire)
memory [off|ondemand|always|status] mode mémoire de l'IA memory [off|ondemand|always|status] mode mémoire de l'IA
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>] 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 web de l'IA (moteur intégré ou serveur Crawl4AI)
Accès distant (ajean.link) : Accès distant (ajean.link) :
link <token> enregistre le token et démarre le lien au relais link <token> enregistre le jeton et ouvre le tunnel
link start | restart | stop pilote le service de lien
link code code d'appairage (10 min, usage unique) pour le portail link code code d'appairage (10 min, usage unique) pour le portail
link status | logout état du lien / oublier le token link status | logout état du jeton / l'oublier
link serve worker au premier plan (lancé par ajean-link.service)
Backend llama.cpp : Backend llama.cpp :
llamacpp install clone + compile llama.cpp (CUDA/ROCm/Metal/CPU), pointe BIN dessus 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 modelsDir() string { return filepath.Join(AjeanHome(), "models") }
func workspaceDir() string { return filepath.Join(AjeanHome(), "workspace") } 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 { func serviceName() string {
if n := os.Getenv("AJEAN_SERVICE"); n != "" { if n := os.Getenv("AJEAN_SERVICE"); n != "" {
return n return n
} }
return "ajean" return "ajean-engine"
} }
// Color helpers (ANSI). Disabled when stdout is not a TTY. // Color helpers (ANSI). Disabled when stdout is not a TTY.
+51 -15
View File
@@ -11,10 +11,34 @@ import (
"strings" "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 %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] const serviceUnitTemplate = `[Unit]
Description=AJEAN llama.cpp server Description=AJEAN llama.cpp server
After=network.target After=network.target
@@ -109,21 +133,29 @@ func cmdInstall(args []string) error {
} }
fmt.Printf(" %s /etc/default/ajean\n", green("✓")) fmt.Printf(" %s /etc/default/ajean\n", green("✓"))
// 4. Write the systemd unit (ExecStart = `ajean serve`, no start.sh needed) // 4. Les deux unités : le moteur (exec llama-server) et l'interface.
unit := fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve") units := map[string]string{
unitPath := "/etc/systemd/system/" + svc + ".service" svc: fmt.Sprintf(serviceUnitTemplate, targetUser, ajeanHome, installedExePath()+" serve"),
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, installedExePath()+" web"),
return err }
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 // 5. Sudoers : une règle par unité, pour que l'interface (sans privilèges)
sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) // puisse redémarrer le moteur ET elle-même.
sudoersPath := "/etc/sudoers.d/ajean-" + svc for _, name := range []string{svc, uiUnitName} {
if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name, targetUser, name, name, name, name, name)
return err 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 // 6. chown AJEAN_HOME contents to target user
chown(ajeanHome, u) chown(ajeanHome, u)
@@ -155,11 +187,15 @@ func cmdUninstall(args []string) error {
keepData = true keepData = true
} }
} }
_ = exec.Command("systemctl", "stop", svc).Run() for _, name := range []string{uiUnitName, svc} {
_ = exec.Command("systemctl", "disable", svc).Run() _ = exec.Command("systemctl", "stop", name).Run()
_ = exec.Command("systemctl", "disable", name).Run()
}
for _, p := range []string{ for _, p := range []string{
"/etc/systemd/system/" + svc + ".service", "/etc/systemd/system/" + svc + ".service",
"/etc/systemd/system/" + uiUnitName + ".service",
"/etc/sudoers.d/ajean-" + svc, "/etc/sudoers.d/ajean-" + svc,
"/etc/sudoers.d/ajean-" + uiUnitName,
"/etc/default/ajean", "/etc/default/ajean",
installedExePath(), installedExePath(),
} { } {
+3 -3
View File
@@ -440,16 +440,16 @@ func restartAfterUpdate() (bool, string) {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return scheduleAppRestart() return scheduleAppRestart()
} }
if runtime.GOOS != "linux" || !linkServiceActive() { if runtime.GOOS != "linux" || !uiServiceActive() {
return false, "" return false, ""
} }
go func() { go func() {
time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client 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 // --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. // 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 { func restartHintText() string {
+10 -10
View File
@@ -27,7 +27,7 @@ func handleLinkStatus(w http.ResponseWriter, r *http.Request) {
tok := readLinkToken() tok := readLinkToken()
sendJSON(w, http.StatusOK, map[string]any{ sendJSON(w, http.StatusOK, map[string]any{
"linked": tok != "", "linked": tok != "",
"active": linkServiceActive(), "active": uiServiceActive(),
"machineURL": remoteServerURL(), "machineURL": remoteServerURL(),
"fingerprint": e2eFingerprint(), "fingerprint": e2eFingerprint(),
}) })
@@ -47,17 +47,17 @@ func handleLinkConnect(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return return
} }
// (re)démarre le worker pour qu'il prenne la nouvelle clé. Sur une machine sans // Redémarre le service d'interface pour qu'il relise la clé et ouvre le
// systemd (dev/Windows), le token est quand même enregistré : on renvoie l'info // tunnel. Sur une machine sans systemd (dev/Windows), la clé est quand même
// mais on signale que le service n'a pas pu démarrer ici. // enregistrée : on renvoie l'info en signalant que le service n'a pas démarré.
svcErr := "" svcErr := ""
if err := startLink(true); err != nil { if err := uiServiceCtl("restart"); err != nil {
svcErr = err.Error() svcErr = err.Error()
} }
sendJSON(w, http.StatusOK, map[string]any{ sendJSON(w, http.StatusOK, map[string]any{
"ok": true, "ok": true,
"linked": true, "linked": true,
"active": linkServiceActive(), "active": uiServiceActive(),
"machineURL": remoteServerURL(), "machineURL": remoteServerURL(),
"serviceErr": svcErr, "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"}) sendJSON(w, http.StatusBadRequest, map[string]any{"error": "aucune clé de liaison enregistrée"})
return return
} }
if err := linkServiceCtl("restart"); err != nil { if err := uiServiceCtl("restart"); err != nil {
sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "active": linkServiceActive()}) sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "active": uiServiceActive()})
return 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 // handleLinkDisconnect (POST /api/link/disconnect) : arrête le service et oublie
// la clé (équivalent `ajean link stop` + `ajean link logout`). // la clé (équivalent `ajean link stop` + `ajean link logout`).
func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) { func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) {
_ = linkServiceCtl("stop") _ = uiServiceCtl("stop")
_ = removeLinkToken() _ = removeLinkToken()
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "linked": false, "active": false}) 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 { } else {
fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]")) 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) return http.Serve(ln, mux)
} }