diff --git a/internal/jean/chat_compact.go b/internal/jean/chat_compact.go
index f6677c7..148756f 100644
--- a/internal/jean/chat_compact.go
+++ b/internal/jean/chat_compact.go
@@ -103,15 +103,23 @@ func estimateTokens(msgs []Message) int {
// surtout, ne « voit » pas le prompt système injecté (machine briefing) ni le
// gabarit de chat — donc elle sous-estime largement le vrai contexte. 0 = inconnu
// (clients sans compteur, ex. terminal) → repli sur l'estimation.
-func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens int) ([]Message, bool) {
+// compactWouldTrigger indique si un tour VA déclencher une compaction proactive
+// (compactage activé ET contexte au-dessus du seuil). Exposé pour que le flux de
+// chat puisse afficher une bannière « compactage en cours » AVANT de lancer le
+// résumé (qui bloque plusieurs secondes), au lieu d'une UI figée sans info.
+func compactWouldTrigger(msgs []Message, knownTokens int) bool {
if !compactEnabled() {
- return msgs, false
+ return false
}
used := knownTokens
if used <= 0 {
used = estimateTokens(msgs)
}
- if used < int(float64(ctxWindow())*compactTriggerFrac) {
+ return used >= int(float64(ctxWindow())*compactTriggerFrac)
+}
+
+func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens int) ([]Message, bool) {
+ if !compactWouldTrigger(msgs, knownTokens) {
return msgs, false
}
return compactMessages(ctx, msgs, caps)
@@ -277,7 +285,7 @@ N'invente rien, n'ajoute pas de préambule ni de conclusion : donne directement
authHeader(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
- return "", err
+ return "", friendlyLLMError(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
diff --git a/internal/jean/chat_conversation.go b/internal/jean/chat_conversation.go
index 37b4b9f..43c30d5 100644
--- a/internal/jean/chat_conversation.go
+++ b/internal/jean/chat_conversation.go
@@ -187,6 +187,15 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// Compaction proactive (façon Hermes) sur la vue MODÈLE uniquement ; le journal
// d'affichage garde le fil complet. On signale juste un toast au client.
+ // Si le contexte dépasse le seuil, le résumé (un appel modèle non streamé) va
+ // bloquer plusieurs secondes AVANT que la vraie réponse commence. On affiche
+ // une bannière « compactage en cours » pendant ce temps (sinon l'UI se fige
+ // sans aucune info), puis on la retire — que le compactage ait changé quelque
+ // chose ou non.
+ willCompact := compactWouldTrigger(msgs, ctxUsed)
+ if willCompact {
+ c.appendDelta(epoch, map[string]any{"compacting": true})
+ }
if compacted, changed := MaybeCompact(ctx, msgs, caps, ctxUsed); changed {
msgs = compacted
c.mu.Lock()
@@ -194,7 +203,10 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.Messages = compacted
}
c.mu.Unlock()
+ c.appendDelta(epoch, map[string]any{"compacting": false})
c.appendDelta(epoch, map[string]any{"compacted": true})
+ } else if willCompact {
+ c.appendDelta(epoch, map[string]any{"compacting": false})
}
// Prompt système personnalisé (UI → /api/sysprompt, fichier côté serveur).
@@ -251,6 +263,53 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.mu.Unlock()
}
+// CompactNow force une compaction du contexte MAINTENANT, sans attendre le seuil
+// (bouton « compacter » de l'UI). Détaché comme la génération : émet la bannière
+// de progression, résume les anciens tours, remplace le torse et persiste. Les
+// événements passent par le flux d'abonnement, donc tous les appareils voient la
+// progression. Renvoie ErrBusy si un tour est déjà en cours.
+func (c *Conversation) CompactNow() error {
+ if !healthCheck() {
+ return fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.")
+ }
+ c.mu.Lock()
+ if c.Generating {
+ c.mu.Unlock()
+ return ErrBusy
+ }
+ c.Generating = true
+ ctx, cancel := context.WithCancel(context.Background())
+ c.cancel = cancel
+ msgs := append([]Message(nil), c.Messages...)
+ epoch := c.epoch
+ c.mu.Unlock()
+
+ go func() {
+ defer func() {
+ c.mu.Lock()
+ c.Generating = false
+ c.cancel = nil
+ c.mu.Unlock()
+ }()
+ c.appendDelta(epoch, map[string]any{"compacting": true})
+ compacted, changed := compactMessages(ctx, msgs, Caps{})
+ c.appendDelta(epoch, map[string]any{"compacting": false})
+ if !changed {
+ c.appendDelta(epoch, map[string]any{"compact_noop": true})
+ return
+ }
+ c.mu.Lock()
+ if c.epoch == epoch {
+ c.Messages = compacted
+ c.CtxUsed = estimateTokens(compacted) // feedback immédiat ; le vrai compte revient au prochain tour
+ }
+ c.mu.Unlock()
+ c.appendDelta(epoch, map[string]any{"compacted": true})
+ c.persist()
+ }()
+ return nil
+}
+
// Stop interrompt la génération en cours (le cas échéant).
func (c *Conversation) Stop() {
c.mu.Lock()
diff --git a/internal/jean/llm_client.go b/internal/jean/llm_client.go
index 12496f2..facfbac 100644
--- a/internal/jean/llm_client.go
+++ b/internal/jean/llm_client.go
@@ -363,6 +363,29 @@ const thinkClose = ""
// keeps the trace of what it already read/ran across user turns — otherwise it
// re-invokes the same skill/command every turn (it has no memory of having done
// it) and can confabulate paths/results it can no longer see.
+// friendlyLLMError transforme une erreur de transport vers llama-server en un
+// message clair. Le cas le plus fréquent — « connection refused » (Linux) /
+// « actively refused » (Windows) — arrive quand le moteur redémarre ou charge
+// encore le modèle ; l'erreur brute (« dial tcp 127.0.0.1:8080… ») ne dit rien à
+// l'utilisateur. On garde le silence sur une annulation volontaire (/stop).
+func friendlyLLMError(err error) error {
+ if err == nil {
+ return nil
+ }
+ low := strings.ToLower(err.Error())
+ switch {
+ case strings.Contains(low, "context canceled"):
+ return err // /stop : pas d'alarme
+ case strings.Contains(low, "connection refused"), strings.Contains(low, "actively refused"), strings.Contains(low, "connectex"), strings.Contains(low, "no connection could be made"):
+ return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort())
+ case strings.Contains(low, "timeout"), strings.Contains(low, "deadline exceeded"):
+ return fmt.Errorf("⚠️ Le moteur (llama-server) met trop de temps à répondre (port %d) — il est peut-être surchargé ou en plein chargement. Réessaie dans un instant.", LLMPort())
+ case strings.Contains(low, "eof"), strings.Contains(low, "connection reset"):
+ return fmt.Errorf("⚠️ Connexion au moteur (llama-server, port %d) interrompue — il a peut-être redémarré. Réessaie.", LLMPort())
+ }
+ return err
+}
+
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) {
var extra []Message
tools := EnabledTools(caps)
@@ -425,6 +448,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
authHeader(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
+ err = friendlyLLMError(err)
cb(StreamEvent{Err: err})
return extra, err
}
diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html
index 64018d7..20e5949 100644
--- a/internal/jean/ui/index.html
+++ b/internal/jean/ui/index.html
@@ -255,6 +255,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:6px;opacity:0;transition:opacity .2s;pointer-events:none}
#toast.show{opacity:1}
@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
+#compact-banner{display:flex;align-items:center;justify-content:center;gap:8px;font-size:12px;color:var(--accent);background:var(--accent-bg);border:1px solid var(--border);border-radius:8px;padding:6px 10px;margin-bottom:8px}
+#compact-banner .cspin{display:inline-block;animation:spin 1s linear infinite}
/* iOS Safari auto-zoome au focus sur tout champ < 16px. On force 16px sur les
appareils tactiles uniquement (le desktop garde son rendu, le pinch-zoom
manuel reste possible). */
@@ -494,6 +496,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
+
⏳ Compactage du contexte en cours — résumé des anciens tours…
@@ -820,7 +823,9 @@ function updateCtxMeter(){
fill.style.width=pct+'%';
fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)';
document.getElementById('ctx-text').textContent='contexte '+CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
- document.getElementById('ctx-compact').style.display = pct>=90 ? 'inline-block' : 'none';
+ // Bouton de compaction MANUELLE : visible dès la moitié du contexte pour qu'on
+ // puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
+ document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
}
async function loadVram(){
const gpus=await jget('/api/vram');
@@ -1535,48 +1540,20 @@ function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); t
// 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(){
- // Le compactage est désormais AUTOMATIQUE côté serveur (façon Hermes) : ce
- // bouton manuel n'a plus d'objet et est neutralisé.
- toast('le compactage est automatique (façon Hermes)'); return;
- if(busy) return;
- if(!msgs.length){ toast('rien à compacter'); return; }
- if(!await askConfirm('L\'IA va résumer la conversation puis repartir d\'une session propre basée sur ce résumé.', {title:'Compacter le contexte ?', okText:'Compacter'})) return;
- busy=true;
- const btn=document.getElementById('ctx-compact'); btn.textContent='compaction…'; btn.disabled=true;
- const instruction='Résume de façon structurée et complète TOUTE notre conversation jusqu\'ici, dans le but de poursuivre le travail dans une nouvelle session sans rien perdre d\'important : objectifs, décisions prises, état actuel, fichiers/commandes/éléments clés, et ce qu\'il reste à faire. Écris uniquement le résumé, sans préambule.';
- let summary='';
+ 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 sys=(document.getElementById('sysprompt').value||'').trim();
- const base=[...msgs,{role:'user',content:instruction}];
- const out = sys ? [{role:'system',content:sys},...base] : base;
- const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:out})});
- 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==='[DONE]') continue;
- try{ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; if(d.content) summary+=d.content; }catch(e){}
- }
- }
- }
- }catch(e){ toast('échec compaction: '+e.message); busy=false; btn.textContent='compacter'; btn.disabled=false; return; }
- summary=summary.trim();
- if(!summary){ toast('résumé vide'); busy=false; btn.textContent='compacter'; btn.disabled=false; return; }
- // Session propre seedée avec le résumé.
- msgs=[{role:'user',content:'Résumé de notre session précédente (reprends le travail à partir de là) :\n\n'+summary},
- {role:'assistant',content:'Compris, je reprends à partir de ce résumé.'}];
- document.getElementById('chat').innerHTML='';
- const note=addMsg('tool',''); note.querySelector('.label').textContent='contexte compacté';
- renderBody(note, '🗜️ **Contexte compacté.** Résumé de reprise :\n\n'+summary);
- saveChat();
- setCtxUsed(0); try{localStorage.removeItem('jean.ctxused');}catch(e){}
- busy=false; btn.textContent='compacter'; btn.disabled=false;
- toast('contexte compacté');
+ 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).
@@ -1658,11 +1635,13 @@ function handleDelta(d){
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
return; }
- if(d.reset!==undefined){ document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
+ if(d.reset!==undefined){ document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); const cb=document.getElementById('compact-banner'); if(cb) cb.style.display='none'; return; }
if(d.user!==undefined){ newTurn(); addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; }
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); 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.compacted){ toast('contexte compacté — les vieux tours ont été résumés'); return; }
+ if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; }
+ if(d.compacted){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('contexte compacté — les vieux tours ont été résumés'); return; }
+ if(d.compact_noop){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('rien à compacter (contexte déjà minimal)'); 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; }
diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html
index 38fb913..ddf1208 100644
--- a/internal/jean/ui/src/index.tmpl.html
+++ b/internal/jean/ui/src/index.tmpl.html
@@ -160,6 +160,7 @@
+
⏳ Compactage du contexte en cours — résumé des anciens tours…
diff --git a/internal/jean/ui/src/js/05-status.js b/internal/jean/ui/src/js/05-status.js
index 8faa92f..3c86ae8 100644
--- a/internal/jean/ui/src/js/05-status.js
+++ b/internal/jean/ui/src/js/05-status.js
@@ -53,7 +53,9 @@ function updateCtxMeter(){
fill.style.width=pct+'%';
fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)';
document.getElementById('ctx-text').textContent='contexte '+CTX_USED+' / '+CTX_MAX+' ('+pct+'%)';
- document.getElementById('ctx-compact').style.display = pct>=90 ? 'inline-block' : 'none';
+ // Bouton de compaction MANUELLE : visible dès la moitié du contexte pour qu'on
+ // puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge.
+ document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none';
}
async function loadVram(){
const gpus=await jget('/api/vram');
diff --git a/internal/jean/ui/src/js/08-chat-render.js b/internal/jean/ui/src/js/08-chat-render.js
index efe3b27..f1b84f4 100644
--- a/internal/jean/ui/src/js/08-chat-render.js
+++ b/internal/jean/ui/src/js/08-chat-render.js
@@ -158,48 +158,20 @@ function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); t
// 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(){
- // Le compactage est désormais AUTOMATIQUE côté serveur (façon Hermes) : ce
- // bouton manuel n'a plus d'objet et est neutralisé.
- toast('le compactage est automatique (façon Hermes)'); return;
- if(busy) return;
- if(!msgs.length){ toast('rien à compacter'); return; }
- if(!await askConfirm('L\'IA va résumer la conversation puis repartir d\'une session propre basée sur ce résumé.', {title:'Compacter le contexte ?', okText:'Compacter'})) return;
- busy=true;
- const btn=document.getElementById('ctx-compact'); btn.textContent='compaction…'; btn.disabled=true;
- const instruction='Résume de façon structurée et complète TOUTE notre conversation jusqu\'ici, dans le but de poursuivre le travail dans une nouvelle session sans rien perdre d\'important : objectifs, décisions prises, état actuel, fichiers/commandes/éléments clés, et ce qu\'il reste à faire. Écris uniquement le résumé, sans préambule.';
- let summary='';
+ 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 sys=(document.getElementById('sysprompt').value||'').trim();
- const base=[...msgs,{role:'user',content:instruction}];
- const out = sys ? [{role:'system',content:sys},...base] : base;
- const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({messages:out})});
- 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==='[DONE]') continue;
- try{ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; if(d.content) summary+=d.content; }catch(e){}
- }
- }
- }
- }catch(e){ toast('échec compaction: '+e.message); busy=false; btn.textContent='compacter'; btn.disabled=false; return; }
- summary=summary.trim();
- if(!summary){ toast('résumé vide'); busy=false; btn.textContent='compacter'; btn.disabled=false; return; }
- // Session propre seedée avec le résumé.
- msgs=[{role:'user',content:'Résumé de notre session précédente (reprends le travail à partir de là) :\n\n'+summary},
- {role:'assistant',content:'Compris, je reprends à partir de ce résumé.'}];
- document.getElementById('chat').innerHTML='';
- const note=addMsg('tool',''); note.querySelector('.label').textContent='contexte compacté';
- renderBody(note, '🗜️ **Contexte compacté.** Résumé de reprise :\n\n'+summary);
- saveChat();
- setCtxUsed(0); try{localStorage.removeItem('jean.ctxused');}catch(e){}
- busy=false; btn.textContent='compacter'; btn.disabled=false;
- toast('contexte compacté');
+ 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).
diff --git a/internal/jean/ui/src/js/09-stream.js b/internal/jean/ui/src/js/09-stream.js
index 787df04..8a12bf7 100644
--- a/internal/jean/ui/src/js/09-stream.js
+++ b/internal/jean/ui/src/js/09-stream.js
@@ -56,11 +56,13 @@ function handleDelta(d){
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
return; }
- if(d.reset!==undefined){ document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
+ if(d.reset!==undefined){ document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); const cb=document.getElementById('compact-banner'); if(cb) cb.style.display='none'; return; }
if(d.user!==undefined){ newTurn(); addMsg('user', d.user); setBusy(true); T.typingEl=addTyping(); return; }
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); 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.compacted){ toast('contexte compacté — les vieux tours ont été résumés'); return; }
+ if(d.compacting!==undefined){ const b=document.getElementById('compact-banner'); if(b) b.style.display = d.compacting ? 'flex' : 'none'; return; }
+ if(d.compacted){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('contexte compacté — les vieux tours ont été résumés'); return; }
+ if(d.compact_noop){ const b=document.getElementById('compact-banner'); if(b) b.style.display='none'; toast('rien à compacter (contexte déjà minimal)'); 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; }
diff --git a/internal/jean/ui/src/styles.css b/internal/jean/ui/src/styles.css
index 815ecc3..6271889 100644
--- a/internal/jean/ui/src/styles.css
+++ b/internal/jean/ui/src/styles.css
@@ -236,6 +236,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:6px;opacity:0;transition:opacity .2s;pointer-events:none}
#toast.show{opacity:1}
@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
+#compact-banner{display:flex;align-items:center;justify-content:center;gap:8px;font-size:12px;color:var(--accent);background:var(--accent-bg);border:1px solid var(--border);border-radius:8px;padding:6px 10px;margin-bottom:8px}
+#compact-banner .cspin{display:inline-block;animation:spin 1s linear infinite}
/* iOS Safari auto-zoome au focus sur tout champ < 16px. On force 16px sur les
appareils tactiles uniquement (le desktop garde son rendu, le pinch-zoom
manuel reste possible). */
diff --git a/internal/jean/web_chat.go b/internal/jean/web_chat.go
index 55255e6..ec7bb87 100644
--- a/internal/jean/web_chat.go
+++ b/internal/jean/web_chat.go
@@ -103,6 +103,20 @@ func handleChatReset(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
}
+// handleChatCompact lance une compaction manuelle du contexte (bouton UI). La
+// progression est diffusée via le flux d'abonnement (compacting/compacted).
+func handleChatCompact(w http.ResponseWriter, r *http.Request) {
+ if err := conv.CompactNow(); err != nil {
+ code := 503
+ if err == ErrBusy {
+ code = 409
+ }
+ sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ sendJSON(w, 200, map[string]any{"ok": true})
+}
+
func handleChatState(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, conv.state())
}
diff --git a/internal/jean/web_server.go b/internal/jean/web_server.go
index badeb4f..67aafe9 100644
--- a/internal/jean/web_server.go
+++ b/internal/jean/web_server.go
@@ -131,6 +131,7 @@ func newWebMux() *http.ServeMux {
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
api("/api/chat/reset", handleChatReset) // nouvelle conversation (pour tous les appareils)
+ api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
return mux