mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-20 13:38:11 +02:00
Modèles : un .gguf gardé se réutilise et s'efface
Un modèle téléchargé depuis l'éditeur d'un preset survit à la suppression de ce preset (c'est voulu : on le réutilise ailleurs). Il devenait pourtant inatteignable — impossible de refaire un preset dessus, impossible de l'effacer. Trois causes, trois correctifs. 1. « Le modèle existe déjà » n'est plus une erreur. La recherche Hugging Face proposait le quant, le clic lançait la sonde, et le téléchargement répondait en rouge « le modèle existe déjà » — fin du parcours. La sonde constate maintenant la présence du fichier (sans même sortir sur le réseau) et renvoie la valeur à écrire dans MODEL= : l'interface le SÉLECTIONNE, et la file continue (le projecteur vision, par exemple). Les quants déjà présents portent une pastille « déjà installé » dans la liste du dépôt, avant le clic. 2. Le sélecteur de modèle reconnaissait mal ce qu'il avait sous les yeux. /api/models comparait le dossier de chaque .gguf à LokiHome() alors que les téléchargements atterrissent dans $LOKI_HOME/models : la comparaison ne pouvait jamais être vraie. Conséquences : l'étiquette « dossier loki » ne s'affichait nulle part, et un preset écrit MODEL=modele.gguf s'affichait « introuvable ; ajoute son dossier ci-dessous » — le fichier étant juste à côté. Un nom simple est désormais résolu comme le fait le moteur : dossier de loki d'abord, puis les autres. 3. Une liste « Modèles installés », dans le groupe Modèle de l'éditeur. 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 liste montre taille, dossier, tranches manquantes, et qui s'en sert : le modèle en service ne s'efface pas (le moteur l'a ouvert, la place ne serait même pas rendue), celui que des presets nomment prévient en les nommant puis obéit. La suppression dit ce qu'elle a libéré. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
This commit is contained in:
9 files changed
+1061
-71
No files matched your search
@@ -1028,6 +1028,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
|
||||
</div>
|
||||
<!-- Modèles installés. Un .gguf survit à la suppression de son preset
|
||||
(c'est voulu : on le réutilise dans un autre). Encore faut-il
|
||||
pouvoir le REPRENDRE et, un jour, l'effacer — sans ça les
|
||||
fichiers gardés devenaient invisibles et indéboulonnables. -->
|
||||
<div id="m-inst-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalled()">
|
||||
<span class="pe-row-l">Modèles installés<span class="pe-sub">les .gguf présents sur le disque — et de quoi en effacer un</span></span>
|
||||
<span class="pe-row-c"><span id="m-inst-state" class="pe-sub"></span><span id="m-inst-caret" class="pe-caret"></span></span>
|
||||
</div>
|
||||
<div id="m-inst-body" class="pe-row stack" style="display:none">
|
||||
<div id="m-inst-list"></div>
|
||||
</div>
|
||||
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
|
||||
touche qu'une fois, le jour où un dépôt à accès restreint refuse
|
||||
le téléchargement. Il vaut aussi pour la recherche, d'où sa place
|
||||
|
||||
@@ -121,6 +121,9 @@ async function openItem(kind, key){
|
||||
// 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);
|
||||
@@ -161,7 +164,8 @@ async function openItem(kind, key){
|
||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||
populateSettings();
|
||||
attachDownload(); // téléchargement encore en cours côté serveur ?
|
||||
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(),
|
||||
populateDlDirs(), refreshInstalled()]);
|
||||
if(seq !== openSeq) return;
|
||||
}
|
||||
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
|
||||
@@ -210,12 +214,31 @@ function samePath(a, b){
|
||||
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, LOKI_HOME d'abord (l'API les renvoie dans cet ordre).
|
||||
// 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);
|
||||
@@ -227,9 +250,7 @@ async function populateModelPicker(){
|
||||
for(const g of groups){
|
||||
let opts = '';
|
||||
for(const m of g.items){
|
||||
// Le preset peut référencer le chemin complet OU le simple nom de fichier.
|
||||
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
|
||||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
|
||||
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.
|
||||
@@ -376,6 +397,141 @@ async function removeModelDir(p){
|
||||
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(), 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;
|
||||
@@ -417,11 +573,11 @@ async function populateMmproj(){
|
||||
const list = await jget('/api/models');
|
||||
const cur = currentMmprojInTextarea();
|
||||
const items = (list||[]).filter(m => isMmprojName(m.name));
|
||||
const hit = matchModel(cur, items);
|
||||
let html = '<option value="">— aucune —</option>';
|
||||
let matched = false;
|
||||
for(const m of items){
|
||||
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
|
||||
samePath(baseName(cur), m.name) ? ' selected' : '';
|
||||
const on = (m === hit) ? ' selected' : '';
|
||||
if(on) matched = true;
|
||||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||
}
|
||||
@@ -877,8 +1033,11 @@ async function delItem(){
|
||||
const r = await jpost(K.delUrl, payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
if(delModel){
|
||||
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
|
||||
else if(r.modelDeleted) toast('preset + modèle supprimés');
|
||||
// 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();
|
||||
@@ -932,6 +1091,22 @@ async function populateDlDirs(){
|
||||
// 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(), 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;
|
||||
};
|
||||
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; }
|
||||
@@ -949,6 +1124,20 @@ async function startDownloadURL(url){
|
||||
// Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
|
||||
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
|
||||
const p = await jpost('/api/models/download/probe', {url, dir});
|
||||
// Modèle DÉJÀ sur le disque : il n'y a rien à télécharger, il y a un modèle à
|
||||
// choisir. C'était le cul-de-sac — un .gguf gardé après la suppression de son
|
||||
// preset ne pouvait plus servir à en refaire un : le téléchargement répondait
|
||||
// « le modèle existe déjà » en rouge et s'arrêtait là.
|
||||
if(p.ok && p.exists){
|
||||
e.bar.style.display = 'none';
|
||||
e.btn.disabled = false;
|
||||
const done = await selectInstalled(p.filename);
|
||||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(p.filename)
|
||||
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '')
|
||||
+ (p.size > 0 ? ' (' + fmtSize(p.size) + ')' : '') + '</span>';
|
||||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||||
return;
|
||||
}
|
||||
if(!p.ok || !p.enough){
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
|
||||
e.bar.style.display = 'none'; e.btn.disabled=false;
|
||||
@@ -964,9 +1153,19 @@ async function startDownloadURL(url){
|
||||
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
|
||||
const r = await jpost('/api/models/download', {url, dir});
|
||||
if(!r.ok){
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(r.error||'')+'</span>';
|
||||
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
|
||||
}
|
||||
// Le serveur peut constater lui aussi que tout est déjà là (fichier apparu
|
||||
// entre la sonde et l'envoi, ou lien collé à la main sans passer par la sonde).
|
||||
if(r.exists){
|
||||
e.bar.style.display = 'none'; e.btn.disabled = false;
|
||||
const done = await selectInstalled(r.filename);
|
||||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(r.filename)
|
||||
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '') + '</span>';
|
||||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||||
return;
|
||||
}
|
||||
watchDownload(r.filename);
|
||||
}
|
||||
// Annule le téléchargement en cours : le serveur coupe les connexions et
|
||||
@@ -1005,18 +1204,7 @@ function watchDownload(fname){
|
||||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
||||
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
|
||||
stop();
|
||||
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
|
||||
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
|
||||
// a pu atterrir hors du dossier loki : l'option porte alors le chemin
|
||||
// complet, pas le simple nom de fichier.
|
||||
const pick = (id, cb)=>{
|
||||
const s = document.getElementById(id);
|
||||
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
|
||||
if(o){ s.value = o.value; cb(); }
|
||||
};
|
||||
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
|
||||
else pick('m-model', onPickModel);
|
||||
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é.
|
||||
@@ -1133,10 +1321,19 @@ function fmtCount(n){
|
||||
|
||||
async function hfPickRepo(repo){
|
||||
hfMsg('lecture du dépôt…');
|
||||
let r;
|
||||
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
|
||||
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; }
|
||||
|
||||
@@ -1191,7 +1388,8 @@ async function hfPickRepo(repo){
|
||||
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)+')';
|
||||
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.';
|
||||
@@ -1209,6 +1407,17 @@ async function hfPickRepo(repo){
|
||||
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;
|
||||
@@ -1240,7 +1449,10 @@ function hfBestProjector(list){
|
||||
// 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(
|
||||
// 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 ? [projector.url] : [];
|
||||
|
||||
@@ -1465,7 +1465,28 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
endroit et ça décide autant de la suite — un dépôt gated ne se télécharge
|
||||
pas sans jeton. */
|
||||
.hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none}
|
||||
/* Déjà sur le disque : ce n'est pas un verdict mémoire non plus, mais c'est
|
||||
l'information qui décide du clic — cliquer sélectionne au lieu de télécharger. */
|
||||
.hf-fit.here{color:var(--accent);border-color:var(--accent);text-transform:none}
|
||||
.hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px}
|
||||
|
||||
/* --- Modèles installés (liste + suppression) --------------------------------
|
||||
Même allure que la liste Hugging Face juste au-dessus, mais la LIGNE n'est
|
||||
pas cliquable : ici c'est le bouton « supprimer » qui agit, et un curseur
|
||||
main sur toute la ligne laisserait croire le contraire. */
|
||||
.mdl-list{display:flex;flex-direction:column;max-height:240px;overflow-y:auto;
|
||||
border:1px solid var(--border);border-radius:3px;margin-top:6px}
|
||||
.mdl-row{display:flex;align-items:center;gap:10px;padding:7px 12px;
|
||||
border-top:1px solid var(--border);font-size:12px}
|
||||
.mdl-row:first-child{border-top:0}
|
||||
.mdl-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)}
|
||||
.mdl-meta{flex:none;color:var(--dim);font-family:var(--mono);font-size:10.5px;white-space:nowrap}
|
||||
.mdl-tag{flex:none;padding:1px 8px;border-radius:999px;font-size:10px;letter-spacing:.03em;
|
||||
border:1px solid var(--border);color:var(--dim)}
|
||||
.mdl-tag.on{color:var(--accent);border-color:var(--accent)}
|
||||
.mdl-tag.warn{color:var(--warn);border-color:var(--warn)}
|
||||
.mdl-row .pe-link{flex:none}
|
||||
.mdl-row .pe-link:disabled{opacity:.4;cursor:not-allowed;text-decoration:none}
|
||||
.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}
|
||||
|
||||
Reference in new issue
Block a user