// ===== 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'); const l=PENDING.querySelector('.label'); if(l) l.textContent='envoi…'; 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'); const l=PENDING.querySelector('.label'); if(l) l.textContent='user'; 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 }; } newTurn(); 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(parts).join(' · ')); else setStats(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, // 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; if(secs>0.05 && n>1){ setLabel(el, role+' · '+n+' tok · '+(n/secs).toFixed(1)+' tok/s'); } else { setLabel(el, role+' · '+n+' tok'); } } // 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 syncSendBtn(){ const sb=document.getElementById('send'); sb.style.display=busy?'none':'inline-block'; document.getElementById('stop').style.display=busy?'inline-block':'none'; // 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; 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'; } // 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. if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); 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); setBusy(true); 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.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=''; 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; } 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=''; } if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; } 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);