Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5 009b585e75 VRAM : un bouton pour décharger le modèle et rendre la carte
Le modèle occupe la mémoire vidéo tant que le moteur tourne : une autre
application qui réclame la carte (jeu, encodage, autre serveur d'inférence)
ne trouvait plus rien à prendre. Le geste existait — « arrêter » le service,
au fond des réglages — mais son nom ne disait pas qu'il libérait la VRAM, et
il laissait tourner le serveur de dictée, qui garde la carte lui aussi.

- POST /api/vram/unload : arrête le moteur ET la dictée, attend que le pilote
  rende la mémoire (une lecture immédiate rapporte « 0 Mo libérés » après un
  déchargement pourtant réussi) et renvoie le bilan chiffré. Refuse pendant une
  génération, sauf {force:true} — la couper perdrait la réponse en cours.
- POST /api/vram/reload : relance le moteur, préflight compris (BIN/MODEL
  absents = la vraie raison tout de suite, pas un « chargement… » sans fin).
- Bouton sur les jauges du moniteur, là où l'on regarde la VRAM ; il devient
  « Recharger le modèle » dès que le moteur est arrêté, d'après /api/status et
  non d'un drapeau local (un second onglet afficherait sinon un bouton qui ment).
- Même commande dans Réglages → Moteur → Service du moteur.
- Carte de saisie : « Modèle déchargé — Recharger le modèle » au lieu de
  « Le modèle charge », qui promettait un chargement qui ne viendrait jamais.

La lecture nvidia-smi de /api/vram passe dans web_vram.go (gpuStats), partagée
avec le bilan du déchargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8n9tDn5sZudAcUT8U6uGg
2026-08-26 20:18:09 +00:00
Claude 895aa67366 Presets : le bouton « + » ne s'affichait jamais
.preset-add est en display:none par défaut, et n'était révélé que par
« details[open]>summary>.preset-add ». Les panneaux de réglages ont
migré vers <section class="set-pane"><div class="set-pane-h">, donc ce
sélecteur ne matchait plus : le « + » des Presets restait invisible et
il n'y avait plus aucun moyen d'en créer un depuis l'interface.

Le même bouton dans « Discussions » est resté visible, lui, parce qu'il
vit encore dans un <details open><summary> — d'où un défaut qui ne
touchait que les presets.

Règle ajoutée pour le nouveau conteneur, puis UI réassemblée
(tools/assemble-ui).

Vérifié dans l'application lancée : display passe de none à block, et le
« + » apparaît dans l'en-tête du volet Presets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
2026-08-23 12:56:17 +00:00
LogiFlow 08eaf10a34 Merge pull request #23 from R0m1k3/fix/arg-scope-dockerfile
Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
2026-08-22 04:16:09 +02:00
MichaelandClaude Fable 5 e177d54e39 Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
Le build de 51 minutes a échoué au lien final : « libcuda.so.1 not found »,
références cuMem* non résolues, et une libggml-cuda.so PARTAGÉE alors que
BUILD_SHARED_LIBS=OFF est censé être passé. Ce dernier détail était le vrai
indice : les drapeaux n'atteignaient pas cmake du tout.

ARG WHISPER_CMAKE_FLAGS="…" était déclaré entre deux étapes. Règle Docker :
un ARG posé après un FROM appartient à l'étape où il apparaît ; les « ARG »
nus des étapes whisperbuild-* héritent, eux, du scope GLOBAL — où rien
n'était défini. Valeur vide, cmake sans aucun drapeau : ggml en bibliothèques
partagées (échec de lien contre les stubs du pilote), et surtout
-march=native — le SIGILL de la PR #18 revenu en silence sur les deux
binaires. Les tests du Dockerfile n'y voyaient rien : ils vérifiaient le
texte, pas les règles de portée de Docker.

- L'ARG remonte avant le premier FROM, à côté de LLAMACPP_IMAGE.
- Chaque étape vérifie désormais SON binaire : un ldd qui montre libggml ou
  libwhisper en dynamique fait échouer le build sur-le-champ, au lieu de
  laisser partir un binaire qui ne trouvera pas ses .so dans l'image finale.
- TestDockerfileArgFlagsGlobal verrouille la position de l'ARG, ancré en
  début de ligne — une première version se laissait berner par une
  occurrence en commentaire, sa contre-épreuve l'a montré.

libcuda.so.1 reste une dépendance dynamique normale du binaire CUDA : c'est
le pilote, injecté à l'exécution par le NVIDIA Container Toolkit, et
l'édition de liens la résout via les stubs du toolkit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-22 04:12:52 +02:00
LogiFlow 66b53c8255 Merge pull request #22 from R0m1k3/fix/workflow-yaml-double-cle
Workflow : clé YAML dupliquée, plus aucun build ne partait
2026-08-21 23:07:17 +02:00
MichaelandClaude Fable 5 3991e68fc9 Workflow : clé YAML dupliquée, plus aucun build ne partait
Depuis la fusion de la PR #21, chaque push sur main échouait en moins d'une
minute avec zéro job lancé : le YAML du workflow était devenu invalide.

La PR #21 ajoutait cache-from/cache-to après build-args, alors que le bloc
with: les portait DÉJÀ en fin de liste. Une clé dupliquée dans un mapping
YAML fait rejeter le workflow avant même son démarrage — d'où des échecs
immédiats, sans le moindre journal.

Rectification au passage : le cache n'était pas absent, il était déjà actif.
Les trente-sept minutes venaient de la nouveauté de l'étape CUDA (cache
froid) et de l'absence de borne d'architectures, corrigée elle pour de bon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 22:36:37 +02:00
LogiFlow cc218ccfd7 Merge pull request #21 from R0m1k3/fix/cuda-blackwell-et-cache
Image : CUDA 12.8 pour Blackwell, architectures bornées, cache des couches
2026-08-21 22:33:06 +02:00
12 changed files with 447 additions and 49 deletions

No files matched your search

+3 -7
View File
@@ -54,14 +54,10 @@ jobs:
push: true
build-args: |
LOKI_VERSION=${{ github.sha }}
# Sans cache, chaque push recompilait whisper.cpp avec CUDA — une
# quarantaine de minutes — alors que ce binaire ne dépend d'AUCUN
# fichier du dépôt. Les étapes whisperbuild-* sont désormais réutilisées
# tant que leurs lignes du Dockerfile ne bougent pas ; seul le code Go
# et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise les étapes whisperbuild-* (compilation
# whisper.cpp CUDA, ~35 min) tant que leurs lignes du Dockerfile ne
# bougent pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
+23 -7
View File
@@ -14,6 +14,18 @@
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
# appartient à l'étape où il apparaît ; les « ARG WHISPER_CMAKE_FLAGS » nus des
# étapes whisperbuild-* héritent du scope GLOBAL — c'est-à-dire d'ici. Placé
# plus bas, ils héritaient d'une valeur VIDE : cmake tournait sans aucun
# drapeau, ggml se construisait en bibliothèques partagées (échec de lien sur
# libcuda.so.1) et surtout en -march=native — le SIGILL de la PR #18 revenu en
# silence. Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
ARG WHISPER_CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \
-DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \
-DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF"
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
FROM golang:1.25 AS gobuild
WORKDIR /src
@@ -50,11 +62,6 @@ RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# pleine transcription : « AMX is not ready to be used! », puis plus rien,
# l'interface affichant un échec sans raison. OFF retombe sur la ligne de base
# AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis 2013.
ARG WHISPER_CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \
-DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \
-DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF"
# Ubuntu 22.04 : glibc plus ancienne que l'image runtime, donc compatible quoi
# qu'elle embarque.
FROM ubuntu:22.04 AS whisperbuild-cpu
@@ -62,9 +69,13 @@ ARG WHISPER_CMAKE_FLAGS
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Le grep final vérifie que ggml est bien lié EN STATIQUE : l'image finale ne
# reçoit que le binaire, une .so ggml manquante ne se verrait qu'au premier
# clic sur le micro. Des libs partagées ici = drapeaux non transmis.
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} \
&& cmake --build /w/build -j"$(nproc)" --target whisper-server
&& cmake --build /w/build -j"$(nproc)" --target whisper-server \
&& ( ! ldd /w/build/bin/whisper-server | grep -E "libggml|libwhisper" )
# Version CUDA : la MÊME que celle de l'image amont (llama.cpp bâtit avec
# CUDA 12.8.1 sur Ubuntu 24.04). Le binaire est lié dynamiquement à libcudart,
@@ -93,11 +104,16 @@ ARG CUDA_ARCHS="75;86;89;120"
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Même vérification statique que l'étape CPU. libcuda.so.1 (le PILOTE) reste
# elle une dépendance dynamique normale : absente du runner de build, injectée
# à l'exécution par le NVIDIA Container Toolkit — l'édition de liens la
# résout via les stubs du toolkit.
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} -DGGML_CUDA=ON \
-DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \
&& cmake --build /w/build -j"$(nproc)" --target whisper-server \
&& strip /w/build/bin/whisper-server
&& strip /w/build/bin/whisper-server \
&& ( ! ldd /w/build/bin/whisper-server | grep -E "libggml|libwhisper" )
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
+6
View File
@@ -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
+104 -7
View File
@@ -311,6 +311,10 @@ details>summary::-webkit-details-marker{display:none}
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
details[open]>summary>.preset-add{display:inline-block}
/* Les panneaux de réglages ne sont plus des <details>/<summary> mais des
<section class="set-pane"> : sans cette règle, le « + » des Presets restait
en display:none et il n'y avait plus aucun moyen d'en créer un. */
.set-pane-h>.preset-add{display:inline-block}
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
.tag.ok{color:var(--ok);border-color:var(--ok)}
.tag.err{color:var(--err);border-color:var(--err)}
@@ -1716,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;
@@ -2227,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. -->
<div id="side-perf">
<div class="perf-head"><span>Performance</span></div>
<!-- « Libérer la VRAM » est posé LÀ, sur les jauges : c'est en les regardant
qu'on décide de rendre la carte à une autre application. Le libellé
bascule en « Recharger le modèle » dès que le moteur est arrêté — voir
paintVramBtn (js/05-status.js). -->
<div class="perf-head"><span>Performance</span><button id="vram-btn" class="perf-act" onclick="vramBtnClick()" title="décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine">Libérer la VRAM</button></div>
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
</div>
</div>
@@ -2587,15 +2605,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<pre id="lc-log" hidden></pre>
</div>
</div>
<!-- Ces trois boutons pilotent le service qui fait tourner llama-server :
leur place est ici, avec le moteur, et pas dans une section « Service »
à part qui ne contenait qu'eux. -->
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — 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).</span></span></div>
<!-- Ces boutons pilotent le service qui fait tourner llama-server : leur
place est ici, avec le moteur, et pas dans une section « Service » à
part qui ne contenait qu'eux. Le dernier, « libérer la VRAM », fait
plus qu'arrêter : il éteint aussi la dictée et annonce la mémoire
vidéo rendue (c'est le même geste que le bouton du moniteur). -->
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — 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).<br><b>Libérer la VRAM</b> arrête le moteur <i>et</i> 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.</span></span></div>
<div class="slist"><div class="rowbody">
<div class="row" style="margin:0">
<button onclick="act('start')">démarrer</button>
<button onclick="act('restart')">redémarrer</button>
<button onclick="act('stop')">arrêter</button>
<button onclick="vramUnload(false)" title="décharge le modèle et rend la mémoire vidéo à la machine">libérer la VRAM</button>
</div>
</div></div>
</section>
@@ -3701,6 +3722,11 @@ async function loadStatus(){
el.className='statuspill '+cls;
el.innerHTML='<span class="dot"></span>'+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;
@@ -3865,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');
@@ -6466,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);
}
}
+12 -5
View File
@@ -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. -->
<div id="side-perf">
<div class="perf-head"><span>Performance</span></div>
<!-- « Libérer la VRAM » est posé LÀ, sur les jauges : c'est en les regardant
qu'on décide de rendre la carte à une autre application. Le libellé
bascule en « Recharger le modèle » dès que le moteur est arrêté — voir
paintVramBtn (js/05-status.js). -->
<div class="perf-head"><span>Performance</span><button id="vram-btn" class="perf-act" onclick="vramBtnClick()" title="décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine">Libérer la VRAM</button></div>
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
</div>
</div>
@@ -450,15 +454,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<pre id="lc-log" hidden></pre>
</div>
</div>
<!-- Ces trois boutons pilotent le service qui fait tourner llama-server :
leur place est ici, avec le moteur, et pas dans une section « Service »
à part qui ne contenait qu'eux. -->
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — 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).</span></span></div>
<!-- Ces boutons pilotent le service qui fait tourner llama-server : leur
place est ici, avec le moteur, et pas dans une section « Service » à
part qui ne contenait qu'eux. Le dernier, « libérer la VRAM », fait
plus qu'arrêter : il éteint aussi la dictée et annonce la mémoire
vidéo rendue (c'est le même geste que le bouton du moniteur). -->
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — 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).<br><b>Libérer la VRAM</b> arrête le moteur <i>et</i> 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.</span></span></div>
<div class="slist"><div class="rowbody">
<div class="row" style="margin:0">
<button onclick="act('start')">démarrer</button>
<button onclick="act('restart')">redémarrer</button>
<button onclick="act('stop')">arrêter</button>
<button onclick="vramUnload(false)" title="décharge le modèle et rend la mémoire vidéo à la machine">libérer la VRAM</button>
</div>
</div></div>
</section>
+70
View File
@@ -18,6 +18,11 @@ async function loadStatus(){
el.className='statuspill '+cls;
el.innerHTML='<span class="dot"></span>'+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');
+8 -2
View File
@@ -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);
}
}
+14
View File
@@ -280,6 +280,10 @@ details>summary::-webkit-details-marker{display:none}
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
details[open]>summary>.preset-add{display:inline-block}
/* Les panneaux de réglages ne sont plus des <details>/<summary> mais des
<section class="set-pane"> : sans cette règle, le « + » des Presets restait
en display:none et il n'y avait plus aucun moyen d'en créer un. */
.set-pane-h>.preset-add{display:inline-block}
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
.tag.ok{color:var(--ok);border-color:var(--ok)}
.tag.err{color:var(--err);border-color:var(--err)}
@@ -1685,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;
+7 -21
View File
@@ -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)
}
+4
View File
@@ -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)
+25
View File
@@ -155,6 +155,31 @@ func TestDockerfileCudaAssezRecent(t *testing.T) {
}
}
// L'ARG qui porte les drapeaux doit être déclaré AVANT le premier FROM. Un ARG
// posé entre deux étapes appartient à l'étape où il apparaît ; les « ARG » nus
// des étapes whisperbuild-* héritent alors du scope global — vide. cmake a
// tourné sans aucun drapeau : ggml en bibliothèques partagées (échec de lien),
// et -march=native — le SIGILL de la PR #18 revenu en silence. 51 minutes de
// build pour le découvrir.
func TestDockerfileArgFlagsGlobal(t *testing.T) {
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
src := string(b)
// Ancré en début de ligne : une occurrence dans un commentaire ne compte
// pas — c'est précisément ainsi qu'une première version de ce test s'est
// laissée berner par sa propre contre-épreuve.
argLoc := regexp.MustCompile(`(?m)^ARG WHISPER_CMAKE_FLAGS=`).FindStringIndex(src)
fromLoc := regexp.MustCompile(`(?m)^FROM `).FindStringIndex(src)
if argLoc == nil {
t.Fatal("ARG WHISPER_CMAKE_FLAGS= introuvable dans le Dockerfile")
}
if fromLoc != nil && argLoc[0] > fromLoc[0] {
t.Error("ARG WHISPER_CMAKE_FLAGS= est déclaré APRÈS un FROM : les étapes whisperbuild-* hériteront d'une valeur vide et cmake tournera sans aucun drapeau")
}
}
func clefs(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
+171
View File
@@ -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})
}