diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 0aee259..ee6db5c 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -1774,7 +1774,7 @@ button:active:not(:disabled){transform:translateY(1px)} ⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées (pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa taille, sa forme et son alignement d'une couche différente. */ -#inputbar #attach,#inputbar #files-btn,#send,#stop{ +#inputbar #attach,#inputbar #files-btn,#inputbar #mic,#send,#stop{ width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0; display:inline-flex;align-items:center;justify-content:center;align-self:center; border:0;border-radius:4px;background:transparent;color:var(--dim); @@ -1785,8 +1785,8 @@ button:active:not(:disabled){transform:translateY(1px)} #stop{display:none} html[data-busy="1"] #send{display:none} html[data-busy="1"] #stop{display:inline-flex} -#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px} -#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)} +#inputbar #attach svg,#inputbar #files-btn svg,#inputbar #mic svg,#send svg,#stop svg{display:block;width:18px;height:18px} +#inputbar #attach:hover,#inputbar #files-btn:hover,#inputbar #mic:hover{background:var(--row-bg-on);color:var(--text)} html[data-files="1"] #files-btn{color:var(--accent)} #send{color:var(--accent)} #send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none} @@ -2060,8 +2060,9 @@ html[data-files="1"] #files-btn{color:var(--accent)} .rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle} /* Dictée : anneau rouge pulsé pendant l'enregistrement, estompé pendant la - transcription. Le bouton garde le gabarit de ses voisins (joindre, fichiers). */ -#mic.rec{color:var(--err);border-color:var(--err);animation:micpulse 1.2s ease-in-out infinite} + transcription. Le gabarit (34 px, fond au survol) vient de la règle commune + des boutons de saisie — #mic y est listé avec #attach et #files-btn. */ +#mic.rec{color:var(--err);animation:micpulse 1.2s ease-in-out infinite} @keyframes micpulse{50%{box-shadow:0 0 0 5px color-mix(in srgb,var(--err) 25%,transparent)}} #mic.busy{opacity:.55} @@ -5988,6 +5989,11 @@ function renderToolMsg(el, tu){ body.appendChild(wait); } addCopyButtons(body); scrollMaybe(); + // Carte à hauteur bornée : tant que l'outil TRAVAILLE, on suit le bas (la + // ligne en cours d'écriture, la commande qui se tape). Terminé, on remonte en + // tête — c'est le résumé qu'on relit, pas la dernière ligne. + const bw = el.querySelector('.bodywrap'); + if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; } } // Inject a "copier" button into every
 code block (idempotent).
 function addCopyButtons(root){
@@ -6209,9 +6215,28 @@ function paintTurnClock(){
   }
   const ms = (T.doneTs || nowServer()) - T.startTs;
   if(!(ms > 0)){ el.remove(); return; }
-  el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms);
+  // Vitesse moyenne de la CONVERSATION à côté du temps du tour : le débit
+  // typique de ce modèle sur ce fil, toutes réponses confondues.
+  const avg = (CONV_MS > 1000 && CONV_TOK > 20)
+    ? '  ·  moy ' + (CONV_TOK / (CONV_MS/1000)).toFixed(1) + ' tok/s' : '';
+  el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms) + avg;
   el.classList.toggle('done', !!T.doneTs);
 }
+// --- Vitesse moyenne de la conversation --------------------------------------
+// Alimentée par les événements `stats` (journalisés, donc rejoués au
+// chargement : la moyenne survit au refresh). gen_tokens / gen_ms sont
+// CUMULATIFS au sein d'une complétion et retombent à la suivante : on n'ajoute
+// que le delta, et une valeur qui recule signale une nouvelle complétion.
+let CONV_TOK = 0, CONV_MS = 0, _cgTok = 0, _cgMs = 0;
+function feedConvSpeed(s){
+  const gt = s.gen_tokens || 0, gms = s.gen_ms || 0;
+  if(!gt) return;
+  if(gt < _cgTok){ _cgTok = 0; _cgMs = 0; }
+  CONV_TOK += gt - _cgTok;
+  CONV_MS  += Math.max(0, gms - _cgMs);
+  _cgTok = gt; _cgMs = gms;
+}
+function resetConvSpeed(){ CONV_TOK = 0; CONV_MS = 0; _cgTok = 0; _cgMs = 0; }
 // Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
 // ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
 // c'est précisément le moment où l'on se demande si ça avance.
@@ -6434,8 +6459,9 @@ function flushRender(final){
   }
   // Carte bornée (raisonnement/outil) : si on était collé en bas, on y reste —
   // la carte suit la génération. Un défilement manuel vers le haut est respecté.
-  const bw=p.el.parentElement;
-  const scrollable=bw && bw.classList.contains('bodywrap');
+  // ⚠️ p.el est la BULLE (.msg), pas .body : le bodywrap est dedans, pas au-dessus.
+  const bw=p.el.querySelector ? p.el.querySelector(':scope > .bodywrap') : null;
+  const scrollable=!!bw;
   const stick=scrollable && bw.scrollTop+bw.clientHeight>=bw.scrollHeight-60;
   const t0=performance.now();
   renderBody(p.el, text);
@@ -6517,7 +6543,7 @@ function handleDelta(d){
   // serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
   // a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
   // appartiennent à la discussion, le panneau doit changer avec elle.
-  if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
+  if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
   // --- Mode code (20-mode.js) -----------------------------------------------
   if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
   if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -6565,7 +6591,7 @@ function handleDelta(d){
   // une trace du fil et DOIT être rejouée.
   if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
   if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
-  if(d.stats){ T.serverStats=d.stats;
+  if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
     // prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
     // certains llama-server récents : le serveur publie alors un `ctx_used`
     // estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js
index 292033d..c07b23d 100644
--- a/internal/loki/ui/src/js/08-chat-render.js
+++ b/internal/loki/ui/src/js/08-chat-render.js
@@ -368,6 +368,11 @@ function renderToolMsg(el, tu){
     body.appendChild(wait);
   }
   addCopyButtons(body); scrollMaybe();
+  // Carte à hauteur bornée : tant que l'outil TRAVAILLE, on suit le bas (la
+  // ligne en cours d'écriture, la commande qui se tape). Terminé, on remonte en
+  // tête — c'est le résumé qu'on relit, pas la dernière ligne.
+  const bw = el.querySelector('.bodywrap');
+  if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
 }
 // Inject a "copier" button into every 
 code block (idempotent).
 function addCopyButtons(root){
diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js
index 485fc21..b26127c 100644
--- a/internal/loki/ui/src/js/09-stream.js
+++ b/internal/loki/ui/src/js/09-stream.js
@@ -89,9 +89,28 @@ function paintTurnClock(){
   }
   const ms = (T.doneTs || nowServer()) - T.startTs;
   if(!(ms > 0)){ el.remove(); return; }
-  el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms);
+  // Vitesse moyenne de la CONVERSATION à côté du temps du tour : le débit
+  // typique de ce modèle sur ce fil, toutes réponses confondues.
+  const avg = (CONV_MS > 1000 && CONV_TOK > 20)
+    ? '  ·  moy ' + (CONV_TOK / (CONV_MS/1000)).toFixed(1) + ' tok/s' : '';
+  el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms) + avg;
   el.classList.toggle('done', !!T.doneTs);
 }
+// --- Vitesse moyenne de la conversation --------------------------------------
+// Alimentée par les événements `stats` (journalisés, donc rejoués au
+// chargement : la moyenne survit au refresh). gen_tokens / gen_ms sont
+// CUMULATIFS au sein d'une complétion et retombent à la suivante : on n'ajoute
+// que le delta, et une valeur qui recule signale une nouvelle complétion.
+let CONV_TOK = 0, CONV_MS = 0, _cgTok = 0, _cgMs = 0;
+function feedConvSpeed(s){
+  const gt = s.gen_tokens || 0, gms = s.gen_ms || 0;
+  if(!gt) return;
+  if(gt < _cgTok){ _cgTok = 0; _cgMs = 0; }
+  CONV_TOK += gt - _cgTok;
+  CONV_MS  += Math.max(0, gms - _cgMs);
+  _cgTok = gt; _cgMs = gms;
+}
+function resetConvSpeed(){ CONV_TOK = 0; CONV_MS = 0; _cgTok = 0; _cgMs = 0; }
 // Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
 // ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
 // c'est précisément le moment où l'on se demande si ça avance.
@@ -314,8 +333,9 @@ function flushRender(final){
   }
   // Carte bornée (raisonnement/outil) : si on était collé en bas, on y reste —
   // la carte suit la génération. Un défilement manuel vers le haut est respecté.
-  const bw=p.el.parentElement;
-  const scrollable=bw && bw.classList.contains('bodywrap');
+  // ⚠️ p.el est la BULLE (.msg), pas .body : le bodywrap est dedans, pas au-dessus.
+  const bw=p.el.querySelector ? p.el.querySelector(':scope > .bodywrap') : null;
+  const scrollable=!!bw;
   const stick=scrollable && bw.scrollTop+bw.clientHeight>=bw.scrollHeight-60;
   const t0=performance.now();
   renderBody(p.el, text);
@@ -397,7 +417,7 @@ function handleDelta(d){
   // serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
   // a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
   // appartiennent à la discussion, le panneau doit changer avec elle.
-  if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
+  if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
   // --- Mode code (20-mode.js) -----------------------------------------------
   if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
   if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -445,7 +465,7 @@ function handleDelta(d){
   // une trace du fil et DOIT être rejouée.
   if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
   if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
-  if(d.stats){ T.serverStats=d.stats;
+  if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
     // prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
     // certains llama-server récents : le serveur publie alors un `ctx_used`
     // estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css
index 6fd5e49..3fed88b 100644
--- a/internal/loki/ui/src/styles.css
+++ b/internal/loki/ui/src/styles.css
@@ -1743,7 +1743,7 @@ button:active:not(:disabled){transform:translateY(1px)}
    ⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
    (pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
    taille, sa forme et son alignement d'une couche différente. */
-#inputbar #attach,#inputbar #files-btn,#send,#stop{
+#inputbar #attach,#inputbar #files-btn,#inputbar #mic,#send,#stop{
   width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
   display:inline-flex;align-items:center;justify-content:center;align-self:center;
   border:0;border-radius:4px;background:transparent;color:var(--dim);
@@ -1754,8 +1754,8 @@ button:active:not(:disabled){transform:translateY(1px)}
 #stop{display:none}
 html[data-busy="1"] #send{display:none}
 html[data-busy="1"] #stop{display:inline-flex}
-#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px}
-#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)}
+#inputbar #attach svg,#inputbar #files-btn svg,#inputbar #mic svg,#send svg,#stop svg{display:block;width:18px;height:18px}
+#inputbar #attach:hover,#inputbar #files-btn:hover,#inputbar #mic:hover{background:var(--row-bg-on);color:var(--text)}
 html[data-files="1"] #files-btn{color:var(--accent)}
 #send{color:var(--accent)}
 #send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
@@ -2029,8 +2029,9 @@ html[data-files="1"] #files-btn{color:var(--accent)}
 .rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
 
 /* Dictée : anneau rouge pulsé pendant l'enregistrement, estompé pendant la
-   transcription. Le bouton garde le gabarit de ses voisins (joindre, fichiers). */
-#mic.rec{color:var(--err);border-color:var(--err);animation:micpulse 1.2s ease-in-out infinite}
+   transcription. Le gabarit (34 px, fond au survol) vient de la règle commune
+   des boutons de saisie — #mic y est listé avec #attach et #files-btn. */
+#mic.rec{color:var(--err);animation:micpulse 1.2s ease-in-out infinite}
 @keyframes micpulse{50%{box-shadow:0 0 0 5px color-mix(in srgb,var(--err) 25%,transparent)}}
 #mic.busy{opacity:.55}