mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Chat : les images ne sont plus réécrites en base64 à chaque tour
Repris d'AJEAN 0.15.7 (chat_images.go et son test), adapté. Une image jointe (ou une capture de web_screenshot) vivait en base64 dans les messages de la conversation, et persist() réécrit la conversation ENTIÈRE à chaque fin de tour : une photo de téléphone, c'étaient des Mo remis sur disque à chaque réponse. Elle est désormais rangée une fois sous $LOKI_HOME/chatimg/<empreinte>.<ext> (hors du dossier de travail, que l'IA peut vider) ; l'historique n'en garde que « loki-img:<nom> ». Juste avant l'envoi, expandImageRefs remet les octets exacts (cache mémoire borné à 64 Mo) : pour le modèle, rien ne change. DEUX ÉCARTS AVEC L'AMONT - Copie à l'écriture : l'amont remplace l'URL en place. Chez nous, persist peut tourner pendant qu'un runChat sérialise ces mêmes maps (compactage en vol, bascule de projet) — « concurrent map read and map write », fatal. Les parties modifiées sont donc recopiées. - Le rechargement reste éphémère : stripImageParts retire toujours les images d'une conversation rouverte (garde-fou contre un modèle sans vision qui lirait le base64 comme du texte). L'amont, lui, les garde ; on pourra le suivre plus tard si l'on veut. Pas de migration des archives : une conversation rouverte est allégée par stripImageParts, comme avant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1db987844e
commit
f7a4ee71e7
4 files changed
+283
-1
No files matched your search
@@ -195,6 +195,9 @@ func reloadEncryptedStores() {
|
||||
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
|
||||
func (c *Conversation) persist() {
|
||||
c.mu.Lock()
|
||||
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
|
||||
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
|
||||
refImagesInMessages(c.Messages)
|
||||
b, err := json.Marshal(c)
|
||||
title := convSummary(c.Messages)
|
||||
turns := 0
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// chat_images.go — images d'une conversation stockées PAR RÉFÉRENCE.
|
||||
//
|
||||
// Une image vue par le modèle (pièce jointe, see_image, capture du navigateur) est
|
||||
// une partie `image_url` en base64 dans un message. Gardée telle quelle dans
|
||||
// l'historique, elle y pesait des Mo (une conversation de 14 messages : 36 Mo), et
|
||||
// l'historique entier est réécrit à chaque fin de tour et relu à chaque ouverture.
|
||||
//
|
||||
// On la range donc UNE fois sur disque, sous LOKI_HOME/chatimg/<empreinte>.<ext>
|
||||
// (hors du dossier de travail, que l'IA peut vider), et l'historique n'en garde
|
||||
// que l'adresse « loki-img:<nom> ». Juste avant l'envoi au modèle,
|
||||
// expandImageRefs remet les octets exacts (cache mémoire) : pour le modèle, rien
|
||||
// ne change. Mêmes octets, même empreinte → aucune copie en double.
|
||||
|
||||
const imgRefScheme = "loki-img:"
|
||||
|
||||
func chatImgDir() string { return filepath.Join(LokiHome(), "chatimg") }
|
||||
|
||||
var imgExtByMime = map[string]string{"image/jpeg": ".jpg", "image/png": ".png", "image/gif": ".gif", "image/webp": ".webp"}
|
||||
|
||||
// storeChatImage range les octets d'une image et renvoie sa référence.
|
||||
func storeChatImage(b []byte, mime string) (string, error) {
|
||||
ext := imgExtByMime[mime]
|
||||
if ext == "" {
|
||||
ext = ".img"
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
name := hex.EncodeToString(sum[:16]) + ext
|
||||
dir := chatImgDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return imgRefScheme + name, nil
|
||||
}
|
||||
|
||||
// Cache des data-URL reconstituées : un tour d'agent renvoie tout l'historique à
|
||||
// CHAQUE étape, on ne relit pas le disque à chaque fois. Borné en taille.
|
||||
var (
|
||||
imgCacheMu sync.Mutex
|
||||
imgCache = map[string]string{}
|
||||
imgCacheOrder []string
|
||||
imgCacheBytes int
|
||||
)
|
||||
|
||||
const imgCacheMax = 64 << 20
|
||||
|
||||
func chatImageDataURL(ref string) (string, bool) {
|
||||
name := strings.TrimPrefix(ref, imgRefScheme)
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return "", false
|
||||
}
|
||||
imgCacheMu.Lock()
|
||||
if u, ok := imgCache[name]; ok {
|
||||
imgCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
imgCacheMu.Unlock()
|
||||
b, err := os.ReadFile(filepath.Join(chatImgDir(), name))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
mime := "image/jpeg"
|
||||
for m, e := range imgExtByMime {
|
||||
if strings.HasSuffix(name, e) {
|
||||
mime = m
|
||||
}
|
||||
}
|
||||
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
|
||||
imgCacheMu.Lock()
|
||||
if _, ok := imgCache[name]; !ok {
|
||||
imgCache[name] = u
|
||||
imgCacheOrder = append(imgCacheOrder, name)
|
||||
imgCacheBytes += len(u)
|
||||
for imgCacheBytes > imgCacheMax && len(imgCacheOrder) > 1 {
|
||||
old := imgCacheOrder[0]
|
||||
imgCacheOrder = imgCacheOrder[1:]
|
||||
imgCacheBytes -= len(imgCache[old])
|
||||
delete(imgCache, old)
|
||||
}
|
||||
}
|
||||
imgCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
|
||||
// contentParts renvoie les parties d'un contenu multimodal, sous l'une ou l'autre
|
||||
// forme (fraîche []map, ou relue du JSON []any).
|
||||
func contentParts(content any) []map[string]any {
|
||||
switch v := content.(type) {
|
||||
case []map[string]any:
|
||||
return v
|
||||
case []any:
|
||||
out := make([]map[string]any, 0, len(v))
|
||||
for _, p := range v {
|
||||
if m, ok := p.(map[string]any); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func partImageURL(p map[string]any) (map[string]any, string) {
|
||||
if p["type"] != "image_url" {
|
||||
return nil, ""
|
||||
}
|
||||
iu, _ := p["image_url"].(map[string]any)
|
||||
if iu == nil {
|
||||
return nil, ""
|
||||
}
|
||||
u, _ := iu["url"].(string)
|
||||
return iu, u
|
||||
}
|
||||
|
||||
// refImagesInMessages remplace EN PLACE les images en base64 des messages par des
|
||||
// références (voir plus haut). Renvoie true si quelque chose a changé. Une image
|
||||
// qui ne peut pas être rangée (disque plein…) reste en base64 : rien ne se perd.
|
||||
//
|
||||
// ⚠️ Écart avec l'amont : les parties modifiées sont RECOPIÉES, jamais mutées.
|
||||
// persist peut tourner pendant qu'un runChat (compactage en vol, bascule de
|
||||
// projet) sérialise encore ces mêmes maps : une écriture en place y déclenchait
|
||||
// un « concurrent map read and map write », fatal pour le process.
|
||||
func refImagesInMessages(msgs []Message) bool {
|
||||
changed := false
|
||||
for mi, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
var np []map[string]any
|
||||
for pi, p := range parts {
|
||||
_, u := partImageURL(p)
|
||||
if !strings.HasPrefix(u, "data:image/") {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(u, ";base64,")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(u[i+8:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ref, err := storeChatImage(b, u[5:i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if np == nil {
|
||||
np = append([]map[string]any(nil), parts...)
|
||||
}
|
||||
np[pi] = map[string]any{"type": "image_url", "image_url": map[string]any{"url": ref}}
|
||||
}
|
||||
if np != nil {
|
||||
msgs[mi].Content = np
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hasInlineImages : au moins une image encore en base64 dans ces messages ?
|
||||
func hasInlineImages(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
for _, p := range contentParts(m.Content) {
|
||||
if _, u := partImageURL(p); strings.HasPrefix(u, "data:image/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expandImageRefs renvoie une COPIE des messages prête pour le modèle : chaque
|
||||
// référence d'image redevient sa data-URL. Les messages sans référence sont
|
||||
// partagés tels quels. Une image introuvable (dossier supprimé à la main) devient
|
||||
// une mention textuelle plutôt qu'une requête qui échouerait.
|
||||
func expandImageRefs(msgs []Message) []Message {
|
||||
out := msgs
|
||||
copied := false
|
||||
for i, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
has := false
|
||||
for _, p := range parts {
|
||||
if _, u := partImageURL(p); strings.HasPrefix(u, imgRefScheme) {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if !copied {
|
||||
out = append([]Message(nil), msgs...)
|
||||
copied = true
|
||||
}
|
||||
np := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
_, u := partImageURL(p)
|
||||
if !strings.HasPrefix(u, imgRefScheme) {
|
||||
np = append(np, p)
|
||||
continue
|
||||
}
|
||||
if d, ok := chatImageDataURL(u); ok {
|
||||
np = append(np, map[string]any{"type": "image_url", "image_url": map[string]any{"url": d}})
|
||||
} else {
|
||||
np = append(np, map[string]any{"type": "text", "text": "[image indisponible : fichier supprimé]"})
|
||||
}
|
||||
}
|
||||
m.Content = np
|
||||
out[i] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Une image en base64 part sur disque et revient identique à l'envoi au modèle ;
|
||||
// l'historique ne garde qu'une référence de quelques octets.
|
||||
func TestImageRefsRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
raw := []byte(strings.Repeat("\x89PNG-fake-image-bytes", 5000))
|
||||
url := "data:image/png;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": url}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
// Relu depuis le JSON (forme []any), comme après un redémarrage.
|
||||
var reread []Message
|
||||
b, _ := json.Marshal(msgs)
|
||||
_ = json.Unmarshal(b, &reread)
|
||||
|
||||
for _, set := range [][]Message{msgs, reread} {
|
||||
if !hasInlineImages(set) || !refImagesInMessages(set) || hasInlineImages(set) {
|
||||
t.Fatal("l'image aurait dû être rangée par référence")
|
||||
}
|
||||
js, _ := json.Marshal(set)
|
||||
if len(js) > 500 || !strings.Contains(string(js), imgRefScheme) {
|
||||
t.Fatalf("historique encore lourd : %d octets", len(js))
|
||||
}
|
||||
out := expandImageRefs(set)
|
||||
_, got := partImageURL(contentParts(out[0].Content)[1])
|
||||
if got != url {
|
||||
t.Fatal("l'image renvoyée au modèle diffère de l'originale")
|
||||
}
|
||||
// L'historique lui-même n'est pas modifié par l'expansion.
|
||||
if _, u := partImageURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, imgRefScheme) {
|
||||
t.Fatal("expandImageRefs ne doit pas toucher l'historique")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -960,7 +960,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
"model": ep.Model,
|
||||
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
|
||||
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
|
||||
"messages": normalizeSystemMessages(messages),
|
||||
// Les images de l'historique y sont rangées par référence
|
||||
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
|
||||
"messages": expandImageRefs(normalizeSystemMessages(messages)),
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
|
||||
|
||||
Reference in new issue
Block a user