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

+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