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 + +
+