From a72739411543242ba68ace374ccce582b37c225c Mon Sep 17 00:00:00 2001 From: nathaninline Date: Thu, 30 Jul 2026 22:17:40 +0200 Subject: [PATCH] v0.6.6 : le compactage automatique du contexte marche vraiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le compactage ne se declenchait qu'au DEBUT d'un tour, avec le contexte du tour precedent. Le tour qui faisait franchir le seuil se terminait donc a 81% sans rien declencher : la jauge restait haute, seul le bouton manuel apparaissait, et le compactage automatique semblait mort. - seuil reteste a trois moments au lieu d'un : debut de tour (inchange), PENDANT la boucle d'outils, et EN FIN de tour des que le seuil est franchi - une compaction faite en cours de tour etait perdue : l'appelant reconstruisait l'historique en ajoutant les messages du tour a sa copie d'AVANT compaction. Le fil complet revenait, la jauge retombait a 43% puis explosait a 92% au message suivant. Elle publie desormais sa nouvelle vue (StreamEvent.NewHistory), qui REMPLACE l'historique. Meme correction pour le filet de secours sur erreur 500 et pour le client terminal. - la queue protegee reculait jusqu'au message `user` precedent. Pendant une longue recherche il n'y en a aucun : la queue avalait toute la sequence d'outils, torse vide, compaction sans effet — les pages web qui remplissent la fenetre etaient justement celles qui etaient protegees. Elle peut maintenant demarrer sur un `assistant`, frontiere tout aussi sure (il part avec ses resultats d'outils). Test de regression ajoute. - une compaction sans effet ne disait plus rien : toast explicite + une ligne [compact] par decision dans le journal (ctx, seuil, avant/apres, changé) UI : la mini-banniere au-dessus de la saisie doublonnait les trois points animes de l'IA. L'indicateur existant est desormais etiquete « compactage du contexte… » en accent, et une marque discrete reste DANS le fil a l'endroit de la coupure — elle survit au rafraichissement, contrairement au toast. --- cmd/jean/resource_windows_amd64.syso | Bin 8340 -> 8340 bytes cmd/jean/resource_windows_arm64.syso | Bin 8340 -> 8340 bytes cmd/jean/versioninfo.json | 6 +-- internal/jean/chat_cmd.go | 10 ++++ internal/jean/chat_compact.go | 30 ++++++++++-- internal/jean/chat_compact_test.go | 60 ++++++++++++++++++++--- internal/jean/chat_conversation.go | 69 ++++++++++++++++++++++++++- internal/jean/llm_client.go | 39 +++++++++++++++ internal/jean/run.go | 2 +- internal/jean/ui/index.html | 58 ++++++++++++++++++---- internal/jean/ui/src/index.tmpl.html | 1 - internal/jean/ui/src/js/09-stream.js | 41 ++++++++++++++-- internal/jean/ui/src/styles.css | 16 +++++-- 13 files changed, 301 insertions(+), 31 deletions(-) diff --git a/cmd/jean/resource_windows_amd64.syso b/cmd/jean/resource_windows_amd64.syso index 94d5d81adb9f35f3328287d35cc6dc31af7e111b..01aa5b7055f8fbfbef84b03d01d1c0d13c69b7e0 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^u0~-Sfux(bB5oKaD+iWQp%m}1<<=Ghlg+vDE delta 36 ncmbQ@IK^>;lnf^;0~-Sfux?hC5oKaD-E1ir%m}1<<=Ghlg#iZS diff --git a/cmd/jean/resource_windows_arm64.syso b/cmd/jean/resource_windows_arm64.syso index c58ae6ac2f4ee683b444912db9096e1064981508..3d0559972e2798bcb2eee8a77d9f39df28e55581 100644 GIT binary patch delta 36 ncmbQ@IK^>;lnf^u0~-Sfux(bB5oKaD+iWQp%m}1<<=Ghlg+vDE delta 36 ncmbQ@IK^>;lnf^;0~-Sfux?hC5oKaD-E1ir%m}1<<=Ghlg#iZS diff --git a/cmd/jean/versioninfo.json b/cmd/jean/versioninfo.json index 8d55758..d3cae49 100644 --- a/cmd/jean/versioninfo.json +++ b/cmd/jean/versioninfo.json @@ -3,13 +3,13 @@ "FileVersion": { "Major": 0, "Minor": 6, - "Patch": 5, + "Patch": 6, "Build": 0 }, "ProductVersion": { "Major": 0, "Minor": 6, - "Patch": 5, + "Patch": 6, "Build": 0 }, "FileFlagsMask": "3f", @@ -25,7 +25,7 @@ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.", "OriginalFilename": "jean.exe", "ProductName": "AJEAN", - "ProductVersion": "0.6.5", + "ProductVersion": "0.6.6", "Comments": "https://github.com/nathaninline/jean — projet open source (MIT)" }, "VarFileInfo": { diff --git a/internal/jean/chat_cmd.go b/internal/jean/chat_cmd.go index 7b9bf8a..2dfc54f 100644 --- a/internal/jean/chat_cmd.go +++ b/internal/jean/chat_cmd.go @@ -77,6 +77,16 @@ func cmdChat(args []string) error { switch { case ev.Err != nil: fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error())) + case ev.NewHistory != nil: + // Compaction survenue pendant le tour : elle REMPLACE l'historique (elle + // contient déjà le tour en cours), préfixe système injecté retiré. Sans + // ça le terminal repartirait du fil complet au tour suivant. + base := ev.NewHistory + for len(base) > 0 && base[0].Role == "system" { + base = base[1:] + } + msgs = append([]Message(nil), base...) + fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]")) case ev.Stats != nil: stats = ev.Stats case ev.DropReasoning: diff --git a/internal/jean/chat_compact.go b/internal/jean/chat_compact.go index 4e279e2..23be98e 100644 --- a/internal/jean/chat_compact.go +++ b/internal/jean/chat_compact.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "os" "strconv" "strings" ) @@ -120,6 +121,17 @@ func compactWouldTrigger(msgs []Message, knownTokens int) bool { return used >= int(float64(ctxWindow())*compactTriggerFrac) } +// logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur +// (donc dans `journalctl -u jean-link`). Sans ça, une compaction qui ne se +// déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté +// UI on ne voit qu'une jauge qui reste haute, sans savoir si le seuil n'a pas +// été atteint ou si la réduction a été refusée. +func logCompact(phase string, used int, before, after []Message, changed bool) { + fmt.Fprintf(os.Stderr, "[compact] %s ctx=%d seuil=%d/%d est_avant=%d est_apres=%d msgs=%d→%d changé=%v\n", + phase, used, int(float64(ctxWindow())*compactTriggerFrac), ctxWindow(), + estimateTokens(before), estimateTokens(after), len(before), len(after), changed) +} + func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens int) ([]Message, bool) { if !compactWouldTrigger(msgs, knownTokens) { return msgs, false @@ -135,9 +147,19 @@ func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens in // - head : nb de messages protégés en tête = messages système + 1er message // utilisateur (il ancre l'objectif). Un message user est une frontière sûre. // - tailStart : index de début de la queue protégée. On remonte depuis la fin -// jusqu'à remplir le budget, puis on recule jusqu'au message `user` précédent -// pour que la queue démarre sur un début de tour — on ne sépare JAMAIS un -// assistant+tool_calls de ses résultats `tool`. +// jusqu'à remplir le budget, puis on recule jusqu'à une frontière SÛRE : +// un message `user`, ou un `assistant` (qui, s'il porte des tool_calls, part +// dans la queue AVEC ses résultats). On ne sépare ainsi jamais un +// assistant+tool_calls de ses `tool`, et on ne laisse jamais un `tool` +// orphelin en tête de queue. +// +// Reculer jusqu'à un `user` UNIQUEMENT était trop strict et rendait toute +// 2ᵉ compaction inopérante dans un même tour : pendant une longue boucle +// d'outils il n'y a AUCUN message `user`, donc la queue avalait toute la +// séquence d'outils et le torse était vide (« le compactage ne fait rien » +// alors que ce sont précisément les pages web lues qui remplissent la +// fenêtre). S'arrêter sur un `assistant` coupe proprement entre deux groupes +// d'appels d'outils. // // Le torse à compacter est [head, tailStart). Il est vide (tailStart <= head) // quand il n'y a rien à résumer. @@ -157,7 +179,7 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) { break } } - for tailStart > head && msgs[tailStart].Role != "user" { + for tailStart > head && msgs[tailStart].Role != "user" && msgs[tailStart].Role != "assistant" { tailStart-- } return head, tailStart diff --git a/internal/jean/chat_compact_test.go b/internal/jean/chat_compact_test.go index 8bc741f..91f6dfa 100644 --- a/internal/jean/chat_compact_test.go +++ b/internal/jean/chat_compact_test.go @@ -1,6 +1,9 @@ package jean -import "testing" +import ( + "strings" + "testing" +) // helpers pour construire des historiques de test lisibles. func um(s string) Message { return Message{Role: "user", Content: s} } @@ -22,8 +25,9 @@ func TestCompactBoundsProtectsHead(t *testing.T) { if head != 2 { // system + premier user t.Fatalf("head = %d, attendu 2", head) } - if msgs[tail].Role != "user" { - t.Fatalf("la queue doit démarrer sur un message user, obtenu %q", msgs[tail].Role) + // Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin). + if r := msgs[tail].Role; r != "user" && r != "assistant" { + t.Fatalf("la queue doit démarrer sur un user ou un assistant, obtenu %q", r) } if tail <= head { t.Fatalf("torse vide (tail=%d head=%d) alors qu'il y a du milieu à compacter", tail, head) @@ -31,7 +35,8 @@ func TestCompactBoundsProtectsHead(t *testing.T) { } // La queue ne doit jamais démarrer entre un assistant+tool_calls et ses -// résultats `tool` : elle recule jusqu'au message user du tour. +// résultats `tool` : elle recule jusqu'à la frontière sûre précédente (user ou +// assistant), donc l'assistant part dans la queue AVEC ses résultats. func TestCompactBoundsKeepsToolPairs(t *testing.T) { msgs := []Message{ um("q1"), am("r1"), @@ -40,8 +45,8 @@ func TestCompactBoundsKeepsToolPairs(t *testing.T) { } // budget moyen qui, sans le recul, couperait au milieu du tour outillé. _, tail := compactBounds(msgs, msgTokens(msgs[6])+msgTokens(msgs[7])+msgTokens(msgs[5])+1) - if msgs[tail].Role != "user" { - t.Fatalf("la queue démarre sur %q, doit être un user (pas d'orphelin tool/assistant)", msgs[tail].Role) + if r := msgs[tail].Role; r != "user" && r != "assistant" { + t.Fatalf("la queue démarre sur %q : frontière non sûre (orphelin tool possible)", r) } // Vérifie qu'aucun message `tool` de la queue n'a perdu son assistant parent. for i := tail; i < len(msgs); i++ { @@ -53,6 +58,49 @@ func TestCompactBoundsKeepsToolPairs(t *testing.T) { } } +// Régression : DEUXIÈME compaction à l'intérieur d'un même tour. Une longue +// boucle d'outils (recherche web : dix pages lues d'affilée) ne contient AUCUN +// message `user` — reculer jusqu'à un `user` faisait donc avaler toute la +// séquence par la queue, torse vide, compaction sans effet. La queue doit +// pouvoir démarrer sur un `assistant`, et les gros résultats d'outils du début +// doivent se retrouver dans le torse (donc résumés). +func TestCompactBoundsSplitsLongToolLoop(t *testing.T) { + page := func(n int) Message { + return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400)) + } + msgs := []Message{ + um("premier"), am("ok"), // head : 1er user protégé + um("cherche des trucs"), // la dernière demande utilisateur du tour + } + for i := 0; i < 10; i++ { + msgs = append(msgs, atc("web_read"), page(i)) + } + head, tail := compactBounds(msgs, estimateTokens(msgs)/4) + if tail <= head { + t.Fatalf("torse vide (tail=%d head=%d) : la boucle d'outils n'est pas compactable", tail, head) + } + if r := msgs[tail].Role; r != "user" && r != "assistant" { + t.Fatalf("la queue démarre sur %q : frontière non sûre", r) + } + // Aucun `tool` orphelin en queue. + for i := tail; i < len(msgs); i++ { + if msgs[i].Role == "tool" && (i == tail || (msgs[i-1].Role != "assistant" && msgs[i-1].Role != "tool")) { + t.Fatalf("message tool orphelin à l'index %d de la queue", i) + } + } + // Le torse doit bien contenir des résultats d'outils (c'est ce qui remplit + // la fenêtre) — sinon la compaction ne libérerait rien. + tools := 0 + for _, m := range msgs[head:tail] { + if m.Role == "tool" { + tools++ + } + } + if tools == 0 { + t.Fatal("aucun résultat d'outil dans le torse : rien à gagner à compacter") + } +} + func TestEstimateTokensGrows(t *testing.T) { small := estimateTokens([]Message{um("court")}) big := estimateTokens([]Message{um("un message nettement plus long que le précédent pour dépasser")}) diff --git a/internal/jean/chat_conversation.go b/internal/jean/chat_conversation.go index bbf4b39..6c5a383 100644 --- a/internal/jean/chat_conversation.go +++ b/internal/jean/chat_conversation.go @@ -286,7 +286,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa if willCompact { c.appendDelta(epoch, map[string]any{"compacting": true}) } - if compacted, changed := MaybeCompact(ctx, msgs, caps, ctxUsed); changed { + compacted, changed := MaybeCompact(ctx, msgs, caps, ctxUsed) + if willCompact { + logCompact("début-tour", ctxUsed, msgs, compacted, changed) + } + if changed { // Surcoût fixe non compactable (prompt système injecté + schémas d'outils + // gabarit) = contexte réel mesuré − estimation des messages. On le rajoute à // l'estimation post-compaction pour que la jauge affiche une valeur réaliste @@ -307,7 +311,11 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa c.appendDelta(epoch, map[string]any{"compacted": true}) c.appendDelta(epoch, map[string]any{"ctx_used": est}) // fait chuter la jauge tout de suite } else if willCompact { + // Seuil franchi mais compaction sans effet (torse vide, ou réduction sous les + // 20% exigés) : on le DIT. Avant, on retirait juste la bannière sans un mot — + // vu de l'UI, « le compactage automatique ne fait rien » sans aucune trace. c.appendDelta(epoch, map[string]any{"compacting": false}) + c.appendDelta(epoch, map[string]any{"compact_noop": true}) } // Prompt système personnalisé (UI → /api/sysprompt, fichier côté serveur). @@ -318,6 +326,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa final = append([]Message{{Role: "system", Content: sp}}, msgs...) } + // newBase : vue modèle publiée par une compaction survenue PENDANT le tour. + // Non-nil = elle remplace l'historique (elle contient déjà le tour en cours). + var newBase []Message var content strings.Builder extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool { switch { @@ -334,6 +345,21 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa tu["diff"] = ev.ToolUsed.Diff } c.appendDelta(epoch, map[string]any{"tool_used": tu}) + case ev.NewHistory != nil: + // Compaction faite en cours de tour : elle remplace la base au lieu de + // s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire le + // préfixe système injecté à la volée (prompt perso + skills, fusionnés en + // UN message system en tête) : il n'appartient pas à l'historique persisté + // et doit rester modifiable à chaud. + base := ev.NewHistory + for len(base) > 0 && base[0].Role == "system" { + base = base[1:] + } + newBase = append([]Message(nil), base...) + case ev.Compacting != nil: + // Compaction déclenchée pendant la boucle d'outils : même bannière que la + // compaction de début de tour. + c.appendDelta(epoch, map[string]any{"compacting": *ev.Compacting}) case ev.Stats != nil: // Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur // et la décision de compactage au tour suivant. @@ -362,12 +388,52 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa // la nouvelle conversation vide ne doit pas hériter de la fin de l'ancienne. c.mu.Lock() if c.epoch == epoch { + if newBase != nil { + c.Messages = newBase + } c.Messages = append(c.Messages, extra...) if s := content.String(); strings.TrimSpace(s) != "" { c.Messages = append(c.Messages, Message{Role: "assistant", Content: s}) } } + msgs = append([]Message(nil), c.Messages...) + ctxUsed = c.CtxUsed + stale := c.epoch != epoch c.mu.Unlock() + + // Compaction de FIN DE TOUR. C'était LE trou : le seuil n'était testé qu'au + // DÉBUT d'un tour, avec le contexte du tour précédent. Le tour qui fait + // franchir le seuil se termine donc à 81% et… rien. La jauge reste haute, seul + // le bouton manuel s'affiche, et l'utilisateur voit un compactage automatique + // qui « ne marche pas » — alors qu'il attendait simplement le message suivant. + // On compacte donc DÈS que le tour qui a franchi le seuil est fini : la jauge + // retombe tout de suite et le tour suivant démarre avec de la marge. + // Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé + // (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop. + if stale || ctx.Err() != nil || !compactWouldTrigger(msgs, ctxUsed) { + return + } + c.appendDelta(epoch, map[string]any{"compacting": true}) + compacted, changed = compactMessages(context.Background(), msgs, caps) + c.appendDelta(epoch, map[string]any{"compacting": false}) + logCompact("fin-tour", ctxUsed, msgs, compacted, changed) + if !changed { + c.appendDelta(epoch, map[string]any{"compact_noop": true}) + return + } + overhead := ctxUsed - estimateTokens(msgs) + if overhead < 0 { + overhead = 0 + } + est := estimateTokens(compacted) + overhead + c.mu.Lock() + if c.epoch == epoch { + c.Messages = compacted + c.CtxUsed = est + } + c.mu.Unlock() + c.appendDelta(epoch, map[string]any{"compacted": true}) + c.appendDelta(epoch, map[string]any{"ctx_used": est}) } // CompactNow force une compaction du contexte MAINTENANT, sans attendre le seuil @@ -402,6 +468,7 @@ func (c *Conversation) CompactNow() error { c.appendDelta(epoch, map[string]any{"compacting": true}) compacted, changed := compactMessages(ctx, msgs, Caps{}) c.appendDelta(epoch, map[string]any{"compacting": false}) + logCompact("manuel", lastReal, msgs, compacted, changed) if !changed { c.appendDelta(epoch, map[string]any{"compact_noop": true}) return diff --git a/internal/jean/llm_client.go b/internal/jean/llm_client.go index 9405871..b1734a7 100644 --- a/internal/jean/llm_client.go +++ b/internal/jean/llm_client.go @@ -258,6 +258,19 @@ type StreamEvent struct { // le modèle a « pensé sans agir » et on relance le tour, ce raisonnement-là // est mort-né et ne doit pas rester à l'écran (sinon double raisonnement). DropReasoning bool + // Compacting signale une compaction déclenchée EN COURS DE TOUR (boucle + // d'outils) : true à l'entrée du résumé, false à la sortie. Même bannière que + // la compaction proactive de début de tour, qui elle est émise directement par + // Conversation.generate. nil = l'événement ne parle pas de compaction. + Compacting *bool + // NewHistory publie la vue modèle APRÈS une compaction faite en cours de tour. + // Sans elle la compaction est perdue : l'appelant reconstruit l'historique en + // ajoutant les messages du tour à sa copie d'AVANT compaction, donc le fil + // complet revient et le tour suivant re-déborde aussitôt (43% → 92% en un + // message). Cette liste contient DÉJÀ tout le tour en cours : l'appelant doit + // REMPLACER son historique par elle (préfixe système injecté retiré), pas l'y + // ajouter. + NewHistory []Message } type ToolUsedEvent struct { Name string @@ -478,6 +491,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps if c, changed := compactMessages(ctx, messages, caps); changed { compactedRetry = true messages = c + logCompact("réactif", 0, messages, c, changed) + // Même publication qu'en cours de tour : sans elle, la compaction de + // secours ne survit pas à la fin du tour et le prompt re-déborde au + // message suivant. + extra = nil + cb(StreamEvent{NewHistory: append([]Message(nil), messages...)}) continue } } @@ -852,6 +871,26 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps messages = append(messages, toolMsg) extra = append(extra, toolMsg) } + // Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour : + // une boucle d'outils peut à elle seule remplir la fenêtre (résultats + // enchaînés), on partait à 60% et on finissait en dépassement — rattrapé au + // mieux par le filet réactif sur 500, une seule fois. On re-teste donc ici, + // avec le contexte RÉEL du dernier appel (usage.prompt_tokens + généré). + if used := stats.PromptTokensTotal + stats.GenTokens; compactWouldTrigger(messages, used) { + yes, no := true, false + cb(StreamEvent{Compacting: &yes}) + c, changed := compactMessages(ctx, messages, caps) + cb(StreamEvent{Compacting: &no}) + logCompact("en-tour", used, messages, c, changed) + if changed { + messages = c + // La nouvelle base contient déjà tout ce tour : on la publie et on + // repart d'un `extra` vide, sinon l'appelant la ré-empilerait avec + // les messages du tour et dupliquerait tout. + extra = nil + cb(StreamEvent{NewHistory: append([]Message(nil), messages...)}) + } + } continue } // Normal end of turn. If the model produced no visible answer at all diff --git a/internal/jean/run.go b/internal/jean/run.go index 22a48dc..d77bc3e 100644 --- a/internal/jean/run.go +++ b/internal/jean/run.go @@ -10,7 +10,7 @@ import ( "strings" ) -const Version = "0.6.5" +const Version = "0.6.6" // Main est le vrai main() du binaire (cmd/jean ne fait que l'appeler). func Main() { diff --git a/internal/jean/ui/index.html b/internal/jean/ui/index.html index c2cc66e..b0e8f84 100644 --- a/internal/jean/ui/index.html +++ b/internal/jean/ui/index.html @@ -288,6 +288,19 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg.typing span:nth-child(2){animation-delay:.16s} .msg.typing span:nth-child(3){animation-delay:.32s} @keyframes typingdot{0%,70%,100%{opacity:.25;transform:translateY(0)}35%{opacity:1;transform:translateY(-4px)}} +/* Compactage du contexte : le MÊME indicateur, juste étiqueté. Avant, une + mini-bannière au-dessus de la saisie doublonnait les 3 points animés — deux + éléments pour un seul état d'attente. Ici les points passent en accent et un + libellé apparaît à côté : un seul objet à l'écran, au bon endroit. */ +.msg.typing.compacting span:not(.tlabel){background:var(--accent)} +.msg.typing .tlabel{width:auto;height:auto;border-radius:0;background:none;animation:tlabelfade 1.6s ease-in-out infinite;font-size:12px;color:var(--accent);margin-left:3px;white-space:nowrap} +@keyframes tlabelfade{0%,100%{opacity:.6}50%{opacity:1}} +/* Trace laissée DANS le fil quand le contexte a été compacté : un séparateur + discret, à sa place chronologique — un toast qui disparaît ne dit pas OÙ la + coupure a eu lieu. */ +.compact-mark{display:flex;align-items:center;gap:10px;margin:14px 2px;font-size:11px;color:var(--dim);letter-spacing:.04em;animation:cmarkin .35s ease} +.compact-mark::before,.compact-mark::after{content:"";flex:1;height:1px;background:var(--border)} +@keyframes cmarkin{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}} /* --- Message en attente d'accusé serveur --------------------------------- Affiché DÈS l'appui sur envoyer, en gris et légèrement effacé : le message ne disparaît plus le temps de l'aller-retour. La classe est retirée quand le @@ -475,8 +488,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} #toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)} #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). */ @@ -775,7 +786,6 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none} #composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 0 calc(8px + env(safe-area-inset-bottom));--col:calc(max(16px,calc(50% - 380px)) + var(--sbw,0px));background:linear-gradient(to bottom,transparent 0,var(--bg) 34%)} #chat{padding-bottom:calc(170px + env(safe-area-inset-bottom))} #scrollbtn{bottom:calc(180px + env(safe-area-inset-bottom));border-radius:999px} -#compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px} #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0} #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px} /* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même @@ -1057,7 +1067,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
chargement de la conversation…
-
@@ -2734,6 +2743,39 @@ function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typin 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(); +} function killTyping(){ if(!T.typingEl) return; if(simpleMode()) return; removeTyping(); } function showTyping(){ if(!simpleMode()) return; const c=document.getElementById('chat'); if(!T.typingEl){ T.typingEl=addTyping(); } else if(c.lastElementChild!==T.typingEl){ c.appendChild(T.typingEl); } } @@ -2780,13 +2822,13 @@ function handleDelta(d){ setChatLoading(null); if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; } return; } - if(d.reset!==undefined){ PENDING=null; 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.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; } if(d.user!==undefined){ newTurn(); if(!confirmPending(d.user)) 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.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.compacting!==undefined){ setCompacting(d.compacting); return; } + if(d.compacted){ setCompacting(false); addCompactMark(); return; } + if(d.compact_noop){ setCompacting(false); 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)); } diff --git a/internal/jean/ui/src/index.tmpl.html b/internal/jean/ui/src/index.tmpl.html index d168863..2071bae 100644 --- a/internal/jean/ui/src/index.tmpl.html +++ b/internal/jean/ui/src/index.tmpl.html @@ -246,7 +246,6 @@
chargement de la conversation…
-
diff --git a/internal/jean/ui/src/js/09-stream.js b/internal/jean/ui/src/js/09-stream.js index 36e76cb..8ec3421 100644 --- a/internal/jean/ui/src/js/09-stream.js +++ b/internal/jean/ui/src/js/09-stream.js @@ -41,6 +41,39 @@ function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typin 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(); +} function killTyping(){ if(!T.typingEl) return; if(simpleMode()) return; removeTyping(); } function showTyping(){ if(!simpleMode()) return; const c=document.getElementById('chat'); if(!T.typingEl){ T.typingEl=addTyping(); } else if(c.lastElementChild!==T.typingEl){ c.appendChild(T.typingEl); } } @@ -87,13 +120,13 @@ function handleDelta(d){ setChatLoading(null); if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; } return; } - if(d.reset!==undefined){ PENDING=null; 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.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; } if(d.user!==undefined){ newTurn(); if(!confirmPending(d.user)) 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.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.compacting!==undefined){ setCompacting(d.compacting); return; } + if(d.compacted){ setCompacting(false); addCompactMark(); return; } + if(d.compact_noop){ setCompacting(false); 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)); } diff --git a/internal/jean/ui/src/styles.css b/internal/jean/ui/src/styles.css index 7a2de02..380fbff 100644 --- a/internal/jean/ui/src/styles.css +++ b/internal/jean/ui/src/styles.css @@ -262,6 +262,19 @@ button:disabled{opacity:.5;cursor:not-allowed} .msg.typing span:nth-child(2){animation-delay:.16s} .msg.typing span:nth-child(3){animation-delay:.32s} @keyframes typingdot{0%,70%,100%{opacity:.25;transform:translateY(0)}35%{opacity:1;transform:translateY(-4px)}} +/* Compactage du contexte : le MÊME indicateur, juste étiqueté. Avant, une + mini-bannière au-dessus de la saisie doublonnait les 3 points animés — deux + éléments pour un seul état d'attente. Ici les points passent en accent et un + libellé apparaît à côté : un seul objet à l'écran, au bon endroit. */ +.msg.typing.compacting span:not(.tlabel){background:var(--accent)} +.msg.typing .tlabel{width:auto;height:auto;border-radius:0;background:none;animation:tlabelfade 1.6s ease-in-out infinite;font-size:12px;color:var(--accent);margin-left:3px;white-space:nowrap} +@keyframes tlabelfade{0%,100%{opacity:.6}50%{opacity:1}} +/* Trace laissée DANS le fil quand le contexte a été compacté : un séparateur + discret, à sa place chronologique — un toast qui disparaît ne dit pas OÙ la + coupure a eu lieu. */ +.compact-mark{display:flex;align-items:center;gap:10px;margin:14px 2px;font-size:11px;color:var(--dim);letter-spacing:.04em;animation:cmarkin .35s ease} +.compact-mark::before,.compact-mark::after{content:"";flex:1;height:1px;background:var(--border)} +@keyframes cmarkin{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}} /* --- Message en attente d'accusé serveur --------------------------------- Affiché DÈS l'appui sur envoyer, en gris et légèrement effacé : le message ne disparaît plus le temps de l'aller-retour. La classe est retirée quand le @@ -449,8 +462,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5} #toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)} #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). */ @@ -749,7 +760,6 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none} #composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 0 calc(8px + env(safe-area-inset-bottom));--col:calc(max(16px,calc(50% - 380px)) + var(--sbw,0px));background:linear-gradient(to bottom,transparent 0,var(--bg) 34%)} #chat{padding-bottom:calc(170px + env(safe-area-inset-bottom))} #scrollbtn{bottom:calc(180px + env(safe-area-inset-bottom));border-radius:999px} -#compact-banner{order:0;margin:0 var(--col) 8px;border-radius:14px} #inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0} #input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px} /* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même