Chat : écrire pendant que l'IA répond, et deux fils ne fusionnent plus

Repris d'AJEAN 0.14.0, adapté.

AJOUT EN COURS DE RÉPONSE
Il fallait arrêter la génération pour glisser une précision (le serveur
répondait 409). Un message envoyé pendant une réponse part désormais EN
FILE : runChat l'injecte à la prochaine frontière d'étape (après un appel
d'outil) — le modèle en tient compte dans la SUITE de sa réponse — ou, si
le tour se termine avant, il devient le tour suivant, dans l'ordre. Le
bouton envoyer réapparaît à côté de stop dès qu'il y a du texte ; le
message s'affiche « en attente » au-dessus de la carte jusqu'à ce que le
flux le confirme. Stop abandonne la file (queue_dropped, le client retire
ses pastilles).

Écarts avec l'amont :
- Les messages en attente sont posés AU-DESSUS de la carte, pas dans le
  fil qui s'écrit encore (ils s'y seraient intercalés entre deux bulles).
- Dédoublonnage par identifiant d'envoi (cid) : l'UI réessaie un envoi dont
  la réponse s'est perdue sur le tunnel. Le 409 « déjà en cours » faisait
  office de garde ; sans lui, le réessai mettait le message deux fois en
  file.
- Une tâche planifiée qui occupe le modèle garde le refus 409 (sa fin ne
  dépile rien).
- Pas d'injection après un stop : la boucle repassait en tête une fois
  l'outil interrompu et journalisait le message en file (vu en test).
- runChat prend l'injecteur en paramètre variadique : les appels hors chat
  (tâches, vérification du mode Code, tests) ne changent pas.

DEUX FILS FUSIONNÉS
Un appareil déconnecté pendant qu'un autre changeait de discussion se
réabonnait avec un `from` hérité de l'ancienne : les Seq n'étant pas
globaux, la fin de la nouvelle discussion se greffait sur le début de
l'ancienne restée à l'écran. caught_up et reset portent maintenant l'id de
la discussion ; le client le renvoie (conv_id) et, s'il ne correspond plus,
le serveur ordonne un reset avant de rejouer. La garde « from au-delà du
dernier Seq » émet elle aussi ce reset (elle rejouait par-dessus l'écran
sans le vider).

Vérifié dans le navigateur avec un faux moteur : précision injectée après
l'outil dans le même tour, message en file devenu tour suivant, stop qui
abandonne la file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:11:40 +02:00
1 parent 58c2da1302
commit 48b8193dca
11 files changed
+459 -41

No files matched your search

+186 -11
View File
@@ -68,6 +68,27 @@ type Conversation struct {
// à une génération fantôme dans la discussion ouverte. // à une génération fantôme dans la discussion ouverte.
runningTaskID string runningTaskID string
runningTaskName string runningTaskName string
// File d'attente des messages envoyés PENDANT une génération (AJEAN
// 0.14.0). Ils sont injectés dans le tour en cours à la prochaine frontière
// d'étape (après un appel d'outil), ou — si le tour se termine avant —
// traités comme tours suivants, dans l'ordre. Protégée par mu.
queued []queuedMsg
// recentCIDs : identifiants d'envoi déjà acceptés. L'UI réessaie un envoi
// dont la réponse s'est perdue (tunnel) : avant la file, le 409 « déjà en
// cours » faisait office de dédoublonnage ; sans lui, le réessai mettrait
// le même message deux fois en file.
recentCIDs []string
}
// queuedMsg = un message mis en file pendant la génération. Caps et
// température sont ceux choisis à l'envoi : un tour démarré depuis la file doit
// s'exécuter avec ces réglages-là.
type queuedMsg struct {
text string
files []attachInfo
caps Caps
temp float64
} }
var conv = func() *Conversation { var conv = func() *Conversation {
@@ -167,6 +188,7 @@ func (c *Conversation) loadFrom(b []byte) {
c.Stop() c.Stop()
c.mu.Lock() c.mu.Lock()
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0 c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
c.queued = nil // file de l'ancienne discussion : elle ne suit pas la bascule
if len(b) > 0 { if len(b) > 0 {
_ = json.Unmarshal(b, c) _ = json.Unmarshal(b, c)
c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement
@@ -506,6 +528,131 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
return nil return nil
} }
// ErrDupSend : cet envoi (même cid) a déjà été accepté — réessai d'un client
// qui n'a pas reçu la réponse. Ce n'est pas une erreur pour l'utilisateur.
var ErrDupSend = fmt.Errorf("envoi déjà reçu")
// seenCID enregistre cid et dit s'il avait déjà été vu. Appelant : mu tenu.
func (c *Conversation) seenCIDLocked(cid string) bool {
if cid == "" {
return false
}
for _, v := range c.recentCIDs {
if v == cid {
return true
}
}
c.recentCIDs = append(c.recentCIDs, cid)
if len(c.recentCIDs) > 64 {
c.recentCIDs = c.recentCIDs[len(c.recentCIDs)-64:]
}
return false
}
// EnqueueOrStart démarre un tour tout de suite si le moteur est libre, sinon
// MET EN FILE le message (AJEAN 0.14.0) — au lieu du 409 d'avant, qui obligeait
// à arrêter la réponse pour ajouter une précision. Seul un tour de CHAT accepte
// une file : une tâche planifiée qui occupe le modèle garde le refus
// (busyReason), sa fin ne dépile rien. cid = identifiant de l'envoi (voir
// recentCIDs) ; un doublon renvoie ErrDupSend.
func (c *Conversation) EnqueueOrStart(cid, text string, files []attachInfo, caps Caps, temperature float64) (bool, error) {
c.mu.Lock()
if c.seenCIDLocked(cid) {
c.mu.Unlock()
return false, ErrDupSend
}
if c.Generating && c.runningTaskName == "" {
c.queued = append(c.queued, queuedMsg{text: text, files: files, caps: caps, temp: temperature})
c.mu.Unlock()
return true, nil
}
c.mu.Unlock()
err := c.StartTurn(text, files, caps, temperature)
if err != nil {
// Refusé (modèle pas prêt, tâche en cours) : le client pourra renvoyer
// ce même message, son cid ne doit pas rester marqué comme reçu.
c.mu.Lock()
for i, v := range c.recentCIDs {
if v == cid {
c.recentCIDs = append(c.recentCIDs[:i], c.recentCIDs[i+1:]...)
break
}
}
c.mu.Unlock()
}
return false, err
}
// drainQueued vide la file et renvoie les messages utilisateur à injecter dans
// le tour EN COURS (vue modèle), en les journalisant pour qu'ils apparaissent
// dans le fil sur tous les appareils. Appelé par runChat entre deux étapes.
// epoch garde contre un reset survenu entre-temps.
func (c *Conversation) drainQueued(epoch int) []Message {
c.mu.Lock()
if c.epoch != epoch || len(c.queued) == 0 {
c.mu.Unlock()
return nil
}
items := c.queued
c.queued = nil
c.mu.Unlock()
var out []Message
for _, q := range items {
delta := map[string]any{"user": q.text}
if m := chatModelName(); m != "" {
delta["model"] = m
}
if len(q.files) > 0 {
delta["files"] = q.files
}
c.appendDelta(epoch, delta)
prompt := q.text
if strings.TrimSpace(prompt) == "" {
prompt = "Prends-en connaissance."
}
out = append(out, Message{Role: "user", Content: userMessageContent(q.files, prompt)})
}
return out
}
// startQueuedIfAny démarre le prochain tour depuis la file, s'il en reste et
// que le moteur est libre. Appelé à la toute fin d'un tour : les messages
// envoyés après la dernière frontière d'étape sont ainsi traités sans que
// l'utilisateur ait à les renvoyer. Chaque tour lancé ainsi rappelle
// startQueuedIfAny à sa fin → la file se vide dans l'ordre. Renvoie true si un
// tour a démarré.
func (c *Conversation) startQueuedIfAny() bool {
c.mu.Lock()
if c.Generating || len(c.queued) == 0 {
c.mu.Unlock()
return false
}
q := c.queued[0]
c.queued = c.queued[1:]
epoch := c.epoch
c.mu.Unlock()
if err := c.StartTurn(q.text, q.files, q.caps, q.temp); err != nil {
// Modèle indisponible au moment de dépiler (rare) : on le dit dans le
// fil plutôt que de perdre le message en silence, et on tente le suivant.
c.appendDelta(epoch, map[string]any{"error": "message en attente non envoyé : " + err.Error()})
return c.startQueuedIfAny()
}
return true
}
// dropQueued abandonne la file (bouton stop : l'utilisateur reprend la main) et
// le signale au client, qui retire ses messages « en attente ».
func (c *Conversation) dropQueued(epoch int) {
c.mu.Lock()
n := len(c.queued)
c.queued = nil
c.mu.Unlock()
if n > 0 {
c.appendDelta(epoch, map[string]any{"queue_dropped": n})
}
}
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans // chatModelName renvoie le nom lisible du modèle configuré : fichier sans
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun. // dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
func chatModelName() string { func chatModelName() string {
@@ -550,15 +697,24 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit
c.mu.Unlock() c.mu.Unlock()
c.persist() c.persist()
// Notification Web Push : ce chemin (generate) ne sert QUE les tours // File d'attente : interrompu par un stop, l'utilisateur reprend la main
// et les messages en file sont abandonnés. Sinon on démarre le prochain
// tour depuis la file — ceux qui n'ont pas pu être injectés en cours de
// route sont traités, dans l'ordre.
if ctx.Err() != nil {
c.dropQueued(epoch)
return
}
// Notification Web Push, seulement quand plus rien ne suit : un message
// en file qui démarre aussitôt n'est pas une « réponse prête », et sa
// propre fin notifiera. Ce chemin (generate) ne sert QUE les tours
// utilisateur — les tâches de fond passent par RunAutonomous et ont leur // utilisateur — les tâches de fond passent par RunAutonomous et ont leur
// propre notification — donc pas de doublon. Détaché : l'envoi HTTP vers // propre notification — donc pas de doublon. Détaché : l'envoi HTTP vers
// le service de push ne doit pas retenir la fin du tour. Corps générique // le service de push ne doit pas retenir la fin du tour. Corps générique
// (pas d'extrait de réponse) : la notif transite par Apple/Google. // (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas
// // de notification après un « stop » (retour ci-dessus) : l'utilisateur
// ctx.Err() != nil = tour interrompu par un « stop » : pas de notification, // est là et a coupé volontairement.
// l'utilisateur est là et a coupé volontairement. if !c.startQueuedIfAny() && hasPushSubs() {
if hasPushSubs() && ctx.Err() == nil {
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart))) go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
} }
}() }()
@@ -680,7 +836,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.appendDelta(epoch, map[string]any{"content": ev.Content}) c.appendDelta(epoch, map[string]any{"content": ev.Content})
} }
return true // génération détachée : on ne s'interrompt jamais sur un abonné return true // génération détachée : on ne s'interrompt jamais sur un abonné
}) }, func() []Message { return c.drainQueued(epoch) })
// Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats) // Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats)
// PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle // PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle
@@ -821,6 +977,7 @@ func (c *Conversation) Reset() {
c.epoch++ c.epoch++
c.Generating = false c.Generating = false
c.cancel = nil c.cancel = nil
c.queued = nil // les messages en attente visaient le fil qu'on vient de vider
c.cond.Broadcast() c.cond.Broadcast()
c.mu.Unlock() c.mu.Unlock()
c.persist() c.persist()
@@ -1028,14 +1185,14 @@ func historyHead(log []LogEvent, cut, hidden int) []map[string]any {
// Subscribe : abonnement sans pagination (rejeu complet). // Subscribe : abonnement sans pagination (rejeu complet).
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) { func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
c.SubscribeTail(ctx, from, -1, emit) c.SubscribeTail(ctx, from, -1, "", emit)
} }
// SubscribeTail diffuse les événements au client via emit : d'abord un REPLAY coalescé // SubscribeTail diffuse les événements au client via emit : d'abord un REPLAY coalescé
// de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement. // de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement.
// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle, // Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
// continue indépendamment. emit renvoie false si l'écriture échoue (client parti). // continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, emit func(map[string]any) bool) { func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, convID string, emit func(map[string]any) bool) {
// Réveille les attentes de cond quand la connexion se ferme. // Réveille les attentes de cond quand la connexion se ferme.
go func() { go func() {
<-ctx.Done() <-ctx.Done()
@@ -1067,8 +1224,25 @@ func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, em
// la conversation ouverte apparaît tronquée, voire vide. En session normale, le // la conversation ouverte apparaît tronquée, voire vide. En session normale, le
// client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il // client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il
// l'est, son curseur vient d'une autre session → on repart du début. // l'est, son curseur vient d'une autre session → on repart du début.
//
// ⚠️ Même famille, cas plus sournois (AJEAN 0.14.0) : un AUTRE appareil a
// changé de discussion pendant que ce client était déconnecté, et son `from`
// est PLUS BAS que le dernier Seq de la nouvelle. Le rejeu greffait alors la
// fin de la nouvelle discussion sur le début de l'ancienne restée à l'écran —
// deux fils fusionnés jusqu'au prochain rafraîchissement. Le client renvoie
// l'id de la discussion qu'il affiche (convID) : s'il ne correspond plus, on
// lui ordonne d'abord de vider l'écran (reset), puis on rejoue tout.
curID := getStr(bkChat, ckActive)
staleConv := from > 0 && convID != "" && convID != curID
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq { if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
from = 0 from = 0
staleConv = staleConv || convID != ""
}
if staleConv {
from = 0
if !emit(map[string]any{"reset": true, "id": curID}) {
return
}
} }
// Chargement initial d'un client paginé : on ne rejoue que la fin du fil, // Chargement initial d'un client paginé : on ne rejoue que la fin du fil,
// précédée de {history_more: N}. Sur un long fil d'agent, tout rejouer // précédée de {history_more: N}. Sur un long fil d'agent, tout rejouer
@@ -1096,7 +1270,7 @@ func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, em
last = s last = s
} }
} }
if !emit(map[string]any{"caught_up": true}) { if !emit(map[string]any{"caught_up": true, "id": curID}) {
return return
} }
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les // Taille du contexte connue MAINTENANT : le journal ne rejoue que les
@@ -1141,7 +1315,8 @@ func (c *Conversation) SubscribeTail(ctx context.Context, from int, tail int, em
} }
} }
c.mu.Unlock() c.mu.Unlock()
if !emit(map[string]any{"reset": true}) { // id : la discussion désormais affichée (lue hors verrou — base).
if !emit(map[string]any{"reset": true, "id": getStr(bkChat, ckActive)}) {
return return
} }
for _, ev := range head { for _, ev := range head {
+1 -1
View File
@@ -56,7 +56,7 @@ func TestSubscribeTailPaginates(t *testing.T) {
collect := func(tail int) (more any, users int) { collect := func(tail int) (more any, users int) {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
defer cancel() defer cancel()
c.SubscribeTail(ctx, 0, tail, func(m map[string]any) bool { c.SubscribeTail(ctx, 0, tail, "", func(m map[string]any) bool {
if v, ok := m["history_more"]; ok { if v, ok := m["history_more"]; ok {
more = v more = v
} }
+82
View File
@@ -0,0 +1,82 @@
package loki
import (
"context"
"testing"
)
// Pendant une génération de chat, un envoi est mis en file ; il ressort à la
// frontière d'étape suivante, journalisé comme une bulle utilisateur.
func TestQueueDuringGeneration(t *testing.T) {
c := newTestConv()
c.Generating = true
queued, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7)
if err != nil || !queued {
t.Fatalf("mise en file attendue, got queued=%v err=%v", queued, err)
}
// Réessai du même envoi (réponse perdue) : pas de doublon.
if _, err := c.EnqueueOrStart("cid-1", "précision", nil, Caps{}, 0.7); err != ErrDupSend {
t.Fatalf("doublon attendu, got %v", err)
}
msgs := c.drainQueued(c.epoch)
if len(msgs) != 1 || msgs[0].Role != "user" {
t.Fatalf("un message user attendu, got %#v", msgs)
}
if len(c.Log) != 1 || c.Log[0].Delta["user"] != "précision" {
t.Fatalf("le message injecté doit apparaître dans le fil, journal : %#v", c.Log)
}
if again := c.drainQueued(c.epoch); len(again) != 0 {
t.Fatal("la file doit être vidée par drainQueued")
}
}
// Une tâche planifiée qui occupe le modèle garde le refus : sa fin ne dépile rien.
func TestQueueRefusedDuringTask(t *testing.T) {
c := newTestConv()
c.Generating = true
c.runningTaskName = "veille"
if queued, _ := c.EnqueueOrStart("", "x", nil, Caps{}, 0.7); queued {
t.Fatal("pas de file pendant une tâche planifiée")
}
}
// Stop : la file est abandonnée et le client en est prévenu.
func TestDropQueuedAnnounces(t *testing.T) {
c := newTestConv()
c.Generating = true
_, _ = c.EnqueueOrStart("", "a", nil, Caps{}, 0.7)
c.dropQueued(c.epoch)
if len(c.queued) != 0 || len(c.Log) != 1 || c.Log[0].Delta["queue_dropped"] != 1 {
t.Fatalf("abandon non signalé : file=%d journal=%#v", len(c.queued), c.Log)
}
}
// Un client dont la discussion affichée n'est plus l'active reçoit d'abord un
// reset, pour ne pas greffer le nouveau fil sur l'ancien.
func TestSubscribeStaleConvResets(t *testing.T) {
testHome(t)
_ = putStr(bkChat, ckActive, "c-nouvelle")
c := newTestConv()
for i := 0; i < 3; i++ {
c.appendDelta(c.epoch, map[string]any{"user": "q"})
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var first map[string]any
c.SubscribeTail(ctx, 2, -1, "c-ancienne", func(m map[string]any) bool {
if _, pad := m["pad"]; pad {
return true
}
if first == nil {
first = m
}
if _, ok := m["caught_up"]; ok {
cancel()
return false
}
return true
})
if first["reset"] != true || first["id"] != "c-nouvelle" {
t.Fatalf("reset attendu en premier, got %v", first)
}
}
+20 -1
View File
@@ -872,7 +872,12 @@ func toolCallLabel(name string, args map[string]any) string {
return label return label
} }
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) { // injectQueued (optionnel, variadique pour ne pas toucher aux appels hors chat)
// est consulté à CHAQUE frontière d'étape de la boucle d'outils : il renvoie les
// messages utilisateur mis en file PENDANT la génération, pour que le modèle les
// prenne en compte dans la SUITE de sa réponse (AJEAN 0.14.0) au lieu
// d'obliger à arrêter puis relancer. Ajoutés à `messages` ET à `extra`.
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback, injectQueued ...func() []Message) ([]Message, error) {
var extra []Message var extra []Message
tools := EnabledTools(caps) tools := EnabledTools(caps)
// Some backends (vanilla llama.cpp builds) don't populate `reasoning_content` // Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
@@ -945,6 +950,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// qui tourne en rond n'avait rien en face de lui sauf le bouton stop. // qui tourne en rond n'avait rien en face de lui sauf le bouton stop.
toolRuns, budgetNudges, budget := 0, 0, agentBudget() toolRuns, budgetNudges, budget := 0, 0, agentBudget()
for iter := 0; ; iter++ { for iter := 0; ; iter++ {
// Ajout en cours de réponse : entre deux étapes (après un appel d'outil ou
// une relance), on injecte les messages mis en file par l'utilisateur. Pas
// à iter==0 : le message initial du tour est déjà dans `messages`. Ni après
// un stop : la boucle repasse ici une fois l'outil interrompu, et le
// message en file serait journalisé alors que l'utilisateur a repris la
// main (dropQueued l'abandonne à la fin du tour).
if iter > 0 && ctx.Err() == nil {
for _, inject := range injectQueued {
if q := inject(); len(q) > 0 {
messages = append(messages, q...)
extra = append(extra, q...)
}
}
}
// Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté // Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté
// llama-server reste valide, seul le nouveau message est à traiter. // llama-server reste valide, seul le nouveau message est à traiter.
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" { if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
+74 -12
View File
@@ -1245,6 +1245,19 @@ html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
#attach-list::-webkit-scrollbar{display:none} #attach-list::-webkit-scrollbar{display:none}
#attach-list.show{display:flex} #attach-list.show{display:flex}
#composer:has(#attach-list.show){--fade:6px} #composer:has(#attach-list.show){--fade:6px}
/* File d'attente : messages envoyés pendant une réponse, pas encore pris en
compte. Même bande que les pièces jointes, en gris. */
#queue-list{order:0;display:none;flex-direction:column;align-items:flex-end;gap:4px;margin:0 var(--col);padding:0 2px 8px}
#queue-list.show{display:flex}
#composer:has(#queue-list.show){--fade:6px}
#queue-list .queued-msg{max-width:80%;padding:5px 12px;border-radius:12px;background:var(--panel);
border:1px dashed var(--border);color:var(--dim);font-size:12px;line-height:1.35;
white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;
box-shadow:0 2px 10px rgba(0,0,0,.10)}
#queue-list .queued-msg::before{content:"en attente · ";font-style:italic;opacity:.8}
/* Pendant une réponse, envoyer reste possible dès qu'il y a du texte : le
message part en file (voir send()). Stop reste à côté. */
html[data-busy="1"][data-hastext="1"] #send{display:inline-flex}
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un /* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
cadre, sinon le texte de la conversation transparaît derrière. */ cadre, sinon le texte de la conversation transparaît derrière. */
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border); #attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
@@ -2372,6 +2385,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi. <!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. --> qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<!-- Messages envoyés PENDANT une réponse (file d'attente, voir send()) : en
gris au-dessus de la carte jusqu'à ce que le serveur les prenne en compte
— injectés dans la réponse en cours, ou traités au tour suivant. -->
<div id="queue-list" aria-live="polite"></div>
<div id="attach-list"></div> <div id="attach-list"></div>
<div id="inputbar"> <div id="inputbar">
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea> <textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
@@ -6944,6 +6961,9 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(
function autoGrow(ta){ function autoGrow(ta){
ta = ta || document.getElementById('input'); ta = ta || document.getElementById('input');
if(!ta) return; if(!ta) return;
// Du texte à envoyer ? Pendant une réponse, c'est ce qui fait réapparaître
// « envoyer » à côté de stop (message mis en file, voir send()).
if(ta.id==='input') document.documentElement.setAttribute('data-hastext', ta.value.trim() ? '1' : '0');
ta.style.height='auto'; ta.style.height='auto';
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200; const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
ta.style.height=Math.min(ta.scrollHeight, max)+'px'; ta.style.height=Math.min(ta.scrollHeight, max)+'px';
@@ -6962,6 +6982,32 @@ let lastSeq=0, streamAbort=null;
// caught_up). // caught_up).
const HIST_TAIL=20; const HIST_TAIL=20;
let HIST_FULL=false, HIST_RESTORE=null; let HIST_FULL=false, HIST_RESTORE=null;
// Discussion AFFICHÉE (id reçu au caught_up/reset). Renvoyée à chaque
// (ré)abonnement : si un autre appareil a changé de discussion pendant une
// coupure, le serveur ordonne un reset au lieu de greffer le nouveau fil sur
// l'ancien (AJEAN 0.14.0).
let CONV_ID='';
// File d'attente (AJEAN 0.14.0) : messages envoyés PENDANT une réponse, montrés
// en gris au-dessus de la carte jusqu'à ce que le flux les confirme (delta
// `user` du même texte) — injectés en cours de réponse ou au tour suivant.
function queueAdd(text){
const box=document.getElementById('queue-list'); if(!box) return null;
const el=document.createElement('div'); el.className='queued-msg'; el.textContent=text||'(pièce jointe)';
el.dataset.text=text;
box.appendChild(el); box.classList.add('show');
return el;
}
function queueSync(){ const box=document.getElementById('queue-list'); if(box) box.classList.toggle('show', !!box.children.length); }
function queueTake(text){
const box=document.getElementById('queue-list'); if(!box) return;
const el=[...box.children].find(e=>e.dataset.text===text);
if(el) el.remove();
queueSync();
}
function queueClear(){ const box=document.getElementById('queue-list'); if(box) box.textContent=''; queueSync(); }
// Identifiant d'envoi, stable d'un réessai à l'autre : le serveur ne met pas
// deux fois le même message en file si la réponse s'est perdue en route.
function newCID(){ try{ return crypto.randomUUID(); }catch(_){ return Date.now().toString(36)+Math.random().toString(36).slice(2); } }
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout // Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la // aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand // frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
@@ -7370,6 +7416,7 @@ function handleDelta(d){
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures. // pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts; if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
if(d.caught_up){ if(d.caught_up){
if(typeof d.id==='string') CONV_ID=d.id;
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas // Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
@@ -7396,7 +7443,8 @@ function handleDelta(d){
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils // a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle. // appartiennent à la discussion, le panneau doit changer avec elle.
if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; } if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; }
if(d.reset!==undefined){ HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; } if(d.queue_dropped!==undefined){ if(!REPLAYING){ queueClear(); toast('messages en attente abandonnés'); } return; }
if(d.reset!==undefined){ if(typeof d.id==='string') CONV_ID=d.id; queueClear(); HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) ----------------------------------------------- // --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; } if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; } if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -7413,6 +7461,7 @@ function handleDelta(d){
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque // donc présent au rejeu comme en direct. Repris par tagModel sur chaque
// bulle de réponse du tour. // bulle de réponse du tour.
T.model = d.model || ''; T.model = d.model || '';
if(!REPLAYING) queueTake(d.user); // message en file désormais pris en compte
let el=PENDING; let el=PENDING;
if(!confirmPending(d.user)) el=addMsg('user', d.user); if(!confirmPending(d.user)) el=addMsg('user', d.user);
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en // Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
@@ -7524,7 +7573,7 @@ async function connectStream(){
while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); } while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController(); streamAbort=new AbortController();
try{ try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq,tail:HIST_FULL?0:HIST_TAIL}),signal:streamAbort.signal}); const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq,tail:HIST_FULL?0:HIST_TAIL,conv_id:CONV_ID}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…'); if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){ while(true){
@@ -7594,17 +7643,23 @@ function loadFullHistory(){
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de // erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
// « network error » alarmiste alors que l'IA répond quand même. // « network error » alarmiste alors que l'IA répond quand même.
async function send(){ async function send(){
if(busy) return;
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici. // Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; } if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim(); const ta=document.getElementById('input'); const text=ta.value.trim();
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »). // Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
if(!text && !ATTACH.length) return; if(!text && !ATTACH.length) return;
ta.value=''; autoGrow(ta); ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps // Réponse en cours : le message part EN FILE (AJEAN 0.14.0) — il sera pris en
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending). // compte à la prochaine étape de la réponse, ou au tour suivant. Il s'affiche
addPending(text); // en attente au-dessus de la carte, pas dans le fil qui s'écrit encore.
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); }; const queuedAtSend = busy;
let qel=null;
// Sinon le message s'affiche TOUT DE SUITE dans le fil, en gris : il ne
// disparaît plus le temps de l'aller-retour. Il s'éclaircit quand le flux le
// confirme (confirmPending).
if(queuedAtSend) qel=queueAdd(text); else addPending(text);
const fail=(m)=>{ if(qel){ qel.remove(); queueSync(); } else clearPending(); toast(m); ta.value=text; autoGrow(ta); };
const cid=newCID();
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles // C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti, // ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
// on doit pouvoir en enlever une, et un échec doit rester visible. // on doit pouvoir en enlever une, et un échec doit rester visible.
@@ -7612,13 +7667,20 @@ async function send(){
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; } if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
// Les pastilles passent dans la bulle en attente : le message porte ses // Les pastilles passent dans la bulle en attente : le message porte ses
// fichiers dès l'envoi, sans attendre l'aller-retour. // fichiers dès l'envoi, sans attendre l'aller-retour.
if(PENDING) addMsgFiles(PENDING, attachSent()); if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){ for(let attempt=0; attempt<3; attempt++){
try{ try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})}); const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid})});
if(r.status===409 || r.ok) clearAttach(); if(r.ok) clearAttach();
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK // 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas.
if(r.ok) return; // la bulle + les tokens arrivent par le flux if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
if(r.ok){
// Parti tout de suite alors qu'on le croyait en file (le tour venait de
// finir) : la pastille disparaît, la bulle arrive par le flux.
let j={}; try{ j=await r.json(); }catch(_){}
if(qel && j.queued===false){ qel.remove(); queueSync(); }
return; // la bulle + les tokens arrivent par le flux
}
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; } if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
}catch(e){ /* réseau : on retente */ } }catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600)); await new Promise(res=>setTimeout(res, 600));
+4
View File
@@ -163,6 +163,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi. <!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. --> qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<!-- Messages envoyés PENDANT une réponse (file d'attente, voir send()) : en
gris au-dessus de la carte jusqu'à ce que le serveur les prenne en compte
— injectés dans la réponse en cours, ou traités au tour suivant. -->
<div id="queue-list" aria-live="polite"></div>
<div id="attach-list"></div> <div id="attach-list"></div>
<div id="inputbar"> <div id="inputbar">
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea> <textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
@@ -572,6 +572,9 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(
function autoGrow(ta){ function autoGrow(ta){
ta = ta || document.getElementById('input'); ta = ta || document.getElementById('input');
if(!ta) return; if(!ta) return;
// Du texte à envoyer ? Pendant une réponse, c'est ce qui fait réapparaître
// « envoyer » à côté de stop (message mis en file, voir send()).
if(ta.id==='input') document.documentElement.setAttribute('data-hastext', ta.value.trim() ? '1' : '0');
ta.style.height='auto'; ta.style.height='auto';
const max=parseInt(getComputedStyle(ta).maxHeight,10)||200; const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
ta.style.height=Math.min(ta.scrollHeight, max)+'px'; ta.style.height=Math.min(ta.scrollHeight, max)+'px';
+54 -12
View File
@@ -11,6 +11,32 @@ let lastSeq=0, streamAbort=null;
// caught_up). // caught_up).
const HIST_TAIL=20; const HIST_TAIL=20;
let HIST_FULL=false, HIST_RESTORE=null; let HIST_FULL=false, HIST_RESTORE=null;
// Discussion AFFICHÉE (id reçu au caught_up/reset). Renvoyée à chaque
// (ré)abonnement : si un autre appareil a changé de discussion pendant une
// coupure, le serveur ordonne un reset au lieu de greffer le nouveau fil sur
// l'ancien (AJEAN 0.14.0).
let CONV_ID='';
// File d'attente (AJEAN 0.14.0) : messages envoyés PENDANT une réponse, montrés
// en gris au-dessus de la carte jusqu'à ce que le flux les confirme (delta
// `user` du même texte) — injectés en cours de réponse ou au tour suivant.
function queueAdd(text){
const box=document.getElementById('queue-list'); if(!box) return null;
const el=document.createElement('div'); el.className='queued-msg'; el.textContent=text||'(pièce jointe)';
el.dataset.text=text;
box.appendChild(el); box.classList.add('show');
return el;
}
function queueSync(){ const box=document.getElementById('queue-list'); if(box) box.classList.toggle('show', !!box.children.length); }
function queueTake(text){
const box=document.getElementById('queue-list'); if(!box) return;
const el=[...box.children].find(e=>e.dataset.text===text);
if(el) el.remove();
queueSync();
}
function queueClear(){ const box=document.getElementById('queue-list'); if(box) box.textContent=''; queueSync(); }
// Identifiant d'envoi, stable d'un réessai à l'autre : le serveur ne met pas
// deux fois le même message en file si la réponse s'est perdue en route.
function newCID(){ try{ return crypto.randomUUID(); }catch(_){ return Date.now().toString(36)+Math.random().toString(36).slice(2); } }
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout // Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la // aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand // frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
@@ -419,6 +445,7 @@ function handleDelta(d){
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures. // pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts; if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
if(d.caught_up){ if(d.caught_up){
if(typeof d.id==='string') CONV_ID=d.id;
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas // Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut). // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
@@ -445,7 +472,8 @@ function handleDelta(d){
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils // a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle. // appartiennent à la discussion, le panneau doit changer avec elle.
if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; } if(d.history_more!==undefined){ showHistoryMore(d.history_more); return; }
if(d.reset!==undefined){ HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; } if(d.queue_dropped!==undefined){ if(!REPLAYING){ queueClear(); toast('messages en attente abandonnés'); } return; }
if(d.reset!==undefined){ if(typeof d.id==='string') CONV_ID=d.id; queueClear(); HIST_FULL=false; smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) ----------------------------------------------- // --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; } if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; } if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
@@ -462,6 +490,7 @@ function handleDelta(d){
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque // donc présent au rejeu comme en direct. Repris par tagModel sur chaque
// bulle de réponse du tour. // bulle de réponse du tour.
T.model = d.model || ''; T.model = d.model || '';
if(!REPLAYING) queueTake(d.user); // message en file désormais pris en compte
let el=PENDING; let el=PENDING;
if(!confirmPending(d.user)) el=addMsg('user', d.user); if(!confirmPending(d.user)) el=addMsg('user', d.user);
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en // Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
@@ -573,7 +602,7 @@ async function connectStream(){
while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); } while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
streamAbort=new AbortController(); streamAbort=new AbortController();
try{ try{
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq,tail:HIST_FULL?0:HIST_TAIL}),signal:streamAbort.signal}); const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq,tail:HIST_FULL?0:HIST_TAIL,conv_id:CONV_ID}),signal:streamAbort.signal});
if(REPLAYING) setChatLoading('chargement de la conversation…'); if(REPLAYING) setChatLoading('chargement de la conversation…');
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){ while(true){
@@ -643,17 +672,23 @@ function loadFullHistory(){
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de // erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
// « network error » alarmiste alors que l'IA répond quand même. // « network error » alarmiste alors que l'IA répond quand même.
async function send(){ async function send(){
if(busy) return;
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici. // Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; } if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
const ta=document.getElementById('input'); const text=ta.value.trim(); const ta=document.getElementById('input'); const text=ta.value.trim();
// Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »). // Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
if(!text && !ATTACH.length) return; if(!text && !ATTACH.length) return;
ta.value=''; autoGrow(ta); ta.value=''; autoGrow(ta);
// Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps // Réponse en cours : le message part EN FILE (AJEAN 0.14.0) — il sera pris en
// de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending). // compte à la prochaine étape de la réponse, ou au tour suivant. Il s'affiche
addPending(text); // en attente au-dessus de la carte, pas dans le fil qui s'écrit encore.
const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); }; const queuedAtSend = busy;
let qel=null;
// Sinon le message s'affiche TOUT DE SUITE dans le fil, en gris : il ne
// disparaît plus le temps de l'aller-retour. Il s'éclaircit quand le flux le
// confirme (confirmPending).
if(queuedAtSend) qel=queueAdd(text); else addPending(text);
const fail=(m)=>{ if(qel){ qel.remove(); queueSync(); } else clearPending(); toast(m); ta.value=text; autoGrow(ta); };
const cid=newCID();
// C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles // C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
// ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti, // ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
// on doit pouvoir en enlever une, et un échec doit rester visible. // on doit pouvoir en enlever une, et un échec doit rester visible.
@@ -661,13 +696,20 @@ async function send(){
if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; } if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
// Les pastilles passent dans la bulle en attente : le message porte ses // Les pastilles passent dans la bulle en attente : le message porte ses
// fichiers dès l'envoi, sans attendre l'aller-retour. // fichiers dès l'envoi, sans attendre l'aller-retour.
if(PENDING) addMsgFiles(PENDING, attachSent()); if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){ for(let attempt=0; attempt<3; attempt++){
try{ try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})}); const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid})});
if(r.status===409 || r.ok) clearAttach(); if(r.ok) clearAttach();
if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK // 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas.
if(r.ok) return; // la bulle + les tokens arrivent par le flux if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
if(r.ok){
// Parti tout de suite alors qu'on le croyait en file (le tour venait de
// finir) : la pastille disparaît, la bulle arrive par le flux.
let j={}; try{ j=await r.json(); }catch(_){}
if(qel && j.queued===false){ qel.remove(); queueSync(); }
return; // la bulle + les tokens arrivent par le flux
}
if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; } if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
}catch(e){ /* réseau : on retente */ } }catch(e){ /* réseau : on retente */ }
await new Promise(res=>setTimeout(res, 600)); await new Promise(res=>setTimeout(res, 600));
+13
View File
@@ -1210,6 +1210,19 @@ html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
#attach-list::-webkit-scrollbar{display:none} #attach-list::-webkit-scrollbar{display:none}
#attach-list.show{display:flex} #attach-list.show{display:flex}
#composer:has(#attach-list.show){--fade:6px} #composer:has(#attach-list.show){--fade:6px}
/* File d'attente : messages envoyés pendant une réponse, pas encore pris en
compte. Même bande que les pièces jointes, en gris. */
#queue-list{order:0;display:none;flex-direction:column;align-items:flex-end;gap:4px;margin:0 var(--col);padding:0 2px 8px}
#queue-list.show{display:flex}
#composer:has(#queue-list.show){--fade:6px}
#queue-list .queued-msg{max-width:80%;padding:5px 12px;border-radius:12px;background:var(--panel);
border:1px dashed var(--border);color:var(--dim);font-size:12px;line-height:1.35;
white-space:pre-wrap;overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;
box-shadow:0 2px 10px rgba(0,0,0,.10)}
#queue-list .queued-msg::before{content:"en attente · ";font-style:italic;opacity:.8}
/* Pendant une réponse, envoyer reste possible dès qu'il y a du texte : le
message part en file (voir send()). Stop reste à côté. */
html[data-busy="1"][data-hastext="1"] #send{display:inline-flex}
/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un /* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un
cadre, sinon le texte de la conversation transparaît derrière. */ cadre, sinon le texte de la conversation transparaît derrière. */
#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border); #attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border);
+9
View File
@@ -1349,6 +1349,15 @@ type chatReq struct {
// chargement ; 0 = tout. nil = ancien client (app relais…), qui ne sait pas // chargement ; 0 = tout. nil = ancien client (app relais…), qui ne sait pas
// afficher le bouton « échanges précédents » : il reçoit toujours tout. // afficher le bouton « échanges précédents » : il reçoit toujours tout.
Tail *int `json:"tail"` Tail *int `json:"tail"`
// ClientID = identifiant unique d'un ENVOI (/api/chat/send), stable d'un
// réessai à l'autre : le serveur ne met pas deux fois le même message en
// file (voir Conversation.recentCIDs).
ClientID string `json:"cid"`
// ConvID = id de la discussion AFFICHÉE par le client (reçu au dernier
// caught_up/reset du flux). Renvoyé à chaque (re)abonnement pour que le
// serveur détecte qu'un AUTRE appareil a changé de discussion entre-temps
// et ordonne un reset avant de rejouer — sinon deux fils fusionnaient.
ConvID string `json:"conv_id"`
// Files = chemins relatifs des fichiers déposés juste avant par // Files = chemins relatifs des fichiers déposés juste avant par
// /api/chat/upload ("uploads/rapport.pdf"). Ils sont annoncés au modèle en // /api/chat/upload ("uploads/rapport.pdf"). Ils sont annoncés au modèle en
// tête du message (voir attachNote) ; le contenu, lui, reste sur le disque et // tête du message (voir attachNote) ; le contenu, lui, reste sur le disque et
+13 -4
View File
@@ -88,7 +88,7 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any)
if body.Tail != nil { if body.Tail != nil {
tail = *body.Tail tail = *body.Tail
} }
conv.SubscribeTail(ctx, body.From, tail, emit) conv.SubscribeTail(ctx, body.From, tail, body.ConvID, emit)
} }
// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse // handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse
@@ -106,8 +106,17 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"}) sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"})
return return
} }
if err := conv.StartTurn(body.Message, files, capsFromBody(body), body.Temperature); err != nil { // Génération en cours : au lieu de refuser, on MET EN FILE (AJEAN 0.14.0).
// 409 = occupé (génération en cours) ; 503 = modèle pas prêt. // Le message est injecté dans la réponse en cours à la prochaine frontière
// d'étape, ou traité comme tour suivant. queued=true le signale au client.
queued, err := conv.EnqueueOrStart(body.ClientID, body.Message, files, capsFromBody(body), body.Temperature)
if err == ErrDupSend {
// Réessai d'un envoi déjà accepté (réponse perdue en route) : succès.
sendJSON(w, 200, map[string]any{"ok": true, "dup": true})
return
}
if err != nil {
// 409 = une tâche planifiée occupe le modèle ; 503 = modèle pas prêt.
code := 503 code := 503
if err == ErrBusy { if err == ErrBusy {
code = 409 code = 409
@@ -116,7 +125,7 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()}) sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
return return
} }
sendJSON(w, 200, map[string]any{"ok": true}) sendJSON(w, 200, map[string]any{"ok": true, "queued": queued})
} }
// handleToolResult renvoie le résultat COMPLET d'un outil dont le flux n'a // handleToolResult renvoie le résultat COMPLET d'un outil dont le flux n'a