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}