From 776de75dd0c40d03b99d1af9e3eba50294fcc11e Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 16 Oct 2026 15:01:32 +0200 Subject: [PATCH] =?UTF-8?q?Vision=20:=20le=20mod=C3=A8le=20peut=20regarder?= =?UTF-8?q?=20des=20vid=C3=A9os=20(outil=20see=5Fvideo=20et=20vid=C3=A9os?= =?UTF-8?q?=20jointes),=20repris=20d'AJEAN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reprise des commits 91f7f977 (PR #100 de Trombo38) et 1dfac0cf d'AJEAN. Une vidéo jointe (mp4, webm, mov, mkv, avi) part au modèle en partie input_video, et l'outil see_video charge une vidéo du disque, mais seulement si le moteur actif sait la lire : preset llama.cpp local avec projecteur (MMPROJ) et binaire dont l'aide annonce --video-fps (videoInputSupported, aide relue quand le binaire change, chemin des bibliothèques passé à la seule commande pour ne pas figer binHelp). Strata et les API externes ignoraient la partie en silence : la vidéo reste alors un fichier, annoncé avec la marche à suivre (ffmpeg puis see_image), et see_video n'est pas proposé. llama.cpp a --video-fps depuis la PR #24318 (fin août 2026) ; l'image server-cuda officielle, base du conteneur, installe ffmpeg. La consigne de repli dit quoi faire si ffmpeg manque (installation hors conteneur). Adapté à Loki : - la vidéo de see_video est toujours éphémère (jamais dans l'historique, KEEP_TURN_IMAGES compris) ; même ordre de refus que see_image, sonde /props en dernier, refus sur un poste distant ; - une vidéo jointe est rangée par référence (loki-vid:, LOKI_HOME/chatvid, chiffrée comme le fil), parties recopiées et non mutées comme les images ; retirée d'un fil rouvert (stripImageParts, videoLostMarker) et effacée par le ménage des médias ; - marqueur de perte à la compaction, élision dans l'export JSON, relais par l'API interne des outils (vision_video). Co-Authored-By: Claude Opus 5.5 --- NOTICE.md | 9 + internal/loki/chat_compact.go | 7 +- internal/loki/chat_export.go | 17 +- internal/loki/chat_images.go | 6 +- internal/loki/chat_persist.go | 1 + internal/loki/chat_screenshot.go | 22 +- internal/loki/chat_video.go | 333 +++++++++++++++++++++++++++++ internal/loki/chat_video_test.go | 332 ++++++++++++++++++++++++++++ internal/loki/chat_video_tool.go | 91 ++++++++ internal/loki/llm_client.go | 41 +++- internal/loki/web_internal_tool.go | 4 + internal/loki/web_upload.go | 61 ++++-- 12 files changed, 886 insertions(+), 38 deletions(-) create mode 100644 internal/loki/chat_video.go create mode 100644 internal/loki/chat_video_test.go create mode 100644 internal/loki/chat_video_tool.go diff --git a/NOTICE.md b/NOTICE.md index 601ad1d..9421f22 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -52,6 +52,15 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`, **pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki expose à la place un export/import de paquet chiffré en fichier local. +La **compréhension des vidéos** est reprise d'AJEAN (PR #100 de Trombo38 et +son correctif « vidéo envoyée seulement si le moteur sait la lire ») : +l'outil `see_video` et les vidéos jointes (`chat_video.go`, +`chat_video_tool.go`), lues par llama.cpp avec `--video-fps` et ffmpeg ; +ailleurs, la vidéo reste un fichier, avec la marche à suivre (ffmpeg puis +`see_image`). Adaptée à Loki : vidéo de `see_video` toujours éphémère, vidéos +jointes chiffrées comme le fil et retirées d'un fil rouvert, outil tenu aussi +par la façade Rust (`imagetools.rs`). + ## Moteur Strata Le second moteur de Loki (`internal/loki/backend_strata.go`) est diff --git a/internal/loki/chat_compact.go b/internal/loki/chat_compact.go index 29a8893..47de9e8 100644 --- a/internal/loki/chat_compact.go +++ b/internal/loki/chat_compact.go @@ -130,7 +130,8 @@ const imageLostMarker = " [image — not kept in the conversation past this poin // (userMessageContent : parties texte + image quand la vision est active) porte // un tableau de parties ; on en recolle les segments `text` pour que l'estimation // de contexte et le transcript de compaction ne partent pas d'un texte vide, et -// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans +// on marque la présence d'une partie `image_url` (voir imageLostMarker) ou +// `input_video` (videoLostMarker, chat_video.go) — sans // quoi elle reste invisible à toute la chaîne de compaction. func msgText(m Message) string { switch v := m.Content.(type) { @@ -146,6 +147,8 @@ func msgText(m Message) string { } case "image_url": b.WriteString(imageLostMarker) + case "input_video": + b.WriteString(videoLostMarker) } } return b.String() @@ -163,6 +166,8 @@ func msgText(m Message) string { } case "image_url": b.WriteString(imageLostMarker) + case "input_video": + b.WriteString(videoLostMarker) } } return b.String() diff --git a/internal/loki/chat_export.go b/internal/loki/chat_export.go index 36456af..ba81215 100644 --- a/internal/loki/chat_export.go +++ b/internal/loki/chat_export.go @@ -112,7 +112,8 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) { } // stripInlineImages remplace les images en base64 (data URI) portées par les -// messages multimodaux (vision : pièces jointes images + outil see_image) par un +// messages multimodaux (vision : pièces jointes images et vidéos + outil +// see_image) par un // court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait // plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown, // lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal @@ -145,6 +146,20 @@ func stripInlineImagesValue(v any) any { } cp["image_url"] = c2 } + // Partie vidéo (input_video, chat_video.go) : même élision, sur les deux + // champs que lit llama-server. + if iv, ok := cp["input_video"].(map[string]any); ok { + c2 := make(map[string]any, len(iv)) + for k, val := range iv { + c2[k] = val + } + for _, key := range []string{"url", "data"} { + if s, ok := c2[key].(string); ok && strings.HasPrefix(s, "data:") { + c2[key] = elideDataURI(s) + } + } + cp["input_video"] = c2 + } return cp } switch parts := v.(type) { diff --git a/internal/loki/chat_images.go b/internal/loki/chat_images.go index 2c30f28..422b8ca 100644 --- a/internal/loki/chat_images.go +++ b/internal/loki/chat_images.go @@ -123,7 +123,11 @@ const chatImgKeep = 24 * time.Hour // rechargé : plus rien ne référence alors les fichiers. Le délai couvre un // fil resté ouvert longtemps sans bascule. func pruneChatImages() { - dir := chatImgDir() + pruneMediaDir(chatImgDir()) + pruneMediaDir(chatVidDir()) // vidéos jointes (chat_video.go), même délai +} + +func pruneMediaDir(dir string) { entries, err := os.ReadDir(dir) if err != nil { return diff --git a/internal/loki/chat_persist.go b/internal/loki/chat_persist.go index 9ea3534..1581f8a 100644 --- a/internal/loki/chat_persist.go +++ b/internal/loki/chat_persist.go @@ -536,6 +536,7 @@ func (c *Conversation) snapshot() *convSnap { // 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) + refVideosInMessages(c.Messages) // vidéos jointes, même rangement (chat_video.go) // En plein tour, le journal porte encore un événement par token et chaque // frappe d'outil : on écrit la forme que produira la fin de tour // (compactLog), sans toucher au journal en mémoire que suivent les abonnés. diff --git a/internal/loki/chat_screenshot.go b/internal/loki/chat_screenshot.go index bff9622..e123868 100644 --- a/internal/loki/chat_screenshot.go +++ b/internal/loki/chat_screenshot.go @@ -163,7 +163,8 @@ func engineSeesImages() bool { // ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le // modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire // au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne. -// Vaut pour toutes les provenances : capture, pièce jointe, see_image. +// Vaut pour toutes les provenances : capture, pièce jointe, see_image — et pour +// les vidéos jointes (input_video), marquées par videoLostMarker. func stripImageParts(msgs []Message) []Message { for i, m := range msgs { parts, ok := m.Content.([]any) @@ -171,22 +172,33 @@ func stripImageParts(msgs []Message) []Message { continue } var texts []string - dropped := false + dropped, droppedVid := false, false for _, p := range parts { pm, ok := p.(map[string]any) if !ok { continue } - if pm["type"] == "image_url" { + switch pm["type"] { + case "image_url": dropped = true continue + case "input_video": // vidéo jointe (chat_video.go) : même sort + droppedVid = true + continue } if t, ok := pm["text"].(string); ok { texts = append(texts, t) } } - if dropped { - msgs[i].Content = strings.Join(texts, "\n") + imageLostMarker + if dropped || droppedVid { + text := strings.Join(texts, "\n") + if dropped { + text += imageLostMarker + } + if droppedVid { + text += videoLostMarker + } + msgs[i].Content = text } } return msgs diff --git a/internal/loki/chat_video.go b/internal/loki/chat_video.go new file mode 100644 index 0000000..74d9213 --- /dev/null +++ b/internal/loki/chat_video.go @@ -0,0 +1,333 @@ +package loki + +// chat_video.go — vidéos vues par le modèle (repris d'AJEAN, PR #100 et son +// correctif « vidéo envoyée seulement si le moteur sait la lire »). +// +// Trois morceaux : +// - videoInputSupported : le moteur actif sait-il lire une partie +// `input_video` ? Seul un llama.cpp local récent le fait (option +// --video-fps, décodage par ffmpeg). Une API externe ou Strata ignorent la +// partie SANS erreur : le modèle ne recevait que le texte et cherchait à voir +// la vidéo avec see_image. Ailleurs, la vidéo reste un simple fichier, avec +// la marche à suivre (videoFallbackNote) ; +// - une vidéo JOINTE au message est rangée PAR RÉFÉRENCE, comme les images +// (chat_images.go) : une fois sur disque sous LOKI_HOME/chatvid/, chiffrée +// comme le fil, et l'historique n'en garde que « loki-vid: ». +// expandVideoRefs remet les octets juste avant l'envoi au moteur ; +// - la vidéo chargée par l'outil see_video (chat_video_tool.go) est, elle, +// TOUJOURS éphémère : elle part dans le tour en cours et jamais dans +// l'historique, KEEP_TURN_IMAGES compris (des milliers de jetons visuels). +// +// Écart avec l'amont : comme les images, un fil rechargé perd ses vidéos +// (stripImageParts, avec videoLostMarker) et pruneChatImages vide aussi +// chatvid/ — Loki ne rejoue jamais un média d'un fil rouvert. + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +const vidRefScheme = "loki-vid:" + +func chatVidDir() string { return filepath.Join(LokiHome(), "chatvid") } + +// maxVideoBytes borne la taille d'une vidéo montrée au modèle : le moteur en +// tire des images à --video-fps (4 par seconde par défaut), donc une vidéo +// longue = des dizaines de milliers de jetons visuels. Au-delà, le contexte +// sature sans que le modèle puisse raisonner dessus. +const maxVideoBytes = 200 << 20 // 200 Mio + +// videoMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme vidéo (partie +// input_video). Le moteur décode lui-même avec ffmpeg ; un format hors de cette +// liste reste un simple fichier du dossier de travail. +var videoMimes = map[string]string{ + ".mp4": "video/mp4", + ".webm": "video/webm", + ".mov": "video/quicktime", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", +} + +// videoMime renvoie le type MIME vidéo d'un nom de fichier, ou "" si ce n'est +// pas une vidéo qu'on sait montrer au modèle. +func videoMime(name string) string { + return videoMimes[strings.ToLower(filepath.Ext(name))] +} + +// videoFallbackNote : consigne quand le moteur ne lit pas la vidéo. Le fichier +// reste utilisable : quelques images extraites avec ffmpeg, puis see_image. +// ffmpeg est dans l'image Docker de Loki (l'image serveur officielle de +// llama.cpp l'installe pour son décodage vidéo) ; une installation hors +// conteneur peut ne pas l'avoir, d'où la dernière phrase. +const videoFallbackNote = "Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.\n" + +// videoLostMarker : équivalent d'imageLostMarker pour une partie input_video +// (résumé de compaction, fil rechargé). +const videoLostMarker = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]" + +// videoPart construit la partie multimodale {type:input_video, input_video:{url}} +// — le format que llama-server lit une fois --mmproj chargé. +func videoPart(b []byte, mime string) map[string]any { + return map[string]any{ + "type": "input_video", + "input_video": map[string]any{ + "url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b), + }, + } +} + +// videoInputSupported : le moteur actif sait-il lire une partie input_video ? +// Preset local avec projecteur (MMPROJ) ET binaire dont l'aide annonce +// --video-fps. Variable : remplaçable dans les tests. +var videoInputSupported = func() bool { + cfg := ReadConfig() + if isExternalConfig(cfg) || isStrataConfig(cfg) || strings.TrimSpace(cfg["MMPROJ"]) == "" { + return false + } + bin := strings.TrimSpace(cfg["BIN"]) + if bin == "" { + return false + } + if !filepath.IsAbs(bin) { + bin = filepath.Join(LokiHome(), bin) + } + return binHelpHasVideo(prebuiltResolveBin(bin)) +} + +var videoCapCache sync.Map // "binaire\x00mtime" → bool + +// binHelpHasVideo lit l'aide du binaire, une fois par binaire ET par date de +// modification (un moteur mis à jour en place est relu). Volontairement à part +// de binHelp : celle-ci garde aussi une aide VIDE pour toujours, et l'appeler +// avant le lancement du moteur (chemin des bibliothèques pas encore posé) +// figerait « aucune capacité » pour le vrai lancement. Ici, le chemin des +// bibliothèques est passé à la seule commande (libraryPathEnv). +func binHelpHasVideo(bin string) bool { + st, err := os.Stat(bin) + if err != nil || st.IsDir() { + return false + } + key := bin + "\x00" + st.ModTime().String() + if v, ok := videoCapCache.Load(key); ok { + return v.(bool) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, "--help") + cmd.Env = libraryPathEnv(filepath.Dir(bin)) + out, _ := cmd.CombinedOutput() + ok := strings.Contains(string(out), "--video-fps") + videoCapCache.Store(key, ok) + return ok +} + +// --- Vidéos jointes, par référence ------------------------------------------- + +var vidExtByMime = map[string]string{ + "video/mp4": ".mp4", + "video/webm": ".webm", + "video/quicktime": ".mov", + "video/x-matroska": ".mkv", + "video/x-msvideo": ".avi", +} + +// storeChatVideo range les octets d'une vidéo et renvoie sa référence. Chiffrée +// comme le fil quand la mémoire l'est ; verrouillée : refus, la vidéo reste en +// base64 dans le message (rien ne se perd). +func storeChatVideo(b []byte, mime string) (string, error) { + ext := vidExtByMime[mime] + if ext == "" { + ext = ".vid" + } + sum := sha256.Sum256(b) + name := hex.EncodeToString(sum[:16]) + ext + dir := chatVidDir() + if err := os.MkdirAll(dir, 0o700); err != nil { + return "", err + } + p := filepath.Join(dir, name) + if _, err := os.Stat(p); err != nil { + enc, err := encodeMemContent(b) + if err != nil { + return "", err + } + tmp := p + ".tmp" + if err := os.WriteFile(tmp, enc, 0o600); err != nil { + return "", err + } + if err := os.Rename(tmp, p); err != nil { + return "", err + } + } + return vidRefScheme + name, nil +} + +// Cache des data-URL reconstituées (même raison que imgCache : un tour d'agent +// renvoie tout l'historique à chaque étape). Borné en taille. +var ( + vidCacheMu sync.Mutex + vidCache = map[string]string{} + vidCacheOrder []string + vidCacheBytes int +) + +const vidCacheMax = 64 << 20 + +func chatVideoDataURL(ref string) (string, bool) { + name := strings.TrimPrefix(ref, vidRefScheme) + if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") { + return "", false + } + vidCacheMu.Lock() + if u, ok := vidCache[name]; ok { + vidCacheMu.Unlock() + return u, true + } + vidCacheMu.Unlock() + raw, err := os.ReadFile(filepath.Join(chatVidDir(), name)) + if err != nil { + return "", false + } + b, err := decodeMemContent(raw) + if err != nil { + return "", false + } + mime := "video/mp4" + for m, e := range vidExtByMime { + if strings.HasSuffix(name, e) { + mime = m + } + } + u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b) + vidCacheMu.Lock() + if _, ok := vidCache[name]; !ok { + vidCache[name] = u + vidCacheOrder = append(vidCacheOrder, name) + vidCacheBytes += len(u) + for vidCacheBytes > vidCacheMax && len(vidCacheOrder) > 1 { + old := vidCacheOrder[0] + vidCacheOrder = vidCacheOrder[1:] + vidCacheBytes -= len(vidCache[old]) + delete(vidCache, old) + } + } + vidCacheMu.Unlock() + return u, true +} + +// partVideoURL : l'adresse d'une partie input_video (url, sinon data — les deux +// formes que lit llama-server), "" pour toute autre partie. +func partVideoURL(p map[string]any) string { + if p["type"] != "input_video" { + return "" + } + iv, _ := p["input_video"].(map[string]any) + if iv == nil { + return "" + } + u, _ := iv["url"].(string) + if u == "" { + u, _ = iv["data"].(string) + } + return u +} + +// refVideosInMessages remplace les vidéos en base64 des messages par des +// références. Comme refImagesInMessages, les parties modifiées sont RECOPIÉES, +// jamais mutées (persist peut tourner pendant qu'un runChat sérialise ces mêmes +// maps). Renvoie true si quelque chose a changé. +func refVideosInMessages(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 := partVideoURL(p) + if !strings.HasPrefix(u, "data:video/") { + continue + } + i := strings.Index(u, ";base64,") + if i < 0 { + continue + } + b, err := base64.StdEncoding.DecodeString(u[i+8:]) + if err != nil { + continue + } + ref, err := storeChatVideo(b, u[5:i]) + if err != nil { + continue + } + if np == nil { + np = append([]map[string]any(nil), parts...) + } + np[pi] = map[string]any{"type": "input_video", "input_video": map[string]any{"url": ref}} + } + if np != nil { + msgs[mi].Content = np + changed = true + } + } + return changed +} + +// hasInlineVideos : au moins une vidéo encore en base64 dans ces messages ? +func hasInlineVideos(msgs []Message) bool { + for _, m := range msgs { + for _, p := range contentParts(m.Content) { + if strings.HasPrefix(partVideoURL(p), "data:video/") { + return true + } + } + } + return false +} + +// expandVideoRefs renvoie une COPIE des messages prête pour le modèle : chaque +// référence de vidéo redevient sa data-URL. Les messages sans référence sont +// partagés tels quels. Une vidéo introuvable devient une mention textuelle. +func expandVideoRefs(msgs []Message) []Message { + out := msgs + copied := false + for i, m := range msgs { + parts := contentParts(m.Content) + has := false + for _, p := range parts { + if strings.HasPrefix(partVideoURL(p), vidRefScheme) { + 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 := partVideoURL(p) + if !strings.HasPrefix(u, vidRefScheme) { + np = append(np, p) + continue + } + if d, ok := chatVideoDataURL(u); ok { + np = append(np, map[string]any{"type": "input_video", "input_video": map[string]any{"url": d}}) + } else { + np = append(np, map[string]any{"type": "text", "text": "[vidéo indisponible : fichier supprimé]"}) + } + } + m.Content = np + out[i] = m + } + return out +} diff --git a/internal/loki/chat_video_test.go b/internal/loki/chat_video_test.go new file mode 100644 index 0000000..92496fc --- /dev/null +++ b/internal/loki/chat_video_test.go @@ -0,0 +1,332 @@ +package loki + +import ( + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// videoEngine simule un moteur qui sait (ou non) lire la vidéo. +func videoEngine(t *testing.T, ok bool) { + t.Helper() + prev := videoInputSupported + videoInputSupported = func() bool { return ok } + t.Cleanup(func() { videoInputSupported = prev }) +} + +// engineSees amorce le cache de la sonde /props : aucun llama-server ne tourne +// pendant les tests. +func engineSees(t *testing.T, seen bool) { + t.Helper() + visionProbeMu.Lock() + visionProbeAt, visionProbeSeen = time.Now(), seen + visionProbeMu.Unlock() + t.Cleanup(func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() }) +} + +// Une vidéo 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 TestVideoRefsRoundTrip(t *testing.T) { + testHome(t) + raw := []byte(strings.Repeat("\x00\x00\x00\x18ftypmp42-fake-video-bytes", 5000)) + url := "data:video/mp4;base64," + base64.StdEncoding.EncodeToString(raw) + msgs := []Message{ + {Role: "user", Content: []map[string]any{ + {"type": "text", "text": "regarde la vidéo"}, + {"type": "input_video", "input_video": 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} { + orig := contentParts(set[0].Content)[1] + if !hasInlineVideos(set) || !refVideosInMessages(set) || hasInlineVideos(set) { + t.Fatal("la vidéo aurait dû être rangée par référence") + } + // Recopiée, jamais mutée : un runChat en vol peut lire ces mêmes maps. + if partVideoURL(orig) != url { + t.Fatal("refVideosInMessages a muté la partie d'origine") + } + js, _ := json.Marshal(set) + if len(js) > 500 || !strings.Contains(string(js), vidRefScheme) { + t.Fatalf("historique encore lourd : %d octets", len(js)) + } + out := expandVideoRefs(set) + if got := partVideoURL(contentParts(out[0].Content)[1]); got != url { + t.Fatal("la vidéo renvoyée au modèle diffère de l'originale") + } + // L'historique lui-même n'est pas modifié par l'expansion. + if u := partVideoURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, vidRefScheme) { + t.Fatal("expandVideoRefs ne doit pas toucher l'historique") + } + } + // Fichier effacé (ménage) : une mention, pas une requête qui échoue. + _ = os.RemoveAll(chatVidDir()) + vidCacheMu.Lock() + vidCache, vidCacheOrder, vidCacheBytes = map[string]string{}, nil, 0 + vidCacheMu.Unlock() + out := expandVideoRefs(msgs) + if p := contentParts(out[0].Content)[1]; p["type"] != "text" || !strings.Contains(p["text"].(string), "indisponible") { + t.Fatalf("vidéo disparue : %v", p) + } +} + +// userMessageContent : une vidéo jointe devient une partie input_video (et une +// image jointe reste une partie image_url), quand la vision est active et que +// le moteur lit la vidéo. +func TestUserMessageContentVideo(t *testing.T) { + videoEngine(t, true) + visionOn(t) + engineSees(t, true) + dir, err := uploadsDir() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "clip-test.mp4"), []byte("fake-mp4-bytes"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "photo.png"), pngPixel, 0o600); err != nil { + t.Fatal(err) + } + in := []attachInfo{ + {Name: "clip-test.mp4", Path: "uploads/clip-test.mp4", Size: 14}, + {Name: "photo.png", Path: "uploads/photo.png", Size: int64(len(pngPixel))}, + } + parts, ok := userMessageContent(in, "décris ce clip").([]map[string]any) + if !ok || len(parts) != 3 { + t.Fatalf("attendu texte + image + vidéo, obtenu %#v", parts) + } + if parts[0]["type"] != "text" || parts[1]["type"] != "image_url" || parts[2]["type"] != "input_video" { + t.Fatalf("ordre des parties : %v %v %v", parts[0]["type"], parts[1]["type"], parts[2]["type"]) + } + if u := partVideoURL(parts[2]); !strings.HasPrefix(u, "data:video/mp4;base64,") { + t.Fatalf("data URI vidéo inattendue : %.40q", u) + } + // Montrée, elle n'est plus annoncée comme simple fichier. + if txt := parts[0]["text"].(string); strings.Contains(txt, "clip-test.mp4") || strings.Contains(txt, "ffmpeg") { + t.Fatalf("vidéo vue annoncée comme fichier : %q", txt) + } +} + +// Moteur sans vidéo (API externe, Strata, llama.cpp ancien) : il ignorait la +// partie input_video sans erreur et le modèle ne recevait que le texte. La +// vidéo doit alors être annoncée comme fichier, avec la marche à suivre +// (ffmpeg puis see_image), et see_video doit le dire au lieu de charger. +func TestVideoUnsupportedEngine(t *testing.T) { + videoEngine(t, false) + visionOn(t) + engineSees(t, true) + dir, err := uploadsDir() + if err != nil { + t.Fatal(err) + } + vname := "clip-test.mp4" + if err := os.WriteFile(filepath.Join(dir, vname), []byte("fake-mp4-bytes"), 0o600); err != nil { + t.Fatal(err) + } + content := userMessageContent([]attachInfo{{Name: vname, Path: "uploads/" + vname, Size: 14}}, "décris ce clip") + s, ok := content.(string) + if !ok { + t.Fatalf("attendu du texte seul (pas de partie input_video), obtenu %T", content) + } + if !strings.Contains(s, "uploads/"+vname) || !strings.Contains(s, "ffmpeg") || !strings.HasSuffix(s, "décris ce clip") { + t.Fatalf("chemin et consigne ffmpeg attendus : %q", s) + } + if msg, part := toolSeeVideo(filepath.Join(dir, vname)); part != nil || !strings.Contains(msg, "ffmpeg") { + t.Fatalf("see_video doit refuser avec la consigne ffmpeg : %q", msg) + } + // Ni outil annoncé, ni consigne quand ce n'est pas une vidéo. + for _, tl := range EnabledTools(Caps{Agent: true}) { + if tl.Function.Name == "see_video" { + t.Fatal("see_video annoncé alors que le moteur ne lit pas la vidéo") + } + } + if s := attachNote([]attachInfo{{Name: "a.txt", Path: "uploads/a.txt", Size: 1}}); strings.Contains(s, "ffmpeg") { + t.Fatalf("consigne vidéo sur un fichier texte : %q", s) + } +} + +// see_video : annoncé avec see_image quand le moteur lit la vidéo. +func TestSeeVideoAnnonce(t *testing.T) { + videoEngine(t, true) + visionOn(t) + has := func(caps Caps) bool { + for _, tl := range EnabledTools(caps) { + if tl.Function.Name == "see_video" { + return true + } + } + return false + } + if !has(Caps{Agent: true}) { + t.Fatal("see_video absent alors que le moteur lit la vidéo") + } + if has(Caps{}) { + t.Fatal("see_video hors du mode agent") + } + if toolCallLabel("see_video", map[string]any{"file": "a.mp4"}) != "a.mp4" { + t.Fatal("libellé de see_video vide : l'outil s'exécuterait sur une chaîne vide") + } + if dedupableTool("see_video") { + t.Fatal("see_video dédupliqué : le rappel rendrait « déjà fait » sans la vidéo") + } +} + +// toolSeeVideo : chaque refus nomme son motif ; un mp4 présent est chargé et +// relayé dans un message utilisateur qui nomme le fichier. +func TestToolSeeVideo(t *testing.T) { + videoEngine(t, true) + home := visionOn(t) + engineSees(t, true) + if err := os.MkdirAll(filepath.Join(home, "dossier.mp4"), 0o755); err != nil { + t.Fatal(err) + } + gros := filepath.Join(home, "gros.mp4") + f, _ := os.Create(gros) + _ = f.Truncate(maxVideoBytes + 1) + f.Close() + _ = os.WriteFile(filepath.Join(home, "vide.webm"), nil, 0o600) + cas := []struct{ path, attendu string }{ + {"", "chemin de fichier manquant"}, + {filepath.Join(home, "notes.txt"), "format non reconnu comme vidéo"}, + {filepath.Join(home, "nulle-part.mp4"), "introuvable"}, + {filepath.Join(home, "dossier.mp4"), "dossier"}, + {gros, "trop lourde"}, + {filepath.Join(home, "vide.webm"), "fichier vide"}, + } + for _, c := range cas { + msg, part := toolSeeVideo(c.path) + if part != nil || !strings.HasPrefix(msg, "[erreur]") || !strings.Contains(msg, c.attendu) { + t.Errorf("%q : %q, attendu une erreur contenant %q", c.path, msg, c.attendu) + } + } + p := filepath.Join(home, "clip.MOV") + if err := os.WriteFile(p, []byte("fake-mov"), 0o600); err != nil { + t.Fatal(err) + } + msg, part := toolSeeVideo(p) + if part == nil || msg != "[ok] vidéo chargée : clip.MOV" { + t.Fatalf("attendu un [ok] : %q / %v", msg, part) + } + if u := partVideoURL(part); u != "data:video/quicktime;base64,"+base64.StdEncoding.EncodeToString([]byte("fake-mov")) { + t.Fatalf("partie inattendue : %v", part) + } + // Le moteur en service ne voit pas : on le dit au lieu d'envoyer 200 Mio. + engineSees(t, false) + if msg, part := toolSeeVideo(p); part != nil || !strings.Contains(msg, "ne traite pas les images") { + t.Fatalf("moteur aveugle : %q", msg) + } + // Relais : passé par l'API interne comme see_image (toolStepExtras). + _, _, relay := toolStepExtras("see_video", "clip.MOV", toolExecResult{Result: "[ok]", VisionVideo: videoPart([]byte("x"), "video/mp4")}) + if relay == nil { + t.Fatal("aucun relais pour see_video") + } + rp := contentParts(relay.Content) + if relay.Role != "user" || rp[0]["text"] != "Vidéo demandée (clip.MOV) :" || rp[1]["type"] != "input_video" { + t.Fatalf("relais inattendu : %#v", relay) + } +} + +// Sans projecteur ou sur un poste distant : refus explicite, avant toute +// question au moteur. +func TestSeeVideoSansVision(t *testing.T) { + videoEngine(t, true) + testHome(t) + if msg, part := toolSeeVideo("a.mp4"); part != nil || !strings.Contains(msg, "MMPROJ") { + t.Fatalf("sans vision : %q", msg) + } +} + +// videoInputSupported : jamais pour une API externe, Strata, un preset sans +// projecteur ou sans moteur ; sinon, l'aide du binaire fait foi. +func TestVideoInputSupportedPresets(t *testing.T) { + testHome(t) + cas := []map[string]string{ + {"BIN": "llama-server", "MODEL": "m.gguf"}, // pas de projecteur + {"MMPROJ": "mm.gguf", "MODEL": "m.gguf"}, // pas de moteur + {"MMPROJ": "mm.gguf", "BIN": "introuvable/llama-server"}, // moteur absent + {"MMPROJ": "mm.gguf", extKeyFlag: "1", extKeyVision: "1"}, // API externe + {"MMPROJ": "mm.gguf", "ENGINE": engineStrata, "BIN": "/bin/sh", "MODEL": "m"}, // Strata + } + for _, cfg := range cas { + if err := WriteConfig(cfg); err != nil { + t.Fatal(err) + } + if videoInputSupported() { + t.Errorf("%v : vidéo annoncée", cfg) + } + } +} + +// L'aide du binaire décide, relue quand le binaire change (mise à jour en place). +func TestBinHelpHasVideo(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("faux moteur en sh") + } + dir := t.TempDir() + bin := filepath.Join(dir, "llama-server") + write := func(help string, mt time.Time) { + if err := os.WriteFile(bin, []byte("#!/bin/sh\necho '"+help+"'\n"), 0o755); err != nil { + t.Fatal(err) + } + _ = os.Chtimes(bin, mt, mt) + } + write("--mmproj FILE", time.Now().Add(-time.Hour)) + if binHelpHasVideo(bin) { + t.Fatal("moteur sans --video-fps annoncé comme lisant la vidéo") + } + write("--video-fps N target fps", time.Now()) + if !binHelpHasVideo(bin) { + t.Fatal("moteur mis à jour : --video-fps non relu") + } +} + +// Compaction, fil rechargé, export : la vidéo n'est jamais rejouée ni +// recopiée, mais sa perte reste visible. +func TestVideoPerdueVisible(t *testing.T) { + part := videoPart([]byte(strings.Repeat("v", 3000)), "video/mp4") + m := Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "regarde"}, part}} + if got := msgText(m); got != "regarde"+videoLostMarker { + t.Fatalf("msgText : %q", got) + } + var reread []Message + b, _ := json.Marshal([]Message{m}) + _ = json.Unmarshal(b, &reread) + out := stripImageParts(reread) + if s, ok := out[0].Content.(string); !ok || s != "regarde"+videoLostMarker { + t.Fatalf("fil rechargé : %#v", out[0].Content) + } + exp := stripInlineImages([]Message{m}) + js, _ := json.Marshal(exp) + if len(js) > 400 || !strings.Contains(string(js), "élidé") { + t.Fatalf("export : %s", js) + } + if partVideoURL(contentParts(m.Content)[1]) == "" || !strings.HasPrefix(partVideoURL(part), "data:video/mp4;base64,") { + t.Fatal("l'export a touché le message vivant") + } +} + +// Le ménage des médias vide aussi les vidéos jointes trop vieilles. +func TestPruneChatVideos(t *testing.T) { + testHome(t) + ref, err := storeChatVideo([]byte("vieille"), "video/webm") + if err != nil || !strings.HasSuffix(ref, ".webm") { + t.Fatalf("rangement : %q %v", ref, err) + } + p := filepath.Join(chatVidDir(), strings.TrimPrefix(ref, vidRefScheme)) + old := time.Now().Add(-2 * chatImgKeep) + _ = os.Chtimes(p, old, old) + pruneChatImages() + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Fatal("vieille vidéo gardée") + } +} diff --git a/internal/loki/chat_video_tool.go b/internal/loki/chat_video_tool.go new file mode 100644 index 0000000..071d01c --- /dev/null +++ b/internal/loki/chat_video_tool.go @@ -0,0 +1,91 @@ +package loki + +// chat_video_tool.go — l'outil see_video (repris d'AJEAN) : le modèle charge +// lui-même un fichier vidéo du disque dans sa VISION, sans que l'utilisateur ait +// à le joindre. Même mécanique que see_image (chat_vision_tool.go) : le résultat +// de l'outil reste un accusé texte, la vidéo part juste après dans un message +// utilisateur multimodal (partie input_video), que llama-server décode en +// images avec ffmpeg. Toujours ÉPHÉMÈRE : jamais dans l'historique. +// +// Annoncé seulement quand le moteur sait lire la vidéo (videoInputSupported) ; +// appelé quand même, il le dit avec la marche à suivre (ffmpeg puis see_image). + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// toolSeeVideo lit un fichier vidéo et renvoie (accusé texte, partie input_video). +// La partie vaut nil en cas d'erreur : l'appelant n'injecte alors rien. Même +// ordre de refus que toolSeeImage, la sonde du moteur en dernier. +func toolSeeVideo(path string) (string, map[string]any) { + if !visionEnabled() { + return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo", nil + } + if agentTargetSlug() != "" { + return "[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil + } + if path == "" { + return "[erreur] chemin de fichier manquant", nil + } + if !videoInputSupported() { + return "[erreur] " + strings.TrimSuffix(videoFallbackNote, "\n"), nil + } + abs := resolveAgentPath(path) + mime := videoMime(abs) + if mime == "" { + return "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : " + path, nil + } + st, err := os.Stat(abs) + if err != nil { + return "[erreur] fichier introuvable : " + path, nil + } + if st.IsDir() { + return "[erreur] c'est un dossier, pas une vidéo : " + path, nil + } + if st.Size() > maxVideoBytes { + return fmt.Sprintf("[erreur] vidéo trop lourde (%s, max %s) — coupe-la en un clip plus court : %s", + humanBytes(st.Size()), humanBytes(maxVideoBytes), path), nil + } + if !engineSeesImages() { + return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil + } + b, err := os.ReadFile(abs) + if err != nil { + return "[erreur] lecture impossible : " + err.Error(), nil + } + if len(b) == 0 { + return "[erreur] fichier vide : " + path, nil + } + return "[ok] vidéo chargée : " + filepath.Base(abs), videoPart(b, mime) +} + +// seeVideoMessage emballe la partie vidéo dans le message utilisateur qui la +// porte jusqu'au modèle ; la légende nomme le fichier (voir seeImageMessage). +func seeVideoMessage(label string, vid map[string]any) Message { + text := "Vidéo :" + if label != "" { + text = "Vidéo demandée (" + label + ") :" + } + return Message{Role: "user", Content: []map[string]any{ + {"type": "text", "text": text}, + vid, + }} +} + +// seeVideoTool — schéma envoyé au modèle, tenu court comme seeImageTool. +func seeVideoTool() Tool { + return Tool{Type: "function", Function: ToolFunction{ + Name: "see_video", + Description: "Ouvre un fichier vidéo du disque pour la REGARDER (mp4, webm, mov, mkv, avi). Clips courts.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "file": map[string]any{"type": "string", "description": "Chemin de la vidéo (relatif au dossier de travail, ou absolu)"}, + }, + "required": []string{"file"}, + }, + }} +} diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index f6b765e..029def3 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -515,6 +515,12 @@ func EnabledTools(caps Caps) []Tool { // pas — il promettrait alors de regarder, puis se contredirait. if caps.Agent && visionEnabled() { tools = append(tools, seeImageTool()) + // Voir une vidéo : seulement si le moteur sait la lire (llama.cpp local + // avec --video-fps, chat_video.go). Ailleurs, la vidéo resterait lettre + // morte : Strata et les API externes ignorent la partie sans erreur. + if videoInputSupported() { + tools = append(tools, seeVideoTool()) + } } if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() { tools = append(tools, webScreenshotTool()) @@ -670,12 +676,12 @@ func fillToolResult(ev *ToolUsedEvent, result string) *ToolUsedEvent { // - leur résultat dépend de l'ÉTAT VIVANT de la page (un browser_snapshot sans // argument a forcément la même clé de dédup à chaque appel, alors que la page // a changé) — les dédupliquer fige l'IA sur un vieux cliché ; -// - surtout, browser_screenshot et see_image portent leur IMAGE dans un message +// - surtout, browser_screenshot et see_image (see_video : sa vidéo) portent leur IMAGE dans un message // à part (visionImg) que le chemin de dédup ne rejoue PAS : l'IA recevrait // « [déjà fait] » SANS l'image et tournerait en boucle. func dedupableTool(name string) bool { switch name { - case "bash", "bash_bg", "bash_tail", "see_image": + case "bash", "bash_bg", "bash_tail", "see_image", "see_video": return false } return !strings.HasPrefix(name, "browser_") @@ -1121,7 +1127,7 @@ func toolCallLabel(name string, args map[string]any) string { switch name { case "mem_search", "web_search", "web_images", "recall_search": label, _ = args["query"].(string) - case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image": + case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image", "see_video": label, _ = args["file"].(string) case "recall": label, _ = args["id"].(string) @@ -1221,9 +1227,10 @@ type chatPayloadOpts struct { func buildChatPayload(ep chatEndpoint, sent []Message, temperature float64, o chatPayloadOpts) map[string]any { payload := map[string]any{ "model": ep.Model, - // 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(sent), + // Les images et vidéos de l'historique y sont rangées par référence + // (chat_images.go, chat_video.go) : on remet leurs octets juste avant + // l'envoi. + "messages": expandVideoRefs(expandImageRefs(sent)), "stream": true, "temperature": temperature, // include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE @@ -2343,7 +2350,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools var diffAdd, diffDel int // vrais totaux (diff est tronqué pour l'UI) // visionImg : partie image_url rendue par see_image, réinjectée après // le résultat de l'outil (un message `tool` ne porte que du texte). - var visionImg map[string]any + var visionImg, visionVideo map[string]any // Appel rigoureusement identique déjà exécuté dans ce tour : on ne le // rejoue pas. Les petits modèles réémettent volontiers deux fois la // même écriture ; la rejouer produisait une fausse erreur (« old @@ -2375,7 +2382,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools } else { ex = execTool(ctx, tc.Function.Name, args, label, caps) } - result, diff, diffAdd, diffDel, visionImg = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg + result, diff, diffAdd, diffDel, visionImg, visionVideo = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg, ex.VisionVideo if ex.Ask != nil { cb(StreamEvent{Ask: ex.Ask}) } @@ -2433,6 +2440,13 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools if visionImg != nil { messages, extra = kt.relay(messages, extra, seeImageMessage(label, visionImg), stepStart) } + // see_video a réussi : même relais, toujours ÉPHÉMÈRE — dans + // `messages` seulement, jamais dans `extra`, KEEP_TURN_IMAGES + // compris (une vidéo pèse des milliers de jetons visuels, et + // kt.relay ne sait ranger que des images). + if visionVideo != nil { + messages = append(messages, seeVideoMessage(label, visionVideo)) + } } // Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour : // une boucle d'outils peut à elle seule remplir la fenêtre (résultats @@ -2616,7 +2630,10 @@ type toolExecResult struct { Added int `json:"added,omitempty"` Removed int `json:"removed,omitempty"` VisionImg map[string]any `json:"vision_img,omitempty"` - Ask *AskEvent `json:"ask,omitempty"` + // VisionVideo : partie input_video rendue par see_video (chat_video_tool.go), + // relayée au modèle comme l'image, mais toujours éphémère. + VisionVideo map[string]any `json:"vision_video,omitempty"` + Ask *AskEvent `json:"ask,omitempty"` } // execToolHook : nil en service. Les tests de parité Go/Rust @@ -2631,11 +2648,13 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin result := "" var diff []DiffLine var diffAdd, diffDel int - var visionImg map[string]any + var visionImg, visionVideo map[string]any var ask *AskEvent switch name { case "see_image": result, visionImg = toolSeeImage(label) + case "see_video": + result, visionVideo = toolSeeVideo(label) case "recall": result = toolRecall(args) case "recall_search": @@ -2820,5 +2839,5 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin result = "[erreur] outil inconnu: " + name } } - return toolExecResult{Result: result, Diff: diff, Added: diffAdd, Removed: diffDel, VisionImg: visionImg, Ask: ask} + return toolExecResult{Result: result, Diff: diff, Added: diffAdd, Removed: diffDel, VisionImg: visionImg, VisionVideo: visionVideo, Ask: ask} } diff --git a/internal/loki/web_internal_tool.go b/internal/loki/web_internal_tool.go index edaae25..7f37948 100644 --- a/internal/loki/web_internal_tool.go +++ b/internal/loki/web_internal_tool.go @@ -84,5 +84,9 @@ func toolStepExtras(name, label string, res toolExecResult) (image, shot string, m := seeImageMessage(label, res.VisionImg) relay = &m } + if res.VisionVideo != nil { + m := seeVideoMessage(label, res.VisionVideo) + relay = &m + } return image, shot, relay } diff --git a/internal/loki/web_upload.go b/internal/loki/web_upload.go index b285820..5f939c1 100644 --- a/internal/loki/web_upload.go +++ b/internal/loki/web_upload.go @@ -152,10 +152,19 @@ func attachNote(files []attachInfo) string { head = "Fichiers joints à ce message, déposés dans ton dossier de travail :" } var lines []string + video := false for _, f := range files { lines = append(lines, fmt.Sprintf("- %s (%s)", f.Path, humanBytes(f.Size))) + video = video || videoMime(f.Name) != "" } - return head + "\n" + strings.Join(lines, "\n") + "\n\n" + note := head + "\n" + strings.Join(lines, "\n") + "\n" + // Vidéo jointe que le moteur ne sait pas lire (chat_video.go) : dire comment + // la regarder quand même, au lieu de laisser le modèle tâtonner — Strata et + // les API externes ignoraient la vidéo en silence. + if video && visionEnabled() && !videoInputSupported() { + note += videoFallbackNote + } + return note + "\n" } // imageMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme image (contenu @@ -189,9 +198,11 @@ func visionEnabled() bool { // userMessageContent construit le champ Content du message utilisateur. Cas // courant : une simple chaîne (note des fichiers joints + texte). Quand la vision -// est active ET qu'au moins une pièce jointe est une image, on renvoie le format -// multimodal d'OpenAI — une partie `text` suivie d'une partie `image_url` par -// image (data URI base64) — que llama-server comprend une fois --mmproj chargé : +// est active ET qu'au moins une pièce jointe est une image (ou une vidéo que le +// moteur sait lire), on renvoie le format multimodal d'OpenAI — une partie +// `text` suivie d'une partie `image_url` par image (data URI base64) et d'une +// partie `input_video` par vidéo — que llama-server comprend une fois --mmproj +// chargé : // le modèle VOIT alors l'image au lieu de devoir l'ouvrir comme un fichier binaire. // Les images ainsi intégrées sortent de la note textuelle (inutile de dire au // modèle d'aller ouvrir un fichier qu'il a déjà sous les yeux) ; les autres @@ -209,31 +220,43 @@ func userMessageContent(files []attachInfo, prompt string) any { if err != nil { return attachNote(files) + prompt } - var imgParts []map[string]any + var imgParts, vidParts []map[string]any var textFiles []attachInfo for _, f := range files { - mime := imageMime(f.Name) - if mime == "" { - textFiles = append(textFiles, f) + if mime := imageMime(f.Name); mime != "" { + b, err := os.ReadFile(filepath.Join(dir, f.Name)) + if err != nil { + textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier + continue + } + // Photo prise au téléphone : l'orientation vit dans un tag EXIF que le + // projecteur ignore (il voit les pixels bruts, donc l'image couchée), et + // une image de plusieurs milliers de pixels ne fait que gonfler le base64 + // — voir prepareImageForModel (web_upload_orient.go). + b, mime = prepareImageForModel(b, mime) + imgParts = append(imgParts, imageURLPart(b, mime)) continue } - b, err := os.ReadFile(filepath.Join(dir, f.Name)) - if err != nil { - textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier + // Vidéo : seulement si le moteur sait la lire (chat_video.go) ; sinon + // elle reste un fichier, annoncé avec la marche à suivre (attachNote). + // Pas de redimensionnement : le moteur échantillonne les images lui-même. + if mime := videoMime(f.Name); mime != "" && videoInputSupported() { + b, err := os.ReadFile(filepath.Join(dir, f.Name)) + if err != nil || len(b) == 0 || len(b) > maxVideoBytes { + textFiles = append(textFiles, f) + continue + } + vidParts = append(vidParts, videoPart(b, mime)) continue } - // Photo prise au téléphone : l'orientation vit dans un tag EXIF que le - // projecteur ignore (il voit les pixels bruts, donc l'image couchée), et - // une image de plusieurs milliers de pixels ne fait que gonfler le base64 - // — voir prepareImageForModel (web_upload_orient.go). - b, mime = prepareImageForModel(b, mime) - imgParts = append(imgParts, imageURLPart(b, mime)) + textFiles = append(textFiles, f) } - if len(imgParts) == 0 { + if len(imgParts) == 0 && len(vidParts) == 0 { return attachNote(files) + prompt } parts := []map[string]any{{"type": "text", "text": attachNote(textFiles) + prompt}} - return append(parts, imgParts...) + parts = append(parts, imgParts...) + return append(parts, vidParts...) } // workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et,