v0.5.6 : backends custom depuis un depot Git + editeur de preset repense (champs facon Reglages iOS) + incompatibilite modele/moteur signalee

This commit is contained in:
nathaninline committed 2026-07-27 10:35:04 +02:00
1 parent 1af9262e97
commit 83a3a79abe
14 files changed
+1158 -119

No files matched your search

Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 5,
"Patch": 5,
"Patch": 6,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 5,
"Patch": 5,
"Patch": 6,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "Jean",
"ProductVersion": "0.5.5",
"ProductVersion": "0.5.6",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {
+173
View File
@@ -111,12 +111,18 @@ func llamacppInstall(args []string) error {
ref := ""
force := false
noSwitch := false
customURL := ""
customName := ""
for _, a := range args {
switch {
case strings.HasPrefix(a, "--dir="):
repo = strings.TrimPrefix(a, "--dir=")
case strings.HasPrefix(a, "--ref="):
ref = strings.TrimPrefix(a, "--ref=")
case strings.HasPrefix(a, "--repo="):
customURL = strings.TrimPrefix(a, "--repo=")
case strings.HasPrefix(a, "--name="):
customName = strings.TrimPrefix(a, "--name=")
case a == "--force":
force = true
case a == "--no-switch":
@@ -126,6 +132,23 @@ func llamacppInstall(args []string) error {
}
}
// Backend CUSTOM : fork llama.cpp installé depuis une URL Git dans
// backends/<nom>. On NE touche PAS au BIN global — un backend custom se
// choisit par modèle (preset → section Moteur → « backend détecté »). C'est
// exactement le cas d'un moteur ternaire type PrismML qui ne sert qu'à un
// seul modèle : le rattacher globalement casserait les autres presets.
if customURL != "" {
bin, err := installCustomBackend(customURL, customName, ref, func(s string) {
fmt.Printf("%s %s\n", cyan("▶"), s)
})
if err != nil {
return err
}
fmt.Printf("\n%s backend custom compilé : %s\n", green("✓"), bin)
fmt.Printf("Pour l'utiliser : édite un modèle → section %s → « backend détecté » et choisis-le.\n", bold("Moteur"))
return nil
}
if err := requireTools("git", "cmake"); err != nil {
return err
}
@@ -347,3 +370,153 @@ func llamacppStatus(args []string) error {
// detectBuildPlan probes the machine and returns the CMake flags for the best
// available accelerator. Order of preference: CUDA → ROCm/HIP → Metal (macOS)
// → Vulkan → CPU.
// (implémentation dans backend_build.go)
// ---------------------------------------------------------------------------
// Backends custom (fork llama.cpp installé depuis une URL Git)
// ---------------------------------------------------------------------------
// installCustomBackend clone (ou met à jour) un fork de llama.cpp depuis `url`
// dans backends/<name> et compile llama-server avec le plan détecté pour la
// machine. Il NE touche PAS à BIN : un backend custom se choisit par modèle
// (éditeur de preset → section Moteur → « backend détecté »). Renvoie le chemin
// du binaire compilé. `phase` reçoit les étapes de haut niveau (clone, build…) ;
// la sortie détaillée du build passe par le sink habituel (terminal en CLI,
// job web sinon).
func installCustomBackend(url, name, ref string, phase func(string)) (string, error) {
if phase == nil {
phase = func(string) {}
}
url = strings.TrimSpace(url)
if url == "" {
return "", fmt.Errorf("URL du dépôt vide")
}
if !looksLikeGitURL(url) {
return "", fmt.Errorf("URL de dépôt invalide (attendu https://…, git@… ou ssh://…) : %s", url)
}
if strings.TrimSpace(name) == "" {
name = deriveBackendName(url)
}
dir, err := backendDir(name)
if err != nil {
return "", err
}
phase("vérification des outils (git, cmake, compilateur)…")
if err := requireTools("git", "cmake"); err != nil {
return "", err
}
if err := ensureCompiler(); err != nil {
return "", err
}
ensureAccelerator() // best-effort : installe le toolkit GPU si une carte est détectée
if isDir(filepath.Join(dir, ".git")) {
// Backend déjà cloné : on le met à jour plutôt que de re-cloner.
phase("dépôt déjà présent — mise à jour…")
_ = runStep("git fetch", dir, "git", "fetch", "origin", "--quiet")
if ref != "" {
if err := runStep("git checkout", dir, "git", "checkout", ref); err != nil {
return "", err
}
} else if branch := gitOutput(dir, "rev-parse", "--abbrev-ref", "HEAD"); branch != "" && branch != "HEAD" {
_ = runStep("git pull --ff-only", dir, "git", "pull", "--ff-only", "origin", branch)
}
} else {
if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
return "", err
}
phase("clone de " + url + "…")
if err := runStep("git clone", "", "git", "clone", "--depth=1", url, dir); err != nil {
return "", fmt.Errorf("git clone a échoué : %w", err)
}
if ref != "" {
// --depth=1 ne récupère que HEAD ; on approfondit pour atteindre le ref.
_ = runStep("git fetch", dir, "git", "fetch", "--unshallow", "origin")
if err := runStep("git checkout", dir, "git", "checkout", ref); err != nil {
return "", err
}
}
}
plan := detectBuildPlan()
phase(fmt.Sprintf("compilation (backend=%s)…", plan.backend))
if err := buildLlamacpp(dir, plan, false); err != nil {
return "", err
}
bin := llamaServerBin(dir)
if bin == "" {
return "", fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(dir, "build"))
}
return bin, nil
}
func looksLikeGitURL(u string) bool {
for _, p := range []string{"https://", "http://", "git@", "ssh://", "git://"} {
if strings.HasPrefix(u, p) {
return true
}
}
return false
}
// deriveBackendName construit un nom de dossier de backend à partir d'une URL de
// dépôt. On garde le nom du dépôt, préfixé du propriétaire quand ça éviterait
// une collision avec le backend canonique (backends/llama.cpp).
func deriveBackendName(url string) string {
s := strings.TrimSuffix(strings.TrimSpace(url), ".git")
s = strings.TrimRight(s, "/")
parts := strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == ':' })
repo, owner := "", ""
if n := len(parts); n > 0 {
repo = parts[n-1]
if n > 1 {
owner = parts[n-2]
}
}
name := repo
if name == "" {
name = "backend"
}
// Un fork nommé « llama.cpp » écraserait le backend optimisé canonique ; on
// le distingue par le propriétaire (ex. llama.cpp-prismml-eng).
if strings.EqualFold(name, "llama.cpp") && owner != "" {
name = name + "-" + owner
}
return sanitizeBackendName(name)
}
// sanitizeBackendName réduit un nom à un identifiant de dossier sûr
// ([a-z0-9._-]), pour qu'il ne puisse jamais s'échapper de backends/.
func sanitizeBackendName(name string) string {
name = strings.ToLower(strings.TrimSpace(name))
var b strings.Builder
for _, r := range name {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
b.WriteRune(r)
case r == ' ' || r == '/' || r == '\\':
b.WriteByte('-')
}
}
out := strings.Trim(b.String(), ".-")
if out == "" {
return "backend"
}
return out
}
// backendDir renvoie backends/<name>, en refusant tout nom qui s'échapperait du
// dossier backends (../, chemin absolu…).
func backendDir(name string) (string, error) {
name = sanitizeBackendName(name)
root, err := filepath.Abs(filepath.Join(JeanHome(), "backends"))
if err != nil {
return "", err
}
p := filepath.Join(root, name)
if !strings.HasPrefix(p, root+string(filepath.Separator)) {
return "", fmt.Errorf("nom de backend invalide")
}
return p, nil
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.5.5"
const Version = "0.5.6"
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
func Main() {
+403 -53
View File
@@ -311,6 +311,92 @@ button:disabled{opacity:.5;cursor:not-allowed}
.be-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer}
.be-opt input{margin:0;flex-shrink:0}
.be-note{font-size:11px;color:var(--dim);margin-left:auto}
/* ===== Éditeur de preset — listes groupées façon Réglages iOS ======== */
#modal .modal-card{max-width:520px;overflow:hidden}
.pe-body{padding:18px 16px 24px;display:flex;flex-direction:column;gap:26px;overflow-y:auto;overflow-x:hidden;flex:1;background:var(--bg)}
/* Nombres : pas de flèches natives (spinner), champ épuré. */
.pe-val[type=number]{-moz-appearance:textfield}
.pe-val::-webkit-outer-spin-button,.pe-val::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
/* Un groupe = un petit titre + une liste encadrée aux coins arrondis. */
.pe-group{display:flex;flex-direction:column;gap:8px}
.pe-gh{font-size:12px;font-weight:600;color:var(--dim);padding:0 6px}
.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)}
.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}
/* Valeur éditable sans bordure, alignée à droite (nombre / texte). */
.pe-val{border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;padding:4px 0;width:110px;max-width:44vw}
.pe-val::placeholder{color:var(--dim)}
.pe-val:focus{outline:none}
.pe-unit{font-size:12px;color:var(--dim)}
/* Select sans bordure, avec chevron discret. */
.pe-selc{position:relative;display:flex;align-items:center;max-width:100%}
.pe-selc select{appearance:none;-webkit-appearance:none;border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;text-align-last:right;padding:4px 16px 4px 0;cursor:pointer;max-width:100%;text-overflow:ellipsis}
.pe-selc select:focus{outline:none}
.pe-selc::after{content:"›";position:absolute;right:0;transform:rotate(90deg);color:var(--dim);font-size:14px;pointer-events:none}
/* Ligne « champ pleine largeur » (Nom, chemin, config brute…). */
.pe-row.stack{flex-direction:column;align-items:stretch;gap:8px}
.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}
.pe-row.stack.plain .pe-inp:focus{box-shadow:none}
/* Champ + bouton accolé (téléchargement) — même trait que les autres champs,
sans pastille colorée qui ressort. */
.pe-inpbtn{display:flex}
.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0}
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-left:0;border-radius:0 8px 8px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s}
.pe-inpbtn button:hover{color:var(--accent);border-color:var(--accent)}
.pe-inpbtn button:disabled{opacity:.5;cursor:default}
/* Contrôle segmenté (moteur) façon iOS. */
.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:9px;padding:3px;width:100%}
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:7px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s}
.pe-seg>label:has(input:checked){background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.22)}
.pe-seg input{display:none}
.pe-seg .be-note{display:none}
.pe-seg-note{font-size:11px;color:var(--dim);padding:0 2px;min-height:14px}
.pe-note{font-size:11px;color:var(--dim);line-height:1.45}
/* Interrupteur à glissière. */
.pe-switch{position:relative;flex:none;width:40px;height:24px}
.pe-switch input{position:absolute;opacity:0;width:0;height:0}
.pe-sl{position:absolute;inset:0;background:color-mix(in srgb,var(--dim) 55%,transparent);border-radius:999px;transition:background .18s}
.pe-sl::before{content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;background:#fff;border-radius:50%;transition:transform .18s;box-shadow:0 1px 3px rgba(0,0,0,.35)}
.pe-switch input:checked+.pe-sl{background:var(--accent)}
.pe-switch input:checked+.pe-sl::before{transform:translateX(16px)}
.pe-switch input:focus-visible+.pe-sl{box-shadow:0 0 0 3px var(--accent-bg)}
/* Repli « avancé » / config brute : une ligne cliquable dans un groupe. */
.pe-adv{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
.pe-adv>summary{cursor:pointer;font-size:14px;color:var(--text);padding:13px 15px;list-style:none;display:flex;align-items:center;user-select:none}
.pe-adv>summary::-webkit-details-marker{display:none}
.pe-adv>summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:translateY(-2px) rotate(45deg);transition:transform .15s}
.pe-adv[open]>summary::after{transform:translateY(1px) rotate(-135deg)}
.pe-adv[open]>summary{border-bottom:1px solid var(--border)}
.pe-adv .pe-adv-body{padding:14px 15px;display:flex;flex-direction:column;gap:10px}
.pe-adv textarea.pe-inp{min-height:210px;resize:vertical;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:hidden}
/* Ligne « éditer le .env » : chevron de disclosure + textarea de config brute. */
.pe-caret{display:inline-block;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:rotate(-45deg);transition:transform .15s}
.pe-caret.open{transform:rotate(45deg)}
#m-content{min-height:220px;resize:vertical;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:hidden}
/* Nombres avancés : chaque paire label/valeur sur sa propre ligne (réutilise pe-row). */
.pe-adv .pe-list{margin:0}
/* Variante « champ en clair » : page mémoire → le contenu est le champ principal. */
.pe-adv.pe-raw-plain{border:none;background:transparent}
.pe-adv.pe-raw-plain>summary{display:none}
.pe-adv.pe-raw-plain .pe-adv-body{padding:0}
/* Pied de modale. */
.pe-foot{padding:13px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
.pe-foot .foot-r{display:flex;gap:8px}
.pe-btn{margin:0;padding:9px 18px;border-radius:9px;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--panel);color:var(--text)}
.pe-btn:hover{border-color:var(--dim)}
.pe-btn.primary{border-color:var(--accent);background:var(--accent);color:#fff}
.pe-btn.primary:hover{filter:brightness(1.08)}
.pe-btn.ghost-danger{border-color:transparent;background:transparent;color:var(--err);padding:9px 10px}
.pe-btn.ghost-danger:hover{background:var(--danger-bg)}
.pe-delmodel{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dim);cursor:pointer}
.pe-delmodel input{margin:0}
/* Éditeur de preset/mémoire — mise en forme propre du modale */
.modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:30;align-items:center;justify-content:center;padding:20px}
.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,.5)}
@@ -403,6 +489,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
</div>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état.
C'est le seul endroit où l'on voit pourquoi un modèle refuse de charger. -->
<div id="svc-log-box" style="display:none;margin:0 0 10px">
@@ -549,6 +636,11 @@ button:disabled{opacity:.5;cursor:not-allowed}
<div class="lc-mode-d">compilée pour votre machine, plus longue</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
<div class="lc-mode-t">🧩 Backend personnalisé</div>
<div class="lc-mode-d">forks llama.cpp pour modèles spéciaux (ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
</div>
<div id="lc-job" style="display:none;margin-top:10px">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
@@ -619,6 +711,36 @@ button:disabled{opacity:.5;cursor:not-allowed}
</div>
</div>
</div>
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
<strong>🧩 Backends personnalisés</strong>
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
<div class="muted" style="font-size:12px;line-height:1.5">Compile un fork de llama.cpp depuis un dépôt Git — pour les modèles qui exigent un moteur spécial (quant ternaire, etc.). Un backend personnalisé <b>ne remplace pas</b> le moteur : vous le choisissez ensuite <b>par modèle</b> (bouton ✎ → section Moteur → « backend détecté »).</div>
<div class="fld">
<label class="fld-l">Installés</label>
<div id="lc-custom-list" class="mcp-list"></div>
</div>
<div class="fld">
<label class="fld-l" for="lc-custom-url">Installer depuis un dépôt Git</label>
<input id="lc-custom-url" class="ctl" placeholder="https://github.com/owner/llama.cpp-fork">
</div>
<div class="fld">
<label class="fld-l" for="lc-custom-name">Nom <span class="opt">(optionnel — déduit de l'URL sinon)</span></label>
<input id="lc-custom-name" class="ctl" placeholder="llama.cpp-prism">
</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="closeCustomBackends()">fermer</button>
<button class="btn-save" onclick="lcInstallCustom()">Installer &amp; compiler</button>
</div>
</div>
</div>
</div>
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
@@ -684,62 +806,131 @@ button:disabled{opacity:.5;cursor:not-allowed}
<strong id="modal-title">Preset</strong>
<button class="modal-x" onclick="closeModal()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
<div class="fld">
<label class="fld-l" for="m-name">Nom <span class="opt">(espaces et accents OK, sauf / et \)</span></label>
<input id="m-name" class="ctl">
</div>
<div id="m-model-row" class="card" style="display:none">
<div class="card-h">Modèle &amp; moteur</div>
<div class="fld">
<label class="fld-l">Moteur <span class="opt">— quel llama.cpp fait tourner ce modèle</span></label>
<div class="seg">
<label><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>⚡ Rapide</span><span class="be-note" id="be-fast-note"></span></label>
<label><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>🔧 Optimisé</span><span class="be-note" id="be-opt-note"></span></label>
<label><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>📁 Perso</span></label>
</div>
<div id="m-backend-custom" style="display:none;margin-top:8px">
<input id="m-backend-path" class="ctl" oninput="onCustomPath()" placeholder="chemin vers llama-server(.exe)">
<select id="m-backend-detected" class="ctl" onchange="onPickDetected()" style="margin-top:6px"></select>
<div class="fld-hint" id="be-drop-hint"></div>
</div>
</div>
<div class="fld">
<label class="fld-l" for="m-model">Modèle <span class="opt">(remplace la ligne MODEL=)</span></label>
<select id="m-model" class="ctl" onchange="onPickModel()"></select>
</div>
<div class="fld">
<label class="fld-l" for="m-hf-url">Télécharger un modèle <span class="opt">(lien direct vers un .gguf)</span></label>
<div class="input-btn">
<input id="m-hf-url" class="ctl" placeholder="https://…/model.gguf">
<button id="m-hf-btn" class="btn-in" onclick="startDownload()" title="Télécharger">⬇</button>
</div>
<div id="m-hf-progress" class="fld-hint" style="display:none"></div>
</div>
<div class="fld">
<label class="fld-l" for="m-quant">Quantization <span class="opt">(vide = détection auto depuis le nom)</span></label>
<input id="m-quant" class="ctl" oninput="applyQuant()" placeholder="ex: Q4_K_M, IQ3_XXS, BF16…">
<div class="pe-body">
<div class="pe-group">
<div class="pe-gh">Nom</div>
<div class="pe-list">
<div class="pe-row stack plain"><input id="m-name" class="pe-inp" placeholder="Nom du preset"></div>
</div>
</div>
<div class="fld">
<label class="fld-l" for="m-content">Contenu <span class="opt">(fichier de config brut)</span></label>
<textarea id="m-content" class="ctl ta" spellcheck="false"></textarea>
<div id="m-model-row" style="display:none;flex-direction:column;gap:26px">
<div class="pe-group">
<div class="pe-gh">Modèle</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Fichier</span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-model" onchange="onPickModel()"></select></span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">⬇</button>
</div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Moteur</div>
<div class="pe-seg">
<label><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Rapide</span><span class="be-note" id="be-fast-note"></span></label>
<label><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Optimisé</span><span class="be-note" id="be-opt-note"></span></label>
<label><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
</div>
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
<div class="pe-row">
<span class="pe-row-l">Backend</span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-backend-detected" onchange="onPickDetected()"></select></span></span>
</div>
<div class="pe-row stack">
<input id="m-backend-path" class="pe-inp" oninput="onCustomPath()" placeholder="ou un chemin vers llama-server(.exe)">
<div class="pe-note" id="be-drop-hint"></div>
</div>
</div>
</div>
</div>
<div id="m-settings-row" class="pe-group" style="display:none">
<div class="pe-gh">Réglages</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Contexte</span>
<span class="pe-row-c"><input id="s-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768" oninput="cfgWriteKey('CTX', this.value)"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row">
<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">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">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)">
<option value="">f16 (max qualité)</option>
<option value="q8_0">q8_0 (léger)</option>
<option value="q4_0">q4_0 (très léger)</option>
<option value="q5_1">q5_1</option>
<option value="bf16">bf16</option>
</select></span></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>
</div>
<div class="pe-row">
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<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>
</details>
</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-c"><span id="m-raw-caret" class="pe-caret"></span></span>
</div>
<div id="m-raw-body" class="pe-row stack" style="display:none">
<textarea id="m-content" class="pe-inp" spellcheck="false"></textarea>
</div>
</div>
</div>
</div>
<div class="modal-foot">
<div class="pe-foot">
<div class="foot-l">
<button id="m-del" class="btn-danger" onclick="delItem()">supprimer</button>
<label id="m-del-model-wrap" class="del-model" style="display:none"><input type="checkbox" id="m-del-model"> + le modèle .gguf</label>
<button id="m-del" class="pe-btn ghost-danger" onclick="delItem()">supprimer</button>
<label id="m-del-model-wrap" class="pe-delmodel" style="display:none"><input type="checkbox" id="m-del-model"> + le modèle .gguf</label>
</div>
<div class="foot-r">
<button onclick="closeModal()">annuler</button>
<button class="btn-save" onclick="saveItem()">enregistrer</button>
<button class="pe-btn" onclick="closeModal()">annuler</button>
<button class="pe-btn primary" onclick="saveItem()">enregistrer</button>
</div>
</div>
</div>
@@ -934,6 +1125,7 @@ async function loadStatus(){
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
let cls='err', txt=s.state;
if(s.active && s.health){ cls='ok'; txt='prêt'; }
else if(s.load_error){ cls='err'; txt='modèle incompatible'; }
else if(s.active){ cls='loading'; txt='chargement…'; }
el.className='statuspill '+cls;
el.innerHTML='<span class="dot"></span>'+txt+' <span class="port">:'+s.port+'</span>';
@@ -949,6 +1141,13 @@ async function loadStatus(){
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
else { wb.style.display='none'; }
}
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
const me=document.getElementById('model-err');
if(me){
if(s.load_error){ me.textContent='⚠ '+s.load_error; me.style.display=''; }
else { me.style.display='none'; }
}
}
// Journal du moteur — replié par défaut, on l'ouvre en cliquant la pastille.
function toggleSvcLog(){
@@ -1356,18 +1555,35 @@ async function openItem(kind, key){
document.getElementById('m-del').style.display = key ? 'inline-block' : 'none';
// Model picker is preset-only: it edits the MODEL= line of config.env.
const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-row');
const delModelWrap = document.getElementById('m-del-model-wrap');
const rawHead = document.getElementById('m-raw-head');
const rawToggle = document.getElementById('m-raw-toggle');
const rawBody = document.getElementById('m-raw-body');
const rawCaret = document.getElementById('m-raw-caret');
if(kind === 'preset'){
modelRow.style.display = 'flex';
settingsRow.style.display = 'flex';
document.getElementById('m-hf-url').value = '';
document.getElementById('m-hf-progress').style.display = 'none';
document.getElementById('m-del-model').checked = false;
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
delModelWrap.style.display = key ? 'inline-flex' : 'none';
// Preset : la config brute est une ligne repliable, fermée par défaut.
rawHead.textContent = 'Configuration';
rawToggle.style.display = '';
rawBody.style.display = 'none';
rawCaret.classList.remove('open');
await Promise.all([populateBackend(), populateModelPicker()]);
} else {
modelRow.style.display = 'none';
settingsRow.style.display = 'none';
delModelWrap.style.display = 'none';
// Page mémoire : le contenu EST le champ principal — affiché en clair.
rawHead.textContent = 'Contenu';
rawToggle.style.display = 'none';
rawBody.style.display = '';
}
document.getElementById('modal').style.display = 'flex';
}
@@ -1442,8 +1658,6 @@ async function populateBackend(){
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
beOptPath = lc.bin || '';
document.getElementById('be-fast-note').textContent = beFastPath ? '' : 'non installée';
document.getElementById('be-opt-note').textContent = beOptPath ? '' : 'non installée';
// 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).
const detected = await jget('/api/backends');
@@ -1452,7 +1666,7 @@ async function populateBackend(){
for(const b of (detected||[])) html += '<option value="'+b.path+'">'+b.name+'</option>';
sel.innerHTML = html;
document.getElementById('be-drop-hint').textContent = lc.backends_dir
? ('Astuce : dépose ton binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : '';
? ('Astuce : déposez votre binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : '';
// Sélectionne l'option correspondant au BIN actuel du preset.
const cur = currentBinInTextarea();
let mode = 'custom';
@@ -1469,10 +1683,10 @@ function toggleBackendCustom(mode){
function onBackendMode(mode){
toggleBackendCustom(mode);
if(mode === 'fast'){
if(!beFastPath){ toast('installe d\'abord la version rapide (section Backend llama.cpp)'); return; }
if(!beFastPath){ toast('installez d\'abord la version rapide (section MOTEUR)'); return; }
setBinInTextarea(beFastPath); toast('moteur : rapide');
} else if(mode === 'opt'){
if(!beOptPath){ toast('installe d\'abord la version optimisée (section Backend llama.cpp)'); return; }
if(!beOptPath){ toast('installez d\'abord la version optimisée (section MOTEUR)'); return; }
setBinInTextarea(beOptPath); toast('moteur : optimisé');
}
// custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend
@@ -1503,6 +1717,72 @@ function applyQuant(){
ta.value = ta.value.replace(re, '').replace(/\n{3,}/g,'\n\n');
}
}
// ---- Réglages simples : champs de formulaire <-> lignes du fichier .env -----
// Le fichier de config (textarea m-content) reste la source de vérité : chaque
// champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT.
function cfgReadKey(key){
const m = document.getElementById('m-content').value.match(new RegExp('^[ \\t]*'+key+'[ \\t]*=[ \\t]*"?([^"\\n]*)"?[ \\t]*$','m'));
return m ? m[1].trim() : '';
}
function cfgWriteKey(key, val){
const ta = document.getElementById('m-content');
val = String(val==null?'':val).trim();
const reLine = new RegExp('^[ \\t]*'+key+'[ \\t]*=.*$','m');
if(val === ''){ // clé vidée → on retire la ligne
ta.value = ta.value.replace(new RegExp('^[ \\t]*'+key+'[ \\t]*=.*\\n?','m'),'').replace(/\n{3,}/g,'\n\n');
return;
}
const line = key+'='+(/\s/.test(val) ? '"'+val+'"' : val);
if(reLine.test(ta.value)) ta.value = ta.value.replace(reLine, line);
else ta.value = ta.value.replace(/\s*$/,'') + '\n'+line+'\n';
}
// EXTRA_ARGS : liste de drapeaux passés tels quels à llama-server. On les édite
// jeton par jeton pour les interrupteurs (mlock, flash-attn, n-cpu-moe…), sans
// toucher aux autres drapeaux (--jinja, --device…) qui restent en config brute.
function eaTokens(){ return cfgReadKey('EXTRA_ARGS').split(/\s+/).filter(Boolean); }
function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); }
function eaHasFlag(flag){ return eaTokens().includes(flag); }
function eaToggleFlag(flag, on){
const t = eaTokens().filter(x=>x!==flag);
if(on) t.push(flag);
eaSetTokens(t);
}
function eaGetValued(flag){
const t = eaTokens(), i = t.indexOf(flag);
return (i>=0 && i+1<t.length && !t[i+1].startsWith('-')) ? t[i+1] : '';
}
function eaSetValued(flag, val){
const t = eaTokens(), i = t.indexOf(flag);
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
val = String(val||'').trim();
if(val !== '') t.push(flag, val);
eaSetTokens(t);
}
// Remplit les champs de réglage depuis le contenu courant du preset.
function populateSettings(){
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
set('s-ctx', cfgReadKey('CTX'));
set('s-ngl', cfgReadKey('NGL'));
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
set('s-moe', eaGetValued('--n-cpu-moe'));
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'));
}
// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset.
function toggleRaw(){
const b = document.getElementById('m-raw-body');
const c = document.getElementById('m-raw-caret');
const show = b.style.display === 'none';
b.style.display = show ? '' : 'none';
if(c) c.classList.toggle('open', show);
}
const openPreset = (id)=>openItem('preset', id);
const openMem = (n)=>openItem('mem', n);
function closeModal(){ document.getElementById('modal').style.display = 'none'; }
@@ -1973,6 +2253,7 @@ async function loadLlamacpp(){
}
lcRenderMode('fast', fastInstalled);
lcRenderMode('opt', optInstalled);
lcRenderCustomCard();
// Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
if(s.job && s.job.exists && s.job.running && !lcPoll){
@@ -2050,6 +2331,75 @@ async function lcUpdate(mode){
lcStartPolling();
}
// --- Backends personnalisés (3e carte + modal de gestion) ------------------
let lcCustomBackends = [];
// État affiché sur la carte « Backend personnalisé » : nombre d'installés.
async function lcRenderCustomCard(){
const state = document.getElementById('lc-custom-state');
if(!state) return;
try{ lcCustomBackends = await jget('/api/backends/custom') || []; }catch(_){ lcCustomBackends = []; }
const n = lcCustomBackends.length;
document.getElementById('lc-mode-custom').classList.toggle('installed', n>0);
state.innerHTML = n>0
? '<span class="lc-mode-active-tag">✓ '+n+' installé'+(n>1?'s':'')+'</span><span class="lc-mode-go">→ gérer</span>'
: '<span class="lc-mode-go">→ voir / installer</span>';
}
function openCustomBackends(){
document.getElementById('lc-custom-modal').style.display = 'flex';
document.getElementById('lc-custom-url').value = '';
document.getElementById('lc-custom-name').value = '';
loadCustomBackends();
}
function closeCustomBackends(){ document.getElementById('lc-custom-modal').style.display = 'none'; }
// Liste les backends custom dans le modal, avec un bouton supprimer par ligne.
async function loadCustomBackends(){
const box = document.getElementById('lc-custom-list');
box.innerHTML = '<span class="muted" style="font-size:12px">chargement…</span>';
let list = [];
try{ list = await jget('/api/backends/custom') || []; }catch(_){}
lcCustomBackends = list;
if(!list.length){ box.innerHTML = '<span class="muted" style="font-size:12px">aucun backend personnalisé pour l\'instant.</span>'; return; }
box.innerHTML = list.map(b=>{
const nm = String(b.name).replace(/[<>&]/g,'');
const used = b.in_use ? '<span class="mcp-tag" style="border-color:var(--accent);color:var(--accent)">utilisé</span>' : '';
return '<div class="mcp-row" style="cursor:default">'
+ '<span class="mcp-dot '+(b.in_use?'mcp-dot-ok':'mcp-dot-off')+'"></span>'
+ '<div class="mcp-info"><div class="mcp-name">'+nm+'</div>'
+ '<div class="mcp-meta">'+used+'<span class="mcp-tag" title="'+String(b.path).replace(/"/g,'&quot;')+'">'+String(b.path).split('/').slice(-3).join('/').replace(/[<>&]/g,'')+'</span></div></div>'
+ '<button class="btn-danger" style="padding:3px 8px;font-size:11px" onclick="lcUninstallCustom(\''+nm.replace(/'/g,"\\'")+'\')">supprimer</button>'
+ '</div>';
}).join('');
}
// Installer un backend CUSTOM depuis une URL de dépôt Git (fork llama.cpp).
// Cloné + compilé dans backends/<nom>, SANS toucher au moteur global : il
// apparaît ensuite dans le menu « backend détecté » de l'éditeur de modèle.
async function lcInstallCustom(){
const url = (document.getElementById('lc-custom-url').value||'').trim();
if(!url){ toast('collez l\'URL d\'un dépôt Git'); return; }
const name = (document.getElementById('lc-custom-name').value||'').trim();
if(!await askConfirm('Cloner et compiler ce backend depuis :\n'+url+'\n\nCela peut prendre de longues minutes (surtout avec une carte NVIDIA). Il ne remplace pas le moteur global — vous le choisirez par modèle.', {title:'Backend personnalisé', okText:'Installer'})) return;
const r = await jpost('/api/llamacpp/install-custom', {repo:url, name});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
closeCustomBackends();
document.getElementById('lc-details').open = true;
lcStartPolling();
}
// Désinstaller (supprime le dossier backends/<name>). Le serveur refuse si le
// backend sert de moteur au modèle actif.
async function lcUninstallCustom(name){
if(!await askConfirm('Supprimer le backend « '+name+' » ? Son dossier compilé sera effacé. Les modèles qui l\'utilisent devront être repointés sur un autre moteur.', {title:'Supprimer le backend', okText:'Supprimer', danger:true})) return;
const r = await jpost('/api/llamacpp/uninstall-custom', {name});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
toast('backend supprimé');
loadCustomBackends();
lcRenderCustomCard();
}
// --- Progression de l'installation (téléchargement / compilation) ----------
function lcBusy(on){ document.querySelector('.lc-modes').classList.toggle('busy', on); }
+153 -48
View File
@@ -28,6 +28,7 @@
</div>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état.
C'est le seul endroit où l'on voit pourquoi un modèle refuse de charger. -->
<div id="svc-log-box" style="display:none;margin:0 0 10px">
@@ -174,6 +175,11 @@
<div class="lc-mode-d">compilée pour votre machine, plus longue</div>
<div class="lc-mode-state" id="lc-opt-state"></div>
</div>
<div class="lc-mode" id="lc-mode-custom" onclick="openCustomBackends()">
<div class="lc-mode-t">🧩 Backend personnalisé</div>
<div class="lc-mode-d">forks llama.cpp pour modèles spéciaux (ternaire…)</div>
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
</div>
<div id="lc-job" style="display:none;margin-top:10px">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
@@ -244,6 +250,36 @@
</div>
</div>
</div>
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
<strong>🧩 Backends personnalisés</strong>
<button class="modal-x" onclick="closeCustomBackends()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
<div class="muted" style="font-size:12px;line-height:1.5">Compile un fork de llama.cpp depuis un dépôt Git — pour les modèles qui exigent un moteur spécial (quant ternaire, etc.). Un backend personnalisé <b>ne remplace pas</b> le moteur : vous le choisissez ensuite <b>par modèle</b> (bouton ✎ → section Moteur → « backend détecté »).</div>
<div class="fld">
<label class="fld-l">Installés</label>
<div id="lc-custom-list" class="mcp-list"></div>
</div>
<div class="fld">
<label class="fld-l" for="lc-custom-url">Installer depuis un dépôt Git</label>
<input id="lc-custom-url" class="ctl" placeholder="https://github.com/owner/llama.cpp-fork">
</div>
<div class="fld">
<label class="fld-l" for="lc-custom-name">Nom <span class="opt">(optionnel — déduit de l'URL sinon)</span></label>
<input id="lc-custom-name" class="ctl" placeholder="llama.cpp-prism">
</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="closeCustomBackends()">fermer</button>
<button class="btn-save" onclick="lcInstallCustom()">Installer &amp; compiler</button>
</div>
</div>
</div>
</div>
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
@@ -309,62 +345,131 @@
<strong id="modal-title">Preset</strong>
<button class="modal-x" onclick="closeModal()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
<div class="fld">
<label class="fld-l" for="m-name">Nom <span class="opt">(espaces et accents OK, sauf / et \)</span></label>
<input id="m-name" class="ctl">
</div>
<div id="m-model-row" class="card" style="display:none">
<div class="card-h">Modèle &amp; moteur</div>
<div class="fld">
<label class="fld-l">Moteur <span class="opt">— quel llama.cpp fait tourner ce modèle</span></label>
<div class="seg">
<label><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>⚡ Rapide</span><span class="be-note" id="be-fast-note"></span></label>
<label><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>🔧 Optimisé</span><span class="be-note" id="be-opt-note"></span></label>
<label><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>📁 Perso</span></label>
</div>
<div id="m-backend-custom" style="display:none;margin-top:8px">
<input id="m-backend-path" class="ctl" oninput="onCustomPath()" placeholder="chemin vers llama-server(.exe)">
<select id="m-backend-detected" class="ctl" onchange="onPickDetected()" style="margin-top:6px"></select>
<div class="fld-hint" id="be-drop-hint"></div>
</div>
</div>
<div class="fld">
<label class="fld-l" for="m-model">Modèle <span class="opt">(remplace la ligne MODEL=)</span></label>
<select id="m-model" class="ctl" onchange="onPickModel()"></select>
</div>
<div class="fld">
<label class="fld-l" for="m-hf-url">Télécharger un modèle <span class="opt">(lien direct vers un .gguf)</span></label>
<div class="input-btn">
<input id="m-hf-url" class="ctl" placeholder="https://…/model.gguf">
<button id="m-hf-btn" class="btn-in" onclick="startDownload()" title="Télécharger">⬇</button>
</div>
<div id="m-hf-progress" class="fld-hint" style="display:none"></div>
</div>
<div class="fld">
<label class="fld-l" for="m-quant">Quantization <span class="opt">(vide = détection auto depuis le nom)</span></label>
<input id="m-quant" class="ctl" oninput="applyQuant()" placeholder="ex: Q4_K_M, IQ3_XXS, BF16…">
<div class="pe-body">
<div class="pe-group">
<div class="pe-gh">Nom</div>
<div class="pe-list">
<div class="pe-row stack plain"><input id="m-name" class="pe-inp" placeholder="Nom du preset"></div>
</div>
</div>
<div class="fld">
<label class="fld-l" for="m-content">Contenu <span class="opt">(fichier de config brut)</span></label>
<textarea id="m-content" class="ctl ta" spellcheck="false"></textarea>
<div id="m-model-row" style="display:none;flex-direction:column;gap:26px">
<div class="pe-group">
<div class="pe-gh">Modèle</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Fichier</span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-model" onchange="onPickModel()"></select></span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
<input id="m-hf-url" class="pe-inp" placeholder="https://…/model.gguf">
<button id="m-hf-btn" onclick="startDownload()" title="Télécharger">⬇</button>
</div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Moteur</div>
<div class="pe-seg">
<label><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Rapide</span><span class="be-note" id="be-fast-note"></span></label>
<label><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Optimisé</span><span class="be-note" id="be-opt-note"></span></label>
<label><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
</div>
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
<div class="pe-row">
<span class="pe-row-l">Backend</span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-backend-detected" onchange="onPickDetected()"></select></span></span>
</div>
<div class="pe-row stack">
<input id="m-backend-path" class="pe-inp" oninput="onCustomPath()" placeholder="ou un chemin vers llama-server(.exe)">
<div class="pe-note" id="be-drop-hint"></div>
</div>
</div>
</div>
</div>
<div id="m-settings-row" class="pe-group" style="display:none">
<div class="pe-gh">Réglages</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Contexte</span>
<span class="pe-row-c"><input id="s-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768" oninput="cfgWriteKey('CTX', this.value)"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row">
<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">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">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)">
<option value="">f16 (max qualité)</option>
<option value="q8_0">q8_0 (léger)</option>
<option value="q4_0">q4_0 (très léger)</option>
<option value="q5_1">q5_1</option>
<option value="bf16">bf16</option>
</select></span></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>
</div>
<div class="pe-row">
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<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>
</details>
</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-c"><span id="m-raw-caret" class="pe-caret"></span></span>
</div>
<div id="m-raw-body" class="pe-row stack" style="display:none">
<textarea id="m-content" class="pe-inp" spellcheck="false"></textarea>
</div>
</div>
</div>
</div>
<div class="modal-foot">
<div class="pe-foot">
<div class="foot-l">
<button id="m-del" class="btn-danger" onclick="delItem()">supprimer</button>
<label id="m-del-model-wrap" class="del-model" style="display:none"><input type="checkbox" id="m-del-model"> + le modèle .gguf</label>
<button id="m-del" class="pe-btn ghost-danger" onclick="delItem()">supprimer</button>
<label id="m-del-model-wrap" class="pe-delmodel" style="display:none"><input type="checkbox" id="m-del-model"> + le modèle .gguf</label>
</div>
<div class="foot-r">
<button onclick="closeModal()">annuler</button>
<button class="btn-save" onclick="saveItem()">enregistrer</button>
<button class="pe-btn" onclick="closeModal()">annuler</button>
<button class="pe-btn primary" onclick="saveItem()">enregistrer</button>
</div>
</div>
</div>
+8
View File
@@ -5,6 +5,7 @@ async function loadStatus(){
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
let cls='err', txt=s.state;
if(s.active && s.health){ cls='ok'; txt='prêt'; }
else if(s.load_error){ cls='err'; txt='modèle incompatible'; }
else if(s.active){ cls='loading'; txt='chargement…'; }
el.className='statuspill '+cls;
el.innerHTML='<span class="dot"></span>'+txt+' <span class="port">:'+s.port+'</span>';
@@ -20,6 +21,13 @@ async function loadStatus(){
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
else { wb.style.display='none'; }
}
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
const me=document.getElementById('model-err');
if(me){
if(s.load_error){ me.textContent='⚠ '+s.load_error; me.style.display=''; }
else { me.style.display='none'; }
}
}
// Journal du moteur — replié par défaut, on l'ouvre en cliquant la pastille.
function toggleSvcLog(){
+86 -5
View File
@@ -68,18 +68,35 @@ async function openItem(kind, key){
document.getElementById('m-del').style.display = key ? 'inline-block' : 'none';
// Model picker is preset-only: it edits the MODEL= line of config.env.
const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-row');
const delModelWrap = document.getElementById('m-del-model-wrap');
const rawHead = document.getElementById('m-raw-head');
const rawToggle = document.getElementById('m-raw-toggle');
const rawBody = document.getElementById('m-raw-body');
const rawCaret = document.getElementById('m-raw-caret');
if(kind === 'preset'){
modelRow.style.display = 'flex';
settingsRow.style.display = 'flex';
document.getElementById('m-hf-url').value = '';
document.getElementById('m-hf-progress').style.display = 'none';
document.getElementById('m-del-model').checked = false;
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
delModelWrap.style.display = key ? 'inline-flex' : 'none';
// Preset : la config brute est une ligne repliable, fermée par défaut.
rawHead.textContent = 'Configuration';
rawToggle.style.display = '';
rawBody.style.display = 'none';
rawCaret.classList.remove('open');
await Promise.all([populateBackend(), populateModelPicker()]);
} else {
modelRow.style.display = 'none';
settingsRow.style.display = 'none';
delModelWrap.style.display = 'none';
// Page mémoire : le contenu EST le champ principal — affiché en clair.
rawHead.textContent = 'Contenu';
rawToggle.style.display = 'none';
rawBody.style.display = '';
}
document.getElementById('modal').style.display = 'flex';
}
@@ -154,8 +171,6 @@ async function populateBackend(){
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
beOptPath = lc.bin || '';
document.getElementById('be-fast-note').textContent = beFastPath ? '' : 'non installée';
document.getElementById('be-opt-note').textContent = beOptPath ? '' : 'non installée';
// 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).
const detected = await jget('/api/backends');
@@ -164,7 +179,7 @@ async function populateBackend(){
for(const b of (detected||[])) html += '<option value="'+b.path+'">'+b.name+'</option>';
sel.innerHTML = html;
document.getElementById('be-drop-hint').textContent = lc.backends_dir
? ('Astuce : dépose ton binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : '';
? ('Astuce : déposez votre binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : '';
// Sélectionne l'option correspondant au BIN actuel du preset.
const cur = currentBinInTextarea();
let mode = 'custom';
@@ -181,10 +196,10 @@ function toggleBackendCustom(mode){
function onBackendMode(mode){
toggleBackendCustom(mode);
if(mode === 'fast'){
if(!beFastPath){ toast('installe d\'abord la version rapide (section Backend llama.cpp)'); return; }
if(!beFastPath){ toast('installez d\'abord la version rapide (section MOTEUR)'); return; }
setBinInTextarea(beFastPath); toast('moteur : rapide');
} else if(mode === 'opt'){
if(!beOptPath){ toast('installe d\'abord la version optimisée (section Backend llama.cpp)'); return; }
if(!beOptPath){ toast('installez d\'abord la version optimisée (section MOTEUR)'); return; }
setBinInTextarea(beOptPath); toast('moteur : optimisé');
}
// custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend
@@ -215,6 +230,72 @@ function applyQuant(){
ta.value = ta.value.replace(re, '').replace(/\n{3,}/g,'\n\n');
}
}
// ---- Réglages simples : champs de formulaire <-> lignes du fichier .env -----
// Le fichier de config (textarea m-content) reste la source de vérité : chaque
// champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT.
function cfgReadKey(key){
const m = document.getElementById('m-content').value.match(new RegExp('^[ \\t]*'+key+'[ \\t]*=[ \\t]*"?([^"\\n]*)"?[ \\t]*$','m'));
return m ? m[1].trim() : '';
}
function cfgWriteKey(key, val){
const ta = document.getElementById('m-content');
val = String(val==null?'':val).trim();
const reLine = new RegExp('^[ \\t]*'+key+'[ \\t]*=.*$','m');
if(val === ''){ // clé vidée → on retire la ligne
ta.value = ta.value.replace(new RegExp('^[ \\t]*'+key+'[ \\t]*=.*\\n?','m'),'').replace(/\n{3,}/g,'\n\n');
return;
}
const line = key+'='+(/\s/.test(val) ? '"'+val+'"' : val);
if(reLine.test(ta.value)) ta.value = ta.value.replace(reLine, line);
else ta.value = ta.value.replace(/\s*$/,'') + '\n'+line+'\n';
}
// EXTRA_ARGS : liste de drapeaux passés tels quels à llama-server. On les édite
// jeton par jeton pour les interrupteurs (mlock, flash-attn, n-cpu-moe…), sans
// toucher aux autres drapeaux (--jinja, --device…) qui restent en config brute.
function eaTokens(){ return cfgReadKey('EXTRA_ARGS').split(/\s+/).filter(Boolean); }
function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); }
function eaHasFlag(flag){ return eaTokens().includes(flag); }
function eaToggleFlag(flag, on){
const t = eaTokens().filter(x=>x!==flag);
if(on) t.push(flag);
eaSetTokens(t);
}
function eaGetValued(flag){
const t = eaTokens(), i = t.indexOf(flag);
return (i>=0 && i+1<t.length && !t[i+1].startsWith('-')) ? t[i+1] : '';
}
function eaSetValued(flag, val){
const t = eaTokens(), i = t.indexOf(flag);
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
val = String(val||'').trim();
if(val !== '') t.push(flag, val);
eaSetTokens(t);
}
// Remplit les champs de réglage depuis le contenu courant du preset.
function populateSettings(){
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
set('s-ctx', cfgReadKey('CTX'));
set('s-ngl', cfgReadKey('NGL'));
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
set('s-moe', eaGetValued('--n-cpu-moe'));
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'));
}
// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset.
function toggleRaw(){
const b = document.getElementById('m-raw-body');
const c = document.getElementById('m-raw-caret');
const show = b.style.display === 'none';
b.style.display = show ? '' : 'none';
if(c) c.classList.toggle('open', show);
}
const openPreset = (id)=>openItem('preset', id);
const openMem = (n)=>openItem('mem', n);
function closeModal(){ document.getElementById('modal').style.display = 'none'; }
+70
View File
@@ -22,6 +22,7 @@ async function loadLlamacpp(){
}
lcRenderMode('fast', fastInstalled);
lcRenderMode('opt', optInstalled);
lcRenderCustomCard();
// Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
if(s.job && s.job.exists && s.job.running && !lcPoll){
@@ -99,6 +100,75 @@ async function lcUpdate(mode){
lcStartPolling();
}
// --- Backends personnalisés (3e carte + modal de gestion) ------------------
let lcCustomBackends = [];
// État affiché sur la carte « Backend personnalisé » : nombre d'installés.
async function lcRenderCustomCard(){
const state = document.getElementById('lc-custom-state');
if(!state) return;
try{ lcCustomBackends = await jget('/api/backends/custom') || []; }catch(_){ lcCustomBackends = []; }
const n = lcCustomBackends.length;
document.getElementById('lc-mode-custom').classList.toggle('installed', n>0);
state.innerHTML = n>0
? '<span class="lc-mode-active-tag">✓ '+n+' installé'+(n>1?'s':'')+'</span><span class="lc-mode-go">→ gérer</span>'
: '<span class="lc-mode-go">→ voir / installer</span>';
}
function openCustomBackends(){
document.getElementById('lc-custom-modal').style.display = 'flex';
document.getElementById('lc-custom-url').value = '';
document.getElementById('lc-custom-name').value = '';
loadCustomBackends();
}
function closeCustomBackends(){ document.getElementById('lc-custom-modal').style.display = 'none'; }
// Liste les backends custom dans le modal, avec un bouton supprimer par ligne.
async function loadCustomBackends(){
const box = document.getElementById('lc-custom-list');
box.innerHTML = '<span class="muted" style="font-size:12px">chargement…</span>';
let list = [];
try{ list = await jget('/api/backends/custom') || []; }catch(_){}
lcCustomBackends = list;
if(!list.length){ box.innerHTML = '<span class="muted" style="font-size:12px">aucun backend personnalisé pour l\'instant.</span>'; return; }
box.innerHTML = list.map(b=>{
const nm = String(b.name).replace(/[<>&]/g,'');
const used = b.in_use ? '<span class="mcp-tag" style="border-color:var(--accent);color:var(--accent)">utilisé</span>' : '';
return '<div class="mcp-row" style="cursor:default">'
+ '<span class="mcp-dot '+(b.in_use?'mcp-dot-ok':'mcp-dot-off')+'"></span>'
+ '<div class="mcp-info"><div class="mcp-name">'+nm+'</div>'
+ '<div class="mcp-meta">'+used+'<span class="mcp-tag" title="'+String(b.path).replace(/"/g,'&quot;')+'">'+String(b.path).split('/').slice(-3).join('/').replace(/[<>&]/g,'')+'</span></div></div>'
+ '<button class="btn-danger" style="padding:3px 8px;font-size:11px" onclick="lcUninstallCustom(\''+nm.replace(/'/g,"\\'")+'\')">supprimer</button>'
+ '</div>';
}).join('');
}
// Installer un backend CUSTOM depuis une URL de dépôt Git (fork llama.cpp).
// Cloné + compilé dans backends/<nom>, SANS toucher au moteur global : il
// apparaît ensuite dans le menu « backend détecté » de l'éditeur de modèle.
async function lcInstallCustom(){
const url = (document.getElementById('lc-custom-url').value||'').trim();
if(!url){ toast('collez l\'URL d\'un dépôt Git'); return; }
const name = (document.getElementById('lc-custom-name').value||'').trim();
if(!await askConfirm('Cloner et compiler ce backend depuis :\n'+url+'\n\nCela peut prendre de longues minutes (surtout avec une carte NVIDIA). Il ne remplace pas le moteur global — vous le choisirez par modèle.', {title:'Backend personnalisé', okText:'Installer'})) return;
const r = await jpost('/api/llamacpp/install-custom', {repo:url, name});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
closeCustomBackends();
document.getElementById('lc-details').open = true;
lcStartPolling();
}
// Désinstaller (supprime le dossier backends/<name>). Le serveur refuse si le
// backend sert de moteur au modèle actif.
async function lcUninstallCustom(name){
if(!await askConfirm('Supprimer le backend « '+name+' » ? Son dossier compilé sera effacé. Les modèles qui l\'utilisent devront être repointés sur un autre moteur.', {title:'Supprimer le backend', okText:'Supprimer', danger:true})) return;
const r = await jpost('/api/llamacpp/uninstall-custom', {name});
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
toast('backend supprimé');
loadCustomBackends();
lcRenderCustomCard();
}
// --- Progression de l'installation (téléchargement / compilation) ----------
function lcBusy(on){ document.querySelector('.lc-modes').classList.toggle('busy', on); }
+86
View File
@@ -292,6 +292,92 @@ button:disabled{opacity:.5;cursor:not-allowed}
.be-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer}
.be-opt input{margin:0;flex-shrink:0}
.be-note{font-size:11px;color:var(--dim);margin-left:auto}
/* ===== Éditeur de preset — listes groupées façon Réglages iOS ======== */
#modal .modal-card{max-width:520px;overflow:hidden}
.pe-body{padding:18px 16px 24px;display:flex;flex-direction:column;gap:26px;overflow-y:auto;overflow-x:hidden;flex:1;background:var(--bg)}
/* Nombres : pas de flèches natives (spinner), champ épuré. */
.pe-val[type=number]{-moz-appearance:textfield}
.pe-val::-webkit-outer-spin-button,.pe-val::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
/* Un groupe = un petit titre + une liste encadrée aux coins arrondis. */
.pe-group{display:flex;flex-direction:column;gap:8px}
.pe-gh{font-size:12px;font-weight:600;color:var(--dim);padding:0 6px}
.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)}
.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}
/* Valeur éditable sans bordure, alignée à droite (nombre / texte). */
.pe-val{border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;padding:4px 0;width:110px;max-width:44vw}
.pe-val::placeholder{color:var(--dim)}
.pe-val:focus{outline:none}
.pe-unit{font-size:12px;color:var(--dim)}
/* Select sans bordure, avec chevron discret. */
.pe-selc{position:relative;display:flex;align-items:center;max-width:100%}
.pe-selc select{appearance:none;-webkit-appearance:none;border:none;background:transparent;color:var(--accent);font:inherit;text-align:right;text-align-last:right;padding:4px 16px 4px 0;cursor:pointer;max-width:100%;text-overflow:ellipsis}
.pe-selc select:focus{outline:none}
.pe-selc::after{content:"›";position:absolute;right:0;transform:rotate(90deg);color:var(--dim);font-size:14px;pointer-events:none}
/* Ligne « champ pleine largeur » (Nom, chemin, config brute…). */
.pe-row.stack{flex-direction:column;align-items:stretch;gap:8px}
.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}
.pe-row.stack.plain .pe-inp:focus{box-shadow:none}
/* Champ + bouton accolé (téléchargement) — même trait que les autres champs,
sans pastille colorée qui ressort. */
.pe-inpbtn{display:flex}
.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0}
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-left:0;border-radius:0 8px 8px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s}
.pe-inpbtn button:hover{color:var(--accent);border-color:var(--accent)}
.pe-inpbtn button:disabled{opacity:.5;cursor:default}
/* Contrôle segmenté (moteur) façon iOS. */
.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:9px;padding:3px;width:100%}
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:7px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s}
.pe-seg>label:has(input:checked){background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.22)}
.pe-seg input{display:none}
.pe-seg .be-note{display:none}
.pe-seg-note{font-size:11px;color:var(--dim);padding:0 2px;min-height:14px}
.pe-note{font-size:11px;color:var(--dim);line-height:1.45}
/* Interrupteur à glissière. */
.pe-switch{position:relative;flex:none;width:40px;height:24px}
.pe-switch input{position:absolute;opacity:0;width:0;height:0}
.pe-sl{position:absolute;inset:0;background:color-mix(in srgb,var(--dim) 55%,transparent);border-radius:999px;transition:background .18s}
.pe-sl::before{content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;background:#fff;border-radius:50%;transition:transform .18s;box-shadow:0 1px 3px rgba(0,0,0,.35)}
.pe-switch input:checked+.pe-sl{background:var(--accent)}
.pe-switch input:checked+.pe-sl::before{transform:translateX(16px)}
.pe-switch input:focus-visible+.pe-sl{box-shadow:0 0 0 3px var(--accent-bg)}
/* Repli « avancé » / config brute : une ligne cliquable dans un groupe. */
.pe-adv{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
.pe-adv>summary{cursor:pointer;font-size:14px;color:var(--text);padding:13px 15px;list-style:none;display:flex;align-items:center;user-select:none}
.pe-adv>summary::-webkit-details-marker{display:none}
.pe-adv>summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:translateY(-2px) rotate(45deg);transition:transform .15s}
.pe-adv[open]>summary::after{transform:translateY(1px) rotate(-135deg)}
.pe-adv[open]>summary{border-bottom:1px solid var(--border)}
.pe-adv .pe-adv-body{padding:14px 15px;display:flex;flex-direction:column;gap:10px}
.pe-adv textarea.pe-inp{min-height:210px;resize:vertical;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:hidden}
/* Ligne « éditer le .env » : chevron de disclosure + textarea de config brute. */
.pe-caret{display:inline-block;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:rotate(-45deg);transition:transform .15s}
.pe-caret.open{transform:rotate(45deg)}
#m-content{min-height:220px;resize:vertical;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:hidden}
/* Nombres avancés : chaque paire label/valeur sur sa propre ligne (réutilise pe-row). */
.pe-adv .pe-list{margin:0}
/* Variante « champ en clair » : page mémoire → le contenu est le champ principal. */
.pe-adv.pe-raw-plain{border:none;background:transparent}
.pe-adv.pe-raw-plain>summary{display:none}
.pe-adv.pe-raw-plain .pe-adv-body{padding:0}
/* Pied de modale. */
.pe-foot{padding:13px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
.pe-foot .foot-r{display:flex;gap:8px}
.pe-btn{margin:0;padding:9px 18px;border-radius:9px;font-weight:600;cursor:pointer;border:1px solid var(--border);background:var(--panel);color:var(--text)}
.pe-btn:hover{border-color:var(--dim)}
.pe-btn.primary{border-color:var(--accent);background:var(--accent);color:#fff}
.pe-btn.primary:hover{filter:brightness(1.08)}
.pe-btn.ghost-danger{border-color:transparent;background:transparent;color:var(--err);padding:9px 10px}
.pe-btn.ghost-danger:hover{background:var(--danger-bg)}
.pe-delmodel{display:inline-flex;align-items:center;gap:6px;font-size:11px;color:var(--dim);cursor:pointer}
.pe-delmodel input{margin:0}
/* Éditeur de preset/mémoire — mise en forme propre du modale */
.modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:30;align-items:center;justify-content:center;padding:20px}
.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,.5)}
+137 -9
View File
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
@@ -34,17 +35,76 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
ctx = n
}
}
// Si le modèle n'est pas prêt, on regarde POURQUOI : une tentative de
// chargement qui a échoué (souvent un modèle incompatible avec le moteur)
// doit être signalée clairement plutôt que de laisser un « chargement… »
// perpétuel / un crash-loop muet.
loadErr := ""
if !health {
loadErr = modelLoadError()
}
sendJSON(w, 200, map[string]any{
"state": state,
"active": active,
"health": health,
"port": LLMPort(),
"ctx": ctx,
"version": Version,
"warn": appWarning(), // ex. App Translocation macOS — vide si tout va bien
"state": state,
"active": active,
"health": health,
"port": LLMPort(),
"ctx": ctx,
"version": Version,
"warn": appWarning(), // ex. App Translocation macOS — vide si tout va bien
"load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon
})
}
// modelLoadError inspecte la fin du journal du service et renvoie un message
// clair quand la DERNIÈRE tentative de chargement du modèle s'est soldée par un
// échec. Le cas le plus courant : un GGUF dont le format de quantification n'est
// pas reconnu par le moteur (BIN) sélectionné — typiquement un modèle ternaire
// qui exige un fork de llama.cpp. Renvoie "" quand le dernier chargement a
// réussi ou qu'aucune tentative n'est visible (évite les faux positifs sur un
// service simplement arrêté).
func modelLoadError() string {
log := serviceLogTail(200)
if log == "" {
return ""
}
lines := strings.Split(log, "\n")
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
start := 0
for i, l := range lines {
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") {
start = i
}
}
loaded, reason := false, ""
for _, l := range lines[start:] {
low := strings.ToLower(l)
switch {
case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"):
loaded = true
case strings.Contains(l, "has offset") && strings.Contains(l, "expected"):
reason = "format de quantification non reconnu par ce moteur"
case strings.Contains(low, "unknown model architecture"),
strings.Contains(low, "unknown ftype"),
strings.Contains(low, "unknown type"),
strings.Contains(low, "unsupported"):
if reason == "" {
reason = "type/architecture de modèle inconnu de ce moteur"
}
case strings.Contains(low, "error loading model"),
strings.Contains(low, "failed to load model"),
strings.Contains(low, "failed to read tensor data"):
if reason == "" {
reason = "échec du chargement du modèle"
}
}
}
if loaded || reason == "" {
return ""
}
return "Le modèle n'a pas pu être chargé : " + reason +
". Il est probablement incompatible avec le moteur sélectionné — choisis un autre backend pour ce modèle (édite le modèle → section Moteur)."
}
// handleServiceLog (GET /api/service/log) : dernières lignes du journal du
// service, pour que l'UI puisse montrer POURQUOI le modèle ne se charge pas
// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu
@@ -102,11 +162,17 @@ func handleConfigEnv(w http.ResponseWriter, r *http.Request) {
// trying common build subpaths (build/bin, build-sm120/bin, bin, .).
// Returns [{name, path}].
func handleBackends(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, listBackendBins())
}
// listBackendBins scanne JEAN_HOME/backends/<name>/ pour un binaire llama-server
// (essaie les sous-chemins des différents générateurs CMake). Renvoie
// [{name, path}] — un par dossier de backend contenant un binaire.
func listBackendBins() []map[string]any {
root := JeanHome() + "/backends"
entries, err := os.ReadDir(root)
if err != nil {
sendJSON(w, 200, []map[string]any{})
return
return []map[string]any{}
}
subpaths := []string{
"build/bin/llama-server", "build-sm120/bin/llama-server",
@@ -130,9 +196,71 @@ func handleBackends(w http.ResponseWriter, r *http.Request) {
}
}
}
return out
}
// handleBackendsCustom liste les seuls backends CUSTOM : les dossiers de
// backends/ hors moteurs gérés par les cartes ⚡ (prebuilt) et 🔧 (build
// canonique). Marque celui qui sert de moteur au modèle actif (in_use).
func handleBackendsCustom(w http.ResponseWriter, r *http.Request) {
canonical := filepath.Base(defaultRepoDir()) // "llama.cpp"
prebuilt := filepath.Base(prebuiltDir()) // "llama.cpp-prebuilt"
cfgBin := ReadConfig()["BIN"]
out := []map[string]any{}
for _, b := range listBackendBins() {
name, _ := b["name"].(string)
if name == canonical || name == prebuilt {
continue
}
path, _ := b["path"].(string)
out = append(out, map[string]any{"name": name, "path": path, "in_use": samePath(path, cfgBin)})
}
sendJSON(w, 200, out)
}
// handleLlamacppUninstallCustom supprime le dossier d'un backend custom
// (backends/<name>). Refuse les moteurs gérés (canonique / prebuilt) et un
// backend actuellement utilisé par le modèle actif (il faut d'abord basculer de
// moteur pour ce modèle).
func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
name := sanitizeBackendName(req.Name)
if name == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom requis"})
return
}
if name == filepath.Base(defaultRepoDir()) || name == filepath.Base(prebuiltDir()) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "ce moteur se gère depuis les cartes ⚡ / 🔧"})
return
}
dir, err := backendDir(name)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if !isDir(dir) {
sendJSON(w, 404, map[string]any{"ok": false, "error": "backend introuvable"})
return
}
cfgBin := ReadConfig()["BIN"]
for _, b := range listBackendBins() {
if b["name"] == name {
if p, _ := b["path"].(string); samePath(p, cfgBin) {
sendJSON(w, 409, map[string]any{"ok": false, "error": "backend utilisé par le modèle actif — bascule d'abord ce modèle sur un autre moteur"})
return
}
}
}
if err := os.RemoveAll(dir); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleModels lists *.gguf files in JEAN_HOME (size in bytes) for the preset
// editor's model picker.
func handleModels(w http.ResponseWriter, r *http.Request) {
+35
View File
@@ -233,6 +233,41 @@ func handleLlamacppInstall(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleLlamacppInstallCustom lance un job d'installation d'un backend CUSTOM
// (fork llama.cpp) depuis une URL de dépôt Git : cloné dans backends/<name> et
// compilé pour la machine, SANS toucher au BIN global. Il apparaît ensuite dans
// /api/backends et se choisit par modèle (éditeur de preset → Moteur).
func handleLlamacppInstallCustom(w http.ResponseWriter, r *http.Request) {
var req struct {
Repo string `json:"repo"`
Name string `json:"name"`
Ref string `json:"ref"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
if strings.TrimSpace(req.Repo) == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "URL du dépôt requise"})
return
}
if err := startLcJob("custom", func() { lcRunCustomInstall(req.Repo, req.Name, req.Ref) }); err != nil {
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
// lcRunCustomInstall : corps du job d'install d'un backend custom (sans bascule
// de BIN — c'est volontaire, il se rattache par preset).
func lcRunCustomInstall(url, name, ref string) {
bin, err := installCustomBackend(url, name, ref, lcPhase)
if err != nil {
lcFail(err)
return
}
lcAppend("binaire compilé : " + bin)
lcAppend("→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.")
lcDone("backend custom installé")
}
// handleLlamacppUpdate lance le job de mise à jour (pull + rebuild + restart).
// {clean:true} force une recompilation from scratch même sans nouveau commit.
func handleLlamacppUpdate(w http.ResponseWriter, r *http.Request) {
+3
View File
@@ -90,9 +90,12 @@ func newWebMux() *http.ServeMux {
api("/api/models/download", handleModelDownload)
api("/api/models/download/status", handleModelDownloadStatus)
api("/api/backends", handleBackends)
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
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
api("/api/llamacpp/install-custom", handleLlamacppInstallCustom) // job : clone d'un fork depuis une URL Git (par preset, sans BIN global)
api("/api/llamacpp/uninstall-custom", handleLlamacppUninstallCustom) // supprime un backend custom (backends/<name>)
api("/api/llamacpp/update", handleLlamacppUpdate) // job : pull + rebuild + restart
api("/api/llamacpp/job", handleLlamacppJob) // progression + logs du job
api("/api/llamacpp/prebuilt", handleLlamacppPrebuilt) // job : binaires officiels précompilés