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
39 changed files with 2117 additions and 2007 deletions

No files matched your search

+8 -7
View File
@@ -52,13 +52,14 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
La **recherche plein-texte des discussions** (`chat_search.go`) reprend celle
de l'historique d'AJEAN (commit `558d3b4d`, issue amont #98, PR #99 d'Olioli4) :
index inversé chiffré comme les discussions, tokens repliés sans accents,
correspondance par début de mot, titre compté double. Adaptée au modèle de
Loki (toutes les discussions dans `bkChat`, index tenu à chaque écriture, y
compris celles de la façade Rust) et à sa barre latérale (« rechercher
partout », extrait autour du passage trouvé).
La **compréhension des vidéos** est reprise d'AJEAN (PR #100 de Trombo38 et
son correctif « vidéo envoyée seulement si le moteur sait la lire ») :
l'outil `see_video` et les vidéos jointes (`chat_video.go`,
`chat_video_tool.go`), lues par llama.cpp avec `--video-fps` et ffmpeg ;
ailleurs, la vidéo reste un fichier, avec la marche à suivre (ffmpeg puis
`see_image`). Adaptée à Loki : vidéo de `see_video` toujours éphémère, vidéos
jointes chiffrées comme le fil et retirées d'un fil rouvert, outil tenu aussi
par la façade Rust (`imagetools.rs`).
## Moteur Strata
+6 -1
View File
@@ -130,7 +130,8 @@ const imageLostMarker = " [image — not kept in the conversation past this poin
// (userMessageContent : parties texte + image quand la vision est active) porte
// un tableau de parties ; on en recolle les segments `text` pour que l'estimation
// de contexte et le transcript de compaction ne partent pas d'un texte vide, et
// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans
// on marque la présence d'une partie `image_url` (voir imageLostMarker) ou
// `input_video` (videoLostMarker, chat_video.go) — sans
// quoi elle reste invisible à toute la chaîne de compaction.
func msgText(m Message) string {
switch v := m.Content.(type) {
@@ -146,6 +147,8 @@ func msgText(m Message) string {
}
case "image_url":
b.WriteString(imageLostMarker)
case "input_video":
b.WriteString(videoLostMarker)
}
}
return b.String()
@@ -163,6 +166,8 @@ func msgText(m Message) string {
}
case "image_url":
b.WriteString(imageLostMarker)
case "input_video":
b.WriteString(videoLostMarker)
}
}
return b.String()
+16 -1
View File
@@ -112,7 +112,8 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
}
// stripInlineImages remplace les images en base64 (data URI) portées par les
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
// messages multimodaux (vision : pièces jointes images et vidéos + outil
// see_image) par un
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
@@ -145,6 +146,20 @@ func stripInlineImagesValue(v any) any {
}
cp["image_url"] = c2
}
// Partie vidéo (input_video, chat_video.go) : même élision, sur les deux
// champs que lit llama-server.
if iv, ok := cp["input_video"].(map[string]any); ok {
c2 := make(map[string]any, len(iv))
for k, val := range iv {
c2[k] = val
}
for _, key := range []string{"url", "data"} {
if s, ok := c2[key].(string); ok && strings.HasPrefix(s, "data:") {
c2[key] = elideDataURI(s)
}
}
cp["input_video"] = c2
}
return cp
}
switch parts := v.(type) {
+5 -1
View File
@@ -123,7 +123,11 @@ const chatImgKeep = 24 * time.Hour
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
// fil resté ouvert longtemps sans bascule.
func pruneChatImages() {
dir := chatImgDir()
pruneMediaDir(chatImgDir())
pruneMediaDir(chatVidDir()) // vidéos jointes (chat_video.go), même délai
}
func pruneMediaDir(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
+1 -3
View File
@@ -173,9 +173,6 @@ func (p *convPersister) run() {
sort.Slice(snaps, func(i, j int) bool { return snaps[i].seq < snaps[j].seq })
wrote, err := persistCommitByPath(snaps, res)
// Index plein-texte (chat_search.go) AVANT de marquer le lot traité : un
// flush attend donc aussi l'index, et une suppression passe toujours après.
histIndexWritten(wrote)
p.mu.Lock()
for _, j := range res {
@@ -539,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
-852
View File
@@ -1,852 +0,0 @@
package loki
// chat_search.go — recherche PLEIN-TEXTE dans les discussions (reprise d'AJEAN,
// commit 558d3b4d, issue amont #98 / PR #99).
//
// Avant, la barre latérale ne filtrait que les TITRES, côté navigateur. Relire
// le corps de chaque discussion à chaque frappe est exclu : une discussion pèse
// vite des centaines de Ko, chiffrée de surcroît. On tient donc un INDEX INVERSÉ
// dans bkChatSearch, à trois familles de clés :
//
// « d|<id> » → histSearchDoc : la fiche d'une discussion (version, date
// d'indexation, aperçu). Petite : la couverture et l'aperçu se
// lisent sans charger la liste des mots.
// « t|<id> » → []string : les mots DISTINCTS de la discussion, repliés et
// triés. Sert seulement à la mise à jour (différence avec
// l'état précédent) et à la suppression.
// « p|<token> » → []string d'identifiants triés (sémantique d'ENSEMBLE).
//
// Écart avec l'amont : AJEAN range la liste des mots dans la fiche « d| » et
// lit TOUTES les fiches à chaque requête pour la couverture. Ici la fiche reste
// petite, et une mise à jour ne touche que les mots ajoutés ou retirés depuis la
// fois précédente — la discussion ouverte est réindexée à chaque écriture, pas
// seulement à l'archivage comme chez l'amont.
//
// QUI ÉCRIT L'INDEX. Ce process, toujours, même quand la façade Rust tient la
// conversation (LOKI_CHAT=rust) : elle n'a pas accès à la base et écrit ses
// discussions par /internal/v1/store (web_internal.go), qui indexe au passage
// (histIndexStoreWrite). L'index suit donc la même discussion quel que soit le
// process qui la tient, sans second écrivain ni test de parité à tenir : la
// route de recherche (/api/conversations/search) est servie ICI, la façade la
// relaie comme toute route qu'elle ne connaît pas (proxy.rs, forward).
//
// ACCENTS : l'index stocke des tokens REPLIÉS (« résumé » → « resume »). C'est
// délibérément différent de la sortie de recallTokens, que l'outil recall_search
// du modèle consomme telle quelle — ne pas « corriger » cette apparente
// incohérence : replier l'index (et la requête, symétriquement) est ce qui fait
// qu'une recherche « resume » trouve « résumé » et inversement.
import (
"bytes"
"encoding/json"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"unicode"
"unicode/utf8"
bolt "go.etcd.io/bbolt"
)
const (
// histSearchIndexVersion : une fiche dont la version diffère est traitée
// comme non indexée et reconstruite en entier.
histSearchIndexVersion = 1
// histSearchMaxTerms borne le nombre de postings lus par requête.
histSearchMaxTerms = 16
// histSearchDefaultLimit / histSearchMaxLimit : taille d'une page de
// résultats. L'extrait en contexte relit le corps de CHAQUE résultat de la
// page : la borne protège la requête d'un « limit=100000 ».
histSearchDefaultLimit = 30
histSearchMaxLimit = 100
)
// histSearchDoc = la fiche d'index d'une discussion.
type histSearchDoc struct {
V int `json:"v"`
At int64 `json:"at"` // indexée à (Unix, s) — comparée à convMeta.Updated
Apercu string `json:"apercu,omitempty"` // début du texte visible, borné (recallSnippet)
}
// histSearchHit = une discussion trouvée, telle que la reçoit l'UI.
type histSearchHit struct {
ID string `json:"id"`
Title string `json:"title"`
Created int64 `json:"created"`
Updated int64 `json:"updated"`
Turns int `json:"turns"`
Score int `json:"score"`
InTitle bool `json:"in_title"`
Snippet string `json:"snippet"`
}
// histSearchResult = le résultat complet d'une recherche.
type histSearchResult struct {
State string `json:"state"` // ok | locked | no_terms
Hits []histSearchHit `json:"conversations"`
Total int `json:"total"` // correspondances AVANT pagination
Indexed int `json:"indexed"` // discussions du projet réellement indexées
IndexedTotal int `json:"indexed_total"` // discussions indexables du projet
Terms []string `json:"terms"` // termes cherchés (repliés, triés)
}
func histDocKey(id string) string { return "d|" + id }
func histToksKey(id string) string { return "t|" + id }
func histPostKey(tok string) string { return "p|" + tok }
// foldHistAccents replie les diacritiques d'un token. Appliqué SYMÉTRIQUEMENT
// à l'indexation et à la requête (voir le commentaire de tête).
func foldHistAccents(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case 'à', 'â', 'ä', 'á', 'ã', 'å':
b.WriteByte('a')
case 'é', 'è', 'ê', 'ë':
b.WriteByte('e')
case 'î', 'ï', 'í', 'ì':
b.WriteByte('i')
case 'ô', 'ö', 'ó', 'ò', 'õ':
b.WriteByte('o')
case 'ù', 'û', 'ü', 'ú':
b.WriteByte('u')
case 'ç':
b.WriteByte('c')
case 'ÿ':
b.WriteByte('y')
case 'œ':
b.WriteString("oe")
case 'æ':
b.WriteString("ae")
default:
b.WriteRune(r)
}
}
return b.String()
}
// histTokens dérive les mots d'index d'une chaîne : recallTokens (réutilisé,
// jamais modifié), puis repli des accents et dédoublonnage, triés. Deux tokens
// distincts peuvent se replier sur le même (« cote » et « côté »).
func histTokens(s string) []string {
set := recallTokens(s)
seen := make(map[string]bool, len(set))
out := make([]string, 0, len(set))
for t := range set {
f := foldHistAccents(t)
if seen[f] {
continue
}
seen[f] = true
out = append(out, f)
}
sort.Strings(out)
return out
}
// histTokenSet : variante « ensemble » de histTokens (titre, discussion ouverte).
func histTokenSet(s string) map[string]bool {
out := map[string]bool{}
for _, t := range histTokens(s) {
out[t] = true
}
return out
}
// histTermMatches est LA règle de correspondance : un token correspond à un
// terme quand il COMMENCE par lui, jamais au milieu du mot (« eepseek » ne
// trouve pas « deepseek »). Servie au titre, à la discussion ouverte et à
// l'extrait ; le scan des postings applique la même règle au niveau de la clé
// (préfixe « p|<terme> »).
func histTermMatches(token, term string) bool {
return strings.HasPrefix(token, term)
}
// histSetMatches : un terme correspond-il à l'un des tokens d'un ensemble ?
// Vrai au plus UNE fois par terme : le score compte les TERMES, pas les tokens.
func histSetMatches(set map[string]bool, term string) bool {
for tok := range set {
if histTermMatches(tok, term) {
return true
}
}
return false
}
// histQueryTerms replie, trie et plafonne les termes de la requête.
func histQueryTerms(q string) []string {
terms := histTokens(q)
if len(terms) > histSearchMaxTerms {
terms = terms[:histSearchMaxTerms]
}
return terms
}
// histVisibleText concatène le texte VISIBLE d'un journal d'affichage, seule
// source indexée. On part du journal (vue UI) et NON des Messages : leur
// contenu peut être multimodal et porte le CORPS des sorties d'outil, qu'on ne
// veut pas indexer. On retient le message de l'utilisateur (« user »), la
// réponse (« content ») et le libellé d'outil (« tool_used.label »). Exclus :
// raisonnement, résultat d'outil, fichiers, deltas de contrôle.
func histVisibleText(log []LogEvent) string {
var b strings.Builder
for _, ev := range log {
if s, ok := ev.Delta["user"].(string); ok {
b.WriteString(s)
b.WriteByte('\n')
}
if s, ok := ev.Delta["content"].(string); ok {
b.WriteString(s)
b.WriteByte('\n')
}
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
if l, ok := tu["label"].(string); ok {
b.WriteString(l)
b.WriteByte('\n')
}
}
}
return b.String()
}
// histFirstVisible : premier texte visible d'un journal (aperçu de la fiche).
func histFirstVisible(log []LogEvent) string {
for _, ev := range log {
if s, ok := ev.Delta["user"].(string); ok && strings.TrimSpace(s) != "" {
return s
}
if s, ok := ev.Delta["content"].(string); ok && strings.TrimSpace(s) != "" {
return s
}
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
if l, ok := tu["label"].(string); ok && strings.TrimSpace(l) != "" {
return l
}
}
}
return ""
}
// histLogOf extrait le journal d'affichage du JSON d'une Conversation, sans
// rien décoder d'autre.
func histLogOf(body []byte) ([]LogEvent, bool) {
var c struct {
Log []LogEvent `json:"log"`
}
if json.Unmarshal(body, &c) != nil {
return nil, false
}
return c.Log, true
}
// histIsWordRune : même découpage que recallTokens (sur la chaîne en minuscules).
func histIsWordRune(r rune) bool {
return r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ'
}
// histContextSnippet renvoie un extrait du texte autour du PREMIER mot qui
// correspond à l'un des termes (même règle de préfixe), ou "" sans
// correspondance. L'aperçu de la fiche ne montre que le début d'une
// discussion — c'est-à-dire, le plus souvent, son titre.
func histContextSnippet(text string, terms []string) string {
const before, after = 60, 140
runes := []rune(text)
lower := make([]rune, len(runes)) // rune par rune : les positions restent alignées
for i, r := range runes {
lower[i] = unicode.ToLower(r)
}
for i := 0; i < len(lower); {
if !histIsWordRune(lower[i]) {
i++
continue
}
j := i
for j < len(lower) && histIsWordRune(lower[j]) {
j++
}
if j-i >= 2 {
w := foldHistAccents(string(lower[i:j]))
for _, t := range terms {
if !histTermMatches(w, t) {
continue
}
from, to := max(0, i-before), min(len(runes), j+after)
// Couper sur une espace pour ne pas commencer ni finir au milieu d'un mot.
for from > 0 && from < i && runes[from-1] != ' ' && runes[from-1] != '\n' {
from++
}
for to < len(runes) && to > j && runes[to] != ' ' && runes[to] != '\n' {
to--
}
s := strings.Join(strings.Fields(string(runes[from:to])), " ")
if from > 0 {
s = "…" + s
}
if to < len(runes) {
s += "…"
}
return s
}
}
i = j
}
return ""
}
// --- accès bas niveau au bucket (chiffré comme les autres) --------------------
// histTxGetJSON lit et déchiffre une valeur dans une transaction ouverte.
// decodeMemContent est sûr ici : sur du clair il ne lit rien, sur du chiffré
// il n'appelle que currentDEK (mutex en RAM, jamais la base).
func histTxGetJSON(b *bolt.Bucket, key string, dst any) bool {
raw := b.Get([]byte(key))
if raw == nil {
return false
}
plain, err := decodeMemContent(raw)
if err != nil {
return false // chiffré + verrouillé : illisible, pas « absent »
}
return json.Unmarshal(plain, dst) == nil
}
// histTxPutJSON sérialise, chiffre (encodeur résolu HORS transaction : bbolt
// n'est pas rentrant, voir memEncoderNow) et écrit.
func histTxPutJSON(b *bolt.Bucket, key string, v any, encode func([]byte) ([]byte, error)) error {
raw, err := json.Marshal(v)
if err != nil {
return err
}
enc, err := encode(raw)
if err != nil {
return err
}
return b.Put([]byte(key), enc)
}
// histPost ajoute sid à la liste du token (ensemble trié, idempotent).
func histPost(b *bolt.Bucket, tok, sid string, encode func([]byte) ([]byte, error)) error {
var ids []string
_ = histTxGetJSON(b, histPostKey(tok), &ids)
i := sort.SearchStrings(ids, sid)
if i < len(ids) && ids[i] == sid {
return nil
}
ids = append(ids, "")
copy(ids[i+1:], ids[i:])
ids[i] = sid
return histTxPutJSON(b, histPostKey(tok), ids, encode)
}
// histUnpost retire sid de la liste du token ; la clé disparaît si elle se vide.
func histUnpost(b *bolt.Bucket, tok, sid string, encode func([]byte) ([]byte, error)) error {
var ids []string
if !histTxGetJSON(b, histPostKey(tok), &ids) {
return nil
}
out := make([]string, 0, len(ids))
for _, x := range ids {
if x != sid {
out = append(out, x)
}
}
if len(out) == len(ids) {
return nil
}
if len(out) == 0 {
return b.Delete([]byte(histPostKey(tok)))
}
return histTxPutJSON(b, histPostKey(tok), out, encode)
}
// --- maintenance : indexation / désindexation ---------------------------------
// histIndexAt (ré)indexe une discussion dans la base path, en UNE transaction :
// mots retirés depuis la dernière fois, fiche, mots ajoutés. Une fiche d'une
// autre version (ou absente) fait tout reposer. Les sémantiques d'ensemble
// rendent l'opération idempotente. Mémoire chiffrée mais verrouillée : erreur,
// rien n'est écrit en clair (le rattrapage reprendra au déverrouillage).
func histIndexAt(path, id string, log []LogEvent) error {
if id == "" {
return nil
}
encode, err := memEncoderNow() // HORS transaction (voir memEncoderNow)
if err != nil {
return err
}
toks := histTokens(histVisibleText(log))
doc := histSearchDoc{V: histSearchIndexVersion, At: time.Now().Unix(),
Apercu: recallSnippet(histFirstVisible(log), 200)}
cacheBust(bkChatSearch)
return withDBAt(path, func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(bkChatSearch))
if err != nil {
return err
}
var prevDoc histSearchDoc
var prev []string
full := !histTxGetJSON(b, histDocKey(id), &prevDoc) || prevDoc.V != histSearchIndexVersion
_ = histTxGetJSON(b, histToksKey(id), &prev)
had := make(map[string]bool, len(prev))
for _, t := range prev {
had[t] = true
}
now := make(map[string]bool, len(toks))
for _, t := range toks {
now[t] = true
}
for _, t := range prev {
if !now[t] {
if err := histUnpost(b, t, id, encode); err != nil {
return err
}
}
}
for _, t := range toks {
if full || !had[t] {
if err := histPost(b, t, id, encode); err != nil {
return err
}
}
}
if err := histTxPutJSON(b, histToksKey(id), toks, encode); err != nil {
return err
}
return histTxPutJSON(b, histDocKey(id), doc, encode)
})
})
}
// histUnindexAt retire une discussion de l'index. Si sa liste de mots est
// lisible, ses postings partent avec ; sinon (verrouillé) seules ses clés
// « d| » et « t| » sont supprimées, en laissant des identifiants ORPHELINS
// dans les postings — tolérés : la recherche écarte tout identifiant absent de
// l'index des discussions.
func histUnindexAt(path, id string) {
if id == "" {
return
}
encode, err := memEncoderNow()
cacheBust(bkChatSearch)
_ = withDBAt(path, func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bkChatSearch))
if b == nil {
return nil
}
var prev []string
if err == nil && histTxGetJSON(b, histToksKey(id), &prev) {
for _, t := range prev {
if err := histUnpost(b, t, id, encode); err != nil {
return err
}
}
}
if err := b.Delete([]byte(histToksKey(id))); err != nil {
return err
}
return b.Delete([]byte(histDocKey(id)))
})
})
}
// histIndexWritten indexe les instantanés que l'écrivain vient de poser
// (chat_persist.go). Appelé DANS la boucle de l'écrivain, avant qu'elle ne
// marque le lot comme traité : un flush attend donc aussi l'index — c'est ce
// qui garantit qu'une suppression (forget, flush, puis histUnindexAt) passe
// toujours après la dernière indexation de la discussion.
func histIndexWritten(snaps []*convSnap) {
for _, s := range snaps {
if log, ok := histLogOf(s.body); ok {
_ = histIndexAt(s.path, s.id, log) // erreur avalée : le rattrapage reprendra
}
}
}
// histIndexStoreWrite suit les écritures de la façade dans /internal/v1/store :
// une discussion écrite (bkChat/conv:<id>) est réindexée, une discussion
// effacée (DELETE, ou valeur vide) est retirée. Le contenu arrive scellé :
// decodeMemContent l'ouvre, la clé ne quitte pas ce process. Synchrone : la
// façade n'envoie la suppression d'une discussion qu'après la réponse à son
// écriture, l'ordre est donc tenu sans file.
func histIndexStoreWrite(bucket, key string, val []byte, deleted bool) {
if bucket != bkChat || !strings.HasPrefix(key, "conv:") {
return
}
id := strings.TrimPrefix(key, "conv:")
if deleted || len(val) == 0 {
histUnindexAt(dbPath(), id)
return
}
plain, err := decodeMemContent(val)
if err != nil {
return
}
if log, ok := histLogOf(plain); ok {
_ = histIndexAt(dbPath(), id, log)
}
}
// --- rattrapage ---------------------------------------------------------------
var histBackfillRunning atomic.Bool
// startHistSearchBackfill lance le rattrapage en tâche de fond, au démarrage
// (après un délai : laisser moteur et lien démarrer d'abord) et au
// déverrouillage de la mémoire. Un seul à la fois.
func startHistSearchBackfill() { startHistSearchBackfillAfter(20 * time.Second) }
func startHistSearchBackfillAfter(d time.Duration) {
if !histBackfillRunning.CompareAndSwap(false, true) {
return
}
path := dbPath()
go func() {
defer histBackfillRunning.Store(false)
time.Sleep(d)
// La base visée au lancement, et elle seule : sous les tests, LOKI_HOME
// change d'un test à l'autre — on ne rattrape pas dans la base d'un autre,
// ni ne recrée celle d'un test terminé.
if _, err := os.Stat(path); err != nil || dbPath() != path {
return
}
histSearchIndexBackfill()
}()
}
// histNeedsIndex : la discussion manque-t-elle à l'index, ou y est-elle plus
// ancienne que sa dernière écriture (une indexation ratée après l'écriture) ?
func histNeedsIndex(docs map[string]histSearchDoc, id string, updated int64) bool {
d, ok := docs[id]
return !ok || d.V != histSearchIndexVersion || d.At < updated
}
// histReadDocs lit toutes les fiches « d| » lisibles (petites : version, date,
// aperçu).
func histReadDocs() map[string]histSearchDoc {
out := map[string]histSearchDoc{}
_ = view(bkChatSearch, func(b *bolt.Bucket) error {
c := b.Cursor()
pfx := []byte("d|")
for k, v := c.Seek(pfx); k != nil && bytes.HasPrefix(k, pfx); k, v = c.Next() {
plain, err := decodeMemContent(v)
if err != nil {
continue
}
var d histSearchDoc
if json.Unmarshal(plain, &d) == nil {
out[string(k[len(pfx):])] = d
}
}
return nil
})
return out
}
// histConvIDs : identifiants des discussions qui ont un CORPS en base (clés
// « conv:<id> » de bkChat), sans lire les valeurs.
func histConvIDs() map[string]bool {
out := map[string]bool{}
_ = view(bkChat, func(b *bolt.Bucket) error {
c := b.Cursor()
pfx := []byte("conv:")
for k, _ := c.Seek(pfx); k != nil && bytes.HasPrefix(k, pfx); k, _ = c.Next() {
out[string(k[len(pfx):])] = true
}
return nil
})
return out
}
// histSearchIndexBackfill indexe les discussions absentes de l'index, ou dont
// la fiche est plus ancienne que leur dernière écriture. S'arrête net
// verrouillé (corps illisibles) : il reprendra au déverrouillage.
func histSearchIndexBackfill() {
if memLocked() {
return
}
updated := map[string]int64{}
for _, m := range convIndex() {
updated[m.ID] = m.Updated
}
docs := histReadDocs()
ids := make([]string, 0)
for id := range histConvIDs() {
if histNeedsIndex(docs, id, updated[id]) {
ids = append(ids, id)
}
}
sort.Strings(ids)
for _, id := range ids {
b, err := getStoreBytesErr(bkChat, convKey(id))
if err != nil {
if memLocked() {
return
}
continue // illisible pour une autre raison (abîmée) : on saute
}
log, ok := histLogOf(b)
if !ok {
continue
}
if err := histIndexAt(dbPath(), id, log); err != nil {
return
}
time.Sleep(30 * time.Millisecond) // ne pas monopoliser la base
}
}
// --- recherche ----------------------------------------------------------------
// histSearchProject : le projet dont la barre latérale montre les discussions.
// Lu en base et non dans le cache de ce process : quand la façade tient la
// conversation, c'est elle qui change de projet.
func histSearchProject() string {
if s := strings.TrimSpace(getStr(bkState, stActiveProjectKey)); s != "" {
return s
}
return defaultProjectSlug
}
// histSearch est le cœur de la recherche, sur les discussions du projet
// `project` ("" = projet actif). Pertinence d'abord (score = 2 × termes du titre
// + termes du texte, même ratio que recall_search), puis plus récente, puis
// identifiant. offset/limit découpent APRÈS le tri ; limit <= 0 = tout.
func histSearch(q, project string, offset, limit int) histSearchResult {
res := histSearchResult{State: "ok", Hits: make([]histSearchHit, 0), Terms: make([]string, 0)}
terms := histQueryTerms(q)
if len(terms) == 0 {
res.State = "no_terms"
return res
}
// Verrouillé décidé AVANT toute lecture : « illisible » et « absent » se
// confondent au fond. Pour une recherche, un vide serait un mensonge.
if memLocked() {
res.State = "locked"
return res
}
res.Terms = terms
if project == "" {
project = histSearchProject()
}
metaByID := map[string]convMeta{}
for _, m := range convIndex() {
if convProjectOf(m) == project {
metaByID[m.ID] = m
}
}
// Discussion ouverte : quand CE process la tient, son journal en RAM est
// plus frais que sa dernière écriture (un tour en cours). Copié sous c.mu
// puis relâché : on tokenise hors verrou, jamais en bloquant une génération.
// Quand la façade la tient, le journal d'ici est périmé : l'index (alimenté
// par ses écritures) fait foi.
var liveID string
var liveLog []LogEvent
if !facadeChat() {
liveID = convActiveCached()
conv.mu.Lock()
liveLog = append([]LogEvent(nil), conv.Log...)
conv.mu.Unlock()
if _, ok := metaByID[liveID]; !ok || len(liveLog) == 0 {
liveID, liveLog = "", nil
}
}
titleHits := map[string]int{}
for id, m := range metaByID {
set := histTokenSet(m.Title)
n := 0
for _, t := range terms {
if histSetMatches(set, t) {
n++
}
}
if n > 0 {
titleHits[id] = n
}
}
// UNE vue sur l'index : postings des termes (scan PRÉFIXE : les clés sont
// triées, toutes celles qui commencent par « p|<terme> » se suivent) et
// fiches des discussions du projet (couverture, aperçu). Aucun corps lu.
bodyHits := map[string]int{}
docs := map[string]histSearchDoc{}
_ = view(bkChatSearch, func(b *bolt.Bucket) error {
for _, t := range terms {
prefix := []byte(histPostKey(t))
matched := map[string]bool{} // un terme ne compte qu'UNE fois par discussion
c := b.Cursor()
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
plain, err := decodeMemContent(v)
if err != nil {
continue
}
var ids []string
if json.Unmarshal(plain, &ids) != nil {
continue
}
for _, sid := range ids {
matched[sid] = true
}
}
for sid := range matched {
bodyHits[sid]++
}
}
for id := range metaByID {
var d histSearchDoc
if histTxGetJSON(b, histDocKey(id), &d) && d.V == histSearchIndexVersion {
docs[id] = d
}
}
return nil
})
// Discussion ouverte : son journal en RAM REMPLACE sa copie indexée (et non
// +=) — une seule entrée par identifiant, jamais de double compte.
if liveID != "" {
set := histTokenSet(histVisibleText(liveLog))
n := 0
for _, t := range terms {
if histSetMatches(set, t) {
n++
}
}
bodyHits[liveID] = n
}
score := func(id string) int { return 2*titleHits[id] + bodyHits[id] }
hits := make([]histSearchHit, 0)
for id, m := range metaByID {
if sc := score(id); sc > 0 {
hits = append(hits, histSearchHit{ID: id, Title: m.Title, Created: m.Created,
Updated: m.Updated, Turns: m.Turns, Score: sc, InTitle: titleHits[id] > 0})
}
}
// Les identifiants des postings absents de l'index des discussions (supprimées
// verrouillé, ou d'un autre projet) ne sont jamais parcourus : seul metaByID l'est.
sort.Slice(hits, func(i, j int) bool {
if hits[i].Score != hits[j].Score {
return hits[i].Score > hits[j].Score
}
if hits[i].Updated != hits[j].Updated {
return hits[i].Updated > hits[j].Updated
}
return hits[i].ID < hits[j].ID
})
res.Total = len(hits)
if limit > 0 {
off := max(0, min(offset, len(hits)))
hits = hits[off:min(off+limit, len(hits))]
}
histFillSnippets(hits, terms, docs, liveID, liveLog)
res.Hits = hits
// Couverture : le dénominateur est l'ensemble des discussions RÉELLEMENT
// indexables du projet — une entrée d'index sans corps en base (discussion
// toute neuve, jamais écrite) ne le sera jamais, la compter laisserait
// l'UI annoncer « indexation en cours » pour toujours (constat amont).
// Invariant : Indexed <= IndexedTotal.
bodies := histConvIDs()
for id, m := range metaByID {
if !bodies[id] && id != liveID {
continue
}
res.IndexedTotal++
if id == liveID {
res.Indexed++
continue
}
if !histNeedsIndex(docs, id, m.Updated) {
res.Indexed++
}
}
return res
}
// histFillSnippets pose l'extrait de chaque résultat de la PAGE : le passage
// autour du premier mot trouvé, relu dans le corps (une seule ouverture de la
// base pour toute la page) ; à défaut — corps illisible, mot trouvé seulement
// dans le titre — l'aperçu de la fiche. Le classement, lui, ne dépend jamais
// des corps.
func histFillSnippets(hits []histSearchHit, terms []string, docs map[string]histSearchDoc, liveID string, liveLog []LogEvent) {
if len(hits) == 0 {
return
}
texts := map[string]string{}
if liveID != "" {
texts[liveID] = histVisibleText(liveLog)
}
_ = view(bkChat, func(b *bolt.Bucket) error {
for _, h := range hits {
if _, ok := texts[h.ID]; ok {
continue
}
raw := b.Get([]byte(convKey(h.ID)))
if raw == nil {
continue
}
plain, err := decodeMemContent(raw)
if err != nil {
continue
}
if log, ok := histLogOf(plain); ok {
texts[h.ID] = histVisibleText(log)
}
}
return nil
})
for i := range hits {
s := ""
if txt, ok := texts[hits[i].ID]; ok {
s = histContextSnippet(txt, terms)
}
if s == "" {
s = docs[hits[i].ID].Apercu
}
if s == "" && liveID == hits[i].ID {
s = recallSnippet(histFirstVisible(liveLog), 200)
}
hits[i].Snippet = s
}
}
// handleConvSearch : GET /api/conversations/search?q=&offset=&limit=.
// Discussions du projet actif dont le titre OU le texte contient les mots
// cherchés (préfixes), avec un extrait. Toujours servie par ce process, même
// quand la façade tient la conversation (voir le commentaire de tête).
func handleConvSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "GET attendu"})
return
}
q := r.URL.Query()
if utf8.RuneCountInString(q.Get("q")) > 500 {
sendJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "requête trop longue"})
return
}
offset, _ := strconv.Atoi(q.Get("offset"))
limit, err := strconv.Atoi(q.Get("limit"))
if err != nil || limit <= 0 {
limit = histSearchDefaultLimit
}
limit = min(limit, histSearchMaxLimit)
res := histSearch(q.Get("q"), "", max(0, offset), limit)
// Index incomplet (discussions d'avant la fonctionnalité, indexation ratée) :
// on relance le rattrapage plutôt que d'attendre le prochain démarrage. L'UI
// annonce la couverture partielle en attendant.
if res.State == "ok" && res.Indexed < res.IndexedTotal {
startHistSearchBackfillAfter(time.Second)
}
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "state": res.State, "terms": res.Terms,
"conversations": res.Hits, "total": res.Total,
"indexed": res.Indexed, "indexed_total": res.IndexedTotal})
}
-728
View File
@@ -1,728 +0,0 @@
package loki
// chat_search_test.go — recherche plein-texte dans les discussions. Repris
// d'AJEAN (chat_search_test.go, commit 558d3b4d, issue amont #98) et adaptés au
// modèle de Loki : pas d'archives à part, toutes les discussions vivent dans
// bkChat/conv:<id> et l'index suit chaque écriture de l'écrivain différé
// (chat_persist.go) — ou de la façade, par /internal/v1/store. S'y ajoutent les
// cas propres à Loki : écritures de la façade, extrait en contexte, mise à jour
// incrémentale, index périmé.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// --- aides --------------------------------------------------------------------
// histSetup isole $LOKI_HOME et neutralise la discussion ouverte du process :
// chaque test pose explicitement celle qu'il veut (histSetLive).
func histSetup(t *testing.T) {
t.Helper()
testHome(t)
clearMemDEK()
conv.mu.Lock()
prevLog := conv.Log
conv.Log = nil
conv.mu.Unlock()
convActiveCache.Store(nil)
t.Cleanup(func() {
conv.mu.Lock()
conv.Log = prevLog
conv.mu.Unlock()
convActiveCache.Store(nil)
clearMemDEK()
})
}
func histUser(ts int64, s string) LogEvent {
return LogEvent{TS: ts, Delta: map[string]any{"user": s}}
}
func histContent(ts int64, s string) LogEvent {
return LogEvent{TS: ts, Delta: map[string]any{"content": s}}
}
func histBody(t *testing.T, log []LogEvent) []byte {
t.Helper()
b, err := json.Marshal(map[string]any{"messages": []Message{}, "log": log})
if err != nil {
t.Fatal(err)
}
return b
}
// histConv écrit une discussion PAR LE CHEMIN DE PRODUCTION (l'écrivain
// différé, qui pose contenu, entrée d'index et index plein-texte), puis fixe
// son titre et sa date dans l'index des discussions.
func histConv(t *testing.T, id, project, title string, updated int64, log []LogEvent) {
t.Helper()
if project == "" {
project = defaultProjectSlug
}
turns := 0
for _, ev := range log {
if _, ok := ev.Delta["user"]; ok {
turns++
}
}
persistQ.wait(persistQ.enqueueSnap(&convSnap{path: dbPath(), id: id, seq: persistSeq.Add(1),
body: histBody(t, log), title: title, turns: turns, project: project}))
histSetMeta(t, convMeta{ID: id, Title: title, Created: updated, Updated: updated, Turns: turns, Project: project})
}
// histSetMeta pose (ou remplace) l'entrée d'une discussion dans l'index des
// discussions, sans toucher à son contenu.
func histSetMeta(t *testing.T, m convMeta) {
t.Helper()
convIndexMu.Lock()
defer convIndexMu.Unlock()
idx := convIndex()
for i := range idx {
if idx[i].ID == m.ID {
idx[i] = m
convIndexSave(idx)
return
}
}
convIndexSave(append(idx, m))
}
// histSetLive fait de id la discussion ouverte, avec ce journal en RAM.
func histSetLive(t *testing.T, id string, log []LogEvent) {
t.Helper()
convActiveRemember(id)
conv.mu.Lock()
conv.Log = append([]LogEvent(nil), log...)
conv.mu.Unlock()
}
func histHasHit(res histSearchResult, id string) bool {
for _, h := range res.Hits {
if h.ID == id {
return true
}
}
return false
}
func histHitIDs(res histSearchResult) string {
var ids []string
for _, h := range res.Hits {
ids = append(ids, h.ID)
}
return strings.Join(ids, ",")
}
func histDocExists(t *testing.T, id string) bool {
t.Helper()
_, ok := getStoreBytes(bkChatSearch, histDocKey(id))
return ok
}
func histPosting(t *testing.T, tok string) []string {
t.Helper()
b, ok := getStoreBytes(bkChatSearch, histPostKey(tok))
if !ok {
return nil
}
var ids []string
if err := json.Unmarshal(b, &ids); err != nil {
t.Fatalf("posting %q illisible : %v", tok, err)
}
return ids
}
// --- 1. texte trouvé même si le titre ne contient rien ------------------------
func TestHistSearchBodyMatch(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Voyage en Bretagne", 100, []LogEvent{histUser(1, "parlons du phare de Brest")})
histConv(t, "B", "", "Recette", 200, []LogEvent{histUser(1, "préparons une tarte")})
res := histSearch("phare", "", 0, 0)
if res.State != "ok" {
t.Fatalf("état attendu ok, obtenu %q", res.State)
}
if res.Total != 1 || len(res.Hits) != 1 || res.Hits[0].ID != "A" {
t.Fatalf("A attendu seul, obtenu total=%d %+v", res.Total, res.Hits)
}
if res.Hits[0].Snippet == "" {
t.Fatal("extrait attendu non vide pour A")
}
if res.Hits[0].InTitle {
t.Fatal("A est trouvée par son texte, pas par son titre")
}
}
// --- 2. un mot du titre pèse plus qu'un mot du texte --------------------------
// Le titre compte double (+2/+1, comme recall_search). B est plus RÉCENTE que
// A : un tri par date seule inverserait l'ordre.
func TestHistSearchTitleBeatsBody(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Le phare de Brest", 100, []LogEvent{histUser(1, "rien de spécial")})
histConv(t, "B", "", "Discussion diverse", 200, []LogEvent{histUser(1, "on a vu un phare hier")})
res := histSearch("phare", "", 0, 0)
if got := histHitIDs(res); got != "A,B" {
t.Fatalf("ordre attendu A,B, obtenu %s", got)
}
if !res.Hits[0].InTitle || res.Hits[1].InTitle {
t.Fatalf("in_title attendu vrai pour A seulement : %+v", res.Hits)
}
}
// --- 3. pertinence, puis date, puis identifiant -------------------------------
func TestHistSearchRelevanceThenDate(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Sans rapport", 200, []LogEvent{histUser(1, "un phare dans le texte")})
histConv(t, "B", "", "Phare ancien", 100, []LogEvent{histUser(1, "texte neutre")})
histConv(t, "C", "", "Phare récent", 300, []LogEvent{histUser(1, "texte neutre")})
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "C,B,A" {
t.Fatalf("ordre attendu C,B,A, obtenu %s", got)
}
}
// --- 4. la discussion OUVERTE est cherchée dans sa version en RAM -------------
// En plein tour, le journal en mémoire est plus frais que la dernière écriture :
// le mot vient d'arriver, il n'est pas encore dans l'index.
func TestHistSearchIncludesActiveSession(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Autre sujet", 100, []LogEvent{histUser(1, "on parle boussole")})
histConv(t, "live1", "", "salut", 200, []LogEvent{histUser(1, "salut")})
histSetLive(t, "live1", []LogEvent{histUser(1, "salut"), histContent(2, "le phare en direct")})
res := histSearch("phare", "", 0, 0)
if res.Total != 1 || len(res.Hits) != 1 || res.Hits[0].ID != "live1" {
t.Fatalf("la discussion ouverte devait ressortir seule : %+v", res.Hits)
}
if !strings.Contains(res.Hits[0].Snippet, "phare") {
t.Fatalf("extrait tiré du journal en RAM attendu, obtenu %q", res.Hits[0].Snippet)
}
}
// --- 5. chercher n'écrit RIEN -------------------------------------------------
func TestHistSearchDoesNotWrite(t *testing.T) {
histSetup(t)
histConv(t, "live1", "", "salut", 100, []LogEvent{histUser(1, "salut")})
histSetLive(t, "live1", []LogEvent{histUser(1, "le phare en direct")})
beforeChat, beforeIdx := allKV(bkChat), allKV(bkChatSearch)
_ = histSearch("phare", "", 0, 0)
if after := allKV(bkChat); len(after) != len(beforeChat) || after[convKey("live1")] != beforeChat[convKey("live1")] || after[ckIndex] != beforeChat[ckIndex] {
t.Fatal("la recherche a réécrit une discussion ou l'index des discussions")
}
after := allKV(bkChatSearch)
if len(after) != len(beforeIdx) {
t.Fatalf("la recherche a modifié l'index plein-texte (%d → %d clés)", len(beforeIdx), len(after))
}
for k, v := range beforeIdx {
if after[k] != v {
t.Fatalf("la recherche a réécrit %q", k)
}
}
}
// --- 6. cloisonnement par projet ----------------------------------------------
func TestHistSearchProjectScope(t *testing.T) {
histSetup(t)
histConv(t, "A", "alpha", "Projet alpha", 100, []LogEvent{histUser(1, "le phare")})
histConv(t, "B", "beta", "Projet beta", 100, []LogEvent{histUser(1, "le phare")})
if got := histHitIDs(histSearch("phare", "alpha", 0, 0)); got != "A" {
t.Fatalf("projet alpha : A attendu seul, obtenu %s", got)
}
if got := histHitIDs(histSearch("phare", "beta", 0, 0)); got != "B" {
t.Fatalf("projet beta : B attendu seul, obtenu %s", got)
}
// Sans projet donné : celui de la barre latérale, lu en base.
if err := setActiveProject("beta"); err != nil {
t.Fatal(err)
}
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "B" {
t.Fatalf("projet actif beta : B attendu seul, obtenu %s", got)
}
}
// --- 7. mémoire verrouillée = état explicite, pas « rien trouvé » -------------
func TestHistSearchLockedState(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Secret", 100, []LogEvent{histUser(1, "le phare secret")})
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatal(err)
}
clearMemDEK()
res := histSearch("phare", "", 0, 0)
if res.State != "locked" {
t.Fatalf("état attendu locked, obtenu %q", res.State)
}
if len(res.Hits) != 0 || res.Total != 0 {
t.Fatalf("aucun résultat attendu verrouillé, obtenu %+v", res.Hits)
}
}
// --- 8. requête sans terme exploitable ----------------------------------------
func TestHistSearchNoUsableTerms(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Phare", 100, []LogEvent{histUser(1, "le phare")})
res := histSearch("a!", "", 0, 0)
if res.State != "no_terms" {
t.Fatalf("état attendu no_terms, obtenu %q", res.State)
}
if res.Terms == nil || len(res.Terms) != 0 || res.Total != 0 {
t.Fatalf("aucun terme (tableau vide, jamais null) ni résultat attendu : terms=%#v total=%d", res.Terms, res.Total)
}
}
// --- 9. cycle de vie : écriture puis suppression de la discussion -------------
func TestHistSearchIndexLifecycle(t *testing.T) {
histSetup(t)
histConv(t, "lifeA", "", "A", 100, []LogEvent{histUser(1, "le phare")})
histConv(t, "lifeB", "", "B", 200, []LogEvent{histUser(1, "le phare")})
if !histDocExists(t, "lifeA") || !histDocExists(t, "lifeB") {
t.Fatal("les deux fiches d| devaient exister après l'écriture")
}
if ids := histPosting(t, "phare"); len(ids) != 2 {
t.Fatalf("posting phare attendu de 2, obtenu %v", ids)
}
if err := convDelete("lifeA"); err != nil {
t.Fatal(err)
}
if histDocExists(t, "lifeA") {
t.Fatal("d|lifeA devait disparaître avec la discussion")
}
if _, ok := getStoreBytes(bkChatSearch, histToksKey("lifeA")); ok {
t.Fatal("t|lifeA devait disparaître avec la discussion")
}
if ids := histPosting(t, "phare"); len(ids) != 1 || ids[0] != "lifeB" {
t.Fatalf("posting phare attendu [lifeB], obtenu %v", ids)
}
}
// --- 9 bis. mise à jour incrémentale ------------------------------------------
// Réécrire une discussion ne touche que les mots qui ont changé — et un mot
// disparu (journal compacté, discussion vidée) quitte bien l'index.
func TestHistSearchIncrementalUpdate(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "phare boussole")})
if ids := histPosting(t, "boussole"); len(ids) != 1 {
t.Fatalf("boussole attendu indexé, obtenu %v", ids)
}
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "phare compas")})
if ids := histPosting(t, "boussole"); len(ids) != 0 {
t.Fatalf("boussole devait quitter l'index, obtenu %v", ids)
}
if ids := histPosting(t, "compas"); len(ids) != 1 || ids[0] != "A" {
t.Fatalf("compas attendu [A], obtenu %v", ids)
}
if ids := histPosting(t, "phare"); len(ids) != 1 {
t.Fatalf("phare attendu une fois, obtenu %v", ids)
}
}
// --- 10. l'index est chiffré ----------------------------------------------------
func TestHistSearchBucketEncrypted(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Titre secret", 100, []LogEvent{histUser(1, "le phare")})
if raw := getBytes(bkChatSearch, histDocKey("A")); looksEncrypted(raw) {
t.Fatal("avant activation, la valeur ne devait pas être chiffrée")
}
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatal(err)
}
for _, k := range []string{histDocKey("A"), histToksKey("A"), histPostKey("phare")} {
if !looksEncrypted(getBytes(bkChatSearch, k)) {
t.Fatalf("%s devait être chiffré (bucket absent d'encryptedBuckets ?)", k)
}
}
// Écrite après l'activation : chiffrée d'emblée, et toujours trouvable.
histConv(t, "B", "", "Autre", 200, []LogEvent{histUser(1, "le phare encore")})
if !looksEncrypted(getBytes(bkChatSearch, histPostKey("encore"))) {
t.Fatal("posting écrit mémoire chiffrée resté en clair")
}
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "B,A" {
t.Fatalf("B,A attendus mémoire déverrouillée, obtenu %s", got)
}
clearMemDEK()
if _, ok := getStoreBytes(bkChatSearch, histDocKey("A")); ok {
t.Fatal("index relisible alors que la mémoire est verrouillée")
}
}
// --- 11. couverture : jamais présenter un index partiel comme complet ---------
func TestHistSearchCoverageContract(t *testing.T) {
histSetup(t)
log := []LogEvent{histUser(1, "le phare")}
// Écrites À LA MAIN, sans passer par l'index : des discussions d'avant la
// fonctionnalité.
for _, id := range []string{"a", "b", "c"} {
if err := putStoreBytes(bkChat, convKey(id), histBody(t, log)); err != nil {
t.Fatal(err)
}
histSetMeta(t, convMeta{ID: id, Title: id, Updated: 100, Project: defaultProjectSlug})
}
if err := histIndexAt(dbPath(), "a", log); err != nil {
t.Fatal(err)
}
res := histSearch("phare", "", 0, 0)
if res.IndexedTotal != 3 || res.Indexed != 1 {
t.Fatalf("couverture attendue 1/3, obtenue %d/%d", res.Indexed, res.IndexedTotal)
}
if res.Total != 1 {
t.Fatalf("1 seule trouvable avant rattrapage, obtenu %d", res.Total)
}
histSearchIndexBackfill()
res = histSearch("phare", "", 0, 0)
if res.Indexed != 3 || res.IndexedTotal != 3 || res.Total != 3 {
t.Fatalf("après rattrapage : %d/%d, %d trouvées (3 attendues)", res.Indexed, res.IndexedTotal, res.Total)
}
}
// --- 11 bis. une fiche plus ancienne que la discussion est à refaire ----------
// L'écriture a réussi, l'indexation qui suit a raté (base occupée, process
// coupé) : la fiche existe mais décrit un état antérieur. Elle ne doit pas
// compter comme à jour, et le rattrapage doit la refaire.
func TestHistSearchStaleDocReindexed(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "le phare")})
future := histReadDocs()["A"].At + 10
if err := putStoreBytes(bkChat, convKey("A"), histBody(t, []LogEvent{histUser(1, "le phare"), histContent(2, "une boussole")})); err != nil {
t.Fatal(err)
}
histSetMeta(t, convMeta{ID: "A", Title: "Neutre", Updated: future, Project: defaultProjectSlug})
res := histSearch("boussole", "", 0, 0)
if res.Total != 0 || res.Indexed != 0 || res.IndexedTotal != 1 {
t.Fatalf("fiche périmée : 0 trouvée et couverture 0/1 attendues, obtenu %d trouvée(s), %d/%d", res.Total, res.Indexed, res.IndexedTotal)
}
histSearchIndexBackfill()
// Le rattrapage date la fiche de MAINTENANT : on recale la discussion dessus.
histSetMeta(t, convMeta{ID: "A", Title: "Neutre", Updated: histReadDocs()["A"].At, Project: defaultProjectSlug})
res = histSearch("boussole", "", 0, 0)
if res.Total != 1 || res.Indexed != 1 {
t.Fatalf("après rattrapage : boussole attendue trouvée et index à jour, obtenu %d trouvée(s), %d/%d", res.Total, res.Indexed, res.IndexedTotal)
}
}
// --- 12. le classement vient de l'index, pas des corps ------------------------
// Corps disparu, fiche et postings restés : la discussion reste trouvable, et
// l'extrait se rabat sur l'aperçu de la fiche.
func TestHistSearchRanksFromIndexNotBodies(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Titre", 100, []LogEvent{histUser(1, "le phare")})
if err := putBytes(bkChat, convKey("A"), nil); err != nil {
t.Fatal(err)
}
res := histSearch("phare", "", 0, 0)
if res.Total != 1 || res.Hits[0].ID != "A" {
t.Fatalf("le résultat devait venir de l'index, obtenu %+v", res.Hits)
}
if res.Hits[0].Snippet != "le phare" {
t.Fatalf("aperçu de la fiche attendu, obtenu %q", res.Hits[0].Snippet)
}
}
// --- 13. la discussion ouverte n'apparaît qu'une fois, son score n'est pas SOMMÉ
// Ouverte ET indexée : une seule entrée, et le journal en RAM REMPLACE la copie
// indexée au lieu de s'y ajouter. Requête « alpha beta », A ouverte :
//
// A : titre « alpha » (1) ; texte « alpha beta » (2) → 2*1+2 = 4 ; sommé → 6.
// B : titre « alpha beta » (2) ; texte « alpha » (1) → 2*2+1 = 5.
//
// Remplacement : [B A]. Somme : [A B] — l'ordre s'inverse.
func TestHistSearchDeduplicatesActiveAgainstIndex(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "alpha", 100, []LogEvent{histUser(1, "alpha beta")})
histConv(t, "B", "", "alpha beta", 200, []LogEvent{histUser(1, "alpha")})
histSetLive(t, "A", []LogEvent{histUser(1, "alpha beta")})
res := histSearch("alpha beta", "", 0, 0)
if got := histHitIDs(res); got != "B,A" {
t.Fatalf("une entrée par discussion, score remplacé : B,A attendus, obtenu %s (%+v)", got, res.Hits)
}
}
// --- 14. pagination : total AVANT découpage -----------------------------------
func TestHistSearchPagination(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Phare A", 100, []LogEvent{histUser(1, "le phare")})
histConv(t, "B", "", "Phare B", 200, []LogEvent{histUser(1, "le phare")})
histConv(t, "C", "", "Phare C", 300, []LogEvent{histUser(1, "le phare")})
page := histSearch("phare", "", 1, 1)
if len(page.Hits) != 1 || page.Total != 3 || page.Hits[0].ID != "B" {
t.Fatalf("page 2 de 1 : B et total 3 attendus, obtenu %+v total=%d", page.Hits, page.Total)
}
beyond := histSearch("phare", "", 10, 5)
if len(beyond.Hits) != 0 || beyond.Total != 3 {
t.Fatalf("au-delà de la fin : 0 résultat et total 3 attendus, obtenu %d / %d", len(beyond.Hits), beyond.Total)
}
}
// --- 15. repli des accents, dans les DEUX sens --------------------------------
func TestHistSearchAccentFolding(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Notes", 100, []LogEvent{histUser(1, "voici le résumé du rapport")})
histConv(t, "B", "", "Notes", 200, []LogEvent{histUser(1, "voici le resume du dossier")})
if n := len(histSearch("resume", "", 0, 0).Hits); n != 2 {
t.Fatalf("resume devait trouver les 2, obtenu %d", n)
}
if n := len(histSearch("RÉSUMÉ", "", 0, 0).Hits); n != 2 {
t.Fatalf("RÉSUMÉ devait trouver les 2, obtenu %d", n)
}
}
// --- 16. seul le texte VISIBLE est indexé -------------------------------------
func TestHistSearchIndexesVisibleTextOnly(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histContent(1, "voici un zèbre dans la réponse")})
histConv(t, "B", "", "Neutre", 200, []LogEvent{{TS: 1, Delta: map[string]any{"tool_used": map[string]any{
"name": "bash", "label": "rg zèbre", "result": "peu importe", "done": true}}}})
histConv(t, "C", "", "Neutre", 300, []LogEvent{{TS: 1, Delta: map[string]any{"reasoning_content": "je pense au zèbre"}}})
histConv(t, "D", "", "Neutre", 400, []LogEvent{{TS: 1, Delta: map[string]any{"tool_used": map[string]any{
"name": "bash", "label": "ls", "result": "le zèbre est dans la sortie", "done": true}}}})
res := histSearch("zèbre", "", 0, 0)
if !histHasHit(res, "A") || !histHasHit(res, "B") {
t.Fatalf("réponse et libellé d'outil devaient être indexés : %+v", res.Hits)
}
if histHasHit(res, "C") || histHasHit(res, "D") {
t.Fatalf("raisonnement et sortie d'outil ne devaient PAS être indexés : %+v", res.Hits)
}
}
// --- 17. correspondance par PRÉFIXE -------------------------------------------
func TestHistSearchPrefixMatchesBody(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Sujet neutre", 100, []LogEvent{histUser(1, "on passe du temps à compiling le binaire")})
histConv(t, "B", "", "Autre", 200, []LogEvent{histUser(1, "rien à voir ici")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "A" {
t.Fatalf("compil devait trouver A (« compiling »), obtenu %s", got)
}
}
// Garde-fous inverses : un préfixe qui ne commence aucun mot ne rend rien, et le
// MILIEU d'un mot ne correspond pas. « deepseek » est dans un TITRE à dessein :
// le scan des postings, borné au préfixe « p|eepseek », ne peut pas atteindre
// « deepseek » ; seul le titre verrait une bascule HasPrefix → Contains.
func TestHistSearchPrefixRejectsNonPrefix(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "compiling", 100, []LogEvent{histUser(1, "compiling le binaire")})
histConv(t, "B", "", "deepseek", 200, []LogEvent{histUser(1, "on parle deepseek ici")})
for _, q := range []string{"compilo", "eepseek"} {
if res := histSearch(q, "", 0, 0); res.Total != 0 {
t.Fatalf("%q ne devait rien trouver, obtenu %s", q, histHitIDs(res))
}
}
}
func TestHistSearchPrefixMatchesTitle(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Compilation annuelle", 100, []LogEvent{histUser(1, "texte neutre")})
histConv(t, "B", "", "Sujet divers", 200, []LogEvent{histUser(1, "on finit le compiling")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "A,B" {
t.Fatalf("le titre (+2) doit primer le texte (+1) : A,B attendus, obtenu %s", got)
}
}
// Un préfixe qui touche plusieurs mots d'une même discussion ne compte qu'une
// fois : à un incrément par mot, A (3 variantes, plus ancienne) passerait devant.
func TestHistSearchPrefixCountsTermOnce(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "compiling compilation compiled")})
histConv(t, "B", "", "Neutre", 200, []LogEvent{histUser(1, "on a fini de compiling")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "B,A" {
t.Fatalf("score par TERME (égalité, B plus récente) : B,A attendus, obtenu %s", got)
}
}
// La discussion ouverte suit la même règle de préfixe que l'index.
func TestHistSearchPrefixMatchesLiveSession(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Autre", 100, []LogEvent{histUser(1, "rien de spécial")})
histConv(t, "live1", "", "salut", 200, []LogEvent{histUser(1, "salut")})
histSetLive(t, "live1", []LogEvent{histUser(1, "salut"), histContent(2, "on parle de compilation ici")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "live1" {
t.Fatalf("la discussion ouverte devait ressortir sur « compil », obtenu %s", got)
}
}
// --- 18. couverture : une entrée d'index SANS corps n'est pas « à indexer » ---
// Une discussion toute neuve est dans l'index des discussions avant d'avoir
// jamais été écrite : elle ne recevra pas de fiche tant qu'elle est vide. La
// compter au dénominateur laissait l'UI annoncer « indexation en cours » pour
// toujours (constat amont, 13/14 stable).
func TestHistSearchCoverageIgnoresMetaWithoutBody(t *testing.T) {
histSetup(t)
histConv(t, "normale", "", "Sujet", 100, []LogEvent{histUser(1, "le phare")})
histSetMeta(t, convMeta{ID: "neuve", Title: "", Updated: 200, Project: defaultProjectSlug})
res := histSearch("phare", "", 0, 0)
if res.IndexedTotal != 1 || res.Indexed != 1 {
t.Fatalf("couverture 1/1 attendue, obtenue %d/%d", res.Indexed, res.IndexedTotal)
}
if !histHasHit(res, "normale") || histHasHit(res, "neuve") {
t.Fatalf("la discussion écrite attendue, la neuve exclue : %+v", res.Hits)
}
}
// --- 19. l'extrait montre le passage trouvé, pas le début -----------------------
// Le début d'une discussion est son titre (convSummary) : l'aperçu seul
// n'apprendrait rien. L'extrait est pris autour du mot trouvé, loin dans le fil.
func TestHistSearchSnippetInContext(t *testing.T) {
histSetup(t)
long := strings.Repeat("du texte sans rapport ", 40)
histConv(t, "A", "", "Question de départ", 100, []LogEvent{
histUser(1, "Question de départ"),
histContent(2, long+"et voici enfin le Phare du Créac'h, au bout de l'île. "+long),
})
res := histSearch("phare", "", 0, 0)
if len(res.Hits) != 1 {
t.Fatalf("A attendue, obtenu %+v", res.Hits)
}
sn := res.Hits[0].Snippet
if !strings.Contains(sn, "Phare du Créac'h") {
t.Fatalf("extrait autour du mot attendu, obtenu %q", sn)
}
if !strings.HasPrefix(sn, "…") || !strings.HasSuffix(sn, "…") {
t.Fatalf("extrait coupé des deux côtés attendu (…), obtenu %q", sn)
}
if n := len([]rune(sn)); n > 260 {
t.Fatalf("extrait trop long (%d runes) : %q", n, sn)
}
}
// --- 20. écritures de la FAÇADE (LOKI_CHAT=rust) ------------------------------
// La façade écrit ses discussions par /internal/v1/store, scellées par ce
// process : l'index doit les suivre — écriture, réécriture, suppression — sans
// que la façade ait rien à en savoir.
func TestHistSearchFacadeStoreWrites(t *testing.T) {
histSetup(t)
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatal(err)
}
histSetMeta(t, convMeta{ID: "r1", Title: "Écrite par la façade", Updated: 100, Project: defaultProjectSlug})
put := func(log []LogEvent) {
t.Helper()
sealed, err := encodeMemContent(histBody(t, log))
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/internal/v1/store?bucket=chat&key=conv:r1", strings.NewReader(string(sealed)))
handleInternalStore(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("PUT : %d %s", rr.Code, rr.Body.String())
}
}
put([]LogEvent{histUser(1, "le phare vu par la façade")})
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "r1" {
t.Fatalf("discussion de la façade attendue, obtenu %q", got)
}
if !looksEncrypted(getBytes(bkChatSearch, histPostKey("phare"))) {
t.Fatal("index tenu pour la façade resté en clair")
}
put([]LogEvent{histUser(1, "maintenant une boussole")})
if res := histSearch("phare", "", 0, 0); res.Total != 0 {
t.Fatalf("réécrite sans « phare » : plus rien attendu, obtenu %s", histHitIDs(res))
}
rr := httptest.NewRecorder()
handleInternalStore(rr, httptest.NewRequest(http.MethodDelete, "/internal/v1/store?bucket=chat&key=conv:r1", nil))
if rr.Code != http.StatusNoContent {
t.Fatalf("DELETE : %d", rr.Code)
}
if histDocExists(t, "r1") || len(histPosting(t, "boussole")) != 0 {
t.Fatal("discussion supprimée par la façade restée dans l'index")
}
// Une autre clé du même bucket (l'index des discussions) n'est pas une discussion.
histIndexStoreWrite(bkChat, ckIndex, []byte("[]"), false)
if histDocExists(t, "index") {
t.Fatal("l'index des discussions a été pris pour une discussion")
}
}
// La route reste à ce process : la façade ne la sert pas (elle la relaie), et
// le tunnel du relais ne la lui remet pas.
func TestHistSearchRouteNotDelegated(t *testing.T) {
if facadeChatRoutes["/api/conversations/search"] {
t.Fatal("/api/conversations/search ne doit pas être remise à la façade : l'index est tenu ici")
}
}
// --- 21. la route HTTP --------------------------------------------------------
func TestHandleConvSearch(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Le phare", 100, []LogEvent{histUser(1, "Le phare")})
rr := httptest.NewRecorder()
handleConvSearch(rr, httptest.NewRequest(http.MethodGet, "/api/conversations/search?q=phare", nil))
var got struct {
OK bool `json:"ok"`
State string `json:"state"`
Conversations []histSearchHit `json:"conversations"`
Total int `json:"total"`
Indexed int `json:"indexed"`
IndexedTotal int `json:"indexed_total"`
Terms []string `json:"terms"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("réponse illisible : %v (%s)", err, rr.Body.String())
}
if !got.OK || got.State != "ok" || got.Total != 1 || len(got.Conversations) != 1 || !got.Conversations[0].InTitle {
t.Fatalf("réponse inattendue : %+v", got)
}
if got.Indexed != 1 || got.IndexedTotal != 1 || strings.Join(got.Terms, ",") != "phare" {
t.Fatalf("couverture ou termes inattendus : %+v", got)
}
rr = httptest.NewRecorder()
handleConvSearch(rr, httptest.NewRequest(http.MethodGet, "/api/conversations/search?q=", nil))
if !strings.Contains(rr.Body.String(), `"conversations":[]`) || !strings.Contains(rr.Body.String(), `"no_terms"`) {
t.Fatalf("requête vide : tableau vide et no_terms attendus, obtenu %s", rr.Body.String())
}
rr = httptest.NewRecorder()
handleConvSearch(rr, httptest.NewRequest(http.MethodPost, "/api/conversations/search", nil))
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("POST : 405 attendu, obtenu %d", rr.Code)
}
}
-1
View File
@@ -330,7 +330,6 @@ func convDelete(id string) error {
convIndexSave(next)
convIndexMu.Unlock()
_ = putBytes(bkChat, convKey(id), nil)
histUnindexAt(dbPath(), id) // son index plein-texte part avec elle (chat_search.go)
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
// elle, et ses critères/mode/puce partent avec ses messages.
stopConvJobs(id)
+333
View File
@@ -0,0 +1,333 @@
package loki
// chat_video.go — vidéos vues par le modèle (repris d'AJEAN, PR #100 et son
// correctif « vidéo envoyée seulement si le moteur sait la lire »).
//
// Trois morceaux :
// - videoInputSupported : le moteur actif sait-il lire une partie
// `input_video` ? Seul un llama.cpp local récent le fait (option
// --video-fps, décodage par ffmpeg). Une API externe ou Strata ignorent la
// partie SANS erreur : le modèle ne recevait que le texte et cherchait à voir
// la vidéo avec see_image. Ailleurs, la vidéo reste un simple fichier, avec
// la marche à suivre (videoFallbackNote) ;
// - une vidéo JOINTE au message est rangée PAR RÉFÉRENCE, comme les images
// (chat_images.go) : une fois sur disque sous LOKI_HOME/chatvid/, chiffrée
// comme le fil, et l'historique n'en garde que « loki-vid:<nom> ».
// expandVideoRefs remet les octets juste avant l'envoi au moteur ;
// - la vidéo chargée par l'outil see_video (chat_video_tool.go) est, elle,
// TOUJOURS éphémère : elle part dans le tour en cours et jamais dans
// l'historique, KEEP_TURN_IMAGES compris (des milliers de jetons visuels).
//
// Écart avec l'amont : comme les images, un fil rechargé perd ses vidéos
// (stripImageParts, avec videoLostMarker) et pruneChatImages vide aussi
// chatvid/ — Loki ne rejoue jamais un média d'un fil rouvert.
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
const vidRefScheme = "loki-vid:"
func chatVidDir() string { return filepath.Join(LokiHome(), "chatvid") }
// maxVideoBytes borne la taille d'une vidéo montrée au modèle : le moteur en
// tire des images à --video-fps (4 par seconde par défaut), donc une vidéo
// longue = des dizaines de milliers de jetons visuels. Au-delà, le contexte
// sature sans que le modèle puisse raisonner dessus.
const maxVideoBytes = 200 << 20 // 200 Mio
// videoMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme vidéo (partie
// input_video). Le moteur décode lui-même avec ffmpeg ; un format hors de cette
// liste reste un simple fichier du dossier de travail.
var videoMimes = map[string]string{
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
}
// videoMime renvoie le type MIME vidéo d'un nom de fichier, ou "" si ce n'est
// pas une vidéo qu'on sait montrer au modèle.
func videoMime(name string) string {
return videoMimes[strings.ToLower(filepath.Ext(name))]
}
// videoFallbackNote : consigne quand le moteur ne lit pas la vidéo. Le fichier
// reste utilisable : quelques images extraites avec ffmpeg, puis see_image.
// ffmpeg est dans l'image Docker de Loki (l'image serveur officielle de
// llama.cpp l'installe pour son décodage vidéo) ; une installation hors
// conteneur peut ne pas l'avoir, d'où la dernière phrase.
const videoFallbackNote = "Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.\n"
// videoLostMarker : équivalent d'imageLostMarker pour une partie input_video
// (résumé de compaction, fil rechargé).
const videoLostMarker = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]"
// videoPart construit la partie multimodale {type:input_video, input_video:{url}}
// — le format que llama-server lit une fois --mmproj chargé.
func videoPart(b []byte, mime string) map[string]any {
return map[string]any{
"type": "input_video",
"input_video": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
},
}
}
// videoInputSupported : le moteur actif sait-il lire une partie input_video ?
// Preset local avec projecteur (MMPROJ) ET binaire dont l'aide annonce
// --video-fps. Variable : remplaçable dans les tests.
var videoInputSupported = func() bool {
cfg := ReadConfig()
if isExternalConfig(cfg) || isStrataConfig(cfg) || strings.TrimSpace(cfg["MMPROJ"]) == "" {
return false
}
bin := strings.TrimSpace(cfg["BIN"])
if bin == "" {
return false
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(LokiHome(), bin)
}
return binHelpHasVideo(prebuiltResolveBin(bin))
}
var videoCapCache sync.Map // "binaire\x00mtime" → bool
// binHelpHasVideo lit l'aide du binaire, une fois par binaire ET par date de
// modification (un moteur mis à jour en place est relu). Volontairement à part
// de binHelp : celle-ci garde aussi une aide VIDE pour toujours, et l'appeler
// avant le lancement du moteur (chemin des bibliothèques pas encore posé)
// figerait « aucune capacité » pour le vrai lancement. Ici, le chemin des
// bibliothèques est passé à la seule commande (libraryPathEnv).
func binHelpHasVideo(bin string) bool {
st, err := os.Stat(bin)
if err != nil || st.IsDir() {
return false
}
key := bin + "\x00" + st.ModTime().String()
if v, ok := videoCapCache.Load(key); ok {
return v.(bool)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "--help")
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput()
ok := strings.Contains(string(out), "--video-fps")
videoCapCache.Store(key, ok)
return ok
}
// --- Vidéos jointes, par référence -------------------------------------------
var vidExtByMime = map[string]string{
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/quicktime": ".mov",
"video/x-matroska": ".mkv",
"video/x-msvideo": ".avi",
}
// storeChatVideo range les octets d'une vidéo et renvoie sa référence. Chiffrée
// comme le fil quand la mémoire l'est ; verrouillée : refus, la vidéo reste en
// base64 dans le message (rien ne se perd).
func storeChatVideo(b []byte, mime string) (string, error) {
ext := vidExtByMime[mime]
if ext == "" {
ext = ".vid"
}
sum := sha256.Sum256(b)
name := hex.EncodeToString(sum[:16]) + ext
dir := chatVidDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err != nil {
enc, err := encodeMemContent(b)
if err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, p); err != nil {
return "", err
}
}
return vidRefScheme + name, nil
}
// Cache des data-URL reconstituées (même raison que imgCache : un tour d'agent
// renvoie tout l'historique à chaque étape). Borné en taille.
var (
vidCacheMu sync.Mutex
vidCache = map[string]string{}
vidCacheOrder []string
vidCacheBytes int
)
const vidCacheMax = 64 << 20
func chatVideoDataURL(ref string) (string, bool) {
name := strings.TrimPrefix(ref, vidRefScheme)
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return "", false
}
vidCacheMu.Lock()
if u, ok := vidCache[name]; ok {
vidCacheMu.Unlock()
return u, true
}
vidCacheMu.Unlock()
raw, err := os.ReadFile(filepath.Join(chatVidDir(), name))
if err != nil {
return "", false
}
b, err := decodeMemContent(raw)
if err != nil {
return "", false
}
mime := "video/mp4"
for m, e := range vidExtByMime {
if strings.HasSuffix(name, e) {
mime = m
}
}
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
vidCacheMu.Lock()
if _, ok := vidCache[name]; !ok {
vidCache[name] = u
vidCacheOrder = append(vidCacheOrder, name)
vidCacheBytes += len(u)
for vidCacheBytes > vidCacheMax && len(vidCacheOrder) > 1 {
old := vidCacheOrder[0]
vidCacheOrder = vidCacheOrder[1:]
vidCacheBytes -= len(vidCache[old])
delete(vidCache, old)
}
}
vidCacheMu.Unlock()
return u, true
}
// partVideoURL : l'adresse d'une partie input_video (url, sinon data — les deux
// formes que lit llama-server), "" pour toute autre partie.
func partVideoURL(p map[string]any) string {
if p["type"] != "input_video" {
return ""
}
iv, _ := p["input_video"].(map[string]any)
if iv == nil {
return ""
}
u, _ := iv["url"].(string)
if u == "" {
u, _ = iv["data"].(string)
}
return u
}
// refVideosInMessages remplace les vidéos en base64 des messages par des
// références. Comme refImagesInMessages, les parties modifiées sont RECOPIÉES,
// jamais mutées (persist peut tourner pendant qu'un runChat sérialise ces mêmes
// maps). Renvoie true si quelque chose a changé.
func refVideosInMessages(msgs []Message) bool {
changed := false
for mi, m := range msgs {
parts := contentParts(m.Content)
var np []map[string]any
for pi, p := range parts {
u := partVideoURL(p)
if !strings.HasPrefix(u, "data:video/") {
continue
}
i := strings.Index(u, ";base64,")
if i < 0 {
continue
}
b, err := base64.StdEncoding.DecodeString(u[i+8:])
if err != nil {
continue
}
ref, err := storeChatVideo(b, u[5:i])
if err != nil {
continue
}
if np == nil {
np = append([]map[string]any(nil), parts...)
}
np[pi] = map[string]any{"type": "input_video", "input_video": map[string]any{"url": ref}}
}
if np != nil {
msgs[mi].Content = np
changed = true
}
}
return changed
}
// hasInlineVideos : au moins une vidéo encore en base64 dans ces messages ?
func hasInlineVideos(msgs []Message) bool {
for _, m := range msgs {
for _, p := range contentParts(m.Content) {
if strings.HasPrefix(partVideoURL(p), "data:video/") {
return true
}
}
}
return false
}
// expandVideoRefs renvoie une COPIE des messages prête pour le modèle : chaque
// référence de vidéo redevient sa data-URL. Les messages sans référence sont
// partagés tels quels. Une vidéo introuvable devient une mention textuelle.
func expandVideoRefs(msgs []Message) []Message {
out := msgs
copied := false
for i, m := range msgs {
parts := contentParts(m.Content)
has := false
for _, p := range parts {
if strings.HasPrefix(partVideoURL(p), vidRefScheme) {
has = true
break
}
}
if !has {
continue
}
if !copied {
out = append([]Message(nil), msgs...)
copied = true
}
np := make([]map[string]any, 0, len(parts))
for _, p := range parts {
u := partVideoURL(p)
if !strings.HasPrefix(u, vidRefScheme) {
np = append(np, p)
continue
}
if d, ok := chatVideoDataURL(u); ok {
np = append(np, map[string]any{"type": "input_video", "input_video": map[string]any{"url": d}})
} else {
np = append(np, map[string]any{"type": "text", "text": "[vidéo indisponible : fichier supprimé]"})
}
}
m.Content = np
out[i] = m
}
return out
}
+332
View File
@@ -0,0 +1,332 @@
package loki
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// videoEngine simule un moteur qui sait (ou non) lire la vidéo.
func videoEngine(t *testing.T, ok bool) {
t.Helper()
prev := videoInputSupported
videoInputSupported = func() bool { return ok }
t.Cleanup(func() { videoInputSupported = prev })
}
// engineSees amorce le cache de la sonde /props : aucun llama-server ne tourne
// pendant les tests.
func engineSees(t *testing.T, seen bool) {
t.Helper()
visionProbeMu.Lock()
visionProbeAt, visionProbeSeen = time.Now(), seen
visionProbeMu.Unlock()
t.Cleanup(func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() })
}
// Une vidéo en base64 part sur disque et revient identique à l'envoi au modèle ;
// l'historique ne garde qu'une référence de quelques octets.
func TestVideoRefsRoundTrip(t *testing.T) {
testHome(t)
raw := []byte(strings.Repeat("\x00\x00\x00\x18ftypmp42-fake-video-bytes", 5000))
url := "data:video/mp4;base64," + base64.StdEncoding.EncodeToString(raw)
msgs := []Message{
{Role: "user", Content: []map[string]any{
{"type": "text", "text": "regarde la vidéo"},
{"type": "input_video", "input_video": map[string]any{"url": url}},
}},
{Role: "assistant", Content: "vu"},
}
// Relu depuis le JSON (forme []any), comme après un redémarrage.
var reread []Message
b, _ := json.Marshal(msgs)
_ = json.Unmarshal(b, &reread)
for _, set := range [][]Message{msgs, reread} {
orig := contentParts(set[0].Content)[1]
if !hasInlineVideos(set) || !refVideosInMessages(set) || hasInlineVideos(set) {
t.Fatal("la vidéo aurait dû être rangée par référence")
}
// Recopiée, jamais mutée : un runChat en vol peut lire ces mêmes maps.
if partVideoURL(orig) != url {
t.Fatal("refVideosInMessages a muté la partie d'origine")
}
js, _ := json.Marshal(set)
if len(js) > 500 || !strings.Contains(string(js), vidRefScheme) {
t.Fatalf("historique encore lourd : %d octets", len(js))
}
out := expandVideoRefs(set)
if got := partVideoURL(contentParts(out[0].Content)[1]); got != url {
t.Fatal("la vidéo renvoyée au modèle diffère de l'originale")
}
// L'historique lui-même n'est pas modifié par l'expansion.
if u := partVideoURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, vidRefScheme) {
t.Fatal("expandVideoRefs ne doit pas toucher l'historique")
}
}
// Fichier effacé (ménage) : une mention, pas une requête qui échoue.
_ = os.RemoveAll(chatVidDir())
vidCacheMu.Lock()
vidCache, vidCacheOrder, vidCacheBytes = map[string]string{}, nil, 0
vidCacheMu.Unlock()
out := expandVideoRefs(msgs)
if p := contentParts(out[0].Content)[1]; p["type"] != "text" || !strings.Contains(p["text"].(string), "indisponible") {
t.Fatalf("vidéo disparue : %v", p)
}
}
// userMessageContent : une vidéo jointe devient une partie input_video (et une
// image jointe reste une partie image_url), quand la vision est active et que
// le moteur lit la vidéo.
func TestUserMessageContentVideo(t *testing.T) {
videoEngine(t, true)
visionOn(t)
engineSees(t, true)
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "clip-test.mp4"), []byte("fake-mp4-bytes"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "photo.png"), pngPixel, 0o600); err != nil {
t.Fatal(err)
}
in := []attachInfo{
{Name: "clip-test.mp4", Path: "uploads/clip-test.mp4", Size: 14},
{Name: "photo.png", Path: "uploads/photo.png", Size: int64(len(pngPixel))},
}
parts, ok := userMessageContent(in, "décris ce clip").([]map[string]any)
if !ok || len(parts) != 3 {
t.Fatalf("attendu texte + image + vidéo, obtenu %#v", parts)
}
if parts[0]["type"] != "text" || parts[1]["type"] != "image_url" || parts[2]["type"] != "input_video" {
t.Fatalf("ordre des parties : %v %v %v", parts[0]["type"], parts[1]["type"], parts[2]["type"])
}
if u := partVideoURL(parts[2]); !strings.HasPrefix(u, "data:video/mp4;base64,") {
t.Fatalf("data URI vidéo inattendue : %.40q", u)
}
// Montrée, elle n'est plus annoncée comme simple fichier.
if txt := parts[0]["text"].(string); strings.Contains(txt, "clip-test.mp4") || strings.Contains(txt, "ffmpeg") {
t.Fatalf("vidéo vue annoncée comme fichier : %q", txt)
}
}
// Moteur sans vidéo (API externe, Strata, llama.cpp ancien) : il ignorait la
// partie input_video sans erreur et le modèle ne recevait que le texte. La
// vidéo doit alors être annoncée comme fichier, avec la marche à suivre
// (ffmpeg puis see_image), et see_video doit le dire au lieu de charger.
func TestVideoUnsupportedEngine(t *testing.T) {
videoEngine(t, false)
visionOn(t)
engineSees(t, true)
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
vname := "clip-test.mp4"
if err := os.WriteFile(filepath.Join(dir, vname), []byte("fake-mp4-bytes"), 0o600); err != nil {
t.Fatal(err)
}
content := userMessageContent([]attachInfo{{Name: vname, Path: "uploads/" + vname, Size: 14}}, "décris ce clip")
s, ok := content.(string)
if !ok {
t.Fatalf("attendu du texte seul (pas de partie input_video), obtenu %T", content)
}
if !strings.Contains(s, "uploads/"+vname) || !strings.Contains(s, "ffmpeg") || !strings.HasSuffix(s, "décris ce clip") {
t.Fatalf("chemin et consigne ffmpeg attendus : %q", s)
}
if msg, part := toolSeeVideo(filepath.Join(dir, vname)); part != nil || !strings.Contains(msg, "ffmpeg") {
t.Fatalf("see_video doit refuser avec la consigne ffmpeg : %q", msg)
}
// Ni outil annoncé, ni consigne quand ce n'est pas une vidéo.
for _, tl := range EnabledTools(Caps{Agent: true}) {
if tl.Function.Name == "see_video" {
t.Fatal("see_video annoncé alors que le moteur ne lit pas la vidéo")
}
}
if s := attachNote([]attachInfo{{Name: "a.txt", Path: "uploads/a.txt", Size: 1}}); strings.Contains(s, "ffmpeg") {
t.Fatalf("consigne vidéo sur un fichier texte : %q", s)
}
}
// see_video : annoncé avec see_image quand le moteur lit la vidéo.
func TestSeeVideoAnnonce(t *testing.T) {
videoEngine(t, true)
visionOn(t)
has := func(caps Caps) bool {
for _, tl := range EnabledTools(caps) {
if tl.Function.Name == "see_video" {
return true
}
}
return false
}
if !has(Caps{Agent: true}) {
t.Fatal("see_video absent alors que le moteur lit la vidéo")
}
if has(Caps{}) {
t.Fatal("see_video hors du mode agent")
}
if toolCallLabel("see_video", map[string]any{"file": "a.mp4"}) != "a.mp4" {
t.Fatal("libellé de see_video vide : l'outil s'exécuterait sur une chaîne vide")
}
if dedupableTool("see_video") {
t.Fatal("see_video dédupliqué : le rappel rendrait « déjà fait » sans la vidéo")
}
}
// toolSeeVideo : chaque refus nomme son motif ; un mp4 présent est chargé et
// relayé dans un message utilisateur qui nomme le fichier.
func TestToolSeeVideo(t *testing.T) {
videoEngine(t, true)
home := visionOn(t)
engineSees(t, true)
if err := os.MkdirAll(filepath.Join(home, "dossier.mp4"), 0o755); err != nil {
t.Fatal(err)
}
gros := filepath.Join(home, "gros.mp4")
f, _ := os.Create(gros)
_ = f.Truncate(maxVideoBytes + 1)
f.Close()
_ = os.WriteFile(filepath.Join(home, "vide.webm"), nil, 0o600)
cas := []struct{ path, attendu string }{
{"", "chemin de fichier manquant"},
{filepath.Join(home, "notes.txt"), "format non reconnu comme vidéo"},
{filepath.Join(home, "nulle-part.mp4"), "introuvable"},
{filepath.Join(home, "dossier.mp4"), "dossier"},
{gros, "trop lourde"},
{filepath.Join(home, "vide.webm"), "fichier vide"},
}
for _, c := range cas {
msg, part := toolSeeVideo(c.path)
if part != nil || !strings.HasPrefix(msg, "[erreur]") || !strings.Contains(msg, c.attendu) {
t.Errorf("%q : %q, attendu une erreur contenant %q", c.path, msg, c.attendu)
}
}
p := filepath.Join(home, "clip.MOV")
if err := os.WriteFile(p, []byte("fake-mov"), 0o600); err != nil {
t.Fatal(err)
}
msg, part := toolSeeVideo(p)
if part == nil || msg != "[ok] vidéo chargée : clip.MOV" {
t.Fatalf("attendu un [ok] : %q / %v", msg, part)
}
if u := partVideoURL(part); u != "data:video/quicktime;base64,"+base64.StdEncoding.EncodeToString([]byte("fake-mov")) {
t.Fatalf("partie inattendue : %v", part)
}
// Le moteur en service ne voit pas : on le dit au lieu d'envoyer 200 Mio.
engineSees(t, false)
if msg, part := toolSeeVideo(p); part != nil || !strings.Contains(msg, "ne traite pas les images") {
t.Fatalf("moteur aveugle : %q", msg)
}
// Relais : passé par l'API interne comme see_image (toolStepExtras).
_, _, relay := toolStepExtras("see_video", "clip.MOV", toolExecResult{Result: "[ok]", VisionVideo: videoPart([]byte("x"), "video/mp4")})
if relay == nil {
t.Fatal("aucun relais pour see_video")
}
rp := contentParts(relay.Content)
if relay.Role != "user" || rp[0]["text"] != "Vidéo demandée (clip.MOV) :" || rp[1]["type"] != "input_video" {
t.Fatalf("relais inattendu : %#v", relay)
}
}
// Sans projecteur ou sur un poste distant : refus explicite, avant toute
// question au moteur.
func TestSeeVideoSansVision(t *testing.T) {
videoEngine(t, true)
testHome(t)
if msg, part := toolSeeVideo("a.mp4"); part != nil || !strings.Contains(msg, "MMPROJ") {
t.Fatalf("sans vision : %q", msg)
}
}
// videoInputSupported : jamais pour une API externe, Strata, un preset sans
// projecteur ou sans moteur ; sinon, l'aide du binaire fait foi.
func TestVideoInputSupportedPresets(t *testing.T) {
testHome(t)
cas := []map[string]string{
{"BIN": "llama-server", "MODEL": "m.gguf"}, // pas de projecteur
{"MMPROJ": "mm.gguf", "MODEL": "m.gguf"}, // pas de moteur
{"MMPROJ": "mm.gguf", "BIN": "introuvable/llama-server"}, // moteur absent
{"MMPROJ": "mm.gguf", extKeyFlag: "1", extKeyVision: "1"}, // API externe
{"MMPROJ": "mm.gguf", "ENGINE": engineStrata, "BIN": "/bin/sh", "MODEL": "m"}, // Strata
}
for _, cfg := range cas {
if err := WriteConfig(cfg); err != nil {
t.Fatal(err)
}
if videoInputSupported() {
t.Errorf("%v : vidéo annoncée", cfg)
}
}
}
// L'aide du binaire décide, relue quand le binaire change (mise à jour en place).
func TestBinHelpHasVideo(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("faux moteur en sh")
}
dir := t.TempDir()
bin := filepath.Join(dir, "llama-server")
write := func(help string, mt time.Time) {
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho '"+help+"'\n"), 0o755); err != nil {
t.Fatal(err)
}
_ = os.Chtimes(bin, mt, mt)
}
write("--mmproj FILE", time.Now().Add(-time.Hour))
if binHelpHasVideo(bin) {
t.Fatal("moteur sans --video-fps annoncé comme lisant la vidéo")
}
write("--video-fps N target fps", time.Now())
if !binHelpHasVideo(bin) {
t.Fatal("moteur mis à jour : --video-fps non relu")
}
}
// Compaction, fil rechargé, export : la vidéo n'est jamais rejouée ni
// recopiée, mais sa perte reste visible.
func TestVideoPerdueVisible(t *testing.T) {
part := videoPart([]byte(strings.Repeat("v", 3000)), "video/mp4")
m := Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "regarde"}, part}}
if got := msgText(m); got != "regarde"+videoLostMarker {
t.Fatalf("msgText : %q", got)
}
var reread []Message
b, _ := json.Marshal([]Message{m})
_ = json.Unmarshal(b, &reread)
out := stripImageParts(reread)
if s, ok := out[0].Content.(string); !ok || s != "regarde"+videoLostMarker {
t.Fatalf("fil rechargé : %#v", out[0].Content)
}
exp := stripInlineImages([]Message{m})
js, _ := json.Marshal(exp)
if len(js) > 400 || !strings.Contains(string(js), "élidé") {
t.Fatalf("export : %s", js)
}
if partVideoURL(contentParts(m.Content)[1]) == "" || !strings.HasPrefix(partVideoURL(part), "data:video/mp4;base64,") {
t.Fatal("l'export a touché le message vivant")
}
}
// Le ménage des médias vide aussi les vidéos jointes trop vieilles.
func TestPruneChatVideos(t *testing.T) {
testHome(t)
ref, err := storeChatVideo([]byte("vieille"), "video/webm")
if err != nil || !strings.HasSuffix(ref, ".webm") {
t.Fatalf("rangement : %q %v", ref, err)
}
p := filepath.Join(chatVidDir(), strings.TrimPrefix(ref, vidRefScheme))
old := time.Now().Add(-2 * chatImgKeep)
_ = os.Chtimes(p, old, old)
pruneChatImages()
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatal("vieille vidéo gardée")
}
}
+91
View File
@@ -0,0 +1,91 @@
package loki
// chat_video_tool.go — l'outil see_video (repris d'AJEAN) : le modèle charge
// lui-même un fichier vidéo du disque dans sa VISION, sans que l'utilisateur ait
// à le joindre. Même mécanique que see_image (chat_vision_tool.go) : le résultat
// de l'outil reste un accusé texte, la vidéo part juste après dans un message
// utilisateur multimodal (partie input_video), que llama-server décode en
// images avec ffmpeg. Toujours ÉPHÉMÈRE : jamais dans l'historique.
//
// Annoncé seulement quand le moteur sait lire la vidéo (videoInputSupported) ;
// appelé quand même, il le dit avec la marche à suivre (ffmpeg puis see_image).
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// toolSeeVideo lit un fichier vidéo et renvoie (accusé texte, partie input_video).
// La partie vaut nil en cas d'erreur : l'appelant n'injecte alors rien. Même
// ordre de refus que toolSeeImage, la sonde du moteur en dernier.
func toolSeeVideo(path string) (string, map[string]any) {
if !visionEnabled() {
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo", nil
}
if agentTargetSlug() != "" {
return "[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
}
if path == "" {
return "[erreur] chemin de fichier manquant", nil
}
if !videoInputSupported() {
return "[erreur] " + strings.TrimSuffix(videoFallbackNote, "\n"), nil
}
abs := resolveAgentPath(path)
mime := videoMime(abs)
if mime == "" {
return "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : " + path, nil
}
st, err := os.Stat(abs)
if err != nil {
return "[erreur] fichier introuvable : " + path, nil
}
if st.IsDir() {
return "[erreur] c'est un dossier, pas une vidéo : " + path, nil
}
if st.Size() > maxVideoBytes {
return fmt.Sprintf("[erreur] vidéo trop lourde (%s, max %s) — coupe-la en un clip plus court : %s",
humanBytes(st.Size()), humanBytes(maxVideoBytes), path), nil
}
if !engineSeesImages() {
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
}
b, err := os.ReadFile(abs)
if err != nil {
return "[erreur] lecture impossible : " + err.Error(), nil
}
if len(b) == 0 {
return "[erreur] fichier vide : " + path, nil
}
return "[ok] vidéo chargée : " + filepath.Base(abs), videoPart(b, mime)
}
// seeVideoMessage emballe la partie vidéo dans le message utilisateur qui la
// porte jusqu'au modèle ; la légende nomme le fichier (voir seeImageMessage).
func seeVideoMessage(label string, vid map[string]any) Message {
text := "Vidéo :"
if label != "" {
text = "Vidéo demandée (" + label + ") :"
}
return Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": text},
vid,
}}
}
// seeVideoTool — schéma envoyé au modèle, tenu court comme seeImageTool.
func seeVideoTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "see_video",
Description: "Ouvre un fichier vidéo du disque pour la REGARDER (mp4, webm, mov, mkv, avi). Clips courts.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Chemin de la vidéo (relatif au dossier de travail, ou absolu)"},
},
"required": []string{"file"},
},
}}
}
+30 -11
View File
@@ -515,6 +515,12 @@ func EnabledTools(caps Caps) []Tool {
// pas — il promettrait alors de regarder, puis se contredirait.
if caps.Agent && visionEnabled() {
tools = append(tools, seeImageTool())
// Voir une vidéo : seulement si le moteur sait la lire (llama.cpp local
// avec --video-fps, chat_video.go). Ailleurs, la vidéo resterait lettre
// morte : Strata et les API externes ignorent la partie sans erreur.
if videoInputSupported() {
tools = append(tools, seeVideoTool())
}
}
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
tools = append(tools, webScreenshotTool())
@@ -670,12 +676,12 @@ func fillToolResult(ev *ToolUsedEvent, result string) *ToolUsedEvent {
// - leur résultat dépend de l'ÉTAT VIVANT de la page (un browser_snapshot sans
// argument a forcément la même clé de dédup à chaque appel, alors que la page
// a changé) — les dédupliquer fige l'IA sur un vieux cliché ;
// - surtout, browser_screenshot et see_image portent leur IMAGE dans un message
// - surtout, browser_screenshot et see_image (see_video : sa vidéo) portent leur IMAGE dans un message
// à part (visionImg) que le chemin de dédup ne rejoue PAS : l'IA recevrait
// « [déjà fait] » SANS l'image et tournerait en boucle.
func dedupableTool(name string) bool {
switch name {
case "bash", "bash_bg", "bash_tail", "see_image":
case "bash", "bash_bg", "bash_tail", "see_image", "see_video":
return false
}
return !strings.HasPrefix(name, "browser_")
@@ -1121,7 +1127,7 @@ func toolCallLabel(name string, args map[string]any) string {
switch name {
case "mem_search", "web_search", "web_images", "recall_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image", "see_video":
label, _ = args["file"].(string)
case "recall":
label, _ = args["id"].(string)
@@ -1221,9 +1227,10 @@ type chatPayloadOpts struct {
func buildChatPayload(ep chatEndpoint, sent []Message, temperature float64, o chatPayloadOpts) map[string]any {
payload := map[string]any{
"model": ep.Model,
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(sent),
// Les images et vidéos de l'historique y sont rangées par référence
// (chat_images.go, chat_video.go) : on remet leurs octets juste avant
// l'envoi.
"messages": expandVideoRefs(expandImageRefs(sent)),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
@@ -2343,7 +2350,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
var diffAdd, diffDel int // vrais totaux (diff est tronqué pour l'UI)
// visionImg : partie image_url rendue par see_image, réinjectée après
// le résultat de l'outil (un message `tool` ne porte que du texte).
var visionImg map[string]any
var visionImg, visionVideo map[string]any
// Appel rigoureusement identique déjà exécuté dans ce tour : on ne le
// rejoue pas. Les petits modèles réémettent volontiers deux fois la
// même écriture ; la rejouer produisait une fausse erreur (« old
@@ -2375,7 +2382,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
} else {
ex = execTool(ctx, tc.Function.Name, args, label, caps)
}
result, diff, diffAdd, diffDel, visionImg = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg
result, diff, diffAdd, diffDel, visionImg, visionVideo = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg, ex.VisionVideo
if ex.Ask != nil {
cb(StreamEvent{Ask: ex.Ask})
}
@@ -2433,6 +2440,13 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
if visionImg != nil {
messages, extra = kt.relay(messages, extra, seeImageMessage(label, visionImg), stepStart)
}
// see_video a réussi : même relais, toujours ÉPHÉMÈRE — dans
// `messages` seulement, jamais dans `extra`, KEEP_TURN_IMAGES
// compris (une vidéo pèse des milliers de jetons visuels, et
// kt.relay ne sait ranger que des images).
if visionVideo != nil {
messages = append(messages, seeVideoMessage(label, visionVideo))
}
}
// Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour :
// une boucle d'outils peut à elle seule remplir la fenêtre (résultats
@@ -2616,7 +2630,10 @@ type toolExecResult struct {
Added int `json:"added,omitempty"`
Removed int `json:"removed,omitempty"`
VisionImg map[string]any `json:"vision_img,omitempty"`
Ask *AskEvent `json:"ask,omitempty"`
// VisionVideo : partie input_video rendue par see_video (chat_video_tool.go),
// relayée au modèle comme l'image, mais toujours éphémère.
VisionVideo map[string]any `json:"vision_video,omitempty"`
Ask *AskEvent `json:"ask,omitempty"`
}
// execToolHook : nil en service. Les tests de parité Go/Rust
@@ -2631,11 +2648,13 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
result := ""
var diff []DiffLine
var diffAdd, diffDel int
var visionImg map[string]any
var visionImg, visionVideo map[string]any
var ask *AskEvent
switch name {
case "see_image":
result, visionImg = toolSeeImage(label)
case "see_video":
result, visionVideo = toolSeeVideo(label)
case "recall":
result = toolRecall(args)
case "recall_search":
@@ -2820,5 +2839,5 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
result = "[erreur] outil inconnu: " + name
}
}
return toolExecResult{Result: result, Diff: diff, Added: diffAdd, Removed: diffDel, VisionImg: visionImg, Ask: ask}
return toolExecResult{Result: result, Diff: diff, Added: diffAdd, Removed: diffDel, VisionImg: visionImg, VisionVideo: visionVideo, Ask: ask}
}
+3 -4
View File
@@ -163,15 +163,14 @@ func bucketFullyEncrypted(bucket string) bool {
// chiffrement est actif. Ici : les discussions (bkChat porte l'index, la
// discussion active et le journal de chacune), les blocs archivés au compactage
// (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et
// les trackers (bkTracker, 3ᵉ type de mémoire), les résultats complets des
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil) et l'index
// plein-texte des discussions (bkChatSearch — leurs mots, chat_search.go).
// les trackers (bkTracker, 3ᵉ type de mémoire), et les résultats complets des
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil).
//
// Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils
// portent des réglages, pas des données personnelles — et surtout le coffre
// lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une
// boucle : plus moyen de déverrouiller quoi que ce soit.
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes, bkChatSearch}
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes}
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
func reencryptChatStores() error {
-3
View File
@@ -79,9 +79,6 @@ func memUnlocked() bool {
return memDEK != nil
}
// memLocked : mémoire chiffrée MAIS verrouillée (DEK absente).
func memLocked() bool { return memEncActive() && !memUnlocked() }
// setMemDEK charge la DEK en RAM (copie défensive).
func setMemDEK(dek []byte) {
memKeyMu.Lock()
+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
}
-5
View File
@@ -42,11 +42,6 @@ const (
// bkTracker : les trackers, 3e type de mémoire (tracker.go). Clé
// « <slug-projet>/<slug-tracker> » — le cloisonnement par projet est dans la clé.
bkTracker = "tracker"
// bkChatSearch : index PLEIN-TEXTE des discussions (chat_search.go) — fiche
// et liste de mots par discussion, liste de discussions par mot. Chiffré
// comme bkChat : un sac de mots en clair à côté de discussions chiffrées
// serait une fuite.
bkChatSearch = "chatsearch"
)
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
+10 -90
View File
@@ -1797,14 +1797,6 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
/* Extrait d'un résultat de recherche plein-texte : sous le titre, à la place
de .preset-meta, une seule ligne tronquée comme le titre. */
.side .preset .conv-snip{font-size:12px;color:var(--dim);margin-top:3px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
/* Ligne à extrait : deux lignes de texte. L'heure et l'anneau se calent sur
la ligne de base du TITRE au lieu de se centrer entre les deux (les boutons,
en position absolue, restent centrés sur la ligne entière). */
.side .preset.has-snip{align-items:baseline}
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
border:0;padding:8px 11px;text-align:left;cursor:pointer}
.conv-more:hover{color:var(--text)}
@@ -2359,12 +2351,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
actif est partagé par tous les appareils (comme la conversation unique
d'origine) : basculer ici bascule partout.
La recherche filtre d'abord les titres CÔTÉ CLIENT (la liste est déjà
chargée, réponse immédiate), puis demande au serveur les discussions dont
le TEXTE contient les mots (index plein-texte, chat_search.go) — avec un
extrait sous le titre. -->
La recherche filtre CÔTÉ CLIENT : la liste est déjà entièrement chargée
(métadonnées seules, sans les messages), un aller-retour serveur par
frappe n'apporterait rien. -->
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion" aria-label="nouvelle discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
<input id="conv-search" type="search" placeholder="rechercher partout…" oninput="renderConversations()" aria-label="rechercher dans les titres et le texte des discussions">
<input id="conv-search" type="search" placeholder="rechercher une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
<div id="conv-list" class="slist"></div>
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
@@ -10537,11 +10528,8 @@ function fmtWhen(sec){
// balisage ni les gestionnaires d'événement.
//
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
// les TITRES sans aller-retour serveur : elle ne porte que des métadonnées —
// identifiant, titre, dates, nombre d'échanges — jamais les messages. Le TEXTE
// des discussions, lui, est cherché par le serveur (/api/conversations/search,
// index plein-texte chiffré comme les discussions) : sa réponse arrive après
// une courte pause de frappe et complète la liste, avec un extrait.
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
// titre, dates, nombre d'échanges — jamais les messages.
let CONVS = [], CONV_ACTIVE = '';
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
@@ -10555,65 +10543,11 @@ let CONV_BUSY = false;
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
const CONV_PAGE = 40;
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
// Recherche plein-texte : dernière réponse du serveur (pour la requête q), et
// minuterie de la pause de frappe. Une réponse arrivée pour une requête qui
// n'est plus celle du champ est jetée.
// convSrvGen numérote les requêtes : un rechargement de la liste en relance
// une pour la même saisie, la réponse d'avant ne doit plus s'afficher.
let CONV_SRV = null, convSrvTimer = 0, convSrvWanted = '', convSrvGen = 0;
const CONV_SRV_PAUSE = 250;
function convSearchSchedule(q){
if(q === convSrvWanted) return;
convSrvWanted = q;
clearTimeout(convSrvTimer);
const gen = ++convSrvGen;
if(!q) return;
convSrvTimer = setTimeout(async () => {
let r;
try{ r = await jget('/api/conversations/search?limit=100&q=' + encodeURIComponent(q)); }catch(_){ r = null; }
if(gen !== convSrvGen) return; // saisie changée, ou liste rechargée depuis
CONV_SRV = {q, r: (r && r.ok) ? r : null, failed: !(r && r.ok)};
renderConversations();
}, CONV_SRV_PAUSE);
}
// Liste affichée pour une requête : les résultats du serveur dans leur ordre
// (pertinence : titre puis texte), puis les titres trouvés ici que le serveur
// n'a pas (il cherche des débuts de mots, le filtre local une sous-chaîne).
// Rend aussi les extraits et la ligne d'état éventuelle.
function convSearchList(q){
const byTitle = CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q));
const snips = new Map();
const cur = CONV_SRV && CONV_SRV.q === q ? CONV_SRV : null;
const srv = cur ? cur.r : null;
if(!srv) return {list: byTitle, snips, note: cur && cur.failed ? 'recherche dans le texte indisponible : titres seulement' : ''};
let note = '';
const got = (srv.conversations || []).length;
if(srv.state === 'locked') note = 'mémoire verrouillée : recherche dans les titres seulement';
else if(srv.indexed < srv.indexed_total) note = 'indexation en cours (' + srv.indexed + '/' + srv.indexed_total + ') : résultats partiels';
else if(srv.total > got) note = 'les ' + got + ' plus pertinentes sur ' + srv.total + ' : précisez la recherche';
const byID = new Map(CONVS.map(c => [c.id, c]));
const list = [], seen = new Set();
for(const h of (srv.conversations || [])){
const c = byID.get(h.id);
if(!c || seen.has(c.id)) continue;
seen.add(c.id);
list.push(c);
if(h.snippet && h.snippet !== (c.title || '')) snips.set(c.id, h.snippet);
}
for(const c of byTitle) if(!seen.has(c.id)) list.push(c);
return {list, snips, note};
}
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
// La liste a changé (fin de tour, bascule, suppression) : une recherche en
// cours est refaite. Ses résultats restent affichés d'ici la nouvelle
// réponse — les effacer ferait clignoter la liste à chaque tour.
convSrvWanted = '';
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
@@ -10632,14 +10566,9 @@ function renderConversations(){
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
convSearchSchedule(q);
const {list, snips, note} = q ? convSearchList(q) : {list: CONVS, snips: new Map(), note: ''};
if(note){
const e = document.createElement('div');
e.className = 'conv-empty';
e.textContent = note;
cont.appendChild(e);
}
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
if(!list.length){
const e = document.createElement('div');
e.className = 'conv-empty';
@@ -10654,8 +10583,7 @@ function renderConversations(){
const shown = Math.max(CONV_SHOWN, ai + 1);
for(const c of list.slice(0, shown)){
const row = document.createElement('div');
const sn = snips.get(c.id);
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : '');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
// qui travaille sans redessiner la liste (un redessin relancerait
// l'animation de l'anneau à chaque événement du flux).
@@ -10668,14 +10596,6 @@ function renderConversations(){
nm.appendChild(document.createTextNode(title));
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
info.appendChild(nm);
// Recherche : le passage du texte où les mots ont été trouvés.
if(sn){
const s = document.createElement('div');
s.className = 'conv-snip';
s.textContent = sn;
s.title = sn;
info.appendChild(s);
}
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
+4 -5
View File
@@ -66,12 +66,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
actif est partagé par tous les appareils (comme la conversation unique
d'origine) : basculer ici bascule partout.
La recherche filtre d'abord les titres CÔTÉ CLIENT (la liste est déjà
chargée, réponse immédiate), puis demande au serveur les discussions dont
le TEXTE contient les mots (index plein-texte, chat_search.go) — avec un
extrait sous le titre. -->
La recherche filtre CÔTÉ CLIENT : la liste est déjà entièrement chargée
(métadonnées seules, sans les messages), un aller-retour serveur par
frappe n'apporterait rien. -->
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion" aria-label="nouvelle discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
<input id="conv-search" type="search" placeholder="rechercher partout…" oninput="renderConversations()" aria-label="rechercher dans les titres et le texte des discussions">
<input id="conv-search" type="search" placeholder="rechercher une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
<div id="conv-list" class="slist"></div>
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
+6 -77
View File
@@ -10,11 +10,8 @@
// balisage ni les gestionnaires d'événement.
//
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
// les TITRES sans aller-retour serveur : elle ne porte que des métadonnées —
// identifiant, titre, dates, nombre d'échanges — jamais les messages. Le TEXTE
// des discussions, lui, est cherché par le serveur (/api/conversations/search,
// index plein-texte chiffré comme les discussions) : sa réponse arrive après
// une courte pause de frappe et complète la liste, avec un extrait.
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
// titre, dates, nombre d'échanges — jamais les messages.
let CONVS = [], CONV_ACTIVE = '';
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
@@ -28,65 +25,11 @@ let CONV_BUSY = false;
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
const CONV_PAGE = 40;
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
// Recherche plein-texte : dernière réponse du serveur (pour la requête q), et
// minuterie de la pause de frappe. Une réponse arrivée pour une requête qui
// n'est plus celle du champ est jetée.
// convSrvGen numérote les requêtes : un rechargement de la liste en relance
// une pour la même saisie, la réponse d'avant ne doit plus s'afficher.
let CONV_SRV = null, convSrvTimer = 0, convSrvWanted = '', convSrvGen = 0;
const CONV_SRV_PAUSE = 250;
function convSearchSchedule(q){
if(q === convSrvWanted) return;
convSrvWanted = q;
clearTimeout(convSrvTimer);
const gen = ++convSrvGen;
if(!q) return;
convSrvTimer = setTimeout(async () => {
let r;
try{ r = await jget('/api/conversations/search?limit=100&q=' + encodeURIComponent(q)); }catch(_){ r = null; }
if(gen !== convSrvGen) return; // saisie changée, ou liste rechargée depuis
CONV_SRV = {q, r: (r && r.ok) ? r : null, failed: !(r && r.ok)};
renderConversations();
}, CONV_SRV_PAUSE);
}
// Liste affichée pour une requête : les résultats du serveur dans leur ordre
// (pertinence : titre puis texte), puis les titres trouvés ici que le serveur
// n'a pas (il cherche des débuts de mots, le filtre local une sous-chaîne).
// Rend aussi les extraits et la ligne d'état éventuelle.
function convSearchList(q){
const byTitle = CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q));
const snips = new Map();
const cur = CONV_SRV && CONV_SRV.q === q ? CONV_SRV : null;
const srv = cur ? cur.r : null;
if(!srv) return {list: byTitle, snips, note: cur && cur.failed ? 'recherche dans le texte indisponible : titres seulement' : ''};
let note = '';
const got = (srv.conversations || []).length;
if(srv.state === 'locked') note = 'mémoire verrouillée : recherche dans les titres seulement';
else if(srv.indexed < srv.indexed_total) note = 'indexation en cours (' + srv.indexed + '/' + srv.indexed_total + ') : résultats partiels';
else if(srv.total > got) note = 'les ' + got + ' plus pertinentes sur ' + srv.total + ' : précisez la recherche';
const byID = new Map(CONVS.map(c => [c.id, c]));
const list = [], seen = new Set();
for(const h of (srv.conversations || [])){
const c = byID.get(h.id);
if(!c || seen.has(c.id)) continue;
seen.add(c.id);
list.push(c);
if(h.snippet && h.snippet !== (c.title || '')) snips.set(c.id, h.snippet);
}
for(const c of byTitle) if(!seen.has(c.id)) list.push(c);
return {list, snips, note};
}
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
// La liste a changé (fin de tour, bascule, suppression) : une recherche en
// cours est refaite. Ses résultats restent affichés d'ici la nouvelle
// réponse — les effacer ferait clignoter la liste à chaque tour.
convSrvWanted = '';
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
@@ -105,14 +48,9 @@ function renderConversations(){
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
convSearchSchedule(q);
const {list, snips, note} = q ? convSearchList(q) : {list: CONVS, snips: new Map(), note: ''};
if(note){
const e = document.createElement('div');
e.className = 'conv-empty';
e.textContent = note;
cont.appendChild(e);
}
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
if(!list.length){
const e = document.createElement('div');
e.className = 'conv-empty';
@@ -127,8 +65,7 @@ function renderConversations(){
const shown = Math.max(CONV_SHOWN, ai + 1);
for(const c of list.slice(0, shown)){
const row = document.createElement('div');
const sn = snips.get(c.id);
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : '');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
// qui travaille sans redessiner la liste (un redessin relancerait
// l'animation de l'anneau à chaque événement du flux).
@@ -141,14 +78,6 @@ function renderConversations(){
nm.appendChild(document.createTextNode(title));
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
info.appendChild(nm);
// Recherche : le passage du texte où les mots ont été trouvés.
if(sn){
const s = document.createElement('div');
s.className = 'conv-snip';
s.textContent = sn;
s.title = sn;
info.appendChild(s);
}
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
-8
View File
@@ -1762,14 +1762,6 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
/* Extrait d'un résultat de recherche plein-texte : sous le titre, à la place
de .preset-meta, une seule ligne tronquée comme le titre. */
.side .preset .conv-snip{font-size:12px;color:var(--dim);margin-top:3px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
/* Ligne à extrait : deux lignes de texte. L'heure et l'anneau se calent sur
la ligne de base du TITRE au lieu de se centrer entre les deux (les boutons,
en position absolue, restent centrés sur la ligne entière). */
.side .preset.has-snip{align-items:baseline}
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
border:0;padding:8px 11px;text-align:left;cursor:pointer}
.conv-more:hover{color:var(--text)}
-1
View File
@@ -951,7 +951,6 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) {
if memEncActive() && memUnlocked() {
_ = reencryptChatStores()
}
startHistSearchBackfill() // index plein-texte : il attendait la clé (chat_search.go)
sendJSON(w, 200, map[string]any{"ok": true, "kind": kind, "health": memHealth()})
}
-5
View File
@@ -228,10 +228,6 @@ func handleInternalStore(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
// Une discussion écrite par la façade : l'index plein-texte la suit
// (chat_search.go). Avant la réponse, pour que sa suppression, qui
// n'arrive qu'après, ne soit jamais doublée par cette indexation.
histIndexStoreWrite(bucket, key, val, false)
w.WriteHeader(http.StatusNoContent)
case http.MethodDelete:
err := update(bucket, func(b *bolt.Bucket) error { return b.Delete([]byte(key)) })
@@ -239,7 +235,6 @@ func handleInternalStore(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
histIndexStoreWrite(bucket, key, nil, true)
w.WriteHeader(http.StatusNoContent)
default:
w.Header().Set("Allow", "GET, PUT, DELETE")
+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
}
-6
View File
@@ -163,9 +163,6 @@ func newWebMux() *http.ServeMux {
// orphelin arrêté, moteur relancé s'il tournait avant (backend_tune_lock.go)
// — au démarrage, puis toutes les 30 s (un stat sans verrou).
go tuneRecoverWatch(time.NewTicker(tuneRecoverEvery).C)
// Index plein-texte des discussions : rattrape celles d'avant la
// fonctionnalité (ou dont l'indexation a échoué), en tâche de fond.
startHistSearchBackfill()
mux := http.NewServeMux()
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
@@ -402,9 +399,6 @@ func newWebMux() *http.ServeMux {
api("/api/conversations/switch", handleConvSwitch)
api("/api/conversations/rename", handleConvRename)
api("/api/conversations/delete", handleConvDelete)
// Recherche plein-texte (chat_search.go) : servie ici même quand la façade
// tient la conversation — l'index est tenu par ce process.
api("/api/conversations/search", handleConvSearch)
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
+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]
-7
View File
@@ -499,11 +499,4 @@ mod tests {
);
assert!(is_internal("/internal/facade/v1/delta") && !is_internal("/internal/v1/auth"));
}
/// La recherche plein-texte reste au Go, qui tient l'index (chat_search.go) :
/// la façade la relaie, elle ne doit pas la servir.
#[test]
fn recherche_relayee_au_go() {
assert!(!handles("/api/conversations/search"));
}
}
+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
+125 -125
View File
@@ -231,8 +231,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -260,7 +260,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -304,7 +304,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -335,8 +335,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -571,7 +571,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -615,7 +615,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -855,8 +855,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1022,7 +1022,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -1066,7 +1066,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -1103,8 +1103,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1270,7 +1270,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -1314,7 +1314,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -1554,8 +1554,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 16403.5,
"prompt_n": 32807,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1715,7 +1715,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -1759,7 +1759,7 @@
"max_tokens": 4096,
"messages": [
{
"content": "<131212 octets f427338939c0>",
"content": "<131201 octets e3226a117903>",
"role": "user"
}
],
@@ -1790,8 +1790,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16403.5,
"prompt_n": 32807,
"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": 16403.5,
"prompt_n": 32807,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1882,7 +1882,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2049,7 +2049,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2093,7 +2093,7 @@
"max_tokens": 16,
"messages": [
{
"content": "<3745 octets f904cd9392eb>",
"content": "<3745 octets 55a9fdff5d02>",
"role": "user"
}
],
@@ -2187,8 +2187,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -2216,7 +2216,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2260,7 +2260,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2291,8 +2291,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2368,7 +2368,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2420,8 +2420,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2497,7 +2497,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2541,7 +2541,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2572,8 +2572,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2649,7 +2649,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2693,7 +2693,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2747,8 +2747,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2824,7 +2824,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -2868,7 +2868,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2992,8 +2992,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3069,7 +3069,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3121,8 +3121,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3218,7 +3218,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3287,7 +3287,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3319,8 +3319,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3412,7 +3412,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3457,7 +3457,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3489,8 +3489,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3608,7 +3608,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3652,7 +3652,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3737,8 +3737,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3823,7 +3823,7 @@
},
{
"body": {
"content": "<163821 octets 62a166d2e838>"
"content": "<163664 octets b7452851885a>"
},
"method": "POST",
"path": "/tokenize"
@@ -3867,7 +3867,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8113 octets 6818f5613608>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3952,8 +3952,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
+245
View File
@@ -0,0 +1,245 @@
{
"files": {
"CLIP.MOV": "AAAAFGZ0eXBxdCAgIG1vdg==",
"anim.webm": "GkXfoyB3ZWJt",
"clip.mp4": "AAAAGGZ0eXBtcDQyIGZhdXNzZSB2aWTDqW8=",
"film.mkv": "GkXfoyBtYXRyb3NrYQ==",
"sous/court.mp4": "ZnR5cCBzb3VzLWRvc3NpZXI=",
"texte.txt": "cGFzIHVuZSB2aWTDqW8=",
"vide.mp4": "",
"vieux.avi": "UklGRhAAAABBVkkgTElTVA=="
},
"steps": [
{
"name": "sans vision",
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo",
"probes": 0
},
{
"name": "chemin manquant",
"vision": true,
"video": true,
"args": {
"file": ""
},
"label": "",
"result": "[erreur] chemin de fichier manquant",
"probes": 0
},
{
"name": "sans argument",
"vision": true,
"video": true,
"args": {},
"label": "",
"result": "[erreur] chemin de fichier manquant",
"probes": 0
},
{
"name": "moteur sans vidéo",
"vision": true,
"sees": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.",
"probes": 0
},
{
"name": "pas une vidéo",
"vision": true,
"video": true,
"args": {
"file": "texte.txt"
},
"label": "texte.txt",
"result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : texte.txt",
"probes": 0
},
{
"name": "image",
"vision": true,
"video": true,
"args": {
"file": "photo.png"
},
"label": "photo.png",
"result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : photo.png",
"probes": 0
},
{
"name": "introuvable",
"vision": true,
"video": true,
"args": {
"file": "absente.mp4"
},
"label": "absente.mp4",
"result": "[erreur] fichier introuvable : absente.mp4",
"probes": 0
},
{
"name": "dossier",
"vision": true,
"video": true,
"args": {
"file": "dossier.mp4"
},
"label": "dossier.mp4",
"result": "[erreur] c'est un dossier, pas une vidéo : dossier.mp4",
"probes": 0
},
{
"name": "trop lourde",
"vision": true,
"video": true,
"args": {
"file": "lourd.mp4"
},
"label": "lourd.mp4",
"result": "[erreur] vidéo trop lourde (200 Mo, max 200 Mo) — coupe-la en un clip plus court : lourd.mp4",
"probes": 0
},
{
"name": "le moteur ne voit pas",
"vision": true,
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir clip.mp4",
"probes": 1
},
{
"name": "vide",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "vide.mp4"
},
"label": "vide.mp4",
"result": "[erreur] fichier vide : vide.mp4",
"probes": 1
},
{
"name": "voir clip.mp4",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée (clip.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "clip.mp4"
},
"probes": 1
},
{
"name": "voir CLIP.MOV",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "CLIP.MOV"
},
"label": "CLIP.MOV",
"result": "[ok] vidéo chargée : CLIP.MOV\n[relais] Vidéo demandée (CLIP.MOV) :",
"part": {
"mime": "video/quicktime",
"same_as": "CLIP.MOV"
},
"probes": 1
},
{
"name": "voir anim.webm",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "anim.webm"
},
"label": "anim.webm",
"result": "[ok] vidéo chargée : anim.webm\n[relais] Vidéo demandée (anim.webm) :",
"part": {
"mime": "video/webm",
"same_as": "anim.webm"
},
"probes": 1
},
{
"name": "voir film.mkv",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "film.mkv"
},
"label": "film.mkv",
"result": "[ok] vidéo chargée : film.mkv\n[relais] Vidéo demandée (film.mkv) :",
"part": {
"mime": "video/x-matroska",
"same_as": "film.mkv"
},
"probes": 1
},
{
"name": "voir vieux.avi",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "vieux.avi"
},
"label": "vieux.avi",
"result": "[ok] vidéo chargée : vieux.avi\n[relais] Vidéo demandée (vieux.avi) :",
"part": {
"mime": "video/x-msvideo",
"same_as": "vieux.avi"
},
"probes": 1
},
{
"name": "voir sous/court.mp4",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "sous/court.mp4"
},
"label": "sous/court.mp4",
"result": "[ok] vidéo chargée : court.mp4\n[relais] Vidéo demandée (sous/court.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "sous/court.mp4"
},
"probes": 1
},
{
"name": "chemin absolu",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "{{FICHIERS}}/clip.mp4"
},
"label": "{{FICHIERS}}/clip.mp4",
"result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée ({{FICHIERS}}/clip.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "clip.mp4"
},
"probes": 1
}
]
}
+231
View File
@@ -0,0 +1,231 @@
//! see_video (repris d'AJEAN) tenu par la façade, rejoué sur le journal du
//! Go (`internal/loki/parity_video_tool_test.go` →
//! `tests/parity_images/video.json`) : mêmes fichiers, même faux moteur
//! (`/props`), même capacité vidéo du moteur, étape par étape. La vidéo
//! relayée se compare à l'octet (jamais réencodée).
mod common;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use bytes::Bytes;
use common::fakego;
use http_body_util::Full;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::imagetools::{self, VisionProbe};
use loki_rs::store::Store;
use serde_json::{Map, Value, json};
use tokio::net::TcpListener;
/// Le faux moteur : `/props` authentifié, la vision réglée par le test.
async fn fake_engine(sees: Arc<Mutex<(bool, u32)>>) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = l.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let sees = sees.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let sees = sees.clone();
async move {
let ok = req.uri().path() == "/props"
&& req
.headers()
.get("authorization")
.and_then(|h| h.to_str().ok())
== Some("Bearer cle-moteur");
let mut r = Response::new(Full::new(Bytes::new()));
if ok {
let mut s = sees.lock().unwrap();
s.1 += 1;
r = Response::new(Full::new(Bytes::from(
json!({ "modalities": { "vision": s.0 } }).to_string(),
)));
} else {
*r.status_mut() = hyper::StatusCode::UNAUTHORIZED;
}
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(st), svc)
.await;
});
}
});
port
}
/// `parityVideoFacts`.
fn part_facts(part: &Value, raw: &Map<String, Value>) -> Value {
let u = part["input_video"]["url"].as_str().unwrap();
let i = u.find(";base64,").unwrap();
let b = base64::engine::general_purpose::STANDARD
.decode(&u[i + 8..])
.unwrap();
let mut out = Map::new();
out.insert("mime".into(), json!(u[..i].trim_start_matches("data:")));
for (name, r) in raw {
let rb = base64::engine::general_purpose::STANDARD
.decode(r.as_str().unwrap_or(""))
.unwrap();
if rb == b {
out.insert("same_as".into(), json!(name));
}
}
Value::Object(out)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn see_video_comme_le_go() {
let fx: Value =
serde_json::from_str(include_str!("parity_images/video.json")).expect("journal");
let tmp = tempfile::tempdir().unwrap();
let files = tmp.path().join("fichiers");
let raw = fx["files"].as_object().unwrap();
for (name, b) in raw {
let p = files.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(
&p,
base64::engine::general_purpose::STANDARD
.decode(b.as_str().unwrap_or(""))
.unwrap(),
)
.unwrap();
}
std::fs::create_dir_all(files.join("dossier.mp4")).unwrap();
std::fs::File::create(files.join("lourd.mp4"))
.unwrap()
.set_len((imagetools::MAX_VIDEO_BYTES + 1) as u64)
.unwrap();
let files_s = files.to_string_lossy().replace('\\', "/");
let sees = Arc::new(Mutex::new((false, 0u32)));
let port = fake_engine(sees.clone()).await;
let (_, go, _stop) = fakego::start(tmp.path().join("home")).await;
let backend = go.backend.lock().unwrap().unwrap();
let store = Store::new(backend, fakego::JETON.into());
go.set("state", "api_key", "cle-moteur");
let mut cfg = std::collections::HashMap::new();
cfg.insert("PORT".to_string(), port.to_string());
let probe = VisionProbe::default();
for st in fx["steps"].as_array().unwrap() {
let name = st["name"].as_str().unwrap();
*sees.lock().unwrap() = (st["sees"] == true, 0);
probe.reset().await;
let label = st["label"]
.as_str()
.unwrap()
.replace("{{FICHIERS}}", &files_s);
let (mut result, vid) = imagetools::see_video(
&label,
&files,
"",
st["vision"] == true,
st["video"] == true,
probe.sees(&store, &cfg),
)
.await;
let relay = vid.map(|v| loki_rs::chat::host::see_video_message(&label, v));
let mut facts = None;
if let Some(parts) = relay
.as_ref()
.and_then(|m| m.content.as_ref())
.and_then(Value::as_array)
{
for p in parts {
if p["type"] == "input_video" {
facts = Some(part_facts(p, raw));
}
if p["type"] == "text" {
result += &format!("\n[relais] {}", p["text"].as_str().unwrap());
}
}
}
assert_eq!(
result.replace(&files_s, "{{FICHIERS}}"),
st["result"].as_str().unwrap(),
"{name}"
);
assert_eq!(facts, st.get("part").cloned(), "{name} : vidéo relayée");
assert_eq!(
sees.lock().unwrap().1 as u64,
st["probes"].as_u64().unwrap(),
"{name} : sondes du moteur"
);
}
}
/// `videoInputSupported` : jamais pour une API externe, Strata, un preset
/// sans projecteur ou sans moteur ; sinon l'aide du binaire fait foi, relue
/// quand il change.
#[test]
fn capacite_video_du_moteur() {
let tmp = tempfile::tempdir().unwrap();
let cfg = |kv: &[(&str, &str)]| -> std::collections::HashMap<String, String> {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
for c in [
cfg(&[("BIN", "llama-server"), ("MODEL", "m.gguf")]),
cfg(&[("MMPROJ", "mm.gguf"), ("MODEL", "m.gguf")]),
cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "absent/llama-server")]),
cfg(&[
("MMPROJ", "mm.gguf"),
("EXTERNAL", "1"),
("EXTERNAL_VISION", "1"),
]),
cfg(&[
("MMPROJ", "mm.gguf"),
("ENGINE", "strata"),
("BIN", "/bin/sh"),
]),
] {
assert!(
!imagetools::video_input_supported(&c, tmp.path()),
"{c:?} : vidéo annoncée"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let bin = tmp.path().join("llama-server");
let write = |help: &str, age: u64| {
std::fs::write(&bin, format!("#!/bin/sh\necho '{help}'\n")).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let f = std::fs::File::options().write(true).open(&bin).unwrap();
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age))
.unwrap();
};
let c = cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "llama-server")]);
write("--mmproj FILE", 3600);
assert!(!imagetools::video_input_supported(&c, tmp.path()));
write("--video-fps N target fps", 0);
assert!(
imagetools::video_input_supported(&c, tmp.path()),
"moteur mis à jour : --video-fps non relu"
);
}
}
#[test]
fn types_de_video() {
for (f, m) in [
("a.mp4", "video/mp4"),
("A.MOV", "video/quicktime"),
("x/b.webm", "video/webm"),
("c.mkv", "video/x-matroska"),
("d.avi", "video/x-msvideo"),
("e.png", ""),
("mp4", ""),
] {
assert_eq!(imagetools::video_mime(f), m, "{f}");
}
}