From 4854ae3784117b15214b3cb7faba8ff97eac7350 Mon Sep 17 00:00:00 2001 From: Loki Date: Sat, 15 Aug 2026 14:43:41 +0000 Subject: [PATCH] =?UTF-8?q?Passe=20de=20qualit=C3=A9=20:=20contexte=20expl?= =?UTF-8?q?os=C3=A9=20par=20les=20captures,=20sonde=20vision,=20courses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le journal moteur de production a livré la vraie cause première des 500 en cascade : « request (55407 tokens) exceeds the available context size (32768) ». Le message multimodal qui relaie une capture d'écran au modèle était PERSISTÉ dans l'historique ; son base64 (des dizaines de milliers de tokens) repartait à chaque tour, et la conversation dépassait définitivement la fenêtre — plus aucun tour ne passait, et l'exception Jinja du rattrapage (corrigée au commit précédent) masquait tout. - L'image devient ÉPHÉMÈRE : jointe au tour en cours, jamais à l'historique. Le modèle la regarde maintenant ; sa description textuelle, elle, reste. - stripImageParts guérit les conversations déjà empoisonnées au chargement et à la bascule : parties image retirées, texte aplati. - engineSeesImages : l'image n'est envoyée que si llama-server DÉCLARE la vision (/props, modalities.vision, cache 10 s). La clé MMPROJ ne suffit pas — projecteur d'un autre modèle ou modèle sans vision (gpt-oss), le gabarit sérialise le base64 en texte. La description de l'outil suit le même état : ne jamais promettre une image qui n'arrivera pas. Deux défauts trouvés à l'analyse, au passage : - renderBody reconstruit le DOM à chaque delta du streaming : une image déjà affichée était RE-TÉLÉCHARGÉE à chaque token arrivé après elle. Cache de blobs par URL, une seule requête par capture. - les opérations de discussions (création, bascule, renommage, suppression) entrelaçaient leurs lectures-écritures d'index sous requêtes simultanées : sérialisées par un verrou dédié. Tests : historique guéri (aplati sans l'image), transmission conditionnée à la sonde, description alignée ; suite complète, vet, staticcheck verts. --- internal/loki/chat_conversation.go | 4 ++ internal/loki/chat_screenshot.go | 87 +++++++++++++++++++++-- internal/loki/chat_screenshot_test.go | 35 ++++++++- internal/loki/chat_sessions.go | 17 +++++ internal/loki/llm_client.go | 9 ++- internal/loki/ui/index.html | 17 +++-- internal/loki/ui/src/js/08-chat-render.js | 17 +++-- 7 files changed, 172 insertions(+), 14 deletions(-) diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 5b304f9..f65e2c3 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -80,6 +80,9 @@ func LoadConversation() { conv.mu.Lock() defer conv.mu.Unlock() _ = json.Unmarshal(b, conv) + // Guérit les conversations d'avant le passage des captures en éphémère : un + // base64 d'image persisté était rejoué à chaque tour et dépassait le contexte. + conv.Messages = stripImageParts(conv.Messages) // Une génération n'a pas pu survivre à l'arrêt du process : on repart propre. conv.Generating = false conv.cancel = nil @@ -95,6 +98,7 @@ func (c *Conversation) loadFrom(b []byte) { c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0 if len(b) > 0 { _ = json.Unmarshal(b, c) + c.Messages = stripImageParts(c.Messages) // même guérison qu'au chargement } c.Generating = false c.cancel = nil diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go index b18025f..e2dc200 100644 --- a/internal/loki/chat_screenshot.go +++ b/internal/loki/chat_screenshot.go @@ -17,13 +17,16 @@ package loki import ( "context" "encoding/base64" + "encoding/json" "fmt" + "net/http" "os" "os/exec" "path/filepath" "regexp" "sort" "strings" + "sync" "time" ) @@ -87,15 +90,91 @@ func webScreenshotTool() Tool { } // screenshotVisionNote : ce que le modèle doit savoir de SA propre perception. -// Avec un projecteur configuré, la capture lui est réellement transmise (voir -// screenshotImageMessage) ; sans projecteur, il photographie sans regarder. +// Alignée sur la capacité RÉELLE du moteur, pas seulement sur la clé MMPROJ — +// sinon on promet une image qui n'arrivera pas et le modèle se contredit. func screenshotVisionNote() string { - if visionEnabled() { + if visionEnabled() && engineSeesImages() { return "L'image t'est ensuite montrée : tu peux la décrire." } return "Tu ne vois pas l'image." } +// engineSeesImages : le moteur ACCEPTE-t-il réellement des images ? On +// interroge /props de llama-server (modalities.vision), avec un cache court — +// l'appel est local et instantané moteur en marche, mais moteur ARRÊTÉ chaque +// sonde attendrait le timeout, et elle est faite à chaque construction du +// catalogue d'outils. +// +// La clé MMPROJ ne suffit pas : configurée avec le projecteur d'un AUTRE +// modèle, ou avec un modèle sans vision (gpt-oss), le gabarit sérialise le +// base64 de l'image en TEXTE — la requête vue en production pesait 55 000 +// tokens pour 32 768 de contexte, et tous les tours suivants échouaient. +var ( + visionProbeMu sync.Mutex + visionProbeAt time.Time + visionProbeSeen bool +) + +func engineSeesImages() bool { + visionProbeMu.Lock() + defer visionProbeMu.Unlock() + if time.Since(visionProbeAt) < 10*time.Second { + return visionProbeSeen + } + visionProbeAt = time.Now() + visionProbeSeen = false + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(fmt.Sprintf("http://localhost:%d/props", LLMPort())) + if err != nil { + return false + } + defer resp.Body.Close() + var props struct { + Modalities struct { + Vision bool `json:"vision"` + } `json:"modalities"` + } + if resp.StatusCode != 200 || json.NewDecoder(resp.Body).Decode(&props) != nil { + return false + } + visionProbeSeen = props.Modalities.Vision + return visionProbeSeen +} + +// stripImageParts retire les parties image_url des messages persistés et +// aplatit ce qui reste en texte simple. Deux raisons : +// - guérir les conversations créées AVANT le passage de l'image en éphémère, +// où un base64 de plusieurs dizaines de milliers de tokens était rejoué à +// chaque tour jusqu'à dépasser définitivement le contexte ; +// - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture. +func stripImageParts(msgs []Message) []Message { + for i, m := range msgs { + parts, ok := m.Content.([]any) + if !ok { + continue + } + var texts []string + dropped := false + for _, p := range parts { + pm, ok := p.(map[string]any) + if !ok { + continue + } + if pm["type"] == "image_url" { + dropped = true + continue + } + if t, ok := pm["text"].(string); ok { + texts = append(texts, t) + } + } + if dropped { + msgs[i].Content = strings.Join(texts, "\n") + } + } + return msgs +} + // screenshotImageMessage construit le message utilisateur qui PORTE la capture // jusqu'au modèle. Le résultat d'un outil est un message `tool`, qui ne // transporte que du texte : pour qu'un modèle multimodal voie l'image, elle doit @@ -106,7 +185,7 @@ func screenshotVisionNote() string { // llama-server rejette un contenu image sans --mmproj, donc mieux vaut ne rien // envoyer que de faire échouer le tour. func screenshotImageMessage(relPath string) (Message, bool) { - if !visionEnabled() { + if !visionEnabled() || !engineSeesImages() { return Message{}, false } abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath)) diff --git a/internal/loki/chat_screenshot_test.go b/internal/loki/chat_screenshot_test.go index c7276c1..6d723bb 100644 --- a/internal/loki/chat_screenshot_test.go +++ b/internal/loki/chat_screenshot_test.go @@ -87,6 +87,11 @@ func TestConvDeleteSupprimeLesCaptures(t *testing.T) { func TestScreenshotSuitLEtatDeLaVision(t *testing.T) { t.Setenv("LOKI_HOME", t.TempDir()) + // Sans projecteur, la sonde moteur ne doit même pas être consultée : on + // s'assure que son cache est froid pour que le test reste hermétique. + visionProbeMu.Lock() + visionProbeAt, visionProbeSeen = time.Now(), false + visionProbeMu.Unlock() if got := screenshotVisionNote(); !strings.Contains(got, "ne vois pas") { t.Fatalf("sans projecteur, la description doit annoncer l'absence de vision : %q", got) } @@ -105,10 +110,16 @@ func TestScreenshotSuitLEtatDeLaVision(t *testing.T) { t.Fatal("image transmise au modèle alors qu'aucun projecteur n'est configuré") } - // Projecteur configuré → description ET transmission changent. + // Projecteur configuré ET moteur qui déclare la vision (on amorce le cache + // de la sonde : aucun llama-server ne tourne pendant les tests) → + // description ET transmission changent. if err := SetConfigKey("MMPROJ", "mmproj-test.gguf"); err != nil { t.Fatal(err) } + visionProbeMu.Lock() + visionProbeAt, visionProbeSeen = time.Now(), true + visionProbeMu.Unlock() + defer func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() }() if got := screenshotVisionNote(); strings.Contains(got, "ne vois pas") { t.Fatalf("avec projecteur, la description ne doit plus nier la vision : %q", got) } @@ -125,6 +136,28 @@ func TestScreenshotSuitLEtatDeLaVision(t *testing.T) { } } +// Un base64 d'image persisté dans l'historique est rejoué à chaque tour : vu en +// production, 55 000 tokens de requête pour 32 768 de contexte — plus aucun +// tour ne passait. stripImageParts guérit les conversations existantes en +// retirant les parties image et en aplatissant le texte restant. +func TestStripImagePartsGueritLHistorique(t *testing.T) { + msgs := []Message{ + {Role: "user", Content: "bonjour"}, // simple chaîne : intouchée + {Role: "user", Content: []any{ // message multimodal persisté (via JSON) + map[string]any{"type": "text", "text": "Voici la capture demandée."}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}}, + }}, + } + out := stripImageParts(msgs) + if out[0].Content.(string) != "bonjour" { + t.Fatalf("message texte modifié : %#v", out[0].Content) + } + got, ok := out[1].Content.(string) + if !ok || got != "Voici la capture demandée." { + t.Fatalf("le message multimodal doit être aplati en texte sans l'image, obtenu %#v", out[1].Content) + } +} + // Le chemin de la capture est extrait du texte rendu par l'outil : si le format // de ce texte change, le relais vers le modèle casse en silence. func TestCapturedRelPath(t *testing.T) { diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index ba9ebae..1d74754 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -24,6 +24,7 @@ import ( "fmt" "sort" "strings" + "sync" "time" ) @@ -33,6 +34,14 @@ const ( ckLegacy = "conversation" // fil unique d'avant le multi-discussions ) +// convOpMu sérialise les opérations de haut niveau sur les discussions +// (création, bascule, renommage, suppression). Chacune fait plusieurs +// lectures-écritures de l'index et de la clé active : deux requêtes HTTP +// simultanées (deux appareils, double-clic) pouvaient entrelacer ces étapes et +// perdre une entrée d'index ou basculer sur une discussion supprimée. Le verrou +// de Conversation protège l'état en mémoire, pas cette séquence-là. +var convOpMu sync.Mutex + // convMeta décrit une discussion SANS ses messages : c'est ce que liste l'UI. type convMeta struct { ID string `json:"id"` @@ -136,6 +145,8 @@ func ConvList() ([]convMeta, string) { return convIndex(), convEnsureActive() } // la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients // rejouent le nouveau fil. func convSwitch(id string) error { + convOpMu.Lock() + defer convOpMu.Unlock() if id == "" { return fmt.Errorf("identifiant manquant") } @@ -172,11 +183,15 @@ func convCreate() string { // convNew met de côté la discussion courante puis en ouvre une neuve. func convNew() string { + convOpMu.Lock() + defer convOpMu.Unlock() conv.persist() return convCreate() } func convRename(id, title string) error { + convOpMu.Lock() + defer convOpMu.Unlock() title = strings.TrimSpace(title) if id == "" || title == "" { return fmt.Errorf("identifiant ou titre manquant") @@ -196,6 +211,8 @@ func convRename(id, title string) error { // sur la plus récente restante — ou sur une discussion neuve s'il n'en reste // aucune : il y a TOUJOURS une discussion active. func convDelete(id string) error { + convOpMu.Lock() + defer convOpMu.Unlock() idx := convIndex() next := make([]convMeta, 0, len(idx)) found := false diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index f38a7d4..424ad9a 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1101,11 +1101,18 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps // texte, donc sans ce relais le modèle recevait le chemin du fichier // et rien d'autre — il annonçait alors à l'utilisateur qu'il ne // voyait pas l'image, alors que le projecteur était bien chargé. + // + // ÉPHÉMÈRE : l'image va dans `messages` (le tour en cours) mais PAS + // dans `extra` (l'historique persistant). Un base64 de capture pèse + // des dizaines de milliers de tokens ; persisté, il était renvoyé à + // CHAQUE tour suivant et la conversation dépassait définitivement le + // contexte (vu en production : requêtes de 55 000 tokens pour une + // fenêtre de 32 768, plus aucun tour ne passait). Le modèle regarde + // l'image MAINTENANT et sa description textuelle, elle, reste. if tc.Function.Name == "web_screenshot" { if rel := capturedRelPath(result); rel != "" { if imgMsg, ok := screenshotImageMessage(rel); ok { messages = append(messages, imgMsg) - extra = append(extra, imgMsg) } } } diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index d38d463..06d95e0 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -4184,21 +4184,30 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLink // est définie. On récupère donc l'image par fetch authentifié et on la pose en // blob:. Sans ça, toute instance protégée par une clé n'affichait que des images // cassées. Les URLs externes (http…) ne sont pas touchées. +// Cache des blobs par URL source. renderBody est rappelé à CHAQUE delta du +// streaming et reconstruit le DOM : sans cache, une image déjà affichée était +// re-téléchargée à chaque token arrivé après elle. On garde l'objet URL (pas de +// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke +// casserait les rendus suivants qui réutilisent la même entrée. +const IMG_CACHE = new Map(); function hydrateImages(root){ root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => { if(img.dataset.hydrated) return; img.dataset.hydrated = '1'; const src = img.getAttribute('src'); + img.classList.add('chatimg'); + const cached = IMG_CACHE.get(src); + if(cached){ img.src = cached; return; } try{ const r = await jfetch(src.startsWith('/') ? src : '/' + src); if(!r.ok) throw new Error(r.status); const url = URL.createObjectURL(await r.blob()); + IMG_CACHE.set(src, url); img.src = url; - img.classList.add('chatimg'); - // La révocation attend le chargement : révoquer tout de suite laisserait - // une image vide sur les navigateurs qui décodent en différé. - img.addEventListener('load', () => URL.revokeObjectURL(url), {once:true}); }catch(e){ + // Pas de remplacement définitif pendant le STREAMING : l'URL peut être + // tronquée en cours de frappe (404 transitoire) et le prochain delta + // re-rend le markdown complet. On note l'échec sans le graver. const note = document.createElement('span'); note.className = 'muted'; note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']'; diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js index 6966518..1f46756 100644 --- a/internal/loki/ui/src/js/08-chat-render.js +++ b/internal/loki/ui/src/js/08-chat-render.js @@ -146,21 +146,30 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLink // est définie. On récupère donc l'image par fetch authentifié et on la pose en // blob:. Sans ça, toute instance protégée par une clé n'affichait que des images // cassées. Les URLs externes (http…) ne sont pas touchées. +// Cache des blobs par URL source. renderBody est rappelé à CHAQUE delta du +// streaming et reconstruit le DOM : sans cache, une image déjà affichée était +// re-téléchargée à chaque token arrivé après elle. On garde l'objet URL (pas de +// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke +// casserait les rendus suivants qui réutilisent la même entrée. +const IMG_CACHE = new Map(); function hydrateImages(root){ root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => { if(img.dataset.hydrated) return; img.dataset.hydrated = '1'; const src = img.getAttribute('src'); + img.classList.add('chatimg'); + const cached = IMG_CACHE.get(src); + if(cached){ img.src = cached; return; } try{ const r = await jfetch(src.startsWith('/') ? src : '/' + src); if(!r.ok) throw new Error(r.status); const url = URL.createObjectURL(await r.blob()); + IMG_CACHE.set(src, url); img.src = url; - img.classList.add('chatimg'); - // La révocation attend le chargement : révoquer tout de suite laisserait - // une image vide sur les navigateurs qui décodent en différé. - img.addEventListener('load', () => URL.revokeObjectURL(url), {once:true}); }catch(e){ + // Pas de remplacement définitif pendant le STREAMING : l'URL peut être + // tronquée en cours de frappe (404 transitoire) et le prochain delta + // re-rend le markdown complet. On note l'échec sans le graver. const note = document.createElement('span'); note.className = 'muted'; note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';