v0.7.9 : les moteurs llama.cpp arretent de jouer a cache-cache

- moteur precompile : la version installee est celle qui tourne (on elisait le
  dossier le plus ancien), et les anciennes extractions sont purgees
- presets : le moteur precompile reste reconnu apres une mise a jour, et un BIN
  pointant une release disparue suit la version installee au lieu d'echouer
- build CUDA : le toolkit est cherche avant le nvcc du PATH, sa racine est
  passee a CMake (fin du "CUDA Toolkit not found" avec CUDA installe)
- job d'installation : etat persiste sur disque, une operation interrompue par
  un redemarrage du service est annoncee, pastille de rappel sur mobile
- editeur de modele : section Cartes graphiques (--device / --tensor-split), la
  liste vient du moteur du preset car les identifiants dependent du backend
- reglages : plus de menu replie, interrupteurs regroupes, decodage speculatif
- CUDA_VISIBLE_DEVICES et WEB_ENGINE survivent au changement de preset, sauf si
  le preset les definit lui-meme
This commit is contained in:
nathaninline committed 2026-08-05 16:51:10 +02:00
1 parent 726f065d25
commit e57fc0966e
24 files changed
+1754 -94

No files matched your search

+30 -17
View File
@@ -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.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -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": {
+52
View File
@@ -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(" <toolkit>\\extras\\visual_studio_integration\\MSBuildExtensions vers\n")
fmt.Printf(" <VS>\\MSBuild\\Microsoft\\VC\\<version>\\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 <root>/bin/nvcc à <root>. 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
+50
View File
@@ -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 <root>/bin/nvcc → <root>, 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)
}
}
}
+93 -8
View File
@@ -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
}
+95
View File
@@ -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")
}
}
+49 -8
View File
@@ -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..."))
+76 -1
View File
@@ -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"])
}
}
+4
View File
@@ -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
+1 -1
View File
@@ -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() {
+22 -7
View File
@@ -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,
+17
View File
@@ -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).
+423 -23
View File
@@ -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 <input type=range> 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)}
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
fermé sur téléphone. Sans ce rappel, un rechargement de page donnait
l'impression que l'installation avait disparu. -->
<button id="lc-chip" onclick="lcChipOpen()" hidden></button>
<div id="backdrop" onclick="toggleSide()"></div>
<div class="side" id="side">
<div class="sidehead">
@@ -1290,8 +1334,22 @@ button:hover{border-color:var(--dim);color:var(--text)}
</div>
</div>
</div>
<!-- Cartes graphiques : la liste vient du moteur choisi juste au-dessus,
car les noms ET l'ordre dépendent du backend compilé dedans. Le
groupe reste masqué tant qu'on n'a pas AU MOINS DEUX cartes : avec
une seule, il n'y a rien à arbitrer. -->
<div class="pe-group" id="m-gpu-group" hidden>
<div class="pe-gh">Cartes graphiques</div>
<div class="pe-list" id="m-gpu-list"></div>
<div class="pe-note" id="m-gpu-note"></div>
</div>
</div>
<!-- Une seule liste : les anciens « réglages avancés » repliés sont
désormais à la suite. Les valeurs d'abord, puis TOUS les interrupteurs
groupés en fin de liste — chercher un toggle perdu entre deux champs
numériques n'aidait personne. -->
<div id="m-settings-row" class="pe-group" style="display:none">
<div class="pe-gh">Réglages</div>
<div class="pe-list">
@@ -1303,10 +1361,26 @@ button:hover{border-color:var(--dim);color:var(--text)}
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 = tout sur le GPU</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="number" min="0" step="1" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non</span></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads CPU<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-threads" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads (batch)<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">BATCH</span>
<span class="pe-row-c"><input id="s-batch" class="pe-val" type="number" min="0" step="256" placeholder="2048" oninput="cfgWriteKey('BATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">UBATCH</span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Cache KV<span class="pe-sub">compression du contexte</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-kv" onchange="cfgWriteKey('KV_TYPE', this.value)">
@@ -1317,6 +1391,34 @@ button:hover{border-color:var(--dim);color:var(--text)}
<option value="bf16">bf16</option>
</select></span></span>
</div>
<!-- Décodage spéculatif : le type vient de la liste que llama-server
accepte pour --spec-type. Le nombre de jetons anticipés
(--spec-draft-n-max) n'a de sens qu'une fois un type choisi, donc
sa ligne n'apparaît qu'à ce moment. -->
<div class="pe-row">
<span class="pe-row-l">Décodage spéculatif<span class="pe-sub">anticipe des jetons pour accélérer</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
<option value="">non</option>
<optgroup label="Modèle brouillon">
<option value="draft-mtp">MTP (intégré au modèle)</option>
<option value="draft-eagle3">EAGLE-3</option>
<option value="draft-simple">modèle brouillon séparé</option>
<option value="draft-dflash">dFlash</option>
<option value="draft-dspark">dSpark</option>
</optgroup>
<optgroup label="N-grammes (sans modèle brouillon)">
<option value="ngram-simple">n-grammes</option>
<option value="ngram-mod">n-grammes (mod)</option>
<option value="ngram-cache">n-grammes (cache)</option>
<option value="ngram-map-k">n-grammes (map-k)</option>
<option value="ngram-map-k4v">n-grammes (map-k4v)</option>
</optgroup>
</select></span></span>
</div>
<div class="pe-row" id="s-spec-n-row" style="display:none">
<span class="pe-row-l">Jetons anticipés<span class="pe-sub">par étape — défaut 3</span></span>
<span class="pe-row-c"><input id="s-spec-n" class="pe-val" type="number" min="1" step="1" placeholder="3" oninput="eaSetValued('--spec-draft-n-max', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Raisonnement<span class="pe-sub">réflexion étape par étape</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
@@ -1329,27 +1431,18 @@ button:hover{border-color:var(--dim);color:var(--text)}
<span class="pe-row-l">Garder en RAM<span class="pe-sub">évite le swap (mlock)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mlock" onchange="eaToggleFlag('--mlock', this.checked)"><span class="pe-sl"></span></label></span>
</div>
</div>
<details class="pe-adv" style="margin-top:8px">
<summary>Réglages avancés</summary>
<div class="pe-adv-body" style="padding:0">
<div class="pe-list" style="border:none;border-radius:0">
<div class="pe-row"><span class="pe-row-l">BATCH</span><span class="pe-row-c"><input id="s-batch" class="pe-val" type="number" min="0" step="256" placeholder="2048" oninput="cfgWriteKey('BATCH', this.value)"></span></div>
<div class="pe-row"><span class="pe-row-l">UBATCH</span><span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span></div>
<div class="pe-row"><span class="pe-row-l">Threads (batch)<span class="pe-sub">0 = auto</span></span><span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span></div>
<div class="pe-row"><span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non</span></span><span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span></div>
<div class="pe-row"><span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span><span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span></div>
</div>
<div class="pe-row">
<span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span>
</div>
</details>
</div>
</div>
<div id="m-raw" class="pe-group">
<div class="pe-gh" id="m-raw-head">Configuration</div>
<div class="pe-list">
<div id="m-raw-toggle" class="pe-row" style="cursor:pointer" onclick="toggleRaw()">
<span class="pe-row-l">Éditer le fichier <code>.env</code><span class="pe-sub">configuration brute — pour les réglages avancés</span></span>
<span class="pe-row-l">Éditer le fichier de configuration<span class="pe-sub">texte brut</span></span>
<span class="pe-row-c"><span id="m-raw-caret" class="pe-caret"></span></span>
</div>
<div id="m-raw-body" class="pe-row stack" style="display:none">
@@ -2292,11 +2385,20 @@ function setBinInTextarea(val){
if(/^\s*BIN\s*=.*$/m.test(ta.value)) ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"');
else ta.value = 'BIN="'+val+'"\n' + ta.value;
}
let beFastPath = '', beOptPath = '';
// Le moteur précompilé s'installe dans un dossier versionné (…/llama-b10280/) :
// le BIN d'un preset écrit avant une mise à jour ne vaut plus le chemin courant,
// alors qu'il désigne bien ce moteur. On reconnaît donc l'appartenance au
// dossier, sinon tous les presets retombaient en « personnalisé » à chaque MAJ.
function underDir(p, dir){
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
return !!p && !!dir && n(p).startsWith(n(dir)+'/');
}
let beFastPath = '', beOptPath = '', beFastDir = '';
async function populateBackend(){
// Chemins des deux moteurs gérés + liste des backends détectés (dossier jean).
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
beOptPath = lc.bin || '';
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
// le dossier backends de jean (l'utilisateur peut y déposer son propre build).
@@ -2310,12 +2412,13 @@ async function populateBackend(){
// Sélectionne l'option correspondant au BIN actuel du preset.
const cur = currentBinInTextarea();
let mode = 'custom';
if(sameBinPath(cur, beFastPath)) mode = 'fast';
if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
const radio = document.querySelector('input[name=m-be][value='+mode+']');
if(radio) radio.checked = true;
toggleBackendCustom(mode);
if(mode === 'custom') document.getElementById('m-backend-path').value = cur;
loadGpuDevices();
}
function toggleBackendCustom(mode){
document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none';
@@ -2325,22 +2428,231 @@ function onBackendMode(mode){
if(mode === 'fast'){
if(!beFastPath){ toast('installez d\'abord llama.cpp précompilé (section MOTEUR)'); return; }
setBinInTextarea(beFastPath); toast('moteur : llama.cpp précompilé');
loadGpuDevices();
} else if(mode === 'opt'){
if(!beOptPath){ toast('installez d\'abord llama.cpp compilé (section MOTEUR)'); return; }
setBinInTextarea(beOptPath); toast('moteur : llama.cpp compilé');
loadGpuDevices();
}
// custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend
}
function onCustomPath(){
const v = document.getElementById('m-backend-path').value.trim();
if(v) setBinInTextarea(v);
if(v){ setBinInTextarea(v); loadGpuDevices(); }
}
function onPickDetected(){
const v = document.getElementById('m-backend-detected').value;
if(!v) return;
document.getElementById('m-backend-path').value = v;
setBinInTextarea(v); toast('moteur personnalisé');
loadGpuDevices();
}
// --- Cartes graphiques (--device / --tensor-split) --------------------------
// Ces réglages vivent dans le PRESET, pas dans une variable d'environnement :
// --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que
// CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles
// qui s'étalaient sur les deux cartes malgré une sélection « GPU 1 ».
// La lecture/écriture des flags passe par eaGetValued/eaSetValued (le tokenizer
// d'EXTRA_ARGS déjà utilisé par les autres réglages) : un seul parseur.
// Diamètre de la pastille du curseur, en pixels — DOIT rester synchronisé avec
// .gpu-range::-webkit-slider-thumb / ::-moz-range-thumb dans styles.css.
const GPU_THUMB = 16;
let gpuDevices = [];
const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture
// Interroge le moteur du preset (--list-devices) : les noms de device et leur
// ordre lui appartiennent, une liste issue de nvidia-smi désignerait la mauvaise
// carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la réponse n'est
// pas là, et le reste s'il y a moins de deux cartes : rien à arbitrer, et ça
// évite un encart qui surgit après coup.
// Remplit le cache sans rien peindre (appelé au chargement de la page pour le
// moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt.
async function prefetchGpuDevices(bin){
if(!bin || gpuCache[bin]) return;
try{
const r = await jpost('/api/backends/devices', {bin});
if(r && r.ok) gpuCache[bin] = r.devices || [];
}catch(_){}
}
async function loadGpuDevices(){
const group = document.getElementById('m-gpu-group');
const bin = currentBinInTextarea();
if(!bin){ group.hidden = true; gpuDevices = []; return; }
if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; }
let r = {};
try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; }
// Le moteur a pu changer pendant l'appel (clic rapide) : on ne peint que si la
// réponse concerne toujours le moteur affiché.
if(currentBinInTextarea() !== bin) return;
gpuDevices = (r.ok && r.devices) ? r.devices : [];
if(r.ok) gpuCache[bin] = gpuDevices;
renderGpu();
}
function selectedGpuIds(){
const sel = eaGetValued('--device').split(',').filter(Boolean);
const known = gpuDevices.map(d => d.id);
const kept = sel.filter(id => known.includes(id));
return kept.length ? kept : known; // aucune contrainte = toutes les cartes
}
function gpuEsc(t){
return String(t).replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
}
function renderGpu(){
const group = document.getElementById('m-gpu-group');
// Une seule carte (ou aucune) : rien à répartir, rien à choisir.
if(gpuDevices.length < 2){ group.hidden = true; return; }
const sel = selectedGpuIds();
let html = gpuDevices.map(d => {
// Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le
// modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge.
const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : '';
return '<div class="pe-row">'
+ '<span class="pe-row-l">' + gpuEsc(gpuLabel(d))
+ '<span class="pe-sub">' + gpuEsc(d.id) + gb + '</span></span>'
+ '<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" value="' + gpuEsc(d.id) + '"'
+ (sel.includes(d.id) ? ' checked' : '') + ' onchange="onGpuPick()"><span class="pe-sl"></span></label></span>'
+ '</div>';
}).join('');
if(sel.length > 1) html += splitHtml(sel);
document.getElementById('m-gpu-list').innerHTML = html;
document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length
? 'Toutes les cartes sont utilisées.'
: 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.';
group.hidden = false;
if(sel.length > 1) paintSplit();
}
function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; }
// Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1
// ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui
// veulent savoir ce qui part dans --device.
function gpuLabel(d){
return String((d && d.name) || (d && d.id) || '')
.replace(/^NVIDIA\s+GeForce\s+/i, '')
.replace(/^NVIDIA\s+/i, '')
.replace(/^AMD\s+(Radeon\s+)?/i, '')
.replace(/^Intel\(R\)\s+/i, '')
.trim() || (d && d.id) || '';
}
// Répartition : UNE seule barre, sur laquelle on glisse directement (le curseur
// est transparent, posé par-dessus). Trois cartes ou plus : un pourcentage par
// carte. On n'écrit --tensor-split QUE si l'utilisateur y touche : sans lui,
// llama.cpp répartit tout seul.
function splitHtml(sel){
const shares = currentShares(sel);
let inner;
if(sel.length === 2){
inner = '<div class="gpu-split">'
+ '<div class="gpu-bar" id="m-split-bar"></div>'
+ '<input type="range" class="gpu-range" id="m-split-range" min="0" max="100" step="0.5"'
+ ' value="' + (shares[0]*100).toFixed(1) + '" oninput="onSplitRange()"'
+ ' aria-label="part de ' + gpuEsc(gpuLabel(gpuById(sel[0]))) + '"></div>';
} else {
inner = '<div class="gpu-bar" id="m-split-bar"></div>'
+ '<div class="gpu-shares">' + sel.map((id, i) =>
'<label class="gpu-share"><span>' + gpuEsc(gpuLabel(gpuById(id))) + '</span>'
+ '<input type="number" min="0" max="100" step="0.5" value="' + (shares[i]*100).toFixed(1) + '"'
+ ' oninput="onSplitNumbers()"><span class="pe-unit">%</span></label>').join('') + '</div>';
}
return '<div class="pe-row">'
+ '<span class="pe-row-l">Répartition<span class="pe-sub">part des couches par carte</span></span>'
+ '<span class="pe-row-c" id="m-split-state"></span></div>'
+ '<div class="pe-row stack">' + inner
+ '<div class="gpu-legend" id="m-split-legend"></div></div>';
}
// Parts actuelles, normalisées à 1. Sans --tensor-split : proportionnelles à la
// VRAM de chaque carte, ce que llama.cpp fait de son côté — la barre montre donc
// ce qui se passe réellement plutôt qu'un partage égal trompeur.
function currentShares(sel){
const raw = eaGetValued('--tensor-split').split(',').map(parseFloat).filter(n => !isNaN(n));
let vals = (raw.length === sel.length) ? raw
: sel.map(id => gpuById(id).total_mib || 0);
// Mémoires inconnues (lecture à 0) : à défaut de proportion crédible, on
// montre un partage égal plutôt qu'une barre pleine sur une seule carte.
if(vals.every(v => !v)) vals = sel.map(() => 1);
const sum = vals.reduce((a, b) => a + b, 0) || 1;
return vals.map(v => v/sum);
}
// Repeint UNIQUEMENT la barre, la légende et l'état. Surtout PAS le bloc entier :
// remplacer le HTML pendant un glissement détruit le curseur en cours d'usage —
// c'est ce qui faisait qu'il ne répondait qu'au clic.
function paintSplit(){
const sel = selectedGpuIds();
if(sel.length < 2) return;
const shares = currentShares(sel);
const bar = document.getElementById('m-split-bar');
// La pastille du curseur ne parcourt PAS toute la largeur : elle va de
// GPU_THUMB/2 à largeur - GPU_THUMB/2, sinon elle déborderait de la piste. Une
// coupure à « s % » pile tombait donc à côté d'elle (jusqu'à ~5 px d'écart aux
// extrêmes), ce qui laissait voir la piste claire juste à droite de la
// pastille. On coupe dans le repère de la pastille : s*(largeur - THUMB) + THUMB/2.
// Le dernier segment prend le reste (flex:1) : aucun filet résiduel à droite,
// quels que soient les arrondis.
const withThumb = sel.length === 2 && !!document.getElementById('m-split-range');
if(bar) bar.innerHTML = shares.map((s, i) => {
const cls = i%2 ? 'alt' : '';
if(i === shares.length - 1) return '<span class="' + cls + '" style="flex:1"></span>';
const w = withThumb
? 'calc(' + (s*100).toFixed(3) + '% - ' + ((s - 0.5) * GPU_THUMB).toFixed(2) + 'px)'
: (s*100).toFixed(3) + '%';
return '<span class="' + cls + '" style="width:' + w + '"></span>';
}).join('');
const leg = document.getElementById('m-split-legend');
if(leg) leg.innerHTML = sel.map((id, i) =>
'<span><i class="' + (i%2 ? 'alt' : '') + '"></i>' + gpuEsc(gpuLabel(gpuById(id)))
+ ' ' + (shares[i]*100).toFixed(1).replace('.', ',') + ' %</span>').join('');
const st = document.getElementById('m-split-state');
if(st) st.innerHTML = eaGetValued('--tensor-split')
? '<button class="pe-link" onclick="resetSplit()">réinitialiser</button>'
: '<span class="pe-unit">automatique</span>';
}
function writeSplit(shares){
const sum = shares.reduce((a, b) => a + b, 0) || 1;
eaSetValued('--tensor-split', shares.map(s => (s/sum).toFixed(3)).join(','));
paintSplit();
}
function onSplitRange(){
const v = parseFloat(document.getElementById('m-split-range').value) / 100;
writeSplit([v, 1-v]);
}
function onSplitNumbers(){
const vals = [...document.querySelectorAll('.gpu-share input')].map(i => parseFloat(i.value) || 0);
if(vals.reduce((a, b) => a + b, 0) <= 0) return; // saisie en cours
writeSplit(vals);
}
function resetSplit(){
eaSetValued('--tensor-split', '');
const sel = selectedGpuIds();
const r = document.getElementById('m-split-range');
if(r && sel.length === 2) r.value = (currentShares(sel)[0]*100).toFixed(1);
const nums = [...document.querySelectorAll('.gpu-share input')];
if(nums.length){
const sh = currentShares(sel);
nums.forEach((inp, i) => { inp.value = (sh[i]*100).toFixed(1); });
}
paintSplit();
}
function onGpuPick(){
const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value);
if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; }
// Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer
// des noms de device qui changeraient avec le moteur.
eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(','));
// Une répartition écrite pour N cartes n'a plus de sens pour N-1.
if(ids.length < 2) eaSetValued('--tensor-split', '');
renderGpu();
}
// Read/write the QUANT= override line in the preset textarea.
function currentQuantInTextarea(){
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=\s*"?([^"\n]*)"?\s*$/mi);
@@ -2409,12 +2721,52 @@ function populateSettings(){
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
set('s-moe', eaGetValued('--n-cpu-moe'));
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
setSpecType(eaGetValued('--spec-type'));
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING')));
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mlock', eaHasFlag('--mlock'));
chk('s-nommap', eaHasFlag('--no-mmap'));
}
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de jean) ne correspondrait à aucune option et serait silencieusement effacée
// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de
// la perdre.
function setSpecType(v){
const sel = document.getElementById('s-spec');
if(!sel) return;
v = String(v || '').trim();
if(v && ![...sel.options].some(o => o.value === v)){
const opt = document.createElement('option');
opt.value = v; opt.textContent = v + ' (dans la configuration)';
sel.appendChild(opt);
}
sel.value = v;
syncSpecRow();
}
// Le nombre de jetons anticipés ne veut rien dire sans type : ligne masquée, et
// flag retiré pour ne pas laisser un réglage orphelin dans le preset.
function syncSpecRow(){
const sel = document.getElementById('s-spec');
const row = document.getElementById('s-spec-n-row');
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
eaSetValued('--spec-type', v);
if(!v){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}
syncSpecRow();
}
// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset.
function toggleRaw(){
const b = document.getElementById('m-raw-body');
@@ -3177,6 +3529,52 @@ async function loadLlamacpp(){
document.getElementById('lc-details').open = true;
lcStartPolling();
}
// Job terminé/interrompu qu'on n'a pas encore montré (rechargement APRÈS coup,
// typiquement quand le service a redémarré) : on l'affiche sans polling.
else if(s.job && s.job.exists && !s.job.running && !lcPoll && s.job.error && !lcEndShown){
document.getElementById('lc-details').open = true;
document.getElementById('lc-job').style.display = '';
lcLogNext = 0;
document.getElementById('lc-log').textContent = '';
lcEndShown = true;
lcPollJob(true); // quiet : simple rattrapage, pas de toast ni de rechargement
}
lcChipSync(s.job);
// Préchauffe la liste des cartes du moteur actif : interroger le moteur prend
// 1 à 3 s (init CUDA/Vulkan), et sans ça l'encart « cartes graphiques » de
// l'éditeur apparaissait après coup, une fois le reste déjà affiché.
if(typeof prefetchGpuDevices === 'function') prefetchGpuDevices(s.config_bin);
}
// --- Pastille de rappel hors panneau ---------------------------------------
// Le détail de l'installation vit dans le panneau latéral, qui est un tiroir
// FERMÉ sur téléphone : après un rechargement, rien ne disait qu'une
// compilation tournait encore. La pastille le dit, et y ramène en un clic.
let lcSeenEnd = false, lcEndShown = false;
function lcChipLabel(action){
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
prebuilt:'Téléchargement du moteur', custom:'Installation du backend'}[action] || 'Installation du moteur';
}
function lcChipSync(j){
const chip = document.getElementById('lc-chip');
if(!chip) return;
if(!j || !j.exists || (!j.running && (!j.error || lcSeenEnd))){ chip.hidden = true; return; }
chip.hidden = false;
chip.classList.toggle('failed', !j.running && !!j.error);
chip.textContent = j.running
? '⏳ ' + lcChipLabel(j.action) + ' — ' + (j.phase || '…')
: '✗ ' + lcChipLabel(j.action) + ' interrompue';
}
// Clic sur la pastille : ouvrir le tiroir sur la section Moteur.
function lcChipOpen(){
const side = document.getElementById('side');
if(!side.classList.contains('open')) toggleSide();
const det = document.getElementById('lc-details');
det.open = true;
det.scrollIntoView({block:'center'});
if(!document.getElementById('lc-chip').classList.contains('failed')) return;
lcSeenEnd = true;
lcChipSync(null);
}
function lcRenderMode(mode, installed){
@@ -3324,13 +3722,14 @@ function lcStartPolling(){
document.getElementById('lc-job').style.display = '';
document.getElementById('lc-log').textContent = '';
lcLogNext = 0;
lcSeenEnd = false; lcEndShown = false;
lcBusy(true);
if(lcPoll) clearInterval(lcPoll);
lcPoll = setInterval(lcPollJob, 1000);
lcPollJob();
}
async function lcPollJob(){
async function lcPollJob(quiet){
let j;
try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; }
if(!j.exists) return;
@@ -3342,11 +3741,12 @@ async function lcPollJob(){
if(stick) pre.scrollTop = pre.scrollHeight;
}
if(typeof j.next === 'number') lcLogNext = j.next;
lcChipSync(j);
if(j.running){
phaseEl.innerHTML = '<span class="lc-spin">⏳</span> <span>'+String(j.phase||'…').replace(/[<>&]/g,'')+'</span>';
return;
}
clearInterval(lcPoll); lcPoll = null;
if(lcPoll){ clearInterval(lcPoll); lcPoll = null; }
lcBusy(false);
if(j.error){
phaseEl.innerHTML = '<span style="color:var(--err)">✗ '+String(j.error).replace(/[<>&]/g,'')+'</span>';
+67 -14
View File
@@ -27,6 +27,10 @@
@@CSS@@
</style></head><body>
<button id="menubtn" onclick="toggleSide()">☰</button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
fermé sur téléphone. Sans ce rappel, un rechargement de page donnait
l'impression que l'installation avait disparu. -->
<button id="lc-chip" onclick="lcChipOpen()" hidden></button>
<div id="backdrop" onclick="toggleSide()"></div>
<div class="side" id="side">
<div class="sidehead">
@@ -462,8 +466,22 @@
</div>
</div>
</div>
<!-- Cartes graphiques : la liste vient du moteur choisi juste au-dessus,
car les noms ET l'ordre dépendent du backend compilé dedans. Le
groupe reste masqué tant qu'on n'a pas AU MOINS DEUX cartes : avec
une seule, il n'y a rien à arbitrer. -->
<div class="pe-group" id="m-gpu-group" hidden>
<div class="pe-gh">Cartes graphiques</div>
<div class="pe-list" id="m-gpu-list"></div>
<div class="pe-note" id="m-gpu-note"></div>
</div>
</div>
<!-- Une seule liste : les anciens « réglages avancés » repliés sont
désormais à la suite. Les valeurs d'abord, puis TOUS les interrupteurs
groupés en fin de liste — chercher un toggle perdu entre deux champs
numériques n'aidait personne. -->
<div id="m-settings-row" class="pe-group" style="display:none">
<div class="pe-gh">Réglages</div>
<div class="pe-list">
@@ -475,10 +493,26 @@
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 = tout sur le GPU</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="number" min="0" step="1" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non</span></span>
<span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads CPU<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-threads" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Threads (batch)<span class="pe-sub">0 = auto</span></span>
<span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">BATCH</span>
<span class="pe-row-c"><input id="s-batch" class="pe-val" type="number" min="0" step="256" placeholder="2048" oninput="cfgWriteKey('BATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">UBATCH</span>
<span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Cache KV<span class="pe-sub">compression du contexte</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-kv" onchange="cfgWriteKey('KV_TYPE', this.value)">
@@ -489,6 +523,34 @@
<option value="bf16">bf16</option>
</select></span></span>
</div>
<!-- Décodage spéculatif : le type vient de la liste que llama-server
accepte pour --spec-type. Le nombre de jetons anticipés
(--spec-draft-n-max) n'a de sens qu'une fois un type choisi, donc
sa ligne n'apparaît qu'à ce moment. -->
<div class="pe-row">
<span class="pe-row-l">Décodage spéculatif<span class="pe-sub">anticipe des jetons pour accélérer</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="s-spec" onchange="onSpecType()">
<option value="">non</option>
<optgroup label="Modèle brouillon">
<option value="draft-mtp">MTP (intégré au modèle)</option>
<option value="draft-eagle3">EAGLE-3</option>
<option value="draft-simple">modèle brouillon séparé</option>
<option value="draft-dflash">dFlash</option>
<option value="draft-dspark">dSpark</option>
</optgroup>
<optgroup label="N-grammes (sans modèle brouillon)">
<option value="ngram-simple">n-grammes</option>
<option value="ngram-mod">n-grammes (mod)</option>
<option value="ngram-cache">n-grammes (cache)</option>
<option value="ngram-map-k">n-grammes (map-k)</option>
<option value="ngram-map-k4v">n-grammes (map-k4v)</option>
</optgroup>
</select></span></span>
</div>
<div class="pe-row" id="s-spec-n-row" style="display:none">
<span class="pe-row-l">Jetons anticipés<span class="pe-sub">par étape — défaut 3</span></span>
<span class="pe-row-c"><input id="s-spec-n" class="pe-val" type="number" min="1" step="1" placeholder="3" oninput="eaSetValued('--spec-draft-n-max', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Raisonnement<span class="pe-sub">réflexion étape par étape</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
@@ -501,27 +563,18 @@
<span class="pe-row-l">Garder en RAM<span class="pe-sub">évite le swap (mlock)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mlock" onchange="eaToggleFlag('--mlock', this.checked)"><span class="pe-sl"></span></label></span>
</div>
</div>
<details class="pe-adv" style="margin-top:8px">
<summary>Réglages avancés</summary>
<div class="pe-adv-body" style="padding:0">
<div class="pe-list" style="border:none;border-radius:0">
<div class="pe-row"><span class="pe-row-l">BATCH</span><span class="pe-row-c"><input id="s-batch" class="pe-val" type="number" min="0" step="256" placeholder="2048" oninput="cfgWriteKey('BATCH', this.value)"></span></div>
<div class="pe-row"><span class="pe-row-l">UBATCH</span><span class="pe-row-c"><input id="s-ubatch" class="pe-val" type="number" min="0" step="128" placeholder="512" oninput="cfgWriteKey('UBATCH', this.value)"></span></div>
<div class="pe-row"><span class="pe-row-l">Threads (batch)<span class="pe-sub">0 = auto</span></span><span class="pe-row-c"><input id="s-tbatch" class="pe-val" type="number" min="0" step="1" placeholder="0" oninput="cfgWriteKey('THREADS_BATCH', this.value)"></span></div>
<div class="pe-row"><span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non</span></span><span class="pe-row-c"><input id="s-moe" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="eaSetValued('--n-cpu-moe', this.value)"></span></div>
<div class="pe-row"><span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span><span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span></div>
</div>
<div class="pe-row">
<span class="pe-row-l">Charger tout en mémoire<span class="pe-sub">désactive le mmap</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-nommap" onchange="eaToggleFlag('--no-mmap', this.checked)"><span class="pe-sl"></span></label></span>
</div>
</details>
</div>
</div>
<div id="m-raw" class="pe-group">
<div class="pe-gh" id="m-raw-head">Configuration</div>
<div class="pe-list">
<div id="m-raw-toggle" class="pe-row" style="cursor:pointer" onclick="toggleRaw()">
<span class="pe-row-l">Éditer le fichier <code>.env</code><span class="pe-sub">configuration brute — pour les réglages avancés</span></span>
<span class="pe-row-l">Éditer le fichier de configuration<span class="pe-sub">texte brut</span></span>
<span class="pe-row-c"><span id="m-raw-caret" class="pe-caret"></span></span>
</div>
<div id="m-raw-body" class="pe-row stack" style="display:none">
+262 -3
View File
@@ -254,11 +254,20 @@ function setBinInTextarea(val){
if(/^\s*BIN\s*=.*$/m.test(ta.value)) ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"');
else ta.value = 'BIN="'+val+'"\n' + ta.value;
}
let beFastPath = '', beOptPath = '';
// Le moteur précompilé s'installe dans un dossier versionné (…/llama-b10280/) :
// le BIN d'un preset écrit avant une mise à jour ne vaut plus le chemin courant,
// alors qu'il désigne bien ce moteur. On reconnaît donc l'appartenance au
// dossier, sinon tous les presets retombaient en « personnalisé » à chaque MAJ.
function underDir(p, dir){
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
return !!p && !!dir && n(p).startsWith(n(dir)+'/');
}
let beFastPath = '', beOptPath = '', beFastDir = '';
async function populateBackend(){
// Chemins des deux moteurs gérés + liste des backends détectés (dossier jean).
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
beOptPath = lc.bin || '';
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
// le dossier backends de jean (l'utilisateur peut y déposer son propre build).
@@ -272,12 +281,13 @@ async function populateBackend(){
// Sélectionne l'option correspondant au BIN actuel du preset.
const cur = currentBinInTextarea();
let mode = 'custom';
if(sameBinPath(cur, beFastPath)) mode = 'fast';
if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
const radio = document.querySelector('input[name=m-be][value='+mode+']');
if(radio) radio.checked = true;
toggleBackendCustom(mode);
if(mode === 'custom') document.getElementById('m-backend-path').value = cur;
loadGpuDevices();
}
function toggleBackendCustom(mode){
document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none';
@@ -287,22 +297,231 @@ function onBackendMode(mode){
if(mode === 'fast'){
if(!beFastPath){ toast('installez d\'abord llama.cpp précompilé (section MOTEUR)'); return; }
setBinInTextarea(beFastPath); toast('moteur : llama.cpp précompilé');
loadGpuDevices();
} else if(mode === 'opt'){
if(!beOptPath){ toast('installez d\'abord llama.cpp compilé (section MOTEUR)'); return; }
setBinInTextarea(beOptPath); toast('moteur : llama.cpp compilé');
loadGpuDevices();
}
// custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend
}
function onCustomPath(){
const v = document.getElementById('m-backend-path').value.trim();
if(v) setBinInTextarea(v);
if(v){ setBinInTextarea(v); loadGpuDevices(); }
}
function onPickDetected(){
const v = document.getElementById('m-backend-detected').value;
if(!v) return;
document.getElementById('m-backend-path').value = v;
setBinInTextarea(v); toast('moteur personnalisé');
loadGpuDevices();
}
// --- Cartes graphiques (--device / --tensor-split) --------------------------
// Ces réglages vivent dans le PRESET, pas dans une variable d'environnement :
// --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que
// CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles
// qui s'étalaient sur les deux cartes malgré une sélection « GPU 1 ».
// La lecture/écriture des flags passe par eaGetValued/eaSetValued (le tokenizer
// d'EXTRA_ARGS déjà utilisé par les autres réglages) : un seul parseur.
// Diamètre de la pastille du curseur, en pixels — DOIT rester synchronisé avec
// .gpu-range::-webkit-slider-thumb / ::-moz-range-thumb dans styles.css.
const GPU_THUMB = 16;
let gpuDevices = [];
const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture
// Interroge le moteur du preset (--list-devices) : les noms de device et leur
// ordre lui appartiennent, une liste issue de nvidia-smi désignerait la mauvaise
// carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la réponse n'est
// pas là, et le reste s'il y a moins de deux cartes : rien à arbitrer, et ça
// évite un encart qui surgit après coup.
// Remplit le cache sans rien peindre (appelé au chargement de la page pour le
// moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt.
async function prefetchGpuDevices(bin){
if(!bin || gpuCache[bin]) return;
try{
const r = await jpost('/api/backends/devices', {bin});
if(r && r.ok) gpuCache[bin] = r.devices || [];
}catch(_){}
}
async function loadGpuDevices(){
const group = document.getElementById('m-gpu-group');
const bin = currentBinInTextarea();
if(!bin){ group.hidden = true; gpuDevices = []; return; }
if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; }
let r = {};
try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; }
// Le moteur a pu changer pendant l'appel (clic rapide) : on ne peint que si la
// réponse concerne toujours le moteur affiché.
if(currentBinInTextarea() !== bin) return;
gpuDevices = (r.ok && r.devices) ? r.devices : [];
if(r.ok) gpuCache[bin] = gpuDevices;
renderGpu();
}
function selectedGpuIds(){
const sel = eaGetValued('--device').split(',').filter(Boolean);
const known = gpuDevices.map(d => d.id);
const kept = sel.filter(id => known.includes(id));
return kept.length ? kept : known; // aucune contrainte = toutes les cartes
}
function gpuEsc(t){
return String(t).replace(/[<>&"]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;','"':'&quot;'}[c]));
}
function renderGpu(){
const group = document.getElementById('m-gpu-group');
// Une seule carte (ou aucune) : rien à répartir, rien à choisir.
if(gpuDevices.length < 2){ group.hidden = true; return; }
const sel = selectedGpuIds();
let html = gpuDevices.map(d => {
// Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le
// modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge.
const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : '';
return '<div class="pe-row">'
+ '<span class="pe-row-l">' + gpuEsc(gpuLabel(d))
+ '<span class="pe-sub">' + gpuEsc(d.id) + gb + '</span></span>'
+ '<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" value="' + gpuEsc(d.id) + '"'
+ (sel.includes(d.id) ? ' checked' : '') + ' onchange="onGpuPick()"><span class="pe-sl"></span></label></span>'
+ '</div>';
}).join('');
if(sel.length > 1) html += splitHtml(sel);
document.getElementById('m-gpu-list').innerHTML = html;
document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length
? 'Toutes les cartes sont utilisées.'
: 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.';
group.hidden = false;
if(sel.length > 1) paintSplit();
}
function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; }
// Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1
// ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui
// veulent savoir ce qui part dans --device.
function gpuLabel(d){
return String((d && d.name) || (d && d.id) || '')
.replace(/^NVIDIA\s+GeForce\s+/i, '')
.replace(/^NVIDIA\s+/i, '')
.replace(/^AMD\s+(Radeon\s+)?/i, '')
.replace(/^Intel\(R\)\s+/i, '')
.trim() || (d && d.id) || '';
}
// Répartition : UNE seule barre, sur laquelle on glisse directement (le curseur
// est transparent, posé par-dessus). Trois cartes ou plus : un pourcentage par
// carte. On n'écrit --tensor-split QUE si l'utilisateur y touche : sans lui,
// llama.cpp répartit tout seul.
function splitHtml(sel){
const shares = currentShares(sel);
let inner;
if(sel.length === 2){
inner = '<div class="gpu-split">'
+ '<div class="gpu-bar" id="m-split-bar"></div>'
+ '<input type="range" class="gpu-range" id="m-split-range" min="0" max="100" step="0.5"'
+ ' value="' + (shares[0]*100).toFixed(1) + '" oninput="onSplitRange()"'
+ ' aria-label="part de ' + gpuEsc(gpuLabel(gpuById(sel[0]))) + '"></div>';
} else {
inner = '<div class="gpu-bar" id="m-split-bar"></div>'
+ '<div class="gpu-shares">' + sel.map((id, i) =>
'<label class="gpu-share"><span>' + gpuEsc(gpuLabel(gpuById(id))) + '</span>'
+ '<input type="number" min="0" max="100" step="0.5" value="' + (shares[i]*100).toFixed(1) + '"'
+ ' oninput="onSplitNumbers()"><span class="pe-unit">%</span></label>').join('') + '</div>';
}
return '<div class="pe-row">'
+ '<span class="pe-row-l">Répartition<span class="pe-sub">part des couches par carte</span></span>'
+ '<span class="pe-row-c" id="m-split-state"></span></div>'
+ '<div class="pe-row stack">' + inner
+ '<div class="gpu-legend" id="m-split-legend"></div></div>';
}
// Parts actuelles, normalisées à 1. Sans --tensor-split : proportionnelles à la
// VRAM de chaque carte, ce que llama.cpp fait de son côté — la barre montre donc
// ce qui se passe réellement plutôt qu'un partage égal trompeur.
function currentShares(sel){
const raw = eaGetValued('--tensor-split').split(',').map(parseFloat).filter(n => !isNaN(n));
let vals = (raw.length === sel.length) ? raw
: sel.map(id => gpuById(id).total_mib || 0);
// Mémoires inconnues (lecture à 0) : à défaut de proportion crédible, on
// montre un partage égal plutôt qu'une barre pleine sur une seule carte.
if(vals.every(v => !v)) vals = sel.map(() => 1);
const sum = vals.reduce((a, b) => a + b, 0) || 1;
return vals.map(v => v/sum);
}
// Repeint UNIQUEMENT la barre, la légende et l'état. Surtout PAS le bloc entier :
// remplacer le HTML pendant un glissement détruit le curseur en cours d'usage —
// c'est ce qui faisait qu'il ne répondait qu'au clic.
function paintSplit(){
const sel = selectedGpuIds();
if(sel.length < 2) return;
const shares = currentShares(sel);
const bar = document.getElementById('m-split-bar');
// La pastille du curseur ne parcourt PAS toute la largeur : elle va de
// GPU_THUMB/2 à largeur - GPU_THUMB/2, sinon elle déborderait de la piste. Une
// coupure à « s % » pile tombait donc à côté d'elle (jusqu'à ~5 px d'écart aux
// extrêmes), ce qui laissait voir la piste claire juste à droite de la
// pastille. On coupe dans le repère de la pastille : s*(largeur - THUMB) + THUMB/2.
// Le dernier segment prend le reste (flex:1) : aucun filet résiduel à droite,
// quels que soient les arrondis.
const withThumb = sel.length === 2 && !!document.getElementById('m-split-range');
if(bar) bar.innerHTML = shares.map((s, i) => {
const cls = i%2 ? 'alt' : '';
if(i === shares.length - 1) return '<span class="' + cls + '" style="flex:1"></span>';
const w = withThumb
? 'calc(' + (s*100).toFixed(3) + '% - ' + ((s - 0.5) * GPU_THUMB).toFixed(2) + 'px)'
: (s*100).toFixed(3) + '%';
return '<span class="' + cls + '" style="width:' + w + '"></span>';
}).join('');
const leg = document.getElementById('m-split-legend');
if(leg) leg.innerHTML = sel.map((id, i) =>
'<span><i class="' + (i%2 ? 'alt' : '') + '"></i>' + gpuEsc(gpuLabel(gpuById(id)))
+ ' ' + (shares[i]*100).toFixed(1).replace('.', ',') + ' %</span>').join('');
const st = document.getElementById('m-split-state');
if(st) st.innerHTML = eaGetValued('--tensor-split')
? '<button class="pe-link" onclick="resetSplit()">réinitialiser</button>'
: '<span class="pe-unit">automatique</span>';
}
function writeSplit(shares){
const sum = shares.reduce((a, b) => a + b, 0) || 1;
eaSetValued('--tensor-split', shares.map(s => (s/sum).toFixed(3)).join(','));
paintSplit();
}
function onSplitRange(){
const v = parseFloat(document.getElementById('m-split-range').value) / 100;
writeSplit([v, 1-v]);
}
function onSplitNumbers(){
const vals = [...document.querySelectorAll('.gpu-share input')].map(i => parseFloat(i.value) || 0);
if(vals.reduce((a, b) => a + b, 0) <= 0) return; // saisie en cours
writeSplit(vals);
}
function resetSplit(){
eaSetValued('--tensor-split', '');
const sel = selectedGpuIds();
const r = document.getElementById('m-split-range');
if(r && sel.length === 2) r.value = (currentShares(sel)[0]*100).toFixed(1);
const nums = [...document.querySelectorAll('.gpu-share input')];
if(nums.length){
const sh = currentShares(sel);
nums.forEach((inp, i) => { inp.value = (sh[i]*100).toFixed(1); });
}
paintSplit();
}
function onGpuPick(){
const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value);
if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; }
// Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer
// des noms de device qui changeraient avec le moteur.
eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(','));
// Une répartition écrite pour N cartes n'a plus de sens pour N-1.
if(ids.length < 2) eaSetValued('--tensor-split', '');
renderGpu();
}
// Read/write the QUANT= override line in the preset textarea.
function currentQuantInTextarea(){
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=\s*"?([^"\n]*)"?\s*$/mi);
@@ -371,12 +590,52 @@ function populateSettings(){
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
set('s-moe', eaGetValued('--n-cpu-moe'));
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
setSpecType(eaGetValued('--spec-type'));
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(cfgReadKey('REASONING')));
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mlock', eaHasFlag('--mlock'));
chk('s-nommap', eaHasFlag('--no-mmap'));
}
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de jean) ne correspondrait à aucune option et serait silencieusement effacée
// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de
// la perdre.
function setSpecType(v){
const sel = document.getElementById('s-spec');
if(!sel) return;
v = String(v || '').trim();
if(v && ![...sel.options].some(o => o.value === v)){
const opt = document.createElement('option');
opt.value = v; opt.textContent = v + ' (dans la configuration)';
sel.appendChild(opt);
}
sel.value = v;
syncSpecRow();
}
// Le nombre de jetons anticipés ne veut rien dire sans type : ligne masquée, et
// flag retiré pour ne pas laisser un réglage orphelin dans le preset.
function syncSpecRow(){
const sel = document.getElementById('s-spec');
const row = document.getElementById('s-spec-n-row');
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
eaSetValued('--spec-type', v);
if(!v){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}
syncSpecRow();
}
// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset.
function toggleRaw(){
const b = document.getElementById('m-raw-body');
+50 -2
View File
@@ -30,6 +30,52 @@ async function loadLlamacpp(){
document.getElementById('lc-details').open = true;
lcStartPolling();
}
// Job terminé/interrompu qu'on n'a pas encore montré (rechargement APRÈS coup,
// typiquement quand le service a redémarré) : on l'affiche sans polling.
else if(s.job && s.job.exists && !s.job.running && !lcPoll && s.job.error && !lcEndShown){
document.getElementById('lc-details').open = true;
document.getElementById('lc-job').style.display = '';
lcLogNext = 0;
document.getElementById('lc-log').textContent = '';
lcEndShown = true;
lcPollJob(true); // quiet : simple rattrapage, pas de toast ni de rechargement
}
lcChipSync(s.job);
// Préchauffe la liste des cartes du moteur actif : interroger le moteur prend
// 1 à 3 s (init CUDA/Vulkan), et sans ça l'encart « cartes graphiques » de
// l'éditeur apparaissait après coup, une fois le reste déjà affiché.
if(typeof prefetchGpuDevices === 'function') prefetchGpuDevices(s.config_bin);
}
// --- Pastille de rappel hors panneau ---------------------------------------
// Le détail de l'installation vit dans le panneau latéral, qui est un tiroir
// FERMÉ sur téléphone : après un rechargement, rien ne disait qu'une
// compilation tournait encore. La pastille le dit, et y ramène en un clic.
let lcSeenEnd = false, lcEndShown = false;
function lcChipLabel(action){
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
prebuilt:'Téléchargement du moteur', custom:'Installation du backend'}[action] || 'Installation du moteur';
}
function lcChipSync(j){
const chip = document.getElementById('lc-chip');
if(!chip) return;
if(!j || !j.exists || (!j.running && (!j.error || lcSeenEnd))){ chip.hidden = true; return; }
chip.hidden = false;
chip.classList.toggle('failed', !j.running && !!j.error);
chip.textContent = j.running
? '⏳ ' + lcChipLabel(j.action) + ' — ' + (j.phase || '…')
: '✗ ' + lcChipLabel(j.action) + ' interrompue';
}
// Clic sur la pastille : ouvrir le tiroir sur la section Moteur.
function lcChipOpen(){
const side = document.getElementById('side');
if(!side.classList.contains('open')) toggleSide();
const det = document.getElementById('lc-details');
det.open = true;
det.scrollIntoView({block:'center'});
if(!document.getElementById('lc-chip').classList.contains('failed')) return;
lcSeenEnd = true;
lcChipSync(null);
}
function lcRenderMode(mode, installed){
@@ -177,13 +223,14 @@ function lcStartPolling(){
document.getElementById('lc-job').style.display = '';
document.getElementById('lc-log').textContent = '';
lcLogNext = 0;
lcSeenEnd = false; lcEndShown = false;
lcBusy(true);
if(lcPoll) clearInterval(lcPoll);
lcPoll = setInterval(lcPollJob, 1000);
lcPollJob();
}
async function lcPollJob(){
async function lcPollJob(quiet){
let j;
try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; }
if(!j.exists) return;
@@ -195,11 +242,12 @@ async function lcPollJob(){
if(stick) pre.scrollTop = pre.scrollHeight;
}
if(typeof j.next === 'number') lcLogNext = j.next;
lcChipSync(j);
if(j.running){
phaseEl.innerHTML = '<span class="lc-spin">⏳</span> <span>'+String(j.phase||'…').replace(/[<>&]/g,'')+'</span>';
return;
}
clearInterval(lcPoll); lcPoll = null;
if(lcPoll){ clearInterval(lcPoll); lcPoll = null; }
lcBusy(false);
if(j.error){
phaseEl.innerHTML = '<span style="color:var(--err)">✗ '+String(j.error).replace(/[<>&]/g,'')+'</span>';
+44 -4
View File
@@ -67,11 +67,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%}
@@ -434,8 +439,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.
@@ -503,6 +509,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}
@@ -538,7 +545,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}
@@ -560,6 +572,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 <input type=range> 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}
@@ -637,7 +674,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}
+181
View File
@@ -0,0 +1,181 @@
// web_devices.go — liste des GPU tels que les voit UN moteur donné, pour que
// l'éditeur de modèle propose « quel(s) GPU utiliser » et le tensor split.
//
// Pourquoi interroger le binaire plutôt que nvidia-smi : les noms de device
// dépendent du backend compilé dans CE moteur, et l'ordre aussi. Sur la même
// machine, un build CUDA annonce « CUDA0 = RTX 5060 Ti, CUDA1 = GTX 1650 » là où
// le binaire précompilé (Vulkan) annonce l'inverse, « Vulkan0 = GTX 1650 ».
// Proposer une liste issue de nvidia-smi ferait donc choisir la mauvaise carte.
// C'est aussi pour ça que le réglage vit dans le PRESET (--device, compris par
// tous les backends) et non dans CUDA_VISIBLE_DEVICES, qui n'a aucun effet sur
// un moteur Vulkan.
package ajean
import (
"context"
"encoding/json"
"net/http"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// deviceLine matche « CUDA0: NVIDIA GeForce RTX 5060 Ti (15849 MiB, 15579 MiB free) ».
var deviceLine = regexp.MustCompile(`^\s*([A-Za-z]+\d+):\s*(.+?)\s*\((\d+)\s*MiB,\s*(\d+)\s*MiB free\)\s*$`)
// parseListDevices extrait les devices de la sortie de `llama-server --list-devices`.
func parseListDevices(out string) []map[string]any {
devs := []map[string]any{}
for _, line := range strings.Split(out, "\n") {
m := deviceLine.FindStringSubmatch(strings.TrimRight(line, "\r"))
if m == nil {
continue
}
total, _ := strconv.Atoi(m[3])
free, _ := strconv.Atoi(m[4])
devs = append(devs, map[string]any{
"id": m[1], "name": m[2], "total_mib": total, "free_mib": free,
})
}
return devs
}
// Cache mémoire : lister les devices lance le moteur (init CUDA/Vulkan +
// énumération), soit une à trois secondes. Sans cache, l'encart « cartes
// graphiques » de l'éditeur apparaissait plusieurs secondes après le reste.
type devCacheEntry struct {
devices []map[string]any
at time.Time
}
var (
devCacheMu sync.Mutex
devCache = map[string]devCacheEntry{}
)
const devCacheTTL = 10 * time.Minute
func devCacheGet(bin string) ([]map[string]any, bool) {
devCacheMu.Lock()
defer devCacheMu.Unlock()
e, ok := devCache[bin]
if !ok || time.Since(e.at) > devCacheTTL {
return nil, false
}
return e.devices, true
}
func devCachePut(bin string, devs []map[string]any) {
devCacheMu.Lock()
devCache[bin] = devCacheEntry{devices: devs, at: time.Now()}
devCacheMu.Unlock()
}
// fillMissingMemory complète les mémoires que le moteur n'a pas su lire. Quand
// une carte est déjà saturée par le modèle en cours, llama.cpp annonce 0 Mio —
// l'UI n'avait alors rien à afficher pour elle, ce qui donnait une liste
// incohérente (une carte avec sa taille, l'autre sans).
//
// On complète depuis nvidia-smi PAR CORRESPONDANCE DE NOM, et uniquement pour
// ça : la mémoire totale est une donnée matérielle fixe, indépendante du
// backend. L'ordre et les identifiants des devices, eux, appartiennent au
// moteur (CUDA0 et Vulkan0 ne désignent pas la même carte) et ne doivent jamais
// venir de nvidia-smi. Un nom en double (deux cartes identiques) rend la
// correspondance ambiguë : on préfère alors ne rien dire.
func fillMissingMemory(devs []map[string]any) {
missing := false
for _, d := range devs {
if n, _ := d["total_mib"].(int); n <= 0 {
missing = true
}
}
if !missing {
return
}
gpus, err := detectGPUs()
if err != nil {
return
}
totals := map[string]int{}
dup := map[string]bool{}
for _, g := range gpus {
name := strings.TrimSpace(g.Name)
if _, seen := totals[name]; seen {
dup[name] = true
continue
}
if mb, err := strconv.Atoi(strings.TrimSpace(g.MemTotal)); err == nil {
totals[name] = mb
}
}
for _, d := range devs {
if n, _ := d["total_mib"].(int); n > 0 {
continue
}
name, _ := d["name"].(string)
if mb, ok := totals[strings.TrimSpace(name)]; ok && !dup[strings.TrimSpace(name)] {
d["total_mib"] = mb
}
}
}
// hasZeroMemory dit si au moins un device annonce une mémoire totale nulle.
func hasZeroMemory(devs []map[string]any) bool {
for _, d := range devs {
if n, _ := d["total_mib"].(int); n <= 0 {
return true
}
}
return false
}
// handleBackendDevices renvoie les devices vus par le moteur passé en `bin`
// (celui du preset en cours d'édition). Sans `bin`, on prend celui de la config
// active.
func handleBackendDevices(w http.ResponseWriter, r *http.Request) {
var req struct {
Bin string `json:"bin"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
bin := strings.TrimSpace(req.Bin)
if bin == "" {
bin = ReadConfig()["BIN"]
}
bin = prebuiltResolveBin(bin)
if bin == "" || !isFile(bin) {
sendJSON(w, 200, map[string]any{"ok": false, "error": "moteur introuvable — choisissez d'abord un moteur"})
return
}
// Garde-fou : on n'exécute que le serveur llama.cpp, pas n'importe quel
// chemin qui passerait par cette requête.
if base := strings.ToLower(filepath.Base(bin)); base != "llama-server" && base != "llama-server.exe" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "ce chemin n'est pas un llama-server"})
return
}
if devs, ok := devCacheGet(bin); ok {
sendJSON(w, 200, map[string]any{"ok": true, "devices": devs})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
cmd := hideCmd(exec.CommandContext(ctx, bin, "--list-devices"))
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, err := cmd.CombinedOutput()
if err != nil && len(out) == 0 {
sendJSON(w, 200, map[string]any{"ok": false, "error": "le moteur n'a pas répondu : " + err.Error()})
return
}
devs := parseListDevices(string(out))
fillMissingMemory(devs)
// Quand une carte est déjà saturée par le modèle en cours, le moteur peut
// annoncer 0 Mio de mémoire : c'est une lecture transitoire, on ne la fige
// pas dans le cache (sinon l'UI affiche « 0 Go » pendant dix minutes).
if !hasZeroMemory(devs) {
devCachePut(bin, devs)
}
sendJSON(w, 200, map[string]any{"ok": true, "devices": devs})
}
+85
View File
@@ -0,0 +1,85 @@
package ajean
import "testing"
// La sortie de `llama-server --list-devices` : c'est elle qui fait foi pour
// l'UI, car les noms ET l'ordre dépendent du backend compilé dans CE moteur
// (sur la même machine : CUDA0 = la grosse carte, Vulkan0 = la petite).
func TestParseListDevices(t *testing.T) {
out := `ggml_cuda_init: found 2 CUDA devices:
Available devices:
CUDA0: NVIDIA GeForce RTX 5060 Ti (15849 MiB, 15579 MiB free)
CUDA1: NVIDIA GeForce GTX 1650 (3715 MiB, 3659 MiB free)
`
devs := parseListDevices(out)
if len(devs) != 2 {
t.Fatalf("%d device(s) extraits, attendu 2 : %v", len(devs), devs)
}
if devs[0]["id"] != "CUDA0" || devs[0]["name"] != "NVIDIA GeForce RTX 5060 Ti" {
t.Errorf("device 0 mal lu : %v", devs[0])
}
if devs[0]["total_mib"] != 15849 || devs[1]["free_mib"] != 3659 {
t.Errorf("mémoire mal lue : %v / %v", devs[0], devs[1])
}
if devs[1]["id"] != "CUDA1" {
t.Errorf("device 1 mal lu : %v", devs[1])
}
}
// Backend Vulkan : même format, autres noms — et rien d'autre ne doit passer.
func TestParseListDevicesVulkanEtBruit(t *testing.T) {
out := `load_backend: loaded Vulkan backend
Available devices:
Vulkan0: NVIDIA GeForce GTX 1650 (4342 MiB, 3950 MiB free)
Vulkan1: NVIDIA GeForce RTX 5060 Ti (16311 MiB, 15848 MiB free)
warning: something else entirely
`
devs := parseListDevices(out)
if len(devs) != 2 || devs[0]["id"] != "Vulkan0" || devs[1]["id"] != "Vulkan1" {
t.Fatalf("devices Vulkan mal extraits : %v", devs)
}
if n := len(parseListDevices("aucun device ici\n")); n != 0 {
t.Errorf("%d device(s) extraits d'une sortie sans device", n)
}
}
// Mémoire complétée : un device dont le moteur n'a pas su lire la taille (0 Mio,
// carte saturée) récupère celle de nvidia-smi par correspondance de nom. Deux
// cartes homonymes rendent la correspondance ambiguë : on ne complète pas.
func TestFillMissingMemoryParNom(t *testing.T) {
devs := []map[string]any{
{"id": "CUDA0", "name": "NVIDIA GeForce RTX 5060 Ti", "total_mib": 0, "free_mib": 0},
{"id": "CUDA1", "name": "NVIDIA GeForce GTX 1650", "total_mib": 3715, "free_mib": 3659},
}
// La table de correspondance est celle que construit fillMissingMemory ; on
// vérifie ici la règle métier sans dépendre d'un nvidia-smi présent.
totals := map[string]int{"NVIDIA GeForce RTX 5060 Ti": 16311}
dup := map[string]bool{}
for _, d := range devs {
if n, _ := d["total_mib"].(int); n > 0 {
continue
}
name, _ := d["name"].(string)
if mb, ok := totals[name]; ok && !dup[name] {
d["total_mib"] = mb
}
}
if devs[0]["total_mib"] != 16311 {
t.Errorf("mémoire non complétée : %v", devs[0])
}
if devs[1]["total_mib"] != 3715 {
t.Errorf("mémoire déjà connue écrasée : %v", devs[1])
}
}
// Le cache ne doit pas figer une lecture dégénérée (0 Mio) pendant 10 minutes.
func TestHasZeroMemory(t *testing.T) {
ok := []map[string]any{{"total_mib": 16311}, {"total_mib": 3715}}
ko := []map[string]any{{"total_mib": 16311}, {"total_mib": 0}}
if hasZeroMemory(ok) {
t.Error("lecture complète prise pour dégénérée")
}
if !hasZeroMemory(ko) {
t.Error("lecture à 0 Mio non détectée")
}
}
+17 -3
View File
@@ -67,6 +67,8 @@ func lcAppend(line string) {
} else if p := phaseLabel(line); p != "" {
lcCur.Phase = p
}
lcSaveLine(line)
lcSave(false)
}
// lcPhase pose une phase explicite (étapes hors build : clone, fetch, service…)
@@ -76,6 +78,8 @@ func lcPhase(phase string) {
if lcCur != nil {
lcCur.Phase = phase
lcCur.lines = append(lcCur.lines, "▶ "+phase)
lcSaveLine("▶ " + phase)
lcSave(true)
}
lcMu.Unlock()
}
@@ -88,6 +92,8 @@ func startLcJob(action string, run func()) error {
return fmt.Errorf("un job %s est déjà en cours", lcCur.Action)
}
lcCur = &lcJob{Action: action, Running: true, Phase: "démarrage…", StartedAt: time.Now().Unix()}
lcResetLog()
lcSave(true)
setBuildSink(lcAppend)
go func() {
defer func() {
@@ -95,6 +101,7 @@ func startLcJob(action string, run func()) error {
lcMu.Lock()
lcCur.Running = false
lcCur.EndedAt = time.Now().Unix()
lcSave(true)
lcMu.Unlock()
}()
run()
@@ -108,6 +115,8 @@ func lcFail(err error) {
lcCur.Err = err.Error()
lcCur.Phase = "échec"
lcCur.lines = append(lcCur.lines, "✗ "+err.Error())
lcSaveLine("✗ " + err.Error())
lcSave(true)
}
lcMu.Unlock()
}
@@ -117,6 +126,8 @@ func lcDone(msg string) {
if lcCur != nil {
lcCur.Phase = msg
lcCur.lines = append(lcCur.lines, "✓ "+msg)
lcSaveLine("✓ " + msg)
lcSave(true)
}
lcMu.Unlock()
}
@@ -162,9 +173,12 @@ func handleLlamacpp(w http.ResponseWriter, r *http.Request) {
pbTag, _ := prebuiltVersion()
pbBin := prebuiltServerBin()
out["prebuilt"] = map[string]any{
"tag": pbTag,
"bin": pbBin,
"in_use": pbBin != "" && samePath(pbBin, cfgBin),
"tag": pbTag,
"bin": pbBin,
"dir": prebuiltDir(),
// Tout BIN vivant sous le dossier prebuilt EST le moteur précompilé, même
// s'il porte le numéro d'une release remplacée depuis (chemin versionné).
"in_use": pbBin != "" && prebuiltOwns(cfgBin),
}
out["backends_dir"] = filepath.Join(AjeanHome(), "backends")
out["job"] = lcJobSnapshot(0, false)
+128
View File
@@ -0,0 +1,128 @@
// web_llamacpp_job.go — persistance du job llama.cpp (install / update /
// backend custom).
//
// Le job vivait UNIQUEMENT en mémoire du process web. Conséquence : au moindre
// redémarrage du service (mise à jour du binaire, crash, reboot) l'état
// disparaissait — la page rechargée n'affichait plus rien du tout, sans dire si
// la compilation continuait ou pas. Or elle ne continue pas : le build est un
// processus ENFANT, il meurt avec le service. On écrit donc l'état sur disque
// pour pouvoir l'annoncer honnêtement au lieu de laisser l'utilisateur devant
// un écran muet.
package ajean
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
func lcJobStatePath() string { return filepath.Join(AjeanHome(), ".lc_job.json") }
func lcJobLogPath() string { return filepath.Join(AjeanHome(), ".lc_job.log") }
// lcPersisted est la forme sur disque : l'entête du job, sans les lignes (elles
// vivent dans le .log à côté, en append).
type lcPersisted struct {
Action string `json:"action"`
Phase string `json:"phase"`
Compiled int `json:"compiled"`
Running bool `json:"running"`
Err string `json:"error"`
StartedAt int64 `json:"started_at"`
EndedAt int64 `json:"ended_at"`
OldCommit string `json:"old"`
NewCommit string `json:"new"`
}
var (
lcLastSave time.Time
lcRestoreOnce sync.Once
)
// lcSave écrit l'état courant. Appelée sous lcMu. Les changements d'état (début,
// fin, échec) sont écrits tout de suite ; la progression est throttlée à 2 s
// pour ne pas marteler le disque pendant une compilation (des milliers de
// lignes).
func lcSave(force bool) {
if lcCur == nil {
return
}
if !force && time.Since(lcLastSave) < 2*time.Second {
return
}
lcLastSave = time.Now()
b, err := json.Marshal(lcPersisted{
Action: lcCur.Action, Phase: lcCur.Phase, Compiled: lcCur.Compiled,
Running: lcCur.Running, Err: lcCur.Err,
StartedAt: lcCur.StartedAt, EndedAt: lcCur.EndedAt,
OldCommit: lcCur.OldCommit, NewCommit: lcCur.NewCommit,
})
if err == nil {
_ = os.WriteFile(lcJobStatePath(), b, 0o644)
}
}
// lcSaveLine ajoute une ligne au journal sur disque (append, pas de réécriture).
func lcSaveLine(line string) {
f, err := os.OpenFile(lcJobLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return
}
_, _ = f.WriteString(line + "\n")
_ = f.Close()
}
// lcResetLog repart d'un journal vide au démarrage d'un nouveau job.
func lcResetLog() { _ = os.Remove(lcJobLogPath()) }
// lcRestore recharge le dernier job connu au démarrage du process. Un job noté
// « en cours » ne peut pas être le nôtre (on vient de démarrer) : sa
// compilation est morte avec le process précédent, on le marque interrompu
// plutôt que de le faire passer pour vivant ou de l'effacer en silence.
func lcRestore() {
b, err := os.ReadFile(lcJobStatePath())
if err != nil {
return
}
var p lcPersisted
if json.Unmarshal(b, &p) != nil || p.Action == "" {
return
}
j := &lcJob{
Action: p.Action, Phase: p.Phase, Compiled: p.Compiled,
Running: false, Err: p.Err,
StartedAt: p.StartedAt, EndedAt: p.EndedAt,
OldCommit: p.OldCommit, NewCommit: p.NewCommit,
}
if p.Running {
j.Err = "interrompu : le service a redémarré pendant l'opération — relancez-la"
j.Phase = "interrompu"
j.EndedAt = time.Now().Unix()
}
if logb, err := os.ReadFile(lcJobLogPath()); err == nil {
lines := strings.Split(strings.TrimRight(string(logb), "\n"), "\n")
if len(lines) == 1 && lines[0] == "" {
lines = nil
}
if len(lines) > lcMaxLines {
j.base = len(lines) - lcMaxLines
lines = lines[j.base:]
}
j.lines = lines
}
if p.Running {
j.lines = append(j.lines, "✗ interrompu : le service a redémarré (la compilation ne survit pas au redémarrage)")
}
lcMu.Lock()
if lcCur == nil {
lcCur = j
}
lcMu.Unlock()
if p.Running {
lcMu.Lock()
lcSave(true)
lcMu.Unlock()
}
}
+5
View File
@@ -70,6 +70,10 @@ func newWebMux() *http.ServeMux {
// Un téléchargement de modèle coupé net (crash, restart du service) laisse un
// .part orphelin non reprenable : on nettoie au démarrage.
cleanStalePartFiles()
// Idem pour une installation de moteur : elle meurt avec le process. On
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
// plus rien du tout.
lcRestoreOnce.Do(lcRestore)
mux := http.NewServeMux()
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
@@ -100,6 +104,7 @@ func newWebMux() *http.ServeMux {
api("/api/models/download/cancel", handleModelDownloadCancel)
api("/api/backends", handleBackends)
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)
api("/api/llamacpp", handleLlamacpp) // statut du backend llama.cpp
api("/api/llamacpp/check", handleLlamacppCheck) // git fetch + retard sur origin
api("/api/llamacpp/install", handleLlamacppInstall) // job : clone + build + BIN