diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso index 4ef25bb..44a941c 100644 Binary files a/cmd/ajean/resource_windows_amd64.syso and b/cmd/ajean/resource_windows_amd64.syso differ diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso index a19dab5..a507d78 100644 Binary files a/cmd/ajean/resource_windows_arm64.syso and b/cmd/ajean/resource_windows_arm64.syso differ diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json index 3bd5d18..dc14f4b 100644 --- a/cmd/ajean/versioninfo.json +++ b/cmd/ajean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 7, - "Patch": 12, + "Patch": 13, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 7, - "Patch": 12, + "Patch": 13, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "ajean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.7.12", + "ProductVersion": "0.7.13", "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/ajean/run.go b/internal/ajean/run.go index 1e9ac83..02faa3d 100644 --- a/internal/ajean/run.go +++ b/internal/ajean/run.go @@ -10,7 +10,7 @@ import ( "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). func Main() { diff --git a/internal/ajean/ui/index.html b/internal/ajean/ui/index.html index a3ceba7..4ca5827 100644 --- a/internal/ajean/ui/index.html +++ b/internal/ajean/ui/index.html @@ -28,8 +28,8 @@ Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par défaut, sombre via data-theme="dark" sur . Toutes les couleurs de l'UI 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)} -[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)} +: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);--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, 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 @@ -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 ça prend de la place. Elle reste visible sur desktop. */ #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. */ @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. */ .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:hover{background:var(--accent-bg)} -.rowlist>.preset.active{background:var(--accent-bg);color:var(--text);font-weight:600; - box-shadow:inset 2px 0 0 var(--text)} +.rowlist>.preset:hover{background:var(--row-bg)} +.rowlist>.preset.active{position:relative;background:var(--row-bg-on);color:var(--text);font-weight:600} +/* 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 resserre donc le haut et le bas. */ .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} .preset-info{flex:1;min-width:0} .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 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; @@ -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 changer ses marges, ce qu'un style en ligne empêchait). */ #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:focus{outline:none;border-color:var(--accent)} #send{padding:0 20px} @@ -1937,6 +1985,10 @@ async function loadStatus(){ el.className='statuspill '+cls; el.innerHTML=''+txt; 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.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 // du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter. 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 updateCtxMeter(){ if(!CTX_MAX) return; @@ -2109,8 +2165,21 @@ function releaseHeights(){ }); } 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(){ 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); // Build via DOM (not string concat) so preset names can contain anything — // spaces, accents, quotes, < > & — without breaking markup or handlers. @@ -2120,13 +2189,24 @@ async function loadPresets(){ const cont=document.getElementById('presets'); cont.innerHTML=''; if(!p.length){ cont.innerHTML='(aucun)'; 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)=>{ 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); const info=document.createElement('div'); info.className='preset-info'; 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. const meta=document.createElement('div'); meta.className='preset-meta'; if(x.quant){ @@ -2429,8 +2509,27 @@ async function runBenchUI(){ async function switchTo(n,name){ if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return; 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}); - 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) // 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(). function setBusy(on){ busy=on; if(!REPLAYING) 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'; + // 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 // 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. async function send(){ 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(); if(!text) return; ta.value=''; autoGrow(ta); diff --git a/internal/ajean/ui/src/js/05-status.js b/internal/ajean/ui/src/js/05-status.js index 89d91d2..2cb43a8 100644 --- a/internal/ajean/ui/src/js/05-status.js +++ b/internal/ajean/ui/src/js/05-status.js @@ -10,6 +10,10 @@ async function loadStatus(){ el.className='statuspill '+cls; el.innerHTML=''+txt; 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.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 // du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter. 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 updateCtxMeter(){ if(!CTX_MAX) return; diff --git a/internal/ajean/ui/src/js/06-settings.js b/internal/ajean/ui/src/js/06-settings.js index d8f7c72..0b22d1d 100644 --- a/internal/ajean/ui/src/js/06-settings.js +++ b/internal/ajean/ui/src/js/06-settings.js @@ -23,8 +23,21 @@ function releaseHeights(){ }); } 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(){ 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); // Build via DOM (not string concat) so preset names can contain anything — // spaces, accents, quotes, < > & — without breaking markup or handlers. @@ -34,13 +47,24 @@ async function loadPresets(){ const cont=document.getElementById('presets'); cont.innerHTML=''; if(!p.length){ cont.innerHTML='(aucun)'; 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)=>{ 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); const info=document.createElement('div'); info.className='preset-info'; 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. const meta=document.createElement('div'); meta.className='preset-meta'; if(x.quant){ diff --git a/internal/ajean/ui/src/js/07-models.js b/internal/ajean/ui/src/js/07-models.js index b4627d3..5f400f8 100644 --- a/internal/ajean/ui/src/js/07-models.js +++ b/internal/ajean/ui/src/js/07-models.js @@ -44,8 +44,27 @@ async function runBenchUI(){ async function switchTo(n,name){ if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return; 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}); - 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) // or a skill name. Empty string = creating a new item. diff --git a/internal/ajean/ui/src/js/09-stream.js b/internal/ajean/ui/src/js/09-stream.js index a57b5ae..e7c422a 100644 --- a/internal/ajean/ui/src/js/09-stream.js +++ b/internal/ajean/ui/src/js/09-stream.js @@ -115,8 +115,22 @@ function labelTokens(el, role, n, firstTs, lastTs){ // L'état final est appliqué une seule fois au caught_up via syncSendBtn(). function setBusy(on){ busy=on; if(!REPLAYING) 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'; + // 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 // 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. async function send(){ 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(); if(!text) return; ta.value=''; autoGrow(ta); diff --git a/internal/ajean/ui/src/styles.css b/internal/ajean/ui/src/styles.css index 0ec2fcc..a4a8704 100644 --- a/internal/ajean/ui/src/styles.css +++ b/internal/ajean/ui/src/styles.css @@ -2,8 +2,8 @@ Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par défaut, sombre via data-theme="dark" sur . Toutes les couleurs de l'UI 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)} -[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)} +: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);--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, 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 @@ -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 ça prend de la place. Elle reste visible sur desktop. */ #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. */ @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. */ .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:hover{background:var(--accent-bg)} -.rowlist>.preset.active{background:var(--accent-bg);color:var(--text);font-weight:600; - box-shadow:inset 2px 0 0 var(--text)} +.rowlist>.preset:hover{background:var(--row-bg)} +.rowlist>.preset.active{position:relative;background:var(--row-bg-on);color:var(--text);font-weight:600} +/* 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 resserre donc le haut et le bas. */ .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} .preset-info{flex:1;min-width:0} .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 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; @@ -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 changer ses marges, ce qu'un style en ligne empêchait). */ #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:focus{outline:none;border-color:var(--accent)} #send{padding:0 20px} diff --git a/internal/ajean/web_api.go b/internal/ajean/web_api.go index 7583f5e..08d9d6f 100644 --- a/internal/ajean/web_api.go +++ b/internal/ajean/web_api.go @@ -4,6 +4,7 @@ package ajean import ( "encoding/json" + "fmt" "net" "net/http" "os" @@ -829,10 +830,22 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) { return } 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()}) 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}) }