v0.7.13 : selection de preset instantanee, envoi bloque tant que le modele charge

- Preset actif : liseré gauche droit, rogné par l'arrondi de la carte, un peu plus large
- Puce de l'actif dessinée en CSS (même taille et même assiette sur Windows et macOS)
- Survol et sélection moins appuyés dans la liste
- Bascule : retour visuel immédiat (barre qui glisse depuis la gauche, atténuée puis
  pleine) ; animations rejouées seulement quand la sélection change vraiment
- /api/switch répond sans attendre le redémarrage du service : bascule instantanée
- Bouton envoyer et touche Entrée désactivés tant que le modèle n'est pas chargé
This commit is contained in:
nathaninline committed 2026-08-06 11:59:41 +02:00
1 parent 742c14544a
commit 081a88c846
11 files changed
+266 -23

No files matched your search

Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -3,13 +3,13 @@
"FileVersion": { "FileVersion": {
"Major": 0, "Major": 0,
"Minor": 7, "Minor": 7,
"Patch": 12, "Patch": 13,
"Build": 0 "Build": 0
}, },
"ProductVersion": { "ProductVersion": {
"Major": 0, "Major": 0,
"Minor": 7, "Minor": 7,
"Patch": 12, "Patch": 13,
"Build": 0 "Build": 0
}, },
"FileFlagsMask": "3f", "FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
"OriginalFilename": "ajean.exe", "OriginalFilename": "ajean.exe",
"ProductName": "AJEAN", "ProductName": "AJEAN",
"ProductVersion": "0.7.12", "ProductVersion": "0.7.13",
"Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
}, },
"VarFileInfo": { "VarFileInfo": {
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings" "strings"
) )
const Version = "0.7.12" const Version = "0.7.13"
// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler). // Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
func Main() { func Main() {
+124 -9
View File
@@ -28,8 +28,8 @@
Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par
défaut, sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI défaut, sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI
passent par ces variables ; rien d'autre ne code une couleur en dur. */ passent par ces variables ; rien d'autre ne code une couleur en dur. */
:root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)} :root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--row-bg:rgba(0,0,0,.03);--row-bg-on:rgba(0,0,0,.042);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)}
[data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)} [data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--row-bg:rgba(255,255,255,.04);--row-bg-on:rgba(255,255,255,.055);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)}
/* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien, /* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien,
résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors
de notre charte et impossible à styler : on le coupe. Nos états :active/:focus de notre charte et impossible à styler : on le coupe. Nos états :active/:focus
@@ -116,6 +116,9 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et /* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et
ça prend de la place. Elle reste visible sur desktop. */ ça prend de la place. Elle reste visible sur desktop. */
#sendhint{display:none} #sendhint{display:none}
/* …sauf quand elle annonce que le modèle charge : là c'est une information
d'état, pas un rappel de raccourci. */
#sendhint.waiting{display:block}
} }
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ /* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){ @media (min-width:721px){
@@ -214,9 +217,42 @@ button:disabled{opacity:.5;cursor:not-allowed}
voile et un liseré à gauche, plus par un cadre propre. */ voile et un liseré à gauche, plus par un cadre propre. */
.rowlist>.preset{border:0;border-radius:0;margin:0;padding:6px 10px;background:transparent} .rowlist>.preset{border:0;border-radius:0;margin:0;padding:6px 10px;background:transparent}
.rowlist>.preset:not(:last-child){border-bottom:1px solid var(--border)} .rowlist>.preset:not(:last-child){border-bottom:1px solid var(--border)}
.rowlist>.preset:hover{background:var(--accent-bg)} .rowlist>.preset:hover{background:var(--row-bg)}
.rowlist>.preset.active{background:var(--accent-bg);color:var(--text);font-weight:600; .rowlist>.preset.active{position:relative;background:var(--row-bg-on);color:var(--text);font-weight:600}
box-shadow:inset 2px 0 0 var(--text)} /* La carte (.slist) laisse volontairement déborder son contenu (menus), elle ne
rogne donc pas ses coins : c'est la LIGNE de bord qui porte l'arrondi, sinon
le voile dépasse l'angle de la carte. */
.rowlist:first-child>.preset:first-child{border-radius:8px 8px 0 0}
.rowlist:last-child>.preset:last-child{border-radius:0 0 8px 8px}
.rowlist:first-child:last-child>.preset:only-child{border-radius:8px}
/* Liseré de gauche : un trait DROIT (pas un arc) qui court sur TOUTE la hauteur
de la ligne. Donc pas de box-shadow interne (l'arrondi le courberait) mais un
élément à part, aux bords francs, que l'arrondi de la ligne de bord se
contente de ROGNER — il se perd dans l'angle au lieu de le suivre. */
.rowlist>.preset{overflow:hidden}
.rowlist>.preset.active::before,.rowlist>.preset.pending::before{content:"";position:absolute;
left:0;top:0;bottom:0;width:3px;background:var(--text)}
/* La liste est REDESSINÉE à chaque rafraîchissement : une transition ne jouerait
pas (l'élément vient de naître), d'où des @keyframes d'entrée. Le liseré entre
en glissant depuis la gauche, le voile et la puce arrivent en fondu.
⚠️ Elles ne sont posées QUE via .sel-anim, classe mise par loadPresets le seul
redessin où la sélection a bougé : sans ça elles repartaient à chaque
rafraîchissement et la puce clignotait en continu. */
@keyframes bar-in{from{transform:translateX(-100%)}to{transform:translateX(0)}}
@keyframes row-in{from{background:transparent}}
@keyframes dot-in{from{opacity:0;transform:scale(.3)}to{opacity:1;transform:scale(1)}}
.sel-anim.active::before,.sel-anim.pending::before{animation:bar-in .26s cubic-bezier(.2,.7,.3,1) both}
.rowlist>.preset.sel-anim.active,.rowlist>.preset.sel-anim.pending{animation:row-in .26s ease-out both}
.sel-anim .preset-dot{animation:dot-in .26s cubic-bezier(.2,1.5,.5,1) both}
/* Bascule en cours : la ligne visée s'allume DÉJÀ (le clic doit se voir tout de
suite), mais sa barre reste EN RETRAIT — même blanc, opacité plus basse, et
elle respire. Le service met plusieurs secondes à redémarrer : la barre pleine
(plus la puce) est réservée au preset vraiment actif. */
.rowlist>.preset.pending{position:relative;background:var(--row-bg);color:var(--text);font-weight:600}
.rowlist>.preset.pending::before{animation:bar-breathe 1.5s ease-in-out infinite}
.rowlist>.preset.sel-anim.pending::before{
animation:bar-in .26s cubic-bezier(.2,.7,.3,1) both,bar-breathe 1.5s ease-in-out .26s infinite}
@keyframes bar-breathe{0%,100%{opacity:.5}50%{opacity:.2}}
/* Ligne à deux étages (titre + étiquettes) : l'interligne interne suffit, on /* Ligne à deux étages (titre + étiquettes) : l'interligne interne suffit, on
resserre donc le haut et le bas. */ resserre donc le haut et le bas. */
.rowlist>.preset .preset-meta{margin-top:4px} .rowlist>.preset .preset-meta{margin-top:4px}
@@ -224,6 +260,16 @@ button:disabled{opacity:.5;cursor:not-allowed}
.rowlist>.muted,.rowlist>.mem-more{padding:9px 10px} .rowlist>.muted,.rowlist>.mem-more{padding:9px 10px}
.preset-info{flex:1;min-width:0} .preset-info{flex:1;min-width:0}
.preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word} .preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word}
/* Puce du preset actif : disque dessiné en CSS (le caractère « ● » dépend de la
police du système — plus petit et plus bas sous Windows que sous macOS). */
.preset-dot{display:inline-block;width:7px;height:7px;border-radius:50%;
background:currentColor;margin:0 7px 0 0;flex-shrink:0;
/* ⚠️ PAS `vertical-align:middle` : ce mot-clé se cale sur la HAUTEUR D'X de la
police, qui n'est pas la même d'un système à l'autre (Segoe UI sous Windows
vs SF sous macOS) — la puce tombait trop bas sur PC. Un décalage EN LONGUEUR
part de la ligne de base et ne dépend que de la taille de texte : même
rendu partout. .34em ≈ milieu des capitales, moins la moitié du disque. */
vertical-align:calc(.34em - 3.5px)}
/* Icône d'engrenage : centrée verticalement sur la ligne (sa silhouette ronde se /* Icône d'engrenage : centrée verticalement sur la ligne (sa silhouette ronde se
lit bien au milieu de son coin, là où le crayon penché tirait de travers). */ lit bien au milieu de son coin, là où le crayon penché tirait de travers). */
.preset-edit{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center; .preset-edit{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;
@@ -559,6 +605,8 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
/* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir /* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir
changer ses marges, ce qu'un style en ligne empêchait). */ changer ses marges, ce qu'un style en ligne empêchait). */
#sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7} #sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7}
/* Moteur pas encore prêt : l'aide clavier cède la place à l'état du chargement. */
#sendhint.waiting{opacity:1;color:var(--dim)}
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden} #input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
#input:focus{outline:none;border-color:var(--accent)} #input:focus{outline:none;border-color:var(--accent)}
#send{padding:0 20px} #send{padding:0 20px}
@@ -1937,6 +1985,10 @@ async function loadStatus(){
el.className='statuspill '+cls; el.className='statuspill '+cls;
el.innerHTML='<span class="dot"></span>'+txt; el.innerHTML='<span class="dot"></span>'+txt;
MODEL_READY = !!(s.active && s.health); MODEL_READY = !!(s.active && s.health);
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un
// message à un modèle qui n'est pas encore chargé (voir syncSendBtn).
STATUS_SEEN = true;
if(typeof syncSendBtn==='function') syncSendBtn();
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); } if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
if(s.version){ if(s.version){
document.getElementById('ver').textContent='v'+s.version; document.getElementById('ver').textContent='v'+s.version;
@@ -2023,6 +2075,10 @@ async function applyUpdate(){
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode // Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter. // du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
let CTX_MAX=0, CTX_USED=0, MODEL_READY=false; let CTX_MAX=0, CTX_USED=0, MODEL_READY=false;
// STATUS_SEEN : /api/status a répondu au moins une fois. Avant ça (ou s'il ne
// répond pas), on ne verrouille RIEN — mieux vaut un envoi qui échoue qu'un chat
// bloqué par un état inconnu.
let STATUS_SEEN=false;
function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); } function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); }
function updateCtxMeter(){ function updateCtxMeter(){
if(!CTX_MAX) return; if(!CTX_MAX) return;
@@ -2109,8 +2165,21 @@ function releaseHeights(){
}); });
} }
document.addEventListener('DOMContentLoaded', reserveHeights); document.addEventListener('DOMContentLoaded', reserveHeights);
// Numéro (1-based) du preset vers lequel on bascule, tant que le serveur n'a pas
// fini de redémarrer le service. Basculer prend plusieurs secondes : sans ça, la
// liste restait FIGÉE sur l'ancien actif et on ne savait pas si le clic avait pris.
// Voir switchTo() dans 07-models.js.
let pendingPreset = 0;
// Dernière sélection PEINTE. La liste est redessinée à chaque rafraîchissement
// (et il y en a beaucoup : sondage de bascule, loadAll…) ; sans ce repère, les
// animations d'entrée (barre qui glisse, puce qui apparaît) repartaient à chaque
// redessin et la puce semblait clignoter en boucle. On ne les rejoue que quand la
// sélection CHANGE vraiment.
let paintedSel = '';
async function loadPresets(){ async function loadPresets(){
const p=await jget('/api/presets'); const p=await jget('/api/presets');
// Bascule terminée : le preset visé est devenu l'actif, on éteint l'attente.
if(pendingPreset && p[pendingPreset-1] && p[pendingPreset-1].active) pendingPreset = 0;
const act = p.find(x=>x.active); const act = p.find(x=>x.active);
// Build via DOM (not string concat) so preset names can contain anything — // Build via DOM (not string concat) so preset names can contain anything —
// spaces, accents, quotes, < > & — without breaking markup or handlers. // spaces, accents, quotes, < > & — without breaking markup or handlers.
@@ -2120,13 +2189,24 @@ async function loadPresets(){
const cont=document.getElementById('presets'); const cont=document.getElementById('presets');
cont.innerHTML=''; cont.innerHTML='';
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; } if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
// Signature de la sélection : quel preset est actif, et vers lequel on bascule.
const sel = (act?act.id:'')+'|'+pendingPreset;
const moved = sel !== paintedSel; paintedSel = sel;
p.forEach((x,i)=>{ p.forEach((x,i)=>{
const row=document.createElement('div'); const row=document.createElement('div');
row.className='preset'+(x.active?' active':''); const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name); row.onclick=()=>switchTo(i+1, x.name);
const info=document.createElement('div'); info.className='preset-info'; const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name'; const nm=document.createElement('div'); nm.className='preset-name';
nm.textContent=(x.active?'● ':'')+x.name; nm.title=x.name; // Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
// est dessiné par la police du système — sur Windows il sortait plus petit et
// plus bas que sur macOS. Un disque CSS a la même taille et la même assiette
// partout.
// Puce réservée à l'actif POUR DE BON : pendant la bascule, seule la barre
// orange parle ; la puce apparaît quand la barre passe au blanc.
if(x.active){ const d=document.createElement('i'); d.className='preset-dot'; nm.appendChild(d); }
nm.appendChild(document.createTextNode(x.name)); nm.title=x.name;
// Second row: quant tag + bench perf, so the title row stays full-width. // Second row: quant tag + bench perf, so the title row stays full-width.
const meta=document.createElement('div'); meta.className='preset-meta'; const meta=document.createElement('div'); meta.className='preset-meta';
if(x.quant){ if(x.quant){
@@ -2429,8 +2509,27 @@ async function runBenchUI(){
async function switchTo(n,name){ async function switchTo(n,name){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return; if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
toast('switching…'); toast('switching…');
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
pendingPreset = n; loadPresets();
const r=await jpost('/api/switch',{n:n}); const r=await jpost('/api/switch',{n:n});
toast(r.ok?'switched':'erreur'); setTimeout(loadAll,2000); if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
toast('switched');
// Le preset ACTIF, c'est celui dont l'empreinte est dans config.env : le serveur
// l'écrit AVANT de répondre (et relance le service en arrière-plan, voir
// handleSwitch), donc un rafraîchissement immédiat suffit — la barre passe au
// blanc tout de suite. Le chargement du modèle par llama-server continue
// derrière ; il ne conditionne pas la sélection.
try{ await loadPresets(); }catch(e){}
// Filet : si l'empreinte n'a pas encore basculé (bascule lente côté disque), on
// reste en attente le temps qu'il faut, sans dépasser ~60 s.
for(let i=0; i<40 && pendingPreset===n; i++){
await new Promise(r=>setTimeout(r,1500));
try{ await loadPresets(); }catch(e){}
}
pendingPreset = 0;
loadAll();
} }
// editingKey = the identifier of the item being edited: a preset id (filename) // editingKey = the identifier of the item being edited: a preset id (filename)
// or a skill name. Empty string = creating a new item. // or a skill name. Empty string = creating a new item.
@@ -3699,8 +3798,22 @@ function labelTokens(el, role, n, firstTs, lastTs){
// L'état final est appliqué une seule fois au caught_up via syncSendBtn(). // L'état final est appliqué une seule fois au caught_up via syncSendBtn().
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); } function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
function syncSendBtn(){ function syncSendBtn(){
document.getElementById('send').style.display=busy?'none':'inline-block'; const sb=document.getElementById('send');
sb.style.display=busy?'none':'inline-block';
document.getElementById('stop').style.display=busy?'inline-block':'none'; document.getElementById('stop').style.display=busy?'inline-block':'none';
// Tant que le moteur n'a pas fini de charger le modèle, envoyer ne mène à rien :
// on bloque le bouton et l'Entrée, et on le DIT sous le champ. `STATUS_SEEN`
// évite de verrouiller le chat quand /api/status n'a pas encore répondu (ou ne
// répond pas du tout) — dans le doute on laisse la main.
const ready = !STATUS_SEEN || MODEL_READY;
sb.disabled = !ready;
sb.title = ready ? '' : 'le modèle n\'est pas encore chargé';
const hint=document.getElementById('sendhint');
if(hint){
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
hint.classList.toggle('waiting', !ready);
}
} }
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais // Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
// piloté par le serveur et rejouable à l'identique. // piloté par le serveur et rejouable à l'identique.
@@ -3821,6 +3934,8 @@ function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toas
// « network error » alarmiste alors que l'IA répond quand même. // « network error » alarmiste alors que l'IA répond quand même.
async function send(){ async function send(){
if(busy) return; if(busy) return;
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim(); const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return; if(!text) return;
ta.value=''; autoGrow(ta); ta.value=''; autoGrow(ta);
+8
View File
@@ -10,6 +10,10 @@ async function loadStatus(){
el.className='statuspill '+cls; el.className='statuspill '+cls;
el.innerHTML='<span class="dot"></span>'+txt; el.innerHTML='<span class="dot"></span>'+txt;
MODEL_READY = !!(s.active && s.health); MODEL_READY = !!(s.active && s.health);
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un
// message à un modèle qui n'est pas encore chargé (voir syncSendBtn).
STATUS_SEEN = true;
if(typeof syncSendBtn==='function') syncSendBtn();
if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); } if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); }
if(s.version){ if(s.version){
document.getElementById('ver').textContent='v'+s.version; document.getElementById('ver').textContent='v'+s.version;
@@ -96,6 +100,10 @@ async function applyUpdate(){
// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode // Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode
// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter. // du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter.
let CTX_MAX=0, CTX_USED=0, MODEL_READY=false; let CTX_MAX=0, CTX_USED=0, MODEL_READY=false;
// STATUS_SEEN : /api/status a répondu au moins une fois. Avant ça (ou s'il ne
// répond pas), on ne verrouille RIEN — mieux vaut un envoi qui échoue qu'un chat
// bloqué par un état inconnu.
let STATUS_SEEN=false;
function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); } function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); }
function updateCtxMeter(){ function updateCtxMeter(){
if(!CTX_MAX) return; if(!CTX_MAX) return;
+26 -2
View File
@@ -23,8 +23,21 @@ function releaseHeights(){
}); });
} }
document.addEventListener('DOMContentLoaded', reserveHeights); document.addEventListener('DOMContentLoaded', reserveHeights);
// Numéro (1-based) du preset vers lequel on bascule, tant que le serveur n'a pas
// fini de redémarrer le service. Basculer prend plusieurs secondes : sans ça, la
// liste restait FIGÉE sur l'ancien actif et on ne savait pas si le clic avait pris.
// Voir switchTo() dans 07-models.js.
let pendingPreset = 0;
// Dernière sélection PEINTE. La liste est redessinée à chaque rafraîchissement
// (et il y en a beaucoup : sondage de bascule, loadAll…) ; sans ce repère, les
// animations d'entrée (barre qui glisse, puce qui apparaît) repartaient à chaque
// redessin et la puce semblait clignoter en boucle. On ne les rejoue que quand la
// sélection CHANGE vraiment.
let paintedSel = '';
async function loadPresets(){ async function loadPresets(){
const p=await jget('/api/presets'); const p=await jget('/api/presets');
// Bascule terminée : le preset visé est devenu l'actif, on éteint l'attente.
if(pendingPreset && p[pendingPreset-1] && p[pendingPreset-1].active) pendingPreset = 0;
const act = p.find(x=>x.active); const act = p.find(x=>x.active);
// Build via DOM (not string concat) so preset names can contain anything — // Build via DOM (not string concat) so preset names can contain anything —
// spaces, accents, quotes, < > & — without breaking markup or handlers. // spaces, accents, quotes, < > & — without breaking markup or handlers.
@@ -34,13 +47,24 @@ async function loadPresets(){
const cont=document.getElementById('presets'); const cont=document.getElementById('presets');
cont.innerHTML=''; cont.innerHTML='';
if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; } if(!p.length){ cont.innerHTML='<span class="muted">(aucun)</span>'; return; }
// Signature de la sélection : quel preset est actif, et vers lequel on bascule.
const sel = (act?act.id:'')+'|'+pendingPreset;
const moved = sel !== paintedSel; paintedSel = sel;
p.forEach((x,i)=>{ p.forEach((x,i)=>{
const row=document.createElement('div'); const row=document.createElement('div');
row.className='preset'+(x.active?' active':''); const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name); row.onclick=()=>switchTo(i+1, x.name);
const info=document.createElement('div'); info.className='preset-info'; const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name'; const nm=document.createElement('div'); nm.className='preset-name';
nm.textContent=(x.active?'● ':'')+x.name; nm.title=x.name; // Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
// est dessiné par la police du système — sur Windows il sortait plus petit et
// plus bas que sur macOS. Un disque CSS a la même taille et la même assiette
// partout.
// Puce réservée à l'actif POUR DE BON : pendant la bascule, seule la barre
// orange parle ; la puce apparaît quand la barre passe au blanc.
if(x.active){ const d=document.createElement('i'); d.className='preset-dot'; nm.appendChild(d); }
nm.appendChild(document.createTextNode(x.name)); nm.title=x.name;
// Second row: quant tag + bench perf, so the title row stays full-width. // Second row: quant tag + bench perf, so the title row stays full-width.
const meta=document.createElement('div'); meta.className='preset-meta'; const meta=document.createElement('div'); meta.className='preset-meta';
if(x.quant){ if(x.quant){
+20 -1
View File
@@ -44,8 +44,27 @@ async function runBenchUI(){
async function switchTo(n,name){ async function switchTo(n,name){
if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return; if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return;
toast('switching…'); toast('switching…');
// Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met
// plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas
// d'un pouce pendant tout ce temps et le clic semblait sans effet.
pendingPreset = n; loadPresets();
const r=await jpost('/api/switch',{n:n}); const r=await jpost('/api/switch',{n:n});
toast(r.ok?'switched':'erreur'); setTimeout(loadAll,2000); if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; }
toast('switched');
// Le preset ACTIF, c'est celui dont l'empreinte est dans config.env : le serveur
// l'écrit AVANT de répondre (et relance le service en arrière-plan, voir
// handleSwitch), donc un rafraîchissement immédiat suffit — la barre passe au
// blanc tout de suite. Le chargement du modèle par llama-server continue
// derrière ; il ne conditionne pas la sélection.
try{ await loadPresets(); }catch(e){}
// Filet : si l'empreinte n'a pas encore basculé (bascule lente côté disque), on
// reste en attente le temps qu'il faut, sans dépasser ~60 s.
for(let i=0; i<40 && pendingPreset===n; i++){
await new Promise(r=>setTimeout(r,1500));
try{ await loadPresets(); }catch(e){}
}
pendingPreset = 0;
loadAll();
} }
// editingKey = the identifier of the item being edited: a preset id (filename) // editingKey = the identifier of the item being edited: a preset id (filename)
// or a skill name. Empty string = creating a new item. // or a skill name. Empty string = creating a new item.
+17 -1
View File
@@ -115,8 +115,22 @@ function labelTokens(el, role, n, firstTs, lastTs){
// L'état final est appliqué une seule fois au caught_up via syncSendBtn(). // L'état final est appliqué une seule fois au caught_up via syncSendBtn().
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); } function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
function syncSendBtn(){ function syncSendBtn(){
document.getElementById('send').style.display=busy?'none':'inline-block'; const sb=document.getElementById('send');
sb.style.display=busy?'none':'inline-block';
document.getElementById('stop').style.display=busy?'inline-block':'none'; document.getElementById('stop').style.display=busy?'inline-block':'none';
// Tant que le moteur n'a pas fini de charger le modèle, envoyer ne mène à rien :
// on bloque le bouton et l'Entrée, et on le DIT sous le champ. `STATUS_SEEN`
// évite de verrouiller le chat quand /api/status n'a pas encore répondu (ou ne
// répond pas du tout) — dans le doute on laisse la main.
const ready = !STATUS_SEEN || MODEL_READY;
sb.disabled = !ready;
sb.title = ready ? '' : 'le modèle n\'est pas encore chargé';
const hint=document.getElementById('sendhint');
if(hint){
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
hint.classList.toggle('waiting', !ready);
}
} }
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais // Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
// piloté par le serveur et rejouable à l'identique. // piloté par le serveur et rejouable à l'identique.
@@ -237,6 +251,8 @@ function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toas
// « network error » alarmiste alors que l'IA répond quand même. // « network error » alarmiste alors que l'IA répond quand même.
async function send(){ async function send(){
if(busy) return; if(busy) return;
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim(); const ta=document.getElementById('input'); const text=ta.value.trim();
if(!text) return; if(!text) return;
ta.value=''; autoGrow(ta); ta.value=''; autoGrow(ta);
+53 -5
View File
@@ -2,8 +2,8 @@
Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par
défaut, sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI défaut, sombre via data-theme="dark" sur <html>. Toutes les couleurs de l'UI
passent par ces variables ; rien d'autre ne code une couleur en dur. */ passent par ces variables ; rien d'autre ne code une couleur en dur. */
:root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)} :root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--row-bg:rgba(0,0,0,.03);--row-bg-on:rgba(0,0,0,.042);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)}
[data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)} [data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--row-bg:rgba(255,255,255,.04);--row-bg-on:rgba(255,255,255,.055);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)}
/* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien, /* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien,
résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors
de notre charte et impossible à styler : on le coupe. Nos états :active/:focus de notre charte et impossible à styler : on le coupe. Nos états :active/:focus
@@ -90,6 +90,9 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et /* Petit écran (menu replié en tiroir) : on masque l'aide clavier — inutile et
ça prend de la place. Elle reste visible sur desktop. */ ça prend de la place. Elle reste visible sur desktop. */
#sendhint{display:none} #sendhint{display:none}
/* …sauf quand elle annonce que le modèle charge : là c'est une information
d'état, pas un rappel de raccourci. */
#sendhint.waiting{display:block}
} }
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */ /* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){ @media (min-width:721px){
@@ -188,9 +191,42 @@ button:disabled{opacity:.5;cursor:not-allowed}
voile et un liseré à gauche, plus par un cadre propre. */ voile et un liseré à gauche, plus par un cadre propre. */
.rowlist>.preset{border:0;border-radius:0;margin:0;padding:6px 10px;background:transparent} .rowlist>.preset{border:0;border-radius:0;margin:0;padding:6px 10px;background:transparent}
.rowlist>.preset:not(:last-child){border-bottom:1px solid var(--border)} .rowlist>.preset:not(:last-child){border-bottom:1px solid var(--border)}
.rowlist>.preset:hover{background:var(--accent-bg)} .rowlist>.preset:hover{background:var(--row-bg)}
.rowlist>.preset.active{background:var(--accent-bg);color:var(--text);font-weight:600; .rowlist>.preset.active{position:relative;background:var(--row-bg-on);color:var(--text);font-weight:600}
box-shadow:inset 2px 0 0 var(--text)} /* La carte (.slist) laisse volontairement déborder son contenu (menus), elle ne
rogne donc pas ses coins : c'est la LIGNE de bord qui porte l'arrondi, sinon
le voile dépasse l'angle de la carte. */
.rowlist:first-child>.preset:first-child{border-radius:8px 8px 0 0}
.rowlist:last-child>.preset:last-child{border-radius:0 0 8px 8px}
.rowlist:first-child:last-child>.preset:only-child{border-radius:8px}
/* Liseré de gauche : un trait DROIT (pas un arc) qui court sur TOUTE la hauteur
de la ligne. Donc pas de box-shadow interne (l'arrondi le courberait) mais un
élément à part, aux bords francs, que l'arrondi de la ligne de bord se
contente de ROGNER — il se perd dans l'angle au lieu de le suivre. */
.rowlist>.preset{overflow:hidden}
.rowlist>.preset.active::before,.rowlist>.preset.pending::before{content:"";position:absolute;
left:0;top:0;bottom:0;width:3px;background:var(--text)}
/* La liste est REDESSINÉE à chaque rafraîchissement : une transition ne jouerait
pas (l'élément vient de naître), d'où des @keyframes d'entrée. Le liseré entre
en glissant depuis la gauche, le voile et la puce arrivent en fondu.
⚠️ Elles ne sont posées QUE via .sel-anim, classe mise par loadPresets le seul
redessin où la sélection a bougé : sans ça elles repartaient à chaque
rafraîchissement et la puce clignotait en continu. */
@keyframes bar-in{from{transform:translateX(-100%)}to{transform:translateX(0)}}
@keyframes row-in{from{background:transparent}}
@keyframes dot-in{from{opacity:0;transform:scale(.3)}to{opacity:1;transform:scale(1)}}
.sel-anim.active::before,.sel-anim.pending::before{animation:bar-in .26s cubic-bezier(.2,.7,.3,1) both}
.rowlist>.preset.sel-anim.active,.rowlist>.preset.sel-anim.pending{animation:row-in .26s ease-out both}
.sel-anim .preset-dot{animation:dot-in .26s cubic-bezier(.2,1.5,.5,1) both}
/* Bascule en cours : la ligne visée s'allume DÉJÀ (le clic doit se voir tout de
suite), mais sa barre reste EN RETRAIT — même blanc, opacité plus basse, et
elle respire. Le service met plusieurs secondes à redémarrer : la barre pleine
(plus la puce) est réservée au preset vraiment actif. */
.rowlist>.preset.pending{position:relative;background:var(--row-bg);color:var(--text);font-weight:600}
.rowlist>.preset.pending::before{animation:bar-breathe 1.5s ease-in-out infinite}
.rowlist>.preset.sel-anim.pending::before{
animation:bar-in .26s cubic-bezier(.2,.7,.3,1) both,bar-breathe 1.5s ease-in-out .26s infinite}
@keyframes bar-breathe{0%,100%{opacity:.5}50%{opacity:.2}}
/* Ligne à deux étages (titre + étiquettes) : l'interligne interne suffit, on /* Ligne à deux étages (titre + étiquettes) : l'interligne interne suffit, on
resserre donc le haut et le bas. */ resserre donc le haut et le bas. */
.rowlist>.preset .preset-meta{margin-top:4px} .rowlist>.preset .preset-meta{margin-top:4px}
@@ -198,6 +234,16 @@ button:disabled{opacity:.5;cursor:not-allowed}
.rowlist>.muted,.rowlist>.mem-more{padding:9px 10px} .rowlist>.muted,.rowlist>.mem-more{padding:9px 10px}
.preset-info{flex:1;min-width:0} .preset-info{flex:1;min-width:0}
.preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word} .preset-name{min-width:0;overflow-wrap:anywhere;word-break:break-word}
/* Puce du preset actif : disque dessiné en CSS (le caractère « ● » dépend de la
police du système — plus petit et plus bas sous Windows que sous macOS). */
.preset-dot{display:inline-block;width:7px;height:7px;border-radius:50%;
background:currentColor;margin:0 7px 0 0;flex-shrink:0;
/* ⚠️ PAS `vertical-align:middle` : ce mot-clé se cale sur la HAUTEUR D'X de la
police, qui n'est pas la même d'un système à l'autre (Segoe UI sous Windows
vs SF sous macOS) — la puce tombait trop bas sur PC. Un décalage EN LONGUEUR
part de la ligne de base et ne dépend que de la taille de texte : même
rendu partout. .34em ≈ milieu des capitales, moins la moitié du disque. */
vertical-align:calc(.34em - 3.5px)}
/* Icône d'engrenage : centrée verticalement sur la ligne (sa silhouette ronde se /* Icône d'engrenage : centrée verticalement sur la ligne (sa silhouette ronde se
lit bien au milieu de son coin, là où le crayon penché tirait de travers). */ lit bien au milieu de son coin, là où le crayon penché tirait de travers). */
.preset-edit{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center; .preset-edit{flex-shrink:0;display:inline-flex;align-items:center;justify-content:center;
@@ -533,6 +579,8 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
/* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir /* Astuce clavier sous la saisie (style repris du HTML : un thème doit pouvoir
changer ses marges, ce qu'un style en ligne empêchait). */ changer ses marges, ce qu'un style en ligne empêchait). */
#sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7} #sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7}
/* Moteur pas encore prêt : l'aide clavier cède la place à l'état du chargement. */
#sendhint.waiting{opacity:1;color:var(--dim)}
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden} #input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
#input:focus{outline:none;border-color:var(--accent)} #input:focus{outline:none;border-color:var(--accent)}
#send{padding:0 20px} #send{padding:0 20px}
+14 -1
View File
@@ -4,6 +4,7 @@ package ajean
import ( import (
"encoding/json" "encoding/json"
"fmt"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -829,10 +830,22 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) {
return return
} }
target := list[req.N-1] target := list[req.N-1]
if err := SwitchToPreset(target.Path); err != nil { // On écrit config.env TOUT DE SUITE (c'est lui qui décide du preset actif),
// puis on répond — le redémarrage du service part en arrière-plan. Passer par
// SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus
// les 2 s de vérification de checkStarted : côté UI, le clic paraissait mou et
// la sélection ne bougeait qu'au bout de plusieurs secondes, pour rien.
if err := applyPresetFile(target.Path); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return return
} }
fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target.Path))
fmt.Println(dim("[info] redémarrage du service..."))
go func() {
if err := serviceAction("restart"); err != nil {
fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err)
}
}()
sendJSON(w, 200, map[string]any{"ok": true, "preset": target.Name}) sendJSON(w, 200, map[string]any{"ok": true, "preset": target.Name})
} }