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