mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Passe de qualité : contexte explosé par les captures, sonde vision, courses
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.
This commit is contained in:
7 files changed
+172
-14
No files matched your search
@@ -80,6 +80,9 @@ func LoadConversation() {
|
|||||||
conv.mu.Lock()
|
conv.mu.Lock()
|
||||||
defer conv.mu.Unlock()
|
defer conv.mu.Unlock()
|
||||||
_ = json.Unmarshal(b, conv)
|
_ = 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.
|
// Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
|
||||||
conv.Generating = false
|
conv.Generating = false
|
||||||
conv.cancel = nil
|
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
|
c.Messages, c.Log, c.Seq, c.CtxUsed = nil, nil, 0, 0
|
||||||
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.Generating = false
|
c.Generating = false
|
||||||
c.cancel = nil
|
c.cancel = nil
|
||||||
|
|||||||
@@ -17,13 +17,16 @@ package loki
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -87,15 +90,91 @@ func webScreenshotTool() Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// screenshotVisionNote : ce que le modèle doit savoir de SA propre perception.
|
// screenshotVisionNote : ce que le modèle doit savoir de SA propre perception.
|
||||||
// Avec un projecteur configuré, la capture lui est réellement transmise (voir
|
// Alignée sur la capacité RÉELLE du moteur, pas seulement sur la clé MMPROJ —
|
||||||
// screenshotImageMessage) ; sans projecteur, il photographie sans regarder.
|
// sinon on promet une image qui n'arrivera pas et le modèle se contredit.
|
||||||
func screenshotVisionNote() string {
|
func screenshotVisionNote() string {
|
||||||
if visionEnabled() {
|
if visionEnabled() && engineSeesImages() {
|
||||||
return "L'image t'est ensuite montrée : tu peux la décrire."
|
return "L'image t'est ensuite montrée : tu peux la décrire."
|
||||||
}
|
}
|
||||||
return "Tu ne vois pas l'image."
|
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
|
// 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
|
// 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
|
// 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
|
// llama-server rejette un contenu image sans --mmproj, donc mieux vaut ne rien
|
||||||
// envoyer que de faire échouer le tour.
|
// envoyer que de faire échouer le tour.
|
||||||
func screenshotImageMessage(relPath string) (Message, bool) {
|
func screenshotImageMessage(relPath string) (Message, bool) {
|
||||||
if !visionEnabled() {
|
if !visionEnabled() || !engineSeesImages() {
|
||||||
return Message{}, false
|
return Message{}, false
|
||||||
}
|
}
|
||||||
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath))
|
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath))
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ func TestConvDeleteSupprimeLesCaptures(t *testing.T) {
|
|||||||
func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
|
func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
|
||||||
t.Setenv("LOKI_HOME", t.TempDir())
|
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") {
|
if got := screenshotVisionNote(); !strings.Contains(got, "ne vois pas") {
|
||||||
t.Fatalf("sans projecteur, la description doit annoncer l'absence de vision : %q", got)
|
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é")
|
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 {
|
if err := SetConfigKey("MMPROJ", "mmproj-test.gguf"); err != nil {
|
||||||
t.Fatal(err)
|
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") {
|
if got := screenshotVisionNote(); strings.Contains(got, "ne vois pas") {
|
||||||
t.Fatalf("avec projecteur, la description ne doit plus nier la vision : %q", got)
|
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
|
// 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.
|
// de ce texte change, le relais vers le modèle casse en silence.
|
||||||
func TestCapturedRelPath(t *testing.T) {
|
func TestCapturedRelPath(t *testing.T) {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +34,14 @@ const (
|
|||||||
ckLegacy = "conversation" // fil unique d'avant le multi-discussions
|
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.
|
// convMeta décrit une discussion SANS ses messages : c'est ce que liste l'UI.
|
||||||
type convMeta struct {
|
type convMeta struct {
|
||||||
ID string `json:"id"`
|
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
|
// la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients
|
||||||
// rejouent le nouveau fil.
|
// rejouent le nouveau fil.
|
||||||
func convSwitch(id string) error {
|
func convSwitch(id string) error {
|
||||||
|
convOpMu.Lock()
|
||||||
|
defer convOpMu.Unlock()
|
||||||
if id == "" {
|
if id == "" {
|
||||||
return fmt.Errorf("identifiant manquant")
|
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.
|
// convNew met de côté la discussion courante puis en ouvre une neuve.
|
||||||
func convNew() string {
|
func convNew() string {
|
||||||
|
convOpMu.Lock()
|
||||||
|
defer convOpMu.Unlock()
|
||||||
conv.persist()
|
conv.persist()
|
||||||
return convCreate()
|
return convCreate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func convRename(id, title string) error {
|
func convRename(id, title string) error {
|
||||||
|
convOpMu.Lock()
|
||||||
|
defer convOpMu.Unlock()
|
||||||
title = strings.TrimSpace(title)
|
title = strings.TrimSpace(title)
|
||||||
if id == "" || title == "" {
|
if id == "" || title == "" {
|
||||||
return fmt.Errorf("identifiant ou titre manquant")
|
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
|
// sur la plus récente restante — ou sur une discussion neuve s'il n'en reste
|
||||||
// aucune : il y a TOUJOURS une discussion active.
|
// aucune : il y a TOUJOURS une discussion active.
|
||||||
func convDelete(id string) error {
|
func convDelete(id string) error {
|
||||||
|
convOpMu.Lock()
|
||||||
|
defer convOpMu.Unlock()
|
||||||
idx := convIndex()
|
idx := convIndex()
|
||||||
next := make([]convMeta, 0, len(idx))
|
next := make([]convMeta, 0, len(idx))
|
||||||
found := false
|
found := false
|
||||||
|
|||||||
@@ -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
|
// 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
|
// et rien d'autre — il annonçait alors à l'utilisateur qu'il ne
|
||||||
// voyait pas l'image, alors que le projecteur était bien chargé.
|
// 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 tc.Function.Name == "web_screenshot" {
|
||||||
if rel := capturedRelPath(result); rel != "" {
|
if rel := capturedRelPath(result); rel != "" {
|
||||||
if imgMsg, ok := screenshotImageMessage(rel); ok {
|
if imgMsg, ok := screenshotImageMessage(rel); ok {
|
||||||
messages = append(messages, imgMsg)
|
messages = append(messages, imgMsg)
|
||||||
extra = append(extra, imgMsg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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){
|
function hydrateImages(root){
|
||||||
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||||||
if(img.dataset.hydrated) return;
|
if(img.dataset.hydrated) return;
|
||||||
img.dataset.hydrated = '1';
|
img.dataset.hydrated = '1';
|
||||||
const src = img.getAttribute('src');
|
const src = img.getAttribute('src');
|
||||||
|
img.classList.add('chatimg');
|
||||||
|
const cached = IMG_CACHE.get(src);
|
||||||
|
if(cached){ img.src = cached; return; }
|
||||||
try{
|
try{
|
||||||
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
||||||
if(!r.ok) throw new Error(r.status);
|
if(!r.ok) throw new Error(r.status);
|
||||||
const url = URL.createObjectURL(await r.blob());
|
const url = URL.createObjectURL(await r.blob());
|
||||||
|
IMG_CACHE.set(src, url);
|
||||||
img.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){
|
}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');
|
const note = document.createElement('span');
|
||||||
note.className = 'muted';
|
note.className = 'muted';
|
||||||
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';
|
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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){
|
function hydrateImages(root){
|
||||||
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||||||
if(img.dataset.hydrated) return;
|
if(img.dataset.hydrated) return;
|
||||||
img.dataset.hydrated = '1';
|
img.dataset.hydrated = '1';
|
||||||
const src = img.getAttribute('src');
|
const src = img.getAttribute('src');
|
||||||
|
img.classList.add('chatimg');
|
||||||
|
const cached = IMG_CACHE.get(src);
|
||||||
|
if(cached){ img.src = cached; return; }
|
||||||
try{
|
try{
|
||||||
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
||||||
if(!r.ok) throw new Error(r.status);
|
if(!r.ok) throw new Error(r.status);
|
||||||
const url = URL.createObjectURL(await r.blob());
|
const url = URL.createObjectURL(await r.blob());
|
||||||
|
IMG_CACHE.set(src, url);
|
||||||
img.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){
|
}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');
|
const note = document.createElement('span');
|
||||||
note.className = 'muted';
|
note.className = 'muted';
|
||||||
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';
|
note.textContent = '[image indisponible : ' + src.replace(/^.*path=/, '') + ']';
|
||||||
|
|||||||
Reference in new issue
Block a user