Chercher et installer un modèle depuis Loki

Installer un modèle demandait d'aller sur huggingface.co, de naviguer dans
l'arborescence d'un dépôt et de coller un lien à la main. Rien ne disait si le
fichier tiendrait en mémoire, et surtout rien ne reliait un modèle à SON
projecteur vision : c'est ainsi qu'un mmproj-Qwen3VL-8B s'est retrouvé
configuré pour un Qwen3.8-27B — deux modèles sans rapport, moteur qui démarre
et ne voit rien.

La recherche Hugging Face arrive dans l'éditeur de preset. Elle ne remonte que
les dépôts GGUF ; déplier un dépôt montre ses quantifications avec leur taille
et un verdict mémoire, et propose le projecteur vision DU MÊME DÉPÔT — le seul
qui corresponde. Quand le dépôt n'en publie pas, Loki le dit au lieu d'aller en
chercher un ailleurs.

Le nouveau code ne télécharge rien : il produit des URL que le chemin existant
consomme tel quel (normalizeHFURL, shardURLSet, sonde d'espace disque, reprise
et annulation). Une file d'attente enchaîne modèle puis projecteur — le serveur
ne mène qu'un transfert à la fois, et le champ Vision ne se remplit que si le
modèle est déjà sélectionné.

Trois familles de .gguf cohabitent dans un dépôt et ne veulent pas dire la même
chose : mmproj-* (projecteur), mtp-* (poids de décodage spéculatif) et le
modèle. Les deux premières ressemblent à un modèle ; les proposer en vrac, ce
serait offrir de lancer llama-server sur un encodeur d'images. Les tranches
d'une famille sont repliées en une entrée de taille TOTALE : annoncer 15 Go
pour un modèle qui en occupe 45 promet une place qui n'existe pas.

Le verdict mémoire s'appuie enfin sur le GPU. detectHardware() ne renvoyait que
la RAM système alors que detectGPUs() existait déjà : sur un serveur à carte
NVIDIA, le verdict se prononçait sur la mauvaise grandeur. Le coût du cache KV
reste une estimation assumée — l'exact demanderait de parser l'en-tête GGUF —
et l'interface l'annonce comme telle plutôt que d'afficher un chiffre
faussement sûr.

Le catalogue ajean.link disparaît. Sa route n'avait aucun consommateur (l'écran
d'accueil qu'elle attendait n'a jamais existé), son repli embarqué proposait du
Qwen2.5 de 2024, et un fork qui laisse le serveur de l'amont décider de ce
qu'il propose n'est pas vraiment un fork.

Une piste écartée en cours de route : marquer les dépôts « vision » d'après les
tags Hugging Face. Ils mentent — des deux dépôts GGUF de Qwen3.8-27B qui
publient tous deux un mmproj, seul ggml-org est taggé image-text-to-text. Une
pastille sur l'un et pas sur l'autre aurait été pire que rien. La vision est
donc déduite de la seule source qui ne se trompe pas : la présence d'un
mmproj-*.gguf dans l'arborescence.

Vérifié : 8 tests unitaires sur les arborescences réelles des deux dépôts, puis
au navigateur contre le vrai Hugging Face — 25 dépôts trouvés, 3 quants listés
sans aucun mtp ni mmproj, projecteur Q8_0 proposé et coché, verdicts affichés
avec leur explication, modale dans l'écran. L'ordre de la file (modèle puis
projecteur) est testé en interceptant les appels, sans transfert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VueWA9xcYadaYq65tBisix
This commit is contained in:
Claude committed 2026-08-16 11:02:35 +00:00
1 parent 1fa8452e44
commit 26e93f43ef
10 files changed
+1152 -66

No files matched your search

+13
View File
@@ -656,6 +656,19 @@
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div>
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
reste là : c'est le mode expert (dépôt privé, fichier exotique,
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
surtout il propose le projecteur vision DU MÊME DÉPÔT — le seul
qui corresponde au modèle choisi. -->
<div class="pe-row stack">
<span class="pe-row-l">Chercher un modèle<span class="pe-sub">sur Hugging Face — dépôts GGUF uniquement</span></span>
<div class="pe-inpbtn">
<input id="hf-q" class="pe-inp" placeholder="qwen3.8, mistral, gemma…" onkeydown="if(event.key==='Enter'){event.preventDefault();hfSearch();}">
<button id="hf-go" onclick="hfSearch()" title="Chercher">⌕</button>
</div>
<div id="hf-out"></div>
</div>
<div class="pe-row stack">
<span class="pe-row-l">Télécharger un modèle <span class="pe-sub">ou un projecteur mmproj — lien direct vers un .gguf</span></span>
<div class="pe-inpbtn">
+164 -3
View File
@@ -110,6 +110,7 @@ async function openItem(kind, key){
settingsRow.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 = '';
@@ -883,9 +884,18 @@ async function populateDlDirs(){
}
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
}
// File d'attente : installer un modèle avec son projecteur, c'est DEUX
// téléchargements, et le serveur n'en mène qu'un à la fois (une seule barre,
// un seul poll). On enchaîne donc à la fin de chacun plutôt que de les lancer
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
// vite et brouillent la progression affichée.
let dlQueue = [];
async function startDownload(){
const url = document.getElementById('m-hf-url').value.trim();
if(!url){ toast('colle un lien .gguf'); return; }
return startDownloadURL(url);
}
async function startDownloadURL(url){
const dir = (document.getElementById('m-hf-dir')||{}).value || '';
const e = dlEls();
e.btn.disabled = true;
@@ -900,6 +910,7 @@ async function startDownload(){
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;
}
@@ -910,7 +921,7 @@ async function startDownload(){
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
e.bar.style.display = 'none'; e.btn.disabled=false; return;
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
}
watchDownload(r.filename);
}
@@ -940,11 +951,11 @@ function watchDownload(fname){
if(!st) return;
if(st.canceled){
e.prog.textContent = 'téléchargement annulé — '+fname;
e.bar.style.display = 'none'; stop(); return;
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'; stop(); return;
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>';
@@ -962,6 +973,10 @@ function watchDownload(fname){
};
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
else pick('m-model', onPickModel);
// Suite de la file : c'est ici que le projecteur part, une fois le modèle
// arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
// est déjà sélectionné.
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
const pct = st.total>0 ? st.done*100/st.total : 0;
@@ -996,5 +1011,151 @@ async function attachDownload(){
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);
list.appendChild(row);
}
o.appendChild(list);
}
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
// « celui-là est le plus utilisé », pas son compte exact.
function fmtCount(n){
n = n || 0;
if(n >= 1e6) return (n/1e6).toFixed(1).replace(/\.0$/,'')+' M';
if(n >= 1e3) return Math.round(n/1e3)+' k';
return String(n);
}
async function hfPickRepo(repo){
hfMsg('lecture du dépôt…');
let r;
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
hfRepoFiles = r;
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
const o = hfOut(); o.textContent = '';
const head = document.createElement('div');
head.className = 'hf-head';
const back = document.createElement('button');
back.className = 'pe-link'; back.textContent = '← résultats';
back.onclick = hfSearch;
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
head.append(back, title);
o.appendChild(head);
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
// encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
// dit — on ne va pas en chercher un.
let mmChk = null;
const mm = (r.projectors||[])[0] && hfBestProjector(r.projectors);
const note = document.createElement('label');
note.className = 'hf-mm';
if(mm){
mmChk = document.createElement('input');
mmChk.type = 'checkbox'; mmChk.checked = true;
const t = document.createElement('span');
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')';
note.append(mmChk, t);
} else {
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
note.classList.add('muted');
}
o.appendChild(note);
const list = document.createElement('div'); list.className = 'hf-list';
for(const m of r.models){
const row = document.createElement('div');
row.className = 'hf-row';
row.onclick = ()=>hfInstall(m, mm && mmChk && mmChk.checked ? mm : null);
const n = document.createElement('span'); n.className = 'hf-name';
n.textContent = m.quant || m.name;
const meta = document.createElement('span'); meta.className = 'hf-meta';
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
row.append(n, meta);
if(m.verdict){
const fit = document.createElement('span');
fit.className = 'hf-fit ' + m.verdict;
fit.textContent = m.verdict;
fit.title = m.why || '';
row.appendChild(fit);
}
list.appendChild(row);
}
o.appendChild(list);
if(r.hardware){
const hw = document.createElement('div');
hw.className = 'pe-note muted';
hw.textContent = r.hardware.vram_gb > 0
? 'Verdict calculé sur '+r.hardware.vram_gb.toFixed(1)+' Go de VRAM et un contexte de '+r.ctx+' jetons (estimation).'
: 'Aucun GPU détecté : verdict calculé sur '+r.hardware.ram_gb.toFixed(1)+' Go de RAM (estimation).';
o.appendChild(hw);
}
}
// Q8_0 d'abord (629 Mo contre 931 pour le BF16, sans différence perceptible sur
// un encodeur d'images), sinon le plus léger. Même règle que hfPickProjector
// côté serveur — l'UI choisit pour ne pas imposer un menu de plus.
function hfBestProjector(list){
return list.find(p => /^Q8_0$/i.test(p.quant || '')) || list.slice().sort((a,b)=>a.size-b.size)[0];
}
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
// champ Vision du preset ne se remplit correctement que dans cet ordre.
async function hfInstall(model, projector){
if(model.verdict === 'trop' && !await askConfirm(
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
dlQueue = projector ? [projector.url] : [];
document.getElementById('m-hf-url').value = model.url;
await startDownloadURL(model.url);
}
// Smart autoscroll: follow the bottom while the user hasn't manually scrolled
// up. Re-stick when they scroll back near bottom themselves.
+28
View File
@@ -1366,3 +1366,31 @@ button:hover{border-color:var(--dim);color:var(--text)}
.sidegroup>details:first-child{border-top:0}
.sidegroup>details[open]{padding-bottom:12px}
}
/* --- Recherche Hugging Face -------------------------------------------
Deux listes successives dans la même zone (dépôts, puis fichiers du dépôt
choisi) : une seule mise en forme sert aux deux, sinon elles divergent. La
liste est bornée en hauteur et défile — un dépôt publie facilement vingt
quantifications, et la modale ne doit pas s'étirer jusqu'à sortir de
l'écran. */
.hf-list{display:flex;flex-direction:column;max-height:220px;overflow-y:auto;
border:1px solid var(--border);border-radius:12px;margin-top:6px}
.hf-row{display:flex;align-items:center;gap:10px;padding:7px 12px;cursor:pointer;
border-top:1px solid var(--border);font-size:12px}
.hf-row:first-child{border-top:0}
.hf-row:hover{background:var(--row-bg-on)}
/* Le nom prend la place restante et se tronque : un identifiant de dépôt long
ne doit pas pousser la taille et le verdict hors de la ligne. */
.hf-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
.hf-meta{flex:none;color:var(--dim);font-family:var(--mono);font-size:10.5px;white-space:nowrap}
/* Verdict mémoire. La couleur seule ne suffit pas — le mot est écrit dedans,
et l'explication complète est dans l'infobulle. */
.hf-fit{flex:none;padding:1px 8px;border-radius:999px;font-size:10px;letter-spacing:.03em;
text-transform:uppercase;border:1px solid transparent}
.hf-fit.ok{color:var(--ok);border-color:var(--ok)}
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
.hf-fit.trop{color:var(--err);border-color:var(--err)}
.hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px}
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
color:var(--dim);cursor:pointer}
.hf-mm.muted{cursor:default}