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)}
Nouvelle discussion
- + @@ -3138,6 +3146,18 @@ html[data-files="1"] #files-btn{color:var(--accent)} Fichier + +
+ 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
Nouvelle discussion
- + @@ -981,6 +981,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid Fichier
+ +
+ 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)