mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 23:18:13 +02:00
v0.6.8 : les modeles peuvent vivre ailleurs que dans le dossier jean
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.
This commit is contained in:
1 parent
acf094f961
commit
73a772520c
15 files changed
+584
-59
No files matched your search
@@ -169,7 +169,7 @@ Tout vit sous **`$JEAN_HOME`** (défaut `/etc/jean` sur Linux/macOS, `%ProgramDa
|
|||||||
| Clé | Signification | Défaut |
|
| Clé | Signification | Défaut |
|
||||||
|-----|---------------|--------|
|
|-----|---------------|--------|
|
||||||
| `BIN` | chemin vers `llama-server` (réglé par `llamacpp install`) | — |
|
| `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` |
|
| `HOST` / `PORT` | adresse / port d'écoute | `0.0.0.0` / `8080` |
|
||||||
| `CTX` | taille du contexte | `32768` |
|
| `CTX` | taille du contexte | `32768` |
|
||||||
| `NGL` | couches à déporter sur le GPU | `999` |
|
| `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`.
|
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
|
### 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é :
|
`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é :
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -3,13 +3,13 @@
|
|||||||
"FileVersion": {
|
"FileVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 6,
|
"Minor": 6,
|
||||||
"Patch": 7,
|
"Patch": 8,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 6,
|
"Minor": 6,
|
||||||
"Patch": 7,
|
"Patch": 8,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
|
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
|
||||||
"OriginalFilename": "jean.exe",
|
"OriginalFilename": "jean.exe",
|
||||||
"ProductName": "AJEAN",
|
"ProductName": "AJEAN",
|
||||||
"ProductVersion": "0.6.7",
|
"ProductVersion": "0.6.8",
|
||||||
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
|
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
@@ -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") != ""})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// downloadDestPath resolves the destination of a downloaded model: always
|
||||||
// anything that would escape it (path traversal). config.env conventionally
|
// JEAN_HOME (les dossiers externes sont en lecture, on n'y écrit pas), en
|
||||||
// stores only the basename, so we deliberately strip any directory component.
|
// refusant tout ce qui en sortirait (path traversal).
|
||||||
func modelFilePath(name string) (string, error) {
|
func downloadDestPath(name string) (string, error) {
|
||||||
base := filepath.Base(strings.TrimSpace(name))
|
base := filepath.Base(strings.TrimSpace(name))
|
||||||
if base == "" || base == "." || base == string(filepath.Separator) {
|
if base == "" || base == "." || base == string(filepath.Separator) {
|
||||||
return "", fmt.Errorf("nom de modèle invalide")
|
return "", fmt.Errorf("nom de modèle invalide")
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(strings.ToLower(base), ".gguf") {
|
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
|
return filepath.Join(JeanHome(), base), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// modelFromPresetContent extracts the MODEL= value (basename) from a preset's
|
// modelFromPresetContent extracts the MODEL= value from a preset's config.env
|
||||||
// config.env body, or "" if absent.
|
// body (nom de fichier ou chemin absolu, tel quel), or "" if absent.
|
||||||
func modelFromPresetContent(content string) string {
|
func modelFromPresetContent(content string) string {
|
||||||
for _, line := range strings.Split(content, "\n") {
|
for _, line := range strings.Split(content, "\n") {
|
||||||
s := strings.TrimSpace(line)
|
s := strings.TrimSpace(line)
|
||||||
@@ -113,16 +113,16 @@ func modelFromPresetContent(content string) string {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(s[:i]) == "MODEL" {
|
if strings.TrimSpace(s[:i]) == "MODEL" {
|
||||||
v := strings.Trim(strings.TrimSpace(s[i+1:]), "\"")
|
return strings.Trim(strings.TrimSpace(s[i+1:]), "\"")
|
||||||
return filepath.Base(v)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
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 {
|
func deleteModelFile(name string) error {
|
||||||
p, err := modelFilePath(name)
|
p, err := resolveModelPath(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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()})
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
dest, err := modelFilePath(name)
|
dest, err := downloadDestPath(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -19,14 +19,17 @@ func cmdServe(args []string) error {
|
|||||||
if model == "" {
|
if model == "" {
|
||||||
return fmt.Errorf("MODEL non défini dans %s", confPath())
|
return fmt.Errorf("MODEL non défini dans %s", confPath())
|
||||||
}
|
}
|
||||||
// MODEL est enregistré sous forme de simple nom de fichier (les .gguf vivent
|
// MODEL vaut soit un simple nom de fichier (le .gguf vit dans JEAN_HOME ou
|
||||||
// dans JEAN_HOME) : sous systemd/launchd le WorkingDirectory vaut JEAN_HOME,
|
// dans un dossier déclaré — disque externe…), soit un chemin absolu. Sous
|
||||||
// donc le chemin relatif tombait juste. Lancé depuis une app de bureau, le
|
// systemd/launchd le WorkingDirectory vaut JEAN_HOME, donc le relatif tombait
|
||||||
// répertoire courant est « / » et llama-server ne trouvait rien. On résout
|
// juste ; lancé depuis une app de bureau, le répertoire courant est « / » et
|
||||||
// donc explicitement, quel que soit le contexte de lancement.
|
// llama-server ne trouvait rien. On résout donc explicitement, quel que soit
|
||||||
if !filepath.IsAbs(model) {
|
// le contexte de lancement.
|
||||||
model = filepath.Join(JeanHome(), model)
|
resolved, err := resolveModelPath(model)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
model = resolved
|
||||||
if _, err := os.Stat(model); err != nil {
|
if _, err := os.Stat(model); err != nil {
|
||||||
return fmt.Errorf("modèle introuvable : %s", model)
|
return fmt.Errorf("modèle introuvable : %s", model)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"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).
|
// Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler).
|
||||||
func Main() {
|
func Main() {
|
||||||
|
|||||||
+114
-10
@@ -1233,6 +1233,18 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
|||||||
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
|
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
|
||||||
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
|
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()">
|
||||||
|
<span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span>
|
||||||
|
<span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span>
|
||||||
|
</div>
|
||||||
|
<div id="m-dirs-body" class="pe-row stack" style="display:none">
|
||||||
|
<div id="m-dirs-list" class="pe-note"></div>
|
||||||
|
<div class="pe-inpbtn">
|
||||||
|
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
|
||||||
|
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier jean.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -2059,6 +2071,9 @@ async function openItem(kind, key){
|
|||||||
rawToggle.style.display = '';
|
rawToggle.style.display = '';
|
||||||
rawBody.style.display = 'none';
|
rawBody.style.display = 'none';
|
||||||
rawCaret.classList.remove('open');
|
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()]);
|
await Promise.all([populateBackend(), populateModelPicker()]);
|
||||||
} else {
|
} else {
|
||||||
modelRow.style.display = 'none';
|
modelRow.style.display = 'none';
|
||||||
@@ -2085,24 +2100,109 @@ function currentModelInTextarea(){
|
|||||||
const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m);
|
const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m);
|
||||||
return m ? m[1].trim() : '';
|
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(){
|
async function populateModelPicker(){
|
||||||
const sel = document.getElementById('m-model');
|
const sel = document.getElementById('m-model');
|
||||||
const list = await jget('/api/models');
|
const list = await jget('/api/models');
|
||||||
const cur = currentModelInTextarea();
|
const cur = currentModelInTextarea();
|
||||||
// Strip directory; config.env conventionally only stores the file basename.
|
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
|
||||||
const curBase = (cur.split('/').pop() || '').trim();
|
// 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 = '<option value="">— choisir un modèle —</option>';
|
let html = '<option value="">— choisir un modèle —</option>';
|
||||||
let matched = false;
|
let matched = false;
|
||||||
for(const m of list){
|
for(const g of groups){
|
||||||
const sel = (m.name === curBase) ? ' selected' : '';
|
let opts = '';
|
||||||
if(sel) matched = true;
|
for(const m of g.items){
|
||||||
html += '<option value="'+m.name+'"'+sel+'>'+m.name+' ('+fmtSize(m.size)+')</option>';
|
// 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 += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||||
|
}
|
||||||
|
html += groups.length > 1
|
||||||
|
? '<optgroup label="'+escHtml(g.home ? 'dossier jean' : g.dir)+'">'+opts+'</optgroup>'
|
||||||
|
: 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){
|
if(cur && !matched){
|
||||||
html += '<option value="" disabled selected>('+(curBase||cur)+' — introuvable dans JEAN_HOME)</option>';
|
html += '<option value="" disabled selected>('+escHtml(cur)+' — introuvable ; ajoute son dossier ci-dessous)</option>';
|
||||||
}
|
}
|
||||||
sel.innerHTML = 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(){
|
function onPickModel(){
|
||||||
const val = document.getElementById('m-model').value;
|
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 off = document.getElementById('remote-off'), on = document.getElementById('remote-on');
|
||||||
const badge = document.getElementById('remote-badge');
|
const badge = document.getElementById('remote-badge');
|
||||||
if(!d || !d.linked){
|
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';
|
off.style.display=''; on.style.display='none';
|
||||||
setBadge(badge, null);
|
setBadge(badge, false, 'non connecté');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
off.style.display='none'; on.style.display='';
|
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.
|
// (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache.
|
||||||
const det = document.getElementById('remote-details');
|
const det = document.getElementById('remote-details');
|
||||||
if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; }
|
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.
|
// Ouvre la popup de connexion et attend la clé renvoyée par postMessage.
|
||||||
|
|||||||
@@ -412,6 +412,18 @@
|
|||||||
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
|
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
|
||||||
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
|
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="m-dirs-toggle" class="pe-row" style="cursor:pointer" onclick="toggleModelDirs()">
|
||||||
|
<span class="pe-row-l">Dossiers de modèles<span class="pe-sub">chercher les .gguf ailleurs (disque externe…)</span></span>
|
||||||
|
<span class="pe-row-c"><span id="m-dirs-caret" class="pe-caret"></span></span>
|
||||||
|
</div>
|
||||||
|
<div id="m-dirs-body" class="pe-row stack" style="display:none">
|
||||||
|
<div id="m-dirs-list" class="pe-note"></div>
|
||||||
|
<div class="pe-inpbtn">
|
||||||
|
<input id="m-dir-path" class="pe-inp" placeholder="/media/disque-externe/models">
|
||||||
|
<button id="m-dir-add" onclick="addModelDir()" title="Ajouter ce dossier">+</button>
|
||||||
|
</div>
|
||||||
|
<div class="pe-note">Les téléchargements, eux, vont toujours dans le dossier jean.</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ async function openItem(kind, key){
|
|||||||
rawToggle.style.display = '';
|
rawToggle.style.display = '';
|
||||||
rawBody.style.display = 'none';
|
rawBody.style.display = 'none';
|
||||||
rawCaret.classList.remove('open');
|
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()]);
|
await Promise.all([populateBackend(), populateModelPicker()]);
|
||||||
} else {
|
} else {
|
||||||
modelRow.style.display = 'none';
|
modelRow.style.display = 'none';
|
||||||
@@ -115,24 +118,109 @@ function currentModelInTextarea(){
|
|||||||
const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m);
|
const m = txt.match(/^\s*MODEL\s*=\s*"?([^"\n]*)"?\s*$/m);
|
||||||
return m ? m[1].trim() : '';
|
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(){
|
async function populateModelPicker(){
|
||||||
const sel = document.getElementById('m-model');
|
const sel = document.getElementById('m-model');
|
||||||
const list = await jget('/api/models');
|
const list = await jget('/api/models');
|
||||||
const cur = currentModelInTextarea();
|
const cur = currentModelInTextarea();
|
||||||
// Strip directory; config.env conventionally only stores the file basename.
|
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
|
||||||
const curBase = (cur.split('/').pop() || '').trim();
|
// 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 = '<option value="">— choisir un modèle —</option>';
|
let html = '<option value="">— choisir un modèle —</option>';
|
||||||
let matched = false;
|
let matched = false;
|
||||||
for(const m of list){
|
for(const g of groups){
|
||||||
const sel = (m.name === curBase) ? ' selected' : '';
|
let opts = '';
|
||||||
if(sel) matched = true;
|
for(const m of g.items){
|
||||||
html += '<option value="'+m.name+'"'+sel+'>'+m.name+' ('+fmtSize(m.size)+')</option>';
|
// 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 += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||||
|
}
|
||||||
|
html += groups.length > 1
|
||||||
|
? '<optgroup label="'+escHtml(g.home ? 'dossier jean' : g.dir)+'">'+opts+'</optgroup>'
|
||||||
|
: 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){
|
if(cur && !matched){
|
||||||
html += '<option value="" disabled selected>('+(curBase||cur)+' — introuvable dans JEAN_HOME)</option>';
|
html += '<option value="" disabled selected>('+escHtml(cur)+' — introuvable ; ajoute son dossier ci-dessous)</option>';
|
||||||
}
|
}
|
||||||
sel.innerHTML = 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(){
|
function onPickModel(){
|
||||||
const val = document.getElementById('m-model').value;
|
const val = document.getElementById('m-model').value;
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ function renderRemote(d){
|
|||||||
const off = document.getElementById('remote-off'), on = document.getElementById('remote-on');
|
const off = document.getElementById('remote-off'), on = document.getElementById('remote-on');
|
||||||
const badge = document.getElementById('remote-badge');
|
const badge = document.getElementById('remote-badge');
|
||||||
if(!d || !d.linked){
|
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';
|
off.style.display=''; on.style.display='none';
|
||||||
setBadge(badge, null);
|
setBadge(badge, false, 'non connecté');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
off.style.display='none'; on.style.display='';
|
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.
|
// (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache.
|
||||||
const det = document.getElementById('remote-details');
|
const det = document.getElementById('remote-details');
|
||||||
if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; }
|
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.
|
// Ouvre la popup de connexion et attend la clé renvoyée par postMessage.
|
||||||
|
|||||||
+33
-14
@@ -261,25 +261,44 @@ func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 200, map[string]any{"ok": true})
|
sendJSON(w, 200, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleModels lists *.gguf files in JEAN_HOME (size in bytes) for the preset
|
// handleModels lists *.gguf files (size in bytes) for the preset editor's model
|
||||||
// editor's model picker.
|
// 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) {
|
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{}
|
out := []map[string]any{}
|
||||||
for _, e := range entries {
|
seen := map[string]bool{}
|
||||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") {
|
home := JeanHome()
|
||||||
|
for _, dir := range modelDirs() {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
info, _ := e.Info()
|
isHome := normDir(dir) == normDir(home)
|
||||||
size := int64(0)
|
for _, e := range entries {
|
||||||
if info != nil {
|
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") {
|
||||||
size = info.Size()
|
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)
|
sendJSON(w, 200, out)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func newWebMux() *http.ServeMux {
|
|||||||
api("/api/update/apply", handleUpdateApply)
|
api("/api/update/apply", handleUpdateApply)
|
||||||
api("/api/models", handleModels)
|
api("/api/models", handleModels)
|
||||||
api("/api/models/delete", handleModelDelete)
|
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", handleModelDownload)
|
||||||
api("/api/models/download/status", handleModelDownloadStatus)
|
api("/api/models/download/status", handleModelDownloadStatus)
|
||||||
api("/api/models/download/cancel", handleModelDownloadCancel)
|
api("/api/models/download/cancel", handleModelDownloadCancel)
|
||||||
|
|||||||
Reference in new issue
Block a user