Compare commits

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:17 +02:00
MichaelandClaude Opus 5.5 776de75dd0 Vision : le modèle peut regarder des vidéos (outil see_video et vidéos jointes), repris d'AJEAN
Reprise des commits 91f7f977 (PR #100 de Trombo38) et 1dfac0cf d'AJEAN.
Une vidéo jointe (mp4, webm, mov, mkv, avi) part au modèle en partie
input_video, et l'outil see_video charge une vidéo du disque, mais
seulement si le moteur actif sait la lire : preset llama.cpp local avec
projecteur (MMPROJ) et binaire dont l'aide annonce --video-fps
(videoInputSupported, aide relue quand le binaire change, chemin des
bibliothèques passé à la seule commande pour ne pas figer binHelp).
Strata et les API externes ignoraient la partie en silence : la vidéo
reste alors un fichier, annoncé avec la marche à suivre (ffmpeg puis
see_image), et see_video n'est pas proposé.

llama.cpp a --video-fps depuis la PR #24318 (fin août 2026) ; l'image
server-cuda officielle, base du conteneur, installe ffmpeg. La consigne
de repli dit quoi faire si ffmpeg manque (installation hors conteneur).

Adapté à Loki :
- la vidéo de see_video est toujours éphémère (jamais dans l'historique,
  KEEP_TURN_IMAGES compris) ; même ordre de refus que see_image, sonde
  /props en dernier, refus sur un poste distant ;
- une vidéo jointe est rangée par référence (loki-vid:, LOKI_HOME/chatvid,
  chiffrée comme le fil), parties recopiées et non mutées comme les
  images ; retirée d'un fil rouvert (stripImageParts, videoLostMarker) et
  effacée par le ménage des médias ;
- marqueur de perte à la compaction, élision dans l'export JSON, relais
  par l'API interne des outils (vision_video).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:17 +02:00
LogiFlow 6a45f19831 Merge pull request #34 from R0m1k3/fix/lot1-ajean-openfox
Correctifs repris d'AJEAN et d'OpenFox : LSP, shell, clé web, MCP, recall, compactage, relances
2026-10-19 11:22:20 +02:00
MichaelandClaude Opus 5.5 d22a087d8e Tests : trace du bench vérifiée sans longueur de prompt en dur
bail_travail_annexe_et_trace attendait prompt_n = 2036 : la longueur du
dernier prompt suit le corpus de code du bench, c'est-à-dire l'UI embarquée
(internal/loki/ui/index.html). Toute retouche de l'interface cassait donc
ce test (ci / rust rouge après l'ajout de la pause des téléchargements et du
choix de la seconde carte Strata). Le test vérifie désormais la forme de la
trace relayée (prompt_ms = prompt_n / 2, débits du faux moteur) pour un
prompt non vide. Le journal de parité du bench, lui, se régénère avec
LOKI_PARITY_UPDATE=1 comme les autres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:36:47 +02:00
MichaelandClaude Opus 5.5 0ef2ed72f5 Téléchargements de modèles : pause et reprise
Repris d'AJEAN 9c7423bf (v0.18.1) : POST /api/models/download/pause
{"filename", "pause"} met en pause ou reprend un téléchargement en cours.
La pause coupe les connexions (rien ne reste ouvert vers le CDN), garde le
fichier partiel, et chaque morceau attend la reprise puis repart de l'octet
où il s'était arrêté par une requête de plage ; une pause ne consomme pas
d'essai. /status expose « paused », la vitesse publiée tombe à zéro.

Façade Rust (downloads.rs) : même route, même porte (watch), mêmes
réponses ; la route est ajoutée à celles que le Go remet à la façade quand
LOKI_MODELS=rust. Tests portés de part et d'autre (pause puis reprise de
bout en bout, fichier identique à la source).

UI : bouton « Mettre en pause » / « Reprendre » à côté de « Annuler le
téléchargement », « en pause » dans la ligne de progression.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:16:39 +02:00
MichaelandClaude Opus 5.5 615e58dd2f Strata : seconde carte en partage des couches au choix, l'aide aux experts reste le défaut
Repris d'AJEAN 38919238 (#121 amont) et de la partie moteur de 6db3f3d0 :
STRATA_SPLIT=layers dans le preset fait lister les deux cartes dans la clé
« gpu » de la config (principale d'abord, numéros nvidia-smi) ; le serveur de
Strata en déduit --layer-split. Rien du mode d'aide ne subsiste alors : ni
--expert-cache-device1 / --remote-expert-opt, ni source d'aide à la lecture
des prompts, ni encodeur d'images déplacé ; la réserve de VRAM de
l'installeur reste. Sans la clé, ou la seconde carte coupée, rien ne change.

Propre à Loki : le placement des experts selon la RAM disponible ne choisit
jamais le mode hors RAM en partage des couches (la seconde carte n'y garde
pas d'experts, la marge calculée pour elle serait fausse).

Réglages : « split » (helper ou layers ; absent = inchangé pour un ancien
client), détail split_layers. Fenêtre Strata : la case « Carte d'appoint »
devient un choix « Seconde carte » (non utilisée, aide aux experts, partage
des couches) ; la carte de configuration du moteur actif l'affiche.

Façade Rust alignée (strata/mod.rs, strata/api.rs, tests portés) ; journal
de parité régénéré sous Linux avec les réglages et l'affichage du partage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:16:37 +02:00
MichaelandClaude Opus 5.5 9792953d62 Strata : paquet AJEAN MoE 1.1, reprise automatique d'une installation 1.0
Repris d'AJEAN 9c7423bf (v0.19.0, renumérotée v0.18.1 par e7b024ad) :

- release moe-v1.1 d'AJEAN, empreintes SHA-256 reprises telles qu'AJEAN les
  publie dans son backend_moe.go ;
- avec une carte d'aide, la lecture d'un prompt passe par elle
  (STRATA_PREFILL_HELPER_SOURCE=1) et le chemin direct ne garde que les
  ajouts sous 48 jetons (--short-read 48 au lieu de 150) ;
- mode hors RAM : réserve de VRAM de la carte d'aide à 640 Mio, contexte
  plafonné à 131 072 jetons, marge de RAM portée de 7 à 8 Go (le moteur 1.1
  prend ~1 Go de plus) ;
- strataMigrate (moeMigrate d'AJEAN) : au premier lancement d'un modèle
  installé avec la 1.0, `loki serve` pose les sources 1.1, reprend
  l'environnement Python par un lien quand les dépendances n'ont pas changé,
  installe le moteur 1.1, recopie la config de l'installeur en réécrivant ses
  chemins, puis repointe les presets et la config vivante ensemble
  (l'empreinte du preset actif suit). En cas d'échec, rien n'est réécrit et
  la 1.0 continue de tourner.

Les correctifs propres à Loki restent : placement des experts selon la RAM
disponible et mode « depuis le disque » (6aaf9360).

Façade Rust : mêmes constantes et même configuration de lancement
(strata/mod.rs) ; la migration reste au Go, seul à servir un preset Strata.
Journal de parité régénéré sous Linux : noms du paquet tirés des constantes,
étapes du seuil du mode hors RAM recalées sur la nouvelle marge, étape du
contexte plafonné ajoutée.

La consolidation de Jean la nuit et le niveau de réflexion du moteur MoE
(même commit amont) ne sont pas repris : Loki n'a ni Jean ni ce raccourci.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:16:32 +02:00
49 changed files with 4194 additions and 587 deletions

No files matched your search

+15 -4
View File
@@ -52,15 +52,26 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
La **compréhension des vidéos** est reprise d'AJEAN (PR #100 de Trombo38 et
son correctif « vidéo envoyée seulement si le moteur sait la lire ») :
l'outil `see_video` et les vidéos jointes (`chat_video.go`,
`chat_video_tool.go`), lues par llama.cpp avec `--video-fps` et ffmpeg ;
ailleurs, la vidéo reste un fichier, avec la marche à suivre (ffmpeg puis
`see_image`). Adaptée à Loki : vidéo de `see_video` toujours éphémère, vidéos
jointes chiffrées comme le fil et retirées d'un fil rouvert, outil tenu aussi
par la façade Rust (`imagetools.rs`).
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.1 »,
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
inclus dans l'image : ils sont téléchargés à la demande.
release `moe-v1.1` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
par AJEAN. La reprise automatique d'une installation 1.0 (`strataMigrate`) est
reprise d'AJEAN (`moeMigrate`). Ni Strata ni le modèle ne sont inclus dans
l'image : ils sont téléchargés à la demande.
## Services externes
+21 -2
View File
@@ -111,7 +111,7 @@ Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.1 », vérifié par
SHA-256).
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
@@ -120,7 +120,26 @@ puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go d
`/data/strata`). Le modèle installé devient un preset comme les autres :
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint).
cache KV fp16/8 bits, prédiction MTP, lecture des images, seconde carte).
La seconde carte a trois réglages : **non utilisée**, **aide aux experts** (le
défaut : elle garde et calcule une part des experts) ou **partage des couches**
(les deux cartes se répartissent les couches du modèle). Le partage est souvent
plus rapide avec deux cartes identiques (+50 % en génération mesuré par un
utilisateur d'AJEAN sur deux RTX 3060) ; sur deux cartes très différentes,
l'aide aux experts reste le meilleur choix. En partage des couches, le mode
« hors RAM » n'est jamais choisi : la seconde carte n'y garde pas d'experts.
Depuis le paquet 1.1, la carte d'appoint calcule elle-même ses experts à la
lecture des prompts (au lieu de les recopier vers la principale) : les ajouts
de 100 à 200 jetons se lisent ~30 à 40 % plus vite (mesures d'AJEAN). Un modèle
installé avec la 1.0 passe à la 1.1 **à son premier lancement**, sans
réinstallation : Loki télécharge le nouveau paquet (~340 Mo), reprend
l'environnement Python et la configuration de la 1.0 (le dossier
`/data/strata/ajean-moe-1.0` doit donc rester en place) et réécrit le preset.
En cas d'échec, la 1.0 continue de tourner et la raison est dans le journal du
moteur. En mode « hors RAM », le contexte est plafonné à 131 072 jetons (au-delà,
le cache KV verrouillé en RAM ne tient plus).
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
+142 -8
View File
@@ -282,10 +282,96 @@ type dlState struct {
Part int `json:"part"` // tranche en cours (1-based)
Finished bool `json:"finished"`
Canceled bool `json:"canceled"`
Paused bool `json:"paused"`
Err string `json:"error"`
StartedAt int64 `json:"started_at"`
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
gate *dlGate `json:"-"`
}
// dlGate met un téléchargement en pause (repris d'AJEAN v0.18.1) : les
// connexions en cours sont coupées (rien ne reste ouvert vers le CDN pendant la
// pause), et chaque morceau attend la reprise puis repart de l'octet où il
// s'était arrêté, par une requête de plage.
type dlGate struct {
mu sync.Mutex
paused bool
wake chan struct{} // fermé à la reprise
attempt map[int]context.CancelFunc // requêtes en cours, coupées par la pause
next int
}
func newDlGate() *dlGate { return &dlGate{attempt: map[int]context.CancelFunc{}} }
func (g *dlGate) setPaused(p bool) {
if g == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
if p == g.paused {
return
}
g.paused = p
if p {
g.wake = make(chan struct{})
for _, c := range g.attempt {
c()
}
return
}
close(g.wake)
}
// wait bloque tant que le téléchargement est en pause. false = annulé entre-temps.
func (g *dlGate) wait(ctx context.Context) bool {
if g == nil {
return ctx.Err() == nil
}
g.mu.Lock()
paused, wake := g.paused, g.wake
g.mu.Unlock()
if !paused {
return ctx.Err() == nil
}
select {
case <-wake:
return ctx.Err() == nil
case <-ctx.Done():
return false
}
}
func (g *dlGate) isPaused() bool {
if g == nil {
return false
}
g.mu.Lock()
defer g.mu.Unlock()
return g.paused
}
// begin ouvre une requête que la pause pourra couper ; la fonction rendue la referme.
func (g *dlGate) begin(ctx context.Context) (context.Context, func()) {
if g == nil {
return ctx, func() {}
}
actx, cancel := context.WithCancel(ctx)
g.mu.Lock()
id := g.next
g.next++
g.attempt[id] = cancel
if g.paused {
cancel() // pause tombée entre wait et begin
}
g.mu.Unlock()
return actx, func() {
g.mu.Lock()
delete(g.attempt, id)
g.mu.Unlock()
cancel()
}
}
var (
@@ -429,7 +515,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(context.Background())
st := &dlState{
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, gate: newDlGate(),
}
dlDownloads[name] = st
dlMu.Unlock()
@@ -759,7 +845,9 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
dt := now.Sub(lastAt).Seconds()
dlMu.Lock()
st.Done = cur
if dt > 0 {
if st.Paused {
st.Speed = 0
} else if dt > 0 {
inst := int64(float64(cur-last) / dt)
if st.Speed == 0 {
st.Speed = inst
@@ -827,7 +915,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
return err
}
}
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
if err := dlFetch(ctx, st.gate, f, dlURL, total, conns, done); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
@@ -845,9 +933,9 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
// dlFetch writes the whole body into f, either as one stream or as `conns`
// parallel byte ranges. done is incremented atomically as bytes land on disk.
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int64, conns int, done *int64) error {
if conns <= 1 {
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
return dlChunk(ctx, g, f, dlURL, 0, total-1, total <= 0, done)
}
size := total / int64(conns)
var wg sync.WaitGroup
@@ -861,7 +949,7 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
wg.Add(1)
go func(i int, start, end int64) {
defer wg.Done()
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
errs[i] = dlChunk(ctx, g, f, dlURL, start, end, false, done)
}(i, start, end)
}
wg.Wait()
@@ -876,11 +964,15 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
// dlChunk downloads [start,end] into f at the right offset, retrying from where
// it stopped if the connection drops mid-chunk. With whole=true it streams the
// entire body sequentially (server without range support, unknown size).
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
const attempts = 4
pos := start
var lastErr error
for try := 0; try < attempts; try++ {
// Une pause n'est pas un échec : on attend la reprise sans consommer d'essai.
if !g.wait(ctx) {
return ctx.Err()
}
if err := ctx.Err(); err != nil {
return err // cancelled: never retry
}
@@ -900,18 +992,26 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
} else if pos > start {
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
}
req, err := dlRequest(ctx, dlURL, rng)
actx, release := g.begin(ctx)
req, err := dlRequest(actx, dlURL, rng)
if err != nil {
release()
return err
}
resp, err := dlClient.Do(req)
if err != nil {
release()
if g.isPaused() && ctx.Err() == nil {
try--
continue
}
lastErr = err
continue
}
if resp.StatusCode != 200 && resp.StatusCode != 206 {
err := dlSourceError(resp, dlURL)
resp.Body.Close()
release()
return err
}
if resp.StatusCode == 200 && pos > start {
@@ -921,10 +1021,15 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
}
n, cerr := dlCopy(f, resp.Body, pos, done)
resp.Body.Close()
release()
pos += n
if cerr == nil {
return nil
}
if g.isPaused() && ctx.Err() == nil {
try-- // coupé par la pause : la reprise repart de pos
continue
}
lastErr = cerr
}
return lastErr
@@ -981,6 +1086,35 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleModelDownloadPause met en pause ({"pause":true}) ou reprend
// ({"pause":false}) un téléchargement en cours. Le fichier partiel est gardé.
func handleModelDownloadPause(w http.ResponseWriter, r *http.Request) {
var req struct {
Filename string `json:"filename"`
Pause bool `json:"pause"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
name := filepath.Base(strings.TrimSpace(req.Filename))
dlMu.Lock()
st, ok := dlDownloads[name]
if !ok || st.Finished {
dlMu.Unlock()
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement en cours pour " + name})
return
}
st.Paused = req.Pause
if req.Pause {
st.Speed = 0
}
g := st.gate
dlMu.Unlock()
g.setPaused(req.Pause)
sendJSON(w, 200, map[string]any{"ok": true, "paused": req.Pause})
}
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
// startup. A download killed by a crash or a service restart can't be resumed
// (its state lived in memory), so the partial file would otherwise sit there
+93
View File
@@ -231,3 +231,96 @@ func TestDLProbeSurfacesExplainedError(t *testing.T) {
t.Errorf("message %q sans indication sur le jeton", err)
}
}
type slowWriter struct{ http.ResponseWriter }
func (s slowWriter) Write(b []byte) (int, error) {
time.Sleep(2 * time.Millisecond)
return s.ResponseWriter.Write(b)
}
// Une pause coupe le flux, garde ce qui est écrit, et la reprise termine un
// fichier identique à la source (repris d'AJEAN v0.18.1).
func TestRunDownloadPauseResume(t *testing.T) {
data := make([]byte, 48<<20)
rand.New(rand.NewSource(2)).Read(data)
t.Setenv("LOKI_DL_CONNS", "4")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(slowWriter{w}, r, "m.gguf", time.Time{}, bytes.NewReader(data))
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "m.gguf")
st := &dlState{Filename: "m.gguf", gate: newDlGate()}
done := make(chan struct{})
go func() {
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
close(done)
}()
time.Sleep(300 * time.Millisecond)
st.gate.setPaused(true)
time.Sleep(1500 * time.Millisecond) // le compteur publié se fige
dlMu.Lock()
a := st.Done
dlMu.Unlock()
time.Sleep(1500 * time.Millisecond)
dlMu.Lock()
b, fin := st.Done, st.Finished
dlMu.Unlock()
if fin || a != b || a == 0 {
t.Fatalf("pause inopérante : %d puis %d octets, terminé=%v", a, b, fin)
}
st.gate.setPaused(false)
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("pas terminé après la reprise")
}
if st.Err != "" {
t.Fatalf("erreur après reprise : %s", st.Err)
}
got, err := os.ReadFile(dest)
if err != nil || !bytes.Equal(got, data) {
t.Fatalf("fichier différent de la source après reprise (err=%v)", err)
}
}
// La route : pause puis reprise d'un téléchargement connu, refus pour
// l'inconnu et pour un téléchargement terminé.
func TestHandleModelDownloadPause(t *testing.T) {
dlMu.Lock()
saved := dlDownloads
dlDownloads = map[string]*dlState{
"a.gguf": {Filename: "a.gguf", Speed: 9, gate: newDlGate()},
"b.gguf": {Filename: "b.gguf", Finished: true},
}
dlMu.Unlock()
t.Cleanup(func() { dlMu.Lock(); dlDownloads = saved; dlMu.Unlock() })
post := func(body string) (int, string) {
w := httptest.NewRecorder()
handleModelDownloadPause(w, httptest.NewRequest("POST", "/api/models/download/pause", strings.NewReader(body)))
return w.Code, strings.TrimSpace(w.Body.String())
}
if code, out := post(`{"filename":"../a.gguf","pause":true}`); code != 200 || out != `{"ok":true,"paused":true}` {
t.Fatalf("pause : %d %s", code, out)
}
dlMu.Lock()
st := dlDownloads["a.gguf"]
paused, speed := st.Paused, st.Speed
dlMu.Unlock()
if !paused || speed != 0 || !st.gate.isPaused() {
t.Errorf("état après pause : paused=%v speed=%d porte=%v", paused, speed, st.gate.isPaused())
}
if code, out := post(`{"filename":"a.gguf","pause":false}`); code != 200 || out != `{"ok":true,"paused":false}` || st.gate.isPaused() {
t.Fatalf("reprise : %d %s", code, out)
}
for _, body := range []string{`{"filename":"b.gguf","pause":true}`, `{"filename":"x.gguf","pause":true}`} {
if code, _ := post(body); code != 404 {
t.Errorf("%s : %d, attendu 404", body, code)
}
}
if code, _ := post(`[`); code != 400 {
t.Errorf("corps illisible : %d", code)
}
}
+167 -16
View File
@@ -5,11 +5,13 @@ package loki
// répartit les experts entre VRAM, RAM et disque pour faire tenir le modèle sur
// une carte de joueur.
//
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.0 ») : le moteur est le paquet
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.0 — sources de Strata et
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.1 ») : le moteur est le paquet
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.1 — sources de Strata et
// binaire Linux CUDA 12 compilé et validé par l'auteur d'AJEAN (avec ses
// modifications : experts de la carte d'appoint gardés hors RAM). Les deux
// fichiers sont vérifiés par SHA-256 avant usage.
// modifications : experts de la carte d'appoint gardés hors RAM et, depuis la
// 1.1, calculés par elle à la lecture des prompts). Les deux fichiers sont
// vérifiés par SHA-256 avant usage. Un modèle installé avec la 1.0 passe à la
// 1.1 à son premier lancement (strataMigrate).
//
// Le choix du moteur se fait par PRESET : ENGINE=strata. Sans la clé (ou
// ENGINE=llamacpp), rien ne change, llama-server reste le moteur.
@@ -47,11 +49,12 @@ import (
"os/exec"
)
// Paquet figé (release moe-v1.0 d'AJEAN). Le moteur est compilé en CUDA 12.8,
// Paquet figé (release moe-v1.1 d'AJEAN). Le moteur est compilé en CUDA 12.8,
// mode portable AVX2, sm_75/80/86/89/120 : RTX 20 à 50. Un moteur installé d'un
// autre paquet est remplacé au lancement (strataEnsureEngine).
// autre paquet est remplacé au lancement (strataEnsureEngine), une installation
// d'une version précédente est reprise (strataMigrate).
const (
strataVersion = "1.0"
strataVersion = "1.1"
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
@@ -69,10 +72,11 @@ var (
strataDiskFree = diskFree
)
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage.
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage
// (celles qu'AJEAN publie dans son backend_moe.go pour moe-v1.1).
var strataAssetSHA = map[string]string{
strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b",
strataEngineAsset: "b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c",
strataSrcAsset: "5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115",
strataEngineAsset: "8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3",
}
// Valeurs de la clé ENGINE d'un preset.
@@ -132,8 +136,14 @@ const strataLowRAMHeadroomGB = 10
// strataDropHeadroomGB : RAM à laisser à côté des experts verrouillés quand la
// carte d'aide garde les siens hors RAM (mesuré par AJEAN : 46 Go de RAM,
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres).
const strataDropHeadroomGB = 7
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres ; le moteur 1.1 prend
// ~1 Go de plus que la 1.0).
const strataDropHeadroomGB = 8
// strataDropMaxCtx : contexte maximal en mode hors RAM. Le cache KV au-delà de
// la part en VRAM est verrouillé en RAM : à 167 936 jetons, le serveur de test
// d'AJEAN tombait à 1,3 Go libres dès la première requête (moteur 1.1).
const strataDropMaxCtx = 131072
// strataHelperExpertsGB : ce que la carte d'aide garde d'experts (sa VRAM moins
// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go).
@@ -210,7 +220,9 @@ func strataPackHasExpertsBin(args []string) bool {
// Pure : la décision se teste en table.
func strataResolveExperts(cfg map[string]string, args []string, availGB, helperGB float64) string {
binReady := strataPackHasExpertsBin(args) && strataEngineInstalled() == strataEngineAsset
dropReady := binReady && strataHelperOn(cfg) && cfg["STRATA_DROP"] != "0"
// (experts de la carte d'aide hors RAM : propre au mode d'aide, jamais en
// partage des couches, où la seconde carte ne garde pas d'experts)
dropReady := binReady && strataHelperOn(cfg) && !strataLayerSplit(cfg) && cfg["STRATA_DROP"] != "0"
arena := strataArenaGB(cfg)
switch strings.ToLower(strings.TrimSpace(cfg["STRATA_EXPERTS"])) {
case strataExpertsDisk:
@@ -494,6 +506,7 @@ type strataDetails struct {
// pour les réglages : ce que la machine permet et l'état actuel
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
HelperOn bool `json:"helper_on"`
Split bool `json:"split_layers"` // les deux cartes se partagent les couches
Vision bool `json:"vision"`
}
@@ -555,6 +568,7 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
if strataHelperOn(pc) {
d.HelperOn = true
d.HelperGPU = gpuName(h)
d.Split = strataLayerSplit(pc)
}
}
d.Vision = pc["STRATA_VISION"] != "0"
@@ -870,7 +884,8 @@ func strataFindConfig(quant string) (string, error) {
//
// - --prefill 16384 : +15 % de lecture sur les longs prompts (40K) ;
// - --short-read 150 : les petits ajouts (< 150 jetons) sont lus comme en
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides ;
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides
// (48 avec une carte d'aide, voir strataBuildConfig) ;
// - --prompt-cache-root 256 : point de reprise posé à la fin du prompt système
// (défaut 2048) : une nouvelle conversation démarre en ~0,3 s au lieu de 4-5 s.
var strataTuning = map[string]string{
@@ -957,6 +972,16 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
}
helper := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
hasHelper := strataHelperOn(cfg)
if strataLayerSplit(cfg) {
// Partage des couches : le serveur de Strata le fait de lui-même quand sa
// clé « gpu » liste plusieurs cartes (--layer-split auto, réparti selon la
// VRAM libre), numérotées comme nvidia-smi. Pas de chemin « aide » : la
// réserve de VRAM de l'installeur et l'encodeur d'images restent en place.
main, _ := strconv.Atoi(strings.TrimSpace(cfg["STRATA_MAIN_GPU"]))
second, _ := strconv.Atoi(helper)
out["gpu"] = []any{main, second}
hasHelper = false
}
// Deuxième carte en aide : elle garde et CALCULE sa part des experts (le
// chemin « helper » du moteur), la carte principale reste CUDA0. Les cartes
// sont posées par CUDA_VISIBLE_DEVICES (serveStrata), PAS par la clé « gpu » :
@@ -1002,6 +1027,10 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
env["STRATA_STAGER_THREADS"] = "8"
env["STRATA_STAGER_RING"] = "32"
// sa VRAM libre réservée : 640 Mio (défaut 1024) = ~230 experts de plus
// hors RAM, l'encodeur d'images y tourne encore ; le moteur refuse moins
// de 512
env["STRATA_REMOTE_DROP_RESERVE_MIB"] = "640"
case "arena":
// experts.bin mappé tel quel (variante mesurée par AJEAN), sans part PCIe :
// le mappage n'en donne pas l'adresse GPU.
@@ -1017,8 +1046,22 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
k, v, _ := strings.Cut(kv, "=")
env[k] = v
}
if hasHelper {
// Lecture d'un prompt (moteur 1.1) : les experts que la carte d'aide garde
// sont calculés par elle au lieu d'être recopiés vers la principale par son
// lien PCIe (le chemin passe par la source « helper ») ; le chemin par blocs
// coûte alors moins cher, les ajouts courts n'en sont exclus que sous 48
// jetons. Mesuré par AJEAN : 100 nouveaux jetons 1,19 -> 0,85 s, 200 :
// 1,96 -> 1,12 s.
env["STRATA_PREFILL_HELPER_SOURCE"] = "1"
args = strataSetArg(args, "--short-read", "48")
}
out["env"] = env
if ctx := strings.TrimSpace(cfg["CTX"]); ctx != "" {
// hors RAM, le cache KV verrouillé au-delà de strataDropMaxCtx ne tient plus
if n, err := strconv.Atoi(ctx); err == nil && n > strataDropMaxCtx && env["STRATA_REMOTE_DROP"] == "1" {
ctx = strconv.Itoa(strataDropMaxCtx)
}
args = strataSetArg(args, "--max-context", ctx)
}
// réglages imposés par le preset (sinon : ceux de l'installeur)
@@ -1162,6 +1205,16 @@ func strataHelperOn(cfg map[string]string) bool {
return h != "" && h != "-1" && cfg["STRATA_HELPER"] != "0"
}
// strataLayerSplit : les deux cartes se partagent les COUCHES du modèle (clé de
// preset STRATA_SPLIT=layers) au lieu que la seconde n'aide que pour une part
// des experts. Mesuré par un utilisateur d'AJEAN sur deux RTX 3060 identiques
// (#121 amont) : la carte d'aide ne travaillait qu'à ~15 %, le partage des
// couches donnait +50 % en génération. Sur deux cartes très différentes, le
// mode d'aide reste le défaut mesuré : le partage est un choix, jamais imposé.
func strataLayerSplit(cfg map[string]string) bool {
return strataHelperOn(cfg) && cfg["STRATA_SPLIT"] == "layers"
}
// strataCudaDevices : ordre des cartes vu par le moteur, la principale en CUDA0.
func strataCudaDevices(cfg map[string]string) string {
main := strings.TrimSpace(cfg["STRATA_MAIN_GPU"])
@@ -1184,17 +1237,99 @@ func strataPreflight(cfg map[string]string) error {
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("configuration de Strata introuvable : %s — réinstalle le modèle", path)
}
py := filepath.Join(filepath.Dir(path), ".venv", "bin", "python")
py := strataVenvPython(filepath.Dir(path))
if _, err := os.Stat(py); err != nil {
return fmt.Errorf("environnement Python de Strata absent (%s) : réinstalle le modèle", py)
}
return nil
}
// strataVenvPython : le Python de l'environnement de Strata, dans ses sources.
func strataVenvPython(srcDir string) string { return filepath.Join(srcDir, ".venv", "bin", "python") }
// strataMigrate : un preset installé par une version précédente du paquet
// (STRATA_CONFIG dans strataHome()/ajean-moe-<ancienne>) passe à la version
// courante au lancement, sans réinstaller : les modèles et packs (data/)
// servent tels quels. Les sources de la version courante sont posées (~340 Mo
// avec le moteur), l'environnement Python de l'ancienne est repris par un lien
// quand les dépendances n'ont pas changé, le moteur compilé est installé par
// strataEnsureEngine, la config de l'installeur est recopiée avec ses chemins
// réécrits, et les presets qui la citaient comme la config vivante pointent sur
// la nouvelle. En cas d'échec rien n'est réécrit : l'ancienne version continue
// de tourner. Repris d'AJEAN (moeMigrate, AJEAN MoE 1.1).
func strataMigrate(cfg map[string]string) error {
old := strings.TrimSpace(cfg["STRATA_CONFIG"])
oldDir := filepath.Dir(old)
newDir := strataSrcDir()
if old == "" || oldDir == newDir || filepath.Dir(oldDir) != strataHome() || !strings.HasPrefix(filepath.Base(oldDir), "ajean-moe-") {
return nil
}
newPath := filepath.Join(newDir, filepath.Base(old))
if !isFile(newPath) {
raw, err := os.ReadFile(old)
if err != nil {
return err
}
if !isFile(filepath.Join(newDir, "setup.py")) {
src, err := strataFetch(strataSrcAsset)
if err != nil {
return err
}
if err := extractArchive(src, strataHome()); err != nil {
return fmt.Errorf("décompression : %w", err)
}
}
venv := filepath.Join(newDir, ".venv")
if _, err := os.Lstat(venv); err != nil {
a, _ := os.ReadFile(filepath.Join(oldDir, "requirements.txt"))
b, _ := os.ReadFile(filepath.Join(newDir, "requirements.txt"))
if len(a) == 0 || string(a) != string(b) || !isFile(strataVenvPython(oldDir)) {
return fmt.Errorf("dépendances Python changées : réinstalle le modèle depuis Paramètres → Moteur → Strata")
}
if err := os.Symlink(filepath.Join(oldDir, ".venv"), venv); err != nil {
return err
}
}
if err := os.MkdirAll(filepath.Join(newDir, "engine-cuda12"), 0o755); err != nil {
return err
}
if err := strataEnsureEngine(); err != nil {
return err
}
body := strings.ReplaceAll(string(raw), oldDir+string(os.PathSeparator), newDir+string(os.PathSeparator))
if err := os.WriteFile(newPath+".new", []byte(body), 0o644); err != nil {
return err
}
if err := os.Rename(newPath+".new", newPath); err != nil {
return err
}
}
// les presets qui citent l'ancienne config, puis la config vivante (les deux
// changent ensemble : l'empreinte du preset actif reste celle de la config)
if list, _ := filepath.Glob(filepath.Join(presetsDir(), "*.env")); len(list) > 0 {
for _, p := range list {
b, err := os.ReadFile(p)
if err != nil || !strings.Contains(string(b), "STRATA_CONFIG="+old+"\n") {
continue
}
_ = os.WriteFile(p, []byte(strings.ReplaceAll(string(b), "STRATA_CONFIG="+old+"\n", "STRATA_CONFIG="+newPath+"\n")), 0o644)
}
}
if err := SetConfigKey("STRATA_CONFIG", newPath); err != nil {
return err
}
cfg["STRATA_CONFIG"] = newPath
fmt.Fprintf(os.Stderr, "[loki serve] paquet AJEAN MoE %s : installation reprise depuis %s\n", strataVersion, filepath.Base(oldDir))
return nil
}
// serveStrata : la branche Strata de `loki serve`. Écrit la config finale à
// côté de celle de l'installeur puis remplace le process par le serveur de
// Strata (comme pour llama-server : le superviseur suit directement le moteur).
func serveStrata(cfg map[string]string) error {
if err := strataMigrate(cfg); err != nil {
fmt.Fprintf(os.Stderr, "[loki serve] passage au paquet AJEAN MoE %s impossible (%v) : la version installée reste utilisée\n", strataVersion, err)
}
if err := strataPreflight(cfg); err != nil {
return err
}
@@ -1223,7 +1358,7 @@ func serveStrata(cfg map[string]string) error {
return err
}
srcDir := filepath.Dir(path)
py := filepath.Join(srcDir, ".venv", "bin", "python")
py := strataVenvPython(srcDir)
tag := strataConfigTag(path)
final["log"] = filepath.Join(strataHome(), "strata-"+tag+".log")
out := filepath.Join(strataHome(), "loki-strata-"+tag+".json")
@@ -1326,6 +1461,9 @@ type strataSettingsReq struct {
Vision bool `json:"vision"`
Helper bool `json:"helper"`
Experts string `json:"experts"` // auto, ram ou disk ; vide = inchangé
// Split : rôle de la seconde carte, « helper » (aide aux experts) ou
// « layers » (partage des couches). Vide = inchangé (anciens clients).
Split string `json:"split"`
}
// strataApplySettings réécrit les clés du preset (pure, testable).
@@ -1340,6 +1478,8 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
return "", fmt.Errorf("MTP hors limites : %d", r.Spec)
case r.Experts != "" && r.Experts != strataExpertsAuto && r.Experts != strataExpertsRAM && r.Experts != strataExpertsDisk:
return "", fmt.Errorf("placement des experts inconnu : %s", r.Experts)
case r.Split != "" && r.Split != "helper" && r.Split != "layers":
return "", fmt.Errorf("répartition inconnue : %s", r.Split)
}
set := map[string]string{
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
@@ -1350,6 +1490,14 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
set["STRATA_EXPERTS"] = r.Experts
keys = append(keys, "STRATA_EXPERTS")
}
if r.Split != "" {
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
set["STRATA_SPLIT"] = ""
if r.Split == "layers" {
set["STRATA_SPLIT"] = "layers"
}
keys = append(keys, "STRATA_SPLIT")
}
var lines []string
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
@@ -1359,6 +1507,9 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
lines = append(lines, l)
}
for _, k := range keys {
if set[k] == "" && k == "STRATA_SPLIT" {
continue
}
lines = append(lines, k+"="+set[k])
}
return strings.Join(lines, "\n") + "\n", nil
@@ -0,0 +1,133 @@
package loki
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// Moteur 1.1 : avec une carte d'aide, la lecture d'un prompt passe par elle
// (source « helper ») et le chemin direct ne garde que les ajouts sous 48 jetons.
func TestStrataBuildConfigHelperPrompt(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p"}}
out, _ := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
if argAfter(strataArgs(t, out), "--short-read") != "48" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != "1" {
t.Errorf("carte d'aide : lecture courte et source d'aide attendues : %v %v", strataArgs(t, out), out["env"])
}
// carte d'aide coupée ou absente : réglages d'avant
for _, cfg := range []map[string]string{{}, {"STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}} {
out, _ = strataBuildConfig(base, cfg, "", "ram")
if argAfter(strataArgs(t, out), "--short-read") != "150" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != nil {
t.Errorf("une carte (%v) : %v %v", cfg, strataArgs(t, out), out["env"])
}
}
}
// Mode hors RAM : réserve de VRAM de la carte d'aide abaissée, contexte plafonné.
func TestStrataBuildConfigDropReserveAndCtx(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p"}, "env": map[string]any{}}
cfg := map[string]string{"STRATA_HELPER_GPU": "0", "CTX": "167936"}
out, _ := strataBuildConfig(base, cfg, "", "drop")
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != "640" {
t.Errorf("réserve de la carte d'aide : %v", out["env"])
}
if got := argAfter(strataArgs(t, out), "--max-context"); got != "131072" {
t.Errorf("contexte hors RAM non plafonné : %s", got)
}
// hors du mode hors RAM : le contexte demandé
for _, mode := range []string{"ram", "arena", "disk"} {
out, _ = strataBuildConfig(base, cfg, "", mode)
if got := argAfter(strataArgs(t, out), "--max-context"); got != "167936" {
t.Errorf("%s : contexte plafonné sans mode hors RAM : %s", mode, got)
}
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != nil {
t.Errorf("%s : réserve du mode hors RAM posée : %v", mode, out["env"])
}
}
}
// Un modèle installé avec le paquet 1.0 passe au paquet courant à son premier
// lancement, sans réinstallation.
func TestStrataMigrate(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("lien symbolique : Strata ne tourne que sous Linux")
}
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
newDir := strataSrcDir()
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), filepath.Join(newDir, "engine-cuda12"), presetsDir()} {
_ = os.MkdirAll(d, 0o755)
}
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
for _, d := range []string{oldDir, newDir} {
_ = os.WriteFile(filepath.Join(d, "requirements.txt"), []byte("numpy\n"), 0o644)
}
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
old := filepath.Join(oldDir, "strata-swift-iq3_xxs.json")
_ = os.WriteFile(old, []byte(`{"exe": "`+filepath.Join(oldDir, "engine-cuda12", "strata")+`", "args": []}`), 0o644)
preset := filepath.Join(presetsDir(), "P.env")
_ = os.WriteFile(preset, []byte("ENGINE=strata\nSTRATA_CONFIG="+old+"\nCTX=1\n"), 0o644)
other := filepath.Join(presetsDir(), "L.env")
_ = os.WriteFile(other, []byte("MODEL=/m.gguf\n"), 0o644)
if err := SetConfigKey("STRATA_CONFIG", old); err != nil {
t.Fatal(err)
}
cfg := map[string]string{"ENGINE": "strata", "STRATA_CONFIG": old}
if err := strataMigrate(cfg); err != nil {
t.Fatal(err)
}
want := filepath.Join(newDir, "strata-swift-iq3_xxs.json")
if cfg["STRATA_CONFIG"] != want || ReadConfig()["STRATA_CONFIG"] != want {
t.Fatalf("config vivante : %q", cfg["STRATA_CONFIG"])
}
b, _ := os.ReadFile(want)
if strings.Contains(string(b), oldDir) || !strings.Contains(string(b), filepath.Join(newDir, "engine-cuda12")) {
t.Errorf("chemins non réécrits : %s", b)
}
if p, _ := os.ReadFile(preset); !strings.Contains(string(p), "STRATA_CONFIG="+want+"\n") {
t.Errorf("preset non réécrit : %s", p)
}
if p, _ := os.ReadFile(other); string(p) != "MODEL=/m.gguf\n" {
t.Errorf("preset d'un autre moteur touché : %s", p)
}
if !isFile(strataVenvPython(newDir)) {
t.Error("environnement Python non repris")
}
// déjà à jour : rien à faire
if err := strataMigrate(cfg); err != nil || cfg["STRATA_CONFIG"] != want {
t.Errorf("seconde passe : %v %q", err, cfg["STRATA_CONFIG"])
}
}
// Dépendances Python changées : rien n'est réécrit, l'ancienne version tourne.
func TestStrataMigrateRequirementsChanged(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
newDir := strataSrcDir()
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), newDir} {
_ = os.MkdirAll(d, 0o755)
}
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
_ = os.WriteFile(filepath.Join(oldDir, "requirements.txt"), []byte("numpy\n"), 0o644)
_ = os.WriteFile(filepath.Join(newDir, "requirements.txt"), []byte("numpy\ntorch\n"), 0o644)
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
old := filepath.Join(oldDir, "strata-iq3_xxs.json")
_ = os.WriteFile(old, []byte(`{"args": []}`), 0o644)
cfg := map[string]string{"STRATA_CONFIG": old}
if err := strataMigrate(cfg); err == nil || !strings.Contains(err.Error(), "dépendances") {
t.Fatalf("erreur de dépendances attendue : %v", err)
}
if cfg["STRATA_CONFIG"] != old || isFile(filepath.Join(newDir, "strata-iq3_xxs.json")) {
t.Errorf("config réécrite malgré l'échec : %q", cfg["STRATA_CONFIG"])
}
// un preset hors de strata/ (copié à la main) n'est pas touché
cfg = map[string]string{"STRATA_CONFIG": filepath.Join(home, "ailleurs", "ajean-moe-1.0", "strata-x.json")}
if err := strataMigrate(cfg); err != nil {
t.Errorf("config hors de strata/ : %v", err)
}
}
+124
View File
@@ -0,0 +1,124 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Partage des couches (#121 amont) : la clé « gpu » liste les deux cartes (le
// serveur de Strata en déduit --layer-split), et rien du mode d'aide ne subsiste.
func TestStrataBuildConfigLayerSplit(t *testing.T) {
base := map[string]any{
"args": []any{"--pack", "/p", "--vram-reserve-mib", "900"},
"vision": map[string]any{"exe": "/v"},
}
cfg := map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_SPLIT": "layers"}
out, err := strataBuildConfig(base, cfg, "", "drop")
if err != nil {
t.Fatal(err)
}
g, ok := out["gpu"].([]any)
if !ok || len(g) != 2 || g[0] != 1 || g[1] != 0 {
t.Fatalf("gpu = %v, attendu [1 0] (principale d'abord, numéros nvidia-smi)", out["gpu"])
}
a := strataArgs(t, out)
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
t.Errorf("options du mode d'aide en partage des couches : %v", a)
}
if argAfter(a, "--vram-reserve-mib") != "900" {
t.Errorf("la réserve de VRAM de l'installeur doit rester : %v", a)
}
if argAfter(a, "--short-read") != "150" {
t.Errorf("lecture des prompts par la carte d'aide en partage des couches : %v", a)
}
env := out["env"].(map[string]any)
if env["STRATA_REMOTE_DROP"] != nil || env["STRATA_PREFILL_HELPER_SOURCE"] != nil {
t.Errorf("experts hors RAM ou source d'aide (mode d'aide) en partage des couches : %v", env)
}
if _, has := out["vision"].(map[string]any)["cuda_device"]; has {
t.Errorf("encodeur d'images déplacé sur la seconde carte en partage des couches")
}
// Sans la clé, rien ne change : le mode d'aide reste le défaut.
delete(cfg, "STRATA_SPLIT")
out, _ = strataBuildConfig(base, cfg, "", "drop")
if _, has := out["gpu"]; has {
t.Errorf("clé gpu posée en mode d'aide : %v", out["gpu"])
}
if !strataHasArg(strataArgs(t, out), "--remote-expert-opt") {
t.Errorf("mode d'aide perdu sans STRATA_SPLIT")
}
// Seconde carte coupée : pas de partage même si la clé traîne.
cfg["STRATA_SPLIT"], cfg["STRATA_HELPER"] = "layers", "0"
if strataLayerSplit(cfg) {
t.Errorf("partage des couches avec la seconde carte coupée")
}
}
// En partage des couches, la seconde carte ne garde pas d'experts : le mode
// hors RAM (qui compte sur elle pour en loger une part) n'est jamais choisi.
func TestStrataResolveExpertsLayerSplit(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
pack := filepath.Join(home, "pack")
_ = os.MkdirAll(pack, 0o755)
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
args := []string{"--pack", pack}
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
t.Fatalf("mode d'aide : %s, attendu drop", got)
}
cfg["STRATA_SPLIT"] = "layers"
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
t.Errorf("partage des couches : %s, attendu arena", got)
}
cfg["STRATA_EXPERTS"] = "ram"
if got := strataResolveExperts(cfg, args, 44, 12); got != "ram" {
t.Errorf("partage des couches, experts en RAM : %s, attendu ram", got)
}
}
func TestStrataApplySettingsSplit(t *testing.T) {
in := "ENGINE=strata\nSTRATA_HELPER_GPU=0\n"
req := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Vision: true, Helper: true, Split: "layers"}
out, err := strataApplySettings(in, req)
if err != nil || !strings.Contains(out, "STRATA_SPLIT=layers\n") {
t.Fatalf("STRATA_SPLIT=layers attendu : %q (%v)", out, err)
}
req.Split = "helper"
if out, _ = strataApplySettings(out, req); strings.Contains(out, "STRATA_SPLIT") {
t.Errorf("retour au mode d'aide : la clé doit disparaître : %q", out)
}
// un ancien client n'envoie pas « split » : la clé est conservée
req.Split = ""
out, _ = strataApplySettings("STRATA_SPLIT=layers\n", req)
if !strings.Contains(out, "STRATA_SPLIT=layers") {
t.Errorf("clé effacée par un client qui ne la connaît pas : %q", out)
}
req.Split = "nimporte"
if _, err := strataApplySettings(in, req); err == nil {
t.Errorf("répartition inconnue acceptée")
}
}
func TestStrataDetailsForLayerSplit(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
base := filepath.Join(home, "base.json")
_ = os.WriteFile(base, []byte(`{"args":["--pack","/p"],"vision":{"exe":"v"}}`), 0o644)
_ = os.MkdirAll(presetsDir(), 0o755)
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_SPLIT=layers\n"
_ = os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644)
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}, {Index: 1, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}}}
d := strataDetailsFor("X", env)
if d == nil {
t.Fatal("pas de détails")
}
if !d.Split || !d.HelperOn || d.HelperGPU == "" || d.VisionGPU != d.MainGPU || d.ShortRead != "150" {
t.Errorf("%+v", *d)
}
}
+1 -1
View File
@@ -184,7 +184,7 @@ func TestStrataDetailsFor(t *testing.T) {
}
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
d.ShortRead != "48" || d.CacheRoot != "256" || d.Prefill != "16384" {
t.Errorf("%+v", *d)
}
}
+6 -1
View File
@@ -130,7 +130,8 @@ const imageLostMarker = " [image — not kept in the conversation past this poin
// (userMessageContent : parties texte + image quand la vision est active) porte
// un tableau de parties ; on en recolle les segments `text` pour que l'estimation
// de contexte et le transcript de compaction ne partent pas d'un texte vide, et
// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans
// on marque la présence d'une partie `image_url` (voir imageLostMarker) ou
// `input_video` (videoLostMarker, chat_video.go) — sans
// quoi elle reste invisible à toute la chaîne de compaction.
func msgText(m Message) string {
switch v := m.Content.(type) {
@@ -146,6 +147,8 @@ func msgText(m Message) string {
}
case "image_url":
b.WriteString(imageLostMarker)
case "input_video":
b.WriteString(videoLostMarker)
}
}
return b.String()
@@ -163,6 +166,8 @@ func msgText(m Message) string {
}
case "image_url":
b.WriteString(imageLostMarker)
case "input_video":
b.WriteString(videoLostMarker)
}
}
return b.String()
+16 -1
View File
@@ -112,7 +112,8 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
}
// stripInlineImages remplace les images en base64 (data URI) portées par les
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
// messages multimodaux (vision : pièces jointes images et vidéos + outil
// see_image) par un
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
@@ -145,6 +146,20 @@ func stripInlineImagesValue(v any) any {
}
cp["image_url"] = c2
}
// Partie vidéo (input_video, chat_video.go) : même élision, sur les deux
// champs que lit llama-server.
if iv, ok := cp["input_video"].(map[string]any); ok {
c2 := make(map[string]any, len(iv))
for k, val := range iv {
c2[k] = val
}
for _, key := range []string{"url", "data"} {
if s, ok := c2[key].(string); ok && strings.HasPrefix(s, "data:") {
c2[key] = elideDataURI(s)
}
}
cp["input_video"] = c2
}
return cp
}
switch parts := v.(type) {
+5 -1
View File
@@ -123,7 +123,11 @@ const chatImgKeep = 24 * time.Hour
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
// fil resté ouvert longtemps sans bascule.
func pruneChatImages() {
dir := chatImgDir()
pruneMediaDir(chatImgDir())
pruneMediaDir(chatVidDir()) // vidéos jointes (chat_video.go), même délai
}
func pruneMediaDir(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
+1
View File
@@ -536,6 +536,7 @@ func (c *Conversation) snapshot() *convSnap {
// Images en base64 → références (chat_images.go) avant d'écrire : une photo
// jointe pesait des Mo, réécrits en entier à CHAQUE fin de tour.
refImagesInMessages(c.Messages)
refVideosInMessages(c.Messages) // vidéos jointes, même rangement (chat_video.go)
// En plein tour, le journal porte encore un événement par token et chaque
// frappe d'outil : on écrit la forme que produira la fin de tour
// (compactLog), sans toucher au journal en mémoire que suivent les abonnés.
+17 -5
View File
@@ -163,7 +163,8 @@ func engineSeesImages() bool {
// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le
// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire
// au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne.
// Vaut pour toutes les provenances : capture, pièce jointe, see_image.
// Vaut pour toutes les provenances : capture, pièce jointe, see_image — et pour
// les vidéos jointes (input_video), marquées par videoLostMarker.
func stripImageParts(msgs []Message) []Message {
for i, m := range msgs {
parts, ok := m.Content.([]any)
@@ -171,22 +172,33 @@ func stripImageParts(msgs []Message) []Message {
continue
}
var texts []string
dropped := false
dropped, droppedVid := false, false
for _, p := range parts {
pm, ok := p.(map[string]any)
if !ok {
continue
}
if pm["type"] == "image_url" {
switch pm["type"] {
case "image_url":
dropped = true
continue
case "input_video": // vidéo jointe (chat_video.go) : même sort
droppedVid = true
continue
}
if t, ok := pm["text"].(string); ok {
texts = append(texts, t)
}
}
if dropped {
msgs[i].Content = strings.Join(texts, "\n") + imageLostMarker
if dropped || droppedVid {
text := strings.Join(texts, "\n")
if dropped {
text += imageLostMarker
}
if droppedVid {
text += videoLostMarker
}
msgs[i].Content = text
}
}
return msgs
+333
View File
@@ -0,0 +1,333 @@
package loki
// chat_video.go — vidéos vues par le modèle (repris d'AJEAN, PR #100 et son
// correctif « vidéo envoyée seulement si le moteur sait la lire »).
//
// Trois morceaux :
// - videoInputSupported : le moteur actif sait-il lire une partie
// `input_video` ? Seul un llama.cpp local récent le fait (option
// --video-fps, décodage par ffmpeg). Une API externe ou Strata ignorent la
// partie SANS erreur : le modèle ne recevait que le texte et cherchait à voir
// la vidéo avec see_image. Ailleurs, la vidéo reste un simple fichier, avec
// la marche à suivre (videoFallbackNote) ;
// - une vidéo JOINTE au message est rangée PAR RÉFÉRENCE, comme les images
// (chat_images.go) : une fois sur disque sous LOKI_HOME/chatvid/, chiffrée
// comme le fil, et l'historique n'en garde que « loki-vid:<nom> ».
// expandVideoRefs remet les octets juste avant l'envoi au moteur ;
// - la vidéo chargée par l'outil see_video (chat_video_tool.go) est, elle,
// TOUJOURS éphémère : elle part dans le tour en cours et jamais dans
// l'historique, KEEP_TURN_IMAGES compris (des milliers de jetons visuels).
//
// Écart avec l'amont : comme les images, un fil rechargé perd ses vidéos
// (stripImageParts, avec videoLostMarker) et pruneChatImages vide aussi
// chatvid/ — Loki ne rejoue jamais un média d'un fil rouvert.
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
const vidRefScheme = "loki-vid:"
func chatVidDir() string { return filepath.Join(LokiHome(), "chatvid") }
// maxVideoBytes borne la taille d'une vidéo montrée au modèle : le moteur en
// tire des images à --video-fps (4 par seconde par défaut), donc une vidéo
// longue = des dizaines de milliers de jetons visuels. Au-delà, le contexte
// sature sans que le modèle puisse raisonner dessus.
const maxVideoBytes = 200 << 20 // 200 Mio
// videoMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme vidéo (partie
// input_video). Le moteur décode lui-même avec ffmpeg ; un format hors de cette
// liste reste un simple fichier du dossier de travail.
var videoMimes = map[string]string{
".mp4": "video/mp4",
".webm": "video/webm",
".mov": "video/quicktime",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
}
// videoMime renvoie le type MIME vidéo d'un nom de fichier, ou "" si ce n'est
// pas une vidéo qu'on sait montrer au modèle.
func videoMime(name string) string {
return videoMimes[strings.ToLower(filepath.Ext(name))]
}
// videoFallbackNote : consigne quand le moteur ne lit pas la vidéo. Le fichier
// reste utilisable : quelques images extraites avec ffmpeg, puis see_image.
// ffmpeg est dans l'image Docker de Loki (l'image serveur officielle de
// llama.cpp l'installe pour son décodage vidéo) ; une installation hors
// conteneur peut ne pas l'avoir, d'où la dernière phrase.
const videoFallbackNote = "Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.\n"
// videoLostMarker : équivalent d'imageLostMarker pour une partie input_video
// (résumé de compaction, fil rechargé).
const videoLostMarker = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]"
// videoPart construit la partie multimodale {type:input_video, input_video:{url}}
// — le format que llama-server lit une fois --mmproj chargé.
func videoPart(b []byte, mime string) map[string]any {
return map[string]any{
"type": "input_video",
"input_video": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
},
}
}
// videoInputSupported : le moteur actif sait-il lire une partie input_video ?
// Preset local avec projecteur (MMPROJ) ET binaire dont l'aide annonce
// --video-fps. Variable : remplaçable dans les tests.
var videoInputSupported = func() bool {
cfg := ReadConfig()
if isExternalConfig(cfg) || isStrataConfig(cfg) || strings.TrimSpace(cfg["MMPROJ"]) == "" {
return false
}
bin := strings.TrimSpace(cfg["BIN"])
if bin == "" {
return false
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(LokiHome(), bin)
}
return binHelpHasVideo(prebuiltResolveBin(bin))
}
var videoCapCache sync.Map // "binaire\x00mtime" → bool
// binHelpHasVideo lit l'aide du binaire, une fois par binaire ET par date de
// modification (un moteur mis à jour en place est relu). Volontairement à part
// de binHelp : celle-ci garde aussi une aide VIDE pour toujours, et l'appeler
// avant le lancement du moteur (chemin des bibliothèques pas encore posé)
// figerait « aucune capacité » pour le vrai lancement. Ici, le chemin des
// bibliothèques est passé à la seule commande (libraryPathEnv).
func binHelpHasVideo(bin string) bool {
st, err := os.Stat(bin)
if err != nil || st.IsDir() {
return false
}
key := bin + "\x00" + st.ModTime().String()
if v, ok := videoCapCache.Load(key); ok {
return v.(bool)
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "--help")
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput()
ok := strings.Contains(string(out), "--video-fps")
videoCapCache.Store(key, ok)
return ok
}
// --- Vidéos jointes, par référence -------------------------------------------
var vidExtByMime = map[string]string{
"video/mp4": ".mp4",
"video/webm": ".webm",
"video/quicktime": ".mov",
"video/x-matroska": ".mkv",
"video/x-msvideo": ".avi",
}
// storeChatVideo range les octets d'une vidéo et renvoie sa référence. Chiffrée
// comme le fil quand la mémoire l'est ; verrouillée : refus, la vidéo reste en
// base64 dans le message (rien ne se perd).
func storeChatVideo(b []byte, mime string) (string, error) {
ext := vidExtByMime[mime]
if ext == "" {
ext = ".vid"
}
sum := sha256.Sum256(b)
name := hex.EncodeToString(sum[:16]) + ext
dir := chatVidDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return "", err
}
p := filepath.Join(dir, name)
if _, err := os.Stat(p); err != nil {
enc, err := encodeMemContent(b)
if err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
return "", err
}
if err := os.Rename(tmp, p); err != nil {
return "", err
}
}
return vidRefScheme + name, nil
}
// Cache des data-URL reconstituées (même raison que imgCache : un tour d'agent
// renvoie tout l'historique à chaque étape). Borné en taille.
var (
vidCacheMu sync.Mutex
vidCache = map[string]string{}
vidCacheOrder []string
vidCacheBytes int
)
const vidCacheMax = 64 << 20
func chatVideoDataURL(ref string) (string, bool) {
name := strings.TrimPrefix(ref, vidRefScheme)
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
return "", false
}
vidCacheMu.Lock()
if u, ok := vidCache[name]; ok {
vidCacheMu.Unlock()
return u, true
}
vidCacheMu.Unlock()
raw, err := os.ReadFile(filepath.Join(chatVidDir(), name))
if err != nil {
return "", false
}
b, err := decodeMemContent(raw)
if err != nil {
return "", false
}
mime := "video/mp4"
for m, e := range vidExtByMime {
if strings.HasSuffix(name, e) {
mime = m
}
}
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
vidCacheMu.Lock()
if _, ok := vidCache[name]; !ok {
vidCache[name] = u
vidCacheOrder = append(vidCacheOrder, name)
vidCacheBytes += len(u)
for vidCacheBytes > vidCacheMax && len(vidCacheOrder) > 1 {
old := vidCacheOrder[0]
vidCacheOrder = vidCacheOrder[1:]
vidCacheBytes -= len(vidCache[old])
delete(vidCache, old)
}
}
vidCacheMu.Unlock()
return u, true
}
// partVideoURL : l'adresse d'une partie input_video (url, sinon data — les deux
// formes que lit llama-server), "" pour toute autre partie.
func partVideoURL(p map[string]any) string {
if p["type"] != "input_video" {
return ""
}
iv, _ := p["input_video"].(map[string]any)
if iv == nil {
return ""
}
u, _ := iv["url"].(string)
if u == "" {
u, _ = iv["data"].(string)
}
return u
}
// refVideosInMessages remplace les vidéos en base64 des messages par des
// références. Comme refImagesInMessages, les parties modifiées sont RECOPIÉES,
// jamais mutées (persist peut tourner pendant qu'un runChat sérialise ces mêmes
// maps). Renvoie true si quelque chose a changé.
func refVideosInMessages(msgs []Message) bool {
changed := false
for mi, m := range msgs {
parts := contentParts(m.Content)
var np []map[string]any
for pi, p := range parts {
u := partVideoURL(p)
if !strings.HasPrefix(u, "data:video/") {
continue
}
i := strings.Index(u, ";base64,")
if i < 0 {
continue
}
b, err := base64.StdEncoding.DecodeString(u[i+8:])
if err != nil {
continue
}
ref, err := storeChatVideo(b, u[5:i])
if err != nil {
continue
}
if np == nil {
np = append([]map[string]any(nil), parts...)
}
np[pi] = map[string]any{"type": "input_video", "input_video": map[string]any{"url": ref}}
}
if np != nil {
msgs[mi].Content = np
changed = true
}
}
return changed
}
// hasInlineVideos : au moins une vidéo encore en base64 dans ces messages ?
func hasInlineVideos(msgs []Message) bool {
for _, m := range msgs {
for _, p := range contentParts(m.Content) {
if strings.HasPrefix(partVideoURL(p), "data:video/") {
return true
}
}
}
return false
}
// expandVideoRefs renvoie une COPIE des messages prête pour le modèle : chaque
// référence de vidéo redevient sa data-URL. Les messages sans référence sont
// partagés tels quels. Une vidéo introuvable devient une mention textuelle.
func expandVideoRefs(msgs []Message) []Message {
out := msgs
copied := false
for i, m := range msgs {
parts := contentParts(m.Content)
has := false
for _, p := range parts {
if strings.HasPrefix(partVideoURL(p), vidRefScheme) {
has = true
break
}
}
if !has {
continue
}
if !copied {
out = append([]Message(nil), msgs...)
copied = true
}
np := make([]map[string]any, 0, len(parts))
for _, p := range parts {
u := partVideoURL(p)
if !strings.HasPrefix(u, vidRefScheme) {
np = append(np, p)
continue
}
if d, ok := chatVideoDataURL(u); ok {
np = append(np, map[string]any{"type": "input_video", "input_video": map[string]any{"url": d}})
} else {
np = append(np, map[string]any{"type": "text", "text": "[vidéo indisponible : fichier supprimé]"})
}
}
m.Content = np
out[i] = m
}
return out
}
+332
View File
@@ -0,0 +1,332 @@
package loki
import (
"encoding/base64"
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
)
// videoEngine simule un moteur qui sait (ou non) lire la vidéo.
func videoEngine(t *testing.T, ok bool) {
t.Helper()
prev := videoInputSupported
videoInputSupported = func() bool { return ok }
t.Cleanup(func() { videoInputSupported = prev })
}
// engineSees amorce le cache de la sonde /props : aucun llama-server ne tourne
// pendant les tests.
func engineSees(t *testing.T, seen bool) {
t.Helper()
visionProbeMu.Lock()
visionProbeAt, visionProbeSeen = time.Now(), seen
visionProbeMu.Unlock()
t.Cleanup(func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() })
}
// Une vidéo en base64 part sur disque et revient identique à l'envoi au modèle ;
// l'historique ne garde qu'une référence de quelques octets.
func TestVideoRefsRoundTrip(t *testing.T) {
testHome(t)
raw := []byte(strings.Repeat("\x00\x00\x00\x18ftypmp42-fake-video-bytes", 5000))
url := "data:video/mp4;base64," + base64.StdEncoding.EncodeToString(raw)
msgs := []Message{
{Role: "user", Content: []map[string]any{
{"type": "text", "text": "regarde la vidéo"},
{"type": "input_video", "input_video": map[string]any{"url": url}},
}},
{Role: "assistant", Content: "vu"},
}
// Relu depuis le JSON (forme []any), comme après un redémarrage.
var reread []Message
b, _ := json.Marshal(msgs)
_ = json.Unmarshal(b, &reread)
for _, set := range [][]Message{msgs, reread} {
orig := contentParts(set[0].Content)[1]
if !hasInlineVideos(set) || !refVideosInMessages(set) || hasInlineVideos(set) {
t.Fatal("la vidéo aurait dû être rangée par référence")
}
// Recopiée, jamais mutée : un runChat en vol peut lire ces mêmes maps.
if partVideoURL(orig) != url {
t.Fatal("refVideosInMessages a muté la partie d'origine")
}
js, _ := json.Marshal(set)
if len(js) > 500 || !strings.Contains(string(js), vidRefScheme) {
t.Fatalf("historique encore lourd : %d octets", len(js))
}
out := expandVideoRefs(set)
if got := partVideoURL(contentParts(out[0].Content)[1]); got != url {
t.Fatal("la vidéo renvoyée au modèle diffère de l'originale")
}
// L'historique lui-même n'est pas modifié par l'expansion.
if u := partVideoURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, vidRefScheme) {
t.Fatal("expandVideoRefs ne doit pas toucher l'historique")
}
}
// Fichier effacé (ménage) : une mention, pas une requête qui échoue.
_ = os.RemoveAll(chatVidDir())
vidCacheMu.Lock()
vidCache, vidCacheOrder, vidCacheBytes = map[string]string{}, nil, 0
vidCacheMu.Unlock()
out := expandVideoRefs(msgs)
if p := contentParts(out[0].Content)[1]; p["type"] != "text" || !strings.Contains(p["text"].(string), "indisponible") {
t.Fatalf("vidéo disparue : %v", p)
}
}
// userMessageContent : une vidéo jointe devient une partie input_video (et une
// image jointe reste une partie image_url), quand la vision est active et que
// le moteur lit la vidéo.
func TestUserMessageContentVideo(t *testing.T) {
videoEngine(t, true)
visionOn(t)
engineSees(t, true)
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "clip-test.mp4"), []byte("fake-mp4-bytes"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "photo.png"), pngPixel, 0o600); err != nil {
t.Fatal(err)
}
in := []attachInfo{
{Name: "clip-test.mp4", Path: "uploads/clip-test.mp4", Size: 14},
{Name: "photo.png", Path: "uploads/photo.png", Size: int64(len(pngPixel))},
}
parts, ok := userMessageContent(in, "décris ce clip").([]map[string]any)
if !ok || len(parts) != 3 {
t.Fatalf("attendu texte + image + vidéo, obtenu %#v", parts)
}
if parts[0]["type"] != "text" || parts[1]["type"] != "image_url" || parts[2]["type"] != "input_video" {
t.Fatalf("ordre des parties : %v %v %v", parts[0]["type"], parts[1]["type"], parts[2]["type"])
}
if u := partVideoURL(parts[2]); !strings.HasPrefix(u, "data:video/mp4;base64,") {
t.Fatalf("data URI vidéo inattendue : %.40q", u)
}
// Montrée, elle n'est plus annoncée comme simple fichier.
if txt := parts[0]["text"].(string); strings.Contains(txt, "clip-test.mp4") || strings.Contains(txt, "ffmpeg") {
t.Fatalf("vidéo vue annoncée comme fichier : %q", txt)
}
}
// Moteur sans vidéo (API externe, Strata, llama.cpp ancien) : il ignorait la
// partie input_video sans erreur et le modèle ne recevait que le texte. La
// vidéo doit alors être annoncée comme fichier, avec la marche à suivre
// (ffmpeg puis see_image), et see_video doit le dire au lieu de charger.
func TestVideoUnsupportedEngine(t *testing.T) {
videoEngine(t, false)
visionOn(t)
engineSees(t, true)
dir, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
vname := "clip-test.mp4"
if err := os.WriteFile(filepath.Join(dir, vname), []byte("fake-mp4-bytes"), 0o600); err != nil {
t.Fatal(err)
}
content := userMessageContent([]attachInfo{{Name: vname, Path: "uploads/" + vname, Size: 14}}, "décris ce clip")
s, ok := content.(string)
if !ok {
t.Fatalf("attendu du texte seul (pas de partie input_video), obtenu %T", content)
}
if !strings.Contains(s, "uploads/"+vname) || !strings.Contains(s, "ffmpeg") || !strings.HasSuffix(s, "décris ce clip") {
t.Fatalf("chemin et consigne ffmpeg attendus : %q", s)
}
if msg, part := toolSeeVideo(filepath.Join(dir, vname)); part != nil || !strings.Contains(msg, "ffmpeg") {
t.Fatalf("see_video doit refuser avec la consigne ffmpeg : %q", msg)
}
// Ni outil annoncé, ni consigne quand ce n'est pas une vidéo.
for _, tl := range EnabledTools(Caps{Agent: true}) {
if tl.Function.Name == "see_video" {
t.Fatal("see_video annoncé alors que le moteur ne lit pas la vidéo")
}
}
if s := attachNote([]attachInfo{{Name: "a.txt", Path: "uploads/a.txt", Size: 1}}); strings.Contains(s, "ffmpeg") {
t.Fatalf("consigne vidéo sur un fichier texte : %q", s)
}
}
// see_video : annoncé avec see_image quand le moteur lit la vidéo.
func TestSeeVideoAnnonce(t *testing.T) {
videoEngine(t, true)
visionOn(t)
has := func(caps Caps) bool {
for _, tl := range EnabledTools(caps) {
if tl.Function.Name == "see_video" {
return true
}
}
return false
}
if !has(Caps{Agent: true}) {
t.Fatal("see_video absent alors que le moteur lit la vidéo")
}
if has(Caps{}) {
t.Fatal("see_video hors du mode agent")
}
if toolCallLabel("see_video", map[string]any{"file": "a.mp4"}) != "a.mp4" {
t.Fatal("libellé de see_video vide : l'outil s'exécuterait sur une chaîne vide")
}
if dedupableTool("see_video") {
t.Fatal("see_video dédupliqué : le rappel rendrait « déjà fait » sans la vidéo")
}
}
// toolSeeVideo : chaque refus nomme son motif ; un mp4 présent est chargé et
// relayé dans un message utilisateur qui nomme le fichier.
func TestToolSeeVideo(t *testing.T) {
videoEngine(t, true)
home := visionOn(t)
engineSees(t, true)
if err := os.MkdirAll(filepath.Join(home, "dossier.mp4"), 0o755); err != nil {
t.Fatal(err)
}
gros := filepath.Join(home, "gros.mp4")
f, _ := os.Create(gros)
_ = f.Truncate(maxVideoBytes + 1)
f.Close()
_ = os.WriteFile(filepath.Join(home, "vide.webm"), nil, 0o600)
cas := []struct{ path, attendu string }{
{"", "chemin de fichier manquant"},
{filepath.Join(home, "notes.txt"), "format non reconnu comme vidéo"},
{filepath.Join(home, "nulle-part.mp4"), "introuvable"},
{filepath.Join(home, "dossier.mp4"), "dossier"},
{gros, "trop lourde"},
{filepath.Join(home, "vide.webm"), "fichier vide"},
}
for _, c := range cas {
msg, part := toolSeeVideo(c.path)
if part != nil || !strings.HasPrefix(msg, "[erreur]") || !strings.Contains(msg, c.attendu) {
t.Errorf("%q : %q, attendu une erreur contenant %q", c.path, msg, c.attendu)
}
}
p := filepath.Join(home, "clip.MOV")
if err := os.WriteFile(p, []byte("fake-mov"), 0o600); err != nil {
t.Fatal(err)
}
msg, part := toolSeeVideo(p)
if part == nil || msg != "[ok] vidéo chargée : clip.MOV" {
t.Fatalf("attendu un [ok] : %q / %v", msg, part)
}
if u := partVideoURL(part); u != "data:video/quicktime;base64,"+base64.StdEncoding.EncodeToString([]byte("fake-mov")) {
t.Fatalf("partie inattendue : %v", part)
}
// Le moteur en service ne voit pas : on le dit au lieu d'envoyer 200 Mio.
engineSees(t, false)
if msg, part := toolSeeVideo(p); part != nil || !strings.Contains(msg, "ne traite pas les images") {
t.Fatalf("moteur aveugle : %q", msg)
}
// Relais : passé par l'API interne comme see_image (toolStepExtras).
_, _, relay := toolStepExtras("see_video", "clip.MOV", toolExecResult{Result: "[ok]", VisionVideo: videoPart([]byte("x"), "video/mp4")})
if relay == nil {
t.Fatal("aucun relais pour see_video")
}
rp := contentParts(relay.Content)
if relay.Role != "user" || rp[0]["text"] != "Vidéo demandée (clip.MOV) :" || rp[1]["type"] != "input_video" {
t.Fatalf("relais inattendu : %#v", relay)
}
}
// Sans projecteur ou sur un poste distant : refus explicite, avant toute
// question au moteur.
func TestSeeVideoSansVision(t *testing.T) {
videoEngine(t, true)
testHome(t)
if msg, part := toolSeeVideo("a.mp4"); part != nil || !strings.Contains(msg, "MMPROJ") {
t.Fatalf("sans vision : %q", msg)
}
}
// videoInputSupported : jamais pour une API externe, Strata, un preset sans
// projecteur ou sans moteur ; sinon, l'aide du binaire fait foi.
func TestVideoInputSupportedPresets(t *testing.T) {
testHome(t)
cas := []map[string]string{
{"BIN": "llama-server", "MODEL": "m.gguf"}, // pas de projecteur
{"MMPROJ": "mm.gguf", "MODEL": "m.gguf"}, // pas de moteur
{"MMPROJ": "mm.gguf", "BIN": "introuvable/llama-server"}, // moteur absent
{"MMPROJ": "mm.gguf", extKeyFlag: "1", extKeyVision: "1"}, // API externe
{"MMPROJ": "mm.gguf", "ENGINE": engineStrata, "BIN": "/bin/sh", "MODEL": "m"}, // Strata
}
for _, cfg := range cas {
if err := WriteConfig(cfg); err != nil {
t.Fatal(err)
}
if videoInputSupported() {
t.Errorf("%v : vidéo annoncée", cfg)
}
}
}
// L'aide du binaire décide, relue quand le binaire change (mise à jour en place).
func TestBinHelpHasVideo(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("faux moteur en sh")
}
dir := t.TempDir()
bin := filepath.Join(dir, "llama-server")
write := func(help string, mt time.Time) {
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho '"+help+"'\n"), 0o755); err != nil {
t.Fatal(err)
}
_ = os.Chtimes(bin, mt, mt)
}
write("--mmproj FILE", time.Now().Add(-time.Hour))
if binHelpHasVideo(bin) {
t.Fatal("moteur sans --video-fps annoncé comme lisant la vidéo")
}
write("--video-fps N target fps", time.Now())
if !binHelpHasVideo(bin) {
t.Fatal("moteur mis à jour : --video-fps non relu")
}
}
// Compaction, fil rechargé, export : la vidéo n'est jamais rejouée ni
// recopiée, mais sa perte reste visible.
func TestVideoPerdueVisible(t *testing.T) {
part := videoPart([]byte(strings.Repeat("v", 3000)), "video/mp4")
m := Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "regarde"}, part}}
if got := msgText(m); got != "regarde"+videoLostMarker {
t.Fatalf("msgText : %q", got)
}
var reread []Message
b, _ := json.Marshal([]Message{m})
_ = json.Unmarshal(b, &reread)
out := stripImageParts(reread)
if s, ok := out[0].Content.(string); !ok || s != "regarde"+videoLostMarker {
t.Fatalf("fil rechargé : %#v", out[0].Content)
}
exp := stripInlineImages([]Message{m})
js, _ := json.Marshal(exp)
if len(js) > 400 || !strings.Contains(string(js), "élidé") {
t.Fatalf("export : %s", js)
}
if partVideoURL(contentParts(m.Content)[1]) == "" || !strings.HasPrefix(partVideoURL(part), "data:video/mp4;base64,") {
t.Fatal("l'export a touché le message vivant")
}
}
// Le ménage des médias vide aussi les vidéos jointes trop vieilles.
func TestPruneChatVideos(t *testing.T) {
testHome(t)
ref, err := storeChatVideo([]byte("vieille"), "video/webm")
if err != nil || !strings.HasSuffix(ref, ".webm") {
t.Fatalf("rangement : %q %v", ref, err)
}
p := filepath.Join(chatVidDir(), strings.TrimPrefix(ref, vidRefScheme))
old := time.Now().Add(-2 * chatImgKeep)
_ = os.Chtimes(p, old, old)
pruneChatImages()
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatal("vieille vidéo gardée")
}
}
+91
View File
@@ -0,0 +1,91 @@
package loki
// chat_video_tool.go — l'outil see_video (repris d'AJEAN) : le modèle charge
// lui-même un fichier vidéo du disque dans sa VISION, sans que l'utilisateur ait
// à le joindre. Même mécanique que see_image (chat_vision_tool.go) : le résultat
// de l'outil reste un accusé texte, la vidéo part juste après dans un message
// utilisateur multimodal (partie input_video), que llama-server décode en
// images avec ffmpeg. Toujours ÉPHÉMÈRE : jamais dans l'historique.
//
// Annoncé seulement quand le moteur sait lire la vidéo (videoInputSupported) ;
// appelé quand même, il le dit avec la marche à suivre (ffmpeg puis see_image).
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// toolSeeVideo lit un fichier vidéo et renvoie (accusé texte, partie input_video).
// La partie vaut nil en cas d'erreur : l'appelant n'injecte alors rien. Même
// ordre de refus que toolSeeImage, la sonde du moteur en dernier.
func toolSeeVideo(path string) (string, map[string]any) {
if !visionEnabled() {
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo", nil
}
if agentTargetSlug() != "" {
return "[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
}
if path == "" {
return "[erreur] chemin de fichier manquant", nil
}
if !videoInputSupported() {
return "[erreur] " + strings.TrimSuffix(videoFallbackNote, "\n"), nil
}
abs := resolveAgentPath(path)
mime := videoMime(abs)
if mime == "" {
return "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : " + path, nil
}
st, err := os.Stat(abs)
if err != nil {
return "[erreur] fichier introuvable : " + path, nil
}
if st.IsDir() {
return "[erreur] c'est un dossier, pas une vidéo : " + path, nil
}
if st.Size() > maxVideoBytes {
return fmt.Sprintf("[erreur] vidéo trop lourde (%s, max %s) — coupe-la en un clip plus court : %s",
humanBytes(st.Size()), humanBytes(maxVideoBytes), path), nil
}
if !engineSeesImages() {
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
}
b, err := os.ReadFile(abs)
if err != nil {
return "[erreur] lecture impossible : " + err.Error(), nil
}
if len(b) == 0 {
return "[erreur] fichier vide : " + path, nil
}
return "[ok] vidéo chargée : " + filepath.Base(abs), videoPart(b, mime)
}
// seeVideoMessage emballe la partie vidéo dans le message utilisateur qui la
// porte jusqu'au modèle ; la légende nomme le fichier (voir seeImageMessage).
func seeVideoMessage(label string, vid map[string]any) Message {
text := "Vidéo :"
if label != "" {
text = "Vidéo demandée (" + label + ") :"
}
return Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": text},
vid,
}}
}
// seeVideoTool — schéma envoyé au modèle, tenu court comme seeImageTool.
func seeVideoTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "see_video",
Description: "Ouvre un fichier vidéo du disque pour la REGARDER (mp4, webm, mov, mkv, avi). Clips courts.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Chemin de la vidéo (relatif au dossier de travail, ou absolu)"},
},
"required": []string{"file"},
},
}}
}
+1 -1
View File
@@ -301,7 +301,7 @@ func TestFacadeRelaieLesModeles(t *testing.T) {
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
routes := []string{"/api/models/download", "/api/models/download/probe",
"/api/models/download/status", "/api/models/download/cancel", "/api/models",
"/api/models/download/status", "/api/models/download/cancel", "/api/models/download/pause", "/api/models",
"/api/models/delete", "/api/models/dirs", "/api/hf/search", "/api/hf/files", "/api/hf/token"}
for _, p := range routes {
w := httptest.NewRecorder()
+1
View File
@@ -49,4 +49,5 @@ var facadeModelRoutes = map[string]bool{
"/api/models/download/probe": true,
"/api/models/download/status": true,
"/api/models/download/cancel": true,
"/api/models/download/pause": true,
}
+30 -11
View File
@@ -515,6 +515,12 @@ func EnabledTools(caps Caps) []Tool {
// pas — il promettrait alors de regarder, puis se contredirait.
if caps.Agent && visionEnabled() {
tools = append(tools, seeImageTool())
// Voir une vidéo : seulement si le moteur sait la lire (llama.cpp local
// avec --video-fps, chat_video.go). Ailleurs, la vidéo resterait lettre
// morte : Strata et les API externes ignorent la partie sans erreur.
if videoInputSupported() {
tools = append(tools, seeVideoTool())
}
}
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
tools = append(tools, webScreenshotTool())
@@ -670,12 +676,12 @@ func fillToolResult(ev *ToolUsedEvent, result string) *ToolUsedEvent {
// - leur résultat dépend de l'ÉTAT VIVANT de la page (un browser_snapshot sans
// argument a forcément la même clé de dédup à chaque appel, alors que la page
// a changé) — les dédupliquer fige l'IA sur un vieux cliché ;
// - surtout, browser_screenshot et see_image portent leur IMAGE dans un message
// - surtout, browser_screenshot et see_image (see_video : sa vidéo) portent leur IMAGE dans un message
// à part (visionImg) que le chemin de dédup ne rejoue PAS : l'IA recevrait
// « [déjà fait] » SANS l'image et tournerait en boucle.
func dedupableTool(name string) bool {
switch name {
case "bash", "bash_bg", "bash_tail", "see_image":
case "bash", "bash_bg", "bash_tail", "see_image", "see_video":
return false
}
return !strings.HasPrefix(name, "browser_")
@@ -1121,7 +1127,7 @@ func toolCallLabel(name string, args map[string]any) string {
switch name {
case "mem_search", "web_search", "web_images", "recall_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image", "see_video":
label, _ = args["file"].(string)
case "recall":
label, _ = args["id"].(string)
@@ -1221,9 +1227,10 @@ type chatPayloadOpts struct {
func buildChatPayload(ep chatEndpoint, sent []Message, temperature float64, o chatPayloadOpts) map[string]any {
payload := map[string]any{
"model": ep.Model,
// Les images de l'historique y sont rangées par référence
// (chat_images.go) : on remet leurs octets juste avant l'envoi.
"messages": expandImageRefs(sent),
// Les images et vidéos de l'historique y sont rangées par référence
// (chat_images.go, chat_video.go) : on remet leurs octets juste avant
// l'envoi.
"messages": expandVideoRefs(expandImageRefs(sent)),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
@@ -2343,7 +2350,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
var diffAdd, diffDel int // vrais totaux (diff est tronqué pour l'UI)
// visionImg : partie image_url rendue par see_image, réinjectée après
// le résultat de l'outil (un message `tool` ne porte que du texte).
var visionImg map[string]any
var visionImg, visionVideo map[string]any
// Appel rigoureusement identique déjà exécuté dans ce tour : on ne le
// rejoue pas. Les petits modèles réémettent volontiers deux fois la
// même écriture ; la rejouer produisait une fausse erreur (« old
@@ -2375,7 +2382,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
} else {
ex = execTool(ctx, tc.Function.Name, args, label, caps)
}
result, diff, diffAdd, diffDel, visionImg = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg
result, diff, diffAdd, diffDel, visionImg, visionVideo = ex.Result, ex.Diff, ex.Added, ex.Removed, ex.VisionImg, ex.VisionVideo
if ex.Ask != nil {
cb(StreamEvent{Ask: ex.Ask})
}
@@ -2433,6 +2440,13 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
if visionImg != nil {
messages, extra = kt.relay(messages, extra, seeImageMessage(label, visionImg), stepStart)
}
// see_video a réussi : même relais, toujours ÉPHÉMÈRE — dans
// `messages` seulement, jamais dans `extra`, KEEP_TURN_IMAGES
// compris (une vidéo pèse des milliers de jetons visuels, et
// kt.relay ne sait ranger que des images).
if visionVideo != nil {
messages = append(messages, seeVideoMessage(label, visionVideo))
}
}
// Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour :
// une boucle d'outils peut à elle seule remplir la fenêtre (résultats
@@ -2616,7 +2630,10 @@ type toolExecResult struct {
Added int `json:"added,omitempty"`
Removed int `json:"removed,omitempty"`
VisionImg map[string]any `json:"vision_img,omitempty"`
Ask *AskEvent `json:"ask,omitempty"`
// VisionVideo : partie input_video rendue par see_video (chat_video_tool.go),
// relayée au modèle comme l'image, mais toujours éphémère.
VisionVideo map[string]any `json:"vision_video,omitempty"`
Ask *AskEvent `json:"ask,omitempty"`
}
// execToolHook : nil en service. Les tests de parité Go/Rust
@@ -2631,11 +2648,13 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
result := ""
var diff []DiffLine
var diffAdd, diffDel int
var visionImg map[string]any
var visionImg, visionVideo map[string]any
var ask *AskEvent
switch name {
case "see_image":
result, visionImg = toolSeeImage(label)
case "see_video":
result, visionVideo = toolSeeVideo(label)
case "recall":
result = toolRecall(args)
case "recall_search":
@@ -2820,5 +2839,5 @@ func execTool(ctx context.Context, name string, args map[string]any, label strin
result = "[erreur] outil inconnu: " + name
}
}
return toolExecResult{Result: result, Diff: diff, Added: diffAdd, Removed: diffDel, VisionImg: visionImg, Ask: ask}
return toolExecResult{Result: result, Diff: diff, Added: diffAdd, Removed: diffDel, VisionImg: visionImg, VisionVideo: visionVideo, Ask: ask}
}
+31 -16
View File
@@ -249,13 +249,14 @@ func TestPariteStrata(t *testing.T) {
return out
}
srcDir := filepath.Base(strataSrcDir())
src := []parityArchEntry{
{Name: "ajean-moe-1.0/", Type: "dir", Mode: 0o755},
{Name: "ajean-moe-1.0/setup.sh", Type: "file", Body: parityStrataSetup, Mode: 0o755},
{Name: "ajean-moe-1.0/setup.py", Type: "file", Body: "# installeur\n", Mode: 0o644},
{Name: "ajean-moe-1.0/serve/server.py", Type: "file", Body: "# serveur\n", Mode: 0o644},
{Name: srcDir + "/", Type: "dir", Mode: 0o755},
{Name: srcDir + "/setup.sh", Type: "file", Body: parityStrataSetup, Mode: 0o755},
{Name: srcDir + "/setup.py", Type: "file", Body: "# installeur\n", Mode: 0o644},
{Name: srcDir + "/serve/server.py", Type: "file", Body: "# serveur\n", Mode: 0o644},
}
goodRel := parityStrataRel{Src: src, Engine: "moteur strata 1.0"}
goodRel := parityStrataRel{Src: src, Engine: "moteur strata " + strataVersion}
mem47 := "MemTotal: 49283072 kB\nMemFree: 1000000 kB\nMemAvailable: 41943040 kB\n"
mem64 := "MemTotal: 67108864 kB\nMemAvailable: 65011712 kB\n"
mem16 := "MemTotal: 16777216 kB\nMemAvailable: 12582912 kB\n"
@@ -332,7 +333,7 @@ func TestPariteStrata(t *testing.T) {
mu.Unlock()
st.After = map[string]string{}
cfg := ReadConfig()
for _, k := range []string{"ENGINE", "STRATA_CONFIG", "CTX", "STRATA_KV", "STRATA_HELPER"} {
for _, k := range []string{"ENGINE", "STRATA_CONFIG", "CTX", "STRATA_KV", "STRATA_HELPER", "STRATA_SPLIT"} {
if v := cfg[k]; v != "" {
st.After["config/"+k] = mask(v)
}
@@ -381,6 +382,10 @@ func TestPariteStrata(t *testing.T) {
Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
{Name: "réglages du preset actif pendant une optimisation", Tune: true,
Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
{Name: "réglages, répartition inconnue", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, `,"split":"nimporte"`)},
{Name: "réglages, partage des couches", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, `,"split":"layers"`)},
{Name: "réglages d'un ancien client, partage gardé", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
{Name: "réglages, retour à l'aide aux experts", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, `,"split":"helper"`)},
// Installation.
{Name: "installation en GET", Method: "GET", Path: "/api/strata/install"},
@@ -393,42 +398,52 @@ func TestPariteStrata(t *testing.T) {
Body: `{"family":"qwen","quant":"IQ3_S"}`, Wait: true},
{Name: "release introuvable", Meminfo: mem47, Release: &parityStrataRel{Status: 404, Src: src, Engine: "x"},
Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "moteur altéré", Release: &parityStrataRel{Src: src, Engine: "moteur strata 1.0", BadSHA: true},
{Name: "moteur altéré", Release: &parityStrataRel{Src: src, Engine: "moteur strata " + strataVersion, BadSHA: true},
Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "installation", Release: &goodRel, Method: "POST", Path: "/api/strata/install",
Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "après l'installation", Method: "GET", Path: "/api/strata"},
{Name: "mode lancé connu, modèles inactifs", State: map[string]string{"strata_experts_mode": "drop"}, Files: map[string]string{
"presets/STRATA FLASH NEXT CLASSIQUE IQ2_XS.env": "# NAME=Classique IQ2_XS\nENGINE=strata\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" +
"presets/STRATA FLASH NEXT CLASSIQUE IQ2_XS.env": "# NAME=Classique IQ2_XS\nENGINE=strata\nSTRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\n" +
"STRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=-1\nSTRATA_EXPERTS=disk\n",
"presets/STRATA FLASH NEXT CLASSIQUE Q2_0.env": "ENGINE=moe\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" +
"presets/STRATA FLASH NEXT CLASSIQUE Q2_0.env": "ENGINE=moe\nSTRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\n" +
"STRATA_MAIN_GPU=0\nSTRATA_HELPER_GPU=1\nSTRATA_HELPER=0\nSTRATA_VISION=0\nSTRATA_EXPERTS=ram\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "experts.bin et moteur du paquet", Files: map[string]string{
"strata/data/packs/swift-iq3_xxs/experts.bin": "x",
}, State: map[string]string{"strata_experts_mode": ""}, Method: "GET", Path: "/api/strata"},
{Name: "RAM juste sous le seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43164063 kB\n",
{Name: "RAM juste sous le seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 44140625 kB\n",
Method: "GET", Path: "/api/strata"},
{Name: "RAM au-dessus du seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43457032 kB\n",
{Name: "RAM au-dessus du seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 44433594 kB\n",
Method: "GET", Path: "/api/strata"},
{Name: "contexte plafonné en mode hors RAM", Files: map[string]string{
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
"STRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=196608\nREASONING=off\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "seconde carte en partage des couches", Files: map[string]string{
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
"STRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=196608\nREASONING=off\nSTRATA_SPLIT=layers\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "mode hors RAM coupé dans le preset", Files: map[string]string{
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
"STRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
"STRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=0\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=131072\nREASONING=off\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "moteur d'un autre paquet", Meminfo: mem47, Files: map[string]string{
"strata/ajean-moe-1.0/engine-cuda12/.loki-engine": "ajean-moe-engine-0.9-linux-x64-cuda12.zip\n",
"strata/" + srcDir + "/engine-cuda12/.loki-engine": "ajean-moe-engine-0.9-linux-x64-cuda12.zip\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "réinstallation, moteur téléchargé périmé", Files: map[string]string{
"strata/dl/ajean-moe-engine-1.0-linux-x64-cuda12.zip": "ancien moteur",
"strata/dl/" + strataEngineAsset: "ancien moteur",
}, Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "installeur en échec", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"Q2_0"}`, Wait: true},
{Name: "installeur sans configuration", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"IQ2_XS"}`, Wait: true},
{Name: "configuration de lancement disparue", Files: map[string]string{
"strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "-",
"strata/" + srcDir + "/strata-swift-iq3_xxs.json": "-",
}, Method: "GET", Path: "/api/strata"},
{Name: "configuration de lancement illisible", Files: map[string]string{
"strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "[1]",
"strata/" + srcDir + "/strata-swift-iq3_xxs.json": "[1]",
}, Method: "GET", Path: "/api/strata"},
} {
run(st)
+197
View File
@@ -0,0 +1,197 @@
package loki
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// parity_video_tool_test.go — journal de référence de l'outil see_video
// (repris d'AJEAN, tenu par la façade comme see_image, lot 45) : refus dans
// le même ordre que see_image, moteur qui ne lit pas la vidéo (consigne
// ffmpeg), sonde « le moteur voit-il » (faux /props) et vidéo relayée au
// modèle, à l'octet (une vidéo n'est jamais réencodée).
//
// La capacité vidéo du moteur (videoInputSupported : aide du binaire) est
// fixée par chaque étape : la façade la calcule de son côté par le même
// chemin (imagetools::video_input_supported) et la passe à see_video. Rangé
// dans rust/loki-rs/tests/parity_images/video.json ; tests/videotools.rs le
// rejoue contre le port.
//
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteOutilVideo
const parityVideoFile = "../../rust/loki-rs/tests/parity_images/video.json"
type parityVideoStep struct {
Name string `json:"name"`
Vision bool `json:"vision,omitempty"`
Sees bool `json:"sees,omitempty"` // le faux /props annonce la vision
Video bool `json:"video,omitempty"` // le moteur lit la vidéo (--video-fps)
Args map[string]any `json:"args"`
Label string `json:"label"`
Result string `json:"result"`
Part map[string]any `json:"part,omitempty"` // vidéo relayée
Probes int `json:"probes"`
}
func TestPariteOutilVideo(t *testing.T) {
testHome(t)
files := filepath.Join(t.TempDir(), "fichiers")
raw := map[string][]byte{
"clip.mp4": []byte("\x00\x00\x00\x18ftypmp42 fausse vidéo"),
"CLIP.MOV": []byte("\x00\x00\x00\x14ftypqt mov"),
"anim.webm": []byte("\x1aE\xdf\xa3 webm"),
"film.mkv": []byte("\x1aE\xdf\xa3 matroska"),
"vieux.avi": []byte("RIFF\x10\x00\x00\x00AVI LIST"),
"sous/court.mp4": []byte("ftyp sous-dossier"),
"texte.txt": []byte("pas une vidéo"),
"vide.mp4": {},
}
for name, b := range raw {
p := filepath.Join(files, name)
_ = os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
}
_ = os.MkdirAll(filepath.Join(files, "dossier.mp4"), 0o755)
lourd, _ := os.Create(filepath.Join(files, "lourd.mp4"))
_ = lourd.Truncate(maxVideoBytes + 1)
lourd.Close()
setTaskWorkspace(files)
t.Cleanup(func() { setTaskWorkspace("") })
// Faux moteur : /props, authentifié.
var mu sync.Mutex
sees, probes := false, 0
eng := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if r.URL.Path != "/props" || r.Header.Get("Authorization") != "Bearer cle-moteur" {
w.WriteHeader(401)
return
}
probes++
_ = json.NewEncoder(w).Encode(map[string]any{"modalities": map[string]any{"vision": sees}})
}))
defer eng.Close()
_ = SetConfigKey("PORT", strings.TrimPrefix(eng.URL, "http://127.0.0.1:"))
_ = putStr(bkState, "api_key", "cle-moteur")
prevVideo := videoInputSupported
t.Cleanup(func() { videoInputSupported = prevVideo })
var steps []parityVideoStep
run := func(st parityVideoStep) {
if st.Vision {
_ = SetConfigKey("MMPROJ", "vision.gguf")
} else {
_ = SetConfigKey("MMPROJ", "")
}
video := st.Video
videoInputSupported = func() bool { return video }
mu.Lock()
sees, probes = st.Sees, 0
mu.Unlock()
visionProbeMu.Lock()
visionProbeAt = time.Time{}
visionProbeMu.Unlock()
args := map[string]any{}
b, _ := json.Marshal(st.Args)
_ = json.Unmarshal([]byte(strings.ReplaceAll(string(b), "{{FICHIERS}}", filepath.ToSlash(files))), &args)
label := toolCallLabel("see_video", args)
st.Label = strings.ReplaceAll(label, filepath.ToSlash(files), "{{FICHIERS}}")
res := execTool(t.Context(), "see_video", args, label, Caps{Agent: true})
_, _, relay := toolStepExtras("see_video", label, res)
st.Result = strings.ReplaceAll(res.Result, filepath.ToSlash(files), "{{FICHIERS}}")
if relay != nil {
for _, p := range contentParts(relay.Content) {
switch p["type"] {
case "text":
st.Result += "\n[relais] " + strings.ReplaceAll(fmt.Sprint(p["text"]), filepath.ToSlash(files), "{{FICHIERS}}")
case "input_video":
st.Part = parityVideoFacts(t, p, raw)
}
}
}
mu.Lock()
st.Probes = probes
mu.Unlock()
if st.Args == nil {
st.Args = map[string]any{}
}
steps = append(steps, st)
}
a := func(f string) map[string]any { return map[string]any{"file": f} }
run(parityVideoStep{Name: "sans vision", Video: true, Args: a("clip.mp4")})
run(parityVideoStep{Name: "chemin manquant", Vision: true, Video: true, Args: a("")})
run(parityVideoStep{Name: "sans argument", Vision: true, Video: true})
run(parityVideoStep{Name: "moteur sans vidéo", Vision: true, Sees: true, Args: a("clip.mp4")})
run(parityVideoStep{Name: "pas une vidéo", Vision: true, Video: true, Args: a("texte.txt")})
run(parityVideoStep{Name: "image", Vision: true, Video: true, Args: a("photo.png")})
run(parityVideoStep{Name: "introuvable", Vision: true, Video: true, Args: a("absente.mp4")})
run(parityVideoStep{Name: "dossier", Vision: true, Video: true, Args: a("dossier.mp4")})
run(parityVideoStep{Name: "trop lourde", Vision: true, Video: true, Args: a("lourd.mp4")})
run(parityVideoStep{Name: "le moteur ne voit pas", Vision: true, Video: true, Args: a("clip.mp4")})
run(parityVideoStep{Name: "vide", Vision: true, Sees: true, Video: true, Args: a("vide.mp4")})
for _, f := range []string{"clip.mp4", "CLIP.MOV", "anim.webm", "film.mkv", "vieux.avi", "sous/court.mp4"} {
run(parityVideoStep{Name: "voir " + f, Vision: true, Sees: true, Video: true, Args: a(f)})
}
run(parityVideoStep{Name: "chemin absolu", Vision: true, Sees: true, Video: true, Args: a("{{FICHIERS}}/clip.mp4")})
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
if err := enc.Encode(map[string]any{"files": raw, "steps": steps}); err != nil {
t.Fatal(err)
}
got := buf.Bytes()
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
if err := os.MkdirAll(filepath.Dir(parityVideoFile), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(parityVideoFile, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(parityVideoFile)
if err != nil {
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
}
if !bytes.Equal(got, want) {
t.Errorf("see_video ne produit plus le journal rangé dans %s.\n"+
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityVideoFile)
}
}
// parityVideoFacts : le type et le fichier d'origine de la vidéo relayée (à
// l'octet : une vidéo part telle quelle).
func parityVideoFacts(t *testing.T, part map[string]any, raw map[string][]byte) map[string]any {
u := partVideoURL(part)
i := strings.Index(u, ";base64,")
if i < 0 {
t.Fatalf("partie vidéo sans base64 : %.60q", u)
}
b, err := base64.StdEncoding.DecodeString(u[i+8:])
if err != nil {
t.Fatal(err)
}
out := map[string]any{"mime": strings.TrimPrefix(u[:i], "data:")}
for name, r := range raw {
if bytes.Equal(r, b) {
out["same_as"] = name
}
}
return out
}
+40 -7
View File
@@ -3500,6 +3500,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-pause" class="pe-link" onclick="togglePauseDownload()" style="display:none">Mettre en pause</button>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
<div class="pe-row">
@@ -4521,6 +4522,7 @@ async function loadCfg(){
rows.push(row('CTX', d.ctx));
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
rows.push(row('MTP', d.spec));
if(d.helper_gpu) rows.push(row('GPU 2', d.split_layers ? 'couches' : 'aide', d.helper_gpu));
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
}else if(c.CTX) rows.push(row('CTX', c.CTX));
@@ -6753,6 +6755,7 @@ function dlEls(){
prog: document.getElementById('m-hf-progress'),
bar: document.getElementById('m-hf-bar'),
cancel: document.getElementById('m-hf-cancel'),
pause: document.getElementById('m-hf-pause'),
};
}
// Arrête le poll local sans toucher au téléchargement serveur.
@@ -6762,7 +6765,7 @@ function resetDlUI(){
const e = dlEls();
stopDlPoll(); dlName='';
e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false;
e.cancel.style.display='none'; e.pause.style.display='none'; e.btn.disabled=false;
}
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
@@ -6879,6 +6882,22 @@ async function cancelDownload(){
await jpost('/api/models/download/cancel', {filename: dlName});
// L'état « annulé » est confirmé par le prochain tick de poll.
}
// Met en pause ou reprend : le fichier partiel est gardé, la reprise repart
// de là où le transfert s'était arrêté (repris d'AJEAN v0.18.1).
let dlPaused = false;
async function togglePauseDownload(){
if(!dlName) return;
const e = dlEls();
e.pause.disabled = true;
try{
const r = await jpost('/api/models/download/pause', {filename: dlName, pause: !dlPaused});
if(r.ok) setDlPaused(r.paused);
}finally{ e.pause.disabled = false; }
}
function setDlPaused(p){
dlPaused = !!p;
dlEls().pause.textContent = dlPaused ? 'Reprendre' : 'Mettre en pause';
}
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
function watchDownload(fname){
const e = dlEls();
@@ -6889,7 +6908,10 @@ function watchDownload(fname){
e.bar.style.display = 'block';
e.cancel.style.display = 'inline';
e.cancel.disabled = false;
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
e.pause.style.display = 'inline';
e.pause.disabled = false;
setDlPaused(false);
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; e.pause.style.display='none'; };
const tick = async ()=>{
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>d.filename===fname);
@@ -6913,6 +6935,7 @@ function watchDownload(fname){
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
if(!!st.paused !== dlPaused) setDlPaused(st.paused);
const pct = st.total>0 ? st.done*100/st.total : 0;
if(st.total>0){
e.bar.className = 'pe-bar';
@@ -6922,7 +6945,8 @@ function watchDownload(fname){
}
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
let extra = '';
if(st.speed>0){
if(st.paused) extra += ' — en pause';
else if(st.speed>0){
extra += ' — '+fmtSize(st.speed)+'/s';
if(st.total>0){
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
@@ -9281,7 +9305,11 @@ function strataRenderSettings(fit){
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
['Lire les images', sw('strata-s-vision', d.vision)],
];
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
// Seconde carte : aide aux experts (défaut mesuré) ou partage des couches,
// souvent plus rapide avec deux cartes identiques (#121 d'AJEAN).
if(d.has_helper) rows.push(['Seconde carte', sel('strata-s-helper',
[['off', 'non utilisée'], ['helper', 'aide aux experts'], ['layers', 'partage des couches']],
!d.helper_on ? 'off' : d.split_layers ? 'layers' : 'helper')]);
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
strataCtxLbl();
@@ -9301,8 +9329,11 @@ async function strataSaveSettings(){
if(!fit || !fit.preset) return;
const v = id => document.getElementById(id);
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
vision: v('strata-s-vision').checked,
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
const second = v('strata-s-helper') ? v('strata-s-helper').value : 'off';
body.helper = second !== 'off';
if(body.helper) body.split = second;
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
const r = await jpost('/api/strata/settings', body);
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
@@ -9321,14 +9352,16 @@ function strataRenderDetails(fit){
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
const rows = [
['Carte principale', d.main_gpu],
["Carte d'appoint", d.helper_gpu || 'aucune'],
['Seconde carte', d.helper_gpu ? d.helper_gpu + (d.split_layers ? ' · partage des couches' : ' · aide aux experts') : 'aucune'],
['Lecture des images', d.vision_gpu || 'aucune'],
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
['Prédiction (MTP)', d.spec+' jetons'],
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
// paquet 1.1 : la carte d'appoint calcule ses experts à la lecture d'un prompt
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'
+ (d.helper_gpu && !d.split_layers ? ", experts de la carte d'appoint calculés par elle" : '')],
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
['Version du moteur', 'paquet AJEAN MoE '+d.version],
];
+1
View File
@@ -1215,6 +1215,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-pause" class="pe-link" onclick="togglePauseDownload()" style="display:none">Mettre en pause</button>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
<div class="pe-row">
+1
View File
@@ -330,6 +330,7 @@ async function loadCfg(){
rows.push(row('CTX', d.ctx));
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
rows.push(row('MTP', d.spec));
if(d.helper_gpu) rows.push(row('GPU 2', d.split_layers ? 'couches' : 'aide', d.helper_gpu));
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
}else if(c.CTX) rows.push(row('CTX', c.CTX));
+25 -3
View File
@@ -1501,6 +1501,7 @@ function dlEls(){
prog: document.getElementById('m-hf-progress'),
bar: document.getElementById('m-hf-bar'),
cancel: document.getElementById('m-hf-cancel'),
pause: document.getElementById('m-hf-pause'),
};
}
// Arrête le poll local sans toucher au téléchargement serveur.
@@ -1510,7 +1511,7 @@ function resetDlUI(){
const e = dlEls();
stopDlPoll(); dlName='';
e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false;
e.cancel.style.display='none'; e.pause.style.display='none'; e.btn.disabled=false;
}
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
@@ -1627,6 +1628,22 @@ async function cancelDownload(){
await jpost('/api/models/download/cancel', {filename: dlName});
// L'état « annulé » est confirmé par le prochain tick de poll.
}
// Met en pause ou reprend : le fichier partiel est gardé, la reprise repart
// de là où le transfert s'était arrêté (repris d'AJEAN v0.18.1).
let dlPaused = false;
async function togglePauseDownload(){
if(!dlName) return;
const e = dlEls();
e.pause.disabled = true;
try{
const r = await jpost('/api/models/download/pause', {filename: dlName, pause: !dlPaused});
if(r.ok) setDlPaused(r.paused);
}finally{ e.pause.disabled = false; }
}
function setDlPaused(p){
dlPaused = !!p;
dlEls().pause.textContent = dlPaused ? 'Reprendre' : 'Mettre en pause';
}
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
function watchDownload(fname){
const e = dlEls();
@@ -1637,7 +1654,10 @@ function watchDownload(fname){
e.bar.style.display = 'block';
e.cancel.style.display = 'inline';
e.cancel.disabled = false;
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
e.pause.style.display = 'inline';
e.pause.disabled = false;
setDlPaused(false);
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; e.pause.style.display='none'; };
const tick = async ()=>{
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>d.filename===fname);
@@ -1661,6 +1681,7 @@ function watchDownload(fname){
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
if(!!st.paused !== dlPaused) setDlPaused(st.paused);
const pct = st.total>0 ? st.done*100/st.total : 0;
if(st.total>0){
e.bar.className = 'pe-bar';
@@ -1670,7 +1691,8 @@ function watchDownload(fname){
}
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
let extra = '';
if(st.speed>0){
if(st.paused) extra += ' — en pause';
else if(st.speed>0){
extra += ' — '+fmtSize(st.speed)+'/s';
if(st.total>0){
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
+13 -4
View File
@@ -128,7 +128,11 @@ function strataRenderSettings(fit){
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
['Lire les images', sw('strata-s-vision', d.vision)],
];
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
// Seconde carte : aide aux experts (défaut mesuré) ou partage des couches,
// souvent plus rapide avec deux cartes identiques (#121 d'AJEAN).
if(d.has_helper) rows.push(['Seconde carte', sel('strata-s-helper',
[['off', 'non utilisée'], ['helper', 'aide aux experts'], ['layers', 'partage des couches']],
!d.helper_on ? 'off' : d.split_layers ? 'layers' : 'helper')]);
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
strataCtxLbl();
@@ -148,8 +152,11 @@ async function strataSaveSettings(){
if(!fit || !fit.preset) return;
const v = id => document.getElementById(id);
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
vision: v('strata-s-vision').checked,
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
const second = v('strata-s-helper') ? v('strata-s-helper').value : 'off';
body.helper = second !== 'off';
if(body.helper) body.split = second;
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
const r = await jpost('/api/strata/settings', body);
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
@@ -168,14 +175,16 @@ function strataRenderDetails(fit){
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
const rows = [
['Carte principale', d.main_gpu],
["Carte d'appoint", d.helper_gpu || 'aucune'],
['Seconde carte', d.helper_gpu ? d.helper_gpu + (d.split_layers ? ' · partage des couches' : ' · aide aux experts') : 'aucune'],
['Lecture des images', d.vision_gpu || 'aucune'],
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
['Prédiction (MTP)', d.spec+' jetons'],
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
// paquet 1.1 : la carte d'appoint calcule ses experts à la lecture d'un prompt
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'
+ (d.helper_gpu && !d.split_layers ? ", experts de la carte d'appoint calculés par elle" : '')],
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
['Version du moteur', 'paquet AJEAN MoE '+d.version],
];
+4
View File
@@ -84,5 +84,9 @@ func toolStepExtras(name, label string, res toolExecResult) (image, shot string,
m := seeImageMessage(label, res.VisionImg)
relay = &m
}
if res.VisionVideo != nil {
m := seeVideoMessage(label, res.VisionVideo)
relay = &m
}
return image, shot, relay
}
+1
View File
@@ -259,6 +259,7 @@ func newWebMux() *http.ServeMux {
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
api("/api/models/download/status", handleModelDownloadStatus)
api("/api/models/download/cancel", handleModelDownloadCancel)
api("/api/models/download/pause", handleModelDownloadPause)
api("/api/hf/search", handleHFSearch) // chercher un modèle GGUF sur Hugging Face
api("/api/hf/files", handleHFFiles) // quants et projecteurs d'un dépôt
api("/api/hf/token", handleHFToken) // jeton pour les dépôts à accès restreint
+42 -19
View File
@@ -152,10 +152,19 @@ func attachNote(files []attachInfo) string {
head = "Fichiers joints à ce message, déposés dans ton dossier de travail :"
}
var lines []string
video := false
for _, f := range files {
lines = append(lines, fmt.Sprintf("- %s (%s)", f.Path, humanBytes(f.Size)))
video = video || videoMime(f.Name) != ""
}
return head + "\n" + strings.Join(lines, "\n") + "\n\n"
note := head + "\n" + strings.Join(lines, "\n") + "\n"
// Vidéo jointe que le moteur ne sait pas lire (chat_video.go) : dire comment
// la regarder quand même, au lieu de laisser le modèle tâtonner — Strata et
// les API externes ignoraient la vidéo en silence.
if video && visionEnabled() && !videoInputSupported() {
note += videoFallbackNote
}
return note + "\n"
}
// imageMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme image (contenu
@@ -189,9 +198,11 @@ func visionEnabled() bool {
// userMessageContent construit le champ Content du message utilisateur. Cas
// courant : une simple chaîne (note des fichiers joints + texte). Quand la vision
// est active ET qu'au moins une pièce jointe est une image, on renvoie le format
// multimodal d'OpenAI — une partie `text` suivie d'une partie `image_url` par
// image (data URI base64) — que llama-server comprend une fois --mmproj chargé :
// est active ET qu'au moins une pièce jointe est une image (ou une vidéo que le
// moteur sait lire), on renvoie le format multimodal d'OpenAI — une partie
// `text` suivie d'une partie `image_url` par image (data URI base64) et d'une
// partie `input_video` par vidéo — que llama-server comprend une fois --mmproj
// chargé :
// le modèle VOIT alors l'image au lieu de devoir l'ouvrir comme un fichier binaire.
// Les images ainsi intégrées sortent de la note textuelle (inutile de dire au
// modèle d'aller ouvrir un fichier qu'il a déjà sous les yeux) ; les autres
@@ -209,31 +220,43 @@ func userMessageContent(files []attachInfo, prompt string) any {
if err != nil {
return attachNote(files) + prompt
}
var imgParts []map[string]any
var imgParts, vidParts []map[string]any
var textFiles []attachInfo
for _, f := range files {
mime := imageMime(f.Name)
if mime == "" {
textFiles = append(textFiles, f)
if mime := imageMime(f.Name); mime != "" {
b, err := os.ReadFile(filepath.Join(dir, f.Name))
if err != nil {
textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier
continue
}
// Photo prise au téléphone : l'orientation vit dans un tag EXIF que le
// projecteur ignore (il voit les pixels bruts, donc l'image couchée), et
// une image de plusieurs milliers de pixels ne fait que gonfler le base64
// — voir prepareImageForModel (web_upload_orient.go).
b, mime = prepareImageForModel(b, mime)
imgParts = append(imgParts, imageURLPart(b, mime))
continue
}
b, err := os.ReadFile(filepath.Join(dir, f.Name))
if err != nil {
textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier
// Vidéo : seulement si le moteur sait la lire (chat_video.go) ; sinon
// elle reste un fichier, annoncé avec la marche à suivre (attachNote).
// Pas de redimensionnement : le moteur échantillonne les images lui-même.
if mime := videoMime(f.Name); mime != "" && videoInputSupported() {
b, err := os.ReadFile(filepath.Join(dir, f.Name))
if err != nil || len(b) == 0 || len(b) > maxVideoBytes {
textFiles = append(textFiles, f)
continue
}
vidParts = append(vidParts, videoPart(b, mime))
continue
}
// Photo prise au téléphone : l'orientation vit dans un tag EXIF que le
// projecteur ignore (il voit les pixels bruts, donc l'image couchée), et
// une image de plusieurs milliers de pixels ne fait que gonfler le base64
// — voir prepareImageForModel (web_upload_orient.go).
b, mime = prepareImageForModel(b, mime)
imgParts = append(imgParts, imageURLPart(b, mime))
textFiles = append(textFiles, f)
}
if len(imgParts) == 0 {
if len(imgParts) == 0 && len(vidParts) == 0 {
return attachNote(files) + prompt
}
parts := []map[string]any{{"type": "text", "text": attachNote(textFiles) + prompt}}
return append(parts, imgParts...)
parts = append(parts, imgParts...)
return append(parts, vidParts...)
}
// workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et,
+4
View File
@@ -58,6 +58,9 @@ pub const COMPACT_SUMMARY_PREFIX: &str = "[CONTEXT COMPACTED]";
/// Remplace la partie `image_url` d'un message multimodal dans [`msg_text`].
pub const IMAGE_LOST_MARKER: &str = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]";
/// `videoLostMarker` : remplace la partie `input_video` (chat_video.go).
pub const VIDEO_LOST_MARKER: &str = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]";
/// La réponse assistant synthétique qui suit le résumé.
pub const COMPACT_ACK_MSG: &str = "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done.";
@@ -265,6 +268,7 @@ pub fn msg_text(m: &Message) -> String {
}
}
Some("image_url") => b.push_str(IMAGE_LOST_MARKER),
Some("input_video") => b.push_str(VIDEO_LOST_MARKER),
_ => {}
}
}
+101 -16
View File
@@ -14,8 +14,9 @@
//! - compaction par [`Compactor`] : résumé demandé au moteur
//! ([`EngineSummarizer`]), blocs archivés pour recall par le Go
//! ([`StoreRecall`]) ;
//! - images jointes (`loki-img:…`) lues sur disque et ouvertes par le Go
//! (déchiffrées), gardées en cache comme `chatImageDataURL` ;
//! - images et vidéos jointes (`loki-img:…`, `loki-vid:…`) lues sur disque
//! et ouvertes par le Go (déchiffrées), gardées en cache comme
//! `chatImageDataURL` / `chatVideoDataURL` ;
//! - slots du moteur (lot 31) : avant chaque requête au moteur local, le Go
//! rend le slot où l'envoyer (SIDE_SLOT) et recharge au besoin l'état
//! gardé de la discussion (SLOT_PERSIST) ; la requête est comptée en vol,
@@ -46,8 +47,8 @@ use super::stream::{CacheHintFn, PreflightFn, RetryPatterns};
use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost};
use super::types::{AskEvent, DiffLine, Message, Tool};
use super::wire::{
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME,
build_chat_payload, content_parts, part_image_url, wire_messages,
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME, VID_REF_SCHEME,
build_chat_payload, content_parts, part_image_url, part_video_url, wire_messages,
};
use crate::store::{BK_STATE, Store};
@@ -97,10 +98,32 @@ fn image_mime(name: &str) -> &'static str {
/// Nom d'une image rangée, si la référence est sûre (ni chemin ni `..`).
fn image_name(r: &str) -> Option<&str> {
let name = r.strip_prefix(IMG_REF_SCHEME)?;
safe_media_name(r.strip_prefix(IMG_REF_SCHEME)?)
}
fn safe_media_name(name: &str) -> Option<&str> {
(!name.is_empty() && !name.contains(['/', '\\']) && !name.contains("..")).then_some(name)
}
/// Média rangé : (clé du cache, fichier relatif à `LOKI_HOME`, type MIME).
/// Une image a pour clé son nom (dossier `chatimg`), une vidéo jointe
/// (`loki-vid:`, chat_video.go) sa référence entière (dossier `chatvid`).
fn media_ref(r: &str) -> Option<(String, PathBuf, &'static str)> {
if let Some(name) = image_name(r) {
return Some((
name.to_string(),
Path::new("chatimg").join(name),
image_mime(name),
));
}
let name = safe_media_name(r.strip_prefix(VID_REF_SCHEME)?)?;
let mime = match crate::imagetools::video_mime(name) {
"" => "video/mp4",
m => m,
};
Some((r.to_string(), Path::new("chatvid").join(name), mime))
}
#[derive(Default)]
struct ImgCacheInner {
map: HashMap<String, String>,
@@ -129,9 +152,10 @@ impl ImageCache {
}
}
/// Data-URL d'une image (`loki-img:`) ou d'une vidéo (`loki-vid:`) rangée.
pub fn get(&self, r: &str) -> Option<String> {
let name = image_name(r)?;
self.inner.lock().ok()?.map.get(name).cloned()
let (key, _, _) = media_ref(r)?;
self.inner.lock().ok()?.map.get(&key).cloned()
}
fn put(&self, name: &str, url: String) {
@@ -151,27 +175,30 @@ impl ImageCache {
}
}
/// Lit et ouvre (par le Go) les images référencées pas encore en cache.
/// Une image illisible reste absente : la requête dira « indisponible ».
/// Lit et ouvre (par le Go) les images et vidéos référencées pas encore
/// en cache. Un média illisible reste absent : la requête dira
/// « indisponible ».
pub async fn prefetch(&self, store: &Store, msgs: &[Message]) {
let home = self.home();
for m in msgs {
for p in content_parts(m.content.as_ref()) {
let Some(name) = image_name(part_image_url(p)) else {
let u = match part_image_url(p) {
"" => part_video_url(p),
u => u,
};
let Some((key, rel, mime)) = media_ref(u) else {
continue;
};
if self.inner.lock().is_ok_and(|g| g.map.contains_key(name)) {
if self.inner.lock().is_ok_and(|g| g.map.contains_key(&key)) {
continue;
}
let Ok(raw) = tokio::fs::read(self.dir.join(name)).await else {
let Ok(raw) = tokio::fs::read(home.join(rel)).await else {
continue;
};
let Ok(b) = store.open(raw).await else {
continue;
};
self.put(
name,
format!("data:{};base64,{}", image_mime(name), b64(&b)),
);
self.put(&key, format!("data:{mime};base64,{}", b64(&b)));
}
}
}
@@ -586,6 +613,11 @@ pub fn tool_outcome(v: &Value, label: &str) -> ToolOutcome {
{
relay = Some(see_image_message(label, img.clone()));
}
if relay.is_none()
&& let Some(vid) = v.get("vision_video").filter(|i| i.is_object())
{
relay = Some(see_video_message(label, vid.clone()));
}
ToolOutcome {
result: s("result"),
diff: v
@@ -617,6 +649,20 @@ pub fn see_image_message(label: &str, img: Value) -> Message {
}
}
/// `seeVideoMessage` : la vidéo relayée au modèle.
pub fn see_video_message(label: &str, vid: Value) -> Message {
let text = if label.is_empty() {
"Vidéo :".to_string()
} else {
format!("Vidéo demandée ({label}) :")
};
Message {
role: "user".into(),
content: Some(json!([{"type": "text", "text": text}, vid])),
..Default::default()
}
}
impl GoTurnHost {
/// Les outils exécutés par la façade, sauf `subagent` ; le reste par le
/// Go (`/tool/exec`). Aussi celui des tours d'un sous-agent.
@@ -751,6 +797,45 @@ impl GoTurnHost {
};
}
}
// see_video (chat_video_tool.go) : comme see_image, dans le dossier de
// la discussion ; la vidéo relayée au modèle, toujours éphémère.
if name == "see_video" {
let cfg = self.shared.store.config().await;
let vision = crate::system::vision_enabled(&cfg);
let st = crate::tools::GoToolState {
store: self.shared.store.clone(),
caps: self.caps.clone(),
};
match crate::tools::ToolState::context(&st).await {
Ok(ctx) => {
let home = self.shared.images.home();
let vcfg = cfg.clone();
let video_ok = tokio::task::spawn_blocking(move || {
crate::imagetools::video_input_supported(&vcfg, &home)
})
.await
.unwrap_or(false);
let sees = self.shared.vision.sees(&self.shared.store, &cfg);
let (result, vid) = crate::imagetools::see_video(
label,
&ctx.cwd,
&ctx.target,
vision,
video_ok,
sees,
)
.await;
return ToolOutcome {
result,
relay: vid.map(|v| see_video_message(label, v)),
..Default::default()
};
}
Err(e) => {
eprintln!("[outils] contexte indisponible, see_video par le Go : {e}")
}
}
}
// Navigateur piloté (lot 44) : la session est ici ; aperçu de la page
// après une action qui la change (jamais après une erreur), capture
// relayée au modèle.
+6 -3
View File
@@ -141,10 +141,13 @@ pub fn steer_system(msgs: &[Message], hint: &str) -> Vec<Message> {
}
/// `dedupableTool` : un appel identique se court-circuite, sauf bash et ses
/// jobs (effets de bord), see_image et le navigateur (état vivant, image à
/// part).
/// jobs (effets de bord), see_image, see_video et le navigateur (état
/// vivant, image ou vidéo à part).
pub fn dedupable_tool(name: &str) -> bool {
!matches!(name, "bash" | "bash_bg" | "bash_tail" | "see_image") && !name.starts_with("browser_")
!matches!(
name,
"bash" | "bash_bg" | "bash_tail" | "see_image" | "see_video"
) && !name.starts_with("browser_")
}
/// `repeatedCallResult` : réponse à un appel RIGOUREUSEMENT identique déjà
+22 -7
View File
@@ -49,7 +49,7 @@ use serde::{Deserialize, Serialize, Serializer};
use serde_json::Value;
use tokio::sync::watch;
use super::compact::{IMAGE_LOST_MARKER, is_loki_injected, without_ctx_update};
use super::compact::{IMAGE_LOST_MARKER, VIDEO_LOST_MARKER, is_loki_injected, without_ctx_update};
use super::journal::compact_log;
use super::types::{LogEvent, Message};
use crate::store::{CryptError, Store};
@@ -269,24 +269,39 @@ fn decode_list<T: DeserializeOwned>(v: Value) -> Vec<T> {
/// `stripImageParts` : un message multimodal persisté (base64 d'une capture
/// d'avant les captures éphémères) est aplati en son texte suivi de
/// [`IMAGE_LOST_MARKER`] — rejoué à chaque tour, il dépassait le contexte.
/// Une vidéo jointe (`input_video`) a le même sort, marquée par
/// [`VIDEO_LOST_MARKER`].
pub fn strip_image_parts(msgs: &mut [Message]) {
for m in msgs.iter_mut() {
let Some(Value::Array(parts)) = &m.content else {
continue;
};
let mut texts: Vec<&str> = Vec::new();
let mut dropped = false;
let (mut dropped, mut dropped_vid) = (false, false);
for p in parts.iter().filter_map(Value::as_object) {
if p.get("type").and_then(Value::as_str) == Some("image_url") {
dropped = true;
continue;
match p.get("type").and_then(Value::as_str) {
Some("image_url") => {
dropped = true;
continue;
}
Some("input_video") => {
dropped_vid = true;
continue;
}
_ => {}
}
if let Some(Value::String(t)) = p.get("text") {
texts.push(t);
}
}
if dropped {
let text = texts.join("\n") + IMAGE_LOST_MARKER;
if dropped || dropped_vid {
let mut text = texts.join("\n");
if dropped {
text += IMAGE_LOST_MARKER;
}
if dropped_vid {
text += VIDEO_LOST_MARKER;
}
m.content = Some(Value::String(text));
}
}
+1 -1
View File
@@ -621,7 +621,7 @@ pub fn tool_call_label(name: &str, args: &Map<String, Value>) -> String {
match name {
"mem_search" | "web_search" | "web_images" | "recall_search" => arg_str(args, "query"),
"mem_read" | "mem_add" | "mem_edit" | "edit" | "write" | "read" | "git_diff"
| "see_image" => arg_str(args, "file"),
| "see_image" | "see_video" => arg_str(args, "file"),
"recall" | "bash_tail" => arg_str(args, "id"),
"tracker" => (go_str(args.get("action")) + " " + &go_str(args.get("name")))
.trim()
+11 -1
View File
@@ -384,6 +384,13 @@ pub fn side_slot_pool_error(msg: &str) -> bool {
&& !REQ_EXCEEDS.is_match(msg)
}
/// Le relais porte-t-il une vidéo (see_video, partie `input_video`) ?
fn is_video_relay(m: &Message) -> bool {
super::wire::content_parts(m.content.as_ref())
.iter()
.any(|p| p.get("type").and_then(Value::as_str) == Some("input_video"))
}
fn text_msg(role: &str, text: impl Into<String>) -> Message {
Message {
role: role.into(),
@@ -1071,8 +1078,11 @@ async fn run_turn_loop<H: TurnHost>(
push_result(&mut messages, &mut extra, cap_tool_result(&ex.result));
if let Some(img) = ex.relay {
match kt.as_mut() {
// Éphémère : jamais dans l'historique.
// Éphémère : jamais dans l'historique. Une vidéo
// (see_video) l'est toujours, KEEP_TURN_IMAGES compris :
// le rangement ne sait garder que des images.
None => messages.push(img),
Some(_) if is_video_relay(&img) => messages.push(img),
// KEEP_TURN_IMAGES : gardée par référence, à l'essai
// jusqu'à la mesure de son coût.
Some(k) => {
+89 -14
View File
@@ -60,6 +60,10 @@ pub const TOOLS_OFF_HINT: &str = "Do not call any more tools. Answer now, direct
/// `imgRefScheme` : adresse d'une image rangée sur disque (chat_images.go).
pub const IMG_REF_SCHEME: &str = "loki-img:";
/// `vidRefScheme` : adresse d'une vidéo jointe rangée sur disque
/// (chat_video.go).
pub const VID_REF_SCHEME: &str = "loki-vid:";
/// Préfixes des messages système propres au projet ou à la conversation
/// (`isProjectSystem`).
pub const PROJECT_CONTEXT_PREFIX: &str = "Project context";
@@ -308,35 +312,61 @@ pub fn part_image_url(p: &Map<String, Value>) -> &str {
.unwrap_or("")
}
/// `expandImageRefs` : COPIE des messages prête pour le modèle — chaque
/// référence `loki-img:` redevient sa data-URL (`image_data_url`, qui tient
/// lieu de `chatImageDataURL` : disque, cache, déchiffrement). Les messages
/// sans référence sont repris tels quels ; aucune référence : pas de copie.
/// Une image introuvable devient une mention textuelle.
/// `partVideoURL` : l'adresse d'une partie `input_video` (`url`, sinon
/// `data`), "" pour toute autre partie (chat_video.go).
pub fn part_video_url(p: &Map<String, Value>) -> &str {
if p.get("type").and_then(Value::as_str) != Some("input_video") {
return "";
}
let Some(iv) = p.get("input_video").and_then(Value::as_object) else {
return "";
};
match iv.get("url").and_then(Value::as_str).unwrap_or("") {
"" => iv.get("data").and_then(Value::as_str).unwrap_or(""),
u => u,
}
}
/// `expandVideoRefs(expandImageRefs(…))` : COPIE des messages prête pour le
/// modèle — chaque référence `loki-img:` ou `loki-vid:` redevient sa
/// data-URL (`image_data_url`, qui tient lieu de `chatImageDataURL` et
/// `chatVideoDataURL` : disque, cache, déchiffrement). Les messages sans
/// référence sont repris tels quels ; aucune référence : pas de copie. Un
/// média introuvable devient une mention textuelle.
pub fn expand_image_refs<'a>(
msgs: &'a [Message],
image_data_url: &dyn Fn(&str) -> Option<String>,
) -> Cow<'a, [Message]> {
let is_ref = |p: &Map<String, Value>| {
part_image_url(p).starts_with(IMG_REF_SCHEME)
|| part_video_url(p).starts_with(VID_REF_SCHEME)
};
let mut out: Option<Vec<Message>> = None;
for (i, m) in msgs.iter().enumerate() {
let parts = content_parts(m.content.as_ref());
if !parts
.iter()
.any(|p| part_image_url(p).starts_with(IMG_REF_SCHEME))
{
if !parts.iter().any(|p| is_ref(p)) {
continue;
}
let mut np: Vec<Value> = Vec::with_capacity(parts.len());
for p in parts {
let u = part_image_url(p);
if !u.starts_with(IMG_REF_SCHEME) {
if !is_ref(p) {
np.push(Value::Object(p.clone()));
continue;
}
match image_data_url(u) {
Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})),
let u = part_image_url(p);
if u.starts_with(IMG_REF_SCHEME) {
match image_data_url(u) {
Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})),
None => np.push(
json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}),
),
}
continue;
}
match image_data_url(part_video_url(p)) {
Some(d) => np.push(json!({"type": "input_video", "input_video": {"url": d}})),
None => np.push(
json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}),
json!({"type": "text", "text": "[vidéo indisponible : fichier supprimé]"}),
),
}
}
@@ -1807,6 +1837,51 @@ mod tests {
));
}
// --- chat_video_test.go (partie envoi) ----------------------------------
#[test]
fn video_refs_round_trip() {
let url = "data:video/mp4;base64,AAAAGGZ0eXA=";
let reference = format!("{VID_REF_SCHEME}0123abcd.mp4");
let img_ref = format!("{IMG_REF_SCHEME}ab.png");
let set = vec![Message {
role: "user".into(),
content: Some(json!([
{"type": "text", "text": "regarde"},
{"type": "image_url", "image_url": {"url": img_ref}},
{"type": "input_video", "input_video": {"url": reference}},
{"type": "input_video", "input_video": {"data": "data:video/webm;base64,AA=="}}
])),
..Default::default()
}];
let resolver = |r: &str| {
if r == reference {
Some(url.to_string())
} else if r == img_ref {
Some("data:image/png;base64,AA==".to_string())
} else {
None
}
};
let out = expand_image_refs(&set, &resolver);
let parts = content_parts(out[0].content.as_ref());
assert_eq!(part_image_url(parts[1]), "data:image/png;base64,AA==");
assert_eq!(part_video_url(parts[2]), url);
assert_eq!(parts[2]["type"], "input_video");
// Vidéo déjà en ligne (champ data) : reprise telle quelle.
assert_eq!(part_video_url(parts[3]), "data:video/webm;base64,AA==");
let first = content_parts(set[0].content.as_ref());
assert!(
part_video_url(first[2]).starts_with(VID_REF_SCHEME),
"l'historique n'est pas touché"
);
let out = expand_image_refs(&set, &|_: &str| None);
assert_eq!(
content_parts(out[0].content.as_ref())[2]["text"],
json!("[vidéo indisponible : fichier supprimé]")
);
}
// --- chat_compact_cont_test.go ------------------------------------------
#[test]
+14
View File
@@ -194,6 +194,20 @@ pub fn strip_inline_images(msgs: Vec<Message>) -> Vec<Message> {
let e = elide_data_uri(url);
iu.insert("url".into(), Value::String(e));
}
// Vidéo jointe (input_video, chat_video.go) : même
// élision, sur ses deux champs.
if let Value::Object(part) = p
&& let Some(Value::Object(iv)) = part.get_mut("input_video")
{
for key in ["url", "data"] {
if let Some(Value::String(u)) = iv.get(key)
&& u.starts_with("data:")
{
let e = elide_data_uri(u);
iv.insert(key.into(), Value::String(e));
}
}
}
}
}
m
+117 -10
View File
@@ -3,7 +3,7 @@
//! `backend_model_dirs.go` et `backend_hf.go` (lot 8 de la migration).
//!
//! Tenus par la façade quand `LOKI_MODELS=rust` (expérimental ; le Go
//! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`,
//! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`, `/pause`,
//! `GET /api/models/download/status`. Le Go remet alors à la façade ces
//! routes quand elles lui arrivent par le tunnel du relais, et ne vide plus
//! les `.part` au démarrage (c'est la façade qui le fait).
@@ -71,6 +71,7 @@ pub fn handles(path: &str) -> bool {
| "/api/models/download/probe"
| "/api/models/download/status"
| "/api/models/download/cancel"
| "/api/models/download/pause"
)
}
@@ -519,6 +520,7 @@ pub struct DlState {
pub part: i64,
pub finished: bool,
pub canceled: bool,
pub paused: bool,
#[serde(rename = "error")]
pub err: String,
pub started_at: i64,
@@ -527,6 +529,8 @@ pub struct DlState {
struct Entry {
state: Mutex<DlState>,
cancel: watch::Sender<bool>,
/// `dlGate` : vrai pendant une pause.
pause: watch::Sender<bool>,
}
impl Entry {
@@ -549,6 +553,37 @@ impl Stop {
}
}
/// `dlGate` : la pause d'un téléchargement. Les requêtes en cours sont
/// coupées (rien ne reste ouvert vers le CDN pendant la pause), et chaque
/// morceau attend la reprise puis repart de l'octet où il s'était arrêté, par
/// une requête de plage.
#[derive(Clone)]
struct Gate(watch::Receiver<bool>);
impl Gate {
fn is_paused(&self) -> bool {
*self.0.borrow()
}
/// Attend la reprise ; `false` : annulé entre-temps.
async fn wait(&self, stop: &Stop) -> bool {
if self.is_paused() {
let mut rx = self.0.clone();
tokio::select! {
_ = rx.wait_for(|p| !*p) => {}
_ = stop.wait() => return false,
}
}
!stop.is_set()
}
/// Se résout quand la pause tombe (jamais si l'émetteur a disparu).
async fn paused(&self) {
let mut rx = self.0.clone();
if rx.wait_for(|p| *p).await.is_err() {
std::future::pending::<()>().await;
}
}
}
/// Ce qu'un téléchargement doit savoir de la machine.
#[derive(Clone)]
pub struct DlEnv {
@@ -615,6 +650,28 @@ impl Downloads {
}
}
/// `handleModelDownloadPause` : met en pause ou reprend ; `false` :
/// inconnu ou terminé. Le fichier partiel est gardé.
pub fn pause(&self, name: &str, paused: bool) -> bool {
let Some(e) = self.map().get(name).cloned() else {
return false;
};
let running = e.with(|s| {
if s.finished {
return false;
}
s.paused = paused;
if paused {
s.speed = 0;
}
true
});
if running {
let _ = e.pause.send_replace(paused);
}
running
}
fn request(&self, env: &DlEnv, url: &str, range: Option<&str>) -> reqwest::RequestBuilder {
let mut rb = self
.client
@@ -722,6 +779,7 @@ impl Downloads {
);
}
let (tx, rx) = watch::channel(false);
let (pause_tx, pause_rx) = watch::channel(false);
let entry = Arc::new(Entry {
state: Mutex::new(DlState {
filename: name.clone(),
@@ -732,12 +790,14 @@ impl Downloads {
..Default::default()
}),
cancel: tx,
pause: pause_tx,
});
map.insert(name.clone(), entry.clone());
drop(map);
let parts = todo.len();
let me = self.clone();
tokio::spawn(async move { me.run_set(env, entry, Stop(rx), todo).await });
let gate = Gate(pause_rx);
tokio::spawn(async move { me.run_set(env, entry, Stop(rx), gate, todo).await });
(
StatusCode::OK,
json!({ "ok": true, "filename": name, "parts": parts }),
@@ -752,6 +812,7 @@ impl Downloads {
env: DlEnv,
entry: Arc<Entry>,
stop: Stop,
gate: Gate,
todo: Vec<(String, String)>,
) {
let done = Arc::new(AtomicI64::new(0));
@@ -812,7 +873,9 @@ impl Downloads {
let dt = now.duration_since(last_at).as_secs_f64();
entry.with(|s| {
s.done = cur;
if dt > 0.0 {
if s.paused {
s.speed = 0;
} else if dt > 0.0 {
let inst = ((cur - last) as f64 / dt) as i64;
s.speed = if s.speed == 0 {
inst
@@ -829,7 +892,7 @@ impl Downloads {
for (i, ((u, dest), (total, ranged))) in todo.iter().zip(sizes).enumerate() {
entry.with(|s| s.part = i as i64 + 1);
if let Err(h) = self
.one_part(&env, &entry, &stop, u, dest, total, ranged, &done)
.one_part(&env, &entry, &stop, &gate, u, dest, total, ranged, &done)
.await
{
ticker.abort();
@@ -851,6 +914,7 @@ impl Downloads {
env: &DlEnv,
entry: &Entry,
stop: &Stop,
gate: &Gate,
url: &str,
dest: &str,
total: i64,
@@ -877,7 +941,7 @@ impl Downloads {
path: tmp.clone(),
});
let res = if conns <= 1 {
self.chunk(env, stop, &file, url, 0, total - 1, total <= 0, done)
self.chunk(env, stop, gate, &file, url, 0, total - 1, total <= 0, done)
.await
} else {
let size = total / conns as i64;
@@ -888,7 +952,7 @@ impl Downloads {
} else {
start + size - 1
};
self.chunk(env, stop, &file, url, start, end, false, done)
self.chunk(env, stop, gate, &file, url, start, end, false, done)
});
futures_util::future::join_all(parts)
.await
@@ -918,6 +982,7 @@ impl Downloads {
&self,
env: &DlEnv,
stop: &Stop,
gate: &Gate,
file: &Arc<PartFile>,
url: &str,
start: i64,
@@ -927,7 +992,13 @@ impl Downloads {
) -> Result<(), Halt> {
let mut pos = start;
let mut last_err = String::new();
for attempt in 0..DL_ATTEMPTS {
let mut attempt: u64 = 0;
while attempt < DL_ATTEMPTS {
// Une pause n'est pas un échec : on attend la reprise sans
// consommer d'essai.
if !gate.wait(stop).await {
return Err(Halt::Canceled);
}
if stop.is_set() {
return Err(Halt::Canceled);
}
@@ -948,13 +1019,16 @@ impl Downloads {
None
};
let resp = tokio::select! {
r = self.request(env, url, range.as_deref()).send() => r,
r = self.request(env, url, range.as_deref()).send() => r.map_err(|e| e.to_string()),
_ = stop.wait() => return Err(Halt::Canceled),
_ = gate.paused() => Err("en pause".to_string()),
};
let resp = match resp {
Ok(r) => r,
Err(_) if gate.is_paused() && !stop.is_set() => continue,
Err(e) => {
last_err = e.to_string();
last_err = e;
attempt += 1;
continue;
}
};
@@ -967,7 +1041,7 @@ impl Downloads {
done.fetch_add(start - pos, Ordering::Relaxed);
pos = start;
}
match copy_at(stop, file, resp, pos, done).await {
match copy_at(stop, gate, file, resp, pos, done).await {
(n, Ok(())) => {
let _ = n;
return Ok(());
@@ -975,7 +1049,12 @@ impl Downloads {
(_, Err(Halt::Canceled)) => return Err(Halt::Canceled),
(n, Err(Halt::Err(e))) => {
pos += n;
// coupé par la pause : la reprise repart de pos
if gate.is_paused() && !stop.is_set() {
continue;
}
last_err = e;
attempt += 1;
}
}
}
@@ -987,6 +1066,7 @@ impl Downloads {
/// même en cas d'erreur (pour reprendre).
async fn copy_at(
stop: &Stop,
gate: &Gate,
file: &Arc<PartFile>,
resp: reqwest::Response,
off: i64,
@@ -999,6 +1079,17 @@ async fn copy_at(
let next = tokio::select! {
n = stream.next() => n,
_ = stop.wait() => return (written, Err(Halt::Canceled)),
_ = gate.paused() => {
// pause : ce qui est reçu est écrit, le flux est coupé
return match flush(file, &mut buf, off + written).await {
Ok(k) => {
written += k;
done.fetch_add(k, Ordering::Relaxed);
(written, Err(Halt::Err("en pause".into())))
}
Err(we) => (written, Err(Halt::Err(we))),
};
}
};
let eof = next.is_none();
match next {
@@ -1186,6 +1277,22 @@ pub async fn serve(
)
}
}
"/api/models/download/pause" => {
let v = match body_json(body).await {
Ok(v) => v,
Err(e) => return bad(e),
};
let name = filepath_base(str_of(&v, "filename").trim());
let paused = v.get("pause").and_then(Value::as_bool).unwrap_or(false);
if dl.pause(&name, paused) {
send_json(StatusCode::OK, &json!({ "ok": true, "paused": paused }))
} else {
send_json(
StatusCode::NOT_FOUND,
&json!({ "ok": false, "error": format!("aucun téléchargement en cours pour {name}") }),
)
}
}
"/api/models/download" => {
let v = match body_json(body).await {
Ok(v) => v,
+163
View File
@@ -225,6 +225,169 @@ pub async fn see_image(
)
}
// --- see_video (chat_video.go, chat_video_tool.go) ------------------------------
/// `maxVideoBytes` : au-delà, des dizaines de milliers de jetons visuels.
pub const MAX_VIDEO_BYTES: i64 = 200 << 20;
/// `videoFallbackNote` : consigne quand le moteur ne lit pas la vidéo.
pub const VIDEO_FALLBACK_NOTE: &str = "Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.\n";
/// `videoMimes` : les vidéos qu'on sait montrer au modèle.
pub fn video_mime(name: &str) -> &'static str {
let ext = Path::new(name)
.extension()
.map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
.unwrap_or_default();
match ext.as_str() {
".mp4" => "video/mp4",
".webm" => "video/webm",
".mov" => "video/quicktime",
".mkv" => "video/x-matroska",
".avi" => "video/x-msvideo",
_ => "",
}
}
/// `videoPart` : la partie `input_video` que lit llama-server.
pub fn video_part(b: &[u8], mime: &str) -> Value {
json!({
"type": "input_video",
"input_video": { "url": format!("data:{mime};base64,{}", base64::engine::general_purpose::STANDARD.encode(b)) },
})
}
/// `videoInputSupported` : preset local avec projecteur (MMPROJ) et moteur
/// dont l'aide annonce `--video-fps`. Lit le disque et lance le moteur :
/// à appeler hors du runtime (`spawn_blocking`). `home` : `LOKI_HOME`.
pub fn video_input_supported(cfg: &crate::serve::Cfg, home: &Path) -> bool {
use crate::serve::{get, trim};
if crate::serve::is_external(cfg)
|| crate::serve::is_strata(cfg)
|| trim(get(cfg, "MMPROJ")).is_empty()
{
return false;
}
let mut bin = trim(get(cfg, "BIN")).to_string();
if bin.is_empty() {
return false;
}
if !crate::paths::go_is_abs(&bin) {
bin = home.join(&bin).to_string_lossy().into_owned();
}
let ep = crate::serve::run::EnginePaths {
home: home.to_path_buf(),
};
bin_help_has_video(&ep.prebuilt_resolve_bin(&bin))
}
/// `binHelpHasVideo` : l'aide du binaire, une fois par binaire et par date
/// de modification, chemin des bibliothèques passé à la seule commande.
pub fn bin_help_has_video(bin: &str) -> bool {
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
static CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
let Ok(st) = std::fs::metadata(bin) else {
return false;
};
if st.is_dir() {
return false;
}
let key = format!("{bin}\0{:?}", st.modified().ok());
let cache = CACHE.get_or_init(Default::default);
if let Some(v) = cache.lock().ok().and_then(|c| c.get(&key).copied()) {
return v;
}
let mut env = crate::serve::run::Env::default();
let dir = Path::new(bin)
.parent()
.map(|d| d.to_string_lossy().into_owned())
.unwrap_or_default();
let (k, v) = crate::serve::run::library_path_value(&env, &dir);
env.set(&k, &v);
let help = crate::serve::run::bin_help(&env, bin);
let ok = help.contains("--video-fps");
if let Ok(mut c) = cache.lock() {
c.insert(key, ok);
}
ok
}
/// `toolSeeVideo` : (accusé, partie vidéo). Même ordre de refus que
/// [`see_image`] ; `video_ok` : [`video_input_supported`], `sees` : la sonde
/// du moteur, appelée en dernier.
pub async fn see_video(
path: &str,
cwd: &Path,
target: &str,
vision: bool,
video_ok: bool,
sees: impl std::future::Future<Output = bool>,
) -> (String, Option<Value>) {
let err = |m: String| (m, None);
if !vision {
return err("[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo".into());
}
if !target.is_empty() {
return err("[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)".into());
}
if path.is_empty() {
return err("[erreur] chemin de fichier manquant".into());
}
if !video_ok {
return err(format!(
"[erreur] {}",
VIDEO_FALLBACK_NOTE.trim_end_matches('\n')
));
}
let abs = crate::tools::policy::resolve_agent_path(cwd, path);
let mime = video_mime(&abs.to_string_lossy());
if mime.is_empty() {
return err(format!(
"[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : {path}"
));
}
let st = match std::fs::metadata(&abs) {
Ok(m) => m,
Err(_) => return err(format!("[erreur] fichier introuvable : {path}")),
};
if st.is_dir() {
return err(format!("[erreur] c'est un dossier, pas une vidéo : {path}"));
}
if st.len() as i64 > MAX_VIDEO_BYTES {
return err(format!(
"[erreur] vidéo trop lourde ({}, max {}) — coupe-la en un clip plus court : {path}",
crate::downloads::human_bytes(st.len() as i64),
crate::downloads::human_bytes(MAX_VIDEO_BYTES)
));
}
if !sees.await {
return err(format!(
"[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir {path}"
));
}
let b = match std::fs::read(&abs) {
Ok(b) => b,
Err(e) => {
return err(format!(
"[erreur] lecture impossible : {}",
crate::paths::os_err("open", &abs.to_string_lossy(), &e)
));
}
};
if b.is_empty() {
return err(format!("[erreur] fichier vide : {path}"));
}
let base = abs
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
(
format!("[ok] vidéo chargée : {base}"),
Some(video_part(&b, mime)),
)
}
// --- web_screenshot -------------------------------------------------------------
/// Le résultat de `web_screenshot` : le texte, et la capture rangée
+4 -1
View File
@@ -345,7 +345,7 @@ impl Strata {
"mmap": mode == "disk" || mode == "arena", "drop": mode == "drop",
"experts": experts, "mode_label": experts_label(&mode), "mode": mode,
"main_gpu": main_gpu,
"has_helper": false, "helper_on": false,
"has_helper": false, "helper_on": false, "split_layers": false,
});
let kvr = val("--kv-resident");
if !kvr.is_empty() {
@@ -356,6 +356,7 @@ impl Strata {
d["has_helper"] = json!(true);
if helper_on(&pc) {
d["helper_on"] = json!(true);
d["split_layers"] = json!(layer_split(&pc));
let hg = gpu_name(h);
if !hg.is_empty() {
d["helper_gpu"] = json!(hg);
@@ -825,6 +826,7 @@ pub async fn serve(
("vision", Kind::Bool),
("helper", Kind::Bool),
("experts", Kind::Str),
("split", Kind::Str),
],
);
if let Some(e) = d.err.clone() {
@@ -838,6 +840,7 @@ pub async fn serve(
vision: d.bool("vision"),
helper: d.bool("helper"),
experts: d.str("experts"),
split: d.str("split"),
};
if r.preset.contains(['/', '\\']) || r.preset.is_empty() {
return fail(StatusCode::BAD_REQUEST, "preset invalide");
+67 -12
View File
@@ -19,24 +19,27 @@ use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
/// Paquet figé (release moe-v1.0 d'AJEAN).
pub const VERSION: &str = "1.0";
pub const RELEASE_BASE: &str = "https://github.com/nathaninline/ajean/releases/download/moe-v1.0/";
pub const SRC_ASSET: &str = "ajean-moe-src-1.0.tar.gz";
pub const ENGINE_ASSET: &str = "ajean-moe-engine-1.0-linux-x64-cuda12.zip";
/// Paquet figé (release moe-v1.1 d'AJEAN : experts de la carte d'aide
/// calculés par elle à la lecture des prompts). Un modèle installé avec la
/// 1.0 passe à la 1.1 à son premier lancement (`strataMigrate`, côté Go :
/// `loki serve` d'un preset Strata reste au Go).
pub const VERSION: &str = "1.1";
pub const RELEASE_BASE: &str = "https://github.com/nathaninline/ajean/releases/download/moe-v1.1/";
pub const SRC_ASSET: &str = "ajean-moe-src-1.1.tar.gz";
pub const ENGINE_ASSET: &str = "ajean-moe-engine-1.1-linux-x64-cuda12.zip";
/// Le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
pub const ENGINE_LOCAL: &str = "strata-linux-x64-cuda12.zip";
/// `strataAssetSHA`.
/// `strataAssetSHA` (celles qu'AJEAN publie pour moe-v1.1).
pub fn default_asset_sha() -> HashMap<String, String> {
HashMap::from([
(
SRC_ASSET.to_string(),
"5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b".to_string(),
"5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115".to_string(),
),
(
ENGINE_ASSET.to_string(),
"b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c".to_string(),
"8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3".to_string(),
),
])
}
@@ -137,7 +140,11 @@ pub fn quant(id: &str) -> Option<Quant> {
}
pub const LOW_RAM_HEADROOM_GB: f64 = 10.0;
pub const DROP_HEADROOM_GB: f64 = 7.0;
/// `strataDropHeadroomGB` (le moteur 1.1 prend ~1 Go de plus que la 1.0).
pub const DROP_HEADROOM_GB: f64 = 8.0;
/// `strataDropMaxCtx` : contexte maximal en mode hors RAM (le cache KV
/// au-delà de la part en VRAM est verrouillé en RAM).
pub const DROP_MAX_CTX: i64 = 131072;
/// `strataHelperExpertsGB`.
pub fn helper_experts_gb(vram_gb: f64) -> f64 {
@@ -280,6 +287,13 @@ pub fn helper_on(cfg: &Cfg) -> bool {
!h.is_empty() && h != "-1" && cfg_get(cfg, "STRATA_HELPER") != "0"
}
/// `strataLayerSplit` : les deux cartes se partagent les couches
/// (`STRATA_SPLIT=layers`) au lieu que la seconde n'aide que pour une part
/// des experts. Un choix, jamais imposé : le mode d'aide reste le défaut.
pub fn layer_split(cfg: &Cfg) -> bool {
helper_on(cfg) && cfg_get(cfg, "STRATA_SPLIT") == "layers"
}
/// `strataResolveExperts` : le mode lancé (drop, ram, arena, disk), d'après
/// le choix du preset et la RAM disponible.
pub fn resolve_experts(
@@ -290,7 +304,9 @@ pub fn resolve_experts(
helper_gb: f64,
) -> &'static str {
let bin_ready = pack_has_experts_bin(args) && paths.engine_installed() == ENGINE_ASSET;
let drop_ready = bin_ready && helper_on(cfg) && cfg_get(cfg, "STRATA_DROP") != "0";
// (jamais en partage des couches : la seconde carte n'y garde pas d'experts)
let drop_ready =
bin_ready && helper_on(cfg) && !layer_split(cfg) && cfg_get(cfg, "STRATA_DROP") != "0";
let arena = arena_gb(cfg);
match cfg_get(cfg, "STRATA_EXPERTS")
.trim()
@@ -404,7 +420,18 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
args = set_arg(args, flag, v);
}
let helper = cfg_get(cfg, "STRATA_HELPER_GPU").trim();
let has_helper = helper_on(cfg);
let mut has_helper = helper_on(cfg);
if layer_split(cfg) {
// Partage des couches : la clé « gpu » liste les deux cartes (numéros
// nvidia-smi), le serveur de Strata en déduit --layer-split.
let main = cfg_get(cfg, "STRATA_MAIN_GPU")
.trim()
.parse::<i64>()
.unwrap_or(0);
let second = helper.parse::<i64>().unwrap_or(0);
out.insert("gpu".into(), json!([main, second]));
has_helper = false;
}
if has_helper {
args = set_arg(args, "--expert-cache-device1", "auto");
if !has_arg(&args, "--remote-expert-opt") {
@@ -431,6 +458,8 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
env.insert("STRATA_REMOTE_DROP".into(), json!("1"));
env.insert("STRATA_STAGER_THREADS".into(), json!("8"));
env.insert("STRATA_STAGER_RING".into(), json!("32"));
// VRAM libre réservée de la carte d'aide : 640 Mio (défaut 1024)
env.insert("STRATA_REMOTE_DROP_RESERVE_MIB".into(), json!("640"));
}
"arena" => {
args = set_arg(args, "--pcie-frac", "0");
@@ -445,10 +474,23 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
for (k, v) in ENV_TUNING {
env.insert(k.into(), json!(v));
}
if has_helper {
// Lecture d'un prompt (moteur 1.1) : les experts de la carte d'aide
// sont calculés par elle (source « helper ») ; le chemin direct ne
// garde que les ajouts sous 48 jetons.
env.insert("STRATA_PREFILL_HELPER_SOURCE".into(), json!("1"));
args = set_arg(args, "--short-read", "48");
}
let drop_on = env.get("STRATA_REMOTE_DROP").and_then(Value::as_str) == Some("1");
out.insert("env".into(), Value::Object(env));
let ctx = cfg_get(cfg, "CTX").trim();
if !ctx.is_empty() {
args = set_arg(args, "--max-context", ctx);
// hors RAM, le cache KV verrouillé au-delà de DROP_MAX_CTX ne tient plus
let capped = match ctx.parse::<i64>() {
Ok(n) if n > DROP_MAX_CTX && drop_on => DROP_MAX_CTX.to_string(),
_ => ctx.to_string(),
};
args = set_arg(args, "--max-context", &capped);
}
for (key, flag) in [
("STRATA_KV", "--kv"),
@@ -766,6 +808,8 @@ pub struct SettingsReq {
pub vision: bool,
pub helper: bool,
pub experts: String,
/// « helper » ou « layers » ; vide = inchangé (anciens clients).
pub split: String,
}
/// `strataApplySettings` : réécrit les clés du preset. Pure.
@@ -786,6 +830,9 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
{
return Err(format!("placement des experts inconnu : {}", r.experts));
}
if !r.split.is_empty() && r.split != "helper" && r.split != "layers" {
return Err(format!("répartition inconnue : {}", r.split));
}
let mut set: Vec<(&str, String)> = vec![
("CTX", r.ctx.to_string()),
("STRATA_KV", r.kv.clone()),
@@ -796,6 +843,11 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
if !r.experts.is_empty() {
set.push(("STRATA_EXPERTS", r.experts.clone()));
}
if !r.split.is_empty() {
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
let v = if r.split == "layers" { "layers" } else { "" };
set.push(("STRATA_SPLIT", v.into()));
}
let mut lines: Vec<String> = Vec::new();
for l in content.trim_end_matches('\n').split('\n') {
let k = l.trim().split_once('=').map_or(l.trim(), |(k, _)| k);
@@ -805,6 +857,9 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
lines.push(l.to_string());
}
for (k, v) in set {
if k == "STRATA_SPLIT" && v.is_empty() {
continue;
}
lines.push(format!("{k}={v}"));
}
Ok(lines.join("\n") + "\n")
+119
View File
@@ -369,6 +369,125 @@ fn modes_des_experts() {
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
}
// Moteur 1.1 (`TestStrataBuildConfigHelperPrompt`).
#[test]
fn lecture_des_prompts_par_la_carte_d_aide() {
let base = json!({ "args": ["--pack", "/p"] });
let out = build_config(&base, &cfg(&[("STRATA_HELPER_GPU", "0")]), "", "ram").unwrap();
assert_eq!(arg_after(&args_of(&out), "--short-read"), "48");
assert_eq!(out["env"]["STRATA_PREFILL_HELPER_SOURCE"], "1");
for c in [
Cfg::new(),
cfg(&[("STRATA_HELPER_GPU", "0"), ("STRATA_HELPER", "0")]),
] {
let out = build_config(&base, &c, "", "ram").unwrap();
assert_eq!(arg_after(&args_of(&out), "--short-read"), "150", "{c:?}");
assert!(out["env"].get("STRATA_PREFILL_HELPER_SOURCE").is_none());
}
}
// `TestStrataBuildConfigDropReserveAndCtx`.
#[test]
fn hors_ram_reserve_et_contexte() {
let base = json!({ "args": ["--pack", "/p"], "env": {} });
let c = cfg(&[("STRATA_HELPER_GPU", "0"), ("CTX", "167936")]);
let out = build_config(&base, &c, "", "drop").unwrap();
assert_eq!(out["env"]["STRATA_REMOTE_DROP_RESERVE_MIB"], "640");
assert_eq!(arg_after(&args_of(&out), "--max-context"), "131072");
for mode in ["ram", "arena", "disk"] {
let out = build_config(&base, &c, "", mode).unwrap();
assert_eq!(
arg_after(&args_of(&out), "--max-context"),
"167936",
"{mode}"
);
assert!(out["env"].get("STRATA_REMOTE_DROP_RESERVE_MIB").is_none());
}
}
// `TestStrataBuildConfigLayerSplit`.
#[test]
fn partage_des_couches() {
let base = json!({
"args": ["--pack", "/p", "--vram-reserve-mib", "900"],
"vision": { "exe": "/v" },
});
let mut c = cfg(&[
("STRATA_MAIN_GPU", "1"),
("STRATA_HELPER_GPU", "0"),
("STRATA_SPLIT", "layers"),
]);
let out = build_config(&base, &c, "", "drop").unwrap();
assert_eq!(out["gpu"], json!([1, 0]));
let a = args_of(&out);
assert!(!has_arg(&a, "--remote-expert-opt") && !has_arg(&a, "--expert-cache-device1"));
assert_eq!(arg_after(&a, "--vram-reserve-mib"), "900");
assert_eq!(arg_after(&a, "--short-read"), "150");
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
assert!(out["env"].get("STRATA_PREFILL_HELPER_SOURCE").is_none());
assert!(out["vision"].get("cuda_device").is_none());
c.remove("STRATA_SPLIT");
let out = build_config(&base, &c, "", "drop").unwrap();
assert!(out.get("gpu").is_none());
assert!(has_arg(&args_of(&out), "--remote-expert-opt"));
c.insert("STRATA_SPLIT".into(), "layers".into());
c.insert("STRATA_HELPER".into(), "0".into());
assert!(!layer_split(&c));
}
// `TestStrataResolveExpertsLayerSplit`.
#[test]
fn partage_des_couches_sans_hors_ram() {
let d = tempfile::tempdir().unwrap();
let p = Paths {
home: d.path().to_path_buf(),
};
let pack = d.path().join("pack");
std::fs::create_dir_all(&pack).unwrap();
std::fs::write(pack.join("experts.bin"), "x").unwrap();
std::fs::create_dir_all(p.src_dir().join("engine-cuda12")).unwrap();
std::fs::write(p.engine_marker(), format!("{ENGINE_ASSET}\n")).unwrap();
let args = vec!["--pack".to_string(), pack.to_string_lossy().into_owned()];
let mut c = cfg(&[
("STRATA_CONFIG", "/x/strata-swift-iq3_xxs.json"),
("STRATA_HELPER_GPU", "1"),
("STRATA_DROP", "1"),
]);
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "drop");
c.insert("STRATA_SPLIT".into(), "layers".into());
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "arena");
c.insert("STRATA_EXPERTS".into(), "ram".into());
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "ram");
}
// `TestStrataApplySettingsSplit`.
#[test]
fn reglages_de_la_seconde_carte() {
let input = "ENGINE=strata\nSTRATA_HELPER_GPU=0\n";
let mut r = SettingsReq {
ctx: 131072,
kv: "fp16".into(),
spec: 3,
vision: true,
helper: true,
split: "layers".into(),
..SettingsReq::default()
};
let out = apply_settings(input, &r).unwrap();
assert!(out.contains("STRATA_SPLIT=layers\n"), "{out:?}");
r.split = "helper".into();
let out = apply_settings(&out, &r).unwrap();
assert!(!out.contains("STRATA_SPLIT"), "{out:?}");
r.split = String::new();
let out = apply_settings("STRATA_SPLIT=layers\n", &r).unwrap();
assert!(out.contains("STRATA_SPLIT=layers"), "{out:?}");
r.split = "nimporte".into();
assert_eq!(
apply_settings(input, &r).unwrap_err(),
"répartition inconnue : nimporte"
);
}
#[test]
fn reglages_des_experts() {
let input = "ENGINE=strata\nSTRATA_EXPERTS=auto\n";
+11 -4
View File
@@ -469,12 +469,19 @@ async fn bail_travail_annexe_et_trace() {
*bn.go.side_jobs.lock().unwrap(),
vec!["begin 1".to_string(), "end 1 true".to_string()]
);
// La longueur du prompt suit le corpus de code, c'est-à-dire l'UI
// embarquée : la vérifier en dur cassait le test à chaque retouche de
// l'interface. On vérifie la forme de la trace.
let perfs = bn.go.bench_perfs.lock().unwrap().clone();
assert_eq!(perfs.len(), 1, "{perfs:?}");
let n = perfs[0]["prompt_n"].as_i64().unwrap_or(0);
assert!(n > 0, "{perfs:?}");
assert_eq!(
*bn.go.bench_perfs.lock().unwrap(),
vec![json!({
"prompt_n": 2036, "prompt_ms": 1018.0, "prompt_per_second": 2000.0,
perfs[0],
json!({
"prompt_n": n, "prompt_ms": n as f64 / 2.0, "prompt_per_second": 2000.0,
"predicted_n": 300, "predicted_per_second": 50.0,
})]
})
);
// La clé de l'API locale accompagne les requêtes du bench.
let auth = bn.fake.lock().unwrap().auth.clone();
+59
View File
@@ -260,6 +260,13 @@ async fn wait_finished(dl: &Downloads, name: &str) -> DlState {
panic!("téléchargement {name} jamais terminé : {:?}", dl.status());
}
fn state_of(dl: &Downloads, name: &str) -> DlState {
dl.status()
.into_iter()
.find(|s| s.filename == name)
.unwrap()
}
fn models(home: &Path) -> PathBuf {
home.join("models")
}
@@ -537,6 +544,58 @@ async fn annulation_efface_le_part() {
assert_eq!(entries(&models(tmp.path())), Vec::<String>::new());
}
// `TestRunDownloadPauseResume` : la pause coupe le flux et garde ce qui est
// écrit ; la reprise repart de là par une plage et termine un fichier
// identique à la source.
#[tokio::test]
async fn pause_puis_reprise() {
let (addr, hf) = fake_hf().await;
let data = payload(8 << 20, 5);
hf.files
.lock()
.unwrap()
.insert("/m.gguf".into(), Arc::new(data.clone()));
*hf.slow.lock().unwrap() = true;
let tmp = tempfile::tempdir().unwrap();
let dl = Arc::new(Downloads::new());
let (code, _) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/m.gguf"),
"",
);
assert_eq!(code, StatusCode::OK);
assert!(!dl.pause("autre.gguf", true));
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(dl.pause("m.gguf", true));
tokio::time::sleep(Duration::from_millis(1500)).await;
let a = state_of(&dl, "m.gguf");
tokio::time::sleep(Duration::from_millis(1500)).await;
let b = state_of(&dl, "m.gguf");
assert!(
a.paused && !b.finished && a.done == b.done && a.done > 0 && b.speed == 0,
"pause inopérante : {a:?} puis {b:?}"
);
let before = hf.log.lock().unwrap().len();
assert!(dl.pause("m.gguf", false));
let end = wait_finished(&dl, "m.gguf").await;
assert!(
end.err.is_empty() && !end.canceled && !end.paused,
"{end:?}"
);
let got = std::fs::read(models(tmp.path()).join("m.gguf")).unwrap();
assert!(got == data, "fichier différent de la source après reprise");
// La reprise demande la suite, pas le début.
let log = hf.log.lock().unwrap().clone();
let resumed = log[before..]
.iter()
.filter_map(|l| l.strip_prefix("/m.gguf bytes="))
.any(|r| !r.starts_with("0-"));
assert!(resumed, "{log:?}");
// Terminé : plus rien à mettre en pause.
assert!(!dl.pause("m.gguf", true));
}
#[tokio::test]
async fn refus_avant_de_lancer() {
let tmp = tempfile::tempdir().unwrap();
+108 -108
View File
@@ -231,8 +231,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -304,7 +304,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -335,8 +335,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -358,8 +358,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -387,8 +387,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -410,8 +410,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -433,8 +433,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -542,8 +542,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -615,7 +615,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -855,8 +855,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -947,8 +947,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -993,8 +993,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1066,7 +1066,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -1103,8 +1103,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1132,8 +1132,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1241,8 +1241,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1314,7 +1314,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -1554,8 +1554,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1646,8 +1646,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1686,8 +1686,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16403,
"prompt_n": 32806,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1759,7 +1759,7 @@
"max_tokens": 4096,
"messages": [
{
"content": "<131209 octets a079a2b6a1db>",
"content": "<131201 octets e3226a117903>",
"role": "user"
}
],
@@ -1790,8 +1790,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16403,
"prompt_n": 32806,
"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,
"prompt_n": 32806,
"prompt_ms": 16402,
"prompt_n": 32804,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2093,7 +2093,7 @@
"max_tokens": 16,
"messages": [
{
"content": "<3745 octets 5578a8f2779c>",
"content": "<3745 octets 55a9fdff5d02>",
"role": "user"
}
],
@@ -2187,8 +2187,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -2260,7 +2260,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2291,8 +2291,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2314,8 +2314,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2420,8 +2420,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2443,8 +2443,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2541,7 +2541,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2572,8 +2572,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2595,8 +2595,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2693,7 +2693,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2747,8 +2747,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2770,8 +2770,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2868,7 +2868,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -2992,8 +2992,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3015,8 +3015,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3121,8 +3121,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3144,8 +3144,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3189,8 +3189,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3287,7 +3287,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3319,8 +3319,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3342,8 +3342,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3383,8 +3383,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3457,7 +3457,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3489,8 +3489,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3512,8 +3512,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3579,8 +3579,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3652,7 +3652,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3737,8 +3737,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3760,8 +3760,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3867,7 +3867,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8144 octets 5c98ee6b6194>",
"role": "user"
}
],
@@ -3952,8 +3952,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3975,8 +3975,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4034,8 +4034,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4057,8 +4057,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4089,8 +4089,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4112,8 +4112,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -4135,8 +4135,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1020,
"prompt_n": 2040,
"prompt_per_second": 2000,
"protocol": 2
}
+245
View File
@@ -0,0 +1,245 @@
{
"files": {
"CLIP.MOV": "AAAAFGZ0eXBxdCAgIG1vdg==",
"anim.webm": "GkXfoyB3ZWJt",
"clip.mp4": "AAAAGGZ0eXBtcDQyIGZhdXNzZSB2aWTDqW8=",
"film.mkv": "GkXfoyBtYXRyb3NrYQ==",
"sous/court.mp4": "ZnR5cCBzb3VzLWRvc3NpZXI=",
"texte.txt": "cGFzIHVuZSB2aWTDqW8=",
"vide.mp4": "",
"vieux.avi": "UklGRhAAAABBVkkgTElTVA=="
},
"steps": [
{
"name": "sans vision",
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo",
"probes": 0
},
{
"name": "chemin manquant",
"vision": true,
"video": true,
"args": {
"file": ""
},
"label": "",
"result": "[erreur] chemin de fichier manquant",
"probes": 0
},
{
"name": "sans argument",
"vision": true,
"video": true,
"args": {},
"label": "",
"result": "[erreur] chemin de fichier manquant",
"probes": 0
},
{
"name": "moteur sans vidéo",
"vision": true,
"sees": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.",
"probes": 0
},
{
"name": "pas une vidéo",
"vision": true,
"video": true,
"args": {
"file": "texte.txt"
},
"label": "texte.txt",
"result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : texte.txt",
"probes": 0
},
{
"name": "image",
"vision": true,
"video": true,
"args": {
"file": "photo.png"
},
"label": "photo.png",
"result": "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : photo.png",
"probes": 0
},
{
"name": "introuvable",
"vision": true,
"video": true,
"args": {
"file": "absente.mp4"
},
"label": "absente.mp4",
"result": "[erreur] fichier introuvable : absente.mp4",
"probes": 0
},
{
"name": "dossier",
"vision": true,
"video": true,
"args": {
"file": "dossier.mp4"
},
"label": "dossier.mp4",
"result": "[erreur] c'est un dossier, pas une vidéo : dossier.mp4",
"probes": 0
},
{
"name": "trop lourde",
"vision": true,
"video": true,
"args": {
"file": "lourd.mp4"
},
"label": "lourd.mp4",
"result": "[erreur] vidéo trop lourde (200 Mo, max 200 Mo) — coupe-la en un clip plus court : lourd.mp4",
"probes": 0
},
{
"name": "le moteur ne voit pas",
"vision": true,
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir clip.mp4",
"probes": 1
},
{
"name": "vide",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "vide.mp4"
},
"label": "vide.mp4",
"result": "[erreur] fichier vide : vide.mp4",
"probes": 1
},
{
"name": "voir clip.mp4",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "clip.mp4"
},
"label": "clip.mp4",
"result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée (clip.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "clip.mp4"
},
"probes": 1
},
{
"name": "voir CLIP.MOV",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "CLIP.MOV"
},
"label": "CLIP.MOV",
"result": "[ok] vidéo chargée : CLIP.MOV\n[relais] Vidéo demandée (CLIP.MOV) :",
"part": {
"mime": "video/quicktime",
"same_as": "CLIP.MOV"
},
"probes": 1
},
{
"name": "voir anim.webm",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "anim.webm"
},
"label": "anim.webm",
"result": "[ok] vidéo chargée : anim.webm\n[relais] Vidéo demandée (anim.webm) :",
"part": {
"mime": "video/webm",
"same_as": "anim.webm"
},
"probes": 1
},
{
"name": "voir film.mkv",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "film.mkv"
},
"label": "film.mkv",
"result": "[ok] vidéo chargée : film.mkv\n[relais] Vidéo demandée (film.mkv) :",
"part": {
"mime": "video/x-matroska",
"same_as": "film.mkv"
},
"probes": 1
},
{
"name": "voir vieux.avi",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "vieux.avi"
},
"label": "vieux.avi",
"result": "[ok] vidéo chargée : vieux.avi\n[relais] Vidéo demandée (vieux.avi) :",
"part": {
"mime": "video/x-msvideo",
"same_as": "vieux.avi"
},
"probes": 1
},
{
"name": "voir sous/court.mp4",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "sous/court.mp4"
},
"label": "sous/court.mp4",
"result": "[ok] vidéo chargée : court.mp4\n[relais] Vidéo demandée (sous/court.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "sous/court.mp4"
},
"probes": 1
},
{
"name": "chemin absolu",
"vision": true,
"sees": true,
"video": true,
"args": {
"file": "{{FICHIERS}}/clip.mp4"
},
"label": "{{FICHIERS}}/clip.mp4",
"result": "[ok] vidéo chargée : clip.mp4\n[relais] Vidéo demandée ({{FICHIERS}}/clip.mp4) :",
"part": {
"mime": "video/mp4",
"same_as": "clip.mp4"
},
"probes": 1
}
]
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -318,6 +318,7 @@ async fn parite_de_strata() {
"CTX",
"STRATA_KV",
"STRATA_HELPER",
"STRATA_SPLIT",
] {
if let Some(v) = bn.go.get("config", k).filter(|v| !v.is_empty()) {
after.insert(format!("config/{k}"), Value::String(bn.mask(&v)));
+231
View File
@@ -0,0 +1,231 @@
//! see_video (repris d'AJEAN) tenu par la façade, rejoué sur le journal du
//! Go (`internal/loki/parity_video_tool_test.go` →
//! `tests/parity_images/video.json`) : mêmes fichiers, même faux moteur
//! (`/props`), même capacité vidéo du moteur, étape par étape. La vidéo
//! relayée se compare à l'octet (jamais réencodée).
mod common;
use std::convert::Infallible;
use std::sync::{Arc, Mutex};
use base64::Engine as _;
use bytes::Bytes;
use common::fakego;
use http_body_util::Full;
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::TokioIo;
use loki_rs::imagetools::{self, VisionProbe};
use loki_rs::store::Store;
use serde_json::{Map, Value, json};
use tokio::net::TcpListener;
/// Le faux moteur : `/props` authentifié, la vision réglée par le test.
async fn fake_engine(sees: Arc<Mutex<(bool, u32)>>) -> u16 {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = l.local_addr().unwrap().port();
tokio::spawn(async move {
while let Ok((st, _)) = l.accept().await {
let sees = sees.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
let sees = sees.clone();
async move {
let ok = req.uri().path() == "/props"
&& req
.headers()
.get("authorization")
.and_then(|h| h.to_str().ok())
== Some("Bearer cle-moteur");
let mut r = Response::new(Full::new(Bytes::new()));
if ok {
let mut s = sees.lock().unwrap();
s.1 += 1;
r = Response::new(Full::new(Bytes::from(
json!({ "modalities": { "vision": s.0 } }).to_string(),
)));
} else {
*r.status_mut() = hyper::StatusCode::UNAUTHORIZED;
}
Ok::<_, Infallible>(r)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(st), svc)
.await;
});
}
});
port
}
/// `parityVideoFacts`.
fn part_facts(part: &Value, raw: &Map<String, Value>) -> Value {
let u = part["input_video"]["url"].as_str().unwrap();
let i = u.find(";base64,").unwrap();
let b = base64::engine::general_purpose::STANDARD
.decode(&u[i + 8..])
.unwrap();
let mut out = Map::new();
out.insert("mime".into(), json!(u[..i].trim_start_matches("data:")));
for (name, r) in raw {
let rb = base64::engine::general_purpose::STANDARD
.decode(r.as_str().unwrap_or(""))
.unwrap();
if rb == b {
out.insert("same_as".into(), json!(name));
}
}
Value::Object(out)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn see_video_comme_le_go() {
let fx: Value =
serde_json::from_str(include_str!("parity_images/video.json")).expect("journal");
let tmp = tempfile::tempdir().unwrap();
let files = tmp.path().join("fichiers");
let raw = fx["files"].as_object().unwrap();
for (name, b) in raw {
let p = files.join(name);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(
&p,
base64::engine::general_purpose::STANDARD
.decode(b.as_str().unwrap_or(""))
.unwrap(),
)
.unwrap();
}
std::fs::create_dir_all(files.join("dossier.mp4")).unwrap();
std::fs::File::create(files.join("lourd.mp4"))
.unwrap()
.set_len((imagetools::MAX_VIDEO_BYTES + 1) as u64)
.unwrap();
let files_s = files.to_string_lossy().replace('\\', "/");
let sees = Arc::new(Mutex::new((false, 0u32)));
let port = fake_engine(sees.clone()).await;
let (_, go, _stop) = fakego::start(tmp.path().join("home")).await;
let backend = go.backend.lock().unwrap().unwrap();
let store = Store::new(backend, fakego::JETON.into());
go.set("state", "api_key", "cle-moteur");
let mut cfg = std::collections::HashMap::new();
cfg.insert("PORT".to_string(), port.to_string());
let probe = VisionProbe::default();
for st in fx["steps"].as_array().unwrap() {
let name = st["name"].as_str().unwrap();
*sees.lock().unwrap() = (st["sees"] == true, 0);
probe.reset().await;
let label = st["label"]
.as_str()
.unwrap()
.replace("{{FICHIERS}}", &files_s);
let (mut result, vid) = imagetools::see_video(
&label,
&files,
"",
st["vision"] == true,
st["video"] == true,
probe.sees(&store, &cfg),
)
.await;
let relay = vid.map(|v| loki_rs::chat::host::see_video_message(&label, v));
let mut facts = None;
if let Some(parts) = relay
.as_ref()
.and_then(|m| m.content.as_ref())
.and_then(Value::as_array)
{
for p in parts {
if p["type"] == "input_video" {
facts = Some(part_facts(p, raw));
}
if p["type"] == "text" {
result += &format!("\n[relais] {}", p["text"].as_str().unwrap());
}
}
}
assert_eq!(
result.replace(&files_s, "{{FICHIERS}}"),
st["result"].as_str().unwrap(),
"{name}"
);
assert_eq!(facts, st.get("part").cloned(), "{name} : vidéo relayée");
assert_eq!(
sees.lock().unwrap().1 as u64,
st["probes"].as_u64().unwrap(),
"{name} : sondes du moteur"
);
}
}
/// `videoInputSupported` : jamais pour une API externe, Strata, un preset
/// sans projecteur ou sans moteur ; sinon l'aide du binaire fait foi, relue
/// quand il change.
#[test]
fn capacite_video_du_moteur() {
let tmp = tempfile::tempdir().unwrap();
let cfg = |kv: &[(&str, &str)]| -> std::collections::HashMap<String, String> {
kv.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
for c in [
cfg(&[("BIN", "llama-server"), ("MODEL", "m.gguf")]),
cfg(&[("MMPROJ", "mm.gguf"), ("MODEL", "m.gguf")]),
cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "absent/llama-server")]),
cfg(&[
("MMPROJ", "mm.gguf"),
("EXTERNAL", "1"),
("EXTERNAL_VISION", "1"),
]),
cfg(&[
("MMPROJ", "mm.gguf"),
("ENGINE", "strata"),
("BIN", "/bin/sh"),
]),
] {
assert!(
!imagetools::video_input_supported(&c, tmp.path()),
"{c:?} : vidéo annoncée"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let bin = tmp.path().join("llama-server");
let write = |help: &str, age: u64| {
std::fs::write(&bin, format!("#!/bin/sh\necho '{help}'\n")).unwrap();
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
let f = std::fs::File::options().write(true).open(&bin).unwrap();
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age))
.unwrap();
};
let c = cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "llama-server")]);
write("--mmproj FILE", 3600);
assert!(!imagetools::video_input_supported(&c, tmp.path()));
write("--video-fps N target fps", 0);
assert!(
imagetools::video_input_supported(&c, tmp.path()),
"moteur mis à jour : --video-fps non relu"
);
}
}
#[test]
fn types_de_video() {
for (f, m) in [
("a.mp4", "video/mp4"),
("A.MOV", "video/quicktime"),
("x/b.webm", "video/webm"),
("c.mkv", "video/x-matroska"),
("d.avi", "video/x-msvideo"),
("e.png", ""),
("mp4", ""),
] {
assert_eq!(imagetools::video_mime(f), m, "{f}");
}
}