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
41 changed files with 2188 additions and 1585 deletions

No files matched your search

+9
View File
@@ -52,6 +52,15 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
La **compréhension des vidéos** est reprise d'AJEAN (PR #100 de Trombo38 et
son correctif « vidéo envoyée seulement si le moteur sait la lire ») :
l'outil `see_video` et les vidéos jointes (`chat_video.go`,
`chat_video_tool.go`), lues par llama.cpp avec `--video-fps` et ffmpeg ;
ailleurs, la vidéo reste un fichier, avec la marche à suivre (ffmpeg puis
`see_image`). Adaptée à Loki : vidéo de `see_video` toujours éphémère, vidéos
jointes chiffrées comme le fil et retirées d'un fil rouvert, outil tenu aussi
par la façade Rust (`imagetools.rs`).
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
+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"},
},
}}
}
+3 -7
View File
@@ -37,15 +37,11 @@ var facadeMCPRoutes = map[string]bool{
"/api/mcp/toggle": true,
"/api/mcp/tool": true,
"/api/mcp/test": true,
// Les délais globaux : la façade tient le pool, c'est elle qui doit
// fermer ses sessions quand ils changent.
"/api/mcp/timeouts": true,
"/api/mcp/timeouts/save": true,
}
// facadeMCPJSON appelle l'API interne MCP de la façade et décode sa réponse.
// Délai large, simple garde-fou : la façade borne elle-même la connexion
// (1 h au plus) et l'appel, nouvel essai compris (mcpCallTimeoutMax chacun).
// Délai large : un appel d'outil MCP peut durer (mcpCallTimeout), une
// première connexion npx/uvx aussi (mcpConnectTimeoutCold).
func facadeMCPJSON(path string, body any, out any) error {
b, err := json.Marshal(body)
if err != nil {
@@ -57,7 +53,7 @@ func facadeMCPJSON(path string, body any, out any) error {
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(internalTokenHeader, internalToken())
resp, err := (&http.Client{Timeout: mcpTimeoutMaxSec*time.Second + 2*mcpCallTimeoutMax}).Do(req)
resp, err := (&http.Client{Timeout: mcpConnectTimeoutCold + mcpCallTimeout}).Do(req)
if err != nil {
return err
}
+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}
}
+9 -14
View File
@@ -74,27 +74,22 @@ func TestMCPCallHonorsTurnCancel(t *testing.T) {
}
func TestMCPCallTimeoutFor(t *testing.T) {
def := MCPTimeouts{}.normalized()
cases := []struct {
cfg MCPServerConfig
t MCPTimeouts
args map[string]any
want time.Duration
}{
{MCPServerConfig{}, def, nil, 120 * time.Second},
{MCPServerConfig{CallTimeoutSec: 600}, def, nil, 10 * time.Minute},
{MCPServerConfig{}, def, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes
{MCPServerConfig{}, def, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes
{MCPServerConfig{}, def, map[string]any{"timeout": 5000.0}, 120 * time.Second}, // 5 s : le défaut reste
{MCPServerConfig{}, def, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne
{MCPServerConfig{}, def, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond
{MCPServerConfig{CallTimeoutSec: 30}, def, nil, 30 * time.Second}, // plus court que le défaut
{MCPServerConfig{}, MCPTimeouts{Connect: 20, Call: 45}, nil, 45 * time.Second}, // réglage global
{MCPServerConfig{CallTimeoutSec: 90}, MCPTimeouts{Connect: 20, Call: 45}, nil, 90 * time.Second},
{MCPServerConfig{}, MCPTimeouts{Connect: 20, Call: 3600}, nil, time.Hour}, // pas rogné par le plafond
{MCPServerConfig{}, nil, mcpCallTimeout},
{MCPServerConfig{CallTimeoutSec: 600}, nil, 10 * time.Minute},
{MCPServerConfig{}, map[string]any{"timeout": 300.0}, 330 * time.Second}, // secondes
{MCPServerConfig{}, map[string]any{"timeout": 200000.0}, 230 * time.Second}, // millisecondes
{MCPServerConfig{}, map[string]any{"timeout": 5000.0}, mcpCallTimeout}, // 5 s : le défaut reste
{MCPServerConfig{}, map[string]any{"timeout": "400"}, 430 * time.Second}, // chaîne
{MCPServerConfig{}, map[string]any{"timeout": 86400.0 * 1000}, mcpCallTimeoutMax}, // plafond
{MCPServerConfig{CallTimeoutSec: 30}, nil, 30 * time.Second}, // plus court que le défaut
}
for i, c := range cases {
if got := mcpCallTimeoutFor(c.cfg, c.t, c.args); got != c.want {
if got := mcpCallTimeoutFor(c.cfg, c.args); got != c.want {
t.Errorf("cas %d : %s, attendu %s", i, got, c.want)
}
}
+26 -38
View File
@@ -36,17 +36,19 @@ import (
// pour router les appels sans avoir à re-parser des noms potentiellement
// ambigus.
// Les délais de connexion et d'appel sont réglables (mcp_timeouts.go) :
// 20 s et 120 s par défaut.
const (
// mcpConnectTimeout borne l'établissement d'une session (handshake initialize
// + tools/list). Un serveur qui rame ne doit pas figer le tour de chat.
mcpConnectTimeout = 20 * time.Second
// mcpConnectTimeoutCold : npx et uvx TÉLÉCHARGENT le paquet du serveur au
// premier lancement (le catalogue les utilise tous les deux). Sur un réseau
// ou un disque lents, ça dépasse largement 20 s, et l'utilisateur voyait
// « enregistré mais connexion échouée : context deadline exceeded » pour un
// serveur parfaitement sain. Les lancements suivants profitent du cache et
// retombent dans le régime normal.
// C'est un minimum : un délai de connexion réglé plus long l'emporte.
mcpConnectTimeoutCold = 3 * time.Minute
// mcpCallTimeout borne un appel d'outil MCP.
mcpCallTimeout = 120 * time.Second
)
// mcpOutputCap borne la sortie d'un outil MCP renvoyée au modèle. L'amont
@@ -192,12 +194,11 @@ func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession {
m.mu.Unlock()
// Connexion hors-lock (peut être lente).
timeouts := LoadMCPTimeouts()
ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg, timeouts))
ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg))
defer cancel()
sess, err := mcpConnect(ctx, name, cfg)
if err != nil {
s.err = mcpFriendlyErr(err, cfg, timeouts)
s.err = mcpFriendlyErr(err, cfg)
} else {
s.sess = sess
lt, lerr := sess.ListTools(ctx, nil)
@@ -390,7 +391,7 @@ func mcpCall(parent context.Context, name string, args map[string]any) string {
}
servers, _ := LoadMCPConfig()
cfg, exists := servers[ref.server]
timeout := mcpCallTimeoutFor(cfg, LoadMCPTimeouts(), args)
timeout := mcpCallTimeoutFor(cfg, args)
result, err := mcpCallOnce(parent, ref, args, timeout)
if err != nil {
@@ -423,16 +424,14 @@ func mcpCall(parent context.Context, name string, args map[string]any) string {
// mcpCallTimeoutMax plafonne le délai d'un appel, quoi que demandent l'outil
// ou la config : au-delà, c'est un job d'arrière-plan, pas un appel d'outil.
// Il suit la borne des réglages (1 h) : un délai réglé à 3600 s n'est pas
// rogné en silence.
const mcpCallTimeoutMax = mcpTimeoutMaxSec * time.Second
const mcpCallTimeoutMax = 30 * time.Minute
// mcpCallTimeoutFor : délai d'un appel. Le réglage global (t.Call), remplacé
// par CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même
// mcpCallTimeoutFor : délai d'un appel. Défaut mcpCallTimeout, remplacé par
// CallTimeoutSec du serveur ; un argument « timeout » de l'outil lui-même
// (beaucoup en ont un : navigateur, build, requête) l'allonge de 30 s pour que
// l'outil ait le temps de rendre SA propre erreur de délai.
func mcpCallTimeoutFor(cfg MCPServerConfig, t MCPTimeouts, args map[string]any) time.Duration {
d := t.call()
func mcpCallTimeoutFor(cfg MCPServerConfig, args map[string]any) time.Duration {
d := mcpCallTimeout
if cfg.CallTimeoutSec > 0 {
d = time.Duration(cfg.CallTimeoutSec) * time.Second
}
@@ -599,8 +598,6 @@ type MCPServerStatus struct {
Env map[string]string `json:"env,omitempty"`
URL string `json:"url,omitempty"`
Headers map[string]string `json:"headers,omitempty"`
// CallTimeoutSec : délai d'un appel propre au serveur (0 = réglage global).
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
}
// MCPStatus renvoie l'état de tous les serveurs configurés, en tentant de
@@ -624,9 +621,7 @@ func MCPStatus() ([]MCPServerStatus, error) {
URL: cfg.URL,
Headers: cfg.Headers,
Tools: []string{},
CallTimeoutSec: cfg.CallTimeoutSec,
Disabled: cfg.DisabledTools,
Disabled: cfg.DisabledTools,
}
if st.Disabled == nil {
st.Disabled = []string{}
@@ -648,36 +643,29 @@ func MCPStatus() ([]MCPServerStatus, error) {
return out, nil
}
// mcpTimeoutFor choisit le délai de connexion d'un serveur : le réglage
// global, mais au moins mcpConnectTimeoutCold pour un lanceur qui télécharge
// son paquet à la volée (npx, uvx).
func mcpTimeoutFor(cfg MCPServerConfig, t MCPTimeouts) time.Duration {
if mcpColdLauncher(cfg) {
return max(t.connect(), mcpConnectTimeoutCold)
}
return t.connect()
}
// mcpColdLauncher : npx ou uvx, qui téléchargent le paquet au premier
// lancement.
func mcpColdLauncher(cfg MCPServerConfig) bool {
// mcpTimeoutFor choisit le délai de connexion d'un serveur : long pour un
// lanceur qui télécharge son paquet à la volée (npx, uvx), court sinon.
func mcpTimeoutFor(cfg MCPServerConfig) time.Duration {
base := strings.ToLower(filepath.Base(cfg.Command))
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
return base == "npx" || base == "uvx"
if base == "npx" || base == "uvx" {
return mcpConnectTimeoutCold
}
return mcpConnectTimeout
}
// mcpFriendlyErr habille l'erreur de connexion la plus vécue : le délai
// dépassé d'un npx/uvx en plein téléchargement. L'erreur brute (« context
// deadline exceeded ») envoyait l'utilisateur chercher un bug de config alors
// qu'il suffit souvent de réessayer une fois le paquet en cache.
func mcpFriendlyErr(err error, cfg MCPServerConfig, t MCPTimeouts) error {
func mcpFriendlyErr(err error, cfg MCPServerConfig) error {
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
return err
}
if mcpColdLauncher(cfg) {
base := strings.ToLower(filepath.Base(cfg.Command))
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
base := strings.ToLower(filepath.Base(cfg.Command))
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
if base == "npx" || base == "uvx" {
return fmt.Errorf("délai dépassé — %s télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", base)
}
return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s (délai de connexion réglable dans les réglages MCP)", t.connect())
return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s", mcpConnectTimeout)
}
+1 -1
View File
@@ -52,7 +52,7 @@ type MCPServerConfig struct {
DisabledTools []string `json:"disabledTools,omitempty"`
// CallTimeoutSec : délai d'un appel d'outil de ce serveur, en secondes.
// 0 = réglage global (MCPTimeouts.Call). Pour un serveur dont les outils sont longs
// 0 = défaut (mcpCallTimeout). Pour un serveur dont les outils sont longs
// par nature (build, scraping, requête lourde).
CallTimeoutSec int `json:"callTimeoutSec,omitempty"`
}
-89
View File
@@ -1,89 +0,0 @@
package loki
import (
"time"
)
// Délais MCP réglables depuis l'UI (repris d'AJEAN c05a0a9f) : connexion d'une
// session et appel d'outil, en secondes, communs à tous les serveurs. Un
// serveur peut remplacer le délai d'appel par le sien (CallTimeoutSec), et un
// lancement npx/uvx garde au moins mcpConnectTimeoutCold pour se connecter.
// MCPTimeouts : les délais globaux, en secondes. Stockés dans bkState sous
// la clé mcpTimeoutsKey ; zéro (ou clé absente) = valeur par défaut.
type MCPTimeouts struct {
// Connect borne l'établissement d'une session (handshake initialize +
// tools/list). Un serveur qui rame ne doit pas figer le tour de chat.
Connect int `json:"connect"`
// Call borne un appel d'outil MCP.
Call int `json:"call"`
}
const (
// Valeurs par défaut, en secondes (les anciennes constantes).
mcpConnectTimeoutDefault = 20
mcpCallTimeoutDefault = 120
// mcpTimeoutMaxSec : borne haute des réglages (1 h), globaux comme par
// serveur.
mcpTimeoutMaxSec = 3600
mcpTimeoutsKey = "mcp_timeouts"
)
// LoadMCPTimeouts lit les délais globaux. Une valeur absente, nulle ou
// illisible retombe sur son défaut ; une valeur hors bornes (base modifiée à
// la main) est ramenée à mcpTimeoutMaxSec.
func LoadMCPTimeouts() MCPTimeouts {
var t MCPTimeouts
if !getJSON(bkState, mcpTimeoutsKey, &t) {
t = MCPTimeouts{}
}
return t.normalized()
}
func (t MCPTimeouts) normalized() MCPTimeouts {
fix := func(v, def int) int {
switch {
case v <= 0:
return def
case v > mcpTimeoutMaxSec:
return mcpTimeoutMaxSec
}
return v
}
return MCPTimeouts{
Connect: fix(t.Connect, mcpConnectTimeoutDefault),
Call: fix(t.Call, mcpCallTimeoutDefault),
}
}
// SaveMCPTimeouts enregistre les délais globaux (valeurs déjà validées par
// l'appelant ; normalisées par sûreté).
func SaveMCPTimeouts(t MCPTimeouts) error {
mcpConfigMu.Lock()
defer mcpConfigMu.Unlock()
return putJSON(bkState, mcpTimeoutsKey, t.normalized())
}
// connect et call : les délais en durées.
func (t MCPTimeouts) connect() time.Duration { return time.Duration(t.Connect) * time.Second }
func (t MCPTimeouts) call() time.Duration { return time.Duration(t.Call) * time.Second }
// mcpTimeoutsError : le refus d'un réglage hors bornes, nil sinon. Même texte
// pour la façade (mcp/mod.rs).
func mcpTimeoutsError(t MCPTimeouts) string {
if t.Connect < 1 || t.Connect > mcpTimeoutMaxSec {
return "connexion : 1 à 3600 s"
}
if t.Call < 1 || t.Call > mcpTimeoutMaxSec {
return "appel d'outil : 1 à 3600 s"
}
return ""
}
// mcpCallTimeoutSecError : le refus d'un délai par serveur hors bornes.
func mcpCallTimeoutSecError(sec int) string {
if sec < 0 || sec > mcpTimeoutMaxSec {
return "délai d'un appel : 0 à 3600 s (vide = réglage global)"
}
return ""
}
-171
View File
@@ -1,171 +0,0 @@
package loki
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
)
// Une base vide donne les délais par défaut : 20 s de connexion, 120 s
// d'appel.
func TestMCPTimeoutsDefautsSansConfig(t *testing.T) {
testHome(t)
got := LoadMCPTimeouts()
if got != (MCPTimeouts{Connect: 20, Call: 120}) {
t.Fatalf("défauts = %+v", got)
}
if d := mcpTimeoutFor(MCPServerConfig{Command: "node"}, got); d != 20*time.Second {
t.Errorf("connexion = %s", d)
}
if d := mcpCallTimeoutFor(MCPServerConfig{}, got, nil); d != 120*time.Second {
t.Errorf("appel = %s", d)
}
}
// Enregistrés puis relus ; une valeur nulle retombe sur son défaut, une
// valeur trop grande (base modifiée à la main) sur la borne, une valeur
// illisible sur les défauts.
func TestMCPTimeoutsAllerRetour(t *testing.T) {
testHome(t)
if err := SaveMCPTimeouts(MCPTimeouts{Connect: 5, Call: 300}); err != nil {
t.Fatal(err)
}
if got := LoadMCPTimeouts(); got != (MCPTimeouts{Connect: 5, Call: 300}) {
t.Fatalf("relecture = %+v", got)
}
for raw, want := range map[string]MCPTimeouts{
`{"connect":0,"call":0}`: {20, 120},
`{"connect":-4,"call":99999}`: {20, 3600},
`{"connect":"x","call":50}`: {20, 120},
`{"CONNECT":7}`: {7, 120},
`null`: {20, 120},
`{"connect":30,"call":40} reste`: {20, 120},
} {
if err := putStr(bkState, mcpTimeoutsKey, raw); err != nil {
t.Fatal(err)
}
if got := LoadMCPTimeouts(); got != want {
t.Errorf("%s : %+v, attendu %+v", raw, got, want)
}
}
}
// Un lanceur npx/uvx garde au moins mcpConnectTimeoutCold ; un délai réglé
// plus long l'emporte.
func TestMCPTimeoutForLanceurFroid(t *testing.T) {
npx := MCPServerConfig{Command: "npx"}
if d := mcpTimeoutFor(npx, MCPTimeouts{Connect: 20, Call: 120}); d != mcpConnectTimeoutCold {
t.Errorf("npx, 20 s : %s", d)
}
if d := mcpTimeoutFor(npx, MCPTimeouts{Connect: 600, Call: 120}); d != 10*time.Minute {
t.Errorf("npx, 600 s : %s", d)
}
if d := mcpTimeoutFor(MCPServerConfig{URL: "http://x"}, MCPTimeouts{Connect: 45, Call: 120}); d != 45*time.Second {
t.Errorf("http, 45 s : %s", d)
}
}
func mcpRoute(t *testing.T, h func(w *httptest.ResponseRecorder), want int) map[string]any {
t.Helper()
w := httptest.NewRecorder()
h(w)
var out map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("réponse %q : %v", w.Body.String(), err)
}
if w.Code != want {
t.Fatalf("code %d, attendu %d : %s", w.Code, want, w.Body.String())
}
return out
}
// Les routes des délais globaux : lecture, refus hors bornes, enregistrement
// qui ferme les sessions ouvertes.
func TestMCPTimeoutsRoutes(t *testing.T) {
testHome(t)
t.Setenv(facadeMCPEnv, "")
get := func() map[string]any {
return mcpRoute(t, func(w *httptest.ResponseRecorder) {
handleMCPTimeouts(w, httptest.NewRequest("GET", "/api/mcp/timeouts", nil))
}, 200)
}
post := func(body string, want int) map[string]any {
return mcpRoute(t, func(w *httptest.ResponseRecorder) {
handleMCPTimeoutsSave(w, httptest.NewRequest("POST", "/api/mcp/timeouts/save", strings.NewReader(body)))
}, want)
}
if v := get(); v["connect"] != 20.0 || v["call"] != 120.0 {
t.Fatalf("GET : %v", v)
}
for body, msg := range map[string]string{
`{"connect":0,"call":120}`: "connexion : 1 à 3600 s",
`{"connect":20,"call":3601}`: "appel d'outil : 1 à 3600 s",
`{"connect":20}`: "appel d'outil : 1 à 3600 s",
`{"connect":"20","call":120}`: "json: cannot unmarshal string into Go struct field MCPTimeouts.connect of type int",
} {
if v := post(body, 400); v["error"] != msg {
t.Errorf("%s : %v", body, v["error"])
}
}
// Une session ouverte sur un serveur configuré : fermée à l'enregistrement.
if err := SetMCPServer("lent", MCPServerConfig{URL: "http://127.0.0.1:1/mcp", Enabled: false}); err != nil {
t.Fatal(err)
}
fakeMCPSlow(t)
if v := post(`{"connect":30,"call":600}`, 200); v["connect"] != 30.0 || v["call"] != 600.0 {
t.Fatalf("POST : %v", v)
}
mcpMgr.mu.Lock()
_, open := mcpMgr.sessions["lent"]
mcpMgr.mu.Unlock()
if open {
t.Error("la session est restée ouverte")
}
if v := get(); v["connect"] != 30.0 || v["call"] != 600.0 {
t.Fatalf("GET après enregistrement : %v", v)
}
if got := getStr(bkState, mcpTimeoutsKey); got != `{"connect":30,"call":600}` {
t.Errorf("stocké : %s", got)
}
}
// Le délai d'un serveur passe par /api/mcp/save, revient dans /api/mcp et
// survit à un nouvel enregistrement sans lui (0 = réglage global).
func TestMCPSaveCallTimeoutSec(t *testing.T) {
testHome(t)
t.Setenv(facadeMCPEnv, "")
t.Cleanup(mcpCloseAll)
save := func(body string, want int) map[string]any {
return mcpRoute(t, func(w *httptest.ResponseRecorder) {
handleMCPSave(w, httptest.NewRequest("POST", "/api/mcp/save", strings.NewReader(body)))
}, want)
}
v := save(`{"name":"long","url":"http://127.0.0.1:1/mcp","enabled":false,"callTimeoutSec":900}`, 200)
srv := v["servers"].([]any)[0].(map[string]any)
if srv["callTimeoutSec"] != 900.0 {
t.Fatalf("état : %v", srv)
}
servers, _ := LoadMCPConfig()
if servers["long"].CallTimeoutSec != 900 {
t.Fatalf("config : %+v", servers["long"])
}
st, _ := MCPStatus()
if len(st) != 1 || st[0].CallTimeoutSec != 900 {
t.Fatalf("MCPStatus : %+v", st)
}
// Champ vidé dans l'UI : retour au réglage global, clé absente.
v = save(`{"name":"long","url":"http://127.0.0.1:1/mcp","enabled":false}`, 200)
if _, ok := v["servers"].([]any)[0].(map[string]any)["callTimeoutSec"]; ok {
t.Fatalf("callTimeoutSec resté : %v", v)
}
for _, body := range []string{
`{"name":"x","url":"http://h","callTimeoutSec":-1}`,
`{"name":"x","url":"http://h","callTimeoutSec":3601}`,
} {
if v := save(body, 400); v["error"] != "délai d'un appel : 0 à 3600 s (vide = réglage global)" {
t.Errorf("%s : %v", body, v["error"])
}
}
}
+5 -68
View File
@@ -91,76 +91,13 @@ func TestPariteMCP(t *testing.T) {
}
fx["prompt_line"] = lines
timeouts := map[string]any{}
def := MCPTimeouts{}.normalized()
for _, cmd := range []string{"npx", "/usr/local/bin/NPX", "uvx.exe", "npx.cmd", "node", "", "uvx-x", "C:/bin/uvx.EXE"} {
cfg := MCPServerConfig{Command: cmd}
timeouts[cmd] = map[string]any{"secs": int(mcpTimeoutFor(cfg, def).Seconds()),
"err": mcpFriendlyErr(context.DeadlineExceeded, cfg, def).Error()}
timeouts[cmd] = map[string]any{"secs": int(mcpTimeoutFor(cfg).Seconds()),
"err": mcpFriendlyErr(context.DeadlineExceeded, cfg).Error()}
}
fx["timeouts"] = timeouts
fx["friendly_other"] = mcpFriendlyErr(errors.New("autre"), MCPServerConfig{Command: "npx"}, def).Error()
// Délais globaux (mcp_timeouts) : lecture de la base, puis leur effet sur
// la connexion et sur l'appel.
var stored []any
for _, raw := range []string{"", `{"connect":5,"call":300}`, `{"connect":0,"call":0}`, `{"connect":-4,"call":99999}`,
`{"connect":"x","call":50}`, `{"CONNECT":7,"Call":8}`, `null`, `[1]`, `{"connect":30,"call":40} reste`,
`{"connect":1.5,"call":60}`, `{"connect":3600,"call":3600,"autre":1}`} {
_ = putStr(bkState, mcpTimeoutsKey, raw)
stored = append(stored, map[string]any{"in": raw, "out": LoadMCPTimeouts()})
}
fx["timeouts_stored"] = stored
var effects []any
for _, tm := range []MCPTimeouts{{20, 120}, {5, 45}, {600, 3600}, {180, 1}} {
for _, c := range []struct {
cfg MCPServerConfig
args string
}{
{MCPServerConfig{Command: "node"}, `{}`},
{MCPServerConfig{Command: "npx"}, `{}`},
{MCPServerConfig{URL: "http://h", CallTimeoutSec: 90}, `{}`},
{MCPServerConfig{Command: "uvx", CallTimeoutSec: 3600}, `{"timeout":7200}`},
{MCPServerConfig{URL: "http://h"}, `{"timeout":600}`},
{MCPServerConfig{URL: "http://h"}, `{"timeout":99999999}`},
} {
var args map[string]any
_ = json.Unmarshal([]byte(c.args), &args)
cfgJSON, _ := json.Marshal(c.cfg)
effects = append(effects, map[string]any{
"timeouts": tm, "cfg": string(cfgJSON), "args": c.args,
"connect": mcpTimeoutFor(c.cfg, tm).Milliseconds(),
"err": mcpFriendlyErr(context.DeadlineExceeded, c.cfg, tm).Error(),
"call": mcpCallTimeoutFor(c.cfg, tm, args).Milliseconds(),
})
}
}
fx["timeouts_effects"] = effects
_ = putStr(bkState, mcpTimeoutsKey, "")
var routes []any
for _, rq := range [][2]string{
{"/api/mcp/timeouts", ""},
{"/api/mcp/timeouts/save", `{"connect":0,"call":120}`},
{"/api/mcp/timeouts/save", `{"connect":20,"call":3601}`},
{"/api/mcp/timeouts/save", `{"connect":20}`},
{"/api/mcp/timeouts/save", `{"connect":"20","call":120}`},
{"/api/mcp/timeouts/save", `{"connect":2.5,"call":120}`},
{"/api/mcp/timeouts/save", `[]`},
{"/api/mcp/timeouts/save", `pas du json`},
{"/api/mcp/timeouts/save", `{"Connect":45,"CALL":900,"x":1}`},
{"/api/mcp/timeouts", ""},
{"/api/mcp/timeouts/save", `{"connect":3600,"call":1}`},
{"/api/mcp/timeouts", ""},
} {
w := httptest.NewRecorder()
if rq[0] == "/api/mcp/timeouts" {
handleMCPTimeouts(w, httptest.NewRequest("GET", rq[0], nil))
} else {
handleMCPTimeoutsSave(w, httptest.NewRequest("POST", rq[0], strings.NewReader(rq[1])))
}
routes = append(routes, map[string]any{"path": rq[0], "body": rq[1], "code": w.Code,
"resp": strings.TrimSpace(w.Body.String()), "state": getStr(bkState, mcpTimeoutsKey)})
}
fx["timeouts_routes"] = routes
_ = putStr(bkState, mcpTimeoutsKey, "")
fx["friendly_other"] = mcpFriendlyErr(errors.New("autre"), MCPServerConfig{Command: "npx"}).Error()
var flat []any
for _, raw := range []string{
`{"content":[{"type":"text","text":" un "},{"type":"text","text":"deux\n"}]}`,
@@ -211,7 +148,7 @@ func TestPariteMCP(t *testing.T) {
{Op: "set", Name: "a__b", Cfg: `{"command":"x"}`},
{Op: "set", Name: "both", Cfg: `{"command":"x","url":"http://u"}`},
{Op: "set", Name: "none", Cfg: `{"enabled":true}`},
{Op: "set", Name: "web", Cfg: `{"url":"http://u/mcp","headers":{"X":"1"},"enabled":false,"callTimeoutSec":45}`},
{Op: "set", Name: "web", Cfg: `{"url":"http://u/mcp","headers":{"X":"1"},"enabled":false}`},
{Op: "enable", Name: "web", On: true},
{Op: "enable", Name: "nope", On: true},
{Op: "tool", Name: "fs", Tool: "write", On: false},
@@ -348,7 +285,7 @@ func TestPariteMCP(t *testing.T) {
}
_ = SetMCPToolEnabled("alpha", "secret", false)
_ = SetMCPServer("beta", MCPServerConfig{Command: "loki-mcp-absent-pour-test", Enabled: true})
_ = SetMCPServer("gamma", MCPServerConfig{URL: ts.URL, Enabled: false, CallTimeoutSec: 300})
_ = SetMCPServer("gamma", MCPServerConfig{URL: ts.URL, Enabled: false})
raw := getStr(bkState, "mcp")
raw = strings.Replace(raw, `}}`, `},"delta":{"enabled":true}}`, 1)
_ = putStr(bkState, "mcp", raw)
+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 -66
View File
@@ -2773,17 +2773,6 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div id="mcp-block" class="gated-group">
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div class="slist">
<!-- Délais communs à tous les serveurs (AJEAN c05a0a9f, e44b6139),
enregistrés à chaque modification. Un serveur peut remplacer le
délai d'appel dans sa fenêtre d'édition. -->
<div class="pe-row">
<span class="pe-row-l">Délai de connexion<span class="pe-sub">pour joindre un serveur ; au moins 3 min au premier lancement npx/uvx</span></span>
<span class="pe-row-c"><input id="mcp-timeout-connect" class="pe-val" type="number" min="1" max="3600" step="1" value="20" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Délai d'un appel d'outil<span class="pe-sub">réglable aussi serveur par serveur</span></span>
<span class="pe-row-c"><input id="mcp-timeout-call" class="pe-val" type="number" min="1" max="3600" step="1" value="120" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="rowbody">
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
@@ -3303,10 +3292,6 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<textarea id="mcp-headers" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="Authorization: Bearer sk-…"></textarea>
</div>
</div>
<div class="fld">
<label class="fld-l" for="mcp-call-timeout">Délai d'un appel d'outil <span class="opt">(secondes, 1 à 3600 — vide = réglage global)</span></label>
<input id="mcp-call-timeout" class="ctl" type="number" min="1" max="3600" step="1" inputmode="numeric" placeholder="réglage global">
</div>
<div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div>
<div id="mcp-tools-section" style="display:none">
<div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400;font-family:var(--font)"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div>
@@ -9517,47 +9502,7 @@ async function deleteExternal(){
let mcpServers = [];
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
async function loadMCP(){
renderMCP(await jget('/api/mcp'));
loadMcpTimeouts();
}
// ─── Délais globaux ──────────────────────────────────────────────────────────
// Connexion et appel d'outil, communs à tous les serveurs (/api/mcp/timeouts).
// Un seul enregistrement par modification : « change » part à la validation
// ou à la perte du focus, pas à chaque frappe.
let mcpTimeouts = {connect: 20, call: 120};
async function loadMcpTimeouts(){
const r = await jget('/api/mcp/timeouts');
if(!r || !r.ok) return;
mcpTimeouts = {connect: r.connect, call: r.call};
document.getElementById('mcp-timeout-connect').value = r.connect;
document.getElementById('mcp-timeout-call').value = r.call;
mcpCallTimeoutPlaceholder();
}
async function saveMcpTimeouts(){
const connect = Number(document.getElementById('mcp-timeout-connect').value);
const call = Number(document.getElementById('mcp-timeout-call').value);
const ok = v => Number.isInteger(v) && v >= 1 && v <= 3600;
if(!ok(connect) || !ok(call)){
toast('délai invalide : de 1 à 3600 s');
loadMcpTimeouts(); // revient aux valeurs enregistrées
return;
}
const r = await jpost('/api/mcp/timeouts/save', {connect, call});
if(!r || !r.ok){ toast('erreur : '+((r && r.error) || 'délais non enregistrés')); loadMcpTimeouts(); return; }
mcpTimeouts = {connect: r.connect, call: r.call};
mcpCallTimeoutPlaceholder();
toast('délais MCP enregistrés');
}
// Le champ « délai d'un appel » de la fenêtre d'édition montre le réglage
// global qu'il remplacerait.
function mcpCallTimeoutPlaceholder(){
document.getElementById('mcp-call-timeout').placeholder = 'réglage global ('+mcpTimeouts.call+' s)';
}
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
function renderMCP(r){
mcpServers = (r && r.servers) || [];
@@ -9730,7 +9675,6 @@ function openMcpFromCatalog(e){
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
document.getElementById('mcp-url').value = '';
document.getElementById('mcp-headers').value = '';
document.getElementById('mcp-call-timeout').value = '';
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
}
const st = document.getElementById('mcp-modal-status');
@@ -9758,8 +9702,6 @@ function openMcp(name){
document.getElementById('mcp-env').value = s && s.env ? Object.entries(s.env).map(([k,v])=>k+'='+v).join('\n') : '';
document.getElementById('mcp-url').value = s ? (s.url||'') : '';
document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : '';
document.getElementById('mcp-call-timeout').value = s && s.callTimeoutSec ? s.callTimeoutSec : '';
mcpCallTimeoutPlaceholder();
const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = '';
if(s && s.enabled && s.error){ st.textContent = '⚠ '+s.error; st.style.color = 'var(--err)'; }
@@ -9838,13 +9780,6 @@ async function saveMcp(){
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
body.command = ''; body.args = undefined; body.env = undefined;
}
// Vide = réglage global (0 côté serveur).
const ct = document.getElementById('mcp-call-timeout').value.trim();
if(ct){
const n = Number(ct);
if(!Number.isInteger(n) || n < 1 || n > 3600){ toast("délai d'un appel d'outil : de 1 à 3600 s, ou vide"); return; }
body.callTimeoutSec = n;
}
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
-15
View File
@@ -488,17 +488,6 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div id="mcp-block" class="gated-group">
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
<div class="slist">
<!-- Délais communs à tous les serveurs (AJEAN c05a0a9f, e44b6139),
enregistrés à chaque modification. Un serveur peut remplacer le
délai d'appel dans sa fenêtre d'édition. -->
<div class="pe-row">
<span class="pe-row-l">Délai de connexion<span class="pe-sub">pour joindre un serveur ; au moins 3 min au premier lancement npx/uvx</span></span>
<span class="pe-row-c"><input id="mcp-timeout-connect" class="pe-val" type="number" min="1" max="3600" step="1" value="20" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Délai d'un appel d'outil<span class="pe-sub">réglable aussi serveur par serveur</span></span>
<span class="pe-row-c"><input id="mcp-timeout-call" class="pe-val" type="number" min="1" max="3600" step="1" value="120" style="width:54px" onchange="saveMcpTimeouts()"><span class="pe-unit">s</span></span>
</div>
<div class="rowbody">
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
@@ -1018,10 +1007,6 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<textarea id="mcp-headers" class="ctl ta" spellcheck="false" style="min-height:44px" placeholder="Authorization: Bearer sk-…"></textarea>
</div>
</div>
<div class="fld">
<label class="fld-l" for="mcp-call-timeout">Délai d'un appel d'outil <span class="opt">(secondes, 1 à 3600 — vide = réglage global)</span></label>
<input id="mcp-call-timeout" class="ctl" type="number" min="1" max="3600" step="1" inputmode="numeric" placeholder="réglage global">
</div>
<div id="mcp-modal-status" class="muted" style="font-size:12px;min-height:16px"></div>
<div id="mcp-tools-section" style="display:none">
<div class="subhead" style="margin-top:6px">Outils <span id="mcp-tools-count" class="muted" style="text-transform:none;letter-spacing:0;font-weight:400;font-family:var(--font)"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Décoche un outil pour le masquer à l'IA sans déconnecter le serveur. Utile pour ne garder que ce dont tu as besoin.</span></span></div>
+1 -51
View File
@@ -5,47 +5,7 @@
let mcpServers = [];
let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau)
async function loadMCP(){
renderMCP(await jget('/api/mcp'));
loadMcpTimeouts();
}
// ─── Délais globaux ──────────────────────────────────────────────────────────
// Connexion et appel d'outil, communs à tous les serveurs (/api/mcp/timeouts).
// Un seul enregistrement par modification : « change » part à la validation
// ou à la perte du focus, pas à chaque frappe.
let mcpTimeouts = {connect: 20, call: 120};
async function loadMcpTimeouts(){
const r = await jget('/api/mcp/timeouts');
if(!r || !r.ok) return;
mcpTimeouts = {connect: r.connect, call: r.call};
document.getElementById('mcp-timeout-connect').value = r.connect;
document.getElementById('mcp-timeout-call').value = r.call;
mcpCallTimeoutPlaceholder();
}
async function saveMcpTimeouts(){
const connect = Number(document.getElementById('mcp-timeout-connect').value);
const call = Number(document.getElementById('mcp-timeout-call').value);
const ok = v => Number.isInteger(v) && v >= 1 && v <= 3600;
if(!ok(connect) || !ok(call)){
toast('délai invalide : de 1 à 3600 s');
loadMcpTimeouts(); // revient aux valeurs enregistrées
return;
}
const r = await jpost('/api/mcp/timeouts/save', {connect, call});
if(!r || !r.ok){ toast('erreur : '+((r && r.error) || 'délais non enregistrés')); loadMcpTimeouts(); return; }
mcpTimeouts = {connect: r.connect, call: r.call};
mcpCallTimeoutPlaceholder();
toast('délais MCP enregistrés');
}
// Le champ « délai d'un appel » de la fenêtre d'édition montre le réglage
// global qu'il remplacerait.
function mcpCallTimeoutPlaceholder(){
document.getElementById('mcp-call-timeout').placeholder = 'réglage global ('+mcpTimeouts.call+' s)';
}
async function loadMCP(){ renderMCP(await jget('/api/mcp')); }
function renderMCP(r){
mcpServers = (r && r.servers) || [];
@@ -218,7 +178,6 @@ function openMcpFromCatalog(e){
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
document.getElementById('mcp-url').value = '';
document.getElementById('mcp-headers').value = '';
document.getElementById('mcp-call-timeout').value = '';
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
}
const st = document.getElementById('mcp-modal-status');
@@ -246,8 +205,6 @@ function openMcp(name){
document.getElementById('mcp-env').value = s && s.env ? Object.entries(s.env).map(([k,v])=>k+'='+v).join('\n') : '';
document.getElementById('mcp-url').value = s ? (s.url||'') : '';
document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : '';
document.getElementById('mcp-call-timeout').value = s && s.callTimeoutSec ? s.callTimeoutSec : '';
mcpCallTimeoutPlaceholder();
const st = document.getElementById('mcp-modal-status');
st.textContent = ''; st.style.color = '';
if(s && s.enabled && s.error){ st.textContent = '⚠ '+s.error; st.style.color = 'var(--err)'; }
@@ -326,13 +283,6 @@ async function saveMcp(){
body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':');
body.command = ''; body.args = undefined; body.env = undefined;
}
// Vide = réglage global (0 côté serveur).
const ct = document.getElementById('mcp-call-timeout').value.trim();
if(ct){
const n = Number(ct);
if(!Number.isInteger(n) || n < 1 || n > 3600){ toast("délai d'un appel d'outil : de 1 à 3600 s, ou vide"); return; }
body.callTimeoutSec = n;
}
const st = document.getElementById('mcp-modal-status');
st.textContent = 'connexion…'; st.style.color = '';
const r = await jpost('/api/mcp/save', body);
-39
View File
@@ -1166,9 +1166,6 @@ type mcpSaveReq struct {
URL string `json:"url"`
Headers map[string]string `json:"headers"`
Enabled *bool `json:"enabled"`
// CallTimeoutSec : délai d'un appel propre au serveur, en secondes
// (0 ou absent = réglage global).
CallTimeoutSec int `json:"callTimeoutSec"`
}
// handleMCP renvoie l'état de tous les serveurs MCP configurés (connexion tentée
@@ -1244,10 +1241,6 @@ func handleMCPSave(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if msg := mcpCallTimeoutSecError(req.CallTimeoutSec); msg != "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": msg})
return
}
enabled := true
if req.Enabled != nil {
enabled = *req.Enabled
@@ -1259,8 +1252,6 @@ func handleMCPSave(w http.ResponseWriter, r *http.Request) {
URL: strings.TrimSpace(req.URL),
Headers: req.Headers,
Enabled: enabled,
CallTimeoutSec: req.CallTimeoutSec,
}
if err := SetMCPServer(req.Name, cfg); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
@@ -1343,36 +1334,6 @@ func handleMCPTest(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
}
// handleMCPTimeouts renvoie les délais MCP globaux (secondes).
func handleMCPTimeouts(w http.ResponseWriter, r *http.Request) {
t := LoadMCPTimeouts()
sendJSON(w, 200, map[string]any{"ok": true, "connect": t.Connect, "call": t.Call})
}
// handleMCPTimeoutsSave enregistre les délais MCP globaux (1 à 3600 s) et
// ferme les sessions ouvertes, pour que le délai de connexion s'applique dès
// la suivante.
func handleMCPTimeoutsSave(w http.ResponseWriter, r *http.Request) {
var req MCPTimeouts
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if msg := mcpTimeoutsError(req); msg != "" {
sendJSON(w, 400, map[string]any{"ok": false, "error": msg})
return
}
if err := SaveMCPTimeouts(req); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
servers, _ := LoadMCPConfig()
for name := range servers {
mcpInvalidate(name)
}
sendJSON(w, 200, map[string]any{"ok": true, "connect": req.Connect, "call": req.Call})
}
// handleMem / handleMemSave / handleMemDelete : éditeur web des pages mémoire
// (memory/<nom>.md). Payload partagé saveReq (name/old/content) ; "name" = nom
// de fichier de la page.
+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
}
-2
View File
@@ -334,8 +334,6 @@ func newWebMux() *http.ServeMux {
api("/api/mcp/toggle", handleMCPToggle)
api("/api/mcp/tool", handleMCPTool)
api("/api/mcp/test", handleMCPTest)
api("/api/mcp/timeouts", handleMCPTimeouts)
api("/api/mcp/timeouts/save", handleMCPTimeoutsSave)
api("/api/memory", handleMemoryMode)
// Tâches planifiées : l'IA exécute des consignes toute seule sur une fréquence
// réglable (tasks.go). Le planificateur tourne dans CE process — celui qui
+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,
+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
+32 -158
View File
@@ -26,7 +26,7 @@ pub struct ServerConfig {
#[serde(rename = "disabledTools", skip_serializing_if = "Vec::is_empty")]
pub disabled_tools: Vec<String>,
/// `CallTimeoutSec` : délai d'un appel d'outil de ce serveur, en
/// secondes ; 0 = réglage global ([`Timeouts::call`]).
/// secondes ; 0 = défaut ([`CALL_TIMEOUT`]).
#[serde(rename = "callTimeoutSec", skip_serializing_if = "is_zero")]
pub call_timeout_sec: i64,
}
@@ -285,107 +285,21 @@ pub fn prompt_line(tools: &[(String, String)]) -> String {
)
}
/// `mcpConnectTimeout`.
pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(20);
/// `mcpConnectTimeoutCold` : npx et uvx téléchargent le paquet au premier
/// lancement ; un minimum, un délai de connexion réglé plus long l'emporte.
/// lancement.
pub const CONNECT_TIMEOUT_COLD: Duration = Duration::from_secs(180);
/// `mcpTimeoutMaxSec` : borne haute des réglages (1 h).
pub const TIMEOUT_MAX_SECS: i64 = 3600;
/// `mcpCallTimeout`.
pub const CALL_TIMEOUT: Duration = Duration::from_secs(120);
/// `mcpCallTimeoutMax` : plafond d'un appel, quoi que demandent l'outil ou la
/// config ; il suit la borne des réglages.
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(TIMEOUT_MAX_SECS as u64);
/// `mcpTimeoutsKey` : clé des délais globaux (`state/mcp_timeouts`).
pub const ST_TIMEOUTS: &str = "mcp_timeouts";
/// config.
pub const CALL_TIMEOUT_MAX: Duration = Duration::from_secs(30 * 60);
/// `MCPTimeouts` : les délais globaux, en secondes (`mcp_timeouts.go`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Timeouts {
pub connect: i64,
pub call: i64,
}
impl Default for Timeouts {
/// 20 s de connexion, 120 s d'appel.
fn default() -> Self {
Timeouts {
connect: 0,
call: 0,
}
.normalized()
}
}
impl Timeouts {
/// `normalized` : nul ou négatif = défaut, au-delà de la borne = la borne.
pub fn normalized(self) -> Self {
let fix = |v: i64, def: i64| if v <= 0 { def } else { v.min(TIMEOUT_MAX_SECS) };
Timeouts {
connect: fix(self.connect, 20),
call: fix(self.call, 120),
}
}
pub fn connect(&self) -> Duration {
Duration::from_secs(self.connect.max(0) as u64)
}
pub fn call(&self) -> Duration {
Duration::from_secs(self.call.max(0) as u64)
}
/// `json.Marshal(MCPTimeouts)`.
pub fn marshal(&self) -> String {
format!(r#"{{"connect":{},"call":{}}}"#, self.connect, self.call)
}
}
/// Le type Go décodé (`MCPTimeouts`) et ses champs, pour [`crate::gojson`].
pub const TIMEOUTS_TYPE: &str = "MCPTimeouts";
pub const TIMEOUTS_FIELDS: [(&str, crate::gojson::Kind); 2] = [
("connect", crate::gojson::Kind::Int),
("call", crate::gojson::Kind::Int),
];
/// `LoadMCPTimeouts` : `getJSON` puis normalisation. Une valeur que
/// `json.Unmarshal` refuse, même en partie, donne les défauts.
pub fn decode_timeouts(raw: Option<&[u8]>) -> Timeouts {
let raw = raw.unwrap_or_default();
if raw.is_empty() || serde_json::from_slice::<Value>(raw).is_err() {
return Timeouts::default();
}
let d = crate::gojson::decode_struct(raw, TIMEOUTS_TYPE, &TIMEOUTS_FIELDS);
if d.err.is_some() {
return Timeouts::default();
}
Timeouts {
connect: d.int("connect"),
call: d.int("call"),
}
.normalized()
}
/// `mcpTimeoutsError` : le refus d'un réglage hors bornes.
pub fn timeouts_error(t: &Timeouts) -> Option<&'static str> {
if !(1..=TIMEOUT_MAX_SECS).contains(&t.connect) {
return Some("connexion : 1 à 3600 s");
}
if !(1..=TIMEOUT_MAX_SECS).contains(&t.call) {
return Some("appel d'outil : 1 à 3600 s");
}
None
}
/// `mcpCallTimeoutSecError` : le refus d'un délai par serveur hors bornes.
pub fn call_timeout_sec_error(sec: i64) -> Option<&'static str> {
if !(0..=TIMEOUT_MAX_SECS).contains(&sec) {
return Some("délai d'un appel : 0 à 3600 s (vide = réglage global)");
}
None
}
/// `mcpCallTimeoutFor` : le réglage global, remplacé par `callTimeoutSec` du
/// `mcpCallTimeoutFor` : [`CALL_TIMEOUT`], remplacé par `callTimeoutSec` du
/// serveur ; un argument `timeout` de l'outil l'allonge de 30 s.
pub fn call_timeout_for(cfg: &ServerConfig, tm: &Timeouts, args: &Map<String, Value>) -> Duration {
let mut d = tm.call();
pub fn call_timeout_for(cfg: &ServerConfig, args: &Map<String, Value>) -> Duration {
let mut d = CALL_TIMEOUT;
if cfg.call_timeout_sec > 0 {
d = Duration::from_secs(cfg.call_timeout_sec as u64);
}
@@ -440,27 +354,23 @@ fn launcher(cfg: &ServerConfig) -> String {
b.strip_suffix(".cmd").unwrap_or(b).to_string()
}
/// `mcpTimeoutFor` : le réglage global, au moins [`CONNECT_TIMEOUT_COLD`]
/// pour npx et uvx.
pub fn timeout_for(cfg: &ServerConfig, tm: &Timeouts) -> Duration {
/// `mcpTimeoutFor`.
pub fn timeout_for(cfg: &ServerConfig) -> Duration {
match launcher(cfg).as_str() {
"npx" | "uvx" => tm.connect().max(CONNECT_TIMEOUT_COLD),
_ => tm.connect(),
"npx" | "uvx" => CONNECT_TIMEOUT_COLD,
_ => CONNECT_TIMEOUT,
}
}
/// `mcpFriendlyErr` pour un délai dépassé.
pub fn timeout_error(cfg: &ServerConfig, tm: &Timeouts) -> String {
pub fn timeout_error(cfg: &ServerConfig) -> String {
let l = launcher(cfg);
if l == "npx" || l == "uvx" {
return format!(
"délai dépassé — {l} télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)"
);
}
format!(
"délai dépassé — le serveur n'a pas répondu au handshake en {} (délai de connexion réglable dans les réglages MCP)",
crate::tools::shell::go_duration(tm.connect())
)
"délai dépassé — le serveur n'a pas répondu au handshake en 20s".into()
}
/// `mcpOutputCap` : environ un caractère par jeton de contexte, jamais sous
@@ -624,24 +534,20 @@ mod tests {
};
let s = Duration::from_secs;
let none = Map::new();
let def = Timeouts::default();
let ct = |c: &ServerConfig, a: &Map<String, Value>| call_timeout_for(c, &def, a);
assert_eq!(ct(&cfg(0), &none), s(120));
assert_eq!(ct(&cfg(600), &none), s(600));
assert_eq!(ct(&cfg(0), &args(json!(300.0))), s(330));
assert_eq!(ct(&cfg(0), &args(json!(200000.0))), s(230));
assert_eq!(ct(&cfg(0), &args(json!(5000.0))), s(120));
assert_eq!(ct(&cfg(0), &args(json!("400"))), s(430));
assert_eq!(call_timeout_for(&cfg(0), &none), CALL_TIMEOUT);
assert_eq!(call_timeout_for(&cfg(600), &none), s(600));
assert_eq!(call_timeout_for(&cfg(0), &args(json!(300.0))), s(330));
assert_eq!(call_timeout_for(&cfg(0), &args(json!(200000.0))), s(230));
assert_eq!(
ct(&cfg(0), &args(json!(86400.0 * 1000.0))),
call_timeout_for(&cfg(0), &args(json!(5000.0))),
CALL_TIMEOUT
);
assert_eq!(call_timeout_for(&cfg(0), &args(json!("400"))), s(430));
assert_eq!(
call_timeout_for(&cfg(0), &args(json!(86400.0 * 1000.0))),
CALL_TIMEOUT_MAX
);
assert_eq!(ct(&cfg(30), &none), s(30));
// Le réglage global, remplacé par celui du serveur, jamais rogné.
let tm = |call| Timeouts { connect: 20, call };
assert_eq!(call_timeout_for(&cfg(0), &tm(45), &none), s(45));
assert_eq!(call_timeout_for(&cfg(90), &tm(45), &none), s(90));
assert_eq!(call_timeout_for(&cfg(0), &tm(3600), &none), s(3600));
assert_eq!(call_timeout_for(&cfg(30), &none), s(30));
assert_eq!(
call_deadline_error(s(330)),
"[erreur MCP] pas de réponse après 5m30s (délai de l'appel ; un argument timeout plus grand l'allonge)"
@@ -671,41 +577,9 @@ mod tests {
command: cmd.into(),
..Default::default()
};
let def = Timeouts::default();
assert_eq!(timeout_for(&c("/usr/bin/npx"), &def), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("UVX.EXE"), &def), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("npx.cmd"), &def), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("node"), &def), Duration::from_secs(20));
// Un délai réglé plus long que le minimum des lanceurs l'emporte.
let long = Timeouts {
connect: 600,
call: 120,
};
assert_eq!(timeout_for(&c("npx"), &long), Duration::from_secs(600));
assert_eq!(timeout_for(&c("node"), &long), Duration::from_secs(600));
}
#[test]
fn delais_globaux() {
let d = |raw: &str| decode_timeouts(Some(raw.as_bytes()));
let t = |connect, call| Timeouts { connect, call };
assert_eq!(decode_timeouts(None), t(20, 120));
assert_eq!(d(r#"{"connect":5,"call":300}"#), t(5, 300));
assert_eq!(d(r#"{"connect":0,"call":0}"#), t(20, 120));
assert_eq!(d(r#"{"connect":-4,"call":99999}"#), t(20, 3600));
assert_eq!(d(r#"{"connect":"x","call":50}"#), t(20, 120));
assert_eq!(d(r#"{"CONNECT":7}"#), t(7, 120));
assert_eq!(d("null"), t(20, 120));
assert_eq!(d(r#"{"connect":30,"call":40} reste"#), t(20, 120));
assert_eq!(t(30, 600).marshal(), r#"{"connect":30,"call":600}"#);
assert_eq!(timeouts_error(&t(0, 120)), Some("connexion : 1 à 3600 s"));
assert_eq!(
timeouts_error(&t(20, 3601)),
Some("appel d'outil : 1 à 3600 s")
);
assert_eq!(timeouts_error(&t(3600, 1)), None);
assert!(call_timeout_sec_error(-1).is_some());
assert!(call_timeout_sec_error(0).is_none());
assert!(call_timeout_sec_error(3601).is_some());
assert_eq!(timeout_for(&c("/usr/bin/npx")), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("UVX.EXE")), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("npx.cmd")), CONNECT_TIMEOUT_COLD);
assert_eq!(timeout_for(&c("node")), CONNECT_TIMEOUT);
}
}
+16 -96
View File
@@ -29,7 +29,7 @@ use crate::gojson::{self, Kind};
use crate::proxy::Body;
use crate::store::{BK_STATE, Store};
use client::Session;
use config::{ServerConfig, Servers, Timeouts};
use config::{ServerConfig, Servers};
/// Clé de la configuration (`state/mcp`).
pub const ST_MCP: &str = "mcp";
@@ -108,26 +108,6 @@ impl Mcp {
Ok(())
}
/// `SaveMCPTimeouts` puis la fermeture des sessions des serveurs
/// configurés : le nouveau délai de connexion vaut dès la suivante.
async fn save_timeouts(&self, store: &Store, t: Timeouts) -> Result<(), String> {
let names: Vec<String> = {
let _g = self.cfg_lock.lock().await;
store
.put(
BK_STATE,
config::ST_TIMEOUTS,
t.normalized().marshal().into_bytes(),
)
.await?;
Self::load(store).await.keys().cloned().collect()
};
for name in names {
self.invalidate(&name).await;
}
Ok(())
}
/// `mcpInvalidate` : la session du serveur fermée et oubliée, ses outils
/// retirés du registre.
pub async fn invalidate(&self, name: &str) {
@@ -156,27 +136,8 @@ impl Mcp {
}
}
/// `LoadMCPTimeouts` : les délais globaux (`state/mcp_timeouts`).
pub async fn timeouts(store: &Store) -> Timeouts {
config::decode_timeouts(
store
.get(BK_STATE, config::ST_TIMEOUTS)
.await
.ok()
.flatten()
.as_deref(),
)
}
/// `ensure` : la session du serveur, connectée ou en erreur. Le délai de
/// connexion est lu au moment de se connecter.
async fn ensure(
&self,
store: &Store,
name: &str,
cfg: &ServerConfig,
version: &str,
) -> Arc<Slot> {
/// `ensure` : la session du serveur, connectée ou en erreur.
async fn ensure(&self, name: &str, cfg: &ServerConfig, version: &str) -> Arc<Slot> {
let slot = lock(&self.slots)
.entry(name.to_string())
.or_insert_with(|| {
@@ -189,15 +150,14 @@ impl Mcp {
slot.state
.get_or_init(|| async {
let cfg = &slot.cfg;
let tm = Self::timeouts(store).await;
let mut st = SlotState::default();
let work = async {
let sess = Session::connect(cfg, version).await?;
let tools = sess.list_tools().await;
Ok::<_, String>((sess, tools))
};
match tokio::time::timeout(config::timeout_for(cfg, &tm), work).await {
Err(_) => st.err = Some(config::timeout_error(cfg, &tm)),
match tokio::time::timeout(config::timeout_for(cfg), work).await {
Err(_) => st.err = Some(config::timeout_error(cfg)),
Ok(Err(e)) => st.err = Some(e),
Ok(Ok((sess, tools))) => {
match tools {
@@ -231,12 +191,12 @@ impl Mcp {
}
/// `mcpEnsureAll` : les serveurs activés connectés en parallèle.
async fn ensure_all(&self, store: &Store, servers: &Servers, version: &str) {
async fn ensure_all(&self, servers: &Servers, version: &str) {
join_all(
servers
.iter()
.filter(|(_, c)| c.enabled && !c.transport().is_empty())
.map(|(n, c)| self.ensure(store, n, c, version)),
.map(|(n, c)| self.ensure(n, c, version)),
)
.await;
}
@@ -248,13 +208,13 @@ impl Mcp {
if servers.is_empty() {
return Vec::new();
}
self.ensure_all(store, &servers, version).await;
self.ensure_all(&servers, version).await;
let mut out = Vec::new();
for (name, cfg) in &servers {
if !cfg.enabled || cfg.transport().is_empty() {
continue;
}
let slot = self.ensure(store, name, cfg, version).await;
let slot = self.ensure(name, cfg, version).await;
let Some(st) = slot.state.get() else { continue };
if st.sess.is_none() {
continue;
@@ -295,11 +255,8 @@ impl Mcp {
};
let cap = config::output_cap(config::ctx_window(&store.config().await));
let servers = Self::load(store).await;
let timeout = config::call_timeout_for(
&servers.get(&server).cloned().unwrap_or_default(),
&Self::timeouts(store).await,
args,
);
let timeout =
config::call_timeout_for(&servers.get(&server).cloned().unwrap_or_default(), args);
let mut err = match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => e,
@@ -309,7 +266,7 @@ impl Mcp {
}
if let Some(cfg) = servers.get(&server).filter(|c| c.enabled) {
self.invalidate(&server).await;
self.ensure(store, &server, cfg, version).await;
self.ensure(&server, cfg, version).await;
match self.call_once(&server, &tool, args, cap, timeout).await {
Ok(r) => return r,
Err(e) => err = e,
@@ -350,7 +307,7 @@ impl Mcp {
/// activés.
pub async fn status(&self, store: &Store, version: &str) -> Vec<Value> {
let servers = Self::load(store).await;
self.ensure_all(store, &servers, version).await;
self.ensure_all(&servers, version).await;
let mut out = Vec::new();
for (name, cfg) in &servers {
let mut st = json!({
@@ -367,13 +324,11 @@ impl Mcp {
("env", json!(cfg.env)),
("url", json!(cfg.url)),
("headers", json!(cfg.headers)),
("callTimeoutSec", json!(cfg.call_timeout_sec)),
] {
let empty = match &v {
Value::String(s) => s.is_empty(),
Value::Array(a) => a.is_empty(),
Value::Object(o) => o.is_empty(),
Value::Number(n) => n.as_i64() == Some(0),
_ => false,
};
if !empty {
@@ -381,7 +336,7 @@ impl Mcp {
}
}
if cfg.enabled && !cfg.transport().is_empty() {
let slot = self.ensure(store, name, cfg, version).await;
let slot = self.ensure(name, cfg, version).await;
if let Some(s) = slot.state.get() {
if s.sess.is_some() {
st["connected"] = json!(true);
@@ -412,7 +367,7 @@ impl Mcp {
}
let servers = Self::load(&store).await;
if !servers.is_empty() {
me.ensure_all(&store, &servers, &version).await;
me.ensure_all(&servers, &version).await;
}
});
}
@@ -539,8 +494,6 @@ pub fn handles(path: &str) -> bool {
| "/api/mcp/toggle"
| "/api/mcp/tool"
| "/api/mcp/test"
| "/api/mcp/timeouts"
| "/api/mcp/timeouts/save"
)
}
@@ -609,16 +562,11 @@ pub async fn serve(
("url", Kind::Str),
("headers", Kind::StrMap),
("enabled", Kind::OptBool),
("callTimeoutSec", Kind::Int),
],
);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let call_timeout_sec = d.int("callTimeoutSec");
if let Some(e) = config::call_timeout_sec_error(call_timeout_sec) {
return fail(StatusCode::BAD_REQUEST, e);
}
let cfg = ServerConfig {
command: d.str("command").trim().to_string(),
args: d.str_list("args").unwrap_or_default(),
@@ -631,7 +579,7 @@ pub async fn serve(
.collect(),
enabled: d.opt_bool("enabled").unwrap_or(true),
disabled_tools: Vec::new(),
call_timeout_sec,
call_timeout_sec: 0,
};
let name = d.str("name");
let res = mcp
@@ -706,34 +654,6 @@ pub async fn serve(
mcp.invalidate(&d.str("name")).await;
servers_ok(mcp.status(store, &version).await)
}
"/api/mcp/timeouts" => {
let t = Mcp::timeouts(store).await;
send_json(
StatusCode::OK,
&json!({ "ok": true, "connect": t.connect, "call": t.call }),
)
}
"/api/mcp/timeouts/save" => {
let raw = read_body(body).await;
let d = gojson::decode_struct(&raw, config::TIMEOUTS_TYPE, &config::TIMEOUTS_FIELDS);
if let Some(e) = d.err.clone() {
return fail(StatusCode::BAD_REQUEST, e);
}
let t = Timeouts {
connect: d.int("connect"),
call: d.int("call"),
};
if let Some(e) = config::timeouts_error(&t) {
return fail(StatusCode::BAD_REQUEST, e);
}
match mcp.save_timeouts(store, t).await {
Ok(()) => send_json(
StatusCode::OK,
&json!({ "ok": true, "connect": t.connect, "call": t.call }),
),
Err(e) => fail(StatusCode::INTERNAL_SERVER_ERROR, e),
}
}
_ => fail(StatusCode::NOT_FOUND, "route inconnue"),
}
}
+2 -136
View File
@@ -66,44 +66,17 @@ fn parite_des_fonctions_pures() {
.unwrap_or_default();
assert_eq!(cf::prompt_line(&tools), s(&c["out"]), "{c}");
}
let def = cf::Timeouts::default();
for (cmd, v) in fx["timeouts"].as_object().unwrap() {
let cfg = cf::ServerConfig {
command: cmd.clone(),
..Default::default()
};
assert_eq!(
cf::timeout_for(&cfg, &def).as_secs() as i64,
cf::timeout_for(&cfg).as_secs() as i64,
v["secs"].as_i64().unwrap(),
"{cmd}"
);
assert_eq!(cf::timeout_error(&cfg, &def), s(&v["err"]), "{cmd}");
}
// Délais globaux : lecture de la base, puis leur effet.
let tm = |v: &Value| cf::Timeouts {
connect: v["connect"].as_i64().unwrap(),
call: v["call"].as_i64().unwrap(),
};
for c in fx["timeouts_stored"].as_array().unwrap() {
let raw = s(&c["in"]);
let got = cf::decode_timeouts((!raw.is_empty()).then_some(raw.as_bytes()));
assert_eq!(got, tm(&c["out"]), "{raw}");
}
for c in fx["timeouts_effects"].as_array().unwrap() {
let t = tm(&c["timeouts"]);
let cfg: cf::ServerConfig = serde_json::from_str(s(&c["cfg"])).unwrap();
let args: Map<String, Value> = serde_json::from_str(s(&c["args"])).unwrap();
assert_eq!(
cf::timeout_for(&cfg, &t).as_millis() as i64,
c["connect"].as_i64().unwrap(),
"{c}"
);
assert_eq!(cf::timeout_error(&cfg, &t), s(&c["err"]), "{c}");
assert_eq!(
cf::call_timeout_for(&cfg, &t, &args).as_millis() as i64,
c["call"].as_i64().unwrap(),
"{c}"
);
assert_eq!(cf::timeout_error(&cfg), s(&v["err"]), "{cmd}");
}
for c in fx["flatten"].as_array().unwrap() {
let v: Value = serde_json::from_str(s(&c["in"])).unwrap();
@@ -449,50 +422,6 @@ async fn routes_de_configuration() {
);
let stored: Value = serde_json::from_str(&go.get("state", "mcp").unwrap()).unwrap();
assert_eq!(stored["web"]["url"], json!(url));
assert!(stored["web"].get("callTimeoutSec").is_none());
// Délai d'un appel propre au serveur : rangé, rendu, puis retiré quand le
// champ revient vide.
let body = |ct: Option<i64>| {
let mut b = json!({ "name": "web", "url": url, "headers": { "X-Cle": "k" } });
if let Some(n) = ct {
b["callTimeoutSec"] = json!(n);
}
b.to_string()
};
let (c, v) = post("/api/mcp/save", body(Some(900))).await;
assert_eq!(
(c, v["servers"][0]["callTimeoutSec"].clone()),
(200, json!(900))
);
let stored: Value = serde_json::from_str(&go.get("state", "mcp").unwrap()).unwrap();
assert_eq!(stored["web"]["callTimeoutSec"], 900);
let (c, v) = post("/api/mcp/save", body(None)).await;
assert_eq!(c, 200);
assert!(v["servers"][0].get("callTimeoutSec").is_none(), "{v}");
for n in [-1, 3601] {
let (c, v) = post("/api/mcp/save", body(Some(n))).await;
assert_eq!(
(c, v["error"].clone()),
(
400,
json!("délai d'un appel : 0 à 3600 s (vide = réglage global)")
)
);
}
let (c, v) = post(
"/api/mcp/save",
r#"{"name":"x","url":"http://h","callTimeoutSec":"9"}"#.into(),
)
.await;
assert_eq!(
(c, v["error"].clone()),
(
400,
json!(
"json: cannot unmarshal string into Go struct field mcpSaveReq.callTimeoutSec of type int"
)
)
);
// Erreurs de la configuration et du JSON.
let (c, v) = post("/api/mcp/save", r#"{"name":"a__b","command":"x"}"#.into()).await;
assert_eq!(
@@ -577,67 +506,6 @@ async fn routes_de_configuration() {
pool.close_all().await;
}
/// Les routes des délais globaux rejouées contre la façade : mêmes codes,
/// mêmes réponses, même valeur rangée ; un enregistrement réussi ferme les
/// sessions ouvertes.
#[tokio::test]
async fn routes_des_delais() {
let tmp = tempfile::tempdir().unwrap();
let seen = Arc::new(Mutex::new(Vec::new()));
let fx = fixture();
let url = fake_server(fx["wire"].clone(), seen.clone()).await;
let pool = Arc::new(Mcp::new(tmp.path().to_path_buf()));
let (facade, go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
mcp: Some(pool.clone()),
..Default::default()
},
)
.await;
go.set(
"state",
"mcp",
&json!({ "web": { "url": url, "enabled": true } }).to_string(),
);
let initialize = || {
seen.lock()
.unwrap()
.iter()
.filter(|l| l.starts_with("initialize"))
.count()
};
let (_, b) = fakego::send(facade, "GET", "/api/mcp", &[], "").await;
let v: Value = serde_json::from_slice(&b).unwrap();
assert_eq!(v["servers"][0]["connected"], true, "{v}");
let mut connections = initialize();
for c in fx["timeouts_routes"].as_array().unwrap() {
let path = s(&c["path"]);
let method = if path.ends_with("/save") {
"POST"
} else {
"GET"
};
let (code, b) = fakego::send(facade, method, path, &[], s(&c["body"])).await;
assert_eq!(code.as_u16() as i64, c["code"].as_i64().unwrap(), "{c}");
let got: Value = serde_json::from_slice(&b).unwrap();
let want: Value = serde_json::from_str(s(&c["resp"])).unwrap();
assert_eq!(got, want, "{c}");
assert_eq!(
go.get("state", cf::ST_TIMEOUTS).unwrap_or_default(),
s(&c["state"]),
"{c}"
);
// Session fermée : la liste suivante se reconnecte.
fakego::send(facade, "GET", "/api/mcp", &[], "").await;
let now = initialize();
let saved = method == "POST" && code.as_u16() == 200;
assert_eq!(now, connections + usize::from(saved), "{c}");
connections = now;
}
pool.close_all().await;
}
#[tokio::test]
async fn catalogue_embarque() {
let tmp = tempfile::tempdir().unwrap();
@@ -676,8 +544,6 @@ async fn sans_la_variable_mcp_reste_au_go() {
"/api/mcp/save",
"/api/mcp/catalog",
"/api/mcp/test",
"/api/mcp/timeouts",
"/api/mcp/timeouts/save",
] {
let (_, _, b) = fakego::get(facade, p, &[]).await;
assert_eq!(&b[..], format!("go:{p}").as_bytes());
+108 -108
View File
@@ -231,8 +231,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -304,7 +304,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -335,8 +335,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -615,7 +615,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -855,8 +855,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1066,7 +1066,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -1103,8 +1103,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1314,7 +1314,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -1554,8 +1554,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 16395,
"prompt_n": 32790,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1759,7 +1759,7 @@
"max_tokens": 4096,
"messages": [
{
"content": "<131145 octets 8d4ca4a3239d>",
"content": "<131201 octets e3226a117903>",
"role": "user"
}
],
@@ -1790,8 +1790,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16395,
"prompt_n": 32790,
"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": 16395,
"prompt_n": 32790,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2093,7 +2093,7 @@
"max_tokens": 16,
"messages": [
{
"content": "<3745 octets 4d9bad4c301c>",
"content": "<3745 octets 55a9fdff5d02>",
"role": "user"
}
],
@@ -2187,8 +2187,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -2260,7 +2260,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2291,8 +2291,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2420,8 +2420,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2541,7 +2541,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2572,8 +2572,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2693,7 +2693,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2747,8 +2747,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2868,7 +2868,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2992,8 +2992,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3121,8 +3121,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3287,7 +3287,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3319,8 +3319,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3457,7 +3457,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3489,8 +3489,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3652,7 +3652,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3737,8 +3737,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3867,7 +3867,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8130 octets 081c43a7796f>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3952,8 +3952,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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": 1018,
"prompt_n": 2036,
"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
}
]
}
+14 -446
View File
@@ -163,37 +163,37 @@
{
"op": "set",
"name": "web",
"cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false,\"callTimeoutSec\":45}",
"cfg": "{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false,\"callTimeoutSec\":45}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":false}}"
},
{
"op": "enable",
"name": "web",
"on": true,
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
},
{
"op": "enable",
"name": "nope",
"on": true,
"error": "serveur MCP inconnu: nope",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
},
{
"op": "tool",
"name": "fs",
"tool": "write",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
},
{
"op": "tool",
"name": "fs",
"tool": "read",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\",\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"write\",\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
},
{
"op": "tool",
@@ -201,21 +201,21 @@
"tool": "write",
"on": true,
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
},
{
"op": "tool",
"name": "fs",
"tool": "read",
"error": "",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
},
{
"op": "tool",
"name": "nope",
"tool": "x",
"error": "serveur MCP inconnu: nope",
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true,\"callTimeoutSec\":45}}"
"state": "{\"fs\":{\"command\":\"npx\",\"args\":[\"-y\",\"@x/fs\",\"\\u003cw\\u003e\"],\"env\":{\"A\":\"\\u0026\"},\"enabled\":true,\"disabledTools\":[\"read\"]},\"web\":{\"url\":\"http://u/mcp\",\"headers\":{\"X\":\"1\"},\"enabled\":true}}"
},
{
"op": "delete",
@@ -382,14 +382,13 @@
"connected": false,
"tools": [],
"disabled": [],
"url": "{{URL}}",
"callTimeoutSec": 300
"url": "{{URL}}"
}
],
"store": "{\"alpha\":{\"url\":\"{{URL}}\",\"enabled\":true,\"disabledTools\":[\"secret\"]},\"beta\":{\"command\":\"loki-mcp-absent-pour-test\",\"enabled\":true},\"gamma\":{\"url\":\"{{URL}}\",\"enabled\":false,\"callTimeoutSec\":300},\"delta\":{\"enabled\":true}}",
"store": "{\"alpha\":{\"url\":\"{{URL}}\",\"enabled\":true,\"disabledTools\":[\"secret\"]},\"beta\":{\"command\":\"loki-mcp-absent-pour-test\",\"enabled\":true},\"gamma\":{\"url\":\"{{URL}}\",\"enabled\":false},\"delta\":{\"enabled\":true}}",
"timeouts": {
"": {
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
"secs": 20
},
"/usr/local/bin/NPX": {
@@ -401,7 +400,7 @@
"secs": 180
},
"node": {
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
"secs": 20
},
"npx": {
@@ -413,7 +412,7 @@
"secs": 180
},
"uvx-x": {
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s",
"secs": 20
},
"uvx.exe": {
@@ -421,437 +420,6 @@
"secs": 180
}
},
"timeouts_effects": [
{
"args": "{}",
"call": 120000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{}",
"call": 120000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 180000,
"err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{\"timeout\":600}",
"call": 630000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 20000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 20s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 20,
"call": 120
}
},
{
"args": "{}",
"call": 45000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{}",
"call": 45000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 180000,
"err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{\"timeout\":600}",
"call": 630000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 5000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 5s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 5,
"call": 45
}
},
{
"args": "{}",
"call": 3600000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{}",
"call": 3600000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 600000,
"err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{\"timeout\":600}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 600000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 10m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 600,
"call": 3600
}
},
{
"args": "{}",
"call": 1000,
"cfg": "{\"command\":\"node\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{}",
"call": 1000,
"cfg": "{\"command\":\"npx\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — npx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{}",
"call": 90000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false,\"callTimeoutSec\":90}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{\"timeout\":7200}",
"call": 3600000,
"cfg": "{\"command\":\"uvx\",\"enabled\":false,\"callTimeoutSec\":3600}",
"connect": 180000,
"err": "délai dépassé — uvx télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{\"timeout\":600}",
"call": 630000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
},
{
"args": "{\"timeout\":99999999}",
"call": 3600000,
"cfg": "{\"url\":\"http://h\",\"enabled\":false}",
"connect": 180000,
"err": "délai dépassé — le serveur n'a pas répondu au handshake en 3m0s (délai de connexion réglable dans les réglages MCP)",
"timeouts": {
"connect": 180,
"call": 1
}
}
],
"timeouts_routes": [
{
"body": "",
"code": 200,
"path": "/api/mcp/timeouts",
"resp": "{\"call\":120,\"connect\":20,\"ok\":true}",
"state": ""
},
{
"body": "{\"connect\":0,\"call\":120}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"connexion : 1 à 3600 s\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":20,\"call\":3601}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"appel d'outil : 1 à 3600 s\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":20}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"appel d'outil : 1 à 3600 s\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":\"20\",\"call\":120}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"json: cannot unmarshal string into Go struct field MCPTimeouts.connect of type int\",\"ok\":false}",
"state": ""
},
{
"body": "{\"connect\":2.5,\"call\":120}",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"json: cannot unmarshal number 2.5 into Go struct field MCPTimeouts.connect of type int\",\"ok\":false}",
"state": ""
},
{
"body": "[]",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"json: cannot unmarshal array into Go value of type loki.MCPTimeouts\",\"ok\":false}",
"state": ""
},
{
"body": "pas du json",
"code": 400,
"path": "/api/mcp/timeouts/save",
"resp": "{\"error\":\"invalid character 'p' looking for beginning of value\",\"ok\":false}",
"state": ""
},
{
"body": "{\"Connect\":45,\"CALL\":900,\"x\":1}",
"code": 200,
"path": "/api/mcp/timeouts/save",
"resp": "{\"call\":900,\"connect\":45,\"ok\":true}",
"state": "{\"connect\":45,\"call\":900}"
},
{
"body": "",
"code": 200,
"path": "/api/mcp/timeouts",
"resp": "{\"call\":900,\"connect\":45,\"ok\":true}",
"state": "{\"connect\":45,\"call\":900}"
},
{
"body": "{\"connect\":3600,\"call\":1}",
"code": 200,
"path": "/api/mcp/timeouts/save",
"resp": "{\"call\":1,\"connect\":3600,\"ok\":true}",
"state": "{\"connect\":3600,\"call\":1}"
},
{
"body": "",
"code": 200,
"path": "/api/mcp/timeouts",
"resp": "{\"call\":1,\"connect\":3600,\"ok\":true}",
"state": "{\"connect\":3600,\"call\":1}"
}
],
"timeouts_stored": [
{
"in": "",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":5,\"call\":300}",
"out": {
"connect": 5,
"call": 300
}
},
{
"in": "{\"connect\":0,\"call\":0}",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":-4,\"call\":99999}",
"out": {
"connect": 20,
"call": 3600
}
},
{
"in": "{\"connect\":\"x\",\"call\":50}",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"CONNECT\":7,\"Call\":8}",
"out": {
"connect": 7,
"call": 8
}
},
{
"in": "null",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "[1]",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":30,\"call\":40} reste",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":1.5,\"call\":60}",
"out": {
"connect": 20,
"call": 120
}
},
{
"in": "{\"connect\":3600,\"call\":3600,\"autre\":1}",
"out": {
"connect": 3600,
"call": 3600
}
}
],
"tool_server": [
{
"desc": "[MCP: alpha] desc",
+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}");
}
}