Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 ac74ecde0b Façade : see_video exécuté par la façade, vidéos jointes rangées relues
Comme see_image (lot 45), see_video est tenu par la façade :
imagetools::see_video porte toolSeeVideo (mêmes refus, même ordre,
sonde du moteur en dernier) et video_input_supported la capacité du
moteur (aide du binaire, --video-fps, relue quand il change). La vidéo
relayée est toujours éphémère, KEEP_TURN_IMAGES compris (turn.rs) ;
vision_video relu de /tool/exec quand le Go exécute l'outil.

Vidéos jointes rangées (loki-vid:) : remises en data-URL avant l'envoi
avec les images (expand_image_refs, cache des médias élargi à chatvid).
Libellé et non-dédup de see_video, videoLostMarker à la compaction et
au rechargement d'un fil, élision dans l'export.

Parité : journal tiré du Go (parity_video_tool_test.go →
tests/parity_images/video.json : refus, moteur sans vidéo, moteur
aveugle, vidéos relayées à l'octet), rejoué par tests/videotools.rs ;
journal multiplateforme (ni faux Playwright ni sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:17 +02:00
MichaelandClaude Opus 5.5 776de75dd0 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>
2026-10-19 11:23:17 +02:00
58 changed files with 2097 additions and 791 deletions

No files matched your search

+8 -14
View File
@@ -52,20 +52,14 @@ 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.
Le **rendu des formules LaTeX** dans les réponses (`ui/src/js/00-math.js`,
`web_katex.go`) est repris d'AJEAN (#123) : $…$, $$…$$, \\(…\\) et \\[…\\] rendus
par KaTeX, chargé à la première formule seulement, sans toucher au code ni aux
montants en $. Loki y ajoute deux garde-fous de détection (« 5 $, » et un $
voisin d'un code en ligne restent du texte) et sert la route `/katex/` aussi
depuis la façade Rust (`rust/loki-rs/src/ui.rs`).
## KaTeX
**[KaTeX](https://katex.org/)** 0.16.11 (licence MIT, © Khan Academy et
contributeurs) est embarqué tel que publié — script, feuille de style et
polices woff2 — sous `internal/loki/ui/katex/`, avec sa licence
(`internal/loki/ui/katex/LICENSE`). Il est servi par Loki sous `/katex/`, sans
appel à un CDN.
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
+6 -1
View File
@@ -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()
+16 -1
View File
@@ -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) {
+5 -1
View File
@@ -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
+1
View File
@@ -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.
+17 -5
View File
@@ -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
+333
View File
@@ -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
}
+332
View File
@@ -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")
}
}
+91
View File
@@ -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
View File
@@ -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}
}
+197
View File
@@ -0,0 +1,197 @@
package loki
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// parity_video_tool_test.go — journal de référence de l'outil see_video
// (repris d'AJEAN, tenu par la façade comme see_image, lot 45) : refus dans
// le même ordre que see_image, moteur qui ne lit pas la vidéo (consigne
// ffmpeg), sonde « le moteur voit-il » (faux /props) et vidéo relayée au
// modèle, à l'octet (une vidéo n'est jamais réencodée).
//
// La capacité vidéo du moteur (videoInputSupported : aide du binaire) est
// fixée par chaque étape : la façade la calcule de son côté par le même
// chemin (imagetools::video_input_supported) et la passe à see_video. Rangé
// dans rust/loki-rs/tests/parity_images/video.json ; tests/videotools.rs le
// rejoue contre le port.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilVideo
const parityVideoFile = "../../rust/loki-rs/tests/parity_images/video.json"
type parityVideoStep struct {
Name string `json:"name"`
Vision bool `json:"vision,omitempty"`
Sees bool `json:"sees,omitempty"` // le faux /props annonce la vision
Video bool `json:"video,omitempty"` // le moteur lit la vidéo (--video-fps)
Args map[string]any `json:"args"`
Label string `json:"label"`
Result string `json:"result"`
Part map[string]any `json:"part,omitempty"` // vidéo relayée
Probes int `json:"probes"`
}
func TestPariteOutilVideo(t *testing.T) {
testHome(t)
files := filepath.Join(t.TempDir(), "fichiers")
raw := map[string][]byte{
"clip.mp4": []byte("\x00\x00\x00\x18ftypmp42 fausse vidéo"),
"CLIP.MOV": []byte("\x00\x00\x00\x14ftypqt mov"),
"anim.webm": []byte("\x1aE\xdf\xa3 webm"),
"film.mkv": []byte("\x1aE\xdf\xa3 matroska"),
"vieux.avi": []byte("RIFF\x10\x00\x00\x00AVI LIST"),
"sous/court.mp4": []byte("ftyp sous-dossier"),
"texte.txt": []byte("pas une vidéo"),
"vide.mp4": {},
}
for name, b := range raw {
p := filepath.Join(files, name)
_ = os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
}
_ = os.MkdirAll(filepath.Join(files, "dossier.mp4"), 0o755)
lourd, _ := os.Create(filepath.Join(files, "lourd.mp4"))
_ = lourd.Truncate(maxVideoBytes + 1)
lourd.Close()
setTaskWorkspace(files)
t.Cleanup(func() { setTaskWorkspace("") })
// Faux moteur : /props, authentifié.
var mu sync.Mutex
sees, probes := false, 0
eng := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if r.URL.Path != "/props" || r.Header.Get("Authorization") != "Bearer cle-moteur" {
w.WriteHeader(401)
return
}
probes++
_ = json.NewEncoder(w).Encode(map[string]any{"modalities": map[string]any{"vision": sees}})
}))
defer eng.Close()
_ = SetConfigKey("PORT", strings.TrimPrefix(eng.URL, "http://127.0.0.1:"))
_ = putStr(bkState, "api_key", "cle-moteur")
prevVideo := videoInputSupported
t.Cleanup(func() { videoInputSupported = prevVideo })
var steps []parityVideoStep
run := func(st parityVideoStep) {
if st.Vision {
_ = SetConfigKey("MMPROJ", "vision.gguf")
} else {
_ = SetConfigKey("MMPROJ", "")
}
video := st.Video
videoInputSupported = func() bool { return video }
mu.Lock()
sees, probes = st.Sees, 0
mu.Unlock()
visionProbeMu.Lock()
visionProbeAt = time.Time{}
visionProbeMu.Unlock()
args := map[string]any{}
b, _ := json.Marshal(st.Args)
_ = json.Unmarshal([]byte(strings.ReplaceAll(string(b), "{{FICHIERS}}", filepath.ToSlash(files))), &args)
label := toolCallLabel("see_video", args)
st.Label = strings.ReplaceAll(label, filepath.ToSlash(files), "{{FICHIERS}}")
res := execTool(t.Context(), "see_video", args, label, Caps{Agent: true})
_, _, relay := toolStepExtras("see_video", label, res)
st.Result = strings.ReplaceAll(res.Result, filepath.ToSlash(files), "{{FICHIERS}}")
if relay != nil {
for _, p := range contentParts(relay.Content) {
switch p["type"] {
case "text":
st.Result += "\n[relais] " + strings.ReplaceAll(fmt.Sprint(p["text"]), filepath.ToSlash(files), "{{FICHIERS}}")
case "input_video":
st.Part = parityVideoFacts(t, p, raw)
}
}
}
mu.Lock()
st.Probes = probes
mu.Unlock()
if st.Args == nil {
st.Args = map[string]any{}
}
steps = append(steps, st)
}
a := func(f string) map[string]any { return map[string]any{"file": f} }
run(parityVideoStep{Name: "sans vision", Video: true, Args: a("clip.mp4")})
run(parityVideoStep{Name: "chemin manquant", Vision: true, Video: true, Args: a("")})
run(parityVideoStep{Name: "sans argument", Vision: true, Video: true})
run(parityVideoStep{Name: "moteur sans vidéo", Vision: true, Sees: true, Args: a("clip.mp4")})
run(parityVideoStep{Name: "pas une vidéo", Vision: true, Video: true, Args: a("texte.txt")})
run(parityVideoStep{Name: "image", Vision: true, Video: true, Args: a("photo.png")})
run(parityVideoStep{Name: "introuvable", Vision: true, Video: true, Args: a("absente.mp4")})
run(parityVideoStep{Name: "dossier", Vision: true, Video: true, Args: a("dossier.mp4")})
run(parityVideoStep{Name: "trop lourde", Vision: true, Video: true, Args: a("lourd.mp4")})
run(parityVideoStep{Name: "le moteur ne voit pas", Vision: true, Video: true, Args: a("clip.mp4")})
run(parityVideoStep{Name: "vide", Vision: true, Sees: true, Video: true, Args: a("vide.mp4")})
for _, f := range []string{"clip.mp4", "CLIP.MOV", "anim.webm", "film.mkv", "vieux.avi", "sous/court.mp4"} {
run(parityVideoStep{Name: "voir " + f, Vision: true, Sees: true, Video: true, Args: a(f)})
}
run(parityVideoStep{Name: "chemin absolu", Vision: true, Sees: true, Video: true, Args: a("{{FICHIERS}}/clip.mp4")})
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(map[string]any{"files": raw, "steps": steps}); err != nil {
t.Fatal(err)
}
got := buf.Bytes()
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityVideoFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityVideoFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityVideoFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if !bytes.Equal(got, want) {
t.Errorf("see_video ne produit plus le journal rangé dans %s.\n"+
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityVideoFile)
}
}
// parityVideoFacts : le type et le fichier d'origine de la vidéo relayée (à
// l'octet : une vidéo part telle quelle).
func parityVideoFacts(t *testing.T, part map[string]any, raw map[string][]byte) map[string]any {
u := partVideoURL(part)
i := strings.Index(u, ";base64,")
if i < 0 {
t.Fatalf("partie vidéo sans base64 : %.60q", u)
}
b, err := base64.StdEncoding.DecodeString(u[i+8:])
if err != nil {
t.Fatal(err)
}
out := map[string]any{"mime": strings.TrimPrefix(u[:i], "data:")}
for name, r := range raw {
if bytes.Equal(r, b) {
out["same_as"] = name
}
}
return out
}
+1 -81
View File
@@ -1418,10 +1418,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
border:1px solid var(--border);border-radius:999px;background:var(--panel);
color:var(--dim);font-size:12px;line-height:1.5}
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
.katex{font-size:1.08em}
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
/* ===== Fenêtres et sélecteurs =====================================
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
@@ -3824,76 +3820,6 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<script src="marked.min.js"></script>
<script>
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
// s'apparierait par-dessus le code en ligne.
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
//
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
// exécute sous node sur les cas limites (montants, code, échappements).
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
function mathExtract(s){
const list = [];
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
if(code !== undefined) return all;
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
// pas d'espace avant le $ final.
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
const display = dd !== undefined || br !== undefined;
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
return '\uE000' + (list.length-1) + '\uE001';
});
return {text, list};
}
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
// sa formule (render : formule → HTML).
function mathRestore(html, list, render){
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
}
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const MATH_OPTS = {throwOnError: false, output: 'html'};
function mathHtml(f){
if(window.katex){
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
}
mathLoad();
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
}
let mathLoading = null;
function mathLoad(){
if(mathLoading) return mathLoading;
if(!document.querySelector('link[data-katex]')){
const css = document.createElement('link');
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
document.head.appendChild(css);
}
mathLoading = new Promise((ok, ko)=>{
const s = document.createElement('script');
s.src = 'katex/katex.min.js'; s.onload = ok;
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
s.onerror = ()=>{ s.remove(); ko(); };
document.head.appendChild(s);
}).then(()=>{
document.querySelectorAll('.math-pending').forEach(el=>{
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
el.classList.remove('math-pending');
});
}).catch(()=>{ mathLoading = null; });
return mathLoading;
}
// marked: GFM on, no auto-breaks (single newlines stay inline).
marked.setOptions({ gfm: true, breaks: false });
// Pre-process to fix common LLM markdown mistakes before handing to marked:
@@ -3910,13 +3836,7 @@ function fixMd(s){
s = s.replace(/\n{3,}/g, '\n\n');
return s;
}
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
function md(src){
if(!src) return '';
const m = mathExtract(fixMd(src));
return mathRestore(marked.parse(m.text), m.list, mathHtml);
}
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
-21
View File
@@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.11"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
-1
View File
@@ -1 +0,0 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Yn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&amp;",">":"&gt;","<":"&lt;",'"':"&quot;","'":"&#x27;"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|&#0*58|&#x0*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
-70
View File
@@ -1,70 +0,0 @@
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
// s'apparierait par-dessus le code en ligne.
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
//
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
// exécute sous node sur les cas limites (montants, code, échappements).
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
function mathExtract(s){
const list = [];
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
if(code !== undefined) return all;
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
// pas d'espace avant le $ final.
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
const display = dd !== undefined || br !== undefined;
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
return '\uE000' + (list.length-1) + '\uE001';
});
return {text, list};
}
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
// sa formule (render : formule → HTML).
function mathRestore(html, list, render){
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
}
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const MATH_OPTS = {throwOnError: false, output: 'html'};
function mathHtml(f){
if(window.katex){
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
}
mathLoad();
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
}
let mathLoading = null;
function mathLoad(){
if(mathLoading) return mathLoading;
if(!document.querySelector('link[data-katex]')){
const css = document.createElement('link');
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
document.head.appendChild(css);
}
mathLoading = new Promise((ok, ko)=>{
const s = document.createElement('script');
s.src = 'katex/katex.min.js'; s.onload = ok;
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
s.onerror = ()=>{ s.remove(); ko(); };
document.head.appendChild(s);
}).then(()=>{
document.querySelectorAll('.math-pending').forEach(el=>{
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
el.classList.remove('math-pending');
});
}).catch(()=>{ mathLoading = null; });
return mathLoading;
}
+1 -7
View File
@@ -14,13 +14,7 @@ function fixMd(s){
s = s.replace(/\n{3,}/g, '\n\n');
return s;
}
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
function md(src){
if(!src) return '';
const m = mathExtract(fixMd(src));
return mathRestore(marked.parse(m.text), m.list, mathHtml);
}
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
-4
View File
@@ -1383,10 +1383,6 @@ button:hover{border-color:var(--dim);color:var(--text)}
border:1px solid var(--border);border-radius:999px;background:var(--panel);
color:var(--dim);font-size:12px;line-height:1.5}
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
.katex{font-size:1.08em}
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
/* ===== Fenêtres et sélecteurs =====================================
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
+4
View File
@@ -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
}
-59
View File
@@ -1,59 +0,0 @@
package loki
import (
"net/http"
"path"
"strconv"
"strings"
)
// web_katex.go — KaTeX 0.16.11 (formules LaTeX des réponses), repris d'AJEAN.
//
// Embarqué sous ui/katex/ et servi sous /katex/ : l'UI ne le charge qu'à la
// première formule d'une réponse (00-md.js), et il doit marcher hors ligne,
// sans appeler un CDN. Seuls le script, la feuille de style et les polices
// woff2 sont servis — pas d'index de dossier — avec un Content-Type fixe : le
// registre MIME de l'OS (Windows notamment) ne doit pas le décider, et la
// façade Rust (rust/loki-rs/src/ui.rs) rend exactement les mêmes en-têtes.
// katexTypes : extensions servies et leur Content-Type.
var katexTypes = map[string]string{
".js": "application/javascript",
".css": "text/css; charset=utf-8",
".woff2": "font/woff2",
}
// katexFile : chemin relatif à ui/katex/ d'une ressource servable, ou "" si la
// demande n'en est pas une (racine, dossier, autre extension, remontée).
func katexFile(urlPath string) (rel, ctype string) {
rel = strings.TrimPrefix(urlPath, "/katex/")
if rel == urlPath || rel == "" || strings.Contains(rel, "..") || path.Clean(rel) != rel {
return "", ""
}
ctype, ok := katexTypes[path.Ext(rel)]
if !ok {
return "", ""
}
return rel, ctype
}
func handleKatex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r)
return
}
rel, ctype := katexFile(r.URL.Path)
if rel == "" {
http.NotFound(w, r)
return
}
b, err := uiFS.ReadFile("ui/katex/" + rel)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
w.Write(b)
}
-143
View File
@@ -1,143 +0,0 @@
package loki
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
// Détection des formules (ui/src/js/00-math.js), exécutée sous node : le dépôt
// n'a pas de banc de tests JS, et MATH_RE/mathExtract sont purs. Ignoré si node
// n'est pas installé (il l'est sur les runners de la CI).
type formule struct {
Tex string `json:"tex"`
Display bool `json:"display"`
}
// harnaisMath : charge 00-math.js et marked.min.js, puis pour chaque entrée
// (JSON sur stdin) rend {text, list} de mathExtract et, pour le bout en bout, le
// HTML de marked après mathRestore (chaque formule rendue « [tex] »).
const harnaisMath = `
const fs = require('fs');
const [mathPath, markedPath] = process.argv.slice(2);
const m = new Function(fs.readFileSync(mathPath, 'utf8') + '; return {mathExtract, mathRestore};')();
const {marked} = require(markedPath);
marked.setOptions({ gfm: true, breaks: false });
const cases = JSON.parse(fs.readFileSync(0, 'utf8'));
console.log(JSON.stringify(cases.map(s => {
const r = m.mathExtract(s);
const html = m.mathRestore(marked.parse(r.text), r.list, f => '[' + (f.display ? 'D:' : '') + f.tex + ']');
return {text: r.text, list: r.list, html};
})));
`
type résultatMath struct {
Text string `json:"text"`
List []formule `json:"list"`
HTML string `json:"html"`
}
func lanceMath(t *testing.T, cas []string) []résultatMath {
t.Helper()
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node absent : détection des formules non testée")
}
dir := t.TempDir()
h := filepath.Join(dir, "harnais.js")
if err := os.WriteFile(h, []byte(harnaisMath), 0o644); err != nil {
t.Fatal(err)
}
math, _ := filepath.Abs("ui/src/js/00-math.js")
marked, _ := filepath.Abs("ui/marked.min.js")
in, _ := json.Marshal(cas)
cmd := exec.Command(node, h, math, marked)
cmd.Stdin = strings.NewReader(string(in))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("node : %v\n%s", err, out)
}
var res []résultatMath
if err := json.Unmarshal(out, &res); err != nil {
t.Fatalf("sortie illisible : %v\n%s", err, out)
}
if len(res) != len(cas) {
t.Fatalf("%d résultats pour %d cas", len(res), len(cas))
}
return res
}
func TestMathDetection(t *testing.T) {
in := func(tex string) formule { return formule{Tex: tex} }
bloc := func(tex string) formule { return formule{Tex: tex, Display: true} }
cas := []struct {
src string
want []formule
}{
// Montants : jamais des formules.
{"Ça coûte 5 $ et 10 $.", nil},
{"Entre $5 et $10 par mois.", nil},
{"Prix : 5$ ou 6$, voire 7 $.", nil},
{`Échappés : \$5 et \$6.`, nil},
{"Collé à un mot : x$y$z.", nil},
{"Espace finale : $x $.", nil},
{"De 5 $, à 10$.", nil},
{"Montant : 5 $, code `$x_1$`.", nil},
// Code : jamais touché.
{"En ligne `$x$` et `$$y$$`.", nil},
{"```\n$x$ et $$y$$\n```", nil},
{"~~~tex\n\\[a\\]\n~~~", nil},
{"```\nbloc non fermé $x$", nil},
// Formules.
{"Soit $x^2 + y^2 = z^2$ ici.", []formule{in("x^2 + y^2 = z^2")}},
{"Deux : $a$, $b$.", []formule{in("a"), in("b")}},
{"$$\n\\int_0^1 x\\,dx\n$$", []formule{bloc(`\int_0^1 x\,dx`)}},
{`En ligne \(a+b\) et en bloc \[c\].`, []formule{in("a+b"), bloc("c")}},
{"Code `$x$` puis $y$.", []formule{in("y")}},
{`Échappement dedans : $a\$b$.`, []formule{in(`a\$b`)}},
}
srcs := make([]string, len(cas))
for i, c := range cas {
srcs[i] = c.src
}
res := lanceMath(t, srcs)
for i, c := range cas {
got := res[i].List
if len(got) == 0 {
got = nil
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%q : %+v, attendu %+v", c.src, got, c.want)
}
if c.want == nil && res[i].Text != c.src {
t.Errorf("%q : texte modifié en %q", c.src, res[i].Text)
}
}
}
// Bout en bout avec marked : \\, _ et * d'une formule arrivent intacts au rendu,
// le markdown autour reste du markdown, et aucun repère ne survit.
func TestMathAvecMarked(t *testing.T) {
res := lanceMath(t, []string{
"*Gras* : $a_1 * b_2$ et $$\\begin{matrix}a & b \\\\ c & d\\end{matrix}$$",
"Montant : 5 $, code `$x_1$`.",
})
for _, want := range []string{"<em>Gras</em>", "[a_1 * b_2]", `[D:\begin{matrix}a & b \\ c & d\end{matrix}]`} {
if !strings.Contains(res[0].HTML, want) {
t.Errorf("HTML sans %q :\n%s", want, res[0].HTML)
}
}
for _, r := range res {
if strings.ContainsAny(r.HTML, "\uE000\uE001") {
t.Errorf("repère resté dans le HTML : %q", r.HTML)
}
}
if !strings.Contains(res[1].HTML, "5 $") || !strings.Contains(res[1].HTML, "<code>$x_1$</code>") {
t.Errorf("montant ou code abîmé : %s", res[1].HTML)
}
}
-94
View File
@@ -1,94 +0,0 @@
package loki
import (
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
)
func getKatex(t *testing.T, méthode, chemin string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
handleKatex(rec, httptest.NewRequest(méthode, chemin, nil))
return rec
}
// Le script, embarqué, est servi sous /katex/ avec les en-têtes que rend aussi
// la façade Rust (ui.rs).
func TestKatexScriptServi(t *testing.T) {
rec := getKatex(t, http.MethodGet, "/katex/katex.min.js")
if rec.Code != http.StatusOK {
t.Fatalf("code %d", rec.Code)
}
h := rec.Header()
if got := h.Get("Content-Type"); got != "application/javascript" {
t.Errorf("Content-Type = %q", got)
}
if got := h.Get("Cache-Control"); got != "public, max-age=86400" {
t.Errorf("Cache-Control = %q", got)
}
if !strings.Contains(rec.Body.String(), `version:"0.16.11"`) {
t.Error("katex.min.js n'est pas KaTeX 0.16.11")
}
if h.Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
t.Errorf("Content-Length = %q pour %d octets", h.Get("Content-Length"), rec.Body.Len())
}
}
// Chaque police woff2 que la feuille de style demande est embarquée et servie.
func TestKatexFeuilleEtPolices(t *testing.T) {
rec := getKatex(t, http.MethodGet, "/katex/katex.min.css")
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/css; charset=utf-8" {
t.Fatalf("css : code %d, type %q", rec.Code, rec.Header().Get("Content-Type"))
}
polices := regexp.MustCompile(`url\((fonts/[^)]+\.woff2)\)`).FindAllStringSubmatch(rec.Body.String(), -1)
if len(polices) < 10 {
t.Fatalf("%d polices woff2 dans la feuille", len(polices))
}
for _, m := range polices {
r := getKatex(t, http.MethodGet, "/katex/"+m[1])
if r.Code != http.StatusOK || r.Header().Get("Content-Type") != "font/woff2" || r.Body.Len() == 0 {
t.Errorf("%s : code %d, type %q", m[1], r.Code, r.Header().Get("Content-Type"))
}
}
}
// Rien d'autre : ni index de dossier, ni licence, ni remontée, ni POST.
func TestKatexRefus(t *testing.T) {
for _, c := range []struct{ méthode, chemin string }{
{http.MethodGet, "/katex/"},
{http.MethodGet, "/katex/fonts/"},
{http.MethodGet, "/katex/LICENSE"},
{http.MethodGet, "/katex/fonts/absente.woff2"},
{http.MethodGet, "/katex/../index.html"},
{http.MethodGet, "/katex/fonts/../katex.min.js"},
{http.MethodGet, "/katexkatex.min.js"},
{http.MethodPost, "/katex/katex.min.js"},
} {
if rec := getKatex(t, c.méthode, c.chemin); rec.Code != http.StatusNotFound {
t.Errorf("%s %s : code %d, attendu 404", c.méthode, c.chemin, rec.Code)
}
}
}
// Derrière gzipHandler (comme en production) : script et feuille compressés,
// polices non — comme la façade.
func TestKatexCompression(t *testing.T) {
h := gzipHandler(http.HandlerFunc(handleKatex))
for chemin, gz := range map[string]bool{
"/katex/katex.min.js": true,
"/katex/katex.min.css": true,
"/katex/fonts/KaTeX_Main-Regular.woff2": false,
} {
req := httptest.NewRequest(http.MethodGet, chemin, nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Content-Encoding") == "gzip"; got != gz {
t.Errorf("%s : gzip %v, attendu %v", chemin, got, gz)
}
}
}
+1 -4
View File
@@ -19,7 +19,7 @@ import (
)
//go:generate go run ../../tools/assemble-ui ui
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2 ui/katex
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2
var uiFS embed.FS
// cmdWeb starts the HTTP server on the given port (default 8090).
@@ -217,9 +217,6 @@ func newWebMux() *http.ServeMux {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(b)
})
// KaTeX (formules LaTeX des réponses, web_katex.go) : chargé par l'UI
// seulement quand une réponse contient des maths. Embarqué, comme les polices.
mux.HandleFunc("/katex/", handleKatex)
// Endpoint compatible OpenAI, servi par Loki SUR SON PORT et relayé vers
// llama-server (voir mountOAI). C'est ce qui le rend joignable par le nom de
// domaine ou l'IP de l'interface, sans publier de second port ni ouvrir le
+42 -19
View File
@@ -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,
-26
View File
@@ -42,32 +42,6 @@ fn main() {
}
out.push_str("];\n");
// KaTeX (formules LaTeX, repris d'AJEAN) : script, feuille et polices
// woff2 sous ui/katex/, les polices nommées par leur chemin relatif
// (« fonts/x.woff2 »), comme les sert web_katex.go.
let katex = ui.join("katex");
println!("cargo:rerun-if-changed={}", katex.display());
for (ident, name) in [("KATEX_JS", "katex.min.js"), ("KATEX_CSS", "katex.min.css")] {
let p = katex.join(name);
assert!(p.is_file(), "fichier KaTeX manquant : {}", p.display());
println!("cargo:rerun-if-changed={}", p.display());
writeln!(out, "pub static {ident}: &[u8] = include_bytes!({p:?});").unwrap();
}
let katex_fonts = katex.join("fonts");
println!("cargo:rerun-if-changed={}", katex_fonts.display());
let mut kfonts: Vec<PathBuf> = std::fs::read_dir(&katex_fonts)
.unwrap_or_else(|e| panic!("{} : {e}", katex_fonts.display()))
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "woff2"))
.collect();
kfonts.sort();
out.push_str("pub static KATEX_FONTS: &[(&str, &[u8])] = &[\n");
for p in &kfonts {
let name = format!("fonts/{}", p.file_name().unwrap().to_str().unwrap());
writeln!(out, " ({name:?}, include_bytes!({p:?})),").unwrap();
}
out.push_str("];\n");
let dst = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("ui_assets.rs");
std::fs::write(dst, out).unwrap();
}
+4
View File
@@ -58,6 +58,9 @@ pub const COMPACT_SUMMARY_PREFIX: &str = "[CONTEXT COMPACTED]";
/// Remplace la partie `image_url` d'un message multimodal dans [`msg_text`].
pub const IMAGE_LOST_MARKER: &str = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]";
/// `videoLostMarker` : remplace la partie `input_video` (chat_video.go).
pub const VIDEO_LOST_MARKER: &str = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]";
/// La réponse assistant synthétique qui suit le résumé.
pub const COMPACT_ACK_MSG: &str = "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done.";
@@ -265,6 +268,7 @@ pub fn msg_text(m: &Message) -> String {
}
}
Some("image_url") => b.push_str(IMAGE_LOST_MARKER),
Some("input_video") => b.push_str(VIDEO_LOST_MARKER),
_ => {}
}
}
+101 -16
View File
@@ -14,8 +14,9 @@
//! - compaction par [`Compactor`] : résumé demandé au moteur
//! ([`EngineSummarizer`]), blocs archivés pour recall par le Go
//! ([`StoreRecall`]) ;
//! - images jointes (`loki-img:…`) lues sur disque et ouvertes par le Go
//! (déchiffrées), gardées en cache comme `chatImageDataURL` ;
//! - images et vidéos jointes (`loki-img:…`, `loki-vid:…`) lues sur disque
//! et ouvertes par le Go (déchiffrées), gardées en cache comme
//! `chatImageDataURL` / `chatVideoDataURL` ;
//! - slots du moteur (lot 31) : avant chaque requête au moteur local, le Go
//! rend le slot où l'envoyer (SIDE_SLOT) et recharge au besoin l'état
//! gardé de la discussion (SLOT_PERSIST) ; la requête est comptée en vol,
@@ -46,8 +47,8 @@ use super::stream::{CacheHintFn, PreflightFn, RetryPatterns};
use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost};
use super::types::{AskEvent, DiffLine, Message, Tool};
use super::wire::{
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME,
build_chat_payload, content_parts, part_image_url, wire_messages,
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME, VID_REF_SCHEME,
build_chat_payload, content_parts, part_image_url, part_video_url, wire_messages,
};
use crate::store::{BK_STATE, Store};
@@ -97,10 +98,32 @@ fn image_mime(name: &str) -> &'static str {
/// Nom d'une image rangée, si la référence est sûre (ni chemin ni `..`).
fn image_name(r: &str) -> Option<&str> {
let name = r.strip_prefix(IMG_REF_SCHEME)?;
safe_media_name(r.strip_prefix(IMG_REF_SCHEME)?)
}
fn safe_media_name(name: &str) -> Option<&str> {
(!name.is_empty() && !name.contains(['/', '\\']) && !name.contains("..")).then_some(name)
}
/// Média rangé : (clé du cache, fichier relatif à `LOKI_HOME`, type MIME).
/// Une image a pour clé son nom (dossier `chatimg`), une vidéo jointe
/// (`loki-vid:`, chat_video.go) sa référence entière (dossier `chatvid`).
fn media_ref(r: &str) -> Option<(String, PathBuf, &'static str)> {
if let Some(name) = image_name(r) {
return Some((
name.to_string(),
Path::new("chatimg").join(name),
image_mime(name),
));
}
let name = safe_media_name(r.strip_prefix(VID_REF_SCHEME)?)?;
let mime = match crate::imagetools::video_mime(name) {
"" => "video/mp4",
m => m,
};
Some((r.to_string(), Path::new("chatvid").join(name), mime))
}
#[derive(Default)]
struct ImgCacheInner {
map: HashMap<String, String>,
@@ -129,9 +152,10 @@ impl ImageCache {
}
}
/// Data-URL d'une image (`loki-img:`) ou d'une vidéo (`loki-vid:`) rangée.
pub fn get(&self, r: &str) -> Option<String> {
let name = image_name(r)?;
self.inner.lock().ok()?.map.get(name).cloned()
let (key, _, _) = media_ref(r)?;
self.inner.lock().ok()?.map.get(&key).cloned()
}
fn put(&self, name: &str, url: String) {
@@ -151,27 +175,30 @@ impl ImageCache {
}
}
/// Lit et ouvre (par le Go) les images référencées pas encore en cache.
/// Une image illisible reste absente : la requête dira « indisponible ».
/// Lit et ouvre (par le Go) les images et vidéos référencées pas encore
/// en cache. Un média illisible reste absent : la requête dira
/// « indisponible ».
pub async fn prefetch(&self, store: &Store, msgs: &[Message]) {
let home = self.home();
for m in msgs {
for p in content_parts(m.content.as_ref()) {
let Some(name) = image_name(part_image_url(p)) else {
let u = match part_image_url(p) {
"" => part_video_url(p),
u => u,
};
let Some((key, rel, mime)) = media_ref(u) else {
continue;
};
if self.inner.lock().is_ok_and(|g| g.map.contains_key(name)) {
if self.inner.lock().is_ok_and(|g| g.map.contains_key(&key)) {
continue;
}
let Ok(raw) = tokio::fs::read(self.dir.join(name)).await else {
let Ok(raw) = tokio::fs::read(home.join(rel)).await else {
continue;
};
let Ok(b) = store.open(raw).await else {
continue;
};
self.put(
name,
format!("data:{};base64,{}", image_mime(name), b64(&b)),
);
self.put(&key, format!("data:{mime};base64,{}", b64(&b)));
}
}
}
@@ -586,6 +613,11 @@ pub fn tool_outcome(v: &Value, label: &str) -> ToolOutcome {
{
relay = Some(see_image_message(label, img.clone()));
}
if relay.is_none()
&& let Some(vid) = v.get("vision_video").filter(|i| i.is_object())
{
relay = Some(see_video_message(label, vid.clone()));
}
ToolOutcome {
result: s("result"),
diff: v
@@ -617,6 +649,20 @@ pub fn see_image_message(label: &str, img: Value) -> Message {
}
}
/// `seeVideoMessage` : la vidéo relayée au modèle.
pub fn see_video_message(label: &str, vid: Value) -> Message {
let text = if label.is_empty() {
"Vidéo :".to_string()
} else {
format!("Vidéo demandée ({label}) :")
};
Message {
role: "user".into(),
content: Some(json!([{"type": "text", "text": text}, vid])),
..Default::default()
}
}
impl GoTurnHost {
/// Les outils exécutés par la façade, sauf `subagent` ; le reste par le
/// Go (`/tool/exec`). Aussi celui des tours d'un sous-agent.
@@ -751,6 +797,45 @@ impl GoTurnHost {
};
}
}
// see_video (chat_video_tool.go) : comme see_image, dans le dossier de
// la discussion ; la vidéo relayée au modèle, toujours éphémère.
if name == "see_video" {
let cfg = self.shared.store.config().await;
let vision = crate::system::vision_enabled(&cfg);
let st = crate::tools::GoToolState {
store: self.shared.store.clone(),
caps: self.caps.clone(),
};
match crate::tools::ToolState::context(&st).await {
Ok(ctx) => {
let home = self.shared.images.home();
let vcfg = cfg.clone();
let video_ok = tokio::task::spawn_blocking(move || {
crate::imagetools::video_input_supported(&vcfg, &home)
})
.await
.unwrap_or(false);
let sees = self.shared.vision.sees(&self.shared.store, &cfg);
let (result, vid) = crate::imagetools::see_video(
label,
&ctx.cwd,
&ctx.target,
vision,
video_ok,
sees,
)
.await;
return ToolOutcome {
result,
relay: vid.map(|v| see_video_message(label, v)),
..Default::default()
};
}
Err(e) => {
eprintln!("[outils] contexte indisponible, see_video par le Go : {e}")
}
}
}
// Navigateur piloté (lot 44) : la session est ici ; aperçu de la page
// après une action qui la change (jamais après une erreur), capture
// relayée au modèle.
+6 -3
View File
@@ -141,10 +141,13 @@ pub fn steer_system(msgs: &[Message], hint: &str) -> Vec<Message> {
}
/// `dedupableTool` : un appel identique se court-circuite, sauf bash et ses
/// jobs (effets de bord), see_image et le navigateur (état vivant, image à
/// part).
/// jobs (effets de bord), see_image, see_video et le navigateur (état
/// vivant, image ou vidéo à part).
pub fn dedupable_tool(name: &str) -> bool {
!matches!(name, "bash" | "bash_bg" | "bash_tail" | "see_image") && !name.starts_with("browser_")
!matches!(
name,
"bash" | "bash_bg" | "bash_tail" | "see_image" | "see_video"
) && !name.starts_with("browser_")
}
/// `repeatedCallResult` : réponse à un appel RIGOUREUSEMENT identique déjà
+22 -7
View File
@@ -49,7 +49,7 @@ use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value;
use tokio::sync::watch;
use super::compact::{IMAGE_LOST_MARKER, is_loki_injected, without_ctx_update};
use super::compact::{IMAGE_LOST_MARKER, VIDEO_LOST_MARKER, is_loki_injected, without_ctx_update};
use super::journal::compact_log;
use super::types::{LogEvent, Message};
use crate::store::{CryptError, Store};
@@ -269,24 +269,39 @@ fn decode_list<T: DeserializeOwned>(v: Value) -> Vec<T> {
/// `stripImageParts` : un message multimodal persisté (base64 d'une capture
/// d'avant les captures éphémères) est aplati en son texte suivi de
/// [`IMAGE_LOST_MARKER`] — rejoué à chaque tour, il dépassait le contexte.
/// Une vidéo jointe (`input_video`) a le même sort, marquée par
/// [`VIDEO_LOST_MARKER`].
pub fn strip_image_parts(msgs: &mut [Message]) {
for m in msgs.iter_mut() {
let Some(Value::Array(parts)) = &m.content else {
continue;
};
let mut texts: Vec<&str> = Vec::new();
let mut dropped = false;
let (mut dropped, mut dropped_vid) = (false, false);
for p in parts.iter().filter_map(Value::as_object) {
if p.get("type").and_then(Value::as_str) == Some("image_url") {
dropped = true;
continue;
match p.get("type").and_then(Value::as_str) {
Some("image_url") => {
dropped = true;
continue;
}
Some("input_video") => {
dropped_vid = true;
continue;
}
_ => {}
}
if let Some(Value::String(t)) = p.get("text") {
texts.push(t);
}
}
if dropped {
let text = texts.join("\n") + IMAGE_LOST_MARKER;
if dropped || dropped_vid {
let mut text = texts.join("\n");
if dropped {
text += IMAGE_LOST_MARKER;
}
if dropped_vid {
text += VIDEO_LOST_MARKER;
}
m.content = Some(Value::String(text));
}
}
+1 -1
View File
@@ -621,7 +621,7 @@ pub fn tool_call_label(name: &str, args: &Map<String, Value>) -> String {
match name {
"mem_search" | "web_search" | "web_images" | "recall_search" => arg_str(args, "query"),
"mem_read" | "mem_add" | "mem_edit" | "edit" | "write" | "read" | "git_diff"
| "see_image" => arg_str(args, "file"),
| "see_image" | "see_video" => arg_str(args, "file"),
"recall" | "bash_tail" => arg_str(args, "id"),
"tracker" => (go_str(args.get("action")) + " " + &go_str(args.get("name")))
.trim()
+11 -1
View File
@@ -384,6 +384,13 @@ pub fn side_slot_pool_error(msg: &str) -> bool {
&& !REQ_EXCEEDS.is_match(msg)
}
/// Le relais porte-t-il une vidéo (see_video, partie `input_video`) ?
fn is_video_relay(m: &Message) -> bool {
super::wire::content_parts(m.content.as_ref())
.iter()
.any(|p| p.get("type").and_then(Value::as_str) == Some("input_video"))
}
fn text_msg(role: &str, text: impl Into<String>) -> Message {
Message {
role: role.into(),
@@ -1071,8 +1078,11 @@ async fn run_turn_loop<H: TurnHost>(
push_result(&mut messages, &mut extra, cap_tool_result(&ex.result));
if let Some(img) = ex.relay {
match kt.as_mut() {
// Éphémère : jamais dans l'historique.
// Éphémère : jamais dans l'historique. Une vidéo
// (see_video) l'est toujours, KEEP_TURN_IMAGES compris :
// le rangement ne sait garder que des images.
None => messages.push(img),
Some(_) if is_video_relay(&img) => messages.push(img),
// KEEP_TURN_IMAGES : gardée par référence, à l'essai
// jusqu'à la mesure de son coût.
Some(k) => {
+89 -14
View File
@@ -60,6 +60,10 @@ pub const TOOLS_OFF_HINT: &str = "Do not call any more tools. Answer now, direct
/// `imgRefScheme` : adresse d'une image rangée sur disque (chat_images.go).
pub const IMG_REF_SCHEME: &str = "loki-img:";
/// `vidRefScheme` : adresse d'une vidéo jointe rangée sur disque
/// (chat_video.go).
pub const VID_REF_SCHEME: &str = "loki-vid:";
/// Préfixes des messages système propres au projet ou à la conversation
/// (`isProjectSystem`).
pub const PROJECT_CONTEXT_PREFIX: &str = "Project context";
@@ -308,35 +312,61 @@ pub fn part_image_url(p: &Map<String, Value>) -> &str {
.unwrap_or("")
}
/// `expandImageRefs` : COPIE des messages prête pour le modèle — chaque
/// référence `loki-img:` redevient sa data-URL (`image_data_url`, qui tient
/// lieu de `chatImageDataURL` : disque, cache, déchiffrement). Les messages
/// sans référence sont repris tels quels ; aucune référence : pas de copie.
/// Une image introuvable devient une mention textuelle.
/// `partVideoURL` : l'adresse d'une partie `input_video` (`url`, sinon
/// `data`), "" pour toute autre partie (chat_video.go).
pub fn part_video_url(p: &Map<String, Value>) -> &str {
if p.get("type").and_then(Value::as_str) != Some("input_video") {
return "";
}
let Some(iv) = p.get("input_video").and_then(Value::as_object) else {
return "";
};
match iv.get("url").and_then(Value::as_str).unwrap_or("") {
"" => iv.get("data").and_then(Value::as_str).unwrap_or(""),
u => u,
}
}
/// `expandVideoRefs(expandImageRefs(…))` : COPIE des messages prête pour le
/// modèle — chaque référence `loki-img:` ou `loki-vid:` redevient sa
/// data-URL (`image_data_url`, qui tient lieu de `chatImageDataURL` et
/// `chatVideoDataURL` : disque, cache, déchiffrement). Les messages sans
/// référence sont repris tels quels ; aucune référence : pas de copie. Un
/// média introuvable devient une mention textuelle.
pub fn expand_image_refs<'a>(
msgs: &'a [Message],
image_data_url: &dyn Fn(&str) -> Option<String>,
) -> Cow<'a, [Message]> {
let is_ref = |p: &Map<String, Value>| {
part_image_url(p).starts_with(IMG_REF_SCHEME)
|| part_video_url(p).starts_with(VID_REF_SCHEME)
};
let mut out: Option<Vec<Message>> = None;
for (i, m) in msgs.iter().enumerate() {
let parts = content_parts(m.content.as_ref());
if !parts
.iter()
.any(|p| part_image_url(p).starts_with(IMG_REF_SCHEME))
{
if !parts.iter().any(|p| is_ref(p)) {
continue;
}
let mut np: Vec<Value> = Vec::with_capacity(parts.len());
for p in parts {
let u = part_image_url(p);
if !u.starts_with(IMG_REF_SCHEME) {
if !is_ref(p) {
np.push(Value::Object(p.clone()));
continue;
}
match image_data_url(u) {
Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})),
let u = part_image_url(p);
if u.starts_with(IMG_REF_SCHEME) {
match image_data_url(u) {
Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})),
None => np.push(
json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}),
),
}
continue;
}
match image_data_url(part_video_url(p)) {
Some(d) => np.push(json!({"type": "input_video", "input_video": {"url": d}})),
None => np.push(
json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}),
json!({"type": "text", "text": "[vidéo indisponible : fichier supprimé]"}),
),
}
}
@@ -1807,6 +1837,51 @@ mod tests {
));
}
// --- chat_video_test.go (partie envoi) ----------------------------------
#[test]
fn video_refs_round_trip() {
let url = "data:video/mp4;base64,AAAAGGZ0eXA=";
let reference = format!("{VID_REF_SCHEME}0123abcd.mp4");
let img_ref = format!("{IMG_REF_SCHEME}ab.png");
let set = vec![Message {
role: "user".into(),
content: Some(json!([
{"type": "text", "text": "regarde"},
{"type": "image_url", "image_url": {"url": img_ref}},
{"type": "input_video", "input_video": {"url": reference}},
{"type": "input_video", "input_video": {"data": "data:video/webm;base64,AA=="}}
])),
..Default::default()
}];
let resolver = |r: &str| {
if r == reference {
Some(url.to_string())
} else if r == img_ref {
Some("data:image/png;base64,AA==".to_string())
} else {
None
}
};
let out = expand_image_refs(&set, &resolver);
let parts = content_parts(out[0].content.as_ref());
assert_eq!(part_image_url(parts[1]), "data:image/png;base64,AA==");
assert_eq!(part_video_url(parts[2]), url);
assert_eq!(parts[2]["type"], "input_video");
// Vidéo déjà en ligne (champ data) : reprise telle quelle.
assert_eq!(part_video_url(parts[3]), "data:video/webm;base64,AA==");
let first = content_parts(set[0].content.as_ref());
assert!(
part_video_url(first[2]).starts_with(VID_REF_SCHEME),
"l'historique n'est pas touché"
);
let out = expand_image_refs(&set, &|_: &str| None);
assert_eq!(
content_parts(out[0].content.as_ref())[2]["text"],
json!("[vidéo indisponible : fichier supprimé]")
);
}
// --- chat_compact_cont_test.go ------------------------------------------
#[test]
+14
View File
@@ -194,6 +194,20 @@ pub fn strip_inline_images(msgs: Vec<Message>) -> Vec<Message> {
let e = elide_data_uri(url);
iu.insert("url".into(), Value::String(e));
}
// Vidéo jointe (input_video, chat_video.go) : même
// élision, sur ses deux champs.
if let Value::Object(part) = p
&& let Some(Value::Object(iv)) = part.get_mut("input_video")
{
for key in ["url", "data"] {
if let Some(Value::String(u)) = iv.get(key)
&& u.starts_with("data:")
{
let e = elide_data_uri(u);
iv.insert(key.into(), Value::String(e));
}
}
}
}
}
m
+163
View File
@@ -225,6 +225,169 @@ pub async fn see_image(
)
}
// --- see_video (chat_video.go, chat_video_tool.go) ------------------------------
/// `maxVideoBytes` : au-delà, des dizaines de milliers de jetons visuels.
pub const MAX_VIDEO_BYTES: i64 = 200 << 20;
/// `videoFallbackNote` : consigne quand le moteur ne lit pas la vidéo.
pub const VIDEO_FALLBACK_NOTE: &str = "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";
/// `videoMimes` : les vidéos qu'on sait montrer au modèle.
pub fn video_mime(name: &str) -> &'static str {
let ext = Path::new(name)
.extension()
.map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
.unwrap_or_default();
match ext.as_str() {
".mp4" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
".mkv" => "video/x-matroska",
".avi" => "video/x-msvideo",
_ => "",
}
}
/// `videoPart` : la partie `input_video` que lit llama-server.
pub fn video_part(b: &[u8], mime: &str) -> Value {
json!({
"type": "input_video",
"input_video": { "url": format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(b)) },
})
}
/// `videoInputSupported` : preset local avec projecteur (MMPROJ) et moteur
/// dont l'aide annonce `--video-fps`. Lit le disque et lance le moteur :
/// à appeler hors du runtime (`spawn_blocking`). `home` : `LOKI_HOME`.
pub fn video_input_supported(cfg: &crate::serve::Cfg, home: &Path) -> bool {
use crate::serve::{get, trim};
if crate::serve::is_external(cfg)
|| crate::serve::is_strata(cfg)
|| trim(get(cfg, "MMPROJ")).is_empty()
{
return false;
}
let mut bin = trim(get(cfg, "BIN")).to_string();
if bin.is_empty() {
return false;
}
if !crate::paths::go_is_abs(&bin) {
bin = home.join(&bin).to_string_lossy().into_owned();
}
let ep = crate::serve::run::EnginePaths {
home: home.to_path_buf(),
};
bin_help_has_video(&ep.prebuilt_resolve_bin(&bin))
}
/// `binHelpHasVideo` : l'aide du binaire, une fois par binaire et par date
/// de modification, chemin des bibliothèques passé à la seule commande.
pub fn bin_help_has_video(bin: &str) -> bool {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
let Ok(st) = std::fs::metadata(bin) else {
return false;
};
if st.is_dir() {
return false;
}
let key = format!("{bin}\0{:?}", st.modified().ok());
let cache = CACHE.get_or_init(Default::default);
if let Some(v) = cache.lock().ok().and_then(|c| c.get(&key).copied()) {
return v;
}
let mut env = crate::serve::run::Env::default();
let dir = Path::new(bin)
.parent()
.map(|d| d.to_string_lossy().into_owned())
.unwrap_or_default();
let (k, v) = crate::serve::run::library_path_value(&env, &dir);
env.set(&k, &v);
let help = crate::serve::run::bin_help(&env, bin);
let ok = help.contains("--video-fps");
if let Ok(mut c) = cache.lock() {
c.insert(key, ok);
}
ok
}
/// `toolSeeVideo` : (accusé, partie vidéo). Même ordre de refus que
/// [`see_image`] ; `video_ok` : [`video_input_supported`], `sees` : la sonde
/// du moteur, appelée en dernier.
pub async fn see_video(
path: &str,
cwd: &Path,
target: &str,
vision: bool,
video_ok: bool,
sees: impl std::future::Future<Output = bool>,
) -> (String, Option<Value>) {
let err = |m: String| (m, None);
if !vision {
return err("[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo".into());
}
if !target.is_empty() {
return err("[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)".into());
}
if path.is_empty() {
return err("[erreur] chemin de fichier manquant".into());
}
if !video_ok {
return err(format!(
"[erreur] {}",
VIDEO_FALLBACK_NOTE.trim_end_matches('\n')
));
}
let abs = crate::tools::policy::resolve_agent_path(cwd, path);
let mime = video_mime(&abs.to_string_lossy());
if mime.is_empty() {
return err(format!(
"[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : {path}"
));
}
let st = match std::fs::metadata(&abs) {
Ok(m) => m,
Err(_) => return err(format!("[erreur] fichier introuvable : {path}")),
};
if st.is_dir() {
return err(format!("[erreur] c'est un dossier, pas une vidéo : {path}"));
}
if st.len() as i64 > MAX_VIDEO_BYTES {
return err(format!(
"[erreur] vidéo trop lourde ({}, max {}) — coupe-la en un clip plus court : {path}",
crate::downloads::human_bytes(st.len() as i64),
crate::downloads::human_bytes(MAX_VIDEO_BYTES)
));
}
if !sees.await {
return err(format!(
"[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir {path}"
));
}
let b = match std::fs::read(&abs) {
Ok(b) => b,
Err(e) => {
return err(format!(
"[erreur] lecture impossible : {}",
crate::paths::os_err("open", &abs.to_string_lossy(), &e)
));
}
};
if b.is_empty() {
return err(format!("[erreur] fichier vide : {path}"));
}
let base = abs
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
(
format!("[ok] vidéo chargée : {base}"),
Some(video_part(&b, mime)),
)
}
// --- web_screenshot -------------------------------------------------------------
/// Le résultat de `web_screenshot` : le texte, et la capture rangée
-61
View File
@@ -48,10 +48,6 @@ static MANIFEST: Asset = Asset::new(
"application/manifest+json",
"public, max-age=3600",
);
/// KaTeX (formules LaTeX des réponses) : mêmes en-têtes que `web_katex.go`.
const KATEX_CACHE: &str = "public, max-age=86400";
static KATEX_JS: Asset = Asset::new(assets::KATEX_JS, "application/javascript", KATEX_CACHE);
static KATEX_CSS: Asset = Asset::new(assets::KATEX_CSS, "text/css; charset=utf-8", KATEX_CACHE);
/// Réponse statique pour cette requête, ou `None` si la route n'est pas une
/// ressource de l'UI (elle part alors au Go, qui rendra son 404 habituel).
@@ -80,9 +76,6 @@ pub fn serve(method: &Method, path: &str, headers: &HeaderMap) -> Option<Respons
}
"/manifest.webmanifest" => asset(&MANIFEST, accepts_gzip),
p if p.starts_with("/fonts/") => font(p)?,
"/katex/katex.min.js" => asset(&KATEX_JS, accepts_gzip),
"/katex/katex.min.css" => asset(&KATEX_CSS, accepts_gzip),
p if p.starts_with("/katex/") => katex_font(p)?,
_ => return None,
};
if accepts_gzip {
@@ -137,20 +130,6 @@ fn font(path: &str) -> Option<Response<Body>> {
Some(r)
}
/// `/katex/fonts/<nom>.woff2` — chemin exact (pas le dernier segment seul,
/// contrairement à `/fonts/`), comme `katexFile` dans `web_katex.go`. Le
/// reste de `/katex/` part au Go, qui rend son 404.
fn katex_font(path: &str) -> Option<Response<Body>> {
let rel = path.strip_prefix("/katex/")?;
let (_, body) = assets::KATEX_FONTS.iter().find(|(n, _)| *n == rel)?;
let mut r = Response::new(full(Bytes::from_static(body)));
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("font/woff2"));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(KATEX_CACHE));
h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len()));
Some(r)
}
fn compress(b: &[u8]) -> Vec<u8> {
let mut enc = GzEncoder::new(Vec::with_capacity(b.len() / 3), Compression::default());
enc.write_all(b).expect("gzip en mémoire");
@@ -214,46 +193,6 @@ mod tests {
assert!(get("/fonts/inter.ttf", false).is_none());
}
#[test]
fn katex() {
let r = get("/katex/katex.min.js", true).unwrap();
assert_eq!(hv(&r, "content-type"), "application/javascript");
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
assert_eq!(hv(&r, "content-encoding"), "gzip");
assert!(
String::from_utf8_lossy(assets::KATEX_JS).contains("version:\"0.16.11\""),
"KaTeX 0.16.11 attendu"
);
let r = get("/katex/katex.min.css", false).unwrap();
assert_eq!(hv(&r, "content-type"), "text/css; charset=utf-8");
// Chaque police woff2 demandée par la feuille est servie, non compressée.
let css = String::from_utf8_lossy(assets::KATEX_CSS);
let polices: Vec<&str> = css
.split("url(")
.skip(1)
.filter_map(|s| s.split(')').next())
.filter(|u| u.ends_with(".woff2"))
.collect();
assert!(polices.len() >= 10, "{} polices", polices.len());
for p in polices {
let r = get(&format!("/katex/{p}"), true).unwrap_or_else(|| panic!("{p}"));
assert_eq!(hv(&r, "content-type"), "font/woff2");
assert_eq!(hv(&r, "content-encoding"), "");
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
}
// Le reste part au Go (404) : ni index, ni licence, ni dernier segment seul.
for p in [
"/katex/",
"/katex/LICENSE",
"/katex/fonts/",
"/katex/fonts/absente.woff2",
"/katex/KaTeX_Main-Regular.woff2",
"/katex/x/fonts/KaTeX_Main-Regular.woff2",
] {
assert!(get(p, false).is_none(), "{p}");
}
}
#[test]
fn gzip_si_accepte() {
let r = get("/", true).unwrap();
+125 -125
View File
@@ -231,8 +231,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -260,7 +260,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -304,7 +304,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -335,8 +335,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -358,8 +358,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -387,8 +387,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -410,8 +410,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -433,8 +433,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -542,8 +542,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -571,7 +571,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -615,7 +615,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -855,8 +855,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -947,8 +947,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -993,8 +993,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1022,7 +1022,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -1066,7 +1066,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -1103,8 +1103,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1132,8 +1132,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1241,8 +1241,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1270,7 +1270,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -1314,7 +1314,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -1554,8 +1554,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1646,8 +1646,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1686,8 +1686,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16400.5,
"prompt_n": 32801,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1715,7 +1715,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -1759,7 +1759,7 @@
"max_tokens": 4096,
"messages": [
{
"content": "<131191 octets 64f00ccc3d63>",
"content": "<131201 octets e3226a117903>",
"role": "user"
}
],
@@ -1790,8 +1790,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16400.5,
"prompt_n": 32801,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1813,8 +1813,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16400.5,
"prompt_n": 32801,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1882,7 +1882,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2049,7 +2049,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2093,7 +2093,7 @@
"max_tokens": 16,
"messages": [
{
"content": "<3744 octets dff015dced82>",
"content": "<3745 octets 55a9fdff5d02>",
"role": "user"
}
],
@@ -2187,8 +2187,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -2216,7 +2216,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2260,7 +2260,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2291,8 +2291,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2314,8 +2314,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2368,7 +2368,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2420,8 +2420,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2443,8 +2443,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2497,7 +2497,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2541,7 +2541,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2572,8 +2572,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2595,8 +2595,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2649,7 +2649,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2693,7 +2693,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2747,8 +2747,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2770,8 +2770,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2824,7 +2824,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2868,7 +2868,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2992,8 +2992,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3015,8 +3015,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3069,7 +3069,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3121,8 +3121,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3144,8 +3144,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3189,8 +3189,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3218,7 +3218,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3287,7 +3287,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3319,8 +3319,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3342,8 +3342,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3383,8 +3383,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3412,7 +3412,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3457,7 +3457,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3489,8 +3489,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3512,8 +3512,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3579,8 +3579,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3608,7 +3608,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3652,7 +3652,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3737,8 +3737,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3760,8 +3760,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3823,7 +3823,7 @@
},
{
"body": {
"content": "<163781 octets 608b16075e1c>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3867,7 +3867,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8103 octets e6352865574a>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3952,8 +3952,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3975,8 +3975,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4034,8 +4034,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4057,8 +4057,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4089,8 +4089,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4112,8 +4112,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4135,8 +4135,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
+245
View File
@@ -0,0 +1,245 @@
{
"files": {
"CLIP.MOV": "AAAAFGZ0eXBxdCAgIG1vdg==",
"anim.webm": "GkXfoyB3ZWJt",
"clip.mp4": "AAAAGGZ0eXBtcDQyIGZhdXNzZSB2aWTDqW8=",
"film.mkv": "GkXfoyBtYXRyb3NrYQ==",
"sous/court.mp4": "ZnR5cCBzb3VzLWRvc3NpZXI=",
"texte.txt": "cGFzIHVuZSB2aWTDqW8=",
"vide.mp4": "",
"vieux.avi": "UklGRhAAAABBVkkgTElTVA=="
},
"steps": [
{
"name": "sans vision",
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo",
"probes": 0
},
{
"name": "chemin manquant",
"vision": true,
"video": true,
"args": {
"file": ""
},
"label": "",
"result": "[erreur] chemin de fichier manquant",
"probes": 0
},
{
"name": "sans argument",
"vision": true,
"video": true,
"args": {},
"label": "",
"result": "[erreur] chemin de fichier manquant",
"probes": 0
},
{
"name": "moteur sans vidéo",
"vision": true,
"sees": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] 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.",
"probes": 0
},
{
"name": "pas une vidéo",
"vision": true,
"video": true,
"args": {
"file": "texte.txt"
},
"label": "texte.txt",
"result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : texte.txt",
"probes": 0
},
{
"name": "image",
"vision": true,
"video": true,
"args": {
"file": "photo.png"
},
"label": "photo.png",
"result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : photo.png",
"probes": 0
},
{
"name": "introuvable",
"vision": true,
"video": true,
"args": {
"file": "absente.mp4"
},
"label": "absente.mp4",
"result": "[erreur] fichier introuvable : absente.mp4",
"probes": 0
},
{
"name": "dossier",
"vision": true,
"video": true,
"args": {
"file": "dossier.mp4"
},
"label": "dossier.mp4",
"result": "[erreur] c'est un dossier, pas une vidéo : dossier.mp4",
"probes": 0
},
{
"name": "trop lourde",
"vision": true,
"video": true,
"args": {
"file": "lourd.mp4"
},
"label": "lourd.mp4",
"result": "[erreur] vidéo trop lourde (200 Mo, max 200 Mo) — coupe-la en un clip plus court : lourd.mp4",
"probes": 0
},
{
"name": "le moteur ne voit pas",
"vision": true,
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir clip.mp4",
"probes": 1
},
{
"name": "vide",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "vide.mp4"
},
"label": "vide.mp4",
"result": "[erreur] fichier vide : vide.mp4",
"probes": 1
},
{
"name": "voir clip.mp4",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée (clip.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "clip.mp4"
},
"probes": 1
},
{
"name": "voir CLIP.MOV",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "CLIP.MOV"
},
"label": "CLIP.MOV",
"result": "[ok] vidéo chargée : CLIP.MOV\n[relais] Vidéo demandée (CLIP.MOV) :",
"part": {
"mime": "video/quicktime",
"same_as": "CLIP.MOV"
},
"probes": 1
},
{
"name": "voir anim.webm",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "anim.webm"
},
"label": "anim.webm",
"result": "[ok] vidéo chargée : anim.webm\n[relais] Vidéo demandée (anim.webm) :",
"part": {
"mime": "video/webm",
"same_as": "anim.webm"
},
"probes": 1
},
{
"name": "voir film.mkv",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "film.mkv"
},
"label": "film.mkv",
"result": "[ok] vidéo chargée : film.mkv\n[relais] Vidéo demandée (film.mkv) :",
"part": {
"mime": "video/x-matroska",
"same_as": "film.mkv"
},
"probes": 1
},
{
"name": "voir vieux.avi",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "vieux.avi"
},
"label": "vieux.avi",
"result": "[ok] vidéo chargée : vieux.avi\n[relais] Vidéo demandée (vieux.avi) :",
"part": {
"mime": "video/x-msvideo",
"same_as": "vieux.avi"
},
"probes": 1
},
{
"name": "voir sous/court.mp4",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "sous/court.mp4"
},
"label": "sous/court.mp4",
"result": "[ok] vidéo chargée : court.mp4\n[relais] Vidéo demandée (sous/court.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "sous/court.mp4"
},
"probes": 1
},
{
"name": "chemin absolu",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "{{FICHIERS}}/clip.mp4"
},
"label": "{{FICHIERS}}/clip.mp4",
"result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée ({{FICHIERS}}/clip.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "clip.mp4"
},
"probes": 1
}
]
}
+231
View File
@@ -0,0 +1,231 @@
//! see_video (repris d'AJEAN) tenu par la façade, rejoué sur le journal du
//! Go (`internal/loki/parity_video_tool_test.go` →
//! `tests/parity_images/video.json`) : mêmes fichiers, même faux moteur
//! (`/props`), même capacité vidéo du moteur, étape par étape. La vidéo
//! relayée se compare à l'octet (jamais réencodée).
mod common;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use bytes::Bytes;
use common::fakego;
use http_body_util::Full;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::imagetools::{self, VisionProbe};
use loki_rs::store::Store;
use serde_json::{Map, Value, json};
use tokio::net::TcpListener;
/// Le faux moteur : `/props` authentifié, la vision réglée par le test.
async fn fake_engine(sees: Arc<Mutex<(bool, u32)>>) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = l.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let sees = sees.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let sees = sees.clone();
async move {
let ok = req.uri().path() == "/props"
&& req
.headers()
.get("authorization")
.and_then(|h| h.to_str().ok())
== Some("Bearer cle-moteur");
let mut r = Response::new(Full::new(Bytes::new()));
if ok {
let mut s = sees.lock().unwrap();
s.1 += 1;
r = Response::new(Full::new(Bytes::from(
json!({ "modalities": { "vision": s.0 } }).to_string(),
)));
} else {
*r.status_mut() = hyper::StatusCode::UNAUTHORIZED;
}
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(st), svc)
.await;
});
}
});
port
}
/// `parityVideoFacts`.
fn part_facts(part: &Value, raw: &Map<String, Value>) -> Value {
let u = part["input_video"]["url"].as_str().unwrap();
let i = u.find(";base64,").unwrap();
let b = base64::engine::general_purpose::STANDARD
.decode(&u[i + 8..])
.unwrap();
let mut out = Map::new();
out.insert("mime".into(), json!(u[..i].trim_start_matches("data:")));
for (name, r) in raw {
let rb = base64::engine::general_purpose::STANDARD
.decode(r.as_str().unwrap_or(""))
.unwrap();
if rb == b {
out.insert("same_as".into(), json!(name));
}
}
Value::Object(out)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn see_video_comme_le_go() {
let fx: Value =
serde_json::from_str(include_str!("parity_images/video.json")).expect("journal");
let tmp = tempfile::tempdir().unwrap();
let files = tmp.path().join("fichiers");
let raw = fx["files"].as_object().unwrap();
for (name, b) in raw {
let p = files.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(
&p,
base64::engine::general_purpose::STANDARD
.decode(b.as_str().unwrap_or(""))
.unwrap(),
)
.unwrap();
}
std::fs::create_dir_all(files.join("dossier.mp4")).unwrap();
std::fs::File::create(files.join("lourd.mp4"))
.unwrap()
.set_len((imagetools::MAX_VIDEO_BYTES + 1) as u64)
.unwrap();
let files_s = files.to_string_lossy().replace('\\', "/");
let sees = Arc::new(Mutex::new((false, 0u32)));
let port = fake_engine(sees.clone()).await;
let (_, go, _stop) = fakego::start(tmp.path().join("home")).await;
let backend = go.backend.lock().unwrap().unwrap();
let store = Store::new(backend, fakego::JETON.into());
go.set("state", "api_key", "cle-moteur");
let mut cfg = std::collections::HashMap::new();
cfg.insert("PORT".to_string(), port.to_string());
let probe = VisionProbe::default();
for st in fx["steps"].as_array().unwrap() {
let name = st["name"].as_str().unwrap();
*sees.lock().unwrap() = (st["sees"] == true, 0);
probe.reset().await;
let label = st["label"]
.as_str()
.unwrap()
.replace("{{FICHIERS}}", &files_s);
let (mut result, vid) = imagetools::see_video(
&label,
&files,
"",
st["vision"] == true,
st["video"] == true,
probe.sees(&store, &cfg),
)
.await;
let relay = vid.map(|v| loki_rs::chat::host::see_video_message(&label, v));
let mut facts = None;
if let Some(parts) = relay
.as_ref()
.and_then(|m| m.content.as_ref())
.and_then(Value::as_array)
{
for p in parts {
if p["type"] == "input_video" {
facts = Some(part_facts(p, raw));
}
if p["type"] == "text" {
result += &format!("\n[relais] {}", p["text"].as_str().unwrap());
}
}
}
assert_eq!(
result.replace(&files_s, "{{FICHIERS}}"),
st["result"].as_str().unwrap(),
"{name}"
);
assert_eq!(facts, st.get("part").cloned(), "{name} : vidéo relayée");
assert_eq!(
sees.lock().unwrap().1 as u64,
st["probes"].as_u64().unwrap(),
"{name} : sondes du moteur"
);
}
}
/// `videoInputSupported` : jamais pour une API externe, Strata, un preset
/// sans projecteur ou sans moteur ; sinon l'aide du binaire fait foi, relue
/// quand il change.
#[test]
fn capacite_video_du_moteur() {
let tmp = tempfile::tempdir().unwrap();
let cfg = |kv: &[(&str, &str)]| -> std::collections::HashMap<String, String> {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
for c in [
cfg(&[("BIN", "llama-server"), ("MODEL", "m.gguf")]),
cfg(&[("MMPROJ", "mm.gguf"), ("MODEL", "m.gguf")]),
cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "absent/llama-server")]),
cfg(&[
("MMPROJ", "mm.gguf"),
("EXTERNAL", "1"),
("EXTERNAL_VISION", "1"),
]),
cfg(&[
("MMPROJ", "mm.gguf"),
("ENGINE", "strata"),
("BIN", "/bin/sh"),
]),
] {
assert!(
!imagetools::video_input_supported(&c, tmp.path()),
"{c:?} : vidéo annoncée"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let bin = tmp.path().join("llama-server");
let write = |help: &str, age: u64| {
std::fs::write(&bin, format!("#!/bin/sh\necho '{help}'\n")).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let f = std::fs::File::options().write(true).open(&bin).unwrap();
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age))
.unwrap();
};
let c = cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "llama-server")]);
write("--mmproj FILE", 3600);
assert!(!imagetools::video_input_supported(&c, tmp.path()));
write("--video-fps N target fps", 0);
assert!(
imagetools::video_input_supported(&c, tmp.path()),
"moteur mis à jour : --video-fps non relu"
);
}
}
#[test]
fn types_de_video() {
for (f, m) in [
("a.mp4", "video/mp4"),
("A.MOV", "video/quicktime"),
("x/b.webm", "video/webm"),
("c.mkv", "video/x-matroska"),
("d.avi", "video/x-msvideo"),
("e.png", ""),
("mp4", ""),
] {
assert_eq!(imagetools::video_mime(f), m, "{f}");
}
}