From 9bdaf00ca45c65bfdc14f9c340945d6d6bed8c96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 12 Sep 2026 20:20:33 +0000 Subject: [PATCH] =?UTF-8?q?Mod=C3=A8les=20:=20un=20.gguf=20gard=C3=A9=20se?= =?UTF-8?q?=20r=C3=A9utilise=20et=20s'efface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un modèle téléchargé depuis l'éditeur d'un preset survit à la suppression de ce preset (c'est voulu : on le réutilise ailleurs). Il devenait pourtant inatteignable — impossible de refaire un preset dessus, impossible de l'effacer. Trois causes, trois correctifs. 1. « Le modèle existe déjà » n'est plus une erreur. La recherche Hugging Face proposait le quant, le clic lançait la sonde, et le téléchargement répondait en rouge « le modèle existe déjà » — fin du parcours. La sonde constate maintenant la présence du fichier (sans même sortir sur le réseau) et renvoie la valeur à écrire dans MODEL= : l'interface le SÉLECTIONNE, et la file continue (le projecteur vision, par exemple). Les quants déjà présents portent une pastille « déjà installé » dans la liste du dépôt, avant le clic. 2. Le sélecteur de modèle reconnaissait mal ce qu'il avait sous les yeux. /api/models comparait le dossier de chaque .gguf à LokiHome() alors que les téléchargements atterrissent dans $LOKI_HOME/models : la comparaison ne pouvait jamais être vraie. Conséquences : l'étiquette « dossier loki » ne s'affichait nulle part, et un preset écrit MODEL=modele.gguf s'affichait « introuvable ; ajoute son dossier ci-dessous » — le fichier étant juste à côté. Un nom simple est désormais résolu comme le fait le moteur : dossier de loki d'abord, puis les autres. 3. Une liste « Modèles installés », dans le groupe Modèle de l'éditeur. La route /api/models/delete existait depuis longtemps ; aucun bouton ne l'appelait. Le seul moment où un .gguf pouvait disparaître, c'était en cochant « supprimer aussi le fichier » à la suppression de son preset. La liste montre taille, dossier, tranches manquantes, et qui s'en sert : le modèle en service ne s'efface pas (le moteur l'a ouvert, la place ne serait même pas rendue), celui que des presets nomment prévient en les nommant puis obéit. La suppression dit ce qu'elle a libéré. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog --- internal/loki/backend_models.go | 76 ++++- internal/loki/backend_models_refs.go | 164 +++++++++++ internal/loki/backend_models_refs_test.go | 246 ++++++++++++++++ internal/loki/backend_models_shards_test.go | 6 +- internal/loki/ui/index.html | 296 ++++++++++++++++++-- internal/loki/ui/src/index.tmpl.html | 11 + internal/loki/ui/src/js/07-models.js | 264 +++++++++++++++-- internal/loki/ui/src/styles.css | 21 ++ internal/loki/web_api.go | 48 +++- 9 files changed, 1061 insertions(+), 71 deletions(-) create mode 100644 internal/loki/backend_models_refs.go create mode 100644 internal/loki/backend_models_refs_test.go diff --git a/internal/loki/backend_models.go b/internal/loki/backend_models.go index 8833513..e226007 100644 --- a/internal/loki/backend_models.go +++ b/internal/loki/backend_models.go @@ -201,38 +201,70 @@ func modelFromPresetContent(content string) string { // n'effacer que la première laissait des dizaines de Go de fichiers que plus // rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les // tranches suivantes n'y apparaissent pas). -func deleteModelFile(name string) error { +// +// Renvoie le nombre d'octets réellement libérés — l'interface le dit, et +// « 41,2 Go libérés » est la seule confirmation qui vaille quelque chose après +// un ménage de disque. +// +// Un modèle EN SERVICE n'est jamais effacé : le fichier reste mappé par +// llama-server (la place n'est donc même pas rendue) et le prochain démarrage +// meurt sur un .gguf absent, sans que rien n'ait prévenu. +func deleteModelFile(name string) (int64, error) { p, err := resolveModelPath(name) if err != nil { - return err + return 0, err } + if _, active := modelUsers(name); active { + return 0, fmt.Errorf("%s", modelInServiceMsg) + } + freed := modelFamilySize(p) if err := os.Remove(p); err != nil { if os.IsNotExist(err) { - return fmt.Errorf("modèle introuvable: %s", filepath.Base(p)) + return 0, fmt.Errorf("modèle introuvable: %s", filepath.Base(p)) } - return err + return 0, err } dir := filepath.Dir(p) for _, n := range shardFamily(filepath.Base(p)) { _ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire } - return nil + return freed, nil } -// handleModelDelete deletes a single .gguf from LOKI_HOME. +// handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés. +// +// Deux garde-fous, et un seul est un refus définitif : +// +// - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ; +// - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets +// concernés. L'interface la montre, l'utilisateur tranche, et renvoie la +// même requête avec force:true. Refuser tout court transformerait le +// nettoyage en impasse — c'est exactement le travers qu'on corrige ici. func handleModelDelete(w http.ResponseWriter, r *http.Request) { var req struct { - Name string `json:"name"` + Name string `json:"name"` + Force bool `json:"force"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } - if err := deleteModelFile(req.Name); err != nil { + users, active := modelUsers(req.Name) + if active { + sendJSON(w, 409, map[string]any{"ok": false, "error": modelInServiceMsg, "active": true}) + return + } + if len(users) > 0 && !req.Force { + sendJSON(w, 409, map[string]any{"ok": false, "used": users, + "error": "modèle utilisé par : " + strings.Join(users, ", ")}) + return + } + freed, err := deleteModelFile(req.Name) + if err != nil { sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) return } - sendJSON(w, 200, map[string]any{"ok": true}) + sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users}) } // ---- Hugging Face downloads ------------------------------------------------- @@ -382,7 +414,16 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) { } if len(todoURLs) == 0 { dlMu.Unlock() - sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name}) + // Rien à faire : toutes les tranches sont là. Ce n'était pas une erreur, + // c'était le résultat voulu — le modèle est installé. On répond ok avec la + // valeur à écrire dans MODEL=, l'interface le sélectionne et enchaîne la + // file (le projecteur vision, par exemple) au lieu de s'arrêter en rouge. + value, _, _ := modelPresence(name, req.Dir) + if value == "" { + value = modelPickerValue(filepath.Dir(dests[0]), name) + } + sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "exists": true, + "value": value, "parts": 0}) return } ctx, cancel := context.WithCancel(context.Background()) @@ -462,6 +503,21 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) { // puis échouer au deux tiers du transfert. urls, names := shardURLSet(dlURL, name) name = names[0] + // Déjà sur le disque : on ne sonde même pas Hugging Face. C'est le cas qui + // mettait l'interface dans une impasse — un modèle gardé après la suppression + // de son preset ne pouvait plus être re-choisi qu'en le retéléchargeant, et + // le téléchargement refusait d'écraser. Il n'y a rien à télécharger : il y a + // un modèle à SÉLECTIONNER, et l'UI a besoin de sa valeur MODEL= pour ça. + // Cherché dans TOUS les dossiers déclarés, pas seulement la destination + // choisie : re-télécharger 40 Go sur le SSD parce que le fichier est sur le + // disque externe déjà déclaré n'aide personne — le sélecteur, lui, le trouve + // où qu'il soit. + if value, path, ok := modelPresence(name, ""); ok { + sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "dir": filepath.Dir(path), + "exists": true, "value": value, "size": modelFamilySize(path), + "free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)}) + return + } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() var total int64 diff --git a/internal/loki/backend_models_refs.go b/internal/loki/backend_models_refs.go new file mode 100644 index 0000000..e8f914d --- /dev/null +++ b/internal/loki/backend_models_refs.go @@ -0,0 +1,164 @@ +package loki + +// backend_models_refs.go — QUI référence un .gguf, et peut-on l'effacer ? +// +// Vécu qui a produit ce fichier : un modèle téléchargé depuis l'éditeur d'un +// preset, le preset supprimé (sans cocher « supprimer aussi le .gguf »), et +// 40 Go de fichier plus rien pour les reprendre — la route /api/models/delete +// existait mais AUCUN bouton ne l'appelait. Supprimer un modèle depuis +// l'interface demande deux choses : savoir qui s'en sert (le moteur en service, +// d'autres presets), et le dire AVANT d'effacer. + +import ( + "os" + "path/filepath" + "strings" +) + +// modelRefIndex — l'index « ce fichier est référencé par… », construit en une +// seule passe. Demander « qui utilise ce modèle ? » fichier par fichier +// relisait tous les presets à chaque ligne de la liste des modèles. +// +// Les clés sont des chemins normalisés (normDir) : un preset peut nommer son +// modèle en chemin absolu et un autre en simple nom de fichier, c'est le même +// fichier sur le disque. +type modelRefIndex struct { + active map[string]bool // référencé par config.env — donc par le moteur qui tourne + presets map[string][]string // chemin -> noms d'affichage des presets +} + +// modelRefKey normalise une valeur MODEL=/MMPROJ= en clé d'index, ou "" si elle +// ne désigne pas un .gguf. resolveModelPath accepte le nom simple comme le +// chemin absolu et les résout dans les dossiers déclarés — c'est exactement la +// règle qu'appliquera le moteur. +func modelRefKey(v string) string { + v = strings.TrimSpace(v) + if v == "" { + return "" + } + p, err := resolveServeModelPath(v) + if err != nil { + // Introuvable : on garde quand même une clé comparable, sinon deux + // presets qui nomment le même fichier absent paraîtraient distincts. + p = filepath.Join(modelsDir(), baseName(v)) + } + return normDir(p) +} + +// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le +// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets +// d'avant la clé MMPROJ l'écrivaient là). +func modelKeysOf(env map[string]string) []string { + vals := []string{env["MODEL"], env["MMPROJ"]} + args := splitArgs(env["EXTRA_ARGS"]) + for i, a := range args { + if a == "--mmproj" && i+1 < len(args) { + vals = append(vals, args[i+1]) + } + } + out := []string{} + for _, v := range vals { + if k := modelRefKey(v); k != "" { + out = append(out, k) + } + } + return out +} + +// buildModelRefIndex lit la configuration active et tous les presets. +func buildModelRefIndex() modelRefIndex { + ix := modelRefIndex{active: map[string]bool{}, presets: map[string][]string{}} + for _, k := range modelKeysOf(ReadConfig()) { + ix.active[k] = true + } + list, err := ListPresets() + if err != nil { + return ix + } + for _, p := range list { + content, err := ReadPreset(p.ID) + if err != nil { + continue + } + seen := map[string]bool{} + for _, k := range modelKeysOf(parseEnv(content)) { + if seen[k] { + continue // MODEL et MMPROJ identiques : un seul « utilisé par » + } + seen[k] = true + ix.presets[k] = append(ix.presets[k], p.Name) + } + } + return ix +} + +// lookup renvoie les presets qui référencent path et s'il est en service. +func (ix modelRefIndex) lookup(path string) (presets []string, active bool) { + k := normDir(path) + return ix.presets[k], ix.active[k] +} + +// modelUsers répond pour UN modèle (nom de fichier ou chemin). +func modelUsers(name string) (presets []string, active bool) { + k := modelRefKey(name) + if k == "" { + return nil, false + } + return buildModelRefIndex().lookup(k) +} + +// modelInServiceErr est le refus opposé à la suppression d'un modèle que le +// moteur a ouvert. Effacer le .gguf sous un llama-server qui tourne ne libère +// même pas la place (le fichier reste mappé) et rend le prochain démarrage +// impossible sans rien dire. +const modelInServiceMsg = "modèle en service — bascule sur un autre preset avant de le supprimer" + +// modelFamilySize additionne ce que la suppression libérera réellement +// (toutes les tranches présentes). +func modelFamilySize(p string) int64 { + return shardFamilySize(filepath.Dir(p), filepath.Base(p)) +} + +// modelPresence dit si un modèle est DÉJÀ sur le disque, et sous quelle valeur +// l'écrire dans MODEL=. dir restreint la recherche à ce dossier (celui visé par +// un téléchargement) ; vide = tous les dossiers déclarés. +// +// C'est ce qui transforme « le modèle existe déjà » — un cul-de-sac rouge au +// bout d'un clic sur un quant — en « déjà là, je le sélectionne ». +func modelPresence(name, dir string) (value, path string, ok bool) { + base := baseName(strings.TrimSpace(name)) + if base == "" || !strings.HasSuffix(strings.ToLower(base), ".gguf") { + return "", "", false + } + dirs := modelDirs() + if strings.TrimSpace(dir) != "" { + if d, err := resolveDownloadDir(dir); err == nil { + dirs = []string{d} + } + } + for _, d := range dirs { + p := filepath.Join(d, base) + if st, err := os.Stat(p); err != nil || st.IsDir() { + continue + } + // Famille incomplète = modèle inutilisable : on ne prétend pas qu'il est + // là, l'appelant doit pouvoir relancer le téléchargement des tranches + // manquantes (handleModelDownload les reprend une par une). + if len(shardFamilyMissing(d, base)) > 0 { + continue + } + return modelPickerValue(d, base), p, true + } + return "", "", false +} + +// modelPickerValue est la valeur à écrire dans MODEL= pour un fichier donné : +// le simple nom quand il vit dans le dossier de téléchargement de Loki (le +// preset reste alors portable d'une machine à l'autre), le chemin complet +// ailleurs. Même règle que la liste /api/models. +func modelPickerValue(dir, base string) string { + if normDir(dir) == normDir(modelsDir()) { + return base + } + return filepath.Join(dir, base) +} diff --git a/internal/loki/backend_models_refs_test.go b/internal/loki/backend_models_refs_test.go new file mode 100644 index 0000000..818c549 --- /dev/null +++ b/internal/loki/backend_models_refs_test.go @@ -0,0 +1,246 @@ +package loki + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// writeModel pose un .gguf factice dans dir et renvoie son chemin. +func writeModel(t *testing.T, dir, name string, size int) string { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, name) + if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +// Le scénario complet du rapport : un modèle téléchargé depuis un preset, le +// preset supprimé sans son .gguf, et plus rien pour reprendre le fichier. +// Après ce correctif il doit être A) retrouvé par le sélecteur, B) effaçable. +func TestModeleOrphelinResteUtilisableEtEffaçable(t *testing.T) { + testHome(t) + writeModel(t, modelsDir(), "modele.gguf", 1024) + + // A) Présent sur le disque, sous la valeur qu'on écrira dans MODEL=. + value, path, ok := modelPresence("modele.gguf", "") + if !ok { + t.Fatal("modèle présent non détecté — le téléchargement resterait un cul-de-sac") + } + if value != "modele.gguf" { + t.Fatalf("valeur MODEL= = %q, attendu le simple nom de fichier", value) + } + if path != filepath.Join(modelsDir(), "modele.gguf") { + t.Fatalf("chemin = %q", path) + } + + // B) Plus personne ne le référence : il s'efface sans force. + users, active := modelUsers("modele.gguf") + if active || len(users) != 0 { + t.Fatalf("modèle orphelin annoncé utilisé : actif=%v presets=%v", active, users) + } + freed, err := deleteModelFile("modele.gguf") + if err != nil { + t.Fatalf("suppression refusée : %v", err) + } + if freed != 1024 { + t.Fatalf("octets libérés = %d, attendu 1024", freed) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("le fichier est toujours là") + } +} + +// Le modèle que le moteur a ouvert ne s'efface pas : la place ne serait même pas +// rendue, et le prochain démarrage mourrait sur un .gguf absent. +func TestSuppressionRefuseeSurLeModeleEnService(t *testing.T) { + testHome(t) + writeModel(t, modelsDir(), "servi.gguf", 8) + if err := WriteConfig(map[string]string{"MODEL": "servi.gguf"}); err != nil { + t.Fatal(err) + } + if _, active := modelUsers("servi.gguf"); !active { + t.Fatal("modèle de la configuration active non reconnu") + } + if _, err := deleteModelFile("servi.gguf"); err == nil { + t.Fatal("le modèle en service a été supprimé") + } + if _, err := os.Stat(filepath.Join(modelsDir(), "servi.gguf")); err != nil { + t.Fatal("fichier supprimé malgré le refus") + } +} + +// Un modèle nommé par des presets n'est pas bloqué : il est SIGNALÉ, et +// force:true tranche. Refuser tout court recréerait l'impasse qu'on corrige. +func TestSuppressionSignaleLesPresetsPuisObeitAForce(t *testing.T) { + testHome(t) + p := writeModel(t, modelsDir(), "partage.gguf", 16) + if _, err := SavePreset("", "Rapide", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil { + t.Fatal(err) + } + if _, err := SavePreset("", "Long", "MODEL=\"partage.gguf\"\nCTX=131072\n"); err != nil { + t.Fatal(err) + } + users, active := modelUsers("partage.gguf") + if active { + t.Fatal("aucune configuration active ne devrait référencer ce modèle") + } + if len(users) != 2 { + t.Fatalf("presets référents = %v, attendu les deux", users) + } + + post := func(body string) (int, map[string]any) { + r := httptest.NewRequest("POST", "/api/models/delete", strings.NewReader(body)) + w := httptest.NewRecorder() + handleModelDelete(w, r) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out + } + code, out := post(`{"name":"partage.gguf"}`) + if code != 409 { + t.Fatalf("code = %d, attendu 409 (presets référents)", code) + } + if len(out["used"].([]any)) != 2 { + t.Fatalf("la réponse doit NOMMER les presets concernés : %v", out) + } + if _, err := os.Stat(p); err != nil { + t.Fatal("fichier supprimé alors que la confirmation n'a pas eu lieu") + } + if code, out = post(`{"name":"partage.gguf","force":true}`); code != 200 { + t.Fatalf("code = %d avec force:true — %v", code, out) + } + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Fatal("force:true n'a pas supprimé le fichier") + } +} + +// MMPROJ et un --mmproj resté dans EXTRA_ARGS comptent aussi : effacer le +// projecteur du modèle en service casse la vision sans rien dire. +func TestProjecteurCompteCommeReference(t *testing.T) { + testHome(t) + writeModel(t, modelsDir(), "mmproj-F16.gguf", 4) + writeModel(t, modelsDir(), "vieux-mmproj.gguf", 4) + if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "MMPROJ": "mmproj-F16.gguf"}); err != nil { + t.Fatal(err) + } + if _, active := modelUsers("mmproj-F16.gguf"); !active { + t.Fatal("MMPROJ= non compté comme référence") + } + if _, err := SavePreset("", "Vision", `MODEL="m.gguf"`+"\n"+`EXTRA_ARGS="--mmproj vieux-mmproj.gguf --flash-attn"`+"\n"); err != nil { + t.Fatal(err) + } + users, _ := modelUsers("vieux-mmproj.gguf") + if len(users) != 1 || users[0] != "Vision" { + t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users) + } +} + +// Une famille de tranches incomplète n'est pas « déjà là » : il reste à +// télécharger, et prétendre le contraire donnerait un moteur qui meurt sur un +// tenseur introuvable. +func TestFamilleIncompleteNestPasPresente(t *testing.T) { + testHome(t) + writeModel(t, modelsDir(), "gros-00001-of-00002.gguf", 4) + if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); ok { + t.Fatal("famille incomplète annoncée présente") + } + writeModel(t, modelsDir(), "gros-00002-of-00002.gguf", 4) + if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); !ok { + t.Fatal("famille complète non détectée") + } +} + +// Un modèle rangé dans un dossier déclaré ailleurs (disque externe) est trouvé +// lui aussi, et sous son chemin complet — un simple nom ne le désignerait pas. +func TestPresenceDansUnDossierDeclare(t *testing.T) { + home := testHome(t) + ext := filepath.Join(home, "externe") + writeModel(t, ext, "ailleurs.gguf", 32) + if _, _, ok := modelPresence("ailleurs.gguf", ""); ok { + t.Fatal("dossier non déclaré : le modèle ne doit pas être visible") + } + if err := saveExtraModelDirs([]string{ext}); err != nil { + t.Fatal(err) + } + value, _, ok := modelPresence("ailleurs.gguf", "") + if !ok { + t.Fatal("modèle d'un dossier déclaré non détecté") + } + if value != filepath.Join(ext, "ailleurs.gguf") { + t.Fatalf("valeur MODEL= = %q, attendu le chemin complet", value) + } +} + +// /api/models doit marquer « dossier loki » le dossier de téléchargement et en +// donner le SIMPLE NOM : c'est ce que resolveModelPath retrouve, et c'est ce qui +// permet au sélecteur de reconnaître un MODEL=modele.gguf écrit à la main. +func TestListeModelesNommeLeDossierDeLoki(t *testing.T) { + testHome(t) + writeModel(t, modelsDir(), "local.gguf", 64) + if err := WriteConfig(map[string]string{"MODEL": "local.gguf"}); err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + handleModels(w, httptest.NewRequest("GET", "/api/models", nil)) + var out []map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if len(out) != 1 { + t.Fatalf("liste = %v", out) + } + if out[0]["home"] != true { + t.Fatal("le dossier de téléchargement n'est pas marqué « dossier loki »") + } + if out[0]["value"] != "local.gguf" { + t.Fatalf("value = %v, attendu le simple nom de fichier", out[0]["value"]) + } + if out[0]["active"] != true { + t.Fatal("le modèle en service doit être signalé comme tel") + } +} + +// Télécharger un modèle DÉJÀ sur le disque n'est pas une erreur : c'est le +// résultat voulu. Les deux routes doivent répondre « ok, exists » avec la valeur +// à écrire dans MODEL= — sinon l'interface s'arrête en rouge sur « le modèle +// existe déjà » et il devient impossible de refaire un preset dessus. +func TestTelechargerUnModelePresentLeSelectionne(t *testing.T) { + testHome(t) + writeModel(t, modelsDir(), "deja.gguf", 2048) + const url = "https://huggingface.co/auteur/depot/resolve/main/deja.gguf" + + call := func(h func(w http.ResponseWriter, r *http.Request), path string) (int, map[string]any) { + body := `{"url":"` + url + `","dir":""}` + w := httptest.NewRecorder() + h(w, httptest.NewRequest("POST", path, strings.NewReader(body))) + var out map[string]any + _ = json.Unmarshal(w.Body.Bytes(), &out) + return w.Code, out + } + + // La sonde ne doit même pas sortir sur le réseau : elle répond depuis le disque. + code, out := call(handleModelDownloadProbe, "/api/models/download/probe") + if code != 200 || out["ok"] != true || out["exists"] != true { + t.Fatalf("sonde : code=%d out=%v", code, out) + } + if out["value"] != "deja.gguf" { + t.Fatalf("sonde : value = %v, attendu le nom à mettre dans MODEL=", out["value"]) + } + + code, out = call(handleModelDownload, "/api/models/download") + if code != 200 || out["ok"] != true || out["exists"] != true { + t.Fatalf("téléchargement : code=%d out=%v", code, out) + } + if out["value"] != "deja.gguf" { + t.Fatalf("téléchargement : value = %v", out["value"]) + } +} diff --git a/internal/loki/backend_models_shards_test.go b/internal/loki/backend_models_shards_test.go index f9f10bc..12ecbd9 100644 --- a/internal/loki/backend_models_shards_test.go +++ b/internal/loki/backend_models_shards_test.go @@ -106,9 +106,13 @@ func TestDeleteModelFileEmporteLesTranches(t *testing.T) { t.Fatal(err) } } - if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil { + freed, err := deleteModelFile("m-00001-of-00002.gguf") + if err != nil { t.Fatal(err) } + if freed != 2 { + t.Fatalf("octets libérés = %d, attendu 2 (les deux tranches d'un octet)", freed) + } for _, n := range names[:2] { if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) { t.Fatalf("tranche %s non supprimée", n) diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index a01b35c..2e94a85 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1496,7 +1496,28 @@ button:hover{border-color:var(--dim);color:var(--text)} endroit et ça décide autant de la suite — un dépôt gated ne se télécharge pas sans jeton. */ .hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none} +/* Déjà sur le disque : ce n'est pas un verdict mémoire non plus, mais c'est + l'information qui décide du clic — cliquer sélectionne au lieu de télécharger. */ +.hf-fit.here{color:var(--accent);border-color:var(--accent);text-transform:none} .hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px} + +/* --- Modèles installés (liste + suppression) -------------------------------- + Même allure que la liste Hugging Face juste au-dessus, mais la LIGNE n'est + pas cliquable : ici c'est le bouton « supprimer » qui agit, et un curseur + main sur toute la ligne laisserait croire le contraire. */ +.mdl-list{display:flex;flex-direction:column;max-height:240px;overflow-y:auto; + border:1px solid var(--border);border-radius:3px;margin-top:6px} +.mdl-row{display:flex;align-items:center;gap:10px;padding:7px 12px; + border-top:1px solid var(--border);font-size:12px} +.mdl-row:first-child{border-top:0} +.mdl-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)} +.mdl-meta{flex:none;color:var(--dim);font-family:var(--mono);font-size:10.5px;white-space:nowrap} +.mdl-tag{flex:none;padding:1px 8px;border-radius:999px;font-size:10px;letter-spacing:.03em; + border:1px solid var(--border);color:var(--dim)} +.mdl-tag.on{color:var(--accent);border-color:var(--accent)} +.mdl-tag.warn{color:var(--warn);border-color:var(--warn)} +.mdl-row .pe-link{flex:none} +.mdl-row .pe-link:disabled{opacity:.4;cursor:not-allowed;text-decoration:none} .hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px; color:var(--dim);cursor:pointer} .hf-mm.muted{cursor:default} @@ -3185,6 +3206,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Ces dossiers servent aussi de destination de téléchargement.
+ +
+ Modèles installésles .gguf présents sur le disque — et de quoi en effacer un + +
+ +
+ Modèles installésles .gguf présents sur le disque — et de quoi en effacer un + +
+