mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Deux cartes inégales (5060 Ti + 3060) se placent par deux règles du moteur qui peuvent s'opposer : --fit remplit d'abord la DERNIÈRE carte, qui porte la couche de sortie, et les experts MoE en RAM sont recopiés au prefill vers la PREMIÈRE. Pour choisir l'ordre il faut voir la liaison de chaque carte ; l'éditeur n'en montrait rien. - detectGPUs lit aussi la liaison PCIe (génération et largeur, actuelles et maximales), le bus et l'horloge mémoire max, dans la même invocation de nvidia-smi. « [N/A] » vaut zéro ; un pilote qui refuse un champ fait retomber sur la requête historique (jamais de GPU perdu), un délai dépassé n'est pas relancé. Borné à 10 s. La largeur du bus mémoire n'existe pas dans nvidia-smi : absente. - /api/backends/devices : une seule lecture nvidia-smi par énumération complète mémoire manquante ET liaison (annotateDevices, pure), par nom de carte, CUDA seulement, rien pour deux cartes homonymes. La lecture des jauges (gpuStatsCached) n'est pas touchée. - Éditeur : liaison max par carte (l'actuelle et l'horloge en info-bulle), guide de placement dans l'ordre du moteur (--device compris), marges FIT_TARGET carte par carte, signalement d'un --tensor-split ou d'un CUDA_VISIBLE_DEVICES propre au preset, lien « inverser l'ordre » ; l'ordre choisi survit aux cases cochées. - Pas de bouton de mesure : comparer deux ordres demande deux rechargements et un ordre inversé peut manquer de VRAM — le guide explique la marche à suivre (copie du preset, bench complet sur chacun). - loki gpu affiche la liaison. Rien ne change dans la ligne de commande du moteur. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1788 lines
87 KiB
JavaScript
1788 lines
87 KiB
JavaScript
function openBenchModal(){ showModal('bench-modal'); }
|
||
function closeBenchModal(){ hideModal('bench-modal'); }
|
||
// Le bench tourne en tâche de fond côté serveur (llm_bench_job.go) : on le lance,
|
||
// puis on suit sa progression. Fermer la fenêtre ne l'arrête pas ; la rouvrir
|
||
// (bouton bench) reprend le suivi au lieu d'en lancer un second.
|
||
let benchPoll=null;
|
||
function benchButtons(busy){
|
||
const btn=document.getElementById('btn-bench');
|
||
btn.disabled=busy; btn.textContent=busy?'⏳ bench…':'bench';
|
||
for(const id of ['bench-rerun','bench-full']){ const b=document.getElementById(id); if(b) b.disabled=busy; }
|
||
const c=document.getElementById('bench-cancel'); if(c) c.style.display=busy?'':'none';
|
||
}
|
||
function benchSpinner(text){
|
||
return '<div style="text-align:center;padding:20px 0">' +
|
||
'<div style="font-size:24px;animation:spin 1s linear infinite;display:inline-block">⏳</div>' +
|
||
'<div class="muted" style="margin-top:8px">'+text+'</div></div>';
|
||
}
|
||
function benchErr(msg){ return '<div style="color:var(--err);text-align:center">erreur : '+escHtml(msg||'?')+'</div>'; }
|
||
async function runBenchUI(mode){
|
||
mode = mode==='full' ? 'full' : 'quick';
|
||
const body=document.getElementById('bench-body');
|
||
openBenchModal();
|
||
benchButtons(true);
|
||
body.innerHTML=benchSpinner('démarrage…');
|
||
try{
|
||
const st=await jget('/api/bench/status');
|
||
if(st.running){ benchWatch(); return; }
|
||
}catch(e){}
|
||
let r;
|
||
try{ r=await jpost('/api/bench',{mode:mode}); }catch(e){ r={ok:false,error:e.message}; }
|
||
if(!r.ok){ body.innerHTML=benchErr(r.error); benchButtons(false); return; }
|
||
benchWatch();
|
||
}
|
||
async function cancelBenchUI(){ try{ await jpost('/api/bench/cancel',{}); }catch(e){} }
|
||
function benchWatch(){
|
||
clearTimeout(benchPoll);
|
||
const body=document.getElementById('bench-body');
|
||
const tick=async()=>{
|
||
let st;
|
||
try{ st=await jget('/api/bench/status'); }
|
||
catch(e){ benchPoll=setTimeout(tick,2000); return; }
|
||
if(st.running){
|
||
const step=st.steps?' ('+st.step+'/'+st.steps+')':'';
|
||
const hint=st.mode==='full'?'<br>bench complet : 1 à 5 minutes, chat et tâches en pause':'';
|
||
body.innerHTML=benchSpinner(escHtml(st.phase||'…')+step+' · '+Math.round(st.elapsed_sec||0)+' s'+hint);
|
||
benchPoll=setTimeout(tick,1000);
|
||
return;
|
||
}
|
||
benchButtons(false);
|
||
if(st.error) body.innerHTML=benchErr(st.error);
|
||
else if(st.result) body.innerHTML=benchResultHTML(st.result, st.saved);
|
||
else body.innerHTML='';
|
||
loadPresets();
|
||
};
|
||
tick();
|
||
}
|
||
function benchDraftTxt(d){ return d&&d.n ? Math.round(d.accepted*100/d.n)+' % ('+d.accepted+'/'+d.n+')' : 'n/a'; }
|
||
function benchCard(label, color, val, unit, sub){
|
||
return '<div style="padding:14px;background:var(--panel);border:1px solid var(--border);border-radius:8px">' +
|
||
'<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.1em">'+label+'</div>' +
|
||
'<div style="font-size:26px;color:'+color+';font-weight:600;margin:6px 0">'+val+'</div>' +
|
||
'<div class="muted">'+unit+'</div>' +
|
||
'<div class="muted" style="font-size:11px;margin-top:8px">'+sub+'</div></div>';
|
||
}
|
||
function benchResultHTML(x, saved){
|
||
let h='<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;text-align:center">' +
|
||
benchCard('Prefill','var(--accent)',x.prompt_per_second.toFixed(0),'tok/s',x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s') +
|
||
benchCard('Decode','var(--ok)',x.predicted_per_second.toFixed(1),'tok/s',x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s') +
|
||
'</div>';
|
||
const rows=[];
|
||
if(x.draft) rows.push(['brouillon accepté (prose)', benchDraftTxt(x.draft)]);
|
||
const d=x.depth;
|
||
const notes=[];
|
||
if(d){
|
||
if(d.skipped) notes.push('profondeur sautée : '+d.skipped);
|
||
if(d.cold) rows.push(['prefill à froid · '+d.cold.new+' tok', d.cold.prompt_per_second.toFixed(0)+' tok/s']);
|
||
if(d.turns&&d.turns.length){
|
||
rows.push(['prefill des tours suivants (cache)', (d.cached_per_second||0).toFixed(0)+' tok/s']);
|
||
rows.push(['decode en profondeur', (d.decode_per_second||0).toFixed(1)+' tok/s']);
|
||
rows.push(['reprise du cache', d.reuse>=0 ? Math.round(d.reuse*100)+' %' : 'n/a']);
|
||
rows.push(['brouillon accepté (code)', benchDraftTxt(d.draft)]);
|
||
}
|
||
for(const n of [d.reuse_note, d.partial, d.hint]) if(n) notes.push(n);
|
||
}
|
||
if(rows.length){
|
||
h+='<table style="width:100%;font-size:12px;border-collapse:collapse">';
|
||
for(const r of rows) h+='<tr><td class="muted" style="padding:3px 0">'+escHtml(r[0])+'</td><td style="text-align:right">'+escHtml(r[1])+'</td></tr>';
|
||
h+='</table>';
|
||
}
|
||
for(const n of notes) h+='<div style="font-size:11px;color:var(--warn)">'+escHtml(n)+'</div>';
|
||
let foot='KV '+(x.kv||'?')+(x.kv_quantized?' (quantifié — zone grise)':'');
|
||
if(x.engine) foot+=' · moteur '+x.engine;
|
||
foot+=' · total '+x.elapsed_sec.toFixed(1)+'s';
|
||
if(saved===false) foot+=' · résultat partiel, non enregistré';
|
||
h+='<div class="muted" style="text-align:center;font-size:11px">'+escHtml(foot)+'</div>';
|
||
if(d&&d.cold) h+='<div class="muted" style="text-align:center;font-size:11px">Le slot est effacé après le bench quand le moteur le permet : sinon, le prochain message reprend la conversation depuis le cache RAM (CACHE_RAM) ou la recalcule.</div>';
|
||
return h;
|
||
}
|
||
async function switchTo(n,name,id){
|
||
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();
|
||
// id d'abord : le numéro seul peut viser un autre preset si la liste a bougé.
|
||
const r=await jpost('/api/switch',id?{id:id}:{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);
|
||
// Modèles installés : replié aussi, mais son RÉSUMÉ (« 4 modèles · 87 Go »)
|
||
// est lu tout de suite — c'est lui qui s'affiche sur la ligne repliée.
|
||
setInstalledOpen(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(), populateDraft(),
|
||
populateDlDirs(), refreshInstalled()]);
|
||
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(); }
|
||
// Retrouve dans `list` (/api/models) l'entrée que désigne la valeur MODEL= d'un
|
||
// preset. Trois écritures possibles et toutes légitimes : le chemin complet, la
|
||
// valeur produite par le sélecteur, ou le SIMPLE NOM DE FICHIER (config écrite à
|
||
// la main, preset repris d'une autre machine, ancien preset). Le nom simple se
|
||
// résout comme le fait le moteur — dossier de loki d'abord, puis les autres.
|
||
//
|
||
// Sans ce dernier cas, un MODEL=modele.gguf parfaitement valide s'affichait
|
||
// « introuvable ; ajoute son dossier ci-dessous » alors que le fichier était là,
|
||
// et le sélecteur ne proposait rien : impossible de refaire un preset sur un
|
||
// modèle déjà présent sans le retélécharger.
|
||
function matchModel(cur, list){
|
||
if(!cur) return null;
|
||
const all = list || [];
|
||
const hit = all.find(x => samePath(cur, x.value) || samePath(cur, x.path));
|
||
if(hit) return hit;
|
||
const b = baseName(cur);
|
||
return all.find(x => x.home && samePath(b, x.name)) || all.find(x => samePath(b, x.name)) || null;
|
||
}
|
||
async function populateModelPicker(){
|
||
const sel = document.getElementById('m-model');
|
||
const list = await jget('/api/models');
|
||
const cur = currentModelInTextarea();
|
||
const hit = matchModel(cur, list);
|
||
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
|
||
// regroupe par dossier, celui de loki 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 = '<option value="">— choisir un modèle —</option>';
|
||
let matched = false;
|
||
for(const g of groups){
|
||
let opts = '';
|
||
for(const m of g.items){
|
||
const on = (m === hit) ? ' 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 += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+tag+')</option>';
|
||
}
|
||
html += groups.length > 1
|
||
? '<optgroup label="'+escHtml(g.home ? 'dossier loki' : g.dir)+'">'+opts+'</optgroup>'
|
||
: 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 += '<option value="" disabled selected>('+escHtml(cur)+' — introuvable ; ajoute son dossier ci-dessous)</option>';
|
||
}
|
||
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()]);
|
||
}
|
||
|
||
// ---- Modèles installés : ce qu'il y a sur le disque, et comment l'effacer ---
|
||
// La route /api/models/delete existait depuis longtemps ; AUCUN bouton ne
|
||
// l'appelait. Le seul moment où un .gguf pouvait disparaître, c'était en cochant
|
||
// « supprimer aussi le fichier » à la suppression de son preset — la case pas
|
||
// cochée, le fichier devenait inatteignable : 40 Go sur le disque que plus rien
|
||
// dans l'interface ne savait ni nommer ni reprendre. D'où cette liste.
|
||
//
|
||
// Elle dit aussi QUI se sert de chaque fichier : le modèle en service ne
|
||
// s'efface pas (le moteur l'a ouvert), et un modèle nommé par d'autres presets
|
||
// prévient avant, en les nommant.
|
||
let installedList = [];
|
||
function setInstalledOpen(open){
|
||
const b = document.getElementById('m-inst-body');
|
||
const c = document.getElementById('m-inst-caret');
|
||
if(!b) return;
|
||
b.style.display = open ? '' : 'none';
|
||
if(c) c.classList.toggle('open', open);
|
||
// Relu à chaque dépliage : un modèle a pu arriver (téléchargement) ou partir
|
||
// depuis l'ouverture de la fenêtre, et une liste périmée ferait cliquer
|
||
// « supprimer » sur un fichier qui n'est plus là.
|
||
if(open) refreshInstalled();
|
||
}
|
||
function toggleInstalled(){
|
||
setInstalledOpen(document.getElementById('m-inst-body').style.display === 'none');
|
||
}
|
||
// Recharge la liste ET le résumé de la ligne repliée (« 4 modèles · 87 Go »),
|
||
// qui reste juste même quand la section n'est pas dépliée.
|
||
async function refreshInstalled(){
|
||
try{ installedList = await jget('/api/models') || []; }catch(_){ return; }
|
||
const st = document.getElementById('m-inst-state');
|
||
if(st){
|
||
const total = installedList.reduce((a, m) => a + (m.size||0), 0);
|
||
st.textContent = installedList.length
|
||
? installedList.length + ' modèle' + (installedList.length>1?'s':'') + ' · ' + fmtSize(total)
|
||
: 'aucun';
|
||
}
|
||
const b = document.getElementById('m-inst-body');
|
||
if(b && b.style.display !== 'none') renderInstalled();
|
||
}
|
||
// Construit en DOM et jamais en innerHTML : ces noms de fichiers viennent du
|
||
// disque de l'utilisateur, pas de nous.
|
||
function renderInstalled(){
|
||
const box = document.getElementById('m-inst-list');
|
||
if(!box) return;
|
||
box.textContent = '';
|
||
if(!installedList.length){
|
||
const d = document.createElement('div');
|
||
d.className = 'pe-note muted';
|
||
d.textContent = 'Aucun .gguf dans les dossiers déclarés.';
|
||
box.append(d);
|
||
return;
|
||
}
|
||
const list = document.createElement('div');
|
||
list.className = 'mdl-list';
|
||
for(const m of installedList){
|
||
const row = document.createElement('div');
|
||
row.className = 'mdl-row';
|
||
const n = document.createElement('span');
|
||
n.className = 'mdl-name';
|
||
n.textContent = m.name;
|
||
n.title = m.path;
|
||
const meta = document.createElement('span');
|
||
meta.className = 'mdl-meta';
|
||
meta.textContent = fmtSize(m.size||0)
|
||
+ (m.shards > 1 ? ' · ' + m.shards + ' fichiers' : '')
|
||
+ (m.home ? '' : ' · ' + m.dir);
|
||
row.append(n, meta);
|
||
// Ce qui EMPÊCHE ou ce qui COÛTE : le modèle en service ne s'efface pas, et
|
||
// celui que des presets nomment se signale avant le clic, pas après.
|
||
if(m.active){
|
||
const b = document.createElement('span');
|
||
b.className = 'mdl-tag on';
|
||
b.textContent = 'en service';
|
||
b.title = 'Ce modèle est celui de la configuration en cours : bascule sur un autre preset avant de l’effacer.';
|
||
row.append(b);
|
||
} else if((m.used||[]).length){
|
||
const b = document.createElement('span');
|
||
b.className = 'mdl-tag';
|
||
b.textContent = m.used.length + ' preset' + (m.used.length>1?'s':'');
|
||
b.title = 'Utilisé par : ' + m.used.join(', ');
|
||
row.append(b);
|
||
}
|
||
if((m.missing||[]).length){
|
||
const b = document.createElement('span');
|
||
b.className = 'mdl-tag warn';
|
||
b.textContent = m.missing.length + ' tranche' + (m.missing.length>1?'s':'') + ' manquante' + (m.missing.length>1?'s':'');
|
||
b.title = 'Téléchargement incomplet : ' + m.missing.join(', ');
|
||
row.append(b);
|
||
}
|
||
const del = document.createElement('button');
|
||
del.className = 'pe-link';
|
||
del.textContent = 'supprimer';
|
||
del.disabled = !!m.active;
|
||
del.onclick = () => deleteInstalled(m);
|
||
row.append(del);
|
||
list.append(row);
|
||
}
|
||
box.append(list);
|
||
}
|
||
async function deleteInstalled(m){
|
||
if(m.active){
|
||
toast('modèle en service — bascule sur un autre preset d’abord');
|
||
return;
|
||
}
|
||
const known = m.used || [];
|
||
const why = known.length
|
||
? '\n\nIl est référencé par : ' + known.join(', ') + '. Ces presets ne démarreront plus tant qu’un autre modèle n’y sera pas choisi.'
|
||
: '';
|
||
const what = (m.shards > 1 ? ' et ses ' + m.shards + ' fichiers' : '');
|
||
if(!await askConfirm('Effacer « ' + m.name + ' » (' + fmtSize(m.size||0) + ')' + what + ' du disque ?' + why
|
||
+ '\n\nIrréversible : il faudra le retélécharger.',
|
||
{title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
|
||
// force dès le premier envoi quand les presets concernés étaient DÉJÀ dans la
|
||
// confirmation : redemander la même chose une seconde fois ne protège de rien.
|
||
let r = await jpost('/api/models/delete', {name: m.path, force: known.length > 0});
|
||
// Le serveur connaît des références que la liste affichée ignorait (preset créé
|
||
// entre-temps, depuis un autre onglet) : là, la question se pose vraiment.
|
||
if(!r.ok && (r.used||[]).length){
|
||
if(!await askConfirm('Ce modèle est référencé par : ' + r.used.join(', ') + '.',
|
||
{title:'Supprimer quand même ?', okText:'Supprimer', danger:true})) return;
|
||
r = await jpost('/api/models/delete', {name: m.path, force: true});
|
||
}
|
||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||
toast('modèle supprimé — ' + fmtSize(r.freed||0) + ' libérés');
|
||
await Promise.all([populateModelPicker(), populateMmproj(), populateDraft(), populateDlDirs(),
|
||
populateModelDirs(), refreshInstalled()]);
|
||
}
|
||
// Un .gguf donné est-il déjà sur le disque, complet ? Sert avant de proposer un
|
||
// téléchargement (recherche Hugging Face).
|
||
async function modelIsOnDisk(name){
|
||
let list = [];
|
||
try{ list = await jget('/api/models') || []; }catch(_){ return false; }
|
||
return !!list.find(m => samePath(m.name, baseName(name)) && !(m.missing||[]).length);
|
||
}
|
||
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));
|
||
const hit = matchModel(cur, items);
|
||
let html = '<option value="">— aucune —</option>';
|
||
let matched = false;
|
||
for(const m of items){
|
||
const on = (m === hit) ? ' selected' : '';
|
||
if(on) matched = true;
|
||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||
}
|
||
// 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 += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
|
||
}
|
||
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' : '';
|
||
const link = gpuLinkText(d);
|
||
return '<div class="pe-row">'
|
||
+ '<span class="pe-row-l">' + gpuEsc(gpuLabel(d))
|
||
+ '<span class="pe-sub" title="' + gpuEsc(gpuLinkTip(d)) + '">' + gpuEsc(d.id) + gb
|
||
+ (link ? ' · ' + gpuEsc(link) : '') + '</span></span>'
|
||
+ '<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" value="' + gpuEsc(d.id) + '"'
|
||
+ (sel.includes(d.id) ? ' checked' : '') + ' onchange="onGpuPick()"><span class="pe-sl"></span></label></span>'
|
||
+ '</div>';
|
||
}).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(', ') + '.';
|
||
const guide = document.getElementById('m-gpu-guide');
|
||
if(guide){ guide.innerHTML = sel.length > 1 ? placementGuideHtml(sel) : ''; guide.hidden = sel.length < 2; }
|
||
group.hidden = false;
|
||
if(sel.length > 1) paintSplit();
|
||
}
|
||
|
||
// Liaison PCIe d'une carte (nvidia-smi, joint par nom côté serveur, CUDA
|
||
// seulement) : le MAXIMUM, seul parlant — l'actuel descend au repos.
|
||
function gpuLinkText(d){
|
||
const l = d && d.link;
|
||
if(!l || !(l.gen_max || l.width_max)) return '';
|
||
return 'PCIe' + (l.gen_max ? ' ' + l.gen_max : '') + (l.width_max ? ' ×' + l.width_max : '');
|
||
}
|
||
function gpuLinkTip(d){
|
||
const l = d && d.link;
|
||
if(!l) return '';
|
||
const t = [];
|
||
if(l.gen || l.width) t.push('liaison actuelle : PCIe ' + (l.gen||'?') + ' ×' + (l.width||'?') + ' (réduite au repos, normale sous charge)');
|
||
if(l.mem_clock_mhz) t.push('horloge mémoire max : ' + l.mem_clock_mhz + ' MHz');
|
||
return t.join('\n');
|
||
}
|
||
|
||
// Guide de placement entre cartes inégales. CONSEIL seulement : rien n'est
|
||
// écrit dans le preset sans un clic, et Loki ne change jamais l'ordre ni les
|
||
// marges d'office. Deux règles du moteur, qui peuvent s'opposer :
|
||
// - --fit (placement auto) remplit les cartes en partant de la DERNIÈRE, qui
|
||
// porte aussi la couche de sortie : un modèle dense décode plus vite si la
|
||
// carte rapide est en dernier, ou si la lente garde une marge plus large ;
|
||
// - les experts MoE restés en RAM sont recopiés, au prefill, vers la PREMIÈRE
|
||
// carte : à elle la liaison PCIe la plus large.
|
||
// « Première » et « dernière » s'entendent dans l'ordre du moteur, --device
|
||
// compris — pas « CUDA0 ».
|
||
function placementGuideHtml(sel){
|
||
const name = id => gpuEsc(gpuLabel(gpuById(id)));
|
||
const order = sel.map((id, i) => (i+1) + '. ' + name(id)
|
||
+ (gpuLinkText(gpuById(id)) ? ' (' + gpuEsc(gpuLinkText(gpuById(id))) + ')' : '')).join(' → ');
|
||
const parts = ['<b>Ordre vu par le moteur</b> : ' + order
|
||
+ (sel.length === 2 ? ' · <button class="pe-link" onclick="reverseGpuOrder()">inverser l\'ordre</button>' : '')];
|
||
parts.push('• Modèle dense en placement auto : --fit remplit d\'abord la <b>dernière</b> carte, qui porte aussi '
|
||
+ 'la couche de sortie — la plus rapide en dernier, ou une marge FIT_TARGET plus large sur la lente '
|
||
+ '(ex. FIT_TARGET=1024,3072 quand la lente est seconde ; 1024 au minimum).');
|
||
parts.push('• Experts MoE sur CPU : le prefill les recopie vers la <b>première</b> carte — à elle la liaison PCIe la plus large.');
|
||
const ft = cfgReadKey('FIT_TARGET');
|
||
if(ft){
|
||
const v = ft.split(/[,/]/).map(s => s.trim());
|
||
parts.push('FIT_TARGET : ' + (v.length === 1 ? gpuEsc(v[0]) + ' Mio sur chaque carte'
|
||
: sel.map((id, i) => name(id) + ' ' + gpuEsc(v[i] || '1024') + ' Mio').join(' · ')));
|
||
}
|
||
if(eaGetValued('--tensor-split'))
|
||
parts.push('--tensor-split fixe la répartition : --fit et FIT_TARGET ne jouent pas.');
|
||
const cvd = cfgReadKey('CUDA_VISIBLE_DEVICES');
|
||
if(cvd) parts.push('CUDA_VISIBLE_DEVICES=' + gpuEsc(cvd) + ' dans ce preset : l\'ordre réel peut différer de cette '
|
||
+ 'liste, établie avec la sélection de la machine — et les marges FIT_TARGET suivent l\'ordre réel.');
|
||
parts.push('Les deux règles peuvent s\'opposer : à départager par la mesure, preset par preset. Loki ne bascule pas '
|
||
+ 'le moteur tout seul pour comparer (deux rechargements complets, et un ordre inversé peut manquer de VRAM sur la '
|
||
+ 'petite carte) : duplique le preset, inverse l\'ordre dans la copie, puis « bench complet » sur chacun. '
|
||
+ 'Vérifie « offloaded N/N layers » au journal.');
|
||
return parts.join('<br>');
|
||
}
|
||
|
||
// Inverse l'ordre des deux cartes (--device), et la répartition avec elles.
|
||
// Geste explicite de l'utilisateur, écrit dans le preset édité seulement.
|
||
function reverseGpuOrder(){
|
||
const sel = selectedGpuIds();
|
||
if(sel.length !== 2) return;
|
||
const rev = sel.slice().reverse();
|
||
eaSetValued('--device', gpuNaturalOrder(rev) ? '' : rev.join(','));
|
||
const ts = eaGetValued('--tensor-split');
|
||
if(ts) eaSetValued('--tensor-split', ts.split(',').reverse().join(','));
|
||
renderGpu();
|
||
toast('ordre : ' + rev.map(id => gpuLabel(gpuById(id))).join(' → '));
|
||
}
|
||
// Toutes les cartes, dans l'ordre du moteur : aucune contrainte à écrire.
|
||
function gpuNaturalOrder(ids){
|
||
return ids.length === gpuDevices.length && ids.every((id, i) => id === gpuDevices[i].id);
|
||
}
|
||
|
||
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 = '<div class="gpu-split">'
|
||
+ '<div class="gpu-bar" id="m-split-bar"></div>'
|
||
+ '<input type="range" class="gpu-range" id="m-split-range" min="0" max="100" step="0.5"'
|
||
+ ' value="' + (shares[0]*100).toFixed(1) + '" oninput="onSplitRange()"'
|
||
+ ' aria-label="part de ' + gpuEsc(gpuLabel(gpuById(sel[0]))) + '"></div>';
|
||
} else {
|
||
inner = '<div class="gpu-bar" id="m-split-bar"></div>'
|
||
+ '<div class="gpu-shares">' + sel.map((id, i) =>
|
||
'<label class="gpu-share"><span>' + gpuEsc(gpuLabel(gpuById(id))) + '</span>'
|
||
+ '<input type="number" min="0" max="100" step="0.5" value="' + (shares[i]*100).toFixed(1) + '"'
|
||
+ ' oninput="onSplitNumbers()"><span class="pe-unit">%</span></label>').join('') + '</div>';
|
||
}
|
||
return '<div class="pe-row">'
|
||
+ '<span class="pe-row-l">Répartition<span class="pe-sub">part des couches par carte</span></span>'
|
||
+ '<span class="pe-row-c" id="m-split-state"></span></div>'
|
||
+ '<div class="pe-row stack">' + inner
|
||
+ '<div class="gpu-legend" id="m-split-legend"></div></div>';
|
||
}
|
||
|
||
// 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 '<span class="' + cls + '" style="flex:1"></span>';
|
||
const w = withThumb
|
||
? 'calc(' + (s*100).toFixed(3) + '% - ' + ((s - 0.5) * GPU_THUMB).toFixed(2) + 'px)'
|
||
: (s*100).toFixed(3) + '%';
|
||
return '<span class="' + cls + '" style="width:' + w + '"></span>';
|
||
}).join('');
|
||
const leg = document.getElementById('m-split-legend');
|
||
if(leg) leg.innerHTML = sel.map((id, i) =>
|
||
'<span><i class="' + (i%2 ? 'alt' : '') + '"></i>' + gpuEsc(gpuLabel(gpuById(id)))
|
||
+ ' ' + (shares[i]*100).toFixed(1).replace('.', ',') + ' %</span>').join('');
|
||
const st = document.getElementById('m-split-state');
|
||
if(st) st.innerHTML = eaGetValued('--tensor-split')
|
||
? '<button class="pe-link" onclick="resetSplit()">réinitialiser</button>'
|
||
: '<span class="pe-unit">automatique</span>';
|
||
}
|
||
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; }
|
||
// L'ordre déjà choisi (« inverser l'ordre ») est gardé pour les cartes restées
|
||
// cochées ; les nouvelles viennent à la suite, dans l'ordre du moteur.
|
||
const prev = eaGetValued('--device').split(',').filter(id => ids.includes(id));
|
||
const order = prev.concat(ids.filter(id => !prev.includes(id)));
|
||
// Toutes cochées dans l'ordre du moteur = pas de contrainte : on retire le
|
||
// flag plutôt que de figer des noms de device qui changeraient avec le moteur.
|
||
eaSetValued('--device', gpuNaturalOrder(order) ? '' : order.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(' ')); syncKVSub(); }
|
||
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<t.length && !t[i+1].startsWith('-')) ? t[i+1] : '';
|
||
}
|
||
function eaSetValued(flag, val){
|
||
const t = eaTokens(), i = t.indexOf(flag);
|
||
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
|
||
val = String(val||'').trim();
|
||
if(val !== '') t.push(flag, val);
|
||
eaSetTokens(t);
|
||
}
|
||
// Remplit les champs de réglage depuis le contenu courant du preset.
|
||
function populateSettings(){
|
||
const set = (id,v)=>{ 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-cram', cfgReadKey('CACHE_RAM'));
|
||
refreshCacheRAM();
|
||
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
|
||
set('s-kv', cfgReadKey('KV_TYPE'));
|
||
syncKVSub();
|
||
// É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') || specFromKey(cfgReadKey('SPEC')));
|
||
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')));
|
||
const lm = eaLoadMode();
|
||
set('s-loadmode', lm);
|
||
syncLoadModeSub(lm);
|
||
}
|
||
// Cache KV : le sous-titre dit ce que le moteur utilisera VRAIMENT, comme
|
||
// effectiveKVTypes côté Go. EXTRA_ARGS ferme la ligne de commande et la dernière
|
||
// occurrence gagne : un --cache-type-k q8_0 écrit à la main l'emporte sur la
|
||
// liste, qui afficherait sinon « f16 » pendant que le moteur tourne en q8_0.
|
||
// Les drapeaux de cache qui changent ce que voit le modèle (--context-shift,
|
||
// --cache-reuse > 0) sont signalés au même endroit. Rien n'est jamais modifié
|
||
// ici : c'est un affichage.
|
||
const KV_FIDELITY = {
|
||
'bf16': 'numérique différente de f16',
|
||
'q8_0': 'modifie légèrement les sorties', 'q5_0': 'modifie légèrement les sorties', 'q5_1': 'modifie légèrement les sorties',
|
||
'q4_0': 'perte mesurable', 'q4_1': 'perte mesurable', 'iq4_nl': 'perte mesurable',
|
||
};
|
||
const KV_RANK = {'':0, 'f16':0, 'f32':0, 'bf16':1, 'q4_0':3, 'q4_1':3, 'iq4_nl':3};
|
||
function eaLastValue(flags){
|
||
let v = '';
|
||
const t = eaTokens();
|
||
t.forEach((a, i) => {
|
||
const eq = a.indexOf('='), name = eq > 0 ? a.slice(0, eq) : a;
|
||
if(!flags.includes(name)) return;
|
||
if(eq > 0) v = a.slice(eq+1);
|
||
else if(i+1 < t.length) v = t[i+1];
|
||
});
|
||
return v;
|
||
}
|
||
// Valeur auto du cache de prompts, calculée côté serveur comme au lancement
|
||
// (VRAM, RAM libre, métadonnées du modèle) : affichée en indication du champ
|
||
// CACHE_RAM. Une seule demande en vol ; la dernière gagne.
|
||
let cramSeq = 0;
|
||
async function refreshCacheRAM(){
|
||
const el = document.getElementById('s-cram'), sub = document.getElementById('s-cram-sub');
|
||
if(!el) return;
|
||
const seq = ++cramSeq;
|
||
let r = {};
|
||
try{ r = await jpost('/api/preset/cacheram', {content: document.getElementById('m-content').value}); }
|
||
catch(_){ r = {}; }
|
||
if(seq !== cramSeq || !r.ok) return;
|
||
// Valeur fixée (CACHE_RAM, -cram, LLAMA_ARG_CACHE_RAM) : -1 et 0 ne sont pas
|
||
// « le défaut du moteur », la raison seule suffit.
|
||
const fixed = /^fixé/.test(r.why || '');
|
||
el.placeholder = fixed ? (r.mib < 0 ? 'sans limite' : r.mib === 0 ? 'coupé' : String(r.mib))
|
||
: 'auto · ' + (r.mib > 0 ? r.mib : r.default);
|
||
// SIDE_SLOT : la VRAM du second slot, ou la raison du refus au lancement.
|
||
const side = r.side ? (r.side.on ? ' · SIDE_SLOT : +' + r.side.vramMiB + ' Mio de VRAM'
|
||
: ' · SIDE_SLOT refusé : ' + r.side.why) : '';
|
||
if(sub) sub.textContent = 'RAM hôte, copie exacte du contexte · jamais la VRAM' +
|
||
(r.why ? ' · ' + r.why : '') + (r.mib > 0 || fixed ? '' : ' · défaut du moteur') + side;
|
||
}
|
||
function syncKVSub(){
|
||
const el = document.getElementById('s-kv-sub');
|
||
if(!el) return;
|
||
const kv = cfgReadKey('KV_TYPE');
|
||
const xk = eaLastValue(['-ctk','--cache-type-k']), xv = eaLastValue(['-ctv','--cache-type-v']);
|
||
const k = (xk || cfgReadKey('KV_TYPE_K') || kv).toLowerCase();
|
||
const v = (xv || cfgReadKey('KV_TYPE_V') || kv).toLowerCase();
|
||
const rank = x => (x in KV_RANK) ? KV_RANK[x] : 2;
|
||
const worst = rank(v) > rank(k) ? v : k;
|
||
const parts = [];
|
||
if(rank(worst) === 0){
|
||
parts.push(xk || xv ? 'f16 (défini par EXTRA_ARGS)' : 'compression du contexte');
|
||
} else {
|
||
const label = KV_FIDELITY[worst] || 'modifie les sorties';
|
||
parts.push((xk || xv ? 'défini par EXTRA_ARGS : ' : '') + (k||'f16')+'/'+(v||'f16')+' — '+label);
|
||
// Placement figé à la main : --fit ne tourne pas et n'absorbera pas le
|
||
// surplus d'un retour en f16. Comme tensorOverride côté Go : un
|
||
// --n-cpu-moe 0 ne place rien.
|
||
const placed = f => { const n = eaLastValue(f).trim(); return n !== '' && n !== '0'; };
|
||
if(eaTokens().some(a => ['-ot','--override-tensor','--cpu-moe','-cmoe'].includes(a.split('=')[0])) ||
|
||
placed(['--n-cpu-moe','-ncmoe']) || placed(['--n-cpu-ffn','-ncffn']))
|
||
parts.push('repasser en f16 : plus de VRAM, --n-cpu-moe à relever');
|
||
}
|
||
let shift = false;
|
||
eaTokens().forEach(a => { if(a === '--context-shift') shift = true; else if(a === '--no-context-shift') shift = false; });
|
||
if(shift) parts.push('--context-shift : jette des jetons quand le contexte est plein');
|
||
const reuse = parseInt(eaLastValue(['--cache-reuse']), 10);
|
||
if(reuse > 0) parts.push('--cache-reuse : cache réutilisé de façon approchée');
|
||
el.textContent = parts.join(' · ');
|
||
}
|
||
// Chargement du modèle. llama.cpp récent a REMPLACÉ --mlock / --no-mmap par
|
||
// --load-mode (auto, none, mmap, mlock, mmap+mlock, dio). L'éditeur écrit la
|
||
// forme moderne ; au lancement, loki serve la retraduit pour un moteur ancien
|
||
// (normalizeLoadFlags côté Go), et inversement.
|
||
function eaLoadMode(){
|
||
const lm = (eaGetValued('--load-mode') || eaGetValued('-lm')).toLowerCase();
|
||
if(lm) return lm === 'auto' ? '' : lm;
|
||
// Preset écrit avec les anciens drapeaux : même correspondance que le serveur.
|
||
const mlock = eaHasFlag('--mlock'), nommap = eaHasFlag('--no-mmap');
|
||
if(mlock && nommap) return 'mlock';
|
||
if(mlock) return 'mmap+mlock';
|
||
if(nommap) return 'none';
|
||
if(eaHasFlag('--mmap')) return 'mmap';
|
||
return '';
|
||
}
|
||
function eaSetLoadMode(mode){
|
||
const t = eaTokens().filter(x=>x!=='--mlock' && x!=='--no-mmap' && x!=='--mmap' && !x.startsWith('--load-mode='));
|
||
for(const f of ['--load-mode','-lm']){
|
||
const i = t.indexOf(f);
|
||
if(i>=0){ const hadVal = i+1<t.length && !t[i+1].startsWith('-'); t.splice(i, hadVal?2:1); }
|
||
}
|
||
if(mode) t.push('--load-mode', mode);
|
||
eaSetTokens(t);
|
||
syncLoadModeSub(mode);
|
||
}
|
||
// Sous-titre : ce que fait le mode choisi (les options ne portent que son nom).
|
||
const LOAD_MODE_SUB = {
|
||
'': 'mmap, sauf si un périphérique ne le permet pas',
|
||
'mmap': 'fichier mappé, lu à la demande',
|
||
'none': 'tout charger, sans mmap',
|
||
'mlock': 'tout en RAM, verrouillé (sans mmap)',
|
||
'mmap+mlock': 'mappé et verrouillé en RAM',
|
||
'dio': 'lecture DirectIO si disponible',
|
||
};
|
||
function syncLoadModeSub(mode){
|
||
const el = document.getElementById('s-loadmode-sub');
|
||
if(el) el.textContent = LOAD_MODE_SUB[mode] ?? mode;
|
||
}
|
||
// --- Décodage spéculatif (SPEC, MODEL_DRAFT, --spec-type, --spec-draft-n-max)
|
||
// « auto » et « MTP » passent par la clé SPEC : Loki cherche la tête MTP dans
|
||
// le fichier et pose les garde-fous au lancement. Les autres types restent des
|
||
// --spec-type d'EXTRA_ARGS, qui ont toujours le dernier mot côté serveur.
|
||
// « ngram » et « mtp+ngram » aussi : Loki écrit lui-même les bornes de
|
||
// ngram-mod et refuse là où les longs lots de vérification coûteraient cher.
|
||
function specFromKey(v){
|
||
v = String(v||'').trim().toLowerCase();
|
||
if(v === 'ngram+mtp') v = 'mtp+ngram';
|
||
return v === 'auto' ? 'auto' : v === 'mtp' ? 'draft-mtp' : (v === 'ngram' || v === 'mtp+ngram') ? v : '';
|
||
}
|
||
const SPEC_KEY_VALUES = {'auto': 'auto', 'draft-mtp': 'mtp', 'ngram': 'ngram', 'mtp+ngram': 'mtp+ngram'};
|
||
// 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;
|
||
// SPEC=ngram n'a pas de brouillon : un --spec-draft-n-max le ferait refuser.
|
||
row.style.display = on && sel.value !== 'ngram' ? '' : 'none';
|
||
// Le brouillon ne sert qu'à SPEC (auto / MTP) ; un brouillon déjà posé reste
|
||
// visible pour qu'on puisse le retirer.
|
||
const dr = document.getElementById('s-draft-row');
|
||
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || sel.value === 'mtp+ngram' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
|
||
return on;
|
||
}
|
||
function onSpecType(){
|
||
const v = document.getElementById('s-spec').value;
|
||
const viaKey = v in SPEC_KEY_VALUES;
|
||
// « non » écrit SPEC=off : explicite, et identique au défaut du serveur.
|
||
cfgWriteKey('SPEC', viaKey ? SPEC_KEY_VALUES[v] : v ? '' : 'off');
|
||
eaSetValued('--spec-type', viaKey ? '' : v);
|
||
if(!v || v === 'ngram'){
|
||
eaSetValued('--spec-draft-n-max', '');
|
||
document.getElementById('s-spec-n').value = '';
|
||
}
|
||
syncSpecRow();
|
||
}
|
||
// Liste des brouillons : tous les .gguf sauf les projecteurs vision. Une tête
|
||
// MTP publiée à part (mtp-*.gguf) est ce qu'on y choisira le plus souvent.
|
||
async function populateDraft(){
|
||
const sel = document.getElementById('m-draft');
|
||
if(!sel) return;
|
||
const list = await jget('/api/models');
|
||
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
|
||
const items = (list||[]).filter(m => !isMmprojName(m.name));
|
||
const hit = matchModel(cur, items);
|
||
let html = '<option value="">— aucun —</option>';
|
||
let matched = false;
|
||
for(const m of items){
|
||
const on = (m === hit) ? ' selected' : '';
|
||
if(on) matched = true;
|
||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||
}
|
||
if(cur && !matched){
|
||
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
|
||
}
|
||
sel.innerHTML = html;
|
||
syncSpecRow();
|
||
}
|
||
// Choisir un brouillon, c'est demander la spéculation : on passe en « auto » si
|
||
// elle était coupée (les garde-fous de Loki restent en place).
|
||
function onPickDraft(){
|
||
const v = document.getElementById('m-draft').value;
|
||
cfgWriteKey('MODEL_DRAFT', v);
|
||
const sel = document.getElementById('s-spec');
|
||
if(v && sel && !sel.value){ sel.value = 'auto'; onSpecType(); toast('décodage spéculatif : auto'); }
|
||
// Un ancien preset portait le MTP en --spec-type dans EXTRA_ARGS : sans clé
|
||
// SPEC, le brouillon serait ignoré au lancement. On le range dans SPEC=mtp.
|
||
else if(v && sel && sel.value === 'draft-mtp' && specFromKey(cfgReadKey('SPEC')) !== 'draft-mtp') onSpecType();
|
||
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; }
|
||
// Preset EN SERVICE modifié : le serveur l'a réappliqué et relance le moteur
|
||
// (SavePresetApplying). On le dit, et on rafraîchit l'état global — la carte
|
||
// du chat doit montrer le nouveau contexte, pas l'ancien.
|
||
if(r.applied){ toast('enregistré et appliqué — redémarrage…'); closeModal(); K.reload(); if(typeof loadAll==='function') loadAll(); 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){
|
||
// modelError est renseigné même quand le fichier A ÉTÉ supprimé (il servait
|
||
// aussi à d'autres presets) : on regarde donc modelDeleted d'abord.
|
||
if(r.modelDeleted) toast('preset + modèle supprimés — ' + fmtSize(r.freed||0) + ' libérés'
|
||
+ (r.modelError ? ' (' + r.modelError + ')' : ''));
|
||
else if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
|
||
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 = [];
|
||
// Un .gguf est disponible (il vient d'arriver, ou il était déjà là) : on le
|
||
// sélectionne dans le bon champ du preset. Un projecteur (mmproj) va dans
|
||
// « Vision », un modèle dans le sélecteur de modèle — d'après son nom de
|
||
// fichier. Le fichier a pu atterrir hors du dossier loki : l'option porte alors
|
||
// le chemin complet, pas le simple nom.
|
||
async function selectInstalled(fname){
|
||
await Promise.all([populateModelPicker(), populateMmproj(), populateDraft(), populateDlDirs(), refreshInstalled()]);
|
||
const pick = (id, cb)=>{
|
||
const s = document.getElementById(id);
|
||
if(!s) return false;
|
||
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
|
||
if(!o) return false;
|
||
s.value = o.value; cb(); return true;
|
||
};
|
||
if(/^mtp-/i.test(fname)) return pick('m-draft', onPickDraft);
|
||
return isMmprojName(fname) ? pick('m-mmproj', onPickMmproj) : pick('m-model', onPickModel);
|
||
}
|
||
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});
|
||
// Modèle DÉJÀ sur le disque : il n'y a rien à télécharger, il y a un modèle à
|
||
// choisir. C'était le cul-de-sac — un .gguf gardé après la suppression de son
|
||
// preset ne pouvait plus servir à en refaire un : le téléchargement répondait
|
||
// « le modèle existe déjà » en rouge et s'arrêtait là.
|
||
if(p.ok && p.exists){
|
||
e.bar.style.display = 'none';
|
||
e.btn.disabled = false;
|
||
const done = await selectInstalled(p.filename);
|
||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(p.filename)
|
||
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '')
|
||
+ (p.size > 0 ? ' (' + fmtSize(p.size) + ')' : '') + '</span>';
|
||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||
return;
|
||
}
|
||
if(!p.ok || !p.enough){
|
||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
|
||
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 = '<span style="color:var(--err)">erreur : '+escHtml(r.error||'')+'</span>';
|
||
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
|
||
}
|
||
// Le serveur peut constater lui aussi que tout est déjà là (fichier apparu
|
||
// entre la sonde et l'envoi, ou lien collé à la main sans passer par la sonde).
|
||
if(r.exists){
|
||
e.bar.style.display = 'none'; e.btn.disabled = false;
|
||
const done = await selectInstalled(r.filename);
|
||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(r.filename)
|
||
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '') + '</span>';
|
||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||
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 = '<span style="color:var(--err)">erreur : '+st.error+'</span>';
|
||
e.bar.style.display = 'none'; dlQueue = []; stop(); return;
|
||
}
|
||
if(st.finished){
|
||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
||
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
|
||
stop();
|
||
await selectInstalled(fname);
|
||
// 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, onDisk = [];
|
||
try{
|
||
// Les .gguf déjà présents sont demandés EN MÊME TEMPS que l'arborescence :
|
||
// c'est ce qui permet de dire « déjà installé » sur la ligne d'un quant au
|
||
// lieu de le découvrir après le clic, par un refus.
|
||
[r, onDisk] = await Promise.all([
|
||
jget('/api/hf/files?repo='+encodeURIComponent(repo)),
|
||
jget('/api/models').catch(()=>[]),
|
||
]);
|
||
}
|
||
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
|
||
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
|
||
const installed = n => (onDisk||[]).find(m => samePath(m.name, baseName(n)) && !(m.missing||[]).length);
|
||
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)+')'
|
||
+ (installed(mm.name) ? ' — déjà sur le disque' : '');
|
||
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);
|
||
|
||
// Tête MTP publiée à part (mtp-*.gguf) : même règle que le projecteur, elle
|
||
// ne vaut que pour SON modèle. Décochée : la spéculation reste un choix.
|
||
let dfChk = null;
|
||
const df = (r.drafts||[])[0] && hfBestProjector(r.drafts);
|
||
if(df){
|
||
const dn = document.createElement('label');
|
||
dn.className = 'hf-mm';
|
||
dfChk = document.createElement('input');
|
||
dfChk.type = 'checkbox';
|
||
const t = document.createElement('span');
|
||
t.textContent = ' installer aussi la tête MTP (décodage plus rapide, ~1-2 Go de VRAM) — '+df.name+' ('+fmtSize(df.size)+')'
|
||
+ (installed(df.name) ? ' — déjà sur le disque' : '');
|
||
dn.append(dfChk, t);
|
||
o.appendChild(dn);
|
||
}
|
||
|
||
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, df && dfChk && dfChk.checked ? df : 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);
|
||
// Déjà sur le disque : la pastille le dit AVANT le clic, et le clic ne
|
||
// télécharge alors rien — il sélectionne le fichier présent. Le verdict
|
||
// mémoire n'a plus grand-chose à arbitrer dans ce cas, mais il reste
|
||
// affiché : « juste » vaut d'être su même sur un modèle déjà installé.
|
||
if(installed(m.name)){
|
||
const here = document.createElement('span');
|
||
here.className = 'hf-fit here';
|
||
here.textContent = 'déjà installé';
|
||
here.title = 'Ce fichier est déjà dans un de tes dossiers de modèles : le choisir le sélectionne, sans rien retélécharger.';
|
||
row.appendChild(here);
|
||
}
|
||
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, draft){
|
||
// Le fichier est là : rien à télécharger, donc rien à confirmer — la sonde le
|
||
// constate côté serveur et startDownloadURL le sélectionne.
|
||
const here = await modelIsOnDisk(model.name);
|
||
if(!here && 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, draft].filter(Boolean).map(x => x.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.
|