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'); } // La carte de saisie flotte AU-DESSUS du fil (position:absolute) : c'est au fil // de réserver sa hauteur en rembourrage bas, sinon ses derniers messages passent // dessous et deviennent hors d'atteinte — aucun défilement ne les ramène. // Cette hauteur n'est pas constante : la saisie grandit avec le texte (autoGrow), // et une pièce jointe en attente ajoute une rangée de pastilles. On la mesure // donc au lieu de la deviner, comme on le fait déjà pour la gouttière. // Pas de boucle d'observation : le composeur est en position:absolute, changer le // rembourrage de #chat ne modifie pas sa hauteur. function syncComposer(){ const c=document.getElementById('composer'); if(!c) return; document.documentElement.style.setProperty('--composer-h', c.offsetHeight+'px'); } addEventListener('resize', syncGutter); addEventListener('DOMContentLoaded', syncGutter); addEventListener('resize', syncComposer); addEventListener('DOMContentLoaded', ()=>{ syncComposer(); const c=document.getElementById('composer'); if(c && window.ResizeObserver) new ResizeObserver(syncComposer).observe(c); }); 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); }); }); // Badge de rôle du mode code (builder / verifier) : posé par 09-stream.js à // réception d'un delta {role}, repris par addMsg sur les bulles suivantes. let ROLE_BADGE=''; function addMsg(role, text){ const el=document.createElement('div'); el.className='msg '+role; if(ROLE_BADGE && (role==='assistant'||role==='reasoning'||role==='tool')) el.dataset.agentRole=ROLE_BADGE; 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);
});
}
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
//
// Confirmé, car le geste emporte aussi les FICHIERS de la discussion (dépôts,
// captures, ce que l'agent y a écrit) : ils n'appartiennent qu'à elle, et plus
// un message ne les mentionnerait.
async function resetChat(){
if(!await askConfirm('Le fil de cette discussion et ses fichiers (pièces jointes, captures, fichiers écrits par l\'agent) seront effacés.\n\nC\'est définitif.',
{title:'Vider la discussion', okText:'Vider', danger:true})) return;
jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{});
toast('discussion vidée');
}
// 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('loki.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');
}
// --- Accueil du fil vide ---------------------------------------------------
// Le logo n'est pas dupliqué dans le HTML : on clone celui de la barre latérale
// (#brand) en retirant ses id (un id ne peut exister qu'une fois) et le numéro
// de version. Les couleurs sont reprises par les classes .ce-*.
function fillEmptyLogo(){
const box=document.getElementById('ce-logo'), brand=document.getElementById('brand');
if(!box || !brand || box.childElementCount) return;
['brand-a','brand-word'].forEach(id=>{
const src=brand.querySelector('#'+id); if(!src) return;
const el=src.cloneNode(true); el.removeAttribute('id');
if(id==='brand-word') el.classList.add('ce-word');
box.appendChild(el);
});
}
// Affiché seulement quand le fil ne contient AUCUNE bulle et que le replay est
// terminé — sinon il apparaîtrait une fraction de seconde à chaque chargement,
// juste avant que les messages rejoués n'arrivent.
function syncChatEmpty(){
const box=document.getElementById('chat-empty'); if(!box) return;
fillEmptyLogo();
const empty = !REPLAYING && !chatEl().querySelector('.msg');
box.classList.toggle('show', empty);
}
document.addEventListener('DOMContentLoaded', ()=>{
const c=chatEl(); if(!c) return;
// Le fil est peuplé par des dizaines de chemins différents (replay, direct,
// reset, effacement). On observe donc le DOM plutôt que d'appeler la synchro
// depuis chacun d'eux — le coût est nul, le callback est groupé et sort tout
// de suite pendant le replay.
new MutationObserver(()=>syncChatEmpty()).observe(c, {childList:true});
syncChatEmpty();
});
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';
}