From 949e26153826da3098a06fb1810e60c401ec3757 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Wed, 24 Jun 2026 19:29:55 +0200 Subject: [PATCH] v0.2.5 : build llama.cpp 100% automatique et propre sur Windows et Linux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows : `jean llamacpp install` part de zéro et provisionne toute la chaîne sans intervention : - install Windows ajoute jean au PATH (copie dans %JEAN_HOME%\bin) - auto-install des outils manquants via winget (cmake, git, ninja) + refresh du PATH du process depuis le registre - compilateur MSVC Build Tools auto-installé ; générateur Visual Studio (localise MSVC sans Developer Prompt) ; binaire multi-config (Release\) - détection GPU + auto-install du CUDA Toolkit quand une carte NVIDIA est vue sans nvcc ; CUDA_PATH/CUDA_PATH_Vx_y injectés pour MSBuild ; DLL runtime CUDA (bin + bin\x64) ajoutées au PATH de llama-server - console UTF-8 + VT pour un affichage correct Multi-OS : - auto-install des outils aussi sous Unix (apt/dnf/pacman/brew) - désactive l'UI web embarquée de llama-server (LLAMA_BUILD_UI=OFF + LLAMA_USE_PREBUILT_UI=OFF) : supprime la dépendance npm/HuggingFace qui cassait le build ; jean fournit sa propre UI - build CUDA allégé (sans FA_ALL_QUANTS) : bien plus rapide - sortie de build propre : configure/​build passent par un runner qui écrit le détail dans un log fichier et n'affiche qu'un spinner animé + le compteur de fichiers (formats MSBuild et Make/Ninja) + les erreurs réelles ; tests unitaires du filtre Validé en exécution réelle : Windows (GTX 1650 Ti, sm_75) et serveur Linux multi-GPU (sm_75;120) jusqu'au serveur GPU opérationnel. --- buildfilter_test.go | 51 +++++++ install_windows.go | 123 ++++++++++++++- llamacpp.go | 359 +++++++++++++++++++++++++++++++++++++++++--- main.go | 2 +- platform_unix.go | 77 ++++++++++ platform_windows.go | 221 ++++++++++++++++++++++++++- web.go | 3 + 7 files changed, 809 insertions(+), 27 deletions(-) create mode 100644 buildfilter_test.go diff --git a/buildfilter_test.go b/buildfilter_test.go new file mode 100644 index 0000000..14c28a8 --- /dev/null +++ b/buildfilter_test.go @@ -0,0 +1,51 @@ +package main + +import "testing" + +func TestCompiledFile(t *testing.T) { + cases := map[string]string{ + ` Compiling CUDA source file ..\..\..\..\ggml\src\ggml-cuda\acc.cu...`: "acc.cu", + ` ggml-threading.cpp`: "ggml-threading.cpp", + ` ggml-quants.c`: "ggml-quants.c", + // Make (Linux) et Ninja. + `[ 45%] Building CXX object src/CMakeFiles/llama.dir/llama.cpp.o`: "llama.cpp", + `[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu", + // La ligne de commande nvcc géante ne doit PAS être prise pour un fichier. + ` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "", + `Building Custom Rule C:/ProgramData/jean/...`: "", + `-- UI: running npm install`: "", + } + for in, want := range cases { + if got := compiledFile(in); got != want { + t.Errorf("compiledFile(%q) = %q, want %q", in, got, want) + } + } +} + +func TestBuildError(t *testing.T) { + keep := []string{ + `acc.cu(12): error C2065: 'foo': undeclared identifier`, + `LINK : fatal error LNK1104: cannot open file`, + // gcc/clang (Linux). + `/x/ggml.cpp:42:9: error: 'foo' was not declared in this scope`, + } + drop := []string{ + // Les warnings ne doivent PAS remonter (bruit tiers). + `ggml.cpp(10): warning C4244: conversion`, + `LINK : warning LNK4098: conflit entre la bibliothèque ...`, + // Contient -D_CRT_SECURE_NO_WARNINGS mais n'est pas un diagnostic. + `nvcc.exe ... -D_CRT_SECURE_NO_WARNINGS -DGGML_SHARED ... -o acc.obj`, + `Compiling CUDA source file acc.cu...`, + `C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.3\bin\nvcc.exe`, + } + for _, l := range keep { + if !reBuildError.MatchString(l) { + t.Errorf("erreur attendue mais ratée: %q", l) + } + } + for _, l := range drop { + if reBuildError.MatchString(l) { + t.Errorf("fausse erreur: %q", l) + } + } +} diff --git a/install_windows.go b/install_windows.go index d2db2c9..67bb0d9 100644 --- a/install_windows.go +++ b/install_windows.go @@ -4,8 +4,11 @@ package main import ( "fmt" + "io" "os" + "os/exec" "path/filepath" + "strings" ) // On Windows there's no systemd unit, sudoers, or /usr/local/bin to populate. @@ -59,16 +62,128 @@ func cmdInstall(args []string) error { fmt.Printf(" %s config.env déjà présent — inchangé\n", dim("•")) } + // 3. Install the binary into JEAN_HOME\bin and put that dir on the user PATH, + // so `jean` is callable from any shell (this is the Windows analogue of the + // /usr/local/bin symlink the Unix installer creates). + binDir := filepath.Join(jeanHome, "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + return err + } + onPath := false + if dst, err := installSelf(binDir); err != nil { + fmt.Printf(" %s copie du binaire impossible (%v) — ajoute-le au PATH à la main\n", dim("•"), err) + } else { + fmt.Printf(" %s binaire installé %s\n", green("✓"), dst) + added, err := addToUserPath(binDir) + switch { + case err != nil: + fmt.Printf(" %s mise à jour du PATH impossible (%v)\n", dim("•"), err) + case added: + fmt.Printf(" %s %s ajouté au PATH utilisateur\n", green("✓"), binDir) + onPath = true + default: + fmt.Printf(" %s %s déjà dans le PATH\n", dim("•"), binDir) + onPath = true + } + } + 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]")) + 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]")) + } else { + fmt.Printf("\n%s pour exécuter 'jean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]")) + } return nil } +// installSelf copies the currently running executable into binDir as jean.exe +// and returns the destination path. If the running exe already lives there +// (re-install), it's a no-op. +func installSelf(binDir string) (string, error) { + src, err := os.Executable() + if err != nil { + return "", err + } + src, _ = filepath.EvalSymlinks(src) + dst := filepath.Join(binDir, "jean.exe") + if strings.EqualFold(src, dst) { + return dst, nil + } + in, err := os.Open(src) + if err != nil { + return "", err + } + defer in.Close() + // Can't overwrite a running exe, but jean.exe in binDir isn't the one we're + // running (src != dst here), so a plain create is fine. + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755) + if err != nil { + return "", err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return "", err + } + if err := out.Close(); err != nil { + return "", err + } + return dst, nil +} + +// addToUserPath appends dir to the per-user PATH (HKCU\Environment) persistently, +// without admin rights and without setx's 1024-char truncation. Returns false if +// dir was already present. The change applies to newly launched shells. +func addToUserPath(dir string) (bool, error) { + // Read the *user* PATH (not the process PATH, which is User+Machine merged), + // edit it, and write it back, all via PowerShell's environment API which + // handles the registry REG_EXPAND_SZ type and the WM_SETTINGCHANGE broadcast. + ps := fmt.Sprintf(`$d=%s +$p=[Environment]::GetEnvironmentVariable('Path','User') +if (-not $p) { $p='' } +$parts=$p.Split(';') | Where-Object { $_ -ne '' } +if ($parts -contains $d) { Write-Output 'present'; exit 0 } +$new=(@($parts) + $d) -join ';' +[Environment]::SetEnvironmentVariable('Path',$new,'User') +Write-Output 'added'`, psQuote(dir)) + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps) + outBytes, err := cmd.CombinedOutput() + out := strings.TrimSpace(string(outBytes)) + if err != nil { + return false, fmt.Errorf("%v: %s", err, out) + } + return strings.Contains(out, "added"), nil +} + +// removeFromUserPath drops dir from the per-user PATH if present. Returns false +// if it wasn't there. +func removeFromUserPath(dir string) (bool, error) { + ps := fmt.Sprintf(`$d=%s +$p=[Environment]::GetEnvironmentVariable('Path','User') +if (-not $p) { Write-Output 'absent'; exit 0 } +$parts=$p.Split(';') | Where-Object { $_ -ne '' -and $_ -ne $d } +if (($p.Split(';') | Where-Object { $_ -eq $d }).Count -eq 0) { Write-Output 'absent'; exit 0 } +[Environment]::SetEnvironmentVariable('Path',($parts -join ';'),'User') +Write-Output 'removed'`, psQuote(dir)) + cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps) + outBytes, err := cmd.CombinedOutput() + out := strings.TrimSpace(string(outBytes)) + if err != nil { + return false, fmt.Errorf("%v: %s", err, out) + } + return strings.Contains(out, "removed"), nil +} + +// psQuote wraps s in a PowerShell single-quoted string literal (doubling any +// embedded single quotes), safe against spaces and metacharacters in the path. +func psQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "''") + "'" +} + func cmdUninstall(args []string) error { keepData := true for _, a := range args { @@ -82,6 +197,12 @@ func cmdUninstall(args []string) error { // Stop the background server if it's running. _ = svcStop(false) + // Pull JEAN_HOME\bin off the user PATH (best-effort). + binDir := filepath.Join(JeanHome(), "bin") + if removed, err := removeFromUserPath(binDir); err == nil && removed { + fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), binDir) + } + if !keepData { jeanHome := JeanHome() if err := os.RemoveAll(jeanHome); err != nil { diff --git a/llamacpp.go b/llamacpp.go index 0241279..37493c0 100644 --- a/llamacpp.go +++ b/llamacpp.go @@ -1,13 +1,18 @@ package main import ( + "bufio" "fmt" + "io" "os" "os/exec" "path/filepath" + "regexp" "runtime" "sort" "strings" + "sync" + "time" ) // llamacpp.go — gestion du backend llama.cpp (clone, build, mise à jour). @@ -29,6 +34,8 @@ type buildPlan struct { cudaCXX string // chemin de nvcc quand backend == cuda flags []string // flags -D… passés à `cmake -B build` jobs int // parallélisme du build + gen string // générateur CMake (-G), vide => défaut de la plateforme + genArch string // architecture du générateur (-A), ex. "x64" (VS uniquement) } func cmdLlamacpp(args []string) error { @@ -116,6 +123,10 @@ func llamacppInstall(args []string) error { if err := requireTools("git", "cmake"); err != nil { return err } + if err := ensureCompiler(); err != nil { + return err + } + ensureAccelerator() // best-effort : installe le toolkit GPU si une carte est détectée // Dépôt déjà présent ? On bascule sur update plutôt que de re-cloner. if isDir(filepath.Join(repo, ".git")) { @@ -153,9 +164,9 @@ func llamacppInstall(args []string) error { return err } - bin := filepath.Join(repo, "build", "bin", "llama-server") - if !isFile(bin) { - return fmt.Errorf("build terminé mais binaire introuvable: %s", bin) + bin := llamaServerBin(repo) + if bin == "" { + return fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build")) } fmt.Printf("\n%s binaire compilé : %s\n", green("✓"), bin) @@ -199,6 +210,10 @@ func llamacppUpdate(args []string) error { if err := requireTools("git", "cmake"); err != nil { return err } + if err := ensureCompiler(); err != nil { + return err + } + ensureAccelerator() // best-effort : installe le toolkit GPU si une carte est détectée repo := llamacppRepoDir() if !isDir(filepath.Join(repo, ".git")) { @@ -224,8 +239,7 @@ func llamacppUpdate(args []string) error { // Déjà à jour ? On s'arrête (sauf --clean / --force qui forcent un rebuild). localRev := gitOutput(repo, "rev-parse", "HEAD") remoteRev := gitOutput(repo, "rev-parse", "origin/"+branch) - bin := filepath.Join(repo, "build", "bin", "llama-server") - if localRev != "" && localRev == remoteRev && !clean && !force && isFile(bin) { + if localRev != "" && localRev == remoteRev && !clean && !force && llamaServerBin(repo) != "" { fmt.Printf("%s déjà à jour (%s) — rien à faire\n", green("[ok]"), oldCommit) fmt.Printf(" (utilise %s pour forcer une recompilation)\n", dim("--force")) return nil @@ -263,8 +277,12 @@ func llamacppUpdate(args []string) error { } return err } - if !isFile(bin) { - return fmt.Errorf("build terminé mais binaire introuvable: %s", bin) + bin := llamaServerBin(repo) + if bin == "" { + return fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build")) + } + if err := SetConfigKey("BIN", bin); err != nil { + return fmt.Errorf("build ok mais échec écriture BIN dans config.env: %w", err) } fmt.Printf("\n%s mis à jour : %s → %s\n", green("✓"), oldCommit, newCommit) @@ -298,8 +316,7 @@ func llamacppStatus(args []string) error { fmt.Printf(" branche : %s\n", branch) fmt.Printf(" commit : %s\n", commit) - bin := filepath.Join(repo, "build", "bin", "llama-server") - if isFile(bin) { + if bin := llamaServerBin(repo); bin != "" { fmt.Printf(" binaire : %s\n", green(bin)) } else { fmt.Printf(" binaire : %s (pas encore compilé)\n", yellow("absent")) @@ -331,6 +348,29 @@ func detectBuildPlan() buildPlan { p.flags = []string{ "-DCMAKE_BUILD_TYPE=Release", "-DGGML_NATIVE=ON", + // L'UI web embarquée de llama-server exige npm (ou un téléchargement + // d'assets pré-compilés depuis HuggingFace) pour générer un service-worker + // PWA — une dépendance lourde qui casse le build sur une machine sans node. + // jean fournit sa propre UI, donc on la désactive : build plus rapide et + // sans dépendance réseau/npm. BUILD_UI=OFF coupe npm ; USE_PREBUILT_UI=OFF + // coupe le téléchargement d'assets pré-compilés depuis HuggingFace (qui + // échoue sur un réseau restreint et fait planter l'embed). Sur un checkout + // neuf le dist est vide → llama-server embarque une UI vide sans erreur. + // Voir scripts/ui-assets.cmake côté llama.cpp. + "-DLLAMA_BUILD_UI=OFF", + "-DLLAMA_USE_PREBUILT_UI=OFF", + } + + // Sur Windows, le générateur CMake par défaut est « NMake Makefiles », qui + // suppose un Developer Command Prompt MSVC. On force le générateur Visual + // Studio : il localise le toolchain MSVC tout seul via le registre, sans + // vcvars, depuis un shell ordinaire. + if runtime.GOOS == "windows" { + p.gen = msvcGenerator() + p.genArch = "x64" + if runtime.GOARCH == "arm64" { + p.genArch = "ARM64" + } } if runtime.GOOS == "darwin" { @@ -344,7 +384,10 @@ func detectBuildPlan() buildPlan { if nvcc := findNvcc(); nvcc != "" && hasNvidiaGPU() { p.backend = "cuda" p.cudaCXX = nvcc - p.flags = append(p.flags, "-DGGML_CUDA=ON", "-DGGML_CUDA_F16=ON", "-DGGML_CUDA_FA_ALL_QUANTS=ON") + // NB : on n'active PAS GGML_CUDA_FA_ALL_QUANTS — il compile les kernels + // Flash-Attention pour toutes les combinaisons de quant (des centaines de + // .cu), ce qui explose le temps de build pour un gain d'inférence marginal. + p.flags = append(p.flags, "-DGGML_CUDA=ON", "-DGGML_CUDA_F16=ON") if arch := detectCudaArch(); arch != "" { p.cudaArch = arch p.flags = append(p.flags, "-DCMAKE_CUDA_ARCHITECTURES="+arch) @@ -391,17 +434,40 @@ func buildLlamacpp(repo string, p buildPlan, clean bool) error { env := "" if p.backend == "cuda" && p.cudaCXX != "" { cudaBin := filepath.Dir(p.cudaCXX) - env = "CUDACXX=" + p.cudaCXX + "\x00PATH=" + cudaBin + string(os.PathListSeparator) + os.Getenv("PATH") + parts := []string{ + "CUDACXX=" + p.cudaCXX, + "PATH=" + cudaBin + string(os.PathListSeparator) + os.Getenv("PATH"), + } + // L'intégration MSBuild CUDA (générateur Visual Studio) résout + // CudaToolkitDir depuis CUDA_PATH / CUDA_PATH_Vx_y. L'installeur les pose + // dans l'environnement persistant, mais pas dans ce process déjà lancé — + // on les réinjecte sinon le configure échoue sur « CUDA Toolkit directory '' ». + parts = append(parts, cudaPathEnv(filepath.Dir(cudaBin))...) + env = strings.Join(parts, "\x00") } - cfgArgs := append([]string{"-B", "build", "-S", "."}, p.flags...) - if err := runStepEnv("cmake configure", repo, env, "cmake", cfgArgs...); err != nil { + cfgArgs := []string{"-B", "build", "-S", "."} + if p.gen != "" { + cfgArgs = append(cfgArgs, "-G", p.gen) + if p.genArch != "" { + cfgArgs = append(cfgArgs, "-A", p.genArch) + } + } + cfgArgs = append(cfgArgs, p.flags...) + cfgLog := filepath.Join(repo, "configure.log") + if err := runBuildStep("cmake configure", repo, env, "cmake", cfgLog, cfgArgs...); err != nil { return fmt.Errorf("configuration CMake échouée: %w", err) } buildArgs := []string{"--build", "build", "--config", "Release", "-j", fmt.Sprintf("%d", p.jobs), "--target", "llama-server"} - if err := runStepEnv("cmake build", repo, env, "cmake", buildArgs...); err != nil { + // Générateur Visual Studio : MSBuild réaffiche par défaut la ligne de commande + // nvcc complète de chaque kernel (des pavés illisibles). On le passe en + // verbosité minimale via les args natifs après « -- ». + if strings.HasPrefix(p.gen, "Visual Studio") { + buildArgs = append(buildArgs, "--", "/nologo", "/verbosity:minimal") + } + if err := runBuildStep("cmake build", repo, env, "cmake", filepath.Join(repo, "build.log"), buildArgs...); err != nil { return fmt.Errorf("compilation échouée: %w", err) } return nil @@ -438,6 +504,26 @@ func findNvcc() string { if p, err := exec.LookPath("nvcc"); err == nil { return p } + if runtime.GOOS == "windows" { + // CUDA_PATH est posé par l'installeur officiel. + if cp := os.Getenv("CUDA_PATH"); cp != "" { + if p := filepath.Join(cp, "bin", "nvcc.exe"); isFile(p) { + return p + } + } + // Layout standard : …\NVIDIA GPU Computing Toolkit\CUDA\v12.x\bin\nvcc.exe + for _, base := range []string{os.Getenv("ProgramFiles"), `C:\Program Files`} { + if base == "" { + continue + } + matches, _ := filepath.Glob(filepath.Join(base, "NVIDIA GPU Computing Toolkit", "CUDA", "v*", "bin", "nvcc.exe")) + if len(matches) > 0 { + sort.Strings(matches) // v12.2 < v12.8 → on prend le plus récent + return matches[len(matches)-1] + } + } + return "" + } if p := "/usr/local/cuda/bin/nvcc"; isFile(p) { return p } @@ -504,22 +590,66 @@ func isDir(p string) bool { return err == nil && fi.IsDir() } +// llamaServerBin returns the path to the built llama-server binary under repo, +// probing the layouts the different CMake generators emit: the Visual Studio +// multi-config generator nests it under build/bin/Release/ and Windows adds a +// .exe suffix, whereas the Unix Makefiles generator drops it in build/bin/. +// Returns "" when no binary is found. +func llamaServerBin(repo string) string { + ext := "" + if runtime.GOOS == "windows" { + ext = ".exe" + } + for _, rel := range []string{ + filepath.Join("build", "bin", "Release", "llama-server"+ext), + filepath.Join("build", "bin", "llama-server"+ext), + filepath.Join("build", "Release", "llama-server"+ext), + filepath.Join("build", "llama-server"+ext), + } { + if p := filepath.Join(repo, rel); isFile(p) { + return p + } + } + return "" +} + func hasTool(name string) bool { _, err := exec.LookPath(name) return err == nil } func requireTools(tools ...string) error { + missing := missingTools(tools) + if len(missing) == 0 { + return nil + } + + // Tentative d'installation automatique (winget sur Windows, apt/brew/dnf sur + // Unix). On rafraîchit ensuite le PATH du process car un installeur système + // écrit le PATH machine sans toucher l'environnement déjà chargé. + fmt.Printf("%s outils manquants: %s — installation automatique…\n", yellow("[info]"), strings.Join(missing, ", ")) + for _, t := range missing { + if err := autoInstallTool(t); err != nil { + fmt.Printf(" %s %s: %v\n", dim("•"), t, err) + } + } + refreshToolPath() + + if still := missingTools(tools); len(still) > 0 { + return fmt.Errorf("outils toujours manquants après tentative d'install: %s — installe-les à la main puis réessaie", strings.Join(still, ", ")) + } + fmt.Printf("%s outils installés.\n", green("✓")) + return nil +} + +func missingTools(tools []string) []string { var missing []string for _, t := range tools { if !hasTool(t) { missing = append(missing, t) } } - if len(missing) > 0 { - return fmt.Errorf("outils manquants: %s — installe-les puis réessaie", strings.Join(missing, ", ")) - } - return nil + return missing } // gitOutput runs a git command in `dir` and returns trimmed stdout (or ""). @@ -560,6 +690,199 @@ func runStepEnv(name, dir, extraEnv, bin string, args ...string) error { return cmd.Run() } +// runBuildStep runs a compile step while keeping the terminal clean: the full +// output goes to logPath, and the screen shows only a single self-rewriting +// progress line (spinner + compiled-file count) plus any real compiler +// diagnostics. The hundreds of per-file nvcc/cl command echoes are hidden. On +// failure the tail of the log is printed so the actual error is never lost. +func runBuildStep(name, dir, extraEnv, bin, logPath string, args ...string) error { + fmt.Printf("\n%s %s\n", cyan("▶"), name) + cmd := exec.Command(bin, args...) + cmd.Dir = dir + if extraEnv != "" { + env := os.Environ() + for _, kv := range strings.Split(extraEnv, "\x00") { + if kv != "" { + env = upsertEnv(env, kv) + } + } + cmd.Env = env + } + + var logf *os.File + if logPath != "" { + if f, err := os.Create(logPath); err == nil { + logf = f + defer logf.Close() + } + } + + pr, pw := io.Pipe() + cmd.Stdout = pw + cmd.Stderr = pw + if err := cmd.Start(); err != nil { + return err + } + + frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} + var ( + mu sync.Mutex + count int + label = "préparation…" + fi int + ) + clearLine := func() { + if colorOn { + fmt.Print("\r\033[K") + } + } + // draw redessine la ligne d'état ; appelé par une horloge pour rester animé + // même quand un seul gros fichier compile pendant plusieurs minutes. + draw := func() { + if !colorOn { + return + } + mu.Lock() + fi = (fi + 1) % len(frames) + fmt.Printf("\r\033[K %c %s", frames[fi], label) + mu.Unlock() + } + + done := make(chan struct{}) + go func() { + sc := bufio.NewScanner(pr) + sc.Buffer(make([]byte, 1<<20), 1<<20) // les échos de commande sont énormes + for sc.Scan() { + line := sc.Text() + if logf != nil { + fmt.Fprintln(logf, line) + } + mu.Lock() + if f := compiledFile(line); f != "" { + count++ + label = fmt.Sprintf("compilation… %d fichiers %s", count, dim("("+f+")")) + mu.Unlock() + continue + } + if p := phaseLabel(line); p != "" { + label = p + } + mu.Unlock() + // On ne fait remonter que les vraies ERREURS (les warnings MSVC/linker + // d'un projet tiers sont du bruit ; ils restent dans le log). Les CMake + // Error de la phase configure sont aussi affichés. + if reBuildError.MatchString(line) || strings.HasPrefix(strings.TrimSpace(line), "CMake Error") { + mu.Lock() + clearLine() + fmt.Println(" " + strings.TrimSpace(line)) + mu.Unlock() + } + } + close(done) + }() + + // Horloge d'animation, indépendante du flux de sortie. + stop := make(chan struct{}) + tickerDone := make(chan struct{}) + go func() { + defer close(tickerDone) + t := time.NewTicker(120 * time.Millisecond) + defer t.Stop() + for { + select { + case <-stop: + return + case <-t.C: + draw() + } + } + }() + + err := cmd.Wait() + _ = pw.Close() + <-done + close(stop) + <-tickerDone + clearLine() + if err == nil && count > 0 { + fmt.Printf(" %s %d fichiers compilés\n", green("✓"), count) + } + if err != nil && logPath != "" { + fmt.Printf("%s étape échouée — log complet : %s\n", yellow("[err]"), logPath) + printLogTail(logPath, 30) + } + return err +} + +// phaseLabel maps a non-compile output line to a short status label, or "" to +// leave the current label unchanged. Keeps the spinner informative during the +// CMake configure phase and the final link. +func phaseLabel(line string) string { + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(t, "-- "): + return "configuration… " + truncLabel(strings.TrimPrefix(t, "-- "), 50) + case strings.Contains(t, "Linking") || strings.Contains(t, "Build files have been written"): + return "édition de liens…" + } + return "" +} + +func truncLabel(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) > n { + return s[:n-1] + "…" + } + return s +} + +var ( + // MSBuild (Windows) : « Compiling CUDA source file …\foo.cu… » ou nom de + // source seul « foo.cpp » imprimé par cl. + reCompilingCUDA = regexp.MustCompile(`Compiling .*?([\w.\-]+\.cu)\b`) + reBareSource = regexp.MustCompile(`^[\w.\-]+\.(c|cc|cpp|cxx|cu|cuh)$`) + // Make / Ninja (Linux, macOS) : « [ 45%] Building CXX object …/foo.cpp.o » ou + // « [12/345] Building CUDA object …/foo.cu.o ». + reBuildingObj = regexp.MustCompile(`Building (?:C|CXX|CUDA|ASM)\w* object .*?/([^/]+?)\.o(?:bj)?\b`) + // Vraies erreurs : « foo.cpp(12): error C2065 » (MSVC), « foo.cpp:12:5: error: » + // (gcc/clang), « LINK : fatal error LNK1104 ». On exige le « : » devant le + // mot-clé pour ne PAS matcher les flags type -D_CRT_SECURE_NO_WARNINGS dans les + // lignes de commande. Les warnings (bruit d'un projet tiers) sont exclus. + reBuildError = regexp.MustCompile(`(?i):\s*(fatal error|error)\b`) +) + +// compiledFile returns the source filename a build line announces compiling, or +// "" if the line isn't a compile-progress marker. Handles both the MSBuild +// (Windows) and Make/Ninja (Unix) output formats. +func compiledFile(line string) string { + t := strings.TrimSpace(line) + if m := reCompilingCUDA.FindStringSubmatch(t); m != nil { + return m[1] + } + if m := reBuildingObj.FindStringSubmatch(t); m != nil { + return m[1] + } + if reBareSource.MatchString(t) { + return t + } + return "" +} + +// printLogTail prints the last n lines of the log file (best-effort). +func printLogTail(path string, n int) { + 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:] + } + for _, l := range lines { + fmt.Println(" " + dim(l)) + } +} + // upsertEnv replaces KEY=… in env if present, else appends kv (kv is "KEY=VAL"). func upsertEnv(env []string, kv string) []string { key := kv diff --git a/main.go b/main.go index 7d93d01..3d3208f 100644 --- a/main.go +++ b/main.go @@ -8,7 +8,7 @@ import ( "strings" ) -const Version = "0.2.4" +const Version = "0.2.5" func main() { args := os.Args[1:] diff --git a/platform_unix.go b/platform_unix.go index cb77175..612c727 100644 --- a/platform_unix.go +++ b/platform_unix.go @@ -4,6 +4,7 @@ package main import ( "context" + "fmt" "os" "os/exec" "path/filepath" @@ -57,6 +58,82 @@ func cudaLibDirs() []string { return dirs } +// autoInstallTool installs a missing build tool with the system package manager +// (apt/dnf/pacman on Linux, brew on macOS). Best-effort: returns an error when no +// known manager is available or the install fails. Uses sudo on Linux when not +// already root (brew refuses to run as root). +func autoInstallTool(name string) error { + managers := []struct { + bin string + install []string // args before the package name + }{ + {"apt-get", []string{"install", "-y"}}, + {"dnf", []string{"install", "-y"}}, + {"pacman", []string{"-S", "--noconfirm"}}, + {"brew", []string{"install"}}, + } + for _, m := range managers { + if _, err := exec.LookPath(m.bin); err != nil { + continue + } + argv := append(append([]string{m.bin}, m.install...), name) + if m.bin != "brew" && os.Geteuid() != 0 { + if _, err := exec.LookPath("sudo"); err != nil { + return fmt.Errorf("%s requiert root (ni root ni sudo disponibles)", m.bin) + } + argv = append([]string{"sudo"}, argv...) + } + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin + return cmd.Run() + } + return fmt.Errorf("aucun gestionnaire de paquets connu — installe %s manuellement", name) +} + +// msvcGenerator is Windows-only; on Unix the default CMake generator (Unix +// Makefiles) is correct, so detectBuildPlan never calls this for real. Present +// only so the shared code compiles. +func msvcGenerator() string { return "" } + +// ensureCompiler makes sure a C/C++ toolchain (cc + c++ + make) is present, +// installing it via the system package manager when missing. build-essential on +// Debian/Ubuntu pulls the lot; elsewhere we fall back to individual packages. +func ensureCompiler() error { + haveCC := hasTool("cc") || hasTool("gcc") || hasTool("clang") + haveCXX := hasTool("c++") || hasTool("g++") || hasTool("clang++") + if haveCC && haveCXX && hasTool("make") { + return nil + } + candidates := []string{"build-essential", "gcc", "g++", "make"} + if _, err := exec.LookPath("apt-get"); err != nil { + // Non-Debian: build-essential n'existe pas, on vise les paquets directs. + candidates = []string{"gcc", "gcc-c++", "make"} + } + fmt.Println(yellow("[info]") + " compilateur C/C++ absent — installation via le gestionnaire de paquets…") + for _, pkg := range candidates { + _ = autoInstallTool(pkg) // best-effort, paquets variables selon la distro + } + if (hasTool("cc") || hasTool("gcc")) && (hasTool("c++") || hasTool("g++")) && hasTool("make") { + fmt.Println(green("✓") + " compilateur C/C++ prêt.") + return nil + } + return fmt.Errorf("compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement") +} + +// cudaPathEnv is Windows-specific (the MSBuild CUDA integration needs CUDA_PATH); +// on Unix the Makefiles/Ninja generators find nvcc via PATH/CUDACXX, so there's +// nothing extra to inject. +func cudaPathEnv(toolkitDir string) []string { return nil } + +// ensureAccelerator is a no-op on Unix: CUDA/ROCm toolkits are installed through +// the distro (their layout is already probed by findNvcc / detectBuildPlan), and +// auto-installing multi-GB GPU toolkits across distros is too varied to do safely. +func ensureAccelerator() {} + +// refreshToolPath is a no-op on Unix: package managers install into directories +// already on PATH (/usr/bin, /usr/local/bin), unlike Windows. +func refreshToolPath() {} + // 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. diff --git a/platform_windows.go b/platform_windows.go index 536b3aa..cff125f 100644 --- a/platform_windows.go +++ b/platform_windows.go @@ -4,11 +4,41 @@ package main import ( "context" + "fmt" "os" "os/exec" "path/filepath" + "strings" + "syscall" + "unsafe" ) +// 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 +// mojibake (the default OEM codepage, e.g. cp850, renders "✓" as "├ö"), and VT +// processing so the ANSI colour/cursor escapes (including the build progress +// line) are interpreted instead of printed literally. Best-effort: a redirected +// or legacy console just keeps its defaults. +func init() { + const ( + cpUTF8 = 65001 + enableVirtualTerminalProcessing = 0x0004 + stdOutputHandle = ^uintptr(10) // -11 as DWORD + ) + kernel32 := syscall.NewLazyDLL("kernel32.dll") + _, _, _ = kernel32.NewProc("SetConsoleOutputCP").Call(uintptr(cpUTF8)) + _, _, _ = kernel32.NewProc("SetConsoleCP").Call(uintptr(cpUTF8)) + + getStdHandle := kernel32.NewProc("GetStdHandle") + getConsoleMode := kernel32.NewProc("GetConsoleMode") + setConsoleMode := kernel32.NewProc("SetConsoleMode") + h, _, _ := getStdHandle.Call(stdOutputHandle) + var mode uint32 + if r, _, _ := getConsoleMode.Call(h, uintptr(unsafe.Pointer(&mode))); r != 0 { + _, _, _ = setConsoleMode.Call(h, uintptr(mode|enableVirtualTerminalProcessing)) + } +} + // 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. @@ -25,15 +55,28 @@ func defaultJeanHome() string { // 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. +// setLibraryPath ensures llama-server can load its dependent DLLs. Windows +// resolves them via PATH (and the binary's own directory), so we prepend dir. +// For a CUDA build we must also add the CUDA Toolkit's bin: ggml-cuda.dll links +// against cublas64_*/cublasLt64_*.dll which live there, not next to the binary — +// without it the server dies with 0xC0000135 (DLL not found) unless the launching +// shell happened to have CUDA on PATH. func setLibraryPath(dir string) { - p := dir - if existing := os.Getenv("PATH"); existing != "" { - p = dir + string(os.PathListSeparator) + existing + parts := []string{dir} + if nvcc := findNvcc(); nvcc != "" { + binDir := filepath.Dir(nvcc) // …\CUDA\vX.Y\bin + parts = append(parts, binDir) + // CUDA 13+ a déplacé les DLL runtime (cublas64_*, cublasLt64_*, cudart64_*) + // dans bin\x64\ ; sur CUDA 12 elles sont directement dans bin\. On ajoute + // les deux pour couvrir les deux layouts. + if x64 := filepath.Join(binDir, "x64"); isDir(x64) { + parts = append(parts, x64) + } } - _ = os.Setenv("PATH", p) + if existing := os.Getenv("PATH"); existing != "" { + parts = append(parts, existing) + } + _ = os.Setenv("PATH", strings.Join(parts, string(os.PathListSeparator))) } // execServer runs llama-server as a child process and waits for it. Windows has @@ -49,6 +92,170 @@ func execServer(bin string, args []string) error { return cmd.Run() } +// wingetIDs maps a tool's command name to its winget package ID. +var wingetIDs = map[string]string{ + "git": "Git.Git", + "cmake": "Kitware.CMake", + "ninja": "Ninja-build.Ninja", +} + +// autoInstallTool installs a missing build tool via winget (bundled with Windows +// 10/11 and Server 2025). Returns an error if winget is absent or the install +// fails; the caller re-checks availability afterwards. +func autoInstallTool(name string) error { + if _, err := exec.LookPath("winget"); err != nil { + return fmt.Errorf("winget introuvable — installe %s manuellement", name) + } + id, ok := wingetIDs[name] + if !ok { + id = name + } + cmd := exec.Command("winget", "install", "--id", id, "-e", + "--accept-source-agreements", "--accept-package-agreements", + "--disable-interactivity", "--silent") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +// refreshToolPath reloads the process PATH from the Windows registry (Machine + +// User), so tools just installed by winget become resolvable without restarting +// the shell. +func refreshToolPath() { + ps := `$m=[Environment]::GetEnvironmentVariable('Path','Machine') +$u=[Environment]::GetEnvironmentVariable('Path','User') +Write-Output ((@($m,$u) | Where-Object { $_ }) -join ';')` + out, err := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps).Output() + if err != nil { + return + } + if merged := strings.TrimSpace(string(out)); merged != "" { + _ = os.Setenv("PATH", merged) + } +} + +// vswherePath returns the location of vswhere.exe, the official tool for +// locating Visual Studio / Build Tools installs. It ships in a fixed spot. +func vswherePath() string { + base := os.Getenv("ProgramFiles(x86)") + if base == "" { + base = os.Getenv("ProgramFiles") + } + return filepath.Join(base, "Microsoft Visual Studio", "Installer", "vswhere.exe") +} + +// msvcInstallVersion returns the major version of the newest MSVC install that +// has the C++ toolchain (e.g. "17"), or "" if none is found. +func msvcInstallVersion() string { + vs := vswherePath() + if _, err := os.Stat(vs); err != nil { + return "" + } + out, err := exec.Command(vs, "-latest", "-products", "*", + "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", "installationVersion").Output() + if err != nil { + return "" + } + ver := strings.TrimSpace(string(out)) + if ver == "" { + return "" + } + if i := strings.IndexByte(ver, '.'); i > 0 { + return ver[:i] + } + return ver +} + +// msvcGenerator returns the CMake generator name for the installed MSVC, falling +// back to VS 2022 (the version `ensureCompiler` installs). +func msvcGenerator() string { + switch msvcInstallVersion() { + case "16": + return "Visual Studio 16 2019" + case "15": + return "Visual Studio 15 2017" + default: + return "Visual Studio 17 2022" + } +} + +// ensureCompiler makes sure an MSVC C++ toolchain is available, installing the +// Visual Studio 2022 Build Tools (VCTools workload) via winget if not. This is a +// large download but keeps `jean llamacpp install` fully unattended on a bare +// Windows box. +func ensureCompiler() error { + if msvcInstallVersion() != "" { + return nil + } + if _, err := exec.LookPath("winget"); err != nil { + return fmt.Errorf("compilateur C++ absent et winget introuvable — installe « Visual Studio Build Tools » (charge de travail C++) manuellement") + } + fmt.Printf("%s compilateur C++ absent — installation des Build Tools MSVC (gros téléchargement, une seule fois)…\n", yellow("[info]")) + cmd := exec.Command("winget", "install", "--id", "Microsoft.VisualStudio.2022.BuildTools", "-e", + "--accept-source-agreements", "--accept-package-agreements", + "--disable-interactivity", + "--override", "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("installation des Build Tools MSVC échouée: %w", err) + } + if msvcInstallVersion() == "" { + return fmt.Errorf("Build Tools installés mais toolchain C++ introuvable — relance la commande ou vérifie l'installation Visual Studio") + } + fmt.Printf("%s compilateur C++ prêt.\n", green("✓")) + return nil +} + +// ensureAccelerator installs the CUDA Toolkit when an NVIDIA GPU is present but +// nvcc isn't, so the build can target the GPU instead of falling back to CPU. +// Best-effort: any failure just leaves the machine on the CPU path (the caller +// ignores the return value). The CUDA download is large; we only trigger it when +// a GPU is actually detected. +func ensureAccelerator() { + if !hasNvidiaGPU() { + return // pas de GPU NVIDIA visible → rien à installer, build CPU + } + if findNvcc() != "" { + return // toolkit déjà présent + } + if _, err := exec.LookPath("winget"); err != nil { + fmt.Printf("%s GPU NVIDIA détecté mais CUDA Toolkit absent et winget introuvable — build CPU (installe le CUDA Toolkit pour l'accélération GPU)\n", yellow("[info]")) + return + } + fmt.Printf("%s GPU NVIDIA détecté — installation du CUDA Toolkit pour l'accélération GPU (gros téléchargement, une seule fois)…\n", yellow("[info]")) + cmd := exec.Command("winget", "install", "--id", "Nvidia.CUDA", "-e", + "--accept-source-agreements", "--accept-package-agreements", + "--disable-interactivity") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + fmt.Printf("%s installation du CUDA Toolkit échouée (%v) — on continue en CPU\n", yellow("[warn]"), err) + return + } + refreshToolPath() + if findNvcc() != "" { + fmt.Printf("%s CUDA Toolkit prêt — build GPU activé.\n", green("✓")) + } else { + fmt.Printf("%s CUDA Toolkit installé mais nvcc introuvable dans cette session — relance la commande pour activer le GPU\n", yellow("[info]")) + } +} + +// cudaPathEnv returns the CUDA toolkit env vars the MSBuild CUDA integration +// needs (CUDA_PATH and the version-specific CUDA_PATH_Vx_y), derived from the +// toolkit root, e.g. "...\CUDA\v13.3" → CUDA_PATH_V13_3. Returns NUL-free +// "KEY=VAL" strings. +func cudaPathEnv(toolkitDir string) []string { + out := []string{"CUDA_PATH=" + toolkitDir} + // Le dossier se nomme "v13.3" → variable CUDA_PATH_V13_3. + ver := strings.TrimPrefix(filepath.Base(toolkitDir), "v") + if ver != "" { + out = append(out, "CUDA_PATH_V"+strings.ReplaceAll(ver, ".", "_")+"="+toolkitDir) + } + return out +} + // 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/web.go b/web.go index aa52cb9..6ceae11 100644 --- a/web.go +++ b/web.go @@ -268,6 +268,9 @@ func handleBackends(w http.ResponseWriter, r *http.Request) { subpaths := []string{ "build/bin/llama-server", "build-sm120/bin/llama-server", "build/llama-server", "bin/llama-server", "llama-server", + // Layout du générateur Visual Studio (multi-config) + suffixe .exe Windows. + "build/bin/Release/llama-server.exe", "build/bin/llama-server.exe", + "build/bin/Release/llama-server", "llama-server.exe", } out := []map[string]any{} for _, e := range entries {