diff --git a/go.mod b/go.mod index 8dc79e7..8c0093c 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/segmentio/encoding v0.5.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // 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/zap v1.27.1 // indirect go.uber.org/zap/exp v0.3.0 // indirect diff --git a/go.sum b/go.sum index 6e85cf1..f2dfb1c 100644 --- a/go.sum +++ b/go.sum @@ -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/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= 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/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= diff --git a/internal/ajean/backend_build.go b/internal/ajean/backend_build.go index ec4f012..53e4918 100644 --- a/internal/ajean/backend_build.go +++ b/internal/ajean/backend_build.go @@ -73,7 +73,7 @@ func detectBuildPlan() buildPlan { // 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 // 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 // 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 diff --git a/internal/ajean/backend_buildfilter_test.go b/internal/ajean/backend_buildfilter_test.go index a1dd2ab..e2a2739 100644 --- a/internal/ajean/backend_buildfilter_test.go +++ b/internal/ajean/backend_buildfilter_test.go @@ -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", // 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"`: "", - `Building Custom Rule C:/ProgramData/jean/...`: "", + `Building Custom Rule C:/ProgramData/ajean/...`: "", `-- UI: running npm install`: "", } for in, want := range cases { diff --git a/internal/ajean/backend_catalog.go b/internal/ajean/backend_catalog.go index d63feb3..380bae4 100644 --- a/internal/ajean/backend_catalog.go +++ b/internal/ajean/backend_catalog.go @@ -5,7 +5,7 @@ package ajean // 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 -// 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. import ( diff --git a/internal/ajean/backend_config.go b/internal/ajean/backend_config.go index 5cb1e5f..ec83097 100644 --- a/internal/ajean/backend_config.go +++ b/internal/ajean/backend_config.go @@ -7,23 +7,18 @@ import ( "fmt" "net/http" "os" + "sort" "strconv" "strings" ) -// readAPIKey returns the trimmed contents of $JEAN_HOME/.api_key, or "" if the -// file is absent/empty. This store is independent of config.env so the key -// survives preset switches (which rewrite config.env wholesale). -func readAPIKey() string { - b, err := os.ReadFile(apiKeyPath()) - if err != nil { - return "" - } - return strings.TrimSpace(string(b)) -} +// readAPIKey renvoie la clé Bearer de llama-server, ou "" si aucune n'est +// définie. Elle est rangée hors de la configuration pour survivre aux +// changements de preset, qui remplacent la configuration en bloc. +func readAPIKey() string { return getStr(bkState, "api_key") } // 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. func authHeader(req *http.Request) { if k := readAPIKey(); k != "" { @@ -35,39 +30,32 @@ func authHeader(req *http.Request) { func genAPIKey() string { buf := make([]byte, 24) _, _ = 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 -// in $JEAN_HOME/.api_key. It also wipes any residual API_KEY in config.env to -// avoid ambiguity. It does NOT restart the service — callers decide when to -// apply (llama-server only reads --api-key at launch). +// writeAPIKey enregistre (key != "") ou supprime (key == "") la clé Bearer. +// Ne redémarre PAS le service : llama-server ne lit --api-key qu'au lancement, +// c'est à l'appelant de choisir quand appliquer. func writeAPIKey(key string) error { - _ = SetConfigKey("API_KEY", "") - if key == "" { - if err := os.Remove(apiKeyPath()); err != nil && !os.IsNotExist(err) { - return err - } - return nil - } - return os.WriteFile(apiKeyPath(), []byte(key+"\n"), 0o600) + _ = SetConfigKey("API_KEY", "") // aucune ambiguïté avec une valeur résiduelle + return putStr(bkState, "api_key", key) } -// 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. func maskAPIKey(k string) string { if k == "" { return "" } - if len(k) <= 12 { + if len(k) <= 13 { 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 -// exposed on the internet, llama-server requires "Authorization: Bearer " -// for every call. +// cmdSetAPIKey définit (ou supprime) la clé Bearer de llama-server. Exposé sur +// internet, le serveur exige alors « Authorization: Bearer » à chaque +// appel. // // ajean set-api-key définit la clé // ajean set-api-key génère une clé aléatoire @@ -83,15 +71,13 @@ func cmdSetAPIKey(args []string) error { default: 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 { return err } if key == "" { fmt.Printf("%s API_KEY supprimée — serveur ouvert (pas d'authentification)\n", yellow("[info]")) } 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.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] ")) @@ -103,72 +89,54 @@ func cmdSetAPIKey(args []string) error { return serviceAction("restart") } -// ReadConfig parses config.env into a key/value map. -// Lines starting with '#' and blanks are ignored. Values may be quoted with ". -func ReadConfig() map[string]string { +// ReadConfig renvoie la configuration active de llama-server. +func ReadConfig() map[string]string { return allKV(bkConfig) } + +// 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{} - b, err := os.ReadFile(confPath()) - if err != nil { - return m - } - for _, line := range strings.Split(string(b), "\n") { + for _, line := range strings.Split(text, "\n") { s := strings.TrimSpace(line) if s == "" || strings.HasPrefix(s, "#") { continue } - i := strings.IndexByte(s, '=') - if i < 0 { + k, v, ok := strings.Cut(strings.TrimPrefix(s, "export "), "=") + if !ok { continue } - k := strings.TrimSpace(s[:i]) - v := strings.TrimSpace(s[i+1:]) - v = strings.Trim(v, "\"") - m[k] = v + m[strings.TrimSpace(k)] = strings.Trim(strings.TrimSpace(v), "\"") } return m } -// SetConfigKey sets key=value in config.env, updating the line in place if the -// key already exists (preserving comments/order) or appending it otherwise. -// An empty value removes the key. The file is created if missing. -func SetConfigKey(key, value string) error { - b, _ := os.ReadFile(confPath()) - lines := []string{} - if len(b) > 0 { - lines = strings.Split(strings.TrimRight(string(b), "\n"), "\n") +// formatEnv rend une configuration au format des presets, clés triées pour que +// deux écritures du même contenu donnent le même fichier. +func formatEnv(m map[string]string) string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) } - newLine := key + "=" + value - found := false - out := []string{} - for _, line := range lines { - 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) + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + fmt.Fprintf(&b, "%s=%s\n", k, m[k]) } - if !found && value != "" { - out = append(out, newLine) - } - content := strings.Join(out, "\n") + "\n" - return os.WriteFile(confPath(), []byte(content), 0o644) + return b.String() } // 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 // 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 { if p, ok := ReadConfig()["PORT"]; ok { if n, err := strconv.Atoi(p); err == nil && n > 0 { diff --git a/internal/ajean/backend_llamacpp.go b/internal/ajean/backend_llamacpp.go index 4208ccf..c84f664 100644 --- a/internal/ajean/backend_llamacpp.go +++ b/internal/ajean/backend_llamacpp.go @@ -53,9 +53,9 @@ func cmdLlamacpp(args []string) error { return err } 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 default: 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 // 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 { if bin := ReadConfig()["BIN"]; bin != "" { 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) 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 } 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", bold("ajean edit"), bold("ajean restart")) return nil diff --git a/internal/ajean/backend_model_dirs.go b/internal/ajean/backend_model_dirs.go index e485763..e191d47 100644 --- a/internal/ajean/backend_model_dirs.go +++ b/internal/ajean/backend_model_dirs.go @@ -10,14 +10,13 @@ import ( "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 -// (disque dur externe, second SSD, montage réseau…). La liste est persistée -// dans $JEAN_HOME/model_dirs.json — PAS dans config.env, que le changement de -// preset réécrit — et peut aussi venir de $JEAN_MODEL_DIRS. -func modelDirsPath() string { return filepath.Join(AjeanHome(), "model_dirs.json") } +// (disque dur externe, second SSD, montage réseau…). La liste est persistée en +// base — PAS dans la configuration, que le changement de preset remplace — et +// peut aussi venir de $AJEAN_MODEL_DIRS. -// 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). func modelDirsListSep() string { if runtime.GOOS == "windows" { @@ -26,20 +25,15 @@ func modelDirsListSep() string { return ":" } -// extraModelDirs renvoie les dossiers déclarés par l'utilisateur (fichier puis -// variable d'environnement), nettoyés et dédoublonnés, JEAN_HOME exclu. +// extraModelDirs renvoie les dossiers déclarés par l'utilisateur (base puis +// variable d'environnement), nettoyés et dédoublonnés, models/ exclu. func extraModelDirs() []string { var raw []string - if b, err := os.ReadFile(modelDirsPath()); err == nil { - var dirs []string - if json.Unmarshal(b, &dirs) == nil { - raw = append(raw, dirs...) - } - } - if v := envAny("AJEAN_MODEL_DIRS", "JEAN_MODEL_DIRS"); v != "" { + getJSON(bkState, "model_dirs", &raw) + if v := os.Getenv("AJEAN_MODEL_DIRS"); v != "" { 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 @@ -79,23 +73,15 @@ func normDir(p string) string { 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. 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 { - clean := dedupDirs(dirs, AjeanHome()) - 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) + return putJSON(bkState, "model_dirs", dedupDirs(dirs, modelsDir())) } // 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 // 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 { p = strings.TrimRight(strings.TrimSpace(p), `/\`) 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 // 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 -// 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) { s := strings.TrimSpace(strings.Trim(strings.TrimSpace(name), `"`)) if s == "" { @@ -147,7 +133,7 @@ func resolveModelPath(name string) (string, error) { 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). @@ -165,7 +151,48 @@ func modelDirCount(dir string) int { 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. func handleModelDirs(w http.ResponseWriter, r *http.Request) { if r.Method == "POST" { @@ -211,10 +238,10 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"ok": true}) 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() { 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") != ""}) } diff --git a/internal/ajean/backend_model_dirs_test.go b/internal/ajean/backend_model_dirs_test.go index f7700a8..91cbb3a 100644 --- a/internal/ajean/backend_model_dirs_test.go +++ b/internal/ajean/backend_model_dirs_test.go @@ -6,13 +6,13 @@ import ( "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. func TestResolveModelPathExtraDir(t *testing.T) { home := t.TempDir() ext := t.TempDir() - t.Setenv("JEAN_HOME", home) - t.Setenv("JEAN_MODEL_DIRS", "") + t.Setenv("AJEAN_HOME", home) + t.Setenv("AJEAN_MODEL_DIRS", "") extModel := filepath.Join(ext, "gros.gguf") 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) { home := t.TempDir() ext := t.TempDir() - t.Setenv("JEAN_HOME", home) - t.Setenv("JEAN_MODEL_DIRS", ext) + t.Setenv("AJEAN_HOME", home) + t.Setenv("AJEAN_MODEL_DIRS", ext) for _, d := range []string{home, ext} { if err := os.WriteFile(filepath.Join(d, "m.gguf"), []byte("x"), 0o644); err != nil { t.Fatal(err) @@ -52,7 +52,7 @@ func TestResolveModelPathHomeFirst(t *testing.T) { } got, err := resolveModelPath("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 { t.Fatal("un fichier non-.gguf devrait être refusé") diff --git a/internal/ajean/backend_models.go b/internal/ajean/backend_models.go index 45e9117..ea68e91 100644 --- a/internal/ajean/backend_models.go +++ b/internal/ajean/backend_models.go @@ -86,10 +86,10 @@ func detectQuant(content string) string { return quantFromName(baseName(modelFromPresetContent(content))) } -// downloadDestPath resolves the destination of a downloaded model: always -// JEAN_HOME (les dossiers externes sont en lecture, on n'y écrit pas), en +// downloadDestPath resolves the destination of a downloaded model : le dossier +// demandé (vide = AJEAN_HOME) parmi les dossiers de modèles déclarés, en // 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)) if base == "" || base == "." || base == string(filepath.Separator) { 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") { 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 @@ -135,7 +139,7 @@ func deleteModelFile(name string) error { 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) { var req struct { Name string `json:"name"` @@ -157,6 +161,7 @@ func handleModelDelete(w http.ResponseWriter, r *http.Request) { type dlState struct { Filename string `json:"filename"` URL string `json:"url"` + Dir string `json:"dir"` // dossier de destination Total int64 `json:"total"` Done int64 `json:"done"` 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. -// Overridable with JEAN_DL_CONNS (1 disables parallelism). +// Overridable with AJEAN_DL_CONNS (1 disables parallelism). func dlConns() int { 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 { 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 -// (typically Hugging Face) into JEAN_HOME. Progress is polled via +// (typically Hugging Face) into AJEAN_HOME. Progress is polled via // /api/models/download/status. func handleModelDownload(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()}) @@ -257,11 +263,15 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } - dest, err := downloadDestPath(name) + dest, err := downloadDestPath(name, req.Dir) if err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) 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() if st, ok := dlDownloads[name]; ok && !st.Finished { @@ -275,7 +285,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) { return } 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 dlMu.Unlock() @@ -283,6 +293,74 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) { 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 // (gated/private repos) and an optional Range header. 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 != "" { 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 if rng != "" { req.Header.Set("Range", rng) @@ -374,6 +452,12 @@ func runDownload(ctx context.Context, st *dlState, dlURL, dest string) { finish(err) 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 if ranged && total > 0 { @@ -595,18 +679,20 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) { 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 // (its state lived in memory), so the partial file would otherwise sit there // forever eating disk. func cleanStalePartFiles() { - matches, err := filepath.Glob(filepath.Join(AjeanHome(), "*.gguf.part")) - if err != nil { - return - } - for _, p := range matches { - if err := os.Remove(p); err == nil { - fmt.Printf("[models] téléchargement incomplet supprimé : %s\n", filepath.Base(p)) + for _, dir := range modelDirs() { + matches, err := filepath.Glob(filepath.Join(dir, "*.gguf.part")) + if err != nil { + continue + } + for _, p := range matches { + if err := os.Remove(p); err == nil { + fmt.Printf("[models] téléchargement incomplet supprimé : %s\n", p) + } } } } diff --git a/internal/ajean/backend_models_dest_test.go b/internal/ajean/backend_models_dest_test.go new file mode 100644 index 0000000..5493a98 --- /dev/null +++ b/internal/ajean/backend_models_dest_test.go @@ -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é") + } +} diff --git a/internal/ajean/backend_models_dl_test.go b/internal/ajean/backend_models_dl_test.go index ec8a09f..4c0cd1f 100644 --- a/internal/ajean/backend_models_dl_test.go +++ b/internal/ajean/backend_models_dl_test.go @@ -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. func TestRunDownloadCancelLeavesNothing(t *testing.T) { - t.Setenv("JEAN_DL_CONNS", "4") + t.Setenv("AJEAN_DL_CONNS", "4") release := make(chan struct{}) 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 @@ -76,7 +76,7 @@ func TestRunDownloadCancelLeavesNothing(t *testing.T) { // cleanStalePartFiles doit balayer les .part orphelins sans toucher aux .gguf. func TestCleanStalePartFiles(t *testing.T) { dir := t.TempDir() - t.Setenv("JEAN_HOME", dir) + t.Setenv("AJEAN_HOME", dir) keep := filepath.Join(dir, "bon.gguf") stale := filepath.Join(dir, "coupe.gguf.part") for _, p := range []string{keep, stale} { @@ -96,7 +96,7 @@ func TestCleanStalePartFiles(t *testing.T) { func TestRunDownloadParallelAndFallback(t *testing.T) { data := make([]byte, 48<<20) // > 3×dlMinChunk so the split actually kicks in 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} { srv := serveBlob(data, ranges) diff --git a/internal/ajean/backend_prebuilt.go b/internal/ajean/backend_prebuilt.go index 8907c65..8330b8f 100644 --- a/internal/ajean/backend_prebuilt.go +++ b/internal/ajean/backend_prebuilt.go @@ -337,6 +337,27 @@ func pickPrebuilt(assets []ghAsset) (main *ghAsset, cudart *ghAsset, label, cuda 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 // précompilés. logf reçoit chaque ligne de log ; phasef la phase courante. // 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)) // 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 // une ré-extraction propre au lieu d'un « déjà à jour » trompeur. if cur := prebuiltServerBin(); curTag == tag && prebuiltVersionFormat() == prebuiltFormat && cur != "" { diff --git a/internal/ajean/backend_prebuilt_reco_test.go b/internal/ajean/backend_prebuilt_reco_test.go new file mode 100644 index 0000000..497b132 --- /dev/null +++ b/internal/ajean/backend_prebuilt_reco_test.go @@ -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) + } + } +} diff --git a/internal/ajean/backend_presets.go b/internal/ajean/backend_presets.go index 3b79ec0..8b88298 100644 --- a/internal/ajean/backend_presets.go +++ b/internal/ajean/backend_presets.go @@ -51,44 +51,29 @@ func withDisplayName(content, name string) string { return line + "\n" + content } -// presetFingerprint hashes config content while IGNORING the device-level keys -// (preservedKeys) that SwitchToPreset re-applies on top of a freshly copied -// preset. Without this, those injected MEM_MODE/CRAWL4AI_URL lines make -// config.env differ from every preset file, so no preset is ever detected as -// active (regression introduced when preservedKeys were added). We strip those -// assignment lines symmetrically from both sides before hashing so a plain -// content match still identifies the active preset. -func presetFingerprint(content []byte) string { - // On réduit le fichier à l'ENSEMBLE de ses affectations KEY=VALUE effectives, - // 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, "#") { +// configFingerprint réduit une configuration à l'ENSEMBLE TRIÉ de ses +// affectations effectives, puis le hache. Les clés « appareil » (preservedKeys, +// réappliquées par SwitchToPreset) sont ignorées : sans ça, les MEM_MODE / +// CRAWL4AI_URL injectés rendraient la config active différente de TOUS les +// presets, et aucun ne serait jamais détecté comme actif. +func configFingerprint(m map[string]string) string { + pairs := make([]string, 0, len(m)) + for k, v := range m { + if isPreservedKey(k) { continue } - t = strings.TrimPrefix(t, "export ") - 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:])) + pairs = append(pairs, k+"="+v) } sort.Strings(pairs) h := sha1.Sum([]byte(strings.Join(pairs, "\n"))) 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. func isPreservedKey(key string) bool { for _, k := range preservedKeys { @@ -99,15 +84,12 @@ func isPreservedKey(key string) bool { return false } -// ListPresets returns all configs/*.env, marking the one whose contents match -// the current config.env (ignoring device-level preservedKeys), sorted by name. +// ListPresets renvoie tous les presets/*.env, en marquant celui qui correspond +// à la configuration active (clés « appareil » ignorées), triés par nom. func ListPresets() ([]Preset, error) { dir := presetsDir() _ = os.MkdirAll(dir, 0o755) - cur := "" - if b, err := os.ReadFile(confPath()); err == nil { - cur = presetFingerprint(b) - } + cur := configFingerprint(ReadConfig()) entries, err := os.ReadDir(dir) if err != nil { 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. var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true} -// presetDefines dit si le contenu d'un preset affecte explicitement la clé. -func presetDefines(content []byte, key string) bool { - for _, line := range strings.Split(string(content), "\n") { - 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). +// applyPresetFile installe le preset comme configuration active, 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 vrai llama-server). func applyPresetFile(target string) error { src, err := os.ReadFile(target) if err != nil { 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() - 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 { v, ok := cur[k] if !ok { continue } - if softPreservedKeys[k] && presetDefines(src, k) { - continue // le preset a son mot à dire sur cette clé : on le laisse gagner + if softPreservedKeys[k] { + 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, -// then restarts the service. Les réglages « appareil » (preservedKeys) sont -// conservés à travers la bascule. +// SwitchToPreset installe le preset et redémarre le service. Les réglages +// « appareil » (preservedKeys) sont conservés à travers la bascule. func SwitchToPreset(target string) error { if err := applyPresetFile(target); err != nil { 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...")) return serviceAction("restart") } @@ -342,12 +307,11 @@ func DeletePreset(id string) error { if err != nil { return err } - cur, _ := os.ReadFile(confPath()) target, err := os.ReadFile(p) if err != nil { 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 os.Remove(p) diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index 674684f..5a2dc95 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -13,15 +13,15 @@ func cmdServe(args []string) error { cfg := ReadConfig() bin := cfg["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"] 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 - // 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 // llama-server ne trouvait rien. On résout donc explicitement, quel que soit // 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 // exige alors l'en-tête "Authorization: Bearer ". 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. if k := readAPIKey(); k != "" { llmArgs = append(llmArgs, "--api-key", k) @@ -104,7 +104,7 @@ func cmdServe(args []string) error { 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. _ = os.Chdir(AjeanHome()) diff --git a/internal/ajean/chat_agent.go b/internal/ajean/chat_agent.go index 48b5b62..768d1fa 100644 --- a/internal/ajean/chat_agent.go +++ b/internal/ajean/chat_agent.go @@ -1,49 +1,14 @@ package ajean -import ( - "fmt" - "os" -) +import "fmt" // 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 -// mémoire (mem_search/mem_read/mem_add/mem_edit) — les anciens « skills » ont -// été fondus dans la mémoire. Plus de drapeaux machine/skills séparés : un seul -// fichier .agent_enabled. +// outils : le shell (run_shell), l'écriture de fichiers et sa mémoire +// (mem_search/mem_read/mem_add/mem_edit). -func agentEnabled() bool { - 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 agentEnabled() bool { return getBool(bkState, "agent") } -func setAgentEnabled(on bool) error { - _ = 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 setAgentEnabled(on bool) error { return putBool(bkState, "agent", on) } func cmdAgent(args []string) error { sub := "" diff --git a/internal/ajean/chat_compact.go b/internal/ajean/chat_compact.go index a7aeca0..14a2746 100644 --- a/internal/ajean/chat_compact.go +++ b/internal/ajean/chat_compact.go @@ -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 -// (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é // 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. @@ -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 // 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. before, after := estimateTokens(msgs), estimateTokens(out) 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.` payload := map[string]any{ - "model": "jean", + "model": "ajean", "messages": []Message{ {Role: "system", Content: sys}, {Role: "user", Content: transcript}, diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go index 1656121..c84dc6f 100644 --- a/internal/ajean/chat_conversation.go +++ b/internal/ajean/chat_conversation.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "fmt" - "os" - "path/filepath" "strings" "sync" "time" @@ -65,16 +63,15 @@ var conv = func() *Conversation { return c }() -// convPath = fichier de persistance sur la box jean (AjeanHome = /etc/jean en -// prod). En clair : la box déchiffre déjà pour lancer le modèle, le relais reste -// aveugle — le persister ici ne change rien à la posture E2E. -func convPath() string { return filepath.Join(AjeanHome(), "conversation.json") } +// La conversation est persistée en base, sur la machine qui fait tourner le +// modèle. En clair : cette machine déchiffre déjà pour lancer le modèle, le +// relais reste aveugle — la persister ici ne change rien à la posture E2E. -// LoadConversation recharge l'état persisté au démarrage du process. Sans fichier -// (première fois) on part d'une conversation vide. +// LoadConversation recharge l'état persisté au démarrage du process. Sans état +// enregistré (première fois) on part d'une conversation vide. func LoadConversation() { - b, err := os.ReadFile(convPath()) - if err != nil { + b := getBytes(bkChat, "conversation") + if len(b) == 0 { return } conv.mu.Lock() @@ -85,8 +82,8 @@ func LoadConversation() { conv.cancel = nil } -// persist écrit l'état sur disque (appelé en fin de tour et sur reset, pas à -// chaque delta). L'appelant NE doit PAS détenir mu. +// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque +// delta). L'appelant NE doit PAS détenir mu. func (c *Conversation) persist() { c.mu.Lock() b, err := json.Marshal(c) @@ -94,12 +91,7 @@ func (c *Conversation) persist() { if err != nil { return } - _ = os.MkdirAll(AjeanHome(), 0o755) - tmp := convPath() + ".tmp" - if err := os.WriteFile(tmp, b, 0o600); err != nil { - return - } - _ = os.Rename(tmp, convPath()) + _ = putBytes(bkChat, "conversation", b) } // appendDelta journalise un événement d'affichage et réveille les abonnés. diff --git a/internal/ajean/chat_internet.go b/internal/ajean/chat_internet.go index 90011f6..dd82c0e 100644 --- a/internal/ajean/chat_internet.go +++ b/internal/ajean/chat_internet.go @@ -10,7 +10,6 @@ import ( "io" "net/http" "net/url" - "os" "regexp" "strings" "sync" @@ -19,7 +18,7 @@ import ( // 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 // 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 // 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 -// dernier est le fichier qu'on copie-colle pour demander de l'aide, une clé en -// clair dedans finit par fuiter. Même modèle que la clé de complétion -// (.api_key). Les installations qui l'avaient dans config.env sont migrées à -// 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 "" -} +// Elle est rangée hors de la configuration : celle-ci est ce qu'on copie-colle +// pour demander de l'aide, et une clé en clair dedans finit par fuiter. Même +// modèle que la clé de complétion. +func crawl4aiKey() string { return getStr(bkState, "crawl_key") } // writeCrawlKey enregistre (clé non vide) ou efface (clé vide) la clé Crawl4AI. func writeCrawlKey(key string) error { - _ = SetConfigKey("CRAWL4AI_KEY", "") // ne laisse rien traîner dans config.env - if 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) + _ = SetConfigKey("CRAWL4AI_KEY", "") // ne laisse rien traîner dans la config + return putStr(bkState, "crawl_key", key) } // 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 -// un moteur web utilisable. Même modèle que agentEnabled() (chat_agent.go). -// -// 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. +// internetEnabled : accès internet actif = interrupteur armé ET moteur web +// utilisable. Avec le moteur intégré (engineGo) il n'y a rien à configurer ; +// avec Crawl4AI il faut en plus une URL de serveur. func internetEnabled() bool { if webEngine() == engineCrawl && crawl4aiURL() == "" { return false } - _, err := os.Stat(internetFlag()) - return err == nil + return getBool(bkState, "internet") } -func setInternetEnabled(on bool) error { - _ = 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 -} +func setInternetEnabled(on bool) error { return putBool(bkState, "internet", on) } // 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 diff --git a/internal/ajean/chat_memory.go b/internal/ajean/chat_memory.go index 8eb9ff4..248e07a 100644 --- a/internal/ajean/chat_memory.go +++ b/internal/ajean/chat_memory.go @@ -10,7 +10,7 @@ import ( "strings" ) -// La mémoire de jean = des fichiers Markdown plats sous MEMORY/.md. +// La mémoire de ajean = des fichiers Markdown plats sous MEMORY/.md. // 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, // mem_add, mem_edit. @@ -299,47 +299,6 @@ func MemDelete(name string) error { return os.Remove(p) } -// migrateSkillsToMemory copie une seule fois les anciens skills -// (SKILLS//SKILL.md) vers la mémoire (MEMORY/.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 // mode agent (shell). Trois modes : // - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne). diff --git a/internal/ajean/chat_tools.go b/internal/ajean/chat_tools.go index 4cb81a5..8a43dea 100644 --- a/internal/ajean/chat_tools.go +++ b/internal/ajean/chat_tools.go @@ -40,9 +40,9 @@ func baseSystemPrompt(caps Caps) string { // sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur // 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. - // « Jean » 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 »). - b.WriteString("You are Jean, an expert assistant operating directly on this machine with real tools.") + // « 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 ajean »). + b.WriteString("You are AJEAN, an expert assistant operating directly on this machine with real tools.") if caps.Mem == MemAlways { 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(".") 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() } @@ -145,7 +145,7 @@ func runShell(command string, timeoutSec int) string { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) defer cancel() 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. cmd.Dir = agentWorkspace() var stdout, stderr strings.Builder diff --git a/internal/ajean/chat_workspace.go b/internal/ajean/chat_workspace.go index dc9f2ad..687b8fb 100644 --- a/internal/ajean/chat_workspace.go +++ b/internal/ajean/chat_workspace.go @@ -11,14 +11,14 @@ import ( // // 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 -// 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 // 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 // restent honorés tels quels — quand l'utilisateur demande d'écrire dans un // dossier précis, ça doit marcher. -const workspaceEnv = "JEAN_WORKSPACE" +const workspaceEnv = "AJEAN_WORKSPACE" var ( workspaceOnce sync.Once @@ -26,7 +26,7 @@ var ( ) // 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. func agentWorkspace() string { workspaceOnce.Do(func() { @@ -62,10 +62,10 @@ func workspaceCandidates() []string { } c = append(c, filepath.Join(AjeanHome(), "workspace")) 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. 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")) return c diff --git a/internal/ajean/chat_workspace_test.go b/internal/ajean/chat_workspace_test.go index 7ee8955..ae8c295 100644 --- a/internal/ajean/chat_workspace_test.go +++ b/internal/ajean/chat_workspace_test.go @@ -7,7 +7,7 @@ import ( ) // 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) { got := resolveAgentPath("meteo.json") want := filepath.Join(agentWorkspace(), "meteo.json") diff --git a/internal/ajean/cli_app.go b/internal/ajean/cli_app.go index 32e1314..cb0d71a 100644 --- a/internal/ajean/cli_app.go +++ b/internal/ajean/cli_app.go @@ -1,12 +1,12 @@ 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 -// 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 -// Windows (voir sys_tray_windows.go) : on voit que Jean tourne et on le pilote -// (« Ouvrir Jean » / « Quitter »). +// Windows (voir sys_tray_windows.go) : on voit que AJEAN tourne et on le pilote +// (« Ouvrir AJEAN » / « Quitter »). import ( "fmt" @@ -34,13 +34,13 @@ func cmdApp(args []string) error { addr := fmt.Sprintf("0.0.0.0:%d", appPort) ln, err := net.Listen("tcp", addr) 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. fmt.Printf("AJEAN est déjà lancé — ouverture de %s\n", 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 // process qui servent la conversation (objet en mémoire persisté dans // conversation.json) donnent deux fils divergents entre le local et diff --git a/internal/ajean/doc.go b/internal/ajean/doc.go index 354d8b5..f595dba 100644 --- a/internal/ajean/doc.go +++ b/internal/ajean/doc.go @@ -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) : // -// 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) // web_* serveur HTTP local :8090 (UI embarquée via go:embed ui/, auth, prefs) // chat_* chat CLI + conversation serveur partagée, compaction, mémoire, diff --git a/internal/ajean/llm_bench.go b/internal/ajean/llm_bench.go index ad9365c..1a29714 100644 --- a/internal/ajean/llm_bench.go +++ b/internal/ajean/llm_bench.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "path/filepath" "strconv" "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 // (chat template, reasoning, OpenAI-compat layer all included). 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."}}, "max_tokens": nPredict, "stream": false, @@ -136,10 +135,6 @@ func runBench(nPrompt, nPredict int) (*benchResult, error) { 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. type savedBench struct { Result benchResult `json:"result"` @@ -147,45 +142,35 @@ type savedBench struct { 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) { sb := savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} - if b, err := json.Marshal(sb); err == nil { - _ = os.WriteFile(lastBenchPath(), b, 0o644) - } + _ = putJSON(bkState, "last_bench", sb) } -// 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 { - b, err := os.ReadFile(lastBenchPath()) - if err != nil { - return nil - } var sb savedBench - if json.Unmarshal(b, &sb) != nil { + if !getJSON(bkState, "last_bench", &sb) { return nil } return &sb } -// benchStorePath holds per-preset benchmark results so the UI can show each -// 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). +// loadBenchStore renvoie les benchmarks par preset (vide s'il n'y en a pas). func loadBenchStore() map[string]savedBench { m := map[string]savedBench{} - b, err := os.ReadFile(benchStorePath()) - if err != nil { - return m + getJSON(bkState, "bench_presets", &m) + if m == nil { + m = map[string]savedBench{} } - _ = json.Unmarshal(b, &m) return m } // 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 -// matches — the bench still lives in .last_bench.json via saveLastBench. +// preset (celui qui correspond à la configuration active). Sans effet si aucun +// preset ne correspond — le benchmark reste enregistré par saveLastBench. func saveBenchForActivePreset(res *benchResult) { list, err := ListPresets() if err != nil { @@ -203,9 +188,7 @@ func saveBenchForActivePreset(res *benchResult) { } m := loadBenchStore() m[id] = savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()} - if b, err := json.Marshal(m); err == nil { - _ = os.WriteFile(benchStorePath(), b, 0o644) - } + _ = putJSON(bkState, "bench_presets", m) } func cmdBench(args []string) error { diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go index 76c7404..f38ff92 100644 --- a/internal/ajean/llm_client.go +++ b/internal/ajean/llm_client.go @@ -526,7 +526,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // le contexte — c'est un choix assumé. for iter := 0; ; iter++ { payload := map[string]any{ - "model": "jean", + "model": "ajean", "messages": messages, "stream": true, "temperature": temperature, diff --git a/internal/ajean/llm_oai.go b/internal/ajean/llm_oai.go index cf5d3d3..a018cce 100644 --- a/internal/ajean/llm_oai.go +++ b/internal/ajean/llm_oai.go @@ -72,38 +72,23 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error { return srv.Serve(tls.NewListener(rawLn, tlsCfg)) } -// oaiPublicPath est le drapeau qui active l'accès OpenAI public (piloté par l'UI, -// lu en direct → activable/coupable sans redémarrer le service de lien). -func oaiPublicPath() string { return filepath.Join(AjeanHome(), ".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) -} +// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette +// machine. Piloté par l'UI et lu en direct → activable/coupable sans redémarrer +// le service de lien. +func oaiPublicEnabled() bool { return getBool(bkState, "oai_public") } // setOAIPublic active (on) ou coupe (off) l'accès OpenAI public. -func setOAIPublic(on bool) error { - if on { - return os.WriteFile(oaiPublicPath(), []byte("1\n"), 0o600) - } - if err := os.Remove(oaiPublicPath()); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} +func setOAIPublic(on bool) error { return putBool(bkState, "oai_public", on) } // 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 // 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. func oaiTLSConfig() *tls.Config { certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(AjeanHome(), "certs")} 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 magic := certmagic.NewDefault() magic.OnDemand = &certmagic.OnDemandConfig{ @@ -256,7 +241,7 @@ func cmdOAI(args []string) error { default: 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(" 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 } } diff --git a/internal/ajean/mcp_client.go b/internal/ajean/mcp_client.go index a82d63f..7fcdf02 100644 --- a/internal/ajean/mcp_client.go +++ b/internal/ajean/mcp_client.go @@ -127,7 +127,7 @@ func (h headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) // connect établit une session pour la config donnée. 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 switch cfg.Transport() { diff --git a/internal/ajean/mcp_client_test.go b/internal/ajean/mcp_client_test.go index 10bd944..682a9d5 100644 --- a/internal/ajean/mcp_client_test.go +++ b/internal/ajean/mcp_client_test.go @@ -12,12 +12,12 @@ import ( // tentative de connexion : sans ça, connecter en parallèle multiplierait les // process stdio lancés pour un même serveur. func TestMCPEnsureDeduplicatesConcurrentConnects(t *testing.T) { - t.Setenv("JEAN_HOME", t.TempDir()) + t.Setenv("AJEAN_HOME", t.TempDir()) t.Cleanup(mcpCloseAll) // Commande inexistante : la connexion échoue, mais l'entrée du pool est bien // partagée — c'est ce qu'on teste. - cfg := MCPServerConfig{Command: "jean-binaire-inexistant-pour-test", Enabled: true} + cfg := MCPServerConfig{Command: "ajean-binaire-inexistant-pour-test", Enabled: true} const callers = 8 var wg sync.WaitGroup @@ -53,7 +53,7 @@ func TestMCPEndToEnd(t *testing.T) { if _, err := exec.LookPath("npx"); err != nil { t.Skip("npx indisponible — test MCP e2e ignoré") } - t.Setenv("JEAN_HOME", t.TempDir()) + t.Setenv("AJEAN_HOME", t.TempDir()) cfg := MCPServerConfig{ Command: "npx", @@ -83,8 +83,8 @@ func TestMCPEndToEnd(t *testing.T) { t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools)) } - out := mcpCall(echoName, map[string]any{"message": "bonjour-jean"}) - if !strings.Contains(out, "bonjour-jean") { + out := mcpCall(echoName, map[string]any{"message": "bonjour-ajean"}) + if !strings.Contains(out, "bonjour-ajean") { t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out) } } diff --git a/internal/ajean/mcp_config.go b/internal/ajean/mcp_config.go index 308d3ce..4c2c97f 100644 --- a/internal/ajean/mcp_config.go +++ b/internal/ajean/mcp_config.go @@ -3,7 +3,6 @@ package ajean import ( "encoding/json" "fmt" - "os" "sort" "strings" "sync" @@ -11,14 +10,14 @@ import ( // 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 // 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 // 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). // // 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. // // 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 // 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"` } -// mcpConfigMu sérialise les accès concurrents au fichier mcp.json (l'UI web et -// les tours de chat peuvent lire/écrire en parallèle). +// mcpConfigMu sérialise les accès concurrents à la déclaration des serveurs +// MCP (l'UI web et les tours de chat peuvent lire/écrire en parallèle). 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) { mcpConfigMu.Lock() defer mcpConfigMu.Unlock() @@ -130,36 +129,16 @@ func LoadMCPConfig() (map[string]MCPServerConfig, error) { } func loadMCPConfigLocked() (map[string]MCPServerConfig, error) { - b, err := os.ReadFile(mcpConfigPath()) - if err != nil { - if os.IsNotExist(err) { - return map[string]MCPServerConfig{}, nil - } - return nil, err + servers := map[string]MCPServerConfig{} + getJSON(bkState, "mcp", &servers) + if servers == nil { + servers = map[string]MCPServerConfig{} } - var f mcpConfigFile - 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 + return servers, nil } -// saveMCPConfigLocked écrit atomiquement mcp.json (write temp + rename). func saveMCPConfigLocked(servers map[string]MCPServerConfig) error { - _ = os.MkdirAll(AjeanHome(), 0o755) - 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()) + return putJSON(bkState, "mcp", servers) } // SetMCPServer ajoute ou remplace un serveur nommé, puis invalide le pool de diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go index 161fb2a..3edcc56 100644 --- a/internal/ajean/relay_link.go +++ b/internal/ajean/relay_link.go @@ -1,6 +1,6 @@ package ajean -// relay_link.go — `ajean link ` : connecte ce serveur Jean au relais public +// relay_link.go — `ajean link ` : connecte ce serveur AJEAN au relais public // (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.). // @@ -10,7 +10,7 @@ package ajean // local. Keepalive + reconnexion automatique avec backoff. // // 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. import ( @@ -39,70 +39,42 @@ import ( ) // 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" -func linkTokenPath() string { return filepath.Join(AjeanHome(), ".link_token") } -func linkMachinePath() string { return filepath.Join(AjeanHome(), ".link_machine") } - -// machineID returns a stable per-machine identifier, creating one on first use. +// machineID renvoie un identifiant stable de la machine, créé au premier appel. // Permet au relais de regrouper les connexions d'une même machine sous le compte. func machineID() string { - if b, err := os.ReadFile(linkMachinePath()); err == nil { - if id := strings.TrimSpace(string(b)); id != "" { - return id - } + if id := getStr(bkState, "link_machine"); id != "" { + return id } buf := make([]byte, 8) _, _ = rand.Read(buf) id := hex.EncodeToString(buf) - _ = os.MkdirAll(AjeanHome(), 0o755) - _ = os.WriteFile(linkMachinePath(), []byte(id+"\n"), 0o600) + _ = putStr(bkState, "link_machine", id) return id } -// readLinkToken returns the saved subscription token, or "". -func readLinkToken() string { - b, err := os.ReadFile(linkTokenPath()) - if err != nil { - return "" - } - return strings.TrimSpace(string(b)) -} +// readLinkToken renvoie la clé d'abonnement enregistrée, ou "". +func readLinkToken() string { return getStr(bkState, "link_token") } -func saveLinkToken(tok string) error { - if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { - return err - } - return os.WriteFile(linkTokenPath(), []byte(tok+"\n"), 0o600) -} +func saveLinkToken(tok string) error { return putStr(bkState, "link_token", tok) } // removeLinkToken oublie la clé de liaison enregistrée (idempotent). -func removeLinkToken() error { - if err := os.Remove(linkTokenPath()); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} +func removeLinkToken() error { return putStr(bkState, "link_token", "") } // relayURL resolves the relay WebSocket endpoint (env override → default). func relayURL() string { - if u := envAny("AJEAN_LINK_URL", "JEAN_LINK_URL"); u != "" { + if u := os.Getenv("AJEAN_LINK_URL"); u != "" { return u } return defaultRelayURL } -// linkUnitName / legacyLinkUnitName : l'unité systemd qui exécute le worker -// « ajean link --foreground », et son nom d'avant le renommage. -const ( - linkUnitName = "ajean-link" - legacyLinkUnitName = "jean-link" -) +// linkUnitName est l'unité qui exécute le worker « ajean link serve ». +const linkUnitName = "ajean-link" -// linkServiceName renvoie l'unité de lien réellement installée sur la machine -// (voir sys_unitname.go : un serveur mis à jour tourne encore sous jean-link). -func linkServiceName() string { return resolveUnitName(linkUnitName, legacyLinkUnitName) } +func linkServiceName() string { return linkUnitName } func cmdLink(args []string) error { sub := "" @@ -135,7 +107,7 @@ func cmdLink(args []string) error { } return nil case "logout": - _ = os.Remove(linkTokenPath()) + _ = removeLinkToken() fmt.Println(green("[ok]") + " token supprimé") return nil case "stop": @@ -239,9 +211,9 @@ func runLinkForeground() error { } // 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 - // 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. webMux := newWebMux() 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à // :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é. var appLink struct { @@ -367,7 +339,7 @@ func killForeignLinkWorker() { } // 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 // distant. Remplace le `ajean web` autonome (à ne plus lancer séparément). func serveLocalWebMux(mux *http.ServeMux) { @@ -382,33 +354,12 @@ func serveLocalWebMux(mux *http.ServeMux) { } // linkPIDPath / linkLogPath : suivi du worker de lien hors systemd (macOS, -// Windows), où Jean est une app de bureau lancée sans droits root. -func linkPIDPath() string { return adoptLegacyLinkFile(".pid") } -func linkLogPath() string { return adoptLegacyLinkFile(".log") } - -// adoptLegacyLinkFile renvoie le chemin « ajean-link » en reprenant au -// passage le « jean-link » 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 -} +// Windows), où AJEAN est une app de bureau lancée sans droits root. +func linkPIDPath() string { return filepath.Join(AjeanHome(), linkUnitName+".pid") } +func linkLogPath() string { return filepath.Join(AjeanHome(), linkUnitName+".log") } // 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 // « 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 @@ -553,7 +504,7 @@ func linkUserSvcCtl(action string) error { // - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint // compatible OpenAI, avec injection de la clé API locale) → permet de // brancher OpenCode, Hermes, etc. sur ajean.link/oai//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 { web := withLocalAuth(mux) 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/req : proxy de contrôle chiffré (presets, VRAM, skills, service…). // 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) { p := r.URL.Path 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 : // il transiterait en clair par le relais. Désactivé par défaut. 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 } 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]")) // 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. // On les distingue au 1er octet (0x16 = handshake TLS). Sans OAI activé, tout // va au HTTP (comportement historique inchangé). diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 02faa3d..63019d8 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -20,9 +20,6 @@ func Main() { // true. À faire AVANT toute écriture. haveConsole := setupConsole() - // Migration one-shot des anciens skills (SKILLS//SKILL.md) vers la - // nouvelle mémoire (MEMORY/.md). Idempotente, silencieuse si rien à faire. - migrateSkillsToMemory() // Nettoie un éventuel binaire .old laissé par une mise à jour Windows. cleanupOldBinary() @@ -95,10 +92,6 @@ func Main() { // Sous-commande interne, absente de l'aide : l'accompagnateur detache qui // attend la fermeture de l'app, migre, puis la relance. 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": mustExit(cmdInstall(args)) 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 status | logout état du lien / oublier le token 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: agent [on|off|status] active TOUS les outils de l'IA (shell complet + mémoire) — un seul interrupteur @@ -171,7 +164,7 @@ Installation: Env: 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) Config: $AJEAN_HOME/config.env @@ -186,84 +179,54 @@ func mustExit(err error) { } // AjeanHome resolves the AJEAN data directory. -// Precedence: $AJEAN_HOME → $JEAN_HOME (héritage) → /etc/default/ajean puis -// /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. +// Précédence : $AJEAN_HOME → /etc/default/ajean (unix) → défaut plateforme. func AjeanHome() string { if h := os.Getenv("AJEAN_HOME"); h != "" { return h } - if h := os.Getenv("JEAN_HOME"); h != "" { - return h - } if h := readEtcDefault(); h != "" { return h } - return migratedDefaultHome() + return defaultAjeanHome() } -// readEtcDefault parses /etc/default/ajean (puis /etc/default/jean, hérité) for -// AJEAN_HOME= ou JEAN_HOME=. Quiet on errors. +// readEtcDefault parses /etc/default/ajean for AJEAN_HOME=. Quiet on errors. func readEtcDefault() string { - for _, path := range []string{"/etc/default/ajean", "/etc/default/jean"} { - b, err := os.ReadFile(path) - if err != nil { + b, err := os.ReadFile("/etc/default/ajean") + if err != nil { + return "" + } + for _, line := range strings.Split(string(b), "\n") { + s := strings.TrimSpace(line) + if s == "" || strings.HasPrefix(s, "#") { continue } - for _, line := range strings.Split(string(b), "\n") { - s := strings.TrimSpace(line) - if s == "" || strings.HasPrefix(s, "#") { - 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 - } - } + s = strings.TrimPrefix(s, "export ") + if k, v, ok := strings.Cut(s, "="); ok && strings.TrimSpace(k) == "AJEAN_HOME" { + return strings.Trim(strings.TrimSpace(v), "\"'") } } return "" } -func confPath() string { return filepath.Join(AjeanHome(), "config.env") } -func presetsDir() string { return filepath.Join(AjeanHome(), "configs") } -func skillsDir() string { return filepath.Join(AjeanHome(), "SKILLS") } -func memoryDir() string { return filepath.Join(AjeanHome(), "MEMORY") } -func agentFlag() string { return filepath.Join(AjeanHome(), ".agent_enabled") } -func internetFlag() string { return filepath.Join(AjeanHome(), ".internet_enabled") } -func mcpConfigPath() string { return filepath.Join(AjeanHome(), "mcp.json") } +// Arborescence de $AJEAN_HOME. Elle tient en six dossiers, et rien d'autre : +// tout le reste (config, préférences, conversation, clés, drapeaux) vit dans la +// base ajean.db — voir store.go. +func backendsDir() string { return filepath.Join(AjeanHome(), "backends") } +func binDir() string { return filepath.Join(AjeanHome(), "bin") } +func presetsDir() string { return filepath.Join(AjeanHome(), "presets") } +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é. -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. +// serviceName est le nom de l'unité qui exécute llama-server. func serviceName() string { if n := os.Getenv("AJEAN_SERVICE"); n != "" { return n } - if n := os.Getenv("JEAN_SERVICE"); n != "" { - 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()) + return "ajean" } -// legacyServiceName est le nom d'avant le renommage. -func legacyServiceName() string { return "jean" } - // Color helpers (ANSI). Disabled when stdout is not a TTY. var colorOn = isTerminal() diff --git a/internal/ajean/store.go b/internal/ajean/store.go new file mode 100644 index 0000000..13a671b --- /dev/null +++ b/internal/ajean/store.go @@ -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 + }) +} diff --git a/internal/ajean/sys_console_darwin.go b/internal/ajean/sys_console_darwin.go index 2d02240..f551ff5 100644 --- a/internal/ajean/sys_console_darwin.go +++ b/internal/ajean/sys_console_darwin.go @@ -7,10 +7,10 @@ package ajean // 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 // 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 -// Jean.app/Contents/MacOS/jean. Si on tourne depuis là, c'est un double-clic +// publie un bundle AJEAN.app dont le binaire vit dans +// 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 -// 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. @@ -28,7 +28,7 @@ func setupConsole() bool { if p, err := filepath.EvalSymlinks(exe); err == nil { exe = p } - // .../Jean.app/Contents/MacOS/jean + // .../AJEAN.app/Contents/MacOS/ajean if strings.HasSuffix(filepath.Dir(exe), "/Contents/MacOS") { fixFinderPath() return false @@ -36,7 +36,7 @@ func setupConsole() bool { 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 // ouverte depuis Téléchargements est exécutée depuis une copie en LECTURE SEULE // sous /private/var/folders/…/AppTranslocation//, à 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 // 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. -// 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 { exe, err := os.Executable() if err != nil { @@ -54,13 +54,13 @@ func appWarning() string { return "" } 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." } // 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 -// 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. func fixFinderPath() { extra := []string{ diff --git a/internal/ajean/sys_console_other.go b/internal/ajean/sys_console_other.go index 63f7a35..1cb9988 100644 --- a/internal/ajean/sys_console_other.go +++ b/internal/ajean/sys_console_other.go @@ -5,8 +5,8 @@ package ajean // setupConsole : sous Linux, le process a toujours une vraie console/tty // standard (stdout hérité). Rien à faire → on signale simplement « on a une // 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 -// (ouverture de Jean.app depuis le Finder). +// sys_console_windows.go (double-clic sur ajean.exe) et sys_console_darwin.go +// (ouverture de AJEAN.app depuis le Finder). func setupConsole() bool { return true } // appWarning : rien à signaler hors macOS (l'App Translocation est spécifique à diff --git a/internal/ajean/sys_disk_other.go b/internal/ajean/sys_disk_other.go new file mode 100644 index 0000000..604dd84 --- /dev/null +++ b/internal/ajean/sys_disk_other.go @@ -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) +} diff --git a/internal/ajean/sys_disk_windows.go b/internal/ajean/sys_disk_windows.go new file mode 100644 index 0000000..166bc32 --- /dev/null +++ b/internal/ajean/sys_disk_windows.go @@ -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) +} diff --git a/internal/ajean/sys_elevate_other.go b/internal/ajean/sys_elevate_other.go deleted file mode 100644 index b5a1a8a..0000000 --- a/internal/ajean/sys_elevate_other.go +++ /dev/null @@ -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 } diff --git a/internal/ajean/sys_elevate_windows.go b/internal/ajean/sys_elevate_windows.go deleted file mode 100644 index 0253a1d..0000000 --- a/internal/ajean/sys_elevate_windows.go +++ /dev/null @@ -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() -} diff --git a/internal/ajean/sys_env.go b/internal/ajean/sys_env.go deleted file mode 100644 index f0aa377..0000000 --- a/internal/ajean/sys_env.go +++ /dev/null @@ -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 "" -} diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go index 5662802..fe54e78 100644 --- a/internal/ajean/sys_firstrun_windows.go +++ b/internal/ajean/sys_firstrun_windows.go @@ -19,8 +19,8 @@ import ( // Premier lancement sous Windows. // -// Avant : double-cliquer jean-windows-amd64.exe déclenchait une installation -// SILENCIEUSE (copie du binaire dans %ProgramData%\jean\bin, ajout au PATH) dont +// Avant : double-cliquer ajean-windows-amd64.exe déclenchait une installation +// SILENCIEUSE (copie du binaire dans %ProgramData%\ajean\bin, ajout au PATH) dont // 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 // 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é) } 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 { messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nAJEAN va redémarrer dans sa version actuelle.", "AJEAN", mbIconInfo) @@ -211,8 +205,6 @@ func runAsInstaller(target string) bool { case cmp > 0: // 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. - elevateForHomeMigration() - target = migrateThenResolveTarget(target) _ = replaceInstalled(target) ensureShortcuts(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 // renommage préalable en .old permet de remplacer un exécutable encore ouvert // (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 { removeOldBinaries(target) 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 -// 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. // // 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 // embarquer l'installation du binaire ni les messages de console. func provisionDataDir() error { - jeanHome := AjeanHome() + ajeanHome := AjeanHome() // 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 // 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 { return err } } - conf := filepath.Join(jeanHome, "config.env") + conf := filepath.Join(ajeanHome, "config.env") if _, err := os.Stat(conf); os.IsNotExist(err) { return os.WriteFile(conf, []byte(configTemplate), 0o644) } @@ -436,7 +413,7 @@ foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) { if (-not (Test-Path $d)) { continue } $lnk=Join-Path $d 'AJEAN.lnk' # 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 # version périmée par son propre raccourci. if (Test-Path $lnk) { diff --git a/internal/ajean/sys_firstrun_windows_test.go b/internal/ajean/sys_firstrun_windows_test.go index 1b385c4..d4bb084 100644 --- a/internal/ajean/sys_firstrun_windows_test.go +++ b/internal/ajean/sys_firstrun_windows_test.go @@ -28,10 +28,10 @@ func TestBinaryVersionReadsResource(t *testing.T) { // Sur une machine vierge, le dossier bin n'existe pas encore : installSelf doit // 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. 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) if err != nil { 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. func TestReplaceInstalled(t *testing.T) { 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 { 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 // restait fige sur une version perimee, et tout raccourci le visant relancait // indefiniment l'ancienne version — qui affichait « une version plus recente @@ -89,7 +89,7 @@ func TestBinaryVersionWithoutResource(t *testing.T) { func TestReplaceExeSurchargeUnFichierVerrouille(t *testing.T) { dir := t.TempDir() 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 { t.Fatal(err) } @@ -120,7 +120,7 @@ func TestReplaceExeSurchargeUnFichierVerrouille(t *testing.T) { // echoue apres le renommage, l'ancien fichier revient a sa place. func TestReplaceExeRestaureSiLaCopieEchoue(t *testing.T) { 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 { t.Fatal(err) } diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go index 79bcc01..3931718 100644 --- a/internal/ajean/sys_install_darwin.go +++ b/internal/ajean/sys_install_darwin.go @@ -14,10 +14,10 @@ import ( // 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 // é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. -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. # Chemins @@ -39,14 +39,14 @@ EXTRA_ARGS="" ` // 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 // boot une fois chargé avec `-w`. // // 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 // 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 = ` @@ -60,7 +60,7 @@ const launchdPlistTemplate = ` UserName%s WorkingDirectory%s EnvironmentVariables - AJEAN_HOME%sJEAN_HOME%s + AJEAN_HOME%sAJEAN_HOME%s RunAtLoad KeepAlive SuccessfulExit @@ -87,7 +87,7 @@ func cmdInstall(args []string) error { if err != nil { 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 // machine est déjà en ajean, ou si l'utilisateur a imposé un chemin. if err := migrateLayout(defaultLayoutPlan()); err != nil { @@ -96,13 +96,13 @@ func cmdInstall(args []string) error { resetResolvedHome() // le dossier a pu changer à l'instant ajeanHome := defaultAjeanHome() - if v := os.Getenv("JEAN_HOME"); v != "" { + if v := os.Getenv("AJEAN_HOME"); v != "" { ajeanHome = v } svc := serviceName() 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) // 1. Répertoires @@ -154,7 +154,7 @@ func cmdInstall(args []string) error { 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 rp, e := filepath.EvalSymlinks(alias); e != nil || rp != self { _ = 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. // 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) 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 { @@ -176,7 +176,7 @@ func cmdInstall(args []string) error { } 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") plist := fmt.Sprintf(launchdPlistTemplate, launchdLabel(svc), target, targetUser, ajeanHome, ajeanHome, ajeanHome, logPath, logPath) plistPath := launchdPlistPath(svc) @@ -185,7 +185,7 @@ func cmdInstall(args []string) error { } fmt.Printf(" %s %s\n", green("✓"), plistPath) - // 6. chown JEAN_HOME au user cible + // 6. chown AJEAN_HOME au user cible chown(ajeanHome, u) fmt.Println() @@ -214,8 +214,8 @@ func cmdUninstall(args []string) error { fmt.Printf(" %s %s\n", green("✓"), p) } } - // On ne supprime jamais JEAN_HOME automatiquement (modèles, configs). - fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)")) + // On ne supprime jamais AJEAN_HOME automatiquement (modèles, configs). + fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)")) fmt.Println(green("[ok]") + " désinstallé") return nil } diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go index d11e674..b782e5a 100644 --- a/internal/ajean/sys_install_linux.go +++ b/internal/ajean/sys_install_linux.go @@ -11,7 +11,7 @@ import ( "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. # Chemins @@ -32,12 +32,12 @@ NGL="999" 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 ` const serviceUnitTemplate = `[Unit] -Description=JEAN llama.cpp server +Description=AJEAN llama.cpp server After=network.target [Service] @@ -76,7 +76,7 @@ func cmdInstall(args []string) error { if err != nil { 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 // machine est déjà en ajean, ou si l'utilisateur a imposé un chemin. if err := migrateLayout(defaultLayoutPlan()); err != nil { @@ -85,13 +85,13 @@ func cmdInstall(args []string) error { resetResolvedHome() // le dossier a pu changer à l'instant ajeanHome := defaultAjeanHome() - if v := os.Getenv("JEAN_HOME"); v != "" { + if v := os.Getenv("AJEAN_HOME"); v != "" { ajeanHome = v } svc := serviceName() 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) // 1. Create directories @@ -114,7 +114,7 @@ func cmdInstall(args []string) error { 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() if err != nil { return err @@ -124,9 +124,9 @@ func cmdInstall(args []string) error { } target := installedExePath() // 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 - // 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). alreadyInPlace := false 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 // 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 // nouveau nom, donc il suit les mises à jour sans entretien. 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. // 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. 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 { @@ -187,7 +187,7 @@ func cmdInstall(args []string) error { } 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) // 7. systemd reload @@ -222,7 +222,7 @@ func cmdUninstall(args []string) error { for _, p := range []string{ "/etc/systemd/system/" + svc + ".service", "/etc/sudoers.d/ajean-" + svc, - "/etc/default/jean", + "/etc/default/ajean", installedExePath(), } { if err := os.Remove(p); err == nil { @@ -231,7 +231,7 @@ func cmdUninstall(args []string) error { } _ = exec.Command("systemctl", "daemon-reload").Run() 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é") return nil diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go index f9fa438..93a186a 100644 --- a/internal/ajean/sys_install_windows.go +++ b/internal/ajean/sys_install_windows.go @@ -16,7 +16,7 @@ import ( // service itself is managed by the PID-file supervisor in sys_service_windows.go // (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. # Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent) @@ -38,18 +38,6 @@ EXTRA_ARGS="" ` 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() fmt.Printf("Installation (Windows)\n") @@ -70,7 +58,7 @@ func cmdInstall(args []string) error { 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 // /usr/local/bin symlink the Unix installer creates). 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(" 3. UI web : %s\n", bold("ajean web")) 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 { - 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 } // installSelf copies the currently running executable into binDir as ajean.exe // 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). // // 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 } -// 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 // 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, // « ajean » reste disponible. func installLegacyAlias(binDir, src string) { - alias := filepath.Join(binDir, "jean.exe") + alias := filepath.Join(binDir, "ajean.exe") if strings.EqualFold(src, alias) { 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 // 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 // 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. @@ -264,7 +252,7 @@ func cmdUninstall(args []string) error { // Stop the background server if it's running. _ = 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") if removed, err := removeFromUserPath(binDir); err == nil && removed { fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir) diff --git a/internal/ajean/sys_layout.go b/internal/ajean/sys_layout.go deleted file mode 100644 index 41e2563..0000000 --- a/internal/ajean/sys_layout.go +++ /dev/null @@ -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 -} diff --git a/internal/ajean/sys_layout_test.go b/internal/ajean/sys_layout_test.go deleted file mode 100644 index 81404b6..0000000 --- a/internal/ajean/sys_layout_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/ajean/sys_migrate.go b/internal/ajean/sys_migrate.go deleted file mode 100644 index 5365a6b..0000000 --- a/internal/ajean/sys_migrate.go +++ /dev/null @@ -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() -} diff --git a/internal/ajean/sys_migrate_test.go b/internal/ajean/sys_migrate_test.go deleted file mode 100644 index 89d7f94..0000000 --- a/internal/ajean/sys_migrate_test.go +++ /dev/null @@ -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=\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) - } -} diff --git a/internal/ajean/sys_paths.go b/internal/ajean/sys_paths.go index 48cf7c2..37c0194 100644 --- a/internal/ajean/sys_paths.go +++ b/internal/ajean/sys_paths.go @@ -17,31 +17,25 @@ import ( type ajeanPaths struct { 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 Installed string `json:"installed"` // binaire installé (peut différer de Exe) Workspace string `json:"workspace"` // dossier de travail du mode agent Memory string `json:"memory"` Presets string `json:"presets"` + Models string `json:"models"` + Backends string `json:"backends"` } // installedExePath est l'emplacement canonique du binaire après installation. // 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, // faute d'équivalent, on utilise AJEAN_HOME\bin ajouté au PATH utilisateur. -func installedExePath() string { return exePathNamed("ajean") } - -// 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 { +func installedExePath() string { 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 { @@ -51,12 +45,14 @@ func currentPaths() ajeanPaths { } return ajeanPaths{ Home: AjeanHome(), - Config: confPath(), + Database: dbPath(), Exe: exe, Installed: installedExePath(), Workspace: agentWorkspace(), Memory: memoryDir(), Presets: presetsDir(), + Models: modelsDir(), + Backends: backendsDir(), } } @@ -65,18 +61,17 @@ func cmdWhere(args []string) error { fmt.Printf("Emplacements AJEAN\n\n") for _, row := range [][2]string{ {"données (AJEAN_HOME)", p.Home}, - {"configuration", p.Config}, + {"base", p.Database}, {"binaire en cours", p.Exe}, {"binaire installé", p.Installed}, {"travail de l'agent", p.Workspace}, {"mémoire", p.Memory}, {"presets", p.Presets}, + {"modèles", p.Models}, + {"backends", p.Backends}, } { 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 { 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") diff --git a/internal/ajean/sys_platform_unix.go b/internal/ajean/sys_platform_unix.go index 69155a5..42416e6 100644 --- a/internal/ajean/sys_platform_unix.go +++ b/internal/ajean/sys_platform_unix.go @@ -16,7 +16,7 @@ import ( "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 // 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. func defaultAjeanHome() string { return unixHome("ajean") } -func legacyDefaultHome() string { return unixHome("jean") } +func legacyDefaultHome() string { return unixHome("ajean") } func unixHome(name string) string { if runtime.GOOS != "darwin" || os.Geteuid() == 0 { @@ -49,7 +49,7 @@ func isWritableDir(dir string) bool { if err != nil || !fi.IsDir() { 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) if err != nil { return false @@ -335,7 +335,7 @@ func ramUsageMB() (used, total int) { // --- Supervision de processus détachés (worker de lien, service en mode // 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. func spawnDetached(name string, args ...string) *exec.Cmd { cmd := exec.Command(name, args...) diff --git a/internal/ajean/sys_platform_windows.go b/internal/ajean/sys_platform_windows.go index c9ac75f..32bfe7c 100644 --- a/internal/ajean/sys_platform_windows.go +++ b/internal/ajean/sys_platform_windows.go @@ -15,7 +15,7 @@ import ( ) // 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 // processing so the ANSI colour/cursor escapes (including the build progress // 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, // the closest analogue to /etc), falling back to %LOCALAPPDATA% for unprivileged // 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 { if pd := os.Getenv("ProgramData"); pd != "" { return pd @@ -54,11 +54,11 @@ func homeRoot() string { 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") } // 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. 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 -// (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 // avec les flags déjà présents pour ne pas écraser un éventuel détachement. 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 -// 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 { return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command)) } diff --git a/internal/ajean/sys_postupdate_other.go b/internal/ajean/sys_postupdate_other.go deleted file mode 100644 index 81e896b..0000000 --- a/internal/ajean/sys_postupdate_other.go +++ /dev/null @@ -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() {} diff --git a/internal/ajean/sys_postupdate_windows.go b/internal/ajean/sys_postupdate_windows.go deleted file mode 100644 index fff8836..0000000 --- a/internal/ajean/sys_postupdate_windows.go +++ /dev/null @@ -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) -} diff --git a/internal/ajean/sys_restart_windows.go b/internal/ajean/sys_restart_windows.go index cff0597..57f127d 100644 --- a/internal/ajean/sys_restart_windows.go +++ b/internal/ajean/sys_restart_windows.go @@ -57,13 +57,6 @@ func cmdRestartAfterUpdate(args []string) error { 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() if _, err := os.Stat(target); err != nil { // La cible a disparu : mieux vaut relancer ce qu'on a que rien du tout. diff --git a/internal/ajean/sys_service.go b/internal/ajean/sys_service.go index 3a688c4..2378848 100644 --- a/internal/ajean/sys_service.go +++ b/internal/ajean/sys_service.go @@ -16,18 +16,39 @@ import ( // // 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 { editor := os.Getenv("EDITOR") if editor == "" { editor = defaultEditor() } - cmd := exec.Command(editor, confPath()) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + tmp, err := os.CreateTemp("", "ajean-config-*.env") + if err != nil { + return err + } + 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 { 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")) return nil } diff --git a/internal/ajean/sys_service_darwin.go b/internal/ajean/sys_service_darwin.go index d5e947a..25170b6 100644 --- a/internal/ajean/sys_service_darwin.go +++ b/internal/ajean/sys_service_darwin.go @@ -19,7 +19,7 @@ import ( // 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 -// 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 // seul, et viser le mauvais label reviendrait à ne plus voir le service. func launchdLabel(svc string) string { @@ -27,7 +27,7 @@ func launchdLabel(svc string) string { if _, err := os.Stat("/Library/LaunchDaemons/" + modern + ".plist"); err == nil { 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 modern @@ -44,7 +44,7 @@ func launchdPlistPath(svc string) string { 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). 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 { svc := serviceName() plist := launchdPlistPath(svc) - // Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où Jean tourne - // via Jean.app sans jamais passer par `sudo ajean install` : on gère alors le + // Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où AJEAN tourne + // 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 // PID. Aucun droit root requis. Sans ça, `start` échouait sur un plist absent // 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 -// lancé détaché, son PID va dans JEAN_HOME/.pid et sa sortie dans -// JEAN_HOME/.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 +// lancé détaché, son PID va dans AJEAN_HOME/.pid et sa sortie dans +// AJEAN_HOME/.log. Setsid le détache de notre session pour qu'il survive à +// la fermeture de AJEAN.app, et permet de tuer tout le groupe (llama-server // enfant compris) d'un seul signal. func pidFilePath() string { return filepath.Join(AjeanHome(), serviceName()+".pid") } @@ -202,7 +202,7 @@ func userSvcStart() error { cmd := exec.Command(self, "serve") cmd.Stdout, cmd.Stderr = logf, logf // 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. cmd.Dir = AjeanHome() cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} diff --git a/internal/ajean/sys_service_windows.go b/internal/ajean/sys_service_windows.go index fe05806..b725532 100644 --- a/internal/ajean/sys_service_windows.go +++ b/internal/ajean/sys_service_windows.go @@ -17,7 +17,7 @@ import ( // 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 -// 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. const ( @@ -71,9 +71,9 @@ func svcStart() error { cmd := exec.Command(self, "serve") cmd.Stdout = 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 - // 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{ HideWindow: true, CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow, @@ -136,7 +136,6 @@ func svcStatus() error { fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName()) } fmt.Printf(" logs : %s\n", logFilePath()) - fmt.Printf(" config : %s\n", confPath()) return nil } diff --git a/internal/ajean/sys_splash_windows.go b/internal/ajean/sys_splash_windows.go index 4a96703..9ef2e41 100644 --- a/internal/ajean/sys_splash_windows.go +++ b/internal/ajean/sys_splash_windows.go @@ -2,7 +2,7 @@ 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 // (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 { - className, _ := syscall.UTF16PtrFromString("JeanSplash") + className, _ := syscall.UTF16PtrFromString("AJEANSplash") hInst, _, _ := pGetModuleHandleW.Call(0) splashClassOnce.Do(func() { diff --git a/internal/ajean/sys_tray_darwin.go b/internal/ajean/sys_tray_darwin.go index 0a2e56b..820d0ac 100644 --- a/internal/ajean/sys_tray_darwin.go +++ b/internal/ajean/sys_tray_darwin.go @@ -2,10 +2,10 @@ 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 -// ouvre Jean.app, l'UI web s'ouvre dans le navigateur ET l'icône apparaît, avec -// le menu « Ouvrir Jean » / « Quitter ». +// ouvre AJEAN.app, l'UI web s'ouvre dans le navigateur ET l'icône apparaît, avec +// le menu « Ouvrir AJEAN » / « Quitter ». // // Contrairement à Windows, systray passe ici par Cocoa → CGO obligatoire : les // binaires macOS sont donc compilés sur un runner macOS (voir release.yml), et diff --git a/internal/ajean/sys_tray_windows.go b/internal/ajean/sys_tray_windows.go index 9ce58f1..ddf2d4f 100644 --- a/internal/ajean/sys_tray_windows.go +++ b/internal/ajean/sys_tray_windows.go @@ -2,10 +2,10 @@ 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 // 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 // binaire reste statique. L'équivalent macOS vit dans sys_tray_darwin.go (Cocoa, diff --git a/internal/ajean/sys_unitname.go b/internal/ajean/sys_unitname.go deleted file mode 100644 index 711e81f..0000000 --- a/internal/ajean/sys_unitname.go +++ /dev/null @@ -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 -} diff --git a/internal/ajean/sys_update.go b/internal/ajean/sys_update.go index 67a143e..5fd8c88 100644 --- a/internal/ajean/sys_update.go +++ b/internal/ajean/sys_update.go @@ -22,9 +22,6 @@ import ( // 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). -// 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" type ghRelease struct { @@ -37,36 +34,23 @@ type ghRelease struct { } `json:"assets"` } -// updateAssetNames reconstruit les noms d'asset acceptables pour la plateforme -// courante, par ordre de préférence : ajean--[.exe] puis, en repli, -// l'ancien nom jean--[.exe]. -// -// 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 { +// updateAssetName est le nom de l'asset de release pour la plateforme courante : +// ajean--[.exe]. Un seul nom publié, un seul nom cherché. +func updateAssetName() string { suffix := runtime.GOOS + "-" + runtime.GOARCH if runtime.GOOS == "windows" { suffix += ".exe" } - return []string{"ajean-" + suffix, "jean-" + suffix} + return "ajean-" + suffix } -// updateAssetName renvoie le nom d'asset préféré (usage : messages à l'écran). -func updateAssetName() string { return updateAssetNames()[0] } - -// 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. +// pickAsset trouve dans la release l'asset de cette plateforme. Renvoie son nom +// (celui qui servira à vérifier le SHA-256), son URL et sa taille. func pickAsset(rel *ghRelease) (name, url string, size int64) { - for _, want := range updateAssetNames() { - for _, a := range rel.Assets { - if a.Name == want { - return a.Name, a.BrowserDownloadURL, a.Size - } + want := updateAssetName() + for _, a := range rel.Assets { + if a.Name == want { + return a.Name, a.BrowserDownloadURL, a.Size } } return "", "", 0 @@ -145,7 +129,7 @@ func applyUpdate() (string, error) { want, url, size := pickAsset(rel) if url == "" { 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() @@ -197,7 +181,7 @@ func applyUpdate() (string, error) { 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. func updatePermissionError(exe string) error { if runtime.GOOS == "windows" { @@ -240,7 +224,7 @@ func cmdUpdate(args []string) error { return fmt.Errorf("impossible de contacter GitHub : %w", err) } 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 } fmt.Printf("nouvelle version disponible : %s (actuelle : %s)\n", info.Latest, Version) @@ -255,10 +239,6 @@ func cmdUpdate(args []string) error { return err } 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() return nil } @@ -412,7 +392,7 @@ func cleanupOldBinary() { removeOldBinaries(exe) // L'alias herite laisse le meme reliquat quand il etait en cours d'execution // 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. @@ -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 // 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. func handleUpdateApply(w http.ResponseWriter, r *http.Request) { 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}) } -// restartAfterUpdate relance le service jean-link juste après une MAJ déclenchée -// depuis l'UI, pour éviter le SSH manuel. Conditions : Linux/systemd + jean-link +// 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 + ajean-link // 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 // 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 @@ -463,15 +443,6 @@ func restartAfterUpdate() (bool, string) { if runtime.GOOS != "linux" || !linkServiceActive() { 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() { 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 @@ -485,7 +456,7 @@ func restartHintText() string { if runtime.GOOS == "windows" { 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()) } diff --git a/internal/ajean/sys_update_aside_test.go b/internal/ajean/sys_update_aside_test.go index 24bf384..b40b81d 100644 --- a/internal/ajean/sys_update_aside_test.go +++ b/internal/ajean/sys_update_aside_test.go @@ -10,7 +10,7 @@ import ( // Le remplacement du binaire doit fonctionner MÊME si un écartement précédent // est toujours là et impossible à supprimer (processus encore vivant dessus). // 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. func TestRenameAsideNeBloquePasSurUnAncienEcartement(t *testing.T) { dir := t.TempDir() diff --git a/internal/ajean/sys_update_asset_test.go b/internal/ajean/sys_update_asset_test.go index db8eb02..13a88b0 100644 --- a/internal/ajean/sys_update_asset_test.go +++ b/internal/ajean/sys_update_asset_test.go @@ -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. func TestPickAssetFallsBackToLegacy(t *testing.T) { names := updateAssetNames() diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index 4ca5827..c03f443 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -1286,24 +1286,23 @@ button:hover{border-color:var(--dim);color:var(--text)}
Moteur -
Installation
- -
Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle.
+
-
llama.cpp précompilé conseillé
-
binaire officiel téléchargé tel quel, prêt en ~2 min
+
llama.cpp précompilé
+
binaire officiel téléchargé tel quel, prêt en ~2 min
-
llama.cpp compilé
-
compilé ici pour votre machine — plus rapide, plus long à installer
+
llama.cpp compilé
+
compilé ici pour votre machine — plus rapide, plus long à installer
@@ -1536,6 +1535,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
+
+ Destination + +
Dossiers de modèleschercher les .gguf ailleurs (disque externe…) @@ -1546,7 +1549,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
-
Les téléchargements, eux, vont toujours dans le dossier AJEAN.
+
Ces dossiers servent aussi de destination de téléchargement.
@@ -2618,7 +2621,7 @@ async function openItem(kind, key){ document.getElementById('m-quant').value = currentQuantInTextarea(); populateSettings(); 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; } // 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'); info.className = 'muted'; 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); if(!x.home){ const del = document.createElement('button'); @@ -2738,13 +2742,13 @@ async function addModelDir(){ if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } inp.value = ''; toast('dossier ajouté'); - await Promise.all([populateModelDirs(), populateModelPicker()]); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } async function removeModelDir(p){ const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } toast('dossier retiré'); - await Promise.all([populateModelDirs(), populateModelPicker()]); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } function onPickModel(){ const val = document.getElementById('m-model').value; @@ -3237,17 +3241,50 @@ function resetDlUI(){ e.prog.style.display='none'; e.bar.style.display='none'; 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(){ const url = document.getElementById('m-hf-url').value.trim(); if(!url){ toast('colle un lien .gguf'); return; } + const dir = (document.getElementById('m-hf-dir')||{}).value || ''; const e = dlEls(); e.btn.disabled = true; 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.className = 'pe-bar indet'; 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 = 'erreur : '+escHtml(p.error||'espace insuffisant')+''; + 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){ e.prog.innerHTML = 'erreur : '+(r.error||'')+''; e.bar.style.display = 'none'; e.btn.disabled=false; return; @@ -3290,8 +3327,12 @@ function watchDownload(fname){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); - await populateModelPicker(); - document.getElementById('m-model').value = fname; onPickModel(); + await Promise.all([populateModelPicker(), populateDlDirs()]); + // 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; } const pct = st.total>0 ? st.done*100/st.total : 0; @@ -3977,12 +4018,7 @@ async function loadLlamacpp(){ const fastInstalled = !!pb.bin; const optInstalled = !!s.bin; - const head = document.getElementById('lc-status'); - if(!fastInstalled && !optInstalled){ - head.innerHTML = 'Installez le moteur d\'IA. Le précompilé convient à presque tout le monde.'; - } else { - head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ⚙).'; - } + lcRenderReco(s.reco); lcRenderMode('fast', fastInstalled); lcRenderMode('opt', optInstalled); lcRenderCustomCard(); @@ -4040,6 +4076,23 @@ function lcChipOpen(){ 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){ const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); diff --git a/internal/ajean/ui/src/index.tmpl.html b/internal/ajean/ui/src/index.tmpl.html index 627d6f8..43b6646 100644 --- a/internal/ajean/ui/src/index.tmpl.html +++ b/internal/ajean/ui/src/index.tmpl.html @@ -239,24 +239,23 @@
Moteur -
Installation
- -
Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle.
+
-
llama.cpp précompilé conseillé
-
binaire officiel téléchargé tel quel, prêt en ~2 min
+
llama.cpp précompilé
+
binaire officiel téléchargé tel quel, prêt en ~2 min
-
llama.cpp compilé
-
compilé ici pour votre machine — plus rapide, plus long à installer
+
llama.cpp compilé
+
compilé ici pour votre machine — plus rapide, plus long à installer
@@ -489,6 +488,10 @@
+
+ Destination + +
Dossiers de modèleschercher les .gguf ailleurs (disque externe…) @@ -499,7 +502,7 @@
-
Les téléchargements, eux, vont toujours dans le dossier AJEAN.
+
Ces dossiers servent aussi de destination de téléchargement.
diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index 5f400f8..6b20b8b 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -153,7 +153,7 @@ async function openItem(kind, key){ document.getElementById('m-quant').value = currentQuantInTextarea(); populateSettings(); 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; } // 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'); info.className = 'muted'; 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); if(!x.home){ const del = document.createElement('button'); @@ -273,13 +274,13 @@ async function addModelDir(){ if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } inp.value = ''; toast('dossier ajouté'); - await Promise.all([populateModelDirs(), populateModelPicker()]); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } async function removeModelDir(p){ const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } toast('dossier retiré'); - await Promise.all([populateModelDirs(), populateModelPicker()]); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } function onPickModel(){ const val = document.getElementById('m-model').value; @@ -772,17 +773,50 @@ function resetDlUI(){ e.prog.style.display='none'; e.bar.style.display='none'; 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(){ const url = document.getElementById('m-hf-url').value.trim(); if(!url){ toast('colle un lien .gguf'); return; } + const dir = (document.getElementById('m-hf-dir')||{}).value || ''; const e = dlEls(); e.btn.disabled = true; 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.className = 'pe-bar indet'; 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 = 'erreur : '+escHtml(p.error||'espace insuffisant')+''; + 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){ e.prog.innerHTML = 'erreur : '+(r.error||'')+''; e.bar.style.display = 'none'; e.btn.disabled=false; return; @@ -825,8 +859,12 @@ function watchDownload(fname){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); - await populateModelPicker(); - document.getElementById('m-model').value = fname; onPickModel(); + await Promise.all([populateModelPicker(), populateDlDirs()]); + // 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; } const pct = st.total>0 ? st.done*100/st.total : 0; diff --git a/internal/ajean/ui/src/js/10-llamacpp.js b/internal/ajean/ui/src/js/10-llamacpp.js index 7864516..6a91ffc 100644 --- a/internal/ajean/ui/src/js/10-llamacpp.js +++ b/internal/ajean/ui/src/js/10-llamacpp.js @@ -15,12 +15,7 @@ async function loadLlamacpp(){ const fastInstalled = !!pb.bin; const optInstalled = !!s.bin; - const head = document.getElementById('lc-status'); - if(!fastInstalled && !optInstalled){ - head.innerHTML = 'Installez le moteur d\'IA. Le précompilé convient à presque tout le monde.'; - } else { - head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ⚙).'; - } + lcRenderReco(s.reco); lcRenderMode('fast', fastInstalled); lcRenderMode('opt', optInstalled); lcRenderCustomCard(); @@ -78,6 +73,23 @@ function lcChipOpen(){ 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){ const card = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt'); const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state'); diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 08d9d6f..3d27069 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -15,7 +15,7 @@ import ( ) 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 @@ -159,14 +159,14 @@ func handleConfigEnv(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, ReadConfig()) } -// handleBackends scans JEAN_HOME/backends// for a llama-server binary, +// handleBackends scans AJEAN_HOME/backends// for a llama-server binary, // trying common build subpaths (build/bin, build-sm120/bin, bin, .). // Returns [{name, path}]. func handleBackends(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, listBackendBins()) } -// listBackendBins scanne JEAN_HOME/backends// pour un binaire llama-server +// listBackendBins scanne AJEAN_HOME/backends// pour un binaire llama-server // (essaie les sous-chemins des différents générateurs CMake). Renvoie // [{name, path}] — un par dossier de backend contenant un binaire. 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 -// 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 -// 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. func handleModels(w http.ResponseWriter, r *http.Request) { out := []map[string]any{} @@ -337,9 +337,9 @@ func handlePresets(w http.ResponseWriter, r *http.Request) { func handlePreset(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" { - // new preset → seed from current config.env so users can tweak rather than start blank - b, _ := os.ReadFile(confPath()) - sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": string(b)}) + // nouveau preset → pré-rempli avec la configuration active, pour ajuster + // plutôt que partir d'une page blanche + sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(ReadConfig())}) return } content, err := ReadPreset(id) @@ -775,7 +775,7 @@ func handleMCPTest(w http.ResponseWriter, r *http.Request) { func handleMem(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(r.URL.Query().Get("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 } 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 // the cross-platform serviceAction (systemd sous Linux, supervision PID-file // 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 { return func(w http.ResponseWriter, r *http.Request) { err := serviceAction(action) diff --git a/internal/ajean/web_auth.go b/internal/ajean/web_auth.go index a7f6e3f..4ee3874 100644 --- a/internal/ajean/web_auth.go +++ b/internal/ajean/web_auth.go @@ -6,8 +6,6 @@ import ( "encoding/hex" "fmt" "net/http" - "os" - "path/filepath" "strings" ) @@ -18,21 +16,11 @@ import ( // 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 // 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 -// requête (pas de cache) pour qu'un changement de clé prenne effet sans -// redémarrer le serveur web. +// et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un +// changement de clé prenne effet sans redémarrer le serveur web. -func webKeyPath() string { return filepath.Join(AjeanHome(), ".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)) -} +// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie. +func readWebKey() string { return getStr(bkState, "web_key") } // requireWebAuth wraps an HTTP handler, rejecting requests that don't present // 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 } 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é"}) return } @@ -68,7 +56,7 @@ func checkBearer(r *http.Request, key string) bool { 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 définit la clé // 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 { 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)) case args[0] == "" || args[0] == "off" || args[0] == "none": key = "" default: key = strings.TrimSpace(args[0]) } + if err := putStr(bkState, "web_key", key); err != nil { + return err + } 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]")) return nil } - if err := os.MkdirAll(AjeanHome(), 0o755); err != nil { - 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("%s clé de pilotage enregistrée\n", green("[ok]")) 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") return nil diff --git a/internal/ajean/web_link_api.go b/internal/ajean/web_link_api.go index 0c6f420..fe0f7ca 100644 --- a/internal/ajean/web_link_api.go +++ b/internal/ajean/web_link_api.go @@ -1,9 +1,9 @@ package ajean // 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 -// 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). // // Ces routes ne sont accessibles qu'en LOCAL : à travers le tunnel du relais, diff --git a/internal/ajean/web_llamacpp.go b/internal/ajean/web_llamacpp.go index f615287..3709404 100644 --- a/internal/ajean/web_llamacpp.go +++ b/internal/ajean/web_llamacpp.go @@ -181,6 +181,7 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) { "in_use": pbBin != "" && prebuiltOwns(cfgBin), } out["backends_dir"] = filepath.Join(AjeanHome(), "backends") + out["reco"] = recommendedMode(plan.backend) out["job"] = lcJobSnapshot(0, false) sendJSON(w, 200, out) } @@ -344,9 +345,9 @@ func lcRunPrebuilt() { bin, err := prebuiltInstall(lcAppend, lcPhase) if err == 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 { - lcAppend("BIN mis à jour dans " + confPath()) + lcAppend("BIN mis à jour") } } if svcWasUp { @@ -583,10 +584,10 @@ func lcBuildAndSwitch(repo string, clean bool) bool { } lcAppend("binaire compilé : " + bin) 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 } - lcAppend("BIN mis à jour dans " + confPath()) + lcAppend("BIN mis à jour") return true } diff --git a/internal/ajean/web_prefs.go b/internal/ajean/web_prefs.go index 9c10988..ec89a1a 100644 --- a/internal/ajean/web_prefs.go +++ b/internal/ajean/web_prefs.go @@ -3,28 +3,20 @@ package ajean import ( "encoding/json" "net/http" - "os" - "path/filepath" "strings" "sync" ) // 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). // 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 // 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). 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}, "hide_reasoning": {"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}, } -// 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 { out := map[string]string{} - b, err := os.ReadFile(webPrefsPath()) - if err != nil { - return out + for k, v := range allKV(bkPrefs) { + out[k] = v } - _ = json.Unmarshal(b, &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) { webPrefsMu.Lock() defer webPrefsMu.Unlock() 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 { allowed, ok := webPrefsAllowed[k] if !ok { @@ -68,14 +50,7 @@ func saveWebPrefs(in map[string]string) (map[string]string, error) { } prefs[k] = v } - b, err := json.MarshalIndent(prefs, "", " ") - if err != nil { - return prefs, err - } - if err := os.WriteFile(webPrefsPath(), b, 0o600); err != nil { - return prefs, err - } - return prefs, nil + return prefs, replaceKV(bkPrefs, prefs) } // handleWebPrefs expose les préférences d'apparence partagées entre appareils. diff --git a/internal/ajean/web_server.go b/internal/ajean/web_server.go index 3f5d5c5..245f8e5 100644 --- a/internal/ajean/web_server.go +++ b/internal/ajean/web_server.go @@ -100,6 +100,7 @@ func newWebMux() *http.ServeMux { api("/api/models/delete", handleModelDelete) api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…) 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/cancel", handleModelDownloadCancel) api("/api/backends", handleBackends) @@ -139,7 +140,7 @@ func newWebMux() *http.ServeMux { api("/api/memory", handleMemoryMode) api("/api/prefs", handleWebPrefs) 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. // On les mappe sur le mode agent unifié le temps que le portail soit mis à jour. 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. func processName(pid int) string { 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() if err == nil { if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 { diff --git a/internal/ajean/web_sysprompt.go b/internal/ajean/web_sysprompt.go index 5722740..06c461b 100644 --- a/internal/ajean/web_sysprompt.go +++ b/internal/ajean/web_sysprompt.go @@ -1,5 +1,5 @@ // 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), // 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