mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +02:00
v0.6.6 : le compactage automatique du contexte marche vraiment
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.
This commit is contained in:
1 parent
ecb05fcb86
commit
a727394115
13 files changed
+301
-31
No files matched your search
Binary file not shown.
Binary file not shown.
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)}
|
||||
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
|
||||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
|
||||
<div id="composer">
|
||||
<div id="compact-banner" style="display:none"><span class="cspin">⏳</span> Compactage du contexte en cours — résumé des anciens tours…</div>
|
||||
<div id="ctxmeta">
|
||||
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
||||
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
||||
@@ -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)); }
|
||||
|
||||
@@ -246,7 +246,6 @@
|
||||
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
|
||||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas">↓</button>
|
||||
<div id="composer">
|
||||
<div id="compact-banner" style="display:none"><span class="cspin">⏳</span> Compactage du contexte en cours — résumé des anciens tours…</div>
|
||||
<div id="ctxmeta">
|
||||
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
||||
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
||||
|
||||
@@ -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)); }
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user