let stickyBottom = true; const chatEl = () => document.getElementById('chat'); function isNearBottom(){ const c = chatEl(); return c.scrollHeight - c.scrollTop - c.clientHeight < 60; } // La zone de chat réserve une gouttière de barre de défilement de chaque côté // (scrollbar-gutter: stable both-edges) ; le composer, lui, n'est pas défilant. // On mesure la gouttière réelle et on la reporte sur le composer, sinon les // messages sont en retrait par rapport à la zone de saisie — décalage visible // surtout quand la barre latérale est escamotée. function syncGutter(){ const chat=document.getElementById('chat'); if(!chat) return; const g=Math.max(0,(chat.offsetWidth-chat.clientWidth)/2); document.documentElement.style.setProperty('--sbw', g+'px'); } addEventListener('resize', syncGutter); addEventListener('DOMContentLoaded', syncGutter); function scrollMaybe(){ // Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque // événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût // quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait // une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}. if(REPLAYING) return; if(stickyBottom){ const c = chatEl(); c.scrollTop = c.scrollHeight; } document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom); } function jumpBottom(){ stickyBottom = true; scrollMaybe(); } document.addEventListener('DOMContentLoaded', ()=>{ const c = chatEl(); c.addEventListener('scroll', ()=>{ stickyBottom = isNearBottom(); document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom); }); }); function addMsg(role, text){ const el=document.createElement('div'); el.className='msg '+role; const collapsible = (role==='reasoning' || role==='tool'); // .body must be a real block so
/
/
code block (idempotent).
function addCopyButtons(root){
root.querySelectorAll('pre').forEach(pre=>{
if(pre.querySelector('.copybtn')) return;
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
if(pre.classList.contains('diff')) return;
const btn=document.createElement('button');
btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
btn.onclick=async(e)=>{
e.stopPropagation();
const code=pre.querySelector('code'), txt=(code||pre).innerText;
try{ await navigator.clipboard.writeText(txt); }
catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
btn.textContent='copié ✓'; btn.classList.add('done');
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
};
pre.appendChild(btn);
});
}
// Nouvelle conversation POUR TOUS LES APPAREILS : le serveur vide le fil et
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); toast('nouvelle conversation'); }
// Compaction : on demande à l'IA un résumé de la conversation destiné à la
// reprendre dans une session neuve, puis on repart d'un contexte propre seedé
// avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
// Compaction MANUELLE : le compactage est automatique (façon Hermes) quand le
// contexte se remplit, mais ce bouton permet de le déclencher à la demande. Le
// serveur possède la conversation : on lance la compaction côté serveur et la
// progression (bannière « compactage en cours », résultat) arrive par le flux
// d'abonnement, comme pour la génération — donc visible sur tous les appareils.
async function compactContext(){
if(!await askConfirm('Résumer les anciens tours pour libérer du contexte ? La conversation continue normalement.', {title:'Compacter le contexte', okText:'Compacter'})) return;
const btn=document.getElementById('ctx-compact'); btn.disabled=true;
try{
const r=await jfetch('/api/chat/compact',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
const j=await r.json().catch(()=>({}));
if(!j.ok) toast(j.error||'compaction indisponible');
}catch(e){ toast('erreur : '+(e.message||e)); }
btn.disabled=false;
}
// Persistance de la conversation : on garde user+assistant en localStorage pour
// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
function saveChat(){ try{ localStorage.setItem('jean.chat', JSON.stringify(msgs)); }catch(e){} }
// Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement
// 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.
// 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.
// Voile de chargement du fil. setChatLoading(null) le masque, setChatLoading(txt)
// l'affiche avec ce libellé (« chargement… » au départ, « connexion au serveur… »
// si le flux tombe). Sans lui, une connexion lente affiche un chat vide qu'on ne
// distingue pas d'une conversation réellement vide.
function setChatLoading(msg){
const el=document.getElementById('chat-loading');
if(!el) return;
if(!msg){ el.classList.remove('show'); return; }
document.getElementById('chat-loading-text').textContent=msg;
el.classList.add('show');
}
function restoreChat(){
// On masque le chat le temps du replay pour ne pas voir défiler le haut puis
// sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au
// signal {caught_up}. Filet de sécurité : révélé quoi qu'il arrive après 2s.
const c=chatEl(); c.style.opacity='0';
setChatLoading('chargement de la conversation…');
// Si {caught_up} tarde au-delà de 2s (replay anormalement long), on révèle quand
// même — et on saute en bas DIRECTEMENT (scrollMaybe est neutralisé tant que
// REPLAYING, donc on force ici le positionnement). Le voile, lui, RESTE : tant
// que le replay n'est pas fini, ce qui est affiché est incomplet et il faut le
// dire. Il finit de toute façon par tomber au {caught_up} ou au filet de 15s.
setTimeout(()=>{ c.style.transition='opacity .15s'; c.style.opacity='1'; c.scrollTop=c.scrollHeight; }, 2000);
setTimeout(()=>{ setChatLoading(null); }, 15000);
connectStream();
}
function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
// La zone de saisie s'ajuste à son contenu : une ligne au repos, puis elle
// grandit jusqu'à sa max-height (au-delà, elle défile). Appelée à la frappe, à
// l'envoi et au chargement.
function autoGrow(ta){
ta = ta || document.getElementById('input');
if(!ta) return;
ta.style.height='auto';
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
ta.style.overflowY = ta.scrollHeight>max ? 'auto' : 'hidden';
}