Sélecteur de modèle qui charge vraiment ; % de chargement qui bouge

Quatre retouches d'interface :

- Le sélecteur de l'en-tête liste maintenant les MODÈLES du disque (groupe
  « Modèles (.gguf) ») en plus des presets : en choisir un le charge —
  POST /api/models/use écrit MODEL seul (contexte, NGL et échantillonnage
  conservés) et redémarre le service en arrière-plan. Sans preset créé, le
  sélecteur n'offrait aucun choix.

- Pourcentage de chargement : sur un redémarrage à chaud, le modèle est déjà
  dans le cache disque — llama-server ne lit rien (read_bytes reste à 0) et
  la pastille passait de « 0 % » à « prêt » sans jamais monter. On prend le
  plus avancé de read_bytes et de la mémoire résidente (VmRSS), qui grandit
  cache ou pas.

- Discussions triées par date de CRÉATION (récentes en tête) : une
  discussion garde sa place, écrire dans un vieux fil ne le fait plus
  remonter.

- Bouton Réglages ancré en pied de barre latérale, juste au-dessus du
  moniteur Performance — toujours au même endroit, quel que soit le nombre
  de discussions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Fable 5 committed 2026-08-20 22:13:16 +02:00
1 parent ad107ed284
commit 86d711e772
9 files changed
+207 -46

No files matched your search

+28
View File
@@ -20,6 +20,24 @@ import (
"strings" "strings"
) )
// procVmRSS lit la mémoire résidente (octets) d'un process — /proc/<pid>/status,
// ligne « VmRSS: N kB ». 0 si illisible.
func procVmRSS(pid int) int64 {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
if err != nil {
return 0
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "VmRSS:"); ok {
v = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB"))
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
return n * 1024
}
}
}
return 0
}
// engineLoadPct renvoie le pourcentage estimé de chargement du modèle, ou -1 // engineLoadPct renvoie le pourcentage estimé de chargement du modèle, ou -1
// quand il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue). // quand il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue).
func engineLoadPct() int { func engineLoadPct() int {
@@ -54,6 +72,16 @@ func engineLoadPct() int {
if read < 0 { if read < 0 {
return -1 return -1
} }
// Modèle déjà dans le cache disque (redémarrage à chaud) : llama-server ne
// LIT plus rien sur le disque — les pages viennent du cache, read_bytes
// reste à zéro et le pourcentage restait figé à 0 jusqu'au « prêt ». La
// mémoire résidente (VmRSS), elle, grandit quoi qu'il arrive : les pages du
// .gguf touchées via mmap y comptent, cache ou pas. On prend le plus avancé
// des deux — approximatif (l'offload GPU peut relâcher des pages), mais le
// compteur BOUGE au lieu de mentir.
if rss := procVmRSS(pid); rss > read {
read = rss
}
pct := int(read * 100 / size) pct := int(read * 100 / size)
if pct > 99 { if pct > 99 {
// 100 % n'existe pas ici : c'est /health qui dit « prêt ». Au-delà de 99 // 100 % n'existe pas ici : c'est /health qui dit « prêt ». Au-delà de 99
+73 -23
View File
@@ -2069,7 +2069,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les
panneaux réutilisent tels quels les gabarits de la barre latérale (.slist, panneaux réutilisent tels quels les gabarits de la barre latérale (.slist,
.switchrow, .subhead…), seuls la coque et le routage sont neufs. */ .switchrow, .subhead…), seuls la coque et le routage sont neufs. */
#side-settings-btn{display:flex;align-items:center;gap:8px;width:100%;margin:8px 0 0;padding:10px 12px; #side-settings-btn{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:10px 14px;flex:none;
background:none;border:0;border-top:1px solid var(--border);border-radius:0;box-shadow:none; background:none;border:0;border-top:1px solid var(--border);border-radius:0;box-shadow:none;
font-size:13px;color:var(--text);cursor:pointer;text-align:left} font-size:13px;color:var(--text);cursor:pointer;text-align:left}
#side-settings-btn:hover{background:var(--bubble)} #side-settings-btn:hover{background:var(--bubble)}
@@ -2205,15 +2205,16 @@ html[data-files="1"] #files-btn{color:var(--accent)}
latérale par défaut, c'est les discussions et la machine. Les sections latérale par défaut, c'est les discussions et la machine. Les sections
ouvertes par programme (journal du moteur, installation llama.cpp) ouvertes par programme (journal du moteur, installation llama.cpp)
passent par openDetails(), qui déplie aussi les parents. --> passent par openDetails(), qui déplie aussi les parents. -->
<!-- Réglages : tout vit désormais dans une modale (nav de sections à gauche, </div><!-- /#side-scroll -->
panneau à droite) — la barre latérale ne garde que les discussions et le <!-- Réglages : tout vit dans une modale (nav de sections à gauche, panneau à
moniteur machine. Voir js/22-settings-modal.js ; les ids historiques des droite) — voir js/22-settings-modal.js ; les ids historiques des sections
sections (svc-log-box, lc-details…) sont conservés sur les panneaux. --> (svc-log-box, lc-details…) sont conservés sur les panneaux. Le bouton est
ANCRÉ en pied de barre, juste au-dessus du moniteur machine : toujours au
même endroit, quel que soit le nombre de discussions. -->
<button id="side-settings-btn" onclick="openSettings()" aria-haspopup="dialog"> <button id="side-settings-btn" onclick="openSettings()" aria-haspopup="dialog">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.35a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.65 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09c0 .68.4 1.3 1.03 1.56a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.06c.26.63.88 1.03 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.56 1.03z"/></svg> <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.35a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.65 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09c0 .68.4 1.3 1.03 1.56a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.06c.26.63.88 1.03 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.56 1.03z"/></svg>
<span>Réglages</span> <span>Réglages</span>
</button> </button>
</div><!-- /#side-scroll -->
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en <!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
jauges fines — visible en permanence sans jamais disputer la place au jauges fines — visible en permanence sans jamais disputer la place au
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
@@ -4300,7 +4301,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test // suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]); await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
} }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
@@ -8102,13 +8103,12 @@ async function loadConversations(){
let r; let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; } try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || []; CONVS = r.conversations || [];
// La plus récente EN TÊTE. Le serveur trie déjà par date d'activité, mais son // Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// tri STABLE laissait une discussion toute neuve SOUS celle qu'on vient de // dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// quitter (les deux sont touchées dans la même seconde) : on départage par la // remonter (le serveur, lui, trie par activité). Départage par l'ordre du
// date de création, puis par l'ordre du fichier d'index (création croissante). // fichier d'index pour les créations dans la même seconde.
const ord = new Map(CONVS.map((c, i) => [c.id, i])); const ord = new Map(CONVS.map((c, i) => [c.id, i]));
CONVS.sort((a, b) => (b.updated || 0) - (a.updated || 0) CONVS.sort((a, b) => (b.created || 0) - (a.created || 0)
|| (b.created || 0) - (a.created || 0)
|| ord.get(b.id) - ord.get(a.id)); || ord.get(b.id) - ord.get(a.id));
CONV_ACTIVE = r.active || ''; CONV_ACTIVE = r.active || '';
CONV_BUSY = !!r.busy; CONV_BUSY = !!r.busy;
@@ -8433,7 +8433,14 @@ function openDetails(el){
// à cliquer la ligne correspondante dans les réglages (switchTo demande // à cliquer la ligne correspondante dans les réglages (switchTo demande
// confirmation puis relance le moteur). // confirmation puis relance le moteur).
function onModelSwitch(sel){ function onModelSwitch(sel){
const n = parseInt(sel.value, 10); const v = sel.value;
// Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
// active garde son contexte et son échantillonnage, seul MODEL change.
if(v && v.startsWith('m:')){
switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
return;
}
const n = parseInt(v, 10);
if(!n) return; if(!n) return;
const name = sel.options[sel.selectedIndex].textContent; const name = sel.options[sel.selectedIndex].textContent;
// La liste est repeinte par loadPresets() une fois la bascule faite (ou // La liste est repeinte par loadPresets() une fois la bascule faite (ou
@@ -8442,6 +8449,22 @@ function onModelSwitch(sel){
switchTo(n, name); switchTo(n, name);
} }
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
// revient sur le modèle réellement chargé.
async function switchModelFile(value, name){
if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
paintModelSwitch();
return;
}
toast('changement de modèle…');
let r = null;
try{ r = await jpost('/api/models/use', {value}); }catch(_){}
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
toast('modèle en cours de chargement…');
loadAll();
}
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif. // Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
// //
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son // Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
@@ -8454,12 +8477,22 @@ function onModelSwitch(sel){
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le // Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent // modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
// en parallèle (loadAll), aucun ordre n'est garanti. // en parallèle (loadAll), aucun ordre n'est garanti.
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = ''; let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
function renderModelSwitch(presets, active){ function renderModelSwitch(presets, active){
MS_PRESETS = presets || []; MS_ACTIVE = active || null; MS_PRESETS = presets || []; MS_ACTIVE = active || null;
paintModelSwitch(); paintModelSwitch();
} }
// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
async function loadModelSwitchFiles(){
try{
const r = await jget('/api/models');
MS_FILES = Array.isArray(r) ? r : [];
}catch(_){ return; }
paintModelSwitch();
}
// Appelé par loadCfg avec la valeur MODEL de la configuration active. // Appelé par loadCfg avec la valeur MODEL de la configuration active.
function setLoadedModel(name){ function setLoadedModel(name){
MS_MODEL = (name || '').trim(); MS_MODEL = (name || '').trim();
@@ -8486,14 +8519,31 @@ function paintModelSwitch(){
o.selected = true; o.selected = true;
sel.appendChild(o); sel.appendChild(o);
} }
MS_PRESETS.forEach((p, i) => { if(MS_PRESETS.length){
const o = document.createElement('option'); const g = document.createElement('optgroup'); g.label = 'Presets';
o.value = String(i + 1); MS_PRESETS.forEach((p, i) => {
o.textContent = p.name; const o = document.createElement('option');
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true; o.value = String(i + 1);
sel.appendChild(o); o.textContent = p.name;
}); if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
sel.disabled = !MS_PRESETS.length; g.appendChild(o);
});
sel.appendChild(g);
}
// Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
// preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
if(MS_FILES.length){
const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
for(const m of MS_FILES){
const o = document.createElement('option');
o.value = 'm:' + m.value;
o.textContent = modelLabel(m.name);
if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
g.appendChild(o);
}
sel.appendChild(g);
}
sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
sel.title = MS_MODEL sel.title = MS_MODEL
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur' ? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
: 'aucun modèle configuré'; : 'aucun modèle configuré';
+6 -5
View File
@@ -75,15 +75,16 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
latérale par défaut, c'est les discussions et la machine. Les sections latérale par défaut, c'est les discussions et la machine. Les sections
ouvertes par programme (journal du moteur, installation llama.cpp) ouvertes par programme (journal du moteur, installation llama.cpp)
passent par openDetails(), qui déplie aussi les parents. --> passent par openDetails(), qui déplie aussi les parents. -->
<!-- Réglages : tout vit désormais dans une modale (nav de sections à gauche, </div><!-- /#side-scroll -->
panneau à droite) — la barre latérale ne garde que les discussions et le <!-- Réglages : tout vit dans une modale (nav de sections à gauche, panneau à
moniteur machine. Voir js/22-settings-modal.js ; les ids historiques des droite) — voir js/22-settings-modal.js ; les ids historiques des sections
sections (svc-log-box, lc-details…) sont conservés sur les panneaux. --> (svc-log-box, lc-details…) sont conservés sur les panneaux. Le bouton est
ANCRÉ en pied de barre, juste au-dessus du moniteur machine : toujours au
même endroit, quel que soit le nombre de discussions. -->
<button id="side-settings-btn" onclick="openSettings()" aria-haspopup="dialog"> <button id="side-settings-btn" onclick="openSettings()" aria-haspopup="dialog">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.35a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.65 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09c0 .68.4 1.3 1.03 1.56a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.06c.26.63.88 1.03 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.56 1.03z"/></svg> <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.35a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.65 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09c0 .68.4 1.3 1.03 1.56a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.06c.26.63.88 1.03 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.56 1.03z"/></svg>
<span>Réglages</span> <span>Réglages</span>
</button> </button>
</div><!-- /#side-scroll -->
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en <!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
jauges fines — visible en permanence sans jamais disputer la place au jauges fines — visible en permanence sans jamais disputer la place au
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
+1 -1
View File
@@ -451,7 +451,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute // MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test // suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard. // navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]); await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
} }
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+5 -6
View File
@@ -24,13 +24,12 @@ async function loadConversations(){
let r; let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; } try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || []; CONVS = r.conversations || [];
// La plus récente EN TÊTE. Le serveur trie déjà par date d'activité, mais son // Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// tri STABLE laissait une discussion toute neuve SOUS celle qu'on vient de // dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// quitter (les deux sont touchées dans la même seconde) : on départage par la // remonter (le serveur, lui, trie par activité). Départage par l'ordre du
// date de création, puis par l'ordre du fichier d'index (création croissante). // fichier d'index pour les créations dans la même seconde.
const ord = new Map(CONVS.map((c, i) => [c.id, i])); const ord = new Map(CONVS.map((c, i) => [c.id, i]));
CONVS.sort((a, b) => (b.updated || 0) - (a.updated || 0) CONVS.sort((a, b) => (b.created || 0) - (a.created || 0)
|| (b.created || 0) - (a.created || 0)
|| ord.get(b.id) - ord.get(a.id)); || ord.get(b.id) - ord.get(a.id));
CONV_ACTIVE = r.active || ''; CONV_ACTIVE = r.active || '';
CONV_BUSY = !!r.busy; CONV_BUSY = !!r.busy;
+60 -10
View File
@@ -52,7 +52,14 @@ function openDetails(el){
// à cliquer la ligne correspondante dans les réglages (switchTo demande // à cliquer la ligne correspondante dans les réglages (switchTo demande
// confirmation puis relance le moteur). // confirmation puis relance le moteur).
function onModelSwitch(sel){ function onModelSwitch(sel){
const n = parseInt(sel.value, 10); const v = sel.value;
// Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
// active garde son contexte et son échantillonnage, seul MODEL change.
if(v && v.startsWith('m:')){
switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
return;
}
const n = parseInt(v, 10);
if(!n) return; if(!n) return;
const name = sel.options[sel.selectedIndex].textContent; const name = sel.options[sel.selectedIndex].textContent;
// La liste est repeinte par loadPresets() une fois la bascule faite (ou // La liste est repeinte par loadPresets() une fois la bascule faite (ou
@@ -61,6 +68,22 @@ function onModelSwitch(sel){
switchTo(n, name); switchTo(n, name);
} }
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
// revient sur le modèle réellement chargé.
async function switchModelFile(value, name){
if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
paintModelSwitch();
return;
}
toast('changement de modèle…');
let r = null;
try{ r = await jpost('/api/models/use', {value}); }catch(_){}
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
toast('modèle en cours de chargement…');
loadAll();
}
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif. // Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
// //
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son // Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
@@ -73,12 +96,22 @@ function onModelSwitch(sel){
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le // Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent // modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
// en parallèle (loadAll), aucun ordre n'est garanti. // en parallèle (loadAll), aucun ordre n'est garanti.
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = ''; let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
function renderModelSwitch(presets, active){ function renderModelSwitch(presets, active){
MS_PRESETS = presets || []; MS_ACTIVE = active || null; MS_PRESETS = presets || []; MS_ACTIVE = active || null;
paintModelSwitch(); paintModelSwitch();
} }
// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
async function loadModelSwitchFiles(){
try{
const r = await jget('/api/models');
MS_FILES = Array.isArray(r) ? r : [];
}catch(_){ return; }
paintModelSwitch();
}
// Appelé par loadCfg avec la valeur MODEL de la configuration active. // Appelé par loadCfg avec la valeur MODEL de la configuration active.
function setLoadedModel(name){ function setLoadedModel(name){
MS_MODEL = (name || '').trim(); MS_MODEL = (name || '').trim();
@@ -105,14 +138,31 @@ function paintModelSwitch(){
o.selected = true; o.selected = true;
sel.appendChild(o); sel.appendChild(o);
} }
MS_PRESETS.forEach((p, i) => { if(MS_PRESETS.length){
const o = document.createElement('option'); const g = document.createElement('optgroup'); g.label = 'Presets';
o.value = String(i + 1); MS_PRESETS.forEach((p, i) => {
o.textContent = p.name; const o = document.createElement('option');
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true; o.value = String(i + 1);
sel.appendChild(o); o.textContent = p.name;
}); if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
sel.disabled = !MS_PRESETS.length; g.appendChild(o);
});
sel.appendChild(g);
}
// Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
// preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
if(MS_FILES.length){
const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
for(const m of MS_FILES){
const o = document.createElement('option');
o.value = 'm:' + m.value;
o.textContent = modelLabel(m.name);
if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
g.appendChild(o);
}
sel.appendChild(g);
}
sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
sel.title = MS_MODEL sel.title = MS_MODEL
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur' ? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
: 'aucun modèle configuré'; : 'aucun modèle configuré';
+1 -1
View File
@@ -2038,7 +2038,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les
panneaux réutilisent tels quels les gabarits de la barre latérale (.slist, panneaux réutilisent tels quels les gabarits de la barre latérale (.slist,
.switchrow, .subhead…), seuls la coque et le routage sont neufs. */ .switchrow, .subhead…), seuls la coque et le routage sont neufs. */
#side-settings-btn{display:flex;align-items:center;gap:8px;width:100%;margin:8px 0 0;padding:10px 12px; #side-settings-btn{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:10px 14px;flex:none;
background:none;border:0;border-top:1px solid var(--border);border-radius:0;box-shadow:none; background:none;border:0;border-top:1px solid var(--border);border-radius:0;box-shadow:none;
font-size:13px;color:var(--text);cursor:pointer;text-align:left} font-size:13px;color:var(--text);cursor:pointer;text-align:left}
#side-settings-btn:hover{background:var(--bubble)} #side-settings-btn:hover{background:var(--bubble)}
+32
View File
@@ -940,6 +940,38 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true}) sendJSON(w, 200, map[string]any{"ok": true})
} }
// handleModelUse charge un MODÈLE précis (fichier .gguf) sans passer par un
// preset : seul MODEL change dans la configuration active — contexte, NGL et
// échantillonnage sont conservés — et le service redémarre en arrière-plan,
// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête
// utilisable même sans avoir créé de preset.
func handleModelUse(w http.ResponseWriter, r *http.Request) {
var req struct {
Value string `json:"value"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Value) == "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle manquant"})
return
}
// Le fichier doit exister AVANT d'écrire la config : une faute de frappe ne
// doit pas laisser un moteur qui boucle sur un modèle introuvable.
if _, err := resolveServeModelPath(req.Value); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := SetConfigKey("MODEL", req.Value); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
fmt.Printf("%s MODEL <- %s\n", green("[ok]"), req.Value)
go func() {
if err := serviceAction("restart"); err != nil {
fmt.Println(red("[err] redémarrage : " + err.Error()))
}
}()
sendJSON(w, 200, map[string]any{"ok": true})
}
func handleSwitch(w http.ResponseWriter, r *http.Request) { func handleSwitch(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
N int `json:"n"` N int `json:"n"`
+1
View File
@@ -179,6 +179,7 @@ func newWebMux() *http.ServeMux {
api("/api/models", handleModels) api("/api/models", handleModels)
api("/api/models/delete", handleModelDelete) api("/api/models/delete", handleModelDelete)
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…) api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
api("/api/models/use", handleModelUse) // charger CE .gguf (sélecteur de l'en-tête)
api("/api/models/download", handleModelDownload) api("/api/models/download", handleModelDownload)
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
api("/api/models/download/status", handleModelDownloadStatus) api("/api/models/download/status", handleModelDownloadStatus)