Purge du code de compatibilite jean, base bbolt et arborescence ajean

- suppression des migrations d'agencement, d'unites et de dossier de donnees
- store bbolt unique (ajean.db) : config, prefs, conversation, cles, drapeaux
- arborescence backends/ bin/ presets/ memory/ models/ workspace/
- plus aucune variable, unite ou asset nomme jean
This commit is contained in:
nathaninline committed 2026-08-07 16:51:36 +02:00
1 parent 081a88c846
commit 2455d9f402
78 files changed
+1160 -2293

No files matched your search

+1
View File
@@ -38,6 +38,7 @@ require (
github.com/segmentio/encoding v0.5.4 // indirect github.com/segmentio/encoding v0.5.4 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect github.com/zeebo/blake3 v0.2.4 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect
+2
View File
@@ -92,6 +92,8 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+1 -1
View File
@@ -73,7 +73,7 @@ func detectBuildPlan() buildPlan {
// L'UI web embarquée de llama-server exige npm (ou un téléchargement // L'UI web embarquée de llama-server exige npm (ou un téléchargement
// d'assets pré-compilés depuis HuggingFace) pour générer un service-worker // d'assets pré-compilés depuis HuggingFace) pour générer un service-worker
// PWA — une dépendance lourde qui casse le build sur une machine sans node. // PWA — une dépendance lourde qui casse le build sur une machine sans node.
// jean fournit sa propre UI, donc on la désactive : build plus rapide et // ajean fournit sa propre UI, donc on la désactive : build plus rapide et
// sans dépendance réseau/npm. BUILD_UI=OFF coupe npm ; USE_PREBUILT_UI=OFF // sans dépendance réseau/npm. BUILD_UI=OFF coupe npm ; USE_PREBUILT_UI=OFF
// coupe le téléchargement d'assets pré-compilés depuis HuggingFace (qui // coupe le téléchargement d'assets pré-compilés depuis HuggingFace (qui
// échoue sur un réseau restreint et fait planter l'embed). Sur un checkout // échoue sur un réseau restreint et fait planter l'embed). Sur un checkout
+1 -1
View File
@@ -12,7 +12,7 @@ func TestCompiledFile(t *testing.T) {
`[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu", `[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu",
// La ligne de commande nvcc géante ne doit PAS être prise pour un fichier. // La ligne de commande nvcc géante ne doit PAS être prise pour un fichier.
` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "", ` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "",
`Building Custom Rule C:/ProgramData/jean/...`: "", `Building Custom Rule C:/ProgramData/ajean/...`: "",
`-- UI: running npm install`: "", `-- UI: running npm install`: "",
} }
for in, want := range cases { for in, want := range cases {
+1 -1
View File
@@ -5,7 +5,7 @@ package ajean
// venir) propose « en un clic » un modèle adapté à la machine. // venir) propose « en un clic » un modèle adapté à la machine.
// //
// La liste vit sur https://ajean.link/models.json (éditée par l'opérateur, voir // La liste vit sur https://ajean.link/models.json (éditée par l'opérateur, voir
// jean-relay/models_catalog.go). Si le réseau échoue, on retombe sur un // ajean-relay/models_catalog.go). Si le réseau échoue, on retombe sur un
// catalogue minimal embarqué (fallbackCatalogJSON) pour ne jamais bloquer. // catalogue minimal embarqué (fallbackCatalogJSON) pour ne jamais bloquer.
import ( import (
+48 -80
View File
@@ -7,23 +7,18 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"sort"
"strconv" "strconv"
"strings" "strings"
) )
// readAPIKey returns the trimmed contents of $JEAN_HOME/.api_key, or "" if the // readAPIKey renvoie la clé Bearer de llama-server, ou "" si aucune n'est
// file is absent/empty. This store is independent of config.env so the key // définie. Elle est rangée hors de la configuration pour survivre aux
// survives preset switches (which rewrite config.env wholesale). // changements de preset, qui remplacent la configuration en bloc.
func readAPIKey() string { func readAPIKey() string { return getStr(bkState, "api_key") }
b, err := os.ReadFile(apiKeyPath())
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// authHeader sets the Authorization: Bearer header on req when an API key is // authHeader sets the Authorization: Bearer header on req when an API key is
// configured, so jean's own internal calls (chat/web/bench/test) authenticate // configured, so AJEAN's own internal calls (chat/web/bench/test) authenticate
// against a protected llama-server. No-op when no key is set. // against a protected llama-server. No-op when no key is set.
func authHeader(req *http.Request) { func authHeader(req *http.Request) {
if k := readAPIKey(); k != "" { if k := readAPIKey(); k != "" {
@@ -35,39 +30,32 @@ func authHeader(req *http.Request) {
func genAPIKey() string { func genAPIKey() string {
buf := make([]byte, 24) buf := make([]byte, 24)
_, _ = rand.Read(buf) _, _ = rand.Read(buf)
return "sk-jean-" + hex.EncodeToString(buf) return "sk-ajean-" + hex.EncodeToString(buf)
} }
// writeAPIKey persists (key != "") or clears (key == "") the completion API key // writeAPIKey enregistre (key != "") ou supprime (key == "") la clé Bearer.
// in $JEAN_HOME/.api_key. It also wipes any residual API_KEY in config.env to // Ne redémarre PAS le service : llama-server ne lit --api-key qu'au lancement,
// avoid ambiguity. It does NOT restart the service — callers decide when to // c'est à l'appelant de choisir quand appliquer.
// apply (llama-server only reads --api-key at launch).
func writeAPIKey(key string) error { func writeAPIKey(key string) error {
_ = SetConfigKey("API_KEY", "") _ = SetConfigKey("API_KEY", "") // aucune ambiguïté avec une valeur résiduelle
if key == "" { return putStr(bkState, "api_key", key)
if err := os.Remove(apiKeyPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
return os.WriteFile(apiKeyPath(), []byte(key+"\n"), 0o600)
} }
// maskAPIKey renders a key for display: keep the "sk-jean-" prefix and last 4 // maskAPIKey renders a key for display: keep the "sk-ajean-" prefix and last 4
// chars, elide the middle. Empty in → empty out. // chars, elide the middle. Empty in → empty out.
func maskAPIKey(k string) string { func maskAPIKey(k string) string {
if k == "" { if k == "" {
return "" return ""
} }
if len(k) <= 12 { if len(k) <= 13 {
return "…" + k[len(k)-2:] return "…" + k[len(k)-2:]
} }
return k[:8] + "…" + k[len(k)-4:] return k[:9] + "…" + k[len(k)-4:]
} }
// cmdSetAPIKey sets (or clears) the API key stored in $JEAN_HOME/.api_key. When // cmdSetAPIKey définit (ou supprime) la clé Bearer de llama-server. Exposé sur
// exposed on the internet, llama-server requires "Authorization: Bearer <clé>" // internet, le serveur exige alors « Authorization: Bearer <clé> » à chaque
// for every call. // appel.
// //
// ajean set-api-key <clé> définit la clé // ajean set-api-key <clé> définit la clé
// ajean set-api-key génère une clé aléatoire // ajean set-api-key génère une clé aléatoire
@@ -83,15 +71,13 @@ func cmdSetAPIKey(args []string) error {
default: default:
key = strings.TrimSpace(args[0]) key = strings.TrimSpace(args[0])
} }
// Stocke dans le fichier dédié (survit aux switches de preset). On nettoie
// aussi un éventuel API_KEY résiduel dans config.env pour éviter la confusion.
if err := writeAPIKey(key); err != nil { if err := writeAPIKey(key); err != nil {
return err return err
} }
if key == "" { if key == "" {
fmt.Printf("%s API_KEY supprimée — serveur ouvert (pas d'authentification)\n", yellow("[info]")) fmt.Printf("%s API_KEY supprimée — serveur ouvert (pas d'authentification)\n", yellow("[info]"))
} else { } else {
fmt.Printf("%s API_KEY enregistrée dans %s\n", green("[ok]"), apiKeyPath()) fmt.Printf("%s API_KEY enregistrée\n", green("[ok]"))
fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key)) fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
} }
fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] ")) fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
@@ -103,72 +89,54 @@ func cmdSetAPIKey(args []string) error {
return serviceAction("restart") return serviceAction("restart")
} }
// ReadConfig parses config.env into a key/value map. // ReadConfig renvoie la configuration active de llama-server.
// Lines starting with '#' and blanks are ignored. Values may be quoted with ". func ReadConfig() map[string]string { return allKV(bkConfig) }
func ReadConfig() map[string]string {
// SetConfigKey définit une clé de configuration. Une valeur vide la supprime.
func SetConfigKey(key, value string) error { return putStr(bkConfig, key, value) }
// WriteConfig remplace TOUTE la configuration en une transaction. C'est ce
// qu'exige l'application d'un preset : jamais un état mi-ancien mi-nouveau.
func WriteConfig(m map[string]string) error { return replaceKV(bkConfig, m) }
// parseEnv lit un fichier au format clé=valeur (les presets). Les lignes vides
// et les commentaires sont ignorés, les guillemets retirés.
func parseEnv(text string) map[string]string {
m := map[string]string{} m := map[string]string{}
b, err := os.ReadFile(confPath()) for _, line := range strings.Split(text, "\n") {
if err != nil {
return m
}
for _, line := range strings.Split(string(b), "\n") {
s := strings.TrimSpace(line) s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") { if s == "" || strings.HasPrefix(s, "#") {
continue continue
} }
i := strings.IndexByte(s, '=') k, v, ok := strings.Cut(strings.TrimPrefix(s, "export "), "=")
if i < 0 { if !ok {
continue continue
} }
k := strings.TrimSpace(s[:i]) m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), "\"")
v := strings.TrimSpace(s[i+1:])
v = strings.Trim(v, "\"")
m[k] = v
} }
return m return m
} }
// SetConfigKey sets key=value in config.env, updating the line in place if the // formatEnv rend une configuration au format des presets, clés triées pour que
// key already exists (preserving comments/order) or appending it otherwise. // deux écritures du même contenu donnent le même fichier.
// An empty value removes the key. The file is created if missing. func formatEnv(m map[string]string) string {
func SetConfigKey(key, value string) error { keys := make([]string, 0, len(m))
b, _ := os.ReadFile(confPath()) for k := range m {
lines := []string{} keys = append(keys, k)
if len(b) > 0 {
lines = strings.Split(strings.TrimRight(string(b), "\n"), "\n")
} }
newLine := key + "=" + value sort.Strings(keys)
found := false var b strings.Builder
out := []string{} for _, k := range keys {
for _, line := range lines { fmt.Fprintf(&b, "%s=%s\n", k, m[k])
s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") {
out = append(out, line)
continue
}
i := strings.IndexByte(s, '=')
if i >= 0 && strings.TrimSpace(s[:i]) == key {
found = true
if value != "" {
out = append(out, newLine)
}
// empty value => drop the line
continue
}
out = append(out, line)
} }
if !found && value != "" { return b.String()
out = append(out, newLine)
}
content := strings.Join(out, "\n") + "\n"
return os.WriteFile(confPath(), []byte(content), 0o644)
} }
// Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été // Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été
// retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le // retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le
// seul frein. // seul frein.
// LLMPort returns the configured server port (config.env PORT), default 8080. // LLMPort renvoie le port du serveur (clé PORT), 8080 par défaut.
func LLMPort() int { func LLMPort() int {
if p, ok := ReadConfig()["PORT"]; ok { if p, ok := ReadConfig()["PORT"]; ok {
if n, err := strconv.Atoi(p); err == nil && n > 0 { if n, err := strconv.Atoi(p); err == nil && n > 0 {
+6 -6
View File
@@ -53,9 +53,9 @@ func cmdLlamacpp(args []string) error {
return err return err
} }
if err := SetConfigKey("BIN", bin); err != nil { if err := SetConfigKey("BIN", bin); err != nil {
return fmt.Errorf("binaires installés mais échec écriture BIN dans config.env: %w", err) return fmt.Errorf("binaires installés mais échec d'écriture de BIN : %w", err)
} }
fmt.Printf("%s BIN mis à jour dans %s — %s pour appliquer\n", green("✓"), confPath(), bold("ajean restart")) fmt.Printf("%s BIN mis à jour — %s pour appliquer\n", green("✓"), bold("ajean restart"))
return nil return nil
default: default:
return fmt.Errorf("sous-commande inconnue: %s (install | update | prebuilt | status)", sub) return fmt.Errorf("sous-commande inconnue: %s (install | update | prebuilt | status)", sub)
@@ -68,7 +68,7 @@ func cmdLlamacpp(args []string) error {
// llamacppRepoDir resolves the llama.cpp checkout: derived from config BIN when // llamacppRepoDir resolves the llama.cpp checkout: derived from config BIN when
// possible (so `update` targets whatever build the service actually runs), // possible (so `update` targets whatever build the service actually runs),
// otherwise the default under $JEAN_HOME/backends/llama.cpp. // otherwise the default under $AJEAN_HOME/backends/llama.cpp.
func llamacppRepoDir() string { func llamacppRepoDir() string {
if bin := ReadConfig()["BIN"]; bin != "" { if bin := ReadConfig()["BIN"]; bin != "" {
if real, err := filepath.EvalSymlinks(bin); err == nil { if real, err := filepath.EvalSymlinks(bin); err == nil {
@@ -200,13 +200,13 @@ func llamacppInstall(args []string) error {
fmt.Printf("\n%s binaire compilé : %s\n", green("✓"), bin) fmt.Printf("\n%s binaire compilé : %s\n", green("✓"), bin)
if noSwitch { if noSwitch {
fmt.Printf("%s --no-switch : config.env inchangée (BIN à régler manuellement)\n", dim("[info]")) fmt.Printf("%s --no-switch : configuration inchangée (BIN à régler manuellement)\n", dim("[info]"))
return nil return nil
} }
if err := SetConfigKey("BIN", bin); err != nil { if err := SetConfigKey("BIN", bin); err != nil {
return fmt.Errorf("build ok mais échec écriture BIN dans config.env: %w", err) return fmt.Errorf("build ok mais échec d'écriture de BIN : %w", err)
} }
fmt.Printf("%s BIN mis à jour dans %s\n", green("✓"), confPath()) fmt.Printf("%s BIN mis à jour\n", green("✓"))
fmt.Printf("\nProchaines étapes :\n 1. renseigne MODEL : %s\n 2. démarre : %s\n", fmt.Printf("\nProchaines étapes :\n 1. renseigne MODEL : %s\n 2. démarre : %s\n",
bold("ajean edit"), bold("ajean restart")) bold("ajean edit"), bold("ajean restart"))
return nil return nil
+62 -35
View File
@@ -10,14 +10,13 @@ import (
"strings" "strings"
) )
// Les .gguf vivent historiquement dans JEAN_HOME, mais un modèle de 40 Go tient // Les .gguf vivent dans $AJEAN_HOME/models, mais un modèle de 40 Go tient
// rarement sur le disque système : on autorise des dossiers supplémentaires // rarement sur le disque système : on autorise des dossiers supplémentaires
// (disque dur externe, second SSD, montage réseau…). La liste est persistée // (disque dur externe, second SSD, montage réseau…). La liste est persistée en
// dans $JEAN_HOME/model_dirs.json — PAS dans config.env, que le changement de // base — PAS dans la configuration, que le changement de preset remplace — et
// preset réécrit — et peut aussi venir de $JEAN_MODEL_DIRS. // peut aussi venir de $AJEAN_MODEL_DIRS.
func modelDirsPath() string { return filepath.Join(AjeanHome(), "model_dirs.json") }
// modelDirsListSep sépare les dossiers dans $JEAN_MODEL_DIRS (':' sous Unix, // modelDirsListSep sépare les dossiers dans $AJEAN_MODEL_DIRS (':' sous Unix,
// ';' sous Windows, comme le PATH). // ';' sous Windows, comme le PATH).
func modelDirsListSep() string { func modelDirsListSep() string {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
@@ -26,20 +25,15 @@ func modelDirsListSep() string {
return ":" return ":"
} }
// extraModelDirs renvoie les dossiers déclarés par l'utilisateur (fichier puis // extraModelDirs renvoie les dossiers déclarés par l'utilisateur (base puis
// variable d'environnement), nettoyés et dédoublonnés, JEAN_HOME exclu. // variable d'environnement), nettoyés et dédoublonnés, models/ exclu.
func extraModelDirs() []string { func extraModelDirs() []string {
var raw []string var raw []string
if b, err := os.ReadFile(modelDirsPath()); err == nil { getJSON(bkState, "model_dirs", &raw)
var dirs []string if v := os.Getenv("AJEAN_MODEL_DIRS"); v != "" {
if json.Unmarshal(b, &dirs) == nil {
raw = append(raw, dirs...)
}
}
if v := envAny("AJEAN_MODEL_DIRS", "JEAN_MODEL_DIRS"); v != "" {
raw = append(raw, strings.Split(v, modelDirsListSep())...) raw = append(raw, strings.Split(v, modelDirsListSep())...)
} }
return dedupDirs(raw, AjeanHome()) return dedupDirs(raw, modelsDir())
} }
// dedupDirs nettoie une liste de chemins : vides retirés, chemins absolus // dedupDirs nettoie une liste de chemins : vides retirés, chemins absolus
@@ -79,23 +73,15 @@ func normDir(p string) string {
return p return p
} }
// modelDirs renvoie tous les dossiers où chercher un .gguf : JEAN_HOME d'abord // modelDirs renvoie tous les dossiers où chercher un .gguf : models/ d'abord
// (il reste la destination des téléchargements), puis les dossiers ajoutés. // (il reste la destination des téléchargements), puis les dossiers ajoutés.
func modelDirs() []string { func modelDirs() []string {
return append([]string{AjeanHome()}, extraModelDirs()...) return append([]string{modelsDir()}, extraModelDirs()...)
} }
// saveExtraModelDirs écrit la liste des dossiers supplémentaires sur disque. // saveExtraModelDirs enregistre la liste des dossiers supplémentaires.
func saveExtraModelDirs(dirs []string) error { func saveExtraModelDirs(dirs []string) error {
clean := dedupDirs(dirs, AjeanHome()) return putJSON(bkState, "model_dirs", dedupDirs(dirs, modelsDir()))
b, err := json.MarshalIndent(clean, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
return os.WriteFile(modelDirsPath(), append(b, '\n'), 0o644)
} }
// pathWithin dit si le fichier p est dans le dossier dir (ou un sous-dossier). // pathWithin dit si le fichier p est dans le dossier dir (ou un sous-dossier).
@@ -106,7 +92,7 @@ func pathWithin(dir, p string) bool {
// baseName renvoie le nom de fichier d'un chemin en acceptant les DEUX // baseName renvoie le nom de fichier d'un chemin en acceptant les DEUX
// séparateurs : un config.env écrit sous Windows (C:\models\x.gguf) peut être // séparateurs : un config.env écrit sous Windows (C:\models\x.gguf) peut être
// relu par un jean Linux, où filepath.Base ne coupe pas sur '\'. // relu par un ajean Linux, où filepath.Base ne coupe pas sur '\'.
func baseName(p string) string { func baseName(p string) string {
p = strings.TrimRight(strings.TrimSpace(p), `/\`) p = strings.TrimRight(strings.TrimSpace(p), `/\`)
if i := strings.LastIndexAny(p, `/\`); i >= 0 { if i := strings.LastIndexAny(p, `/\`); i >= 0 {
@@ -119,7 +105,7 @@ func baseName(p string) string {
// absolu) en chemin absolu vers un .gguf. Un chemin absolu n'est accepté que // absolu) en chemin absolu vers un .gguf. Un chemin absolu n'est accepté que
// s'il est dans un des dossiers déclarés — sinon l'UI web deviendrait un moyen // s'il est dans un des dossiers déclarés — sinon l'UI web deviendrait un moyen
// de lire/supprimer n'importe quel fichier de la machine. Un simple nom de // de lire/supprimer n'importe quel fichier de la machine. Un simple nom de
// fichier est cherché dans chaque dossier, JEAN_HOME en premier. // fichier est cherché dans chaque dossier, AJEAN_HOME en premier.
func resolveModelPath(name string) (string, error) { func resolveModelPath(name string) (string, error) {
s := strings.TrimSpace(strings.Trim(strings.TrimSpace(name), `"`)) s := strings.TrimSpace(strings.Trim(strings.TrimSpace(name), `"`))
if s == "" { if s == "" {
@@ -147,7 +133,7 @@ func resolveModelPath(name string) (string, error) {
return p, nil return p, nil
} }
} }
return filepath.Join(AjeanHome(), base), nil // introuvable : l'appelant décidera return filepath.Join(modelsDir(), base), nil // introuvable : l'appelant décidera
} }
// modelDirCount compte les .gguf lisibles d'un dossier (-1 si illisible). // modelDirCount compte les .gguf lisibles d'un dossier (-1 si illisible).
@@ -165,7 +151,48 @@ func modelDirCount(dir string) int {
return n return n
} }
// handleModelDirs : GET liste les dossiers de modèles (JEAN_HOME + ajoutés), // diskFree renvoie l'espace libre du volume qui porte dir, ou -1 s'il est
// inconnu. Le dossier n'existe pas forcément encore (models/ au premier
// lancement) : on remonte les parents jusqu'à en trouver un qui existe.
func diskFree(dir string) int64 {
d, err := filepath.Abs(strings.TrimSpace(dir))
if err != nil {
return -1
}
for {
if st, err := os.Stat(d); err == nil && st.IsDir() {
return diskFreeAt(d)
}
parent := filepath.Dir(d)
if parent == d {
return -1
}
d = parent
}
}
// resolveDownloadDir valide le dossier de destination demandé pour un
// téléchargement : vide = models/, sinon il doit être un des dossiers
// déclarés — sans quoi l'UI web permettrait d'écrire n'importe où sur la
// machine.
func resolveDownloadDir(dir string) (string, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return modelsDir(), nil
}
abs, err := filepath.Abs(dir)
if err != nil {
return "", fmt.Errorf("chemin invalide")
}
for _, d := range modelDirs() {
if normDir(d) == normDir(abs) {
return d, nil
}
}
return "", fmt.Errorf("dossier non autorisé : %s — ajoute-le dans « Dossiers de modèles »", abs)
}
// handleModelDirs : GET liste les dossiers de modèles (AJEAN_HOME + ajoutés),
// POST {path, action:"add"|"remove"} en ajoute ou en retire un. // POST {path, action:"add"|"remove"} en ajoute ou en retire un.
func handleModelDirs(w http.ResponseWriter, r *http.Request) { func handleModelDirs(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" { if r.Method == "POST" {
@@ -211,10 +238,10 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true}) sendJSON(w, 200, map[string]any{"ok": true})
return return
} }
out := []map[string]any{{"path": AjeanHome(), "home": true, "count": modelDirCount(AjeanHome()), "exists": true}} out := []map[string]any{{"path": modelsDir(), "home": true, "count": modelDirCount(modelsDir()), "exists": true, "free": diskFree(modelsDir())}}
for _, d := range extraModelDirs() { for _, d := range extraModelDirs() {
n := modelDirCount(d) n := modelDirCount(d)
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0}) out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0, "free": diskFree(d)})
} }
sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": envAny("AJEAN_MODEL_DIRS", "JEAN_MODEL_DIRS") != ""}) sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("AJEAN_MODEL_DIRS") != ""})
} }
+7 -7
View File
@@ -6,13 +6,13 @@ import (
"testing" "testing"
) )
// Un modèle posé hors de JEAN_HOME (disque externe) doit être utilisable dès // Un modèle posé hors de AJEAN_HOME (disque externe) doit être utilisable dès
// que son dossier est déclaré — et refusé tant qu'il ne l'est pas. // que son dossier est déclaré — et refusé tant qu'il ne l'est pas.
func TestResolveModelPathExtraDir(t *testing.T) { func TestResolveModelPathExtraDir(t *testing.T) {
home := t.TempDir() home := t.TempDir()
ext := t.TempDir() ext := t.TempDir()
t.Setenv("JEAN_HOME", home) t.Setenv("AJEAN_HOME", home)
t.Setenv("JEAN_MODEL_DIRS", "") t.Setenv("AJEAN_MODEL_DIRS", "")
extModel := filepath.Join(ext, "gros.gguf") extModel := filepath.Join(ext, "gros.gguf")
if err := os.WriteFile(extModel, []byte("x"), 0o644); err != nil { if err := os.WriteFile(extModel, []byte("x"), 0o644); err != nil {
@@ -39,12 +39,12 @@ func TestResolveModelPathExtraDir(t *testing.T) {
} }
} }
// JEAN_HOME garde la priorité et les non-.gguf restent refusés. // AJEAN_HOME garde la priorité et les non-.gguf restent refusés.
func TestResolveModelPathHomeFirst(t *testing.T) { func TestResolveModelPathHomeFirst(t *testing.T) {
home := t.TempDir() home := t.TempDir()
ext := t.TempDir() ext := t.TempDir()
t.Setenv("JEAN_HOME", home) t.Setenv("AJEAN_HOME", home)
t.Setenv("JEAN_MODEL_DIRS", ext) t.Setenv("AJEAN_MODEL_DIRS", ext)
for _, d := range []string{home, ext} { for _, d := range []string{home, ext} {
if err := os.WriteFile(filepath.Join(d, "m.gguf"), []byte("x"), 0o644); err != nil { if err := os.WriteFile(filepath.Join(d, "m.gguf"), []byte("x"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -52,7 +52,7 @@ func TestResolveModelPathHomeFirst(t *testing.T) {
} }
got, err := resolveModelPath("m.gguf") got, err := resolveModelPath("m.gguf")
if err != nil || got != filepath.Join(home, "m.gguf") { if err != nil || got != filepath.Join(home, "m.gguf") {
t.Fatalf("got %q (%v), attendu le fichier de JEAN_HOME", got, err) t.Fatalf("got %q (%v), attendu le fichier de AJEAN_HOME", got, err)
} }
if _, err := resolveModelPath("/etc/passwd"); err == nil { if _, err := resolveModelPath("/etc/passwd"); err == nil {
t.Fatal("un fichier non-.gguf devrait être refusé") t.Fatal("un fichier non-.gguf devrait être refusé")
+105 -19
View File
@@ -86,10 +86,10 @@ func detectQuant(content string) string {
return quantFromName(baseName(modelFromPresetContent(content))) return quantFromName(baseName(modelFromPresetContent(content)))
} }
// downloadDestPath resolves the destination of a downloaded model: always // downloadDestPath resolves the destination of a downloaded model : le dossier
// JEAN_HOME (les dossiers externes sont en lecture, on n'y écrit pas), en // demandé (vide = AJEAN_HOME) parmi les dossiers de modèles déclarés, en
// refusant tout ce qui en sortirait (path traversal). // refusant tout ce qui en sortirait (path traversal).
func downloadDestPath(name string) (string, error) { func downloadDestPath(name, dir string) (string, error) {
base := filepath.Base(strings.TrimSpace(name)) base := filepath.Base(strings.TrimSpace(name))
if base == "" || base == "." || base == string(filepath.Separator) { if base == "" || base == "." || base == string(filepath.Separator) {
return "", fmt.Errorf("nom de modèle invalide") return "", fmt.Errorf("nom de modèle invalide")
@@ -97,7 +97,11 @@ func downloadDestPath(name string) (string, error) {
if !strings.HasSuffix(strings.ToLower(base), ".gguf") { if !strings.HasSuffix(strings.ToLower(base), ".gguf") {
return "", fmt.Errorf("seuls les fichiers .gguf sont acceptés") return "", fmt.Errorf("seuls les fichiers .gguf sont acceptés")
} }
return filepath.Join(AjeanHome(), base), nil d, err := resolveDownloadDir(dir)
if err != nil {
return "", err
}
return filepath.Join(d, base), nil
} }
// modelFromPresetContent extracts the MODEL= value from a preset's config.env // modelFromPresetContent extracts the MODEL= value from a preset's config.env
@@ -135,7 +139,7 @@ func deleteModelFile(name string) error {
return nil return nil
} }
// handleModelDelete deletes a single .gguf from JEAN_HOME. // handleModelDelete deletes a single .gguf from AJEAN_HOME.
func handleModelDelete(w http.ResponseWriter, r *http.Request) { func handleModelDelete(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
@@ -157,6 +161,7 @@ func handleModelDelete(w http.ResponseWriter, r *http.Request) {
type dlState struct { type dlState struct {
Filename string `json:"filename"` Filename string `json:"filename"`
URL string `json:"url"` URL string `json:"url"`
Dir string `json:"dir"` // dossier de destination
Total int64 `json:"total"` Total int64 `json:"total"`
Done int64 `json:"done"` Done int64 `json:"done"`
Speed int64 `json:"speed"` // bytes/s, smoothed over the last samples Speed int64 `json:"speed"` // bytes/s, smoothed over the last samples
@@ -194,10 +199,10 @@ var dlClient = &http.Client{
} }
// dlConns is the number of parallel range requests used per download. // dlConns is the number of parallel range requests used per download.
// Overridable with JEAN_DL_CONNS (1 disables parallelism). // Overridable with AJEAN_DL_CONNS (1 disables parallelism).
func dlConns() int { func dlConns() int {
n := 8 n := 8
if v := envAny("AJEAN_DL_CONNS", "JEAN_DL_CONNS"); v != "" { if v := os.Getenv("AJEAN_DL_CONNS"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 { if p, err := strconv.Atoi(v); err == nil && p > 0 {
n = p n = p
} }
@@ -242,11 +247,12 @@ func normalizeHFURL(raw string) (string, string, error) {
} }
// handleModelDownload kicks off a background download of a .gguf from a URL // handleModelDownload kicks off a background download of a .gguf from a URL
// (typically Hugging Face) into JEAN_HOME. Progress is polled via // (typically Hugging Face) into AJEAN_HOME. Progress is polled via
// /api/models/download/status. // /api/models/download/status.
func handleModelDownload(w http.ResponseWriter, r *http.Request) { func handleModelDownload(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
URL string `json:"url"` URL string `json:"url"`
Dir string `json:"dir"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
@@ -257,11 +263,15 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return return
} }
dest, err := downloadDestPath(name) dest, err := downloadDestPath(name, req.Dir)
if err != nil { if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return return
} }
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": "dossier de destination inaccessible : " + err.Error()})
return
}
dlMu.Lock() dlMu.Lock()
if st, ok := dlDownloads[name]; ok && !st.Finished { if st, ok := dlDownloads[name]; ok && !st.Finished {
@@ -275,7 +285,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
return return
} }
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
st := &dlState{Filename: name, URL: dlURL, StartedAt: time.Now().Unix(), cancel: cancel} st := &dlState{Filename: name, URL: dlURL, Dir: filepath.Dir(dest), StartedAt: time.Now().Unix(), cancel: cancel}
dlDownloads[name] = st dlDownloads[name] = st
dlMu.Unlock() dlMu.Unlock()
@@ -283,6 +293,74 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "filename": name}) sendJSON(w, 200, map[string]any{"ok": true, "filename": name})
} }
// dlSpaceMargin est la marge laissée libre après le téléchargement : un disque
// rempli à ras bord met en danger tout le reste (logs, .part d'un autre
// modèle, swap).
const dlSpaceMargin = 256 << 20
// checkDiskSpace refuse le téléchargement si le fichier ne tient pas dans le
// dossier visé. free < 0 = mesure impossible : on laisse passer plutôt que de
// bloquer sur un système de fichiers exotique.
func checkDiskSpace(dir string, size int64) error {
free := diskFree(dir)
if size <= 0 || free < 0 {
return nil
}
if free < size+dlSpaceMargin {
return fmt.Errorf("espace insuffisant sur %s : %s libres, %s nécessaires", dir, humanBytes(free), humanBytes(size+dlSpaceMargin))
}
return nil
}
// humanBytes formate une taille en Go/Mo pour les messages d'erreur.
func humanBytes(n int64) string {
switch {
case n >= 1<<30:
return fmt.Sprintf("%.1f Go", float64(n)/float64(1<<30))
case n >= 1<<20:
return fmt.Sprintf("%.0f Mo", float64(n)/float64(1<<20))
default:
return fmt.Sprintf("%d o", n)
}
}
// handleModelDownloadProbe renseigne l'UI avant de lancer quoi que ce soit :
// taille du fichier distant, espace libre du dossier visé, et si ça tient. Une
// seule requête d'un octet côté CDN, donc c'est gratuit.
func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
Dir string `json:"dir"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
dlURL, name, err := normalizeHFURL(req.URL)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
dir, err := resolveDownloadDir(req.Dir)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
total, _, err := dlProbe(ctx, dlURL)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true}
if err := checkDiskSpace(dir, total); err != nil {
out["enough"] = false
out["error"] = err.Error()
}
sendJSON(w, 200, out)
}
// dlRequest builds a GET for the download URL, carrying the HF token when set // dlRequest builds a GET for the download URL, carrying the HF token when set
// (gated/private repos) and an optional Range header. // (gated/private repos) and an optional Range header.
func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) { func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
@@ -294,7 +372,7 @@ func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
if k := os.Getenv("HF_TOKEN"); k != "" { if k := os.Getenv("HF_TOKEN"); k != "" {
req.Header.Set("Authorization", "Bearer "+k) req.Header.Set("Authorization", "Bearer "+k)
} }
req.Header.Set("User-Agent", "jean/"+Version) req.Header.Set("User-Agent", "ajean/"+Version)
req.Header.Set("Accept-Encoding", "identity") // never gzip a .gguf: it breaks ranges req.Header.Set("Accept-Encoding", "identity") // never gzip a .gguf: it breaks ranges
if rng != "" { if rng != "" {
req.Header.Set("Range", rng) req.Header.Set("Range", rng)
@@ -374,6 +452,12 @@ func runDownload(ctx context.Context, st *dlState, dlURL, dest string) {
finish(err) finish(err)
return return
} }
// Vérification serveur : l'UI a déjà prévenu, mais rien ne garantit qu'elle
// l'ait fait (autre client, disque rempli entre-temps).
if err := checkDiskSpace(filepath.Dir(dest), total); err != nil {
finish(err)
return
}
conns := 1 conns := 1
if ranged && total > 0 { if ranged && total > 0 {
@@ -595,18 +679,20 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true}) sendJSON(w, 200, map[string]any{"ok": true})
} }
// cleanStalePartFiles removes leftover *.gguf.part files in JEAN_HOME at // cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
// startup. A download killed by a crash or a service restart can't be resumed // startup. A download killed by a crash or a service restart can't be resumed
// (its state lived in memory), so the partial file would otherwise sit there // (its state lived in memory), so the partial file would otherwise sit there
// forever eating disk. // forever eating disk.
func cleanStalePartFiles() { func cleanStalePartFiles() {
matches, err := filepath.Glob(filepath.Join(AjeanHome(), "*.gguf.part")) for _, dir := range modelDirs() {
if err != nil { matches, err := filepath.Glob(filepath.Join(dir, "*.gguf.part"))
return if err != nil {
} continue
for _, p := range matches { }
if err := os.Remove(p); err == nil { for _, p := range matches {
fmt.Printf("[models] téléchargement incomplet supprimé : %s\n", filepath.Base(p)) if err := os.Remove(p); err == nil {
fmt.Printf("[models] téléchargement incomplet supprimé : %s\n", p)
}
} }
} }
} }
@@ -0,0 +1,90 @@
package ajean
// Destination des téléchargements : le dossier doit être choisi parmi les
// dossiers de modèles déclarés (sinon l'UI web écrirait n'importe où), et
// l'espace libre doit être vérifié AVANT de transférer 40 Go.
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDownloadDestPathDir(t *testing.T) {
home := t.TempDir()
extra := t.TempDir()
t.Setenv("AJEAN_HOME", home)
t.Setenv("AJEAN_MODEL_DIRS", extra)
// Dossier vide → AJEAN_HOME.
got, err := downloadDestPath("m.gguf", "")
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(home, "m.gguf"); got != want {
t.Fatalf("défaut = %s, attendu %s", got, want)
}
// Dossier déclaré → accepté.
got, err = downloadDestPath("m.gguf", extra)
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(extra, "m.gguf"); got != want {
t.Fatalf("dossier déclaré = %s, attendu %s", got, want)
}
// Dossier non déclaré → refusé.
if _, err := downloadDestPath("m.gguf", t.TempDir()); err == nil {
t.Fatal("un dossier non déclaré a été accepté")
}
// Le nom reste un simple fichier .gguf : pas de remontée de dossier.
if _, err := downloadDestPath(filepath.Join("..", "evil.gguf"), extra); err != nil {
t.Fatal(err)
} else if p, _ := downloadDestPath(filepath.Join("..", "evil.gguf"), extra); filepath.Dir(p) != extra {
t.Fatalf("path traversal : %s", p)
}
}
func TestDiskFreeAndCheck(t *testing.T) {
dir := t.TempDir()
free := diskFree(dir)
if free <= 0 {
t.Skipf("espace libre non mesurable ici (%d)", free)
}
// Un dossier pas encore créé doit être mesuré via son parent.
if diskFree(filepath.Join(dir, "pas", "encore")) <= 0 {
t.Fatal("diskFree ne remonte pas aux parents existants")
}
if err := checkDiskSpace(dir, 1<<10); err != nil {
t.Fatalf("1 Ko refusé alors que %d octets sont libres : %v", free, err)
}
err := checkDiskSpace(dir, free)
if err == nil {
t.Fatal("téléchargement accepté alors qu'il remplit le disque")
}
if !strings.Contains(err.Error(), "espace insuffisant") {
t.Fatalf("message inattendu : %v", err)
}
// Taille inconnue (serveur sans Content-Length) : on laisse passer.
if err := checkDiskSpace(dir, 0); err != nil {
t.Fatalf("taille inconnue refusée : %v", err)
}
}
// Les .part orphelins doivent aussi être nettoyés dans les dossiers ajoutés.
func TestCleanStalePartFilesExtraDirs(t *testing.T) {
home, extra := t.TempDir(), t.TempDir()
t.Setenv("AJEAN_HOME", home)
t.Setenv("AJEAN_MODEL_DIRS", extra)
stale := filepath.Join(extra, "coupe.gguf.part")
if err := os.WriteFile(stale, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
cleanStalePartFiles()
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Fatal(".part orphelin d'un dossier ajouté non supprimé")
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ func serveBlob(data []byte, ranges bool) *httptest.Server {
// Un téléchargement annulé ne doit laisser NI le .gguf final NI le .part. // Un téléchargement annulé ne doit laisser NI le .gguf final NI le .part.
func TestRunDownloadCancelLeavesNothing(t *testing.T) { func TestRunDownloadCancelLeavesNothing(t *testing.T) {
t.Setenv("JEAN_DL_CONNS", "4") t.Setenv("AJEAN_DL_CONNS", "4")
release := make(chan struct{}) release := make(chan struct{})
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") == "bytes=0-0" { // sonde : réponse immédiate if r.Header.Get("Range") == "bytes=0-0" { // sonde : réponse immédiate
@@ -76,7 +76,7 @@ func TestRunDownloadCancelLeavesNothing(t *testing.T) {
// cleanStalePartFiles doit balayer les .part orphelins sans toucher aux .gguf. // cleanStalePartFiles doit balayer les .part orphelins sans toucher aux .gguf.
func TestCleanStalePartFiles(t *testing.T) { func TestCleanStalePartFiles(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
t.Setenv("JEAN_HOME", dir) t.Setenv("AJEAN_HOME", dir)
keep := filepath.Join(dir, "bon.gguf") keep := filepath.Join(dir, "bon.gguf")
stale := filepath.Join(dir, "coupe.gguf.part") stale := filepath.Join(dir, "coupe.gguf.part")
for _, p := range []string{keep, stale} { for _, p := range []string{keep, stale} {
@@ -96,7 +96,7 @@ func TestCleanStalePartFiles(t *testing.T) {
func TestRunDownloadParallelAndFallback(t *testing.T) { func TestRunDownloadParallelAndFallback(t *testing.T) {
data := make([]byte, 48<<20) // > 3×dlMinChunk so the split actually kicks in data := make([]byte, 48<<20) // > 3×dlMinChunk so the split actually kicks in
rand.New(rand.NewSource(1)).Read(data) rand.New(rand.NewSource(1)).Read(data)
t.Setenv("JEAN_DL_CONNS", "4") t.Setenv("AJEAN_DL_CONNS", "4")
for _, ranges := range []bool{true, false} { for _, ranges := range []bool{true, false} {
srv := serveBlob(data, ranges) srv := serveBlob(data, ranges)
+22 -1
View File
@@ -337,6 +337,27 @@ func pickPrebuilt(assets []ghAsset) (main *ghAsset, cudart *ghAsset, label, cuda
return main, cudart, label, cudaVer, nil return main, cudart, label, cudaVer, nil
} }
// recommendedMode dit laquelle des deux installations conseiller sur CETTE
// machine, et pourquoi. Le précompilé convient presque partout — sauf dans un
// cas qui concerne beaucoup de monde : **Linux avec une carte NVIDIA**.
// llama.cpp ne publie AUCUN binaire CUDA pour Linux (vérifié release b10299 :
// seuls Windows a bin-win-cuda-*), donc pickPrebuilt y retombe sur Vulkan, qui
// marche mais laisse une bonne part de la carte inexploitée. Conseiller le
// précompilé dans ce cas revenait à pousser vers l'option la plus lente.
//
// `backend` est celui qu'une compilation LOCALE produirait (detectBuildPlan).
// Renvoie {mode: "fast"|"opt", why: "…"} — le libellé est affiché sous la carte
// conseillée, pour que le choix soit justifié plutôt qu'imposé.
func recommendedMode(backend string) map[string]any {
if runtime.GOOS == "linux" && backend == "cuda" {
return map[string]any{
"mode": "opt",
"why": "seule voie vers du CUDA natif : llama.cpp ne publie aucun binaire CUDA pour Linux, le précompilé ne donnerait que du Vulkan. Compte plusieurs minutes de compilation.",
}
}
return map[string]any{"mode": "fast", "why": ""}
}
// prebuiltInstall télécharge et installe (ou met à jour) les binaires // prebuiltInstall télécharge et installe (ou met à jour) les binaires
// précompilés. logf reçoit chaque ligne de log ; phasef la phase courante. // précompilés. logf reçoit chaque ligne de log ; phasef la phase courante.
// Renvoie le chemin du binaire installé. // Renvoie le chemin du binaire installé.
@@ -354,7 +375,7 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) {
logf(fmt.Sprintf("release %s — variant retenu : %s", tag, label)) logf(fmt.Sprintf("release %s — variant retenu : %s", tag, label))
// Réinstaller à l'identique est inutile SAUF si l'extraction date d'une // Réinstaller à l'identique est inutile SAUF si l'extraction date d'une
// version de Jean qui ignorait les liens des archives (backend installé mais // version de AJEAN qui ignorait les liens des archives (backend installé mais
// bibliothèques introuvables au lancement) : le marqueur de format force alors // bibliothèques introuvables au lancement) : le marqueur de format force alors
// une ré-extraction propre au lieu d'un « déjà à jour » trompeur. // une ré-extraction propre au lieu d'un « déjà à jour » trompeur.
if cur := prebuiltServerBin(); curTag == tag && prebuiltVersionFormat() == prebuiltFormat && cur != "" { if cur := prebuiltServerBin(); curTag == tag && prebuiltVersionFormat() == prebuiltFormat && cur != "" {
@@ -0,0 +1,39 @@
package ajean
import (
"runtime"
"testing"
)
// Le seul cas où compiler vaut mieux que télécharger : Linux + CUDA. llama.cpp
// ne publie pas de binaire CUDA pour Linux (seul Windows a bin-win-cuda-*), donc
// le précompilé y retombe sur Vulkan. Ailleurs, l'asset officiel exploite bien
// la machine (CUDA sous Windows, Metal sous macOS, ROCm sous Linux).
func TestRecommendedMode(t *testing.T) {
cases := []struct {
backend string
wantMode string
wantWhy bool
}{
{"cuda", "opt", true}, // le cas qui motive toute la fonction
{"rocm", "fast", false},
{"vulkan", "fast", false},
{"cpu", "fast", false},
{"metal", "fast", false},
}
for _, c := range cases {
got := recommendedMode(c.backend)
wantMode, wantWhy := c.wantMode, c.wantWhy
// La règle ne vise que Linux : sur les autres OS le précompilé reste
// conseillé même avec CUDA détecté.
if runtime.GOOS != "linux" {
wantMode, wantWhy = "fast", false
}
if got["mode"] != wantMode {
t.Errorf("backend %q sur %s : mode = %v, attendu %q", c.backend, runtime.GOOS, got["mode"], wantMode)
}
if hasWhy := got["why"] != ""; hasWhy != wantWhy {
t.Errorf("backend %q sur %s : why non vide = %v, attendu %v", c.backend, runtime.GOOS, hasWhy, wantWhy)
}
}
}
+34 -70
View File
@@ -51,44 +51,29 @@ func withDisplayName(content, name string) string {
return line + "\n" + content return line + "\n" + content
} }
// presetFingerprint hashes config content while IGNORING the device-level keys // configFingerprint réduit une configuration à l'ENSEMBLE TRIÉ de ses
// (preservedKeys) that SwitchToPreset re-applies on top of a freshly copied // affectations effectives, puis le hache. Les clés « appareil » (preservedKeys,
// preset. Without this, those injected MEM_MODE/CRAWL4AI_URL lines make // réappliquées par SwitchToPreset) sont ignorées : sans ça, les MEM_MODE /
// config.env differ from every preset file, so no preset is ever detected as // CRAWL4AI_URL injectés rendraient la config active différente de TOUS les
// active (regression introduced when preservedKeys were added). We strip those // presets, et aucun ne serait jamais détecté comme actif.
// assignment lines symmetrically from both sides before hashing so a plain func configFingerprint(m map[string]string) string {
// content match still identifies the active preset. pairs := make([]string, 0, len(m))
func presetFingerprint(content []byte) string { for k, v := range m {
// On réduit le fichier à l'ENSEMBLE de ses affectations KEY=VALUE effectives, if isPreservedKey(k) {
// puis on hache cet ensemble TRIÉ. On ignore : commentaires (dont `# NAME=`),
// lignes vides, l'ordre des lignes, et les clés « appareil » (preservedKeys,
// réappliquées par SwitchToPreset). Ainsi un simple reformatage de config.env
// — réécriture par un toggle mémoire/internet, réordonnancement, commentaires —
// ne « perd » plus le preset actif : seule la config effective est comparée.
// (Avant : hachage ligne à ligne, cassé par le moindre changement cosmétique →
// aucun preset détecté comme actif, souvent après un changement de réglage.)
pairs := []string{}
for _, line := range strings.Split(string(content), "\n") {
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
continue continue
} }
t = strings.TrimPrefix(t, "export ") pairs = append(pairs, k+"="+v)
eq := strings.IndexByte(t, '=')
if eq < 0 {
continue
}
key := strings.TrimSpace(t[:eq])
if isPreservedKey(key) {
continue
}
pairs = append(pairs, key+"="+strings.TrimSpace(t[eq+1:]))
} }
sort.Strings(pairs) sort.Strings(pairs)
h := sha1.Sum([]byte(strings.Join(pairs, "\n"))) h := sha1.Sum([]byte(strings.Join(pairs, "\n")))
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:])
} }
// presetFingerprint est configFingerprint appliqué au contenu d'un preset.
func presetFingerprint(content []byte) string {
return configFingerprint(parseEnv(string(content)))
}
// isPreservedKey reports whether key is one of the device-level preservedKeys. // isPreservedKey reports whether key is one of the device-level preservedKeys.
func isPreservedKey(key string) bool { func isPreservedKey(key string) bool {
for _, k := range preservedKeys { for _, k := range preservedKeys {
@@ -99,15 +84,12 @@ func isPreservedKey(key string) bool {
return false return false
} }
// ListPresets returns all configs/*.env, marking the one whose contents match // ListPresets renvoie tous les presets/*.env, en marquant celui qui correspond
// the current config.env (ignoring device-level preservedKeys), sorted by name. // à la configuration active (clés « appareil » ignorées), triés par nom.
func ListPresets() ([]Preset, error) { func ListPresets() ([]Preset, error) {
dir := presetsDir() dir := presetsDir()
_ = os.MkdirAll(dir, 0o755) _ = os.MkdirAll(dir, 0o755)
cur := "" cur := configFingerprint(ReadConfig())
if b, err := os.ReadFile(confPath()); err == nil {
cur = presetFingerprint(b)
}
entries, err := os.ReadDir(dir) entries, err := os.ReadDir(dir)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -213,57 +195,40 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS
// « cudaMalloc failed: out of memory ». Le preset explicite gagne. // « cudaMalloc failed: out of memory ». Le preset explicite gagne.
var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true} var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true}
// presetDefines dit si le contenu d'un preset affecte explicitement la clé. // applyPresetFile installe le preset comme configuration active, en réinjectant
func presetDefines(content []byte, key string) bool { // les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
for _, line := range strings.Split(string(content), "\n") { // testable sans redémarrer le service (donc sans lancer un vrai llama-server).
t := strings.TrimSpace(line)
if t == "" || strings.HasPrefix(t, "#") {
continue
}
t = strings.TrimPrefix(t, "export ")
if eq := strings.IndexByte(t, '='); eq >= 0 && strings.TrimSpace(t[:eq]) == key {
return true
}
}
return false
}
// applyPresetFile copie le preset dans config.env en réinjectant les réglages
// « appareil » par-dessus. Séparé de SwitchToPreset pour être testable sans
// redémarrer le service (donc sans lancer un llama-server pour de vrai).
func applyPresetFile(target string) error { func applyPresetFile(target string) error {
src, err := os.ReadFile(target) src, err := os.ReadFile(target)
if err != nil { if err != nil {
return err return err
} }
// Capture les réglages appareil AVANT d'écraser config.env. next := parseEnv(string(src))
// Ré-applique les réglages appareil par-dessus le preset — sauf ceux que le
// preset revendique explicitement (softPreservedKeys).
cur := ReadConfig() cur := ReadConfig()
if err := os.WriteFile(confPath(), src, 0o644); err != nil {
return err
}
// Ré-applique les réglages appareil par-dessus le preset fraîchement copié —
// sauf ceux que le preset revendique explicitement (softPreservedKeys).
for _, k := range preservedKeys { for _, k := range preservedKeys {
v, ok := cur[k] v, ok := cur[k]
if !ok { if !ok {
continue continue
} }
if softPreservedKeys[k] && presetDefines(src, k) { if softPreservedKeys[k] {
continue // le preset a son mot à dire sur cette clé : on le laisse gagner if _, claimed := next[k]; claimed {
continue // le preset a son mot à dire sur cette clé : il gagne
}
} }
_ = SetConfigKey(k, v) next[k] = v
} }
return nil return WriteConfig(next)
} }
// SwitchToPreset backs up the current config and copies the target into place, // SwitchToPreset installe le preset et redémarre le service. Les réglages
// then restarts the service. Les réglages « appareil » (preservedKeys) sont // « appareil » (preservedKeys) sont conservés à travers la bascule.
// conservés à travers la bascule.
func SwitchToPreset(target string) error { func SwitchToPreset(target string) error {
if err := applyPresetFile(target); err != nil { if err := applyPresetFile(target); err != nil {
return err return err
} }
fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target)) fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
fmt.Println(dim("[info] redémarrage du service...")) fmt.Println(dim("[info] redémarrage du service..."))
return serviceAction("restart") return serviceAction("restart")
} }
@@ -342,12 +307,11 @@ func DeletePreset(id string) error {
if err != nil { if err != nil {
return err return err
} }
cur, _ := os.ReadFile(confPath())
target, err := os.ReadFile(p) target, err := os.ReadFile(p)
if err != nil { if err != nil {
return fmt.Errorf("introuvable") return fmt.Errorf("introuvable")
} }
if sha1.Sum(cur) == sha1.Sum(target) { if presetFingerprint(target) == configFingerprint(ReadConfig()) {
return fmt.Errorf("preset actif, switche d'abord") return fmt.Errorf("preset actif, switche d'abord")
} }
return os.Remove(p) return os.Remove(p)
+6 -6
View File
@@ -13,15 +13,15 @@ func cmdServe(args []string) error {
cfg := ReadConfig() cfg := ReadConfig()
bin := cfg["BIN"] bin := cfg["BIN"]
if bin == "" { if bin == "" {
return fmt.Errorf("BIN non défini dans %s", confPath()) return fmt.Errorf("BIN non défini — lance « ajean edit »")
} }
model := cfg["MODEL"] model := cfg["MODEL"]
if model == "" { if model == "" {
return fmt.Errorf("MODEL non défini dans %s", confPath()) return fmt.Errorf("MODEL non défini — lance « ajean edit »")
} }
// MODEL vaut soit un simple nom de fichier (le .gguf vit dans JEAN_HOME ou // MODEL vaut soit un simple nom de fichier (le .gguf vit dans AJEAN_HOME ou
// dans un dossier déclaré — disque externe…), soit un chemin absolu. Sous // dans un dossier déclaré — disque externe…), soit un chemin absolu. Sous
// systemd/launchd le WorkingDirectory vaut JEAN_HOME, donc le relatif tombait // systemd/launchd le WorkingDirectory vaut AJEAN_HOME, donc le relatif tombait
// juste ; lancé depuis une app de bureau, le répertoire courant est « / » et // juste ; lancé depuis une app de bureau, le répertoire courant est « / » et
// llama-server ne trouvait rien. On résout donc explicitement, quel que soit // llama-server ne trouvait rien. On résout donc explicitement, quel que soit
// le contexte de lancement. // le contexte de lancement.
@@ -92,7 +92,7 @@ func cmdServe(args []string) error {
} }
// API_KEY protège le serveur quand il est exposé sur internet : llama-server // API_KEY protège le serveur quand il est exposé sur internet : llama-server
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis // exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $JEAN_HOME/.api_key en priorité (elle survit ainsi aux changements de preset // $AJEAN_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible. // qui réécrivent config.env), avec config.env comme repli rétro-compatible.
if k := readAPIKey(); k != "" { if k := readAPIKey(); k != "" {
llmArgs = append(llmArgs, "--api-key", k) llmArgs = append(llmArgs, "--api-key", k)
@@ -104,7 +104,7 @@ func cmdServe(args []string) error {
llmArgs = append(llmArgs, a) llmArgs = append(llmArgs, a)
} }
// Working dir = JEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj // Working dir = AJEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve. // mmproj-F16.gguf) still resolve.
_ = os.Chdir(AjeanHome()) _ = os.Chdir(AjeanHome())
+5 -40
View File
@@ -1,49 +1,14 @@
package ajean package ajean
import ( import "fmt"
"fmt"
"os"
)
// Le « mode agent » est l'unique interrupteur qui donne à l'IA l'accès à ses // Le « mode agent » est l'unique interrupteur qui donne à l'IA l'accès à ses
// outils. Quand il est actif, l'IA dispose du shell (run_shell) et de sa // outils : le shell (run_shell), l'écriture de fichiers et sa mémoire
// mémoire (mem_search/mem_read/mem_add/mem_edit) — les anciens « skills » ont // (mem_search/mem_read/mem_add/mem_edit).
// été fondus dans la mémoire. Plus de drapeaux machine/skills séparés : un seul
// fichier .agent_enabled.
func agentEnabled() bool { func agentEnabled() bool { return getBool(bkState, "agent") }
if _, err := os.Stat(agentFlag()); err == nil {
return true
}
// Migration : si l'un des anciens drapeaux séparés traîne encore, on
// considère le mode agent comme actif (et on le matérialise au prochain set).
if _, err := os.Stat(legacyToolsFlag()); err == nil {
return true
}
if _, err := os.Stat(legacySkillsFlag()); err == nil {
return true
}
return false
}
func setAgentEnabled(on bool) error { func setAgentEnabled(on bool) error { return putBool(bkState, "agent", on) }
_ = os.MkdirAll(AjeanHome(), 0o755)
// On nettoie systématiquement les anciens drapeaux pour ne pas garder un
// état fantôme « à moitié activé » hérité de l'ancien modèle à deux toggles.
_ = os.Remove(legacyToolsFlag())
_ = os.Remove(legacySkillsFlag())
if on {
f, err := os.Create(agentFlag())
if err != nil {
return err
}
return f.Close()
}
if err := os.Remove(agentFlag()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func cmdAgent(args []string) error { func cmdAgent(args []string) error {
sub := "" sub := ""
+3 -3
View File
@@ -135,7 +135,7 @@ func compactWouldTrigger(msgs []Message, knownTokens int) bool {
} }
// logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur // logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur
// (donc dans `journalctl -u jean-link`). Sans ça, une compaction qui ne se // (donc dans `journalctl -u ajean-link`). Sans ça, une compaction qui ne se
// déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté // déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté
// UI on ne voit qu'une jauge qui reste haute, sans savoir si le seuil n'a pas // UI on ne voit qu'une jauge qui reste haute, sans savoir si le seuil n'a pas
// été atteint ou si la réduction a été refusée. // été atteint ou si la réduction a été refusée.
@@ -294,7 +294,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// Garantie de réduction : on n'accepte la compaction que si elle enlève au // Garantie de réduction : on n'accepte la compaction que si elle enlève au
// moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable) // moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable)
// on la refuse — sans ça, jean « compactait » à presque chaque message sans // on la refuse — sans ça, ajean « compactait » à presque chaque message sans
// vraiment réduire, puis re-déclenchait aussitôt. // vraiment réduire, puis re-déclenchait aussitôt.
before, after := estimateTokens(msgs), estimateTokens(out) before, after := estimateTokens(msgs), estimateTokens(out)
if after > before*4/5 { if after > before*4/5 {
@@ -363,7 +363,7 @@ Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwawa
Write the summary in the SAME language as the conversation.` Write the summary in the SAME language as the conversation.`
payload := map[string]any{ payload := map[string]any{
"model": "jean", "model": "ajean",
"messages": []Message{ "messages": []Message{
{Role: "system", Content: sys}, {Role: "system", Content: sys},
{Role: "user", Content: transcript}, {Role: "user", Content: transcript},
+10 -18
View File
@@ -4,8 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -65,16 +63,15 @@ var conv = func() *Conversation {
return c return c
}() }()
// convPath = fichier de persistance sur la box jean (AjeanHome = /etc/jean en // La conversation est persistée en base, sur la machine qui fait tourner le
// prod). En clair : la box déchiffre déjà pour lancer le modèle, le relais reste // modèle. En clair : cette machine déchiffre déjà pour lancer le modèle, le
// aveugle — le persister ici ne change rien à la posture E2E. // relais reste aveugle — la persister ici ne change rien à la posture E2E.
func convPath() string { return filepath.Join(AjeanHome(), "conversation.json") }
// LoadConversation recharge l'état persisté au démarrage du process. Sans fichier // LoadConversation recharge l'état persisté au démarrage du process. Sans état
// (première fois) on part d'une conversation vide. // enregistré (première fois) on part d'une conversation vide.
func LoadConversation() { func LoadConversation() {
b, err := os.ReadFile(convPath()) b := getBytes(bkChat, "conversation")
if err != nil { if len(b) == 0 {
return return
} }
conv.mu.Lock() conv.mu.Lock()
@@ -85,8 +82,8 @@ func LoadConversation() {
conv.cancel = nil conv.cancel = nil
} }
// persist écrit l'état sur disque (appelé en fin de tour et sur reset, pas à // persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
// chaque delta). L'appelant NE doit PAS détenir mu. // delta). L'appelant NE doit PAS détenir mu.
func (c *Conversation) persist() { func (c *Conversation) persist() {
c.mu.Lock() c.mu.Lock()
b, err := json.Marshal(c) b, err := json.Marshal(c)
@@ -94,12 +91,7 @@ func (c *Conversation) persist() {
if err != nil { if err != nil {
return return
} }
_ = os.MkdirAll(AjeanHome(), 0o755) _ = putBytes(bkChat, "conversation", b)
tmp := convPath() + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return
}
_ = os.Rename(tmp, convPath())
} }
// appendDelta journalise un événement d'affichage et réveille les abonnés. // appendDelta journalise un événement d'affichage et réveille les abonnés.
+12 -52
View File
@@ -10,7 +10,6 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"regexp" "regexp"
"strings" "strings"
"sync" "sync"
@@ -19,7 +18,7 @@ import (
// Accès internet de l'IA — port Go de l'extension pi ~/.pi/agent/extensions/web.ts. // Accès internet de l'IA — port Go de l'extension pi ~/.pi/agent/extensions/web.ts.
// //
// jean parle à un serveur Crawl4AI (Chrome headless, endpoint /crawl) dont l'URL // ajean parle à un serveur Crawl4AI (Chrome headless, endpoint /crawl) dont l'URL
// est configurée dans config.env (CRAWL4AI_URL). Quand le mode agent ET l'accès // est configurée dans config.env (CRAWL4AI_URL). Quand le mode agent ET l'accès
// internet sont actifs ET que le serveur répond, l'IA dispose de 4 outils : // internet sont actifs ET que le serveur répond, l'IA dispose de 4 outils :
// //
@@ -44,38 +43,15 @@ func crawl4aiURL() string {
// est ouvert. Envoyée en « Authorization: Bearer … », ce qu'attend Crawl4AI // est ouvert. Envoyée en « Authorization: Bearer … », ce qu'attend Crawl4AI
// quand l'authentification est activée (CRAWL4AI_API_TOKEN côté serveur). // quand l'authentification est activée (CRAWL4AI_API_TOKEN côté serveur).
// //
// Elle vit dans $JEAN_HOME/.crawl4ai_key (0600) et NON dans config.env : ce // Elle est rangée hors de la configuration : celle-ci est ce qu'on copie-colle
// dernier est le fichier qu'on copie-colle pour demander de l'aide, une clé en // pour demander de l'aide, et une clé en clair dedans finit par fuiter. Même
// clair dedans finit par fuiter. Même modèle que la clé de complétion // modèle que la clé de complétion.
// (.api_key). Les installations qui l'avaient dans config.env sont migrées à func crawl4aiKey() string { return getStr(bkState, "crawl_key") }
// la première lecture.
func crawl4aiKey() string {
if b, err := os.ReadFile(crawlKeyPath()); err == nil {
if k := strings.TrimSpace(string(b)); k != "" {
return k
}
}
// Migration depuis l'ancien emplacement (jean 0.5.11).
if k := strings.TrimSpace(ReadConfig()["CRAWL4AI_KEY"]); k != "" {
if err := writeCrawlKey(k); err == nil {
_ = SetConfigKey("CRAWL4AI_KEY", "")
}
return k
}
return ""
}
// writeCrawlKey enregistre (clé non vide) ou efface (clé vide) la clé Crawl4AI. // writeCrawlKey enregistre (clé non vide) ou efface (clé vide) la clé Crawl4AI.
func writeCrawlKey(key string) error { func writeCrawlKey(key string) error {
_ = SetConfigKey("CRAWL4AI_KEY", "") // ne laisse rien traîner dans config.env _ = SetConfigKey("CRAWL4AI_KEY", "") // ne laisse rien traîner dans la config
if key == "" { return putStr(bkState, "crawl_key", key)
if err := os.Remove(crawlKeyPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
_ = os.MkdirAll(AjeanHome(), 0o755)
return os.WriteFile(crawlKeyPath(), []byte(key+"\n"), 0o600)
} }
// crawlAuth pose l'en-tête d'authentification sur une requête vers Crawl4AI. // crawlAuth pose l'en-tête d'authentification sur une requête vers Crawl4AI.
@@ -86,33 +62,17 @@ func crawlAuth(req *http.Request) {
} }
} }
// internetEnabled : accès internet actif = drapeau .internet_enabled présent ET // internetEnabled : accès internet actif = interrupteur armé ET moteur web
// un moteur web utilisable. Même modèle que agentEnabled() (chat_agent.go). // utilisable. Avec le moteur intégré (engineGo) il n'y a rien à configurer ;
// // avec Crawl4AI il faut en plus une URL de serveur.
// Avec le moteur intégré (webEngine() == engineGo) il n'y a rien à configurer :
// le drapeau suffit. Avec Crawl4AI il faut en plus une URL de serveur.
func internetEnabled() bool { func internetEnabled() bool {
if webEngine() == engineCrawl && crawl4aiURL() == "" { if webEngine() == engineCrawl && crawl4aiURL() == "" {
return false return false
} }
_, err := os.Stat(internetFlag()) return getBool(bkState, "internet")
return err == nil
} }
func setInternetEnabled(on bool) error { func setInternetEnabled(on bool) error { return putBool(bkState, "internet", on) }
_ = os.MkdirAll(AjeanHome(), 0o755)
if on {
f, err := os.Create(internetFlag())
if err != nil {
return err
}
return f.Close()
}
if err := os.Remove(internetFlag()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// crawlReachable teste que le serveur Crawl4AI répond, avec un cache court (~30 s) // crawlReachable teste que le serveur Crawl4AI répond, avec un cache court (~30 s)
// pour ne pas ralentir chaque tour de chat. Un serveur configuré mais injoignable // pour ne pas ralentir chaque tour de chat. Un serveur configuré mais injoignable
+1 -42
View File
@@ -10,7 +10,7 @@ import (
"strings" "strings"
) )
// La mémoire de jean = des fichiers Markdown plats sous MEMORY/<nom>.md. // La mémoire de ajean = des fichiers Markdown plats sous MEMORY/<nom>.md.
// L'IA y range ce qu'elle veut retenir entre les sessions (préférences, // L'IA y range ce qu'elle veut retenir entre les sessions (préférences,
// décisions, procédures, infos projet). Quatre outils : mem_search, mem_read, // décisions, procédures, infos projet). Quatre outils : mem_search, mem_read,
// mem_add, mem_edit. // mem_add, mem_edit.
@@ -299,47 +299,6 @@ func MemDelete(name string) error {
return os.Remove(p) return os.Remove(p)
} }
// migrateSkillsToMemory copie une seule fois les anciens skills
// (SKILLS/<nom>/SKILL.md) vers la mémoire (MEMORY/<nom>.md). Idempotente : ne
// touche pas une page mémoire déjà existante, et pose un drapeau .migrated pour
// ne pas reparcourir à chaque lancement. Silencieuse en cas d'absence de SKILLS.
func migrateSkillsToMemory() {
flag := filepath.Join(memoryDir(), ".skills_migrated")
if _, err := os.Stat(flag); err == nil {
return // déjà fait
}
entries, err := os.ReadDir(skillsDir())
if err != nil {
return // pas d'anciens skills
}
migrated := 0
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
src := filepath.Join(skillsDir(), e.Name(), "SKILL.md")
b, rerr := os.ReadFile(src)
if rerr != nil {
continue
}
dst, derr := safeMemPath(e.Name())
if derr != nil {
continue
}
if _, err := os.Stat(dst); err == nil {
continue // page mémoire homonyme déjà présente : on ne l'écrase pas
}
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
return
}
if os.WriteFile(dst, b, 0o644) == nil {
migrated++
}
}
_ = os.MkdirAll(memoryDir(), 0o755)
_ = os.WriteFile(flag, []byte(fmt.Sprintf("%d skills migrés\n", migrated)), 0o644)
}
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du // MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
// mode agent (shell). Trois modes : // mode agent (shell). Trois modes :
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne). // - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
+5 -5
View File
@@ -40,9 +40,9 @@ func baseSystemPrompt(caps Caps) string {
// sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think> // sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think>
// puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts » // puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts »
// mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler. // mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler.
// « Jean » avec une majuscule : c'est un nom propre, et le modèle recopie // « AJEAN » avec une majuscule : c'est un nom propre, et le modèle recopie
// littéralement la casse d'ici quand il se présente (« je suis jean »). // littéralement la casse d'ici quand il se présente (« je suis ajean »).
b.WriteString("You are Jean, an expert assistant operating directly on this machine with real tools.") b.WriteString("You are AJEAN, an expert assistant operating directly on this machine with real tools.")
if caps.Mem == MemAlways { if caps.Mem == MemAlways {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.") b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
} }
@@ -126,7 +126,7 @@ func machineSystemPrompt(caps Caps) string {
} }
b.WriteString(".") b.WriteString(".")
if cwd != "" { if cwd != "" {
b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder jean was launched from.") b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder ajean was launched from.")
} }
return b.String() return b.String()
} }
@@ -145,7 +145,7 @@ func runShell(command string, timeoutSec int) string {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second)
defer cancel() defer cancel()
cmd := newShellCmd(ctx, command) cmd := newShellCmd(ctx, command)
// Le shell démarre dans le workspace, pas dans le dossier d'où jean a été // Le shell démarre dans le workspace, pas dans le dossier d'où ajean a été
// lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau. // lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau.
cmd.Dir = agentWorkspace() cmd.Dir = agentWorkspace()
var stdout, stderr strings.Builder var stdout, stderr strings.Builder
+5 -5
View File
@@ -11,14 +11,14 @@ import (
// //
// Sans ça, les outils write/edit/bash héritent du répertoire courant du PROCESSUS : // Sans ça, les outils write/edit/bash héritent du répertoire courant du PROCESSUS :
// le modèle écrit "meteo.json", et le fichier atterrit là d'où l'utilisateur a // le modèle écrit "meteo.json", et le fichier atterrit là d'où l'utilisateur a
// lancé jean — le Bureau quand on double-clique l'installateur, C:\ProgramData\jean\bin // lancé ajean — le Bureau quand on double-clique l'installateur, C:\ProgramData\ajean\bin
// quand on lance le binaire installé. Personne ne s'attend à voir un chat déposer // quand on lance le binaire installé. Personne ne s'attend à voir un chat déposer
// des fichiers sur son Bureau. On donne donc à l'agent un dossier à lui : les // des fichiers sur son Bureau. On donne donc à l'agent un dossier à lui : les
// chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS // chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS
// restent honorés tels quels — quand l'utilisateur demande d'écrire dans un // restent honorés tels quels — quand l'utilisateur demande d'écrire dans un
// dossier précis, ça doit marcher. // dossier précis, ça doit marcher.
const workspaceEnv = "JEAN_WORKSPACE" const workspaceEnv = "AJEAN_WORKSPACE"
var ( var (
workspaceOnce sync.Once workspaceOnce sync.Once
@@ -26,7 +26,7 @@ var (
) )
// agentWorkspace renvoie le dossier de travail de l'agent, créé au besoin. Il // agentWorkspace renvoie le dossier de travail de l'agent, créé au besoin. Il
// essaie plusieurs emplacements car AjeanHome() vaut %ProgramData%\jean sous // essaie plusieurs emplacements car AjeanHome() vaut %ProgramData%\ajean sous
// Windows, qui n'est pas inscriptible par un utilisateur non administrateur. // Windows, qui n'est pas inscriptible par un utilisateur non administrateur.
func agentWorkspace() string { func agentWorkspace() string {
workspaceOnce.Do(func() { workspaceOnce.Do(func() {
@@ -62,10 +62,10 @@ func workspaceCandidates() []string {
} }
c = append(c, filepath.Join(AjeanHome(), "workspace")) c = append(c, filepath.Join(AjeanHome(), "workspace"))
if home, err := os.UserHomeDir(); err == nil && home != "" { if home, err := os.UserHomeDir(); err == nil && home != "" {
// Le nouveau nom d'abord, l'ancien ensuite : un dossier ~/jean/workspace // Le nouveau nom d'abord, l'ancien ensuite : un dossier ~/ajean/workspace
// déjà rempli par l'agent reste utilisable au lieu de repartir de zéro. // déjà rempli par l'agent reste utilisable au lieu de repartir de zéro.
c = append(c, filepath.Join(home, "ajean", "workspace")) c = append(c, filepath.Join(home, "ajean", "workspace"))
c = append(c, filepath.Join(home, "jean", "workspace")) c = append(c, filepath.Join(home, "ajean", "workspace"))
} }
c = append(c, filepath.Join(os.TempDir(), "ajean-workspace")) c = append(c, filepath.Join(os.TempDir(), "ajean-workspace"))
return c return c
+1 -1
View File
@@ -7,7 +7,7 @@ import (
) )
// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le // Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le
// répertoire courant du processus (Bureau, C:\ProgramData\jean\bin…). // répertoire courant du processus (Bureau, C:\ProgramData\ajean\bin…).
func TestResolveAgentPathRelative(t *testing.T) { func TestResolveAgentPathRelative(t *testing.T) {
got := resolveAgentPath("meteo.json") got := resolveAgentPath("meteo.json")
want := filepath.Join(agentWorkspace(), "meteo.json") want := filepath.Join(agentWorkspace(), "meteo.json")
+6 -6
View File
@@ -1,12 +1,12 @@
package ajean package ajean
// cli_app.go — expérience « application » de Jean. // cli_app.go — expérience « application » de AJEAN.
// //
// Quand on double-clique sur le binaire (aucun argument, console fraîche), au // Quand on double-clique sur le binaire (aucun argument, console fraîche), au
// lieu d'afficher l'aide dans une console qui se ferme, Jean démarre son UI web, // lieu d'afficher l'aide dans une console qui se ferme, AJEAN démarre son UI web,
// l'ouvre dans le navigateur ET pose une icône dans la zone de notification // l'ouvre dans le navigateur ET pose une icône dans la zone de notification
// Windows (voir sys_tray_windows.go) : on voit que Jean tourne et on le pilote // Windows (voir sys_tray_windows.go) : on voit que AJEAN tourne et on le pilote
// (« Ouvrir Jean » / « Quitter »). // (« Ouvrir AJEAN » / « Quitter »).
import ( import (
"fmt" "fmt"
@@ -34,13 +34,13 @@ func cmdApp(args []string) error {
addr := fmt.Sprintf("0.0.0.0:%d", appPort) addr := fmt.Sprintf("0.0.0.0:%d", appPort)
ln, err := net.Listen("tcp", addr) ln, err := net.Listen("tcp", addr)
if err != nil { if err != nil {
// Jean tourne déjà sur ce port : on ouvre juste l'UI sur l'instance // AJEAN tourne déjà sur ce port : on ouvre juste l'UI sur l'instance
// existante plutôt que d'échouer. // existante plutôt que d'échouer.
fmt.Printf("AJEAN est déjà lancé — ouverture de %s\n", url) fmt.Printf("AJEAN est déjà lancé — ouverture de %s\n", url)
return openBrowser(url) return openBrowser(url)
} }
// UN SEUL process propriétaire de la conversation, comme le service jean-link // UN SEUL process propriétaire de la conversation, comme le service ajean-link
// sous Linux : l'app sert l'UI locale ET le tunnel avec le MÊME mux. Deux // sous Linux : l'app sert l'UI locale ET le tunnel avec le MÊME mux. Deux
// process qui servent la conversation (objet en mémoire persisté dans // process qui servent la conversation (objet en mémoire persisté dans
// conversation.json) donnent deux fils divergents entre le local et // conversation.json) donnent deux fils divergents entre le local et
+2 -2
View File
@@ -1,7 +1,7 @@
// Jean — cœur du binaire (package jean, appelé par cmd/ajean). Les fichiers sont préfixés par domaine // AJEAN — cœur du binaire (package ajean, appelé par cmd/ajean). Les fichiers sont préfixés par domaine
// (Go n'autorise pas de sous-dossiers dans un même package) : // (Go n'autorise pas de sous-dossiers dans un même package) :
// //
// main.go point d'entrée : dispatch des sous-commandes, chemins (JeanHome…) // main.go point d'entrée : dispatch des sous-commandes, chemins (AJEANHome…)
// cli_* expérience « application » (double-clic : UI + tray + splash) // cli_* expérience « application » (double-clic : UI + tray + splash)
// web_* serveur HTTP local :8090 (UI embarquée via go:embed ui/, auth, prefs) // web_* serveur HTTP local :8090 (UI embarquée via go:embed ui/, auth, prefs)
// chat_* chat CLI + conversation serveur partagée, compaction, mémoire, // chat_* chat CLI + conversation serveur partagée, compaction, mémoire,
+13 -30
View File
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -71,7 +70,7 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) {
// Use the same endpoint your real chat hits, so the comparison is honest // Use the same endpoint your real chat hits, so the comparison is honest
// (chat template, reasoning, OpenAI-compat layer all included). // (chat template, reasoning, OpenAI-compat layer all included).
payload := map[string]any{ payload := map[string]any{
"model": "jean", "model": "ajean",
"messages": []Message{{Role: "user", Content: prompt + "\n\nContinue this passage with another 1000+ words of original varied prose, mixing French and English narrative paragraphs on different topics."}}, "messages": []Message{{Role: "user", Content: prompt + "\n\nContinue this passage with another 1000+ words of original varied prose, mixing French and English narrative paragraphs on different topics."}},
"max_tokens": nPredict, "max_tokens": nPredict,
"stream": false, "stream": false,
@@ -136,10 +135,6 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) {
return res, nil return res, nil
} }
// lastBenchPath stores the most recent benchmark result so the web UI can show
// it without re-running. Lives in JEAN_HOME so it survives restarts.
func lastBenchPath() string { return filepath.Join(AjeanHome(), ".last_bench.json") }
// savedBench is a benchResult plus the model it was run against and a timestamp. // savedBench is a benchResult plus the model it was run against and a timestamp.
type savedBench struct { type savedBench struct {
Result benchResult `json:"result"` Result benchResult `json:"result"`
@@ -147,45 +142,35 @@ type savedBench struct {
At int64 `json:"at"` At int64 `json:"at"`
} }
// saveLastBench persists res to JEAN_HOME/.last_bench.json (best-effort). // saveLastBench enregistre le dernier benchmark (best-effort) pour que l'UI
// puisse l'afficher sans le relancer.
func saveLastBench(res *benchResult) { func saveLastBench(res *benchResult) {
sb := savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} sb := savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()}
if b, err := json.Marshal(sb); err == nil { _ = putJSON(bkState, "last_bench", sb)
_ = os.WriteFile(lastBenchPath(), b, 0o644)
}
} }
// loadLastBench reads the persisted benchmark, or nil if none/unreadable. // loadLastBench relit le benchmark enregistré, ou nil s'il n'y en a pas.
func loadLastBench() *savedBench { func loadLastBench() *savedBench {
b, err := os.ReadFile(lastBenchPath())
if err != nil {
return nil
}
var sb savedBench var sb savedBench
if json.Unmarshal(b, &sb) != nil { if !getJSON(bkState, "last_bench", &sb) {
return nil return nil
} }
return &sb return &sb
} }
// benchStorePath holds per-preset benchmark results so the UI can show each // loadBenchStore renvoie les benchmarks par preset (vide s'il n'y en a pas).
// preset's measured performance. Keyed by preset name.
func benchStorePath() string { return filepath.Join(AjeanHome(), ".bench_presets.json") }
// loadBenchStore returns the per-preset bench map (empty if none/unreadable).
func loadBenchStore() map[string]savedBench { func loadBenchStore() map[string]savedBench {
m := map[string]savedBench{} m := map[string]savedBench{}
b, err := os.ReadFile(benchStorePath()) getJSON(bkState, "bench_presets", &m)
if err != nil { if m == nil {
return m m = map[string]savedBench{}
} }
_ = json.Unmarshal(b, &m)
return m return m
} }
// saveBenchForActivePreset records res under the name of the currently active // saveBenchForActivePreset records res under the name of the currently active
// preset (the one whose config.env matches the live config). No-op if no preset // preset (celui qui correspond à la configuration active). Sans effet si aucun
// matches — the bench still lives in .last_bench.json via saveLastBench. // preset ne correspond — le benchmark reste enregistré par saveLastBench.
func saveBenchForActivePreset(res *benchResult) { func saveBenchForActivePreset(res *benchResult) {
list, err := ListPresets() list, err := ListPresets()
if err != nil { if err != nil {
@@ -203,9 +188,7 @@ func saveBenchForActivePreset(res *benchResult) {
} }
m := loadBenchStore() m := loadBenchStore()
m[id] = savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} m[id] = savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()}
if b, err := json.Marshal(m); err == nil { _ = putJSON(bkState, "bench_presets", m)
_ = os.WriteFile(benchStorePath(), b, 0o644)
}
} }
func cmdBench(args []string) error { func cmdBench(args []string) error {
+1 -1
View File
@@ -526,7 +526,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// le contexte — c'est un choix assumé. // le contexte — c'est un choix assumé.
for iter := 0; ; iter++ { for iter := 0; ; iter++ {
payload := map[string]any{ payload := map[string]any{
"model": "jean", "model": "ajean",
"messages": messages, "messages": messages,
"stream": true, "stream": true,
"temperature": temperature, "temperature": temperature,
+8 -23
View File
@@ -72,38 +72,23 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
return srv.Serve(tls.NewListener(rawLn, tlsCfg)) return srv.Serve(tls.NewListener(rawLn, tlsCfg))
} }
// oaiPublicPath est le drapeau qui active l'accès OpenAI public (piloté par l'UI, // oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette
// lu en direct → activable/coupable sans redémarrer le service de lien). // machine. Piloté par l'UI et lu en direct → activable/coupable sans redémarrer
func oaiPublicPath() string { return filepath.Join(AjeanHome(), ".oai_public") } // le service de lien.
func oaiPublicEnabled() bool { return getBool(bkState, "oai_public") }
// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette machine.
func oaiPublicEnabled() bool {
if _, err := os.Stat(oaiPublicPath()); err == nil {
return true
}
return os.Getenv("JEAN_LINK_ALLOW_OAI") == "1" // rétro-compat (ancien drapeau env)
}
// setOAIPublic active (on) ou coupe (off) l'accès OpenAI public. // setOAIPublic active (on) ou coupe (off) l'accès OpenAI public.
func setOAIPublic(on bool) error { func setOAIPublic(on bool) error { return putBool(bkState, "oai_public", on) }
if on {
return os.WriteFile(oaiPublicPath(), []byte("1\n"), 0o600)
}
if err := os.Remove(oaiPublicPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via // oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via
// Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et // Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et
// répond elle-même aux challenges ACME. La clé privée est stockée dans // répond elle-même aux challenges ACME. La clé privée est stockée dans
// $JEAN_HOME/certs et ne quitte jamais la machine. Toujours construite ; c'est le // $AJEAN_HOME/certs et ne quitte jamais la machine. Toujours construite ; c'est le
// démux (oaiPublicEnabled, lu en direct) qui décide de router ou non le trafic. // démux (oaiPublicEnabled, lu en direct) qui décide de router ou non le trafic.
func oaiTLSConfig() *tls.Config { func oaiTLSConfig() *tls.Config {
certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(AjeanHome(), "certs")} certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(AjeanHome(), "certs")}
certmagic.DefaultACME.Agreed = true certmagic.DefaultACME.Agreed = true
certmagic.DefaultACME.Email = envAny("AJEAN_ACME_EMAIL", "JEAN_ACME_EMAIL") certmagic.DefaultACME.Email = os.Getenv("AJEAN_ACME_EMAIL")
certmagic.DefaultACME.DisableHTTPChallenge = true // pas de :80 accessible (CGNAT) → TLS-ALPN uniquement certmagic.DefaultACME.DisableHTTPChallenge = true // pas de :80 accessible (CGNAT) → TLS-ALPN uniquement
magic := certmagic.NewDefault() magic := certmagic.NewDefault()
magic.OnDemand = &certmagic.OnDemandConfig{ magic.OnDemand = &certmagic.OnDemandConfig{
@@ -256,7 +241,7 @@ func cmdOAI(args []string) error {
default: default:
fmt.Println("usage: ajean oai serve [port] [host] (front TLS de test → llama /v1)") fmt.Println("usage: ajean oai serve [port] [host] (front TLS de test → llama /v1)")
fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (ajean link)") fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (ajean link)")
fmt.Println(" quand JEAN_LINK_ALLOW_OAI=1 ; cert Let's Encrypt via TLS-ALPN-01.") fmt.Println(" quand AJEAN_LINK_ALLOW_OAI=1 ; cert Let's Encrypt via TLS-ALPN-01.")
return nil return nil
} }
} }
+1 -1
View File
@@ -127,7 +127,7 @@ func (h headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
// connect établit une session pour la config donnée. // connect établit une session pour la config donnée.
func mcpConnect(ctx context.Context, name string, cfg MCPServerConfig) (*mcpsdk.ClientSession, error) { func mcpConnect(ctx context.Context, name string, cfg MCPServerConfig) (*mcpsdk.ClientSession, error) {
client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "jean", Version: Version}, nil) client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "ajean", Version: Version}, nil)
var transport mcpsdk.Transport var transport mcpsdk.Transport
switch cfg.Transport() { switch cfg.Transport() {
+5 -5
View File
@@ -12,12 +12,12 @@ import (
// tentative de connexion : sans ça, connecter en parallèle multiplierait les // tentative de connexion : sans ça, connecter en parallèle multiplierait les
// process stdio lancés pour un même serveur. // process stdio lancés pour un même serveur.
func TestMCPEnsureDeduplicatesConcurrentConnects(t *testing.T) { func TestMCPEnsureDeduplicatesConcurrentConnects(t *testing.T) {
t.Setenv("JEAN_HOME", t.TempDir()) t.Setenv("AJEAN_HOME", t.TempDir())
t.Cleanup(mcpCloseAll) t.Cleanup(mcpCloseAll)
// Commande inexistante : la connexion échoue, mais l'entrée du pool est bien // Commande inexistante : la connexion échoue, mais l'entrée du pool est bien
// partagée — c'est ce qu'on teste. // partagée — c'est ce qu'on teste.
cfg := MCPServerConfig{Command: "jean-binaire-inexistant-pour-test", Enabled: true} cfg := MCPServerConfig{Command: "ajean-binaire-inexistant-pour-test", Enabled: true}
const callers = 8 const callers = 8
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -53,7 +53,7 @@ func TestMCPEndToEnd(t *testing.T) {
if _, err := exec.LookPath("npx"); err != nil { if _, err := exec.LookPath("npx"); err != nil {
t.Skip("npx indisponible — test MCP e2e ignoré") t.Skip("npx indisponible — test MCP e2e ignoré")
} }
t.Setenv("JEAN_HOME", t.TempDir()) t.Setenv("AJEAN_HOME", t.TempDir())
cfg := MCPServerConfig{ cfg := MCPServerConfig{
Command: "npx", Command: "npx",
@@ -83,8 +83,8 @@ func TestMCPEndToEnd(t *testing.T) {
t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools)) t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools))
} }
out := mcpCall(echoName, map[string]any{"message": "bonjour-jean"}) out := mcpCall(echoName, map[string]any{"message": "bonjour-ajean"})
if !strings.Contains(out, "bonjour-jean") { if !strings.Contains(out, "bonjour-ajean") {
t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out) t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out)
} }
} }
+13 -34
View File
@@ -3,7 +3,6 @@ package ajean
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@@ -11,14 +10,14 @@ import (
// Configuration des serveurs MCP (Model Context Protocol). // Configuration des serveurs MCP (Model Context Protocol).
// //
// jean peut se connecter à des serveurs MCP tiers pour enrichir la palette // ajean peut se connecter à des serveurs MCP tiers pour enrichir la palette
// d'outils de l'IA (accès fichiers rapide type desktop-commander, bases de // d'outils de l'IA (accès fichiers rapide type desktop-commander, bases de
// données, APIs métier…). Deux transports : // données, APIs métier…). Deux transports :
// //
// - stdio : jean lance un process local (command + args) et parle en // - stdio : ajean lance un process local (command + args) et parle en
// JSON-RPC sur stdin/stdout. C'est le cas de desktop-commander, du serveur // JSON-RPC sur stdin/stdout. C'est le cas de desktop-commander, du serveur
// filesystem officiel, etc. — lancés via npx/uvx/un binaire. // filesystem officiel, etc. — lancés via npx/uvx/un binaire.
// - http : jean parle à un serveur MCP distant en Streamable HTTP (url + // - http : ajean parle à un serveur MCP distant en Streamable HTTP (url +
// éventuels en-têtes d'auth). // éventuels en-têtes d'auth).
// //
// Le format du fichier mcp.json reprend celui de Claude Desktop (clé // Le format du fichier mcp.json reprend celui de Claude Desktop (clé
@@ -26,7 +25,7 @@ import (
// leurs configs existantes. On ajoute un champ "enabled" par serveur. // leurs configs existantes. On ajoute un champ "enabled" par serveur.
// //
// IMPORTANT (sécurité) : un serveur MCP stdio exécute un process arbitraire sur // IMPORTANT (sécurité) : un serveur MCP stdio exécute un process arbitraire sur
// la machine où tourne jean — même niveau de confiance que l'outil `bash` du // la machine où tourne ajean — même niveau de confiance que l'outil `bash` du
// mode agent. La configuration MCP est donc réservée au propriétaire local de la // mode agent. La configuration MCP est donc réservée au propriétaire local de la
// machine et ne doit JAMAIS être pilotable depuis le relais/accès distant. // machine et ne doit JAMAIS être pilotable depuis le relais/accès distant.
@@ -118,11 +117,11 @@ type mcpConfigFile struct {
MCPServers map[string]MCPServerConfig `json:"mcpServers"` MCPServers map[string]MCPServerConfig `json:"mcpServers"`
} }
// mcpConfigMu sérialise les accès concurrents au fichier mcp.json (l'UI web et // mcpConfigMu sérialise les accès concurrents à la déclaration des serveurs
// les tours de chat peuvent lire/écrire en parallèle). // MCP (l'UI web et les tours de chat peuvent lire/écrire en parallèle).
var mcpConfigMu sync.Mutex var mcpConfigMu sync.Mutex
// LoadMCPConfig lit mcp.json. Fichier absent => config vide (pas d'erreur). // LoadMCPConfig lit les serveurs MCP déclarés. Aucun => map vide, pas d'erreur.
func LoadMCPConfig() (map[string]MCPServerConfig, error) { func LoadMCPConfig() (map[string]MCPServerConfig, error) {
mcpConfigMu.Lock() mcpConfigMu.Lock()
defer mcpConfigMu.Unlock() defer mcpConfigMu.Unlock()
@@ -130,36 +129,16 @@ func LoadMCPConfig() (map[string]MCPServerConfig, error) {
} }
func loadMCPConfigLocked() (map[string]MCPServerConfig, error) { func loadMCPConfigLocked() (map[string]MCPServerConfig, error) {
b, err := os.ReadFile(mcpConfigPath()) servers := map[string]MCPServerConfig{}
if err != nil { getJSON(bkState, "mcp", &servers)
if os.IsNotExist(err) { if servers == nil {
return map[string]MCPServerConfig{}, nil servers = map[string]MCPServerConfig{}
}
return nil, err
} }
var f mcpConfigFile return servers, nil
if err := json.Unmarshal(b, &f); err != nil {
return nil, fmt.Errorf("mcp.json invalide: %w", err)
}
if f.MCPServers == nil {
f.MCPServers = map[string]MCPServerConfig{}
}
return f.MCPServers, nil
} }
// saveMCPConfigLocked écrit atomiquement mcp.json (write temp + rename).
func saveMCPConfigLocked(servers map[string]MCPServerConfig) error { func saveMCPConfigLocked(servers map[string]MCPServerConfig) error {
_ = os.MkdirAll(AjeanHome(), 0o755) return putJSON(bkState, "mcp", servers)
f := mcpConfigFile{MCPServers: servers}
b, err := json.MarshalIndent(f, "", " ")
if err != nil {
return err
}
tmp := mcpConfigPath() + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, mcpConfigPath())
} }
// SetMCPServer ajoute ou remplace un serveur nommé, puis invalide le pool de // SetMCPServer ajoute ou remplace un serveur nommé, puis invalide le pool de
+28 -77
View File
@@ -1,6 +1,6 @@
package ajean package ajean
// relay_link.go — `ajean link <token>` : connecte ce serveur Jean au relais public // relay_link.go — `ajean link <token>` : connecte ce serveur AJEAN au relais public
// (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur // (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur
// y accède depuis n'importe où sans ouvrir de port (CGNAT, box, etc.). // y accède depuis n'importe où sans ouvrir de port (CGNAT, box, etc.).
// //
@@ -10,7 +10,7 @@ package ajean
// local. Keepalive + reconnexion automatique avec backoff. // local. Keepalive + reconnexion automatique avec backoff.
// //
// Le token est fourni par la boutique à l'achat. Il est mémorisé dans // Le token est fourni par la boutique à l'achat. Il est mémorisé dans
// $JEAN_HOME/.link_token pour que `ajean link` (sans argument) reprenne la // $AJEAN_HOME/.link_token pour que `ajean link` (sans argument) reprenne la
// connexion. // connexion.
import ( import (
@@ -39,70 +39,42 @@ import (
) )
// defaultRelayURL est l'endpoint WebSocket du relais. Surchageable via // defaultRelayURL est l'endpoint WebSocket du relais. Surchageable via
// $JEAN_LINK_URL (utile pour tester contre un relais local). // $AJEAN_LINK_URL (utile pour tester contre un relais local).
const defaultRelayURL = "wss://ajean.link/agent" const defaultRelayURL = "wss://ajean.link/agent"
func linkTokenPath() string { return filepath.Join(AjeanHome(), ".link_token") } // machineID renvoie un identifiant stable de la machine, créé au premier appel.
func linkMachinePath() string { return filepath.Join(AjeanHome(), ".link_machine") }
// machineID returns a stable per-machine identifier, creating one on first use.
// Permet au relais de regrouper les connexions d'une même machine sous le compte. // Permet au relais de regrouper les connexions d'une même machine sous le compte.
func machineID() string { func machineID() string {
if b, err := os.ReadFile(linkMachinePath()); err == nil { if id := getStr(bkState, "link_machine"); id != "" {
if id := strings.TrimSpace(string(b)); id != "" { return id
return id
}
} }
buf := make([]byte, 8) buf := make([]byte, 8)
_, _ = rand.Read(buf) _, _ = rand.Read(buf)
id := hex.EncodeToString(buf) id := hex.EncodeToString(buf)
_ = os.MkdirAll(AjeanHome(), 0o755) _ = putStr(bkState, "link_machine", id)
_ = os.WriteFile(linkMachinePath(), []byte(id+"\n"), 0o600)
return id return id
} }
// readLinkToken returns the saved subscription token, or "". // readLinkToken renvoie la clé d'abonnement enregistrée, ou "".
func readLinkToken() string { func readLinkToken() string { return getStr(bkState, "link_token") }
b, err := os.ReadFile(linkTokenPath())
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func saveLinkToken(tok string) error { func saveLinkToken(tok string) error { return putStr(bkState, "link_token", tok) }
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
return err
}
return os.WriteFile(linkTokenPath(), []byte(tok+"\n"), 0o600)
}
// removeLinkToken oublie la clé de liaison enregistrée (idempotent). // removeLinkToken oublie la clé de liaison enregistrée (idempotent).
func removeLinkToken() error { func removeLinkToken() error { return putStr(bkState, "link_token", "") }
if err := os.Remove(linkTokenPath()); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// relayURL resolves the relay WebSocket endpoint (env override → default). // relayURL resolves the relay WebSocket endpoint (env override → default).
func relayURL() string { func relayURL() string {
if u := envAny("AJEAN_LINK_URL", "JEAN_LINK_URL"); u != "" { if u := os.Getenv("AJEAN_LINK_URL"); u != "" {
return u return u
} }
return defaultRelayURL return defaultRelayURL
} }
// linkUnitName / legacyLinkUnitName : l'unité systemd qui exécute le worker // linkUnitName est l'unité qui exécute le worker « ajean link serve ».
// « ajean link --foreground », et son nom d'avant le renommage. const linkUnitName = "ajean-link"
const (
linkUnitName = "ajean-link"
legacyLinkUnitName = "jean-link"
)
// linkServiceName renvoie l'unité de lien réellement installée sur la machine func linkServiceName() string { return linkUnitName }
// (voir sys_unitname.go : un serveur mis à jour tourne encore sous jean-link).
func linkServiceName() string { return resolveUnitName(linkUnitName, legacyLinkUnitName) }
func cmdLink(args []string) error { func cmdLink(args []string) error {
sub := "" sub := ""
@@ -135,7 +107,7 @@ func cmdLink(args []string) error {
} }
return nil return nil
case "logout": case "logout":
_ = os.Remove(linkTokenPath()) _ = removeLinkToken()
fmt.Println(green("[ok]") + " token supprimé") fmt.Println(green("[ok]") + " token supprimé")
return nil return nil
case "stop": case "stop":
@@ -239,9 +211,9 @@ func runLinkForeground() error {
} }
// UNE seule instance du mux web → UNE seule conversation en mémoire, PARTAGÉE // UNE seule instance du mux web → UNE seule conversation en mémoire, PARTAGÉE
// entre l'UI locale (:8090, jean.n27.fr) et le tunnel (app.ajean.link). Avant, // 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 // `ajean web` et `ajean link` étaient deux process distincts avec chacun leur
// historique → confusion. Désormais le process jean-link est l'unique // historique → confusion. Désormais le process ajean-link est l'unique
// propriétaire de la conversation et sert les DEUX surfaces. // propriétaire de la conversation et sert les DEUX surfaces.
webMux := newWebMux() webMux := newWebMux()
go serveLocalWebMux(webMux) go serveLocalWebMux(webMux)
@@ -280,7 +252,7 @@ func runLinkForeground() error {
// //
// Sur un poste de bureau, l'app EST ce process propriétaire : elle sert déjà // Sur un poste de bureau, l'app EST ce process propriétaire : elle sert déjà
// :8090, donc elle fait aussi tourner le tunnel elle-même au lieu de déléguer à // :8090, donc elle fait aussi tourner le tunnel elle-même au lieu de déléguer à
// un worker détaché. L'accès distant vit donc aussi longtemps que Jean.app est // un worker détaché. L'accès distant vit donc aussi longtemps que AJEAN.app est
// ouverte — sur un portable qui s'endort, c'est de toute façon la réalité. // ouverte — sur un portable qui s'endort, c'est de toute façon la réalité.
var appLink struct { var appLink struct {
@@ -367,7 +339,7 @@ func killForeignLinkWorker() {
} }
// serveLocalWebMux sert l'UI web locale sur :8090 avec le mux fourni — le MÊME // serveLocalWebMux sert l'UI web locale sur :8090 avec le mux fourni — le MÊME
// que celui exposé dans le tunnel. Le process jean-link devient ainsi l'unique // 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 // 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). // distant. Remplace le `ajean web` autonome (à ne plus lancer séparément).
func serveLocalWebMux(mux *http.ServeMux) { func serveLocalWebMux(mux *http.ServeMux) {
@@ -382,33 +354,12 @@ func serveLocalWebMux(mux *http.ServeMux) {
} }
// linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, // linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS,
// Windows), où Jean 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 adoptLegacyLinkFile(".pid") } func linkPIDPath() string { return filepath.Join(AjeanHome(), linkUnitName+".pid") }
func linkLogPath() string { return adoptLegacyLinkFile(".log") } func linkLogPath() string { return filepath.Join(AjeanHome(), linkUnitName+".log") }
// adoptLegacyLinkFile renvoie le chemin « ajean-link<ext> » en reprenant au
// passage le « jean-link<ext> » laissé par une version antérieure.
//
// Reprendre le fichier PID n'est pas cosmétique : c'est LUI qui dit si un worker
// tourne déjà. Sans cette reprise, la première version renommée ne verrait plus
// le worker en cours, en démarrerait un second, et la machine se retrouverait
// avec deux tunnels concurrents vers le relais. Idempotent, silencieux en cas
// d'échec (on retombe simplement sur le nouveau nom).
func adoptLegacyLinkFile(ext string) string {
newPath := filepath.Join(AjeanHome(), linkUnitName+ext)
if _, err := os.Stat(newPath); err == nil {
return newPath
}
legacy := filepath.Join(AjeanHome(), legacyLinkUnitName+ext)
if _, err := os.Stat(legacy); err != nil {
return newPath
}
_ = os.Rename(legacy, newPath)
return newPath
}
// linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est // linkServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est
// l'unité systemd jean-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
@@ -553,7 +504,7 @@ func linkUserSvcCtl(action string) error {
// - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint // - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint
// compatible OpenAI, avec injection de la clé API locale) → permet de // compatible OpenAI, avec injection de la clé API locale) → permet de
// brancher OpenCode, Hermes, etc. sur ajean.link/oai/<machine>/v1 // brancher OpenCode, Hermes, etc. sur ajean.link/oai/<machine>/v1
// - tout le reste → l'UI web de Jean (avec injection de la clé de pilotage) // - tout le reste → l'UI web de AJEAN (avec injection de la clé de pilotage)
func newLinkHandler(mux *http.ServeMux) http.Handler { func newLinkHandler(mux *http.ServeMux) http.Handler {
web := withLocalAuth(mux) web := withLocalAuth(mux)
llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())} llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}
@@ -577,7 +528,7 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
// - /api/e2e/chat : chat chiffré (streaming SSE chiffré). // - /api/e2e/chat : chat chiffré (streaming SSE chiffré).
// - /api/e2e/req : proxy de contrôle chiffré (presets, VRAM, skills, service…). // - /api/e2e/req : proxy de contrôle chiffré (presets, VRAM, skills, service…).
// Tout autre /api/* en clair est REFUSÉ via le tunnel. // Tout autre /api/* en clair est REFUSÉ via le tunnel.
oaiAllowed := os.Getenv("JEAN_LINK_ALLOW_OAI") == "1" oaiAllowed := os.Getenv("AJEAN_LINK_ALLOW_OAI") == "1"
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path p := r.URL.Path
if strings.HasPrefix(p, "/api/e2e/") { if strings.HasPrefix(p, "/api/e2e/") {
@@ -600,7 +551,7 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
// L'endpoint OpenAI (OpenCode/Hermes) ne peut pas être chiffré navigateur : // L'endpoint OpenAI (OpenCode/Hermes) ne peut pas être chiffré navigateur :
// il transiterait en clair par le relais. Désactivé par défaut. // il transiterait en clair par le relais. Désactivé par défaut.
if !oaiAllowed { if !oaiAllowed {
http.Error(w, "endpoint OpenAI désactivé via le relais (transiterait en clair) ; JEAN_LINK_ALLOW_OAI=1 pour l'autoriser", http.StatusForbidden) http.Error(w, "endpoint OpenAI désactivé via le relais (transiterait en clair) ; AJEAN_LINK_ALLOW_OAI=1 pour l'autoriser", http.StatusForbidden)
return return
} }
lp.ServeHTTP(w, r) lp.ServeHTTP(w, r)
@@ -671,7 +622,7 @@ func runLinkSession(token string, handler http.Handler, oaiTLS *tls.Config) erro
fmt.Printf("%s lien établi ✓\n", green("[link]")) fmt.Printf("%s lien établi ✓\n", green("[link]"))
// Chaque Accept() = un stream. Deux natures possibles : // Chaque Accept() = un stream. Deux natures possibles :
// - requête HTTP normale (UI Jean / E2E) → servie par `handler` ; // - requête HTTP normale (UI AJEAN / E2E) → servie par `handler` ;
// - session TLS brute (accès OpenAI public) → terminée par le front TLS. // - session TLS brute (accès OpenAI public) → terminée par le front TLS.
// On les distingue au 1er octet (0x16 = handshake TLS). Sans OAI activé, tout // On les distingue au 1er octet (0x16 = handshake TLS). Sans OAI activé, tout
// va au HTTP (comportement historique inchangé). // va au HTTP (comportement historique inchangé).
+26 -63
View File
@@ -20,9 +20,6 @@ func Main() {
// true. À faire AVANT toute écriture. // true. À faire AVANT toute écriture.
haveConsole := setupConsole() haveConsole := setupConsole()
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
// nouvelle mémoire (MEMORY/<nom>.md). Idempotente, silencieuse si rien à faire.
migrateSkillsToMemory()
// Nettoie un éventuel binaire .old laissé par une mise à jour Windows. // Nettoie un éventuel binaire .old laissé par une mise à jour Windows.
cleanupOldBinary() cleanupOldBinary()
@@ -95,10 +92,6 @@ func Main() {
// Sous-commande interne, absente de l'aide : l'accompagnateur detache qui // Sous-commande interne, absente de l'aide : l'accompagnateur detache qui
// attend la fermeture de l'app, migre, puis la relance. // attend la fermeture de l'app, migre, puis la relance.
mustExit(cmdRestartAfterUpdate(args)) mustExit(cmdRestartAfterUpdate(args))
case migrateHomeArg:
// Sous-commande interne, volontairement absente de l'aide : c'est le
// travail delegue au process elevé par elevateForHomeMigration().
mustExit(cmdMigrateHomeElevated())
case "install": case "install":
mustExit(cmdInstall(args)) mustExit(cmdInstall(args))
case "uninstall": case "uninstall":
@@ -150,7 +143,7 @@ Accès distant (ajean.link) :
link code génère un code d'appairage (valable 10 min, à usage unique) pour le portail link code génère un code d'appairage (valable 10 min, à usage unique) pour le portail
link status | logout état du lien / oublier le token link status | logout état du lien / oublier le token
link (sans argument) affiche l'aide des sous-commandes link link (sans argument) affiche l'aide des sous-commandes link
link serve exécute le worker au premier plan (utilisé par jean-link.service ; pendant de 'ajean serve') link serve exécute le worker au premier plan (utilisé par ajean-link.service ; pendant de 'ajean serve')
Mode agent: Mode agent:
agent [on|off|status] active TOUS les outils de l'IA (shell complet + mémoire) — un seul interrupteur agent [on|off|status] active TOUS les outils de l'IA (shell complet + mémoire) — un seul interrupteur
@@ -171,7 +164,7 @@ Installation:
Env: Env:
AJEAN_HOME racine (défaut: /etc/ajean sur Linux/macOS, %%ProgramData%%\ajean sur Windows) AJEAN_HOME racine (défaut: /etc/ajean sur Linux/macOS, %%ProgramData%%\ajean sur Windows)
JEAN_HOME reste accepté (héritage) AJEAN_HOME reste accepté (héritage)
EDITOR éditeur pour 'ajean edit' (défaut: nano sur Unix, notepad sur Windows) EDITOR éditeur pour 'ajean edit' (défaut: nano sur Unix, notepad sur Windows)
Config: $AJEAN_HOME/config.env Config: $AJEAN_HOME/config.env
@@ -186,84 +179,54 @@ func mustExit(err error) {
} }
// AjeanHome resolves the AJEAN data directory. // AjeanHome resolves the AJEAN data directory.
// Precedence: $AJEAN_HOME → $JEAN_HOME (héritage) → /etc/default/ajean puis // Précédence : $AJEAN_HOME → /etc/default/ajean (unix) → défaut plateforme.
// /etc/default/jean (unix only) → migratedDefaultHome().
//
// Les deux noms de variable sont acceptés indéfiniment : ils sont posés À LA MAIN
// dans les config des utilisateurs et dans des scripts qu'on ne voit pas. Une
// variable explicite est un ordre — on ne migre RIEN quand il y en a une, on obéit.
func AjeanHome() string { func AjeanHome() string {
if h := os.Getenv("AJEAN_HOME"); h != "" { if h := os.Getenv("AJEAN_HOME"); h != "" {
return h return h
} }
if h := os.Getenv("JEAN_HOME"); h != "" {
return h
}
if h := readEtcDefault(); h != "" { if h := readEtcDefault(); h != "" {
return h return h
} }
return migratedDefaultHome() return defaultAjeanHome()
} }
// readEtcDefault parses /etc/default/ajean (puis /etc/default/jean, hérité) for // readEtcDefault parses /etc/default/ajean for AJEAN_HOME=<path>. Quiet on errors.
// AJEAN_HOME=<path> ou JEAN_HOME=<path>. Quiet on errors.
func readEtcDefault() string { func readEtcDefault() string {
for _, path := range []string{"/etc/default/ajean", "/etc/default/jean"} { b, err := os.ReadFile("/etc/default/ajean")
b, err := os.ReadFile(path) if err != nil {
if err != nil { return ""
}
for _, line := range strings.Split(string(b), "\n") {
s := strings.TrimSpace(line)
if s == "" || strings.HasPrefix(s, "#") {
continue continue
} }
for _, line := range strings.Split(string(b), "\n") { s = strings.TrimPrefix(s, "export ")
s := strings.TrimSpace(line) if k, v, ok := strings.Cut(s, "="); ok && strings.TrimSpace(k) == "AJEAN_HOME" {
if s == "" || strings.HasPrefix(s, "#") { return strings.Trim(strings.TrimSpace(v), "\"'")
continue
}
s = strings.TrimPrefix(s, "export ")
if eq := strings.IndexByte(s, '='); eq > 0 {
k := strings.TrimSpace(s[:eq])
v := strings.Trim(strings.TrimSpace(s[eq+1:]), "\"'")
if k == "AJEAN_HOME" || k == "JEAN_HOME" {
return v
}
}
} }
} }
return "" return ""
} }
func confPath() string { return filepath.Join(AjeanHome(), "config.env") } // Arborescence de $AJEAN_HOME. Elle tient en six dossiers, et rien d'autre :
func presetsDir() string { return filepath.Join(AjeanHome(), "configs") } // tout le reste (config, préférences, conversation, clés, drapeaux) vit dans la
func skillsDir() string { return filepath.Join(AjeanHome(), "SKILLS") } // base ajean.db — voir store.go.
func memoryDir() string { return filepath.Join(AjeanHome(), "MEMORY") } func backendsDir() string { return filepath.Join(AjeanHome(), "backends") }
func agentFlag() string { return filepath.Join(AjeanHome(), ".agent_enabled") } func binDir() string { return filepath.Join(AjeanHome(), "bin") }
func internetFlag() string { return filepath.Join(AjeanHome(), ".internet_enabled") } func presetsDir() string { return filepath.Join(AjeanHome(), "presets") }
func mcpConfigPath() string { return filepath.Join(AjeanHome(), "mcp.json") } func memoryDir() string { return filepath.Join(AjeanHome(), "memory") }
func modelsDir() string { return filepath.Join(AjeanHome(), "models") }
func workspaceDir() string { return filepath.Join(AjeanHome(), "workspace") }
// Anciens drapeaux séparés, conservés pour la migration vers le mode agent unifié. // serviceName est le nom de l'unité qui exécute llama-server.
func legacySkillsFlag() string { return filepath.Join(skillsDir(), ".enabled") }
func legacyToolsFlag() string { return filepath.Join(AjeanHome(), ".tools_enabled") }
func apiKeyPath() string { return filepath.Join(AjeanHome(), ".api_key") }
func crawlKeyPath() string { return filepath.Join(AjeanHome(), ".crawl4ai_key") }
// serviceName est le nom de l'unité qui exécute llama-server. AJEAN_SERVICE et
// JEAN_SERVICE sont tous deux honorés : le second est posé à la main sur des
// machines qu'on ne voit pas, le renommage ne doit pas les casser.
func serviceName() string { func serviceName() string {
if n := os.Getenv("AJEAN_SERVICE"); n != "" { if n := os.Getenv("AJEAN_SERVICE"); n != "" {
return n return n
} }
if n := os.Getenv("JEAN_SERVICE"); n != "" { return "ajean"
return n
}
// Pas de nom imposé : on prend l'unité réellement installée. Un serveur
// simplement mis à jour tourne encore sous « jean.service » — voir
// sys_unitname.go.
return resolveUnitName("ajean", legacyServiceName())
} }
// legacyServiceName est le nom d'avant le renommage.
func legacyServiceName() string { return "jean" }
// Color helpers (ANSI). Disabled when stdout is not a TTY. // Color helpers (ANSI). Disabled when stdout is not a TTY.
var colorOn = isTerminal() var colorOn = isTerminal()
+190
View File
@@ -0,0 +1,190 @@
package ajean
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
// store.go — l'unique endroit où AJEAN écrit son état.
//
// Avant, chaque réglage avait son fichier : config.env, webprefs.json,
// conversation.json, .api_key, .link_token, .agent_enabled, model_dirs.json…
// Une douzaine de formats, une douzaine de façons de rater une écriture
// concurrente, et un dossier de données illisible. Tout ça tient désormais dans
// une seule base bbolt — pur Go, un seul fichier, transactionnelle.
//
// Ce qui N'EST PAS en base, et pourquoi : les presets (presets/*.env) et les
// pages de mémoire (memory/*.md) restent des fichiers, parce qu'ils sont faits
// pour être lus, édités et sauvegardés à la main. Les modèles (.gguf) et les
// backends compilés restent des fichiers, évidemment.
// Buckets. Un par nature de donnée : ça garde les itérations bornées et rend le
// contenu de la base lisible au débogage.
const (
bkConfig = "config" // configuration de llama-server (ex-config.env)
bkPrefs = "prefs" // préférences de l'UI web
bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
bkChat = "chat" // conversation partagée
)
var (
dbOnce sync.Once
dbConn *bolt.DB
dbErr error
)
func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") }
// db ouvre la base à la première utilisation et la garde ouverte pour la durée
// du process. bbolt prend un verrou exclusif sur le fichier : le délai d'attente
// évite qu'un second process (l'app lancée deux fois) se bloque indéfiniment.
func db() (*bolt.DB, error) {
dbOnce.Do(func() {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
dbErr = err
return
}
d, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 3 * time.Second})
if err != nil {
dbErr = fmt.Errorf("base %s inaccessible : %w", dbPath(), err)
return
}
err = d.Update(func(tx *bolt.Tx) error {
for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} {
if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil {
return err
}
}
return nil
})
if err != nil {
d.Close()
dbErr = err
return
}
dbConn = d
})
return dbConn, dbErr
}
// getBytes lit une valeur. Une base inaccessible se comporte comme une base
// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile
// face à une erreur d'E/S, et tous ont déjà un défaut.
func getBytes(bucket, key string) []byte {
d, err := db()
if err != nil {
return nil
}
var out []byte
_ = d.View(func(tx *bolt.Tx) error {
if v := tx.Bucket([]byte(bucket)).Get([]byte(key)); v != nil {
out = append([]byte(nil), v...) // la valeur ne survit pas à la transaction
}
return nil
})
return out
}
// putBytes écrit une valeur. Une valeur nil supprime la clé.
func putBytes(bucket, key string, val []byte) error {
d, err := db()
if err != nil {
return err
}
return d.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucket))
if val == nil {
return b.Delete([]byte(key))
}
return b.Put([]byte(key), val)
})
}
func getStr(bucket, key string) string { return string(getBytes(bucket, key)) }
// putStr écrit une chaîne ; une chaîne vide supprime la clé, pour que « absent »
// et « vide » ne soient jamais deux états distincts à distinguer.
func putStr(bucket, key, val string) error {
if val == "" {
return putBytes(bucket, key, nil)
}
return putBytes(bucket, key, []byte(val))
}
func getBool(bucket, key string) bool { return getStr(bucket, key) == "1" }
func putBool(bucket, key string, on bool) error {
if !on {
return putBytes(bucket, key, nil)
}
return putStr(bucket, key, "1")
}
// getJSON décode une valeur JSON dans dst. Renvoie false si la clé est absente
// ou illisible — dans les deux cas l'appelant garde son zéro.
func getJSON(bucket, key string, dst any) bool {
b := getBytes(bucket, key)
if len(b) == 0 {
return false
}
return json.Unmarshal(b, dst) == nil
}
func putJSON(bucket, key string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return putBytes(bucket, key, b)
}
// allKV renvoie tout le contenu d'un bucket. Utilisé par la configuration, dont
// les clés ne sont pas connues à l'avance (EXTRA_ARGS et consorts).
func allKV(bucket string) map[string]string {
m := map[string]string{}
d, err := db()
if err != nil {
return m
}
_ = d.View(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(bucket)).ForEach(func(k, v []byte) error {
m[string(k)] = string(v)
return nil
})
})
return m
}
// replaceKV remplace tout le contenu d'un bucket en une seule transaction.
// C'est ce qu'exige l'application d'un preset : à aucun instant la config ne
// doit être un mélange de l'ancienne et de la nouvelle.
func replaceKV(bucket string, m map[string]string) error {
d, err := db()
if err != nil {
return err
}
return d.Update(func(tx *bolt.Tx) error {
if err := tx.DeleteBucket([]byte(bucket)); err != nil {
return err
}
b, err := tx.CreateBucket([]byte(bucket))
if err != nil {
return err
}
for k, v := range m {
if v == "" {
continue
}
if err := b.Put([]byte(k), []byte(v)); err != nil {
return err
}
}
return nil
})
}
+8 -8
View File
@@ -7,10 +7,10 @@ package ajean
// Sur macOS il n'y a pas d'équivalent d'AttachConsole : un binaire lancé depuis // Sur macOS il n'y a pas d'équivalent d'AttachConsole : un binaire lancé depuis
// le Finder hérite quand même de descripteurs stdout/stderr (vers les logs // le Finder hérite quand même de descripteurs stdout/stderr (vers les logs
// système). Le signal fiable, c'est l'emplacement de l'exécutable : la release // système). Le signal fiable, c'est l'emplacement de l'exécutable : la release
// publie un bundle Jean.app dont le binaire vit dans // publie un bundle AJEAN.app dont le binaire vit dans
// Jean.app/Contents/MacOS/jean. Si on tourne depuis là, c'est un double-clic // AJEAN.app/Contents/MacOS/ajean. Si on tourne depuis là, c'est un double-clic
// dans le Finder → expérience « application » (UI web + navigateur), exactement // dans le Finder → expérience « application » (UI web + navigateur), exactement
// comme le double-clic sur jean.exe sous Windows. // comme le double-clic sur ajean.exe sous Windows.
// //
// Le même binaire copié dans /usr/local/bin garde évidemment le comportement CLI. // Le même binaire copié dans /usr/local/bin garde évidemment le comportement CLI.
@@ -28,7 +28,7 @@ func setupConsole() bool {
if p, err := filepath.EvalSymlinks(exe); err == nil { if p, err := filepath.EvalSymlinks(exe); err == nil {
exe = p exe = p
} }
// .../Jean.app/Contents/MacOS/jean // .../AJEAN.app/Contents/MacOS/ajean
if strings.HasSuffix(filepath.Dir(exe), "/Contents/MacOS") { if strings.HasSuffix(filepath.Dir(exe), "/Contents/MacOS") {
fixFinderPath() fixFinderPath()
return false return false
@@ -36,7 +36,7 @@ func setupConsole() bool {
return true return true
} }
// appWarning signale une situation de lancement qui casse Jean de façon peu // appWarning signale une situation de lancement qui casse AJEAN de façon peu
// évidente. Aujourd'hui : l'App Translocation de Gatekeeper. Une app non signée // évidente. Aujourd'hui : l'App Translocation de Gatekeeper. Une app non signée
// ouverte depuis Téléchargements est exécutée depuis une copie en LECTURE SEULE // ouverte depuis Téléchargements est exécutée depuis une copie en LECTURE SEULE
// sous /private/var/folders/…/AppTranslocation/<uuid>/, à un chemin différent à // sous /private/var/folders/…/AppTranslocation/<uuid>/, à un chemin différent à
@@ -44,7 +44,7 @@ func setupConsole() bool {
// dessus (la seconde trouve le port 8090 occupé et se contente d'ouvrir l'UI de // dessus (la seconde trouve le port 8090 occupé et se contente d'ouvrir l'UI de
// la première, souvent une version périmée), des process fantômes issus de // la première, souvent une version périmée), des process fantômes issus de
// copies précédentes, et une mise à jour en place impossible. // copies précédentes, et une mise à jour en place impossible.
// Le remède est côté utilisateur : déplacer Jean.app dans /Applications. // Le remède est côté utilisateur : déplacer AJEAN.app dans /Applications.
func appWarning() string { func appWarning() string {
exe, err := os.Executable() exe, err := os.Executable()
if err != nil { if err != nil {
@@ -54,13 +54,13 @@ func appWarning() string {
return "" return ""
} }
return "AJEAN tourne depuis une copie temporaire en lecture seule (App Translocation de macOS). " + return "AJEAN tourne depuis une copie temporaire en lecture seule (App Translocation de macOS). " +
"Fermez l'app, déplacez Jean.app dans le dossier Applications, puis rouvrez-la — " + "Fermez l'app, déplacez AJEAN.app dans le dossier Applications, puis rouvrez-la — " +
"sinon chaque lancement crée une instance séparée et les mises à jour du moteur échouent." "sinon chaque lancement crée une instance séparée et les mises à jour du moteur échouent."
} }
// fixFinderPath complète le PATH famélique hérité du Finder. Une app lancée par // fixFinderPath complète le PATH famélique hérité du Finder. Une app lancée par
// LaunchServices reçoit /usr/bin:/bin:/usr/sbin:/sbin — pas les dossiers de // LaunchServices reçoit /usr/bin:/bin:/usr/sbin:/sbin — pas les dossiers de
// Homebrew : sans ça, Jean ne voit ni `brew`, ni `cmake`, ni `git`, et la // Homebrew : sans ça, AJEAN ne voit ni `brew`, ni `cmake`, ni `git`, et la
// compilation d'un backend échoue alors que les outils sont bien installés. // compilation d'un backend échoue alors que les outils sont bien installés.
func fixFinderPath() { func fixFinderPath() {
extra := []string{ extra := []string{
+2 -2
View File
@@ -5,8 +5,8 @@ package ajean
// setupConsole : sous Linux, le process a toujours une vraie console/tty // setupConsole : sous Linux, le process a toujours une vraie console/tty
// standard (stdout hérité). Rien à faire → on signale simplement « on a une // standard (stdout hérité). Rien à faire → on signale simplement « on a une
// console ». Les cas « lancé par un clic » sont traités par // console ». Les cas « lancé par un clic » sont traités par
// sys_console_windows.go (double-clic sur jean.exe) et sys_console_darwin.go // sys_console_windows.go (double-clic sur ajean.exe) et sys_console_darwin.go
// (ouverture de Jean.app depuis le Finder). // (ouverture de AJEAN.app depuis le Finder).
func setupConsole() bool { return true } func setupConsole() bool { return true }
// appWarning : rien à signaler hors macOS (l'App Translocation est spécifique à // appWarning : rien à signaler hors macOS (l'App Translocation est spécifique à
+26
View File
@@ -0,0 +1,26 @@
//go:build !windows
package ajean
// sys_disk_other.go — espace libre d'un système de fichiers via statfs(2).
// Bavail (et pas Bfree) : les blocs réservés à root ne sont pas utilisables pour
// poser un .gguf de 40 Go.
import "syscall"
// diskFreeAt renvoie les octets libres du système de fichiers contenant dir, ou
// -1 si la mesure échoue. dir doit exister (l'appelant remonte les parents au
// besoin).
func diskFreeAt(dir string) int64 {
var st syscall.Statfs_t
if err := syscall.Statfs(dir, &st); err != nil {
return -1
}
// Les types de Bavail/Bsize diffèrent selon l'OS (int64 sous Linux, uint64
// et uint32 sous macOS) : on passe par uint64 avant de multiplier.
free := uint64(st.Bavail) * uint64(st.Bsize)
if free > 1<<62 {
return -1
}
return int64(free)
}
+32
View File
@@ -0,0 +1,32 @@
package ajean
// sys_disk_windows.go — espace libre d'un volume via GetDiskFreeSpaceExW. On
// veut l'espace réellement disponible pour l'utilisateur courant (quotas
// compris), donc le premier paramètre de sortie, pas le total du volume.
import (
"syscall"
"unsafe"
)
var procGetDiskFreeSpaceExW = syscall.NewLazyDLL("kernel32.dll").NewProc("GetDiskFreeSpaceExW")
// diskFreeAt renvoie les octets libres du volume contenant dir, ou -1 si la
// mesure échoue. dir doit exister (l'appelant remonte les parents au besoin).
func diskFreeAt(dir string) int64 {
p, err := syscall.UTF16PtrFromString(dir)
if err != nil {
return -1
}
var free, total, totalFree uint64
r, _, _ := procGetDiskFreeSpaceExW.Call(
uintptr(unsafe.Pointer(p)),
uintptr(unsafe.Pointer(&free)),
uintptr(unsafe.Pointer(&total)),
uintptr(unsafe.Pointer(&totalFree)),
)
if r == 0 || free > 1<<62 {
return -1
}
return int64(free)
}
-15
View File
@@ -1,15 +0,0 @@
//go:build !windows
package ajean
// Pendant non-Windows de sys_elevate_windows.go.
//
// L'élévation ponctuelle n'a de sens que sous Windows : ailleurs, l'installation
// se fait déjà sous `sudo ajean install`, qui a les droits nécessaires et migre
// l'agencement complet (voir sys_layout.go). Il n'y a donc rien à relancer.
const migrateHomeArg = "migrate-home-elevated"
func cmdMigrateHomeElevated() error { return nil }
func elevateForHomeMigration() bool { return false }
-79
View File
@@ -1,79 +0,0 @@
//go:build windows
package ajean
import (
"fmt"
"os"
"os/exec"
"strings"
)
// Élévation ponctuelle pour terminer la migration du dossier de données.
//
// Renommer %ProgramData%\jean exige d'écrire dans %ProgramData%, ce qu'un compte
// standard ne peut pas faire. Sans élévation, ces postes gardent l'ancien nom
// pour toujours — ce qui est sans conséquence, mais laisse le renommage à
// moitié fait.
//
// La fenêtre UAC n'apparaît QUE si elle sert à quelque chose :
//
// - migration déjà faite, inutile, ou chemin imposé par l'utilisateur → rien ;
// - migration en attente, dans un parcours d'installation → on demande.
//
// On ne cherche PAS à deviner la cause depuis le code d'erreur : Windows renvoie
// ERROR_ACCESS_DENIED aussi bien pour un manque de droits que pour un dossier
// verrouillé par un programme en cours. Impossible de les séparer — vérifié.
//
// C'est donc le LIEU D'APPEL qui fait la sélection, et lui est fiable : on
// n'élève que depuis `install` et depuis l'installeur de mise à jour, tous deux
// après l'arrêt des instances en cours. Le cas « verrouillé » y est déjà écarté ;
// ce qui reste est un problème de droits, que l'élévation résout.
// migrateHomeArg est la sous-commande interne exécutée par le process élevé.
// Absente de l'aide : ce n'est pas une commande destinée aux utilisateurs, mais
// le travail précis qu'on délègue à un process ayant les droits.
const migrateHomeArg = "migrate-home-elevated"
// cmdMigrateHomeElevated est le point d'entrée du process élevé : il migre, dit
// ce qu'il a fait, et rend la main. Rien d'autre — moins il en fait sous des
// droits administrateur, mieux c'est.
func cmdMigrateHomeElevated() error {
if retryHomeMigration() {
fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome())
return nil
}
if migrationDeferred != "" {
return fmt.Errorf("migration toujours impossible : %s", migrationDeferred)
}
return nil
}
// elevateForHomeMigration relance AJEAN en administrateur le temps de la seule
// migration, puis revérifie. Renvoie true si le dossier a effectivement changé.
//
// Silencieux et sans effet quand l'élévation ne servirait à rien, y compris si
// l'utilisateur refuse la fenêtre UAC : son refus est une réponse valable, la
// machine continue de fonctionner sur l'ancien chemin.
func elevateForHomeMigration() bool {
if migrationDeferred == "" {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
// Start-Process -Verb RunAs déclenche l'invite UAC ; -Wait pour que la suite
// de l'installation reparte sur le bon dossier, pas sur l'ancien.
ps := fmt.Sprintf(
`try { Start-Process -FilePath %s -ArgumentList '%s' -Verb RunAs -Wait -WindowStyle Hidden; 'ok' } catch { 'refus' }`,
psQuote(exe), migrateHomeArg)
out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-Command", ps)).CombinedOutput()
if err != nil || !strings.Contains(string(out), "ok") {
return false // refus de l'utilisateur, ou élévation indisponible
}
// Le process élevé a fait le travail dans SON espace mémoire : on relit le
// disque pour savoir ce qu'il en est réellement ici.
migrationDeferred = ""
return retryHomeMigration()
}
-23
View File
@@ -1,23 +0,0 @@
package ajean
import (
"os"
"strings"
)
// envAny lit la première variable d'environnement renseignée parmi names.
//
// Sert au renommage jean → AJEAN : chaque réglage s'appelle désormais AJEAN_*,
// mais l'ancien nom JEAN_* reste lu en second. Ces variables sont posées à la
// main — dans un shell, un fichier d'unité, un docker-compose, un script de
// déploiement — et rien ne nous permet de les retrouver pour les réécrire. Les
// cesser de les lire, c'est casser silencieusement des installations qui
// marchaient, avec un réglage qui redevient sa valeur par défaut sans le dire.
func envAny(names ...string) string {
for _, n := range names {
if v := strings.TrimSpace(os.Getenv(n)); v != "" {
return v
}
}
return ""
}
+7 -30
View File
@@ -19,8 +19,8 @@ import (
// Premier lancement sous Windows. // Premier lancement sous Windows.
// //
// Avant : double-cliquer jean-windows-amd64.exe déclenchait une installation // Avant : double-cliquer ajean-windows-amd64.exe déclenchait une installation
// SILENCIEUSE (copie du binaire dans %ProgramData%\jean\bin, ajout au PATH) dont // SILENCIEUSE (copie du binaire dans %ProgramData%\ajean\bin, ajout au PATH) dont
// l'utilisateur ne voyait rien — cmdApp appelait cmdInstall sans console pour en // l'utilisateur ne voyait rien — cmdApp appelait cmdInstall sans console pour en
// afficher la sortie. D'où la confusion légitime : est-ce que ça installe ou est-ce // afficher la sortie. D'où la confusion légitime : est-ce que ça installe ou est-ce
// que ça lance ? Et surtout, deux copies du binaire coexistaient (celle du Bureau, // que ça lance ? Et surtout, deux copies du binaire coexistaient (celle du Bureau,
@@ -195,12 +195,6 @@ func runAsInstaller(target string) bool {
return launch(target) // l'instance en cours reprend la main (port occupé) return launch(target) // l'instance en cours reprend la main (port occupé)
} }
stopProcesses(running) stopProcesses(running)
// Les instances sont arrêtées : c'est le moment où le renommage peut
// aboutir. S'il bute encore, et seulement sur un refus de droits, on
// demande l'élévation — l'utilisateur est déjà dans un parcours
// d'installation, une fenêtre UAC n'y est pas une surprise.
elevateForHomeMigration()
target = migrateThenResolveTarget(target)
if err := replaceInstalled(target); err != nil { if err := replaceInstalled(target); err != nil {
messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nAJEAN va redémarrer dans sa version actuelle.", messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nAJEAN va redémarrer dans sa version actuelle.",
"AJEAN", mbIconInfo) "AJEAN", mbIconInfo)
@@ -211,8 +205,6 @@ func runAsInstaller(target string) bool {
case cmp > 0: case cmp > 0:
// Mise à jour, application arrêtée : rien à décider, on remplace et on // Mise à jour, application arrêtée : rien à décider, on remplace et on
// démarre. C'est le cas courant et il doit rester muet. // démarre. C'est le cas courant et il doit rester muet.
elevateForHomeMigration()
target = migrateThenResolveTarget(target)
_ = replaceInstalled(target) _ = replaceInstalled(target)
ensureShortcuts(target) ensureShortcuts(target)
return launch(target) return launch(target)
@@ -234,21 +226,6 @@ func verLabel(v string) string {
// replaceInstalled écrase le binaire installé par celui qu'on exécute. Le // replaceInstalled écrase le binaire installé par celui qu'on exécute. Le
// renommage préalable en .old permet de remplacer un exécutable encore ouvert // renommage préalable en .old permet de remplacer un exécutable encore ouvert
// (même mécanique que replaceBinary pour `ajean update`). // (même mécanique que replaceBinary pour `ajean update`).
// migrateThenResolveTarget tente la migration du dossier de données maintenant
// qu'aucune instance ne tient plus de handle, puis renvoie le chemin du binaire
// installé DANS le dossier résultant.
//
// Recalculer le chemin est indispensable et pas cosmétique : `target` a été
// calculé avant la migration, il désigne donc encore …\jean\bin\ajean.exe. Sans
// ce recalcul, on réinstallerait le binaire dans l'ancien dossier qu'on vient
// tout juste de déplacer, en recréant au passage une arborescence fantôme.
func migrateThenResolveTarget(target string) string {
if !retryHomeMigration() {
return target
}
return installedExePath()
}
func replaceInstalled(target string) error { func replaceInstalled(target string) error {
removeOldBinaries(target) removeOldBinaries(target)
old, err := renameAside(target) // nom unique, cf. renameAside old, err := renameAside(target) // nom unique, cf. renameAside
@@ -264,7 +241,7 @@ func replaceInstalled(target string) error {
} }
// runningPIDs liste les processus qui exécutent exactement ce fichier. On compare // runningPIDs liste les processus qui exécutent exactement ce fichier. On compare
// le CHEMIN, pas le nom : tuer par nom d'image (« jean.exe ») emporterait aussi // le CHEMIN, pas le nom : tuer par nom d'image (« ajean.exe ») emporterait aussi
// le processus courant et toute autre copie sans rapport. // le processus courant et toute autre copie sans rapport.
// //
// La comparaison se fait ICI, sur des chemins canonisés, et non dans le script // La comparaison se fait ICI, sur des chemins canonisés, et non dans le script
@@ -399,16 +376,16 @@ type vsFixedFileInfo struct {
// Extrait de cmdInstall pour être réutilisable au premier lancement, sans // Extrait de cmdInstall pour être réutilisable au premier lancement, sans
// embarquer l'installation du binaire ni les messages de console. // embarquer l'installation du binaire ni les messages de console.
func provisionDataDir() error { func provisionDataDir() error {
jeanHome := AjeanHome() ajeanHome := AjeanHome()
// Plus de dossier SKILLS : les skills ont ete fondus dans la memoire. // Plus de dossier SKILLS : les skills ont ete fondus dans la memoire.
// On ne le CREE plus, mais on continue de le LIRE une fois au demarrage // On ne le CREE plus, mais on continue de le LIRE une fois au demarrage
// pour reprendre ceux d'une ancienne installation (migrateSkillsToMemory). // pour reprendre ceux d'une ancienne installation (migrateSkillsToMemory).
for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs")} { for _, d := range []string{ajeanHome, filepath.Join(ajeanHome, "configs")} {
if err := os.MkdirAll(d, 0o755); err != nil { if err := os.MkdirAll(d, 0o755); err != nil {
return err return err
} }
} }
conf := filepath.Join(jeanHome, "config.env") conf := filepath.Join(ajeanHome, "config.env")
if _, err := os.Stat(conf); os.IsNotExist(err) { if _, err := os.Stat(conf); os.IsNotExist(err) {
return os.WriteFile(conf, []byte(configTemplate), 0o644) return os.WriteFile(conf, []byte(configTemplate), 0o644)
} }
@@ -436,7 +413,7 @@ foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) {
if (-not (Test-Path $d)) { continue } if (-not (Test-Path $d)) { continue }
$lnk=Join-Path $d 'AJEAN.lnk' $lnk=Join-Path $d 'AJEAN.lnk'
# Un raccourci existant est REPOINTÉ s'il vise autre chose que le binaire # Un raccourci existant est REPOINTÉ s'il vise autre chose que le binaire
# canonique — typiquement l'ancien « jean.exe », qui n'est plus qu'un alias. # canonique — typiquement l'ancien « ajean.exe », qui n'est plus qu'un alias.
# Le laisser en l'état condamnait l'utilisateur à relancer indéfiniment une # Le laisser en l'état condamnait l'utilisateur à relancer indéfiniment une
# version périmée par son propre raccourci. # version périmée par son propre raccourci.
if (Test-Path $lnk) { if (Test-Path $lnk) {
+6 -6
View File
@@ -28,10 +28,10 @@ func TestBinaryVersionReadsResource(t *testing.T) {
// Sur une machine vierge, le dossier bin n'existe pas encore : installSelf doit // Sur une machine vierge, le dossier bin n'existe pas encore : installSelf doit
// le créer. Sans ça, le premier lancement échouait sur « open // le créer. Sans ça, le premier lancement échouait sur « open
// C:\ProgramData\jean\bin\jean.exe: The system cannot find the path specified » // C:\ProgramData\ajean\bin\ajean.exe: The system cannot find the path specified »
// et AJEAN ne s'installait jamais. // et AJEAN ne s'installait jamais.
func TestInstallSelfCreatesBinDir(t *testing.T) { func TestInstallSelfCreatesBinDir(t *testing.T) {
binDir := filepath.Join(t.TempDir(), "jean", "bin") // deux niveaux absents binDir := filepath.Join(t.TempDir(), "ajean", "bin") // deux niveaux absents
dst, err := installSelf(binDir) dst, err := installSelf(binDir)
if err != nil { if err != nil {
t.Fatalf("installSelf sur un dossier absent : %v", err) t.Fatalf("installSelf sur un dossier absent : %v", err)
@@ -45,7 +45,7 @@ func TestInstallSelfCreatesBinDir(t *testing.T) {
// cible existe déjà, et laisser l'ancien de côté sans le confondre avec la cible. // cible existe déjà, et laisser l'ancien de côté sans le confondre avec la cible.
func TestReplaceInstalled(t *testing.T) { func TestReplaceInstalled(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
target := filepath.Join(dir, "jean.exe") target := filepath.Join(dir, "ajean.exe")
if err := os.WriteFile(target, []byte("ancienne version"), 0o755); err != nil { if err := os.WriteFile(target, []byte("ancienne version"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -81,7 +81,7 @@ func TestBinaryVersionWithoutResource(t *testing.T) {
} }
} }
// Reproduit le cas signale en usage : l'alias « jean.exe » est EN COURS // Reproduit le cas signale en usage : l'alias « ajean.exe » est EN COURS
// d'execution au moment de la mise a jour. copyExe seul echouait, l'alias // d'execution au moment de la mise a jour. copyExe seul echouait, l'alias
// restait fige sur une version perimee, et tout raccourci le visant relancait // restait fige sur une version perimee, et tout raccourci le visant relancait
// indefiniment l'ancienne version — qui affichait « une version plus recente // indefiniment l'ancienne version — qui affichait « une version plus recente
@@ -89,7 +89,7 @@ func TestBinaryVersionWithoutResource(t *testing.T) {
func TestReplaceExeSurchargeUnFichierVerrouille(t *testing.T) { func TestReplaceExeSurchargeUnFichierVerrouille(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
src := filepath.Join(dir, "neuf.exe") src := filepath.Join(dir, "neuf.exe")
dst := filepath.Join(dir, "jean.exe") dst := filepath.Join(dir, "ajean.exe")
if err := os.WriteFile(src, []byte("VERSION-NEUVE"), 0o755); err != nil { if err := os.WriteFile(src, []byte("VERSION-NEUVE"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -120,7 +120,7 @@ func TestReplaceExeSurchargeUnFichierVerrouille(t *testing.T) {
// echoue apres le renommage, l'ancien fichier revient a sa place. // echoue apres le renommage, l'ancien fichier revient a sa place.
func TestReplaceExeRestaureSiLaCopieEchoue(t *testing.T) { func TestReplaceExeRestaureSiLaCopieEchoue(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
dst := filepath.Join(dir, "jean.exe") dst := filepath.Join(dir, "ajean.exe")
if err := os.WriteFile(dst, []byte("a-preserver"), 0o755); err != nil { if err := os.WriteFile(dst, []byte("a-preserver"), 0o755); err != nil {
t.Fatal(err) t.Fatal(err)
} }
+14 -14
View File
@@ -14,10 +14,10 @@ import (
// sys_install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent // sys_install_darwin.go — installation macOS via launchd (LaunchDaemon), équivalent
// de sys_install_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : le support macOS // de sys_install_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : le support macOS
// était totalement absent (le code systemd/Linux tournait par erreur et échouait // était totalement absent (le code systemd/Linux tournait par erreur et échouait
// sur « systemctl: command not found » / « /etc/default/jean », cf. issue #4). // sur « systemctl: command not found » / « /etc/default/ajean », cf. issue #4).
// Implémentation prudente ; à valider sur une machine Apple. // Implémentation prudente ; à valider sur une machine Apple.
const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart const configTemplate = `# Configuration AJEAN — édite-moi puis: ajean restart
# Le service launchd lit ce fichier et lance ton binaire llama.cpp. # Le service launchd lit ce fichier et lance ton binaire llama.cpp.
# Chemins # Chemins
@@ -39,14 +39,14 @@ EXTRA_ARGS=""
` `
// launchdPlistTemplate — champs formatés : Label, chemin du binaire, UserName, // launchdPlistTemplate — champs formatés : Label, chemin du binaire, UserName,
// WorkingDirectory, AJEAN_HOME, JEAN_HOME, StandardOutPath, StandardErrorPath. // WorkingDirectory, AJEAN_HOME, AJEAN_HOME, StandardOutPath, StandardErrorPath.
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au // KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au
// boot une fois chargé avec `-w`. // boot une fois chargé avec `-w`.
// //
// Le chemin du binaire est injecté et non codé en dur : il a changé avec le // Le chemin du binaire est injecté et non codé en dur : il a changé avec le
// renommage, et un plist figé sur l'ancien chemin donnerait un daemon qui refuse // renommage, et un plist figé sur l'ancien chemin donnerait un daemon qui refuse
// de démarrer. Les deux variables d'environnement sont posées car le code lit // de démarrer. Les deux variables d'environnement sont posées car le code lit
// encore JEAN_HOME en héritage. // encore AJEAN_HOME en héritage.
const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?> const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
@@ -60,7 +60,7 @@ const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<key>UserName</key><string>%s</string> <key>UserName</key><string>%s</string>
<key>WorkingDirectory</key><string>%s</string> <key>WorkingDirectory</key><string>%s</string>
<key>EnvironmentVariables</key> <key>EnvironmentVariables</key>
<dict><key>AJEAN_HOME</key><string>%s</string><key>JEAN_HOME</key><string>%s</string></dict> <dict><key>AJEAN_HOME</key><string>%s</string><key>AJEAN_HOME</key><string>%s</string></dict>
<key>RunAtLoad</key><true/> <key>RunAtLoad</key><true/>
<key>KeepAlive</key> <key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict> <dict><key>SuccessfulExit</key><false/></dict>
@@ -87,7 +87,7 @@ func cmdInstall(args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
} }
// Migration de l'agencement jean -> ajean AVANT de résoudre les chemins : // Migration de l'agencement ajean -> ajean AVANT de résoudre les chemins :
// `install` est root et délibéré, c'est le bon moment. Ne fait rien si la // `install` est root et délibéré, c'est le bon moment. Ne fait rien si la
// machine est déjà en ajean, ou si l'utilisateur a imposé un chemin. // machine est déjà en ajean, ou si l'utilisateur a imposé un chemin.
if err := migrateLayout(defaultLayoutPlan()); err != nil { if err := migrateLayout(defaultLayoutPlan()); err != nil {
@@ -96,13 +96,13 @@ func cmdInstall(args []string) error {
resetResolvedHome() // le dossier a pu changer à l'instant resetResolvedHome() // le dossier a pu changer à l'instant
ajeanHome := defaultAjeanHome() ajeanHome := defaultAjeanHome()
if v := os.Getenv("JEAN_HOME"); v != "" { if v := os.Getenv("AJEAN_HOME"); v != "" {
ajeanHome = v ajeanHome = v
} }
svc := serviceName() svc := serviceName()
fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser))
fmt.Printf(" JEAN_HOME = %s\n", ajeanHome) fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
fmt.Printf(" service = %s (launchd)\n", svc) fmt.Printf(" service = %s (launchd)\n", svc)
// 1. Répertoires // 1. Répertoires
@@ -154,7 +154,7 @@ func cmdInstall(args []string) error {
fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) fmt.Printf(" %s %s -> %s\n", green("✓"), target, self)
} }
// 3 bis. Alias hérité : `jean` doit rester tapable (voir sys_paths.go). // 3 bis. Alias hérité : `ajean` doit rester tapable (voir sys_paths.go).
if alias := legacyExePath(); alias != target { if alias := legacyExePath(); alias != target {
if rp, e := filepath.EvalSymlinks(alias); e != nil || rp != self { if rp, e := filepath.EvalSymlinks(alias); e != nil || rp != self {
_ = os.Remove(alias) _ = os.Remove(alias)
@@ -168,7 +168,7 @@ func cmdInstall(args []string) error {
// 4. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME. // 4. /etc/default/ajean pour que les invocations CLI résolvent AJEAN_HOME.
// Les deux clés sont écrites : des scripts d'utilisateurs sourcent ce fichier // Les deux clés sont écrites : des scripts d'utilisateurs sourcent ce fichier
// et lisent encore $JEAN_HOME. // et lisent encore $AJEAN_HOME.
_ = os.MkdirAll("/etc/default", 0o755) _ = os.MkdirAll("/etc/default", 0o755)
defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome) defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome)
if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil {
@@ -176,7 +176,7 @@ func cmdInstall(args []string) error {
} }
fmt.Printf(" %s /etc/default/ajean\n", green("✓")) fmt.Printf(" %s /etc/default/ajean\n", green("✓"))
// 5. LaunchDaemon plist (le log va sous JEAN_HOME, accessible au user cible) // 5. LaunchDaemon plist (le log va sous AJEAN_HOME, accessible au user cible)
logPath := filepath.Join(ajeanHome, svc+".log") logPath := filepath.Join(ajeanHome, svc+".log")
plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), target, targetUser, ajeanHome, ajeanHome, ajeanHome, logPath, logPath) plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), target, targetUser, ajeanHome, ajeanHome, ajeanHome, logPath, logPath)
plistPath := launchdPlistPath(svc) plistPath := launchdPlistPath(svc)
@@ -185,7 +185,7 @@ func cmdInstall(args []string) error {
} }
fmt.Printf(" %s %s\n", green("✓"), plistPath) fmt.Printf(" %s %s\n", green("✓"), plistPath)
// 6. chown JEAN_HOME au user cible // 6. chown AJEAN_HOME au user cible
chown(ajeanHome, u) chown(ajeanHome, u)
fmt.Println() fmt.Println()
@@ -214,8 +214,8 @@ func cmdUninstall(args []string) error {
fmt.Printf(" %s %s\n", green("✓"), p) fmt.Printf(" %s %s\n", green("✓"), p)
} }
} }
// On ne supprime jamais JEAN_HOME automatiquement (modèles, configs). // On ne supprime jamais AJEAN_HOME automatiquement (modèles, configs).
fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)")) fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)"))
fmt.Println(green("[ok]") + " désinstallé") fmt.Println(green("[ok]") + " désinstallé")
return nil return nil
} }
+14 -14
View File
@@ -11,7 +11,7 @@ import (
"strings" "strings"
) )
const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart const configTemplate = `# Configuration AJEAN — édite-moi puis: ajean restart
# Le service systemd lit ce fichier et lance ton binaire llama.cpp. # Le service systemd lit ce fichier et lance ton binaire llama.cpp.
# Chemins # Chemins
@@ -32,12 +32,12 @@ NGL="999"
EXTRA_ARGS="" EXTRA_ARGS=""
` `
const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by jean). const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by ajean).
%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
` `
const serviceUnitTemplate = `[Unit] const serviceUnitTemplate = `[Unit]
Description=JEAN llama.cpp server Description=AJEAN llama.cpp server
After=network.target After=network.target
[Service] [Service]
@@ -76,7 +76,7 @@ func cmdInstall(args []string) error {
if err != nil { if err != nil {
return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
} }
// Migration de l'agencement jean -> ajean AVANT de résoudre les chemins : // Migration de l'agencement ajean -> ajean AVANT de résoudre les chemins :
// `install` est root et délibéré, c'est le bon moment. Ne fait rien si la // `install` est root et délibéré, c'est le bon moment. Ne fait rien si la
// machine est déjà en ajean, ou si l'utilisateur a imposé un chemin. // machine est déjà en ajean, ou si l'utilisateur a imposé un chemin.
if err := migrateLayout(defaultLayoutPlan()); err != nil { if err := migrateLayout(defaultLayoutPlan()); err != nil {
@@ -85,13 +85,13 @@ func cmdInstall(args []string) error {
resetResolvedHome() // le dossier a pu changer à l'instant resetResolvedHome() // le dossier a pu changer à l'instant
ajeanHome := defaultAjeanHome() ajeanHome := defaultAjeanHome()
if v := os.Getenv("JEAN_HOME"); v != "" { if v := os.Getenv("AJEAN_HOME"); v != "" {
ajeanHome = v ajeanHome = v
} }
svc := serviceName() svc := serviceName()
fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser))
fmt.Printf(" JEAN_HOME = %s\n", ajeanHome) fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
fmt.Printf(" service = %s\n", svc) fmt.Printf(" service = %s\n", svc)
// 1. Create directories // 1. Create directories
@@ -114,7 +114,7 @@ func cmdInstall(args []string) error {
fmt.Printf(" %s écrit %s\n", green("✓"), conf) fmt.Printf(" %s écrit %s\n", green("✓"), conf)
} }
// 3. Symlink current binary to /usr/local/bin/jean // 3. Symlink current binary to /usr/local/bin/ajean
self, err := os.Executable() self, err := os.Executable()
if err != nil { if err != nil {
return err return err
@@ -124,9 +124,9 @@ func cmdInstall(args []string) error {
} }
target := installedExePath() target := installedExePath()
// Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a // Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible (l'utilisateur a
// posé le binaire dans /usr/local/bin/jean puis lancé `sudo ajean install`), // posé le binaire dans /usr/local/bin/ajean puis lancé `sudo ajean install`),
// ne surtout pas Remove+Symlink sur soi-même — ça effacerait le vrai binaire // ne surtout pas Remove+Symlink sur soi-même — ça effacerait le vrai binaire
// et créerait /usr/local/bin/jean -> /usr/local/bin/jean (« Too many levels // et créerait /usr/local/bin/ajean -> /usr/local/bin/ajean (« Too many levels
// of symbolic links »). On compare les chemins réels (résolus). // of symbolic links »). On compare les chemins réels (résolus).
alreadyInPlace := false alreadyInPlace := false
if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self { if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self {
@@ -147,7 +147,7 @@ func cmdInstall(args []string) error {
// 3 bis. Alias hérité : `ajean` doit rester tapable. Des utilisateurs ont des // 3 bis. Alias hérité : `ajean` doit rester tapable. Des utilisateurs ont des
// alias shell, des cron, des scripts de déploiement et des tutos qui appellent // alias shell, des cron, des scripts de déploiement et des tutos qui appellent
// « jean » — on ne contrôle rien de tout ça, et un binaire renommé qui casse // « ajean » — on ne contrôle rien de tout ça, et un binaire renommé qui casse
// la commande d'hier est une régression pour eux. Le lien pointe sur le // la commande d'hier est une régression pour eux. Le lien pointe sur le
// nouveau nom, donc il suit les mises à jour sans entretien. // nouveau nom, donc il suit les mises à jour sans entretien.
if alias := legacyExePath(); alias != target { if alias := legacyExePath(); alias != target {
@@ -163,7 +163,7 @@ func cmdInstall(args []string) error {
// 4. Drop /etc/default/ajean so root invocations resolve AJEAN_HOME correctly. // 4. Drop /etc/default/ajean so root invocations resolve AJEAN_HOME correctly.
// On écrit les DEUX clés : des scripts d'utilisateurs sourcent ce fichier et // On écrit les DEUX clés : des scripts d'utilisateurs sourcent ce fichier et
// lisent $JEAN_HOME. L'ancien /etc/default/jean est laissé en place — le // lisent $AJEAN_HOME. L'ancien /etc/default/ajean est laissé en place — le
// supprimer casserait ces mêmes scripts, et AjeanHome() sait déjà le lire. // supprimer casserait ces mêmes scripts, et AjeanHome() sait déjà le lire.
defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome) defaults := fmt.Sprintf("# Generated by ajean install — racine des configs/memoire/presets\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", ajeanHome, ajeanHome)
if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil { if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil {
@@ -187,7 +187,7 @@ func cmdInstall(args []string) error {
} }
fmt.Printf(" %s %s\n", green("✓"), sudoersPath) fmt.Printf(" %s %s\n", green("✓"), sudoersPath)
// 6. chown JEAN_HOME contents to target user // 6. chown AJEAN_HOME contents to target user
chown(ajeanHome, u) chown(ajeanHome, u)
// 7. systemd reload // 7. systemd reload
@@ -222,7 +222,7 @@ func cmdUninstall(args []string) error {
for _, p := range []string{ for _, p := range []string{
"/etc/systemd/system/" + svc + ".service", "/etc/systemd/system/" + svc + ".service",
"/etc/sudoers.d/ajean-" + svc, "/etc/sudoers.d/ajean-" + svc,
"/etc/default/jean", "/etc/default/ajean",
installedExePath(), installedExePath(),
} { } {
if err := os.Remove(p); err == nil { if err := os.Remove(p); err == nil {
@@ -231,7 +231,7 @@ func cmdUninstall(args []string) error {
} }
_ = exec.Command("systemctl", "daemon-reload").Run() _ = exec.Command("systemctl", "daemon-reload").Run()
if !keepData { if !keepData {
fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)")) fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)"))
} }
fmt.Println(green("[ok]") + " désinstallé") fmt.Println(green("[ok]") + " désinstallé")
return nil return nil
+9 -21
View File
@@ -16,7 +16,7 @@ import (
// service itself is managed by the PID-file supervisor in sys_service_windows.go // service itself is managed by the PID-file supervisor in sys_service_windows.go
// (ajean start / stop / status), which needs no admin rights. // (ajean start / stop / status), which needs no admin rights.
const configTemplate = `# Configuration JEAN — édite-moi puis: ajean restart const configTemplate = `# Configuration AJEAN — édite-moi puis: ajean restart
# ajean serve lit ce fichier et lance ton binaire llama.cpp. # ajean serve lit ce fichier et lance ton binaire llama.cpp.
# Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent) # Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent)
@@ -38,18 +38,6 @@ EXTRA_ARGS=""
` `
func cmdInstall(args []string) error { func cmdInstall(args []string) error {
// Lancé en administrateur, `install` est le moment où la migration peut
// enfin aboutir sur un poste où l'utilisateur courant n'a pas les droits sur
// %ProgramData%. On la retente donc AVANT de résoudre le dossier : l'inverse
// laisserait la variable pointer sur l'ancien chemin et installerait à côté.
// elevateForHomeMigration ne demande l'élévation que si elle peut débloquer
// la situation : jamais quand il n'y a rien à migrer, ni quand l'obstacle
// est un fichier verrouillé que des droits administrateur ne libéreraient pas.
if retryHomeMigration() || elevateForHomeMigration() {
fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome())
} else if notice := homeMigrationNotice(); notice != "" {
fmt.Printf("%s %s\n", yellow("[info]"), notice)
}
ajeanHome := AjeanHome() ajeanHome := AjeanHome()
fmt.Printf("Installation (Windows)\n") fmt.Printf("Installation (Windows)\n")
@@ -70,7 +58,7 @@ func cmdInstall(args []string) error {
fmt.Printf(" %s écrit %s\n", green("✓"), conf) fmt.Printf(" %s écrit %s\n", green("✓"), conf)
} }
// 3. Install the binary into JEAN_HOME\bin and put that dir on the user PATH, // 3. Install the binary into AJEAN_HOME\bin and put that dir on the user PATH,
// so `ajean` is callable from any shell (this is the Windows analogue of the // so `ajean` is callable from any shell (this is the Windows analogue of the
// /usr/local/bin symlink the Unix installer creates). // /usr/local/bin symlink the Unix installer creates).
binDir := filepath.Join(ajeanHome, "bin") binDir := filepath.Join(ajeanHome, "bin")
@@ -102,16 +90,16 @@ func cmdInstall(args []string) error {
fmt.Printf(" 2. démarre le service: %s\n", bold("ajean start")) fmt.Printf(" 2. démarre le service: %s\n", bold("ajean start"))
fmt.Printf(" 3. UI web : %s\n", bold("ajean web")) fmt.Printf(" 3. UI web : %s\n", bold("ajean web"))
if onPath { if onPath {
fmt.Printf("\n%s ouvre un NOUVEAU terminal pour que 'jean' soit reconnu (le PATH n'est lu qu'au démarrage du shell).\n", dim("[info]")) fmt.Printf("\n%s ouvre un NOUVEAU terminal pour que 'ajean' soit reconnu (le PATH n'est lu qu'au démarrage du shell).\n", dim("[info]"))
} else { } else {
fmt.Printf("\n%s pour exécuter 'jean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]")) fmt.Printf("\n%s pour exécuter 'ajean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]"))
} }
return nil return nil
} }
// installSelf copies the currently running executable into binDir as ajean.exe // installSelf copies the currently running executable into binDir as ajean.exe
// and returns the destination path. If the running exe already lives there // and returns the destination path. If the running exe already lives there
// (re-install), it's a no-op. Une copie « jean.exe » est posée à côté pour que // (re-install), it's a no-op. Une copie « ajean.exe » est posée à côté pour que
// l'ancienne commande reste tapable (voir installLegacyAlias). // l'ancienne commande reste tapable (voir installLegacyAlias).
// //
// binDir est créé ici, et pas seulement par l'appelant : sur une machine vierge, // binDir est créé ici, et pas seulement par l'appelant : sur une machine vierge,
@@ -142,7 +130,7 @@ func installSelf(binDir string) (string, error) {
return dst, nil return dst, nil
} }
// installLegacyAlias pose une copie « jean.exe » à côté de « ajean.exe ». // installLegacyAlias pose une copie « ajean.exe » à côté de « ajean.exe ».
// //
// Une copie et pas un lien : créer un lien symbolique sous Windows demande des // Une copie et pas un lien : créer un lien symbolique sous Windows demande des
// droits particuliers qu'on n'a pas toujours, et un binaire de quelques dizaines // droits particuliers qu'on n'a pas toujours, et un binaire de quelques dizaines
@@ -151,7 +139,7 @@ func installSelf(binDir string) (string, error) {
// écrits eux-mêmes. Best-effort : un échec ne compromet pas l'installation, // écrits eux-mêmes. Best-effort : un échec ne compromet pas l'installation,
// « ajean » reste disponible. // « ajean » reste disponible.
func installLegacyAlias(binDir, src string) { func installLegacyAlias(binDir, src string) {
alias := filepath.Join(binDir, "jean.exe") alias := filepath.Join(binDir, "ajean.exe")
if strings.EqualFold(src, alias) { if strings.EqualFold(src, alias) {
return return
} }
@@ -162,7 +150,7 @@ func installLegacyAlias(binDir, src string) {
// //
// Windows refuse d'écraser un .exe en cours d'exécution, mais accepte de le // Windows refuse d'écraser un .exe en cours d'exécution, mais accepte de le
// RENOMMER : on décale l'ancien puis on écrit le nouveau à sa place. Sans ça, // RENOMMER : on décale l'ancien puis on écrit le nouveau à sa place. Sans ça,
// l'alias « jean.exe » restait figé sur une version périmée dès qu'il tournait // l'alias « ajean.exe » restait figé sur une version périmée dès qu'il tournait
// au moment de la mise à jour — et comme les raccourcis existants le visent // au moment de la mise à jour — et comme les raccourcis existants le visent
// encore, chaque lancement relançait l'ancienne version, qui constatait qu'une // encore, chaque lancement relançait l'ancienne version, qui constatait qu'une
// plus récente était installée et le disait. À chaque fois. Constaté en usage. // plus récente était installée et le disait. À chaque fois. Constaté en usage.
@@ -264,7 +252,7 @@ func cmdUninstall(args []string) error {
// Stop the background server if it's running. // Stop the background server if it's running.
_ = svcStop(false) _ = svcStop(false)
// Pull JEAN_HOME\bin off the user PATH (best-effort). // Pull AJEAN_HOME\bin off the user PATH (best-effort).
binDir := filepath.Join(AjeanHome(), "bin") binDir := filepath.Join(AjeanHome(), "bin")
if removed, err := removeFromUserPath(binDir); err == nil && removed { if removed, err := removeFromUserPath(binDir); err == nil && removed {
fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir) fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir)
-298
View File
@@ -1,298 +0,0 @@
package ajean
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Migration complète de l'agencement système jean → ajean (Unix).
//
// Renommer le dossier de données ne suffit pas sur une machine installée : le
// chemin est aussi inscrit dans /etc/default/jean et dans l'unité systemd. Les
// trois doivent bouger ensemble, sinon on obtient le pire des cas — des données
// déplacées et un service qui ne redémarre plus.
//
// QUAND. Jamais au démarrage ordinaire : un service qui réécrit des unités
// systemd à chaque boot finirait par en casser une, et sur un parc entier ça
// veut dire des machines qui ne redémarrent plus. Uniquement à deux moments
// délibérés et déjà privilégiés : `ajean install`, et juste après une mise à
// jour réussie — le service est de toute façon sur le point d'être relancé.
//
// CE QU'ON S'AUTORISE À DÉPLACER. Un chemin inscrit à la main est un ordre. On
// ne migre donc QUE si le chemin actuel est exactement l'ancien emplacement par
// défaut, celui que notre propre installeur a écrit. Un utilisateur qui a pointé
// JEAN_HOME vers son gros disque garde son choix, intact et silencieux.
// layoutPlan décrit une migration d'agencement. Les chemins sont des champs
// plutôt que des constantes pour que la logique soit testable sur un faux
// système de fichiers, sans toucher au vrai /etc.
type layoutPlan struct {
Old string // /etc/jean
New string // /etc/ajean
DefaultDir string // /etc/default
UnitDir string // /etc/systemd/system
Units []string // unités à arrêter puis relancer
BackupDir string // où déposer l'archive de sécurité
// stopUnit / startUnit / reloadUnits sont injectables pour les tests.
stopUnit func(string) error
startUnit func(string) error
unitActive func(string) bool
reloadUnits func() error
isRoot func() bool
backup func(layoutPlan) error
}
func defaultLayoutPlan() layoutPlan {
return layoutPlan{
Old: legacyDefaultHome(),
New: defaultAjeanHome(),
DefaultDir: "/etc/default",
UnitDir: "/etc/systemd/system",
Units: []string{serviceName(), linkServiceName()},
BackupDir: "/root",
stopUnit: func(u string) error { return exec.Command("systemctl", "stop", u).Run() },
startUnit: func(u string) error { return exec.Command("systemctl", "start", u).Run() },
unitActive: func(u string) bool {
out, _ := exec.Command("systemctl", "is-active", u).Output()
return strings.TrimSpace(string(out)) == "active"
},
reloadUnits: func() error { return exec.Command("systemctl", "daemon-reload").Run() },
isRoot: func() bool { return os.Geteuid() == 0 },
backup: backupLayout,
}
}
// updateLayoutPlan est la variante utilisée juste après une mise à jour, quand
// on s'exécute DANS le service de lien. Elle n'arrête et ne relance rien.
//
// Deux raisons, et la première est fatale :
//
// 1. Arrêter jean-link depuis jean-link, c'est se tuer soi-même au milieu de la
// migration — dossier déplacé, chemins pas encore réécrits, machine cassée.
// 2. Arrêter le service d'inférence rechargerait un modèle de plusieurs
// dizaines de Go, ce qui n'a rien à faire derrière un bouton « mettre à jour ».
//
// C'est sans danger : sous Linux un rename de dossier fonctionne même avec des
// fichiers ouverts, et un process en cours garde son répertoire de travail par
// son inode. L'unité est réécrite dans la foulée, donc le prochain redémarrage —
// celui de jean-link, immédiat, puis celui du service d'inférence, plus tard —
// repart sur les bons chemins.
func updateLayoutPlan() layoutPlan {
p := defaultLayoutPlan()
p.unitActive = func(string) bool { return false }
return p
}
// pinnedHome lit le chemin impose par /etc/default/ajean ou /etc/default/jean.
// Renvoie ("", "") si aucun des deux ne fixe de valeur.
func (p layoutPlan) pinnedHome() (file, value string) {
for _, name := range []string{"ajean", "jean"} {
f := filepath.Join(p.DefaultDir, name)
b, err := os.ReadFile(f)
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "export "))
if s == "" || strings.HasPrefix(s, "#") {
continue
}
k, v, ok := strings.Cut(s, "=")
if !ok {
continue
}
if k = strings.TrimSpace(k); k == "AJEAN_HOME" || k == "JEAN_HOME" {
return f, strings.Trim(strings.TrimSpace(v), "\"'")
}
}
}
return "", ""
}
// layoutNeedsMigration dit si une migration est possible ET legitime, avec la
// raison quand elle ne l'est pas (affichée telle quelle par `ajean install`).
func (p layoutPlan) layoutNeedsMigration() (bool, string) {
if !isDir(p.Old) {
return false, "" // rien à migrer : machine neuve ou déjà migrée
}
if isDir(p.New) {
return false, fmt.Sprintf("%s et %s existent tous les deux — migration à faire à la main", p.Old, p.New)
}
// Le point délicat : distinguer notre artefact d'un choix de l'utilisateur.
if file, value := p.pinnedHome(); value != "" && value != p.Old {
return false, fmt.Sprintf("%s fixe le dossier à %s — chemin choisi explicitement, laissé intact", file, value)
}
return true, ""
}
// migrateLayout exécute la migration complète. Renvoie nil sans rien faire
// quand il n'y a rien à migrer. En cas d'échec après le déplacement, remet la
// machine dans son état initial et relance ce qui tournait.
func migrateLayout(p layoutPlan) error {
ok, why := p.layoutNeedsMigration()
if !ok {
if why != "" {
fmt.Printf("%s %s\n", dim("[info]"), why)
}
return nil
}
if !p.isRoot() {
fmt.Printf("%s dossier de données encore en %s — relance en root pour le migrer\n", yellow("[info]"), p.Old)
return nil
}
fmt.Printf("%s migration de l'agencement %s → %s\n", cyan("[migration]"), p.Old, p.New)
if err := p.backup(p); err != nil {
return fmt.Errorf("sauvegarde impossible, migration annulée : %w", err)
}
var stopped []string
for _, u := range p.Units {
if p.unitActive(u) {
_ = p.stopUnit(u)
stopped = append(stopped, u)
fmt.Printf(" %s arrêté %s\n", green("✓"), u)
}
}
restart := func() {
for _, u := range stopped {
_ = p.startUnit(u)
}
}
// Déplacement atomique : même volume, donc instantané même avec 200 Go de
// modèles, et jamais un état où les données sont à moitié quelque part.
if err := os.Rename(p.Old, p.New); err != nil {
restart()
return fmt.Errorf("déplacement impossible (%w) — rien n'a été modifié", err)
}
// À partir d'ici, toute erreur doit rendre la machine à son état d'origine.
rollback := func(cause error) error {
_ = os.Rename(p.New, p.Old)
restart()
return fmt.Errorf("%w — état initial restauré", cause)
}
if err := rewriteTree(p.New, p.Old, p.New); err != nil {
return rollback(err)
}
if err := writeDefaults(p); err != nil {
return rollback(err)
}
if err := rewriteUnits(p); err != nil {
return rollback(err)
}
_ = p.reloadUnits()
restart()
fmt.Printf(" %s dossier de données : %s\n", green("✓"), p.New)
return nil
}
// backupLayout archive les petits fichiers (config, presets, mémoire, clés).
// Les .gguf et backends/ sont exclus : des dizaines de Go qui se retéléchargent,
// et qui ne sont de toute façon pas menacés — ils ne font que changer de nom de
// dossier parent.
func backupLayout(p layoutPlan) error {
if _, err := exec.LookPath("tar"); err != nil {
return err
}
if err := os.MkdirAll(p.BackupDir, 0o755); err != nil {
return err
}
dst := filepath.Join(p.BackupDir, "ajean-sauvegarde-"+time.Now().Format("20060102-150405")+".tar.gz")
cmd := exec.Command("tar", "czf", dst, "-C", p.Old,
"--exclude=./backends", "--exclude=*.gguf", "--exclude=*.gguf.part", "--exclude=*.log", ".")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
}
fmt.Printf(" %s sauvegarde %s\n", green("✓"), dst)
return nil
}
// rewriteTree remplace toute mention de oldPath par newPath dans les fichiers
// texte de root. On PARCOURT au lieu de deviner une liste : l'expérience montre
// que les chemins absolus se cachent partout — EXTRA_ARGS d'un preset, copies
// .bak, notes de MEMORY. Les .gguf et backends/ sont sautés (aucun chemin à y
// trouver, et les lire prendrait des heures).
func rewriteTree(root, oldPath, newPath string) error {
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // un fichier illisible ne doit pas faire échouer la migration
}
if info.IsDir() {
if info.Name() == "backends" {
return filepath.SkipDir
}
return nil
}
switch {
case strings.HasSuffix(path, ".gguf"), strings.HasSuffix(path, ".gguf.part"):
return nil
case info.Size() > 4<<20: // au-delà, ce n'est plus de la configuration
return nil
}
b, err := os.ReadFile(path)
if err != nil || !strings.Contains(string(b), oldPath) {
return nil
}
out := strings.ReplaceAll(string(b), oldPath, newPath)
mode := info.Mode().Perm()
if err := os.WriteFile(path, []byte(out), mode); err != nil {
return fmt.Errorf("réécriture de %s : %w", path, err)
}
fmt.Printf(" %s chemins mis à jour : %s\n", green("✓"), strings.TrimPrefix(path, root+string(os.PathSeparator)))
return nil
})
}
// writeDefaults pose /etc/default/ajean et repointe l'ancien fichier au lieu de
// le supprimer : des scripts d'utilisateurs le sourcent pour lire $JEAN_HOME.
func writeDefaults(p layoutPlan) error {
if err := os.MkdirAll(p.DefaultDir, 0o755); err != nil {
return err
}
body := fmt.Sprintf("# Généré par ajean — racine des données\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", p.New, p.New)
if err := os.WriteFile(filepath.Join(p.DefaultDir, "ajean"), []byte(body), 0o644); err != nil {
return err
}
legacy := filepath.Join(p.DefaultDir, "jean")
if b, err := os.ReadFile(legacy); err == nil {
if out := strings.ReplaceAll(string(b), p.Old, p.New); out != string(b) {
if err := os.WriteFile(legacy, []byte(out), 0o644); err != nil {
return err
}
}
}
return nil
}
// rewriteUnits corrige les chemins DANS les unités, sans les renommer. Renommer
// imposerait un disable/enable et une fenêtre où plus rien n'est actif, pour un
// gain invisible : le code sait déjà utiliser l'unité réellement installée.
func rewriteUnits(p layoutPlan) error {
entries, err := filepath.Glob(filepath.Join(p.UnitDir, "*.service"))
if err != nil {
return err
}
for _, unit := range entries {
b, err := os.ReadFile(unit)
if err != nil || !strings.Contains(string(b), p.Old) {
continue
}
if err := os.WriteFile(unit+".avant-ajean", b, 0o644); err != nil {
return err
}
if err := os.WriteFile(unit, []byte(strings.ReplaceAll(string(b), p.Old, p.New)), 0o644); err != nil {
return err
}
fmt.Printf(" %s unité corrigée : %s\n", green("✓"), filepath.Base(unit))
}
return nil
}
-243
View File
@@ -1,243 +0,0 @@
package ajean
import (
"os"
"path/filepath"
"strings"
"testing"
)
// fakeLayout monte un faux /etc : dossier de donnees installe, /etc/default et
// une unite systemd, exactement l'agencement d'une machine ou `jean install` est
// passe. Les commandes systemctl sont remplacees par des mouchards.
type fakeLayout struct {
plan layoutPlan
root string
stopped []string
started []string
active map[string]bool
backedUp bool
}
func newFakeLayout(t *testing.T) *fakeLayout {
t.Helper()
root := t.TempDir()
f := &fakeLayout{
root: root,
active: map[string]bool{"jean": true, "jean-link": true},
}
old := filepath.Join(root, "etc", "jean")
for _, d := range []string{
filepath.Join(old, "configs"),
filepath.Join(old, "MEMORY"),
filepath.Join(old, "backends", "llama.cpp", "build"),
filepath.Join(root, "etc", "default"),
filepath.Join(root, "etc", "systemd", "system"),
} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write := func(p, body string) {
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// Un BIN externe (donc a NE PAS toucher) et un jinja interne (a suivre).
write(filepath.Join(old, "config.env"),
"BIN=\"/home/n/llama.cpp/llama-server\"\nEXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n")
write(filepath.Join(old, "fix.jinja"), "template")
write(filepath.Join(old, "configs", "GROS MODELE (2).env"), "EXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n")
write(filepath.Join(old, "MEMORY", "note.md"), "voir "+old+"/dataset.jsonl\n")
write(filepath.Join(old, ".e2e_key"), "cle-precieuse\n")
// Un faux modele et un gros fichier de build : ni l'un ni l'autre ne doit
// etre reecrit ni meme lu.
write(filepath.Join(old, "modele.gguf"), "binaire")
write(filepath.Join(old, "backends", "llama.cpp", "build", "cache.txt"), old+"/quelque-chose\n")
write(filepath.Join(root, "etc", "default", "jean"), "# Generated by jean install\nJEAN_HOME="+old+"\n")
write(filepath.Join(root, "etc", "systemd", "system", "jean.service"),
"[Service]\nWorkingDirectory="+old+"\nExecStart=/usr/local/bin/jean serve\n")
write(filepath.Join(root, "etc", "systemd", "system", "jean-link.service"),
"[Service]\nExecStart=/usr/local/bin/jean link serve\n")
f.plan = layoutPlan{
Old: old,
New: filepath.Join(root, "etc", "ajean"),
DefaultDir: filepath.Join(root, "etc", "default"),
UnitDir: filepath.Join(root, "etc", "systemd", "system"),
Units: []string{"jean", "jean-link"},
BackupDir: filepath.Join(root, "sauvegardes"),
stopUnit: func(u string) error { f.stopped = append(f.stopped, u); f.active[u] = false; return nil },
startUnit: func(u string) error { f.started = append(f.started, u); f.active[u] = true; return nil },
unitActive: func(u string) bool { return f.active[u] },
reloadUnits: func() error { return nil },
isRoot: func() bool { return true },
// tar refuse un chemin contenant « C: » sous Windows (il y voit un hote
// distant) : on remplace la sauvegarde par un mouchard, elle est verifiee
// pour ce qu'elle est — un prealable bloquant — dans son propre test.
backup: func(layoutPlan) error { f.backedUp = true; return nil },
}
return f
}
func (f *fakeLayout) read(t *testing.T, rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(f.plan.New, rel))
if err != nil {
t.Fatalf("%s illisible: %v", rel, err)
}
return string(b)
}
// Cas nominal complet : donnees deplacees, chemins reecrits partout, /etc/default
// et unite corriges, services arretes puis relances.
func TestMigrateLayoutNominal(t *testing.T) {
f := newFakeLayout(t)
if err := migrateLayout(f.plan); err != nil {
t.Fatalf("migration echouee: %v", err)
}
if isDir(f.plan.Old) {
t.Error("l'ancien dossier existe encore")
}
if !isDir(f.plan.New) {
t.Fatal("le nouveau dossier est absent")
}
// Le jinja interne suit, le BIN externe ne bouge pas.
conf := f.read(t, "config.env")
if !strings.Contains(conf, f.plan.New+"/fix.jinja") {
t.Errorf("le chat-template-file n'a pas suivi:\n%s", conf)
}
if !strings.Contains(conf, "/home/n/llama.cpp/llama-server") {
t.Errorf("le BIN externe a ete modifie a tort:\n%s", conf)
}
// Preset avec espaces/parentheses, et note de MEMORY.
if strings.Contains(f.read(t, filepath.Join("configs", "GROS MODELE (2).env")), f.plan.Old) {
t.Error("preset non reecrit")
}
if strings.Contains(f.read(t, filepath.Join("MEMORY", "note.md")), f.plan.Old) {
t.Error("note MEMORY non reecrite")
}
if f.read(t, ".e2e_key") != "cle-precieuse\n" {
t.Error("la cle E2E a ete alteree")
}
// backends/ doit etre saute : le reecrire ferait perdre des heures pour rien.
if !strings.Contains(f.read(t, filepath.Join("backends", "llama.cpp", "build", "cache.txt")), f.plan.Old) {
t.Error("backends/ a ete parcouru alors qu'il doit etre saute")
}
// /etc/default : nouveau fichier ET ancien repointe (des scripts le sourcent).
newDef, err := os.ReadFile(filepath.Join(f.plan.DefaultDir, "ajean"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(newDef), "AJEAN_HOME="+f.plan.New) || !strings.Contains(string(newDef), "JEAN_HOME="+f.plan.New) {
t.Errorf("/etc/default/ajean incomplet:\n%s", newDef)
}
oldDef, _ := os.ReadFile(filepath.Join(f.plan.DefaultDir, "jean"))
if strings.Contains(string(oldDef), f.plan.Old) {
t.Errorf("/etc/default/jean pointe encore sur l'ancien chemin:\n%s", oldDef)
}
// Unite corrigee, copie de securite conservee, nom inchange.
unit, _ := os.ReadFile(filepath.Join(f.plan.UnitDir, "jean.service"))
if strings.Contains(string(unit), f.plan.Old) {
t.Errorf("unite non corrigee:\n%s", unit)
}
if _, err := os.Stat(filepath.Join(f.plan.UnitDir, "jean.service.avant-ajean")); err != nil {
t.Error("copie de securite de l'unite absente")
}
if !f.backedUp {
t.Error("aucune sauvegarde avant migration")
}
if len(f.stopped) != 2 || len(f.started) != 2 {
t.Errorf("services: arretes=%v relances=%v", f.stopped, f.started)
}
}
// Chemin choisi explicitement par l'utilisateur : on ne migre RIEN.
func TestMigrateLayoutRespectsUserChoice(t *testing.T) {
f := newFakeLayout(t)
custom := filepath.Join(f.root, "mnt", "gros-disque", "jean")
if err := os.WriteFile(filepath.Join(f.plan.DefaultDir, "jean"),
[]byte("JEAN_HOME="+custom+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if ok, why := f.plan.layoutNeedsMigration(); ok {
t.Fatal("une migration a ete jugee legitime malgre un chemin impose")
} else if !strings.Contains(why, custom) {
t.Errorf("raison peu claire: %q", why)
}
if err := migrateLayout(f.plan); err != nil {
t.Fatal(err)
}
if !isDir(f.plan.Old) || isDir(f.plan.New) {
t.Error("le dossier a bouge alors que l'utilisateur avait impose un chemin")
}
if len(f.stopped) != 0 {
t.Error("des services ont ete arretes pour rien")
}
}
// Sans les droits root, on n'entreprend rien et on ne casse rien.
func TestMigrateLayoutNonRoot(t *testing.T) {
f := newFakeLayout(t)
f.plan.isRoot = func() bool { return false }
if err := migrateLayout(f.plan); err != nil {
t.Fatal(err)
}
if !isDir(f.plan.Old) || isDir(f.plan.New) {
t.Error("migration effectuee sans les droits root")
}
}
// Les deux dossiers presents : situation ambigue, on s'abstient.
func TestMigrateLayoutBothPresent(t *testing.T) {
f := newFakeLayout(t)
if err := os.MkdirAll(f.plan.New, 0o755); err != nil {
t.Fatal(err)
}
if ok, why := f.plan.layoutNeedsMigration(); ok || why == "" {
t.Fatalf("attendu un refus explicite, got ok=%v why=%q", ok, why)
}
}
// Deja migre (plus d'ancien dossier) : silencieux, aucun message inquietant.
func TestMigrateLayoutAlreadyDone(t *testing.T) {
f := newFakeLayout(t)
if err := os.Rename(f.plan.Old, f.plan.New); err != nil {
t.Fatal(err)
}
ok, why := f.plan.layoutNeedsMigration()
if ok || why != "" {
t.Fatalf("attendu un silence, got ok=%v why=%q", ok, why)
}
}
// La variante « apres mise a jour » ne doit toucher a AUCUN service : on tourne
// dans jean-link, l'arreter reviendrait a se tuer au milieu de la migration.
func TestUpdateLayoutPlanNeverStopsServices(t *testing.T) {
f := newFakeLayout(t)
p := f.plan
p.unitActive = updateLayoutPlan().unitActive // seule difference testee
if err := migrateLayout(p); err != nil {
t.Fatalf("migration echouee: %v", err)
}
if len(f.stopped) != 0 || len(f.started) != 0 {
t.Fatalf("services touches: arretes=%v relances=%v", f.stopped, f.started)
}
// La migration doit rester complete malgre tout.
if isDir(p.Old) || !isDir(p.New) {
t.Fatal("le dossier n'a pas ete migre")
}
if strings.Contains(f.read(t, "config.env"), p.Old) {
t.Error("chemins non reecrits")
}
unit, _ := os.ReadFile(filepath.Join(p.UnitDir, "jean.service"))
if strings.Contains(string(unit), p.Old) {
t.Error("unite non corrigee : le prochain redemarrage echouerait")
}
}
-274
View File
@@ -1,274 +0,0 @@
package ajean
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
)
// Migration du dossier de données jean → ajean.
//
// C'est le seul endroit du renommage qui peut faire PERDRE des données : le
// dossier contient la config, les clés, la mémoire, les conversations, et
// surtout des .gguf qui pèsent des dizaines de gigaoctets. Trois règles en
// découlent, et elles expliquent tout le code ci-dessous :
//
// 1. On déplace par os.Rename DANS LE DOSSIER PARENT de l'ancien. Un rename
// intra-volume est atomique et instantané — il ne recopie pas les 40 Go, et
// il n'existe aucun instant où les données seraient à moitié quelque part.
// Choisir le parent de l'ancien chemin (et pas le défaut de la plateforme)
// garantit qu'on reste sur le même volume, donc que le rename est bien un
// rename et non un copy+delete déguisé.
//
// 2. Si le rename échoue — service en cours qui tient des handles sous Windows,
// droits insuffisants, volume monté en lecture seule — on CONTINUE SUR
// L'ANCIEN CHEMIN. Une machine qui n'a pas migré marche exactement comme
// avant ; une machine qui a perdu ses modèles, non. La migration est donc
// retentée à chaque démarrage jusqu'à ce qu'elle passe.
//
// 3. Aucune suppression, jamais. Le pire cas est « rien n'a bougé ».
var (
// homeMu protège la résolution du dossier. Un simple sync.Once ne suffisait
// pas : retryHomeMigration le RÉINITIALISE, et il est appelé depuis le
// handler HTTP de mise à jour pendant que le serveur sert d'autres requêtes.
// Réécrire ces variables sans verrou était une course de données.
homeMu sync.RWMutex
homeResolved bool
homePath string
// migrationDeferred retient pourquoi le dossier n'a pas pu être renommé,
// pour que seules les commandes qui parlent d'emplacements en fassent état.
migrationDeferred string
)
// homeMigrationNotice renvoie le message à montrer quand la migration attend
// encore, ou "" s'il n'y a rien à signaler. Renvoie aussi le conseil qui va
// avec : sous Windows, l'obstacle est presque toujours un manque de droits sur
// C:\ProgramData, et la réponse tient en une phrase.
func homeMigrationNotice() string {
if migrationDeferred == "" {
return ""
}
msg := migrationDeferred + "\n Tout fonctionne : AJEAN continue d'utiliser ce dossier."
if runtime.GOOS == "windows" {
return msg + "\n Pour aligner les noms, ferme AJEAN puis lance « ajean install » en administrateur."
}
return msg + "\n Pour aligner les noms : sudo ajean install."
}
// renameCause extrait la cause réelle d'un échec de rename. os.Rename renvoie un
// *os.LinkError dont le texte répète les deux chemins complets — dans un message
// qui les cite déjà, ça donne trois fois la même chose et rend l'essentiel
// (« Accès refusé ») illisible. On ne garde que ce dernier.
func renameCause(err error) string {
var le *os.LinkError
if errors.As(err, &le) && le.Err != nil {
return le.Err.Error()
}
return err.Error()
}
// migratedDefaultHome renvoie le dossier de données par défaut, en migrant
// l'ancien dossier « jean » vers « ajean » à la première résolution du process.
// Le résultat est mis en cache : deux appels ne doivent jamais désigner deux
// dossiers différents, sinon une moitié du programme écrirait à côté de l'autre.
//
// N'est PAS appelé quand $AJEAN_HOME/$JEAN_HOME ou /etc/default/* imposent un
// chemin : un choix explicite de l'utilisateur ne se migre pas.
func migratedDefaultHome() string {
homeMu.RLock()
if homeResolved {
p := homePath
homeMu.RUnlock()
return p
}
homeMu.RUnlock()
homeMu.Lock()
defer homeMu.Unlock()
if !homeResolved { // un autre appelant a pu résoudre entre les deux verrous
homePath, homeResolved = resolveDefaultHome(), true
}
return homePath
}
func resolveDefaultHome() string {
return migrateHome(defaultAjeanHome(), legacyDefaultHome())
}
// retryHomeMigration relance la résolution — donc la migration — après avoir
// arrêté ce qui tournait. Renvoie true si le dossier a effectivement changé.
//
// Raison d'être : sous Windows, un rename de dossier échoue tant qu'un process
// y tient un handle. Or le service AJEAN détaché en tient en permanence, si bien
// que la migration tentée au démarrage échouerait indéfiniment et que la machine
// resterait sur l'ancien chemin. L'installateur, lui, a une fenêtre où tout est
// arrêté : c'est là qu'on retente.
//
// L'accès au cache est protégé par homeMu, mais cela ne rend pas l'appel anodin
// pour autant : les chemins DÉJÀ CALCULÉS par l'appelant deviennent obsolètes
// dès que la migration aboutit (voir migrateThenResolveTarget et
// relinkAfterMigration, qui reconstruisent ce qui en dépend).
func retryHomeMigration() bool {
before := migratedDefaultHome() // force la résolution si elle n'a pas eu lieu
homeMu.Lock()
homeResolved = false
homeMu.Unlock()
return migratedDefaultHome() != before
}
// migrateHome contient toute la logique de migration, isolée des chemins réels
// de la plateforme pour être testable telle quelle. Renvoie le dossier à
// utiliser — le nouveau si la migration a réussi ou n'était pas nécessaire,
// l'ancien si elle a échoué.
func migrateHome(target, legacy string) string {
if isDir(target) {
return target // déjà migré (ou installation neuve déjà faite)
}
if !isDir(legacy) {
return target // installation neuve : rien à migrer
}
// Même parent que l'ancien dossier ⇒ même volume ⇒ rename atomique.
sibling := filepath.Join(filepath.Dir(legacy), filepath.Base(target))
if isDir(sibling) {
return sibling
}
if err := os.Rename(legacy, sibling); err != nil {
// On reste sur l'ancien chemin — tout fonctionne — et on retentera plus
// tard. Deux causes courantes sous Windows : un service AJEAN qui tient
// encore un handle, ou, bien plus fréquent, un utilisateur non
// administrateur : renommer C:\ProgramData\jean exige d'écrire dans
// C:\ProgramData, ce qu'un compte standard ne peut pas faire.
//
// On ENREGISTRE la raison au lieu de l'afficher. L'afficher condamnait
// tout utilisateur non administrateur à voir un message d'erreur
// technique avant CHAQUE commande, y compris `ajean help`, pour une
// situation qui n'a aucune conséquence : son installation fonctionne.
// Le message n'a sa place que là où il est actionnable — `ajean where`
// et `ajean install`, voir homeMigrationNotice().
migrationDeferred = fmt.Sprintf("dossier de données encore en %s — renommage en %s refusé (%s).",
legacy, filepath.Base(sibling), renameCause(err))
return legacy
}
fmt.Fprintf(os.Stderr, "[ok] dossier de données migré : %s → %s\n", legacy, sibling)
rewriteHomeReferences(legacy, sibling)
return sibling
}
// configFilesToRewrite liste les fichiers de configuration susceptibles de
// contenir un chemin ABSOLU vers le dossier de données. Volontairement restreint
// à des fichiers texte, petits et écrits par nous : on ne réécrit pas à l'aveugle
// les 51 000 fichiers d'un dossier de données.
func configFilesToRewrite(home string) []string {
files := []string{
filepath.Join(home, "config.env"),
filepath.Join(home, "model_dirs.json"),
filepath.Join(home, "mcp.json"),
filepath.Join(home, "webprefs.json"),
}
// Les presets sont des config.env alternatives : ils portent le même BIN.
presets, _ := filepath.Glob(filepath.Join(home, "configs", "*"))
return append(files, presets...)
}
// rewriteHomeReferences réécrit les chemins absolus pointant vers l'ANCIEN
// dossier de données dans les fichiers de configuration.
//
// Sans ça, la migration casse l'installation qu'elle est censée préserver :
// `config.env` contient typiquement
//
// BIN=C:\ProgramData\jean\backends\llama.cpp\build\bin\Release\llama-server.exe
//
// c'est-à-dire un chemin absolu VERS le dossier qu'on vient de renommer. Le
// dossier a bougé, la ligne pointe dans le vide, et llama-server ne démarre plus.
// Ça concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal.
//
// Trois écritures d'un même chemin doivent être couvertes, sans quoi on croit
// avoir tout réécrit alors qu'il reste des références mortes :
//
// C:\ProgramData\jean forme native, dans config.env
// C:/ProgramData/jean Windows accepte les deux séparateurs dans une valeur
// C:\\ProgramData\\jean forme JSON, où l'antislash est échappé (model_dirs.json,
// mcp.json, webprefs.json sont écrits par json.Marshal)
//
// Best-effort par fichier : un fichier illisible est sauté sans compromettre
// les autres.
func rewriteHomeReferences(oldHome, newHome string) {
variants := [][2]string{{oldHome, newHome}}
if slash := filepath.ToSlash(oldHome); slash != oldHome {
variants = append(variants, [2]string{slash, filepath.ToSlash(newHome)})
// Forme JSON : chaque antislash est doublé.
variants = append(variants, [2]string{jsonEscapePath(oldHome), jsonEscapePath(newHome)})
}
for _, path := range configFilesToRewrite(newHome) {
b, err := os.ReadFile(path)
if err != nil {
continue
}
out := string(b)
for _, v := range variants {
out = strings.ReplaceAll(out, v[0], v[1])
}
if out == string(b) {
continue
}
fi, err := os.Stat(path)
mode := os.FileMode(0o644)
if err == nil {
mode = fi.Mode()
}
if err := os.WriteFile(path, []byte(out), mode); err != nil {
fmt.Fprintf(os.Stderr, "[warn] %s non réécrit (%v) — vérifie les chemins absolus qu'il contient\n", path, err)
continue
}
fmt.Fprintf(os.Stderr, "[ok] chemins mis à jour dans %s\n", filepath.Base(path))
}
adoptLegacyStateFiles(newHome)
}
// jsonEscapePath renvoie l'écriture d'un chemin telle qu'elle apparaît DANS un
// fichier JSON : sous Windows, json.Marshal double chaque antislash. Le
// remplacement littéral doit donc chercher cette forme-là, sinon model_dirs.json
// et mcp.json gardent des chemins morts.
//
// Attention en modifiant : "\\" est UN antislash et "\\\\" en est deux. Une
// version antérieure utilisait des littéraux bruts et remplaçait en fait un
// antislash par lui-même, ce qui laissait passer le remplacement natif et
// produisait un JSON invalide (« invalid character 'U' in string escape code »),
// donc une liste de dossiers de modèles perdue. Couvert par un test.
func jsonEscapePath(p string) string { return strings.ReplaceAll(p, "\\", "\\\\") }
// adoptLegacyStateFiles reprend les fichiers d'état nommés d'après le service
// (jean.pid / jean.log → ajean.pid / ajean.log). Le fichier PID dit si le
// service tourne : ne pas le reprendre reviendrait à croire qu'il est arrêté et
// à en démarrer un second.
func adoptLegacyStateFiles(home string) {
for _, ext := range []string{".pid", ".log"} {
from := filepath.Join(home, legacyServiceName()+ext)
to := filepath.Join(home, "ajean"+ext)
if _, err := os.Stat(to); err == nil {
continue
}
if _, err := os.Stat(from); err != nil {
continue
}
_ = os.Rename(from, to)
}
}
// resetResolvedHome oublie le dossier résolu, pour que le prochain appel reparte
// du disque. Utilisé par `install` juste après avoir migré l'agencement : sans
// ça, la suite de l'installation continuerait de viser l'ancien emplacement.
func resetResolvedHome() {
homeMu.Lock()
homeResolved = false
homeMu.Unlock()
}
-286
View File
@@ -1,286 +0,0 @@
package ajean
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// mkHome crée un dossier de données factice contenant un fichier témoin, pour
// vérifier que la migration déplace bien le CONTENU et pas seulement le dossier.
func mkHome(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.env"), []byte("MODEL=x.gguf\n"), 0o644); err != nil {
t.Fatal(err)
}
}
// Cas nominal : seul l'ancien dossier existe → il est migré, contenu compris.
func TestMigrateHomeMovesLegacy(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
if got := migrateHome(target, legacy); got != target {
t.Fatalf("got %q, attendu le nouveau chemin %q", got, target)
}
if b, err := os.ReadFile(filepath.Join(target, "config.env")); err != nil || string(b) != "MODEL=x.gguf\n" {
t.Fatalf("contenu non migré: %q (%v)", b, err)
}
if isDir(legacy) {
t.Fatalf("l'ancien dossier existe encore après un rename réussi")
}
}
// Installation neuve : rien à migrer, on renvoie le nouveau chemin sans rien créer.
func TestMigrateHomeFreshInstall(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ajean")
if got := migrateHome(target, filepath.Join(root, "jean")); got != target {
t.Fatalf("got %q, attendu %q", got, target)
}
}
// Déjà migré : le nouveau dossier existe → on ne retouche à rien, même si un
// vieux dossier « jean » traîne encore (utilisateur qui l'a recréé, restauration
// de sauvegarde…). Écraser serait perdre les données courantes.
func TestMigrateHomeKeepsExistingTarget(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
mkHome(t, target)
if err := os.WriteFile(filepath.Join(target, "config.env"), []byte("MODEL=courant.gguf\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := migrateHome(target, legacy); got != target {
t.Fatalf("got %q, attendu %q", got, target)
}
b, _ := os.ReadFile(filepath.Join(target, "config.env"))
if string(b) != "MODEL=courant.gguf\n" {
t.Fatalf("le dossier courant a été écrasé par l'ancien: %q", b)
}
if !isDir(legacy) {
t.Fatalf("l'ancien dossier a été supprimé — la migration ne doit JAMAIS supprimer")
}
}
// Rename impossible → on doit retomber sur l'ancien chemin, intact. C'est la
// garantie « au pire, rien n'a bougé », celle qui protège les .gguf.
//
// On reproduit le scénario réel : un service AJEAN tourne encore et tient un
// handle ouvert dans le dossier de données, ce qui fait échouer le rename du
// dossier sous Windows. Unix, lui, renomme sans broncher un dossier dont des
// fichiers sont ouverts — il n'y a donc rien à simuler là-bas.
func TestMigrateHomeFallsBackWhenRenameFails(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("un rename de dossier réussit avec des fichiers ouverts hors Windows")
}
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
held, err := os.Open(filepath.Join(legacy, "config.env"))
if err != nil {
t.Fatal(err)
}
defer held.Close()
if got := migrateHome(target, legacy); got != legacy {
t.Fatalf("got %q, attendu un repli sur l'ancien chemin %q", got, legacy)
}
if b, err := os.ReadFile(filepath.Join(legacy, "config.env")); err != nil || string(b) != "MODEL=x.gguf\n" {
t.Fatalf("l'ancien dossier a été abîmé par une migration ratée: %q (%v)", b, err)
}
}
// Le cas qui a casse une vraie machine : config.env contient un chemin ABSOLU
// vers le dossier de donnees (BIN=<home>\backends\...\llama-server.exe). Apres
// migration ce chemin doit suivre, sinon llama-server ne demarre plus — et ca
// concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal.
func TestMigrateHomeRewritesAbsolutePaths(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
if err := os.MkdirAll(filepath.Join(legacy, "configs"), 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(legacy, "backends", "llama.cpp", "build", "bin", "llama-server")
write := func(rel, body string) {
if err := os.WriteFile(filepath.Join(legacy, rel), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("config.env", "BIN="+bin+"\nMODEL=\"x.gguf\"\n")
// Les presets portent le meme BIN et doivent suivre aussi.
write(filepath.Join("configs", "gros.env"), "BIN="+bin+"\n")
// Ecriture avec des slashs : Windows accepte les deux formes.
write("model_dirs.json", `{"dirs":["`+filepath.ToSlash(legacy)+`/models"]}`)
if got := migrateHome(target, legacy); got != target {
t.Fatalf("migration echouee: %q", got)
}
for _, rel := range []string{"config.env", filepath.Join("configs", "gros.env"), "model_dirs.json"} {
b, err := os.ReadFile(filepath.Join(target, rel))
if err != nil {
t.Fatalf("%s illisible: %v", rel, err)
}
if strings.Contains(string(b), legacy) || strings.Contains(string(b), filepath.ToSlash(legacy)) {
t.Errorf("%s reference encore l'ancien dossier:\n%s", rel, b)
}
if !strings.Contains(string(b), target) && !strings.Contains(string(b), filepath.ToSlash(target)) {
t.Errorf("%s ne pointe pas vers le nouveau dossier:\n%s", rel, b)
}
}
// Le BIN reecrit doit designer un chemin reellement atteignable.
newBin := filepath.Join(target, "backends", "llama.cpp", "build", "bin", "llama-server")
if b, _ := os.ReadFile(filepath.Join(target, "config.env")); !strings.Contains(string(b), newBin) {
t.Errorf("BIN ne pointe pas sur %s:\n%s", newBin, b)
}
}
// Les fichiers d'etat du service (jean.pid/jean.log) doivent etre repris : le
// PID dit si le service tourne, l'ignorer ferait demarrer un second service.
func TestMigrateHomeAdoptsServiceStateFiles(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
if err := os.WriteFile(filepath.Join(legacy, "jean.pid"), []byte("4242\n"), 0o644); err != nil {
t.Fatal(err)
}
migrateHome(target, legacy)
b, err := os.ReadFile(filepath.Join(target, "ajean.pid"))
if err != nil {
t.Fatalf("ajean.pid absent apres migration: %v", err)
}
if strings.TrimSpace(string(b)) != "4242" {
t.Fatalf("PID perdu: %q", b)
}
}
// Les fichiers JSON (model_dirs.json, mcp.json, webprefs.json) sont ecrits par
// json.Marshal : sous Windows chaque antislash y est DOUBLE. Chercher la forme
// native ne les trouve donc pas, et on croirait avoir tout reecrit en laissant
// des references mortes. Constate en bac a sable.
func TestMigrateHomeRewritesJSONEscapedPaths(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("l'echappement JSON des separateurs ne concerne que Windows")
}
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
esc := jsonEscapePath
body := `{"dirs":["` + esc(filepath.Join(legacy, "models")) + `"]}`
if err := os.WriteFile(filepath.Join(legacy, "model_dirs.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
migrateHome(target, legacy)
b, err := os.ReadFile(filepath.Join(target, "model_dirs.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), esc(legacy)) {
t.Errorf("reference JSON a l'ancien dossier non reecrite:\n%s", b)
}
if !strings.Contains(string(b), esc(target)) {
t.Errorf("le JSON ne pointe pas vers le nouveau dossier:\n%s", b)
}
// Le fichier doit rester du JSON valide apres reecriture.
var parsed struct {
Dirs []string `json:"dirs"`
}
if err := json.Unmarshal(b, &parsed); err != nil {
t.Fatalf("JSON casse par la reecriture: %v\n%s", err, b)
}
if len(parsed.Dirs) != 1 || !strings.HasPrefix(parsed.Dirs[0], target) {
t.Fatalf("chemin decode inattendu: %+v", parsed.Dirs)
}
}
// Cas d'un utilisateur Windows NON administrateur : il ne peut pas renommer
// C:\ProgramData\jean faute de droits sur C:\ProgramData. La migration echoue a
// chaque lancement, definitivement.
//
// Elle doit rester SILENCIEUSE. L'afficher condamnait cet utilisateur a voir un
// message d'erreur technique avant chaque commande, y compris `ajean help`,
// alors que son installation fonctionne parfaitement. La raison est enregistree
// et n'est ressortie que la ou elle est actionnable.
func TestMigrationFailureIsSilentButRecorded(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("reproduit un echec de rename propre a Windows")
}
migrationDeferred = ""
t.Cleanup(func() { migrationDeferred = "" })
root := t.TempDir()
legacy := filepath.Join(root, "jean")
mkHome(t, legacy)
held, err := os.Open(filepath.Join(legacy, "config.env"))
if err != nil {
t.Fatal(err)
}
defer held.Close()
if got := migrateHome(filepath.Join(root, "ajean"), legacy); got != legacy {
t.Fatalf("attendu un repli sur %s, got %s", legacy, got)
}
if migrationDeferred == "" {
t.Fatal("la raison de l'echec n'a pas ete enregistree")
}
notice := homeMigrationNotice()
if !strings.Contains(notice, legacy) {
t.Errorf("le message ne dit pas quel dossier reste utilise:\n%s", notice)
}
if !strings.Contains(notice, "Tout fonctionne") {
t.Errorf("le message doit rassurer, pas alarmer:\n%s", notice)
}
if !strings.Contains(notice, "administrateur") {
t.Errorf("le message doit dire quoi faire:\n%s", notice)
}
}
// Rien a signaler quand tout va bien : pas de message parasite.
func TestNoNoticeWhenMigrationSucceeds(t *testing.T) {
migrationDeferred = ""
t.Cleanup(func() { migrationDeferred = "" })
root := t.TempDir()
legacy := filepath.Join(root, "jean")
mkHome(t, legacy)
migrateHome(filepath.Join(root, "ajean"), legacy)
if n := homeMigrationNotice(); n != "" {
t.Fatalf("message inattendu apres une migration reussie:\n%s", n)
}
}
// Migration reussie : aucun residu, donc aucune demande d'elevation nulle part.
func TestAucunResiduApresMigrationReussie(t *testing.T) {
migrationDeferred = ""
t.Cleanup(func() { migrationDeferred = "" })
root := t.TempDir()
legacy := filepath.Join(root, "jean")
mkHome(t, legacy)
migrateHome(filepath.Join(root, "ajean"), legacy)
if migrationDeferred != "" {
t.Errorf("etat inattendu apres une migration reussie: %q", migrationDeferred)
}
}
+12 -17
View File
@@ -17,31 +17,25 @@ import (
type ajeanPaths struct { type ajeanPaths struct {
Home string `json:"home"` // AJEAN_HOME : racine des données Home string `json:"home"` // AJEAN_HOME : racine des données
Config string `json:"config"` // config.env Database string `json:"database"` // ajean.db : config, préférences, conversation, clés
Exe string `json:"exe"` // binaire en cours d'exécution Exe string `json:"exe"` // binaire en cours d'exécution
Installed string `json:"installed"` // binaire installé (peut différer de Exe) Installed string `json:"installed"` // binaire installé (peut différer de Exe)
Workspace string `json:"workspace"` // dossier de travail du mode agent Workspace string `json:"workspace"` // dossier de travail du mode agent
Memory string `json:"memory"` Memory string `json:"memory"`
Presets string `json:"presets"` Presets string `json:"presets"`
Models string `json:"models"`
Backends string `json:"backends"`
} }
// installedExePath est l'emplacement canonique du binaire après installation. // installedExePath est l'emplacement canonique du binaire après installation.
// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/ajean // Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/ajean
// (c'est ce que référencent les unités systemd et le plist launchd) ; sous Windows, // (c'est ce que référencent les unités systemd et le plist launchd) ; sous Windows,
// faute d'équivalent, on utilise AJEAN_HOME\bin ajouté au PATH utilisateur. // faute d'équivalent, on utilise AJEAN_HOME\bin ajouté au PATH utilisateur.
func installedExePath() string { return exePathNamed("ajean") } func installedExePath() string {
// legacyExePath est l'emplacement d'avant le renommage. L'installation continue
// d'y poser un alias : `jean` reste tapable indéfiniment. Ça ne coûte qu'un lien
// et ça préserve les habitudes, les scripts cron, les alias shell et les tutos
// déjà écrits par les utilisateurs — dont on ne contrôle aucun.
func legacyExePath() string { return exePathNamed("jean") }
func exePathNamed(name string) string {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return filepath.Join(AjeanHome(), "bin", name+".exe") return filepath.Join(binDir(), "ajean.exe")
} }
return "/usr/local/bin/" + name return "/usr/local/bin/ajean"
} }
func currentPaths() ajeanPaths { func currentPaths() ajeanPaths {
@@ -51,12 +45,14 @@ func currentPaths() ajeanPaths {
} }
return ajeanPaths{ return ajeanPaths{
Home: AjeanHome(), Home: AjeanHome(),
Config: confPath(), Database: dbPath(),
Exe: exe, Exe: exe,
Installed: installedExePath(), Installed: installedExePath(),
Workspace: agentWorkspace(), Workspace: agentWorkspace(),
Memory: memoryDir(), Memory: memoryDir(),
Presets: presetsDir(), Presets: presetsDir(),
Models: modelsDir(),
Backends: backendsDir(),
} }
} }
@@ -65,18 +61,17 @@ func cmdWhere(args []string) error {
fmt.Printf("Emplacements AJEAN\n\n") fmt.Printf("Emplacements AJEAN\n\n")
for _, row := range [][2]string{ for _, row := range [][2]string{
{"données (AJEAN_HOME)", p.Home}, {"données (AJEAN_HOME)", p.Home},
{"configuration", p.Config}, {"base", p.Database},
{"binaire en cours", p.Exe}, {"binaire en cours", p.Exe},
{"binaire installé", p.Installed}, {"binaire installé", p.Installed},
{"travail de l'agent", p.Workspace}, {"travail de l'agent", p.Workspace},
{"mémoire", p.Memory}, {"mémoire", p.Memory},
{"presets", p.Presets}, {"presets", p.Presets},
{"modèles", p.Models},
{"backends", p.Backends},
} { } {
fmt.Printf(" %-20s %s\n", row[0], row[1]) fmt.Printf(" %-20s %s\n", row[0], row[1])
} }
if notice := homeMigrationNotice(); notice != "" {
fmt.Printf("\n%s %s\n", yellow("[info]"), notice)
}
if p.Exe != p.Installed { if p.Exe != p.Installed {
fmt.Printf("\n%s tu exécutes une copie qui n'est PAS le binaire installé.\n", dim("[info]")) fmt.Printf("\n%s tu exécutes une copie qui n'est PAS le binaire installé.\n", dim("[info]"))
fmt.Printf(" Les mises à jour depuis l'application ne modifient que la copie lancée.\n") fmt.Printf(" Les mises à jour depuis l'application ne modifient que la copie lancée.\n")
+4 -4
View File
@@ -16,7 +16,7 @@ import (
"time" "time"
) )
// defaultAjeanHome is the data root when neither $AJEAN_HOME/$JEAN_HOME nor // defaultAjeanHome is the data root when neither $AJEAN_HOME/$AJEAN_HOME nor
// /etc/default/* override it. legacyDefaultHome est son équivalent d'avant le // /etc/default/* override it. legacyDefaultHome est son équivalent d'avant le
// renommage, migré au premier lancement (voir sys_migrate.go). // renommage, migré au premier lancement (voir sys_migrate.go).
// //
@@ -27,7 +27,7 @@ import (
// un `sudo ajean install` a déjà créé /etc/ajean à notre nom. // un `sudo ajean install` a déjà créé /etc/ajean à notre nom.
func defaultAjeanHome() string { return unixHome("ajean") } func defaultAjeanHome() string { return unixHome("ajean") }
func legacyDefaultHome() string { return unixHome("jean") } func legacyDefaultHome() string { return unixHome("ajean") }
func unixHome(name string) string { func unixHome(name string) string {
if runtime.GOOS != "darwin" || os.Geteuid() == 0 { if runtime.GOOS != "darwin" || os.Geteuid() == 0 {
@@ -49,7 +49,7 @@ func isWritableDir(dir string) bool {
if err != nil || !fi.IsDir() { if err != nil || !fi.IsDir() {
return false return false
} }
probe := filepath.Join(dir, ".jean-write-test") probe := filepath.Join(dir, ".ajean-write-test")
f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY, 0o644) f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil { if err != nil {
return false return false
@@ -335,7 +335,7 @@ func ramUsageMB() (used, total int) {
// --- Supervision de processus détachés (worker de lien, service en mode // --- Supervision de processus détachés (worker de lien, service en mode
// utilisateur). Pendant Unix de sys_platform_windows.go. // utilisateur). Pendant Unix de sys_platform_windows.go.
// spawnDetached prépare une commande qui survivra à la mort de Jean : Setsid la // spawnDetached prépare une commande qui survivra à la mort de AJEAN : Setsid la
// place dans une nouvelle session, donc elle n'est pas tuée avec notre groupe. // place dans une nouvelle session, donc elle n'est pas tuée avec notre groupe.
func spawnDetached(name string, args ...string) *exec.Cmd { func spawnDetached(name string, args ...string) *exec.Cmd {
cmd := exec.Command(name, args...) cmd := exec.Command(name, args...)
+6 -6
View File
@@ -15,7 +15,7 @@ import (
) )
// init makes the Windows console behave like a modern terminal: UTF-8 so the // init makes the Windows console behave like a modern terminal: UTF-8 so the
// Unicode glyphs jean prints (✓ ▶ …) and child-process output don't turn into // Unicode glyphs ajean prints (✓ ▶ …) and child-process output don't turn into
// mojibake (the default OEM codepage, e.g. cp850, renders "✓" as "├ö"), and VT // mojibake (the default OEM codepage, e.g. cp850, renders "✓" as "├ö"), and VT
// processing so the ANSI colour/cursor escapes (including the build progress // processing so the ANSI colour/cursor escapes (including the build progress
// line) are interpreted instead of printed literally. Best-effort: a redirected // line) are interpreted instead of printed literally. Best-effort: a redirected
@@ -43,7 +43,7 @@ func init() {
// homeRoot is the parent directory of the data root: %ProgramData% (machine-wide, // homeRoot is the parent directory of the data root: %ProgramData% (machine-wide,
// the closest analogue to /etc), falling back to %LOCALAPPDATA% for unprivileged // the closest analogue to /etc), falling back to %LOCALAPPDATA% for unprivileged
// setups. Ancien et nouveau dossier partagent ce parent, ce qui garantit que la // setups. Ancien et nouveau dossier partagent ce parent, ce qui garantit que la
// migration jean → ajean est un rename intra-volume (voir sys_migrate.go). // migration ajean → ajean est un rename intra-volume (voir sys_migrate.go).
func homeRoot() string { func homeRoot() string {
if pd := os.Getenv("ProgramData"); pd != "" { if pd := os.Getenv("ProgramData"); pd != "" {
return pd return pd
@@ -54,11 +54,11 @@ func homeRoot() string {
return os.TempDir() return os.TempDir()
} }
// defaultAjeanHome is the data root when no AJEAN_HOME/JEAN_HOME is set. // defaultAjeanHome is the data root when no AJEAN_HOME/AJEAN_HOME is set.
func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") } func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") }
// legacyDefaultHome is the pre-rename data root, migrated on first run. // legacyDefaultHome is the pre-rename data root, migrated on first run.
func legacyDefaultHome() string { return filepath.Join(homeRoot(), "jean") } func legacyDefaultHome() string { return filepath.Join(homeRoot(), "ajean") }
// defaultEditor is used by `ajean edit` when $EDITOR is unset. // defaultEditor is used by `ajean edit` when $EDITOR is unset.
func defaultEditor() string { return "notepad" } func defaultEditor() string { return "notepad" }
@@ -70,7 +70,7 @@ func openBrowser(url string) error {
} }
// hideCmd empêche une commande externe d'ouvrir une fenêtre de console // hideCmd empêche une commande externe d'ouvrir une fenêtre de console
// (CREATE_NO_WINDOW). Indispensable quand Jean tourne sans console (mode app) : // (CREATE_NO_WINDOW). Indispensable quand AJEAN tourne sans console (mode app) :
// sinon chaque `nvidia-smi`/`git`/… ferait clignoter une fenêtre noire. Fusionne // sinon chaque `nvidia-smi`/`git`/… ferait clignoter une fenêtre noire. Fusionne
// avec les flags déjà présents pour ne pas écraser un éventuel détachement. // avec les flags déjà présents pour ne pas écraser un éventuel détachement.
func hideCmd(cmd *exec.Cmd) *exec.Cmd { func hideCmd(cmd *exec.Cmd) *exec.Cmd {
@@ -389,7 +389,7 @@ func cudaPathEnv(toolkitDir string) []string {
} }
// newShellCmd builds the command used by the run_shell tool. hideCmd évite un // newShellCmd builds the command used by the run_shell tool. hideCmd évite un
// flash de console à chaque commande d'agent quand Jean tourne en mode app. // flash de console à chaque commande d'agent quand AJEAN tourne en mode app.
func newShellCmd(ctx context.Context, command string) *exec.Cmd { func newShellCmd(ctx context.Context, command string) *exec.Cmd {
return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command)) return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command))
} }
-7
View File
@@ -1,7 +0,0 @@
//go:build !windows
package ajean
// Pendant non-Windows de sys_postupdate_windows.go : la migration d'agencement
// se fait dans restartAfterUpdate (voir sys_layout.go), qui tourne deja en root.
func postUpdateMigrate() {}
-51
View File
@@ -1,51 +0,0 @@
//go:build windows
package ajean
import (
"fmt"
"path/filepath"
)
// Migration du dossier juste après une mise à jour, sous Windows.
//
// C'est le chaînon qui manquait. Les utilisateurs ne relancent pas `install` et
// ne retéléchargent pas l'exécutable : ils font `ajean update`, ou cliquent le
// bouton de l'interface. Or ce chemin-là ne tentait rien — l'élévation n'était
// câblée que dans `install` et dans l'installeur de mise à jour. Résultat : un
// poste sans droits administrateur restait sur %ProgramData%\jean pour toujours,
// sans qu'aucune fenêtre ne propose jamais de le régler.
//
// Contrairement à ce qu'on pourrait croire, le binaire en cours d'exécution
// n'empêche PAS de renommer le dossier qui le contient : Windows verrouille le
// fichier .exe lui-même, pas le nom de ses dossiers parents. Vérifié sur banc
// d'essai. La migration peut donc se faire depuis l'application en marche.
func postUpdateMigrate() {
if !retryHomeMigration() && !elevateForHomeMigration() {
return
}
fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome())
relinkAfterMigration()
}
// relinkAfterMigration répare ce que le déplacement laisse derrière lui.
//
// Le binaire installé vit DANS le dossier de données (%AJEAN_HOME%\bin). Le
// renommer déplace donc aussi le programme : le raccourci du menu Démarrer et
// l'entrée de PATH désignent un chemin qui n'existe plus. Sans cette étape, la
// migration « réussit » et l'utilisateur se retrouve avec un raccourci mort et
// une commande `ajean` introuvable — soit exactement ce qu'on cherchait à éviter.
func relinkAfterMigration() {
target := installedExePath()
binDir := filepath.Dir(target)
if _, err := addToUserPath(binDir); err != nil {
fmt.Printf(" %s PATH non mis à jour (%v) — ajoute %s à la main\n", dim("•"), err, binDir)
}
// L'ancienne entrée ne désigne plus rien : la laisser encombrerait le PATH
// d'un chemin mort, et ferait échouer un `jean` tapé par habitude.
if legacyBin := filepath.Join(legacyDefaultHome(), "bin"); legacyBin != binDir {
_, _ = removeFromUserPath(legacyBin)
}
ensureShortcuts(target)
}
-7
View File
@@ -57,13 +57,6 @@ func cmdRestartAfterUpdate(args []string) error {
waitForExit(pid, 30*time.Second) waitForExit(pid, 30*time.Second)
} }
} }
// L'application vient de se fermer : plus rien ne tient le dossier de
// données, le renommage peut enfin aboutir. postUpdateMigrate ne fait rien
// s'il n'y a pas lieu, et repointe raccourci et PATH s'il a migré.
postUpdateMigrate()
// installedExePath() est relu APRÈS la migration : le binaire a pu changer
// de dossier avec elle, et relancer l'ancien chemin échouerait.
target := installedExePath() target := installedExePath()
if _, err := os.Stat(target); err != nil { if _, err := os.Stat(target); err != nil {
// La cible a disparu : mieux vaut relancer ce qu'on a que rien du tout. // La cible a disparu : mieux vaut relancer ce qu'on a que rien du tout.
+25 -4
View File
@@ -16,18 +16,39 @@ import (
// //
// editConfig and showVram live here because they work the same everywhere. // editConfig and showVram live here because they work the same everywhere.
// editConfig ouvre la configuration dans $EDITOR. La configuration vit en base
// (voir store.go) : on la déroule dans un fichier temporaire au format clé=valeur,
// on laisse l'éditeur faire son travail, puis on relit. Le contenu n'est réécrit
// que si l'éditeur sort proprement — un éditeur avorté ne doit rien effacer.
func editConfig() error { func editConfig() error {
editor := os.Getenv("EDITOR") editor := os.Getenv("EDITOR")
if editor == "" { if editor == "" {
editor = defaultEditor() editor = defaultEditor()
} }
cmd := exec.Command(editor, confPath()) tmp, err := os.CreateTemp("", "ajean-config-*.env")
cmd.Stdin = os.Stdin if err != nil {
cmd.Stdout = os.Stdout return err
cmd.Stderr = os.Stderr }
path := tmp.Name()
defer os.Remove(path)
if _, err := tmp.WriteString(formatEnv(ReadConfig())); err != nil {
tmp.Close()
return err
}
tmp.Close()
cmd := exec.Command(editor, path)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
return err return err
} }
b, err := os.ReadFile(path)
if err != nil {
return err
}
if err := WriteConfig(parseEnv(string(b))); err != nil {
return err
}
fmt.Println(dim("[info] ajean restart pour appliquer")) fmt.Println(dim("[info] ajean restart pour appliquer"))
return nil return nil
} }
+9 -9
View File
@@ -19,7 +19,7 @@ import (
// cf. issue #4). Implémentation prudente basée sur launchctl load/unload/list. // cf. issue #4). Implémentation prudente basée sur launchctl load/unload/list.
// launchdLabel dérive le label launchd du service (ex. "com.ajean.ajean"), en // launchdLabel dérive le label launchd du service (ex. "com.ajean.ajean"), en
// conservant le préfixe hérité « com.jean. » tant que c'est LUI qui est // conservant le préfixe hérité « com.ajean. » tant que c'est LUI qui est
// réellement installé : le plist d'un daemon déjà chargé ne se renomme pas tout // réellement installé : le plist d'un daemon déjà chargé ne se renomme pas tout
// seul, et viser le mauvais label reviendrait à ne plus voir le service. // seul, et viser le mauvais label reviendrait à ne plus voir le service.
func launchdLabel(svc string) string { func launchdLabel(svc string) string {
@@ -27,7 +27,7 @@ func launchdLabel(svc string) string {
if _, err := os.Stat("/Library/LaunchDaemons/" + modern + ".plist"); err == nil { if _, err := os.Stat("/Library/LaunchDaemons/" + modern + ".plist"); err == nil {
return modern return modern
} }
if legacy := "com.jean." + svc; fileExists("/Library/LaunchDaemons/" + legacy + ".plist") { if legacy := "com.ajean." + svc; fileExists("/Library/LaunchDaemons/" + legacy + ".plist") {
return legacy return legacy
} }
return modern return modern
@@ -44,7 +44,7 @@ func launchdPlistPath(svc string) string {
return "/Library/LaunchDaemons/" + launchdLabel(svc) + ".plist" return "/Library/LaunchDaemons/" + launchdLabel(svc) + ".plist"
} }
// launchdLogPath : sortie standard/erreur du service, sous JEAN_HOME (accessible // launchdLogPath : sortie standard/erreur du service, sous AJEAN_HOME (accessible
// en écriture par l'utilisateur du service après le chown de l'installation). // en écriture par l'utilisateur du service après le chown de l'installation).
func launchdLogPath() string { return filepath.Join(AjeanHome(), serviceName()+".log") } func launchdLogPath() string { return filepath.Join(AjeanHome(), serviceName()+".log") }
@@ -53,8 +53,8 @@ func launchdLogPath() string { return filepath.Join(AjeanHome(), serviceName()+"
func serviceAction(action string) error { func serviceAction(action string) error {
svc := serviceName() svc := serviceName()
plist := launchdPlistPath(svc) plist := launchdPlistPath(svc)
// Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où Jean tourne // Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où AJEAN tourne
// via Jean.app sans jamais passer par `sudo ajean install` : on gère alors le // via AJEAN.app sans jamais passer par `sudo ajean install` : on gère alors le
// service comme sous Windows, avec un `ajean serve` détaché suivi par un fichier // service comme sous Windows, avec un `ajean serve` détaché suivi par un fichier
// PID. Aucun droit root requis. Sans ça, `start` échouait sur un plist absent // PID. Aucun droit root requis. Sans ça, `start` échouait sur un plist absent
// et l'UI affichait éternellement « service arrêté ». // et l'UI affichait éternellement « service arrêté ».
@@ -145,9 +145,9 @@ func serviceIsActive() bool {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Mode utilisateur (sans launchd) — même approche que Windows : `ajean serve` est // Mode utilisateur (sans launchd) — même approche que Windows : `ajean serve` est
// lancé détaché, son PID va dans JEAN_HOME/<svc>.pid et sa sortie dans // lancé détaché, son PID va dans AJEAN_HOME/<svc>.pid et sa sortie dans
// JEAN_HOME/<svc>.log. Setsid le détache de notre session pour qu'il survive à // AJEAN_HOME/<svc>.log. Setsid le détache de notre session pour qu'il survive à
// la fermeture de Jean.app, et permet de tuer tout le groupe (llama-server // la fermeture de AJEAN.app, et permet de tuer tout le groupe (llama-server
// enfant compris) d'un seul signal. // enfant compris) d'un seul signal.
func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") } func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") }
@@ -202,7 +202,7 @@ func userSvcStart() error {
cmd := exec.Command(self, "serve") cmd := exec.Command(self, "serve")
cmd.Stdout, cmd.Stderr = logf, logf cmd.Stdout, cmd.Stderr = logf, logf
// Même répertoire de travail que sous systemd/launchd : les chemins relatifs // Même répertoire de travail que sous systemd/launchd : les chemins relatifs
// de config.env (MODEL=…gguf) se résolvent depuis JEAN_HOME, pas depuis « / » // de config.env (MODEL=…gguf) se résolvent depuis AJEAN_HOME, pas depuis « / »
// que nous hérite le Finder. // que nous hérite le Finder.
cmd.Dir = AjeanHome() cmd.Dir = AjeanHome()
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+3 -4
View File
@@ -17,7 +17,7 @@ import (
// On Windows there is no systemd, so "the service" is a background copy of // On Windows there is no systemd, so "the service" is a background copy of
// `ajean serve` that this process launches detached. We track it with a PID file // `ajean serve` that this process launches detached. We track it with a PID file
// and stream its output to a log file under JEAN_HOME. This needs no admin // and stream its output to a log file under AJEAN_HOME. This needs no admin
// rights and no external tools beyond the always-present tasklist/taskkill. // rights and no external tools beyond the always-present tasklist/taskkill.
const ( const (
@@ -71,9 +71,9 @@ func svcStart() error {
cmd := exec.Command(self, "serve") cmd := exec.Command(self, "serve")
cmd.Stdout = logf cmd.Stdout = logf
cmd.Stderr = logf cmd.Stderr = logf
cmd.Dir = AjeanHome() // les chemins relatifs de config.env se résolvent depuis JEAN_HOME cmd.Dir = AjeanHome() // les chemins relatifs de config.env se résolvent depuis AJEAN_HOME
// createNoWindow + HideWindow : le service enfant (`ajean serve`) ne doit JAMAIS // createNoWindow + HideWindow : le service enfant (`ajean serve`) ne doit JAMAIS
// faire clignoter de console noire quand Jean est lancé en mode app (double-clic). // faire clignoter de console noire quand AJEAN est lancé en mode app (double-clic).
cmd.SysProcAttr = &syscall.SysProcAttr{ cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true, HideWindow: true,
CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow, CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow,
@@ -136,7 +136,6 @@ func svcStatus() error {
fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName()) fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName())
} }
fmt.Printf(" logs : %s\n", logFilePath()) fmt.Printf(" logs : %s\n", logFilePath())
fmt.Printf(" config : %s\n", confPath())
return nil return nil
} }
+2 -2
View File
@@ -2,7 +2,7 @@
package ajean package ajean
// sys_splash_windows.go — écran de démarrage « Lancement de Jean… ». // sys_splash_windows.go — écran de démarrage « Lancement de AJEAN… ».
// //
// Petite fenêtre sans bordure, à coins arrondis, aux couleurs de la marque // Petite fenêtre sans bordure, à coins arrondis, aux couleurs de la marque
// (fond bleu #1f6feb, logo « j » blanc, texte blanc), affichée le temps que le // (fond bleu #1f6feb, logo « j » blanc, texte blanc), affichée le temps que le
@@ -127,7 +127,7 @@ func (s *splash) close() {
} }
func createSplashWindow() uintptr { func createSplashWindow() uintptr {
className, _ := syscall.UTF16PtrFromString("JeanSplash") className, _ := syscall.UTF16PtrFromString("AJEANSplash")
hInst, _, _ := pGetModuleHandleW.Call(0) hInst, _, _ := pGetModuleHandleW.Call(0)
splashClassOnce.Do(func() { splashClassOnce.Do(func() {
+3 -3
View File
@@ -2,10 +2,10 @@
package ajean package ajean
// sys_tray_darwin.go — icône de Jean dans la barre de menus macOS (en haut à // sys_tray_darwin.go — icône de AJEAN dans la barre de menus macOS (en haut à
// droite, à côté de l'horloge), pendant exact du system tray Windows : quand on // droite, à côté de l'horloge), pendant exact du system tray Windows : quand on
// ouvre Jean.app, l'UI web s'ouvre dans le navigateur ET l'icône apparaît, avec // ouvre AJEAN.app, l'UI web s'ouvre dans le navigateur ET l'icône apparaît, avec
// le menu « Ouvrir Jean » / « Quitter ». // le menu « Ouvrir AJEAN » / « Quitter ».
// //
// Contrairement à Windows, systray passe ici par Cocoa → CGO obligatoire : les // Contrairement à Windows, systray passe ici par Cocoa → CGO obligatoire : les
// binaires macOS sont donc compilés sur un runner macOS (voir release.yml), et // binaires macOS sont donc compilés sur un runner macOS (voir release.yml), et
+2 -2
View File
@@ -2,10 +2,10 @@
package ajean package ajean
// sys_tray_windows.go — icône de Jean dans la zone de notification Windows (system // sys_tray_windows.go — icône de AJEAN dans la zone de notification Windows (system
// tray). Quand on lance l'app (double-clic ou `ajean app`), l'UI web s'ouvre dans // tray). Quand on lance l'app (double-clic ou `ajean app`), l'UI web s'ouvre dans
// le navigateur ET une petite icône apparaît près de l'horloge : elle montre que // le navigateur ET une petite icône apparaît près de l'horloge : elle montre que
// Jean tourne et offre un menu « Ouvrir Jean » / « Quitter ». // AJEAN tourne et offre un menu « Ouvrir AJEAN » / « Quitter ».
// //
// getlantern/systray est pur Go sur Windows (Win32 via x/sys, aucun CGO) — le // getlantern/systray est pur Go sur Windows (Win32 via x/sys, aucun CGO) — le
// binaire reste statique. L'équivalent macOS vit dans sys_tray_darwin.go (Cocoa, // binaire reste statique. L'équivalent macOS vit dans sys_tray_darwin.go (Cocoa,
-49
View File
@@ -1,49 +0,0 @@
package ajean
import "os"
// Nom des unités système, pendant et après le renommage jean → ajean.
//
// Le problème : une mise à jour remplace le BINAIRE, pas les unités systemd.
// Un serveur déjà installé continue donc de tourner sous « jean.service » et
// « jean-link.service », qui pointent sur /usr/local/bin/jean. Si le binaire
// renommé partait du principe que les unités s'appellent désormais « ajean* »,
// le premier `systemctl restart` échouerait — et sur un serveur distant, ça veut
// dire l'accès coupé, sans terminal pour réparer.
//
// La solution retenue n'est PAS de migrer les unités au démarrage (il faudrait
// root, et un échec laisserait la machine sans service). C'est de résoudre le
// nom à l'usage : on utilise l'unité qui existe réellement. Une machine ancienne
// reste sur ses unités « jean* » et fonctionne ; une machine fraîchement
// installée — ou réinstallée via `ajean install` — utilise les « ajean* ».
// Aucune fenêtre pendant laquelle plus rien ne tourne.
// unitExists dit si un service de ce nom est installé sur la machine : unité
// systemd sous Linux, daemon launchd sous macOS (repéré par son plist, sous
// l'un ou l'autre des deux préfixes de label). Toujours faux sous Windows, où
// le « service » est un simple processus détaché suivi par un fichier PID.
func unitExists(name string) bool {
paths := []string{
"/etc/systemd/system/" + name + ".service",
"/Library/LaunchDaemons/com.ajean." + name + ".plist",
"/Library/LaunchDaemons/com.jean." + name + ".plist",
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
// resolveUnitName renvoie le nom d'unité à utiliser : le nouveau s'il est
// installé, sinon l'ancien s'il l'est, sinon le nouveau (installation à venir).
func resolveUnitName(modern, legacy string) string {
if unitExists(modern) {
return modern
}
if unitExists(legacy) {
return legacy
}
return modern
}
+19 -48
View File
@@ -22,9 +22,6 @@ import (
// l'OS/arch courant, remplace le binaire en place, puis affiche quoi redémarrer. // l'OS/arch courant, remplace le binaire en place, puis affiche quoi redémarrer.
// AUCUN redémarrage de service automatique (choix volontaire, plus sûr). // AUCUN redémarrage de service automatique (choix volontaire, plus sûr).
// Le dépôt a été renommé jean → ajean ; GitHub redirige les anciennes URL et
// l'API REST, donc les binaires déjà installés qui pointent encore sur
// « nathaninline/jean » continuent de trouver les releases.
const updateRepo = "nathaninline/ajean" const updateRepo = "nathaninline/ajean"
type ghRelease struct { type ghRelease struct {
@@ -37,36 +34,23 @@ type ghRelease struct {
} `json:"assets"` } `json:"assets"`
} }
// updateAssetNames reconstruit les noms d'asset acceptables pour la plateforme // updateAssetName est le nom de l'asset de release pour la plateforme courante :
// courante, par ordre de préférence : ajean-<os>-<arch>[.exe] puis, en repli, // ajean-<os>-<arch>[.exe]. Un seul nom publié, un seul nom cherché.
// l'ancien nom jean-<os>-<arch>[.exe]. func updateAssetName() string {
//
// Le repli n'est pas de la coquetterie : pendant la transition, les releases
// publient les DEUX noms (mêmes binaires) pour que le parc déjà installé — qui
// ne connaît que « jean-* » — continue de se mettre à jour. Ce binaire-ci sait
// lire les deux, il fonctionne donc aussi bien avec une release de transition
// qu'avec une release future qui n'aura plus que « ajean-* ».
func updateAssetNames() []string {
suffix := runtime.GOOS + "-" + runtime.GOARCH suffix := runtime.GOOS + "-" + runtime.GOARCH
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
suffix += ".exe" suffix += ".exe"
} }
return []string{"ajean-" + suffix, "jean-" + suffix} return "ajean-" + suffix
} }
// updateAssetName renvoie le nom d'asset préféré (usage : messages à l'écran). // pickAsset trouve dans la release l'asset de cette plateforme. Renvoie son nom
func updateAssetName() string { return updateAssetNames()[0] } // (celui qui servira à vérifier le SHA-256), son URL et sa taille.
// pickAsset choisit dans la release le premier asset correspondant à la
// plateforme, en respectant l'ordre de préférence. Renvoie son nom, son URL et
// sa taille — le nom retourné est celui qui servira à vérifier le SHA-256, qui
// doit donc être celui réellement téléchargé, pas celui qu'on préférait.
func pickAsset(rel *ghRelease) (name, url string, size int64) { func pickAsset(rel *ghRelease) (name, url string, size int64) {
for _, want := range updateAssetNames() { want := updateAssetName()
for _, a := range rel.Assets { for _, a := range rel.Assets {
if a.Name == want { if a.Name == want {
return a.Name, a.BrowserDownloadURL, a.Size return a.Name, a.BrowserDownloadURL, a.Size
}
} }
} }
return "", "", 0 return "", "", 0
@@ -145,7 +129,7 @@ func applyUpdate() (string, error) {
want, url, size := pickAsset(rel) want, url, size := pickAsset(rel)
if url == "" { if url == "" {
return "", fmt.Errorf("aucun binaire %s dans la release %s (os/arch %s/%s)", return "", fmt.Errorf("aucun binaire %s dans la release %s (os/arch %s/%s)",
strings.Join(updateAssetNames(), " ni "), latest, runtime.GOOS, runtime.GOARCH) updateAssetName(), latest, runtime.GOOS, runtime.GOARCH)
} }
exe, err := os.Executable() exe, err := os.Executable()
@@ -197,7 +181,7 @@ func applyUpdate() (string, error) {
return strings.TrimPrefix(latest, "v"), nil return strings.TrimPrefix(latest, "v"), nil
} }
// updatePermissionError formule le seul message utile quand jean n'a pas les // updatePermissionError formule le seul message utile quand ajean n'a pas les
// droits d'écrire son propre binaire : la commande exacte à lancer. // droits d'écrire son propre binaire : la commande exacte à lancer.
func updatePermissionError(exe string) error { func updatePermissionError(exe string) error {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
@@ -240,7 +224,7 @@ func cmdUpdate(args []string) error {
return fmt.Errorf("impossible de contacter GitHub : %w", err) return fmt.Errorf("impossible de contacter GitHub : %w", err)
} }
if !info.Available { if !info.Available {
fmt.Printf("jean est déjà à jour (%s).\n", Version) fmt.Printf("ajean est déjà à jour (%s).\n", Version)
return nil return nil
} }
fmt.Printf("nouvelle version disponible : %s (actuelle : %s)\n", info.Latest, Version) fmt.Printf("nouvelle version disponible : %s (actuelle : %s)\n", info.Latest, Version)
@@ -255,10 +239,6 @@ func cmdUpdate(args []string) error {
return err return err
} }
fmt.Printf("✓ ajean mis à jour en %s\n", newVer) fmt.Printf("✓ ajean mis à jour en %s\n", newVer)
// Sous Windows, c'est ICI qu'on peut enfin migrer le dossier : `update` est
// le geste délibéré de l'utilisateur, et c'est le seul que la plupart
// feront. No-op ailleurs (Unix migre dans restartAfterUpdate, déjà en root).
postUpdateMigrate()
printRestartHint() printRestartHint()
return nil return nil
} }
@@ -412,7 +392,7 @@ func cleanupOldBinary() {
removeOldBinaries(exe) removeOldBinaries(exe)
// L'alias herite laisse le meme reliquat quand il etait en cours d'execution // L'alias herite laisse le meme reliquat quand il etait en cours d'execution
// au moment ou on l'a remplace (voir replaceExe). // au moment ou on l'a remplace (voir replaceExe).
removeOldBinaries(filepath.Join(filepath.Dir(exe), "jean.exe")) removeOldBinaries(filepath.Join(filepath.Dir(exe), "ajean.exe"))
} }
// handleUpdateCheck (GET /api/update) : renvoie l'état de mise à jour pour l'UI. // handleUpdateCheck (GET /api/update) : renvoie l'état de mise à jour pour l'UI.
@@ -427,7 +407,7 @@ func handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
// handleUpdateApply (POST /api/update/apply) : télécharge et installe la dernière // handleUpdateApply (POST /api/update/apply) : télécharge et installe la dernière
// version. Sur un serveur Linux/systemd, relance ensuite AUTOMATIQUEMENT le service // version. Sur un serveur Linux/systemd, relance ensuite AUTOMATIQUEMENT le service
// jean-link (celui qui sert l'UI, le chat et le tunnel) pour appliquer la MAJ sans // ajean-link (celui qui sert l'UI, le chat et le tunnel) pour appliquer la MAJ sans
// avoir à SSH — voir restartAfterUpdate. Renvoie la version + le message de statut. // avoir à SSH — voir restartAfterUpdate. Renvoie la version + le message de statut.
func handleUpdateApply(w http.ResponseWriter, r *http.Request) { func handleUpdateApply(w http.ResponseWriter, r *http.Request) {
newVer, err := applyUpdate() newVer, err := applyUpdate()
@@ -443,10 +423,10 @@ func handleUpdateApply(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "version": newVer, "restart": msg, "restarting": restarting}) sendJSON(w, 200, map[string]any{"ok": true, "version": newVer, "restart": msg, "restarting": restarting})
} }
// restartAfterUpdate relance le service jean-link juste après une MAJ déclenchée // restartAfterUpdate relance le service ajean-link juste après une MAJ déclenchée
// depuis l'UI, pour éviter le SSH manuel. Conditions : Linux/systemd + jean-link // depuis l'UI, pour éviter le SSH manuel. Conditions : Linux/systemd + ajean-link
// actif (le service tourne en root → systemctl passe sans sudo). On NE touche PAS à // actif (le service tourne en root → systemctl passe sans sudo). On NE touche PAS à
// jean.service (llama-server) pour ne pas recharger le modèle (long et inattendu // ajean.service (llama-server) pour ne pas recharger le modèle (long et inattendu
// depuis un bouton « mettre à jour »). Le redémarrage est DIFFÉRÉ (la réponse HTTP // depuis un bouton « mettre à jour »). Le redémarrage est DIFFÉRÉ (la réponse HTTP
// doit partir d'abord) et lancé en --no-block : systemd enregistre le job puis nous // doit partir d'abord) et lancé en --no-block : systemd enregistre le job puis nous
// arrête/relance ; la clé E2E (.e2e_key) survit au restart, donc pas de ré-appairage // arrête/relance ; la clé E2E (.e2e_key) survit au restart, donc pas de ré-appairage
@@ -463,15 +443,6 @@ func restartAfterUpdate() (bool, string) {
if runtime.GOOS != "linux" || !linkServiceActive() { if runtime.GOOS != "linux" || !linkServiceActive() {
return false, "" return false, ""
} }
// Le seul moment où l'on peut migrer l'agencement d'une machine déjà
// installée : on est root (jean-link tourne en root), le binaire vient
// d'être remplacé, et le service va être relancé dans la foulée. Les
// utilisateurs ne relancent jamais `install`, donc ne rien faire ici
// reviendrait à ne jamais migrer un serveur Linux. Sans effet si la machine
// est déjà en ajean ou si un chemin a été imposé à la main.
if err := migrateLayout(updateLayoutPlan()); err != nil {
fmt.Fprintf(os.Stderr, "[warn] migration de l'agencement non effectuée : %v\n", err)
}
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
@@ -485,7 +456,7 @@ func restartHintText() string {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
return "Redémarre AJEAN (quitte puis relance) pour appliquer la mise à jour." return "Redémarre AJEAN (quitte puis relance) pour appliquer la mise à jour."
} }
return "Redémarre les services pour appliquer : sudo systemctl restart jean jean-link" return "Redémarre les services pour appliquer : sudo systemctl restart ajean ajean-link"
} }
func printRestartHint() { fmt.Println(restartHintText()) } func printRestartHint() { fmt.Println(restartHintText()) }
+1 -1
View File
@@ -10,7 +10,7 @@ import (
// Le remplacement du binaire doit fonctionner MÊME si un écartement précédent // Le remplacement du binaire doit fonctionner MÊME si un écartement précédent
// est toujours là et impossible à supprimer (processus encore vivant dessus). // est toujours là et impossible à supprimer (processus encore vivant dessus).
// Avec un nom fixe « .old », le renommage échouait alors en « Accès refusé », // Avec un nom fixe « .old », le renommage échouait alors en « Accès refusé »,
// que jean traduisait par « droits insuffisants, relance en administrateur » : // que ajean traduisait par « droits insuffisants, relance en administrateur » :
// un conseil faux, aucun privilège ne remplace une image en cours d'exécution. // un conseil faux, aucun privilège ne remplace une image en cours d'exécution.
func TestRenameAsideNeBloquePasSurUnAncienEcartement(t *testing.T) { func TestRenameAsideNeBloquePasSurUnAncienEcartement(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
+1 -1
View File
@@ -35,7 +35,7 @@ func TestPickAssetAjeanOnly(t *testing.T) {
} }
} }
// Ancienne release, antérieure au renommage : le repli sur jean-* doit marcher, // Ancienne release, antérieure au renommage : le repli sur ajean-* doit marcher,
// sinon un client neuf ne pourrait pas s'installer depuis une release existante. // sinon un client neuf ne pourrait pas s'installer depuis une release existante.
func TestPickAssetFallsBackToLegacy(t *testing.T) { func TestPickAssetFallsBackToLegacy(t *testing.T) {
names := updateAssetNames() names := updateAssetNames()
+77 -24
View File
@@ -1286,24 +1286,23 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div> </div>
</details> </details>
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary> <details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
<div class="subhead">Installation</div>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le <!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). --> étaient des cartes dans une carte). -->
<div class="slist"> <div class="slist">
<!-- Texte de départ IDENTIQUE (à la variante près) à celui posé après le <!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
chargement : un simple « … » faisait grandir la carte sous les yeux dès la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
que l'état arrivait. --> donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
<div class="rowbody"><div id="lc-status" class="lc-head muted">Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle.</div></div> alors que compiler donne du CUDA natif. -->
<div class="lc-modes"> <div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')"> <div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco">conseillé</span></div> <div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
<div class="lc-mode-d">binaire officiel téléchargé tel quel, prêt en ~2 min</div> <div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div> <div class="lc-mode-state" id="lc-fast-state"></div>
</div> </div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')"> <div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">llama.cpp compilé</div> <div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
<div class="lc-mode-d">compilé ici pour votre machine — plus rapide, plus long à installer</div> <div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-state" id="lc-opt-state"></div> <div class="lc-mode-state" id="lc-opt-state"></div>
</div> </div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()"> <div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
@@ -1536,6 +1535,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
<div id="m-hf-progress" class="pe-note" style="display:none"></div> <div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button> <button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div> </div>
<div class="pe-row">
<span class="pe-row-l">Destination</span>
<span class="pe-selc"><select id="m-hf-dir"></select></span>
</div>
<div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()"> <div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()">
<span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span> <span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span>
<span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span> <span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span>
@@ -1546,7 +1549,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models"> <input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button> <button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
</div> </div>
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier AJEAN.</div> <div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -2618,7 +2621,7 @@ async function openItem(kind, key){
document.getElementById('m-quant').value = currentQuantInTextarea(); document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings(); populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ? attachDownload(); // téléchargement encore en cours côté serveur ?
await Promise.all([populateBackend(), populateModelPicker()]); await Promise.all([populateBackend(), populateModelPicker(), populateDlDirs()]);
if(seq !== openSeq) return; if(seq !== openSeq) return;
} }
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend // On lève le voile SANS transition (voir la note dans styles.css), puis on rend
@@ -2717,7 +2720,8 @@ async function populateModelDirs(){
const info = document.createElement('span'); const info = document.createElement('span');
info.className = 'muted'; info.className = 'muted';
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
info.textContent = '(' + n + (x.home ? ', dossier jean' : '') + ')'; const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
info.textContent = '(' + n + free + (x.home ? ', dossier jean' : '') + ')';
row.append(p, info); row.append(p, info);
if(!x.home){ if(!x.home){
const del = document.createElement('button'); const del = document.createElement('button');
@@ -2738,13 +2742,13 @@ async function addModelDir(){
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
inp.value = ''; inp.value = '';
toast('dossier ajouté'); toast('dossier ajouté');
await Promise.all([populateModelDirs(), populateModelPicker()]); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
} }
async function removeModelDir(p){ async function removeModelDir(p){
const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); const r = await jpost('/api/models/dirs', {path: p, action: 'remove'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('dossier retiré'); toast('dossier retiré');
await Promise.all([populateModelDirs(), populateModelPicker()]); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
} }
function onPickModel(){ function onPickModel(){
const val = document.getElementById('m-model').value; const val = document.getElementById('m-model').value;
@@ -3237,17 +3241,50 @@ function resetDlUI(){
e.prog.style.display='none'; e.bar.style.display='none'; e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false; e.cancel.style.display='none'; e.btn.disabled=false;
} }
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
// de choisir, et ça évite une ligne de plus dans une modale déjà chargée.
// Même source que « Dossiers de modèles » : ajouter un disque externe là-bas le
// rend aussitôt disponible ici.
let dlDirList = [];
async function populateDlDirs(){
const sel = document.getElementById('m-hf-dir');
if(!sel) return;
let d = {};
try{ d = await jget('/api/models/dirs'); }catch(_){ return; }
dlDirList = d.dirs || [];
const prev = sel.value;
sel.innerHTML = '';
for(const x of dlDirList){
const o = document.createElement('option');
o.value = x.path;
o.textContent = x.path + (x.free >= 0 ? ' — ' + fmtSize(x.free) + ' libres' : '');
sel.append(o);
}
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
}
async function startDownload(){ async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim(); const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; } if(!url){ toast('colle un lien .gguf'); return; }
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
const e = dlEls(); const e = dlEls();
e.btn.disabled = true; e.btn.disabled = true;
e.prog.style.display = 'block'; e.prog.style.display = 'block';
e.prog.textContent = 'démarrage…'; e.prog.textContent = 'vérification de la taille et de l’espace disque…';
e.bar.style.display = 'block'; e.bar.style.display = 'block';
e.bar.className = 'pe-bar indet'; e.bar.className = 'pe-bar indet';
e.bar.firstElementChild.style.width = ''; e.bar.firstElementChild.style.width = '';
const r = await jpost('/api/models/download', {url}); // Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
const p = await jpost('/api/models/download/probe', {url, dir});
if(!p.ok || !p.enough){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false;
await populateDlDirs();
return;
}
e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres';
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){ if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>'; e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; return; e.bar.style.display = 'none'; e.btn.disabled=false; return;
@@ -3290,8 +3327,12 @@ function watchDownload(fname){
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>'; e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop(); stop();
await populateModelPicker(); await Promise.all([populateModelPicker(), populateDlDirs()]);
document.getElementById('m-model').value = fname; onPickModel(); // Le modèle a pu atterrir hors du dossier jean : l'option porte alors le
// chemin complet, pas le simple nom de fichier.
const sel = document.getElementById('m-model');
const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname));
if(opt){ sel.value = opt.value; onPickModel(); }
return; return;
} }
const pct = st.total>0 ? st.done*100/st.total : 0; const pct = st.total>0 ? st.done*100/st.total : 0;
@@ -3977,12 +4018,7 @@ async function loadLlamacpp(){
const fastInstalled = !!pb.bin; const fastInstalled = !!pb.bin;
const optInstalled = !!s.bin; const optInstalled = !!s.bin;
const head = document.getElementById('lc-status'); lcRenderReco(s.reco);
if(!fastInstalled && !optInstalled){
head.innerHTML = 'Installez le moteur d\'IA. Le <b>précompilé</b> convient à presque tout le monde.';
} else {
head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ⚙).';
}
lcRenderMode('fast', fastInstalled); lcRenderMode('fast', fastInstalled);
lcRenderMode('opt', optInstalled); lcRenderMode('opt', optInstalled);
lcRenderCustomCard(); lcRenderCustomCard();
@@ -4040,6 +4076,23 @@ function lcChipOpen(){
lcChipSync(null); lcChipSync(null);
} }
// Place la pastille « conseillé » sur la carte que le SERVEUR recommande pour
// cette machine (voir recommendedMode) : le précompilé partout, sauf sur Linux
// avec une carte NVIDIA où il ne donnerait que du Vulkan. La raison est ajoutée
// à la description de la carte, pour expliquer plutôt que d'imposer.
function lcRenderReco(reco){
const mode = (reco && reco.mode) || 'fast';
for(const m of ['fast','opt']){
const badge = document.getElementById('lc-reco-'+m);
if(badge) badge.hidden = (m !== mode);
}
// La raison REMPLACE la description générique de la carte conseillée : elle
// dit déjà ce que fait l'option et pourquoi c'est le bon choix ici. (loadAll
// repasse par là, donc pas d'accumulation possible.)
const desc = document.getElementById('lc-desc-'+mode);
if(desc && reco && reco.why) desc.textContent = reco.why;
}
function lcRenderMode(mode, installed){ function lcRenderMode(mode, installed){
const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt');
const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state');
+13 -10
View File
@@ -239,24 +239,23 @@
</div> </div>
</details> </details>
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary> <details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
<div class="subhead">Installation</div>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le <!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). --> étaient des cartes dans une carte). -->
<div class="slist"> <div class="slist">
<!-- Texte de départ IDENTIQUE (à la variante près) à celui posé après le <!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
chargement : un simple « … » faisait grandir la carte sous les yeux dès la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
que l'état arrivait. --> donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
<div class="rowbody"><div id="lc-status" class="lc-head muted">Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle.</div></div> alors que compiler donne du CUDA natif. -->
<div class="lc-modes"> <div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')"> <div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco">conseillé</span></div> <div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
<div class="lc-mode-d">binaire officiel téléchargé tel quel, prêt en ~2 min</div> <div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div> <div class="lc-mode-state" id="lc-fast-state"></div>
</div> </div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')"> <div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">llama.cpp compilé</div> <div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
<div class="lc-mode-d">compilé ici pour votre machine — plus rapide, plus long à installer</div> <div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-state" id="lc-opt-state"></div> <div class="lc-mode-state" id="lc-opt-state"></div>
</div> </div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()"> <div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
@@ -489,6 +488,10 @@
<div id="m-hf-progress" class="pe-note" style="display:none"></div> <div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button> <button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div> </div>
<div class="pe-row">
<span class="pe-row-l">Destination</span>
<span class="pe-selc"><select id="m-hf-dir"></select></span>
</div>
<div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()"> <div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()">
<span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span> <span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span>
<span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span> <span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span>
@@ -499,7 +502,7 @@
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models"> <input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button> <button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
</div> </div>
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier AJEAN.</div> <div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div> </div>
</div> </div>
</div> </div>
+46 -8
View File
@@ -153,7 +153,7 @@ async function openItem(kind, key){
document.getElementById('m-quant').value = currentQuantInTextarea(); document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings(); populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ? attachDownload(); // téléchargement encore en cours côté serveur ?
await Promise.all([populateBackend(), populateModelPicker()]); await Promise.all([populateBackend(), populateModelPicker(), populateDlDirs()]);
if(seq !== openSeq) return; if(seq !== openSeq) return;
} }
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend // On lève le voile SANS transition (voir la note dans styles.css), puis on rend
@@ -252,7 +252,8 @@ async function populateModelDirs(){
const info = document.createElement('span'); const info = document.createElement('span');
info.className = 'muted'; info.className = 'muted';
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
info.textContent = '(' + n + (x.home ? ', dossier jean' : '') + ')'; const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
info.textContent = '(' + n + free + (x.home ? ', dossier jean' : '') + ')';
row.append(p, info); row.append(p, info);
if(!x.home){ if(!x.home){
const del = document.createElement('button'); const del = document.createElement('button');
@@ -273,13 +274,13 @@ async function addModelDir(){
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
inp.value = ''; inp.value = '';
toast('dossier ajouté'); toast('dossier ajouté');
await Promise.all([populateModelDirs(), populateModelPicker()]); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
} }
async function removeModelDir(p){ async function removeModelDir(p){
const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); const r = await jpost('/api/models/dirs', {path: p, action: 'remove'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('dossier retiré'); toast('dossier retiré');
await Promise.all([populateModelDirs(), populateModelPicker()]); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
} }
function onPickModel(){ function onPickModel(){
const val = document.getElementById('m-model').value; const val = document.getElementById('m-model').value;
@@ -772,17 +773,50 @@ function resetDlUI(){
e.prog.style.display='none'; e.bar.style.display='none'; e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false; e.cancel.style.display='none'; e.btn.disabled=false;
} }
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
// de choisir, et ça évite une ligne de plus dans une modale déjà chargée.
// Même source que « Dossiers de modèles » : ajouter un disque externe là-bas le
// rend aussitôt disponible ici.
let dlDirList = [];
async function populateDlDirs(){
const sel = document.getElementById('m-hf-dir');
if(!sel) return;
let d = {};
try{ d = await jget('/api/models/dirs'); }catch(_){ return; }
dlDirList = d.dirs || [];
const prev = sel.value;
sel.innerHTML = '';
for(const x of dlDirList){
const o = document.createElement('option');
o.value = x.path;
o.textContent = x.path + (x.free >= 0 ? ' — ' + fmtSize(x.free) + ' libres' : '');
sel.append(o);
}
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
}
async function startDownload(){ async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim(); const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; } if(!url){ toast('colle un lien .gguf'); return; }
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
const e = dlEls(); const e = dlEls();
e.btn.disabled = true; e.btn.disabled = true;
e.prog.style.display = 'block'; e.prog.style.display = 'block';
e.prog.textContent = 'démarrage…'; e.prog.textContent = 'vérification de la taille et de l’espace disque…';
e.bar.style.display = 'block'; e.bar.style.display = 'block';
e.bar.className = 'pe-bar indet'; e.bar.className = 'pe-bar indet';
e.bar.firstElementChild.style.width = ''; e.bar.firstElementChild.style.width = '';
const r = await jpost('/api/models/download', {url}); // Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
const p = await jpost('/api/models/download/probe', {url, dir});
if(!p.ok || !p.enough){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false;
await populateDlDirs();
return;
}
e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres';
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){ if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>'; e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; return; e.bar.style.display = 'none'; e.btn.disabled=false; return;
@@ -825,8 +859,12 @@ function watchDownload(fname){
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>'; e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop(); stop();
await populateModelPicker(); await Promise.all([populateModelPicker(), populateDlDirs()]);
document.getElementById('m-model').value = fname; onPickModel(); // Le modèle a pu atterrir hors du dossier jean : l'option porte alors le
// chemin complet, pas le simple nom de fichier.
const sel = document.getElementById('m-model');
const opt = Array.from(sel.options).find(o => samePath(baseName(o.value), fname));
if(opt){ sel.value = opt.value; onPickModel(); }
return; return;
} }
const pct = st.total>0 ? st.done*100/st.total : 0; const pct = st.total>0 ? st.done*100/st.total : 0;
+18 -6
View File
@@ -15,12 +15,7 @@ async function loadLlamacpp(){
const fastInstalled = !!pb.bin; const fastInstalled = !!pb.bin;
const optInstalled = !!s.bin; const optInstalled = !!s.bin;
const head = document.getElementById('lc-status'); lcRenderReco(s.reco);
if(!fastInstalled && !optInstalled){
head.innerHTML = 'Installez le moteur d\'IA. Le <b>précompilé</b> convient à presque tout le monde.';
} else {
head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ⚙).';
}
lcRenderMode('fast', fastInstalled); lcRenderMode('fast', fastInstalled);
lcRenderMode('opt', optInstalled); lcRenderMode('opt', optInstalled);
lcRenderCustomCard(); lcRenderCustomCard();
@@ -78,6 +73,23 @@ function lcChipOpen(){
lcChipSync(null); lcChipSync(null);
} }
// Place la pastille « conseillé » sur la carte que le SERVEUR recommande pour
// cette machine (voir recommendedMode) : le précompilé partout, sauf sur Linux
// avec une carte NVIDIA où il ne donnerait que du Vulkan. La raison est ajoutée
// à la description de la carte, pour expliquer plutôt que d'imposer.
function lcRenderReco(reco){
const mode = (reco && reco.mode) || 'fast';
for(const m of ['fast','opt']){
const badge = document.getElementById('lc-reco-'+m);
if(badge) badge.hidden = (m !== mode);
}
// La raison REMPLACE la description générique de la carte conseillée : elle
// dit déjà ce que fait l'option et pourquoi c'est le bon choix ici. (loadAll
// repasse par là, donc pas d'accumulation possible.)
const desc = document.getElementById('lc-desc-'+mode);
if(desc && reco && reco.why) desc.textContent = reco.why;
}
function lcRenderMode(mode, installed){ function lcRenderMode(mode, installed){
const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt');
const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state');
+10 -10
View File
@@ -15,7 +15,7 @@ import (
) )
func handlePing(w http.ResponseWriter, r *http.Request) { func handlePing(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "service": "jean", "version": Version}) sendJSON(w, 200, map[string]any{"ok": true, "service": "ajean", "version": Version})
} }
// handleStatus reports service state cross-platform via serviceIsActive // handleStatus reports service state cross-platform via serviceIsActive
@@ -159,14 +159,14 @@ func handleConfigEnv(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, ReadConfig()) sendJSON(w, 200, ReadConfig())
} }
// handleBackends scans JEAN_HOME/backends/<name>/ for a llama-server binary, // handleBackends scans AJEAN_HOME/backends/<name>/ for a llama-server binary,
// trying common build subpaths (build/bin, build-sm120/bin, bin, .). // trying common build subpaths (build/bin, build-sm120/bin, bin, .).
// Returns [{name, path}]. // Returns [{name, path}].
func handleBackends(w http.ResponseWriter, r *http.Request) { func handleBackends(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, listBackendBins()) sendJSON(w, 200, listBackendBins())
} }
// listBackendBins scanne JEAN_HOME/backends/<name>/ pour un binaire llama-server // listBackendBins scanne AJEAN_HOME/backends/<name>/ pour un binaire llama-server
// (essaie les sous-chemins des différents générateurs CMake). Renvoie // (essaie les sous-chemins des différents générateurs CMake). Renvoie
// [{name, path}] — un par dossier de backend contenant un binaire. // [{name, path}] — un par dossier de backend contenant un binaire.
func listBackendBins() []map[string]any { func listBackendBins() []map[string]any {
@@ -263,9 +263,9 @@ func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) {
} }
// handleModels lists *.gguf files (size in bytes) for the preset editor's model // handleModels lists *.gguf files (size in bytes) for the preset editor's model
// picker, dans JEAN_HOME ET dans les dossiers supplémentaires déclarés (disque // picker, dans AJEAN_HOME ET dans les dossiers supplémentaires déclarés (disque
// externe…). "value" est ce qu'il faut écrire dans MODEL= : un simple nom de // externe…). "value" est ce qu'il faut écrire dans MODEL= : un simple nom de
// fichier pour JEAN_HOME (compatibilité avec l'existant), le chemin complet // fichier pour AJEAN_HOME (compatibilité avec l'existant), le chemin complet
// pour un dossier externe. // pour un dossier externe.
func handleModels(w http.ResponseWriter, r *http.Request) { func handleModels(w http.ResponseWriter, r *http.Request) {
out := []map[string]any{} out := []map[string]any{}
@@ -337,9 +337,9 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
func handlePreset(w http.ResponseWriter, r *http.Request) { func handlePreset(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id")) id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" { if id == "" {
// new preset → seed from current config.env so users can tweak rather than start blank // nouveau preset → pré-rempli avec la configuration active, pour ajuster
b, _ := os.ReadFile(confPath()) // plutôt que partir d'une page blanche
sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": string(b)}) sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(ReadConfig())})
return return
} }
content, err := ReadPreset(id) content, err := ReadPreset(id)
@@ -775,7 +775,7 @@ func handleMCPTest(w http.ResponseWriter, r *http.Request) {
func handleMem(w http.ResponseWriter, r *http.Request) { func handleMem(w http.ResponseWriter, r *http.Request) {
name := strings.TrimSpace(r.URL.Query().Get("name")) name := strings.TrimSpace(r.URL.Query().Get("name"))
if name == "" { if name == "" {
sendJSON(w, 200, map[string]any{"name": "", "content": "# nouvelle page\n\nNote ici ce que jean doit retenir entre les sessions.\n"}) sendJSON(w, 200, map[string]any{"name": "", "content": "# nouvelle page\n\nNote ici ce que ajean doit retenir entre les sessions.\n"})
return return
} }
c := MemContent(name) c := MemContent(name)
@@ -852,7 +852,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
// svcHandler returns an HTTP handler that triggers a start/stop/restart through // svcHandler returns an HTTP handler that triggers a start/stop/restart through
// the cross-platform serviceAction (systemd sous Linux, supervision PID-file // the cross-platform serviceAction (systemd sous Linux, supervision PID-file
// sous Windows — voir sys_service_*.go). C'est ce qui permet à un client distant // sous Windows — voir sys_service_*.go). C'est ce qui permet à un client distant
// de relancer Jean. // de relancer AJEAN.
func svcHandler(action string) http.HandlerFunc { func svcHandler(action string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
err := serviceAction(action) err := serviceAction(action)
+11 -29
View File
@@ -6,8 +6,6 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
) )
@@ -18,21 +16,11 @@ import (
// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle, // La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle,
// protège llama-server / les complétions). On veut pouvoir donner à un client // protège llama-server / les complétions). On veut pouvoir donner à un client
// un accès aux complétions sans lui donner le droit de redémarrer la machine, // un accès aux complétions sans lui donner le droit de redémarrer la machine,
// et inversement. Elle est stockée dans $JEAN_HOME/.web_key et lue à chaque // et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un
// requête (pas de cache) pour qu'un changement de clé prenne effet sans // changement de clé prenne effet sans redémarrer le serveur web.
// redémarrer le serveur web.
func webKeyPath() string { return filepath.Join(AjeanHome(), ".web_key") } // readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie.
func readWebKey() string { return getStr(bkState, "web_key") }
// readWebKey returns the trimmed contents of $JEAN_HOME/.web_key, or "" if the
// file is absent/empty.
func readWebKey() string {
b, err := os.ReadFile(webKeyPath())
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// requireWebAuth wraps an HTTP handler, rejecting requests that don't present // requireWebAuth wraps an HTTP handler, rejecting requests that don't present
// the configured Bearer token. When no key is configured the handler is left // the configured Bearer token. When no key is configured the handler is left
@@ -45,7 +33,7 @@ func requireWebAuth(next http.HandlerFunc) http.HandlerFunc {
return return
} }
if !checkBearer(r, key) { if !checkBearer(r, key) {
w.Header().Set("WWW-Authenticate", `Bearer realm="jean"`) w.Header().Set("WWW-Authenticate", `Bearer realm="ajean"`)
sendJSON(w, http.StatusUnauthorized, map[string]any{"error": "non autorisé"}) sendJSON(w, http.StatusUnauthorized, map[string]any{"error": "non autorisé"})
return return
} }
@@ -68,7 +56,7 @@ func checkBearer(r *http.Request, key string) bool {
return false return false
} }
// cmdSetWebKey sets (or clears) the control-API key in $JEAN_HOME/.web_key. // cmdSetWebKey sets (or clears) the control-API key in $AJEAN_HOME/.web_key.
// //
// ajean set-web-key <clé> définit la clé // ajean set-web-key <clé> définit la clé
// ajean set-web-key génère une clé aléatoire // ajean set-web-key génère une clé aléatoire
@@ -84,27 +72,21 @@ func cmdSetWebKey(args []string) error {
if _, err := rand.Read(buf); err != nil { if _, err := rand.Read(buf); err != nil {
return err return err
} }
key = "jean-web-" + hex.EncodeToString(buf) key = "ajean-web-" + hex.EncodeToString(buf)
fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key)) fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key))
case args[0] == "" || args[0] == "off" || args[0] == "none": case args[0] == "" || args[0] == "off" || args[0] == "none":
key = "" key = ""
default: default:
key = strings.TrimSpace(args[0]) key = strings.TrimSpace(args[0])
} }
if err := putStr(bkState, "web_key", key); err != nil {
return err
}
if key == "" { if key == "" {
if err := os.Remove(webKeyPath()); err != nil && !os.IsNotExist(err) {
return err
}
fmt.Printf("%s clé de pilotage supprimée — l'API web n'est plus protégée\n", yellow("[info]")) fmt.Printf("%s clé de pilotage supprimée — l'API web n'est plus protégée\n", yellow("[info]"))
return nil return nil
} }
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { fmt.Printf("%s clé de pilotage enregistrée\n", green("[ok]"))
return err
}
if err := os.WriteFile(webKeyPath(), []byte(key+"\n"), 0o600); err != nil {
return err
}
fmt.Printf("%s clé de pilotage enregistrée dans %s\n", green("[ok]"), webKeyPath())
fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key)) fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
fmt.Printf(" (relance 'ajean web' si le serveur web tourne déjà — non requis, lu à chaud)\n") fmt.Printf(" (relance 'ajean web' si le serveur web tourne déjà — non requis, lu à chaud)\n")
return nil return nil
+2 -2
View File
@@ -1,9 +1,9 @@
package ajean package ajean
// web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI // web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI
// web de Jean, pour éviter le terminal. Le panneau « Accès distant » ouvre une // web de AJEAN, pour éviter le terminal. Le panneau « Accès distant » ouvre une
// popup app.ajean.link/connect.html qui gère compte + paiement puis renvoie une // popup app.ajean.link/connect.html qui gère compte + paiement puis renvoie une
// clé de liaison (jl_…) ; l'UI la POSTe ici, et Jean fait le `ajean link` tout // clé de liaison (jl_…) ; l'UI la POSTe ici, et AJEAN fait le `ajean link` tout
// seul (écrit le token + (re)démarre le service). // seul (écrit le token + (re)démarre le service).
// //
// Ces routes ne sont accessibles qu'en LOCAL : à travers le tunnel du relais, // Ces routes ne sont accessibles qu'en LOCAL : à travers le tunnel du relais,
+5 -4
View File
@@ -181,6 +181,7 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) {
"in_use": pbBin != "" && prebuiltOwns(cfgBin), "in_use": pbBin != "" && prebuiltOwns(cfgBin),
} }
out["backends_dir"] = filepath.Join(AjeanHome(), "backends") out["backends_dir"] = filepath.Join(AjeanHome(), "backends")
out["reco"] = recommendedMode(plan.backend)
out["job"] = lcJobSnapshot(0, false) out["job"] = lcJobSnapshot(0, false)
sendJSON(w, 200, out) sendJSON(w, 200, out)
} }
@@ -344,9 +345,9 @@ func lcRunPrebuilt() {
bin, err := prebuiltInstall(lcAppend, lcPhase) bin, err := prebuiltInstall(lcAppend, lcPhase)
if err == nil { if err == nil {
if serr := SetConfigKey("BIN", bin); serr != nil { if serr := SetConfigKey("BIN", bin); serr != nil {
err = fmt.Errorf("binaires installés mais échec écriture BIN dans config.env : %w", serr) err = fmt.Errorf("binaires installés mais échec d'écriture de BIN : %w", serr)
} else { } else {
lcAppend("BIN mis à jour dans " + confPath()) lcAppend("BIN mis à jour")
} }
} }
if svcWasUp { if svcWasUp {
@@ -583,10 +584,10 @@ func lcBuildAndSwitch(repo string, clean bool) bool {
} }
lcAppend("binaire compilé : " + bin) lcAppend("binaire compilé : " + bin)
if err := SetConfigKey("BIN", bin); err != nil { if err := SetConfigKey("BIN", bin); err != nil {
lcFail(fmt.Errorf("build ok mais échec écriture BIN dans config.env : %w", err)) lcFail(fmt.Errorf("build ok mais échec d'écriture de BIN : %w", err))
return false return false
} }
lcAppend("BIN mis à jour dans " + confPath()) lcAppend("BIN mis à jour")
return true return true
} }
+6 -31
View File
@@ -3,28 +3,20 @@ package ajean
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os"
"path/filepath"
"strings" "strings"
"sync" "sync"
) )
// Préférences d'apparence de l'UI web (thème, mode d'affichage). Stockées côté // Préférences d'apparence de l'UI web (thème, mode d'affichage). Stockées côté
// serveur — sur la machine jean — pour être partagées entre tous les appareils // serveur — sur la machine AJEAN — pour être partagées entre tous les appareils
// qui pilotent la même instance (téléphone, laptop, accès distant ajean.link). // qui pilotent la même instance (téléphone, laptop, accès distant ajean.link).
// Avant ça, elles ne vivaient qu'en localStorage, donc par navigateur/appareil. // Avant ça, elles ne vivaient qu'en localStorage, donc par navigateur/appareil.
// webPrefsPath est le fichier JSON des préférences d'apparence.
func webPrefsPath() string { return filepath.Join(AjeanHome(), "webprefs.json") }
var webPrefsMu sync.Mutex var webPrefsMu sync.Mutex
// webPrefsAllowed liste les clés de préférence acceptées et, pour chacune, les // webPrefsAllowed liste les clés de préférence acceptées et, pour chacune, les
// valeurs valides. On ne stocke que ce qui est connu (pas de champ libre). // valeurs valides. On ne stocke que ce qui est connu (pas de champ libre).
var webPrefsAllowed = map[string]map[string]bool{ var webPrefsAllowed = map[string]map[string]bool{
// Un seul thème, deux variantes. Les anciennes valeurs (ajean, gpt-dark…)
// ne sont plus acceptées : le client les normalise en clair/sombre au
// chargement puis réenregistre la valeur normalisée.
"theme": {"light": true, "dark": true}, "theme": {"light": true, "dark": true},
"hide_reasoning": {"0": true, "1": true}, "hide_reasoning": {"0": true, "1": true},
"hide_tools": {"0": true, "1": true}, "hide_tools": {"0": true, "1": true},
@@ -33,30 +25,20 @@ var webPrefsAllowed = map[string]map[string]bool{
"hide_stats": {"0": true, "1": true}, "hide_stats": {"0": true, "1": true},
} }
// loadWebPrefs lit les préférences enregistrées (map vide si absent/illisible). // loadWebPrefs lit les préférences enregistrées (map vide si aucune).
func loadWebPrefs() map[string]string { func loadWebPrefs() map[string]string {
out := map[string]string{} out := map[string]string{}
b, err := os.ReadFile(webPrefsPath()) for k, v := range allKV(bkPrefs) {
if err != nil { out[k] = v
return out
} }
_ = json.Unmarshal(b, &out)
return out return out
} }
// saveWebPrefs fusionne les valeurs valides de `in` dans le fichier et l'écrit. // saveWebPrefs fusionne les valeurs valides de `in` puis enregistre le tout.
func saveWebPrefs(in map[string]string) (map[string]string, error) { func saveWebPrefs(in map[string]string) (map[string]string, error) {
webPrefsMu.Lock() webPrefsMu.Lock()
defer webPrefsMu.Unlock() defer webPrefsMu.Unlock()
prefs := loadWebPrefs() prefs := loadWebPrefs()
// Purge les clés/valeurs devenues invalides (anciens thèmes, réglage de
// police retiré) : sans ça, un fichier écrit par une version précédente
// renverrait indéfiniment des valeurs que le client ne sait plus traiter.
for k, v := range prefs {
if allowed, ok := webPrefsAllowed[k]; !ok || !allowed[v] {
delete(prefs, k)
}
}
for k, v := range in { for k, v := range in {
allowed, ok := webPrefsAllowed[k] allowed, ok := webPrefsAllowed[k]
if !ok { if !ok {
@@ -68,14 +50,7 @@ func saveWebPrefs(in map[string]string) (map[string]string, error) {
} }
prefs[k] = v prefs[k] = v
} }
b, err := json.MarshalIndent(prefs, "", " ") return prefs, replaceKV(bkPrefs, prefs)
if err != nil {
return prefs, err
}
if err := os.WriteFile(webPrefsPath(), b, 0o600); err != nil {
return prefs, err
}
return prefs, nil
} }
// handleWebPrefs expose les préférences d'apparence partagées entre appareils. // handleWebPrefs expose les préférences d'apparence partagées entre appareils.
+3 -2
View File
@@ -100,6 +100,7 @@ func newWebMux() *http.ServeMux {
api("/api/models/delete", handleModelDelete) api("/api/models/delete", handleModelDelete)
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…) api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
api("/api/models/download", handleModelDownload) api("/api/models/download", handleModelDownload)
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
api("/api/models/download/status", handleModelDownloadStatus) api("/api/models/download/status", handleModelDownloadStatus)
api("/api/models/download/cancel", handleModelDownloadCancel) api("/api/models/download/cancel", handleModelDownloadCancel)
api("/api/backends", handleBackends) api("/api/backends", handleBackends)
@@ -139,7 +140,7 @@ func newWebMux() *http.ServeMux {
api("/api/memory", handleMemoryMode) api("/api/memory", handleMemoryMode)
api("/api/prefs", handleWebPrefs) api("/api/prefs", handleWebPrefs)
api("/api/sysprompt", handleSysPrompt) api("/api/sysprompt", handleSysPrompt)
// Alias rétro-compat : l'ancien portail ajean.link (dépôt jean-relay) pilote // Alias rétro-compat : l'ancien portail ajean.link (dépôt ajean-relay) pilote
// encore l'agent via /api/tools* et /api/skills/toggle à travers le tunnel E2E. // encore l'agent via /api/tools* et /api/skills/toggle à travers le tunnel E2E.
// On les mappe sur le mode agent unifié le temps que le portail soit mis à jour. // On les mappe sur le mode agent unifié le temps que le portail soit mis à jour.
api("/api/tools", handleAgent) api("/api/tools", handleAgent)
@@ -261,7 +262,7 @@ func pidOnPort(port int) (int, string) {
// processName returns a short command name for a PID, or "?" if unknown. // processName returns a short command name for a PID, or "?" if unknown.
func processName(pid int) string { func processName(pid int) string {
if runtime.GOOS == "windows" { if runtime.GOOS == "windows" {
// tasklist CSV : "jean.exe","1234","Console","1","12 345 K" // tasklist CSV : "ajean.exe","1234","Console","1","12 345 K"
out, err := hideCmd(exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH")).Output() out, err := hideCmd(exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH")).Output()
if err == nil { if err == nil {
if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 { if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 {
+1 -1
View File
@@ -1,5 +1,5 @@
// web_sysprompt.go — prompt système personnalisé de l'utilisateur, persisté // web_sysprompt.go — prompt système personnalisé de l'utilisateur, persisté
// CÔTÉ SERVEUR ($JEAN_HOME/sysprompt.txt) et partagé entre appareils, comme la // CÔTÉ SERVEUR ($AJEAN_HOME/sysprompt.txt) et partagé entre appareils, comme la
// conversation elle-même. Historique : avant la conversation serveur (v0.4.x), // conversation elle-même. Historique : avant la conversation serveur (v0.4.x),
// l'UI envoyait son prompt système dans chaque requête /api/chat ; depuis, // l'UI envoyait son prompt système dans chaque requête /api/chat ; depuis,
// /api/chat/send ne porte que le message → le champ de l'UI n'avait plus aucun // /api/chat/send ne porte que le message → le champ de l'UI n'avait plus aucun