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. -->
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. -->
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})
+}