mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
Sur Unraid, /mnt/user/… n'est pas un disque mais shfs, un démon FUSE : chaque fsync de loki.db et chaque page d'un gros MoE mmappé relue depuis le disque (modèle plus gros que la RAM) le traverse, à chaque token. Rien n'est perdu ni altéré, mais le décodage ralentit — sans que rien ne le dise. Loki ne déplace rien : changer le chemin hôte d'une installation donnerait un /data vide. Il le voit et le dit, sur un ton d'information : - sys_storagefuse.go lit /proc/self/mountinfo (type lu après le séparateur « - », montage le plus long qui contient le chemin) pour LOKI_HOME et chaque dossier de modèles ; Linux en conteneur seulement, mis en cache une minute puisque /api/status est interrogé en boucle ; - conseil en jaune au démarrage et champ « hint » de /api/status, affiché en encart discret, distinct du bandeau rouge de perte de données ; - compose Unraid et README : « Exclusive access » recommandé (même chemin, FUSE court-circuité), /mnt/<pool> en option avancée avec migration manuelle et mise en garde sur un pool inexistant (RAM). La détection de thrash du cache de pages est laissée de côté : elle exige un signal majflt calibré par modèle sur le serveur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
17 KiB
JavaScript
333 lines
17 KiB
JavaScript
let LAST_PRESET=null; // id du preset actif vu au dernier poll (détecte une bascule faite ailleurs)
|
|
async function loadStatus(){
|
|
const s=await jget('/api/status');
|
|
// Preset actif changé alors qu'on n'a rien touché ici : un AUTRE appareil a
|
|
// basculé de preset/modèle. On rafraîchit la liste (et le libellé du preset)
|
|
// pour rester en phase sans recharger l'app. Premier poll (LAST_PRESET null) :
|
|
// on note seulement la valeur, loadPresets a déjà tourné au démarrage.
|
|
if(typeof s.preset!=='undefined'){
|
|
if(LAST_PRESET!==null && s.preset!==LAST_PRESET && typeof loadPresets==='function') loadPresets();
|
|
LAST_PRESET=s.preset;
|
|
}
|
|
const el=document.getElementById('status-svc');
|
|
// Trois états : service coupé (err) · service actif mais modèle pas encore
|
|
// chargé (loading, llama-server renvoie 503) · modèle prêt (ok).
|
|
// Pastille COURTE et honnête. Le détail (cause exacte) va dans #model-err
|
|
// dessous : la pastille ne doit pas affirmer « incompatible » quand l'échec
|
|
// peut être tout autre chose.
|
|
let cls='err', txt='arrêté';
|
|
if(s.active && s.health){ cls='ok'; txt='prêt'; }
|
|
else if(s.load_error){ cls='err'; txt='erreur'; }
|
|
else if(s.active){
|
|
cls='loading'; txt='chargement…';
|
|
// Pourcentage estimé (octets du modèle déjà lus) : voir « ça avance »
|
|
// plutôt qu'un « chargement… » figé qui ressemble à un plantage.
|
|
if(typeof s.load_pct==='number' && s.load_pct>=0) txt='chargement '+s.load_pct+' %';
|
|
}
|
|
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;
|
|
// Un moteur qui a PLANTÉ au chargement n'est pas « déchargé » : sans ce
|
|
// drapeau, syncSendBtn proposerait de recharger un modèle qui va replanter.
|
|
LOAD_ERROR = s.load_error || '';
|
|
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;
|
|
if(typeof syncSendBtn==='function') syncSendBtn();
|
|
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
|
|
if(s.version){
|
|
document.getElementById('ver').textContent='v'+s.version;
|
|
}
|
|
// Avertissement de lancement (App Translocation macOS) : rare, mais il explique
|
|
// des symptomes tres deroutants, donc on l'affiche en permanence tant qu'il dure.
|
|
const wb=document.getElementById('app-warn');
|
|
if(wb){
|
|
if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; }
|
|
else { wb.style.display='none'; }
|
|
}
|
|
// Conseil de performance (stockage Unraid via fuse.shfs) : encart discret,
|
|
// pas le bandeau d'alerte — rien n'est perdu, seul le décodage ralentit.
|
|
const hb=document.getElementById('app-hint');
|
|
if(hb){
|
|
if(s.hint){ hb.textContent=s.hint; hb.style.display=''; }
|
|
else { hb.style.display='none'; }
|
|
}
|
|
// Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite
|
|
// plutôt qu'un « chargement… » perpétuel ou un crash-loop muet.
|
|
const me=document.getElementById('model-err');
|
|
if(me){
|
|
if(s.load_error){ me.textContent='⚠ '+s.load_error; me.style.display=''; }
|
|
else { me.style.display='none'; }
|
|
}
|
|
}
|
|
// Journal du moteur — section repliable comme les autres. La pastille d'état
|
|
// reste un raccourci ; le chargement du journal est déclenché par l'événement
|
|
// `toggle` du <details>, qu'on l'ouvre par la pastille ou par son chevron.
|
|
function toggleSvcLog(){
|
|
const box=document.getElementById('svc-log-box');
|
|
if(!box) return;
|
|
// openDetails déplie aussi « Réglages » : sans lui, la section s'ouvrait à
|
|
// l'intérieur d'un repli fermé — donc nulle part, du point de vue de l'écran.
|
|
if(box.open) box.open = false; else openDetails(box);
|
|
}
|
|
async function loadSvcLog(){
|
|
const el=document.getElementById('svc-log');
|
|
if(!el) return;
|
|
el.textContent='chargement du journal…';
|
|
try{
|
|
const r=await jget('/api/service/log?n=120');
|
|
el.textContent = (r && r.log && r.log.trim()) ? r.log : 'journal vide — le moteur n\'a encore rien écrit.';
|
|
el.scrollTop = el.scrollHeight;
|
|
}catch(e){ el.textContent='journal indisponible : '+e; }
|
|
}
|
|
async function checkUpdate(){
|
|
const b=document.getElementById('upd-check'), msg=document.getElementById('upd-msg');
|
|
b.disabled=true; msg.textContent='Vérification…';
|
|
try{
|
|
const r=await jget('/api/update');
|
|
// En conteneur, la mise à jour passe par l'image : on l'explique au lieu de
|
|
// proposer un remplacement de binaire impossible ici.
|
|
if(r.note){ msg.textContent=r.note; }
|
|
else if(r.error){ msg.textContent='Erreur : '+r.error; }
|
|
else if(r.available){
|
|
msg.innerHTML='Nouvelle version <b>v'+r.latest+'</b> disponible. ';
|
|
const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate;
|
|
msg.appendChild(btn);
|
|
} else { msg.textContent='Loki est à jour ✓'; }
|
|
}catch(e){ msg.textContent='Erreur réseau'; }
|
|
b.disabled=false;
|
|
}
|
|
// Emplacements — affichés avec le journal du moteur : c'est le panneau qu'on
|
|
// ouvre quand on cherche à comprendre l'état de son installation.
|
|
async function showPaths(){
|
|
const el=document.getElementById('paths-msg');
|
|
if(!el) return;
|
|
el.textContent='…';
|
|
try{
|
|
const p=await jget('/api/paths');
|
|
const rows=[['Données',p.home],['Base (config, préférences, conversation)',p.database],['Modèles',p.models],['Presets',p.presets],['Mémoire',p.memory],['Fichiers créés par l\'IA',p.workspace],['Moteur llama.cpp',p.backends],['Programme',p.exe]];
|
|
el.innerHTML=rows.map(r=>'<div style="margin-bottom:4px">'+r[0]+'<br><code style="word-break:break-all">'+escHtml(r[1]||'')+'</code></div>').join('');
|
|
}catch(e){ el.textContent='Erreur'; }
|
|
}
|
|
async function applyUpdate(){
|
|
const msg=document.getElementById('upd-msg');
|
|
msg.textContent='Téléchargement et installation…';
|
|
try{
|
|
// Signal dédié : le timeout par défaut (30 s) coupe le téléchargement du
|
|
// binaire sur une connexion lente et fait croire à un échec alors que la
|
|
// mise à jour aboutit côté serveur.
|
|
const ac=new AbortController(); const t=setTimeout(()=>ac.abort(), 10*60*1000);
|
|
let r;
|
|
try{ r=await (await jfetch('/api/update/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',signal:ac.signal})).json(); }
|
|
finally{ clearTimeout(t); }
|
|
if(r.ok){
|
|
msg.innerHTML='✓ Installé en <b>v'+r.version+'</b>.<br>'+(r.restart||'');
|
|
// Redémarrage auto du service côté serveur : le flux va se couper puis
|
|
// reconnecter tout seul (connectStream boucle). On rafraîchit l'état après.
|
|
if(r.restarting){ toast('mise à jour appliquée — reconnexion…'); setTimeout(loadAll, 6000); }
|
|
}
|
|
else { msg.textContent='Échec : '+(r.error||'inconnu'); }
|
|
}catch(e){ msg.textContent='Erreur pendant la mise à jour (réessaie).'; }
|
|
}
|
|
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
|
|
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
|
|
let CTX_MAX=0, CTX_USED=0, MODEL_READY=false;
|
|
// STATUS_SEEN : /api/status a répondu au moins une fois. Avant ça (ou s'il ne
|
|
// répond pas), on ne verrouille RIEN — mieux vaut un envoi qui échoue qu'un chat
|
|
// bloqué par un état inconnu.
|
|
let STATUS_SEEN=false;
|
|
function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); }
|
|
function updateCtxMeter(){
|
|
if(!CTX_MAX) return;
|
|
const pct=Math.min(100, Math.round(CTX_USED*100/CTX_MAX));
|
|
const fill=document.getElementById('ctx-fill');
|
|
fill.style.width=pct+'%';
|
|
fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)';
|
|
// Les chiffres SANS le mot « contexte » : la jauge juste en dessous dit déjà de
|
|
// quoi on parle, et le pied de carte est étroit. Le libellé complet reste en
|
|
// infobulle.
|
|
const ct=document.getElementById('ctx-text');
|
|
ct.textContent=CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
|
|
ct.title='contexte utilisé';
|
|
// Bouton de compaction MANUELLE : visible dès la moitié du contexte pour qu'on
|
|
// puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
|
|
document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
|
|
}
|
|
// ─── Moniteur machine (pied de barre latérale) ───────────────────────────────
|
|
// Une jauge par ressource : nom, pourcentage, barre, puis le détail chiffré en
|
|
// dessous. C'est la hiérarchie de la maquette — on lit la charge d'un coup
|
|
// d'œil, et le détail (Gio, température) reste là pour qui le cherche.
|
|
//
|
|
// Construit EN DOM et non en innerHTML : les noms de GPU viennent du pilote,
|
|
// donc d'ailleurs. Même règle que pour les noms de fichiers et les titres de
|
|
// discussions.
|
|
function perfRow(label, pct, title){
|
|
pct = Math.max(0, Math.min(100, Math.round(pct||0)));
|
|
const row=document.createElement('div'); row.className='perf-row';
|
|
if(title) row.title=title;
|
|
const l=document.createElement('div'); l.className='perf-l';
|
|
const b=document.createElement('b'); b.textContent=label;
|
|
const v=document.createElement('span'); v.className='perf-pct'; v.textContent=pct+'%';
|
|
l.append(b,v);
|
|
const track=document.createElement('div'); track.className='perf-track';
|
|
const fill=document.createElement('div');
|
|
// Sauge jusqu'à 75 %, ambre au-delà, rouille à 90 : la couleur ne sert à rien
|
|
// tant que la machine respire, elle doit sauter aux yeux quand elle sature.
|
|
fill.className='perf-fill'+(pct>=90?' max':pct>=75?' hot':'');
|
|
fill.style.width=pct+'%';
|
|
track.appendChild(fill);
|
|
row.append(l,track);
|
|
return row;
|
|
}
|
|
async function loadVram(){
|
|
const gpus=await jget('/api/vram');
|
|
const box=document.getElementById('vram');
|
|
if(!box) return;
|
|
box.textContent='';
|
|
if(!gpus || !gpus.length){
|
|
const d=document.createElement('div'); d.className='perf-sub'; d.textContent='(pas de GPU détecté)';
|
|
box.appendChild(d);
|
|
return;
|
|
}
|
|
// Deux mesures par carte, deux jauges — une seule barre ne peut pas porter la
|
|
// charge du processeur ET le remplissage de la mémoire vidéo, et pour du LLM
|
|
// c'est la VRAM qui décide si un modèle tient. Sur une machine à UNE carte on
|
|
// retombe exactement sur les trois jauges de la maquette (GPU, VRAM, RAM).
|
|
const many = gpus.length > 1;
|
|
gpus.forEach((g,i)=>{
|
|
const vram=(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' Gio';
|
|
box.appendChild(perfRow('GPU '+i, g.util, g.name+' · '+vram+' · '+g.temp+' °C'));
|
|
box.appendChild(perfRow(many ? 'VRAM '+i : 'VRAM', g.used*100/g.total,
|
|
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, LOAD_ERROR = '';
|
|
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).
|
|
// Les deux gestes sont exclusifs : un second clic (le bouton des réglages
|
|
// pendant que celui du moniteur travaille) lancerait un « stop » au milieu d'un
|
|
// démarrage, ou l'inverse. VRAM_BUSY tient lieu de verrou.
|
|
async function vramUnload(force){
|
|
if(VRAM_BUSY) return;
|
|
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(_){}
|
|
// Le serveur dit l'état RÉEL après coup : le repeindre depuis l'ancien
|
|
// ENGINE_ACTIVE (relu seulement toutes les 5 s) afficherait « Libérer » sur
|
|
// un moteur déjà arrêté.
|
|
if(r && typeof r.active === 'boolean') ENGINE_ACTIVE = r.active;
|
|
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é. Sous quelques dizaines de Mo, c'est le bruit du bureau entre
|
|
// deux lectures, pas une libération : on ne l'annonce pas.
|
|
if(!r || !r.ok) toast('échec : ' + ((r && r.error) || 'réseau'));
|
|
else if(r.gpu && r.freed_mb >= 64) toast('VRAM libérée : ' + fmtMo(r.freed_mb));
|
|
else if(!r.was_active) toast('le moteur était déjà arrêté');
|
|
else toast('modèle déchargé');
|
|
loadAll();
|
|
}
|
|
function fmtMo(mb){ return mb >= 1024 ? (mb/1024).toFixed(1) + ' Gio' : mb + ' Mo'; }
|
|
async function vramReload(){
|
|
if(VRAM_BUSY) return;
|
|
vramBusy(true, 'démarrage…');
|
|
let r = null;
|
|
try{ r = await jpost('/api/vram/reload', {}); }catch(_){}
|
|
if(r && r.ok) ENGINE_ACTIVE = true;
|
|
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');
|
|
if(!box) return;
|
|
if(!m || !m.total){ box.style.display='none'; return; }
|
|
box.style.display='';
|
|
const host=document.getElementById('ram');
|
|
host.textContent='';
|
|
host.appendChild(perfRow('Mémoire vive', m.used*100/m.total,
|
|
(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' Gio utilisés'));
|
|
}
|
|
async function loadCfg(){
|
|
// /api/llamacpp en parallèle : il indique si le BIN de la config correspond au
|
|
// précompilé (prebuilt.in_use) ou compilé ici (in_use) — sinon c'est un fork perso.
|
|
const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
|
|
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'"')+'">'+String(v)+'</span></div>';
|
|
const rows=[];
|
|
if(c.BIN){
|
|
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
|
// toujours le chemin complet, quel que soit le libellé.
|
|
let v;
|
|
// En conteneur, le moteur vient de l'image : le dire, plutôt que de le
|
|
// ranger dans « personnalisé » qui laisse croire à un bricolage.
|
|
if(lc && lc.provided && sameBinPath(c.BIN, lc.provided_bin)) v='llama.cpp de l\'image';
|
|
// Moteur mis à jour depuis l'image officielle (Réglages → Moteur) : c'est la
|
|
// voie normale en conteneur, pas un « personnalisé ».
|
|
else if(lc && lc.engine && lc.engine.in_use){
|
|
const b = (lc.engine.tag||'').match(/b\d+$/);
|
|
v = b ? 'llama.cpp '+b[0]+' (mis à jour)' : 'llama.cpp mis à jour';
|
|
}
|
|
else if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé';
|
|
else if(lc && lc.in_use) v='llama.cpp compilé';
|
|
else v='llama.cpp personnalisé : '+c.BIN;
|
|
rows.push(row('MOTEUR', v, c.BIN));
|
|
}
|
|
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
|
|
// source qui le connaisse quand aucun preset ne correspond à la configuration.
|
|
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
|
|
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
|
|
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
|
|
rows.push(row(k, v));
|
|
});
|
|
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
|
|
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
|
|
if(m) rows.push(row('N-CPU-MOE', m[1]));
|
|
document.getElementById('cfg').innerHTML = rows.join('');
|
|
}
|