Files
Loki/internal/loki/ui/src/js/09-stream.js
T
MichaelandClaude Opus 5 895ff92ed8 Composeur : bandeaux au-dessus de la carte, chrono du tour, anneau réparé
Trois défauts d'affichage, une cause commune pour deux d'entre eux.

Le panneau des critères et la puce « mode Code » étaient insérés AVANT
#composer, qui est en position:absolute : ils restaient dans le flux de
la page et passaient donc SOUS la carte de saisie. Ils vivent maintenant
DANS le composeur, alignés sur sa colonne, et les critères s'ouvrent
repliés (cinq lignes dépliées mangeaient la moitié de l'écran).

Chrono du tour complet au-dessus de la carte : le temps entre l'envoi du
message et le moment où l'on peut reparler — appels d'outils, passes de
vérification et compaction compris. Les durées portées par les cartes ne
mesurent que la génération de texte ; celle-ci répond à « ça a pris
combien de temps, en tout ? ». Il avance à la seconde puis se fige sur le
total.

Anneau d'activité de la liste des discussions : l'animation portait sur
le <svg>, dont l'origine de rotation dépend de transform-box — défaut
qui a changé au fil des versions, d'où un anneau figé. Elle porte
désormais sur le conteneur HTML, qui tourne partout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:16:28 +02:00

455 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===== Conversation SERVEUR (source de vérité, partagée entre appareils) =====
// L'historique et la génération vivent sur le serveur loki. 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.
let lastSeq=0, streamAbort=null;
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
// l'événement `user` revient par le flux — preuve que le serveur l'a bien
// enregistré. En cas d'échec d'envoi, elle est retirée et le texte est rendu.
let PENDING=null;
// Retire aussi la rangée de pièces jointes, qui vit JUSTE AVANT la bulle : sans
// ça, un envoi échoué laissait les fichiers seuls dans le fil, sans message.
function clearPending(){
if(!PENDING) return;
const f=PENDING.previousElementSibling;
if(f&&f.classList.contains('msg-files')) f.remove();
PENDING.remove(); PENDING=null;
}
function addPending(text){
clearPending();
PENDING=addMsg('user', text);
PENDING.classList.add('pending');
// L'étiquette garde l'avatar et le prénom (paintLabel, posé par addMsg) : une
// bulle ne doit JAMAIS s'appeler autrement d'un tour à l'autre. L'envoi en
// cours se lit à la bulle grisée (.pending) et à l'infobulle.
const l=PENDING.querySelector('.label'); if(l) l.title='envoi en cours…';
jumpBottom();
return PENDING;
}
// Le serveur confirme le message : on réutilise la bulle grise au lieu d'en
// ajouter une seconde (sinon le message clignoterait en double).
function confirmPending(text){
if(!PENDING) return false;
const b=PENDING.querySelector('.body');
if(!b || b.textContent!==text) return false;
PENDING.classList.remove('pending');
// ⚠️ NE PAS réécrire l'étiquette ici : elle porte l'avatar et le prénom
// (17-identity.js). Elle y était remise à « user », si bien que le message
// qu'on venait d'envoyer s'affichait « USER » alors que ceux rejoués au
// chargement portaient le prénom — deux bulles identiques, deux libellés.
const l=PENDING.querySelector('.label'); if(l) l.removeAttribute('title');
PENDING=null;
return true;
}
// REPLAYING = on est dans le replay initial (rejeu du journal au chargement).
// Pendant ce temps, les bulles raisonnement/outil sont créées DÉJÀ repliées →
// pas d'animation d'ouverture/fermeture au refresh. Le serveur envoie {caught_up}
// quand le replay est fini, on repasse alors en direct.
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, 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(); paintTurnClock(); }
// Chrono du TOUR COMPLET, affiché au-dessus de la carte de saisie : le temps
// entre l'envoi du message et le moment où l'on peut reparler — outils, passes
// de vérification et compaction compris. Les durées portées par les cartes ne
// disent que la génération de texte ; celle-ci répond à « ça a pris combien de
// temps, en tout ? ».
function paintTurnClock(){
const composer = document.getElementById('composer');
if(!composer || !T.startTs) return;
let el = document.getElementById('turn-clock');
if(!el){
el = document.createElement('div');
el.id = 'turn-clock';
el.className = 'composer-banner';
composer.insertBefore(el, composer.firstChild);
}
const ms = (T.doneTs || nowServer()) - T.startTs;
if(!(ms > 0)){ el.remove(); return; }
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms);
el.classList.toggle('done', !!T.doneTs);
}
// 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
// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
// recrée, en le marquant pour le reprendre quand le compactage est fini.
function setCompacting(on){
if(on){
if(!T.typingEl){ T.typingEl=addTyping(); T.typingEl.dataset.forCompact='1'; }
T.typingEl.classList.add('compacting');
if(!T.typingEl.querySelector('.tlabel')){
const s=document.createElement('span');
s.className='tlabel';
s.textContent='compactage du contexte…';
T.typingEl.appendChild(s);
}
scrollMaybe();
return;
}
if(!T.typingEl) return;
if(T.typingEl.dataset.forCompact){ removeTyping(); return; }
T.typingEl.classList.remove('compacting');
const s=T.typingEl.querySelector('.tlabel'); if(s) s.remove();
}
// Séparateur laissé dans le fil à l'endroit exact de la coupure.
function addCompactMark(){
const el=document.createElement('div');
el.className='compact-mark';
el.textContent='contexte compacté — anciens tours résumés';
// Devant l'indicateur de frappe s'il est encore là (compactage en cours de
// tour) : la suite de la réponse doit rester APRÈS la marque de coupure.
if(T.typingEl && T.typingEl.parentNode===chatEl()) chatEl().insertBefore(el, T.typingEl);
else chatEl().appendChild(el);
scrollMaybe();
}
// L'indicateur « … » est retiré dès que quelque chose de VISIBLE le remplace.
// Il doit donc survivre quand la bulle qui arrive ne sera pas affichée : mode
// simplifié, mais aussi raisonnement/outils masqués par les préférences — sinon
// le fil reste totalement vide pendant que le modèle travaille (rien à voir, et
// aucun signe que ça tourne).
const typingKept=(kind)=>simpleMode()
|| (kind==='reasoning' && viewOn('hide-reasoning'))
|| (kind==='tool' && viewOn('hide-tools'));
function killTyping(kind){ if(!T.typingEl) return; if(typingKept(kind)) return; removeTyping(); }
function showTyping(kind){ if(!typingKept(kind)) return; const c=document.getElementById('chat');
if(!T.typingEl){ T.typingEl=addTyping(); } else if(c.lastElementChild!==T.typingEl){ c.appendChild(T.typingEl); } }
// Label de vitesse rendu depuis les valeurs (fonctionne aussi bien en direct
// qu'au replay — pas de timer performance.now, qui n'a pas de sens hors-ligne).
function renderStats(el, s){
if(!el||!s) return;
const parts=[];
const pt=s.prompt_tokens||s.prompt_tokens_total;
if(pt) parts.push('prefill '+pt+' tok · '+(s.prompt_per_second||0).toFixed(0)+' tok/s');
if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
if(!parts.length) return;
// 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(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
// 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;
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].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();
// 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
// boutons étaient montrés/cachés avec `style.display='inline-block'`, qui
// l'emportait sur le `display:flex` de la feuille : le bouton gardait sa
// boîte de 34 px mais son icône de 18 px se collait à gauche, décalée de 8 px.
// C'est invisible tant que l'icône est un glyphe de texte, flagrant en SVG.
document.documentElement.setAttribute('data-busy', busy ? '1' : '0');
// Tant que le moteur n'a pas fini de charger le modèle, envoyer ne mène à rien :
// on bloque le bouton et l'Entrée, et on le DIT sous le champ. `STATUS_SEEN`
// évite de verrouiller le chat quand /api/status n'a pas encore répondu (ou ne
// répond pas du tout) — dans le doute on laisse la main.
const ready = !STATUS_SEEN || MODEL_READY;
sb.disabled = !ready;
sb.title = ready ? '' : 'le modèle n\'est pas encore chargé';
const hint=document.getElementById('sendhint');
if(hint){
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
hint.classList.toggle('waiting', !ready);
}
}
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
// piloté par le serveur et rejouable à l'identique.
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();
return; }
// `reset` = fil vidé OU bascule de discussion (même mécanisme d'epoch côté
// 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){ 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; }
// --- 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; }
if(d.criteria!==undefined){ if(typeof renderCriteria==='function') renderCriteria(d.criteria); return; }
if(d.role!==undefined){ ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; }
if(d.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; }
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
if(d.user!==undefined){
newTurn();
let el=PENDING;
if(!confirmPending(d.user)) el=addMsg('user', d.user);
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
// 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);
// 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(); paintTurnClock(); 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); 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
paintTurnClock(); // fige le total au-dessus de la carte
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; }
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
// donc une notification persistée se re-déclenchait à chaque rafraîchissement
// (« rien à compacter » qui revient sans raison). C'est un événement ponctuel,
// il n'a de sens qu'en direct — contrairement à la marque `compacted`, qui est
// 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.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
renderToolMsg(T.pendingToolEl, tu);
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
return; }
if(d.drop_reasoning){
if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; }
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=''; 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=''; 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.
if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1);
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=''; 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);
return; }
}
// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
// re-téléchargement complet après une coupure / bascule d'appareil).
// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Loki laissé
// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
// au-delà, toute requête (journal, installation du moteur…) reste en file
// d'attente sans jamais partir ni échouer — un blocage silencieux très
// déroutant. Au retour de l'onglet on se reconnecte, et le replay depuis
// lastSeq rattrape tout ce qui s'est passé entre-temps.
let streamPaused=false;
document.addEventListener('visibilitychange', ()=>{
if(document.hidden){
streamPaused=true;
if(streamAbort) try{ streamAbort.abort(); }catch(e){}
} else if(streamPaused){
streamPaused=false;
}
});
async function connectStream(){
while(true){
// Onglet en arrière-plan : on n'ouvre aucune connexion, on attend le retour.
while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController();
try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
const {done,value}=await reader.read(); if(done) break;
buf+=dec.decode(value,{stream:true}); let i;
while((i=buf.indexOf('\n\n'))>=0){
const chunk=buf.slice(0,i); buf=buf.slice(i+2);
for(const line of chunk.split('\n')){
if(!line.startsWith('data:')) continue;
const data=line.slice(5).trim(); if(data===''||data==='[DONE]') continue;
try{ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; handleDelta(d); }catch(e){}
}
}
}
}catch(e){ /* coupure : on reconnecte silencieusement */ }
// Le flux s'est arrêté (coupure ou fin prématurée). Si le fil n'a JAMAIS fini
// de charger, le silence est trompeur — un chat vide sans explication. On le
// dit dans le voile ; il disparaîtra au {caught_up} de la reconnexion.
if(REPLAYING) setChatLoading('connexion au serveur…');
await new Promise(res=>setTimeout(res, 600));
}
}
// Interrompt la génération en cours côté serveur (la goroutine détachée est
// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
// « network error » alarmiste alors que l'IA répond quand même.
async function send(){
if(busy) return;
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim();
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
if(!text && !ATTACH.length) return;
ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
addPending(text);
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
// on doit pouvoir en enlever une, et un échec doit rester visible.
const files=await attachPaths();
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
// Les pastilles passent dans la bulle en attente : le message porte ses
// fichiers dès l'envoi, sans attendre l'aller-retour.
if(PENDING) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){
try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
if(r.status===409 || r.ok) clearAttach();
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK
if(r.ok) return; // la bulle + les tokens arrivent par le flux
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
}catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600));
}
// Après plusieurs échecs : le serveur a peut-être quand même reçu le message.
try{ const s=await (await jfetch('/api/chat/state')).json(); if(s.generating) return; }catch(_){}
fail('échec de l\'envoi — réessaie');
}
loadAll();
setInterval(loadStatus, 5000);
setInterval(loadVram, 3000);
setInterval(loadRam, 3000);