diff --git a/README.md b/README.md index d5c430b..f857cfe 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ Prends un binaire pré-compilé depuis la page [Releases](../../releases), ou [c ```bash # exemple : Linux x86_64 -curl -L -o jean https://github.com/nathaninline/jean/releases/latest/download/jean-linux-amd64 +curl -L -o jean https://github.com/jean-llm/jean/releases/latest/download/jean-linux-amd64 chmod +x jean sudo mv jean /usr/local/bin/jean ``` @@ -69,6 +69,27 @@ jean test # vérifie que le modèle répond jean web # http://:8090 ``` +## Windows + +Jean fonctionne aussi sur Windows. Les différences avec Linux : + +- **Pas de systemd.** `jean start` lance `jean serve` en arrière-plan (processus détaché), suivi via un fichier PID. `stop` / `restart` / `status` / `logs` agissent dessus — aucun droit administrateur requis. `enable` / `disable` (démarrage au boot) ne sont pas gérés : utilise une tâche planifiée ou `sc.exe` si tu en as besoin. +- **`JEAN_HOME`** vaut par défaut `%ProgramData%\jean` (repli `%LOCALAPPDATA%\jean`). Surcharge avec la variable d'environnement `JEAN_HOME`. +- **`jean install`** crée seulement le dossier de données et un `config.env` de départ (pas de sudoers ni de symlink). Ajoute toi-même le dossier de `jean.exe` au `PATH` pour l'appeler de partout. +- **`jean edit`** ouvre `notepad` par défaut (surchargeable via `%EDITOR%`). +- **`jean tools`** exécute les commandes via `cmd /C` (au lieu de `bash`). + +```powershell +# depuis PowerShell +jean install # crée %ProgramData%\jean\config.env +jean edit # règle BIN=...\llama-server.exe et MODEL=...\modele.gguf +jean start +jean status +jean logs # suit %ProgramData%\jean\jean.log +``` + +`jean llamacpp install` peut compiler llama.cpp si `git` et `cmake` sont présents ; sinon récupère un binaire `llama-server.exe` pré-compilé et pointe `BIN` dessus avec `jean edit`. + ## Commandes ``` @@ -79,6 +100,7 @@ Service : edit éditer $JEAN_HOME/config.env set-api-key [clé] protéger l'API (Bearer) ; vide = générer, "" = retirer vram utilisation GPU/VRAM (nvidia-smi) + gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) test vérifie que le modèle répond (health + completion) bench [N] mesure prefill + decode tok/s @@ -118,7 +140,7 @@ update [--ref=REF_GIT] [--clean] [--no-restart] [--force] ## Configuration -Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean`). Le service lit `config.env` : +Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramData%\jean` sur Windows). Le service lit `config.env` : | Clé | Signification | Défaut | |-----|---------------|--------| @@ -129,6 +151,7 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean`). Le service lit `config.env | `NGL` | couches à déporter sur le GPU | `999` | | `BATCH` / `UBATCH` | batch / micro-batch | `2048` / `512` | | `THREADS` / `THREADS_BATCH` | threads CPU | `0` (auto) | +| `CUDA_VISIBLE_DEVICES` | GPU à utiliser (réglé par `jean gpu`) | tous | | `KV_TYPE` (`_K`/`_V`) | quantization du cache KV | — | | `REASONING` | passthrough du mode raisonnement | — | | `EXTRA_ARGS` | ajouté tel quel à `llama-server` | — | @@ -148,7 +171,7 @@ La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans Nécessite Go 1.22+. Jean est un binaire 100 % Go (l'UI web est embarquée via `go:embed`) : ```bash -git clone https://github.com/nathaninline/jean.git +git clone https://github.com/jean-llm/jean.git cd jean CGO_ENABLED=0 go build -o jean . diff --git a/gpu.go b/gpu.go new file mode 100644 index 0000000..ccf0586 --- /dev/null +++ b/gpu.go @@ -0,0 +1,158 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "os/exec" + "strconv" + "strings" +) + +// gpu.go — sélection du/des GPU utilisés par llama-server. +// +// jean gpu liste les GPU et montre la sélection courante +// jean gpu 1 n'utilise que le GPU d'index 1 +// jean gpu 0 1 utilise les GPU 0 et 1 +// jean gpu all réinitialise (tous les GPU visibles) +// +// La sélection est stockée dans config.env sous CUDA_VISIBLE_DEVICES ; serve.go +// l'exporte (avec CUDA_DEVICE_ORDER=PCI_BUS_ID pour que les index correspondent +// à ceux affichés par nvidia-smi). + +type gpuInfo struct { + Index int + Name string + MemTotal string // en MiB + MemUsed string + Cap string // compute capability +} + +func cmdGPU(args []string) error { + if len(args) == 0 || args[0] == "list" || args[0] == "ls" { + return gpuList() + } + switch args[0] { + case "all", "reset", "none", "auto": + return gpuSet("") + } + // Sinon : une liste d'index (« 1 », « 0 1 », « 0,1 »). + gpus, err := detectGPUs() + if err != nil { + return err + } + raw := strings.Join(args, ",") + var idx []string + for _, tok := range strings.Split(raw, ",") { + tok = strings.TrimSpace(tok) + if tok == "" { + continue + } + n, err := strconv.Atoi(tok) + if err != nil { + return fmt.Errorf("index GPU invalide: %q (attendu un nombre)", tok) + } + if n < 0 || n >= len(gpus) { + return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « jean gpu »", n, len(gpus)-1) + } + idx = append(idx, strconv.Itoa(n)) + } + if len(idx) == 0 { + return fmt.Errorf("aucun index fourni") + } + return gpuSet(strings.Join(idx, ",")) +} + +// gpuList affiche les GPU détectés et marque ceux actuellement sélectionnés. +func gpuList() error { + gpus, err := detectGPUs() + if err != nil { + return err + } + sel := ReadConfig()["CUDA_VISIBLE_DEVICES"] + selected := map[int]bool{} + if sel != "" { + for _, t := range strings.Split(sel, ",") { + if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil { + selected[n] = true + } + } + } + + fmt.Println() + for _, g := range gpus { + mark := " " + line := fmt.Sprintf("[%d] %s — %s/%s MiB (cc %s)", g.Index, g.Name, g.MemUsed, g.MemTotal, g.Cap) + active := sel == "" || selected[g.Index] + if sel != "" && selected[g.Index] { + mark = green("● ") + line = green(line) + } else if sel == "" { + mark = dim("○ ") + } else { + mark = dim("○ ") + line = dim(line) + } + _ = active + fmt.Printf(" %s%s\n", mark, line) + } + fmt.Println() + if sel == "" { + fmt.Printf(" Sélection : %s (tous les GPU)\n", bold("auto")) + } else { + fmt.Printf(" Sélection : %s (CUDA_VISIBLE_DEVICES=%s)\n", bold(sel), sel) + } + fmt.Printf(" %s jean gpu pour choisir, jean gpu all pour réinitialiser\n", dim("→")) + return nil +} + +// gpuSet writes (or clears) CUDA_VISIBLE_DEVICES in config.env then offers a +// restart so the change takes effect. +func gpuSet(value string) error { + if err := SetConfigKey("CUDA_VISIBLE_DEVICES", value); err != nil { + return err + } + if value == "" { + fmt.Printf("%s sélection GPU réinitialisée — tous les GPU seront visibles\n", green("[ok]")) + } else { + fmt.Printf("%s GPU sélectionné(s) : %s\n", green("[ok]"), bold(value)) + } + fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] ")) + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") { + fmt.Println(dim("[info] pense à lancer 'jean restart'")) + return nil + } + return serviceAction("restart") +} + +// detectGPUs queries nvidia-smi for the list of NVIDIA GPUs. +func detectGPUs() ([]gpuInfo, error) { + if !hasTool("nvidia-smi") { + return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA") + } + out, err := exec.Command("nvidia-smi", + "--query-gpu=index,name,memory.total,memory.used,compute_cap", + "--format=csv,noheader,nounits").Output() + if err != nil { + return nil, fmt.Errorf("nvidia-smi a échoué: %w", err) + } + var gpus []gpuInfo + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + parts := strings.Split(line, ",") + if len(parts) < 5 { + continue + } + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + idx, _ := strconv.Atoi(parts[0]) + gpus = append(gpus, gpuInfo{ + Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4], + }) + } + if len(gpus) == 0 { + return nil, fmt.Errorf("aucun GPU NVIDIA détecté") + } + return gpus, nil +} diff --git a/install_unix.go b/install_unix.go new file mode 100644 index 0000000..089f491 --- /dev/null +++ b/install_unix.go @@ -0,0 +1,198 @@ +//go:build unix + +package main + +import ( + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" +) + +const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart +# Le service systemd lit ce fichier et lance ton binaire llama.cpp. + +# Chemins +BIN="/usr/local/bin/llama-server" +MODEL="/home/USER/models/your-model.gguf" + +# Serveur +PORT="8080" +HOST="0.0.0.0" + +# Inference +CTX="32768" +BATCH="2048" +UBATCH="512" +NGL="999" + +# Args supplémentaires passés à llama-server +EXTRA_ARGS="" +` + +const sudoersTemplate = `# Allow %s to manage the %s systemd unit without a password (installed by jean). +%s ALL=(root) NOPASSWD: /bin/systemctl start %s, /bin/systemctl stop %s, /bin/systemctl restart %s, /bin/systemctl enable %s, /bin/systemctl disable %s +` + +const serviceUnitTemplate = `[Unit] +Description=JEAN llama.cpp server +After=network.target + +[Service] +Type=simple +User=%s +WorkingDirectory=%s +ExecStart=%s +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +` + +func cmdInstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("jean install doit être exécuté en root (sudo jean install)") + } + targetUser := os.Getenv("SUDO_USER") + if targetUser == "" { + targetUser = "root" + } + for _, a := range args { + if strings.HasPrefix(a, "--user=") { + targetUser = strings.TrimPrefix(a, "--user=") + } + } + u, err := user.Lookup(targetUser) + if err != nil { + return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err) + } + jeanHome := defaultJeanHome() + if v := os.Getenv("JEAN_HOME"); v != "" { + jeanHome = v + } + svc := serviceName() + + fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser)) + fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" service = %s\n", svc) + + // 1. Create directories + for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + + // 2. Drop a config.env if none exists + conf := filepath.Join(jeanHome, "config.env") + if _, err := os.Stat(conf); os.IsNotExist(err) { + body := strings.ReplaceAll(configTemplate, "USER", targetUser) + if err := os.WriteFile(conf, []byte(body), 0o644); err != nil { + return err + } + fmt.Printf(" %s écrit %s\n", green("✓"), conf) + } + + // 3. Symlink current binary to /usr/local/bin/jean + self, err := os.Executable() + if err != nil { + return err + } + target := "/usr/local/bin/jean" + _ = os.Remove(target) + if err := os.Symlink(self, target); err != nil { + // fall back to copy if symlink fails (e.g. cross-fs) + if data, err := os.ReadFile(self); err == nil { + _ = os.WriteFile(target, data, 0o755) + } + } + fmt.Printf(" %s %s -> %s\n", green("✓"), target, self) + + // 4. Drop /etc/default/jean so root invocations resolve JEAN_HOME correctly. + defaults := fmt.Sprintf("# Generated by jean install — racine des configs/skills/SKILLS\nJEAN_HOME=%s\n", jeanHome) + if err := os.WriteFile("/etc/default/jean", []byte(defaults), 0o644); err != nil { + return err + } + fmt.Printf(" %s /etc/default/jean\n", green("✓")) + + // 5. Write the systemd unit (ExecStart = `jean serve`, no start.sh needed) + unit := fmt.Sprintf(serviceUnitTemplate, targetUser, jeanHome, "/usr/local/bin/jean serve") + unitPath := "/etc/systemd/system/" + svc + ".service" + if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), unitPath) + + // 5. Sudoers drop-in + sudoers := fmt.Sprintf(sudoersTemplate, targetUser, svc, targetUser, svc, svc, svc, svc, svc) + sudoersPath := "/etc/sudoers.d/jean-" + svc + if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil { + return err + } + fmt.Printf(" %s %s\n", green("✓"), sudoersPath) + + // 6. chown JEAN_HOME contents to target user + chown(jeanHome, u) + + // 7. systemd reload + _ = exec.Command("systemctl", "daemon-reload").Run() + + fmt.Println() + fmt.Printf("%s installation terminée.\n", green("[ok]")) + fmt.Printf("\nProchaines étapes :\n") + fmt.Printf(" 1. édite la config : %s\n", bold("sudo -u "+targetUser+" jean edit")) + fmt.Printf(" (renseigne BIN, MODEL, etc.)\n") + fmt.Printf(" 2. démarre le service: %s\n", bold("sudo -u "+targetUser+" jean start")) + fmt.Printf(" 3. UI web : %s\n", bold("sudo -u "+targetUser+" jean web")) + return nil +} + +func cmdUninstall(args []string) error { + if os.Geteuid() != 0 { + return fmt.Errorf("jean uninstall doit être exécuté en root") + } + svc := serviceName() + keepData := false + for _, a := range args { + if a == "--purge" { + keepData = false + } + if a == "--keep-data" { + keepData = true + } + } + _ = exec.Command("systemctl", "stop", svc).Run() + _ = exec.Command("systemctl", "disable", svc).Run() + for _, p := range []string{ + "/etc/systemd/system/" + svc + ".service", + "/etc/sudoers.d/jean-" + svc, + "/etc/default/jean", + "/usr/local/bin/jean", + } { + if err := os.Remove(p); err == nil { + fmt.Printf(" %s %s\n", green("✓"), p) + } + } + _ = exec.Command("systemctl", "daemon-reload").Run() + if !keepData { + fmt.Println(dim("(données utilisateur conservées — supprime $JEAN_HOME manuellement si tu veux purger)")) + } + fmt.Println(green("[ok]") + " désinstallé") + return nil +} + +// chown recursively changes ownership of path to the given user/group. +func chown(path string, u *user.User) { + var uid, gid int + fmt.Sscanf(u.Uid, "%d", &uid) + fmt.Sscanf(u.Gid, "%d", &gid) + filepath.Walk(path, func(p string, info os.FileInfo, err error) error { + if err == nil { + _ = os.Chown(p, uid, gid) + } + return nil + }) +} diff --git a/install_windows.go b/install_windows.go new file mode 100644 index 0000000..d2db2c9 --- /dev/null +++ b/install_windows.go @@ -0,0 +1,96 @@ +//go:build windows + +package main + +import ( + "fmt" + "os" + "path/filepath" +) + +// On Windows there's no systemd unit, sudoers, or /usr/local/bin to populate. +// `jean install` simply provisions the data directory and a starter config; the +// service itself is managed by the PID-file supervisor in service_windows.go +// (jean start / stop / status), which needs no admin rights. + +const configTemplate = `# Configuration JEAN — édite-moi puis: jean restart +# jean serve lit ce fichier et lance ton binaire llama.cpp. + +# Chemins (utilise des chemins Windows ; les antislashs ou slashs marchent) +BIN="C:/llama.cpp/build/bin/Release/llama-server.exe" +MODEL="C:/models/your-model.gguf" + +# Serveur +PORT="8080" +HOST="127.0.0.1" + +# Inference +CTX="32768" +BATCH="2048" +UBATCH="512" +NGL="999" + +# Args supplémentaires passés à llama-server +EXTRA_ARGS="" +` + +func cmdInstall(args []string) error { + jeanHome := JeanHome() + + fmt.Printf("Installation (Windows)\n") + fmt.Printf(" JEAN_HOME = %s\n", jeanHome) + fmt.Printf(" service = %s\n", serviceName()) + + // 1. Create directories + for _, d := range []string{jeanHome, filepath.Join(jeanHome, "configs"), filepath.Join(jeanHome, "SKILLS")} { + if err := os.MkdirAll(d, 0o755); err != nil { + return err + } + } + + // 2. Drop a config.env if none exists + conf := filepath.Join(jeanHome, "config.env") + if _, err := os.Stat(conf); os.IsNotExist(err) { + if err := os.WriteFile(conf, []byte(configTemplate), 0o644); err != nil { + return err + } + fmt.Printf(" %s écrit %s\n", green("✓"), conf) + } else { + fmt.Printf(" %s config.env déjà présent — inchangé\n", dim("•")) + } + + fmt.Println() + fmt.Printf("%s installation terminée.\n", green("[ok]")) + fmt.Printf("\nProchaines étapes :\n") + fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("jean edit")) + fmt.Printf(" 2. démarre le service: %s\n", bold("jean start")) + fmt.Printf(" 3. UI web : %s\n", bold("jean web")) + fmt.Printf("\n%s pour exécuter 'jean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]")) + return nil +} + +func cmdUninstall(args []string) error { + keepData := true + for _, a := range args { + if a == "--purge" { + keepData = false + } + if a == "--keep-data" { + keepData = true + } + } + // Stop the background server if it's running. + _ = svcStop(false) + + if !keepData { + jeanHome := JeanHome() + if err := os.RemoveAll(jeanHome); err != nil { + return fmt.Errorf("suppression de %s: %w", jeanHome, err) + } + fmt.Printf(" %s %s supprimé\n", green("✓"), jeanHome) + } else { + fmt.Println(dim("(données utilisateur conservées — relance avec --purge pour tout supprimer)")) + } + fmt.Println(green("[ok]") + " désinstallé") + return nil +} diff --git a/llamacpp.go b/llamacpp.go index 367d846..0241279 100644 --- a/llamacpp.go +++ b/llamacpp.go @@ -522,11 +522,6 @@ func requireTools(tools ...string) error { return nil } -func serviceIsActive() bool { - out, _ := exec.Command("systemctl", "is-active", serviceName()).Output() - return strings.TrimSpace(string(out)) == "active" -} - // gitOutput runs a git command in `dir` and returns trimmed stdout (or ""). func gitOutput(dir string, args ...string) string { cmd := exec.Command("git", args...) diff --git a/main.go b/main.go index 1a66a42..477fdf5 100644 --- a/main.go +++ b/main.go @@ -28,6 +28,8 @@ func main() { mustExit(cmdSetAPIKey(args)) case "vram": mustExit(showVram()) + case "gpu": + mustExit(cmdGPU(args)) case "switch": mustExit(cmdSwitch(args)) case "chat": @@ -67,12 +69,13 @@ func printHelp() { Usage: jean [args] Service: - start | stop | restart gérer le service systemd + start | stop | restart gérer le service (systemd sous Linux, processus en arrière-plan sous Windows) status | logs état / logs en direct enable | disable auto-démarrage au boot edit éditer $JEAN_HOME/config.env set-api-key [clé] protéger l'API (clé Bearer); vide = générer, "" = retirer vram utilisation GPU/VRAM (nvidia-smi) + gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous) test vérifie que l'IA répond (health + completion) bench [N] mesure prefill + decode tok/s (prompt 2000 tok, N=200 par défaut) @@ -96,12 +99,12 @@ Entrypoint (utilisé par jean.service) : serve lit config.env et exec le binaire llama-server Installation: - install installer (systemd unit, sudoers, dossiers) + install installer (Linux: unité systemd, sudoers, dossiers ; Windows: dossiers + config) uninstall désinstaller Env: - JEAN_HOME racine (défaut: $HOME/JEAN, ou /home//JEAN si root) - EDITOR éditeur pour 'jean edit' (défaut: nano) + JEAN_HOME racine (défaut: /etc/jean sur Linux/macOS, %%ProgramData%%\jean sur Windows) + EDITOR éditeur pour 'jean edit' (défaut: nano sur Unix, notepad sur Windows) Config: $JEAN_HOME/config.env `, Version) @@ -114,11 +117,9 @@ func mustExit(err error) { } } -// DefaultJeanHome is where everything lives unless overridden. -const DefaultJeanHome = "/etc/jean" - // JeanHome resolves the JEAN data directory. -// Precedence: $JEAN_HOME → /etc/default/jean → DefaultJeanHome. +// Precedence: $JEAN_HOME → /etc/default/jean (unix only) → defaultJeanHome(). +// defaultJeanHome() is platform-specific (see platform_unix.go / platform_windows.go). func JeanHome() string { if h := os.Getenv("JEAN_HOME"); h != "" { return h @@ -126,7 +127,7 @@ func JeanHome() string { if h := readEtcDefault(); h != "" { return h } - return DefaultJeanHome + return defaultJeanHome() } // readEtcDefault parses /etc/default/jean for JEAN_HOME=. Quiet on errors. diff --git a/platform_unix.go b/platform_unix.go new file mode 100644 index 0000000..bfa083f --- /dev/null +++ b/platform_unix.go @@ -0,0 +1,39 @@ +//go:build unix + +package main + +import ( + "context" + "os" + "os/exec" + "syscall" +) + +// defaultJeanHome is the data root when neither $JEAN_HOME nor /etc/default/jean +// override it. +func defaultJeanHome() string { return "/etc/jean" } + +// defaultEditor is used by `jean edit` when $EDITOR is unset. +func defaultEditor() string { return "nano" } + +// setLibraryPath ensures llama-server can load shared libs bundled next to the +// binary by prepending dir to LD_LIBRARY_PATH. +func setLibraryPath(dir string) { + ld := dir + if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" { + ld = ld + ":" + existing + } + _ = os.Setenv("LD_LIBRARY_PATH", ld) +} + +// execServer replaces the current process with llama-server (so systemd +// supervises llama-server directly, as the old start.sh did with `exec`). +// args[0] must be the binary path. +func execServer(bin string, args []string) error { + return syscall.Exec(bin, args, os.Environ()) +} + +// newShellCmd builds the command used by the run_shell tool. +func newShellCmd(ctx context.Context, command string) *exec.Cmd { + return exec.CommandContext(ctx, "/bin/bash", "-c", command) +} diff --git a/platform_windows.go b/platform_windows.go new file mode 100644 index 0000000..536b3aa --- /dev/null +++ b/platform_windows.go @@ -0,0 +1,55 @@ +//go:build windows + +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" +) + +// defaultJeanHome is the data root when $JEAN_HOME is unset. We use +// %ProgramData%\jean (machine-wide, the closest analogue to /etc/jean), falling +// back to %LOCALAPPDATA%\jean for unprivileged setups. +func defaultJeanHome() string { + if pd := os.Getenv("ProgramData"); pd != "" { + return filepath.Join(pd, "jean") + } + if la := os.Getenv("LOCALAPPDATA"); la != "" { + return filepath.Join(la, "jean") + } + return filepath.Join(os.TempDir(), "jean") +} + +// defaultEditor is used by `jean edit` when $EDITOR is unset. +func defaultEditor() string { return "notepad" } + +// setLibraryPath ensures llama-server can load DLLs bundled next to the binary. +// Windows resolves dependent DLLs via PATH (and the binary's own directory), so +// we prepend dir to PATH. +func setLibraryPath(dir string) { + p := dir + if existing := os.Getenv("PATH"); existing != "" { + p = dir + string(os.PathListSeparator) + existing + } + _ = os.Setenv("PATH", p) +} + +// execServer runs llama-server as a child process and waits for it. Windows has +// no exec() that replaces the current image, so `jean serve` stays alive as the +// parent (this is the detached process the service supervisor tracks). +// args[0] is the binary path; the rest are its arguments. +func execServer(bin string, args []string) error { + cmd := exec.Command(bin, args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = os.Environ() + return cmd.Run() +} + +// newShellCmd builds the command used by the run_shell tool. +func newShellCmd(ctx context.Context, command string) *exec.Cmd { + return exec.CommandContext(ctx, "cmd", "/C", command) +} diff --git a/serve.go b/serve.go index ed6e9b9..f9b708c 100644 --- a/serve.go +++ b/serve.go @@ -4,7 +4,6 @@ import ( "fmt" "os" "path/filepath" - "syscall" ) // cmdServe replaces the historic start.sh: read config.env, build the @@ -21,13 +20,18 @@ func cmdServe(args []string) error { return fmt.Errorf("MODEL non défini dans %s", confPath()) } - // Resolve LD_LIBRARY_PATH the same way start.sh did so that custom llama.cpp - // builds with bundled shared libs continue to load their .so neighbours. - ld := filepath.Dir(bin) - if existing := os.Getenv("LD_LIBRARY_PATH"); existing != "" { - ld = ld + ":" + existing + // Make sure llama-server can find its bundled shared libraries (the .so/.dll + // neighbours of the binary). This is platform-specific: LD_LIBRARY_PATH on + // Linux, PATH on Windows — handled inside execServer. + setLibraryPath(filepath.Dir(bin)) + + // Sélection GPU (jean gpu) : on filtre les devices visibles par llama-server. + // CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux + // affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide"). + if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" { + _ = os.Setenv("CUDA_VISIBLE_DEVICES", v) + _ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID") } - _ = os.Setenv("LD_LIBRARY_PATH", ld) get := func(key, fallback string) string { if v, ok := cfg[key]; ok && v != "" { @@ -80,6 +84,7 @@ func cmdServe(args []string) error { fmt.Fprintf(os.Stderr, "[jean serve] %s model=%s port=%s\n", bin, filepath.Base(model), get("PORT", "8080")) - // exec replaces this process — same as start.sh's `exec`. - return syscall.Exec(bin, llmArgs, os.Environ()) + // Hand off to the llama-server process. On Unix this replaces the current + // process (exec); on Windows it runs as a child and waits. See platform_*.go. + return execServer(bin, llmArgs) } diff --git a/service.go b/service.go index 1916aa9..33ca02a 100644 --- a/service.go +++ b/service.go @@ -6,68 +6,19 @@ import ( "os/exec" "strconv" "strings" - "time" ) -// serviceAction wraps `systemctl ` with passwordless sudo where -// it makes sense, and prints a follow-up status check after start/restart. -func serviceAction(action string) error { - svc := serviceName() - needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable" - args := []string{} - bin := "systemctl" - if needsRoot && os.Geteuid() != 0 { - bin = "sudo" - args = append(args, "-n", "systemctl") - } - args = append(args, action, svc) - cmd := exec.Command(bin, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return err - } - switch action { - case "start", "restart": - return checkStarted(svc) - case "stop": - fmt.Println(green("[ok]") + " arrêté") - case "enable": - fmt.Println(green("[ok]") + " démarrage auto activé") - case "disable": - fmt.Println(green("[ok]") + " démarrage auto désactivé") - } - return nil -} - -func checkStarted(svc string) error { - time.Sleep(2 * time.Second) - out, _ := exec.Command("systemctl", "is-active", svc).Output() - state := strings.TrimSpace(string(out)) - if state == "active" || state == "activating" { - fmt.Printf("%s %s: %s\n", green("[ok]"), svc, state) - return nil - } - fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, state) - fmt.Println("------------------------------------------------") - logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output() - fmt.Print(string(logs)) - fmt.Println("------------------------------------------------") - fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") - return fmt.Errorf("service %s non démarré", svc) -} - -func serviceLogs() error { - cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() -} +// service.go holds the platform-neutral pieces of service management. The +// actual start/stop/restart/status/logs implementation is platform-specific: +// - service_unix.go → systemd (systemctl/journalctl) +// - service_windows.go → PID-file background process supervisor +// +// editConfig and showVram live here because they work the same everywhere. func editConfig() error { editor := os.Getenv("EDITOR") if editor == "" { - editor = "nano" + editor = defaultEditor() } cmd := exec.Command(editor, confPath()) cmd.Stdin = os.Stdin @@ -81,6 +32,8 @@ func editConfig() error { } // showVram parses `nvidia-smi --query-gpu=...` and renders a colored bar. +// nvidia-smi is available on both Linux and Windows when an NVIDIA driver is +// installed, so this is platform-neutral. func showVram() error { out, err := exec.Command("nvidia-smi", "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu", diff --git a/service_unix.go b/service_unix.go new file mode 100644 index 0000000..2ce6cb5 --- /dev/null +++ b/service_unix.go @@ -0,0 +1,72 @@ +//go:build unix + +package main + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// serviceAction wraps `systemctl ` with passwordless sudo where +// it makes sense, and prints a follow-up status check after start/restart. +func serviceAction(action string) error { + svc := serviceName() + needsRoot := action == "start" || action == "stop" || action == "restart" || action == "enable" || action == "disable" + args := []string{} + bin := "systemctl" + if needsRoot && os.Geteuid() != 0 { + bin = "sudo" + args = append(args, "-n", "systemctl") + } + args = append(args, action, svc) + cmd := exec.Command(bin, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return err + } + switch action { + case "start", "restart": + return checkStarted(svc) + case "stop": + fmt.Println(green("[ok]") + " arrêté") + case "enable": + fmt.Println(green("[ok]") + " démarrage auto activé") + case "disable": + fmt.Println(green("[ok]") + " démarrage auto désactivé") + } + return nil +} + +func checkStarted(svc string) error { + time.Sleep(2 * time.Second) + out, _ := exec.Command("systemctl", "is-active", svc).Output() + state := strings.TrimSpace(string(out)) + if state == "active" || state == "activating" { + fmt.Printf("%s %s: %s\n", green("[ok]"), svc, state) + return nil + } + fmt.Printf("%s %s: %s — derniers logs :\n", red("[ERREUR]"), svc, state) + fmt.Println("------------------------------------------------") + logs, _ := exec.Command("journalctl", "-u", svc, "-n", "20", "--no-pager").Output() + fmt.Print(string(logs)) + fmt.Println("------------------------------------------------") + fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") + return fmt.Errorf("service %s non démarré", svc) +} + +func serviceLogs() error { + cmd := exec.Command("journalctl", "-u", serviceName(), "-n", "80", "-f") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// serviceIsActive reports whether the systemd unit is currently running. +func serviceIsActive() bool { + out, _ := exec.Command("systemctl", "is-active", serviceName()).Output() + return strings.TrimSpace(string(out)) == "active" +} diff --git a/service_windows.go b/service_windows.go new file mode 100644 index 0000000..0d2b81b --- /dev/null +++ b/service_windows.go @@ -0,0 +1,203 @@ +//go:build windows + +package main + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// On Windows there is no systemd, so "the service" is a background copy of +// `jean 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 +// rights and no external tools beyond the always-present tasklist/taskkill. + +const ( + createNewProcessGroup = 0x00000200 // CREATE_NEW_PROCESS_GROUP + detachedProcess = 0x00000008 // DETACHED_PROCESS +) + +func pidFilePath() string { return filepath.Join(JeanHome(), serviceName()+".pid") } +func logFilePath() string { return filepath.Join(JeanHome(), serviceName()+".log") } + +func serviceAction(action string) error { + switch action { + case "start": + return svcStart() + case "stop": + return svcStop(true) + case "restart": + _ = svcStop(false) + time.Sleep(500 * time.Millisecond) + return svcStart() + case "status": + return svcStatus() + case "enable", "disable": + fmt.Printf("%s '%s' n'est pas géré sur Windows (pas de service système).\n", yellow("[info]"), action) + fmt.Printf(" Pour un démarrage au boot, crée une tâche planifiée ou un service via %s.\n", bold("sc.exe")) + return nil + default: + return fmt.Errorf("action inconnue: %s", action) + } +} + +func svcStart() error { + if pid := readServicePID(); pid > 0 && processAlive(pid) { + fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid) + return nil + } + self, err := os.Executable() + if err != nil { + return err + } + if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + return err + } + logf, err := os.OpenFile(logFilePath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("ouverture du log %s: %w", logFilePath(), err) + } + defer logf.Close() + + cmd := exec.Command(self, "serve") + cmd.Stdout = logf + cmd.Stderr = logf + cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: createNewProcessGroup | detachedProcess} + if err := cmd.Start(); err != nil { + return fmt.Errorf("démarrage de 'jean serve': %w", err) + } + pid := cmd.Process.Pid + if err := os.WriteFile(pidFilePath(), []byte(strconv.Itoa(pid)), 0o644); err != nil { + return fmt.Errorf("écriture du PID: %w", err) + } + // Don't wait — let it run detached. + _ = cmd.Process.Release() + return checkStarted(pid) +} + +func checkStarted(pid int) error { + time.Sleep(2 * time.Second) + if processAlive(pid) { + fmt.Printf("%s %s: démarré (PID %d)\n", green("[ok]"), serviceName(), pid) + fmt.Printf(" logs: %s (jean logs pour suivre)\n", dim(logFilePath())) + return nil + } + fmt.Printf("%s %s: le processus s'est arrêté — derniers logs :\n", red("[ERREUR]"), serviceName()) + fmt.Println("------------------------------------------------") + fmt.Print(tailFile(logFilePath(), 20)) + fmt.Println("------------------------------------------------") + fmt.Printf("→ jean logs pour plus de détails\n→ jean edit pour corriger config.env\n") + _ = os.Remove(pidFilePath()) + return fmt.Errorf("service %s non démarré", serviceName()) +} + +func svcStop(verbose bool) error { + pid := readServicePID() + if pid <= 0 || !processAlive(pid) { + _ = os.Remove(pidFilePath()) + if verbose { + fmt.Println(yellow("[info]") + " aucun service en cours d'exécution") + } + return nil + } + // taskkill /T tue aussi le processus enfant llama-server. + cmd := exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("arrêt du PID %d: %w\n%s", pid, err, string(out)) + } + _ = os.Remove(pidFilePath()) + if verbose { + fmt.Println(green("[ok]") + " arrêté") + } + return nil +} + +func svcStatus() error { + pid := readServicePID() + if pid > 0 && processAlive(pid) { + fmt.Printf("%s %s: actif (PID %d)\n", green("[ok]"), serviceName(), pid) + } else { + fmt.Printf("%s %s: arrêté\n", yellow("[info]"), serviceName()) + } + fmt.Printf(" logs : %s\n", logFilePath()) + fmt.Printf(" config : %s\n", confPath()) + return nil +} + +func serviceLogs() error { + path := logFilePath() + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("aucun log à %s (le service a-t-il déjà démarré ?): %w", path, err) + } + defer f.Close() + // Print the tail, then follow appended bytes (poor man's tail -f). + fmt.Print(tailFile(path, 80)) + if _, err := f.Seek(0, io.SeekEnd); err != nil { + return err + } + buf := make([]byte, 4096) + for { + n, err := f.Read(buf) + if n > 0 { + os.Stdout.Write(buf[:n]) + } + if err == io.EOF { + time.Sleep(500 * time.Millisecond) + continue + } + if err != nil { + return err + } + } +} + +// serviceIsActive reports whether the background server is running. +func serviceIsActive() bool { + pid := readServicePID() + return pid > 0 && processAlive(pid) +} + +func readServicePID() int { + b, err := os.ReadFile(pidFilePath()) + if err != nil { + return 0 + } + pid, _ := strconv.Atoi(strings.TrimSpace(string(b))) + return pid +} + +// processAlive reports whether a PID is currently running, via tasklist (always +// present on Windows, needs no special privileges). +func processAlive(pid int) bool { + out, err := exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/NH").Output() + if err != nil { + return false + } + s := string(out) + if strings.Contains(s, "No tasks") { + return false + } + return strings.Contains(s, strconv.Itoa(pid)) +} + +// tailFile returns the last n lines of the file at path (best-effort). +func tailFile(path string, n int) string { + b, err := os.ReadFile(path) + if err != nil { + return "" + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + if len(lines) > n { + lines = lines[len(lines)-n:] + } + return strings.Join(lines, "\n") + "\n" +} diff --git a/tools.go b/tools.go index 967dc1e..9143501 100644 --- a/tools.go +++ b/tools.go @@ -35,9 +35,10 @@ func setToolsEnabled(on bool) error { return nil } -// runShell executes a command via `bash -c` with a clamped timeout, returning -// a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..." truncated -// to keep tool output bounded. +// runShell executes a command via the platform shell (bash -c on Unix, cmd /C +// on Windows — see newShellCmd in platform_*.go) with a clamped timeout, +// returning a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..." +// truncated to keep tool output bounded. func runShell(command string, timeoutSec int) string { if timeoutSec <= 0 { timeoutSec = toolDefaultTimeout @@ -47,7 +48,7 @@ func runShell(command string, timeoutSec int) string { } ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, "/bin/bash", "-c", command) + cmd := newShellCmd(ctx, command) var stdout, stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr diff --git a/web.go b/web.go index 06ddf95..9ac48bf 100644 --- a/web.go +++ b/web.go @@ -1,14 +1,18 @@ package main import ( + "bufio" "embed" "encoding/json" "fmt" + "net" "net/http" "os" "os/exec" + "regexp" "strconv" "strings" + "time" ) //go:embed ui/index.html ui/marked.min.js @@ -55,8 +59,91 @@ func cmdWeb(args []string) error { mux.HandleFunc("/api/bench", handleBench) mux.HandleFunc("/api/chat", handleChat) addr := fmt.Sprintf("0.0.0.0:%d", port) + + ln, err := net.Listen("tcp", addr) + if err != nil { + // Port occupé : on identifie le process qui le tient et on propose de + // le terminer pour relancer à sa place. + if !resolvePortConflict(port) { + return err + } + if ln, err = net.Listen("tcp", addr); err != nil { + return err + } + } fmt.Printf("[jean web] http://%s (Ctrl-C pour arrêter)\n", addr) - return http.ListenAndServe(addr, mux) + return http.Serve(ln, mux) +} + +// resolvePortConflict identifies the process listening on `port`, asks the user +// whether to terminate it, and (on yes) kills it and waits for the port to free. +// Returns true if the caller should retry binding. +func resolvePortConflict(port int) bool { + pid, name := pidOnPort(port) + if pid == 0 { + fmt.Printf("%s port %d déjà utilisé, mais le process n'a pas pu être identifié (essaie en root ?)\n", red("[err]"), port) + return false + } + fmt.Printf("%s le port %d est déjà utilisé par %s (PID %d).\n", yellow("[!]"), port, bold(name), pid) + fmt.Print(dim(" terminer ce process et relancer ? [Y/n] ")) + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") { + fmt.Println(dim(" annulé.")) + return false + } + // SIGTERM d'abord, puis SIGKILL si le port ne se libère pas. + _ = exec.Command("kill", "-TERM", strconv.Itoa(pid)).Run() + for i := 0; i < 15; i++ { + time.Sleep(200 * time.Millisecond) + if p, _ := pidOnPort(port); p == 0 { + fmt.Printf("%s process %d terminé, redémarrage…\n", green("[ok]"), pid) + return true + } + } + _ = exec.Command("kill", "-KILL", strconv.Itoa(pid)).Run() + time.Sleep(500 * time.Millisecond) + if p, _ := pidOnPort(port); p != 0 { + fmt.Printf("%s impossible de libérer le port %d (PID %d toujours présent)\n", red("[err]"), port, p) + return false + } + fmt.Printf("%s process %d terminé (forcé), redémarrage…\n", green("[ok]"), pid) + return true +} + +// pidOnPort returns the PID and command name of the process listening on the +// given TCP port, via `ss` (Linux) with an `lsof` fallback. Returns 0 if none +// is found or if the tools can't see it (e.g. owned by another user). +func pidOnPort(port int) (int, string) { + redir := regexp.MustCompile(`pid=(\d+)`) + if out, err := exec.Command("ss", "-ltnHp", fmt.Sprintf("sport = :%d", port)).Output(); err == nil { + if m := redir.FindStringSubmatch(string(out)); m != nil { + pid, _ := strconv.Atoi(m[1]) + return pid, processName(pid) + } + } + if out, err := exec.Command("lsof", "-ti", fmt.Sprintf("tcp:%d", port), "-sTCP:LISTEN").Output(); err == nil { + for _, line := range strings.Fields(string(out)) { + if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil { + return pid, processName(pid) + } + } + } + return 0, "" +} + +// processName returns a short command name for a PID, or "?" if unknown. +func processName(pid int) string { + if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)); err == nil { + if n := strings.TrimSpace(string(b)); n != "" { + return n + } + } + if out, err := exec.Command("ps", "-o", "comm=", "-p", strconv.Itoa(pid)).Output(); err == nil { + if n := strings.TrimSpace(string(out)); n != "" { + return n + } + } + return "?" } func handleIndex(w http.ResponseWriter, r *http.Request) {