mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
Vision : le modèle peut regarder des vidéos (outil see_video et vidéos jointes), repris d'AJEAN
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6a45f19831
commit
776de75dd0
12 files changed
+886
-38
No files matched your search
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<nom> ».
|
||||
// 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 <vidéo> -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
+30
-11
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+42
-19
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user