mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac74ecde0b | ||
|
|
776de75dd0 | ||
|
|
6a45f19831 | ||
|
|
d22a087d8e | ||
|
|
0ef2ed72f5 | ||
|
|
615e58dd2f | ||
|
|
9792953d62 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,7 +130,8 @@ const imageLostMarker = " [image — not kept in the conversation past this poin
|
||||
// (userMessageContent : parties texte + image quand la vision est active) porte
|
||||
// un tableau de parties ; on en recolle les segments `text` pour que l'estimation
|
||||
// de contexte et le transcript de compaction ne partent pas d'un texte vide, et
|
||||
// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans
|
||||
// on marque la présence d'une partie `image_url` (voir imageLostMarker) ou
|
||||
// `input_video` (videoLostMarker, chat_video.go) — sans
|
||||
// quoi elle reste invisible à toute la chaîne de compaction.
|
||||
func msgText(m Message) string {
|
||||
switch v := m.Content.(type) {
|
||||
@@ -146,6 +147,8 @@ func msgText(m Message) string {
|
||||
}
|
||||
case "image_url":
|
||||
b.WriteString(imageLostMarker)
|
||||
case "input_video":
|
||||
b.WriteString(videoLostMarker)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
@@ -163,6 +166,8 @@ func msgText(m Message) string {
|
||||
}
|
||||
case "image_url":
|
||||
b.WriteString(imageLostMarker)
|
||||
case "input_video":
|
||||
b.WriteString(videoLostMarker)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
|
||||
@@ -112,7 +112,8 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
|
||||
}
|
||||
|
||||
// stripInlineImages remplace les images en base64 (data URI) portées par les
|
||||
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
|
||||
// messages multimodaux (vision : pièces jointes images et vidéos + outil
|
||||
// see_image) par un
|
||||
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
|
||||
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
|
||||
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
|
||||
@@ -145,6 +146,20 @@ func stripInlineImagesValue(v any) any {
|
||||
}
|
||||
cp["image_url"] = c2
|
||||
}
|
||||
// Partie vidéo (input_video, chat_video.go) : même élision, sur les deux
|
||||
// champs que lit llama-server.
|
||||
if iv, ok := cp["input_video"].(map[string]any); ok {
|
||||
c2 := make(map[string]any, len(iv))
|
||||
for k, val := range iv {
|
||||
c2[k] = val
|
||||
}
|
||||
for _, key := range []string{"url", "data"} {
|
||||
if s, ok := c2[key].(string); ok && strings.HasPrefix(s, "data:") {
|
||||
c2[key] = elideDataURI(s)
|
||||
}
|
||||
}
|
||||
cp["input_video"] = c2
|
||||
}
|
||||
return cp
|
||||
}
|
||||
switch parts := v.(type) {
|
||||
|
||||
@@ -123,7 +123,11 @@ const chatImgKeep = 24 * time.Hour
|
||||
// rechargé : plus rien ne référence alors les fichiers. Le délai couvre un
|
||||
// fil resté ouvert longtemps sans bascule.
|
||||
func pruneChatImages() {
|
||||
dir := chatImgDir()
|
||||
pruneMediaDir(chatImgDir())
|
||||
pruneMediaDir(chatVidDir()) // vidéos jointes (chat_video.go), même délai
|
||||
}
|
||||
|
||||
func pruneMediaDir(dir string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
package loki
|
||||
|
||||
// chat_video.go — vidéos vues par le modèle (repris d'AJEAN, PR #100 et son
|
||||
// correctif « vidéo envoyée seulement si le moteur sait la lire »).
|
||||
//
|
||||
// Trois morceaux :
|
||||
// - videoInputSupported : le moteur actif sait-il lire une partie
|
||||
// `input_video` ? Seul un llama.cpp local récent le fait (option
|
||||
// --video-fps, décodage par ffmpeg). Une API externe ou Strata ignorent la
|
||||
// partie SANS erreur : le modèle ne recevait que le texte et cherchait à voir
|
||||
// la vidéo avec see_image. Ailleurs, la vidéo reste un simple fichier, avec
|
||||
// la marche à suivre (videoFallbackNote) ;
|
||||
// - une vidéo JOINTE au message est rangée PAR RÉFÉRENCE, comme les images
|
||||
// (chat_images.go) : une fois sur disque sous LOKI_HOME/chatvid/, chiffrée
|
||||
// comme le fil, et l'historique n'en garde que « loki-vid:<nom> ».
|
||||
// expandVideoRefs remet les octets juste avant l'envoi au moteur ;
|
||||
// - la vidéo chargée par l'outil see_video (chat_video_tool.go) est, elle,
|
||||
// TOUJOURS éphémère : elle part dans le tour en cours et jamais dans
|
||||
// l'historique, KEEP_TURN_IMAGES compris (des milliers de jetons visuels).
|
||||
//
|
||||
// Écart avec l'amont : comme les images, un fil rechargé perd ses vidéos
|
||||
// (stripImageParts, avec videoLostMarker) et pruneChatImages vide aussi
|
||||
// chatvid/ — Loki ne rejoue jamais un média d'un fil rouvert.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const vidRefScheme = "loki-vid:"
|
||||
|
||||
func chatVidDir() string { return filepath.Join(LokiHome(), "chatvid") }
|
||||
|
||||
// maxVideoBytes borne la taille d'une vidéo montrée au modèle : le moteur en
|
||||
// tire des images à --video-fps (4 par seconde par défaut), donc une vidéo
|
||||
// longue = des dizaines de milliers de jetons visuels. Au-delà, le contexte
|
||||
// sature sans que le modèle puisse raisonner dessus.
|
||||
const maxVideoBytes = 200 << 20 // 200 Mio
|
||||
|
||||
// videoMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme vidéo (partie
|
||||
// input_video). Le moteur décode lui-même avec ffmpeg ; un format hors de cette
|
||||
// liste reste un simple fichier du dossier de travail.
|
||||
var videoMimes = map[string]string{
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".mov": "video/quicktime",
|
||||
".mkv": "video/x-matroska",
|
||||
".avi": "video/x-msvideo",
|
||||
}
|
||||
|
||||
// videoMime renvoie le type MIME vidéo d'un nom de fichier, ou "" si ce n'est
|
||||
// pas une vidéo qu'on sait montrer au modèle.
|
||||
func videoMime(name string) string {
|
||||
return videoMimes[strings.ToLower(filepath.Ext(name))]
|
||||
}
|
||||
|
||||
// videoFallbackNote : consigne quand le moteur ne lit pas la vidéo. Le fichier
|
||||
// reste utilisable : quelques images extraites avec ffmpeg, puis see_image.
|
||||
// ffmpeg est dans l'image Docker de Loki (l'image serveur officielle de
|
||||
// llama.cpp l'installe pour son décodage vidéo) ; une installation hors
|
||||
// conteneur peut ne pas l'avoir, d'où la dernière phrase.
|
||||
const videoFallbackNote = "Le moteur actif ne lit pas la vidéo directement. Pour en voir le contenu, extrais quelques images avec ffmpeg (par exemple `ffmpeg -i <vidéo> -vf fps=1 frame_%03d.jpg`) puis regarde-les avec see_image ; si ffmpeg est absent, dis-le plutôt que de deviner le contenu.\n"
|
||||
|
||||
// videoLostMarker : équivalent d'imageLostMarker pour une partie input_video
|
||||
// (résumé de compaction, fil rechargé).
|
||||
const videoLostMarker = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]"
|
||||
|
||||
// videoPart construit la partie multimodale {type:input_video, input_video:{url}}
|
||||
// — le format que llama-server lit une fois --mmproj chargé.
|
||||
func videoPart(b []byte, mime string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "input_video",
|
||||
"input_video": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// videoInputSupported : le moteur actif sait-il lire une partie input_video ?
|
||||
// Preset local avec projecteur (MMPROJ) ET binaire dont l'aide annonce
|
||||
// --video-fps. Variable : remplaçable dans les tests.
|
||||
var videoInputSupported = func() bool {
|
||||
cfg := ReadConfig()
|
||||
if isExternalConfig(cfg) || isStrataConfig(cfg) || strings.TrimSpace(cfg["MMPROJ"]) == "" {
|
||||
return false
|
||||
}
|
||||
bin := strings.TrimSpace(cfg["BIN"])
|
||||
if bin == "" {
|
||||
return false
|
||||
}
|
||||
if !filepath.IsAbs(bin) {
|
||||
bin = filepath.Join(LokiHome(), bin)
|
||||
}
|
||||
return binHelpHasVideo(prebuiltResolveBin(bin))
|
||||
}
|
||||
|
||||
var videoCapCache sync.Map // "binaire\x00mtime" → bool
|
||||
|
||||
// binHelpHasVideo lit l'aide du binaire, une fois par binaire ET par date de
|
||||
// modification (un moteur mis à jour en place est relu). Volontairement à part
|
||||
// de binHelp : celle-ci garde aussi une aide VIDE pour toujours, et l'appeler
|
||||
// avant le lancement du moteur (chemin des bibliothèques pas encore posé)
|
||||
// figerait « aucune capacité » pour le vrai lancement. Ici, le chemin des
|
||||
// bibliothèques est passé à la seule commande (libraryPathEnv).
|
||||
func binHelpHasVideo(bin string) bool {
|
||||
st, err := os.Stat(bin)
|
||||
if err != nil || st.IsDir() {
|
||||
return false
|
||||
}
|
||||
key := bin + "\x00" + st.ModTime().String()
|
||||
if v, ok := videoCapCache.Load(key); ok {
|
||||
return v.(bool)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, "--help")
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, _ := cmd.CombinedOutput()
|
||||
ok := strings.Contains(string(out), "--video-fps")
|
||||
videoCapCache.Store(key, ok)
|
||||
return ok
|
||||
}
|
||||
|
||||
// --- Vidéos jointes, par référence -------------------------------------------
|
||||
|
||||
var vidExtByMime = map[string]string{
|
||||
"video/mp4": ".mp4",
|
||||
"video/webm": ".webm",
|
||||
"video/quicktime": ".mov",
|
||||
"video/x-matroska": ".mkv",
|
||||
"video/x-msvideo": ".avi",
|
||||
}
|
||||
|
||||
// storeChatVideo range les octets d'une vidéo et renvoie sa référence. Chiffrée
|
||||
// comme le fil quand la mémoire l'est ; verrouillée : refus, la vidéo reste en
|
||||
// base64 dans le message (rien ne se perd).
|
||||
func storeChatVideo(b []byte, mime string) (string, error) {
|
||||
ext := vidExtByMime[mime]
|
||||
if ext == "" {
|
||||
ext = ".vid"
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
name := hex.EncodeToString(sum[:16]) + ext
|
||||
dir := chatVidDir()
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
enc, err := encodeMemContent(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, enc, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return vidRefScheme + name, nil
|
||||
}
|
||||
|
||||
// Cache des data-URL reconstituées (même raison que imgCache : un tour d'agent
|
||||
// renvoie tout l'historique à chaque étape). Borné en taille.
|
||||
var (
|
||||
vidCacheMu sync.Mutex
|
||||
vidCache = map[string]string{}
|
||||
vidCacheOrder []string
|
||||
vidCacheBytes int
|
||||
)
|
||||
|
||||
const vidCacheMax = 64 << 20
|
||||
|
||||
func chatVideoDataURL(ref string) (string, bool) {
|
||||
name := strings.TrimPrefix(ref, vidRefScheme)
|
||||
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") {
|
||||
return "", false
|
||||
}
|
||||
vidCacheMu.Lock()
|
||||
if u, ok := vidCache[name]; ok {
|
||||
vidCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
vidCacheMu.Unlock()
|
||||
raw, err := os.ReadFile(filepath.Join(chatVidDir(), name))
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
b, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
mime := "video/mp4"
|
||||
for m, e := range vidExtByMime {
|
||||
if strings.HasSuffix(name, e) {
|
||||
mime = m
|
||||
}
|
||||
}
|
||||
u := "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b)
|
||||
vidCacheMu.Lock()
|
||||
if _, ok := vidCache[name]; !ok {
|
||||
vidCache[name] = u
|
||||
vidCacheOrder = append(vidCacheOrder, name)
|
||||
vidCacheBytes += len(u)
|
||||
for vidCacheBytes > vidCacheMax && len(vidCacheOrder) > 1 {
|
||||
old := vidCacheOrder[0]
|
||||
vidCacheOrder = vidCacheOrder[1:]
|
||||
vidCacheBytes -= len(vidCache[old])
|
||||
delete(vidCache, old)
|
||||
}
|
||||
}
|
||||
vidCacheMu.Unlock()
|
||||
return u, true
|
||||
}
|
||||
|
||||
// partVideoURL : l'adresse d'une partie input_video (url, sinon data — les deux
|
||||
// formes que lit llama-server), "" pour toute autre partie.
|
||||
func partVideoURL(p map[string]any) string {
|
||||
if p["type"] != "input_video" {
|
||||
return ""
|
||||
}
|
||||
iv, _ := p["input_video"].(map[string]any)
|
||||
if iv == nil {
|
||||
return ""
|
||||
}
|
||||
u, _ := iv["url"].(string)
|
||||
if u == "" {
|
||||
u, _ = iv["data"].(string)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// refVideosInMessages remplace les vidéos en base64 des messages par des
|
||||
// références. Comme refImagesInMessages, les parties modifiées sont RECOPIÉES,
|
||||
// jamais mutées (persist peut tourner pendant qu'un runChat sérialise ces mêmes
|
||||
// maps). Renvoie true si quelque chose a changé.
|
||||
func refVideosInMessages(msgs []Message) bool {
|
||||
changed := false
|
||||
for mi, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
var np []map[string]any
|
||||
for pi, p := range parts {
|
||||
u := partVideoURL(p)
|
||||
if !strings.HasPrefix(u, "data:video/") {
|
||||
continue
|
||||
}
|
||||
i := strings.Index(u, ";base64,")
|
||||
if i < 0 {
|
||||
continue
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(u[i+8:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ref, err := storeChatVideo(b, u[5:i])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if np == nil {
|
||||
np = append([]map[string]any(nil), parts...)
|
||||
}
|
||||
np[pi] = map[string]any{"type": "input_video", "input_video": map[string]any{"url": ref}}
|
||||
}
|
||||
if np != nil {
|
||||
msgs[mi].Content = np
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// hasInlineVideos : au moins une vidéo encore en base64 dans ces messages ?
|
||||
func hasInlineVideos(msgs []Message) bool {
|
||||
for _, m := range msgs {
|
||||
for _, p := range contentParts(m.Content) {
|
||||
if strings.HasPrefix(partVideoURL(p), "data:video/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expandVideoRefs renvoie une COPIE des messages prête pour le modèle : chaque
|
||||
// référence de vidéo redevient sa data-URL. Les messages sans référence sont
|
||||
// partagés tels quels. Une vidéo introuvable devient une mention textuelle.
|
||||
func expandVideoRefs(msgs []Message) []Message {
|
||||
out := msgs
|
||||
copied := false
|
||||
for i, m := range msgs {
|
||||
parts := contentParts(m.Content)
|
||||
has := false
|
||||
for _, p := range parts {
|
||||
if strings.HasPrefix(partVideoURL(p), vidRefScheme) {
|
||||
has = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
if !copied {
|
||||
out = append([]Message(nil), msgs...)
|
||||
copied = true
|
||||
}
|
||||
np := make([]map[string]any, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
u := partVideoURL(p)
|
||||
if !strings.HasPrefix(u, vidRefScheme) {
|
||||
np = append(np, p)
|
||||
continue
|
||||
}
|
||||
if d, ok := chatVideoDataURL(u); ok {
|
||||
np = append(np, map[string]any{"type": "input_video", "input_video": map[string]any{"url": d}})
|
||||
} else {
|
||||
np = append(np, map[string]any{"type": "text", "text": "[vidéo indisponible : fichier supprimé]"})
|
||||
}
|
||||
}
|
||||
m.Content = np
|
||||
out[i] = m
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// videoEngine simule un moteur qui sait (ou non) lire la vidéo.
|
||||
func videoEngine(t *testing.T, ok bool) {
|
||||
t.Helper()
|
||||
prev := videoInputSupported
|
||||
videoInputSupported = func() bool { return ok }
|
||||
t.Cleanup(func() { videoInputSupported = prev })
|
||||
}
|
||||
|
||||
// engineSees amorce le cache de la sonde /props : aucun llama-server ne tourne
|
||||
// pendant les tests.
|
||||
func engineSees(t *testing.T, seen bool) {
|
||||
t.Helper()
|
||||
visionProbeMu.Lock()
|
||||
visionProbeAt, visionProbeSeen = time.Now(), seen
|
||||
visionProbeMu.Unlock()
|
||||
t.Cleanup(func() { visionProbeMu.Lock(); visionProbeAt = time.Time{}; visionProbeMu.Unlock() })
|
||||
}
|
||||
|
||||
// Une vidéo en base64 part sur disque et revient identique à l'envoi au modèle ;
|
||||
// l'historique ne garde qu'une référence de quelques octets.
|
||||
func TestVideoRefsRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
raw := []byte(strings.Repeat("\x00\x00\x00\x18ftypmp42-fake-video-bytes", 5000))
|
||||
url := "data:video/mp4;base64," + base64.StdEncoding.EncodeToString(raw)
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde la vidéo"},
|
||||
{"type": "input_video", "input_video": map[string]any{"url": url}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
// Relu depuis le JSON (forme []any), comme après un redémarrage.
|
||||
var reread []Message
|
||||
b, _ := json.Marshal(msgs)
|
||||
_ = json.Unmarshal(b, &reread)
|
||||
|
||||
for _, set := range [][]Message{msgs, reread} {
|
||||
orig := contentParts(set[0].Content)[1]
|
||||
if !hasInlineVideos(set) || !refVideosInMessages(set) || hasInlineVideos(set) {
|
||||
t.Fatal("la vidéo aurait dû être rangée par référence")
|
||||
}
|
||||
// Recopiée, jamais mutée : un runChat en vol peut lire ces mêmes maps.
|
||||
if partVideoURL(orig) != url {
|
||||
t.Fatal("refVideosInMessages a muté la partie d'origine")
|
||||
}
|
||||
js, _ := json.Marshal(set)
|
||||
if len(js) > 500 || !strings.Contains(string(js), vidRefScheme) {
|
||||
t.Fatalf("historique encore lourd : %d octets", len(js))
|
||||
}
|
||||
out := expandVideoRefs(set)
|
||||
if got := partVideoURL(contentParts(out[0].Content)[1]); got != url {
|
||||
t.Fatal("la vidéo renvoyée au modèle diffère de l'originale")
|
||||
}
|
||||
// L'historique lui-même n'est pas modifié par l'expansion.
|
||||
if u := partVideoURL(contentParts(set[0].Content)[1]); !strings.HasPrefix(u, vidRefScheme) {
|
||||
t.Fatal("expandVideoRefs ne doit pas toucher l'historique")
|
||||
}
|
||||
}
|
||||
// Fichier effacé (ménage) : une mention, pas une requête qui échoue.
|
||||
_ = os.RemoveAll(chatVidDir())
|
||||
vidCacheMu.Lock()
|
||||
vidCache, vidCacheOrder, vidCacheBytes = map[string]string{}, nil, 0
|
||||
vidCacheMu.Unlock()
|
||||
out := expandVideoRefs(msgs)
|
||||
if p := contentParts(out[0].Content)[1]; p["type"] != "text" || !strings.Contains(p["text"].(string), "indisponible") {
|
||||
t.Fatalf("vidéo disparue : %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// userMessageContent : une vidéo jointe devient une partie input_video (et une
|
||||
// image jointe reste une partie image_url), quand la vision est active et que
|
||||
// le moteur lit la vidéo.
|
||||
func TestUserMessageContentVideo(t *testing.T) {
|
||||
videoEngine(t, true)
|
||||
visionOn(t)
|
||||
engineSees(t, true)
|
||||
dir, err := uploadsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "clip-test.mp4"), []byte("fake-mp4-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "photo.png"), pngPixel, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := []attachInfo{
|
||||
{Name: "clip-test.mp4", Path: "uploads/clip-test.mp4", Size: 14},
|
||||
{Name: "photo.png", Path: "uploads/photo.png", Size: int64(len(pngPixel))},
|
||||
}
|
||||
parts, ok := userMessageContent(in, "décris ce clip").([]map[string]any)
|
||||
if !ok || len(parts) != 3 {
|
||||
t.Fatalf("attendu texte + image + vidéo, obtenu %#v", parts)
|
||||
}
|
||||
if parts[0]["type"] != "text" || parts[1]["type"] != "image_url" || parts[2]["type"] != "input_video" {
|
||||
t.Fatalf("ordre des parties : %v %v %v", parts[0]["type"], parts[1]["type"], parts[2]["type"])
|
||||
}
|
||||
if u := partVideoURL(parts[2]); !strings.HasPrefix(u, "data:video/mp4;base64,") {
|
||||
t.Fatalf("data URI vidéo inattendue : %.40q", u)
|
||||
}
|
||||
// Montrée, elle n'est plus annoncée comme simple fichier.
|
||||
if txt := parts[0]["text"].(string); strings.Contains(txt, "clip-test.mp4") || strings.Contains(txt, "ffmpeg") {
|
||||
t.Fatalf("vidéo vue annoncée comme fichier : %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// Moteur sans vidéo (API externe, Strata, llama.cpp ancien) : il ignorait la
|
||||
// partie input_video sans erreur et le modèle ne recevait que le texte. La
|
||||
// vidéo doit alors être annoncée comme fichier, avec la marche à suivre
|
||||
// (ffmpeg puis see_image), et see_video doit le dire au lieu de charger.
|
||||
func TestVideoUnsupportedEngine(t *testing.T) {
|
||||
videoEngine(t, false)
|
||||
visionOn(t)
|
||||
engineSees(t, true)
|
||||
dir, err := uploadsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
vname := "clip-test.mp4"
|
||||
if err := os.WriteFile(filepath.Join(dir, vname), []byte("fake-mp4-bytes"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := userMessageContent([]attachInfo{{Name: vname, Path: "uploads/" + vname, Size: 14}}, "décris ce clip")
|
||||
s, ok := content.(string)
|
||||
if !ok {
|
||||
t.Fatalf("attendu du texte seul (pas de partie input_video), obtenu %T", content)
|
||||
}
|
||||
if !strings.Contains(s, "uploads/"+vname) || !strings.Contains(s, "ffmpeg") || !strings.HasSuffix(s, "décris ce clip") {
|
||||
t.Fatalf("chemin et consigne ffmpeg attendus : %q", s)
|
||||
}
|
||||
if msg, part := toolSeeVideo(filepath.Join(dir, vname)); part != nil || !strings.Contains(msg, "ffmpeg") {
|
||||
t.Fatalf("see_video doit refuser avec la consigne ffmpeg : %q", msg)
|
||||
}
|
||||
// Ni outil annoncé, ni consigne quand ce n'est pas une vidéo.
|
||||
for _, tl := range EnabledTools(Caps{Agent: true}) {
|
||||
if tl.Function.Name == "see_video" {
|
||||
t.Fatal("see_video annoncé alors que le moteur ne lit pas la vidéo")
|
||||
}
|
||||
}
|
||||
if s := attachNote([]attachInfo{{Name: "a.txt", Path: "uploads/a.txt", Size: 1}}); strings.Contains(s, "ffmpeg") {
|
||||
t.Fatalf("consigne vidéo sur un fichier texte : %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// see_video : annoncé avec see_image quand le moteur lit la vidéo.
|
||||
func TestSeeVideoAnnonce(t *testing.T) {
|
||||
videoEngine(t, true)
|
||||
visionOn(t)
|
||||
has := func(caps Caps) bool {
|
||||
for _, tl := range EnabledTools(caps) {
|
||||
if tl.Function.Name == "see_video" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if !has(Caps{Agent: true}) {
|
||||
t.Fatal("see_video absent alors que le moteur lit la vidéo")
|
||||
}
|
||||
if has(Caps{}) {
|
||||
t.Fatal("see_video hors du mode agent")
|
||||
}
|
||||
if toolCallLabel("see_video", map[string]any{"file": "a.mp4"}) != "a.mp4" {
|
||||
t.Fatal("libellé de see_video vide : l'outil s'exécuterait sur une chaîne vide")
|
||||
}
|
||||
if dedupableTool("see_video") {
|
||||
t.Fatal("see_video dédupliqué : le rappel rendrait « déjà fait » sans la vidéo")
|
||||
}
|
||||
}
|
||||
|
||||
// toolSeeVideo : chaque refus nomme son motif ; un mp4 présent est chargé et
|
||||
// relayé dans un message utilisateur qui nomme le fichier.
|
||||
func TestToolSeeVideo(t *testing.T) {
|
||||
videoEngine(t, true)
|
||||
home := visionOn(t)
|
||||
engineSees(t, true)
|
||||
if err := os.MkdirAll(filepath.Join(home, "dossier.mp4"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gros := filepath.Join(home, "gros.mp4")
|
||||
f, _ := os.Create(gros)
|
||||
_ = f.Truncate(maxVideoBytes + 1)
|
||||
f.Close()
|
||||
_ = os.WriteFile(filepath.Join(home, "vide.webm"), nil, 0o600)
|
||||
cas := []struct{ path, attendu string }{
|
||||
{"", "chemin de fichier manquant"},
|
||||
{filepath.Join(home, "notes.txt"), "format non reconnu comme vidéo"},
|
||||
{filepath.Join(home, "nulle-part.mp4"), "introuvable"},
|
||||
{filepath.Join(home, "dossier.mp4"), "dossier"},
|
||||
{gros, "trop lourde"},
|
||||
{filepath.Join(home, "vide.webm"), "fichier vide"},
|
||||
}
|
||||
for _, c := range cas {
|
||||
msg, part := toolSeeVideo(c.path)
|
||||
if part != nil || !strings.HasPrefix(msg, "[erreur]") || !strings.Contains(msg, c.attendu) {
|
||||
t.Errorf("%q : %q, attendu une erreur contenant %q", c.path, msg, c.attendu)
|
||||
}
|
||||
}
|
||||
p := filepath.Join(home, "clip.MOV")
|
||||
if err := os.WriteFile(p, []byte("fake-mov"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg, part := toolSeeVideo(p)
|
||||
if part == nil || msg != "[ok] vidéo chargée : clip.MOV" {
|
||||
t.Fatalf("attendu un [ok] : %q / %v", msg, part)
|
||||
}
|
||||
if u := partVideoURL(part); u != "data:video/quicktime;base64,"+base64.StdEncoding.EncodeToString([]byte("fake-mov")) {
|
||||
t.Fatalf("partie inattendue : %v", part)
|
||||
}
|
||||
// Le moteur en service ne voit pas : on le dit au lieu d'envoyer 200 Mio.
|
||||
engineSees(t, false)
|
||||
if msg, part := toolSeeVideo(p); part != nil || !strings.Contains(msg, "ne traite pas les images") {
|
||||
t.Fatalf("moteur aveugle : %q", msg)
|
||||
}
|
||||
// Relais : passé par l'API interne comme see_image (toolStepExtras).
|
||||
_, _, relay := toolStepExtras("see_video", "clip.MOV", toolExecResult{Result: "[ok]", VisionVideo: videoPart([]byte("x"), "video/mp4")})
|
||||
if relay == nil {
|
||||
t.Fatal("aucun relais pour see_video")
|
||||
}
|
||||
rp := contentParts(relay.Content)
|
||||
if relay.Role != "user" || rp[0]["text"] != "Vidéo demandée (clip.MOV) :" || rp[1]["type"] != "input_video" {
|
||||
t.Fatalf("relais inattendu : %#v", relay)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans projecteur ou sur un poste distant : refus explicite, avant toute
|
||||
// question au moteur.
|
||||
func TestSeeVideoSansVision(t *testing.T) {
|
||||
videoEngine(t, true)
|
||||
testHome(t)
|
||||
if msg, part := toolSeeVideo("a.mp4"); part != nil || !strings.Contains(msg, "MMPROJ") {
|
||||
t.Fatalf("sans vision : %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// videoInputSupported : jamais pour une API externe, Strata, un preset sans
|
||||
// projecteur ou sans moteur ; sinon, l'aide du binaire fait foi.
|
||||
func TestVideoInputSupportedPresets(t *testing.T) {
|
||||
testHome(t)
|
||||
cas := []map[string]string{
|
||||
{"BIN": "llama-server", "MODEL": "m.gguf"}, // pas de projecteur
|
||||
{"MMPROJ": "mm.gguf", "MODEL": "m.gguf"}, // pas de moteur
|
||||
{"MMPROJ": "mm.gguf", "BIN": "introuvable/llama-server"}, // moteur absent
|
||||
{"MMPROJ": "mm.gguf", extKeyFlag: "1", extKeyVision: "1"}, // API externe
|
||||
{"MMPROJ": "mm.gguf", "ENGINE": engineStrata, "BIN": "/bin/sh", "MODEL": "m"}, // Strata
|
||||
}
|
||||
for _, cfg := range cas {
|
||||
if err := WriteConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if videoInputSupported() {
|
||||
t.Errorf("%v : vidéo annoncée", cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// L'aide du binaire décide, relue quand le binaire change (mise à jour en place).
|
||||
func TestBinHelpHasVideo(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("faux moteur en sh")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "llama-server")
|
||||
write := func(help string, mt time.Time) {
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho '"+help+"'\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.Chtimes(bin, mt, mt)
|
||||
}
|
||||
write("--mmproj FILE", time.Now().Add(-time.Hour))
|
||||
if binHelpHasVideo(bin) {
|
||||
t.Fatal("moteur sans --video-fps annoncé comme lisant la vidéo")
|
||||
}
|
||||
write("--video-fps N target fps", time.Now())
|
||||
if !binHelpHasVideo(bin) {
|
||||
t.Fatal("moteur mis à jour : --video-fps non relu")
|
||||
}
|
||||
}
|
||||
|
||||
// Compaction, fil rechargé, export : la vidéo n'est jamais rejouée ni
|
||||
// recopiée, mais sa perte reste visible.
|
||||
func TestVideoPerdueVisible(t *testing.T) {
|
||||
part := videoPart([]byte(strings.Repeat("v", 3000)), "video/mp4")
|
||||
m := Message{Role: "user", Content: []map[string]any{{"type": "text", "text": "regarde"}, part}}
|
||||
if got := msgText(m); got != "regarde"+videoLostMarker {
|
||||
t.Fatalf("msgText : %q", got)
|
||||
}
|
||||
var reread []Message
|
||||
b, _ := json.Marshal([]Message{m})
|
||||
_ = json.Unmarshal(b, &reread)
|
||||
out := stripImageParts(reread)
|
||||
if s, ok := out[0].Content.(string); !ok || s != "regarde"+videoLostMarker {
|
||||
t.Fatalf("fil rechargé : %#v", out[0].Content)
|
||||
}
|
||||
exp := stripInlineImages([]Message{m})
|
||||
js, _ := json.Marshal(exp)
|
||||
if len(js) > 400 || !strings.Contains(string(js), "élidé") {
|
||||
t.Fatalf("export : %s", js)
|
||||
}
|
||||
if partVideoURL(contentParts(m.Content)[1]) == "" || !strings.HasPrefix(partVideoURL(part), "data:video/mp4;base64,") {
|
||||
t.Fatal("l'export a touché le message vivant")
|
||||
}
|
||||
}
|
||||
|
||||
// Le ménage des médias vide aussi les vidéos jointes trop vieilles.
|
||||
func TestPruneChatVideos(t *testing.T) {
|
||||
testHome(t)
|
||||
ref, err := storeChatVideo([]byte("vieille"), "video/webm")
|
||||
if err != nil || !strings.HasSuffix(ref, ".webm") {
|
||||
t.Fatalf("rangement : %q %v", ref, err)
|
||||
}
|
||||
p := filepath.Join(chatVidDir(), strings.TrimPrefix(ref, vidRefScheme))
|
||||
old := time.Now().Add(-2 * chatImgKeep)
|
||||
_ = os.Chtimes(p, old, old)
|
||||
pruneChatImages()
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatal("vieille vidéo gardée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package loki
|
||||
|
||||
// chat_video_tool.go — l'outil see_video (repris d'AJEAN) : le modèle charge
|
||||
// lui-même un fichier vidéo du disque dans sa VISION, sans que l'utilisateur ait
|
||||
// à le joindre. Même mécanique que see_image (chat_vision_tool.go) : le résultat
|
||||
// de l'outil reste un accusé texte, la vidéo part juste après dans un message
|
||||
// utilisateur multimodal (partie input_video), que llama-server décode en
|
||||
// images avec ffmpeg. Toujours ÉPHÉMÈRE : jamais dans l'historique.
|
||||
//
|
||||
// Annoncé seulement quand le moteur sait lire la vidéo (videoInputSupported) ;
|
||||
// appelé quand même, il le dit avec la marche à suivre (ffmpeg puis see_image).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// toolSeeVideo lit un fichier vidéo et renvoie (accusé texte, partie input_video).
|
||||
// La partie vaut nil en cas d'erreur : l'appelant n'injecte alors rien. Même
|
||||
// ordre de refus que toolSeeImage, la sonde du moteur en dernier.
|
||||
func toolSeeVideo(path string) (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une vidéo", nil
|
||||
}
|
||||
if agentTargetSlug() != "" {
|
||||
return "[erreur] voir une vidéo n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
|
||||
}
|
||||
if path == "" {
|
||||
return "[erreur] chemin de fichier manquant", nil
|
||||
}
|
||||
if !videoInputSupported() {
|
||||
return "[erreur] " + strings.TrimSuffix(videoFallbackNote, "\n"), nil
|
||||
}
|
||||
abs := resolveAgentPath(path)
|
||||
mime := videoMime(abs)
|
||||
if mime == "" {
|
||||
return "[erreur] format non reconnu comme vidéo (attendu : mp4, webm, mov, mkv, avi) : " + path, nil
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return "[erreur] fichier introuvable : " + path, nil
|
||||
}
|
||||
if st.IsDir() {
|
||||
return "[erreur] c'est un dossier, pas une vidéo : " + path, nil
|
||||
}
|
||||
if st.Size() > maxVideoBytes {
|
||||
return fmt.Sprintf("[erreur] vidéo trop lourde (%s, max %s) — coupe-la en un clip plus court : %s",
|
||||
humanBytes(st.Size()), humanBytes(maxVideoBytes), path), nil
|
||||
}
|
||||
if !engineSeesImages() {
|
||||
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "[erreur] lecture impossible : " + err.Error(), nil
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return "[erreur] fichier vide : " + path, nil
|
||||
}
|
||||
return "[ok] vidéo chargée : " + filepath.Base(abs), videoPart(b, mime)
|
||||
}
|
||||
|
||||
// seeVideoMessage emballe la partie vidéo dans le message utilisateur qui la
|
||||
// porte jusqu'au modèle ; la légende nomme le fichier (voir seeImageMessage).
|
||||
func seeVideoMessage(label string, vid map[string]any) Message {
|
||||
text := "Vidéo :"
|
||||
if label != "" {
|
||||
text = "Vidéo demandée (" + label + ") :"
|
||||
}
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": text},
|
||||
vid,
|
||||
}}
|
||||
}
|
||||
|
||||
// seeVideoTool — schéma envoyé au modèle, tenu court comme seeImageTool.
|
||||
func seeVideoTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "see_video",
|
||||
Description: "Ouvre un fichier vidéo du disque pour la REGARDER (mp4, webm, mov, mkv, avi). Clips courts.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Chemin de la vidéo (relatif au dossier de travail, ou absolu)"},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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],
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -152,10 +152,19 @@ func attachNote(files []attachInfo) string {
|
||||
head = "Fichiers joints à ce message, déposés dans ton dossier de travail :"
|
||||
}
|
||||
var lines []string
|
||||
video := false
|
||||
for _, f := range files {
|
||||
lines = append(lines, fmt.Sprintf("- %s (%s)", f.Path, humanBytes(f.Size)))
|
||||
video = video || videoMime(f.Name) != ""
|
||||
}
|
||||
return head + "\n" + strings.Join(lines, "\n") + "\n\n"
|
||||
note := head + "\n" + strings.Join(lines, "\n") + "\n"
|
||||
// Vidéo jointe que le moteur ne sait pas lire (chat_video.go) : dire comment
|
||||
// la regarder quand même, au lieu de laisser le modèle tâtonner — Strata et
|
||||
// les API externes ignoraient la vidéo en silence.
|
||||
if video && visionEnabled() && !videoInputSupported() {
|
||||
note += videoFallbackNote
|
||||
}
|
||||
return note + "\n"
|
||||
}
|
||||
|
||||
// imageMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme image (contenu
|
||||
@@ -189,9 +198,11 @@ func visionEnabled() bool {
|
||||
|
||||
// userMessageContent construit le champ Content du message utilisateur. Cas
|
||||
// courant : une simple chaîne (note des fichiers joints + texte). Quand la vision
|
||||
// est active ET qu'au moins une pièce jointe est une image, on renvoie le format
|
||||
// multimodal d'OpenAI — une partie `text` suivie d'une partie `image_url` par
|
||||
// image (data URI base64) — que llama-server comprend une fois --mmproj chargé :
|
||||
// est active ET qu'au moins une pièce jointe est une image (ou une vidéo que le
|
||||
// moteur sait lire), on renvoie le format multimodal d'OpenAI — une partie
|
||||
// `text` suivie d'une partie `image_url` par image (data URI base64) et d'une
|
||||
// partie `input_video` par vidéo — que llama-server comprend une fois --mmproj
|
||||
// chargé :
|
||||
// le modèle VOIT alors l'image au lieu de devoir l'ouvrir comme un fichier binaire.
|
||||
// Les images ainsi intégrées sortent de la note textuelle (inutile de dire au
|
||||
// modèle d'aller ouvrir un fichier qu'il a déjà sous les yeux) ; les autres
|
||||
@@ -209,31 +220,43 @@ func userMessageContent(files []attachInfo, prompt string) any {
|
||||
if err != nil {
|
||||
return attachNote(files) + prompt
|
||||
}
|
||||
var imgParts []map[string]any
|
||||
var imgParts, vidParts []map[string]any
|
||||
var textFiles []attachInfo
|
||||
for _, f := range files {
|
||||
mime := imageMime(f.Name)
|
||||
if mime == "" {
|
||||
textFiles = append(textFiles, f)
|
||||
if mime := imageMime(f.Name); mime != "" {
|
||||
b, err := os.ReadFile(filepath.Join(dir, f.Name))
|
||||
if err != nil {
|
||||
textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier
|
||||
continue
|
||||
}
|
||||
// Photo prise au téléphone : l'orientation vit dans un tag EXIF que le
|
||||
// projecteur ignore (il voit les pixels bruts, donc l'image couchée), et
|
||||
// une image de plusieurs milliers de pixels ne fait que gonfler le base64
|
||||
// — voir prepareImageForModel (web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
imgParts = append(imgParts, imageURLPart(b, mime))
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, f.Name))
|
||||
if err != nil {
|
||||
textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier
|
||||
// Vidéo : seulement si le moteur sait la lire (chat_video.go) ; sinon
|
||||
// elle reste un fichier, annoncé avec la marche à suivre (attachNote).
|
||||
// Pas de redimensionnement : le moteur échantillonne les images lui-même.
|
||||
if mime := videoMime(f.Name); mime != "" && videoInputSupported() {
|
||||
b, err := os.ReadFile(filepath.Join(dir, f.Name))
|
||||
if err != nil || len(b) == 0 || len(b) > maxVideoBytes {
|
||||
textFiles = append(textFiles, f)
|
||||
continue
|
||||
}
|
||||
vidParts = append(vidParts, videoPart(b, mime))
|
||||
continue
|
||||
}
|
||||
// Photo prise au téléphone : l'orientation vit dans un tag EXIF que le
|
||||
// projecteur ignore (il voit les pixels bruts, donc l'image couchée), et
|
||||
// une image de plusieurs milliers de pixels ne fait que gonfler le base64
|
||||
// — voir prepareImageForModel (web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
imgParts = append(imgParts, imageURLPart(b, mime))
|
||||
textFiles = append(textFiles, f)
|
||||
}
|
||||
if len(imgParts) == 0 {
|
||||
if len(imgParts) == 0 && len(vidParts) == 0 {
|
||||
return attachNote(files) + prompt
|
||||
}
|
||||
parts := []map[string]any{{"type": "text", "text": attachNote(textFiles) + prompt}}
|
||||
return append(parts, imgParts...)
|
||||
parts = append(parts, imgParts...)
|
||||
return append(parts, vidParts...)
|
||||
}
|
||||
|
||||
// workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et,
|
||||
|
||||
@@ -58,6 +58,9 @@ pub const COMPACT_SUMMARY_PREFIX: &str = "[CONTEXT COMPACTED]";
|
||||
/// Remplace la partie `image_url` d'un message multimodal dans [`msg_text`].
|
||||
pub const IMAGE_LOST_MARKER: &str = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]";
|
||||
|
||||
/// `videoLostMarker` : remplace la partie `input_video` (chat_video.go).
|
||||
pub const VIDEO_LOST_MARKER: &str = " [video — not kept in the conversation past this point; call see_video on the file again if you still need to watch it]";
|
||||
|
||||
/// La réponse assistant synthétique qui suit le résumé.
|
||||
pub const COMPACT_ACK_MSG: &str = "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done.";
|
||||
|
||||
@@ -265,6 +268,7 @@ pub fn msg_text(m: &Message) -> String {
|
||||
}
|
||||
}
|
||||
Some("image_url") => b.push_str(IMAGE_LOST_MARKER),
|
||||
Some("input_video") => b.push_str(VIDEO_LOST_MARKER),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
+101
-16
@@ -14,8 +14,9 @@
|
||||
//! - compaction par [`Compactor`] : résumé demandé au moteur
|
||||
//! ([`EngineSummarizer`]), blocs archivés pour recall par le Go
|
||||
//! ([`StoreRecall`]) ;
|
||||
//! - images jointes (`loki-img:…`) lues sur disque et ouvertes par le Go
|
||||
//! (déchiffrées), gardées en cache comme `chatImageDataURL` ;
|
||||
//! - images et vidéos jointes (`loki-img:…`, `loki-vid:…`) lues sur disque
|
||||
//! et ouvertes par le Go (déchiffrées), gardées en cache comme
|
||||
//! `chatImageDataURL` / `chatVideoDataURL` ;
|
||||
//! - slots du moteur (lot 31) : avant chaque requête au moteur local, le Go
|
||||
//! rend le slot où l'envoyer (SIDE_SLOT) et recharge au besoin l'état
|
||||
//! gardé de la discussion (SLOT_PERSIST) ; la requête est comptée en vol,
|
||||
@@ -46,8 +47,8 @@ use super::stream::{CacheHintFn, PreflightFn, RetryPatterns};
|
||||
use super::turn::{CompactPhase, PerfMarks, PerfRec, Reply, SendError, ToolOutcome, TurnHost};
|
||||
use super::types::{AskEvent, DiffLine, Message, Tool};
|
||||
use super::wire::{
|
||||
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME,
|
||||
build_chat_payload, content_parts, part_image_url, wire_messages,
|
||||
ChatEndpoint, ChatPayloadOpts, EchoRefusals, EffortFallbacks, IMG_REF_SCHEME, VID_REF_SCHEME,
|
||||
build_chat_payload, content_parts, part_image_url, part_video_url, wire_messages,
|
||||
};
|
||||
use crate::store::{BK_STATE, Store};
|
||||
|
||||
@@ -97,10 +98,32 @@ fn image_mime(name: &str) -> &'static str {
|
||||
|
||||
/// Nom d'une image rangée, si la référence est sûre (ni chemin ni `..`).
|
||||
fn image_name(r: &str) -> Option<&str> {
|
||||
let name = r.strip_prefix(IMG_REF_SCHEME)?;
|
||||
safe_media_name(r.strip_prefix(IMG_REF_SCHEME)?)
|
||||
}
|
||||
|
||||
fn safe_media_name(name: &str) -> Option<&str> {
|
||||
(!name.is_empty() && !name.contains(['/', '\\']) && !name.contains("..")).then_some(name)
|
||||
}
|
||||
|
||||
/// Média rangé : (clé du cache, fichier relatif à `LOKI_HOME`, type MIME).
|
||||
/// Une image a pour clé son nom (dossier `chatimg`), une vidéo jointe
|
||||
/// (`loki-vid:`, chat_video.go) sa référence entière (dossier `chatvid`).
|
||||
fn media_ref(r: &str) -> Option<(String, PathBuf, &'static str)> {
|
||||
if let Some(name) = image_name(r) {
|
||||
return Some((
|
||||
name.to_string(),
|
||||
Path::new("chatimg").join(name),
|
||||
image_mime(name),
|
||||
));
|
||||
}
|
||||
let name = safe_media_name(r.strip_prefix(VID_REF_SCHEME)?)?;
|
||||
let mime = match crate::imagetools::video_mime(name) {
|
||||
"" => "video/mp4",
|
||||
m => m,
|
||||
};
|
||||
Some((r.to_string(), Path::new("chatvid").join(name), mime))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ImgCacheInner {
|
||||
map: HashMap<String, String>,
|
||||
@@ -129,9 +152,10 @@ impl ImageCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Data-URL d'une image (`loki-img:`) ou d'une vidéo (`loki-vid:`) rangée.
|
||||
pub fn get(&self, r: &str) -> Option<String> {
|
||||
let name = image_name(r)?;
|
||||
self.inner.lock().ok()?.map.get(name).cloned()
|
||||
let (key, _, _) = media_ref(r)?;
|
||||
self.inner.lock().ok()?.map.get(&key).cloned()
|
||||
}
|
||||
|
||||
fn put(&self, name: &str, url: String) {
|
||||
@@ -151,27 +175,30 @@ impl ImageCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lit et ouvre (par le Go) les images référencées pas encore en cache.
|
||||
/// Une image illisible reste absente : la requête dira « indisponible ».
|
||||
/// Lit et ouvre (par le Go) les images et vidéos référencées pas encore
|
||||
/// en cache. Un média illisible reste absent : la requête dira
|
||||
/// « indisponible ».
|
||||
pub async fn prefetch(&self, store: &Store, msgs: &[Message]) {
|
||||
let home = self.home();
|
||||
for m in msgs {
|
||||
for p in content_parts(m.content.as_ref()) {
|
||||
let Some(name) = image_name(part_image_url(p)) else {
|
||||
let u = match part_image_url(p) {
|
||||
"" => part_video_url(p),
|
||||
u => u,
|
||||
};
|
||||
let Some((key, rel, mime)) = media_ref(u) else {
|
||||
continue;
|
||||
};
|
||||
if self.inner.lock().is_ok_and(|g| g.map.contains_key(name)) {
|
||||
if self.inner.lock().is_ok_and(|g| g.map.contains_key(&key)) {
|
||||
continue;
|
||||
}
|
||||
let Ok(raw) = tokio::fs::read(self.dir.join(name)).await else {
|
||||
let Ok(raw) = tokio::fs::read(home.join(rel)).await else {
|
||||
continue;
|
||||
};
|
||||
let Ok(b) = store.open(raw).await else {
|
||||
continue;
|
||||
};
|
||||
self.put(
|
||||
name,
|
||||
format!("data:{};base64,{}", image_mime(name), b64(&b)),
|
||||
);
|
||||
self.put(&key, format!("data:{mime};base64,{}", b64(&b)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -586,6 +613,11 @@ pub fn tool_outcome(v: &Value, label: &str) -> ToolOutcome {
|
||||
{
|
||||
relay = Some(see_image_message(label, img.clone()));
|
||||
}
|
||||
if relay.is_none()
|
||||
&& let Some(vid) = v.get("vision_video").filter(|i| i.is_object())
|
||||
{
|
||||
relay = Some(see_video_message(label, vid.clone()));
|
||||
}
|
||||
ToolOutcome {
|
||||
result: s("result"),
|
||||
diff: v
|
||||
@@ -617,6 +649,20 @@ pub fn see_image_message(label: &str, img: Value) -> Message {
|
||||
}
|
||||
}
|
||||
|
||||
/// `seeVideoMessage` : la vidéo relayée au modèle.
|
||||
pub fn see_video_message(label: &str, vid: Value) -> Message {
|
||||
let text = if label.is_empty() {
|
||||
"Vidéo :".to_string()
|
||||
} else {
|
||||
format!("Vidéo demandée ({label}) :")
|
||||
};
|
||||
Message {
|
||||
role: "user".into(),
|
||||
content: Some(json!([{"type": "text", "text": text}, vid])),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl GoTurnHost {
|
||||
/// Les outils exécutés par la façade, sauf `subagent` ; le reste par le
|
||||
/// Go (`/tool/exec`). Aussi celui des tours d'un sous-agent.
|
||||
@@ -751,6 +797,45 @@ impl GoTurnHost {
|
||||
};
|
||||
}
|
||||
}
|
||||
// see_video (chat_video_tool.go) : comme see_image, dans le dossier de
|
||||
// la discussion ; la vidéo relayée au modèle, toujours éphémère.
|
||||
if name == "see_video" {
|
||||
let cfg = self.shared.store.config().await;
|
||||
let vision = crate::system::vision_enabled(&cfg);
|
||||
let st = crate::tools::GoToolState {
|
||||
store: self.shared.store.clone(),
|
||||
caps: self.caps.clone(),
|
||||
};
|
||||
match crate::tools::ToolState::context(&st).await {
|
||||
Ok(ctx) => {
|
||||
let home = self.shared.images.home();
|
||||
let vcfg = cfg.clone();
|
||||
let video_ok = tokio::task::spawn_blocking(move || {
|
||||
crate::imagetools::video_input_supported(&vcfg, &home)
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
let sees = self.shared.vision.sees(&self.shared.store, &cfg);
|
||||
let (result, vid) = crate::imagetools::see_video(
|
||||
label,
|
||||
&ctx.cwd,
|
||||
&ctx.target,
|
||||
vision,
|
||||
video_ok,
|
||||
sees,
|
||||
)
|
||||
.await;
|
||||
return ToolOutcome {
|
||||
result,
|
||||
relay: vid.map(|v| see_video_message(label, v)),
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[outils] contexte indisponible, see_video par le Go : {e}")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Navigateur piloté (lot 44) : la session est ici ; aperçu de la page
|
||||
// après une action qui la change (jamais après une erreur), capture
|
||||
// relayée au modèle.
|
||||
|
||||
@@ -141,10 +141,13 @@ pub fn steer_system(msgs: &[Message], hint: &str) -> Vec<Message> {
|
||||
}
|
||||
|
||||
/// `dedupableTool` : un appel identique se court-circuite, sauf bash et ses
|
||||
/// jobs (effets de bord), see_image et le navigateur (état vivant, image à
|
||||
/// part).
|
||||
/// jobs (effets de bord), see_image, see_video et le navigateur (état
|
||||
/// vivant, image ou vidéo à part).
|
||||
pub fn dedupable_tool(name: &str) -> bool {
|
||||
!matches!(name, "bash" | "bash_bg" | "bash_tail" | "see_image") && !name.starts_with("browser_")
|
||||
!matches!(
|
||||
name,
|
||||
"bash" | "bash_bg" | "bash_tail" | "see_image" | "see_video"
|
||||
) && !name.starts_with("browser_")
|
||||
}
|
||||
|
||||
/// `repeatedCallResult` : réponse à un appel RIGOUREUSEMENT identique déjà
|
||||
|
||||
@@ -49,7 +49,7 @@ use serde::{Deserialize, Serialize, Serializer};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::watch;
|
||||
|
||||
use super::compact::{IMAGE_LOST_MARKER, is_loki_injected, without_ctx_update};
|
||||
use super::compact::{IMAGE_LOST_MARKER, VIDEO_LOST_MARKER, is_loki_injected, without_ctx_update};
|
||||
use super::journal::compact_log;
|
||||
use super::types::{LogEvent, Message};
|
||||
use crate::store::{CryptError, Store};
|
||||
@@ -269,24 +269,39 @@ fn decode_list<T: DeserializeOwned>(v: Value) -> Vec<T> {
|
||||
/// `stripImageParts` : un message multimodal persisté (base64 d'une capture
|
||||
/// d'avant les captures éphémères) est aplati en son texte suivi de
|
||||
/// [`IMAGE_LOST_MARKER`] — rejoué à chaque tour, il dépassait le contexte.
|
||||
/// Une vidéo jointe (`input_video`) a le même sort, marquée par
|
||||
/// [`VIDEO_LOST_MARKER`].
|
||||
pub fn strip_image_parts(msgs: &mut [Message]) {
|
||||
for m in msgs.iter_mut() {
|
||||
let Some(Value::Array(parts)) = &m.content else {
|
||||
continue;
|
||||
};
|
||||
let mut texts: Vec<&str> = Vec::new();
|
||||
let mut dropped = false;
|
||||
let (mut dropped, mut dropped_vid) = (false, false);
|
||||
for p in parts.iter().filter_map(Value::as_object) {
|
||||
if p.get("type").and_then(Value::as_str) == Some("image_url") {
|
||||
dropped = true;
|
||||
continue;
|
||||
match p.get("type").and_then(Value::as_str) {
|
||||
Some("image_url") => {
|
||||
dropped = true;
|
||||
continue;
|
||||
}
|
||||
Some("input_video") => {
|
||||
dropped_vid = true;
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(Value::String(t)) = p.get("text") {
|
||||
texts.push(t);
|
||||
}
|
||||
}
|
||||
if dropped {
|
||||
let text = texts.join("\n") + IMAGE_LOST_MARKER;
|
||||
if dropped || dropped_vid {
|
||||
let mut text = texts.join("\n");
|
||||
if dropped {
|
||||
text += IMAGE_LOST_MARKER;
|
||||
}
|
||||
if dropped_vid {
|
||||
text += VIDEO_LOST_MARKER;
|
||||
}
|
||||
m.content = Some(Value::String(text));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ pub fn tool_call_label(name: &str, args: &Map<String, Value>) -> String {
|
||||
match name {
|
||||
"mem_search" | "web_search" | "web_images" | "recall_search" => arg_str(args, "query"),
|
||||
"mem_read" | "mem_add" | "mem_edit" | "edit" | "write" | "read" | "git_diff"
|
||||
| "see_image" => arg_str(args, "file"),
|
||||
| "see_image" | "see_video" => arg_str(args, "file"),
|
||||
"recall" | "bash_tail" => arg_str(args, "id"),
|
||||
"tracker" => (go_str(args.get("action")) + " " + &go_str(args.get("name")))
|
||||
.trim()
|
||||
|
||||
@@ -384,6 +384,13 @@ pub fn side_slot_pool_error(msg: &str) -> bool {
|
||||
&& !REQ_EXCEEDS.is_match(msg)
|
||||
}
|
||||
|
||||
/// Le relais porte-t-il une vidéo (see_video, partie `input_video`) ?
|
||||
fn is_video_relay(m: &Message) -> bool {
|
||||
super::wire::content_parts(m.content.as_ref())
|
||||
.iter()
|
||||
.any(|p| p.get("type").and_then(Value::as_str) == Some("input_video"))
|
||||
}
|
||||
|
||||
fn text_msg(role: &str, text: impl Into<String>) -> Message {
|
||||
Message {
|
||||
role: role.into(),
|
||||
@@ -1071,8 +1078,11 @@ async fn run_turn_loop<H: TurnHost>(
|
||||
push_result(&mut messages, &mut extra, cap_tool_result(&ex.result));
|
||||
if let Some(img) = ex.relay {
|
||||
match kt.as_mut() {
|
||||
// Éphémère : jamais dans l'historique.
|
||||
// Éphémère : jamais dans l'historique. Une vidéo
|
||||
// (see_video) l'est toujours, KEEP_TURN_IMAGES compris :
|
||||
// le rangement ne sait garder que des images.
|
||||
None => messages.push(img),
|
||||
Some(_) if is_video_relay(&img) => messages.push(img),
|
||||
// KEEP_TURN_IMAGES : gardée par référence, à l'essai
|
||||
// jusqu'à la mesure de son coût.
|
||||
Some(k) => {
|
||||
|
||||
@@ -60,6 +60,10 @@ pub const TOOLS_OFF_HINT: &str = "Do not call any more tools. Answer now, direct
|
||||
/// `imgRefScheme` : adresse d'une image rangée sur disque (chat_images.go).
|
||||
pub const IMG_REF_SCHEME: &str = "loki-img:";
|
||||
|
||||
/// `vidRefScheme` : adresse d'une vidéo jointe rangée sur disque
|
||||
/// (chat_video.go).
|
||||
pub const VID_REF_SCHEME: &str = "loki-vid:";
|
||||
|
||||
/// Préfixes des messages système propres au projet ou à la conversation
|
||||
/// (`isProjectSystem`).
|
||||
pub const PROJECT_CONTEXT_PREFIX: &str = "Project context";
|
||||
@@ -308,35 +312,61 @@ pub fn part_image_url(p: &Map<String, Value>) -> &str {
|
||||
.unwrap_or("")
|
||||
}
|
||||
|
||||
/// `expandImageRefs` : COPIE des messages prête pour le modèle — chaque
|
||||
/// référence `loki-img:` redevient sa data-URL (`image_data_url`, qui tient
|
||||
/// lieu de `chatImageDataURL` : disque, cache, déchiffrement). Les messages
|
||||
/// sans référence sont repris tels quels ; aucune référence : pas de copie.
|
||||
/// Une image introuvable devient une mention textuelle.
|
||||
/// `partVideoURL` : l'adresse d'une partie `input_video` (`url`, sinon
|
||||
/// `data`), "" pour toute autre partie (chat_video.go).
|
||||
pub fn part_video_url(p: &Map<String, Value>) -> &str {
|
||||
if p.get("type").and_then(Value::as_str) != Some("input_video") {
|
||||
return "";
|
||||
}
|
||||
let Some(iv) = p.get("input_video").and_then(Value::as_object) else {
|
||||
return "";
|
||||
};
|
||||
match iv.get("url").and_then(Value::as_str).unwrap_or("") {
|
||||
"" => iv.get("data").and_then(Value::as_str).unwrap_or(""),
|
||||
u => u,
|
||||
}
|
||||
}
|
||||
|
||||
/// `expandVideoRefs(expandImageRefs(…))` : COPIE des messages prête pour le
|
||||
/// modèle — chaque référence `loki-img:` ou `loki-vid:` redevient sa
|
||||
/// data-URL (`image_data_url`, qui tient lieu de `chatImageDataURL` et
|
||||
/// `chatVideoDataURL` : disque, cache, déchiffrement). Les messages sans
|
||||
/// référence sont repris tels quels ; aucune référence : pas de copie. Un
|
||||
/// média introuvable devient une mention textuelle.
|
||||
pub fn expand_image_refs<'a>(
|
||||
msgs: &'a [Message],
|
||||
image_data_url: &dyn Fn(&str) -> Option<String>,
|
||||
) -> Cow<'a, [Message]> {
|
||||
let is_ref = |p: &Map<String, Value>| {
|
||||
part_image_url(p).starts_with(IMG_REF_SCHEME)
|
||||
|| part_video_url(p).starts_with(VID_REF_SCHEME)
|
||||
};
|
||||
let mut out: Option<Vec<Message>> = None;
|
||||
for (i, m) in msgs.iter().enumerate() {
|
||||
let parts = content_parts(m.content.as_ref());
|
||||
if !parts
|
||||
.iter()
|
||||
.any(|p| part_image_url(p).starts_with(IMG_REF_SCHEME))
|
||||
{
|
||||
if !parts.iter().any(|p| is_ref(p)) {
|
||||
continue;
|
||||
}
|
||||
let mut np: Vec<Value> = Vec::with_capacity(parts.len());
|
||||
for p in parts {
|
||||
let u = part_image_url(p);
|
||||
if !u.starts_with(IMG_REF_SCHEME) {
|
||||
if !is_ref(p) {
|
||||
np.push(Value::Object(p.clone()));
|
||||
continue;
|
||||
}
|
||||
match image_data_url(u) {
|
||||
Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})),
|
||||
let u = part_image_url(p);
|
||||
if u.starts_with(IMG_REF_SCHEME) {
|
||||
match image_data_url(u) {
|
||||
Some(d) => np.push(json!({"type": "image_url", "image_url": {"url": d}})),
|
||||
None => np.push(
|
||||
json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}),
|
||||
),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match image_data_url(part_video_url(p)) {
|
||||
Some(d) => np.push(json!({"type": "input_video", "input_video": {"url": d}})),
|
||||
None => np.push(
|
||||
json!({"type": "text", "text": "[image indisponible : fichier supprimé]"}),
|
||||
json!({"type": "text", "text": "[vidéo indisponible : fichier supprimé]"}),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -1807,6 +1837,51 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
// --- chat_video_test.go (partie envoi) ----------------------------------
|
||||
|
||||
#[test]
|
||||
fn video_refs_round_trip() {
|
||||
let url = "data:video/mp4;base64,AAAAGGZ0eXA=";
|
||||
let reference = format!("{VID_REF_SCHEME}0123abcd.mp4");
|
||||
let img_ref = format!("{IMG_REF_SCHEME}ab.png");
|
||||
let set = vec![Message {
|
||||
role: "user".into(),
|
||||
content: Some(json!([
|
||||
{"type": "text", "text": "regarde"},
|
||||
{"type": "image_url", "image_url": {"url": img_ref}},
|
||||
{"type": "input_video", "input_video": {"url": reference}},
|
||||
{"type": "input_video", "input_video": {"data": "data:video/webm;base64,AA=="}}
|
||||
])),
|
||||
..Default::default()
|
||||
}];
|
||||
let resolver = |r: &str| {
|
||||
if r == reference {
|
||||
Some(url.to_string())
|
||||
} else if r == img_ref {
|
||||
Some("data:image/png;base64,AA==".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
let out = expand_image_refs(&set, &resolver);
|
||||
let parts = content_parts(out[0].content.as_ref());
|
||||
assert_eq!(part_image_url(parts[1]), "data:image/png;base64,AA==");
|
||||
assert_eq!(part_video_url(parts[2]), url);
|
||||
assert_eq!(parts[2]["type"], "input_video");
|
||||
// Vidéo déjà en ligne (champ data) : reprise telle quelle.
|
||||
assert_eq!(part_video_url(parts[3]), "data:video/webm;base64,AA==");
|
||||
let first = content_parts(set[0].content.as_ref());
|
||||
assert!(
|
||||
part_video_url(first[2]).starts_with(VID_REF_SCHEME),
|
||||
"l'historique n'est pas touché"
|
||||
);
|
||||
let out = expand_image_refs(&set, &|_: &str| None);
|
||||
assert_eq!(
|
||||
content_parts(out[0].content.as_ref())[2]["text"],
|
||||
json!("[vidéo indisponible : fichier supprimé]")
|
||||
);
|
||||
}
|
||||
|
||||
// --- chat_compact_cont_test.go ------------------------------------------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! see_video (repris d'AJEAN) tenu par la façade, rejoué sur le journal du
|
||||
//! Go (`internal/loki/parity_video_tool_test.go` →
|
||||
//! `tests/parity_images/video.json`) : mêmes fichiers, même faux moteur
|
||||
//! (`/props`), même capacité vidéo du moteur, étape par étape. La vidéo
|
||||
//! relayée se compare à l'octet (jamais réencodée).
|
||||
|
||||
mod common;
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use base64::Engine as _;
|
||||
use bytes::Bytes;
|
||||
use common::fakego;
|
||||
use http_body_util::Full;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Request, Response};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use loki_rs::imagetools::{self, VisionProbe};
|
||||
use loki_rs::store::Store;
|
||||
use serde_json::{Map, Value, json};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Le faux moteur : `/props` authentifié, la vision réglée par le test.
|
||||
async fn fake_engine(sees: Arc<Mutex<(bool, u32)>>) -> u16 {
|
||||
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((st, _)) = l.accept().await {
|
||||
let sees = sees.clone();
|
||||
tokio::spawn(async move {
|
||||
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
|
||||
let sees = sees.clone();
|
||||
async move {
|
||||
let ok = req.uri().path() == "/props"
|
||||
&& req
|
||||
.headers()
|
||||
.get("authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
== Some("Bearer cle-moteur");
|
||||
let mut r = Response::new(Full::new(Bytes::new()));
|
||||
if ok {
|
||||
let mut s = sees.lock().unwrap();
|
||||
s.1 += 1;
|
||||
r = Response::new(Full::new(Bytes::from(
|
||||
json!({ "modalities": { "vision": s.0 } }).to_string(),
|
||||
)));
|
||||
} else {
|
||||
*r.status_mut() = hyper::StatusCode::UNAUTHORIZED;
|
||||
}
|
||||
Ok::<_, Infallible>(r)
|
||||
}
|
||||
});
|
||||
let _ = hyper::server::conn::http1::Builder::new()
|
||||
.serve_connection(TokioIo::new(st), svc)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
/// `parityVideoFacts`.
|
||||
fn part_facts(part: &Value, raw: &Map<String, Value>) -> Value {
|
||||
let u = part["input_video"]["url"].as_str().unwrap();
|
||||
let i = u.find(";base64,").unwrap();
|
||||
let b = base64::engine::general_purpose::STANDARD
|
||||
.decode(&u[i + 8..])
|
||||
.unwrap();
|
||||
let mut out = Map::new();
|
||||
out.insert("mime".into(), json!(u[..i].trim_start_matches("data:")));
|
||||
for (name, r) in raw {
|
||||
let rb = base64::engine::general_purpose::STANDARD
|
||||
.decode(r.as_str().unwrap_or(""))
|
||||
.unwrap();
|
||||
if rb == b {
|
||||
out.insert("same_as".into(), json!(name));
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn see_video_comme_le_go() {
|
||||
let fx: Value =
|
||||
serde_json::from_str(include_str!("parity_images/video.json")).expect("journal");
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let files = tmp.path().join("fichiers");
|
||||
let raw = fx["files"].as_object().unwrap();
|
||||
for (name, b) in raw {
|
||||
let p = files.join(name);
|
||||
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&p,
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(b.as_str().unwrap_or(""))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(files.join("dossier.mp4")).unwrap();
|
||||
std::fs::File::create(files.join("lourd.mp4"))
|
||||
.unwrap()
|
||||
.set_len((imagetools::MAX_VIDEO_BYTES + 1) as u64)
|
||||
.unwrap();
|
||||
let files_s = files.to_string_lossy().replace('\\', "/");
|
||||
|
||||
let sees = Arc::new(Mutex::new((false, 0u32)));
|
||||
let port = fake_engine(sees.clone()).await;
|
||||
let (_, go, _stop) = fakego::start(tmp.path().join("home")).await;
|
||||
let backend = go.backend.lock().unwrap().unwrap();
|
||||
let store = Store::new(backend, fakego::JETON.into());
|
||||
go.set("state", "api_key", "cle-moteur");
|
||||
let mut cfg = std::collections::HashMap::new();
|
||||
cfg.insert("PORT".to_string(), port.to_string());
|
||||
let probe = VisionProbe::default();
|
||||
|
||||
for st in fx["steps"].as_array().unwrap() {
|
||||
let name = st["name"].as_str().unwrap();
|
||||
*sees.lock().unwrap() = (st["sees"] == true, 0);
|
||||
probe.reset().await;
|
||||
let label = st["label"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.replace("{{FICHIERS}}", &files_s);
|
||||
let (mut result, vid) = imagetools::see_video(
|
||||
&label,
|
||||
&files,
|
||||
"",
|
||||
st["vision"] == true,
|
||||
st["video"] == true,
|
||||
probe.sees(&store, &cfg),
|
||||
)
|
||||
.await;
|
||||
let relay = vid.map(|v| loki_rs::chat::host::see_video_message(&label, v));
|
||||
let mut facts = None;
|
||||
if let Some(parts) = relay
|
||||
.as_ref()
|
||||
.and_then(|m| m.content.as_ref())
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
for p in parts {
|
||||
if p["type"] == "input_video" {
|
||||
facts = Some(part_facts(p, raw));
|
||||
}
|
||||
if p["type"] == "text" {
|
||||
result += &format!("\n[relais] {}", p["text"].as_str().unwrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
result.replace(&files_s, "{{FICHIERS}}"),
|
||||
st["result"].as_str().unwrap(),
|
||||
"{name}"
|
||||
);
|
||||
assert_eq!(facts, st.get("part").cloned(), "{name} : vidéo relayée");
|
||||
assert_eq!(
|
||||
sees.lock().unwrap().1 as u64,
|
||||
st["probes"].as_u64().unwrap(),
|
||||
"{name} : sondes du moteur"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `videoInputSupported` : jamais pour une API externe, Strata, un preset
|
||||
/// sans projecteur ou sans moteur ; sinon l'aide du binaire fait foi, relue
|
||||
/// quand il change.
|
||||
#[test]
|
||||
fn capacite_video_du_moteur() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = |kv: &[(&str, &str)]| -> std::collections::HashMap<String, String> {
|
||||
kv.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
.collect()
|
||||
};
|
||||
for c in [
|
||||
cfg(&[("BIN", "llama-server"), ("MODEL", "m.gguf")]),
|
||||
cfg(&[("MMPROJ", "mm.gguf"), ("MODEL", "m.gguf")]),
|
||||
cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "absent/llama-server")]),
|
||||
cfg(&[
|
||||
("MMPROJ", "mm.gguf"),
|
||||
("EXTERNAL", "1"),
|
||||
("EXTERNAL_VISION", "1"),
|
||||
]),
|
||||
cfg(&[
|
||||
("MMPROJ", "mm.gguf"),
|
||||
("ENGINE", "strata"),
|
||||
("BIN", "/bin/sh"),
|
||||
]),
|
||||
] {
|
||||
assert!(
|
||||
!imagetools::video_input_supported(&c, tmp.path()),
|
||||
"{c:?} : vidéo annoncée"
|
||||
);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let bin = tmp.path().join("llama-server");
|
||||
let write = |help: &str, age: u64| {
|
||||
std::fs::write(&bin, format!("#!/bin/sh\necho '{help}'\n")).unwrap();
|
||||
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let f = std::fs::File::options().write(true).open(&bin).unwrap();
|
||||
f.set_modified(std::time::SystemTime::now() - std::time::Duration::from_secs(age))
|
||||
.unwrap();
|
||||
};
|
||||
let c = cfg(&[("MMPROJ", "mm.gguf"), ("BIN", "llama-server")]);
|
||||
write("--mmproj FILE", 3600);
|
||||
assert!(!imagetools::video_input_supported(&c, tmp.path()));
|
||||
write("--video-fps N target fps", 0);
|
||||
assert!(
|
||||
imagetools::video_input_supported(&c, tmp.path()),
|
||||
"moteur mis à jour : --video-fps non relu"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn types_de_video() {
|
||||
for (f, m) in [
|
||||
("a.mp4", "video/mp4"),
|
||||
("A.MOV", "video/quicktime"),
|
||||
("x/b.webm", "video/webm"),
|
||||
("c.mkv", "video/x-matroska"),
|
||||
("d.avi", "video/x-msvideo"),
|
||||
("e.png", ""),
|
||||
("mp4", ""),
|
||||
] {
|
||||
assert_eq!(imagetools::video_mime(f), m, "{f}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user