From 48b8193dcaaaacd04592d8f3047f97095527b6bc Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Mon, 28 Sep 2026 00:49:20 +0200 Subject: [PATCH] =?UTF-8?q?Chat=20:=20=C3=A9crire=20pendant=20que=20l'IA?= =?UTF-8?q?=20r=C3=A9pond,=20et=20deux=20fils=20ne=20fusionnent=20plus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/loki/chat_conversation.go | 197 ++++++++++++++++++++-- internal/loki/chat_history_tail_test.go | 2 +- internal/loki/chat_queue_test.go | 82 +++++++++ internal/loki/llm_client.go | 21 ++- internal/loki/ui/index.html | 86 ++++++++-- internal/loki/ui/src/index.tmpl.html | 4 + internal/loki/ui/src/js/08-chat-render.js | 3 + internal/loki/ui/src/js/09-stream.js | 66 ++++++-- internal/loki/ui/src/styles.css | 13 ++ internal/loki/web_api.go | 9 + internal/loki/web_chat.go | 17 +- 11 files changed, 459 insertions(+), 41 deletions(-) create mode 100644 internal/loki/chat_queue_test.go diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 90b5ee5..1333d7d 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -68,6 +68,27 @@ type Conversation struct { // à une génération fantôme dans la discussion ouverte. runningTaskID 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 { @@ -167,6 +188,7 @@ func (c *Conversation) loadFrom(b []byte) { c.Stop() c.mu.Lock() 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 { _ = json.Unmarshal(b, c) 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 } +// 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 // dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun. 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.mu.Unlock() 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 // 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 - // (pas d'extrait de réponse) : la notif transite par Apple/Google. - // - // ctx.Err() != nil = tour interrompu par un « stop » : pas de notification, - // l'utilisateur est là et a coupé volontairement. - if hasPushSubs() && ctx.Err() == nil { + // (pas d'extrait de réponse) : la notif transite par Apple/Google. Pas + // de notification après un « stop » (retour ci-dessus) : l'utilisateur + // est là et a coupé volontairement. + if !c.startQueuedIfAny() && hasPushSubs() { 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}) } 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) // 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.Generating = false c.cancel = nil + c.queued = nil // les messages en attente visaient le fil qu'on vient de vider c.cond.Broadcast() c.mu.Unlock() c.persist() @@ -1028,14 +1185,14 @@ func historyHead(log []LogEvent, cut, hidden int) []map[string]any { // Subscribe : abonnement sans pagination (rejeu complet). 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é // 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, // 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. go func() { <-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 // 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. + // + // ⚠️ 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 { 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, // 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 } } - if !emit(map[string]any{"caught_up": true}) { + if !emit(map[string]any{"caught_up": true, "id": curID}) { return } // 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() - 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 } for _, ev := range head { diff --git a/internal/loki/chat_history_tail_test.go b/internal/loki/chat_history_tail_test.go index bf246ab..f592492 100644 --- a/internal/loki/chat_history_tail_test.go +++ b/internal/loki/chat_history_tail_test.go @@ -56,7 +56,7 @@ func TestSubscribeTailPaginates(t *testing.T) { collect := func(tail int) (more any, users int) { ctx, cancel := context.WithCancel(context.Background()) 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 { more = v } diff --git a/internal/loki/chat_queue_test.go b/internal/loki/chat_queue_test.go new file mode 100644 index 0000000..4d058c9 --- /dev/null +++ b/internal/loki/chat_queue_test.go @@ -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) + } +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index e130291..9bc789d 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -872,7 +872,12 @@ func toolCallLabel(name string, args map[string]any) string { 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 tools := EnabledTools(caps) // 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. toolRuns, budgetNudges, budget := 0, 0, agentBudget() 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é // llama-server reste valide, seul le nouveau message est à traiter. if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" { diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 70eb78b..a473aad 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -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.show{display:flex} #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 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); @@ -2372,6 +2385,10 @@ html[data-files="1"] #files-btn{color:var(--accent)} + +
@@ -6944,6 +6961,9 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send( function autoGrow(ta){ ta = ta || document.getElementById('input'); 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'; const max=parseInt(getComputedStyle(ta).maxHeight,10)||200; ta.style.height=Math.min(ta.scrollHeight, max)+'px'; @@ -6962,6 +6982,32 @@ let lastSeq=0, streamAbort=null; // caught_up). const HIST_TAIL=20; 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 // 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 @@ -7370,6 +7416,7 @@ function handleDelta(d){ // 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(d.caught_up){ + if(typeof d.id==='string') CONV_ID=d.id; 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 // 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 // appartiennent à la discussion, le panneau doit changer avec elle. 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) ----------------------------------------------- if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); 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 // bulle de réponse du tour. T.model = d.model || ''; + if(!REPLAYING) queueTake(d.user); // message en file désormais pris en compte let el=PENDING; if(!confirmPending(d.user)) el=addMsg('user', d.user); // 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)); } streamAbort=new AbortController(); 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…'); const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; while(true){ @@ -7594,17 +7643,23 @@ function loadFullHistory(){ // 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. async function send(){ - if(busy) return; // 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; } 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 »). if(!text && !ATTACH.length) return; ta.value=''; autoGrow(ta); - // Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps - // de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending). - addPending(text); - const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); }; + // Réponse en cours : le message part EN FILE (AJEAN 0.14.0) — il sera pris en + // compte à la prochaine étape de la réponse, ou au tour suivant. Il s'affiche + // en attente au-dessus de la carte, pas dans le fil qui s'écrit encore. + 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 // 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. @@ -7612,13 +7667,20 @@ async function send(){ 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 // 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++){ 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})}); - if(r.status===409 || r.ok) clearAttach(); - if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK - if(r.ok) return; // la bulle + les tokens arrivent par le flux + 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.ok) clearAttach(); + // 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas. + 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; } }catch(e){ /* réseau : on retente */ } await new Promise(res=>setTimeout(res, 600)); diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index dd4a2e0..e1574b1 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -163,6 +163,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid + +
diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js index f649aa3..2d0c908 100644 --- a/internal/loki/ui/src/js/08-chat-render.js +++ b/internal/loki/ui/src/js/08-chat-render.js @@ -572,6 +572,9 @@ function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send( function autoGrow(ta){ ta = ta || document.getElementById('input'); 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'; const max=parseInt(getComputedStyle(ta).maxHeight,10)||200; ta.style.height=Math.min(ta.scrollHeight, max)+'px'; diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js index 1deb730..695e109 100644 --- a/internal/loki/ui/src/js/09-stream.js +++ b/internal/loki/ui/src/js/09-stream.js @@ -11,6 +11,32 @@ let lastSeq=0, streamAbort=null; // caught_up). const HIST_TAIL=20; 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 // 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 @@ -419,6 +445,7 @@ function handleDelta(d){ // 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(d.caught_up){ + if(typeof d.id==='string') CONV_ID=d.id; 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 // 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 // appartiennent à la discussion, le panneau doit changer avec elle. 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) ----------------------------------------------- if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); 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 // bulle de réponse du tour. T.model = d.model || ''; + if(!REPLAYING) queueTake(d.user); // message en file désormais pris en compte let el=PENDING; if(!confirmPending(d.user)) el=addMsg('user', d.user); // 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)); } streamAbort=new AbortController(); 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…'); const reader=r.body.getReader(); const dec=new TextDecoder(); let buf=''; while(true){ @@ -643,17 +672,23 @@ function loadFullHistory(){ // 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. async function send(){ - if(busy) return; // 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; } 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 »). if(!text && !ATTACH.length) return; ta.value=''; autoGrow(ta); - // Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps - // de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending). - addPending(text); - const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); }; + // Réponse en cours : le message part EN FILE (AJEAN 0.14.0) — il sera pris en + // compte à la prochaine étape de la réponse, ou au tour suivant. Il s'affiche + // en attente au-dessus de la carte, pas dans le fil qui s'écrit encore. + 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 // 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. @@ -661,13 +696,20 @@ async function send(){ 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 // 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++){ 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})}); - if(r.status===409 || r.ok) clearAttach(); - if(r.status===409) return; // déjà en cours (notre envoi a abouti) → OK - if(r.ok) return; // la bulle + les tokens arrivent par le flux + 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.ok) clearAttach(); + // 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas. + 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; } }catch(e){ /* réseau : on retente */ } await new Promise(res=>setTimeout(res, 600)); diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css index 540bd82..e4536fc 100644 --- a/internal/loki/ui/src/styles.css +++ b/internal/loki/ui/src/styles.css @@ -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.show{display:flex} #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 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); diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index dff67cb..4c78204 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -1349,6 +1349,15 @@ type chatReq struct { // chargement ; 0 = tout. nil = ancien client (app relais…), qui ne sait pas // afficher le bouton « échanges précédents » : il reçoit toujours tout. 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 // /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 diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go index 5286ecf..3df0100 100644 --- a/internal/loki/web_chat.go +++ b/internal/loki/web_chat.go @@ -88,7 +88,7 @@ func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) if body.Tail != nil { 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 @@ -106,8 +106,17 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) { sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"}) return } - if err := conv.StartTurn(body.Message, files, capsFromBody(body), body.Temperature); err != nil { - // 409 = occupé (génération en cours) ; 503 = modèle pas prêt. + // Génération en cours : au lieu de refuser, on MET EN FILE (AJEAN 0.14.0). + // 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 if err == ErrBusy { 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()}) 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