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 '
' + '
⏳
' + '
'+text+'
'; } function benchErr(msg){ return '
erreur : '+escHtml(msg||'?')+'
'; } 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'?'
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 '
' + '
'+label+'
' + '
'+val+'
' + '
'+unit+'
' + '
'+sub+'
'; } function benchResultHTML(x, saved){ let h='
' + 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') + '
'; 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+=''; for(const r of rows) h+=''; h+='
'+escHtml(r[0])+''+escHtml(r[1])+'
'; } for(const n of notes) h+='
'+escHtml(n)+'
'; 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+='
'+escHtml(foot)+'
'; if(d&&d.cold) h+='
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.
'; 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 = ''; 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 += ''; } html += groups.length > 1 ? ''+opts+'' : opts; } // MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré : // on déplie alors la section des dossiers, c'est là que ça se répare. if(cur && !matched){ html += ''; } sel.innerHTML = html; if(cur && !matched) setModelDirsOpen(true); } // ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) --- // Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. function setModelDirsOpen(open){ const b = document.getElementById('m-dirs-body'); const c = document.getElementById('m-dirs-caret'); if(!b) return; b.style.display = open ? '' : 'none'; if(c) c.classList.toggle('open', open); if(open) populateModelDirs(); } function toggleModelDirs(){ setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none'); } // ---- Jeton Hugging Face ---------------------------------------------------- // Un dépôt « gated » se lit sans jeton mais ne se télécharge pas. Le jeton se // posait uniquement par la variable d'environnement HF_TOKEN : en conteneur, ça // voulait dire éditer un docker-compose et recréer le conteneur pour répondre à // un 401 découvert ici. Il se règle donc à l'endroit où le problème apparaît. function setHFTokenOpen(open){ const b = document.getElementById('m-hft-body'); const c = document.getElementById('m-hft-caret'); if(!b) return; b.style.display = open ? '' : 'none'; if(c) c.classList.toggle('open', open); } function toggleHFToken(){ setHFTokenOpen(document.getElementById('m-hft-body').style.display === 'none'); } async function loadHFToken(){ let d = {}; try{ d = await jget('/api/hf/token'); }catch(_){ return; } renderHFToken(d); } // Le jeton n'est jamais renvoyé en clair par le serveur : on n'affiche que sa // forme masquée et sa provenance. « env » se signale à part — l'interface ne // peut pas effacer une variable du conteneur, promettre le contraire par un // bouton « Retirer » serait mentir. function renderHFToken(d){ const st = document.getElementById('m-hft-state'); const note = document.getElementById('m-hft-note'); const clear = document.getElementById('m-hft-clear'); const inp = document.getElementById('m-hft-input'); if(!st || !note || !clear || !inp) return; inp.value = ''; const src = (d && d.source) || ''; st.textContent = src ? (d.masked || 'enregistré') : 'aucun'; clear.style.display = src === 'config' ? '' : 'none'; if(src === 'env'){ note.textContent = 'Jeton fourni par la variable d’environnement HF_TOKEN. En enregistrer un ici le remplacera.'; } else if(src === 'config'){ note.textContent = 'Jeton enregistré' + (d.account ? ' — compte ' + d.account : '') + '. Un dépôt à accès restreint exige EN PLUS d’avoir accepté ses conditions sur huggingface.co avec ce compte.'; } else { note.textContent = 'Sans jeton, les dépôts à accès restreint répondent 401 au téléchargement. Crée un jeton en lecture sur huggingface.co/settings/tokens, accepte les conditions du dépôt, puis colle-le ici.'; } } // Vérifié côté serveur AVANT d'être enregistré : un jeton mal collé accepté en // silence rendrait exactement le 401 qu'on cherche à expliquer. async function saveHFToken(){ const inp = document.getElementById('m-hft-input'); const tok = (inp.value || '').trim(); if(!tok){ toast('colle un jeton (hf_…)'); return; } toast('vérification du jeton…'); const r = await jpost('/api/hf/token', {token: tok}); if(!r || !r.ok){ toast('jeton refusé : ' + ((r && r.error) || '')); return; } renderHFToken(r); toast(r.account ? 'jeton enregistré — compte ' + r.account : 'jeton enregistré'); } async function clearHFToken(){ if(!await askConfirm('Les dépôts à accès restreint répondront de nouveau 401 au téléchargement.', {title:'Retirer le jeton Hugging Face ?', okText:'Retirer'})) return; const r = await jpost('/api/hf/token', {token: ''}); if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || '')); return; } renderHFToken(r); toast('jeton retiré'); } async function populateModelDirs(){ const box = document.getElementById('m-dirs-list'); if(!box) return; let d = {}; try{ d = await jget('/api/models/dirs'); }catch(_){ return; } // Construit en DOM (pas en HTML concaténé) : les chemins viennent de // l'utilisateur et se retrouveraient sinon dans un attribut onclick. box.innerHTML = ''; for(const x of (d.dirs||[])){ const row = document.createElement('div'); const p = document.createElement('span'); p.textContent = x.path + ' '; const info = document.createElement('span'); info.className = 'muted'; const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; // « ~ » quand le système de fichiers approxime (FUSE/shfs d'Unraid, // overlay, réseau) : le chiffre est indicatif, il ne bloque plus rien. const free = x.free >= 0 ? ', ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : ''; info.textContent = '(' + n + free + (x.home ? ', dossier loki' : '') + ')'; row.append(p, info); if(!x.home){ const del = document.createElement('button'); del.className = 'pe-link'; del.textContent = 'retirer'; del.style.marginLeft = '8px'; del.onclick = () => removeModelDir(x.path); row.append(del); } box.append(row); } } async function addModelDir(){ const inp = document.getElementById('m-dir-path'); const p = inp.value.trim(); if(!p){ toast('indique un chemin de dossier'); return; } const r = await jpost('/api/models/dirs', {path: p, action: 'add'}); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } inp.value = ''; toast('dossier ajouté'); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } async function removeModelDir(p){ const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } toast('dossier retiré'); await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); } // ---- 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 = ''; let matched = false; for(const m of items){ const on = (m === hit) ? ' selected' : ''; if(on) matched = true; html += ''; } // MMPROJ pointe sur un fichier absent des dossiers déclarés (ou nommé hors // convention) : on le garde affiché plutôt que de l'effacer silencieusement. if(cur && !matched){ html += ''; } sel.innerHTML = html; } // Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows). // Sert à la ligne MOTEUR du panneau d'état (05-status.js). function sameBinPath(a, b){ const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase(); return !!a && !!b && n(a) === n(b); } // --- Cartes graphiques (--device / --tensor-split) -------------------------- // Le moteur n'est PAS un réglage de preset : c'est celui de la machine (l'image, // ou la version mise à jour dans Réglages → Moteur), et applyPresetFile le // conserve à la bascule. L'éditeur n'offre donc plus de choix de moteur — il // interroge celui qui tourne pour lister les cartes. // Ces réglages vivent dans le PRESET, pas dans une variable d'environnement : // --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que // CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles // qui s'étalaient sur les deux cartes malgré une sélection « GPU 1 ». // La lecture/écriture des flags passe par eaGetValued/eaSetValued (le tokenizer // d'EXTRA_ARGS déjà utilisé par les autres réglages) : un seul parseur. // Diamètre de la pastille du curseur, en pixels — DOIT rester synchronisé avec // .gpu-range::-webkit-slider-thumb / ::-moz-range-thumb dans styles.css. const GPU_THUMB = 16; let gpuDevices = []; const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture // Interroge le moteur de la machine (--list-devices) : les noms de device et // leur ordre lui appartiennent, une liste issue de nvidia-smi désignerait la // mauvaise carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la // réponse n'est pas là, et le reste s'il y a moins de deux cartes : rien à // arbitrer, et ça évite un encart qui surgit après coup. // Chemin du moteur courant (config_bin de /api/llamacpp), mémorisé au // préchauffage : l'éditeur s'en sert sans refaire l'appel. let engineBin = ''; // Remplit le cache sans rien peindre (appelé au chargement de la page pour le // moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt. async function prefetchGpuDevices(bin){ if(bin) engineBin = bin; if(!bin || gpuCache[bin]) return; try{ const r = await jpost('/api/backends/devices', {bin}); if(r && r.ok) gpuCache[bin] = r.devices || []; }catch(_){} } // Moteur courant : ce que le préchauffage a vu, sinon on le demande (éditeur // ouvert avant que le statut llama.cpp ne soit arrivé). async function currentEngineBin(){ if(engineBin) return engineBin; try{ const lc = await jget('/api/llamacpp'); engineBin = lc.config_bin || ''; }catch(_){} return engineBin; } async function loadGpuDevices(){ const group = document.getElementById('m-gpu-group'); const bin = await currentEngineBin(); if(!bin){ group.hidden = true; gpuDevices = []; return; } if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; } let r = {}; try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; } // Le moteur a pu changer pendant l'appel (mise à jour en cours) : on ne peint // que si la réponse concerne toujours le moteur courant. if(engineBin !== bin) return; gpuDevices = (r.ok && r.devices) ? r.devices : []; if(r.ok) gpuCache[bin] = gpuDevices; renderGpu(); } function selectedGpuIds(){ const sel = eaGetValued('--device').split(',').filter(Boolean); const known = gpuDevices.map(d => d.id); const kept = sel.filter(id => known.includes(id)); return kept.length ? kept : known; // aucune contrainte = toutes les cartes } function gpuEsc(t){ return String(t).replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); } function renderGpu(){ const group = document.getElementById('m-gpu-group'); // Une seule carte (ou aucune) : rien à répartir, rien à choisir. if(gpuDevices.length < 2){ group.hidden = true; return; } const sel = selectedGpuIds(); let html = gpuDevices.map(d => { // Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le // modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge. const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : ''; const link = gpuLinkText(d); return '
' + '' + gpuEsc(gpuLabel(d)) + '' + gpuEsc(d.id) + gb + (link ? ' · ' + gpuEsc(link) : '') + '' + '' + '
'; }).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 = ['Ordre vu par le moteur : ' + order + (sel.length === 2 ? ' · ' : '')]; parts.push('• Modèle dense en placement auto : --fit remplit d\'abord la dernière 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 première 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('
'); } // 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 = '
' + '
' + '
'; } else { inner = '
' + '
' + sel.map((id, i) => '').join('') + '
'; } return '
' + 'Répartitionpart des couches par carte' + '
' + '
' + inner + '
'; } // Parts actuelles, normalisées à 1. Sans --tensor-split : proportionnelles à la // VRAM de chaque carte, ce que llama.cpp fait de son côté — la barre montre donc // ce qui se passe réellement plutôt qu'un partage égal trompeur. function currentShares(sel){ const raw = eaGetValued('--tensor-split').split(',').map(parseFloat).filter(n => !isNaN(n)); let vals = (raw.length === sel.length) ? raw : sel.map(id => gpuById(id).total_mib || 0); // Mémoires inconnues (lecture à 0) : à défaut de proportion crédible, on // montre un partage égal plutôt qu'une barre pleine sur une seule carte. if(vals.every(v => !v)) vals = sel.map(() => 1); const sum = vals.reduce((a, b) => a + b, 0) || 1; return vals.map(v => v/sum); } // Repeint UNIQUEMENT la barre, la légende et l'état. Surtout PAS le bloc entier : // remplacer le HTML pendant un glissement détruit le curseur en cours d'usage — // c'est ce qui faisait qu'il ne répondait qu'au clic. function paintSplit(){ const sel = selectedGpuIds(); if(sel.length < 2) return; const shares = currentShares(sel); const bar = document.getElementById('m-split-bar'); // La pastille du curseur ne parcourt PAS toute la largeur : elle va de // GPU_THUMB/2 à largeur - GPU_THUMB/2, sinon elle déborderait de la piste. Une // coupure à « s % » pile tombait donc à côté d'elle (jusqu'à ~5 px d'écart aux // extrêmes), ce qui laissait voir la piste claire juste à droite de la // pastille. On coupe dans le repère de la pastille : s*(largeur - THUMB) + THUMB/2. // Le dernier segment prend le reste (flex:1) : aucun filet résiduel à droite, // quels que soient les arrondis. const withThumb = sel.length === 2 && !!document.getElementById('m-split-range'); if(bar) bar.innerHTML = shares.map((s, i) => { const cls = i%2 ? 'alt' : ''; if(i === shares.length - 1) return ''; const w = withThumb ? 'calc(' + (s*100).toFixed(3) + '% - ' + ((s - 0.5) * GPU_THUMB).toFixed(2) + 'px)' : (s*100).toFixed(3) + '%'; return ''; }).join(''); const leg = document.getElementById('m-split-legend'); if(leg) leg.innerHTML = sel.map((id, i) => '' + gpuEsc(gpuLabel(gpuById(id))) + ' ' + (shares[i]*100).toFixed(1).replace('.', ',') + ' %').join(''); const st = document.getElementById('m-split-state'); if(st) st.innerHTML = eaGetValued('--tensor-split') ? '' : 'automatique'; } function writeSplit(shares){ const sum = shares.reduce((a, b) => a + b, 0) || 1; eaSetValued('--tensor-split', shares.map(s => (s/sum).toFixed(3)).join(',')); paintSplit(); } function onSplitRange(){ const v = parseFloat(document.getElementById('m-split-range').value) / 100; writeSplit([v, 1-v]); } function onSplitNumbers(){ const vals = [...document.querySelectorAll('.gpu-share input')].map(i => parseFloat(i.value) || 0); if(vals.reduce((a, b) => a + b, 0) <= 0) return; // saisie en cours writeSplit(vals); } function resetSplit(){ eaSetValued('--tensor-split', ''); const sel = selectedGpuIds(); const r = document.getElementById('m-split-range'); if(r && sel.length === 2) r.value = (currentShares(sel)[0]*100).toFixed(1); const nums = [...document.querySelectorAll('.gpu-share input')]; if(nums.length){ const sh = currentShares(sel); nums.forEach((inp, i) => { inp.value = (sh[i]*100).toFixed(1); }); } paintSplit(); } function onGpuPick(){ const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value); if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; } // 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=0){ const hadVal = i+1{ const e=document.getElementById(id); if(e) e.value=v; }; set('s-ctx', cfgReadKey('CTX')); set('s-ngl', cfgReadKey('NGL')); set('s-threads', cfgReadKey('THREADS')); set('s-batch', cfgReadKey('BATCH')); set('s-ubatch', cfgReadKey('UBATCH')); set('s-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 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 = ''; let matched = false; for(const m of items){ const on = (m === hit) ? ' selected' : ''; if(on) matched = true; html += ''; } if(cur && !matched){ html += ''; } 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 = '✓ ' + escHtml(p.filename) + ' est déjà sur le disque' + (done ? ' — sélectionné' : '') + (p.size > 0 ? ' (' + fmtSize(p.size) + ')' : '') + ''; if(dlQueue.length) startDownloadURL(dlQueue.shift()); return; } if(!p.ok || !p.enough){ e.prog.innerHTML = 'erreur : '+escHtml(p.error||'espace insuffisant')+''; e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; // le reste de la file dépendait de ce fichier : on n'enchaîne pas await populateDlDirs(); return; } // p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas // les 15 Go du fichier dont le lien a été collé. const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : ''; e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — ' + (p.free_exact === false ? '~' : '') + fmtSize(p.free) + ' libres' + (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : ''); const r = await jpost('/api/models/download', {url, dir}); if(!r.ok){ e.prog.innerHTML = 'erreur : '+escHtml(r.error||'')+''; 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 = '✓ ' + escHtml(r.filename) + ' est déjà sur le disque' + (done ? ' — sélectionné' : '') + ''; 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 = 'erreur : '+st.error+''; e.bar.style.display = 'none'; dlQueue = []; stop(); return; } if(st.finished){ e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; stop(); await 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.