diff --git a/conversation.go b/conversation.go index 73344eb..cd69cd1 100644 --- a/conversation.go +++ b/conversation.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "sync" + "time" ) // État de conversation CÔTÉ SERVEUR — une seule conversation partagée par tous @@ -30,9 +31,12 @@ import ( const maxLogEvents = 5000 // LogEvent = un événement d'affichage rejouable (un delta SSE + son numéro de -// séquence monotone). +// séquence monotone + un horodatage serveur en ms). Le TS permet au client de +// calculer la vitesse (tok/s) à partir du temps RÉEL de génération — correct +// aussi bien en direct qu'au replay (où tout arrive d'un bloc côté client). type LogEvent struct { Seq int `json:"seq"` + TS int64 `json:"ts"` Delta map[string]any `json:"delta"` } @@ -100,7 +104,7 @@ func (c *Conversation) persist() { func (c *Conversation) appendDelta(delta map[string]any) { c.mu.Lock() c.Seq++ - c.Log = append(c.Log, LogEvent{Seq: c.Seq, Delta: delta}) + c.Log = append(c.Log, LogEvent{Seq: c.Seq, TS: time.Now().UnixMilli(), Delta: delta}) if len(c.Log) > maxLogEvents { c.Log = c.Log[len(c.Log)-maxLogEvents:] } @@ -281,8 +285,9 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st } last = ev.Seq delta := ev.Delta + ts := ev.TS c.mu.Unlock() - out := map[string]any{"seq": ev.Seq} + out := map[string]any{"seq": ev.Seq, "ts": ts} for k, v := range delta { out[k] = v } diff --git a/ui/index.html b/ui/index.html index e14d8ad..db52a30 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1373,25 +1373,20 @@ function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs) // permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte, // appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de // localStorage : le même contexte est partagé par tous les appareils. -async function restoreChat(){ - // On note où en est le serveur : tout ce qui existe déjà sera rejoué d'un bloc - // (replay, sans vitesse) ; ce qui vient après est du direct (avec vitesse). - try{ const s=await (await jfetch('/api/chat/state')).json(); replayTarget=s.seq||0; }catch(e){} - connectStream(); -} +// Source de vérité = SERVEUR : on ouvre le flux d'abonnement permanent qui rejoue +// tout le fil (texte, outils, vitesses via les horodatages serveur, raisonnement) +// puis suit le direct. Partagé par tous les appareils. +function restoreChat(){ connectStream(); } function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } } // ===== Conversation SERVEUR (source de vérité, partagée entre appareils) ===== // L'historique et la génération vivent sur le serveur jean. Le client ouvre un // flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis // suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté // serveur) ; se reconnecter rejoue tout le fil, détails compris. -// replayTarget = dernier Seq déjà présent sur le serveur au moment où l'on se -// (re)connecte. Les événements jusque-là sont du REPLAY (arrivent d'un bloc) → -// on n'affiche PAS de vitesse (elle serait absurde). Au-delà = direct → vitesse. -let lastSeq=0, streamAbort=null, replayTarget=0; +let lastSeq=0, streamAbort=null; // É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, reasonStart:0, contentStart: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 }; } newTurn(); const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple'; function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } } @@ -1409,14 +1404,15 @@ function renderStats(el, s){ if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s'); setLabel(el, parts.join(' · ')); } -// Label LIVE d'une bulle : nombre de tokens + vitesse mesurée côté client au fil -// des deltas (comme avant la refonte). En replay (seq<=replayTarget), on n'affiche -// que le nombre de tokens — pas la vitesse, car tout arrive d'un bloc. -function labelTokens(el, role, n, start, seq){ +// 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 +// bloc côté client, mais leurs ts serveur restent espacés du vrai temps écoulé). +function labelTokens(el, role, n, firstTs, lastTs){ if(!el) return; - if(seq && seq<=replayTarget){ setLabel(el, role+' · '+n+' tok'); return; } - const secs=(performance.now()-start)/1000; const tps= secs>0.05 ? (n/secs):0; - setLabel(el, role+' · '+n+' tok · '+tps.toFixed(1)+' tok/s'); + const secs=(lastTs-firstTs)/1000; + if(secs>0.05 && n>1){ setLabel(el, role+' · '+n+' tok · '+(n/secs).toFixed(1)+' tok/s'); } + else { setLabel(el, role+' · '+n+' tok'); } } function setBusy(on){ busy=on; document.getElementById('send').style.display=on?'none':'inline-block'; @@ -1447,15 +1443,15 @@ function handleDelta(d){ killTyping(); if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); } showTyping(); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason); - if(!T.reasonStart) T.reasonStart=performance.now(); T.reasonTok++; - labelTokens(T.reasonEl, 'reasoning', T.reasonTok, T.reasonStart, d.seq); + if(!T.reasonFirstTs) T.reasonFirstTs=d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok++; + labelTokens(T.reasonEl, 'reasoning', T.reasonTok, T.reasonFirstTs, T.reasonLastTs); return; } if(d.content){ removeTyping(); if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; } T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent); - if(!T.contentStart) T.contentStart=performance.now(); T.contentTok++; - labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentStart, d.seq); + if(!T.contentFirstTs) T.contentFirstTs=d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok++; + labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs); return; } } // Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de