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

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

No files matched your search

+1
View File
@@ -38,6 +38,7 @@ require (
github.com/segmentio/encoding v0.5.4 // indirect
github.com/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
+2
View File
@@ -92,6 +92,8 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/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=
+1 -1
View File
@@ -73,7 +73,7 @@ func detectBuildPlan() buildPlan {
// L'UI web embarquée de llama-server exige npm (ou un téléchargement
// 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
+1 -1
View File
@@ -12,7 +12,7 @@ func TestCompiledFile(t *testing.T) {
`[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu",
// 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 {
+1 -1
View File
@@ -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 (
+48 -80
View File
@@ -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 <clé>"
// for every call.
// cmdSetAPIKey définit (ou supprime) la clé Bearer de llama-server. Exposé sur
// internet, le serveur exige alors « Authorization: Bearer <clé> » à chaque
// appel.
//
// ajean set-api-key <clé> 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 {
+6 -6
View File
@@ -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
+62 -35
View File
@@ -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") != ""})
}
+7 -7
View File
@@ -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é")
+105 -19
View File
@@ -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)
}
}
}
}
@@ -0,0 +1,90 @@
package ajean
// Destination des téléchargements : le dossier doit être choisi parmi les
// dossiers de modèles déclarés (sinon l'UI web écrirait n'importe où), et
// l'espace libre doit être vérifié AVANT de transférer 40 Go.
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDownloadDestPathDir(t *testing.T) {
home := t.TempDir()
extra := t.TempDir()
t.Setenv("AJEAN_HOME", home)
t.Setenv("AJEAN_MODEL_DIRS", extra)
// Dossier vide → AJEAN_HOME.
got, err := downloadDestPath("m.gguf", "")
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(home, "m.gguf"); got != want {
t.Fatalf("défaut = %s, attendu %s", got, want)
}
// Dossier déclaré → accepté.
got, err = downloadDestPath("m.gguf", extra)
if err != nil {
t.Fatal(err)
}
if want := filepath.Join(extra, "m.gguf"); got != want {
t.Fatalf("dossier déclaré = %s, attendu %s", got, want)
}
// Dossier non déclaré → refusé.
if _, err := downloadDestPath("m.gguf", t.TempDir()); err == nil {
t.Fatal("un dossier non déclaré a été accepté")
}
// Le nom reste un simple fichier .gguf : pas de remontée de dossier.
if _, err := downloadDestPath(filepath.Join("..", "evil.gguf"), extra); err != nil {
t.Fatal(err)
} else if p, _ := downloadDestPath(filepath.Join("..", "evil.gguf"), extra); filepath.Dir(p) != extra {
t.Fatalf("path traversal : %s", p)
}
}
func TestDiskFreeAndCheck(t *testing.T) {
dir := t.TempDir()
free := diskFree(dir)
if free <= 0 {
t.Skipf("espace libre non mesurable ici (%d)", free)
}
// Un dossier pas encore créé doit être mesuré via son parent.
if diskFree(filepath.Join(dir, "pas", "encore")) <= 0 {
t.Fatal("diskFree ne remonte pas aux parents existants")
}
if err := checkDiskSpace(dir, 1<<10); err != nil {
t.Fatalf("1 Ko refusé alors que %d octets sont libres : %v", free, err)
}
err := checkDiskSpace(dir, free)
if err == nil {
t.Fatal("téléchargement accepté alors qu'il remplit le disque")
}
if !strings.Contains(err.Error(), "espace insuffisant") {
t.Fatalf("message inattendu : %v", err)
}
// Taille inconnue (serveur sans Content-Length) : on laisse passer.
if err := checkDiskSpace(dir, 0); err != nil {
t.Fatalf("taille inconnue refusée : %v", err)
}
}
// Les .part orphelins doivent aussi être nettoyés dans les dossiers ajoutés.
func TestCleanStalePartFilesExtraDirs(t *testing.T) {
home, extra := t.TempDir(), t.TempDir()
t.Setenv("AJEAN_HOME", home)
t.Setenv("AJEAN_MODEL_DIRS", extra)
stale := filepath.Join(extra, "coupe.gguf.part")
if err := os.WriteFile(stale, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
cleanStalePartFiles()
if _, err := os.Stat(stale); !os.IsNotExist(err) {
t.Fatal(".part orphelin d'un dossier ajouté non supprimé")
}
}
+3 -3
View File
@@ -27,7 +27,7 @@ func serveBlob(data []byte, ranges bool) *httptest.Server {
// Un téléchargement annulé ne doit laisser NI le .gguf final NI le .part.
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)
+22 -1
View File
@@ -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 != "" {
@@ -0,0 +1,39 @@
package ajean
import (
"runtime"
"testing"
)
// Le seul cas où compiler vaut mieux que télécharger : Linux + CUDA. llama.cpp
// ne publie pas de binaire CUDA pour Linux (seul Windows a bin-win-cuda-*), donc
// le précompilé y retombe sur Vulkan. Ailleurs, l'asset officiel exploite bien
// la machine (CUDA sous Windows, Metal sous macOS, ROCm sous Linux).
func TestRecommendedMode(t *testing.T) {
cases := []struct {
backend string
wantMode string
wantWhy bool
}{
{"cuda", "opt", true}, // le cas qui motive toute la fonction
{"rocm", "fast", false},
{"vulkan", "fast", false},
{"cpu", "fast", false},
{"metal", "fast", false},
}
for _, c := range cases {
got := recommendedMode(c.backend)
wantMode, wantWhy := c.wantMode, c.wantWhy
// La règle ne vise que Linux : sur les autres OS le précompilé reste
// conseillé même avec CUDA détecté.
if runtime.GOOS != "linux" {
wantMode, wantWhy = "fast", false
}
if got["mode"] != wantMode {
t.Errorf("backend %q sur %s : mode = %v, attendu %q", c.backend, runtime.GOOS, got["mode"], wantMode)
}
if hasWhy := got["why"] != ""; hasWhy != wantWhy {
t.Errorf("backend %q sur %s : why non vide = %v, attendu %v", c.backend, runtime.GOOS, hasWhy, wantWhy)
}
}
}
+34 -70
View File
@@ -51,44 +51,29 @@ func withDisplayName(content, name string) string {
return line + "\n" + content
}
// 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)
+6 -6
View File
@@ -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 <clé>". La clé est lue depuis
// $JEAN_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// $AJEAN_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
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())
+5 -40
View File
@@ -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 := ""
+3 -3
View File
@@ -135,7 +135,7 @@ func compactWouldTrigger(msgs []Message, knownTokens int) bool {
}
// logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur
// (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},
+10 -18
View File
@@ -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.
+12 -52
View File
@@ -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
+1 -42
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
// La mémoire de jean = des fichiers Markdown plats sous MEMORY/<nom>.md.
// La mémoire de ajean = des fichiers Markdown plats sous MEMORY/<nom>.md.
// L'IA y range ce qu'elle veut retenir entre les sessions (préférences,
// 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/<nom>/SKILL.md) vers la mémoire (MEMORY/<nom>.md). Idempotente : ne
// touche pas une page mémoire déjà existante, et pose un drapeau .migrated pour
// ne pas reparcourir à chaque lancement. Silencieuse en cas d'absence de SKILLS.
func migrateSkillsToMemory() {
flag := filepath.Join(memoryDir(), ".skills_migrated")
if _, err := os.Stat(flag); err == nil {
return // déjà fait
}
entries, err := os.ReadDir(skillsDir())
if err != nil {
return // pas d'anciens skills
}
migrated := 0
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
src := filepath.Join(skillsDir(), e.Name(), "SKILL.md")
b, rerr := os.ReadFile(src)
if rerr != nil {
continue
}
dst, derr := safeMemPath(e.Name())
if derr != nil {
continue
}
if _, err := os.Stat(dst); err == nil {
continue // page mémoire homonyme déjà présente : on ne l'écrase pas
}
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
return
}
if os.WriteFile(dst, b, 0o644) == nil {
migrated++
}
}
_ = os.MkdirAll(memoryDir(), 0o755)
_ = os.WriteFile(flag, []byte(fmt.Sprintf("%d skills migrés\n", migrated)), 0o644)
}
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
// mode agent (shell). Trois modes :
// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
+5 -5
View File
@@ -40,9 +40,9 @@ func baseSystemPrompt(caps Caps) string {
// sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur <think>
// 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
+5 -5
View File
@@ -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
+1 -1
View File
@@ -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")
+6 -6
View File
@@ -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
+2 -2
View File
@@ -1,7 +1,7 @@
// Jean — cœur du binaire (package jean, appelé par cmd/ajean). Les fichiers sont préfixés par domaine
// AJEAN — cœur du binaire (package ajean, appelé par cmd/ajean). Les fichiers sont préfixés par domaine
// (Go n'autorise pas de sous-dossiers dans un même package) :
//
// 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,
+13 -30
View File
@@ -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 {
+1 -1
View File
@@ -526,7 +526,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// le contexte — c'est un choix assumé.
for iter := 0; ; iter++ {
payload := map[string]any{
"model": "jean",
"model": "ajean",
"messages": messages,
"stream": true,
"temperature": temperature,
+8 -23
View File
@@ -72,38 +72,23 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
return srv.Serve(tls.NewListener(rawLn, tlsCfg))
}
// 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
}
}
+1 -1
View File
@@ -127,7 +127,7 @@ func (h headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error)
// connect établit une session pour la config donnée.
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() {
+5 -5
View File
@@ -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)
}
}
+13 -34
View File
@@ -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
+28 -77
View File
@@ -1,6 +1,6 @@
package ajean
// relay_link.go — `ajean link <token>` : connecte ce serveur Jean au relais public
// relay_link.go — `ajean link <token>` : connecte ce serveur AJEAN au relais public
// (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur
// 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<ext> » en reprenant au
// passage le « jean-link<ext> » laissé par une version antérieure.
//
// Reprendre le fichier PID n'est pas cosmétique : c'est LUI qui dit si un worker
// tourne déjà. Sans cette reprise, la première version renommée ne verrait plus
// le worker en cours, en démarrerait un second, et la machine se retrouverait
// avec deux tunnels concurrents vers le relais. Idempotent, silencieux en cas
// d'échec (on retombe simplement sur le nouveau nom).
func adoptLegacyLinkFile(ext string) string {
newPath := filepath.Join(AjeanHome(), linkUnitName+ext)
if _, err := os.Stat(newPath); err == nil {
return newPath
}
legacy := filepath.Join(AjeanHome(), legacyLinkUnitName+ext)
if _, err := os.Stat(legacy); err != nil {
return newPath
}
_ = os.Rename(legacy, newPath)
return newPath
}
// 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/<machine>/v1
// - tout le reste → l'UI web de Jean (avec injection de la clé de pilotage)
// - tout le reste → l'UI web de AJEAN (avec injection de la clé de pilotage)
func newLinkHandler(mux *http.ServeMux) http.Handler {
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é).
+26 -63
View File
@@ -20,9 +20,6 @@ func Main() {
// true. À faire AVANT toute écriture.
haveConsole := setupConsole()
// Migration one-shot des anciens skills (SKILLS/<nom>/SKILL.md) vers la
// nouvelle mémoire (MEMORY/<nom>.md). Idempotente, silencieuse si rien à faire.
migrateSkillsToMemory()
// Nettoie un éventuel binaire .old laissé par une mise à jour Windows.
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=<path> ou JEAN_HOME=<path>. Quiet on errors.
// readEtcDefault parses /etc/default/ajean for AJEAN_HOME=<path>. 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()
+190
View File
@@ -0,0 +1,190 @@
package ajean
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
bolt "go.etcd.io/bbolt"
)
// store.go — l'unique endroit où AJEAN écrit son état.
//
// Avant, chaque réglage avait son fichier : config.env, webprefs.json,
// conversation.json, .api_key, .link_token, .agent_enabled, model_dirs.json…
// Une douzaine de formats, une douzaine de façons de rater une écriture
// concurrente, et un dossier de données illisible. Tout ça tient désormais dans
// une seule base bbolt — pur Go, un seul fichier, transactionnelle.
//
// Ce qui N'EST PAS en base, et pourquoi : les presets (presets/*.env) et les
// pages de mémoire (memory/*.md) restent des fichiers, parce qu'ils sont faits
// pour être lus, édités et sauvegardés à la main. Les modèles (.gguf) et les
// backends compilés restent des fichiers, évidemment.
// Buckets. Un par nature de donnée : ça garde les itérations bornées et rend le
// contenu de la base lisible au débogage.
const (
bkConfig = "config" // configuration de llama-server (ex-config.env)
bkPrefs = "prefs" // préférences de l'UI web
bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
bkChat = "chat" // conversation partagée
)
var (
dbOnce sync.Once
dbConn *bolt.DB
dbErr error
)
func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") }
// db ouvre la base à la première utilisation et la garde ouverte pour la durée
// du process. bbolt prend un verrou exclusif sur le fichier : le délai d'attente
// évite qu'un second process (l'app lancée deux fois) se bloque indéfiniment.
func db() (*bolt.DB, error) {
dbOnce.Do(func() {
if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
dbErr = err
return
}
d, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 3 * time.Second})
if err != nil {
dbErr = fmt.Errorf("base %s inaccessible : %w", dbPath(), err)
return
}
err = d.Update(func(tx *bolt.Tx) error {
for _, b := range []string{bkConfig, bkPrefs, bkState, bkChat} {
if _, err := tx.CreateBucketIfNotExists([]byte(b)); err != nil {
return err
}
}
return nil
})
if err != nil {
d.Close()
dbErr = err
return
}
dbConn = d
})
return dbConn, dbErr
}
// getBytes lit une valeur. Une base inaccessible se comporte comme une base
// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile
// face à une erreur d'E/S, et tous ont déjà un défaut.
func getBytes(bucket, key string) []byte {
d, err := db()
if err != nil {
return nil
}
var out []byte
_ = d.View(func(tx *bolt.Tx) error {
if v := tx.Bucket([]byte(bucket)).Get([]byte(key)); v != nil {
out = append([]byte(nil), v...) // la valeur ne survit pas à la transaction
}
return nil
})
return out
}
// putBytes écrit une valeur. Une valeur nil supprime la clé.
func putBytes(bucket, key string, val []byte) error {
d, err := db()
if err != nil {
return err
}
return d.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bucket))
if val == nil {
return b.Delete([]byte(key))
}
return b.Put([]byte(key), val)
})
}
func getStr(bucket, key string) string { return string(getBytes(bucket, key)) }
// putStr écrit une chaîne ; une chaîne vide supprime la clé, pour que « absent »
// et « vide » ne soient jamais deux états distincts à distinguer.
func putStr(bucket, key, val string) error {
if val == "" {
return putBytes(bucket, key, nil)
}
return putBytes(bucket, key, []byte(val))
}
func getBool(bucket, key string) bool { return getStr(bucket, key) == "1" }
func putBool(bucket, key string, on bool) error {
if !on {
return putBytes(bucket, key, nil)
}
return putStr(bucket, key, "1")
}
// getJSON décode une valeur JSON dans dst. Renvoie false si la clé est absente
// ou illisible — dans les deux cas l'appelant garde son zéro.
func getJSON(bucket, key string, dst any) bool {
b := getBytes(bucket, key)
if len(b) == 0 {
return false
}
return json.Unmarshal(b, dst) == nil
}
func putJSON(bucket, key string, v any) error {
b, err := json.Marshal(v)
if err != nil {
return err
}
return putBytes(bucket, key, b)
}
// allKV renvoie tout le contenu d'un bucket. Utilisé par la configuration, dont
// les clés ne sont pas connues à l'avance (EXTRA_ARGS et consorts).
func allKV(bucket string) map[string]string {
m := map[string]string{}
d, err := db()
if err != nil {
return m
}
_ = d.View(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(bucket)).ForEach(func(k, v []byte) error {
m[string(k)] = string(v)
return nil
})
})
return m
}
// replaceKV remplace tout le contenu d'un bucket en une seule transaction.
// C'est ce qu'exige l'application d'un preset : à aucun instant la config ne
// doit être un mélange de l'ancienne et de la nouvelle.
func replaceKV(bucket string, m map[string]string) error {
d, err := db()
if err != nil {
return err
}
return d.Update(func(tx *bolt.Tx) error {
if err := tx.DeleteBucket([]byte(bucket)); err != nil {
return err
}
b, err := tx.CreateBucket([]byte(bucket))
if err != nil {
return err
}
for k, v := range m {
if v == "" {
continue
}
if err := b.Put([]byte(k), []byte(v)); err != nil {
return err
}
}
return nil
})
}
+8 -8
View File
@@ -7,10 +7,10 @@ package ajean
// Sur macOS il n'y a pas d'équivalent d'AttachConsole : un binaire lancé depuis
// 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/<uuid>/, à un chemin différent à
@@ -44,7 +44,7 @@ func setupConsole() bool {
// dessus (la seconde trouve le port 8090 occupé et se contente d'ouvrir l'UI de
// 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{
+2 -2
View File
@@ -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 à
+26
View File
@@ -0,0 +1,26 @@
//go:build !windows
package ajean
// sys_disk_other.go — espace libre d'un système de fichiers via statfs(2).
// Bavail (et pas Bfree) : les blocs réservés à root ne sont pas utilisables pour
// poser un .gguf de 40 Go.
import "syscall"
// diskFreeAt renvoie les octets libres du système de fichiers contenant dir, ou
// -1 si la mesure échoue. dir doit exister (l'appelant remonte les parents au
// besoin).
func diskFreeAt(dir string) int64 {
var st syscall.Statfs_t
if err := syscall.Statfs(dir, &st); err != nil {
return -1
}
// Les types de Bavail/Bsize diffèrent selon l'OS (int64 sous Linux, uint64
// et uint32 sous macOS) : on passe par uint64 avant de multiplier.
free := uint64(st.Bavail) * uint64(st.Bsize)
if free > 1<<62 {
return -1
}
return int64(free)
}
+32
View File
@@ -0,0 +1,32 @@
package ajean
// sys_disk_windows.go — espace libre d'un volume via GetDiskFreeSpaceExW. On
// veut l'espace réellement disponible pour l'utilisateur courant (quotas
// compris), donc le premier paramètre de sortie, pas le total du volume.
import (
"syscall"
"unsafe"
)
var procGetDiskFreeSpaceExW = syscall.NewLazyDLL("kernel32.dll").NewProc("GetDiskFreeSpaceExW")
// diskFreeAt renvoie les octets libres du volume contenant dir, ou -1 si la
// mesure échoue. dir doit exister (l'appelant remonte les parents au besoin).
func diskFreeAt(dir string) int64 {
p, err := syscall.UTF16PtrFromString(dir)
if err != nil {
return -1
}
var free, total, totalFree uint64
r, _, _ := procGetDiskFreeSpaceExW.Call(
uintptr(unsafe.Pointer(p)),
uintptr(unsafe.Pointer(&free)),
uintptr(unsafe.Pointer(&total)),
uintptr(unsafe.Pointer(&totalFree)),
)
if r == 0 || free > 1<<62 {
return -1
}
return int64(free)
}
-15
View File
@@ -1,15 +0,0 @@
//go:build !windows
package ajean
// Pendant non-Windows de sys_elevate_windows.go.
//
// L'élévation ponctuelle n'a de sens que sous Windows : ailleurs, l'installation
// se fait déjà sous `sudo ajean install`, qui a les droits nécessaires et migre
// l'agencement complet (voir sys_layout.go). Il n'y a donc rien à relancer.
const migrateHomeArg = "migrate-home-elevated"
func cmdMigrateHomeElevated() error { return nil }
func elevateForHomeMigration() bool { return false }
-79
View File
@@ -1,79 +0,0 @@
//go:build windows
package ajean
import (
"fmt"
"os"
"os/exec"
"strings"
)
// Élévation ponctuelle pour terminer la migration du dossier de données.
//
// Renommer %ProgramData%\jean exige d'écrire dans %ProgramData%, ce qu'un compte
// standard ne peut pas faire. Sans élévation, ces postes gardent l'ancien nom
// pour toujours — ce qui est sans conséquence, mais laisse le renommage à
// moitié fait.
//
// La fenêtre UAC n'apparaît QUE si elle sert à quelque chose :
//
// - migration déjà faite, inutile, ou chemin imposé par l'utilisateur → rien ;
// - migration en attente, dans un parcours d'installation → on demande.
//
// On ne cherche PAS à deviner la cause depuis le code d'erreur : Windows renvoie
// ERROR_ACCESS_DENIED aussi bien pour un manque de droits que pour un dossier
// verrouillé par un programme en cours. Impossible de les séparer — vérifié.
//
// C'est donc le LIEU D'APPEL qui fait la sélection, et lui est fiable : on
// n'élève que depuis `install` et depuis l'installeur de mise à jour, tous deux
// après l'arrêt des instances en cours. Le cas « verrouillé » y est déjà écarté ;
// ce qui reste est un problème de droits, que l'élévation résout.
// migrateHomeArg est la sous-commande interne exécutée par le process élevé.
// Absente de l'aide : ce n'est pas une commande destinée aux utilisateurs, mais
// le travail précis qu'on délègue à un process ayant les droits.
const migrateHomeArg = "migrate-home-elevated"
// cmdMigrateHomeElevated est le point d'entrée du process élevé : il migre, dit
// ce qu'il a fait, et rend la main. Rien d'autre — moins il en fait sous des
// droits administrateur, mieux c'est.
func cmdMigrateHomeElevated() error {
if retryHomeMigration() {
fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome())
return nil
}
if migrationDeferred != "" {
return fmt.Errorf("migration toujours impossible : %s", migrationDeferred)
}
return nil
}
// elevateForHomeMigration relance AJEAN en administrateur le temps de la seule
// migration, puis revérifie. Renvoie true si le dossier a effectivement changé.
//
// Silencieux et sans effet quand l'élévation ne servirait à rien, y compris si
// l'utilisateur refuse la fenêtre UAC : son refus est une réponse valable, la
// machine continue de fonctionner sur l'ancien chemin.
func elevateForHomeMigration() bool {
if migrationDeferred == "" {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
// Start-Process -Verb RunAs déclenche l'invite UAC ; -Wait pour que la suite
// de l'installation reparte sur le bon dossier, pas sur l'ancien.
ps := fmt.Sprintf(
`try { Start-Process -FilePath %s -ArgumentList '%s' -Verb RunAs -Wait -WindowStyle Hidden; 'ok' } catch { 'refus' }`,
psQuote(exe), migrateHomeArg)
out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-Command", ps)).CombinedOutput()
if err != nil || !strings.Contains(string(out), "ok") {
return false // refus de l'utilisateur, ou élévation indisponible
}
// Le process élevé a fait le travail dans SON espace mémoire : on relit le
// disque pour savoir ce qu'il en est réellement ici.
migrationDeferred = ""
return retryHomeMigration()
}
-23
View File
@@ -1,23 +0,0 @@
package ajean
import (
"os"
"strings"
)
// envAny lit la première variable d'environnement renseignée parmi names.
//
// Sert au renommage jean → AJEAN : chaque réglage s'appelle désormais AJEAN_*,
// mais l'ancien nom JEAN_* reste lu en second. Ces variables sont posées à la
// main — dans un shell, un fichier d'unité, un docker-compose, un script de
// déploiement — et rien ne nous permet de les retrouver pour les réécrire. Les
// cesser de les lire, c'est casser silencieusement des installations qui
// marchaient, avec un réglage qui redevient sa valeur par défaut sans le dire.
func envAny(names ...string) string {
for _, n := range names {
if v := strings.TrimSpace(os.Getenv(n)); v != "" {
return v
}
}
return ""
}
+7 -30
View File
@@ -19,8 +19,8 @@ import (
// Premier lancement sous Windows.
//
// 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) {
+6 -6
View File
@@ -28,10 +28,10 @@ func TestBinaryVersionReadsResource(t *testing.T) {
// Sur une machine vierge, le dossier bin n'existe pas encore : installSelf doit
// 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)
}
+14 -14
View File
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
@@ -60,7 +60,7 @@ const launchdPlistTemplate = `<?xml version="1.0" encoding="UTF-8"?>
<key>UserName</key><string>%s</string>
<key>WorkingDirectory</key><string>%s</string>
<key>EnvironmentVariables</key>
<dict><key>AJEAN_HOME</key><string>%s</string><key>JEAN_HOME</key><string>%s</string></dict>
<dict><key>AJEAN_HOME</key><string>%s</string><key>AJEAN_HOME</key><string>%s</string></dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key>
<dict><key>SuccessfulExit</key><false/></dict>
@@ -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
}
+14 -14
View File
@@ -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
+9 -21
View File
@@ -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)
-298
View File
@@ -1,298 +0,0 @@
package ajean
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// Migration complète de l'agencement système jean → ajean (Unix).
//
// Renommer le dossier de données ne suffit pas sur une machine installée : le
// chemin est aussi inscrit dans /etc/default/jean et dans l'unité systemd. Les
// trois doivent bouger ensemble, sinon on obtient le pire des cas — des données
// déplacées et un service qui ne redémarre plus.
//
// QUAND. Jamais au démarrage ordinaire : un service qui réécrit des unités
// systemd à chaque boot finirait par en casser une, et sur un parc entier ça
// veut dire des machines qui ne redémarrent plus. Uniquement à deux moments
// délibérés et déjà privilégiés : `ajean install`, et juste après une mise à
// jour réussie — le service est de toute façon sur le point d'être relancé.
//
// CE QU'ON S'AUTORISE À DÉPLACER. Un chemin inscrit à la main est un ordre. On
// ne migre donc QUE si le chemin actuel est exactement l'ancien emplacement par
// défaut, celui que notre propre installeur a écrit. Un utilisateur qui a pointé
// JEAN_HOME vers son gros disque garde son choix, intact et silencieux.
// layoutPlan décrit une migration d'agencement. Les chemins sont des champs
// plutôt que des constantes pour que la logique soit testable sur un faux
// système de fichiers, sans toucher au vrai /etc.
type layoutPlan struct {
Old string // /etc/jean
New string // /etc/ajean
DefaultDir string // /etc/default
UnitDir string // /etc/systemd/system
Units []string // unités à arrêter puis relancer
BackupDir string // où déposer l'archive de sécurité
// stopUnit / startUnit / reloadUnits sont injectables pour les tests.
stopUnit func(string) error
startUnit func(string) error
unitActive func(string) bool
reloadUnits func() error
isRoot func() bool
backup func(layoutPlan) error
}
func defaultLayoutPlan() layoutPlan {
return layoutPlan{
Old: legacyDefaultHome(),
New: defaultAjeanHome(),
DefaultDir: "/etc/default",
UnitDir: "/etc/systemd/system",
Units: []string{serviceName(), linkServiceName()},
BackupDir: "/root",
stopUnit: func(u string) error { return exec.Command("systemctl", "stop", u).Run() },
startUnit: func(u string) error { return exec.Command("systemctl", "start", u).Run() },
unitActive: func(u string) bool {
out, _ := exec.Command("systemctl", "is-active", u).Output()
return strings.TrimSpace(string(out)) == "active"
},
reloadUnits: func() error { return exec.Command("systemctl", "daemon-reload").Run() },
isRoot: func() bool { return os.Geteuid() == 0 },
backup: backupLayout,
}
}
// updateLayoutPlan est la variante utilisée juste après une mise à jour, quand
// on s'exécute DANS le service de lien. Elle n'arrête et ne relance rien.
//
// Deux raisons, et la première est fatale :
//
// 1. Arrêter jean-link depuis jean-link, c'est se tuer soi-même au milieu de la
// migration — dossier déplacé, chemins pas encore réécrits, machine cassée.
// 2. Arrêter le service d'inférence rechargerait un modèle de plusieurs
// dizaines de Go, ce qui n'a rien à faire derrière un bouton « mettre à jour ».
//
// C'est sans danger : sous Linux un rename de dossier fonctionne même avec des
// fichiers ouverts, et un process en cours garde son répertoire de travail par
// son inode. L'unité est réécrite dans la foulée, donc le prochain redémarrage —
// celui de jean-link, immédiat, puis celui du service d'inférence, plus tard —
// repart sur les bons chemins.
func updateLayoutPlan() layoutPlan {
p := defaultLayoutPlan()
p.unitActive = func(string) bool { return false }
return p
}
// pinnedHome lit le chemin impose par /etc/default/ajean ou /etc/default/jean.
// Renvoie ("", "") si aucun des deux ne fixe de valeur.
func (p layoutPlan) pinnedHome() (file, value string) {
for _, name := range []string{"ajean", "jean"} {
f := filepath.Join(p.DefaultDir, name)
b, err := os.ReadFile(f)
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "export "))
if s == "" || strings.HasPrefix(s, "#") {
continue
}
k, v, ok := strings.Cut(s, "=")
if !ok {
continue
}
if k = strings.TrimSpace(k); k == "AJEAN_HOME" || k == "JEAN_HOME" {
return f, strings.Trim(strings.TrimSpace(v), "\"'")
}
}
}
return "", ""
}
// layoutNeedsMigration dit si une migration est possible ET legitime, avec la
// raison quand elle ne l'est pas (affichée telle quelle par `ajean install`).
func (p layoutPlan) layoutNeedsMigration() (bool, string) {
if !isDir(p.Old) {
return false, "" // rien à migrer : machine neuve ou déjà migrée
}
if isDir(p.New) {
return false, fmt.Sprintf("%s et %s existent tous les deux — migration à faire à la main", p.Old, p.New)
}
// Le point délicat : distinguer notre artefact d'un choix de l'utilisateur.
if file, value := p.pinnedHome(); value != "" && value != p.Old {
return false, fmt.Sprintf("%s fixe le dossier à %s — chemin choisi explicitement, laissé intact", file, value)
}
return true, ""
}
// migrateLayout exécute la migration complète. Renvoie nil sans rien faire
// quand il n'y a rien à migrer. En cas d'échec après le déplacement, remet la
// machine dans son état initial et relance ce qui tournait.
func migrateLayout(p layoutPlan) error {
ok, why := p.layoutNeedsMigration()
if !ok {
if why != "" {
fmt.Printf("%s %s\n", dim("[info]"), why)
}
return nil
}
if !p.isRoot() {
fmt.Printf("%s dossier de données encore en %s — relance en root pour le migrer\n", yellow("[info]"), p.Old)
return nil
}
fmt.Printf("%s migration de l'agencement %s → %s\n", cyan("[migration]"), p.Old, p.New)
if err := p.backup(p); err != nil {
return fmt.Errorf("sauvegarde impossible, migration annulée : %w", err)
}
var stopped []string
for _, u := range p.Units {
if p.unitActive(u) {
_ = p.stopUnit(u)
stopped = append(stopped, u)
fmt.Printf(" %s arrêté %s\n", green("✓"), u)
}
}
restart := func() {
for _, u := range stopped {
_ = p.startUnit(u)
}
}
// Déplacement atomique : même volume, donc instantané même avec 200 Go de
// modèles, et jamais un état où les données sont à moitié quelque part.
if err := os.Rename(p.Old, p.New); err != nil {
restart()
return fmt.Errorf("déplacement impossible (%w) — rien n'a été modifié", err)
}
// À partir d'ici, toute erreur doit rendre la machine à son état d'origine.
rollback := func(cause error) error {
_ = os.Rename(p.New, p.Old)
restart()
return fmt.Errorf("%w — état initial restauré", cause)
}
if err := rewriteTree(p.New, p.Old, p.New); err != nil {
return rollback(err)
}
if err := writeDefaults(p); err != nil {
return rollback(err)
}
if err := rewriteUnits(p); err != nil {
return rollback(err)
}
_ = p.reloadUnits()
restart()
fmt.Printf(" %s dossier de données : %s\n", green("✓"), p.New)
return nil
}
// backupLayout archive les petits fichiers (config, presets, mémoire, clés).
// Les .gguf et backends/ sont exclus : des dizaines de Go qui se retéléchargent,
// et qui ne sont de toute façon pas menacés — ils ne font que changer de nom de
// dossier parent.
func backupLayout(p layoutPlan) error {
if _, err := exec.LookPath("tar"); err != nil {
return err
}
if err := os.MkdirAll(p.BackupDir, 0o755); err != nil {
return err
}
dst := filepath.Join(p.BackupDir, "ajean-sauvegarde-"+time.Now().Format("20060102-150405")+".tar.gz")
cmd := exec.Command("tar", "czf", dst, "-C", p.Old,
"--exclude=./backends", "--exclude=*.gguf", "--exclude=*.gguf.part", "--exclude=*.log", ".")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
}
fmt.Printf(" %s sauvegarde %s\n", green("✓"), dst)
return nil
}
// rewriteTree remplace toute mention de oldPath par newPath dans les fichiers
// texte de root. On PARCOURT au lieu de deviner une liste : l'expérience montre
// que les chemins absolus se cachent partout — EXTRA_ARGS d'un preset, copies
// .bak, notes de MEMORY. Les .gguf et backends/ sont sautés (aucun chemin à y
// trouver, et les lire prendrait des heures).
func rewriteTree(root, oldPath, newPath string) error {
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil // un fichier illisible ne doit pas faire échouer la migration
}
if info.IsDir() {
if info.Name() == "backends" {
return filepath.SkipDir
}
return nil
}
switch {
case strings.HasSuffix(path, ".gguf"), strings.HasSuffix(path, ".gguf.part"):
return nil
case info.Size() > 4<<20: // au-delà, ce n'est plus de la configuration
return nil
}
b, err := os.ReadFile(path)
if err != nil || !strings.Contains(string(b), oldPath) {
return nil
}
out := strings.ReplaceAll(string(b), oldPath, newPath)
mode := info.Mode().Perm()
if err := os.WriteFile(path, []byte(out), mode); err != nil {
return fmt.Errorf("réécriture de %s : %w", path, err)
}
fmt.Printf(" %s chemins mis à jour : %s\n", green("✓"), strings.TrimPrefix(path, root+string(os.PathSeparator)))
return nil
})
}
// writeDefaults pose /etc/default/ajean et repointe l'ancien fichier au lieu de
// le supprimer : des scripts d'utilisateurs le sourcent pour lire $JEAN_HOME.
func writeDefaults(p layoutPlan) error {
if err := os.MkdirAll(p.DefaultDir, 0o755); err != nil {
return err
}
body := fmt.Sprintf("# Généré par ajean — racine des données\nAJEAN_HOME=%s\nJEAN_HOME=%s\n", p.New, p.New)
if err := os.WriteFile(filepath.Join(p.DefaultDir, "ajean"), []byte(body), 0o644); err != nil {
return err
}
legacy := filepath.Join(p.DefaultDir, "jean")
if b, err := os.ReadFile(legacy); err == nil {
if out := strings.ReplaceAll(string(b), p.Old, p.New); out != string(b) {
if err := os.WriteFile(legacy, []byte(out), 0o644); err != nil {
return err
}
}
}
return nil
}
// rewriteUnits corrige les chemins DANS les unités, sans les renommer. Renommer
// imposerait un disable/enable et une fenêtre où plus rien n'est actif, pour un
// gain invisible : le code sait déjà utiliser l'unité réellement installée.
func rewriteUnits(p layoutPlan) error {
entries, err := filepath.Glob(filepath.Join(p.UnitDir, "*.service"))
if err != nil {
return err
}
for _, unit := range entries {
b, err := os.ReadFile(unit)
if err != nil || !strings.Contains(string(b), p.Old) {
continue
}
if err := os.WriteFile(unit+".avant-ajean", b, 0o644); err != nil {
return err
}
if err := os.WriteFile(unit, []byte(strings.ReplaceAll(string(b), p.Old, p.New)), 0o644); err != nil {
return err
}
fmt.Printf(" %s unité corrigée : %s\n", green("✓"), filepath.Base(unit))
}
return nil
}
-243
View File
@@ -1,243 +0,0 @@
package ajean
import (
"os"
"path/filepath"
"strings"
"testing"
)
// fakeLayout monte un faux /etc : dossier de donnees installe, /etc/default et
// une unite systemd, exactement l'agencement d'une machine ou `jean install` est
// passe. Les commandes systemctl sont remplacees par des mouchards.
type fakeLayout struct {
plan layoutPlan
root string
stopped []string
started []string
active map[string]bool
backedUp bool
}
func newFakeLayout(t *testing.T) *fakeLayout {
t.Helper()
root := t.TempDir()
f := &fakeLayout{
root: root,
active: map[string]bool{"jean": true, "jean-link": true},
}
old := filepath.Join(root, "etc", "jean")
for _, d := range []string{
filepath.Join(old, "configs"),
filepath.Join(old, "MEMORY"),
filepath.Join(old, "backends", "llama.cpp", "build"),
filepath.Join(root, "etc", "default"),
filepath.Join(root, "etc", "systemd", "system"),
} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write := func(p, body string) {
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// Un BIN externe (donc a NE PAS toucher) et un jinja interne (a suivre).
write(filepath.Join(old, "config.env"),
"BIN=\"/home/n/llama.cpp/llama-server\"\nEXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n")
write(filepath.Join(old, "fix.jinja"), "template")
write(filepath.Join(old, "configs", "GROS MODELE (2).env"), "EXTRA_ARGS=\"--chat-template-file "+old+"/fix.jinja\"\n")
write(filepath.Join(old, "MEMORY", "note.md"), "voir "+old+"/dataset.jsonl\n")
write(filepath.Join(old, ".e2e_key"), "cle-precieuse\n")
// Un faux modele et un gros fichier de build : ni l'un ni l'autre ne doit
// etre reecrit ni meme lu.
write(filepath.Join(old, "modele.gguf"), "binaire")
write(filepath.Join(old, "backends", "llama.cpp", "build", "cache.txt"), old+"/quelque-chose\n")
write(filepath.Join(root, "etc", "default", "jean"), "# Generated by jean install\nJEAN_HOME="+old+"\n")
write(filepath.Join(root, "etc", "systemd", "system", "jean.service"),
"[Service]\nWorkingDirectory="+old+"\nExecStart=/usr/local/bin/jean serve\n")
write(filepath.Join(root, "etc", "systemd", "system", "jean-link.service"),
"[Service]\nExecStart=/usr/local/bin/jean link serve\n")
f.plan = layoutPlan{
Old: old,
New: filepath.Join(root, "etc", "ajean"),
DefaultDir: filepath.Join(root, "etc", "default"),
UnitDir: filepath.Join(root, "etc", "systemd", "system"),
Units: []string{"jean", "jean-link"},
BackupDir: filepath.Join(root, "sauvegardes"),
stopUnit: func(u string) error { f.stopped = append(f.stopped, u); f.active[u] = false; return nil },
startUnit: func(u string) error { f.started = append(f.started, u); f.active[u] = true; return nil },
unitActive: func(u string) bool { return f.active[u] },
reloadUnits: func() error { return nil },
isRoot: func() bool { return true },
// tar refuse un chemin contenant « C: » sous Windows (il y voit un hote
// distant) : on remplace la sauvegarde par un mouchard, elle est verifiee
// pour ce qu'elle est — un prealable bloquant — dans son propre test.
backup: func(layoutPlan) error { f.backedUp = true; return nil },
}
return f
}
func (f *fakeLayout) read(t *testing.T, rel string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join(f.plan.New, rel))
if err != nil {
t.Fatalf("%s illisible: %v", rel, err)
}
return string(b)
}
// Cas nominal complet : donnees deplacees, chemins reecrits partout, /etc/default
// et unite corriges, services arretes puis relances.
func TestMigrateLayoutNominal(t *testing.T) {
f := newFakeLayout(t)
if err := migrateLayout(f.plan); err != nil {
t.Fatalf("migration echouee: %v", err)
}
if isDir(f.plan.Old) {
t.Error("l'ancien dossier existe encore")
}
if !isDir(f.plan.New) {
t.Fatal("le nouveau dossier est absent")
}
// Le jinja interne suit, le BIN externe ne bouge pas.
conf := f.read(t, "config.env")
if !strings.Contains(conf, f.plan.New+"/fix.jinja") {
t.Errorf("le chat-template-file n'a pas suivi:\n%s", conf)
}
if !strings.Contains(conf, "/home/n/llama.cpp/llama-server") {
t.Errorf("le BIN externe a ete modifie a tort:\n%s", conf)
}
// Preset avec espaces/parentheses, et note de MEMORY.
if strings.Contains(f.read(t, filepath.Join("configs", "GROS MODELE (2).env")), f.plan.Old) {
t.Error("preset non reecrit")
}
if strings.Contains(f.read(t, filepath.Join("MEMORY", "note.md")), f.plan.Old) {
t.Error("note MEMORY non reecrite")
}
if f.read(t, ".e2e_key") != "cle-precieuse\n" {
t.Error("la cle E2E a ete alteree")
}
// backends/ doit etre saute : le reecrire ferait perdre des heures pour rien.
if !strings.Contains(f.read(t, filepath.Join("backends", "llama.cpp", "build", "cache.txt")), f.plan.Old) {
t.Error("backends/ a ete parcouru alors qu'il doit etre saute")
}
// /etc/default : nouveau fichier ET ancien repointe (des scripts le sourcent).
newDef, err := os.ReadFile(filepath.Join(f.plan.DefaultDir, "ajean"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(newDef), "AJEAN_HOME="+f.plan.New) || !strings.Contains(string(newDef), "JEAN_HOME="+f.plan.New) {
t.Errorf("/etc/default/ajean incomplet:\n%s", newDef)
}
oldDef, _ := os.ReadFile(filepath.Join(f.plan.DefaultDir, "jean"))
if strings.Contains(string(oldDef), f.plan.Old) {
t.Errorf("/etc/default/jean pointe encore sur l'ancien chemin:\n%s", oldDef)
}
// Unite corrigee, copie de securite conservee, nom inchange.
unit, _ := os.ReadFile(filepath.Join(f.plan.UnitDir, "jean.service"))
if strings.Contains(string(unit), f.plan.Old) {
t.Errorf("unite non corrigee:\n%s", unit)
}
if _, err := os.Stat(filepath.Join(f.plan.UnitDir, "jean.service.avant-ajean")); err != nil {
t.Error("copie de securite de l'unite absente")
}
if !f.backedUp {
t.Error("aucune sauvegarde avant migration")
}
if len(f.stopped) != 2 || len(f.started) != 2 {
t.Errorf("services: arretes=%v relances=%v", f.stopped, f.started)
}
}
// Chemin choisi explicitement par l'utilisateur : on ne migre RIEN.
func TestMigrateLayoutRespectsUserChoice(t *testing.T) {
f := newFakeLayout(t)
custom := filepath.Join(f.root, "mnt", "gros-disque", "jean")
if err := os.WriteFile(filepath.Join(f.plan.DefaultDir, "jean"),
[]byte("JEAN_HOME="+custom+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if ok, why := f.plan.layoutNeedsMigration(); ok {
t.Fatal("une migration a ete jugee legitime malgre un chemin impose")
} else if !strings.Contains(why, custom) {
t.Errorf("raison peu claire: %q", why)
}
if err := migrateLayout(f.plan); err != nil {
t.Fatal(err)
}
if !isDir(f.plan.Old) || isDir(f.plan.New) {
t.Error("le dossier a bouge alors que l'utilisateur avait impose un chemin")
}
if len(f.stopped) != 0 {
t.Error("des services ont ete arretes pour rien")
}
}
// Sans les droits root, on n'entreprend rien et on ne casse rien.
func TestMigrateLayoutNonRoot(t *testing.T) {
f := newFakeLayout(t)
f.plan.isRoot = func() bool { return false }
if err := migrateLayout(f.plan); err != nil {
t.Fatal(err)
}
if !isDir(f.plan.Old) || isDir(f.plan.New) {
t.Error("migration effectuee sans les droits root")
}
}
// Les deux dossiers presents : situation ambigue, on s'abstient.
func TestMigrateLayoutBothPresent(t *testing.T) {
f := newFakeLayout(t)
if err := os.MkdirAll(f.plan.New, 0o755); err != nil {
t.Fatal(err)
}
if ok, why := f.plan.layoutNeedsMigration(); ok || why == "" {
t.Fatalf("attendu un refus explicite, got ok=%v why=%q", ok, why)
}
}
// Deja migre (plus d'ancien dossier) : silencieux, aucun message inquietant.
func TestMigrateLayoutAlreadyDone(t *testing.T) {
f := newFakeLayout(t)
if err := os.Rename(f.plan.Old, f.plan.New); err != nil {
t.Fatal(err)
}
ok, why := f.plan.layoutNeedsMigration()
if ok || why != "" {
t.Fatalf("attendu un silence, got ok=%v why=%q", ok, why)
}
}
// La variante « apres mise a jour » ne doit toucher a AUCUN service : on tourne
// dans jean-link, l'arreter reviendrait a se tuer au milieu de la migration.
func TestUpdateLayoutPlanNeverStopsServices(t *testing.T) {
f := newFakeLayout(t)
p := f.plan
p.unitActive = updateLayoutPlan().unitActive // seule difference testee
if err := migrateLayout(p); err != nil {
t.Fatalf("migration echouee: %v", err)
}
if len(f.stopped) != 0 || len(f.started) != 0 {
t.Fatalf("services touches: arretes=%v relances=%v", f.stopped, f.started)
}
// La migration doit rester complete malgre tout.
if isDir(p.Old) || !isDir(p.New) {
t.Fatal("le dossier n'a pas ete migre")
}
if strings.Contains(f.read(t, "config.env"), p.Old) {
t.Error("chemins non reecrits")
}
unit, _ := os.ReadFile(filepath.Join(p.UnitDir, "jean.service"))
if strings.Contains(string(unit), p.Old) {
t.Error("unite non corrigee : le prochain redemarrage echouerait")
}
}
-274
View File
@@ -1,274 +0,0 @@
package ajean
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
)
// Migration du dossier de données jean → ajean.
//
// C'est le seul endroit du renommage qui peut faire PERDRE des données : le
// dossier contient la config, les clés, la mémoire, les conversations, et
// surtout des .gguf qui pèsent des dizaines de gigaoctets. Trois règles en
// découlent, et elles expliquent tout le code ci-dessous :
//
// 1. On déplace par os.Rename DANS LE DOSSIER PARENT de l'ancien. Un rename
// intra-volume est atomique et instantané — il ne recopie pas les 40 Go, et
// il n'existe aucun instant où les données seraient à moitié quelque part.
// Choisir le parent de l'ancien chemin (et pas le défaut de la plateforme)
// garantit qu'on reste sur le même volume, donc que le rename est bien un
// rename et non un copy+delete déguisé.
//
// 2. Si le rename échoue — service en cours qui tient des handles sous Windows,
// droits insuffisants, volume monté en lecture seule — on CONTINUE SUR
// L'ANCIEN CHEMIN. Une machine qui n'a pas migré marche exactement comme
// avant ; une machine qui a perdu ses modèles, non. La migration est donc
// retentée à chaque démarrage jusqu'à ce qu'elle passe.
//
// 3. Aucune suppression, jamais. Le pire cas est « rien n'a bougé ».
var (
// homeMu protège la résolution du dossier. Un simple sync.Once ne suffisait
// pas : retryHomeMigration le RÉINITIALISE, et il est appelé depuis le
// handler HTTP de mise à jour pendant que le serveur sert d'autres requêtes.
// Réécrire ces variables sans verrou était une course de données.
homeMu sync.RWMutex
homeResolved bool
homePath string
// migrationDeferred retient pourquoi le dossier n'a pas pu être renommé,
// pour que seules les commandes qui parlent d'emplacements en fassent état.
migrationDeferred string
)
// homeMigrationNotice renvoie le message à montrer quand la migration attend
// encore, ou "" s'il n'y a rien à signaler. Renvoie aussi le conseil qui va
// avec : sous Windows, l'obstacle est presque toujours un manque de droits sur
// C:\ProgramData, et la réponse tient en une phrase.
func homeMigrationNotice() string {
if migrationDeferred == "" {
return ""
}
msg := migrationDeferred + "\n Tout fonctionne : AJEAN continue d'utiliser ce dossier."
if runtime.GOOS == "windows" {
return msg + "\n Pour aligner les noms, ferme AJEAN puis lance « ajean install » en administrateur."
}
return msg + "\n Pour aligner les noms : sudo ajean install."
}
// renameCause extrait la cause réelle d'un échec de rename. os.Rename renvoie un
// *os.LinkError dont le texte répète les deux chemins complets — dans un message
// qui les cite déjà, ça donne trois fois la même chose et rend l'essentiel
// (« Accès refusé ») illisible. On ne garde que ce dernier.
func renameCause(err error) string {
var le *os.LinkError
if errors.As(err, &le) && le.Err != nil {
return le.Err.Error()
}
return err.Error()
}
// migratedDefaultHome renvoie le dossier de données par défaut, en migrant
// l'ancien dossier « jean » vers « ajean » à la première résolution du process.
// Le résultat est mis en cache : deux appels ne doivent jamais désigner deux
// dossiers différents, sinon une moitié du programme écrirait à côté de l'autre.
//
// N'est PAS appelé quand $AJEAN_HOME/$JEAN_HOME ou /etc/default/* imposent un
// chemin : un choix explicite de l'utilisateur ne se migre pas.
func migratedDefaultHome() string {
homeMu.RLock()
if homeResolved {
p := homePath
homeMu.RUnlock()
return p
}
homeMu.RUnlock()
homeMu.Lock()
defer homeMu.Unlock()
if !homeResolved { // un autre appelant a pu résoudre entre les deux verrous
homePath, homeResolved = resolveDefaultHome(), true
}
return homePath
}
func resolveDefaultHome() string {
return migrateHome(defaultAjeanHome(), legacyDefaultHome())
}
// retryHomeMigration relance la résolution — donc la migration — après avoir
// arrêté ce qui tournait. Renvoie true si le dossier a effectivement changé.
//
// Raison d'être : sous Windows, un rename de dossier échoue tant qu'un process
// y tient un handle. Or le service AJEAN détaché en tient en permanence, si bien
// que la migration tentée au démarrage échouerait indéfiniment et que la machine
// resterait sur l'ancien chemin. L'installateur, lui, a une fenêtre où tout est
// arrêté : c'est là qu'on retente.
//
// L'accès au cache est protégé par homeMu, mais cela ne rend pas l'appel anodin
// pour autant : les chemins DÉJÀ CALCULÉS par l'appelant deviennent obsolètes
// dès que la migration aboutit (voir migrateThenResolveTarget et
// relinkAfterMigration, qui reconstruisent ce qui en dépend).
func retryHomeMigration() bool {
before := migratedDefaultHome() // force la résolution si elle n'a pas eu lieu
homeMu.Lock()
homeResolved = false
homeMu.Unlock()
return migratedDefaultHome() != before
}
// migrateHome contient toute la logique de migration, isolée des chemins réels
// de la plateforme pour être testable telle quelle. Renvoie le dossier à
// utiliser — le nouveau si la migration a réussi ou n'était pas nécessaire,
// l'ancien si elle a échoué.
func migrateHome(target, legacy string) string {
if isDir(target) {
return target // déjà migré (ou installation neuve déjà faite)
}
if !isDir(legacy) {
return target // installation neuve : rien à migrer
}
// Même parent que l'ancien dossier ⇒ même volume ⇒ rename atomique.
sibling := filepath.Join(filepath.Dir(legacy), filepath.Base(target))
if isDir(sibling) {
return sibling
}
if err := os.Rename(legacy, sibling); err != nil {
// On reste sur l'ancien chemin — tout fonctionne — et on retentera plus
// tard. Deux causes courantes sous Windows : un service AJEAN qui tient
// encore un handle, ou, bien plus fréquent, un utilisateur non
// administrateur : renommer C:\ProgramData\jean exige d'écrire dans
// C:\ProgramData, ce qu'un compte standard ne peut pas faire.
//
// On ENREGISTRE la raison au lieu de l'afficher. L'afficher condamnait
// tout utilisateur non administrateur à voir un message d'erreur
// technique avant CHAQUE commande, y compris `ajean help`, pour une
// situation qui n'a aucune conséquence : son installation fonctionne.
// Le message n'a sa place que là où il est actionnable — `ajean where`
// et `ajean install`, voir homeMigrationNotice().
migrationDeferred = fmt.Sprintf("dossier de données encore en %s — renommage en %s refusé (%s).",
legacy, filepath.Base(sibling), renameCause(err))
return legacy
}
fmt.Fprintf(os.Stderr, "[ok] dossier de données migré : %s → %s\n", legacy, sibling)
rewriteHomeReferences(legacy, sibling)
return sibling
}
// configFilesToRewrite liste les fichiers de configuration susceptibles de
// contenir un chemin ABSOLU vers le dossier de données. Volontairement restreint
// à des fichiers texte, petits et écrits par nous : on ne réécrit pas à l'aveugle
// les 51 000 fichiers d'un dossier de données.
func configFilesToRewrite(home string) []string {
files := []string{
filepath.Join(home, "config.env"),
filepath.Join(home, "model_dirs.json"),
filepath.Join(home, "mcp.json"),
filepath.Join(home, "webprefs.json"),
}
// Les presets sont des config.env alternatives : ils portent le même BIN.
presets, _ := filepath.Glob(filepath.Join(home, "configs", "*"))
return append(files, presets...)
}
// rewriteHomeReferences réécrit les chemins absolus pointant vers l'ANCIEN
// dossier de données dans les fichiers de configuration.
//
// Sans ça, la migration casse l'installation qu'elle est censée préserver :
// `config.env` contient typiquement
//
// BIN=C:\ProgramData\jean\backends\llama.cpp\build\bin\Release\llama-server.exe
//
// c'est-à-dire un chemin absolu VERS le dossier qu'on vient de renommer. Le
// dossier a bougé, la ligne pointe dans le vide, et llama-server ne démarre plus.
// Ça concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal.
//
// Trois écritures d'un même chemin doivent être couvertes, sans quoi on croit
// avoir tout réécrit alors qu'il reste des références mortes :
//
// C:\ProgramData\jean forme native, dans config.env
// C:/ProgramData/jean Windows accepte les deux séparateurs dans une valeur
// C:\\ProgramData\\jean forme JSON, où l'antislash est échappé (model_dirs.json,
// mcp.json, webprefs.json sont écrits par json.Marshal)
//
// Best-effort par fichier : un fichier illisible est sauté sans compromettre
// les autres.
func rewriteHomeReferences(oldHome, newHome string) {
variants := [][2]string{{oldHome, newHome}}
if slash := filepath.ToSlash(oldHome); slash != oldHome {
variants = append(variants, [2]string{slash, filepath.ToSlash(newHome)})
// Forme JSON : chaque antislash est doublé.
variants = append(variants, [2]string{jsonEscapePath(oldHome), jsonEscapePath(newHome)})
}
for _, path := range configFilesToRewrite(newHome) {
b, err := os.ReadFile(path)
if err != nil {
continue
}
out := string(b)
for _, v := range variants {
out = strings.ReplaceAll(out, v[0], v[1])
}
if out == string(b) {
continue
}
fi, err := os.Stat(path)
mode := os.FileMode(0o644)
if err == nil {
mode = fi.Mode()
}
if err := os.WriteFile(path, []byte(out), mode); err != nil {
fmt.Fprintf(os.Stderr, "[warn] %s non réécrit (%v) — vérifie les chemins absolus qu'il contient\n", path, err)
continue
}
fmt.Fprintf(os.Stderr, "[ok] chemins mis à jour dans %s\n", filepath.Base(path))
}
adoptLegacyStateFiles(newHome)
}
// jsonEscapePath renvoie l'écriture d'un chemin telle qu'elle apparaît DANS un
// fichier JSON : sous Windows, json.Marshal double chaque antislash. Le
// remplacement littéral doit donc chercher cette forme-là, sinon model_dirs.json
// et mcp.json gardent des chemins morts.
//
// Attention en modifiant : "\\" est UN antislash et "\\\\" en est deux. Une
// version antérieure utilisait des littéraux bruts et remplaçait en fait un
// antislash par lui-même, ce qui laissait passer le remplacement natif et
// produisait un JSON invalide (« invalid character 'U' in string escape code »),
// donc une liste de dossiers de modèles perdue. Couvert par un test.
func jsonEscapePath(p string) string { return strings.ReplaceAll(p, "\\", "\\\\") }
// adoptLegacyStateFiles reprend les fichiers d'état nommés d'après le service
// (jean.pid / jean.log → ajean.pid / ajean.log). Le fichier PID dit si le
// service tourne : ne pas le reprendre reviendrait à croire qu'il est arrêté et
// à en démarrer un second.
func adoptLegacyStateFiles(home string) {
for _, ext := range []string{".pid", ".log"} {
from := filepath.Join(home, legacyServiceName()+ext)
to := filepath.Join(home, "ajean"+ext)
if _, err := os.Stat(to); err == nil {
continue
}
if _, err := os.Stat(from); err != nil {
continue
}
_ = os.Rename(from, to)
}
}
// resetResolvedHome oublie le dossier résolu, pour que le prochain appel reparte
// du disque. Utilisé par `install` juste après avoir migré l'agencement : sans
// ça, la suite de l'installation continuerait de viser l'ancien emplacement.
func resetResolvedHome() {
homeMu.Lock()
homeResolved = false
homeMu.Unlock()
}
-286
View File
@@ -1,286 +0,0 @@
package ajean
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// mkHome crée un dossier de données factice contenant un fichier témoin, pour
// vérifier que la migration déplace bien le CONTENU et pas seulement le dossier.
func mkHome(t *testing.T, dir string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config.env"), []byte("MODEL=x.gguf\n"), 0o644); err != nil {
t.Fatal(err)
}
}
// Cas nominal : seul l'ancien dossier existe → il est migré, contenu compris.
func TestMigrateHomeMovesLegacy(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
if got := migrateHome(target, legacy); got != target {
t.Fatalf("got %q, attendu le nouveau chemin %q", got, target)
}
if b, err := os.ReadFile(filepath.Join(target, "config.env")); err != nil || string(b) != "MODEL=x.gguf\n" {
t.Fatalf("contenu non migré: %q (%v)", b, err)
}
if isDir(legacy) {
t.Fatalf("l'ancien dossier existe encore après un rename réussi")
}
}
// Installation neuve : rien à migrer, on renvoie le nouveau chemin sans rien créer.
func TestMigrateHomeFreshInstall(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "ajean")
if got := migrateHome(target, filepath.Join(root, "jean")); got != target {
t.Fatalf("got %q, attendu %q", got, target)
}
}
// Déjà migré : le nouveau dossier existe → on ne retouche à rien, même si un
// vieux dossier « jean » traîne encore (utilisateur qui l'a recréé, restauration
// de sauvegarde…). Écraser serait perdre les données courantes.
func TestMigrateHomeKeepsExistingTarget(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
mkHome(t, target)
if err := os.WriteFile(filepath.Join(target, "config.env"), []byte("MODEL=courant.gguf\n"), 0o644); err != nil {
t.Fatal(err)
}
if got := migrateHome(target, legacy); got != target {
t.Fatalf("got %q, attendu %q", got, target)
}
b, _ := os.ReadFile(filepath.Join(target, "config.env"))
if string(b) != "MODEL=courant.gguf\n" {
t.Fatalf("le dossier courant a été écrasé par l'ancien: %q", b)
}
if !isDir(legacy) {
t.Fatalf("l'ancien dossier a été supprimé — la migration ne doit JAMAIS supprimer")
}
}
// Rename impossible → on doit retomber sur l'ancien chemin, intact. C'est la
// garantie « au pire, rien n'a bougé », celle qui protège les .gguf.
//
// On reproduit le scénario réel : un service AJEAN tourne encore et tient un
// handle ouvert dans le dossier de données, ce qui fait échouer le rename du
// dossier sous Windows. Unix, lui, renomme sans broncher un dossier dont des
// fichiers sont ouverts — il n'y a donc rien à simuler là-bas.
func TestMigrateHomeFallsBackWhenRenameFails(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("un rename de dossier réussit avec des fichiers ouverts hors Windows")
}
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
held, err := os.Open(filepath.Join(legacy, "config.env"))
if err != nil {
t.Fatal(err)
}
defer held.Close()
if got := migrateHome(target, legacy); got != legacy {
t.Fatalf("got %q, attendu un repli sur l'ancien chemin %q", got, legacy)
}
if b, err := os.ReadFile(filepath.Join(legacy, "config.env")); err != nil || string(b) != "MODEL=x.gguf\n" {
t.Fatalf("l'ancien dossier a été abîmé par une migration ratée: %q (%v)", b, err)
}
}
// Le cas qui a casse une vraie machine : config.env contient un chemin ABSOLU
// vers le dossier de donnees (BIN=<home>\backends\...\llama-server.exe). Apres
// migration ce chemin doit suivre, sinon llama-server ne demarre plus — et ca
// concerne tous ceux qui ont fait un `llamacpp install`, donc le cas nominal.
func TestMigrateHomeRewritesAbsolutePaths(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
if err := os.MkdirAll(filepath.Join(legacy, "configs"), 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(legacy, "backends", "llama.cpp", "build", "bin", "llama-server")
write := func(rel, body string) {
if err := os.WriteFile(filepath.Join(legacy, rel), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("config.env", "BIN="+bin+"\nMODEL=\"x.gguf\"\n")
// Les presets portent le meme BIN et doivent suivre aussi.
write(filepath.Join("configs", "gros.env"), "BIN="+bin+"\n")
// Ecriture avec des slashs : Windows accepte les deux formes.
write("model_dirs.json", `{"dirs":["`+filepath.ToSlash(legacy)+`/models"]}`)
if got := migrateHome(target, legacy); got != target {
t.Fatalf("migration echouee: %q", got)
}
for _, rel := range []string{"config.env", filepath.Join("configs", "gros.env"), "model_dirs.json"} {
b, err := os.ReadFile(filepath.Join(target, rel))
if err != nil {
t.Fatalf("%s illisible: %v", rel, err)
}
if strings.Contains(string(b), legacy) || strings.Contains(string(b), filepath.ToSlash(legacy)) {
t.Errorf("%s reference encore l'ancien dossier:\n%s", rel, b)
}
if !strings.Contains(string(b), target) && !strings.Contains(string(b), filepath.ToSlash(target)) {
t.Errorf("%s ne pointe pas vers le nouveau dossier:\n%s", rel, b)
}
}
// Le BIN reecrit doit designer un chemin reellement atteignable.
newBin := filepath.Join(target, "backends", "llama.cpp", "build", "bin", "llama-server")
if b, _ := os.ReadFile(filepath.Join(target, "config.env")); !strings.Contains(string(b), newBin) {
t.Errorf("BIN ne pointe pas sur %s:\n%s", newBin, b)
}
}
// Les fichiers d'etat du service (jean.pid/jean.log) doivent etre repris : le
// PID dit si le service tourne, l'ignorer ferait demarrer un second service.
func TestMigrateHomeAdoptsServiceStateFiles(t *testing.T) {
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
if err := os.WriteFile(filepath.Join(legacy, "jean.pid"), []byte("4242\n"), 0o644); err != nil {
t.Fatal(err)
}
migrateHome(target, legacy)
b, err := os.ReadFile(filepath.Join(target, "ajean.pid"))
if err != nil {
t.Fatalf("ajean.pid absent apres migration: %v", err)
}
if strings.TrimSpace(string(b)) != "4242" {
t.Fatalf("PID perdu: %q", b)
}
}
// Les fichiers JSON (model_dirs.json, mcp.json, webprefs.json) sont ecrits par
// json.Marshal : sous Windows chaque antislash y est DOUBLE. Chercher la forme
// native ne les trouve donc pas, et on croirait avoir tout reecrit en laissant
// des references mortes. Constate en bac a sable.
func TestMigrateHomeRewritesJSONEscapedPaths(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("l'echappement JSON des separateurs ne concerne que Windows")
}
root := t.TempDir()
legacy := filepath.Join(root, "jean")
target := filepath.Join(root, "ajean")
mkHome(t, legacy)
esc := jsonEscapePath
body := `{"dirs":["` + esc(filepath.Join(legacy, "models")) + `"]}`
if err := os.WriteFile(filepath.Join(legacy, "model_dirs.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
migrateHome(target, legacy)
b, err := os.ReadFile(filepath.Join(target, "model_dirs.json"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), esc(legacy)) {
t.Errorf("reference JSON a l'ancien dossier non reecrite:\n%s", b)
}
if !strings.Contains(string(b), esc(target)) {
t.Errorf("le JSON ne pointe pas vers le nouveau dossier:\n%s", b)
}
// Le fichier doit rester du JSON valide apres reecriture.
var parsed struct {
Dirs []string `json:"dirs"`
}
if err := json.Unmarshal(b, &parsed); err != nil {
t.Fatalf("JSON casse par la reecriture: %v\n%s", err, b)
}
if len(parsed.Dirs) != 1 || !strings.HasPrefix(parsed.Dirs[0], target) {
t.Fatalf("chemin decode inattendu: %+v", parsed.Dirs)
}
}
// Cas d'un utilisateur Windows NON administrateur : il ne peut pas renommer
// C:\ProgramData\jean faute de droits sur C:\ProgramData. La migration echoue a
// chaque lancement, definitivement.
//
// Elle doit rester SILENCIEUSE. L'afficher condamnait cet utilisateur a voir un
// message d'erreur technique avant chaque commande, y compris `ajean help`,
// alors que son installation fonctionne parfaitement. La raison est enregistree
// et n'est ressortie que la ou elle est actionnable.
func TestMigrationFailureIsSilentButRecorded(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("reproduit un echec de rename propre a Windows")
}
migrationDeferred = ""
t.Cleanup(func() { migrationDeferred = "" })
root := t.TempDir()
legacy := filepath.Join(root, "jean")
mkHome(t, legacy)
held, err := os.Open(filepath.Join(legacy, "config.env"))
if err != nil {
t.Fatal(err)
}
defer held.Close()
if got := migrateHome(filepath.Join(root, "ajean"), legacy); got != legacy {
t.Fatalf("attendu un repli sur %s, got %s", legacy, got)
}
if migrationDeferred == "" {
t.Fatal("la raison de l'echec n'a pas ete enregistree")
}
notice := homeMigrationNotice()
if !strings.Contains(notice, legacy) {
t.Errorf("le message ne dit pas quel dossier reste utilise:\n%s", notice)
}
if !strings.Contains(notice, "Tout fonctionne") {
t.Errorf("le message doit rassurer, pas alarmer:\n%s", notice)
}
if !strings.Contains(notice, "administrateur") {
t.Errorf("le message doit dire quoi faire:\n%s", notice)
}
}
// Rien a signaler quand tout va bien : pas de message parasite.
func TestNoNoticeWhenMigrationSucceeds(t *testing.T) {
migrationDeferred = ""
t.Cleanup(func() { migrationDeferred = "" })
root := t.TempDir()
legacy := filepath.Join(root, "jean")
mkHome(t, legacy)
migrateHome(filepath.Join(root, "ajean"), legacy)
if n := homeMigrationNotice(); n != "" {
t.Fatalf("message inattendu apres une migration reussie:\n%s", n)
}
}
// Migration reussie : aucun residu, donc aucune demande d'elevation nulle part.
func TestAucunResiduApresMigrationReussie(t *testing.T) {
migrationDeferred = ""
t.Cleanup(func() { migrationDeferred = "" })
root := t.TempDir()
legacy := filepath.Join(root, "jean")
mkHome(t, legacy)
migrateHome(filepath.Join(root, "ajean"), legacy)
if migrationDeferred != "" {
t.Errorf("etat inattendu apres une migration reussie: %q", migrationDeferred)
}
}
+12 -17
View File
@@ -17,31 +17,25 @@ import (
type ajeanPaths struct {
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")
+4 -4
View File
@@ -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...)
+6 -6
View File
@@ -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))
}
-7
View File
@@ -1,7 +0,0 @@
//go:build !windows
package ajean
// Pendant non-Windows de sys_postupdate_windows.go : la migration d'agencement
// se fait dans restartAfterUpdate (voir sys_layout.go), qui tourne deja en root.
func postUpdateMigrate() {}
-51
View File
@@ -1,51 +0,0 @@
//go:build windows
package ajean
import (
"fmt"
"path/filepath"
)
// Migration du dossier juste après une mise à jour, sous Windows.
//
// C'est le chaînon qui manquait. Les utilisateurs ne relancent pas `install` et
// ne retéléchargent pas l'exécutable : ils font `ajean update`, ou cliquent le
// bouton de l'interface. Or ce chemin-là ne tentait rien — l'élévation n'était
// câblée que dans `install` et dans l'installeur de mise à jour. Résultat : un
// poste sans droits administrateur restait sur %ProgramData%\jean pour toujours,
// sans qu'aucune fenêtre ne propose jamais de le régler.
//
// Contrairement à ce qu'on pourrait croire, le binaire en cours d'exécution
// n'empêche PAS de renommer le dossier qui le contient : Windows verrouille le
// fichier .exe lui-même, pas le nom de ses dossiers parents. Vérifié sur banc
// d'essai. La migration peut donc se faire depuis l'application en marche.
func postUpdateMigrate() {
if !retryHomeMigration() && !elevateForHomeMigration() {
return
}
fmt.Printf("%s dossier de données migré vers %s\n", green("✓"), AjeanHome())
relinkAfterMigration()
}
// relinkAfterMigration répare ce que le déplacement laisse derrière lui.
//
// Le binaire installé vit DANS le dossier de données (%AJEAN_HOME%\bin). Le
// renommer déplace donc aussi le programme : le raccourci du menu Démarrer et
// l'entrée de PATH désignent un chemin qui n'existe plus. Sans cette étape, la
// migration « réussit » et l'utilisateur se retrouve avec un raccourci mort et
// une commande `ajean` introuvable — soit exactement ce qu'on cherchait à éviter.
func relinkAfterMigration() {
target := installedExePath()
binDir := filepath.Dir(target)
if _, err := addToUserPath(binDir); err != nil {
fmt.Printf(" %s PATH non mis à jour (%v) — ajoute %s à la main\n", dim("•"), err, binDir)
}
// L'ancienne entrée ne désigne plus rien : la laisser encombrerait le PATH
// d'un chemin mort, et ferait échouer un `jean` tapé par habitude.
if legacyBin := filepath.Join(legacyDefaultHome(), "bin"); legacyBin != binDir {
_, _ = removeFromUserPath(legacyBin)
}
ensureShortcuts(target)
}
-7
View File
@@ -57,13 +57,6 @@ func cmdRestartAfterUpdate(args []string) error {
waitForExit(pid, 30*time.Second)
}
}
// 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.
+25 -4
View File
@@ -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
}
+9 -9
View File
@@ -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/<svc>.pid et sa sortie dans
// JEAN_HOME/<svc>.log. Setsid le détache de notre session pour qu'il survive à
// la fermeture de Jean.app, et permet de tuer tout le groupe (llama-server
// lancé détaché, son PID va dans AJEAN_HOME/<svc>.pid et sa sortie dans
// AJEAN_HOME/<svc>.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}
+3 -4
View File
@@ -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
}
+2 -2
View File
@@ -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() {
+3 -3
View File
@@ -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
+2 -2
View File
@@ -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,
-49
View File
@@ -1,49 +0,0 @@
package ajean
import "os"
// Nom des unités système, pendant et après le renommage jean → ajean.
//
// Le problème : une mise à jour remplace le BINAIRE, pas les unités systemd.
// Un serveur déjà installé continue donc de tourner sous « jean.service » et
// « jean-link.service », qui pointent sur /usr/local/bin/jean. Si le binaire
// renommé partait du principe que les unités s'appellent désormais « ajean* »,
// le premier `systemctl restart` échouerait — et sur un serveur distant, ça veut
// dire l'accès coupé, sans terminal pour réparer.
//
// La solution retenue n'est PAS de migrer les unités au démarrage (il faudrait
// root, et un échec laisserait la machine sans service). C'est de résoudre le
// nom à l'usage : on utilise l'unité qui existe réellement. Une machine ancienne
// reste sur ses unités « jean* » et fonctionne ; une machine fraîchement
// installée — ou réinstallée via `ajean install` — utilise les « ajean* ».
// Aucune fenêtre pendant laquelle plus rien ne tourne.
// unitExists dit si un service de ce nom est installé sur la machine : unité
// systemd sous Linux, daemon launchd sous macOS (repéré par son plist, sous
// l'un ou l'autre des deux préfixes de label). Toujours faux sous Windows, où
// le « service » est un simple processus détaché suivi par un fichier PID.
func unitExists(name string) bool {
paths := []string{
"/etc/systemd/system/" + name + ".service",
"/Library/LaunchDaemons/com.ajean." + name + ".plist",
"/Library/LaunchDaemons/com.jean." + name + ".plist",
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
return true
}
}
return false
}
// resolveUnitName renvoie le nom d'unité à utiliser : le nouveau s'il est
// installé, sinon l'ancien s'il l'est, sinon le nouveau (installation à venir).
func resolveUnitName(modern, legacy string) string {
if unitExists(modern) {
return modern
}
if unitExists(legacy) {
return legacy
}
return modern
}
+19 -48
View File
@@ -22,9 +22,6 @@ import (
// l'OS/arch courant, remplace le binaire en place, puis affiche quoi redémarrer.
// 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-<os>-<arch>[.exe] puis, en repli,
// l'ancien nom jean-<os>-<arch>[.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-<os>-<arch>[.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()) }
+1 -1
View File
@@ -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()
+1 -1
View File
@@ -35,7 +35,7 @@ func TestPickAssetAjeanOnly(t *testing.T) {
}
}
// Ancienne release, antérieure au renommage : le repli sur jean-* doit marcher,
// Ancienne release, antérieure au renommage : le repli sur ajean-* doit marcher,
// sinon un client neuf ne pourrait pas s'installer depuis une release existante.
func TestPickAssetFallsBackToLegacy(t *testing.T) {
names := updateAssetNames()
+77 -24
View File
@@ -1286,24 +1286,23 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</details>
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
<div class="subhead">Installation</div>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
<div class="slist">
<!-- Texte de départ IDENTIQUE (à la variante près) à celui posé après le
chargement : un simple « … » faisait grandir la carte sous les yeux dès
que l'état arrivait. -->
<div class="rowbody"><div id="lc-status" class="lc-head muted">Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle.</div></div>
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
alors que compiler donne du CUDA natif. -->
<div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco">conseillé</span></div>
<div class="lc-mode-d">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div>
</div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">llama.cpp compilé</div>
<div class="lc-mode-d">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
@@ -1536,6 +1535,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
<div class="pe-row">
<span class="pe-row-l">Destination</span>
<span class="pe-selc"><select id="m-hf-dir"></select></span>
</div>
<div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()">
<span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span>
<span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span>
@@ -1546,7 +1549,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
</div>
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier AJEAN.</div>
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div>
</div>
</div>
@@ -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 = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false;
await populateDlDirs();
return;
}
e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres';
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; return;
@@ -3290,8 +3327,12 @@ function watchDownload(fname){
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
e.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 <b>précompilé</b> convient à presque tout le monde.';
} else {
head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ⚙).';
}
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');
+13 -10
View File
@@ -239,24 +239,23 @@
</div>
</details>
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
<div class="subhead">Installation</div>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
<div class="slist">
<!-- Texte de départ IDENTIQUE (à la variante près) à celui posé après le
chargement : un simple « … » faisait grandir la carte sous les yeux dès
que l'état arrivait. -->
<div class="rowbody"><div id="lc-status" class="lc-head muted">Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle.</div></div>
<!-- La pastille « conseillé » n'est PAS écrite ici : le bon choix dépend de
la machine (voir lcRenderReco). Sur Linux + NVIDIA le précompilé ne
donne que du Vulkan — llama.cpp ne publie aucun binaire CUDA Linux —
alors que compiler donne du CUDA natif. -->
<div class="lc-modes">
<div class="lc-mode" id="lc-mode-fast" onclick="lcPick('fast')">
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco">conseillé</span></div>
<div class="lc-mode-d">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-t">llama.cpp précompilé <span class="lc-reco" id="lc-reco-fast" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-fast">binaire officiel téléchargé tel quel, prêt en ~2 min</div>
<div class="lc-mode-state" id="lc-fast-state"></div>
</div>
<div class="lc-mode" id="lc-mode-opt" onclick="lcPick('opt')">
<div class="lc-mode-t">llama.cpp compilé</div>
<div class="lc-mode-d">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-t">llama.cpp compilé <span class="lc-reco" id="lc-reco-opt" hidden>conseillé</span></div>
<div class="lc-mode-d" id="lc-desc-opt">compilé ici pour votre machine — plus rapide, plus long à installer</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
@@ -489,6 +488,10 @@
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
<div class="pe-row">
<span class="pe-row-l">Destination</span>
<span class="pe-selc"><select id="m-hf-dir"></select></span>
</div>
<div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()">
<span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span>
<span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span>
@@ -499,7 +502,7 @@
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
</div>
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier AJEAN.</div>
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div>
</div>
</div>
+46 -8
View File
@@ -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 = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false;
await populateDlDirs();
return;
}
e.prog.textContent = fmtSize(p.size)+' à télécharger — '+fmtSize(p.free)+' libres';
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; return;
@@ -825,8 +859,12 @@ function watchDownload(fname){
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
e.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;
+18 -6
View File
@@ -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 <b>précompilé</b> convient à presque tout le monde.';
} else {
head.innerHTML = 'Installez les moteurs ici. Vous choisissez lequel utiliser en éditant un modèle (bouton ⚙).';
}
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');
+10 -10
View File
@@ -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/<name>/ for a llama-server binary,
// handleBackends scans AJEAN_HOME/backends/<name>/ for a llama-server binary,
// trying common build subpaths (build/bin, build-sm120/bin, bin, .).
// Returns [{name, path}].
func handleBackends(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, listBackendBins())
}
// listBackendBins scanne JEAN_HOME/backends/<name>/ pour un binaire llama-server
// listBackendBins scanne AJEAN_HOME/backends/<name>/ pour un binaire llama-server
// (essaie les sous-chemins des différents générateurs CMake). Renvoie
// [{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)
+11 -29
View File
@@ -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 <clé> 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
+2 -2
View File
@@ -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,
+5 -4
View File
@@ -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
}
+6 -31
View File
@@ -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.
+3 -2
View File
@@ -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 {
+1 -1
View File
@@ -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