mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
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:
1 parent
ad107ed284
commit
86d711e772
9 files changed
+207
-46
No files matched your search
@@ -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
@@ -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é';
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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); }
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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é';
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user