Modèles : un .gguf gardé se réutilise et s'efface

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
This commit is contained in:
Claude committed 2026-09-12 20:20:33 +00:00
1 parent 54468b3336
commit 9bdaf00ca4
9 files changed
+1061 -71

No files matched your search

+66 -10
View File
@@ -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
+164
View File
@@ -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)
}
+246
View File
@@ -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"])
}
}
+5 -1
View File
@@ -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)
+270 -26
View File
@@ -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)}
</div>
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div>
<!-- Modèles installés. Un .gguf survit à la suppression de son preset
(c'est voulu : on le réutilise dans un autre). Encore faut-il
pouvoir le REPRENDRE et, un jour, l'effacer — sans ça les
fichiers gardés devenaient invisibles et indéboulonnables. -->
<div id="m-inst-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalled()">
<span class="pe-row-l">Modèles installés<span class="pe-sub">les .gguf présents sur le disque — et de quoi en effacer un</span></span>
<span class="pe-row-c"><span id="m-inst-state" class="pe-sub"></span><span id="m-inst-caret" class="pe-caret"></span></span>
</div>
<div id="m-inst-body" class="pe-row stack" style="display:none">
<div id="m-inst-list"></div>
</div>
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
touche qu'une fois, le jour où un dépôt à accès restreint refuse
le téléchargement. Il vaut aussi pour la recherche, d'où sa place
@@ -4639,6 +4671,9 @@ async function openItem(kind, key){
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
document.getElementById('m-dir-path').value = '';
setModelDirsOpen(false);
// Modèles installés : replié aussi, mais son RÉSUMÉ (« 4 modèles · 87 Go »)
// est lu tout de suite — c'est lui qui s'affiche sur la ligne repliée.
setInstalledOpen(false);
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
setHFTokenOpen(false);
@@ -4679,7 +4714,8 @@ async function openItem(kind, key){
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ?
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(),
populateDlDirs(), refreshInstalled()]);
if(seq !== openSeq) return;
}
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
@@ -4728,12 +4764,31 @@ function samePath(a, b){
return !!a && !!b && n(a) === n(b);
}
function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); }
// Retrouve dans `list` (/api/models) l'entrée que désigne la valeur MODEL= d'un
// preset. Trois écritures possibles et toutes légitimes : le chemin complet, la
// valeur produite par le sélecteur, ou le SIMPLE NOM DE FICHIER (config écrite à
// la main, preset repris d'une autre machine, ancien preset). Le nom simple se
// résout comme le fait le moteur — dossier de loki d'abord, puis les autres.
//
// Sans ce dernier cas, un MODEL=modele.gguf parfaitement valide s'affichait
// « introuvable ; ajoute son dossier ci-dessous » alors que le fichier était là,
// et le sélecteur ne proposait rien : impossible de refaire un preset sur un
// modèle déjà présent sans le retélécharger.
function matchModel(cur, list){
if(!cur) return null;
const all = list || [];
const hit = all.find(x => samePath(cur, x.value) || samePath(cur, x.path));
if(hit) return hit;
const b = baseName(cur);
return all.find(x => x.home && samePath(b, x.name)) || all.find(x => samePath(b, x.name)) || null;
}
async function populateModelPicker(){
const sel = document.getElementById('m-model');
const list = await jget('/api/models');
const cur = currentModelInTextarea();
const hit = matchModel(cur, list);
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, LOKI_HOME d'abord (l'API les renvoie dans cet ordre).
// regroupe par dossier, celui de loki 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);
@@ -4745,9 +4800,7 @@ async function populateModelPicker(){
for(const g of groups){
let opts = '';
for(const m of g.items){
// Le preset peut référencer le chemin complet OU le simple nom de fichier.
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
const on = (m === hit) ? ' selected' : '';
if(on) matched = true;
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
// (les autres ne se lancent pas seules) et la taille de la famille entière.
@@ -4894,6 +4947,141 @@ async function removeModelDir(p){
toast('dossier retiré');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
}
// ---- Modèles installés : ce qu'il y a sur le disque, et comment l'effacer ---
// 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 case pas
// cochée, le fichier devenait inatteignable : 40 Go sur le disque que plus rien
// dans l'interface ne savait ni nommer ni reprendre. D'où cette liste.
//
// Elle dit aussi QUI se sert de chaque fichier : le modèle en service ne
// s'efface pas (le moteur l'a ouvert), et un modèle nommé par d'autres presets
// prévient avant, en les nommant.
let installedList = [];
function setInstalledOpen(open){
const b = document.getElementById('m-inst-body');
const c = document.getElementById('m-inst-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
// Relu à chaque dépliage : un modèle a pu arriver (téléchargement) ou partir
// depuis l'ouverture de la fenêtre, et une liste périmée ferait cliquer
// « supprimer » sur un fichier qui n'est plus là.
if(open) refreshInstalled();
}
function toggleInstalled(){
setInstalledOpen(document.getElementById('m-inst-body').style.display === 'none');
}
// Recharge la liste ET le résumé de la ligne repliée (« 4 modèles · 87 Go »),
// qui reste juste même quand la section n'est pas dépliée.
async function refreshInstalled(){
try{ installedList = await jget('/api/models') || []; }catch(_){ return; }
const st = document.getElementById('m-inst-state');
if(st){
const total = installedList.reduce((a, m) => a + (m.size||0), 0);
st.textContent = installedList.length
? installedList.length + ' modèle' + (installedList.length>1?'s':'') + ' · ' + fmtSize(total)
: 'aucun';
}
const b = document.getElementById('m-inst-body');
if(b && b.style.display !== 'none') renderInstalled();
}
// Construit en DOM et jamais en innerHTML : ces noms de fichiers viennent du
// disque de l'utilisateur, pas de nous.
function renderInstalled(){
const box = document.getElementById('m-inst-list');
if(!box) return;
box.textContent = '';
if(!installedList.length){
const d = document.createElement('div');
d.className = 'pe-note muted';
d.textContent = 'Aucun .gguf dans les dossiers déclarés.';
box.append(d);
return;
}
const list = document.createElement('div');
list.className = 'mdl-list';
for(const m of installedList){
const row = document.createElement('div');
row.className = 'mdl-row';
const n = document.createElement('span');
n.className = 'mdl-name';
n.textContent = m.name;
n.title = m.path;
const meta = document.createElement('span');
meta.className = 'mdl-meta';
meta.textContent = fmtSize(m.size||0)
+ (m.shards > 1 ? ' · ' + m.shards + ' fichiers' : '')
+ (m.home ? '' : ' · ' + m.dir);
row.append(n, meta);
// Ce qui EMPÊCHE ou ce qui COÛTE : le modèle en service ne s'efface pas, et
// celui que des presets nomment se signale avant le clic, pas après.
if(m.active){
const b = document.createElement('span');
b.className = 'mdl-tag on';
b.textContent = 'en service';
b.title = 'Ce modèle est celui de la configuration en cours : bascule sur un autre preset avant de l’effacer.';
row.append(b);
} else if((m.used||[]).length){
const b = document.createElement('span');
b.className = 'mdl-tag';
b.textContent = m.used.length + ' preset' + (m.used.length>1?'s':'');
b.title = 'Utilisé par : ' + m.used.join(', ');
row.append(b);
}
if((m.missing||[]).length){
const b = document.createElement('span');
b.className = 'mdl-tag warn';
b.textContent = m.missing.length + ' tranche' + (m.missing.length>1?'s':'') + ' manquante' + (m.missing.length>1?'s':'');
b.title = 'Téléchargement incomplet : ' + m.missing.join(', ');
row.append(b);
}
const del = document.createElement('button');
del.className = 'pe-link';
del.textContent = 'supprimer';
del.disabled = !!m.active;
del.onclick = () => deleteInstalled(m);
row.append(del);
list.append(row);
}
box.append(list);
}
async function deleteInstalled(m){
if(m.active){
toast('modèle en service — bascule sur un autre preset d’abord');
return;
}
const known = m.used || [];
const why = known.length
? '\n\nIl est référencé par : ' + known.join(', ') + '. Ces presets ne démarreront plus tant qu’un autre modèle n’y sera pas choisi.'
: '';
const what = (m.shards > 1 ? ' et ses ' + m.shards + ' fichiers' : '');
if(!await askConfirm('Effacer « ' + m.name + ' » (' + fmtSize(m.size||0) + ')' + what + ' du disque ?' + why
+ '\n\nIrréversible : il faudra le retélécharger.',
{title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
// force dès le premier envoi quand les presets concernés étaient DÉJÀ dans la
// confirmation : redemander la même chose une seconde fois ne protège de rien.
let r = await jpost('/api/models/delete', {name: m.path, force: known.length > 0});
// Le serveur connaît des références que la liste affichée ignorait (preset créé
// entre-temps, depuis un autre onglet) : là, la question se pose vraiment.
if(!r.ok && (r.used||[]).length){
if(!await askConfirm('Ce modèle est référencé par : ' + r.used.join(', ') + '.',
{title:'Supprimer quand même ?', okText:'Supprimer', danger:true})) return;
r = await jpost('/api/models/delete', {name: m.path, force: true});
}
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('modèle supprimé — ' + fmtSize(r.freed||0) + ' libérés');
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(),
populateModelDirs(), refreshInstalled()]);
}
// Un .gguf donné est-il déjà sur le disque, complet ? Sert avant de proposer un
// téléchargement (recherche Hugging Face).
async function modelIsOnDisk(name){
let list = [];
try{ list = await jget('/api/models') || []; }catch(_){ return false; }
return !!list.find(m => samePath(m.name, baseName(name)) && !(m.missing||[]).length);
}
function onPickModel(){
const val = document.getElementById('m-model').value;
if(!val) return;
@@ -4935,11 +5123,11 @@ async function populateMmproj(){
const list = await jget('/api/models');
const cur = currentMmprojInTextarea();
const items = (list||[]).filter(m => isMmprojName(m.name));
const hit = matchModel(cur, items);
let html = '<option value="">— aucune —</option>';
let matched = false;
for(const m of items){
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
samePath(baseName(cur), m.name) ? ' selected' : '';
const on = (m === hit) ? ' selected' : '';
if(on) matched = true;
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
@@ -5395,8 +5583,11 @@ async function delItem(){
const r = await jpost(K.delUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
if(delModel){
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
else if(r.modelDeleted) toast('preset + modèle supprimés');
// modelError est renseigné même quand le fichier A ÉTÉ supprimé (il servait
// aussi à d'autres presets) : on regarde donc modelDeleted d'abord.
if(r.modelDeleted) toast('preset + modèle supprimés — ' + fmtSize(r.freed||0) + ' libérés'
+ (r.modelError ? ' (' + r.modelError + ')' : ''));
else if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
else toast('supprimé (aucun modèle référencé)');
} else { toast('supprimé'); }
closeModal(); K.reload();
@@ -5450,6 +5641,22 @@ async function populateDlDirs(){
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
// vite et brouillent la progression affichée.
let dlQueue = [];
// Un .gguf est disponible (il vient d'arriver, ou il était déjà là) : on le
// sélectionne dans le bon champ du preset. Un projecteur (mmproj) va dans
// « Vision », un modèle dans le sélecteur de modèle — d'après son nom de
// fichier. Le fichier a pu atterrir hors du dossier loki : l'option porte alors
// le chemin complet, pas le simple nom.
async function selectInstalled(fname){
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), refreshInstalled()]);
const pick = (id, cb)=>{
const s = document.getElementById(id);
if(!s) return false;
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(!o) return false;
s.value = o.value; cb(); return true;
};
return isMmprojName(fname) ? pick('m-mmproj', onPickMmproj) : pick('m-model', onPickModel);
}
async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; }
@@ -5467,6 +5674,20 @@ async function startDownloadURL(url){
// Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
const p = await jpost('/api/models/download/probe', {url, dir});
// Modèle DÉJÀ sur le disque : il n'y a rien à télécharger, il y a un modèle à
// choisir. C'était le cul-de-sac — un .gguf gardé après la suppression de son
// preset ne pouvait plus servir à en refaire un : le téléchargement répondait
// « le modèle existe déjà » en rouge et s'arrêtait là.
if(p.ok && p.exists){
e.bar.style.display = 'none';
e.btn.disabled = false;
const done = await selectInstalled(p.filename);
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(p.filename)
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '')
+ (p.size > 0 ? ' (' + fmtSize(p.size) + ')' : '') + '</span>';
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
if(!p.ok || !p.enough){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false;
@@ -5482,9 +5703,19 @@ async function startDownloadURL(url){
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
}
// Le serveur peut constater lui aussi que tout est déjà là (fichier apparu
// entre la sonde et l'envoi, ou lien collé à la main sans passer par la sonde).
if(r.exists){
e.bar.style.display = 'none'; e.btn.disabled = false;
const done = await selectInstalled(r.filename);
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(r.filename)
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '') + '</span>';
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
watchDownload(r.filename);
}
// Annule le téléchargement en cours : le serveur coupe les connexions et
@@ -5523,18 +5754,7 @@ function watchDownload(fname){
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
// a pu atterrir hors du dossier loki : l'option porte alors le chemin
// complet, pas le simple nom de fichier.
const pick = (id, cb)=>{
const s = document.getElementById(id);
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(o){ s.value = o.value; cb(); }
};
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
else pick('m-model', onPickModel);
await selectInstalled(fname);
// Suite de la file : c'est ici que le projecteur part, une fois le modèle
// arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
// est déjà sélectionné.
@@ -5651,10 +5871,19 @@ function fmtCount(n){
async function hfPickRepo(repo){
hfMsg('lecture du dépôt…');
let r;
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
let r, onDisk = [];
try{
// Les .gguf déjà présents sont demandés EN MÊME TEMPS que l'arborescence :
// c'est ce qui permet de dire « déjà installé » sur la ligne d'un quant au
// lieu de le découvrir après le clic, par un refus.
[r, onDisk] = await Promise.all([
jget('/api/hf/files?repo='+encodeURIComponent(repo)),
jget('/api/models').catch(()=>[]),
]);
}
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
const installed = n => (onDisk||[]).find(m => samePath(m.name, baseName(n)) && !(m.missing||[]).length);
hfRepoFiles = r;
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
@@ -5709,7 +5938,8 @@ async function hfPickRepo(repo){
mmChk = document.createElement('input');
mmChk.type = 'checkbox'; mmChk.checked = true;
const t = document.createElement('span');
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')';
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')'
+ (installed(mm.name) ? ' — déjà sur le disque' : '');
note.append(mmChk, t);
} else {
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
@@ -5727,6 +5957,17 @@ async function hfPickRepo(repo){
const meta = document.createElement('span'); meta.className = 'hf-meta';
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
row.append(n, meta);
// Déjà sur le disque : la pastille le dit AVANT le clic, et le clic ne
// télécharge alors rien — il sélectionne le fichier présent. Le verdict
// mémoire n'a plus grand-chose à arbitrer dans ce cas, mais il reste
// affiché : « juste » vaut d'être su même sur un modèle déjà installé.
if(installed(m.name)){
const here = document.createElement('span');
here.className = 'hf-fit here';
here.textContent = 'déjà installé';
here.title = 'Ce fichier est déjà dans un de tes dossiers de modèles : le choisir le sélectionne, sans rien retélécharger.';
row.appendChild(here);
}
if(m.verdict){
const fit = document.createElement('span');
fit.className = 'hf-fit ' + m.verdict;
@@ -5758,7 +5999,10 @@ function hfBestProjector(list){
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
// champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){
if(model.verdict === 'trop' && !await askConfirm(
// Le fichier est là : rien à télécharger, donc rien à confirmer — la sonde le
// constate côté serveur et startDownloadURL le sélectionne.
const here = await modelIsOnDisk(model.name);
if(!here && model.verdict === 'trop' && !await askConfirm(
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : [];
+11
View File
@@ -1028,6 +1028,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div>
<!-- Modèles installés. Un .gguf survit à la suppression de son preset
(c'est voulu : on le réutilise dans un autre). Encore faut-il
pouvoir le REPRENDRE et, un jour, l'effacer — sans ça les
fichiers gardés devenaient invisibles et indéboulonnables. -->
<div id="m-inst-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalled()">
<span class="pe-row-l">Modèles installés<span class="pe-sub">les .gguf présents sur le disque — et de quoi en effacer un</span></span>
<span class="pe-row-c"><span id="m-inst-state" class="pe-sub"></span><span id="m-inst-caret" class="pe-caret"></span></span>
</div>
<div id="m-inst-body" class="pe-row stack" style="display:none">
<div id="m-inst-list"></div>
</div>
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
touche qu'une fois, le jour où un dépôt à accès restreint refuse
le téléchargement. Il vaut aussi pour la recherche, d'où sa place
+238 -26
View File
@@ -121,6 +121,9 @@ async function openItem(kind, key){
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
document.getElementById('m-dir-path').value = '';
setModelDirsOpen(false);
// Modèles installés : replié aussi, mais son RÉSUMÉ (« 4 modèles · 87 Go »)
// est lu tout de suite — c'est lui qui s'affiche sur la ligne repliée.
setInstalledOpen(false);
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
setHFTokenOpen(false);
@@ -161,7 +164,8 @@ async function openItem(kind, key){
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ?
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(),
populateDlDirs(), refreshInstalled()]);
if(seq !== openSeq) return;
}
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
@@ -210,12 +214,31 @@ function samePath(a, b){
return !!a && !!b && n(a) === n(b);
}
function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); }
// Retrouve dans `list` (/api/models) l'entrée que désigne la valeur MODEL= d'un
// preset. Trois écritures possibles et toutes légitimes : le chemin complet, la
// valeur produite par le sélecteur, ou le SIMPLE NOM DE FICHIER (config écrite à
// la main, preset repris d'une autre machine, ancien preset). Le nom simple se
// résout comme le fait le moteur — dossier de loki d'abord, puis les autres.
//
// Sans ce dernier cas, un MODEL=modele.gguf parfaitement valide s'affichait
// « introuvable ; ajoute son dossier ci-dessous » alors que le fichier était là,
// et le sélecteur ne proposait rien : impossible de refaire un preset sur un
// modèle déjà présent sans le retélécharger.
function matchModel(cur, list){
if(!cur) return null;
const all = list || [];
const hit = all.find(x => samePath(cur, x.value) || samePath(cur, x.path));
if(hit) return hit;
const b = baseName(cur);
return all.find(x => x.home && samePath(b, x.name)) || all.find(x => samePath(b, x.name)) || null;
}
async function populateModelPicker(){
const sel = document.getElementById('m-model');
const list = await jget('/api/models');
const cur = currentModelInTextarea();
const hit = matchModel(cur, list);
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, LOKI_HOME d'abord (l'API les renvoie dans cet ordre).
// regroupe par dossier, celui de loki 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);
@@ -227,9 +250,7 @@ async function populateModelPicker(){
for(const g of groups){
let opts = '';
for(const m of g.items){
// Le preset peut référencer le chemin complet OU le simple nom de fichier.
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
const on = (m === hit) ? ' selected' : '';
if(on) matched = true;
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
// (les autres ne se lancent pas seules) et la taille de la famille entière.
@@ -376,6 +397,141 @@ async function removeModelDir(p){
toast('dossier retiré');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
}
// ---- Modèles installés : ce qu'il y a sur le disque, et comment l'effacer ---
// 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 case pas
// cochée, le fichier devenait inatteignable : 40 Go sur le disque que plus rien
// dans l'interface ne savait ni nommer ni reprendre. D'où cette liste.
//
// Elle dit aussi QUI se sert de chaque fichier : le modèle en service ne
// s'efface pas (le moteur l'a ouvert), et un modèle nommé par d'autres presets
// prévient avant, en les nommant.
let installedList = [];
function setInstalledOpen(open){
const b = document.getElementById('m-inst-body');
const c = document.getElementById('m-inst-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
// Relu à chaque dépliage : un modèle a pu arriver (téléchargement) ou partir
// depuis l'ouverture de la fenêtre, et une liste périmée ferait cliquer
// « supprimer » sur un fichier qui n'est plus là.
if(open) refreshInstalled();
}
function toggleInstalled(){
setInstalledOpen(document.getElementById('m-inst-body').style.display === 'none');
}
// Recharge la liste ET le résumé de la ligne repliée (« 4 modèles · 87 Go »),
// qui reste juste même quand la section n'est pas dépliée.
async function refreshInstalled(){
try{ installedList = await jget('/api/models') || []; }catch(_){ return; }
const st = document.getElementById('m-inst-state');
if(st){
const total = installedList.reduce((a, m) => a + (m.size||0), 0);
st.textContent = installedList.length
? installedList.length + ' modèle' + (installedList.length>1?'s':'') + ' · ' + fmtSize(total)
: 'aucun';
}
const b = document.getElementById('m-inst-body');
if(b && b.style.display !== 'none') renderInstalled();
}
// Construit en DOM et jamais en innerHTML : ces noms de fichiers viennent du
// disque de l'utilisateur, pas de nous.
function renderInstalled(){
const box = document.getElementById('m-inst-list');
if(!box) return;
box.textContent = '';
if(!installedList.length){
const d = document.createElement('div');
d.className = 'pe-note muted';
d.textContent = 'Aucun .gguf dans les dossiers déclarés.';
box.append(d);
return;
}
const list = document.createElement('div');
list.className = 'mdl-list';
for(const m of installedList){
const row = document.createElement('div');
row.className = 'mdl-row';
const n = document.createElement('span');
n.className = 'mdl-name';
n.textContent = m.name;
n.title = m.path;
const meta = document.createElement('span');
meta.className = 'mdl-meta';
meta.textContent = fmtSize(m.size||0)
+ (m.shards > 1 ? ' · ' + m.shards + ' fichiers' : '')
+ (m.home ? '' : ' · ' + m.dir);
row.append(n, meta);
// Ce qui EMPÊCHE ou ce qui COÛTE : le modèle en service ne s'efface pas, et
// celui que des presets nomment se signale avant le clic, pas après.
if(m.active){
const b = document.createElement('span');
b.className = 'mdl-tag on';
b.textContent = 'en service';
b.title = 'Ce modèle est celui de la configuration en cours : bascule sur un autre preset avant de l’effacer.';
row.append(b);
} else if((m.used||[]).length){
const b = document.createElement('span');
b.className = 'mdl-tag';
b.textContent = m.used.length + ' preset' + (m.used.length>1?'s':'');
b.title = 'Utilisé par : ' + m.used.join(', ');
row.append(b);
}
if((m.missing||[]).length){
const b = document.createElement('span');
b.className = 'mdl-tag warn';
b.textContent = m.missing.length + ' tranche' + (m.missing.length>1?'s':'') + ' manquante' + (m.missing.length>1?'s':'');
b.title = 'Téléchargement incomplet : ' + m.missing.join(', ');
row.append(b);
}
const del = document.createElement('button');
del.className = 'pe-link';
del.textContent = 'supprimer';
del.disabled = !!m.active;
del.onclick = () => deleteInstalled(m);
row.append(del);
list.append(row);
}
box.append(list);
}
async function deleteInstalled(m){
if(m.active){
toast('modèle en service — bascule sur un autre preset d’abord');
return;
}
const known = m.used || [];
const why = known.length
? '\n\nIl est référencé par : ' + known.join(', ') + '. Ces presets ne démarreront plus tant qu’un autre modèle n’y sera pas choisi.'
: '';
const what = (m.shards > 1 ? ' et ses ' + m.shards + ' fichiers' : '');
if(!await askConfirm('Effacer « ' + m.name + ' » (' + fmtSize(m.size||0) + ')' + what + ' du disque ?' + why
+ '\n\nIrréversible : il faudra le retélécharger.',
{title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
// force dès le premier envoi quand les presets concernés étaient DÉJÀ dans la
// confirmation : redemander la même chose une seconde fois ne protège de rien.
let r = await jpost('/api/models/delete', {name: m.path, force: known.length > 0});
// Le serveur connaît des références que la liste affichée ignorait (preset créé
// entre-temps, depuis un autre onglet) : là, la question se pose vraiment.
if(!r.ok && (r.used||[]).length){
if(!await askConfirm('Ce modèle est référencé par : ' + r.used.join(', ') + '.',
{title:'Supprimer quand même ?', okText:'Supprimer', danger:true})) return;
r = await jpost('/api/models/delete', {name: m.path, force: true});
}
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('modèle supprimé — ' + fmtSize(r.freed||0) + ' libérés');
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(),
populateModelDirs(), refreshInstalled()]);
}
// Un .gguf donné est-il déjà sur le disque, complet ? Sert avant de proposer un
// téléchargement (recherche Hugging Face).
async function modelIsOnDisk(name){
let list = [];
try{ list = await jget('/api/models') || []; }catch(_){ return false; }
return !!list.find(m => samePath(m.name, baseName(name)) && !(m.missing||[]).length);
}
function onPickModel(){
const val = document.getElementById('m-model').value;
if(!val) return;
@@ -417,11 +573,11 @@ async function populateMmproj(){
const list = await jget('/api/models');
const cur = currentMmprojInTextarea();
const items = (list||[]).filter(m => isMmprojName(m.name));
const hit = matchModel(cur, items);
let html = '<option value="">— aucune —</option>';
let matched = false;
for(const m of items){
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
samePath(baseName(cur), m.name) ? ' selected' : '';
const on = (m === hit) ? ' selected' : '';
if(on) matched = true;
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
@@ -877,8 +1033,11 @@ async function delItem(){
const r = await jpost(K.delUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
if(delModel){
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
else if(r.modelDeleted) toast('preset + modèle supprimés');
// modelError est renseigné même quand le fichier A ÉTÉ supprimé (il servait
// aussi à d'autres presets) : on regarde donc modelDeleted d'abord.
if(r.modelDeleted) toast('preset + modèle supprimés — ' + fmtSize(r.freed||0) + ' libérés'
+ (r.modelError ? ' (' + r.modelError + ')' : ''));
else if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
else toast('supprimé (aucun modèle référencé)');
} else { toast('supprimé'); }
closeModal(); K.reload();
@@ -932,6 +1091,22 @@ async function populateDlDirs(){
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
// vite et brouillent la progression affichée.
let dlQueue = [];
// Un .gguf est disponible (il vient d'arriver, ou il était déjà là) : on le
// sélectionne dans le bon champ du preset. Un projecteur (mmproj) va dans
// « Vision », un modèle dans le sélecteur de modèle — d'après son nom de
// fichier. Le fichier a pu atterrir hors du dossier loki : l'option porte alors
// le chemin complet, pas le simple nom.
async function selectInstalled(fname){
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), refreshInstalled()]);
const pick = (id, cb)=>{
const s = document.getElementById(id);
if(!s) return false;
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(!o) return false;
s.value = o.value; cb(); return true;
};
return isMmprojName(fname) ? pick('m-mmproj', onPickMmproj) : pick('m-model', onPickModel);
}
async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; }
@@ -949,6 +1124,20 @@ async function startDownloadURL(url){
// Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
const p = await jpost('/api/models/download/probe', {url, dir});
// Modèle DÉJÀ sur le disque : il n'y a rien à télécharger, il y a un modèle à
// choisir. C'était le cul-de-sac — un .gguf gardé après la suppression de son
// preset ne pouvait plus servir à en refaire un : le téléchargement répondait
// « le modèle existe déjà » en rouge et s'arrêtait là.
if(p.ok && p.exists){
e.bar.style.display = 'none';
e.btn.disabled = false;
const done = await selectInstalled(p.filename);
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(p.filename)
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '')
+ (p.size > 0 ? ' (' + fmtSize(p.size) + ')' : '') + '</span>';
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
if(!p.ok || !p.enough){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false;
@@ -964,9 +1153,19 @@ async function startDownloadURL(url){
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
}
// Le serveur peut constater lui aussi que tout est déjà là (fichier apparu
// entre la sonde et l'envoi, ou lien collé à la main sans passer par la sonde).
if(r.exists){
e.bar.style.display = 'none'; e.btn.disabled = false;
const done = await selectInstalled(r.filename);
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(r.filename)
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '') + '</span>';
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
watchDownload(r.filename);
}
// Annule le téléchargement en cours : le serveur coupe les connexions et
@@ -1005,18 +1204,7 @@ function watchDownload(fname){
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
// a pu atterrir hors du dossier loki : l'option porte alors le chemin
// complet, pas le simple nom de fichier.
const pick = (id, cb)=>{
const s = document.getElementById(id);
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(o){ s.value = o.value; cb(); }
};
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
else pick('m-model', onPickModel);
await selectInstalled(fname);
// Suite de la file : c'est ici que le projecteur part, une fois le modèle
// arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
// est déjà sélectionné.
@@ -1133,10 +1321,19 @@ function fmtCount(n){
async function hfPickRepo(repo){
hfMsg('lecture du dépôt…');
let r;
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
let r, onDisk = [];
try{
// Les .gguf déjà présents sont demandés EN MÊME TEMPS que l'arborescence :
// c'est ce qui permet de dire « déjà installé » sur la ligne d'un quant au
// lieu de le découvrir après le clic, par un refus.
[r, onDisk] = await Promise.all([
jget('/api/hf/files?repo='+encodeURIComponent(repo)),
jget('/api/models').catch(()=>[]),
]);
}
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
const installed = n => (onDisk||[]).find(m => samePath(m.name, baseName(n)) && !(m.missing||[]).length);
hfRepoFiles = r;
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
@@ -1191,7 +1388,8 @@ async function hfPickRepo(repo){
mmChk = document.createElement('input');
mmChk.type = 'checkbox'; mmChk.checked = true;
const t = document.createElement('span');
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')';
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')'
+ (installed(mm.name) ? ' — déjà sur le disque' : '');
note.append(mmChk, t);
} else {
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
@@ -1209,6 +1407,17 @@ async function hfPickRepo(repo){
const meta = document.createElement('span'); meta.className = 'hf-meta';
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
row.append(n, meta);
// Déjà sur le disque : la pastille le dit AVANT le clic, et le clic ne
// télécharge alors rien — il sélectionne le fichier présent. Le verdict
// mémoire n'a plus grand-chose à arbitrer dans ce cas, mais il reste
// affiché : « juste » vaut d'être su même sur un modèle déjà installé.
if(installed(m.name)){
const here = document.createElement('span');
here.className = 'hf-fit here';
here.textContent = 'déjà installé';
here.title = 'Ce fichier est déjà dans un de tes dossiers de modèles : le choisir le sélectionne, sans rien retélécharger.';
row.appendChild(here);
}
if(m.verdict){
const fit = document.createElement('span');
fit.className = 'hf-fit ' + m.verdict;
@@ -1240,7 +1449,10 @@ function hfBestProjector(list){
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
// champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){
if(model.verdict === 'trop' && !await askConfirm(
// Le fichier est là : rien à télécharger, donc rien à confirmer — la sonde le
// constate côté serveur et startDownloadURL le sélectionne.
const here = await modelIsOnDisk(model.name);
if(!here && model.verdict === 'trop' && !await askConfirm(
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : [];
+21
View File
@@ -1465,7 +1465,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}
+40 -8
View File
@@ -299,14 +299,30 @@ func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) {
}
// handleModels lists *.gguf files (size in bytes) for the preset editor's model
// picker, dans LOKI_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 LOKI_HOME (compatibilité avec l'existant), le chemin complet
// pour un dossier externe.
// picker, dans le dossier de modèles de Loki 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 le dossier de Loki
// (compatibilité avec l'existant, et preset portable d'une machine à l'autre),
// le chemin complet pour un dossier externe.
//
// ⚠️ Le dossier « maison » est modelsDir() — $LOKI_HOME/models — et non
// LokiHome() : c'est là que les téléchargements atterrissent (downloadDestPath)
// et c'est lui que resolveModelPath essaie en premier pour un nom simple. Le
// comparer à LokiHome() ne pouvait JAMAIS être vrai : chaque modèle sortait en
// chemin absolu, l'étiquette « dossier loki » ne s'affichait nulle part, et un
// preset écrit à la main avec MODEL=modele.gguf ne correspondait à aucune
// option du sélecteur — il s'affichait « introuvable » alors que le fichier
// était bien là.
//
// Chaque entrée porte aussi QUI la référence (« used » : noms de presets,
// « active » : la configuration en service). C'est ce qui permet à l'interface
// de proposer la suppression d'un .gguf sans faire disparaître le modèle sous
// le moteur qui tourne.
func handleModels(w http.ResponseWriter, r *http.Request) {
out := []map[string]any{}
seen := map[string]bool{}
home := LokiHome()
home := modelsDir()
refs := buildModelRefIndex()
for _, dir := range modelDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
@@ -341,6 +357,12 @@ func handleModels(w http.ResponseWriter, r *http.Request) {
"name": e.Name(), "size": size, "value": value,
"path": full, "dir": dir, "home": isHome,
}
if users, active := refs.lookup(full); len(users) > 0 || active {
if len(users) > 0 {
m["used"] = users
}
m["active"] = active
}
// Taille annoncée = la famille entière, et on signale les tranches
// manquantes : un modèle incomplet démarre puis meurt sur un tenseur
// introuvable, autant le voir avant de le sélectionner.
@@ -462,14 +484,24 @@ func handlePresetDelete(w http.ResponseWriter, r *http.Request) {
return
}
modelDeleted, modelErr := "", ""
freed := int64(0)
if req.DeleteModel && model != "" {
if err := deleteModelFile(model); err != nil {
// Le preset vient de disparaître : les « utilisé par » comptés ici sont
// donc les AUTRES presets, et le refus « en service » vise bien le modèle
// que le moteur a ouvert. On ne bloque pas sur un autre preset — c'est un
// geste explicite, coché dans la confirmation — mais on le DIT.
others, _ := modelUsers(model)
if n, err := deleteModelFile(model); err != nil {
modelErr = err.Error()
} else {
modelDeleted = model
modelDeleted, freed = model, n
if len(others) > 0 {
modelErr = "supprimé, mais il était aussi référencé par : " + strings.Join(others, ", ")
}
}
}
sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr})
sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted,
"modelError": modelErr, "freed": freed})
}
// handleAgent renvoie l'état du mode agent ET la liste des pages mémoire (que