mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Modèles : les supprimer depuis l'interface, et ne charger que des presets
Un .gguf ne pouvait s'effacer qu'en cochant « supprimer aussi le fichier »
au moment de supprimer son preset. Tout ce qui avait été 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.
L'éditeur de preset porte désormais une ligne « Modèles installés » (repliée,
sous le sélecteur de fichier) : chaque .gguf des dossiers déclarés avec sa
taille, ses tranches, qui le réclame, et un bouton « supprimer ».
Rien n'est réversible ici, d'où deux garde-fous côté serveur, partagés par
/api/models/delete ET par la case de l'éditeur de preset :
- le modèle CHARGÉ est intouchable — l'effacer sous le moteur ne rend pas
l'espace tant qu'il tient le descripteur, et le démarrage suivant meurt
sur un fichier introuvable ;
- un modèle réclamé par des presets ne part qu'avec `force`, le refus 409
nommant les presets pour que la seconde confirmation dise ce qu'on casse.
Supprimer un preset ne peut donc plus emporter le modèle de son voisin.
La suppression emporte toute la famille de tranches et annonce la place
réellement libérée. /api/models marque au passage le modèle chargé et les
presets qui référencent chaque fichier.
Le sélecteur de l'en-tête ne liste plus que des presets. Il proposait aussi
les .gguf du disque, chargés en n'écrivant que MODEL= : le contexte, le NGL
et l'échantillonnage du modèle PRÉCÉDENT restaient en place, et un 27B chargé
par-dessus les réglages d'un 4B partait sur 100k de contexte pour mourir 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. /api/models/use
disparaît avec le groupe qu'il servait.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
This commit is contained in:
10 files changed
+637
-152
No files matched your search
@@ -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 -------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+155
-55
@@ -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)}
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
||||
</button>
|
||||
<div id="topbar-title">Nouvelle discussion</div>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="preset actif — en changer recharge tous ses réglages et relance le moteur" aria-label="preset actif"></select>
|
||||
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
@@ -3138,6 +3146,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<span class="pe-row-l">Fichier</span>
|
||||
<span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span>
|
||||
</div>
|
||||
<!-- Gestion des .gguf du disque. Un modèle ne se supprimait que « en
|
||||
même temps que son preset » : les fichiers téléchargés puis
|
||||
abandonnés (mauvaise quantization, essai) restaient là pour des
|
||||
dizaines de Go, sans aucun moyen de les effacer depuis Loki.
|
||||
Repliée par défaut — on n'y va que le jour où le disque sature. -->
|
||||
<div id="m-mdl-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalledModels()">
|
||||
<span class="pe-row-l">Modèles installés<span class="pe-sub">supprimer un .gguf pour libérer de la place</span></span>
|
||||
<span class="pe-row-c"><span id="m-mdl-state" class="pe-sub"></span><span id="m-mdl-caret" class="pe-caret"></span></span>
|
||||
</div>
|
||||
<div id="m-mdl-body" class="pe-row stack" style="display:none">
|
||||
<div id="m-mdl-list" class="pe-note"></div>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
|
||||
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
|
||||
@@ -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 = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
||||
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
|
||||
stop();
|
||||
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
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é';
|
||||
|
||||
@@ -115,7 +115,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
||||
</button>
|
||||
<div id="topbar-title">Nouvelle discussion</div>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
||||
<select id="model-switch" onchange="onModelSwitch(this)" title="preset actif — en changer recharge tous ses réglages et relance le moteur" aria-label="preset actif"></select>
|
||||
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||
</button>
|
||||
@@ -981,6 +981,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-l">Fichier</span>
|
||||
<span class="pe-selc wide"><select id="m-model" onchange="onPickModel()"></select></span>
|
||||
</div>
|
||||
<!-- Gestion des .gguf du disque. Un modèle ne se supprimait que « en
|
||||
même temps que son preset » : les fichiers téléchargés puis
|
||||
abandonnés (mauvaise quantization, essai) restaient là pour des
|
||||
dizaines de Go, sans aucun moyen de les effacer depuis Loki.
|
||||
Repliée par défaut — on n'y va que le jour où le disque sature. -->
|
||||
<div id="m-mdl-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalledModels()">
|
||||
<span class="pe-row-l">Modèles installés<span class="pe-sub">supprimer un .gguf pour libérer de la place</span></span>
|
||||
<span class="pe-row-c"><span id="m-mdl-state" class="pe-sub"></span><span id="m-mdl-caret" class="pe-caret"></span></span>
|
||||
</div>
|
||||
<div id="m-mdl-body" class="pe-row stack" style="display:none">
|
||||
<div id="m-mdl-list" class="pe-note"></div>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Quantization<span class="pe-sub">auto si vide</span></span>
|
||||
<span class="pe-row-c"><input id="m-quant" class="pe-val" oninput="applyQuant()" placeholder="Auto"></span>
|
||||
|
||||
@@ -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); }
|
||||
@@ -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 = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
||||
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
|
||||
stop();
|
||||
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
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
|
||||
|
||||
@@ -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é';
|
||||
|
||||
@@ -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)}
|
||||
|
||||
+44
-37
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user