-
-
+
+
-
+
+ contexte 0 / 0
+
- contexte 0 / 0
-
+
+
+
+
+
+ Entrée pour envoyer · Maj+Entrée = nouvelle ligne
-
-
-
-
- Entrée pour envoyer · Maj+Entrée = nouvelle ligne
@@ -668,13 +689,46 @@ document.addEventListener('DOMContentLoaded', ()=>{
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
/
/
- margins behave properly.
- el.innerHTML=''+role+'';
+ if(collapsible){
+ el.classList.add('collapsible');
+ el.innerHTML=''+role+'
';
+ el.querySelector('.label').onclick=()=>toggleCollapse(el);
+ } else {
+ el.innerHTML=''+role+'';
+ }
el.querySelector('.body').textContent=text;
chatEl().appendChild(el);
scrollMaybe();
return el;
}
+// Replie/déplie en douceur les bulles reasoning/tool. Hauteur animée en JS :
+// on fige scrollHeight puis on va à 0 (fermeture) ou de 0 vers scrollHeight
+// (ouverture), sans jamais dépasser. overflow:hidden clippe pendant l'animation.
+function collapseBody(el){
+ const bw=el.querySelector('.bodywrap'); if(!bw || el.classList.contains('collapsed')) return;
+ bw.style.height = bw.scrollHeight+'px'; // fige les dimensions courantes
+ bw.style.width = bw.scrollWidth+'px';
+ void bw.offsetHeight; // reflow pour que la transition parte de là
+ el.classList.add('collapsed');
+ bw.style.height = '0px'; // → anime height ET width vers 0
+ bw.style.width = '0px';
+}
+function expandBody(el){
+ const bw=el.querySelector('.bodywrap'); if(!bw) return;
+ el.classList.remove('collapsed');
+ bw.style.height=''; bw.style.width=''; // mesure les dimensions naturelles…
+ const h=bw.scrollHeight, w=bw.scrollWidth;
+ bw.style.height='0px'; bw.style.width='0px';// …repart de 0 (pas de flash, même frame)
+ void bw.offsetHeight;
+ bw.style.height=h+'px'; bw.style.width=w+'px';
+ const done=e=>{ if(e.propertyName!=='height') return; bw.style.height=''; bw.style.width=''; bw.removeEventListener('transitionend',done); };
+ bw.addEventListener('transitionend',done);
+}
+function toggleCollapse(el){ el.classList.contains('collapsed') ? expandBody(el) : collapseBody(el); }
+// Replie toutes les bulles d'un tour une fois la réponse finale entamée.
+function collapseAll(list){ for(const el of list){ if(el) collapseBody(el); } list.length=0; }
function setLabel(el, text){ el.querySelector('.label').textContent = text; }
function bodyOf(el){ return el.querySelector('.body'); }
// Render markdown into a message body in place; safe because md() escapes HTML.
@@ -683,7 +737,10 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(text); addCo
// wrote, then the response it got back. textContent keeps it injection-safe.
function renderToolMsg(el, tu){
const isShell = tu.name==='run_shell';
- setLabel(el, isShell ? 'terminal' : 'skill');
+ let lbl = isShell ? 'terminal' : 'skill';
+ // Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
+ if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
+ setLabel(el, lbl);
const body=bodyOf(el); body.innerHTML='';
const head=document.createElement('div'); head.className='tool-head';
head.textContent = isShell ? '⚙️ commande' : '📚 skill';
@@ -801,6 +858,7 @@ async function send(){
addMsg('user', text);
msgs.push({role:'user',content:text}); saveChat();
let reasonEl=null, contentEl=null, pendingToolEl=null, fullContent='', fullReason='', toolMsgs=null;
+ let turnCollapsibles=[]; // bulles reasoning/tool à replier dès la réponse finale
let tokCount=0, reasonTokCount=0, t0=performance.now(), firstTok=0, statsTimer=null;
let serverStats=null;
const updateStats=(final)=>{
@@ -813,7 +871,10 @@ async function send(){
const s = serverStats;
const totalSec = (performance.now() - t0) / 1000;
const parts = [role];
- if(s.prompt_tokens) parts.push('prefill ' + s.prompt_tokens + ' tok · ' + s.prompt_per_second.toFixed(0) + ' tok/s');
+ // prompt_tokens_total = taille réelle du prompt (system inclus, même si en
+ // cache) ; repli sur prompt_tokens (neuf traité) si le backend n'envoie pas l'usage.
+ const pt = s.prompt_tokens_total || s.prompt_tokens;
+ 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.toFixed(1) + ' tok/s');
parts.push('total ' + totalSec.toFixed(1) + 's');
setLabel(el, parts.join(' · '));
@@ -858,7 +919,9 @@ async function send(){
const tu=d.tool_used;
// One bubble per call: the live "typing" updates, the "en cours"
// spinner, and the final result all re-render the SAME element.
- if(!pendingToolEl) pendingToolEl=addMsg('tool','');
+ // Chaînage : replie les étapes précédentes (reasoning/outils déjà
+ // finis) dès qu'une nouvelle démarre — seule l'étape en cours reste ouverte.
+ if(!pendingToolEl){ collapseAll(turnCollapsibles); pendingToolEl=addMsg('tool',''); turnCollapsibles.push(pendingToolEl); }
renderToolMsg(pendingToolEl, tu);
if(tu.done){
pendingToolEl=null;
@@ -871,13 +934,13 @@ async function send(){
// Start a fresh buffer per bubble — otherwise a reasoning block that
// follows a tool call re-renders ALL previously accumulated reasoning
// into the new bubble (duplicated dozens of times over a turn).
- if(!reasonEl){ reasonEl=addMsg('reasoning',''); fullReason=''; }
+ if(!reasonEl){ collapseAll(turnCollapsibles); reasonEl=addMsg('reasoning',''); fullReason=''; turnCollapsibles.push(reasonEl); }
if(!firstTok) firstTok=performance.now();
reasonTokCount++;
fullReason+=d.reasoning_content; renderBody(reasonEl, fullReason);
}
if(d.content){
- if(!contentEl){ contentEl=addMsg('assistant',''); fullContent=''; firstTok=performance.now(); }
+ if(!contentEl){ collapseAll(turnCollapsibles); contentEl=addMsg('assistant',''); fullContent=''; firstTok=performance.now(); }
tokCount++;
fullContent+=d.content; renderBody(contentEl, fullContent);
}
@@ -890,7 +953,14 @@ async function send(){
// Le KV cache s'accumule : chaque tour ajoute les tokens NOUVELLEMENT traités
// (prompt_tokens, le préfixe caché n'est pas recompté) + ceux générés. La
// taille réelle du contexte = somme cumulée, donc monotone croissante.
- if(serverStats){ setCtxUsed(CTX_USED + (serverStats.prompt_tokens||0) + (serverStats.gen_tokens||0)); try{ localStorage.setItem('jean.ctxused', CTX_USED); }catch(e){} }
+ if(serverStats){
+ // prompt_tokens_total (usage) = taille TOTALE du prompt ce tour (system +
+ // historique, préfixe caché compris) → contexte ABSOLU et exact. Sinon repli
+ // sur l'ancienne accumulation (prompt_tokens = seulement le neuf traité).
+ if(serverStats.prompt_tokens_total){ setCtxUsed((serverStats.prompt_tokens_total||0) + (serverStats.gen_tokens||0)); }
+ else { setCtxUsed(CTX_USED + (serverStats.prompt_tokens||0) + (serverStats.gen_tokens||0)); }
+ try{ localStorage.setItem('jean.ctxused', CTX_USED); }catch(e){}
+ }
}catch(e){
if(e.name==='AbortError'){
if(contentEl) setLabel(contentEl, 'assistant · '+tokCount+' tok · interrompu');
diff --git a/versioninfo.json b/versioninfo.json
index 83e1c6a..ea102e4 100644
--- a/versioninfo.json
+++ b/versioninfo.json
@@ -3,13 +3,13 @@
"FileVersion": {
"Major": 0,
"Minor": 2,
- "Patch": 8,
+ "Patch": 9,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 2,
- "Patch": 8,
+ "Patch": 9,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Jean contributors. MIT License.",
"OriginalFilename": "jean.exe",
"ProductName": "Jean",
- "ProductVersion": "0.2.8",
+ "ProductVersion": "0.2.9",
"Comments": "https://github.com/nathaninline/jean — projet open source (MIT)"
},
"VarFileInfo": {