Chat : écrire pendant que l'IA répond, et deux fils ne fusionnent plus

Repris d'AJEAN 0.14.0, adapté.

AJOUT EN COURS DE RÉPONSE
Il fallait arrêter la génération pour glisser une précision (le serveur
répondait 409). Un message envoyé pendant une réponse part désormais EN
FILE : runChat l'injecte à la prochaine frontière d'étape (après un appel
d'outil) — le modèle en tient compte dans la SUITE de sa réponse — ou, si
le tour se termine avant, il devient le tour suivant, dans l'ordre. Le
bouton envoyer réapparaît à côté de stop dès qu'il y a du texte ; le
message s'affiche « en attente » au-dessus de la carte jusqu'à ce que le
flux le confirme. Stop abandonne la file (queue_dropped, le client retire
ses pastilles).

Écarts avec l'amont :
- Les messages en attente sont posés AU-DESSUS de la carte, pas dans le
  fil qui s'écrit encore (ils s'y seraient intercalés entre deux bulles).
- Dédoublonnage par identifiant d'envoi (cid) : l'UI réessaie un envoi dont
  la réponse s'est perdue sur le tunnel. Le 409 « déjà en cours » faisait
  office de garde ; sans lui, le réessai mettait le message deux fois en
  file.
- Une tâche planifiée qui occupe le modèle garde le refus 409 (sa fin ne
  dépile rien).
- Pas d'injection après un stop : la boucle repassait en tête une fois
  l'outil interrompu et journalisait le message en file (vu en test).
- runChat prend l'injecteur en paramètre variadique : les appels hors chat
  (tâches, vérification du mode Code, tests) ne changent pas.

DEUX FILS FUSIONNÉS
Un appareil déconnecté pendant qu'un autre changeait de discussion se
réabonnait avec un `from` hérité de l'ancienne : les Seq n'étant pas
globaux, la fin de la nouvelle discussion se greffait sur le début de
l'ancienne restée à l'écran. caught_up et reset portent maintenant l'id de
la discussion ; le client le renvoie (conv_id) et, s'il ne correspond plus,
le serveur ordonne un reset avant de rejouer. La garde « from au-delà du
dernier Seq » émet elle aussi ce reset (elle rejouait par-dessus l'écran
sans le vider).

Vérifié dans le navigateur avec un faux moteur : précision injectée après
l'outil dans le même tour, message en file devenu tour suivant, stop qui
abandonne la file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:11:40 +02:00
1 parent 58c2da1302
commit 48b8193dca
11 files changed
+459 -41

No files matched your search

+4
View File
@@ -163,6 +163,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<!-- Messages envoyés PENDANT une réponse (file d'attente, voir send()) : en
gris au-dessus de la carte jusqu'à ce que le serveur les prenne en compte
— injectés dans la réponse en cours, ou traités au tour suivant. -->
<div id="queue-list" aria-live="polite"></div>
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
@@ -572,6 +572,9 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(
function autoGrow(ta){
ta = ta || document.getElementById('input');
if(!ta) return;
// Du texte à envoyer ? Pendant une réponse, c'est ce qui fait réapparaître
// « envoyer » à côté de stop (message mis en file, voir send()).
if(ta.id==='input') document.documentElement.setAttribute('data-hastext', ta.value.trim() ? '1' : '0');
ta.style.height='auto';
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
ta.style.height=Math.min(ta.scrollHeight, max)+'px';
+54 -12
View File
@@ -11,6 +11,32 @@ let lastSeq=0, streamAbort=null;
// caught_up).
const HIST_TAIL=20;
let HIST_FULL=false, HIST_RESTORE=null;
// Discussion AFFICHÉE (id reçu au caught_up/reset). Renvoyée à chaque
// (ré)abonnement : si un autre appareil a changé de discussion pendant une
// coupure, le serveur ordonne un reset au lieu de greffer le nouveau fil sur
// l'ancien (AJEAN 0.14.0).
let CONV_ID='';
// File d'attente (AJEAN 0.14.0) : messages envoyés PENDANT une réponse, montrés
// en gris au-dessus de la carte jusqu'à ce que le flux les confirme (delta
// `user` du même texte) — injectés en cours de réponse ou au tour suivant.
function queueAdd(text){
const box=document.getElementById('queue-list'); if(!box) return null;
const el=document.createElement('div'); el.className='queued-msg'; el.textContent=text||'(pièce jointe)';
el.dataset.text=text;
box.appendChild(el); box.classList.add('show');
return el;
}
function queueSync(){ const box=document.getElementById('queue-list'); if(box) box.classList.toggle('show', !!box.children.length); }
function queueTake(text){
const box=document.getElementById('queue-list'); if(!box) return;
const el=[...box.children].find(e=>e.dataset.text===text);
if(el) el.remove();
queueSync();
}
function queueClear(){ const box=document.getElementById('queue-list'); if(box) box.textContent=''; queueSync(); }
// Identifiant d'envoi, stable d'un réessai à l'autre : le serveur ne met pas
// deux fois le même message en file si la réponse s'est perdue en route.
function newCID(){ try{ return crypto.randomUUID(); }catch(_){ return Date.now().toString(36)+Math.random().toString(36).slice(2); } }
// 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
@@ -419,6 +445,7 @@ function handleDelta(d){
// 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){
if(typeof d.id==='string') CONV_ID=d.id;
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
// 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).
@@ -445,7 +472,8 @@ function handleDelta(d){
// 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.history_more!==undefined){ showHistoryMore(d.history_more); return; }
if(d.reset!==undefined){ HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); 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; }
if(d.queue_dropped!==undefined){ if(!REPLAYING){ queueClear(); toast('messages en attente abandonnés'); } return; }
if(d.reset!==undefined){ if(typeof d.id==='string') CONV_ID=d.id; queueClear(); HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); 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; }
@@ -462,6 +490,7 @@ function handleDelta(d){
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque
// bulle de réponse du tour.
T.model = d.model || '';
if(!REPLAYING) queueTake(d.user); // message en file désormais pris en compte
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
@@ -573,7 +602,7 @@ async function connectStream(){
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,tail:HIST_FULL?0:HIST_TAIL}),signal:streamAbort.signal});
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq,tail:HIST_FULL?0:HIST_TAIL,conv_id:CONV_ID}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
@@ -643,17 +672,23 @@ function loadFullHistory(){
// 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); };
// Réponse en cours : le message part EN FILE (AJEAN 0.14.0) — il sera pris en
// compte à la prochaine étape de la réponse, ou au tour suivant. Il s'affiche
// en attente au-dessus de la carte, pas dans le fil qui s'écrit encore.
const queuedAtSend = busy;
let qel=null;
// Sinon le message s'affiche TOUT DE SUITE dans le fil, en gris : il ne
// disparaît plus le temps de l'aller-retour. Il s'éclaircit quand le flux le
// confirme (confirmPending).
if(queuedAtSend) qel=queueAdd(text); else addPending(text);
const fail=(m)=>{ if(qel){ qel.remove(); queueSync(); } else clearPending(); toast(m); ta.value=text; autoGrow(ta); };
const cid=newCID();
// 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.
@@ -661,13 +696,20 @@ async function send(){
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());
if(PENDING && !queuedAtSend) 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
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,cid})});
if(r.ok) clearAttach();
// 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas.
if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
if(r.ok){
// Parti tout de suite alors qu'on le croyait en file (le tour venait de
// finir) : la pastille disparaît, la bulle arrive par le flux.
let j={}; try{ j=await r.json(); }catch(_){}
if(qel && j.queued===false){ qel.remove(); queueSync(); }
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));
+13
View File
@@ -1210,6 +1210,19 @@ html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
#attach-list::-webkit-scrollbar{display:none}
#attach-list.show{display:flex}
#composer:has(#attach-list.show){--fade:6px}
/* File d'attente : messages envoyés pendant une réponse, pas encore pris en
compte. Même bande que les pièces jointes, en gris. */
#queue-list{order:0;display:none;flex-direction:column;align-items:flex-end;gap:4px;margin:0 var(--col);padding:0 2px 8px}
#queue-list.show{display:flex}
#composer:has(#queue-list.show){--fade:6px}
#queue-list .queued-msg{max-width:80%;padding:5px 12px;border-radius:12px;background:var(--panel);
border:1px dashed var(--border);color:var(--dim);font-size:12px;line-height:1.35;
white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;
box-shadow:0 2px 10px rgba(0,0,0,.10)}
#queue-list .queued-msg::before{content:"en attente · ";font-style:italic;opacity:.8}
/* Pendant une réponse, envoyer reste possible dès qu'il y a du texte : le
message part en file (voir send()). Stop reste à côté. */
html[data-busy="1"][data-hastext="1"] #send{display:inline-flex}
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
cadre, sinon le texte de la conversation transparaît derrière. */
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);