From 83a3a79abe7135b35ef03f03287729ee8244016b Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 27 Jul 2026 10:35:04 +0200 Subject: [PATCH] v0.5.6 : backends custom depuis un depot Git + editeur de preset repense (champs facon Reglages iOS) + incompatibilite modele/moteur signalee --- cmd/jean/resource_windows_amd64.syso | Bin 8340 -> 8340 bytes cmd/jean/resource_windows_arm64.syso | Bin 8340 -> 8340 bytes cmd/jean/versioninfo.json | 6 +- internal/jean/backend_llamacpp.go | 173 ++++++++++ internal/jean/run.go | 2 +- internal/jean/ui/index.html | 456 ++++++++++++++++++++++--- internal/jean/ui/src/index.tmpl.html | 201 ++++++++--- internal/jean/ui/src/js/05-status.js | 8 + internal/jean/ui/src/js/07-models.js | 91 ++++- internal/jean/ui/src/js/10-llamacpp.js | 70 ++++ internal/jean/ui/src/styles.css | 86 +++++ internal/jean/web_api.go | 146 +++++++- internal/jean/web_llamacpp.go | 35 ++ internal/jean/web_server.go | 3 + 14 files changed, 1158 insertions(+), 119 deletions(-) diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index d100964724ec2e2c2c800702a96d1ba3424b7ee3..e047fa475ac6049934812ea5e081c08190f79e1a 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^u11kdvux(bB5oBUC+iWTq%m}19<+&LFgti9X delta 36 ncmbQ@IK^>;lnf^;11kdvux?hC5oBUC-E1ls%m}19<+&LFgmVVl diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index b8ff8f375d1d954df3bb46efbd2e3a4f3edbc63b..c37a2463795a303aa3b5d4740fae654fa8ffab5f 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^u11kdvux(bB5oBUC+iWTq%m}19<+&LFgti9X delta 36 ncmbQ@IK^>;lnf^;11kdvux?hC5oBUC-E1ls%m}19<+&LFgmVVl diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 974bf6f..9b98f0f 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -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": { diff --git a/internal/jean/backend_llamacpp.go b/internal/jean/backend_llamacpp.go index 3576774..4ea1805 100644 --- a/internal/jean/backend_llamacpp.go +++ b/internal/jean/backend_llamacpp.go @@ -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/. 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/ 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/, 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 +} diff --git a/internal/jean/run.go b/internal/jean/run.go index 26cd21f..1b7302d 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -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() { diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index 64a8d08..f498f8f 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -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} + +
+
🧩 Backend personnalisé
+
forks llama.cpp pour modèles spéciaux (ternaire…)
+
+
+