mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
726f065d25
commit
e57fc0966e
24 files changed
+1754
-94
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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..."))
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[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>';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 => ({'<':'<','>':'>','&':'&','"':'"'}[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');
|
||||
|
||||
@@ -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>';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user