From 73a772520c974cd2abb1b871dd4ae84107f2876c Mon Sep 17 00:00:00 2001 From: nathaninline Date: Mon, 3 Aug 2026 11:58:00 +0200 Subject: [PATCH] v0.6.8 : les modeles peuvent vivre ailleurs que dans le dossier jean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un .gguf pose sur un disque externe etait invisible dans l'UI : la liste des modeles ne scannait que JEAN_HOME et un preset pointant ailleurs affichait « introuvable ». On peut desormais declarer des dossiers de modeles supplementaires (replies derriere une ligne dans l'editeur de preset, persistes dans model_dirs.json, ou via JEAN_MODEL_DIRS). Acces distant : la pastille affiche « non connecte » au lieu de rester vide quand le serveur n'est lie a aucun compte. --- README.md | 4 +- 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_model_dirs.go | 220 +++++++++++++++++++++++ internal/jean/backend_model_dirs_test.go | 72 ++++++++ internal/jean/backend_models.go | 26 +-- internal/jean/backend_serve.go | 17 +- internal/jean/run.go | 2 +- internal/jean/ui/index.html | 124 +++++++++++-- internal/jean/ui/src/index.tmpl.html | 12 ++ internal/jean/ui/src/js/07-models.js | 104 ++++++++++- internal/jean/ui/src/js/13-remote.js | 8 +- internal/jean/web_api.go | 47 +++-- internal/jean/web_server.go | 1 + 15 files changed, 584 insertions(+), 59 deletions(-) create mode 100644 internal/jean/backend_model_dirs.go create mode 100644 internal/jean/backend_model_dirs_test.go diff --git a/README.md b/README.md index b01343c..3750f06 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramDa | Clé | Signification | Défaut | |-----|---------------|--------| | `BIN` | chemin vers `llama-server` (réglé par `llamacpp install`) | — | -| `MODEL` | chemin vers le modèle `.gguf` | — | +| `MODEL` | nom de fichier (cherché dans les dossiers de modèles) ou chemin complet vers le `.gguf` | — | | `HOST` / `PORT` | adresse / port d'écoute | `0.0.0.0` / `8080` | | `CTX` | taille du contexte | `32768` | | `NGL` | couches à déporter sur le GPU | `999` | @@ -185,6 +185,8 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramDa La clé API (quand elle est définie avec `jean set-api-key`) est stockée dans `$JEAN_HOME/.api_key`, séparément de `config.env`. +**Modèles sur un autre disque.** Les `.gguf` n'ont pas à vivre dans `$JEAN_HOME` : dans l'éditeur de preset de l'UI web, section *Modèle → Dossiers de modèles*, ajoutez le dossier de votre disque externe. Ses modèles apparaissent alors dans la liste déroulante, groupés par dossier. La liste est enregistrée dans `$JEAN_HOME/model_dirs.json` (elle survit donc aux changements de preset) et peut aussi être fournie par `JEAN_MODEL_DIRS` (dossiers séparés par `:`, `;` sous Windows). Les téléchargements, eux, atterrissent toujours dans `$JEAN_HOME`. + ### API de pilotage à distance `jean web` expose une API HTTP pour piloter AJEAN à distance : status, VRAM, liste/sélection de presets (switch de modèle), démarrage/arrêt/redémarrage du service, chat. Pour l'exposer sur internet en sécurité, protège-la par une clé : diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 520db9d027ed8c217c188c2afde36ce6d9628df6..daa703f81df55dcfdbc004d232d97aa0ce4c209d 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^a0~-SfaBNnV5oKbu*lZ~m%m}1<<=Ghlg~|r- delta 36 ncmbQ@IK^>;lnf_30~-Sfuy0nD5oKaD-)t!t%m}1<<=Ghlg@*?0 diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index 5b8c13baee360c6a53cff215e8bf9c66714af64c..2da7299b3ffaf4d2dca5ae79dc517bd59b82617f 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^a0~-SfaBNnV5oKbu*lZ~m%m}1<<=Ghlg~|r- delta 36 ncmbQ@IK^>;lnf_30~-Sfuy0nD5oKaD-)t!t%m}1<<=Ghlg@*?0 diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 8c72040..3834654 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 7, + "Patch": 8, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 7, + "Patch": 8, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.7", + "ProductVersion": "0.6.8", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/backend_model_dirs.go b/internal/jean/backend_model_dirs.go new file mode 100644 index 0000000..3bef6f8 --- /dev/null +++ b/internal/jean/backend_model_dirs.go @@ -0,0 +1,220 @@ +package jean + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" +) + +// Les .gguf vivent historiquement dans JEAN_HOME, mais un modèle de 40 Go tient +// rarement sur le disque système : on autorise des dossiers supplémentaires +// (disque dur externe, second SSD, montage réseau…). La liste est persistée +// dans $JEAN_HOME/model_dirs.json — PAS dans config.env, que le changement de +// preset réécrit — et peut aussi venir de $JEAN_MODEL_DIRS. +func modelDirsPath() string { return filepath.Join(JeanHome(), "model_dirs.json") } + +// modelDirsListSep sépare les dossiers dans $JEAN_MODEL_DIRS (':' sous Unix, +// ';' sous Windows, comme le PATH). +func modelDirsListSep() string { + if runtime.GOOS == "windows" { + return ";" + } + return ":" +} + +// extraModelDirs renvoie les dossiers déclarés par l'utilisateur (fichier puis +// variable d'environnement), nettoyés et dédoublonnés, JEAN_HOME exclu. +func extraModelDirs() []string { + var raw []string + if b, err := os.ReadFile(modelDirsPath()); err == nil { + var dirs []string + if json.Unmarshal(b, &dirs) == nil { + raw = append(raw, dirs...) + } + } + if v := strings.TrimSpace(os.Getenv("JEAN_MODEL_DIRS")); v != "" { + raw = append(raw, strings.Split(v, modelDirsListSep())...) + } + return dedupDirs(raw, JeanHome()) +} + +// dedupDirs nettoie une liste de chemins : vides retirés, chemins absolus +// normalisés, doublons et `skip` écartés (comparaison insensible à la casse +// sous Windows, comme le système de fichiers). +func dedupDirs(list []string, skip string) []string { + seen := map[string]bool{normDir(skip): true} + out := []string{} + for _, d := range list { + d = strings.TrimSpace(d) + if d == "" { + continue + } + abs, err := filepath.Abs(d) + if err != nil { + continue + } + abs = filepath.Clean(abs) + k := normDir(abs) + if seen[k] { + continue + } + seen[k] = true + out = append(out, abs) + } + return out +} + +// normDir met un chemin sous forme comparable (séparateurs unifiés, casse +// neutralisée sous Windows, slash final retiré). +func normDir(p string) string { + // Clean d'abord (il réintroduit le séparateur natif), puis on unifie en '/'. + p = strings.ReplaceAll(filepath.Clean(strings.TrimSpace(p)), "\\", "/") + if runtime.GOOS == "windows" { + p = strings.ToLower(p) + } + return p +} + +// modelDirs renvoie tous les dossiers où chercher un .gguf : JEAN_HOME d'abord +// (il reste la destination des téléchargements), puis les dossiers ajoutés. +func modelDirs() []string { + return append([]string{JeanHome()}, extraModelDirs()...) +} + +// saveExtraModelDirs écrit la liste des dossiers supplémentaires sur disque. +func saveExtraModelDirs(dirs []string) error { + clean := dedupDirs(dirs, JeanHome()) + b, err := json.MarshalIndent(clean, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(JeanHome(), 0o755); err != nil { + return err + } + return os.WriteFile(modelDirsPath(), append(b, '\n'), 0o644) +} + +// pathWithin dit si le fichier p est dans le dossier dir (ou un sous-dossier). +func pathWithin(dir, p string) bool { + d, f := normDir(dir), normDir(p) + return f == d || strings.HasPrefix(f, d+"/") +} + +// baseName renvoie le nom de fichier d'un chemin en acceptant les DEUX +// séparateurs : un config.env écrit sous Windows (C:\models\x.gguf) peut être +// relu par un jean Linux, où filepath.Base ne coupe pas sur '\'. +func baseName(p string) string { + p = strings.TrimRight(strings.TrimSpace(p), `/\`) + if i := strings.LastIndexAny(p, `/\`); i >= 0 { + return p[i+1:] + } + return p +} + +// resolveModelPath transforme une valeur MODEL= (nom de fichier OU chemin +// absolu) en chemin absolu vers un .gguf. Un chemin absolu n'est accepté que +// s'il est dans un des dossiers déclarés — sinon l'UI web deviendrait un moyen +// de lire/supprimer n'importe quel fichier de la machine. Un simple nom de +// fichier est cherché dans chaque dossier, JEAN_HOME en premier. +func resolveModelPath(name string) (string, error) { + s := strings.TrimSpace(strings.Trim(strings.TrimSpace(name), `"`)) + if s == "" { + return "", fmt.Errorf("nom de modèle invalide") + } + if !strings.HasSuffix(strings.ToLower(s), ".gguf") { + return "", fmt.Errorf("le modèle doit être un fichier .gguf") + } + if filepath.IsAbs(s) { + abs := filepath.Clean(s) + for _, d := range modelDirs() { + if pathWithin(d, abs) { + return abs, nil + } + } + return "", fmt.Errorf("dossier non autorisé : %s — ajoute-le dans « Dossiers de modèles »", filepath.Dir(abs)) + } + base := baseName(s) + if base == "" || base == "." { + return "", fmt.Errorf("nom de modèle invalide") + } + for _, d := range modelDirs() { + p := filepath.Join(d, base) + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, nil + } + } + return filepath.Join(JeanHome(), base), nil // introuvable : l'appelant décidera +} + +// modelDirCount compte les .gguf lisibles d'un dossier (-1 si illisible). +func modelDirCount(dir string) int { + entries, err := os.ReadDir(dir) + if err != nil { + return -1 + } + n := 0 + for _, e := range entries { + if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { + n++ + } + } + return n +} + +// handleModelDirs : GET liste les dossiers de modèles (JEAN_HOME + ajoutés), +// POST {path, action:"add"|"remove"} en ajoute ou en retire un. +func handleModelDirs(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + var req struct { + Path string `json:"path"` + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + p := strings.TrimSpace(req.Path) + if p == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin vide"}) + return + } + cur := extraModelDirs() + if req.Action == "remove" { + kept := []string{} + for _, d := range cur { + if normDir(d) != normDir(p) { + kept = append(kept, d) + } + } + cur = kept + } else { + abs, err := filepath.Abs(p) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin invalide"}) + return + } + st, err := os.Stat(abs) + if err != nil || !st.IsDir() { + sendJSON(w, 400, map[string]any{"ok": false, "error": "dossier introuvable : " + abs}) + return + } + cur = append(cur, abs) + } + if err := saveExtraModelDirs(cur); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) + return + } + out := []map[string]any{{"path": JeanHome(), "home": true, "count": modelDirCount(JeanHome()), "exists": true}} + for _, d := range extraModelDirs() { + n := modelDirCount(d) + out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0}) + } + sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("JEAN_MODEL_DIRS") != ""}) +} diff --git a/internal/jean/backend_model_dirs_test.go b/internal/jean/backend_model_dirs_test.go new file mode 100644 index 0000000..f2dc780 --- /dev/null +++ b/internal/jean/backend_model_dirs_test.go @@ -0,0 +1,72 @@ +package jean + +import ( + "os" + "path/filepath" + "testing" +) + +// Un modèle posé hors de JEAN_HOME (disque externe) doit être utilisable dès +// que son dossier est déclaré — et refusé tant qu'il ne l'est pas. +func TestResolveModelPathExtraDir(t *testing.T) { + home := t.TempDir() + ext := t.TempDir() + t.Setenv("JEAN_HOME", home) + t.Setenv("JEAN_MODEL_DIRS", "") + + extModel := filepath.Join(ext, "gros.gguf") + if err := os.WriteFile(extModel, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + if _, err := resolveModelPath(extModel); err == nil { + t.Fatal("un dossier non déclaré devrait être refusé") + } + + if err := saveExtraModelDirs([]string{ext}); err != nil { + t.Fatal(err) + } + got, err := resolveModelPath(extModel) + if err != nil { + t.Fatalf("chemin absolu déclaré refusé : %v", err) + } + if got != filepath.Clean(extModel) { + t.Fatalf("chemin = %s, attendu %s", got, extModel) + } + // Le simple nom de fichier doit aussi être retrouvé dans le dossier ajouté. + if got, err := resolveModelPath("gros.gguf"); err != nil || got != extModel { + t.Fatalf("nom seul = %q (%v), attendu %s", got, err, extModel) + } +} + +// JEAN_HOME garde la priorité et les non-.gguf restent refusés. +func TestResolveModelPathHomeFirst(t *testing.T) { + home := t.TempDir() + ext := t.TempDir() + t.Setenv("JEAN_HOME", home) + t.Setenv("JEAN_MODEL_DIRS", ext) + for _, d := range []string{home, ext} { + if err := os.WriteFile(filepath.Join(d, "m.gguf"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + got, err := resolveModelPath("m.gguf") + if err != nil || got != filepath.Join(home, "m.gguf") { + t.Fatalf("got %q (%v), attendu le fichier de JEAN_HOME", got, err) + } + if _, err := resolveModelPath("/etc/passwd"); err == nil { + t.Fatal("un fichier non-.gguf devrait être refusé") + } +} + +// La ligne MODEL= garde son chemin complet, mais la quantization se déduit +// toujours du nom de fichier. +func TestPresetModelKeepsPath(t *testing.T) { + content := "MODEL=\"/mnt/ext/Qwen3-30B-Q5_K_M.gguf\"\nCTX=8192\n" + if got := modelFromPresetContent(content); got != "/mnt/ext/Qwen3-30B-Q5_K_M.gguf" { + t.Fatalf("MODEL = %q, chemin tronqué", got) + } + if got := detectQuant(content); got != "Q5_K_M" { + t.Fatalf("quant = %q, attendu Q5_K_M", got) + } +} diff --git a/internal/jean/backend_models.go b/internal/jean/backend_models.go index 45402b7..379b34d 100644 --- a/internal/jean/backend_models.go +++ b/internal/jean/backend_models.go @@ -83,25 +83,25 @@ func detectQuant(content string) string { } } } - return quantFromName(modelFromPresetContent(content)) + return quantFromName(baseName(modelFromPresetContent(content))) } -// modelFilePath resolves a model file name to a path inside JEAN_HOME, refusing -// anything that would escape it (path traversal). config.env conventionally -// stores only the basename, so we deliberately strip any directory component. -func modelFilePath(name string) (string, error) { +// downloadDestPath resolves the destination of a downloaded model: always +// JEAN_HOME (les dossiers externes sont en lecture, on n'y écrit pas), en +// refusant tout ce qui en sortirait (path traversal). +func downloadDestPath(name string) (string, error) { base := filepath.Base(strings.TrimSpace(name)) if base == "" || base == "." || base == string(filepath.Separator) { return "", fmt.Errorf("nom de modèle invalide") } if !strings.HasSuffix(strings.ToLower(base), ".gguf") { - return "", fmt.Errorf("seuls les fichiers .gguf peuvent être supprimés") + return "", fmt.Errorf("seuls les fichiers .gguf sont acceptés") } return filepath.Join(JeanHome(), base), nil } -// modelFromPresetContent extracts the MODEL= value (basename) from a preset's -// config.env body, or "" if absent. +// modelFromPresetContent extracts the MODEL= value from a preset's config.env +// body (nom de fichier ou chemin absolu, tel quel), or "" if absent. func modelFromPresetContent(content string) string { for _, line := range strings.Split(content, "\n") { s := strings.TrimSpace(line) @@ -113,16 +113,16 @@ func modelFromPresetContent(content string) string { continue } if strings.TrimSpace(s[:i]) == "MODEL" { - v := strings.Trim(strings.TrimSpace(s[i+1:]), "\"") - return filepath.Base(v) + return strings.Trim(strings.TrimSpace(s[i+1:]), "\"") } } return "" } -// deleteModelFile removes a .gguf file from JEAN_HOME after validating the name. +// deleteModelFile removes a .gguf file from one of the declared model folders +// after validating the name. func deleteModelFile(name string) error { - p, err := modelFilePath(name) + p, err := resolveModelPath(name) if err != nil { return err } @@ -257,7 +257,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } - dest, err := modelFilePath(name) + dest, err := downloadDestPath(name) if err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return diff --git a/internal/jean/backend_serve.go b/internal/jean/backend_serve.go index 149ecff..d706298 100644 --- a/internal/jean/backend_serve.go +++ b/internal/jean/backend_serve.go @@ -19,14 +19,17 @@ func cmdServe(args []string) error { if model == "" { return fmt.Errorf("MODEL non défini dans %s", confPath()) } - // MODEL est enregistré sous forme de simple nom de fichier (les .gguf vivent - // dans JEAN_HOME) : sous systemd/launchd le WorkingDirectory vaut JEAN_HOME, - // donc le chemin relatif tombait juste. Lancé depuis une app de bureau, le - // répertoire courant est « / » et llama-server ne trouvait rien. On résout - // donc explicitement, quel que soit le contexte de lancement. - if !filepath.IsAbs(model) { - model = filepath.Join(JeanHome(), model) + // MODEL vaut soit un simple nom de fichier (le .gguf vit dans JEAN_HOME ou + // dans un dossier déclaré — disque externe…), soit un chemin absolu. Sous + // systemd/launchd le WorkingDirectory vaut JEAN_HOME, donc le relatif tombait + // juste ; lancé depuis une app de bureau, le répertoire courant est « / » et + // llama-server ne trouvait rien. On résout donc explicitement, quel que soit + // le contexte de lancement. + resolved, err := resolveModelPath(model) + if err != nil { + return err } + model = resolved if _, err := os.Stat(model); err != nil { return fmt.Errorf("modèle introuvable : %s", model) } diff --git a/internal/jean/run.go b/internal/jean/run.go index a31f56b..9441879 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.7" +const Version = "0.6.8" // 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 d490781..a6539bc 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -1233,6 +1233,18 @@ button:hover{border-color:var(--dim);color:var(--text)} +
+ Dossiers de modèleschercher les .gguf ailleurs (disque externe…) + +
+ @@ -2059,6 +2071,9 @@ async function openItem(kind, key){ rawToggle.style.display = ''; rawBody.style.display = 'none'; rawCaret.classList.remove('open'); + // Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture. + document.getElementById('m-dir-path').value = ''; + setModelDirsOpen(false); await Promise.all([populateBackend(), populateModelPicker()]); } else { modelRow.style.display = 'none'; @@ -2085,24 +2100,109 @@ function currentModelInTextarea(){ const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m); return m ? m[1].trim() : ''; } +// Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers : +// on ne contrôle pas ce que l'utilisateur y met). +function escHtml(s){ + return String(s==null?'':s).replace(/[&<>"']/g, c => + ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} +// Compare deux chemins/noms de modèle en neutralisant séparateurs et casse. +function samePath(a, b){ + const n = x => String(x||'').trim().replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase(); + return !!a && !!b && n(a) === n(b); +} +function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); } async function populateModelPicker(){ const sel = document.getElementById('m-model'); const list = await jget('/api/models'); const cur = currentModelInTextarea(); - // Strip directory; config.env conventionally only stores the file basename. - const curBase = (cur.split('/').pop() || '').trim(); + // Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les + // regroupe par dossier, JEAN_HOME d'abord (l'API les renvoie dans cet ordre). + const groups = []; + for(const m of (list||[])){ + let g = groups.find(x => x.dir === m.dir); + if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); } + g.items.push(m); + } let html = ''; let matched = false; - for(const m of list){ - const sel = (m.name === curBase) ? ' selected' : ''; - if(sel) matched = true; - html += ''; + for(const g of groups){ + let opts = ''; + for(const m of g.items){ + // Le preset peut référencer le chemin complet OU le simple nom de fichier. + const on = samePath(cur, m.value) || samePath(cur, m.path) || + (g.home && samePath(baseName(cur), m.name)) ? ' selected' : ''; + if(on) matched = true; + opts += ''; + } + html += groups.length > 1 + ? ''+opts+'' + : opts; } - // If MODEL points to something not in JEAN_HOME, show it as a disabled hint. + // MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré : + // on déplie alors la section des dossiers, c'est là que ça se répare. if(cur && !matched){ - html += ''; + html += ''; } sel.innerHTML = html; + if(cur && !matched) setModelDirsOpen(true); +} +// ---- Dossiers de modèles : JEAN_HOME + dossiers ajoutés (disque externe…) --- +// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. +function setModelDirsOpen(open){ + const b = document.getElementById('m-dirs-body'); + const c = document.getElementById('m-dirs-caret'); + if(!b) return; + b.style.display = open ? '' : 'none'; + if(c) c.classList.toggle('open', open); + if(open) populateModelDirs(); +} +function toggleModelDirs(){ + setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none'); +} +async function populateModelDirs(){ + const box = document.getElementById('m-dirs-list'); + if(!box) return; + let d = {}; + try{ d = await jget('/api/models/dirs'); }catch(_){ return; } + // Construit en DOM (pas en HTML concaténé) : les chemins viennent de + // l'utilisateur et se retrouveraient sinon dans un attribut onclick. + box.innerHTML = ''; + for(const x of (d.dirs||[])){ + const row = document.createElement('div'); + const p = document.createElement('span'); + p.textContent = x.path + ' '; + const info = document.createElement('span'); + info.className = 'muted'; + const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; + info.textContent = '(' + n + (x.home ? ', dossier jean' : '') + ')'; + row.append(p, info); + if(!x.home){ + const del = document.createElement('button'); + del.className = 'pe-link'; + del.textContent = 'retirer'; + del.style.marginLeft = '8px'; + del.onclick = () => removeModelDir(x.path); + row.append(del); + } + box.append(row); + } +} +async function addModelDir(){ + const inp = document.getElementById('m-dir-path'); + const p = inp.value.trim(); + if(!p){ toast('indique un chemin de dossier'); return; } + const r = await jpost('/api/models/dirs', {path: p, action: 'add'}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + inp.value = ''; + toast('dossier ajouté'); + await Promise.all([populateModelDirs(), populateModelPicker()]); +} +async function removeModelDir(p){ + const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + toast('dossier retiré'); + await Promise.all([populateModelDirs(), populateModelPicker()]); } function onPickModel(){ const val = document.getElementById('m-model').value; @@ -3375,8 +3475,10 @@ function renderRemote(d){ const off = document.getElementById('remote-off'), on = document.getElementById('remote-on'); const badge = document.getElementById('remote-badge'); if(!d || !d.linked){ + // Serveur jamais lié : on l'affiche explicitement plutôt que de laisser la + // pastille vide, qui se lisait comme « je ne sais pas ». off.style.display=''; on.style.display='none'; - setBadge(badge, null); + setBadge(badge, false, 'non connecté'); return; } off.style.display='none'; on.style.display=''; @@ -3394,7 +3496,9 @@ async function loadRemote(){ // (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache. const det = document.getElementById('remote-details'); if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; } - try{ renderRemote(await jget('/api/link/status')); }catch(e){} + // Statut injoignable : on retombe sur « non connecté » plutôt que de garder + // l'affichage précédent, qui pourrait annoncer « connecté » à tort. + try{ renderRemote(await jget('/api/link/status')); }catch(e){ renderRemote(null); } } // Ouvre la popup de connexion et attend la clé renvoyée par postMessage. diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index 2071bae..239d7d5 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -412,6 +412,18 @@ +
+ Dossiers de modèleschercher les .gguf ailleurs (disque externe…) + +
+ diff --git a/internal/jean/ui/src/js/07-models.js b/internal/jean/ui/src/js/07-models.js index 42e2cf3..2a504ff 100644 --- a/internal/jean/ui/src/js/07-models.js +++ b/internal/jean/ui/src/js/07-models.js @@ -89,6 +89,9 @@ async function openItem(kind, key){ rawToggle.style.display = ''; rawBody.style.display = 'none'; rawCaret.classList.remove('open'); + // Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture. + document.getElementById('m-dir-path').value = ''; + setModelDirsOpen(false); await Promise.all([populateBackend(), populateModelPicker()]); } else { modelRow.style.display = 'none'; @@ -115,24 +118,109 @@ function currentModelInTextarea(){ const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m); return m ? m[1].trim() : ''; } +// Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers : +// on ne contrôle pas ce que l'utilisateur y met). +function escHtml(s){ + return String(s==null?'':s).replace(/[&<>"']/g, c => + ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} +// Compare deux chemins/noms de modèle en neutralisant séparateurs et casse. +function samePath(a, b){ + const n = x => String(x||'').trim().replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase(); + return !!a && !!b && n(a) === n(b); +} +function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); } async function populateModelPicker(){ const sel = document.getElementById('m-model'); const list = await jget('/api/models'); const cur = currentModelInTextarea(); - // Strip directory; config.env conventionally only stores the file basename. - const curBase = (cur.split('/').pop() || '').trim(); + // Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les + // regroupe par dossier, JEAN_HOME d'abord (l'API les renvoie dans cet ordre). + const groups = []; + for(const m of (list||[])){ + let g = groups.find(x => x.dir === m.dir); + if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); } + g.items.push(m); + } let html = ''; let matched = false; - for(const m of list){ - const sel = (m.name === curBase) ? ' selected' : ''; - if(sel) matched = true; - html += ''; + for(const g of groups){ + let opts = ''; + for(const m of g.items){ + // Le preset peut référencer le chemin complet OU le simple nom de fichier. + const on = samePath(cur, m.value) || samePath(cur, m.path) || + (g.home && samePath(baseName(cur), m.name)) ? ' selected' : ''; + if(on) matched = true; + opts += ''; + } + html += groups.length > 1 + ? ''+opts+'' + : opts; } - // If MODEL points to something not in JEAN_HOME, show it as a disabled hint. + // MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré : + // on déplie alors la section des dossiers, c'est là que ça se répare. if(cur && !matched){ - html += ''; + html += ''; } sel.innerHTML = html; + if(cur && !matched) setModelDirsOpen(true); +} +// ---- Dossiers de modèles : JEAN_HOME + dossiers ajoutés (disque externe…) --- +// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. +function setModelDirsOpen(open){ + const b = document.getElementById('m-dirs-body'); + const c = document.getElementById('m-dirs-caret'); + if(!b) return; + b.style.display = open ? '' : 'none'; + if(c) c.classList.toggle('open', open); + if(open) populateModelDirs(); +} +function toggleModelDirs(){ + setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none'); +} +async function populateModelDirs(){ + const box = document.getElementById('m-dirs-list'); + if(!box) return; + let d = {}; + try{ d = await jget('/api/models/dirs'); }catch(_){ return; } + // Construit en DOM (pas en HTML concaténé) : les chemins viennent de + // l'utilisateur et se retrouveraient sinon dans un attribut onclick. + box.innerHTML = ''; + for(const x of (d.dirs||[])){ + const row = document.createElement('div'); + const p = document.createElement('span'); + p.textContent = x.path + ' '; + const info = document.createElement('span'); + info.className = 'muted'; + const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; + info.textContent = '(' + n + (x.home ? ', dossier jean' : '') + ')'; + row.append(p, info); + if(!x.home){ + const del = document.createElement('button'); + del.className = 'pe-link'; + del.textContent = 'retirer'; + del.style.marginLeft = '8px'; + del.onclick = () => removeModelDir(x.path); + row.append(del); + } + box.append(row); + } +} +async function addModelDir(){ + const inp = document.getElementById('m-dir-path'); + const p = inp.value.trim(); + if(!p){ toast('indique un chemin de dossier'); return; } + const r = await jpost('/api/models/dirs', {path: p, action: 'add'}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + inp.value = ''; + toast('dossier ajouté'); + await Promise.all([populateModelDirs(), populateModelPicker()]); +} +async function removeModelDir(p){ + const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + toast('dossier retiré'); + await Promise.all([populateModelDirs(), populateModelPicker()]); } function onPickModel(){ const val = document.getElementById('m-model').value; diff --git a/internal/jean/ui/src/js/13-remote.js b/internal/jean/ui/src/js/13-remote.js index 040c64f..cc158fa 100644 --- a/internal/jean/ui/src/js/13-remote.js +++ b/internal/jean/ui/src/js/13-remote.js @@ -9,8 +9,10 @@ function renderRemote(d){ const off = document.getElementById('remote-off'), on = document.getElementById('remote-on'); const badge = document.getElementById('remote-badge'); if(!d || !d.linked){ + // Serveur jamais lié : on l'affiche explicitement plutôt que de laisser la + // pastille vide, qui se lisait comme « je ne sais pas ». off.style.display=''; on.style.display='none'; - setBadge(badge, null); + setBadge(badge, false, 'non connecté'); return; } off.style.display='none'; on.style.display=''; @@ -28,7 +30,9 @@ async function loadRemote(){ // (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache. const det = document.getElementById('remote-details'); if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; } - try{ renderRemote(await jget('/api/link/status')); }catch(e){} + // Statut injoignable : on retombe sur « non connecté » plutôt que de garder + // l'affichage précédent, qui pourrait annoncer « connecté » à tort. + try{ renderRemote(await jget('/api/link/status')); }catch(e){ renderRemote(null); } } // Ouvre la popup de connexion et attend la clé renvoyée par postMessage. diff --git a/internal/jean/web_api.go b/internal/jean/web_api.go index 43bf035..70d35f8 100644 --- a/internal/jean/web_api.go +++ b/internal/jean/web_api.go @@ -261,25 +261,44 @@ func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) { 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. +// handleModels lists *.gguf files (size in bytes) for the preset editor's model +// picker, dans JEAN_HOME ET dans les dossiers supplémentaires déclarés (disque +// externe…). "value" est ce qu'il faut écrire dans MODEL= : un simple nom de +// fichier pour JEAN_HOME (compatibilité avec l'existant), le chemin complet +// pour un dossier externe. func handleModels(w http.ResponseWriter, r *http.Request) { - entries, err := os.ReadDir(JeanHome()) - if err != nil { - sendJSON(w, 200, []map[string]any{}) - return - } out := []map[string]any{} - for _, e := range entries { - if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { + seen := map[string]bool{} + home := JeanHome() + for _, dir := range modelDirs() { + entries, err := os.ReadDir(dir) + if err != nil { continue } - info, _ := e.Info() - size := int64(0) - if info != nil { - size = info.Size() + isHome := normDir(dir) == normDir(home) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { + continue + } + full := filepath.Join(dir, e.Name()) + if seen[normDir(full)] { + continue + } + seen[normDir(full)] = true + info, _ := e.Info() + size := int64(0) + if info != nil { + size = info.Size() + } + value := full + if isHome { + value = e.Name() + } + out = append(out, map[string]any{ + "name": e.Name(), "size": size, "value": value, + "path": full, "dir": dir, "home": isHome, + }) } - out = append(out, map[string]any{"name": e.Name(), "size": size}) } sendJSON(w, 200, out) } diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go index 211b8ca..e51fdd8 100644 --- a/internal/jean/web_server.go +++ b/internal/jean/web_server.go @@ -93,6 +93,7 @@ func newWebMux() *http.ServeMux { api("/api/update/apply", handleUpdateApply) api("/api/models", handleModels) api("/api/models/delete", handleModelDelete) + api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…) api("/api/models/download", handleModelDownload) api("/api/models/download/status", handleModelDownloadStatus) api("/api/models/download/cancel", handleModelDownloadCancel)