From 3d8a72754c6076dc7e167211733ac5ae47bcb1b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 08:35:51 +0000 Subject: [PATCH 1/2] =?UTF-8?q?UI=20:=20anneau=20d'activit=C3=A9=20dans=20?= =?UTF-8?q?la=20liste=20des=20discussions,=20temps=20de=20travail=20sous?= =?UTF-8?q?=20les=20r=C3=A9ponses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux manques dans la présentation d'un tour : 1. Rien ne disait sur QUELLE discussion un agent était en train de travailler. La ligne concernée porte maintenant un anneau qui tourne, à gauche du titre — et l'en-tête aussi, puisque la barre latérale est escamotée sur téléphone. L'état vient de deux sources : le flux SSE (instantané) et /api/conversations, qui expose désormais `busy` pour que la page ouverte en cours de génération s'anime sans attendre le rejeu du journal. L'indicateur est posé et retiré SANS redessiner la liste : un redessin relancerait l'animation à chaque événement. 2. Le pied des réponses ne montrait que la vitesse du moteur, jamais le temps que le tour avait pris. Il porte maintenant « travail 1 min 04 s » — de la question envoyée à la fin du tour, raisonnement, appels d'outils et attentes compris. La durée avance à la seconde pendant le tour et se fige au turn_done ; elle apparaît aussi sur l'indicateur « … », là où il n'y a pas encore de réponse sous laquelle écrire. La bulle de raisonnement gagne au passage sa propre durée. La mesure est prise sur les HORODATAGES SERVEUR (événements `user` et `turn_done`) : elle est donc juste en direct comme au rejeu, où tout arrive d'un bloc côté client. Le compteur vivant se recale sur l'écart entre l'horloge du navigateur et celle du serveur, réévalué à chaque événement reçu en direct — un téléphone n'est pas à la même heure que la machine. « Masquer la vitesse de génération » ne masque plus que la vitesse : la durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J5UndZ9DedRXPuAoRXbmDb --- internal/loki/chat_conversation.go | 9 + internal/loki/ui/index.html | 230 ++++++++++++++++++-- internal/loki/ui/src/js/03-icons.js | 3 + internal/loki/ui/src/js/08-chat-render.js | 31 ++- internal/loki/ui/src/js/09-stream.js | 101 ++++++++- internal/loki/ui/src/js/16-conversations.js | 62 ++++++ internal/loki/ui/src/styles.css | 33 ++- internal/loki/web_chat.go | 6 +- internal/loki/web_conv_busy_test.go | 48 ++++ 9 files changed, 488 insertions(+), 35 deletions(-) create mode 100644 internal/loki/web_conv_busy_test.go diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index bc72aec..10c30e2 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -289,6 +289,15 @@ func (c *Conversation) state() map[string]any { return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns} } +// isGenerating dit si un tour est en cours. Sert à la LISTE des discussions : +// elle doit pouvoir montrer laquelle travaille dès le chargement de la page, +// sans attendre que le rejeu du flux SSE ait rattrapé son retard. +func (c *Conversation) isGenerating() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.Generating +} + // ErrBusy : une génération est déjà en cours (un seul tour à la fois). var ErrBusy = fmt.Errorf("génération en cours") diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index daff7c6..154beb5 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -475,7 +475,12 @@ button:disabled{opacity:.5;cursor:not-allowed} mini-bannière au-dessus de la saisie doublonnait les 3 points animés — deux éléments pour un seul état d'attente. Ici les points passent en accent et un libellé apparaît à côté : un seul objet à l'écran, au bon endroit. */ -.msg.typing.compacting span:not(.tlabel){background:var(--accent)} +.msg.typing.compacting span:not(.tlabel):not(.wclock){background:var(--accent)} +/* Compteur de travail sur l'indicateur « … » : il annule la mise en forme de + pastille que `.msg.typing span` applique à TOUT span de la bulle. */ +.msg.typing .wclock{width:auto;height:auto;border-radius:0;background:none;animation:none; + font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--dim); + margin-left:7px;white-space:nowrap} .msg.typing .tlabel{width:auto;height:auto;border-radius:0;background:none;animation:tlabelfade 1.6s ease-in-out infinite;font-size:12px;color:var(--accent);margin-left:3px;white-space:nowrap} @keyframes tlabelfade{0%,100%{opacity:.6}50%{opacity:1}} /* Trace laissée DANS le fil quand le contexte a été compacté : un séparateur @@ -1110,7 +1115,12 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em} #chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em; color:var(--dim);opacity:.75} #chat .msg .statline:empty{display:none} -html[data-hide-stats="1"] #chat .msg .statline{display:none} +/* « Masquer la vitesse de génération » ne masque QUE la vitesse : le temps de + travail du tour (.worktime) n'est pas une mesure de moteur, c'est ce que la + réponse a coûté. Une ligne qui ne portait que de la vitesse disparaît en + entier — sans .has-work il ne resterait qu'un blanc. */ +html[data-hide-stats="1"] #chat .msg .statline>.spd{display:none} +html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none} /* Arrivée en fondu montant plutôt qu'un surgissement sec. L'opacité finale (.75) est celle de la règle ci-dessus : l'animation doit y ATTERRIR, sinon la ligne clignote en repassant à sa valeur de repos. */ @@ -1645,6 +1655,25 @@ html[data-files="1"] #files-btn{color:var(--accent)} color:var(--dim);letter-spacing:-.01em;overflow:visible} .side .preset:hover .conv-when{opacity:0} .conv-empty{font-size:12px;color:var(--dim);padding:8px 11px} +/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul + élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un + agent est en train de travailler, y compris depuis un autre appareil. */ +/* ⚠️ Sélecteur en DEUX classes : `.preset>span` (plus haut) pose flex:1 sur tout + span enfant d'une ligne — l'anneau prenait sinon la moitié de la largeur et + écrasait le titre, exactement comme l'heure avant lui. */ +.preset>.conv-spin,#topbar>.conv-spin{flex:none;display:inline-flex;align-items:center; + justify-content:center;line-height:0;color:var(--accent)} +.conv-spin svg{animation:conv-spin 900ms linear infinite} +@keyframes conv-spin{to{transform:rotate(360deg)}} +/* Mouvement réduit : l'anneau ne tourne plus mais respire — l'information reste, + le mouvement de rotation disparaît. */ +@media (prefers-reduced-motion:reduce){ + .conv-spin svg{animation:conv-breathe 1.6s ease-in-out infinite} + @keyframes conv-breathe{0%,100%{opacity:1}50%{opacity:.25}} +} +/* Dans l'en-tête, l'anneau précède le titre : même gabarit que les boutons + voisins pour ne pas décaler la ligne quand il apparaît et disparaît. */ +#topbar>.conv-spin{width:18px} /* Repli des réglages : tout ce qui n'est pas la conversation tient sous cette seule ligne, pour que la barre latérale de la maquette (discussions + machine) reste la vue par défaut. */ @@ -3016,6 +3045,9 @@ const ICONS = { plus: '', chevron: '', search: '', + // Anneau ouvert : c'est la ROTATION (CSS) qui en fait un indicateur d'activité, + // le dessin reste au même trait que le reste du jeu. + spinner: '', sun: '', }; @@ -5199,13 +5231,21 @@ function setLabelCounts(el, add, del){ if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); lab.appendChild(cnt); } -// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki -// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait -// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée -// par la préférence d'affichage « masquer la vitesse de génération » (CSS). -function setStats(el, text){ +// Ligne de mesures sous une réponse. Les étiquettes VOUS/Loki sont masquées dans +// cette mise en page, donc les chiffres qu'on y écrivait avaient disparu : ils +// ont leur propre ligne, discrète, sous le texte. +// +// Deux informations de nature différente y cohabitent, d'où deux éléments : +// .worktime — le TEMPS DE TRAVAIL du tour (de la question à la fin de la +// réponse, outils et raisonnement compris) ; +// .spd — les mesures de vitesse (prefill / decode). +// Seule la seconde est escamotée par « masquer la vitesse de génération » : la +// durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté en +// temps. La ligne entière disparaît si elle ne porte plus que de la vitesse. +function setStats(el, work, speed){ if(!el) return; let s = el.querySelector(':scope > .statline'); + if(!work && !speed){ if(s) s.textContent=''; return; } if(!s){ s=document.createElement('div'); s.className='statline'; // Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois @@ -5215,7 +5255,18 @@ function setStats(el, text){ if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in'); el.appendChild(s); } - s.textContent = text; + // Les deux travées sont RÉUTILISÉES d'un rafraîchissement à l'autre : la ligne + // est repeinte à chaque token reçu, y recréer des éléments ferait travailler la + // mise en page des dizaines de fois par seconde pour rien. + let w = s.querySelector(':scope > .worktime'); + let p = s.querySelector(':scope > .spd'); + if(!w){ w=document.createElement('span'); w.className='worktime'; s.appendChild(w); } + if(!p){ p=document.createElement('span'); p.className='spd'; s.appendChild(p); } + w.textContent = work || ''; + // Le séparateur voyage AVEC la vitesse : masquer celle-ci ne doit pas laisser + // un « · » orphelin en bout de durée. + p.textContent = speed ? ((work ? ' \u00b7 ' : '') + speed) : ''; + s.classList.toggle('has-work', !!work); } function bodyOf(el){ return el.querySelector('.body'); } // Render markdown into a message body in place; safe because md() escapes HTML. @@ -5543,8 +5594,67 @@ function confirmPending(text){ let REPLAYING=true; // État de rendu du tour courant, délimité par les événements user / turn_done. let T=null; -function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; } +function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'' }; } newTurn(); + +// --- Temps de travail du tour ------------------------------------------------ +// « Combien de temps l'IA a-t-elle travaillé ? » se lit sous la réponse, de la +// question envoyée à la fin du tour — raisonnement, appels d'outils et attentes +// compris. La mesure est prise sur les HORODATAGES SERVEUR (l'événement `user` +// puis `turn_done`) : elle est donc exacte en direct comme au rejeu du journal, +// où tout arrive d'un bloc côté client. +// +// Pendant le tour il n'y a pas encore de `turn_done` : le compteur avance à la +// seconde. Il ne peut pas se baser sur l'horloge du navigateur telle quelle (un +// téléphone n'est pas à la même heure que la machine), on garde donc l'ÉCART +// entre les deux, réévalué à chaque événement reçu en direct. +let TS_SKEW = 0; // horloge client − horloge serveur (ms) +function nowServer(){ return Date.now() - TS_SKEW; } +let WORK_TIMER = null; +function startWorkTimer(){ if(WORK_TIMER || REPLAYING) return; WORK_TIMER=setInterval(tickWork, 1000); } +function stopWorkTimer(){ if(WORK_TIMER){ clearInterval(WORK_TIMER); WORK_TIMER=null; } } +function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); } +// 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. +function paintTypingClock(){ + const el = T.typingEl; + if(!el || el.classList.contains('compacting')) return; // le compactage a son propre libellé + if(!T.startTs) return; + const ms = nowServer() - T.startTs; + if(!(ms > 1500)) return; // pas de compteur pour une réponse immédiate + let c = el.querySelector('.wclock'); + if(!c){ c=document.createElement('span'); c.className='wclock'; el.appendChild(c); } + c.textContent = fmtDur(ms); +} +// Durée lisible : dixièmes sous 10 s (un tour court se juge à la fraction), +// secondes rondes ensuite, puis minutes et heures. Le séparateur décimal reste +// le POINT — la durée voisine des « 21.5 tok/s » sur la même ligne, une virgule +// y ferait deux conventions à trois mots d'écart. +function fmtDur(ms){ + if(!(ms > 0)) return ''; + if(ms < 10000) return (ms/1000).toFixed(1) + ' s'; + const t = Math.round(ms/1000); + if(t < 60) return t + ' s'; + const m = Math.floor(t/60), sec = t % 60; + if(m < 60) return m + ' min ' + String(sec).padStart(2, '0') + ' s'; + return Math.floor(m/60) + ' h ' + String(m % 60).padStart(2, '0') + ' min'; +} +// Libellé de durée du tour courant, vide tant qu'il n'y a rien à montrer (tour +// non commencé, ou horloges trop désaccordées pour que l'écart ait un sens). +function workLabel(){ + if(!T.startTs) return ''; + const ms = (T.doneTs || nowServer()) - T.startTs; + if(!(ms > 300)) return ''; + return 'travail ' + fmtDur(ms); +} +// Repeint la ligne de mesures de la réponse : durée d'abord, vitesse ensuite. +// `speed` non fourni = on garde le dernier libellé de vitesse connu (c'est le +// cas du tic de seconde, qui ne fait avancer que la durée). +function paintStats(el, speed){ + if(speed !== undefined) T.speedText = speed; + setStats(el, workLabel(), T.speedText); +} const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple'; function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } } // Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu @@ -5603,8 +5713,8 @@ function renderStats(el, s){ // Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette // est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le // bouton de repli, on y écrit comme avant. - if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(parts).join(' · ')); - else setStats(el, parts.join(' · ')); + if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · ')); + else paintStats(el, parts.join(' · ')); } // Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à // partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération, @@ -5614,18 +5724,25 @@ function labelTokens(el, role, n, firstTs, lastTs){ if(!el) return; const secs=(lastTs-firstTs)/1000; const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok'; + // Bulle de raisonnement : sa propre durée de génération, comme le « réfléchi + // pendant… » d'un fil de discussion — elle reste lisible une fois repliée. + const d = (secs>0.05 && n>1) ? fmtDur(lastTs-firstTs) : ''; // Bulle technique : son étiquette EST le bouton de repli, les mesures y vont. // Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures // vont dans la ligne dédiée — comme le font déjà les stats de fin de tour // (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct // par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement. - if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m); - else setStats(el, m); + if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · ')); + else paintStats(el, m); } // Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons // (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible). // 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(); + // La liste des discussions montre laquelle travaille : elle doit suivre l'état + // MÊME pendant le rejeu (une page rechargée en cours de génération doit voir + // l'anneau tourner sans attendre le premier événement en direct). + if(typeof convSyncBusy==='function') convSyncBusy(); } function syncSendBtn(){ const sb=document.getElementById('send'); // ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux @@ -5652,11 +5769,19 @@ function syncSendBtn(){ // piloté par le serveur et rejouable à l'identique. function handleDelta(d){ if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq; + // Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son + // `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas + // pendant le rejeu, où les `ts` sont vieux de plusieurs heures. + if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts; if(d.caught_up){ // Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). setChatLoading(null); if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; } + // Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur + // des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il + // faut le lancer maintenant, sinon la durée resterait figée à l'écran. + if(busy && T.startTs && !T.doneTs) startWorkTimer(); // Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra // déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil. syncChatEmpty(); @@ -5665,7 +5790,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){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; } + if(d.reset!==undefined){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; } if(d.user!==undefined){ newTurn(); let el=PENDING; @@ -5674,11 +5799,20 @@ function handleDelta(d){ // porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une // bulle neuve — sinon elles apparaîtraient en double. if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files); - setBusy(true); T.typingEl=addTyping(); return; } + // Départ du chronomètre : l'horodatage SERVEUR du message, pas l'heure + // locale — c'est ce qui rend la durée juste au rejeu comme en direct. + T.startTs = d.ts || 0; T.doneTs = 0; + setBusy(true); startWorkTimer(); T.typingEl=addTyping(); return; } // Fin de tour : la discussion vient d'être enregistrée côté serveur — son // titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au // replay, qui rejoue tous les tours passés d'un bloc. - if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; } + if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer(); + // La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer + // le temps réellement passé et non continuer d'avancer. + T.doneTs = d.ts || T.doneTs || 0; + if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); + else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste + setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; } if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } if(d.compacting!==undefined){ setCompacting(d.compacting); return; } if(d.compacted){ setCompacting(false); addCompactMark(); return; } @@ -7155,12 +7289,18 @@ function fmtWhen(sec){ // titre, dates, nombre d'échanges — jamais les messages. let CONVS = [], CONV_ACTIVE = ''; +// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au +// premier affichage : le flux SSE n'a pas encore rejoué son journal, mais la +// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de +// route, ou ouverte depuis un autre appareil). +let CONV_BUSY = false; async function loadConversations(){ let r; try{ r = await jget('/api/conversations'); }catch(_){ return; } CONVS = r.conversations || []; CONV_ACTIVE = r.active || ''; + CONV_BUSY = !!r.busy; renderConversations(); } @@ -7178,11 +7318,16 @@ function renderConversations(){ e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)'; cont.appendChild(e); syncTopbarTitle(); + convSyncBusy(); return; } for(const c of list){ const row = document.createElement('div'); row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : ''); + // L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE + // qui travaille sans redessiner la liste (un redessin relancerait + // l'animation de l'anneau à chaque événement du flux). + row.dataset.conv = c.id; row.onclick = () => convSwitch(c.id); const info = document.createElement('div'); info.className = 'preset-info'; @@ -7215,6 +7360,57 @@ function renderConversations(){ cont.appendChild(row); } syncTopbarTitle(); + convSyncBusy(); +} + +// --- Discussion au travail : anneau qui tourne ------------------------------ +// Une seule génération tourne à la fois, et c'est forcément sur la discussion +// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la +// ligne active. Deux sources le renseignent — le flux SSE (`busy`, instantané) +// et la liste (`CONV_BUSY`, utile avant que le rejeu ait rattrapé). Une fois le +// rattrapage fini, l'état local fait foi : il est plus frais que la liste, qui +// n'est rechargée qu'en fin de tour. +function convBusyNow(){ + const live = (typeof busy !== 'undefined') && busy; + if(typeof REPLAYING !== 'undefined' && !REPLAYING) return !!live; + return CONV_BUSY || !!live; +} + +// Pose ou retire l'anneau dans un conteneur, SANS toucher au reste de son +// contenu : réécrire l'hôte remettrait l'animation à zéro. +function convSpinToggle(host, on, before){ + if(!host) return; + let sp = host.querySelector(':scope > .conv-spin'); + if(on && !sp){ + sp = document.createElement('span'); + sp.className = 'conv-spin'; + sp.title = 'l\u2019agent travaille sur cette discussion\u2026'; + sp.setAttribute('role', 'img'); + sp.setAttribute('aria-label', 'génération en cours'); + sp.appendChild(icon('spinner', 14)); + if(before) host.insertBefore(sp, before); else host.appendChild(sp); + } else if(!on && sp){ + sp.remove(); + } +} + +// Rafraîchit l'indicateur partout où il se montre : la ligne de la discussion +// concernée, et l'en-tête (la barre latérale est escamotée sur téléphone — sans +// ça, rien ne dirait que ça travaille). +function convSyncBusy(){ + const on = convBusyNow(); + const cont = document.getElementById('conv-list'); + if(cont){ + for(const row of cont.querySelectorAll('.preset')){ + const mine = on && row.dataset.conv === CONV_ACTIVE; + row.classList.toggle('working', mine); + convSpinToggle(row, mine, row.firstChild); + } + } + // Dans l'en-tête, l'anneau est un FRÈRE du titre : celui-ci tronque son texte + // à l'ellipse, un enfant y serait rogné avec lui sur un titre long. + const tb = document.getElementById('topbar-title'); + convSpinToggle(tb && tb.parentNode, on, tb); } // Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle diff --git a/internal/loki/ui/src/js/03-icons.js b/internal/loki/ui/src/js/03-icons.js index 75e4c0e..54c7dd9 100644 --- a/internal/loki/ui/src/js/03-icons.js +++ b/internal/loki/ui/src/js/03-icons.js @@ -26,6 +26,9 @@ const ICONS = { plus: '', chevron: '', search: '', + // Anneau ouvert : c'est la ROTATION (CSS) qui en fait un indicateur d'activité, + // le dessin reste au même trait que le reste du jeu. + spinner: '', sun: '', }; diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js index 981db7b..718e813 100644 --- a/internal/loki/ui/src/js/08-chat-render.js +++ b/internal/loki/ui/src/js/08-chat-render.js @@ -153,13 +153,21 @@ function setLabelCounts(el, add, del){ if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); lab.appendChild(cnt); } -// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki -// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait -// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée -// par la préférence d'affichage « masquer la vitesse de génération » (CSS). -function setStats(el, text){ +// Ligne de mesures sous une réponse. Les étiquettes VOUS/Loki sont masquées dans +// cette mise en page, donc les chiffres qu'on y écrivait avaient disparu : ils +// ont leur propre ligne, discrète, sous le texte. +// +// Deux informations de nature différente y cohabitent, d'où deux éléments : +// .worktime — le TEMPS DE TRAVAIL du tour (de la question à la fin de la +// réponse, outils et raisonnement compris) ; +// .spd — les mesures de vitesse (prefill / decode). +// Seule la seconde est escamotée par « masquer la vitesse de génération » : la +// durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté en +// temps. La ligne entière disparaît si elle ne porte plus que de la vitesse. +function setStats(el, work, speed){ if(!el) return; let s = el.querySelector(':scope > .statline'); + if(!work && !speed){ if(s) s.textContent=''; return; } if(!s){ s=document.createElement('div'); s.className='statline'; // Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois @@ -169,7 +177,18 @@ function setStats(el, text){ if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in'); el.appendChild(s); } - s.textContent = text; + // Les deux travées sont RÉUTILISÉES d'un rafraîchissement à l'autre : la ligne + // est repeinte à chaque token reçu, y recréer des éléments ferait travailler la + // mise en page des dizaines de fois par seconde pour rien. + let w = s.querySelector(':scope > .worktime'); + let p = s.querySelector(':scope > .spd'); + if(!w){ w=document.createElement('span'); w.className='worktime'; s.appendChild(w); } + if(!p){ p=document.createElement('span'); p.className='spd'; s.appendChild(p); } + w.textContent = work || ''; + // Le séparateur voyage AVEC la vitesse : masquer celle-ci ne doit pas laisser + // un « · » orphelin en bout de durée. + p.textContent = speed ? ((work ? ' \u00b7 ' : '') + speed) : ''; + s.classList.toggle('has-work', !!work); } function bodyOf(el){ return el.querySelector('.body'); } // Render markdown into a message body in place; safe because md() escapes HTML. diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index 29ea230..d6cc63a 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -51,8 +51,67 @@ function confirmPending(text){ let REPLAYING=true; // État de rendu du tour courant, délimité par les événements user / turn_done. let T=null; -function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; } +function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'' }; } newTurn(); + +// --- Temps de travail du tour ------------------------------------------------ +// « Combien de temps l'IA a-t-elle travaillé ? » se lit sous la réponse, de la +// question envoyée à la fin du tour — raisonnement, appels d'outils et attentes +// compris. La mesure est prise sur les HORODATAGES SERVEUR (l'événement `user` +// puis `turn_done`) : elle est donc exacte en direct comme au rejeu du journal, +// où tout arrive d'un bloc côté client. +// +// Pendant le tour il n'y a pas encore de `turn_done` : le compteur avance à la +// seconde. Il ne peut pas se baser sur l'horloge du navigateur telle quelle (un +// téléphone n'est pas à la même heure que la machine), on garde donc l'ÉCART +// entre les deux, réévalué à chaque événement reçu en direct. +let TS_SKEW = 0; // horloge client − horloge serveur (ms) +function nowServer(){ return Date.now() - TS_SKEW; } +let WORK_TIMER = null; +function startWorkTimer(){ if(WORK_TIMER || REPLAYING) return; WORK_TIMER=setInterval(tickWork, 1000); } +function stopWorkTimer(){ if(WORK_TIMER){ clearInterval(WORK_TIMER); WORK_TIMER=null; } } +function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); } +// 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. +function paintTypingClock(){ + const el = T.typingEl; + if(!el || el.classList.contains('compacting')) return; // le compactage a son propre libellé + if(!T.startTs) return; + const ms = nowServer() - T.startTs; + if(!(ms > 1500)) return; // pas de compteur pour une réponse immédiate + let c = el.querySelector('.wclock'); + if(!c){ c=document.createElement('span'); c.className='wclock'; el.appendChild(c); } + c.textContent = fmtDur(ms); +} +// Durée lisible : dixièmes sous 10 s (un tour court se juge à la fraction), +// secondes rondes ensuite, puis minutes et heures. Le séparateur décimal reste +// le POINT — la durée voisine des « 21.5 tok/s » sur la même ligne, une virgule +// y ferait deux conventions à trois mots d'écart. +function fmtDur(ms){ + if(!(ms > 0)) return ''; + if(ms < 10000) return (ms/1000).toFixed(1) + ' s'; + const t = Math.round(ms/1000); + if(t < 60) return t + ' s'; + const m = Math.floor(t/60), sec = t % 60; + if(m < 60) return m + ' min ' + String(sec).padStart(2, '0') + ' s'; + return Math.floor(m/60) + ' h ' + String(m % 60).padStart(2, '0') + ' min'; +} +// Libellé de durée du tour courant, vide tant qu'il n'y a rien à montrer (tour +// non commencé, ou horloges trop désaccordées pour que l'écart ait un sens). +function workLabel(){ + if(!T.startTs) return ''; + const ms = (T.doneTs || nowServer()) - T.startTs; + if(!(ms > 300)) return ''; + return 'travail ' + fmtDur(ms); +} +// Repeint la ligne de mesures de la réponse : durée d'abord, vitesse ensuite. +// `speed` non fourni = on garde le dernier libellé de vitesse connu (c'est le +// cas du tic de seconde, qui ne fait avancer que la durée). +function paintStats(el, speed){ + if(speed !== undefined) T.speedText = speed; + setStats(el, workLabel(), T.speedText); +} const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple'; function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } } // Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu @@ -111,8 +170,8 @@ function renderStats(el, s){ // Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette // est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le // bouton de repli, on y écrit comme avant. - if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(parts).join(' · ')); - else setStats(el, parts.join(' · ')); + if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · ')); + else paintStats(el, parts.join(' · ')); } // Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à // partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération, @@ -122,18 +181,25 @@ function labelTokens(el, role, n, firstTs, lastTs){ if(!el) return; const secs=(lastTs-firstTs)/1000; const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok'; + // Bulle de raisonnement : sa propre durée de génération, comme le « réfléchi + // pendant… » d'un fil de discussion — elle reste lisible une fois repliée. + const d = (secs>0.05 && n>1) ? fmtDur(lastTs-firstTs) : ''; // Bulle technique : son étiquette EST le bouton de repli, les mesures y vont. // Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures // vont dans la ligne dédiée — comme le font déjà les stats de fin de tour // (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct // par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement. - if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m); - else setStats(el, m); + if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · ')); + else paintStats(el, m); } // Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons // (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible). // 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(); + // La liste des discussions montre laquelle travaille : elle doit suivre l'état + // MÊME pendant le rejeu (une page rechargée en cours de génération doit voir + // l'anneau tourner sans attendre le premier événement en direct). + if(typeof convSyncBusy==='function') convSyncBusy(); } function syncSendBtn(){ const sb=document.getElementById('send'); // ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux @@ -160,11 +226,19 @@ function syncSendBtn(){ // piloté par le serveur et rejouable à l'identique. function handleDelta(d){ if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq; + // Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son + // `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas + // pendant le rejeu, où les `ts` sont vieux de plusieurs heures. + if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts; if(d.caught_up){ // Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). setChatLoading(null); if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; } + // Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur + // des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il + // faut le lancer maintenant, sinon la durée resterait figée à l'écran. + if(busy && T.startTs && !T.doneTs) startWorkTimer(); // Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra // déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil. syncChatEmpty(); @@ -173,7 +247,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){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; } + if(d.reset!==undefined){ stopWorkTimer(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; } if(d.user!==undefined){ newTurn(); let el=PENDING; @@ -182,11 +256,20 @@ function handleDelta(d){ // porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une // bulle neuve — sinon elles apparaîtraient en double. if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files); - setBusy(true); T.typingEl=addTyping(); return; } + // Départ du chronomètre : l'horodatage SERVEUR du message, pas l'heure + // locale — c'est ce qui rend la durée juste au rejeu comme en direct. + T.startTs = d.ts || 0; T.doneTs = 0; + setBusy(true); startWorkTimer(); T.typingEl=addTyping(); return; } // Fin de tour : la discussion vient d'être enregistrée côté serveur — son // titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au // replay, qui rejoue tous les tours passés d'un bloc. - if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; } + if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer(); + // La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer + // le temps réellement passé et non continuer d'avancer. + T.doneTs = d.ts || T.doneTs || 0; + if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); + else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste + setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; } if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; } if(d.compacting!==undefined){ setCompacting(d.compacting); return; } if(d.compacted){ setCompacting(false); addCompactMark(); return; } diff --git a/internal/loki/ui/src/js/16-conversations.js b/internal/loki/ui/src/js/16-conversations.js index 28139e3..845fdf5 100644 --- a/internal/loki/ui/src/js/16-conversations.js +++ b/internal/loki/ui/src/js/16-conversations.js @@ -14,12 +14,18 @@ // titre, dates, nombre d'échanges — jamais les messages. let CONVS = [], CONV_ACTIVE = ''; +// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au +// premier affichage : le flux SSE n'a pas encore rejoué son journal, mais la +// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de +// route, ou ouverte depuis un autre appareil). +let CONV_BUSY = false; async function loadConversations(){ let r; try{ r = await jget('/api/conversations'); }catch(_){ return; } CONVS = r.conversations || []; CONV_ACTIVE = r.active || ''; + CONV_BUSY = !!r.busy; renderConversations(); } @@ -37,11 +43,16 @@ function renderConversations(){ e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)'; cont.appendChild(e); syncTopbarTitle(); + convSyncBusy(); return; } for(const c of list){ const row = document.createElement('div'); row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : ''); + // L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE + // qui travaille sans redessiner la liste (un redessin relancerait + // l'animation de l'anneau à chaque événement du flux). + row.dataset.conv = c.id; row.onclick = () => convSwitch(c.id); const info = document.createElement('div'); info.className = 'preset-info'; @@ -74,6 +85,57 @@ function renderConversations(){ cont.appendChild(row); } syncTopbarTitle(); + convSyncBusy(); +} + +// --- Discussion au travail : anneau qui tourne ------------------------------ +// Une seule génération tourne à la fois, et c'est forcément sur la discussion +// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la +// ligne active. Deux sources le renseignent — le flux SSE (`busy`, instantané) +// et la liste (`CONV_BUSY`, utile avant que le rejeu ait rattrapé). Une fois le +// rattrapage fini, l'état local fait foi : il est plus frais que la liste, qui +// n'est rechargée qu'en fin de tour. +function convBusyNow(){ + const live = (typeof busy !== 'undefined') && busy; + if(typeof REPLAYING !== 'undefined' && !REPLAYING) return !!live; + return CONV_BUSY || !!live; +} + +// Pose ou retire l'anneau dans un conteneur, SANS toucher au reste de son +// contenu : réécrire l'hôte remettrait l'animation à zéro. +function convSpinToggle(host, on, before){ + if(!host) return; + let sp = host.querySelector(':scope > .conv-spin'); + if(on && !sp){ + sp = document.createElement('span'); + sp.className = 'conv-spin'; + sp.title = 'l\u2019agent travaille sur cette discussion\u2026'; + sp.setAttribute('role', 'img'); + sp.setAttribute('aria-label', 'génération en cours'); + sp.appendChild(icon('spinner', 14)); + if(before) host.insertBefore(sp, before); else host.appendChild(sp); + } else if(!on && sp){ + sp.remove(); + } +} + +// Rafraîchit l'indicateur partout où il se montre : la ligne de la discussion +// concernée, et l'en-tête (la barre latérale est escamotée sur téléphone — sans +// ça, rien ne dirait que ça travaille). +function convSyncBusy(){ + const on = convBusyNow(); + const cont = document.getElementById('conv-list'); + if(cont){ + for(const row of cont.querySelectorAll('.preset')){ + const mine = on && row.dataset.conv === CONV_ACTIVE; + row.classList.toggle('working', mine); + convSpinToggle(row, mine, row.firstChild); + } + } + // Dans l'en-tête, l'anneau est un FRÈRE du titre : celui-ci tronque son texte + // à l'ellipse, un enfant y serait rogné avec lui sur un titre long. + const tb = document.getElementById('topbar-title'); + convSpinToggle(tb && tb.parentNode, on, tb); } // Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 0c87e05..d3f69dc 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -444,7 +444,12 @@ button:disabled{opacity:.5;cursor:not-allowed} mini-bannière au-dessus de la saisie doublonnait les 3 points animés — deux éléments pour un seul état d'attente. Ici les points passent en accent et un libellé apparaît à côté : un seul objet à l'écran, au bon endroit. */ -.msg.typing.compacting span:not(.tlabel){background:var(--accent)} +.msg.typing.compacting span:not(.tlabel):not(.wclock){background:var(--accent)} +/* Compteur de travail sur l'indicateur « … » : il annule la mise en forme de + pastille que `.msg.typing span` applique à TOUT span de la bulle. */ +.msg.typing .wclock{width:auto;height:auto;border-radius:0;background:none;animation:none; + font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--dim); + margin-left:7px;white-space:nowrap} .msg.typing .tlabel{width:auto;height:auto;border-radius:0;background:none;animation:tlabelfade 1.6s ease-in-out infinite;font-size:12px;color:var(--accent);margin-left:3px;white-space:nowrap} @keyframes tlabelfade{0%,100%{opacity:.6}50%{opacity:1}} /* Trace laissée DANS le fil quand le contexte a été compacté : un séparateur @@ -1079,7 +1084,12 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em} #chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em; color:var(--dim);opacity:.75} #chat .msg .statline:empty{display:none} -html[data-hide-stats="1"] #chat .msg .statline{display:none} +/* « Masquer la vitesse de génération » ne masque QUE la vitesse : le temps de + travail du tour (.worktime) n'est pas une mesure de moteur, c'est ce que la + réponse a coûté. Une ligne qui ne portait que de la vitesse disparaît en + entier — sans .has-work il ne resterait qu'un blanc. */ +html[data-hide-stats="1"] #chat .msg .statline>.spd{display:none} +html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none} /* Arrivée en fondu montant plutôt qu'un surgissement sec. L'opacité finale (.75) est celle de la règle ci-dessus : l'animation doit y ATTERRIR, sinon la ligne clignote en repassant à sa valeur de repos. */ @@ -1614,6 +1624,25 @@ html[data-files="1"] #files-btn{color:var(--accent)} color:var(--dim);letter-spacing:-.01em;overflow:visible} .side .preset:hover .conv-when{opacity:0} .conv-empty{font-size:12px;color:var(--dim);padding:8px 11px} +/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul + élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un + agent est en train de travailler, y compris depuis un autre appareil. */ +/* ⚠️ Sélecteur en DEUX classes : `.preset>span` (plus haut) pose flex:1 sur tout + span enfant d'une ligne — l'anneau prenait sinon la moitié de la largeur et + écrasait le titre, exactement comme l'heure avant lui. */ +.preset>.conv-spin,#topbar>.conv-spin{flex:none;display:inline-flex;align-items:center; + justify-content:center;line-height:0;color:var(--accent)} +.conv-spin svg{animation:conv-spin 900ms linear infinite} +@keyframes conv-spin{to{transform:rotate(360deg)}} +/* Mouvement réduit : l'anneau ne tourne plus mais respire — l'information reste, + le mouvement de rotation disparaît. */ +@media (prefers-reduced-motion:reduce){ + .conv-spin svg{animation:conv-breathe 1.6s ease-in-out infinite} + @keyframes conv-breathe{0%,100%{opacity:1}50%{opacity:.25}} +} +/* Dans l'en-tête, l'anneau précède le titre : même gabarit que les boutons + voisins pour ne pas décaler la ligne quand il apparaît et disparaît. */ +#topbar>.conv-spin{width:18px} /* Repli des réglages : tout ce qui n'est pas la conversation tient sous cette seule ligne, pour que la barre latérale de la maquette (discussions + machine) reste la vue par défaut. */ diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go index 41c25ee..a7f12a7 100644 --- a/internal/loki/web_chat.go +++ b/internal/loki/web_chat.go @@ -130,7 +130,11 @@ func handleConvList(w http.ResponseWriter, r *http.Request) { if list == nil { list = []convMeta{} } - sendJSON(w, 200, map[string]any{"conversations": list, "active": active}) + // `busy` = un tour est en cours SUR LA DISCUSSION ACTIVE (il n'y a qu'une + // génération à la fois, sur le fil ouvert). L'UI s'en sert pour animer la + // ligne concernée dès le chargement, avant même que le flux SSE ait rejoué + // le journal — sinon la page reste muette pendant qu'un agent travaille. + sendJSON(w, 200, map[string]any{"conversations": list, "active": active, "busy": conv.isGenerating()}) } func handleConvNew(w http.ResponseWriter, r *http.Request) { diff --git a/internal/loki/web_conv_busy_test.go b/internal/loki/web_conv_busy_test.go new file mode 100644 index 0000000..cee9ec7 --- /dev/null +++ b/internal/loki/web_conv_busy_test.go @@ -0,0 +1,48 @@ +package loki + +import ( + "encoding/json" + "net/http/httptest" + "testing" +) + +// La liste des discussions doit dire LAQUELLE travaille : c'est ce qui fait +// tourner l'anneau dans la barre latérale dès le chargement de la page, sans +// attendre que le flux SSE ait rejoué son journal. +func TestConvListRapporteLaGeneration(t *testing.T) { + withWorkspace(t) + + busy := func() bool { + rr := httptest.NewRecorder() + handleConvList(rr, httptest.NewRequest("GET", "/api/conversations", nil)) + if rr.Code != 200 { + t.Fatalf("code %d : %s", rr.Code, rr.Body.String()) + } + var out struct { + Active string `json:"active"` + Busy bool `json:"busy"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.Active == "" { + t.Fatal("aucune discussion active dans la liste") + } + return out.Busy + } + + if busy() { + t.Fatal("busy=true alors qu'aucune génération ne tourne") + } + conv.mu.Lock() + conv.Generating = true + conv.mu.Unlock() + defer func() { + conv.mu.Lock() + conv.Generating = false + conv.mu.Unlock() + }() + if !busy() { + t.Fatal("busy=false alors qu'une génération tourne") + } +} From 69e89e5c2f6e49986d9eca4fb43ca3e69f60a7e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 08:56:32 +0000 Subject: [PATCH 2/2] Compteur de vitesse par bulle, et budget souple d'appels d'outils MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deux défauts révélés par un tour d'agent d'une heure (~50 appels d'outils) sur un modèle très quantifié. 1. La vitesse affichée sous les réponses tombait de 17 tok/s à 0,9 au fil du tour, ce qui donnait à croire que le moteur s'effondrait. Il n'en était rien : un tour d'agent ouvre une bulle NEUVE après chaque appel d'outil (le flux repasse contentEl à null), mais les compteurs n'étaient jamais remis à zéro. Chaque bulle affichait donc le CUMUL de tout le tour divisé par le temps écoulé depuis le tout premier token — exécution des outils, pages web et prefill compris. La vitesse convergeait mécaniquement vers « tokens générés ÷ durée totale du tour ». Les compteurs sont maintenant remis à zéro à la CRÉATION de la bulle, ce qui couvre tout chemin qui en ouvre une neuve, aujourd'hui comme demain. Rejoué sur un tour synthétique où le moteur décode à 20 tok/s constants entre deux outils de deux minutes : 20,5 / 0,6 / 0,5 tok/s avant, 20,5 sur les trois bulles après. La durée « travail », elle, reste bien celle du tour entier — c'est sa définition. 2. Rien n'exerçait de pression sur un tour qui tourne en rond. Le plafond d'itérations avait été retiré en v0.6.3 (il coupait des recherches légitimes) et la déduplication d'appels ne rattrape pas ce cas : sa clé est « nom + arguments bruts », or relire le même fichier par tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés différentes. D'où un budget SOUPLE : au-delà de 24 appels d'outils sur un tour, on rappelle au modèle combien il en a déjà faits et on lui demande de conclure. Le rappel revient à chaque palier en durcissant le ton, et ne coupe jamais le tour. Il est ajouté EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté llama-server, et n'est pas persisté. `AGENT_BUDGET` dans config.env règle le palier, `off` le désactive. Élargir plutôt la clé de déduplication à la CIBLE de l'appel a été écarté : deux tranches d'un même fichier renvoient un contenu différent, les confondre casserait toute lecture paginée légitime. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J5UndZ9DedRXPuAoRXbmDb --- README.md | 9 +++ internal/loki/llm_budget.go | 91 +++++++++++++++++++++++++++ internal/loki/llm_budget_test.go | 93 ++++++++++++++++++++++++++++ internal/loki/llm_client.go | 18 ++++-- internal/loki/ui/index.html | 22 +++++-- internal/loki/ui/src/js/09-stream.js | 22 +++++-- 6 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 internal/loki/llm_budget.go create mode 100644 internal/loki/llm_budget_test.go diff --git a/README.md b/README.md index 85096ee..be8270a 100644 --- a/README.md +++ b/README.md @@ -237,6 +237,15 @@ Ajoutées par ce fork : (`.oai.ajean.link`) est retirée de l'interface : elle exigeait un jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne pouvait donc qu'échouer. +- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une + recherche légitime est pire que la laisser durer — mais au-delà de 24 appels + sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui + demande de conclure. Le rappel revient tous les 24 appels, en durcissant le + ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière. + Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf + le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois + les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` — + `AGENT_BUDGET=off` le désactive complètement. - **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés dans le fil. - **Paramètres** : les réglages d'application (identité, apparence, accès diff --git a/internal/loki/llm_budget.go b/internal/loki/llm_budget.go new file mode 100644 index 0000000..af9a374 --- /dev/null +++ b/internal/loki/llm_budget.go @@ -0,0 +1,91 @@ +package loki + +// llm_budget.go — budget SOUPLE d'appels d'outils par tour. +// +// Le plafond dur (TOOL_LIMIT) et l'anti-boucle ont été retirés en v0.6.3 : ils +// coupaient des tours parfaitement légitimes, une recherche enchaînant sans +// problème des dizaines d'appels. Mais il ne restait plus RIEN entre « le +// modèle converge » et « le modèle tourne en rond pendant une heure » — vu en +// production : ~50 appels d'outil, 55 minutes, le modèle relisant cinq fois les +// mêmes fonctions pour re-dériver une conclusion qu'il avait déjà écrite. +// +// La déduplication d'appels identiques (repeatedCallResult) ne rattrape pas ce +// cas : sa clé est « nom + arguments bruts », et relire le même fichier par +// tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés +// différentes. Élargir cette clé à la CIBLE serait pire que le mal — deux +// tranches d'un même fichier renvoient un contenu différent, les confondre +// casserait toute lecture paginée légitime. +// +// D'où un budget qui ne coupe rien : au-delà d'un palier, on RAPPELLE au modèle +// combien d'appels il a déjà faits et on lui demande de conclure. Le tour reste +// entièrement sous son contrôle — c'est de la pression, pas une barrière. + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// agentBudgetDefault : nombre d'appels d'outils au-delà duquel un tour cesse +// d'être une recherche et commence à ressembler à du sur-place. Assez haut pour +// qu'une vraie enquête (lire dix fichiers, croiser trois pages web) passe sans +// jamais voir le rappel. +const agentBudgetDefault = 24 + +// agentBudget lit le palier dans config.env (AGENT_BUDGET). 0 — ou une valeur +// off/false/no/non — désactive complètement le mécanisme. +func agentBudget() int { + v := strings.ToLower(strings.TrimSpace(ReadConfig()["AGENT_BUDGET"])) + switch v { + case "": + return agentBudgetDefault + case "off", "false", "no", "non", "disable", "disabled": + return 0 + } + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + return n + } + return agentBudgetDefault +} + +// budgetNudge renvoie le rappel à injecter quand le tour vient de franchir un +// palier, ou "" s'il n'y a rien à dire. +// +// calls : appels d'outils exécutés depuis le début du tour +// budget : taille d'un palier (0 = mécanisme désactivé) +// sent : rappels déjà envoyés dans ce tour +// +// Les paliers sont à budget, 2×budget, 3×budget… : la pression revient donc +// régulièrement au lieu de s'éteindre après un avertissement ignoré. Le coût en +// contexte est négligeable (une phrase toutes les 24 requêtes) et le message est +// AJOUTÉ EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté +// llama-server. +// +// Le texte est en anglais comme le reste du protocole d'outils : c'est la langue +// dans laquelle les modèles suivent le mieux une consigne impérative, quelle que +// soit celle de la conversation. +func budgetNudge(calls, budget, sent int) string { + if budget <= 0 || calls < budget*(sent+1) { + return "" + } + n := fmt.Sprintf("%d tool calls", calls) + switch sent { + case 0: + return "[system] You have made " + n + " in this single turn. That is a lot: you almost certainly have enough to answer already. " + + "Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing." + case 1: + return "[system] " + n + " now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. " + + "Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further." + default: + return "[system] " + n + ". Stop. Write your final answer in this message. Do not call another tool." + } +} + +// logBudget trace UNE ligne par rappel sur la sortie d'erreur (donc dans +// `journalctl -u loki-ui`), comme logCompact. Sans ça, un tour qui part en +// vrille est invisible côté serveur : on ne voit qu'une génération qui dure. +func logBudget(calls, budget, sent int) { + fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n", + calls, budget, sent) +} diff --git a/internal/loki/llm_budget_test.go b/internal/loki/llm_budget_test.go new file mode 100644 index 0000000..17363db --- /dev/null +++ b/internal/loki/llm_budget_test.go @@ -0,0 +1,93 @@ +package loki + +import "testing" + +// Le budget ne se déclenche pas avant son palier, et il revient à chaque +// palier suivant : un modèle qui ignore le premier rappel doit en recevoir un +// second, plus ferme. +func TestBudgetNudgePaliers(t *testing.T) { + const budget = 24 + cases := []struct { + calls, sent int + want bool + }{ + {0, 0, false}, + {1, 0, false}, + {23, 0, false}, + {24, 0, true}, // 1er palier + {40, 1, false}, // rappel déjà envoyé, palier suivant pas atteint + {48, 1, true}, // 2e palier + {72, 2, true}, // 3e palier + {95, 3, false}, + {96, 3, true}, // 4e palier : la pression ne s'éteint pas + } + for _, c := range cases { + got := budgetNudge(c.calls, budget, c.sent) != "" + if got != c.want { + t.Errorf("budgetNudge(calls=%d, sent=%d) = %v, attendu %v", c.calls, c.sent, got, c.want) + } + } +} + +// Le message durcit à chaque rappel : trois textes distincts, tous porteurs du +// nombre d'appels (c'est LUI qui informe le modèle, pas le ton). +func TestBudgetNudgeEscalade(t *testing.T) { + const budget = 10 + seen := map[string]bool{} + for sent := 0; sent < 3; sent++ { + m := budgetNudge(budget*(sent+1), budget, sent) + if m == "" { + t.Fatalf("rappel n°%d vide", sent+1) + } + if seen[m] { + t.Errorf("rappel n°%d identique à un précédent : %q", sent+1, m) + } + seen[m] = true + } + // Au-delà, on réutilise le texte le plus ferme plutôt que d'en inventer un + // nouveau à chaque palier (à nombre d'appels égal, le message est le même). + if a, b := budgetNudge(50, budget, 3), budgetNudge(50, budget, 4); a != b { + t.Errorf("les rappels tardifs devraient partager le même texte :\n%q\n%q", a, b) + } +} + +// budget=0 (AGENT_BUDGET=off) : plus aucun rappel, quel que soit le nombre +// d'appels — le mécanisme doit pouvoir être totalement coupé. +func TestBudgetNudgeDesactive(t *testing.T) { + for _, calls := range []int{0, 24, 500, 10000} { + if m := budgetNudge(calls, 0, 0); m != "" { + t.Errorf("budget désactivé mais rappel émis à %d appels : %q", calls, m) + } + } + if m := budgetNudge(100, -5, 0); m != "" { + t.Errorf("budget négatif devrait être inerte, obtenu %q", m) + } +} + +func TestAgentBudgetConfig(t *testing.T) { + testHome(t) + set := func(v string) { + cfg := ReadConfig() + cfg["AGENT_BUDGET"] = v + if err := WriteConfig(cfg); err != nil { + t.Fatal(err) + } + } + for _, c := range []struct { + val string + want int + }{ + {"", agentBudgetDefault}, + {"40", 40}, + {"0", 0}, + {"off", 0}, + {"NON", 0}, + {"n'importe quoi", agentBudgetDefault}, // valeur illisible : on ne l'invente pas + {"-3", agentBudgetDefault}, + } { + set(c.val) + if got := agentBudget(); got != c.want { + t.Errorf("AGENT_BUDGET=%q → %d, attendu %d", c.val, got, c.want) + } + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index 709e003..20b7e4b 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -599,11 +599,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge // explicite au lieu d'afficher « pas de réponse ». nudged := false - // Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours - // parfaitement légitimes (une recherche enchaîne facilement des dizaines - // d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule - // le contexte — c'est un choix assumé. + // Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond + // d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà + // d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui + // demande de conclure — de la pression, pas une barrière. Sans ça, un modèle + // qui tourne en rond n'avait rien en face de lui sauf le bouton stop. + toolRuns, budgetNudges, budget := 0, 0, agentBudget() for iter := 0; ; iter++ { + // Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté + // llama-server reste valide, seul le nouveau message est à traiter. + if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" { + budgetNudges++ + logBudget(toolRuns, budget, budgetNudges) + messages = append(messages, Message{Role: "user", Content: msg}) + } payload := map[string]any{ "model": "loki", "messages": messages, @@ -946,6 +955,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if ctx.Err() != nil { return extra, nil } + toolRuns++ // alimente le budget souple (voir budgetNudge) var args map[string]any _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) // Derive the human label (command / skill name) up front so we can diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 154beb5..abed389 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -5716,6 +5716,20 @@ function renderStats(el, s){ if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · ')); else paintStats(el, parts.join(' · ')); } +// --- Compteurs de bulle ----------------------------------------------------- +// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle +// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les +// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le +// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier +// token — exécution des outils, pages web et prefill compris. +// +// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait +// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur +// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite +// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert, +// aujourd'hui comme demain. +function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; } +function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; } // Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à // partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération, // donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un @@ -5840,11 +5854,11 @@ function handleDelta(d){ return; } if(d.reasoning_content){ killTyping('reasoning'); - if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); } + if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); } // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. - if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; } + if(d.replace){ T.fullReason=''; resetReasonStats(); } showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. @@ -5853,8 +5867,8 @@ function handleDelta(d){ return; } if(d.content){ removeTyping(); - if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; } - if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; } + if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } + if(d.replace){ T.fullContent=''; resetContentStats(); } T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent); if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1); labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs); diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index d6cc63a..d28bf5c 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -173,6 +173,20 @@ function renderStats(el, s){ if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · ')); else paintStats(el, parts.join(' · ')); } +// --- Compteurs de bulle ----------------------------------------------------- +// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle +// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les +// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le +// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier +// token — exécution des outils, pages web et prefill compris. +// +// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait +// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur +// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite +// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert, +// aujourd'hui comme demain. +function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; } +function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; } // Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à // partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération, // donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un @@ -297,11 +311,11 @@ function handleDelta(d){ return; } if(d.reasoning_content){ killTyping('reasoning'); - if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); } + if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); } // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro // au lieu de concaténer, sinon le texte apparaît en double. - if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; } + if(d.replace){ T.fullReason=''; resetReasonStats(); } showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs // tokens d'un coup. Sinon (direct), 1 token, ts0=ts. @@ -310,8 +324,8 @@ function handleDelta(d){ return; } if(d.content){ removeTyping(); - if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; } - if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; } + if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); } + if(d.replace){ T.fullContent=''; resetContentStats(); } T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent); if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1); labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);