';
return;
}
const x = r.result;
body.innerHTML =
'
' +
'
' +
'
Prefill
' +
'
'+x.prompt_per_second.toFixed(0)+'
' +
'
tok/s
' +
'
'+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s
' +
'
' +
'
' +
'
Decode
' +
'
'+x.predicted_per_second.toFixed(1)+'
' +
'
tok/s
' +
'
'+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s
' +
'
' +
'
' +
'
total '+x.elapsed_sec.toFixed(2)+'s
';
} finally {
btn.disabled = false; btn.textContent = 'bench';
rerun.disabled = false;
loadPresets();
}
}
async function switchTo(n,name){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
toast('switching…');
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
pendingPreset = n; loadPresets();
const r=await jpost('/api/switch',{n:n});
if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
toast('switched');
// Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur
// l'écrit AVANT de répondre (et relance le service en arrière-plan, voir
// handleSwitch), donc un rafraîchissement immédiat suffit — la barre passe au
// blanc tout de suite. Le chargement du modèle par llama-server continue
// derrière ; il ne conditionne pas la sélection.
try{ await loadPresets(); }catch(e){}
// Filet : si l'empreinte n'a pas encore basculé (bascule lente côté disque), on
// reste en attente le temps qu'il faut, sans dépasser ~60 s.
for(let i=0; i<40 && pendingPreset===n; i++){
await new Promise(r=>setTimeout(r,1500));
try{ await loadPresets(); }catch(e){}
}
pendingPreset = 0;
loadAll();
}
// editingKey = the identifier of the item being edited: a preset id (filename)
// or a skill name. Empty string = creating a new item.
let editingKey = '', editingKind = 'preset';
const KINDS = {
// presets are keyed by `id` (filename) so several can share a display name;
// skills keep name-as-identity (param 'name').
// newLabel / nameHint : « Nouveau Page » et « Nom du preset » dans l'éditeur de
// mémoire venaient d'un libellé unique décliné mécaniquement.
preset: {label:'', newLabel:'Nouveau preset', nameHint:'Nom du preset',
param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()},
mem: {label:'Page', newLabel:'Nouvelle page', nameHint:'Nom de la page',
param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()},
};
// ⚠️ La modale s'ouvre AVANT d'aller chercher quoi que ce soit. Elle attendait
// auparavant la fin de 3 requêtes (contenu + backends + modèles disponibles) :
// en accès distant, ça faisait un clic sans réaction pendant une seconde, comme
// si le bouton était mort. On affiche la coquille tout de suite, puis on la
// remplit. `openSeq` protège du cas « deux ouvertures coup sur coup » : une
// réponse en retard ne doit jamais écraser la modale ouverte après elle.
// Pendant ce remplissage la modale est en `.loading` : un rond tourne à la place
// du formulaire, qui n'est révélé qu'une fois TOUT en place (contenu, réglages,
// backends, GPU) — sinon on voyait les champs se peupler un par un.
let openSeq = 0;
async function openItem(kind, key){
const K = KINDS[kind];
const seq = ++openSeq;
editingKind = kind; editingKey = key || '';
document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + key : key) : K.newLabel;
document.getElementById('m-name').value = key || '';
document.getElementById('m-name').placeholder = K.nameHint;
document.getElementById('m-content').value = '';
document.getElementById('m-del').style.display = key ? 'inline-flex' : 'none';
// Model picker is preset-only: it edits the MODEL= line of the preset.
const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-row');
const samplingRow = document.getElementById('m-sampling-row');
const rawHead = document.getElementById('m-raw-head');
const rawToggle = document.getElementById('m-raw-toggle');
const rawBody = document.getElementById('m-raw-body');
const rawCaret = document.getElementById('m-raw-caret');
if(kind === 'preset'){
modelRow.style.display = 'flex';
settingsRow.style.display = 'flex';
if(samplingRow) samplingRow.style.display = 'flex';
document.getElementById('m-hf-url').value = '';
resetDlUI();
resetHfUI();
// Preset : la config brute est une ligne repliable, fermée par défaut.
rawHead.textContent = 'Configuration';
rawToggle.style.display = '';
rawBody.style.display = 'none';
rawCaret.classList.remove('open');
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
document.getElementById('m-dir-path').value = '';
setModelDirsOpen(false);
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
setHFTokenOpen(false);
loadHFToken();
} else {
modelRow.style.display = 'none';
settingsRow.style.display = 'none';
if(samplingRow) samplingRow.style.display = 'none';
// Page mémoire : le contenu EST le champ principal — affiché en clair.
rawHead.textContent = 'Contenu';
rawToggle.style.display = 'none';
rawBody.style.display = '';
}
const modalEl=document.getElementById('modal');
modalEl.classList.add('loading');
showModal('modal');
// Le voile s'arrête au bas de l'en-tête : on lui passe sa hauteur réelle. ⚠️ À
// mesurer APRÈS l'affichage — un élément en display:none mesure 0.
const mHead=modalEl.querySelector('.modal-head');
if(mHead) modalEl.querySelector('.modal-card').style.setProperty('--mh', mHead.offsetHeight+'px');
// ⚠️ APRÈS showModal, jamais avant : le corps de la modale est un conteneur
// défilant RÉUTILISÉ d'une ouverture à l'autre, et écrire scrollTop sur un
// élément en display:none ne fait RIEN (il n'a pas de boîte de rendu) — le
// navigateur restaurait donc l'ancienne position à l'affichage, et on rouvrait
// la fiche au milieu. On le refait après le remplissage, la hauteur ayant changé.
const topPeBody=()=>{ const b=document.querySelector('#modal .pe-body'); if(b) b.scrollTop=0; };
topPeBody();
// --- Remplissage, une fois la modale à l'écran -----------------------------
const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||''));
const d = await r.json();
if(seq !== openSeq) return; // une autre ouverture a pris la main
const display = d.name || key || '';
document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + display : display) : K.newLabel;
document.getElementById('m-name').value = display;
document.getElementById('m-content').value = d.content || '';
if(kind === 'preset'){
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
attachDownload(); // téléchargement encore en cours côté serveur ?
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
if(seq !== openSeq) return;
}
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
// les transitions deux frames plus tard, une fois le nouvel état peint.
modalEl.classList.add('no-anim');
modalEl.classList.remove('loading');
topPeBody();
requestAnimationFrame(()=>requestAnimationFrame(()=>modalEl.classList.remove('no-anim')));
}
// Pretty-print bytes — handy for the dropdown options.
function fmtSize(b){
if(b > 1e9) return (b/1e9).toFixed(1)+' GB';
if(b > 1e6) return (b/1e6).toFixed(0)+' MB';
if(b > 1e3) return (b/1e3).toFixed(0)+' KB';
return b+' B';
}
// Retire les guillemets ENTOURANTS d'une valeur, et seulement eux. Les anciens
// motifs `"?([^"\n]*)"?` s'arrêtaient au premier guillemet INTERNE : une valeur
// comme `--chat-template-file "/etc/loki/tpl.jinja"` était lue tronquée, puis
// réécrite avec des guillemets non appariés (issue #17).
function unquoteVal(v){
v = String(v==null?'':v).trim();
const q = v[0];
if(v.length >= 2 && (q === '"' || q === "'") && v[v.length-1] === q) return v.slice(1,-1);
return v;
}
// Lit la valeur d'une clé KEY= dans un texte au format .env.
function readEnvKey(txt, key){
const m = String(txt).match(new RegExp('^[ \\t]*'+key+'[ \\t]*=(.*)$','m'));
return m ? unquoteVal(m[1]) : '';
}
// Read the current MODEL= value out of the textarea, handling quotes / spaces.
function currentModelInTextarea(){
return readEnvKey(document.getElementById('m-content').value, 'MODEL');
}
// Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers :
// on ne contrôle pas ce que l'utilisateur y met).
function escHtml(s){
return String(s==null?'':s).replace(/[&<>"']/g, c =>
({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
// Compare deux chemins/noms de modèle en neutralisant séparateurs et casse.
function samePath(a, b){
const n = x => String(x||'').trim().replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
return !!a && !!b && n(a) === n(b);
}
function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); }
async function populateModelPicker(){
const sel = document.getElementById('m-model');
const list = await jget('/api/models');
const cur = currentModelInTextarea();
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
// regroupe par dossier, LOKI_HOME d'abord (l'API les renvoie dans cet ordre).
const groups = [];
for(const m of (list||[])){
let g = groups.find(x => x.dir === m.dir);
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
g.items.push(m);
}
let html = '';
let matched = false;
for(const g of groups){
let opts = '';
for(const m of g.items){
// Le preset peut référencer le chemin complet OU le simple nom de fichier.
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
if(on) matched = true;
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
// (les autres ne se lancent pas seules) et la taille de la famille entière.
// On le dit, et surtout on signale les tranches manquantes — sinon le
// modèle se sélectionne sans broncher puis le moteur meurt au démarrage.
let tag = '';
if(m.shards > 1) tag = ' · ' + m.shards + ' fichiers';
if(m.missing && m.missing.length) tag += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':'');
opts += '';
}
html += groups.length > 1
? ''
: opts;
}
// MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré :
// on déplie alors la section des dossiers, c'est là que ça se répare.
if(cur && !matched){
html += '';
}
sel.innerHTML = html;
if(cur && !matched) setModelDirsOpen(true);
}
// ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) ---
// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut.
function setModelDirsOpen(open){
const b = document.getElementById('m-dirs-body');
const c = document.getElementById('m-dirs-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
if(open) populateModelDirs();
}
function toggleModelDirs(){
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
}
// ---- Jeton Hugging Face ----------------------------------------------------
// Un dépôt « gated » se lit sans jeton mais ne se télécharge pas. Le jeton se
// posait uniquement par la variable d'environnement HF_TOKEN : en conteneur, ça
// voulait dire éditer un docker-compose et recréer le conteneur pour répondre à
// un 401 découvert ici. Il se règle donc à l'endroit où le problème apparaît.
function setHFTokenOpen(open){
const b = document.getElementById('m-hft-body');
const c = document.getElementById('m-hft-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
}
function toggleHFToken(){
setHFTokenOpen(document.getElementById('m-hft-body').style.display === 'none');
}
async function loadHFToken(){
let d = {};
try{ d = await jget('/api/hf/token'); }catch(_){ return; }
renderHFToken(d);
}
// Le jeton n'est jamais renvoyé en clair par le serveur : on n'affiche que sa
// forme masquée et sa provenance. « env » se signale à part — l'interface ne
// peut pas effacer une variable du conteneur, promettre le contraire par un
// bouton « Retirer » serait mentir.
function renderHFToken(d){
const st = document.getElementById('m-hft-state');
const note = document.getElementById('m-hft-note');
const clear = document.getElementById('m-hft-clear');
const inp = document.getElementById('m-hft-input');
if(!st || !note || !clear || !inp) return;
inp.value = '';
const src = (d && d.source) || '';
st.textContent = src ? (d.masked || 'enregistré') : 'aucun';
clear.style.display = src === 'config' ? '' : 'none';
if(src === 'env'){
note.textContent = 'Jeton fourni par la variable d’environnement HF_TOKEN. En enregistrer un ici le remplacera.';
} else if(src === 'config'){
note.textContent = 'Jeton enregistré' + (d.account ? ' — compte ' + d.account : '')
+ '. Un dépôt à accès restreint exige EN PLUS d’avoir accepté ses conditions sur huggingface.co avec ce compte.';
} else {
note.textContent = 'Sans jeton, les dépôts à accès restreint répondent 401 au téléchargement. Crée un jeton en lecture sur huggingface.co/settings/tokens, accepte les conditions du dépôt, puis colle-le ici.';
}
}
// Vérifié côté serveur AVANT d'être enregistré : un jeton mal collé accepté en
// silence rendrait exactement le 401 qu'on cherche à expliquer.
async function saveHFToken(){
const inp = document.getElementById('m-hft-input');
const tok = (inp.value || '').trim();
if(!tok){ toast('colle un jeton (hf_…)'); return; }
toast('vérification du jeton…');
const r = await jpost('/api/hf/token', {token: tok});
if(!r || !r.ok){ toast('jeton refusé : ' + ((r && r.error) || '')); return; }
renderHFToken(r);
toast(r.account ? 'jeton enregistré — compte ' + r.account : 'jeton enregistré');
}
async function clearHFToken(){
if(!await askConfirm('Les dépôts à accès restreint répondront de nouveau 401 au téléchargement.',
{title:'Retirer le jeton Hugging Face ?', okText:'Retirer'})) return;
const r = await jpost('/api/hf/token', {token: ''});
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || '')); return; }
renderHFToken(r);
toast('jeton retiré');
}
async function populateModelDirs(){
const box = document.getElementById('m-dirs-list');
if(!box) return;
let d = {};
try{ d = await jget('/api/models/dirs'); }catch(_){ return; }
// Construit en DOM (pas en HTML concaténé) : les chemins viennent de
// l'utilisateur et se retrouveraient sinon dans un attribut onclick.
box.innerHTML = '';
for(const x of (d.dirs||[])){
const row = document.createElement('div');
const p = document.createElement('span');
p.textContent = x.path + ' ';
const info = document.createElement('span');
info.className = 'muted';
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
// « ~ » quand le système de fichiers approxime (FUSE/shfs d'Unraid,
// overlay, réseau) : le chiffre est indicatif, il ne bloque plus rien.
const free = x.free >= 0 ? ', ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : '';
info.textContent = '(' + n + free + (x.home ? ', dossier loki' : '') + ')';
row.append(p, info);
if(!x.home){
const del = document.createElement('button');
del.className = 'pe-link';
del.textContent = 'retirer';
del.style.marginLeft = '8px';
del.onclick = () => removeModelDir(x.path);
row.append(del);
}
box.append(row);
}
}
async function addModelDir(){
const inp = document.getElementById('m-dir-path');
const p = inp.value.trim();
if(!p){ toast('indique un chemin de dossier'); return; }
const r = await jpost('/api/models/dirs', {path: p, action: 'add'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
inp.value = '';
toast('dossier ajouté');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
}
async function removeModelDir(p){
const r = await jpost('/api/models/dirs', {path: p, action: 'remove'});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('dossier retiré');
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
}
function onPickModel(){
const val = document.getElementById('m-model').value;
if(!val) return;
const ta = document.getElementById('m-content');
if(/^\s*MODEL\s*=.*$/m.test(ta.value)){
ta.value = ta.value.replace(/^\s*MODEL\s*=.*$/m, 'MODEL="'+val+'"');
} else {
// No MODEL= line yet — prepend so it's visible at the top.
ta.value = 'MODEL="'+val+'"\n' + ta.value;
}
toast('MODEL='+val);
}
// --- Vision (projecteur multimodal --mmproj) --------------------------------
// Le projecteur est un .gguf séparé du modèle : il apparaît donc dans la même
// liste que les modèles (/api/models). On le range dans sa PROPRE clé MMPROJ,
// que backend_serve.go traduit en --mmproj — plutôt que dans EXTRA_ARGS, pour
// que le chemin soit résolu comme celui du modèle (nom simple = cherché dans
// les dossiers déclarés).
function currentMmprojInTextarea(){
// Clé dédiée d'abord ; à défaut, un --mmproj posé à la main dans EXTRA_ARGS
// (anciens presets), pour que le champ montre la vision déjà configurée.
return readEnvKey(document.getElementById('m-content').value, 'MMPROJ')
|| eaGetValued('--mmproj');
}
function onPickMmproj(){
// On retire un éventuel --mmproj d'EXTRA_ARGS pour ne pas le charger deux fois,
// et on centralise le choix dans la clé MMPROJ.
eaSetValued('--mmproj', '');
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
}
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
// mêler les modèles de plusieurs Go n'aiderait pas à choisir des yeux.
function isMmprojName(name){ return /mmproj/i.test(String(name||'')); }
async function populateMmproj(){
const sel = document.getElementById('m-mmproj');
if(!sel) return;
const list = await jget('/api/models');
const cur = currentMmprojInTextarea();
const items = (list||[]).filter(m => isMmprojName(m.name));
let html = '';
let matched = false;
for(const m of items){
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
samePath(baseName(cur), m.name) ? ' selected' : '';
if(on) matched = true;
html += '';
}
// MMPROJ pointe sur un fichier absent des dossiers déclarés (ou nommé hors
// convention) : on le garde affiché plutôt que de l'effacer silencieusement.
if(cur && !matched){
html += '';
}
sel.innerHTML = html;
}
// Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows).
// Sert à la ligne MOTEUR du panneau d'état (05-status.js).
function sameBinPath(a, b){
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
return !!a && !!b && n(a) === n(b);
}
// --- Cartes graphiques (--device / --tensor-split) --------------------------
// Le moteur n'est PAS un réglage de preset : c'est celui de la machine (l'image,
// ou la version mise à jour dans Réglages → Moteur), et applyPresetFile le
// conserve à la bascule. L'éditeur n'offre donc plus de choix de moteur — il
// interroge celui qui tourne pour lister les cartes.
// Ces réglages vivent dans le PRESET, pas dans une variable d'environnement :
// --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que
// CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles
// qui s'étalaient sur les deux cartes malgré une sélection « GPU 1 ».
// La lecture/écriture des flags passe par eaGetValued/eaSetValued (le tokenizer
// d'EXTRA_ARGS déjà utilisé par les autres réglages) : un seul parseur.
// Diamètre de la pastille du curseur, en pixels — DOIT rester synchronisé avec
// .gpu-range::-webkit-slider-thumb / ::-moz-range-thumb dans styles.css.
const GPU_THUMB = 16;
let gpuDevices = [];
const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture
// Interroge le moteur de la machine (--list-devices) : les noms de device et
// leur ordre lui appartiennent, une liste issue de nvidia-smi désignerait la
// mauvaise carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la
// réponse n'est pas là, et le reste s'il y a moins de deux cartes : rien à
// arbitrer, et ça évite un encart qui surgit après coup.
// Chemin du moteur courant (config_bin de /api/llamacpp), mémorisé au
// préchauffage : l'éditeur s'en sert sans refaire l'appel.
let engineBin = '';
// Remplit le cache sans rien peindre (appelé au chargement de la page pour le
// moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt.
async function prefetchGpuDevices(bin){
if(bin) engineBin = bin;
if(!bin || gpuCache[bin]) return;
try{
const r = await jpost('/api/backends/devices', {bin});
if(r && r.ok) gpuCache[bin] = r.devices || [];
}catch(_){}
}
// Moteur courant : ce que le préchauffage a vu, sinon on le demande (éditeur
// ouvert avant que le statut llama.cpp ne soit arrivé).
async function currentEngineBin(){
if(engineBin) return engineBin;
try{ const lc = await jget('/api/llamacpp'); engineBin = lc.config_bin || ''; }catch(_){}
return engineBin;
}
async function loadGpuDevices(){
const group = document.getElementById('m-gpu-group');
const bin = await currentEngineBin();
if(!bin){ group.hidden = true; gpuDevices = []; return; }
if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; }
let r = {};
try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; }
// Le moteur a pu changer pendant l'appel (mise à jour en cours) : on ne peint
// que si la réponse concerne toujours le moteur courant.
if(engineBin !== bin) return;
gpuDevices = (r.ok && r.devices) ? r.devices : [];
if(r.ok) gpuCache[bin] = gpuDevices;
renderGpu();
}
function selectedGpuIds(){
const sel = eaGetValued('--device').split(',').filter(Boolean);
const known = gpuDevices.map(d => d.id);
const kept = sel.filter(id => known.includes(id));
return kept.length ? kept : known; // aucune contrainte = toutes les cartes
}
function gpuEsc(t){
return String(t).replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c]));
}
function renderGpu(){
const group = document.getElementById('m-gpu-group');
// Une seule carte (ou aucune) : rien à répartir, rien à choisir.
if(gpuDevices.length < 2){ group.hidden = true; return; }
const sel = selectedGpuIds();
let html = gpuDevices.map(d => {
// Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le
// modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge.
const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : '';
return '
';
}).join('');
if(sel.length > 1) html += splitHtml(sel);
document.getElementById('m-gpu-list').innerHTML = html;
document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length
? 'Toutes les cartes sont utilisées.'
: 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.';
group.hidden = false;
if(sel.length > 1) paintSplit();
}
function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; }
// Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1
// ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui
// veulent savoir ce qui part dans --device.
function gpuLabel(d){
return String((d && d.name) || (d && d.id) || '')
.replace(/^NVIDIA\s+GeForce\s+/i, '')
.replace(/^NVIDIA\s+/i, '')
.replace(/^AMD\s+(Radeon\s+)?/i, '')
.replace(/^Intel\(R\)\s+/i, '')
.trim() || (d && d.id) || '';
}
// Répartition : UNE seule barre, sur laquelle on glisse directement (le curseur
// est transparent, posé par-dessus). Trois cartes ou plus : un pourcentage par
// carte. On n'écrit --tensor-split QUE si l'utilisateur y touche : sans lui,
// llama.cpp répartit tout seul.
function splitHtml(sel){
const shares = currentShares(sel);
let inner;
if(sel.length === 2){
inner = '
'
+ ''
+ '
';
} else {
inner = ''
+ '
' + sel.map((id, i) =>
'').join('') + '
';
}
return '
'
+ 'Répartitionpart des couches par carte'
+ '
'
+ '
' + inner
+ '
';
}
// Parts actuelles, normalisées à 1. Sans --tensor-split : proportionnelles à la
// VRAM de chaque carte, ce que llama.cpp fait de son côté — la barre montre donc
// ce qui se passe réellement plutôt qu'un partage égal trompeur.
function currentShares(sel){
const raw = eaGetValued('--tensor-split').split(',').map(parseFloat).filter(n => !isNaN(n));
let vals = (raw.length === sel.length) ? raw
: sel.map(id => gpuById(id).total_mib || 0);
// Mémoires inconnues (lecture à 0) : à défaut de proportion crédible, on
// montre un partage égal plutôt qu'une barre pleine sur une seule carte.
if(vals.every(v => !v)) vals = sel.map(() => 1);
const sum = vals.reduce((a, b) => a + b, 0) || 1;
return vals.map(v => v/sum);
}
// Repeint UNIQUEMENT la barre, la légende et l'état. Surtout PAS le bloc entier :
// remplacer le HTML pendant un glissement détruit le curseur en cours d'usage —
// c'est ce qui faisait qu'il ne répondait qu'au clic.
function paintSplit(){
const sel = selectedGpuIds();
if(sel.length < 2) return;
const shares = currentShares(sel);
const bar = document.getElementById('m-split-bar');
// La pastille du curseur ne parcourt PAS toute la largeur : elle va de
// GPU_THUMB/2 à largeur - GPU_THUMB/2, sinon elle déborderait de la piste. Une
// coupure à « s % » pile tombait donc à côté d'elle (jusqu'à ~5 px d'écart aux
// extrêmes), ce qui laissait voir la piste claire juste à droite de la
// pastille. On coupe dans le repère de la pastille : s*(largeur - THUMB) + THUMB/2.
// Le dernier segment prend le reste (flex:1) : aucun filet résiduel à droite,
// quels que soient les arrondis.
const withThumb = sel.length === 2 && !!document.getElementById('m-split-range');
if(bar) bar.innerHTML = shares.map((s, i) => {
const cls = i%2 ? 'alt' : '';
if(i === shares.length - 1) return '';
const w = withThumb
? 'calc(' + (s*100).toFixed(3) + '% - ' + ((s - 0.5) * GPU_THUMB).toFixed(2) + 'px)'
: (s*100).toFixed(3) + '%';
return '';
}).join('');
const leg = document.getElementById('m-split-legend');
if(leg) leg.innerHTML = sel.map((id, i) =>
'' + gpuEsc(gpuLabel(gpuById(id)))
+ ' ' + (shares[i]*100).toFixed(1).replace('.', ',') + ' %').join('');
const st = document.getElementById('m-split-state');
if(st) st.innerHTML = eaGetValued('--tensor-split')
? ''
: 'automatique';
}
function writeSplit(shares){
const sum = shares.reduce((a, b) => a + b, 0) || 1;
eaSetValued('--tensor-split', shares.map(s => (s/sum).toFixed(3)).join(','));
paintSplit();
}
function onSplitRange(){
const v = parseFloat(document.getElementById('m-split-range').value) / 100;
writeSplit([v, 1-v]);
}
function onSplitNumbers(){
const vals = [...document.querySelectorAll('.gpu-share input')].map(i => parseFloat(i.value) || 0);
if(vals.reduce((a, b) => a + b, 0) <= 0) return; // saisie en cours
writeSplit(vals);
}
function resetSplit(){
eaSetValued('--tensor-split', '');
const sel = selectedGpuIds();
const r = document.getElementById('m-split-range');
if(r && sel.length === 2) r.value = (currentShares(sel)[0]*100).toFixed(1);
const nums = [...document.querySelectorAll('.gpu-share input')];
if(nums.length){
const sh = currentShares(sel);
nums.forEach((inp, i) => { inp.value = (sh[i]*100).toFixed(1); });
}
paintSplit();
}
function onGpuPick(){
const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value);
if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; }
// Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer
// des noms de device qui changeraient avec le moteur.
eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(','));
// Une répartition écrite pour N cartes n'a plus de sens pour N-1.
if(ids.length < 2) eaSetValued('--tensor-split', '');
renderGpu();
}
// Read/write the QUANT= override line in the preset textarea.
function currentQuantInTextarea(){
const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=(.*)$/mi);
return m ? unquoteVal(m[1]) : '';
}
function applyQuant(){
const val = document.getElementById('m-quant').value.trim();
const ta = document.getElementById('m-content');
const re = /^\s*#?\s*QUANT\s*=.*$/mi;
if(val){
if(re.test(ta.value)) ta.value = ta.value.replace(re, 'QUANT="'+val+'"');
else ta.value = ta.value.replace(/\s*$/,'') + '\nQUANT="'+val+'"\n';
} else {
ta.value = ta.value.replace(re, '').replace(/\n{3,}/g,'\n\n');
}
}
// ---- Réglages simples : champs de formulaire <-> lignes du fichier .env -----
// Le fichier de config (textarea m-content) reste la source de vérité : chaque
// champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT.
function cfgReadKey(key){
return readEnvKey(document.getElementById('m-content').value, key);
}
function cfgWriteKey(key, val){
const ta = document.getElementById('m-content');
val = String(val==null?'':val).trim();
const reLine = new RegExp('^[ \\t]*'+key+'[ \\t]*=.*$','m');
if(val === ''){ // clé vidée → on retire la ligne
ta.value = ta.value.replace(new RegExp('^[ \\t]*'+key+'[ \\t]*=.*\\n?','m'),'').replace(/\n{3,}/g,'\n\n');
return;
}
const line = key+'='+(/\s/.test(val) ? '"'+val+'"' : val);
if(reLine.test(ta.value)) ta.value = ta.value.replace(reLine, line);
else ta.value = ta.value.replace(/\s*$/,'') + '\n'+line+'\n';
}
// EXTRA_ARGS : liste de drapeaux passés tels quels à llama-server. On les édite
// jeton par jeton pour les interrupteurs (mlock, flash-attn, n-cpu-moe…), sans
// toucher aux autres drapeaux (--jinja, --device…) qui restent en config brute.
function eaTokens(){ return cfgReadKey('EXTRA_ARGS').split(/\s+/).filter(Boolean); }
function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); }
function eaHasFlag(flag){ return eaTokens().includes(flag); }
function eaToggleFlag(flag, on){
const t = eaTokens().filter(x=>x!==flag);
if(on) t.push(flag);
eaSetTokens(t);
}
function eaGetValued(flag){
const t = eaTokens(), i = t.indexOf(flag);
return (i>=0 && i+1=0){ const hadVal = i+1{ const e=document.getElementById(id); if(e) e.value=v; };
set('s-ctx', cfgReadKey('CTX'));
set('s-ngl', cfgReadKey('NGL'));
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
// Échantillonnage : envoyé PAR REQUÊTE (applySampling côté serveur), donc pas
// besoin de redémarrer le moteur pour qu'un changement prenne effet.
set('s-temp', cfgReadKey('TEMP'));
set('s-topp', cfgReadKey('TOP_P'));
set('s-topk', cfgReadKey('TOP_K'));
set('s-minp', cfgReadKey('MIN_P'));
set('s-presp', cfgReadKey('PRESENCE_PENALTY'));
set('s-reppen', cfgReadKey('REPEAT_PENALTY'));
set('s-moe', eaGetValued('--n-cpu-moe'));
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
setSpecType(eaGetValued('--spec-type'));
const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; };
// Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur.
// « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle,
// laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement
// réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le
// sous-titre la dit. (Les presets créés ou modifiés depuis cette version
// écrivent toujours on ou off, la question ne se pose plus pour eux.)
const rz = cfgReadKey('REASONING');
chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz));
const rzSub = document.getElementById('s-reasoning-sub');
if(rzSub){
rzSub.textContent = rz ? 'réflexion étape par étape'
: 'réflexion étape par étape — non précisé : le modèle décide';
}
// Intensité : passée telle quelle au gabarit jinja du modèle via
// `reasoning_effort`. Seuls les gabarits qui la lisent (gpt-oss et apparentés)
// changent de comportement ; ailleurs elle est ignorée sans erreur — le
// sous-titre le dit pour ne pas laisser croire à un effet garanti.
const eff = document.getElementById('s-reasoning-effort');
if(eff){
eff.value = (cfgReadKey('REASONING_EFFORT')||'').toLowerCase();
const effSub = document.getElementById('s-reasoning-effort-sub');
if(effSub){
// Deux emplacements, deux portées : ici c'est le défaut ENREGISTRÉ avec le
// preset (donc rattaché au modèle) ; la liste de la barre de saisie le
// dévie pour la suite de la session, jusqu'au prochain preset appliqué.
effSub.textContent = eff.value
? 'défaut de ce preset — sans effet si le gabarit du modèle l\'ignore'
: 'défaut de ce preset — ajustable à la volée depuis la barre de saisie';
}
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mlock', eaHasFlag('--mlock'));
chk('s-nommap', eaHasFlag('--no-mmap'));
}
// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ----------------
// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée
// par des virgules ; une valeur composée (ou un type sorti après cette version
// de loki) ne correspondrait à aucune option et serait silencieusement effacée
// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de
// la perdre.
function setSpecType(v){
const sel = document.getElementById('s-spec');
if(!sel) return;
v = String(v || '').trim();
if(v && ![...sel.options].some(o => o.value === v)){
const opt = document.createElement('option');
opt.value = v; opt.textContent = v + ' (dans la configuration)';
sel.appendChild(opt);
}
sel.value = v;
syncSpecRow();
}
// Le nombre de jetons anticipés ne veut rien dire sans type : ligne masquée, et
// flag retiré pour ne pas laisser un réglage orphelin dans le preset.
function syncSpecRow(){
const sel = document.getElementById('s-spec');
const row = document.getElementById('s-spec-n-row');
if(!sel || !row) return;
const on = !!sel.value;
row.style.display = on ? '' : 'none';
return on;
}
function onSpecType(){
const v = document.getElementById('s-spec').value;
eaSetValued('--spec-type', v);
if(!v){
eaSetValued('--spec-draft-n-max', '');
document.getElementById('s-spec-n').value = '';
}
syncSpecRow();
}
// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset.
function toggleRaw(){
const b = document.getElementById('m-raw-body');
const c = document.getElementById('m-raw-caret');
const show = b.style.display === 'none';
b.style.display = show ? '' : 'none';
if(c) c.classList.toggle('open', show);
}
const openPreset = (id)=>openItem('preset', id);
const openMem = (n)=>openItem('mem', n);
// Fermer la modale n'annule PAS un téléchargement en cours (il vit côté
// serveur) : on arrête juste de l'interroger.
function closeModal(){ hideModal('modal'); document.getElementById('modal').classList.remove('loading'); stopDlPoll(); }
async function saveItem(){
const K = KINDS[editingKind];
const name = document.getElementById('m-name').value.trim();
// Interrupteur « Raisonnement » : on matérialise son état dans le preset AVANT
// de lire le contenu. onchange ne part que sur une interaction : un switch
// laissé sur off à la création n'écrivait donc jamais REASONING=, et la clé
// ABSENTE laisse le moteur raisonner malgré l'interrupteur affiché sur off
// (amont, issue #46). Décoché → REASONING=off explicite. Coché → on, sauf si
// une valeur active plus précise (auto/deepseek) est déjà là : on la garde.
if(editingKind==='preset'){
const rsw = document.getElementById('s-reasoning');
if(rsw){
const cur = cfgReadKey('REASONING');
const active = /^(on|1|true|auto|deepseek)$/i.test(cur);
if(!rsw.checked){ if(!/^off$/i.test(cur)) cfgWriteKey('REASONING', 'off'); }
else if(!active){ cfgWriteKey('REASONING', 'on'); }
}
}
const content = document.getElementById('m-content').value;
if(!name){ toast('nom requis'); return; }
// Presets: keyed by id (filename); duplicate display names are allowed.
// Skills: keyed by name, rename via `old`.
const payload = editingKind==='preset'
? {id: editingKey, name, content}
: {name, old: editingKey, content};
const r = await jpost(K.saveUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('enregistré'); closeModal(); K.reload();
}
async function delItem(){
if(!editingKey) return;
const K = KINDS[editingKind];
const name = document.getElementById('m-name').value.trim() || editingKey;
// Le choix « supprimer aussi le .gguf » est posé DANS la confirmation : il n'a
// de sens qu'au moment de supprimer, et il occupait le pied de l'éditeur en
// permanence. Décoché à chaque ouverture — jamais de modèle effacé parce que
// la case serait restée cochée d'une fois sur l'autre.
const msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?'
+ (editingKind==='preset' ? '\n\nLe fichier .gguf du modèle est conservé, sauf si vous cochez ci-dessous (irréversible).' : '');
const opts = {title:'Suppression', okText:'Supprimer', danger:true};
if(editingKind==='preset') opts.check = 'supprimer aussi le fichier .gguf';
if(!await askConfirm(msg, opts)) return;
const delModel = editingKind==='preset' && askChecked();
const payload = editingKind==='preset'
? {id: editingKey, deleteModel: delModel}
: {name: editingKey};
const r = await jpost(K.delUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
if(delModel){
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
else if(r.modelDeleted) toast('preset + modèle supprimés');
else toast('supprimé (aucun modèle référencé)');
} else { toast('supprimé'); }
closeModal(); K.reload();
}
// Download a .gguf from Hugging Face. Le téléchargement vit côté serveur : fermer
// la modale ne l'interrompt pas, on se rebranche dessus à la réouverture
// (attachDownload) et seul « Annuler » l'arrête vraiment.
let dlPoll = null, dlName = '';
function dlEls(){
return {
btn: document.getElementById('m-hf-btn'),
prog: document.getElementById('m-hf-progress'),
bar: document.getElementById('m-hf-bar'),
cancel: document.getElementById('m-hf-cancel'),
};
}
// Arrête le poll local sans toucher au téléchargement serveur.
function stopDlPoll(){ if(dlPoll){ clearInterval(dlPoll); dlPoll=null; } }
// Remet la zone de téléchargement à zéro (réouverture de la modale).
function resetDlUI(){
const e = dlEls();
stopDlPoll(); dlName='';
e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false;
}
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
// de choisir, et ça évite une ligne de plus dans une modale déjà chargée.
// Même source que « Dossiers de modèles » : ajouter un disque externe là-bas le
// rend aussitôt disponible ici.
let dlDirList = [];
async function populateDlDirs(){
const sel = document.getElementById('m-hf-dir');
if(!sel) return;
let d = {};
try{ d = await jget('/api/models/dirs'); }catch(_){ return; }
dlDirList = d.dirs || [];
const prev = sel.value;
sel.innerHTML = '';
for(const x of dlDirList){
const o = document.createElement('option');
o.value = x.path;
o.textContent = x.path + (x.free >= 0 ? ' — ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : '');
sel.append(o);
}
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
}
// File d'attente : installer un modèle avec son projecteur, c'est DEUX
// téléchargements, et le serveur n'en mène qu'un à la fois (une seule barre,
// un seul poll). On enchaîne donc à la fin de chacun plutôt que de les lancer
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
// vite et brouillent la progression affichée.
let dlQueue = [];
async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; }
return startDownloadURL(url);
}
async function startDownloadURL(url){
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
const e = dlEls();
e.btn.disabled = true;
e.prog.style.display = 'block';
e.prog.textContent = 'vérification de la taille et de l’espace disque…';
e.bar.style.display = 'block';
e.bar.className = 'pe-bar indet';
e.bar.firstElementChild.style.width = '';
// Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
const p = await jpost('/api/models/download/probe', {url, dir});
if(!p.ok || !p.enough){
e.prog.innerHTML = 'erreur : '+escHtml(p.error||'espace insuffisant')+'';
e.bar.style.display = 'none'; e.btn.disabled=false;
dlQueue = []; // le reste de la file dépendait de ce fichier : on n'enchaîne pas
await populateDlDirs();
return;
}
// p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas
// les 15 Go du fichier dont le lien a été collé.
const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : '';
e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '
+ (p.free_exact === false ? '~' : '') + fmtSize(p.free) + ' libres'
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = 'erreur : '+(r.error||'')+'';
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
}
watchDownload(r.filename);
}
// Annule le téléchargement en cours : le serveur coupe les connexions et
// supprime le fichier partiel.
async function cancelDownload(){
if(!dlName) return;
const e = dlEls();
e.cancel.disabled = true;
await jpost('/api/models/download/cancel', {filename: dlName});
// L'état « annulé » est confirmé par le prochain tick de poll.
}
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
function watchDownload(fname){
const e = dlEls();
dlName = fname;
stopDlPoll();
e.btn.disabled = true;
e.prog.style.display = 'block';
e.bar.style.display = 'block';
e.cancel.style.display = 'inline';
e.cancel.disabled = false;
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
const tick = async ()=>{
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>d.filename===fname);
if(!st) return;
if(st.canceled){
e.prog.textContent = 'téléchargement annulé — '+fname;
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
}
if(st.error){
e.prog.innerHTML = 'erreur : '+st.error+'';
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
}
if(st.finished){
e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')';
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
stop();
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
// a pu atterrir hors du dossier loki : l'option porte alors le chemin
// complet, pas le simple nom de fichier.
const pick = (id, cb)=>{
const s = document.getElementById(id);
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
if(o){ s.value = o.value; cb(); }
};
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
else pick('m-model', onPickModel);
// Suite de la file : c'est ici que le projecteur part, une fois le modèle
// arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
// est déjà sélectionné.
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
const pct = st.total>0 ? st.done*100/st.total : 0;
if(st.total>0){
e.bar.className = 'pe-bar';
e.bar.firstElementChild.style.width = Math.min(100, pct).toFixed(1)+'%';
} else {
e.bar.className = 'pe-bar indet';
}
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
let extra = '';
if(st.speed>0){
extra += ' — '+fmtSize(st.speed)+'/s';
if(st.total>0){
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
const m = Math.floor(eta/60), s = eta%60;
extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s');
}
}
// Une seule barre pour tout le modèle ; le compteur de tranches dit où on en
// est, sans quoi un téléchargement en 3 fichiers semble se figer par paliers.
const part = st.parts > 1 ? ' — fichier '+(st.part||1)+'/'+st.parts : '';
e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+part+' — '+fname;
};
dlPoll = setInterval(tick, 800);
tick();
}
// À l'ouverture de la modale : se rebrancher sur un téléchargement encore en
// cours côté serveur (modale fermée entre-temps, page rechargée, autre appareil).
async function attachDownload(){
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>!d.finished);
if(st) watchDownload(st.filename);
}
// --- Recherche Hugging Face -------------------------------------------
// Deux écrans : la liste des dépôts, puis les fichiers d'un dépôt. Tout est
// construit en DOM et JAMAIS en innerHTML — noms de dépôts et de fichiers
// viennent d'un tiers, c'est la même règle que pour les titres de discussions.
let hfRepoFiles = null; // dernier dépôt déplié, pour l'installation
function hfOut(){ return document.getElementById('hf-out'); }
function hfMsg(txt, err){
const o = hfOut(); if(!o) return;
o.textContent = '';
const d = document.createElement('div');
d.className = 'pe-note';
if(err) d.style.color = 'var(--err)';
d.textContent = txt;
o.appendChild(d);
}
// Remet la zone de recherche à zéro (réouverture de la modale).
function resetHfUI(){
hfRepoFiles = null;
const o = hfOut(); if(o) o.textContent = '';
const q = document.getElementById('hf-q'); if(q) q.value = '';
}
async function hfSearch(){
const q = (document.getElementById('hf-q').value || '').trim();
if(!q){ toast('tape un nom de modèle'); return; }
hfMsg('recherche…');
let r;
try{ r = await jget('/api/hf/search?q='+encodeURIComponent(q)); }
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'recherche impossible', true); return; }
if(!r.repos || !r.repos.length){ hfMsg('aucun dépôt GGUF pour « '+q+' »'); return; }
const o = hfOut(); o.textContent = '';
const list = document.createElement('div'); list.className = 'hf-list';
for(const rep of r.repos){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfPickRepo(rep.id);
const n = document.createElement('span'); n.className = 'hf-name'; n.textContent = rep.id;
const m = document.createElement('span'); m.className = 'hf-meta';
m.textContent = fmtCount(rep.downloads)+' ↓';
row.append(n, m);
// Dépôt verrouillé : il se LIT sans rien, mais chaque .gguf répond 401 au
// transfert. La pastille le dit dès la liste — sinon on choisit un quant,
// on lance 15 Go et l'erreur tombe à la fin de la sonde.
if(rep.gated){
const g = document.createElement('span');
g.className = 'hf-fit gated';
g.textContent = 'accès restreint';
g.title = hfGatedHint(r.hf_token);
row.appendChild(g);
}
list.appendChild(row);
}
o.appendChild(list);
}
// Le geste à faire n'est pas le même selon qu'un jeton est configuré ou non :
// sans jeton il en faut un, avec jeton c'est l'accès au dépôt qui manque.
function hfGatedHint(hasToken){
return hasToken
? 'Dépôt à accès restreint : le jeton enregistré sera utilisé. Si le téléchargement échoue en 401, accepte les conditions du dépôt sur huggingface.co avec le compte de ce jeton.'
: 'Dépôt à accès restreint : accepte ses conditions sur huggingface.co, puis enregistre un jeton dans « Jeton Hugging Face » plus bas. Sans jeton, le téléchargement échouera en 401.';
}
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
// « celui-là est le plus utilisé », pas son compte exact.
function fmtCount(n){
n = n || 0;
if(n >= 1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+' M';
if(n >= 1e3) return Math.round(n/1e3)+' k';
return String(n);
}
async function hfPickRepo(repo){
hfMsg('lecture du dépôt…');
let r;
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
hfRepoFiles = r;
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
const o = hfOut(); o.textContent = '';
const head = document.createElement('div');
head.className = 'hf-head';
const back = document.createElement('button');
back.className = 'pe-link'; back.textContent = '← résultats';
back.onclick = hfSearch;
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
head.append(back, title);
if(r.gated){
const g = document.createElement('span');
g.className = 'hf-fit gated'; g.textContent = 'accès restreint';
head.appendChild(g);
}
o.appendChild(head);
// Avertissement en toutes lettres : la pastille seule ne dit pas quoi faire,
// et c'est ici que le téléchargement se déclenche.
if(r.gated){
const warn = document.createElement('div');
warn.className = 'pe-note';
warn.style.color = r.hf_token ? 'var(--warn)' : 'var(--err)';
warn.textContent = hfGatedHint(r.hf_token);
o.appendChild(warn);
// Le réglage est plus bas dans la même fenêtre : y mener directement évite
// de le chercher, et c'est le seul geste qui débloque ce dépôt.
if(!r.hf_token){
const go = document.createElement('button');
go.className = 'pe-link'; go.textContent = 'Renseigner un jeton Hugging Face';
go.onclick = ()=>{
setHFTokenOpen(true);
const inp = document.getElementById('m-hft-input');
document.getElementById('m-hft-toggle').scrollIntoView({block:'center', behavior:'smooth'});
if(inp) inp.focus();
};
o.appendChild(go);
}
}
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
// encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
// dit — on ne va pas en chercher un.
let mmChk = null;
const mm = (r.projectors||[])[0] && hfBestProjector(r.projectors);
const note = document.createElement('label');
note.className = 'hf-mm';
if(mm){
mmChk = document.createElement('input');
mmChk.type = 'checkbox'; mmChk.checked = true;
const t = document.createElement('span');
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')';
note.append(mmChk, t);
} else {
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
note.classList.add('muted');
}
o.appendChild(note);
const list = document.createElement('div'); list.className = 'hf-list';
for(const m of r.models){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null);
const n = document.createElement('span'); n.className = 'hf-name';
n.textContent = m.quant || m.name;
const meta = document.createElement('span'); meta.className = 'hf-meta';
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
row.append(n, meta);
if(m.verdict){
const fit = document.createElement('span');
fit.className = 'hf-fit ' + m.verdict;
fit.textContent = m.verdict;
fit.title = m.why || '';
row.appendChild(fit);
}
list.appendChild(row);
}
o.appendChild(list);
if(r.hardware){
const hw = document.createElement('div');
hw.className = 'pe-note muted';
hw.textContent = r.hardware.vram_gb > 0
? 'Verdict calculé sur '+r.hardware.vram_gb.toFixed(1)+' Go de VRAM et un contexte de '+r.ctx+' jetons (estimation).'
: 'Aucun GPU détecté : verdict calculé sur '+r.hardware.ram_gb.toFixed(1)+' Go de RAM (estimation).';
o.appendChild(hw);
}
}
// Q8_0 d'abord (629 Mo contre 931 pour le BF16, sans différence perceptible sur
// un encodeur d'images), sinon le plus léger. Même règle que hfPickProjector
// côté serveur — l'UI choisit pour ne pas imposer un menu de plus.
function hfBestProjector(list){
return list.find(p => /^Q8_0$/i.test(p.quant || '')) || list.slice().sort((a,b)=>a.size-b.size)[0];
}
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
// champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){
if(model.verdict === 'trop' && !await askConfirm(
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : [];
document.getElementById('m-hf-url').value = model.url;
await startDownloadURL(model.url);
}
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
// up. Re-stick when they scroll back near bottom themselves.