mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-13 18:28:03 +02:00
La ligne de l'optimiseur s'affichait dans l'éditeur de TOUT preset local, y compris un preset neuf ou un autre que celui en service, alors que la mesure porte toujours sur le preset actif (le README le dit) : un clic depuis l'éditeur d'un autre preset aurait optimisé et proposé d'écrire celui en service. - La dernière liste des presets est gardée ; la ligne n'apparaît que si le preset ouvert est l'actif, et local. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1845 lines
91 KiB
JavaScript
1845 lines
91 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();
|
||
refreshMoe();
|
||
// L'optimiseur mesure le preset EN SERVICE (local) : sa ligne n'apparaît que
|
||
// dans l'éditeur de celui-ci, pas d'un autre preset ni d'un preset neuf.
|
||
const tuneRow = document.getElementById('m-tune-row');
|
||
if(tuneRow){
|
||
const act = (typeof presetsLast !== 'undefined' ? presetsLast : []).find(x => x.active);
|
||
tuneRow.style.display = (act && !act.external && editingKey && act.id === editingKey) ? '' : 'none';
|
||
}
|
||
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;
|
||
}
|
||
// Avis MoE et mode de chargement, calculés côté serveur comme au lancement
|
||
// (backend_serve_moe.go) : experts placés à la main, UBATCH conseillé, moteur
|
||
// sans --fit, chargement résident trop gros pour la RAM. Des avis seulement :
|
||
// rien n'est écrit dans le preset. Une demande en vol ; la dernière gagne.
|
||
let moeSeq = 0, moeTimer = null, moeChanges = null;
|
||
function refreshMoeSoon(){ clearTimeout(moeTimer); moeTimer = setTimeout(refreshMoe, 400); }
|
||
async function refreshMoe(){
|
||
const row = document.getElementById('m-moe-row'), note = document.getElementById('m-moe-note');
|
||
const dup = document.getElementById('m-moe-dup');
|
||
const lrow = document.getElementById('s-loadmode-warn-row'), lw = document.getElementById('s-loadmode-warn');
|
||
if(!row) return;
|
||
const seq = ++moeSeq;
|
||
let r = {};
|
||
try{ r = await jpost('/api/preset/moe', {content: document.getElementById('m-content').value}); }
|
||
catch(_){ r = {}; }
|
||
if(seq !== moeSeq) return;
|
||
const ap = r.ok ? r.autoplace : null;
|
||
const parts = (r.ok && r.notes) ? r.notes.slice() : [];
|
||
if(ap && !ap.ok) parts.push('placement auto impossible : ' + ap.why);
|
||
note.textContent = parts.join(' · ');
|
||
moeChanges = (ap && ap.ok) ? (ap.changes || []) : null;
|
||
dup.hidden = !moeChanges;
|
||
row.style.display = (parts.length || moeChanges) ? '' : 'none';
|
||
if(lrow && lw){
|
||
lw.textContent = (r.ok && r.load) || '';
|
||
lrow.style.display = lw.textContent ? '' : 'none';
|
||
}
|
||
}
|
||
// « Dupliquer en placement auto » : une COPIE du preset tel qu'affiché, sans
|
||
// les experts placés à la main (--fit les répartit carte par carte), UBATCH
|
||
// 2048 / BATCH 4096, NGL auto, chargement mmap, CTX et cache KV intacts. Le
|
||
// preset d'origine ne bouge pas : c'est le repli si --fit échoue.
|
||
async function dupAutoPlacement(){
|
||
if(!moeChanges) return;
|
||
const msg = 'Créer une COPIE de ce preset en placement automatique (--fit place les experts carte par carte) :\n\n'
|
||
+ moeChanges.map(c => '• ' + c).join('\n')
|
||
+ '\n\nLe preset d\'origine reste tel quel : c\'est le repli. Après la bascule sur la copie, le journal doit dire '
|
||
+ '« successfully fit params » — « failed to fit » met tous les experts sur GPU : revenir à l\'original. '
|
||
+ 'Puis comparer les deux au « bench complet ».';
|
||
if(!await askConfirm(msg, {title:'Placement auto', okText:'Créer la copie'})) return;
|
||
let r = {};
|
||
try{
|
||
r = await jpost('/api/preset/autoplace', {id: editingKey || '', name: document.getElementById('m-name').value.trim(),
|
||
content: document.getElementById('m-content').value});
|
||
}catch(e){ r = {ok:false, error:e.message}; }
|
||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||
toast('copie créée : ' + r.name);
|
||
KINDS.preset.reload();
|
||
}
|
||
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.
|