diff --git a/README.md b/README.md index e2c6d07..54e687b 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,12 @@ Ajoutées par ce fork : latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent visibles en pied de colonne. +- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge + le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la + carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu, + encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement + rendus, et le même bouton devient **Recharger le modèle** pour reprendre la + main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`. - **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse du moteur. Conséquence directe : l'API est joignable partout où l'interface diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 8622a99..0c1a92d 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1720,6 +1720,16 @@ html[data-files="1"] #files-btn{color:var(--accent)} /* --- Moniteur machine (pied de barre latérale) ----------------------- */ .perf-head{display:flex;align-items:center;gap:8px;margin-bottom:11px; font-size:14px;font-weight:600;color:var(--text);letter-spacing:-.01em} +/* Bouton du moniteur (« Libérer la VRAM » / « Recharger le modèle ») : poussé + à droite du titre, discret tant qu'on ne le cherche pas. En mode rechargement + il prend la couleur d'accent — le moteur est arrêté, c'est l'action qui + compte à ce moment-là. */ +.perf-act{margin-left:auto;flex:none;padding:3px 8px;font-size:11px;line-height:1.4; + color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer; + white-space:nowrap} +.perf-act:hover:not(:disabled){color:var(--text)} +.perf-act:disabled{opacity:.55;cursor:progress} +.perf-act.reload{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent)} .perf-row{margin:0 0 14px} .perf-l{display:flex;align-items:baseline;gap:8px;font-size:12.5px;color:var(--text);margin-bottom:6px} .perf-l b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; @@ -2231,7 +2241,11 @@ html[data-files="1"] #files-btn{color:var(--accent)} fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le bloc entier se replie d'un clic. -->
-
Performance
+ +
Performance
…
@@ -2591,15 +2605,18 @@ html[data-files="1"] #files-btn{color:var(--accent)} - -
Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
+ +
Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
Libérer la VRAM arrête le moteur et la dictée, puis annonce la mémoire vidéo rendue : c'est ce qu'il faut quand une autre application réclame la carte. Le même bouton vit sur les jauges, en pied de barre latérale.
+
@@ -3705,6 +3722,11 @@ async function loadStatus(){ el.className='statuspill '+cls; el.innerHTML=''+txt; MODEL_READY = !!(s.active && s.health); + // Le bouton « Libérer la VRAM » suit l'état RÉEL du moteur (et pas un drapeau + // local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se + // lire ici aussi, sinon le bouton propose de libérer une carte déjà libre. + ENGINE_ACTIVE = !!s.active; + paintVramBtn(); // Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un // message à un modèle qui n'est pas encore chargé (voir syncSendBtn). STATUS_SEEN = true; @@ -3869,6 +3891,71 @@ async function loadVram(){ g.name+' · '+vram)); }); } +// ─── Libérer la mémoire vidéo ─────────────────────────────────────────────── +// Tant que le moteur tourne, le modèle occupe la VRAM : une autre application +// qui réclame la carte (un jeu, un encodage, un autre serveur d'inférence) ne +// trouve plus rien à prendre. Ce bouton l'en sort, et le MÊME bouton la +// reprend ensuite — le geste existait déjà (« arrêter » le service, au fond des +// réglages), mais son nom ne disait pas qu'il rendait la carte. +// +// ENGINE_ACTIVE vient de /api/status (relu toutes les 5 s), jamais d'un drapeau +// posé au clic : sinon un second onglet, ou un arrêt venu du terminal, +// afficherait un bouton qui ment. +let ENGINE_ACTIVE = true, VRAM_BUSY = false; +function paintVramBtn(){ + const b = document.getElementById('vram-btn'); + if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy + b.disabled = false; + b.classList.toggle('reload', !ENGINE_ACTIVE); + b.textContent = ENGINE_ACTIVE ? 'Libérer la VRAM' : 'Recharger le modèle'; + b.title = ENGINE_ACTIVE + ? 'décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine' + : 'relance le moteur : le modèle revient en mémoire vidéo'; +} +function vramBtnClick(){ return ENGINE_ACTIVE ? vramUnload(false) : vramReload(); } +function vramBusy(on, label){ + VRAM_BUSY = on; + const b = document.getElementById('vram-btn'); + if(b){ b.disabled = on; if(on && label) b.textContent = label; } + if(!on) paintVramBtn(); +} +// force=true : on a déjà demandé confirmation ET accepté de couper une réponse +// en cours d'écriture (le serveur refuse sans ça, voir handleVramUnload). +async function vramUnload(force){ + if(!force){ + const ok = await askConfirm( + 'Décharger le modèle ? Le moteur s\'arrête et rend la mémoire vidéo — le chat reste indisponible jusqu\'au rechargement.', + {title:'Libérer la VRAM', okText:'Libérer'}); + if(!ok) return; + } + vramBusy(true, 'libération…'); + let r = null; + try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){} + vramBusy(false); + if(r && r.generating){ + const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?', + {title:'Génération en cours', okText:'Décharger quand même'}); + if(ok) return vramUnload(true); + return; + } + // La mesure d'abord : la dictée peut avoir rendu de la VRAM alors même que le + // moteur était déjà arrêté — annoncer « déjà arrêté » nierait ce qui vient + // d'être libéré. + if(!r || !r.ok) toast('échec : ' + ((r && r.error) || 'réseau')); + else if(r.gpu && r.freed_mb > 0) toast('VRAM libérée : ' + (r.freed_mb/1024).toFixed(1) + ' Gio'); + else if(!r.was_active) toast('le moteur était déjà arrêté'); + else toast('modèle déchargé'); + loadAll(); +} +async function vramReload(){ + vramBusy(true, 'démarrage…'); + let r = null; + try{ r = await jpost('/api/vram/reload', {}); }catch(_){} + vramBusy(false); + if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; } + toast('modèle en cours de chargement…'); + loadAll(); +} async function loadRam(){ const m=await jget('/api/ram'); const box=document.getElementById('ram-details'); @@ -6470,11 +6557,17 @@ function syncSendBtn(){ // répond pas du tout) — dans le doute on laisse la main. const ready = !STATUS_SEEN || MODEL_READY; sb.disabled = !ready; - sb.title = ready ? '' : 'le modèle n\'est pas encore chargé'; + // Moteur ARRÊTÉ (VRAM libérée à la demande, ou service coupé) : dire « le + // modèle charge » serait faux, et on attendrait un chargement qui ne viendra + // jamais. On nomme alors le geste qui remet le modèle en mémoire. + const unloaded = !ready && !ENGINE_ACTIVE; + sb.title = ready ? '' : (unloaded ? 'le modèle est déchargé — recharge-le pour écrire' + : 'le modèle n\'est pas encore chargé'); const hint=document.getElementById('sendhint'); if(hint){ hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne' - : 'Le modèle charge — envoi possible dès qu\'il est prêt.'; + : unloaded ? 'Modèle déchargé — « Recharger le modèle » dans le moniteur, en bas de la barre latérale.' + : 'Le modèle charge — envoi possible dès qu\'il est prêt.'; hint.classList.toggle('waiting', !ready); } } diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index 8932c5b..957c6f1 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -90,7 +90,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le bloc entier se replie d'un clic. -->
-
Performance
+ +
Performance
…
@@ -450,15 +454,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid - -
Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
+ +
Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
Libérer la VRAM arrête le moteur et la dictée, puis annonce la mémoire vidéo rendue : c'est ce qu'il faut quand une autre application réclame la carte. Le même bouton vit sur les jauges, en pied de barre latérale.
+
diff --git a/internal/loki/ui/src/js/05-status.js b/internal/loki/ui/src/js/05-status.js index a80c9c9..6616fce 100644 --- a/internal/loki/ui/src/js/05-status.js +++ b/internal/loki/ui/src/js/05-status.js @@ -18,6 +18,11 @@ async function loadStatus(){ el.className='statuspill '+cls; el.innerHTML=''+txt; MODEL_READY = !!(s.active && s.health); + // Le bouton « Libérer la VRAM » suit l'état RÉEL du moteur (et pas un drapeau + // local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se + // lire ici aussi, sinon le bouton propose de libérer une carte déjà libre. + ENGINE_ACTIVE = !!s.active; + paintVramBtn(); // Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un // message à un modèle qui n'est pas encore chargé (voir syncSendBtn). STATUS_SEEN = true; @@ -182,6 +187,71 @@ async function loadVram(){ g.name+' · '+vram)); }); } +// ─── Libérer la mémoire vidéo ─────────────────────────────────────────────── +// Tant que le moteur tourne, le modèle occupe la VRAM : une autre application +// qui réclame la carte (un jeu, un encodage, un autre serveur d'inférence) ne +// trouve plus rien à prendre. Ce bouton l'en sort, et le MÊME bouton la +// reprend ensuite — le geste existait déjà (« arrêter » le service, au fond des +// réglages), mais son nom ne disait pas qu'il rendait la carte. +// +// ENGINE_ACTIVE vient de /api/status (relu toutes les 5 s), jamais d'un drapeau +// posé au clic : sinon un second onglet, ou un arrêt venu du terminal, +// afficherait un bouton qui ment. +let ENGINE_ACTIVE = true, VRAM_BUSY = false; +function paintVramBtn(){ + const b = document.getElementById('vram-btn'); + if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy + b.disabled = false; + b.classList.toggle('reload', !ENGINE_ACTIVE); + b.textContent = ENGINE_ACTIVE ? 'Libérer la VRAM' : 'Recharger le modèle'; + b.title = ENGINE_ACTIVE + ? 'décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine' + : 'relance le moteur : le modèle revient en mémoire vidéo'; +} +function vramBtnClick(){ return ENGINE_ACTIVE ? vramUnload(false) : vramReload(); } +function vramBusy(on, label){ + VRAM_BUSY = on; + const b = document.getElementById('vram-btn'); + if(b){ b.disabled = on; if(on && label) b.textContent = label; } + if(!on) paintVramBtn(); +} +// force=true : on a déjà demandé confirmation ET accepté de couper une réponse +// en cours d'écriture (le serveur refuse sans ça, voir handleVramUnload). +async function vramUnload(force){ + if(!force){ + const ok = await askConfirm( + 'Décharger le modèle ? Le moteur s\'arrête et rend la mémoire vidéo — le chat reste indisponible jusqu\'au rechargement.', + {title:'Libérer la VRAM', okText:'Libérer'}); + if(!ok) return; + } + vramBusy(true, 'libération…'); + let r = null; + try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){} + vramBusy(false); + if(r && r.generating){ + const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?', + {title:'Génération en cours', okText:'Décharger quand même'}); + if(ok) return vramUnload(true); + return; + } + // La mesure d'abord : la dictée peut avoir rendu de la VRAM alors même que le + // moteur était déjà arrêté — annoncer « déjà arrêté » nierait ce qui vient + // d'être libéré. + if(!r || !r.ok) toast('échec : ' + ((r && r.error) || 'réseau')); + else if(r.gpu && r.freed_mb > 0) toast('VRAM libérée : ' + (r.freed_mb/1024).toFixed(1) + ' Gio'); + else if(!r.was_active) toast('le moteur était déjà arrêté'); + else toast('modèle déchargé'); + loadAll(); +} +async function vramReload(){ + vramBusy(true, 'démarrage…'); + let r = null; + try{ r = await jpost('/api/vram/reload', {}); }catch(_){} + vramBusy(false); + if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; } + toast('modèle en cours de chargement…'); + loadAll(); +} async function loadRam(){ const m=await jget('/api/ram'); const box=document.getElementById('ram-details'); diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index 2603256..ae06599 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -277,11 +277,17 @@ function syncSendBtn(){ // répond pas du tout) — dans le doute on laisse la main. const ready = !STATUS_SEEN || MODEL_READY; sb.disabled = !ready; - sb.title = ready ? '' : 'le modèle n\'est pas encore chargé'; + // Moteur ARRÊTÉ (VRAM libérée à la demande, ou service coupé) : dire « le + // modèle charge » serait faux, et on attendrait un chargement qui ne viendra + // jamais. On nomme alors le geste qui remet le modèle en mémoire. + const unloaded = !ready && !ENGINE_ACTIVE; + sb.title = ready ? '' : (unloaded ? 'le modèle est déchargé — recharge-le pour écrire' + : 'le modèle n\'est pas encore chargé'); const hint=document.getElementById('sendhint'); if(hint){ hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne' - : 'Le modèle charge — envoi possible dès qu\'il est prêt.'; + : unloaded ? 'Modèle déchargé — « Recharger le modèle » dans le moniteur, en bas de la barre latérale.' + : 'Le modèle charge — envoi possible dès qu\'il est prêt.'; hint.classList.toggle('waiting', !ready); } } diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 0dbc706..36c66e3 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -1689,6 +1689,16 @@ html[data-files="1"] #files-btn{color:var(--accent)} /* --- Moniteur machine (pied de barre latérale) ----------------------- */ .perf-head{display:flex;align-items:center;gap:8px;margin-bottom:11px; font-size:14px;font-weight:600;color:var(--text);letter-spacing:-.01em} +/* Bouton du moniteur (« Libérer la VRAM » / « Recharger le modèle ») : poussé + à droite du titre, discret tant qu'on ne le cherche pas. En mode rechargement + il prend la couleur d'accent — le moteur est arrêté, c'est l'action qui + compte à ce moment-là. */ +.perf-act{margin-left:auto;flex:none;padding:3px 8px;font-size:11px;line-height:1.4; + color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer; + white-space:nowrap} +.perf-act:hover:not(:disabled){color:var(--text)} +.perf-act:disabled{opacity:.55;cursor:progress} +.perf-act.reload{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent)} .perf-row{margin:0 0 14px} .perf-l{display:flex;align-items:baseline;gap:8px;font-size:12.5px;color:var(--text);margin-bottom:6px} .perf-l b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap; diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index b1b7c85..f4bc0d8 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -8,7 +8,6 @@ import ( "net" "net/http" "os" - "os/exec" "path/filepath" "strconv" "strings" @@ -170,28 +169,15 @@ func handleServiceLog(w http.ResponseWriter, r *http.Request) { sendJSON(w, 200, map[string]any{"log": serviceLogTail(n)}) } +// handleVram expose l'état des cartes à l'interface (jauges du moniteur). +// La lecture nvidia-smi elle-même vit dans web_vram.go, avec le déchargement +// de la VRAM qui s'en sert pour chiffrer la mémoire rendue. func handleVram(w http.ResponseWriter, r *http.Request) { - out, err := hideCmd(exec.Command("nvidia-smi", - "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu", - "--format=csv,noheader,nounits")).Output() gpus := []map[string]any{} - if err == nil { - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - parts := strings.Split(line, ",") - if len(parts) != 5 { - continue - } - for i := range parts { - parts[i] = strings.TrimSpace(parts[i]) - } - used, _ := strconv.Atoi(parts[1]) - total, _ := strconv.Atoi(parts[2]) - util, _ := strconv.Atoi(parts[3]) - temp, _ := strconv.Atoi(parts[4]) - gpus = append(gpus, map[string]any{ - "name": parts[0], "used": used, "total": total, "util": util, "temp": temp, - }) - } + for _, g := range gpuStats() { + gpus = append(gpus, map[string]any{ + "name": g.Name, "used": g.Used, "total": g.Total, "util": g.Util, "temp": g.Temp, + }) } sendJSON(w, 200, gpus) } diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 2e8347c..4168116 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -175,6 +175,10 @@ func newWebMux() *http.ServeMux { api("/api/dictate/state", handleDictateState) // serveur allumé, modèle chargé, dernière erreur api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas api("/api/vram", handleVram) + // Rendre la carte à la machine : le modèle occupe la VRAM tant que le moteur + // tourne, et une autre application qui en a besoin ne trouve rien. + api("/api/vram/unload", handleVramUnload) // arrête moteur + dictée, rapporte la VRAM rendue + api("/api/vram/reload", handleVramReload) // relance le moteur (le modèle se recharge) api("/api/ram", handleRam) api("/api/config", handleConfigEnv) api("/api/paths", handlePaths) diff --git a/internal/loki/web_vram.go b/internal/loki/web_vram.go new file mode 100644 index 0000000..042a23e --- /dev/null +++ b/internal/loki/web_vram.go @@ -0,0 +1,171 @@ +// web_vram.go — rendre la mémoire vidéo à la machine, à la demande. +// +// Tant que le moteur tourne, le modèle occupe la VRAM : une autre application +// (jeu, encodage, entraînement, un second serveur d'inférence…) qui réclame la +// carte ne trouve plus rien à prendre. Le geste existait déjà — arrêter le +// service — mais il vivait dans les réglages sous le nom « arrêter », qui ne +// dit pas qu'il libère la carte, et il laissait tourner le serveur de dictée +// qui, lui aussi, garde de la VRAM quand il est allumé sur GPU. +// +// POST /api/vram/unload → arrête le moteur ET la dictée, puis rapporte la +// mémoire réellement rendue (before/after/freed) +// POST /api/vram/reload → relance le moteur (le modèle se recharge) +package loki + +import ( + "encoding/json" + "net/http" + "os/exec" + "regexp" + "strconv" + "strings" + "time" +) + +// gpuStat : une carte vue par nvidia-smi. Mémoire en Mo (unité de nvidia-smi, +// et celle que /api/vram expose déjà à l'interface). +type gpuStat struct { + Name string + Used int + Total int + Util int + Temp int +} + +// gpuStats interroge nvidia-smi. Renvoie nil quand il est absent (machine sans +// GPU NVIDIA, Mac, CPU seul) : l'absence de mesure n'est pas une erreur, elle +// prive juste le déchargement de son bilan chiffré. +func gpuStats() []gpuStat { + out, err := hideCmd(exec.Command("nvidia-smi", + "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu", + "--format=csv,noheader,nounits")).Output() + if err != nil { + return nil + } + var gpus []gpuStat + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + parts := strings.Split(line, ",") + if len(parts) != 5 { + continue + } + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + g := gpuStat{Name: parts[0]} + g.Used, _ = strconv.Atoi(parts[1]) + g.Total, _ = strconv.Atoi(parts[2]) + g.Util, _ = strconv.Atoi(parts[3]) + g.Temp, _ = strconv.Atoi(parts[4]) + gpus = append(gpus, g) + } + return gpus +} + +// gpuUsedMB : VRAM occupée, toutes cartes confondues. +func gpuUsedMB(gpus []gpuStat) int { + used := 0 + for _, g := range gpus { + used += g.Used + } + return used +} + +// gpuUsedSettled attend que la VRAM cesse de baisser avant de la relire : le +// pilote ne rend pas la mémoire à l'instant où le processus meurt, et une +// lecture immédiate rapporterait « 0 Mo libérés » juste après un déchargement +// qui a pourtant marché. Au plus ~3 s, et rien du tout sans GPU. +func gpuUsedSettled() int { + gpus := gpuStats() + if gpus == nil { + return 0 + } + last := gpuUsedMB(gpus) + for i := 0; i < 6; i++ { + time.Sleep(500 * time.Millisecond) + cur := gpuUsedMB(gpuStats()) + if cur >= last { + return cur + } + last = cur + } + return last +} + +// ansiRe / plainErr : les messages de preflightEngine sont écrits pour le +// terminal (bold() y glisse des séquences ANSI). Envoyés tels quels à +// l'interface, ils y affichent des « [1m » au milieu de la phrase. +var ansiRe = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func plainErr(err error) string { + if err == nil { + return "" + } + return ansiRe.ReplaceAllString(err.Error(), "") +} + +// handleVramUnload arrête le moteur (et la dictée) pour rendre la mémoire +// vidéo. Refuse pendant une génération — la couper perdrait la réponse en +// cours — sauf si l'appelant insiste avec {force:true}. +func handleVramUnload(w http.ResponseWriter, r *http.Request) { + var req struct { + Force bool `json:"force"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if !req.Force && conv.isGenerating() { + sendJSON(w, 409, map[string]any{"ok": false, "generating": true, + "error": "une réponse est en cours d'écriture — décharger maintenant l'interromprait"}) + return + } + gpus := gpuStats() + before := gpuUsedMB(gpus) + wasActive := serviceIsActive() + + var errs []string + if wasActive { + if err := serviceAction("stop"); err != nil { + errs = append(errs, "arrêt du moteur : "+plainErr(err)) + } + } + // La dictée tourne dans un processus séparé, allumé à la demande et éteint + // après dix minutes d'inactivité : sur GPU, elle occupe la carte pendant tout + // ce temps. Libérer la VRAM sans elle ne libérerait pas tout. + whisperShutdown() + + after := before + if gpus != nil { + after = gpuUsedSettled() + } + freed := before - after + if freed < 0 { + freed = 0 + } + sendJSON(w, 200, map[string]any{ + "ok": len(errs) == 0, + "error": strings.Join(errs, " ; "), + "was_active": wasActive, + "active": serviceIsActive(), + "gpu": gpus != nil, + "before_mb": before, + "used_mb": after, + "freed_mb": freed, + }) +} + +// handleVramReload relance le moteur après un déchargement. Le préflight évite +// de démarrer un service condamné à mourir en boucle (BIN ou MODEL absents) : +// mieux vaut la vraie raison tout de suite qu'un « chargement… » sans fin. +func handleVramReload(w http.ResponseWriter, r *http.Request) { + if serviceIsActive() { + sendJSON(w, 200, map[string]any{"ok": true, "already": true}) + return + } + if err := preflightEngine(); err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": plainErr(err)}) + return + } + if err := serviceAction("start"); err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": plainErr(err)}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +}