diff --git a/internal/loki/backend_models.go b/internal/loki/backend_models.go
index 8833513..7d93e19 100644
--- a/internal/loki/backend_models.go
+++ b/internal/loki/backend_models.go
@@ -219,20 +219,123 @@ func deleteModelFile(name string) error {
return nil
}
-// handleModelDelete deletes a single .gguf from LOKI_HOME.
+// modelSizeOnDisk additionne les octets qu'un modèle occupe RÉELLEMENT — la
+// famille de tranches entière, pas le seul fichier nommé. C'est ce chiffre
+// qu'on annonce après une suppression : « 42 Go libérés » pour un modèle
+// découpé, et non les 15 Go de sa première tranche.
+func modelSizeOnDisk(p string) int64 {
+ return shardFamilySize(filepath.Dir(p), filepath.Base(p))
+}
+
+// modelIsLoaded dit si p est le modèle de la configuration ACTIVE, celui que
+// llama-server a ouvert. L'effacer sous ses pieds ne libère rien tant que le
+// processus tient le descripteur, et le prochain démarrage meurt sur un fichier
+// introuvable : on refuse, et on renvoie vers un changement de preset.
+func modelIsLoaded(p string) bool {
+ cur := strings.TrimSpace(ReadConfig()["MODEL"])
+ if cur == "" {
+ return false
+ }
+ q, err := resolveServeModelPath(cur)
+ if err != nil {
+ return false
+ }
+ return samePath(p, q)
+}
+
+// modelPresetRefs mappe chaque .gguf référencé par un preset (MODEL= ou
+// MMPROJ=, chemin normalisé) vers les noms des presets qui le réclament. Calculé
+// UNE fois par listage : sans ça, chercher les références d'un modèle à la fois
+// relisait tous les presets pour chaque fichier du disque.
+func modelPresetRefs() map[string][]string {
+ refs := map[string][]string{}
+ list, err := ListPresets()
+ if err != nil {
+ return refs
+ }
+ for _, pr := range list {
+ content, err := os.ReadFile(pr.Path)
+ if err != nil {
+ continue
+ }
+ env := parseEnv(string(content))
+ for _, ref := range []string{env["MODEL"], env["MMPROJ"]} {
+ if strings.TrimSpace(ref) == "" {
+ continue
+ }
+ q, err := resolveServeModelPath(ref)
+ if err != nil {
+ continue
+ }
+ k := normDir(q)
+ // Un preset qui pointe le même fichier en MODEL et en MMPROJ ne compte
+ // qu'une fois.
+ if n := len(refs[k]); n > 0 && refs[k][n-1] == pr.Name {
+ continue
+ }
+ refs[k] = append(refs[k], pr.Name)
+ }
+ }
+ return refs
+}
+
+// presetsUsingModel liste les noms d'affichage des presets dont MODEL= ou
+// MMPROJ= désigne p. Supprimer un .gguf ne casse pas qu'un fichier : chaque
+// preset qui le référence devient un moteur qui meurt au démarrage. On les
+// nomme AVANT, dans la confirmation, plutôt que de laisser la découverte au
+// prochain switch.
+func presetsUsingModel(p string) []string {
+ return modelPresetRefs()[normDir(p)]
+}
+
+// handleModelDelete supprime un .gguf (et toutes ses tranches) d'un dossier de
+// modèles déclaré.
+//
+// Deux garde-fous, parce que rien n'est réversible ici et qu'un modèle pèse des
+// dizaines de gigaoctets :
+// - le modèle CHARGÉ est intouchable (modelIsLoaded) ;
+// - un modèle référencé par des presets ne part qu'avec `force`, l'appelant
+// ayant reçu leur liste dans la réponse 409 pour la poser à l'utilisateur.
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 {
+ p, err := resolveModelPath(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})
+ if st, err := os.Stat(p); err != nil || st.IsDir() {
+ sendJSON(w, 404, map[string]any{"ok": false, "error": "modèle introuvable : " + filepath.Base(p)})
+ return
+ }
+ if modelIsLoaded(p) {
+ sendJSON(w, 409, map[string]any{"ok": false, "loaded": true,
+ "error": "modèle chargé par le moteur — bascule d'abord sur un autre preset"})
+ return
+ }
+ used := presetsUsingModel(p)
+ if len(used) > 0 && !req.Force {
+ sendJSON(w, 409, map[string]any{"ok": false, "presets": used, "needsForce": true,
+ "error": "modèle utilisé par un preset"})
+ return
+ }
+ freed := modelSizeOnDisk(p)
+ if err := deleteModelFile(p); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ fmt.Printf("%s modèle supprimé : %s\n", green("[ok]"), p)
+ resp := map[string]any{"ok": true, "freed": freed}
+ if len(used) > 0 {
+ resp["presets"] = used // presets cassés par la suppression forcée
+ }
+ sendJSON(w, 200, resp)
}
// ---- Hugging Face downloads -------------------------------------------------
diff --git a/internal/loki/backend_models_delete_test.go b/internal/loki/backend_models_delete_test.go
new file mode 100644
index 0000000..06742aa
--- /dev/null
+++ b/internal/loki/backend_models_delete_test.go
@@ -0,0 +1,176 @@
+package loki
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// writeModel pose un .gguf factice dans le dossier des modèles.
+func writeModel(t *testing.T, name string) string {
+ t.Helper()
+ dir := modelsDir()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(dir, name)
+ if err := os.WriteFile(p, []byte("gguf"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ return p
+}
+
+// postDelete joue /api/models/delete et renvoie le code HTTP + le corps décodé.
+func postDelete(t *testing.T, body map[string]any) (int, map[string]any) {
+ t.Helper()
+ b, _ := json.Marshal(body)
+ rec := httptest.NewRecorder()
+ handleModelDelete(rec, httptest.NewRequest(http.MethodPost, "/api/models/delete", bytes.NewReader(b)))
+ var out map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
+ t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
+ }
+ return rec.Code, out
+}
+
+// Le modèle que le moteur a ouvert ne se supprime pas : l'espace ne serait pas
+// rendu tant que le processus tient le descripteur, et le démarrage suivant
+// mourrait sur un fichier introuvable.
+func TestModelDeleteRefuseLeModeleCharge(t *testing.T) {
+ testHome(t)
+ writeModel(t, "charge.gguf")
+ if err := WriteConfig(map[string]string{"MODEL": "charge.gguf"}); err != nil {
+ t.Fatal(err)
+ }
+ code, out := postDelete(t, map[string]any{"name": "charge.gguf"})
+ if code != 409 || out["ok"] != false || out["loaded"] != true {
+ t.Fatalf("modèle chargé supprimable : %d %v", code, out)
+ }
+ if _, err := os.Stat(filepath.Join(modelsDir(), "charge.gguf")); err != nil {
+ t.Fatal("le modèle chargé a été supprimé")
+ }
+}
+
+// Un modèle réclamé par des presets part quand même, mais seulement après un
+// second « oui » : le serveur refuse sans force et NOMME les presets, pour que
+// la confirmation dise ce qu'on casse.
+func TestModelDeleteExigeForceQuandUnPresetLUtilise(t *testing.T) {
+ testHome(t)
+ writeModel(t, "vieux.gguf")
+ if _, err := SavePreset("", "Mon preset", "MODEL=\"vieux.gguf\"\nCTX=8192\n"); err != nil {
+ t.Fatal(err)
+ }
+ code, out := postDelete(t, map[string]any{"name": "vieux.gguf"})
+ if code != 409 || out["needsForce"] != true {
+ t.Fatalf("suppression non protégée : %d %v", code, out)
+ }
+ names, _ := out["presets"].([]any)
+ if len(names) != 1 || names[0] != "Mon preset" {
+ t.Fatalf("presets référents non renvoyés : %v", out["presets"])
+ }
+ if _, err := os.Stat(filepath.Join(modelsDir(), "vieux.gguf")); err != nil {
+ t.Fatal("supprimé malgré le refus")
+ }
+ if code, out = postDelete(t, map[string]any{"name": "vieux.gguf", "force": true}); code != 200 || out["ok"] != true {
+ t.Fatalf("force refusé : %d %v", code, out)
+ }
+ if _, err := os.Stat(filepath.Join(modelsDir(), "vieux.gguf")); !os.IsNotExist(err) {
+ t.Fatal("le modèle n'a pas été supprimé")
+ }
+}
+
+// Un modèle que personne ne réclame part du premier coup, et l'espace annoncé
+// couvre TOUTES les tranches — c'est le chiffre qu'on affiche à l'utilisateur.
+func TestModelDeleteLibereLaFamilleEntiere(t *testing.T) {
+ testHome(t)
+ writeModel(t, "libre-00001-of-00002.gguf")
+ writeModel(t, "libre-00002-of-00002.gguf")
+ code, out := postDelete(t, map[string]any{"name": "libre-00001-of-00002.gguf"})
+ if code != 200 || out["ok"] != true {
+ t.Fatalf("suppression refusée : %d %v", code, out)
+ }
+ if freed, _ := out["freed"].(float64); freed != 8 {
+ t.Fatalf("espace libéré = %v ; attendu la famille entière (8 octets)", out["freed"])
+ }
+ for _, n := range []string{"libre-00001-of-00002.gguf", "libre-00002-of-00002.gguf"} {
+ if _, err := os.Stat(filepath.Join(modelsDir(), n)); !os.IsNotExist(err) {
+ t.Fatalf("tranche %s conservée", n)
+ }
+ }
+}
+
+// Un modèle absent ne doit pas répondre « supprimé » : sans ça, l'interface
+// annonçait de la place libérée qui ne l'était pas.
+func TestModelDeleteModeleAbsent(t *testing.T) {
+ testHome(t)
+ if code, out := postDelete(t, map[string]any{"name": "fantome.gguf"}); code != 404 || out["ok"] != false {
+ t.Fatalf("modèle absent : %d %v", code, out)
+ }
+}
+
+// /api/models annonce ce que le moteur a ouvert et qui réclame quoi : c'est ce
+// qui permet à l'interface de griser le bouton « supprimer » du modèle chargé.
+func TestModelsListeMarqueChargeEtPresets(t *testing.T) {
+ testHome(t)
+ writeModel(t, "a.gguf")
+ writeModel(t, "b.gguf")
+ if err := WriteConfig(map[string]string{"MODEL": "a.gguf"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SavePreset("", "Preset B", "MODEL=\"b.gguf\"\n"); err != nil {
+ t.Fatal(err)
+ }
+ rec := httptest.NewRecorder()
+ handleModels(rec, httptest.NewRequest(http.MethodGet, "/api/models", nil))
+ var list []map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
+ t.Fatal(err)
+ }
+ byName := map[string]map[string]any{}
+ for _, m := range list {
+ byName[m["name"].(string)] = m
+ }
+ if byName["a.gguf"]["loaded"] != true {
+ t.Fatalf("modèle chargé non marqué : %v", byName["a.gguf"])
+ }
+ refs, _ := byName["b.gguf"]["presets"].([]any)
+ if len(refs) != 1 || refs[0] != "Preset B" {
+ t.Fatalf("presets référents absents : %v", byName["b.gguf"])
+ }
+}
+
+// La case « supprimer aussi le .gguf » de l'éditeur de preset passe par les
+// mêmes garde-fous : un modèle qu'un AUTRE preset réclame reste sur le disque,
+// sinon supprimer un preset laissait son voisin avec un moteur qui meurt au
+// démarrage.
+func TestSuppressionPresetConserveUnModelePartage(t *testing.T) {
+ testHome(t)
+ writeModel(t, "partage.gguf")
+ if _, err := SavePreset("", "Preset A", "MODEL=\"partage.gguf\"\n"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := SavePreset("", "Preset B", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
+ t.Fatal(err)
+ }
+ deleted, errMsg := deletePresetModel("partage.gguf")
+ if deleted != "" || errMsg == "" {
+ t.Fatalf("modèle partagé supprimé : %q / %q", deleted, errMsg)
+ }
+ if _, err := os.Stat(filepath.Join(modelsDir(), "partage.gguf")); err != nil {
+ t.Fatal("le modèle partagé a disparu")
+ }
+ // Plus aucun preset ne le réclame : il part.
+ if err := DeletePreset("Preset A"); err != nil {
+ t.Fatal(err)
+ }
+ if err := DeletePreset("Preset B"); err != nil {
+ t.Fatal(err)
+ }
+ if deleted, errMsg = deletePresetModel("partage.gguf"); deleted == "" || errMsg != "" {
+ t.Fatalf("modèle orphelin conservé : %q / %q", deleted, errMsg)
+ }
+}
diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html
index a01b35c..09ac944 100644
--- a/internal/loki/ui/index.html
+++ b/internal/loki/ui/index.html
@@ -886,6 +886,14 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none}
.pe-bar.done>i{width:100%}
@keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}}
+/* Liste des modèles installés (suppression des .gguf). Le nom prend toute la
+ place restante et se tronque : un fichier de modèle mesure facilement 60
+ caractères, et c'est la taille + le bouton qu'on vient chercher ici. */
+.mdl-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border)}
+.mdl-row:last-child{border-bottom:none}
+.mdl-name{flex:1;min-width:0;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.mdl-size{flex:none;color:var(--dim);font-variant-numeric:tabular-nums}
+.mdl-del{align-self:center;flex:none}
/* Action discrète en texte (annuler un téléchargement…). */
.pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer}
.pe-link:hover{color:var(--text)}
@@ -2272,7 +2280,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
+ Modèles installéssupprimer un .gguf pour libérer de la place
+
+
+
+
+
Quantizationauto si vide
@@ -4512,7 +4532,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
- await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
+ await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
@@ -4679,7 +4699,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()]);
+ setInstalledModelsOpen(false);
+ await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs(), loadInstalledModels()]);
if(seq !== openSeq) return;
}
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
@@ -4770,6 +4791,123 @@ async function populateModelPicker(){
sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true);
}
+// ---- Modèles installés : lister et SUPPRIMER les .gguf du disque ------------
+// Un modèle ne pouvait s'effacer qu'en même temps que son preset (case « supprimer
+// aussi le fichier .gguf »). Tout ce qu'on avait téléchargé sans preset — une
+// quantization essayée puis abandonnée, un projecteur du mauvais dépôt — restait
+// donc sur le disque pour des dizaines de gigaoctets, sans aucun moyen de partir
+// depuis Loki. La liste vit ici, à côté du sélecteur de fichier : c'est là qu'on
+// découvre qu'un modèle ne sert plus.
+//
+// Repliée par défaut : c'est un ménage, pas un réglage. La ligne fermée porte
+// déjà l'essentiel — combien de modèles, et la place qu'ils prennent.
+let installedModels = [];
+function setInstalledModelsOpen(open){
+ const b = document.getElementById('m-mdl-body');
+ const c = document.getElementById('m-mdl-caret');
+ if(!b) return;
+ b.style.display = open ? '' : 'none';
+ if(c) c.classList.toggle('open', open);
+}
+function toggleInstalledModels(){
+ setInstalledModelsOpen(document.getElementById('m-mdl-body').style.display === 'none');
+}
+async function loadInstalledModels(){
+ try{ installedModels = (await jget('/api/models')) || []; }catch(_){ installedModels = []; }
+ const st = document.getElementById('m-mdl-state');
+ if(st){
+ const total = installedModels.reduce((a, m) => a + (m.size || 0), 0);
+ st.textContent = installedModels.length ? installedModels.length + ' · ' + fmtSize(total) : 'aucun';
+ }
+ renderInstalledModels();
+}
+function renderInstalledModels(){
+ const box = document.getElementById('m-mdl-list');
+ if(!box) return;
+ // Construit en DOM : ces noms de fichiers viennent du disque, ils n'ont rien à
+ // faire dans du HTML concaténé ni dans un attribut onclick.
+ box.textContent = '';
+ if(!installedModels.length){
+ box.textContent = 'Aucun .gguf dans les dossiers de modèles.';
+ return;
+ }
+ const multi = new Set(installedModels.map(m => m.dir)).size > 1;
+ for(const m of installedModels){
+ const row = document.createElement('div');
+ row.className = 'mdl-row';
+ const nm = document.createElement('span');
+ nm.className = 'mdl-name';
+ nm.textContent = m.name;
+ nm.title = m.path;
+ const info = document.createElement('span');
+ info.className = 'mdl-size';
+ let t = fmtSize(m.size);
+ if(m.shards > 1) t += ' · ' + m.shards + ' fichiers';
+ if(m.missing && m.missing.length) t += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':'');
+ if(multi) t += ' · ' + (m.home ? 'dossier loki' : m.dir);
+ info.textContent = t;
+ row.append(nm, info);
+ // Chargé = intouchable (le moteur tient le fichier ouvert) ; référencé par des
+ // presets = supprimable, mais on le dit avant, pas au prochain démarrage raté.
+ if(m.loaded || (m.presets && m.presets.length)){
+ const tag = document.createElement('span');
+ tag.className = 'qtag';
+ if(m.loaded){
+ tag.textContent = 'chargé';
+ tag.title = 'modèle ouvert par le moteur';
+ } else {
+ tag.textContent = m.presets.length + ' preset' + (m.presets.length > 1 ? 's' : '');
+ tag.title = m.presets.join(', ');
+ }
+ row.append(tag);
+ }
+ const del = document.createElement('button');
+ del.className = 'pe-link mdl-del';
+ del.textContent = 'supprimer';
+ del.disabled = !!m.loaded;
+ del.title = m.loaded ? 'modèle chargé — bascule d’abord sur un autre preset' : 'supprimer ce fichier du disque';
+ del.onclick = () => deleteInstalledModel(m);
+ row.append(del);
+ box.append(row);
+ }
+}
+// Suppression d'un .gguf : irréversible, et de l'ordre de la dizaine de Go. Le
+// serveur refuse tout seul le modèle CHARGÉ, et refuse SANS `force` un modèle
+// que des presets réclament — en renvoyant leurs noms, qu'on pose alors dans une
+// seconde confirmation. Le premier « oui » porte sur le fichier, le second sur
+// les presets qu'on casse : ce ne sont pas la même décision.
+async function deleteInstalledModel(m){
+ const shards = m.shards > 1 ? '\n\n' + m.shards + ' fichiers seront effacés (modèle découpé).' : '';
+ if(!await askConfirm('Supprimer « ' + m.name + ' » (' + fmtSize(m.size) + ') du disque ? Il n’y a pas de corbeille.' + shards,
+ {title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
+ const del = async force => { try{ return await jpost('/api/models/delete', {name: m.value, force}); }catch(_){ return null; } };
+ let r = await del(false);
+ if(r && !r.ok && r.needsForce){
+ if(!await askConfirm('Utilisé par : ' + (r.presets || []).join(', ')
+ + '.\n\nCes presets ne démarreront plus tant qu’un autre modèle ne leur aura pas été choisi.',
+ {title:'Modèle utilisé par un preset', okText:'Supprimer quand même', danger:true})) return;
+ r = await del(true);
+ }
+ if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); return; }
+ toast('supprimé — ' + fmtSize(r.freed || m.size) + ' libérés');
+ await reloadModelsAndPreset();
+}
+// Après un changement dans les modèles du disque (suppression, téléchargement
+// terminé, dossier ajouté), on recharge le PRESET COMPLET, pas seulement la
+// liste déroulante : le fichier choisi, le projecteur vision, la quantization
+// déduite du nom, les réglages dérivés et l'espace libre des destinations
+// dépendent tous de ce qui est réellement sur le disque. N'en rafraîchir qu'une
+// partie laissait l'éditeur afficher un modèle qui n'existait plus.
+async function reloadModelsAndPreset(){
+ await Promise.all([
+ loadInstalledModels(), populateModelPicker(), populateMmproj(),
+ populateModelDirs(), populateDlDirs(),
+ ]);
+ document.getElementById('m-quant').value = currentQuantInTextarea();
+ populateSettings();
+ if(typeof loadPresets === 'function') loadPresets();
+}
+
// ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){
@@ -4886,13 +5024,13 @@ async function addModelDir(){
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
inp.value = '';
toast('dossier ajouté');
- await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
+ await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs(), loadInstalledModels()]);
}
async function removeModelDir(p){
const r = await jpost('/api/models/dirs', {path: p, action: 'remove'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('dossier retiré');
- await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
+ await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs(), loadInstalledModels()]);
}
function onPickModel(){
const val = document.getElementById('m-model').value;
@@ -5523,7 +5661,7 @@ function watchDownload(fname){
e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
- await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
+ await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), loadInstalledModels()]);
// 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
@@ -8649,17 +8787,18 @@ function openDetails(el){
}
}
-// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
-// à cliquer la ligne correspondante dans les réglages (switchTo demande
+// Sélecteur de modèle de l'en-tête. Il ne liste QUE des presets ; en choisir un
+// revient à cliquer la ligne correspondante dans les réglages (switchTo demande
// confirmation puis relance le moteur).
+//
+// Il a listé un temps les .gguf du disque, qu'on chargeait en n'écrivant que
+// MODEL= : le contexte, le NGL et l'échantillonnage du modèle PRÉCÉDENT
+// restaient en place. Un 27B chargé par-dessus les réglages d'un 4B partait sur
+// 100k de contexte et mourait en OOM, sans que rien ne dise pourquoi. Charger un
+// modèle, c'est appliquer TOUS ses réglages : ça s'appelle un preset, et c'est
+// le seul chemin.
function onModelSwitch(sel){
const v = sel.value;
- // Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
- // active garde son contexte et son échantillonnage, seul MODEL change.
- if(v && v.startsWith('m:')){
- switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
- return;
- }
const n = parseInt(v, 10);
if(!n) return;
const name = sel.options[sel.selectedIndex].textContent;
@@ -8669,22 +8808,6 @@ function onModelSwitch(sel){
switchTo(n, name);
}
-// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
-// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
-// revient sur le modèle réellement chargé.
-async function switchModelFile(value, name){
- if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
- paintModelSwitch();
- return;
- }
- toast('changement de modèle…');
- let r = null;
- try{ r = await jpost('/api/models/use', {value}); }catch(_){}
- if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
- toast('modèle en cours de chargement…');
- loadAll();
-}
-
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
//
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
@@ -8697,22 +8820,12 @@ async function switchModelFile(value, name){
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
// en parallèle (loadAll), aucun ordre n'est garanti.
-let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
+let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
function renderModelSwitch(presets, active){
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
paintModelSwitch();
}
-// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
-// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
-// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
-async function loadModelSwitchFiles(){
- try{
- const r = await jget('/api/models');
- MS_FILES = Array.isArray(r) ? r : [];
- }catch(_){ return; }
- paintModelSwitch();
-}
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
function setLoadedModel(name){
MS_MODEL = (name || '').trim();
@@ -8750,20 +8863,7 @@ function paintModelSwitch(){
});
sel.appendChild(g);
}
- // Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
- // preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
- if(MS_FILES.length){
- const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
- for(const m of MS_FILES){
- const o = document.createElement('option');
- o.value = 'm:' + m.value;
- o.textContent = modelLabel(m.name);
- if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
- g.appendChild(o);
- }
- sel.appendChild(g);
- }
- sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
+ sel.disabled = !MS_PRESETS.length;
sel.title = MS_MODEL
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
: 'aucun modèle configuré';
diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html
index c8272d5..523397c 100644
--- a/internal/loki/ui/src/index.tmpl.html
+++ b/internal/loki/ui/src/index.tmpl.html
@@ -115,7 +115,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
+ Modèles installéssupprimer un .gguf pour libérer de la place
+
+
+
+
+
Quantizationauto si vide
diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js
index 37f16f7..25636b2 100644
--- a/internal/loki/ui/src/js/06-settings.js
+++ b/internal/loki/ui/src/js/06-settings.js
@@ -460,7 +460,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
- await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
+ await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js
index 5ec92bd..c8dcf6b 100644
--- a/internal/loki/ui/src/js/07-models.js
+++ b/internal/loki/ui/src/js/07-models.js
@@ -161,7 +161,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()]);
+ setInstalledModelsOpen(false);
+ await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs(), loadInstalledModels()]);
if(seq !== openSeq) return;
}
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
@@ -252,6 +253,123 @@ async function populateModelPicker(){
sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true);
}
+// ---- Modèles installés : lister et SUPPRIMER les .gguf du disque ------------
+// Un modèle ne pouvait s'effacer qu'en même temps que son preset (case « supprimer
+// aussi le fichier .gguf »). Tout ce qu'on avait téléchargé sans preset — une
+// quantization essayée puis abandonnée, un projecteur du mauvais dépôt — restait
+// donc sur le disque pour des dizaines de gigaoctets, sans aucun moyen de partir
+// depuis Loki. La liste vit ici, à côté du sélecteur de fichier : c'est là qu'on
+// découvre qu'un modèle ne sert plus.
+//
+// Repliée par défaut : c'est un ménage, pas un réglage. La ligne fermée porte
+// déjà l'essentiel — combien de modèles, et la place qu'ils prennent.
+let installedModels = [];
+function setInstalledModelsOpen(open){
+ const b = document.getElementById('m-mdl-body');
+ const c = document.getElementById('m-mdl-caret');
+ if(!b) return;
+ b.style.display = open ? '' : 'none';
+ if(c) c.classList.toggle('open', open);
+}
+function toggleInstalledModels(){
+ setInstalledModelsOpen(document.getElementById('m-mdl-body').style.display === 'none');
+}
+async function loadInstalledModels(){
+ try{ installedModels = (await jget('/api/models')) || []; }catch(_){ installedModels = []; }
+ const st = document.getElementById('m-mdl-state');
+ if(st){
+ const total = installedModels.reduce((a, m) => a + (m.size || 0), 0);
+ st.textContent = installedModels.length ? installedModels.length + ' · ' + fmtSize(total) : 'aucun';
+ }
+ renderInstalledModels();
+}
+function renderInstalledModels(){
+ const box = document.getElementById('m-mdl-list');
+ if(!box) return;
+ // Construit en DOM : ces noms de fichiers viennent du disque, ils n'ont rien à
+ // faire dans du HTML concaténé ni dans un attribut onclick.
+ box.textContent = '';
+ if(!installedModels.length){
+ box.textContent = 'Aucun .gguf dans les dossiers de modèles.';
+ return;
+ }
+ const multi = new Set(installedModels.map(m => m.dir)).size > 1;
+ for(const m of installedModels){
+ const row = document.createElement('div');
+ row.className = 'mdl-row';
+ const nm = document.createElement('span');
+ nm.className = 'mdl-name';
+ nm.textContent = m.name;
+ nm.title = m.path;
+ const info = document.createElement('span');
+ info.className = 'mdl-size';
+ let t = fmtSize(m.size);
+ if(m.shards > 1) t += ' · ' + m.shards + ' fichiers';
+ if(m.missing && m.missing.length) t += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':'');
+ if(multi) t += ' · ' + (m.home ? 'dossier loki' : m.dir);
+ info.textContent = t;
+ row.append(nm, info);
+ // Chargé = intouchable (le moteur tient le fichier ouvert) ; référencé par des
+ // presets = supprimable, mais on le dit avant, pas au prochain démarrage raté.
+ if(m.loaded || (m.presets && m.presets.length)){
+ const tag = document.createElement('span');
+ tag.className = 'qtag';
+ if(m.loaded){
+ tag.textContent = 'chargé';
+ tag.title = 'modèle ouvert par le moteur';
+ } else {
+ tag.textContent = m.presets.length + ' preset' + (m.presets.length > 1 ? 's' : '');
+ tag.title = m.presets.join(', ');
+ }
+ row.append(tag);
+ }
+ const del = document.createElement('button');
+ del.className = 'pe-link mdl-del';
+ del.textContent = 'supprimer';
+ del.disabled = !!m.loaded;
+ del.title = m.loaded ? 'modèle chargé — bascule d’abord sur un autre preset' : 'supprimer ce fichier du disque';
+ del.onclick = () => deleteInstalledModel(m);
+ row.append(del);
+ box.append(row);
+ }
+}
+// Suppression d'un .gguf : irréversible, et de l'ordre de la dizaine de Go. Le
+// serveur refuse tout seul le modèle CHARGÉ, et refuse SANS `force` un modèle
+// que des presets réclament — en renvoyant leurs noms, qu'on pose alors dans une
+// seconde confirmation. Le premier « oui » porte sur le fichier, le second sur
+// les presets qu'on casse : ce ne sont pas la même décision.
+async function deleteInstalledModel(m){
+ const shards = m.shards > 1 ? '\n\n' + m.shards + ' fichiers seront effacés (modèle découpé).' : '';
+ if(!await askConfirm('Supprimer « ' + m.name + ' » (' + fmtSize(m.size) + ') du disque ? Il n’y a pas de corbeille.' + shards,
+ {title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
+ const del = async force => { try{ return await jpost('/api/models/delete', {name: m.value, force}); }catch(_){ return null; } };
+ let r = await del(false);
+ if(r && !r.ok && r.needsForce){
+ if(!await askConfirm('Utilisé par : ' + (r.presets || []).join(', ')
+ + '.\n\nCes presets ne démarreront plus tant qu’un autre modèle ne leur aura pas été choisi.',
+ {title:'Modèle utilisé par un preset', okText:'Supprimer quand même', danger:true})) return;
+ r = await del(true);
+ }
+ if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); return; }
+ toast('supprimé — ' + fmtSize(r.freed || m.size) + ' libérés');
+ await reloadModelsAndPreset();
+}
+// Après un changement dans les modèles du disque (suppression, téléchargement
+// terminé, dossier ajouté), on recharge le PRESET COMPLET, pas seulement la
+// liste déroulante : le fichier choisi, le projecteur vision, la quantization
+// déduite du nom, les réglages dérivés et l'espace libre des destinations
+// dépendent tous de ce qui est réellement sur le disque. N'en rafraîchir qu'une
+// partie laissait l'éditeur afficher un modèle qui n'existait plus.
+async function reloadModelsAndPreset(){
+ await Promise.all([
+ loadInstalledModels(), populateModelPicker(), populateMmproj(),
+ populateModelDirs(), populateDlDirs(),
+ ]);
+ document.getElementById('m-quant').value = currentQuantInTextarea();
+ populateSettings();
+ if(typeof loadPresets === 'function') loadPresets();
+}
+
// ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){
@@ -368,13 +486,13 @@ async function addModelDir(){
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
inp.value = '';
toast('dossier ajouté');
- await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
+ await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs(), loadInstalledModels()]);
}
async function removeModelDir(p){
const r = await jpost('/api/models/dirs', {path: p, action: 'remove'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('dossier retiré');
- await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
+ await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs(), loadInstalledModels()]);
}
function onPickModel(){
const val = document.getElementById('m-model').value;
@@ -1005,7 +1123,7 @@ function watchDownload(fname){
e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
- await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
+ await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), loadInstalledModels()]);
// 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
diff --git a/internal/loki/ui/src/js/18-shell.js b/internal/loki/ui/src/js/18-shell.js
index 3ca5fb8..4482d82 100644
--- a/internal/loki/ui/src/js/18-shell.js
+++ b/internal/loki/ui/src/js/18-shell.js
@@ -48,17 +48,18 @@ function openDetails(el){
}
}
-// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
-// à cliquer la ligne correspondante dans les réglages (switchTo demande
+// Sélecteur de modèle de l'en-tête. Il ne liste QUE des presets ; en choisir un
+// revient à cliquer la ligne correspondante dans les réglages (switchTo demande
// confirmation puis relance le moteur).
+//
+// Il a listé un temps les .gguf du disque, qu'on chargeait en n'écrivant que
+// MODEL= : le contexte, le NGL et l'échantillonnage du modèle PRÉCÉDENT
+// restaient en place. Un 27B chargé par-dessus les réglages d'un 4B partait sur
+// 100k de contexte et mourait en OOM, sans que rien ne dise pourquoi. Charger un
+// modèle, c'est appliquer TOUS ses réglages : ça s'appelle un preset, et c'est
+// le seul chemin.
function onModelSwitch(sel){
const v = sel.value;
- // Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
- // active garde son contexte et son échantillonnage, seul MODEL change.
- if(v && v.startsWith('m:')){
- switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
- return;
- }
const n = parseInt(v, 10);
if(!n) return;
const name = sel.options[sel.selectedIndex].textContent;
@@ -68,22 +69,6 @@ function onModelSwitch(sel){
switchTo(n, name);
}
-// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
-// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
-// revient sur le modèle réellement chargé.
-async function switchModelFile(value, name){
- if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
- paintModelSwitch();
- return;
- }
- toast('changement de modèle…');
- let r = null;
- try{ r = await jpost('/api/models/use', {value}); }catch(_){}
- if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
- toast('modèle en cours de chargement…');
- loadAll();
-}
-
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
//
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
@@ -96,22 +81,12 @@ async function switchModelFile(value, name){
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
// en parallèle (loadAll), aucun ordre n'est garanti.
-let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
+let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
function renderModelSwitch(presets, active){
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
paintModelSwitch();
}
-// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
-// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
-// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
-async function loadModelSwitchFiles(){
- try{
- const r = await jget('/api/models');
- MS_FILES = Array.isArray(r) ? r : [];
- }catch(_){ return; }
- paintModelSwitch();
-}
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
function setLoadedModel(name){
MS_MODEL = (name || '').trim();
@@ -149,20 +124,7 @@ function paintModelSwitch(){
});
sel.appendChild(g);
}
- // Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
- // preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
- if(MS_FILES.length){
- const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
- for(const m of MS_FILES){
- const o = document.createElement('option');
- o.value = 'm:' + m.value;
- o.textContent = modelLabel(m.name);
- if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
- g.appendChild(o);
- }
- sel.appendChild(g);
- }
- sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
+ sel.disabled = !MS_PRESETS.length;
sel.title = MS_MODEL
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
: 'aucun modèle configuré';
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index b7e14ca..a78ef9b 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -855,6 +855,14 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
.pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none}
.pe-bar.done>i{width:100%}
@keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}}
+/* Liste des modèles installés (suppression des .gguf). Le nom prend toute la
+ place restante et se tronque : un fichier de modèle mesure facilement 60
+ caractères, et c'est la taille + le bouton qu'on vient chercher ici. */
+.mdl-row{display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border)}
+.mdl-row:last-child{border-bottom:none}
+.mdl-name{flex:1;min-width:0;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.mdl-size{flex:none;color:var(--dim);font-variant-numeric:tabular-nums}
+.mdl-del{align-self:center;flex:none}
/* Action discrète en texte (annuler un téléchargement…). */
.pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer}
.pe-link:hover{color:var(--text)}
diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go
index 86766c3..57ebbf7 100644
--- a/internal/loki/web_api.go
+++ b/internal/loki/web_api.go
@@ -307,6 +307,15 @@ func handleModels(w http.ResponseWriter, r *http.Request) {
out := []map[string]any{}
seen := map[string]bool{}
home := LokiHome()
+ // Ce que le moteur a ouvert, et ce que les presets réclament : l'interface en
+ // a besoin pour dire lesquels sont supprimables sans rien casser.
+ refs := modelPresetRefs()
+ loaded := ""
+ if cur := strings.TrimSpace(ReadConfig()["MODEL"]); cur != "" {
+ if q, err := resolveServeModelPath(cur); err == nil {
+ loaded = normDir(q)
+ }
+ }
for _, dir := range modelDirs() {
entries, err := os.ReadDir(dir)
if err != nil {
@@ -341,6 +350,13 @@ func handleModels(w http.ResponseWriter, r *http.Request) {
"name": e.Name(), "size": size, "value": value,
"path": full, "dir": dir, "home": isHome,
}
+ key := normDir(full)
+ if key == loaded {
+ m["loaded"] = true
+ }
+ if len(refs[key]) > 0 {
+ m["presets"] = refs[key]
+ }
// 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.
@@ -463,15 +479,38 @@ func handlePresetDelete(w http.ResponseWriter, r *http.Request) {
}
modelDeleted, modelErr := "", ""
if req.DeleteModel && model != "" {
- if err := deleteModelFile(model); err != nil {
- modelErr = err.Error()
- } else {
- modelDeleted = model
- }
+ modelDeleted, modelErr = deletePresetModel(model)
}
sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr})
}
+// deletePresetModel efface le .gguf d'un preset qu'on vient de supprimer, avec
+// les MÊMES garde-fous que la suppression directe (/api/models/delete) : la case
+// « supprimer aussi le fichier » ne doit pas être un chemin détourné pour
+// effacer un modèle qu'on protège ailleurs.
+//
+// Le modèle CHARGÉ est conservé (le preset supprimé n'était pas forcément celui
+// en service : deux presets peuvent désigner le même .gguf), et un modèle encore
+// réclamé par un AUTRE preset aussi — sinon supprimer un preset laissait son
+// voisin avec un moteur qui meurt au démarrage. Le refus est renvoyé à
+// l'interface, qui le dit ; la suppression du preset, elle, a déjà eu lieu.
+func deletePresetModel(model string) (deleted, errMsg string) {
+ p, err := resolveModelPath(model)
+ if err != nil {
+ return "", err.Error()
+ }
+ if modelIsLoaded(p) {
+ return "", "modèle chargé par le moteur — fichier conservé"
+ }
+ if used := presetsUsingModel(p); len(used) > 0 {
+ return "", "encore utilisé par : " + strings.Join(used, ", ") + " — fichier conservé"
+ }
+ if err := deleteModelFile(p); err != nil {
+ return "", err.Error()
+ }
+ return model, ""
+}
+
// handleAgent renvoie l'état du mode agent ET la liste des pages mémoire (que
// l'IA gère via les outils mem_*) — un seul aller-retour pour l'UI. La clé
// "skills" est conservée en miroir de "pages" pour l'ancien portail ajean.link.
@@ -938,38 +977,6 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
}
-// handleModelUse charge un MODÈLE précis (fichier .gguf) sans passer par un
-// preset : seul MODEL change dans la configuration active — contexte, NGL et
-// échantillonnage sont conservés — et le service redémarre en arrière-plan,
-// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête
-// utilisable même sans avoir créé de preset.
-func handleModelUse(w http.ResponseWriter, r *http.Request) {
- var req struct {
- Value string `json:"value"`
- }
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Value) == "" {
- sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle manquant"})
- return
- }
- // Le fichier doit exister AVANT d'écrire la config : une faute de frappe ne
- // doit pas laisser un moteur qui boucle sur un modèle introuvable.
- if _, err := resolveServeModelPath(req.Value); err != nil {
- sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
- return
- }
- if err := SetConfigKey("MODEL", req.Value); err != nil {
- sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
- return
- }
- fmt.Printf("%s MODEL <- %s\n", green("[ok]"), req.Value)
- go func() {
- if err := serviceAction("restart"); err != nil {
- fmt.Println(red("[err] redémarrage : " + err.Error()))
- }
- }()
- sendJSON(w, 200, map[string]any{"ok": true})
-}
-
func handleSwitch(w http.ResponseWriter, r *http.Request) {
var req struct {
N int `json:"n"`
diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go
index fb01d76..9766be9 100644
--- a/internal/loki/web_server.go
+++ b/internal/loki/web_server.go
@@ -191,7 +191,6 @@ func newWebMux() *http.ServeMux {
api("/api/models", handleModels)
api("/api/models/delete", handleModelDelete)
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
- api("/api/models/use", handleModelUse) // charger CE .gguf (sélecteur de l'en-tête)
api("/api/models/download", handleModelDownload)
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
api("/api/models/download/status", handleModelDownloadStatus)