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