// --- Réserve de hauteur des blocs peuplés par le réseau ---------------------
// Ces blocs sont vides (ou réduits à « … ») tant que le serveur n'a pas répondu,
// puis prennent leur vraie taille : tout le menu sautait alors d'un cran, ce qui
// se voyait surtout sur les sections du bas (« Moteur »). On mémorise leur
// hauteur d'une session à l'autre et on la réserve au démarrage ; la réserve
// tombe dès que le vrai contenu est là (elle ne fait que TENIR la place, elle
// n'affiche jamais rien de faux).
const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list'];
function reserveHeights(){
HRESERVE.forEach(id=>{
try{
const h=parseInt(localStorage.getItem('loki-h-'+id)||'0',10);
const el=document.getElementById(id);
if(el && h>0) el.style.minHeight=h+'px';
}catch(e){}
});
}
function releaseHeights(){
HRESERVE.forEach(id=>{
const el=document.getElementById(id); if(!el) return;
el.style.minHeight='';
try{ localStorage.setItem('loki-h-'+id, String(el.offsetHeight||0)); }catch(e){}
});
}
document.addEventListener('DOMContentLoaded', reserveHeights);
// Numéro (1-based) du preset vers lequel on bascule, tant que le serveur n'a pas
// fini de redémarrer le service. Basculer prend plusieurs secondes : sans ça, la
// liste restait FIGÉE sur l'ancien actif et on ne savait pas si le clic avait pris.
// Voir switchTo() dans 07-models.js.
let pendingPreset = 0;
// Dernière sélection PEINTE. La liste est redessinée à chaque rafraîchissement
// (et il y en a beaucoup : sondage de bascule, loadAll…) ; sans ce repère, les
// animations d'entrée (barre qui glisse, puce qui apparaît) repartaient à chaque
// redessin et la puce semblait clignoter en boucle. On ne les rejoue que quand la
// sélection CHANGE vraiment.
let paintedSel = '';
async function loadPresets(){
const p=await jget('/api/presets');
// Bascule terminée : le preset visé est devenu l'actif, on éteint l'attente.
if(pendingPreset && p[pendingPreset-1] && p[pendingPreset-1].active) pendingPreset = 0;
const act = p.find(x=>x.active);
// Build via DOM (not string concat) so preset names can contain anything —
// spaces, accents, quotes, < > & — without breaking markup or handlers.
const sp = document.getElementById('status-preset');
sp.textContent = act ? act.name : '';
sp.title = act ? 'preset actif' : '';
// Sélecteur de modèle de l'en-tête : même donnée, autre point d'entrée.
if(typeof renderModelSwitch === 'function') renderModelSwitch(p, act);
const cont=document.getElementById('presets');
cont.innerHTML='';
if(!p.length){ cont.innerHTML='(aucun)'; return; }
// Signature de la sélection : quel preset est actif, et vers lequel on bascule.
const sel = (act?act.id:'')+'|'+pendingPreset;
const moved = sel !== paintedSel; paintedSel = sel;
p.forEach((x,i)=>{
const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name, x.id);
const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
// est dessiné par la police du système — sur Windows il sortait plus petit et
// plus bas que sur macOS. Un disque CSS a la même taille et la même assiette
// partout.
// Puce réservée à l'actif POUR DE BON : pendant la bascule, seule la barre
// orange parle ; la puce apparaît quand la barre passe au blanc.
if(x.active){ const d=document.createElement('i'); d.className='preset-dot'; nm.appendChild(d); }
nm.appendChild(document.createTextNode(x.name)); nm.title=x.name;
// Second row: quant tag + bench perf, so the title row stays full-width.
const meta=document.createElement('div'); meta.className='preset-meta';
// Preset externe : ni quant ni bench à montrer — le modèle tourne ailleurs.
// On affiche à la place d'où vient la réponse, sans quoi rien à l'écran ne
// distinguerait un preset local d'un appel qui part sur internet.
if(x.external){
const et=document.createElement('span'); et.className='xtag';
et.title='API externe — le chat part vers un serveur distant';
et.innerHTML='';
et.appendChild(document.createTextNode(x.model || 'externe'));
meta.appendChild(et);
}
if(x.quant){
const q=document.createElement('span'); q.className='qtag';
q.textContent=x.quant; q.title='quantization';
meta.appendChild(q);
}
if(x.reasoning){
const rt=document.createElement('span'); rt.className='rtag';
rt.title='raisonnement actif ('+x.reasoning+')';
rt.innerHTML='';
rt.appendChild(document.createTextNode(x.reasoning));
meta.appendChild(rt);
}
if(x.bench){
const bt=document.createElement('span'); bt.className='btag';
bt.title='prefill / decode — dernier bench de ce preset';
bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s';
meta.appendChild(bt);
}
info.appendChild(nm);
if(meta.children.length) info.appendChild(meta);
const edit=document.createElement('button');
// Engrenage (et non plus un crayon) : la silhouette est ronde, donc elle se
// lit comme centrée dans son coin, là où le crayon penché tirait de travers.
edit.className='preset-edit'; edit.title='réglages du preset';
edit.innerHTML='';
// Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
row.appendChild(info); row.appendChild(edit);
cont.appendChild(row);
});
}
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
async function loadAgent(){
const s=await jget('/api/agent');
const on = s.enabled;
document.getElementById('agent-toggle').checked = on;
document.getElementById('compact-toggle').checked = (s.compact !== false);
setBadge('agent-badge', on, on?'actif':'inactif');
document.getElementById('brand').classList.toggle('agent', on);
setAgentGate(on);
if(s.mem_mode){ document.getElementById('mem-mode').value = s.mem_mode; renderMemModeDesc(s.mem_mode); }
memPages = (s.pages || s.skills || []).slice().sort((a,b)=>a.name.localeCompare(b.name));
memShown = MEM_PAGE;
document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : '';
document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none';
renderMemList();
loadMemEnc();
}
// Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
function setAgentGate(on){
['mem-block','net-block','computer-block','mcp-block','param-block'].forEach(id=>{
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
});
}
// Repli/dépli de la liste des pages mémoire (fermée par défaut → gagne de la place).
function toggleMemPages(){
const body=document.getElementById('mem-pages-body');
const bar=document.getElementById('mem-pages-bar');
const open=body.hasAttribute('hidden');
if(open){ body.removeAttribute('hidden'); bar.classList.add('open'); }
else { body.setAttribute('hidden',''); bar.classList.remove('open'); }
}
// Liste mémoire scalable : recherche + rendu plafonné (les milliers de pages ne
// déroulent plus une barre géante). memShown grimpe par paliers via « voir plus ».
let memPages=[], memShown=0; const MEM_PAGE=50;
function renderMemList(){
const q=(document.getElementById('mem-search').value||'').trim().toLowerCase();
const list=document.getElementById('mem-list');
list.textContent='';
if(!memPages.length){ list.innerHTML='