Le sélecteur de l'en-tête ne charge que des presets (repris de la PR #25)

Le sélecteur de l'en-tête proposait, sous les presets, un groupe « Modèles
(.gguf) » listant les fichiers du disque. En choisir un écrivait MODEL= et
rien d'autre : le contexte, le NGL et l'échantillonnage du modèle
précédent restaient en place — un 27B chargé par-dessus les réglages d'un
4B partait sur 100k de contexte et mourait en OOM, sans que rien ne dise
pourquoi. Charger un modèle, c'est appliquer tous ses réglages : ça
s'appelle un preset, et c'est désormais le seul chemin.

/api/models/use disparaît avec le groupe qu'elle servait, ainsi que
switchModelFile, loadModelSwitchFiles et MS_FILES côté interface. Le title
du sélecteur le dit : « preset actif — en changer recharge tous ses
réglages et relance le moteur ».

Repris tel quel de la PR #25, dont l'historique (antérieur à la réécriture
de main) ne peut plus y être fusionné. index.html régénéré par
tools/assemble-ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-15 12:35:50 +00:00
1 parent 911501ac0b
commit 57629f7254
6 files changed
+26 -140

No files matched your search

+13 -52
View File
@@ -2407,7 +2407,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
</button>
<div id="topbar-title">Nouvelle discussion</div>
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
<select id="model-switch" onchange="onModelSwitch(this)" title="preset actif — en changer recharge tous ses réglages et relance le moteur" aria-label="preset actif"></select>
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
@@ -5246,7 +5246,7 @@ async function loadAll(){
// 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
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]);
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); }
@@ -10863,17 +10863,18 @@ function openDetails(el){
}
}
// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
// à cliquer la ligne correspondante dans les réglages (switchTo demande
// Sélecteur de modèle de l'en-tête. Il ne liste QUE des presets ; en choisir un
// revient à cliquer la ligne correspondante dans les réglages (switchTo demande
// confirmation puis relance le moteur).
//
// Il a listé un temps les .gguf du disque, qu'on chargeait en n'écrivant que
// MODEL= : le contexte, le NGL et l'échantillonnage du modèle PRÉCÉDENT
// restaient en place. Un 27B chargé par-dessus les réglages d'un 4B partait sur
// 100k de contexte et mourait en OOM, sans que rien ne dise pourquoi. Charger un
// modèle, c'est appliquer TOUS ses réglages : ça s'appelle un preset, et c'est
// le seul chemin.
function onModelSwitch(sel){
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;
const name = sel.options[sel.selectedIndex].textContent;
@@ -10883,22 +10884,6 @@ function onModelSwitch(sel){
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
}
// 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.
//
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
@@ -10911,23 +10896,12 @@ async function switchModelFile(value, name){
// 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
// en parallèle (loadAll), aucun ordre n'est garanti.
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
function renderModelSwitch(presets, active){
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
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');
// Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas.
MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||'')));
}catch(_){ return; }
paintModelSwitch();
}
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
function setLoadedModel(name){
MS_MODEL = (name || '').trim();
@@ -10966,20 +10940,7 @@ function paintModelSwitch(){
});
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.disabled = !MS_PRESETS.length;
sel.title = MS_MODEL
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
: 'aucun modèle configuré';
+1 -1
View File
@@ -122,7 +122,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
</button>
<div id="topbar-title">Nouvelle discussion</div>
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
<select id="model-switch" onchange="onModelSwitch(this)" title="preset actif — en changer recharge tous ses réglages et relance le moteur" aria-label="preset actif"></select>
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
+1 -1
View File
@@ -673,7 +673,7 @@ async function loadAll(){
// 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
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]);
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); }
+11 -50
View File
@@ -48,17 +48,18 @@ function openDetails(el){
}
}
// Sélecteur de modèle de l'en-tête. Il liste les presets ; en choisir un revient
// à cliquer la ligne correspondante dans les réglages (switchTo demande
// Sélecteur de modèle de l'en-tête. Il ne liste QUE des presets ; en choisir un
// revient à cliquer la ligne correspondante dans les réglages (switchTo demande
// confirmation puis relance le moteur).
//
// Il a listé un temps les .gguf du disque, qu'on chargeait en n'écrivant que
// MODEL= : le contexte, le NGL et l'échantillonnage du modèle PRÉCÉDENT
// restaient en place. Un 27B chargé par-dessus les réglages d'un 4B partait sur
// 100k de contexte et mourait en OOM, sans que rien ne dise pourquoi. Charger un
// modèle, c'est appliquer TOUS ses réglages : ça s'appelle un preset, et c'est
// le seul chemin.
function onModelSwitch(sel){
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;
const name = sel.options[sel.selectedIndex].textContent;
@@ -68,22 +69,6 @@ function onModelSwitch(sel){
switchTo(n, name, sel.options[sel.selectedIndex].dataset.id);
}
// 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.
//
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
@@ -96,23 +81,12 @@ async function switchModelFile(value, name){
// 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
// en parallèle (loadAll), aucun ordre n'est garanti.
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
function renderModelSwitch(presets, active){
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
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');
// Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas.
MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||'')));
}catch(_){ return; }
paintModelSwitch();
}
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
function setLoadedModel(name){
MS_MODEL = (name || '').trim();
@@ -151,20 +125,7 @@ function paintModelSwitch(){
});
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.disabled = !MS_PRESETS.length;
sel.title = MS_MODEL
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
: 'aucun modèle configuré';
-35
View File
@@ -1377,41 +1377,6 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) {
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) {
if tuneDenyHTTP(w) {
return
}
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) {
// Pendant une optimisation, la configuration active est celle qu'elle mesure
// et le moteur ne doit pas redémarrer (backend_tune_lock.go).
-1
View File
@@ -243,7 +243,6 @@ func newWebMux() *http.ServeMux {
api("/api/models", handleModels)
api("/api/models/delete", handleModelDelete)
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/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
api("/api/models/download/status", handleModelDownloadStatus)