mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
L'amont ne connaît qu'un fil unique (bkChat/conversation) que « clear chat »
effaçait définitivement. On garde toute la machinerie (un seul conv en
mémoire, mêmes flux SSE, même compactage) mais rangée par discussion :
bkChat/index liste des discussions (métadonnées seules)
bkChat/active discussion ouverte, partagée par tous les appareils
bkChat/conv:<id> état complet d'une discussion
Basculer réutilise le mécanisme d'epoch du reset : les abonnés SSE reçoivent
{reset:true} et rejouent le nouveau fil — aucun code de rendu à toucher. Le
fil unique existant est repris comme première discussion au premier
démarrage, et sa clé d'origine est laissée intacte.
- routes /api/conversations (liste, new, switch, rename, delete)
- barre latérale : liste (titre déduit du 1er message, date, nb d'échanges),
bouton +, renommer, supprimer ; lignes construites en DOM et non en
innerHTML, les titres venant de messages utilisateur
- suppression de la dernière discussion : convCreate et non convNew, qui
aurait réenregistré celle qu'on vient d'effacer
Le bouton « Vérifier les mises à jour » répondait « GitHub a répondu 404 » :
il interrogeait les releases du dépôt du fork, qui n'en publie aucune. En
conteneur, remplacer le binaire n'a de toute façon pas de sens — l'UI, l'API
et la CLI renvoient désormais « docker compose pull ».
Vérifié en conteneur : création, bascule, renommage (titre accentué avec
< > &), suppression de l'active puis de la dernière ; /api/update et
loki update renvoient la note Docker.
311 lines
18 KiB
JavaScript
311 lines
18 KiB
JavaScript
// ===== 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);
|