diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3e3d4eb..649cea7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,31 +1,44 @@ -Cette version donne à l'IA l'accès à internet sans rien installer. Jusqu'ici il fallait héberger un serveur Crawl4AI — un conteneur Docker, un Chromium, quelques centaines de mégaoctets — avant que l'IA puisse chercher ou lire quoi que ce soit sur le web. C'était une barrière que peu de gens franchissaient. AJEAN embarque maintenant son propre moteur web. +Cette version répare une série de problèmes autour des moteurs llama.cpp : un moteur mis à jour qui ne l'était pas vraiment, des presets qui perdaient leur moteur, une compilation qui échouait alors que tout était installé. Elle ajoute aussi le réglage des cartes graphiques directement dans l'éditeur de modèle. -## Internet fonctionne dès l'installation +## Le moteur précompilé se met vraiment à jour -Le nouveau moteur est écrit en Go et vit dans le binaire. Il n'y a rien à télécharger, rien à lancer, aucune adresse de serveur à renseigner : vous activez l'accès internet, et l'IA dispose de `web_search`, `web_open`, `web_read` et `web_grep`. +Chaque mise à jour du moteur précompilé s'installait dans un dossier portant son numéro de version, sans jamais supprimer les précédents. AJEAN retenait ensuite le premier dossier trouvé par ordre alphabétique, c'est à dire le plus ANCIEN. Sur une machine mise à jour plusieurs fois, le moteur réellement utilisé pouvait donc être une vieille version, parfois une installation incomplète qui refusait de démarrer avec une erreur de bibliothèque manquante. -Il récupère les pages, en extrait le contenu réel — en écartant les menus, les bandeaux et les pieds de page — puis le convertit en texte lisible. La recherche passe par DuckDuckGo, dont les résultats sont désormais lus directement dans la structure de la page plutôt que devinés dans du texte reformaté : les titres, les adresses et les extraits sont plus fiables qu'avant. +Le moteur retenu est maintenant celui de la version installée, et les anciennes extractions sont supprimées après une mise à jour réussie (plusieurs centaines de mégaoctets récupérés à chaque fois). -## Crawl4AI reste disponible pour les pages en JavaScript +## Vos presets ne perdent plus leur moteur -Le moteur intégré lit les pages telles que le serveur les envoie, sans exécuter leur JavaScript. C'est sans conséquence pour la documentation, les articles, les blogs, Wikipédia, les dépôts de code ou les forums, qui représentent l'essentiel de ce qu'une IA a besoin de consulter. En revanche, une application web dont le contenu n'apparaît qu'une fois le JavaScript exécuté restera illisible. +Comme le chemin du moteur précompilé contient son numéro de version, il changeait à chaque mise à jour. Tous les presets qui l'utilisaient basculaient alors en mode « personnalisé », et il fallait les repointer un par un. Un preset dont le moteur a été mis à jour est désormais reconnu, et il démarre même si son chemin exact a disparu : il suit la version installée au lieu d'échouer. -Pour ces cas-là, le moteur Crawl4AI est toujours là, avec son navigateur complet. Le choix se fait dans le panneau « Accès internet » de l'interface, ou en ligne de commande : +## Compilation d'un moteur : le CUDA Toolkit est enfin trouvé -``` -ajean internet engine go -ajean internet engine crawl4ai -``` +Sur les machines où `nvcc` vient du paquet de la distribution (`/usr/bin/nvcc`) alors que le toolkit complet vit ailleurs (`/usr/local/cuda`), la configuration échouait sur « CUDA Toolkit not found », après avoir pourtant affiché la version de CUDA. AJEAN cherche maintenant le toolkit avant le raccourci du PATH, et indique explicitement sa racine à CMake. Si le toolkit manque vraiment, le message le dit au lieu de laisser l'erreur brute de CMake. -Les installations qui utilisaient déjà un serveur Crawl4AI le gardent : rien ne change pour elles tant qu'elles n'ont pas choisi l'autre moteur. +## L'installation d'un moteur ne disparaît plus des écrans -## L'IA sait maintenant quand une page lui est inaccessible +L'avancement d'une installation vivait uniquement en mémoire du service. Au moindre redémarrage (mise à jour du binaire, plantage, reboot), la page rechargée n'affichait plus rien : impossible de savoir si la compilation continuait. Elle ne continuait pas, car elle meurt avec le service, mais personne ne le disait. -Une page vide ne dit rien de ce qui s'est passé. L'IA en concluait qu'elle avait mal lu, et rouvrait la même adresse encore et encore. +L'état est maintenant écrit sur disque et rechargé au démarrage. Une opération interrompue est annoncée comme telle, avec son journal. Et sur téléphone, où le panneau latéral est fermé, une pastille en haut de l'écran rappelle qu'une installation est en cours et y ramène en un clic. -Quand une page arrive bien mais ne contient presque aucun texte, AJEAN reconnaît la signature d'un contenu affiché par JavaScript et l'explique à l'IA, en lui demandant de chercher ailleurs plutôt que d'insister. Les pages courtes mais légitimes, elles, continuent d'être lues normalement. +## Choisir ses cartes graphiques par modèle -Dans le même esprit, l'IA ne se voit plus proposer d'agir sur une page — dérouler une section, fermer un bandeau, attendre un élément — quand le moteur intégré est actif, puisqu'il ne peut pas le faire. Elle ne perd plus de temps à essayer. +Nouvelle section « Cartes graphiques » dans l'éditeur de modèle, visible seulement si la machine en a plusieurs. Un interrupteur par carte, et une barre de répartition sur laquelle on fait glisser la part de chacune. + +La liste des cartes est demandée au moteur du preset, pas au système : les identifiants et leur ordre appartiennent au backend. Sur une même machine, un moteur CUDA annonce `CUDA0` pour la grosse carte quand un moteur Vulkan annonce `Vulkan0` pour la petite. Une liste générique aurait fait sélectionner la mauvaise. + +Le réglage passe par `--device`, compris par tous les backends, là où la variable `CUDA_VISIBLE_DEVICES` utilisée par la commande `ajean gpu` reste sans effet sur un moteur Vulkan. + +## Réglages regroupés et décodage spéculatif + +Les « réglages avancés » ne sont plus repliés dans un menu : BATCH, UBATCH, threads de traitement par lots, experts MoE sur CPU et chargement complet en mémoire sont à la suite des autres, avec tous les interrupteurs regroupés en fin de liste. + +Le décodage spéculatif se règle maintenant visuellement : le type (MTP, EAGLE 3, n grammes et les autres valeurs acceptées par le moteur) et le nombre de jetons anticipés, ce dernier n'apparaissant qu'une fois un type choisi. + +## Corrections d'interface + +La sélection de cartes faite avec `ajean gpu` était effacée au changement de preset, tout comme le choix du moteur web. Ces réglages survivent maintenant à une bascule, sauf quand le preset les définit lui même, auquel cas c'est le preset qui gagne (un preset multi GPU impose sa configuration). + +Les listes repliées affichaient une double bordure en bas. La croix de fermeture des fenêtres était écrasée en largeur, elle est maintenant carrée et plus facile à viser au doigt. ## Mise à jour @@ -33,4 +46,4 @@ Dans le même esprit, l'IA ne se voit plus proposer d'agir sur une page — dér ajean update ``` -Ou le bouton de l'interface. +Testé sur Linux avec un moteur CUDA compilé et le moteur Vulkan précompilé, sur deux cartes NVIDIA. La section « Cartes graphiques » n'a pas été essayée sur une machine ROCm ou Metal, ni sur Windows. Le support macOS reste non testé sur du matériel Apple. diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 1fde3ce..01f5dae 100644 Binary files a/cmd/ajean/resource_windows_amd64.syso and b/cmd/ajean/resource_windows_amd64.syso differ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index c2764fe..c94b1ed 100644 Binary files a/cmd/ajean/resource_windows_arm64.syso and b/cmd/ajean/resource_windows_arm64.syso differ diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index bbecc0d..7dfca4e 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 7, - "Patch": 7, + "Patch": 9, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 7, - "Patch": 7, + "Patch": 9, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.7.7", + "ProductVersion": "0.7.9", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/backend_build.go b/internal/ajean/backend_build.go index 6d5fb9f..ec4f012 100644 --- a/internal/ajean/backend_build.go +++ b/internal/ajean/backend_build.go @@ -110,6 +110,15 @@ func detectBuildPlan() buildPlan { // 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") + // Racine du toolkit explicite : sans elle, CMake la déduit du chemin de + // nvcc. Avec un nvcc hors toolkit (/usr/bin/nvcc, paquet Ubuntu) il cherche + // cuda_runtime.h et cudart dans /usr, ne les trouve pas, et sort « CUDA + // Toolkit not found » APRÈS avoir pourtant affiché la version de nvcc. + if root := cudaToolkitRoot(nvcc); root != "" && runtime.GOOS != "windows" { + p.flags = append(p.flags, + "-DCUDAToolkit_ROOT="+root, + "-DCMAKE_CUDA_COMPILER="+nvcc) + } if arch := detectCudaArch(); arch != "" { p.cudaArch = arch p.flags = append(p.flags, "-DCMAKE_CUDA_ARCHITECTURES="+arch) @@ -230,6 +239,14 @@ func hintMissingBuildDep(p buildPlan, cfgLog string) { fmt.Printf(" \\extras\\visual_studio_integration\\MSBuildExtensions vers\n") fmt.Printf(" \\MSBuild\\Microsoft\\VC\\\\BuildCustomizations, puis relance %s.\n", bold("ajean llamacpp install")) } + // Linux/CUDA : nvcc trouvé mais en-têtes/cudart introuvables = le toolkit + // complet n'est pas installé (seul le paquet nvcc l'est). On passe déjà + // CUDAToolkit_ROOT quand un vrai toolkit existe ; si ça échoue quand même, + // c'est qu'il manque pour de bon. + if p.backend == "cuda" && strings.Contains(log, "CUDA Toolkit not found") { + fmt.Printf("\n%s nvcc est présent mais le CUDA Toolkit complet (en-têtes + cudart) est introuvable.\n", yellow("[dépendance]")) + fmt.Printf(" Installe le toolkit NVIDIA officiel (il se pose dans /usr/local/cuda), puis relance %s.\n", bold("ajean llamacpp install")) + } if p.backend == "vulkan" && strings.Contains(log, "SPIRV-Headers") { fmt.Printf("\n%s dépendance manquante pour le backend %s : les en-têtes SPIR-V (paquet « SPIRV-Headers ») sont introuvables.\n", yellow("[dépendance]"), green("Vulkan")) @@ -288,6 +305,9 @@ func cacheStale(build, repo string) bool { // findNvcc returns the path to nvcc from PATH or a /usr/local/cuda* install, // preferring the highest version. func findNvcc() string { + if runtime.GOOS != "windows" { + return findNvccUnix(exec.LookPath) + } if p, err := exec.LookPath("nvcc"); err == nil { return p } @@ -311,6 +331,17 @@ func findNvcc() string { } return "" } + return "" +} + +// findNvccUnix choisit nvcc sous Linux/macOS. L'ORDRE compte : un nvcc rangé +// dans un vrai toolkit (/usr/local/cuda/bin/nvcc) passe AVANT celui que le PATH +// expose. Sur Ubuntu, le paquet nvidia-cuda-toolkit pose un shim /usr/bin/nvcc +// alors que les en-têtes et cudart vivent dans /usr/local/cuda : CMake déduit +// alors la racine du toolkit depuis le chemin de nvcc (donc /usr), n'y trouve ni +// cuda_runtime.h ni cudart, et échoue sur « CUDA Toolkit not found » alors que +// nvcc a bien été détecté. lookPath est injecté pour les tests. +func findNvccUnix(lookPath func(string) (string, error)) string { if p := "/usr/local/cuda/bin/nvcc"; isFile(p) { return p } @@ -319,9 +350,30 @@ func findNvcc() string { sort.Strings(matches) // cuda-12.2 < cuda-12.8 lexicographiquement → on prend le dernier return matches[len(matches)-1] } + if p, err := lookPath("nvcc"); err == nil { + return p + } return "" } +// cudaToolkitRoot remonte de /bin/nvcc à . Renvoie "" quand le +// chemin ne suit pas ce layout, ou quand la racine déduite est /usr : dans ce +// cas la racine n'apprend rien à CMake (c'est justement le cas qui échoue). +func cudaToolkitRoot(nvcc string) string { + if nvcc == "" { + return "" + } + if filepath.Base(filepath.Dir(nvcc)) != "bin" { + return "" + } + root := filepath.Dir(filepath.Dir(nvcc)) + switch filepath.ToSlash(root) { + case "", ".", "/", "/usr": + return "" + } + return root +} + func hasNvidiaGPU() bool { if !hasTool("nvidia-smi") { return false diff --git a/internal/ajean/backend_build_cuda_test.go b/internal/ajean/backend_build_cuda_test.go new file mode 100644 index 0000000..fc200be --- /dev/null +++ b/internal/ajean/backend_build_cuda_test.go @@ -0,0 +1,50 @@ +package ajean + +import ( + "errors" + "path/filepath" + "testing" +) + +// Le shim /usr/bin/nvcc d'Ubuntu ne doit JAMAIS l'emporter sur un vrai toolkit : +// CMake déduit la racine du toolkit du chemin de nvcc, et /usr ne contient ni +// cuda_runtime.h ni cudart → « CUDA Toolkit not found » alors que nvcc est là. +func TestFindNvccUnixIgnoreLeShimQuandUnToolkitExiste(t *testing.T) { + if !isFile("/usr/local/cuda/bin/nvcc") { + t.Skip("pas de toolkit /usr/local/cuda sur cette machine") + } + got := findNvccUnix(func(string) (string, error) { return "/usr/bin/nvcc", nil }) + if got == "/usr/bin/nvcc" { + t.Error("le shim /usr/bin/nvcc a été préféré au toolkit /usr/local/cuda") + } +} + +// Sans toolkit installé, on retombe sur le PATH plutôt que de renoncer à CUDA. +func TestFindNvccUnixRetombeSurLePath(t *testing.T) { + if isFile("/usr/local/cuda/bin/nvcc") { + t.Skip("un toolkit est installé ici : le repli n'est pas exerçable") + } + if got := findNvccUnix(func(string) (string, error) { return "/usr/bin/nvcc", nil }); got != "/usr/bin/nvcc" { + t.Errorf("repli PATH = %q, attendu /usr/bin/nvcc", got) + } + if got := findNvccUnix(func(string) (string, error) { return "", errors.New("absent") }); got != "" { + t.Errorf("aucun nvcc nulle part : %q, attendu \"\"", got) + } +} + +// cudaToolkitRoot remonte /bin/nvcc → , et refuse les racines qui +// n'apprennent rien à CMake (/usr, layout inattendu). +func TestCudaToolkitRoot(t *testing.T) { + cases := map[string]string{ + "/usr/local/cuda/bin/nvcc": "/usr/local/cuda", + "/usr/local/cuda-12.8/bin/nvcc": "/usr/local/cuda-12.8", + "/usr/bin/nvcc": "", // racine déduite = /usr : inutile + "/opt/nvcc": "", // pas de dossier bin + "": "", + } + for in, want := range cases { + if got := filepath.ToSlash(cudaToolkitRoot(filepath.FromSlash(in))); got != want { + t.Errorf("cudaToolkitRoot(%q) = %q, attendu %q", in, got, want) + } + } +} diff --git a/internal/ajean/backend_prebuilt.go b/internal/ajean/backend_prebuilt.go index 13cea55..8907c65 100644 --- a/internal/ajean/backend_prebuilt.go +++ b/internal/ajean/backend_prebuilt.go @@ -23,6 +23,7 @@ import ( "path/filepath" "regexp" "runtime" + "sort" "strconv" "strings" "time" @@ -78,25 +79,105 @@ func prebuiltVersionFormat() string { // prebuiltServerBin localise llama-server(.exe) sous le dossier prebuilt // (l'arborescence interne des archives officielles varie : racine, build/bin…). +// +// Chaque release s'extrait dans son propre sous-dossier (llama-b10280/…), donc +// plusieurs versions peuvent cohabiter — notamment des installations anciennes +// et incomplètes. On retient donc EN PRIORITÉ le binaire de la version notée +// dans VERSION, et à défaut le plus récent (numéro de build le plus élevé) : +// prendre « le premier trouvé » renvoyait le plus ANCIEN dossier, donc un +// binaire périmé voire cassé. func prebuiltServerBin() string { + all := prebuiltServerBins() + if len(all) == 0 { + return "" + } + if tag, _ := prebuiltVersion(); tag != "" { + for _, p := range all { + if strings.Contains(filepath.ToSlash(p), "/llama-"+tag+"/") { + return p + } + } + } + return all[len(all)-1] // ordre lexical = ordre des numéros de build +} + +// prebuiltServerBins liste tous les llama-server présents sous le dossier +// prebuilt, triés par chemin (donc par numéro de build croissant). +func prebuiltServerBins() []string { want := "llama-server" if runtime.GOOS == "windows" { want += ".exe" } - var found string + var found []string _ = filepath.WalkDir(prebuiltDir(), func(p string, d os.DirEntry, err error) error { if err != nil || d.IsDir() { return nil } if d.Name() == want { - found = p - return filepath.SkipAll + found = append(found, p) } return nil }) + sort.Strings(found) return found } +// prebuiltOwns dit si p désigne un binaire du moteur précompilé, quelle que +// soit la version installée. Les presets enregistrent un chemin versionné : +// après une mise à jour ce chemin n'est plus celui du moteur courant, mais le +// preset utilise bien « le moteur précompilé » et doit être reconnu comme tel. +func prebuiltOwns(p string) bool { + if p == "" { + return false + } + norm := func(s string) string { + return strings.ToLower(filepath.ToSlash(filepath.Clean(s))) + } + return strings.HasPrefix(norm(p), norm(prebuiltDir())+"/") +} + +// prebuiltResolveBin fait suivre un BIN de preset aux mises à jour du moteur : +// un chemin versionné qui n'existe plus (release remplacée) est remplacé par le +// binaire précompilé courant. Tout autre chemin est renvoyé tel quel. +func prebuiltResolveBin(bin string) string { + if !prebuiltOwns(bin) { + return bin + } + if _, err := os.Stat(bin); err == nil { + return bin + } + if cur := prebuiltServerBin(); cur != "" { + return cur + } + return bin +} + +// prebuiltPrune supprime les extractions d'autres releases : elles ne servent +// plus à rien (quelques centaines de Mo chacune) et, laissées en place, elles +// se faisaient élire comme binaire courant. +func prebuiltPrune(keep string, logf func(string)) { + keepDir := "" + if keep != "" { + keepDir = filepath.ToSlash(filepath.Clean(keep)) + } + entries, err := os.ReadDir(prebuiltDir()) + if err != nil { + return + } + for _, e := range entries { + if !e.IsDir() || !strings.HasPrefix(e.Name(), "llama-b") { + continue + } + d := filepath.Join(prebuiltDir(), e.Name()) + if keepDir != "" && strings.HasPrefix(keepDir, filepath.ToSlash(filepath.Clean(d))+"/") { + continue + } + if err := os.RemoveAll(d); err == nil && logf != nil { + logf("ancienne version supprimée : " + e.Name()) + } + } +} + // fetchLlamaLatest interroge l'API GitHub pour la dernière release officielle. func fetchLlamaLatest() (string, []ghAsset, error) { req, err := http.NewRequest("GET", llamaReleasesAPI, nil) @@ -276,9 +357,10 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) { // version de Jean qui ignorait les liens des archives (backend installé mais // bibliothèques introuvables au lancement) : le marqueur de format force alors // une ré-extraction propre au lieu d'un « déjà à jour » trompeur. - if curTag == tag && prebuiltVersionFormat() == prebuiltFormat && prebuiltServerBin() != "" { + if cur := prebuiltServerBin(); curTag == tag && prebuiltVersionFormat() == prebuiltFormat && cur != "" { logf("déjà à jour (" + tag + ")") - return prebuiltServerBin(), nil + prebuiltPrune(cur, logf) // ménage des versions laissées par les installs précédentes + return cur, nil } dir := prebuiltDir() @@ -317,6 +399,11 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) { _ = os.Remove(tmp) } + // Le marqueur est écrit AVANT de localiser le binaire : prebuiltServerBin + // s'en sert pour élire la version fraîchement extraite s'il en reste d'autres. + if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte(tag+" "+cudaVer+" "+prebuiltFormat+"\n"), 0o644); err != nil { + return "", err + } bin := prebuiltServerBin() if bin == "" { return "", fmt.Errorf("archives extraites mais llama-server introuvable sous %s", dir) @@ -324,9 +411,7 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) { if runtime.GOOS != "windows" { _ = os.Chmod(bin, 0o755) } - if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte(tag+" "+cudaVer+" "+prebuiltFormat+"\n"), 0o644); err != nil { - return "", err - } + prebuiltPrune(bin, logf) logf("binaire installé : " + bin + " (release " + tag + ")") return bin, nil } diff --git a/internal/ajean/backend_prebuilt_test.go b/internal/ajean/backend_prebuilt_test.go index 98719b0..326e35e 100644 --- a/internal/ajean/backend_prebuilt_test.go +++ b/internal/ajean/backend_prebuilt_test.go @@ -5,6 +5,8 @@ import ( "compress/gzip" "os" "path/filepath" + "runtime" + "strings" "testing" ) @@ -60,3 +62,96 @@ func TestExtractArchiveSymlink(t *testing.T) { t.Fatalf("contenu résolu = %q, attendu %q", got, body) } } + +// fakePrebuilt monte un dossier prebuilt contenant plusieurs releases extraites, +// comme sur une machine mise à jour plusieurs fois. +func fakePrebuilt(t *testing.T, version string, tags ...string) string { + t.Helper() + home := t.TempDir() + t.Setenv("AJEAN_HOME", home) + dir := filepath.Join(home, "backends", "llama.cpp-prebuilt") + name := "llama-server" + if runtime.GOOS == "windows" { + name += ".exe" + } + for _, tag := range tags { + d := filepath.Join(dir, "llama-"+tag) + if err := os.MkdirAll(d, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, name), []byte("bin"), 0o755); err != nil { + t.Fatal(err) + } + } + if version != "" { + if err := os.WriteFile(filepath.Join(dir, "VERSION"), + []byte(version+" - "+prebuiltFormat+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + return dir +} + +// La version notée dans VERSION gagne, même si une extraction plus ancienne +// vient avant dans l'ordre alphabétique — le bug : on servait b10088, une +// installation incomplète, alors que b10280 était la version installée. +func TestPrebuiltServerBinSuitLeMarqueurVersion(t *testing.T) { + fakePrebuilt(t, "b10280", "b10088", "b10159", "b10280") + got := filepath.ToSlash(prebuiltServerBin()) + if !strings.Contains(got, "/llama-b10280/") { + t.Errorf("binaire élu = %s, attendu sous /llama-b10280/", got) + } +} + +// Sans marqueur exploitable, on prend la release la plus récente. +func TestPrebuiltServerBinSansVersionPrendLePlusRecent(t *testing.T) { + fakePrebuilt(t, "", "b10088", "b10280") + if got := filepath.ToSlash(prebuiltServerBin()); !strings.Contains(got, "/llama-b10280/") { + t.Errorf("binaire élu = %s, attendu la release la plus récente", got) + } +} + +// Un BIN de preset écrit avant une mise à jour pointe une release disparue : il +// doit suivre le moteur courant au lieu d'échouer au lancement (exit 127). +func TestPrebuiltResolveBinSuitLaMiseAJour(t *testing.T) { + dir := fakePrebuilt(t, "b10280", "b10280") + stale := filepath.Join(dir, "llama-b10088", "llama-server") + if got := filepath.ToSlash(prebuiltResolveBin(stale)); !strings.Contains(got, "/llama-b10280/") { + t.Errorf("BIN périmé résolu en %s, attendu la release installée", got) + } + // Un binaire hors du dossier prebuilt (fork, build maison) n'est jamais touché. + ext := filepath.Join(t.TempDir(), "llama-server") + if got := prebuiltResolveBin(ext); got != ext { + t.Errorf("BIN externe réécrit en %s", got) + } +} + +// L'installation fait le ménage : les autres releases sont supprimées. +func TestPrebuiltPruneGardeLaVersionCourante(t *testing.T) { + dir := fakePrebuilt(t, "b10280", "b10088", "b10159", "b10280") + prebuiltPrune(prebuiltServerBin(), nil) + for _, tag := range []string{"b10088", "b10159"} { + if _, err := os.Stat(filepath.Join(dir, "llama-"+tag)); err == nil { + t.Errorf("%s aurait dû être supprimé", tag) + } + } + if _, err := os.Stat(filepath.Join(dir, "llama-b10280")); err != nil { + t.Errorf("la version courante a été supprimée : %v", err) + } +} + +// Un preset pointant une release remplacée désigne toujours « le moteur +// précompilé » : sans ça, l'UI repassait tous les presets en « personnalisé » +// après chaque mise à jour du moteur. +func TestPrebuiltOwns(t *testing.T) { + dir := fakePrebuilt(t, "b10280", "b10280") + if !prebuiltOwns(filepath.Join(dir, "llama-b10088", "llama-server")) { + t.Error("une release périmée du dossier prebuilt doit rester reconnue") + } + if prebuiltOwns(filepath.Join(t.TempDir(), "llama-server")) { + t.Error("un binaire externe ne doit pas être pris pour le moteur précompilé") + } + if prebuiltOwns("") { + t.Error("BIN vide reconnu à tort") + } +} diff --git a/internal/ajean/backend_presets.go b/internal/ajean/backend_presets.go index cddec24..3b79ec0 100644 --- a/internal/ajean/backend_presets.go +++ b/internal/ajean/backend_presets.go @@ -201,12 +201,37 @@ func safePresetPath(name string) (string, error) { // paramètres de modèle) qui doivent survivre à un changement de preset. Sans ça, // écraser config.env avec le preset ré-imposerait le mode mémoire et effacerait // l'URL du serveur internet à chaque bascule — ce qui obligeait à tout remettre. -var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL"} +var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES"} -// SwitchToPreset backs up the current config and copies the target into place, -// then restarts the service. Les réglages « appareil » (preservedKeys) sont -// conservés à travers la bascule. -func SwitchToPreset(target string) error { +// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit +// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un +// choix de machine (`ajean gpu`) qui doit survivre aux bascules, MAIS un preset +// a le droit de l'imposer — « QWEN 3.6 27B FABLE 2 GPU » réclame `1,0` pour que +// son `--tensor-split 0.965,0.035` ait bien deux cartes en face. Le préserver +// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le +// modèle se retrouvait entièrement sur une seule carte et mourait sur +// « cudaMalloc failed: out of memory ». Le preset explicite gagne. +var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true} + +// presetDefines dit si le contenu d'un preset affecte explicitement la clé. +func presetDefines(content []byte, key string) bool { + for _, line := range strings.Split(string(content), "\n") { + t := strings.TrimSpace(line) + if t == "" || strings.HasPrefix(t, "#") { + continue + } + t = strings.TrimPrefix(t, "export ") + if eq := strings.IndexByte(t, '='); eq >= 0 && strings.TrimSpace(t[:eq]) == key { + return true + } + } + return false +} + +// applyPresetFile copie le preset dans config.env en réinjectant les réglages +// « appareil » par-dessus. Séparé de SwitchToPreset pour être testable sans +// redémarrer le service (donc sans lancer un llama-server pour de vrai). +func applyPresetFile(target string) error { src, err := os.ReadFile(target) if err != nil { return err @@ -216,11 +241,27 @@ func SwitchToPreset(target string) error { if err := os.WriteFile(confPath(), src, 0o644); err != nil { return err } - // Ré-applique les réglages appareil par-dessus le preset fraîchement copié. + // 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 { - if v, ok := cur[k]; ok { - _ = SetConfigKey(k, v) + v, ok := cur[k] + if !ok { + continue } + if softPreservedKeys[k] && presetDefines(src, k) { + continue // le preset a son mot à dire sur cette clé : on le laisse gagner + } + _ = SetConfigKey(k, v) + } + return nil +} + +// SwitchToPreset backs up the current config and copies the target into place, +// then restarts the service. Les réglages « appareil » (preservedKeys) sont +// conservés à travers la bascule. +func SwitchToPreset(target string) error { + if err := applyPresetFile(target); err != nil { + return err } fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target)) fmt.Println(dim("[info] redémarrage du service...")) diff --git a/internal/ajean/backend_presets_test.go b/internal/ajean/backend_presets_test.go index 1d896e9..b002666 100644 --- a/internal/ajean/backend_presets_test.go +++ b/internal/ajean/backend_presets_test.go @@ -1,6 +1,10 @@ package ajean -import "testing" +import ( + "os" + "path/filepath" + "testing" +) // TestPresetFingerprintIgnoresCosmetics vérifie que la détection du preset actif // ne dépend QUE de la config effective (ensemble KEY=VALUE), pas de la mise en @@ -21,3 +25,74 @@ func TestPresetFingerprintIgnoresCosmetics(t *testing.T) { t.Fatal("empreintes identiques alors que CTX diffère") } } + +// TestSwitchToPresetGardeLesReglagesMachine : basculer de preset ne doit pas +// effacer les réglages qui décrivent la MACHINE et non le modèle. Le cas vécu : +// `ajean gpu 1` écrit CUDA_VISIBLE_DEVICES dans config.env, la bascule de preset +// suivante l'écrasait, llama.cpp revoyait les deux cartes et réétalait le modèle +// sur la petite — pendant que `ajean gpu` réaffichait « auto ». +func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) { + home := t.TempDir() + t.Setenv("AJEAN_HOME", home) + write := func(p, body string) { + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + // config.env courante : un preset + les réglages machine. + write(confPath(), "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n") + target := filepath.Join(home, "cible.env") + write(target, "MODEL=nouveau.gguf\nCTX=8192\n") + + if err := applyPresetFile(target); err != nil { + t.Fatal(err) + } + + cfg := ReadConfig() + if cfg["MODEL"] != "nouveau.gguf" || cfg["CTX"] != "8192" { + t.Fatalf("le preset n'a pas été appliqué : %v", cfg) + } + for k, want := range map[string]string{ + "CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always", + } { + if cfg[k] != want { + t.Errorf("%s = %q après bascule, attendu %q (réglage machine effacé)", k, cfg[k], want) + } + } +} + +// Un preset qui définit LUI-MÊME la sélection de cartes doit gagner sur celle +// de la machine : « FABLE 2 GPU » impose CUDA_VISIBLE_DEVICES=1,0 pour que son +// --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait +// mourir le chargement sur « cudaMalloc failed: out of memory ». +func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) { + home := t.TempDir() + t.Setenv("AJEAN_HOME", home) + write := func(p, body string) { + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + write(confPath(), "MODEL=ancien.gguf\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\n") + deuxGPU := filepath.Join(home, "deux-gpu.env") + write(deuxGPU, "MODEL=fable.gguf\nCUDA_VISIBLE_DEVICES=1,0\n") + if err := applyPresetFile(deuxGPU); err != nil { + t.Fatal(err) + } + if got := ReadConfig()["CUDA_VISIBLE_DEVICES"]; got != "1,0" { + t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu 1,0 (le preset doit gagner)", got) + } + // Un preset MUET sur les GPU laisse, lui, la sélection machine en place. + muet := filepath.Join(home, "muet.env") + write(muet, "MODEL=autre.gguf\n") + if err := applyPresetFile(muet); err != nil { + t.Fatal(err) + } + cfg := ReadConfig() + if cfg["CUDA_VISIBLE_DEVICES"] != "1,0" { + t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu 1,0 conservé", cfg["CUDA_VISIBLE_DEVICES"]) + } + if cfg["WEB_ENGINE"] != "go" { + t.Errorf("WEB_ENGINE = %q, attendu go (réglage machine, jamais dicté par un preset)", cfg["WEB_ENGINE"]) + } +} diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go index 096b3c5..674684f 100644 --- a/internal/ajean/backend_serve.go +++ b/internal/ajean/backend_serve.go @@ -36,6 +36,10 @@ func cmdServe(args []string) error { if !filepath.IsAbs(bin) { bin = filepath.Join(AjeanHome(), bin) } + // Le moteur précompilé s'installe dans un dossier versionné : un preset écrit + // avant une mise à jour pointe sur une release qui n'existe plus. On le fait + // suivre au moteur courant plutôt que d'échouer en 127. + bin = prebuiltResolveBin(bin) // 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 diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 6ccfe65..8ab71f4 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.7.8" +const Version = "0.7.9" // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). func Main() { diff --git a/internal/ajean/sys_platform_unix.go b/internal/ajean/sys_platform_unix.go index a2d4ab2..69155a5 100644 --- a/internal/ajean/sys_platform_unix.go +++ b/internal/ajean/sys_platform_unix.go @@ -117,6 +117,20 @@ func totalRAMGB() float64 { // llama-server meurt instantanément sur « Library not loaded », donc sans le // moindre message dans l'UI. func setLibraryPath(dir string) { + envVar, joined := libraryPathValue(dir) + _ = os.Setenv(envVar, joined) + if runtime.GOOS == "darwin" { + // Filet de sécurité : DYLD_FALLBACK_LIBRARY_PATH est consulté en dernier + // recours et survit à certains contextes où DYLD_LIBRARY_PATH est purgé. + _ = os.Setenv("DYLD_FALLBACK_LIBRARY_PATH", joined+":/usr/local/lib:/usr/lib") + } +} + +// libraryPathValue calcule (nom de variable, valeur) sans toucher à +// l'environnement du process : utilisé pour lancer un llama-server ÉPHÉMÈRE +// (ex. `--list-devices` depuis l'UI) sans polluer — ni faire grossir à chaque +// appel — le LD_LIBRARY_PATH du serveur web lui-même. +func libraryPathValue(dir string) (string, string) { parts := []string{dir} parts = append(parts, cudaLibDirs()...) envVar := "LD_LIBRARY_PATH" @@ -126,13 +140,14 @@ func setLibraryPath(dir string) { if existing := os.Getenv(envVar); existing != "" { parts = append(parts, existing) } - joined := strings.Join(parts, ":") - _ = os.Setenv(envVar, joined) - if runtime.GOOS == "darwin" { - // Filet de sécurité : DYLD_FALLBACK_LIBRARY_PATH est consulté en dernier - // recours et survit à certains contextes où DYLD_LIBRARY_PATH est purgé. - _ = os.Setenv("DYLD_FALLBACK_LIBRARY_PATH", joined+":/usr/local/lib:/usr/lib") - } + return envVar, strings.Join(parts, ":") +} + +// libraryPathEnv renvoie un environnement complet (os.Environ + la variable de +// recherche de bibliothèques) pour une commande ponctuelle. +func libraryPathEnv(dir string) []string { + k, v := libraryPathValue(dir) + return append(os.Environ(), k+"="+v) } // cudaLibDirs returns the CUDA runtime lib directories present on the machine, diff --git a/internal/ajean/sys_platform_windows.go b/internal/ajean/sys_platform_windows.go index bb3fff3..c9ac75f 100644 --- a/internal/ajean/sys_platform_windows.go +++ b/internal/ajean/sys_platform_windows.go @@ -129,6 +129,23 @@ func setLibraryPath(dir string) { _ = os.Setenv("PATH", strings.Join(parts, string(os.PathListSeparator))) } +// libraryPathEnv : même chose pour une commande ponctuelle (ex. --list-devices +// depuis l'UI), sans modifier le PATH du process web. +func libraryPathEnv(dir string) []string { + parts := []string{dir} + if nvcc := findNvcc(); nvcc != "" { + binDir := filepath.Dir(nvcc) + parts = append(parts, binDir) + if x64 := filepath.Join(binDir, "x64"); isDir(x64) { + parts = append(parts, x64) + } + } + if existing := os.Getenv("PATH"); existing != "" { + parts = append(parts, existing) + } + return append(os.Environ(), "PATH="+strings.Join(parts, string(os.PathListSeparator))) +} + // execServer runs llama-server as a child process and waits for it. Windows has // no exec() that replaces the current image, so `ajean serve` stays alive as the // parent (this is the detached process the service supervisor tracks). diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index ab79818..1940a36 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -93,11 +93,16 @@ option,optgroup{background:var(--panel);color:var(--text)} .main{flex:1;display:flex;flex-direction:column;min-width:0} #menubtn{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(10px + env(safe-area-inset-left));z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:6px;font-size:18px;cursor:pointer} #backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10} +/* Pastille « installation en cours » : suit exactement les règles d'affichage + du bouton ☰ (elle ne sert que quand le panneau est en tiroir, donc caché). */ +#lc-chip{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(58px + env(safe-area-inset-left));z-index:20;max-width:60vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--panel);border:1px solid var(--accent);color:var(--accent);height:40px;padding:0 12px;border-radius:6px;font-size:12px;cursor:pointer} +#lc-chip.failed{border-color:var(--err);color:var(--err)} @media (max-width:720px){ .side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left))} .side.open{transform:translateX(0)} #menubtn,#backdrop.open{display:block} - body.drawer-open #menubtn{display:none} + #lc-chip:not([hidden]){display:block} + body.drawer-open #menubtn,body.drawer-open #lc-chip{display:none} .main{width:100%} #chat{padding:60px 8px 12px} .msg{max-width:92%} @@ -460,8 +465,9 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} @media (min-width:721px){html[data-hide-side="1"] #chat{padding-top:60px}} [data-hide-side="1"] .side.open{transform:translateX(0)} [data-hide-side="1"] #menubtn{display:block} +[data-hide-side="1"] #lc-chip:not([hidden]){display:block} [data-hide-side="1"] #backdrop.open{display:block} -[data-hide-side="1"] body.drawer-open #menubtn{display:none} +[data-hide-side="1"] body.drawer-open #menubtn,[data-hide-side="1"] body.drawer-open #lc-chip{display:none} [data-hide-side="1"] .main{width:100%} /* Hauteur animée en JS (mesure scrollHeight → 0) plutôt que grid 1fr→0fr qui peut « dépasser ». overflow:hidden clippe le contenu pendant tout le repli. @@ -529,6 +535,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} h1{font-size:15px} h2,details>summary{font-size:13px} #menubtn{width:32px;height:32px;font-size:15px;top:calc(6px + env(safe-area-inset-top))} + #lc-chip{height:32px;font-size:11px;left:calc(46px + env(safe-area-inset-left));top:calc(6px + env(safe-area-inset-top))} } /* --- Backend llama.cpp (2 modes : rapide / optimisée) --------------------- */ .lc-head{font-size:12px;line-height:1.5;margin-bottom:10px} @@ -564,7 +571,12 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} .pe-list{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden} /* Ligne : libellé à gauche, contrôle à droite, séparateur fin entre lignes. */ .pe-row{display:flex;align-items:center;gap:14px;min-height:48px;padding:9px 15px} -.pe-row:not(:last-child){border-bottom:1px solid var(--border)} +/* Séparateur porté par la ligne SUIVANTE (border-top), pas par la précédente + (border-bottom + :not(:last-child)) : `:last-child` compte aussi les lignes + masquées, donc une ligne repliée gardait sa bordure basse, qui doublait + celle de la carte. Avec border-top, la bordure est portée par l'élément + caché lui-même — invisible tant qu'il l'est. */ +.pe-row + .pe-row{border-top:1px solid var(--border)} .pe-row-l{font-size:14px;color:var(--text);flex:1;min-width:0;line-height:1.3} .pe-row-l .pe-sub{display:block;font-size:11px;color:var(--dim);margin-top:2px} .pe-row-c{flex:none;display:flex;align-items:center;gap:6px;max-width:62%;justify-content:flex-end} @@ -586,6 +598,31 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} .pe-selc.wide::after{right:2px} /* Ligne « champ pleine largeur » (Nom, chemin, config brute…). */ .pe-row.stack{flex-direction:column;align-items:stretch;gap:8px} +/* Répartition entre cartes graphiques : UNE barre, sur laquelle on glisse + directement. Le est posé par-dessus, piste transparente : + sinon on voyait deux barres superposées (la barre de proportion + la piste + blanche native du navigateur). Deux teintes du MÊME accent, pas deux + couleurs — la barre exprime une proportion, pas deux catégories. */ +.gpu-split{position:relative;display:flex;align-items:center;height:22px} +.gpu-bar{display:flex;width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)} +.gpu-bar>span{background:var(--accent);flex:none;min-width:0;transition:width .12s} +.gpu-bar>span.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)} +.gpu-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize} +.gpu-range:focus{outline:none} +.gpu-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none} +.gpu-range::-moz-range-track{height:22px;background:transparent;border:none} +.gpu-range::-moz-range-progress{background:transparent} +.gpu-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.gpu-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.gpu-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)} +.gpu-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)} +.gpu-legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:11.5px;color:var(--dim)} +.gpu-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:5px} +.gpu-legend i.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)} +.gpu-shares{display:flex;flex-wrap:wrap;gap:10px} +.gpu-share{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--dim)} +.gpu-share input{width:62px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:7px;padding:5px 7px;font:inherit;text-align:right} +.gpu-share input:focus{outline:none;border-color:var(--accent)} .pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:9px 11px;font:inherit;transition:border-color .12s,box-shadow .12s} .pe-inp:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)} .pe-row.stack.plain .pe-inp{border:none;background:transparent;padding:6px 0;font-size:15px} @@ -663,7 +700,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} .modal-card{background:var(--bg);border:1px solid var(--border);border-radius:12px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.18)} .modal-head{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center} .modal-head strong{font-size:14px} -.modal-x{margin:0;padding:3px 10px;font-size:16px;line-height:1;color:var(--dim)} +/* Croix de fermeture : un CARRÉ, pas un rectangle. Avec un padding asymétrique + (3px vertical / 10px horizontal) le glyphe paraissait écrasé et la cible de + clic était basse. 32px, glyphe centré, taille de cible correcte au doigt. */ +.modal-x{margin:0;padding:0;width:32px;height:32px;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:19px;line-height:1;color:var(--dim)} .modal-body{padding:16px;overflow:auto;flex:1} .modal-foot{padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap} .foot-l{display:flex;align-items:center;gap:10px;flex-wrap:wrap} @@ -855,6 +895,10 @@ button:hover{border-color:var(--dim);color:var(--text)} + +
@@ -1290,8 +1334,22 @@ button:hover{border-color:var(--dim);color:var(--text)}
+ + + + - -
- Réglages avancés -
-
-
BATCH
-
UBATCH
-
Threads (batch)0 = auto
-
Experts MoE sur CPUvide = non
-
Charger tout en mémoiredésactive le mmap
-
+
+ Charger tout en mémoiredésactive le mmap +
-
+
Configuration
- Éditer le fichier .envconfiguration brute — pour les réglages avancés + Éditer le fichier de configurationtexte brut
+ + +
+ - -
- Réglages avancés -
-
-
BATCH
-
UBATCH
-
Threads (batch)0 = auto
-
Experts MoE sur CPUvide = non
-
Charger tout en mémoiredésactive le mmap
-
+
+ Charger tout en mémoiredésactive le mmap +
-
+
Configuration
- Éditer le fichier .envconfiguration brute — pour les réglages avancés + Éditer le fichier de configurationtexte brut