Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5.5 6a6911af27 Bench de la façade : journal régénéré pour le nouvel index.html
Le banc d'essai prend l'index.html embarqué comme corpus de code et en
coupe un morceau au milieu du fichier : les formules LaTeX (CSS et
00-math.js) déplacent ce milieu. Journal tiré sous Linux
(LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteBench) ;
seules changent les tailles et empreintes des textes et les prompt_n
qui en découlent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:28:50 +02:00
MichaelandClaude Opus 5.5 965a4d6247 Formules LaTeX rendues dans les réponses (KaTeX, repris d'AJEAN #123)
00-math.js : $…$, $$…$$, \(…\) et \[…\] mis de côté avant marked (qui
abîmerait \, _ et *), remplacés par un repère puis par le rendu KaTeX.
KaTeX n'est chargé qu'à la première formule ; d'ici là la source
s'affiche, puis est rendue sur place. Le code (blocs et en ligne) et les
montants (« 5 $ et 10 $ », « $5 et $10 », \$) restent du texte.

Logique de détection d'AJEAN, avec deux garde-fous en plus : un $ ouvrant
précédé d'un chiffre et d'une espace est un montant (« 5 $, à 10$ »), et
$…$ ne contient pas de backtick — sans quoi « 5 $, code `$x$` »
s'appariait par-dessus le code en ligne (le cas passe aussi chez AJEAN).

Fonctions pures dans un fichier à part : web_katex_math_test.go les
exécute sous node (ignoré sans node) sur les cas limites, et de bout en
bout avec marked. index.html régénéré par tools/assemble-ui ; NOTICE.md
mentionne la reprise et la licence MIT de KaTeX.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:16 +02:00
MichaelandClaude Opus 5.5 99c96a6c33 KaTeX 0.16.11 embarqué et servi sous /katex/ (Go et façade)
Repris d'AJEAN (d6136caa, #123) : script, feuille de style, polices woff2
et licence MIT sous internal/loki/ui/katex/, embarqués par le go:embed et
par build.rs de la façade. Pas de CDN : l'interface doit marcher hors
ligne.

Route /katex/ des deux côtés avec les mêmes en-têtes : katex.min.js
(application/javascript), katex.min.css (text/css), fonts/*.woff2
(font/woff2), Cache-Control public 1 jour ; script et feuille compressés
en gzip, polices non. Contrairement à AJEAN (http.FileServer), le Go
(web_katex.go) ne sert que ces fichiers, avec un Content-Type fixe : ni
index de dossier, ni type décidé par le registre MIME de l'OS. La façade
(ui.rs) rend les mêmes réponses ; le reste de /katex/ part au Go (404).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No files matched your search

+21 -4
View File
@@ -52,15 +52,32 @@ 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.
Le **rendu des formules LaTeX** dans les réponses (`ui/src/js/00-math.js`,
`web_katex.go`) est repris d'AJEAN (#123) : $…$, $$…$$, \\(…\\) et \\[…\\] rendus
par KaTeX, chargé à la première formule seulement, sans toucher au code ni aux
montants en $. Loki y ajoute deux garde-fous de détection (« 5 $, » et un $
voisin d'un code en ligne restent du texte) et sert la route `/katex/` aussi
depuis la façade Rust (`rust/loki-rs/src/ui.rs`).
## KaTeX
**[KaTeX](https://katex.org/)** 0.16.11 (licence MIT, © Khan Academy et
contributeurs) est embarqué tel que publié — script, feuille de style et
polices woff2 — sous `internal/loki/ui/katex/`, avec sa licence
(`internal/loki/ui/katex/LICENSE`). Il est servi par Loki sous `/katex/`, sans
appel à un CDN.
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.1 »,
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
inclus dans l'image : ils sont téléchargés à la demande.
release `moe-v1.1` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
par AJEAN. La reprise automatique d'une installation 1.0 (`strataMigrate`) est
reprise d'AJEAN (`moeMigrate`). Ni Strata ni le modèle ne sont inclus dans
l'image : ils sont téléchargés à la demande.
## Services externes
+21 -2
View File
@@ -111,7 +111,7 @@ Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.1 », vérifié par
SHA-256).
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
@@ -120,7 +120,26 @@ puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go d
`/data/strata`). Le modèle installé devient un preset comme les autres :
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint).
cache KV fp16/8 bits, prédiction MTP, lecture des images, seconde carte).
La seconde carte a trois réglages : **non utilisée**, **aide aux experts** (le
défaut : elle garde et calcule une part des experts) ou **partage des couches**
(les deux cartes se répartissent les couches du modèle). Le partage est souvent
plus rapide avec deux cartes identiques (+50 % en génération mesuré par un
utilisateur d'AJEAN sur deux RTX 3060) ; sur deux cartes très différentes,
l'aide aux experts reste le meilleur choix. En partage des couches, le mode
« hors RAM » n'est jamais choisi : la seconde carte n'y garde pas d'experts.
Depuis le paquet 1.1, la carte d'appoint calcule elle-même ses experts à la
lecture des prompts (au lieu de les recopier vers la principale) : les ajouts
de 100 à 200 jetons se lisent ~30 à 40 % plus vite (mesures d'AJEAN). Un modèle
installé avec la 1.0 passe à la 1.1 **à son premier lancement**, sans
réinstallation : Loki télécharge le nouveau paquet (~340 Mo), reprend
l'environnement Python et la configuration de la 1.0 (le dossier
`/data/strata/ajean-moe-1.0` doit donc rester en place) et réécrit le preset.
En cas d'échec, la 1.0 continue de tourner et la raison est dans le journal du
moteur. En mode « hors RAM », le contexte est plafonné à 131 072 jetons (au-delà,
le cache KV verrouillé en RAM ne tient plus).
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
+142 -8
View File
@@ -282,10 +282,96 @@ type dlState struct {
Part int `json:"part"` // tranche en cours (1-based)
Finished bool `json:"finished"`
Canceled bool `json:"canceled"`
Paused bool `json:"paused"`
Err string `json:"error"`
StartedAt int64 `json:"started_at"`
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
gate *dlGate `json:"-"`
}
// dlGate met un téléchargement en pause (repris d'AJEAN v0.18.1) : les
// connexions en cours sont coupées (rien ne reste ouvert vers le CDN pendant la
// pause), et chaque morceau attend la reprise puis repart de l'octet où il
// s'était arrêté, par une requête de plage.
type dlGate struct {
mu sync.Mutex
paused bool
wake chan struct{} // fermé à la reprise
attempt map[int]context.CancelFunc // requêtes en cours, coupées par la pause
next int
}
func newDlGate() *dlGate { return &dlGate{attempt: map[int]context.CancelFunc{}} }
func (g *dlGate) setPaused(p bool) {
if g == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
if p == g.paused {
return
}
g.paused = p
if p {
g.wake = make(chan struct{})
for _, c := range g.attempt {
c()
}
return
}
close(g.wake)
}
// wait bloque tant que le téléchargement est en pause. false = annulé entre-temps.
func (g *dlGate) wait(ctx context.Context) bool {
if g == nil {
return ctx.Err() == nil
}
g.mu.Lock()
paused, wake := g.paused, g.wake
g.mu.Unlock()
if !paused {
return ctx.Err() == nil
}
select {
case <-wake:
return ctx.Err() == nil
case <-ctx.Done():
return false
}
}
func (g *dlGate) isPaused() bool {
if g == nil {
return false
}
g.mu.Lock()
defer g.mu.Unlock()
return g.paused
}
// begin ouvre une requête que la pause pourra couper ; la fonction rendue la referme.
func (g *dlGate) begin(ctx context.Context) (context.Context, func()) {
if g == nil {
return ctx, func() {}
}
actx, cancel := context.WithCancel(ctx)
g.mu.Lock()
id := g.next
g.next++
g.attempt[id] = cancel
if g.paused {
cancel() // pause tombée entre wait et begin
}
g.mu.Unlock()
return actx, func() {
g.mu.Lock()
delete(g.attempt, id)
g.mu.Unlock()
cancel()
}
}
var (
@@ -429,7 +515,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(context.Background())
st := &dlState{
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, gate: newDlGate(),
}
dlDownloads[name] = st
dlMu.Unlock()
@@ -759,7 +845,9 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
dt := now.Sub(lastAt).Seconds()
dlMu.Lock()
st.Done = cur
if dt > 0 {
if st.Paused {
st.Speed = 0
} else if dt > 0 {
inst := int64(float64(cur-last) / dt)
if st.Speed == 0 {
st.Speed = inst
@@ -827,7 +915,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
return err
}
}
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
if err := dlFetch(ctx, st.gate, f, dlURL, total, conns, done); err != nil {
f.Close()
_ = os.Remove(tmp)
return err
@@ -845,9 +933,9 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
// dlFetch writes the whole body into f, either as one stream or as `conns`
// parallel byte ranges. done is incremented atomically as bytes land on disk.
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int64, conns int, done *int64) error {
if conns <= 1 {
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
return dlChunk(ctx, g, f, dlURL, 0, total-1, total <= 0, done)
}
size := total / int64(conns)
var wg sync.WaitGroup
@@ -861,7 +949,7 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
wg.Add(1)
go func(i int, start, end int64) {
defer wg.Done()
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
errs[i] = dlChunk(ctx, g, f, dlURL, start, end, false, done)
}(i, start, end)
}
wg.Wait()
@@ -876,11 +964,15 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
// dlChunk downloads [start,end] into f at the right offset, retrying from where
// it stopped if the connection drops mid-chunk. With whole=true it streams the
// entire body sequentially (server without range support, unknown size).
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
const attempts = 4
pos := start
var lastErr error
for try := 0; try < attempts; try++ {
// Une pause n'est pas un échec : on attend la reprise sans consommer d'essai.
if !g.wait(ctx) {
return ctx.Err()
}
if err := ctx.Err(); err != nil {
return err // cancelled: never retry
}
@@ -900,18 +992,26 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
} else if pos > start {
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
}
req, err := dlRequest(ctx, dlURL, rng)
actx, release := g.begin(ctx)
req, err := dlRequest(actx, dlURL, rng)
if err != nil {
release()
return err
}
resp, err := dlClient.Do(req)
if err != nil {
release()
if g.isPaused() && ctx.Err() == nil {
try--
continue
}
lastErr = err
continue
}
if resp.StatusCode != 200 && resp.StatusCode != 206 {
err := dlSourceError(resp, dlURL)
resp.Body.Close()
release()
return err
}
if resp.StatusCode == 200 && pos > start {
@@ -921,10 +1021,15 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
}
n, cerr := dlCopy(f, resp.Body, pos, done)
resp.Body.Close()
release()
pos += n
if cerr == nil {
return nil
}
if g.isPaused() && ctx.Err() == nil {
try-- // coupé par la pause : la reprise repart de pos
continue
}
lastErr = cerr
}
return lastErr
@@ -981,6 +1086,35 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
}
// handleModelDownloadPause met en pause ({"pause":true}) ou reprend
// ({"pause":false}) un téléchargement en cours. Le fichier partiel est gardé.
func handleModelDownloadPause(w http.ResponseWriter, r *http.Request) {
var req struct {
Filename string `json:"filename"`
Pause bool `json:"pause"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
name := filepath.Base(strings.TrimSpace(req.Filename))
dlMu.Lock()
st, ok := dlDownloads[name]
if !ok || st.Finished {
dlMu.Unlock()
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement en cours pour " + name})
return
}
st.Paused = req.Pause
if req.Pause {
st.Speed = 0
}
g := st.gate
dlMu.Unlock()
g.setPaused(req.Pause)
sendJSON(w, 200, map[string]any{"ok": true, "paused": req.Pause})
}
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
// startup. A download killed by a crash or a service restart can't be resumed
// (its state lived in memory), so the partial file would otherwise sit there
+93
View File
@@ -231,3 +231,96 @@ func TestDLProbeSurfacesExplainedError(t *testing.T) {
t.Errorf("message %q sans indication sur le jeton", err)
}
}
type slowWriter struct{ http.ResponseWriter }
func (s slowWriter) Write(b []byte) (int, error) {
time.Sleep(2 * time.Millisecond)
return s.ResponseWriter.Write(b)
}
// Une pause coupe le flux, garde ce qui est écrit, et la reprise termine un
// fichier identique à la source (repris d'AJEAN v0.18.1).
func TestRunDownloadPauseResume(t *testing.T) {
data := make([]byte, 48<<20)
rand.New(rand.NewSource(2)).Read(data)
t.Setenv("LOKI_DL_CONNS", "4")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.ServeContent(slowWriter{w}, r, "m.gguf", time.Time{}, bytes.NewReader(data))
}))
defer srv.Close()
dest := filepath.Join(t.TempDir(), "m.gguf")
st := &dlState{Filename: "m.gguf", gate: newDlGate()}
done := make(chan struct{})
go func() {
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
close(done)
}()
time.Sleep(300 * time.Millisecond)
st.gate.setPaused(true)
time.Sleep(1500 * time.Millisecond) // le compteur publié se fige
dlMu.Lock()
a := st.Done
dlMu.Unlock()
time.Sleep(1500 * time.Millisecond)
dlMu.Lock()
b, fin := st.Done, st.Finished
dlMu.Unlock()
if fin || a != b || a == 0 {
t.Fatalf("pause inopérante : %d puis %d octets, terminé=%v", a, b, fin)
}
st.gate.setPaused(false)
select {
case <-done:
case <-time.After(30 * time.Second):
t.Fatal("pas terminé après la reprise")
}
if st.Err != "" {
t.Fatalf("erreur après reprise : %s", st.Err)
}
got, err := os.ReadFile(dest)
if err != nil || !bytes.Equal(got, data) {
t.Fatalf("fichier différent de la source après reprise (err=%v)", err)
}
}
// La route : pause puis reprise d'un téléchargement connu, refus pour
// l'inconnu et pour un téléchargement terminé.
func TestHandleModelDownloadPause(t *testing.T) {
dlMu.Lock()
saved := dlDownloads
dlDownloads = map[string]*dlState{
"a.gguf": {Filename: "a.gguf", Speed: 9, gate: newDlGate()},
"b.gguf": {Filename: "b.gguf", Finished: true},
}
dlMu.Unlock()
t.Cleanup(func() { dlMu.Lock(); dlDownloads = saved; dlMu.Unlock() })
post := func(body string) (int, string) {
w := httptest.NewRecorder()
handleModelDownloadPause(w, httptest.NewRequest("POST", "/api/models/download/pause", strings.NewReader(body)))
return w.Code, strings.TrimSpace(w.Body.String())
}
if code, out := post(`{"filename":"../a.gguf","pause":true}`); code != 200 || out != `{"ok":true,"paused":true}` {
t.Fatalf("pause : %d %s", code, out)
}
dlMu.Lock()
st := dlDownloads["a.gguf"]
paused, speed := st.Paused, st.Speed
dlMu.Unlock()
if !paused || speed != 0 || !st.gate.isPaused() {
t.Errorf("état après pause : paused=%v speed=%d porte=%v", paused, speed, st.gate.isPaused())
}
if code, out := post(`{"filename":"a.gguf","pause":false}`); code != 200 || out != `{"ok":true,"paused":false}` || st.gate.isPaused() {
t.Fatalf("reprise : %d %s", code, out)
}
for _, body := range []string{`{"filename":"b.gguf","pause":true}`, `{"filename":"x.gguf","pause":true}`} {
if code, _ := post(body); code != 404 {
t.Errorf("%s : %d, attendu 404", body, code)
}
}
if code, _ := post(`[`); code != 400 {
t.Errorf("corps illisible : %d", code)
}
}
+167 -16
View File
@@ -5,11 +5,13 @@ package loki
// répartit les experts entre VRAM, RAM et disque pour faire tenir le modèle sur
// une carte de joueur.
//
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.0 ») : le moteur est le paquet
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.0 — sources de Strata et
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.1 ») : le moteur est le paquet
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.1 — sources de Strata et
// binaire Linux CUDA 12 compilé et validé par l'auteur d'AJEAN (avec ses
// modifications : experts de la carte d'appoint gardés hors RAM). Les deux
// fichiers sont vérifiés par SHA-256 avant usage.
// modifications : experts de la carte d'appoint gardés hors RAM et, depuis la
// 1.1, calculés par elle à la lecture des prompts). Les deux fichiers sont
// vérifiés par SHA-256 avant usage. Un modèle installé avec la 1.0 passe à la
// 1.1 à son premier lancement (strataMigrate).
//
// Le choix du moteur se fait par PRESET : ENGINE=strata. Sans la clé (ou
// ENGINE=llamacpp), rien ne change, llama-server reste le moteur.
@@ -47,11 +49,12 @@ import (
"os/exec"
)
// Paquet figé (release moe-v1.0 d'AJEAN). Le moteur est compilé en CUDA 12.8,
// Paquet figé (release moe-v1.1 d'AJEAN). Le moteur est compilé en CUDA 12.8,
// mode portable AVX2, sm_75/80/86/89/120 : RTX 20 à 50. Un moteur installé d'un
// autre paquet est remplacé au lancement (strataEnsureEngine).
// autre paquet est remplacé au lancement (strataEnsureEngine), une installation
// d'une version précédente est reprise (strataMigrate).
const (
strataVersion = "1.0"
strataVersion = "1.1"
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
@@ -69,10 +72,11 @@ var (
strataDiskFree = diskFree
)
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage.
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage
// (celles qu'AJEAN publie dans son backend_moe.go pour moe-v1.1).
var strataAssetSHA = map[string]string{
strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b",
strataEngineAsset: "b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c",
strataSrcAsset: "5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115",
strataEngineAsset: "8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3",
}
// Valeurs de la clé ENGINE d'un preset.
@@ -132,8 +136,14 @@ const strataLowRAMHeadroomGB = 10
// strataDropHeadroomGB : RAM à laisser à côté des experts verrouillés quand la
// carte d'aide garde les siens hors RAM (mesuré par AJEAN : 46 Go de RAM,
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres).
const strataDropHeadroomGB = 7
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres ; le moteur 1.1 prend
// ~1 Go de plus que la 1.0).
const strataDropHeadroomGB = 8
// strataDropMaxCtx : contexte maximal en mode hors RAM. Le cache KV au-delà de
// la part en VRAM est verrouillé en RAM : à 167 936 jetons, le serveur de test
// d'AJEAN tombait à 1,3 Go libres dès la première requête (moteur 1.1).
const strataDropMaxCtx = 131072
// strataHelperExpertsGB : ce que la carte d'aide garde d'experts (sa VRAM moins
// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go).
@@ -210,7 +220,9 @@ func strataPackHasExpertsBin(args []string) bool {
// Pure : la décision se teste en table.
func strataResolveExperts(cfg map[string]string, args []string, availGB, helperGB float64) string {
binReady := strataPackHasExpertsBin(args) && strataEngineInstalled() == strataEngineAsset
dropReady := binReady && strataHelperOn(cfg) && cfg["STRATA_DROP"] != "0"
// (experts de la carte d'aide hors RAM : propre au mode d'aide, jamais en
// partage des couches, où la seconde carte ne garde pas d'experts)
dropReady := binReady && strataHelperOn(cfg) && !strataLayerSplit(cfg) && cfg["STRATA_DROP"] != "0"
arena := strataArenaGB(cfg)
switch strings.ToLower(strings.TrimSpace(cfg["STRATA_EXPERTS"])) {
case strataExpertsDisk:
@@ -494,6 +506,7 @@ type strataDetails struct {
// pour les réglages : ce que la machine permet et l'état actuel
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
HelperOn bool `json:"helper_on"`
Split bool `json:"split_layers"` // les deux cartes se partagent les couches
Vision bool `json:"vision"`
}
@@ -555,6 +568,7 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
if strataHelperOn(pc) {
d.HelperOn = true
d.HelperGPU = gpuName(h)
d.Split = strataLayerSplit(pc)
}
}
d.Vision = pc["STRATA_VISION"] != "0"
@@ -870,7 +884,8 @@ func strataFindConfig(quant string) (string, error) {
//
// - --prefill 16384 : +15 % de lecture sur les longs prompts (40K) ;
// - --short-read 150 : les petits ajouts (< 150 jetons) sont lus comme en
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides ;
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides
// (48 avec une carte d'aide, voir strataBuildConfig) ;
// - --prompt-cache-root 256 : point de reprise posé à la fin du prompt système
// (défaut 2048) : une nouvelle conversation démarre en ~0,3 s au lieu de 4-5 s.
var strataTuning = map[string]string{
@@ -957,6 +972,16 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
}
helper := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
hasHelper := strataHelperOn(cfg)
if strataLayerSplit(cfg) {
// Partage des couches : le serveur de Strata le fait de lui-même quand sa
// clé « gpu » liste plusieurs cartes (--layer-split auto, réparti selon la
// VRAM libre), numérotées comme nvidia-smi. Pas de chemin « aide » : la
// réserve de VRAM de l'installeur et l'encodeur d'images restent en place.
main, _ := strconv.Atoi(strings.TrimSpace(cfg["STRATA_MAIN_GPU"]))
second, _ := strconv.Atoi(helper)
out["gpu"] = []any{main, second}
hasHelper = false
}
// Deuxième carte en aide : elle garde et CALCULE sa part des experts (le
// chemin « helper » du moteur), la carte principale reste CUDA0. Les cartes
// sont posées par CUDA_VISIBLE_DEVICES (serveStrata), PAS par la clé « gpu » :
@@ -1002,6 +1027,10 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
env["STRATA_STAGER_THREADS"] = "8"
env["STRATA_STAGER_RING"] = "32"
// sa VRAM libre réservée : 640 Mio (défaut 1024) = ~230 experts de plus
// hors RAM, l'encodeur d'images y tourne encore ; le moteur refuse moins
// de 512
env["STRATA_REMOTE_DROP_RESERVE_MIB"] = "640"
case "arena":
// experts.bin mappé tel quel (variante mesurée par AJEAN), sans part PCIe :
// le mappage n'en donne pas l'adresse GPU.
@@ -1017,8 +1046,22 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
k, v, _ := strings.Cut(kv, "=")
env[k] = v
}
if hasHelper {
// Lecture d'un prompt (moteur 1.1) : les experts que la carte d'aide garde
// sont calculés par elle au lieu d'être recopiés vers la principale par son
// lien PCIe (le chemin passe par la source « helper ») ; le chemin par blocs
// coûte alors moins cher, les ajouts courts n'en sont exclus que sous 48
// jetons. Mesuré par AJEAN : 100 nouveaux jetons 1,19 -> 0,85 s, 200 :
// 1,96 -> 1,12 s.
env["STRATA_PREFILL_HELPER_SOURCE"] = "1"
args = strataSetArg(args, "--short-read", "48")
}
out["env"] = env
if ctx := strings.TrimSpace(cfg["CTX"]); ctx != "" {
// hors RAM, le cache KV verrouillé au-delà de strataDropMaxCtx ne tient plus
if n, err := strconv.Atoi(ctx); err == nil && n > strataDropMaxCtx && env["STRATA_REMOTE_DROP"] == "1" {
ctx = strconv.Itoa(strataDropMaxCtx)
}
args = strataSetArg(args, "--max-context", ctx)
}
// réglages imposés par le preset (sinon : ceux de l'installeur)
@@ -1162,6 +1205,16 @@ func strataHelperOn(cfg map[string]string) bool {
return h != "" && h != "-1" && cfg["STRATA_HELPER"] != "0"
}
// strataLayerSplit : les deux cartes se partagent les COUCHES du modèle (clé de
// preset STRATA_SPLIT=layers) au lieu que la seconde n'aide que pour une part
// des experts. Mesuré par un utilisateur d'AJEAN sur deux RTX 3060 identiques
// (#121 amont) : la carte d'aide ne travaillait qu'à ~15 %, le partage des
// couches donnait +50 % en génération. Sur deux cartes très différentes, le
// mode d'aide reste le défaut mesuré : le partage est un choix, jamais imposé.
func strataLayerSplit(cfg map[string]string) bool {
return strataHelperOn(cfg) && cfg["STRATA_SPLIT"] == "layers"
}
// strataCudaDevices : ordre des cartes vu par le moteur, la principale en CUDA0.
func strataCudaDevices(cfg map[string]string) string {
main := strings.TrimSpace(cfg["STRATA_MAIN_GPU"])
@@ -1184,17 +1237,99 @@ func strataPreflight(cfg map[string]string) error {
if _, err := os.Stat(path); err != nil {
return fmt.Errorf("configuration de Strata introuvable : %s — réinstalle le modèle", path)
}
py := filepath.Join(filepath.Dir(path), ".venv", "bin", "python")
py := strataVenvPython(filepath.Dir(path))
if _, err := os.Stat(py); err != nil {
return fmt.Errorf("environnement Python de Strata absent (%s) : réinstalle le modèle", py)
}
return nil
}
// strataVenvPython : le Python de l'environnement de Strata, dans ses sources.
func strataVenvPython(srcDir string) string { return filepath.Join(srcDir, ".venv", "bin", "python") }
// strataMigrate : un preset installé par une version précédente du paquet
// (STRATA_CONFIG dans strataHome()/ajean-moe-<ancienne>) passe à la version
// courante au lancement, sans réinstaller : les modèles et packs (data/)
// servent tels quels. Les sources de la version courante sont posées (~340 Mo
// avec le moteur), l'environnement Python de l'ancienne est repris par un lien
// quand les dépendances n'ont pas changé, le moteur compilé est installé par
// strataEnsureEngine, la config de l'installeur est recopiée avec ses chemins
// réécrits, et les presets qui la citaient comme la config vivante pointent sur
// la nouvelle. En cas d'échec rien n'est réécrit : l'ancienne version continue
// de tourner. Repris d'AJEAN (moeMigrate, AJEAN MoE 1.1).
func strataMigrate(cfg map[string]string) error {
old := strings.TrimSpace(cfg["STRATA_CONFIG"])
oldDir := filepath.Dir(old)
newDir := strataSrcDir()
if old == "" || oldDir == newDir || filepath.Dir(oldDir) != strataHome() || !strings.HasPrefix(filepath.Base(oldDir), "ajean-moe-") {
return nil
}
newPath := filepath.Join(newDir, filepath.Base(old))
if !isFile(newPath) {
raw, err := os.ReadFile(old)
if err != nil {
return err
}
if !isFile(filepath.Join(newDir, "setup.py")) {
src, err := strataFetch(strataSrcAsset)
if err != nil {
return err
}
if err := extractArchive(src, strataHome()); err != nil {
return fmt.Errorf("décompression : %w", err)
}
}
venv := filepath.Join(newDir, ".venv")
if _, err := os.Lstat(venv); err != nil {
a, _ := os.ReadFile(filepath.Join(oldDir, "requirements.txt"))
b, _ := os.ReadFile(filepath.Join(newDir, "requirements.txt"))
if len(a) == 0 || string(a) != string(b) || !isFile(strataVenvPython(oldDir)) {
return fmt.Errorf("dépendances Python changées : réinstalle le modèle depuis Paramètres → Moteur → Strata")
}
if err := os.Symlink(filepath.Join(oldDir, ".venv"), venv); err != nil {
return err
}
}
if err := os.MkdirAll(filepath.Join(newDir, "engine-cuda12"), 0o755); err != nil {
return err
}
if err := strataEnsureEngine(); err != nil {
return err
}
body := strings.ReplaceAll(string(raw), oldDir+string(os.PathSeparator), newDir+string(os.PathSeparator))
if err := os.WriteFile(newPath+".new", []byte(body), 0o644); err != nil {
return err
}
if err := os.Rename(newPath+".new", newPath); err != nil {
return err
}
}
// les presets qui citent l'ancienne config, puis la config vivante (les deux
// changent ensemble : l'empreinte du preset actif reste celle de la config)
if list, _ := filepath.Glob(filepath.Join(presetsDir(), "*.env")); len(list) > 0 {
for _, p := range list {
b, err := os.ReadFile(p)
if err != nil || !strings.Contains(string(b), "STRATA_CONFIG="+old+"\n") {
continue
}
_ = os.WriteFile(p, []byte(strings.ReplaceAll(string(b), "STRATA_CONFIG="+old+"\n", "STRATA_CONFIG="+newPath+"\n")), 0o644)
}
}
if err := SetConfigKey("STRATA_CONFIG", newPath); err != nil {
return err
}
cfg["STRATA_CONFIG"] = newPath
fmt.Fprintf(os.Stderr, "[loki serve] paquet AJEAN MoE %s : installation reprise depuis %s\n", strataVersion, filepath.Base(oldDir))
return nil
}
// serveStrata : la branche Strata de `loki serve`. Écrit la config finale à
// côté de celle de l'installeur puis remplace le process par le serveur de
// Strata (comme pour llama-server : le superviseur suit directement le moteur).
func serveStrata(cfg map[string]string) error {
if err := strataMigrate(cfg); err != nil {
fmt.Fprintf(os.Stderr, "[loki serve] passage au paquet AJEAN MoE %s impossible (%v) : la version installée reste utilisée\n", strataVersion, err)
}
if err := strataPreflight(cfg); err != nil {
return err
}
@@ -1223,7 +1358,7 @@ func serveStrata(cfg map[string]string) error {
return err
}
srcDir := filepath.Dir(path)
py := filepath.Join(srcDir, ".venv", "bin", "python")
py := strataVenvPython(srcDir)
tag := strataConfigTag(path)
final["log"] = filepath.Join(strataHome(), "strata-"+tag+".log")
out := filepath.Join(strataHome(), "loki-strata-"+tag+".json")
@@ -1326,6 +1461,9 @@ type strataSettingsReq struct {
Vision bool `json:"vision"`
Helper bool `json:"helper"`
Experts string `json:"experts"` // auto, ram ou disk ; vide = inchangé
// Split : rôle de la seconde carte, « helper » (aide aux experts) ou
// « layers » (partage des couches). Vide = inchangé (anciens clients).
Split string `json:"split"`
}
// strataApplySettings réécrit les clés du preset (pure, testable).
@@ -1340,6 +1478,8 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
return "", fmt.Errorf("MTP hors limites : %d", r.Spec)
case r.Experts != "" && r.Experts != strataExpertsAuto && r.Experts != strataExpertsRAM && r.Experts != strataExpertsDisk:
return "", fmt.Errorf("placement des experts inconnu : %s", r.Experts)
case r.Split != "" && r.Split != "helper" && r.Split != "layers":
return "", fmt.Errorf("répartition inconnue : %s", r.Split)
}
set := map[string]string{
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
@@ -1350,6 +1490,14 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
set["STRATA_EXPERTS"] = r.Experts
keys = append(keys, "STRATA_EXPERTS")
}
if r.Split != "" {
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
set["STRATA_SPLIT"] = ""
if r.Split == "layers" {
set["STRATA_SPLIT"] = "layers"
}
keys = append(keys, "STRATA_SPLIT")
}
var lines []string
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
@@ -1359,6 +1507,9 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
lines = append(lines, l)
}
for _, k := range keys {
if set[k] == "" && k == "STRATA_SPLIT" {
continue
}
lines = append(lines, k+"="+set[k])
}
return strings.Join(lines, "\n") + "\n", nil
@@ -0,0 +1,133 @@
package loki
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// Moteur 1.1 : avec une carte d'aide, la lecture d'un prompt passe par elle
// (source « helper ») et le chemin direct ne garde que les ajouts sous 48 jetons.
func TestStrataBuildConfigHelperPrompt(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p"}}
out, _ := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
if argAfter(strataArgs(t, out), "--short-read") != "48" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != "1" {
t.Errorf("carte d'aide : lecture courte et source d'aide attendues : %v %v", strataArgs(t, out), out["env"])
}
// carte d'aide coupée ou absente : réglages d'avant
for _, cfg := range []map[string]string{{}, {"STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}} {
out, _ = strataBuildConfig(base, cfg, "", "ram")
if argAfter(strataArgs(t, out), "--short-read") != "150" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != nil {
t.Errorf("une carte (%v) : %v %v", cfg, strataArgs(t, out), out["env"])
}
}
}
// Mode hors RAM : réserve de VRAM de la carte d'aide abaissée, contexte plafonné.
func TestStrataBuildConfigDropReserveAndCtx(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p"}, "env": map[string]any{}}
cfg := map[string]string{"STRATA_HELPER_GPU": "0", "CTX": "167936"}
out, _ := strataBuildConfig(base, cfg, "", "drop")
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != "640" {
t.Errorf("réserve de la carte d'aide : %v", out["env"])
}
if got := argAfter(strataArgs(t, out), "--max-context"); got != "131072" {
t.Errorf("contexte hors RAM non plafonné : %s", got)
}
// hors du mode hors RAM : le contexte demandé
for _, mode := range []string{"ram", "arena", "disk"} {
out, _ = strataBuildConfig(base, cfg, "", mode)
if got := argAfter(strataArgs(t, out), "--max-context"); got != "167936" {
t.Errorf("%s : contexte plafonné sans mode hors RAM : %s", mode, got)
}
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != nil {
t.Errorf("%s : réserve du mode hors RAM posée : %v", mode, out["env"])
}
}
}
// Un modèle installé avec le paquet 1.0 passe au paquet courant à son premier
// lancement, sans réinstallation.
func TestStrataMigrate(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("lien symbolique : Strata ne tourne que sous Linux")
}
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
newDir := strataSrcDir()
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), filepath.Join(newDir, "engine-cuda12"), presetsDir()} {
_ = os.MkdirAll(d, 0o755)
}
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
for _, d := range []string{oldDir, newDir} {
_ = os.WriteFile(filepath.Join(d, "requirements.txt"), []byte("numpy\n"), 0o644)
}
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
old := filepath.Join(oldDir, "strata-swift-iq3_xxs.json")
_ = os.WriteFile(old, []byte(`{"exe": "`+filepath.Join(oldDir, "engine-cuda12", "strata")+`", "args": []}`), 0o644)
preset := filepath.Join(presetsDir(), "P.env")
_ = os.WriteFile(preset, []byte("ENGINE=strata\nSTRATA_CONFIG="+old+"\nCTX=1\n"), 0o644)
other := filepath.Join(presetsDir(), "L.env")
_ = os.WriteFile(other, []byte("MODEL=/m.gguf\n"), 0o644)
if err := SetConfigKey("STRATA_CONFIG", old); err != nil {
t.Fatal(err)
}
cfg := map[string]string{"ENGINE": "strata", "STRATA_CONFIG": old}
if err := strataMigrate(cfg); err != nil {
t.Fatal(err)
}
want := filepath.Join(newDir, "strata-swift-iq3_xxs.json")
if cfg["STRATA_CONFIG"] != want || ReadConfig()["STRATA_CONFIG"] != want {
t.Fatalf("config vivante : %q", cfg["STRATA_CONFIG"])
}
b, _ := os.ReadFile(want)
if strings.Contains(string(b), oldDir) || !strings.Contains(string(b), filepath.Join(newDir, "engine-cuda12")) {
t.Errorf("chemins non réécrits : %s", b)
}
if p, _ := os.ReadFile(preset); !strings.Contains(string(p), "STRATA_CONFIG="+want+"\n") {
t.Errorf("preset non réécrit : %s", p)
}
if p, _ := os.ReadFile(other); string(p) != "MODEL=/m.gguf\n" {
t.Errorf("preset d'un autre moteur touché : %s", p)
}
if !isFile(strataVenvPython(newDir)) {
t.Error("environnement Python non repris")
}
// déjà à jour : rien à faire
if err := strataMigrate(cfg); err != nil || cfg["STRATA_CONFIG"] != want {
t.Errorf("seconde passe : %v %q", err, cfg["STRATA_CONFIG"])
}
}
// Dépendances Python changées : rien n'est réécrit, l'ancienne version tourne.
func TestStrataMigrateRequirementsChanged(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
newDir := strataSrcDir()
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), newDir} {
_ = os.MkdirAll(d, 0o755)
}
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
_ = os.WriteFile(filepath.Join(oldDir, "requirements.txt"), []byte("numpy\n"), 0o644)
_ = os.WriteFile(filepath.Join(newDir, "requirements.txt"), []byte("numpy\ntorch\n"), 0o644)
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
old := filepath.Join(oldDir, "strata-iq3_xxs.json")
_ = os.WriteFile(old, []byte(`{"args": []}`), 0o644)
cfg := map[string]string{"STRATA_CONFIG": old}
if err := strataMigrate(cfg); err == nil || !strings.Contains(err.Error(), "dépendances") {
t.Fatalf("erreur de dépendances attendue : %v", err)
}
if cfg["STRATA_CONFIG"] != old || isFile(filepath.Join(newDir, "strata-iq3_xxs.json")) {
t.Errorf("config réécrite malgré l'échec : %q", cfg["STRATA_CONFIG"])
}
// un preset hors de strata/ (copié à la main) n'est pas touché
cfg = map[string]string{"STRATA_CONFIG": filepath.Join(home, "ailleurs", "ajean-moe-1.0", "strata-x.json")}
if err := strataMigrate(cfg); err != nil {
t.Errorf("config hors de strata/ : %v", err)
}
}
+124
View File
@@ -0,0 +1,124 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// Partage des couches (#121 amont) : la clé « gpu » liste les deux cartes (le
// serveur de Strata en déduit --layer-split), et rien du mode d'aide ne subsiste.
func TestStrataBuildConfigLayerSplit(t *testing.T) {
base := map[string]any{
"args": []any{"--pack", "/p", "--vram-reserve-mib", "900"},
"vision": map[string]any{"exe": "/v"},
}
cfg := map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_SPLIT": "layers"}
out, err := strataBuildConfig(base, cfg, "", "drop")
if err != nil {
t.Fatal(err)
}
g, ok := out["gpu"].([]any)
if !ok || len(g) != 2 || g[0] != 1 || g[1] != 0 {
t.Fatalf("gpu = %v, attendu [1 0] (principale d'abord, numéros nvidia-smi)", out["gpu"])
}
a := strataArgs(t, out)
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
t.Errorf("options du mode d'aide en partage des couches : %v", a)
}
if argAfter(a, "--vram-reserve-mib") != "900" {
t.Errorf("la réserve de VRAM de l'installeur doit rester : %v", a)
}
if argAfter(a, "--short-read") != "150" {
t.Errorf("lecture des prompts par la carte d'aide en partage des couches : %v", a)
}
env := out["env"].(map[string]any)
if env["STRATA_REMOTE_DROP"] != nil || env["STRATA_PREFILL_HELPER_SOURCE"] != nil {
t.Errorf("experts hors RAM ou source d'aide (mode d'aide) en partage des couches : %v", env)
}
if _, has := out["vision"].(map[string]any)["cuda_device"]; has {
t.Errorf("encodeur d'images déplacé sur la seconde carte en partage des couches")
}
// Sans la clé, rien ne change : le mode d'aide reste le défaut.
delete(cfg, "STRATA_SPLIT")
out, _ = strataBuildConfig(base, cfg, "", "drop")
if _, has := out["gpu"]; has {
t.Errorf("clé gpu posée en mode d'aide : %v", out["gpu"])
}
if !strataHasArg(strataArgs(t, out), "--remote-expert-opt") {
t.Errorf("mode d'aide perdu sans STRATA_SPLIT")
}
// Seconde carte coupée : pas de partage même si la clé traîne.
cfg["STRATA_SPLIT"], cfg["STRATA_HELPER"] = "layers", "0"
if strataLayerSplit(cfg) {
t.Errorf("partage des couches avec la seconde carte coupée")
}
}
// En partage des couches, la seconde carte ne garde pas d'experts : le mode
// hors RAM (qui compte sur elle pour en loger une part) n'est jamais choisi.
func TestStrataResolveExpertsLayerSplit(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
pack := filepath.Join(home, "pack")
_ = os.MkdirAll(pack, 0o755)
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
args := []string{"--pack", pack}
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
t.Fatalf("mode d'aide : %s, attendu drop", got)
}
cfg["STRATA_SPLIT"] = "layers"
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
t.Errorf("partage des couches : %s, attendu arena", got)
}
cfg["STRATA_EXPERTS"] = "ram"
if got := strataResolveExperts(cfg, args, 44, 12); got != "ram" {
t.Errorf("partage des couches, experts en RAM : %s, attendu ram", got)
}
}
func TestStrataApplySettingsSplit(t *testing.T) {
in := "ENGINE=strata\nSTRATA_HELPER_GPU=0\n"
req := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Vision: true, Helper: true, Split: "layers"}
out, err := strataApplySettings(in, req)
if err != nil || !strings.Contains(out, "STRATA_SPLIT=layers\n") {
t.Fatalf("STRATA_SPLIT=layers attendu : %q (%v)", out, err)
}
req.Split = "helper"
if out, _ = strataApplySettings(out, req); strings.Contains(out, "STRATA_SPLIT") {
t.Errorf("retour au mode d'aide : la clé doit disparaître : %q", out)
}
// un ancien client n'envoie pas « split » : la clé est conservée
req.Split = ""
out, _ = strataApplySettings("STRATA_SPLIT=layers\n", req)
if !strings.Contains(out, "STRATA_SPLIT=layers") {
t.Errorf("clé effacée par un client qui ne la connaît pas : %q", out)
}
req.Split = "nimporte"
if _, err := strataApplySettings(in, req); err == nil {
t.Errorf("répartition inconnue acceptée")
}
}
func TestStrataDetailsForLayerSplit(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
base := filepath.Join(home, "base.json")
_ = os.WriteFile(base, []byte(`{"args":["--pack","/p"],"vision":{"exe":"v"}}`), 0o644)
_ = os.MkdirAll(presetsDir(), 0o755)
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_SPLIT=layers\n"
_ = os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644)
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}, {Index: 1, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}}}
d := strataDetailsFor("X", env)
if d == nil {
t.Fatal("pas de détails")
}
if !d.Split || !d.HelperOn || d.HelperGPU == "" || d.VisionGPU != d.MainGPU || d.ShortRead != "150" {
t.Errorf("%+v", *d)
}
}
+1 -1
View File
@@ -184,7 +184,7 @@ func TestStrataDetailsFor(t *testing.T) {
}
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
d.ShortRead != "48" || d.CacheRoot != "256" || d.Prefill != "16384" {
t.Errorf("%+v", *d)
}
}
+1 -1
View File
@@ -301,7 +301,7 @@ func TestFacadeRelaieLesModeles(t *testing.T) {
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
routes := []string{"/api/models/download", "/api/models/download/probe",
"/api/models/download/status", "/api/models/download/cancel", "/api/models",
"/api/models/download/status", "/api/models/download/cancel", "/api/models/download/pause", "/api/models",
"/api/models/delete", "/api/models/dirs", "/api/hf/search", "/api/hf/files", "/api/hf/token"}
for _, p := range routes {
w := httptest.NewRecorder()
+1
View File
@@ -49,4 +49,5 @@ var facadeModelRoutes = map[string]bool{
"/api/models/download/probe": true,
"/api/models/download/status": true,
"/api/models/download/cancel": true,
"/api/models/download/pause": true,
}
+31 -16
View File
@@ -249,13 +249,14 @@ func TestPariteStrata(t *testing.T) {
return out
}
srcDir := filepath.Base(strataSrcDir())
src := []parityArchEntry{
{Name: "ajean-moe-1.0/", Type: "dir", Mode: 0o755},
{Name: "ajean-moe-1.0/setup.sh", Type: "file", Body: parityStrataSetup, Mode: 0o755},
{Name: "ajean-moe-1.0/setup.py", Type: "file", Body: "# installeur\n", Mode: 0o644},
{Name: "ajean-moe-1.0/serve/server.py", Type: "file", Body: "# serveur\n", Mode: 0o644},
{Name: srcDir + "/", Type: "dir", Mode: 0o755},
{Name: srcDir + "/setup.sh", Type: "file", Body: parityStrataSetup, Mode: 0o755},
{Name: srcDir + "/setup.py", Type: "file", Body: "# installeur\n", Mode: 0o644},
{Name: srcDir + "/serve/server.py", Type: "file", Body: "# serveur\n", Mode: 0o644},
}
goodRel := parityStrataRel{Src: src, Engine: "moteur strata 1.0"}
goodRel := parityStrataRel{Src: src, Engine: "moteur strata " + strataVersion}
mem47 := "MemTotal: 49283072 kB\nMemFree: 1000000 kB\nMemAvailable: 41943040 kB\n"
mem64 := "MemTotal: 67108864 kB\nMemAvailable: 65011712 kB\n"
mem16 := "MemTotal: 16777216 kB\nMemAvailable: 12582912 kB\n"
@@ -332,7 +333,7 @@ func TestPariteStrata(t *testing.T) {
mu.Unlock()
st.After = map[string]string{}
cfg := ReadConfig()
for _, k := range []string{"ENGINE", "STRATA_CONFIG", "CTX", "STRATA_KV", "STRATA_HELPER"} {
for _, k := range []string{"ENGINE", "STRATA_CONFIG", "CTX", "STRATA_KV", "STRATA_HELPER", "STRATA_SPLIT"} {
if v := cfg[k]; v != "" {
st.After["config/"+k] = mask(v)
}
@@ -381,6 +382,10 @@ func TestPariteStrata(t *testing.T) {
Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
{Name: "réglages du preset actif pendant une optimisation", Tune: true,
Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
{Name: "réglages, répartition inconnue", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, `,"split":"nimporte"`)},
{Name: "réglages, partage des couches", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, `,"split":"layers"`)},
{Name: "réglages d'un ancien client, partage gardé", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, "")},
{Name: "réglages, retour à l'aide aux experts", Method: "POST", Path: "/api/strata/settings", Body: settings("S", 98304, `,"split":"helper"`)},
// Installation.
{Name: "installation en GET", Method: "GET", Path: "/api/strata/install"},
@@ -393,42 +398,52 @@ func TestPariteStrata(t *testing.T) {
Body: `{"family":"qwen","quant":"IQ3_S"}`, Wait: true},
{Name: "release introuvable", Meminfo: mem47, Release: &parityStrataRel{Status: 404, Src: src, Engine: "x"},
Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "moteur altéré", Release: &parityStrataRel{Src: src, Engine: "moteur strata 1.0", BadSHA: true},
{Name: "moteur altéré", Release: &parityStrataRel{Src: src, Engine: "moteur strata " + strataVersion, BadSHA: true},
Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "installation", Release: &goodRel, Method: "POST", Path: "/api/strata/install",
Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "après l'installation", Method: "GET", Path: "/api/strata"},
{Name: "mode lancé connu, modèles inactifs", State: map[string]string{"strata_experts_mode": "drop"}, Files: map[string]string{
"presets/STRATA FLASH NEXT CLASSIQUE IQ2_XS.env": "# NAME=Classique IQ2_XS\nENGINE=strata\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" +
"presets/STRATA FLASH NEXT CLASSIQUE IQ2_XS.env": "# NAME=Classique IQ2_XS\nENGINE=strata\nSTRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\n" +
"STRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=-1\nSTRATA_EXPERTS=disk\n",
"presets/STRATA FLASH NEXT CLASSIQUE Q2_0.env": "ENGINE=moe\nSTRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\n" +
"presets/STRATA FLASH NEXT CLASSIQUE Q2_0.env": "ENGINE=moe\nSTRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\n" +
"STRATA_MAIN_GPU=0\nSTRATA_HELPER_GPU=1\nSTRATA_HELPER=0\nSTRATA_VISION=0\nSTRATA_EXPERTS=ram\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "experts.bin et moteur du paquet", Files: map[string]string{
"strata/data/packs/swift-iq3_xxs/experts.bin": "x",
}, State: map[string]string{"strata_experts_mode": ""}, Method: "GET", Path: "/api/strata"},
{Name: "RAM juste sous le seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43164063 kB\n",
{Name: "RAM juste sous le seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 44140625 kB\n",
Method: "GET", Path: "/api/strata"},
{Name: "RAM au-dessus du seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 43457032 kB\n",
{Name: "RAM au-dessus du seuil du mode hors RAM", Meminfo: "MemTotal: 49283072 kB\nMemAvailable: 44433594 kB\n",
Method: "GET", Path: "/api/strata"},
{Name: "contexte plafonné en mode hors RAM", Files: map[string]string{
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
"STRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=196608\nREASONING=off\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "seconde carte en partage des couches", Files: map[string]string{
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
"STRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=196608\nREASONING=off\nSTRATA_SPLIT=layers\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "mode hors RAM coupé dans le preset", Files: map[string]string{
"presets/STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS.env": "# NAME=Qwen3.8 Flash Next Swift 1.5 IQ3_XXS (Strata)\nENGINE=strata\n" +
"STRATA_CONFIG={{HOME}}/strata/ajean-moe-1.0/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
"STRATA_CONFIG={{HOME}}/strata/" + srcDir + "/strata-swift-iq3_xxs.json\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\n" +
"STRATA_VISION=1\nSTRATA_EXPERTS=auto\nSTRATA_DROP=0\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\nCTX=131072\nREASONING=off\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "moteur d'un autre paquet", Meminfo: mem47, Files: map[string]string{
"strata/ajean-moe-1.0/engine-cuda12/.loki-engine": "ajean-moe-engine-0.9-linux-x64-cuda12.zip\n",
"strata/" + srcDir + "/engine-cuda12/.loki-engine": "ajean-moe-engine-0.9-linux-x64-cuda12.zip\n",
}, Method: "GET", Path: "/api/strata"},
{Name: "réinstallation, moteur téléchargé périmé", Files: map[string]string{
"strata/dl/ajean-moe-engine-1.0-linux-x64-cuda12.zip": "ancien moteur",
"strata/dl/" + strataEngineAsset: "ancien moteur",
}, Method: "POST", Path: "/api/strata/install", Body: `{"family":"swift","quant":"IQ3_XXS"}`, Wait: true},
{Name: "installeur en échec", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"Q2_0"}`, Wait: true},
{Name: "installeur sans configuration", Method: "POST", Path: "/api/strata/install", Body: `{"family":"qwen","quant":"IQ2_XS"}`, Wait: true},
{Name: "configuration de lancement disparue", Files: map[string]string{
"strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "-",
"strata/" + srcDir + "/strata-swift-iq3_xxs.json": "-",
}, Method: "GET", Path: "/api/strata"},
{Name: "configuration de lancement illisible", Files: map[string]string{
"strata/ajean-moe-1.0/strata-swift-iq3_xxs.json": "[1]",
"strata/" + srcDir + "/strata-swift-iq3_xxs.json": "[1]",
}, Method: "GET", Path: "/api/strata"},
} {
run(st)
+121 -8
View File
@@ -1418,6 +1418,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
border:1px solid var(--border);border-radius:999px;background:var(--panel);
color:var(--dim);font-size:12px;line-height:1.5}
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
.katex{font-size:1.08em}
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
/* ===== Fenêtres et sélecteurs =====================================
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
@@ -3500,6 +3504,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">
@@ -3819,6 +3824,76 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<script src="marked.min.js"></script>
<script>
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
// s'apparierait par-dessus le code en ligne.
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
//
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
// exécute sous node sur les cas limites (montants, code, échappements).
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
function mathExtract(s){
const list = [];
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
if(code !== undefined) return all;
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
// pas d'espace avant le $ final.
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
const display = dd !== undefined || br !== undefined;
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
return '\uE000' + (list.length-1) + '\uE001';
});
return {text, list};
}
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
// sa formule (render : formule → HTML).
function mathRestore(html, list, render){
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
}
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const MATH_OPTS = {throwOnError: false, output: 'html'};
function mathHtml(f){
if(window.katex){
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
}
mathLoad();
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
}
let mathLoading = null;
function mathLoad(){
if(mathLoading) return mathLoading;
if(!document.querySelector('link[data-katex]')){
const css = document.createElement('link');
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
document.head.appendChild(css);
}
mathLoading = new Promise((ok, ko)=>{
const s = document.createElement('script');
s.src = 'katex/katex.min.js'; s.onload = ok;
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
s.onerror = ()=>{ s.remove(); ko(); };
document.head.appendChild(s);
}).then(()=>{
document.querySelectorAll('.math-pending').forEach(el=>{
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
el.classList.remove('math-pending');
});
}).catch(()=>{ mathLoading = null; });
return mathLoading;
}
// marked: GFM on, no auto-breaks (single newlines stay inline).
marked.setOptions({ gfm: true, breaks: false });
// Pre-process to fix common LLM markdown mistakes before handing to marked:
@@ -3835,7 +3910,13 @@ function fixMd(s){
s = s.replace(/\n{3,}/g, '\n\n');
return s;
}
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
function md(src){
if(!src) return '';
const m = mathExtract(fixMd(src));
return mathRestore(marked.parse(m.text), m.list, mathHtml);
}
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
@@ -4521,6 +4602,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 +6835,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 +6845,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 +6962,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 +6988,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 +7015,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 +7025,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 +9385,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 +9409,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 +9432,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],
];
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2020 Khan Academy and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
@font-face{font-family:KaTeX_AMS;font-style:normal;font-weight:400;src:url(fonts/KaTeX_AMS-Regular.woff2) format("woff2"),url(fonts/KaTeX_AMS-Regular.woff) format("woff"),url(fonts/KaTeX_AMS-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Caligraphic-Bold.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Bold.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Caligraphic;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Caligraphic-Regular.woff2) format("woff2"),url(fonts/KaTeX_Caligraphic-Regular.woff) format("woff"),url(fonts/KaTeX_Caligraphic-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Fraktur-Bold.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Bold.woff) format("woff"),url(fonts/KaTeX_Fraktur-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Fraktur;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Fraktur-Regular.woff2) format("woff2"),url(fonts/KaTeX_Fraktur-Regular.woff) format("woff"),url(fonts/KaTeX_Fraktur-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:700;src:url(fonts/KaTeX_Main-Bold.woff2) format("woff2"),url(fonts/KaTeX_Main-Bold.woff) format("woff"),url(fonts/KaTeX_Main-Bold.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Main-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Main-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Main-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Main-Italic.woff2) format("woff2"),url(fonts/KaTeX_Main-Italic.woff) format("woff"),url(fonts/KaTeX_Main-Italic.ttf) format("truetype")}@font-face{font-family:KaTeX_Main;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Main-Regular.woff2) format("woff2"),url(fonts/KaTeX_Main-Regular.woff) format("woff"),url(fonts/KaTeX_Main-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:700;src:url(fonts/KaTeX_Math-BoldItalic.woff2) format("woff2"),url(fonts/KaTeX_Math-BoldItalic.woff) format("woff"),url(fonts/KaTeX_Math-BoldItalic.ttf) format("truetype")}@font-face{font-family:KaTeX_Math;font-style:italic;font-weight:400;src:url(fonts/KaTeX_Math-Italic.woff2) format("woff2"),url(fonts/KaTeX_Math-Italic.woff) format("woff"),url(fonts/KaTeX_Math-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:700;src:url(fonts/KaTeX_SansSerif-Bold.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Bold.woff) format("woff"),url(fonts/KaTeX_SansSerif-Bold.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:italic;font-weight:400;src:url(fonts/KaTeX_SansSerif-Italic.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Italic.woff) format("woff"),url(fonts/KaTeX_SansSerif-Italic.ttf) format("truetype")}@font-face{font-family:"KaTeX_SansSerif";font-style:normal;font-weight:400;src:url(fonts/KaTeX_SansSerif-Regular.woff2) format("woff2"),url(fonts/KaTeX_SansSerif-Regular.woff) format("woff"),url(fonts/KaTeX_SansSerif-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Script;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Script-Regular.woff2) format("woff2"),url(fonts/KaTeX_Script-Regular.woff) format("woff"),url(fonts/KaTeX_Script-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size1;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size1-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size1-Regular.woff) format("woff"),url(fonts/KaTeX_Size1-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size2;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size2-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size2-Regular.woff) format("woff"),url(fonts/KaTeX_Size2-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size3;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size3-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size3-Regular.woff) format("woff"),url(fonts/KaTeX_Size3-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Size4;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Size4-Regular.woff2) format("woff2"),url(fonts/KaTeX_Size4-Regular.woff) format("woff"),url(fonts/KaTeX_Size4-Regular.ttf) format("truetype")}@font-face{font-family:KaTeX_Typewriter;font-style:normal;font-weight:400;src:url(fonts/KaTeX_Typewriter-Regular.woff2) format("woff2"),url(fonts/KaTeX_Typewriter-Regular.woff) format("woff"),url(fonts/KaTeX_Typewriter-Regular.ttf) format("truetype")}.katex{font:normal 1.21em KaTeX_Main,Times New Roman,serif;line-height:1.2;text-indent:0;text-rendering:auto}.katex *{-ms-high-contrast-adjust:none!important;border-color:currentColor}.katex .katex-version:after{content:"0.16.11"}.katex .katex-mathml{clip:rect(1px,1px,1px,1px);border:0;height:1pLine truncated
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.katex=t():e.katex=t()}("undefined"!=typeof self?self:this,(function(){return function(){"use strict";var e={d:function(t,r){for(var n in r)e.o(r,n)&&!e.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:r[n]})},o:function(e,t){return Object.prototype.hasOwnProperty.call(e,t)}},t={};e.d(t,{default:function(){return Yn}});class r{constructor(e,t){this.name=void 0,this.position=void 0,this.length=void 0,this.rawMessage=void 0;let n,o,s="KaTeX parse error: "+e;const i=t&&t.loc;if(i&&i.start<=i.end){const e=i.lexer.input;n=i.start,o=i.end,n===e.length?s+=" at end of input: ":s+=" at position "+(n+1)+": ";const t=e.slice(n,o).replace(/[^]/g,"$&\u0332");let r,a;r=n>15?"\u2026"+e.slice(n-15,n):e.slice(0,n),a=o+15<e.length?e.slice(o,o+15)+"\u2026":e.slice(o),s+=r+t+a}const a=new Error(s);return a.name="ParseError",a.__proto__=r.prototype,a.position=n,null!=n&&null!=o&&(a.length=o-n),a.rawMessage=e,a}}r.prototype.__proto__=Error.prototype;var n=r;const o=/([A-Z])/g,s={"&":"&amp;",">":"&gt;","<":"&lt;",'"':"&quot;","'":"&#x27;"},i=/[&><"']/g;const a=function(e){return"ordgroup"===e.type||"color"===e.type?1===e.body.length?a(e.body[0]):e:"font"===e.type?a(e.body):e};var l={contains:function(e,t){return-1!==e.indexOf(t)},deflt:function(e,t){return void 0===e?t:e},escape:function(e){return String(e).replace(i,(e=>s[e]))},hyphenate:function(e){return e.replace(o,"-$1").toLowerCase()},getBaseElem:a,isCharacterBox:function(e){const t=a(e);return"mathord"===t.type||"textord"===t.type||"atom"===t.type},protocolFromUrl:function(e){const t=/^[\x00-\x20]*([^\\/#?]*?)(:|&#0*58|&#x0*3a|&colon)/i.exec(e);return t?":"!==t[2]?null:/^[a-zA-Z][a-zA-Z0-9+\-.]*$/.test(t[1])?t[1].toLowerCase():null:"_relative"}};const h={displayMode:{type:"boolean",description:"Render math in display mode, which puts the math in display style (so \\int and \\sum are large, for example), and centers the math on the page on its own line.",cli:"-d, --display-mode"},output:{type:{enum:["htmlAndMathml","html","mathml"]},description:"Determines the markup language of the output.",cli:"-F, --format <type>"},leqno:{type:"boolean",description:"Render display math in leqno style (left-justified tags)."},fleqn:{type:"boolean",description:"Render display math flush left."},throwOnError:{type:"boolean",default:!0,cli:"-t, --no-throw-on-error",cliDescription:"Render errors (in the color given by --error-color) instead of throwing a ParseError exception when encountering an error."},errorColor:{type:"string",default:"#cc0000",cli:"-c, --error-color <color>",cliDescription:"A color string given in the format 'rgb' or 'rrggbb' (no #). This option determines the color of errors rendered by the -t option.",cliProcessor:e=>"#"+e},macros:{type:"object",cli:"-m, --macro <def>",cliDescription:"Define custom macro of the form '\\foo:expansion' (use multiple -m arguments for multiple macros).",cliDefault:[],cliProcessor:(e,t)=>(t.push(e),t)},minRuleThickness:{type:"number",description:"Specifies a minimum thickness, in ems, for fraction lines, `\\sqrt` top lines, `{array}` vertical lines, `\\hline`, `\\hdashline`, `\\underline`, `\\overline`, and the borders of `\\fbox`, `\\boxed`, and `\\fcolorbox`.",processor:e=>Math.max(0,e),cli:"--min-rule-thickness <size>",cliProcessor:parseFloat},colorIsTextColor:{type:"boolean",description:"Makes \\color behave like LaTeX's 2-argument \\textcolor, instead of LaTeX's one-argument \\color mode change.",cli:"-b, --color-is-text-color"},strict:{type:[{enum:["warn","ignore","error"]},"boolean","function"],description:"Turn on strict / LaTeX faithfulness mode, which throws an error if the input uses features that are not supported by LaTeX.",cli:"-S, --strict",cliDefault:!1},trust:{type:["boolean","function"],description:"Trust the input, enabling all HTML features such as \\url.",cli:"-T, --trust"},maxSize:{type:"number",default:1/0,description:"If non-zero, all user-specified sizes, e.g. in \\rule{500em}{500em}, will be capped to maxSize ems. Otherwise, elements and spaces can be arbitrarily large",processor:e=>Math.max(0,e),cli:"-s, --max-size <n>",cliProcessor:parseInt},maxExpand:{type:"number",default:1e3,description:"Limit the number of macro expansions to the specified number, to prevent e.g. infinite macro loops. If set to Infinity, the macro expander will try to fully expand as in LaTeX.",processor:e=>Math.max(0,e),cli:"-e, --max-expand <n>",cliProcessor:e=>"Infinity"===e?1/0:parseInt(e)},globalGroup:{type:"boolean",cli:!1}};function c(e){if(e.default)return e.default;const t=e.type,r=Array.isArray(t)?t[0]:t;if("string"!=typeof r)return r.enum[0];switch(r){case"boolean":return!1;case"string":return"";case"number":return 0;case"object":return{}}}class m{constructor(e){this.displayMode=void 0,this.output=void 0,this.leqno=void 0,this.fleqn=void 0,this.throwOnError=void 0Line truncated
+1
View File
@@ -1215,6 +1215,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-pause" class="pe-link" onclick="togglePauseDownload()" style="display:none">Mettre en pause</button>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
<div class="pe-row">
+70
View File
@@ -0,0 +1,70 @@
// --- Formules LaTeX (KaTeX) ---------------------------------------------------
// Repris d'AJEAN (#123). md() (00-md.js) met les formules de côté AVANT marked,
// qui abîmerait \\, _ et * : un repère (caractères à usage privé U+E000/U+E001
// autour du numéro) prend leur place, puis reçoit le rendu. Le code (blocs et
// `en ligne`) n'est jamais touché. Formes reconnues : $$…$$ et \[…\] (bloc),
// \(…\) et $…$ (en ligne). $…$ n'est une formule que s'il ne commence ni ne finit
// par une espace, n'est pas collé à un mot ni échappé (\$) avant, et n'est pas
// suivi d'un chiffre : « 5 $ et 10 $ » ou « $5 et $10 » restent du texte.
// Ajouts de Loki : un $ ouvrant précédé d'un chiffre et d'une espace (« 5 $, »)
// est un montant, et $…$ ne contient pas de ` — sans quoi « 5 $, code `$x$` »
// s'apparierait par-dessus le code en ligne.
// KaTeX (embarqué, servi sous /katex/ par web_katex.go et la façade) n'est chargé
// qu'à la première formule ; d'ici là la source s'affiche, puis est rendue sur
// place. (Pas de lookbehind dans l'expression : les iOS d'avant 16.4 refusent de
// charger le script entier. Ces contrôles-là sont faits dans le remplacement.)
//
// MATH_RE et mathExtract sont purs (ni DOM ni réseau) : web_katex_math_test.go les
// exécute sous node sur les cas limites (montants, code, échappements).
const MATH_RE = /(```[\s\S]*?(?:```|$)|~~~[\s\S]*?(?:~~~|$)|`[^`\n]*`)|\$\$([\s\S]+?)\$\$|\\\[([\s\S]+?)\\\]|\\\(([\s\S]+?)\\\)|\$(?![\s$])((?:\\.|[^$\\\n`])+?)\$(?![\d$])/g;
const MATH_MARK_RE = /\uE000(\d+)\uE001/g;
function mathExtract(s){
const list = [];
const text = s.replace(MATH_RE, (all, code, dd, br, pa, d, at)=>{
if(code !== undefined) return all;
// $…$ : pas collé à un mot ni échappé (\$) avant, pas un montant (« 5 $ »),
// pas d'espace avant le $ final.
if(d !== undefined && (/[\\$\w]/.test(s[at-1] || '') || /\d\s$/.test(s.slice(Math.max(0, at-2), at)) || /\s$/.test(d))) return all;
const display = dd !== undefined || br !== undefined;
list.push({tex: (dd ?? br ?? pa ?? d).trim(), display});
return '\uE000' + (list.length-1) + '\uE001';
});
return {text, list};
}
// mathRestore remplace chaque repère du HTML produit par marked par le rendu de
// sa formule (render : formule → HTML).
function mathRestore(html, list, render){
return list.length ? html.replace(MATH_MARK_RE, (all, i)=>list[+i] ? render(list[+i]) : all) : html;
}
const mathEsc = (s)=>s.replace(/[&<>"]/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const MATH_OPTS = {throwOnError: false, output: 'html'};
function mathHtml(f){
if(window.katex){
try{ return katex.renderToString(f.tex, {...MATH_OPTS, displayMode: f.display}); }
catch(_){ return '<code>'+mathEsc(f.tex)+'</code>'; }
}
mathLoad();
return '<span class="math-pending" data-d="'+(f.display?1:0)+'">'+mathEsc(f.tex)+'</span>';
}
let mathLoading = null;
function mathLoad(){
if(mathLoading) return mathLoading;
if(!document.querySelector('link[data-katex]')){
const css = document.createElement('link');
css.rel = 'stylesheet'; css.href = 'katex/katex.min.css'; css.dataset.katex = '1';
document.head.appendChild(css);
}
mathLoading = new Promise((ok, ko)=>{
const s = document.createElement('script');
s.src = 'katex/katex.min.js'; s.onload = ok;
// Échec (hors ligne, 404) : script retiré, nouvel essai à la prochaine formule.
s.onerror = ()=>{ s.remove(); ko(); };
document.head.appendChild(s);
}).then(()=>{
document.querySelectorAll('.math-pending').forEach(el=>{
try{ katex.render(el.textContent, el, {...MATH_OPTS, displayMode: el.dataset.d === '1'}); }catch(_){}
el.classList.remove('math-pending');
});
}).catch(()=>{ mathLoading = null; });
return mathLoading;
}
+7 -1
View File
@@ -14,7 +14,13 @@ function fixMd(s){
s = s.replace(/\n{3,}/g, '\n\n');
return s;
}
function md(src){ return src ? marked.parse(fixMd(src)) : ''; }
// Les formules LaTeX sont mises de côté avant marked puis rendues par KaTeX
// (00-math.js) ; le code et les montants en $ ne sont pas touchés.
function md(src){
if(!src) return '';
const m = mathExtract(fixMd(src));
return mathRestore(marked.parse(m.text), m.list, mathHtml);
}
// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés
// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown.
// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du
+1
View File
@@ -330,6 +330,7 @@ async function loadCfg(){
rows.push(row('CTX', d.ctx));
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
rows.push(row('MTP', d.spec));
if(d.helper_gpu) rows.push(row('GPU 2', d.split_layers ? 'couches' : 'aide', d.helper_gpu));
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
}else if(c.CTX) rows.push(row('CTX', c.CTX));
+25 -3
View File
@@ -1501,6 +1501,7 @@ function dlEls(){
prog: document.getElementById('m-hf-progress'),
bar: document.getElementById('m-hf-bar'),
cancel: document.getElementById('m-hf-cancel'),
pause: document.getElementById('m-hf-pause'),
};
}
// Arrête le poll local sans toucher au téléchargement serveur.
@@ -1510,7 +1511,7 @@ function resetDlUI(){
const e = dlEls();
stopDlPoll(); dlName='';
e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false;
e.cancel.style.display='none'; e.pause.style.display='none'; e.btn.disabled=false;
}
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
@@ -1627,6 +1628,22 @@ async function cancelDownload(){
await jpost('/api/models/download/cancel', {filename: dlName});
// L'état « annulé » est confirmé par le prochain tick de poll.
}
// Met en pause ou reprend : le fichier partiel est gardé, la reprise repart
// de là où le transfert s'était arrêté (repris d'AJEAN v0.18.1).
let dlPaused = false;
async function togglePauseDownload(){
if(!dlName) return;
const e = dlEls();
e.pause.disabled = true;
try{
const r = await jpost('/api/models/download/pause', {filename: dlName, pause: !dlPaused});
if(r.ok) setDlPaused(r.paused);
}finally{ e.pause.disabled = false; }
}
function setDlPaused(p){
dlPaused = !!p;
dlEls().pause.textContent = dlPaused ? 'Reprendre' : 'Mettre en pause';
}
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
function watchDownload(fname){
const e = dlEls();
@@ -1637,7 +1654,10 @@ function watchDownload(fname){
e.bar.style.display = 'block';
e.cancel.style.display = 'inline';
e.cancel.disabled = false;
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
e.pause.style.display = 'inline';
e.pause.disabled = false;
setDlPaused(false);
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; e.pause.style.display='none'; };
const tick = async ()=>{
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>d.filename===fname);
@@ -1661,6 +1681,7 @@ function watchDownload(fname){
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
if(!!st.paused !== dlPaused) setDlPaused(st.paused);
const pct = st.total>0 ? st.done*100/st.total : 0;
if(st.total>0){
e.bar.className = 'pe-bar';
@@ -1670,7 +1691,8 @@ function watchDownload(fname){
}
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
let extra = '';
if(st.speed>0){
if(st.paused) extra += ' — en pause';
else if(st.speed>0){
extra += ' — '+fmtSize(st.speed)+'/s';
if(st.total>0){
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
+13 -4
View File
@@ -128,7 +128,11 @@ function strataRenderSettings(fit){
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
['Lire les images', sw('strata-s-vision', d.vision)],
];
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
// Seconde carte : aide aux experts (défaut mesuré) ou partage des couches,
// souvent plus rapide avec deux cartes identiques (#121 d'AJEAN).
if(d.has_helper) rows.push(['Seconde carte', sel('strata-s-helper',
[['off', 'non utilisée'], ['helper', 'aide aux experts'], ['layers', 'partage des couches']],
!d.helper_on ? 'off' : d.split_layers ? 'layers' : 'helper')]);
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
strataCtxLbl();
@@ -148,8 +152,11 @@ async function strataSaveSettings(){
if(!fit || !fit.preset) return;
const v = id => document.getElementById(id);
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
vision: v('strata-s-vision').checked,
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
const second = v('strata-s-helper') ? v('strata-s-helper').value : 'off';
body.helper = second !== 'off';
if(body.helper) body.split = second;
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
const r = await jpost('/api/strata/settings', body);
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
@@ -168,14 +175,16 @@ function strataRenderDetails(fit){
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
const rows = [
['Carte principale', d.main_gpu],
["Carte d'appoint", d.helper_gpu || 'aucune'],
['Seconde carte', d.helper_gpu ? d.helper_gpu + (d.split_layers ? ' · partage des couches' : ' · aide aux experts') : 'aucune'],
['Lecture des images', d.vision_gpu || 'aucune'],
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
['Prédiction (MTP)', d.spec+' jetons'],
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
// paquet 1.1 : la carte d'appoint calcule ses experts à la lecture d'un prompt
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'
+ (d.helper_gpu && !d.split_layers ? ", experts de la carte d'appoint calculés par elle" : '')],
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
['Version du moteur', 'paquet AJEAN MoE '+d.version],
];
+4
View File
@@ -1383,6 +1383,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
border:1px solid var(--border);border-radius:999px;background:var(--panel);
color:var(--dim);font-size:12px;line-height:1.5}
.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)}
/* Formules LaTeX (KaTeX, 00-math.js) : une longue formule défile au lieu de déborder. */
.katex{font-size:1.08em}
.katex-display{margin:.6em 0;overflow-x:auto;overflow-y:hidden;padding:2px 0}
.math-pending{font-family:var(--mono);font-size:.92em;color:var(--dim)}
/* ===== Fenêtres et sélecteurs =====================================
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
+59
View File
@@ -0,0 +1,59 @@
package loki
import (
"net/http"
"path"
"strconv"
"strings"
)
// web_katex.go — KaTeX 0.16.11 (formules LaTeX des réponses), repris d'AJEAN.
//
// Embarqué sous ui/katex/ et servi sous /katex/ : l'UI ne le charge qu'à la
// première formule d'une réponse (00-md.js), et il doit marcher hors ligne,
// sans appeler un CDN. Seuls le script, la feuille de style et les polices
// woff2 sont servis — pas d'index de dossier — avec un Content-Type fixe : le
// registre MIME de l'OS (Windows notamment) ne doit pas le décider, et la
// façade Rust (rust/loki-rs/src/ui.rs) rend exactement les mêmes en-têtes.
// katexTypes : extensions servies et leur Content-Type.
var katexTypes = map[string]string{
".js": "application/javascript",
".css": "text/css; charset=utf-8",
".woff2": "font/woff2",
}
// katexFile : chemin relatif à ui/katex/ d'une ressource servable, ou "" si la
// demande n'en est pas une (racine, dossier, autre extension, remontée).
func katexFile(urlPath string) (rel, ctype string) {
rel = strings.TrimPrefix(urlPath, "/katex/")
if rel == urlPath || rel == "" || strings.Contains(rel, "..") || path.Clean(rel) != rel {
return "", ""
}
ctype, ok := katexTypes[path.Ext(rel)]
if !ok {
return "", ""
}
return rel, ctype
}
func handleKatex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r)
return
}
rel, ctype := katexFile(r.URL.Path)
if rel == "" {
http.NotFound(w, r)
return
}
b, err := uiFS.ReadFile("ui/katex/" + rel)
if err != nil {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Cache-Control", "public, max-age=86400")
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
w.Write(b)
}
+143
View File
@@ -0,0 +1,143 @@
package loki
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
// Détection des formules (ui/src/js/00-math.js), exécutée sous node : le dépôt
// n'a pas de banc de tests JS, et MATH_RE/mathExtract sont purs. Ignoré si node
// n'est pas installé (il l'est sur les runners de la CI).
type formule struct {
Tex string `json:"tex"`
Display bool `json:"display"`
}
// harnaisMath : charge 00-math.js et marked.min.js, puis pour chaque entrée
// (JSON sur stdin) rend {text, list} de mathExtract et, pour le bout en bout, le
// HTML de marked après mathRestore (chaque formule rendue « [tex] »).
const harnaisMath = `
const fs = require('fs');
const [mathPath, markedPath] = process.argv.slice(2);
const m = new Function(fs.readFileSync(mathPath, 'utf8') + '; return {mathExtract, mathRestore};')();
const {marked} = require(markedPath);
marked.setOptions({ gfm: true, breaks: false });
const cases = JSON.parse(fs.readFileSync(0, 'utf8'));
console.log(JSON.stringify(cases.map(s => {
const r = m.mathExtract(s);
const html = m.mathRestore(marked.parse(r.text), r.list, f => '[' + (f.display ? 'D:' : '') + f.tex + ']');
return {text: r.text, list: r.list, html};
})));
`
type résultatMath struct {
Text string `json:"text"`
List []formule `json:"list"`
HTML string `json:"html"`
}
func lanceMath(t *testing.T, cas []string) []résultatMath {
t.Helper()
node, err := exec.LookPath("node")
if err != nil {
t.Skip("node absent : détection des formules non testée")
}
dir := t.TempDir()
h := filepath.Join(dir, "harnais.js")
if err := os.WriteFile(h, []byte(harnaisMath), 0o644); err != nil {
t.Fatal(err)
}
math, _ := filepath.Abs("ui/src/js/00-math.js")
marked, _ := filepath.Abs("ui/marked.min.js")
in, _ := json.Marshal(cas)
cmd := exec.Command(node, h, math, marked)
cmd.Stdin = strings.NewReader(string(in))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("node : %v\n%s", err, out)
}
var res []résultatMath
if err := json.Unmarshal(out, &res); err != nil {
t.Fatalf("sortie illisible : %v\n%s", err, out)
}
if len(res) != len(cas) {
t.Fatalf("%d résultats pour %d cas", len(res), len(cas))
}
return res
}
func TestMathDetection(t *testing.T) {
in := func(tex string) formule { return formule{Tex: tex} }
bloc := func(tex string) formule { return formule{Tex: tex, Display: true} }
cas := []struct {
src string
want []formule
}{
// Montants : jamais des formules.
{"Ça coûte 5 $ et 10 $.", nil},
{"Entre $5 et $10 par mois.", nil},
{"Prix : 5$ ou 6$, voire 7 $.", nil},
{`Échappés : \$5 et \$6.`, nil},
{"Collé à un mot : x$y$z.", nil},
{"Espace finale : $x $.", nil},
{"De 5 $, à 10$.", nil},
{"Montant : 5 $, code `$x_1$`.", nil},
// Code : jamais touché.
{"En ligne `$x$` et `$$y$$`.", nil},
{"```\n$x$ et $$y$$\n```", nil},
{"~~~tex\n\\[a\\]\n~~~", nil},
{"```\nbloc non fermé $x$", nil},
// Formules.
{"Soit $x^2 + y^2 = z^2$ ici.", []formule{in("x^2 + y^2 = z^2")}},
{"Deux : $a$, $b$.", []formule{in("a"), in("b")}},
{"$$\n\\int_0^1 x\\,dx\n$$", []formule{bloc(`\int_0^1 x\,dx`)}},
{`En ligne \(a+b\) et en bloc \[c\].`, []formule{in("a+b"), bloc("c")}},
{"Code `$x$` puis $y$.", []formule{in("y")}},
{`Échappement dedans : $a\$b$.`, []formule{in(`a\$b`)}},
}
srcs := make([]string, len(cas))
for i, c := range cas {
srcs[i] = c.src
}
res := lanceMath(t, srcs)
for i, c := range cas {
got := res[i].List
if len(got) == 0 {
got = nil
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%q : %+v, attendu %+v", c.src, got, c.want)
}
if c.want == nil && res[i].Text != c.src {
t.Errorf("%q : texte modifié en %q", c.src, res[i].Text)
}
}
}
// Bout en bout avec marked : \\, _ et * d'une formule arrivent intacts au rendu,
// le markdown autour reste du markdown, et aucun repère ne survit.
func TestMathAvecMarked(t *testing.T) {
res := lanceMath(t, []string{
"*Gras* : $a_1 * b_2$ et $$\\begin{matrix}a & b \\\\ c & d\\end{matrix}$$",
"Montant : 5 $, code `$x_1$`.",
})
for _, want := range []string{"<em>Gras</em>", "[a_1 * b_2]", `[D:\begin{matrix}a & b \\ c & d\end{matrix}]`} {
if !strings.Contains(res[0].HTML, want) {
t.Errorf("HTML sans %q :\n%s", want, res[0].HTML)
}
}
for _, r := range res {
if strings.ContainsAny(r.HTML, "\uE000\uE001") {
t.Errorf("repère resté dans le HTML : %q", r.HTML)
}
}
if !strings.Contains(res[1].HTML, "5 $") || !strings.Contains(res[1].HTML, "<code>$x_1$</code>") {
t.Errorf("montant ou code abîmé : %s", res[1].HTML)
}
}
+94
View File
@@ -0,0 +1,94 @@
package loki
import (
"net/http"
"net/http/httptest"
"regexp"
"strconv"
"strings"
"testing"
)
func getKatex(t *testing.T, méthode, chemin string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
handleKatex(rec, httptest.NewRequest(méthode, chemin, nil))
return rec
}
// Le script, embarqué, est servi sous /katex/ avec les en-têtes que rend aussi
// la façade Rust (ui.rs).
func TestKatexScriptServi(t *testing.T) {
rec := getKatex(t, http.MethodGet, "/katex/katex.min.js")
if rec.Code != http.StatusOK {
t.Fatalf("code %d", rec.Code)
}
h := rec.Header()
if got := h.Get("Content-Type"); got != "application/javascript" {
t.Errorf("Content-Type = %q", got)
}
if got := h.Get("Cache-Control"); got != "public, max-age=86400" {
t.Errorf("Cache-Control = %q", got)
}
if !strings.Contains(rec.Body.String(), `version:"0.16.11"`) {
t.Error("katex.min.js n'est pas KaTeX 0.16.11")
}
if h.Get("Content-Length") != strconv.Itoa(rec.Body.Len()) {
t.Errorf("Content-Length = %q pour %d octets", h.Get("Content-Length"), rec.Body.Len())
}
}
// Chaque police woff2 que la feuille de style demande est embarquée et servie.
func TestKatexFeuilleEtPolices(t *testing.T) {
rec := getKatex(t, http.MethodGet, "/katex/katex.min.css")
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/css; charset=utf-8" {
t.Fatalf("css : code %d, type %q", rec.Code, rec.Header().Get("Content-Type"))
}
polices := regexp.MustCompile(`url\((fonts/[^)]+\.woff2)\)`).FindAllStringSubmatch(rec.Body.String(), -1)
if len(polices) < 10 {
t.Fatalf("%d polices woff2 dans la feuille", len(polices))
}
for _, m := range polices {
r := getKatex(t, http.MethodGet, "/katex/"+m[1])
if r.Code != http.StatusOK || r.Header().Get("Content-Type") != "font/woff2" || r.Body.Len() == 0 {
t.Errorf("%s : code %d, type %q", m[1], r.Code, r.Header().Get("Content-Type"))
}
}
}
// Rien d'autre : ni index de dossier, ni licence, ni remontée, ni POST.
func TestKatexRefus(t *testing.T) {
for _, c := range []struct{ méthode, chemin string }{
{http.MethodGet, "/katex/"},
{http.MethodGet, "/katex/fonts/"},
{http.MethodGet, "/katex/LICENSE"},
{http.MethodGet, "/katex/fonts/absente.woff2"},
{http.MethodGet, "/katex/../index.html"},
{http.MethodGet, "/katex/fonts/../katex.min.js"},
{http.MethodGet, "/katexkatex.min.js"},
{http.MethodPost, "/katex/katex.min.js"},
} {
if rec := getKatex(t, c.méthode, c.chemin); rec.Code != http.StatusNotFound {
t.Errorf("%s %s : code %d, attendu 404", c.méthode, c.chemin, rec.Code)
}
}
}
// Derrière gzipHandler (comme en production) : script et feuille compressés,
// polices non — comme la façade.
func TestKatexCompression(t *testing.T) {
h := gzipHandler(http.HandlerFunc(handleKatex))
for chemin, gz := range map[string]bool{
"/katex/katex.min.js": true,
"/katex/katex.min.css": true,
"/katex/fonts/KaTeX_Main-Regular.woff2": false,
} {
req := httptest.NewRequest(http.MethodGet, chemin, nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if got := rec.Header().Get("Content-Encoding") == "gzip"; got != gz {
t.Errorf("%s : gzip %v, attendu %v", chemin, got, gz)
}
}
}
+5 -1
View File
@@ -19,7 +19,7 @@ import (
)
//go:generate go run ../../tools/assemble-ui ui
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2
//go:embed ui/index.html ui/marked.min.js ui/sw.js ui/manifest.webmanifest ui/fonts/*.woff2 ui/katex
var uiFS embed.FS
// cmdWeb starts the HTTP server on the given port (default 8090).
@@ -217,6 +217,9 @@ func newWebMux() *http.ServeMux {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Write(b)
})
// KaTeX (formules LaTeX des réponses, web_katex.go) : chargé par l'UI
// seulement quand une réponse contient des maths. Embarqué, comme les polices.
mux.HandleFunc("/katex/", handleKatex)
// Endpoint compatible OpenAI, servi par Loki SUR SON PORT et relayé vers
// llama-server (voir mountOAI). C'est ce qui le rend joignable par le nom de
// domaine ou l'IP de l'interface, sans publier de second port ni ouvrir le
@@ -259,6 +262,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
+26
View File
@@ -42,6 +42,32 @@ fn main() {
}
out.push_str("];\n");
// KaTeX (formules LaTeX, repris d'AJEAN) : script, feuille et polices
// woff2 sous ui/katex/, les polices nommées par leur chemin relatif
// (« fonts/x.woff2 »), comme les sert web_katex.go.
let katex = ui.join("katex");
println!("cargo:rerun-if-changed={}", katex.display());
for (ident, name) in [("KATEX_JS", "katex.min.js"), ("KATEX_CSS", "katex.min.css")] {
let p = katex.join(name);
assert!(p.is_file(), "fichier KaTeX manquant : {}", p.display());
println!("cargo:rerun-if-changed={}", p.display());
writeln!(out, "pub static {ident}: &[u8] = include_bytes!({p:?});").unwrap();
}
let katex_fonts = katex.join("fonts");
println!("cargo:rerun-if-changed={}", katex_fonts.display());
let mut kfonts: Vec<PathBuf> = std::fs::read_dir(&katex_fonts)
.unwrap_or_else(|e| panic!("{} : {e}", katex_fonts.display()))
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.extension().is_some_and(|x| x == "woff2"))
.collect();
kfonts.sort();
out.push_str("pub static KATEX_FONTS: &[(&str, &[u8])] = &[\n");
for p in &kfonts {
let name = format!("fonts/{}", p.file_name().unwrap().to_str().unwrap());
writeln!(out, " ({name:?}, include_bytes!({p:?})),").unwrap();
}
out.push_str("];\n");
let dst = PathBuf::from(std::env::var("OUT_DIR").unwrap()).join("ui_assets.rs");
std::fs::write(dst, out).unwrap();
}
+117 -10
View File
@@ -3,7 +3,7 @@
//! `backend_model_dirs.go` et `backend_hf.go` (lot 8 de la migration).
//!
//! Tenus par la façade quand `LOKI_MODELS=rust` (expérimental ; le Go
//! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`,
//! par défaut) : `POST /api/models/download`, `/probe`, `/cancel`, `/pause`,
//! `GET /api/models/download/status`. Le Go remet alors à la façade ces
//! routes quand elles lui arrivent par le tunnel du relais, et ne vide plus
//! les `.part` au démarrage (c'est la façade qui le fait).
@@ -71,6 +71,7 @@ pub fn handles(path: &str) -> bool {
| "/api/models/download/probe"
| "/api/models/download/status"
| "/api/models/download/cancel"
| "/api/models/download/pause"
)
}
@@ -519,6 +520,7 @@ pub struct DlState {
pub part: i64,
pub finished: bool,
pub canceled: bool,
pub paused: bool,
#[serde(rename = "error")]
pub err: String,
pub started_at: i64,
@@ -527,6 +529,8 @@ pub struct DlState {
struct Entry {
state: Mutex<DlState>,
cancel: watch::Sender<bool>,
/// `dlGate` : vrai pendant une pause.
pause: watch::Sender<bool>,
}
impl Entry {
@@ -549,6 +553,37 @@ impl Stop {
}
}
/// `dlGate` : la pause d'un téléchargement. Les requêtes en cours sont
/// coupées (rien ne reste ouvert vers le CDN pendant la pause), et chaque
/// morceau attend la reprise puis repart de l'octet où il s'était arrêté, par
/// une requête de plage.
#[derive(Clone)]
struct Gate(watch::Receiver<bool>);
impl Gate {
fn is_paused(&self) -> bool {
*self.0.borrow()
}
/// Attend la reprise ; `false` : annulé entre-temps.
async fn wait(&self, stop: &Stop) -> bool {
if self.is_paused() {
let mut rx = self.0.clone();
tokio::select! {
_ = rx.wait_for(|p| !*p) => {}
_ = stop.wait() => return false,
}
}
!stop.is_set()
}
/// Se résout quand la pause tombe (jamais si l'émetteur a disparu).
async fn paused(&self) {
let mut rx = self.0.clone();
if rx.wait_for(|p| *p).await.is_err() {
std::future::pending::<()>().await;
}
}
}
/// Ce qu'un téléchargement doit savoir de la machine.
#[derive(Clone)]
pub struct DlEnv {
@@ -615,6 +650,28 @@ impl Downloads {
}
}
/// `handleModelDownloadPause` : met en pause ou reprend ; `false` :
/// inconnu ou terminé. Le fichier partiel est gardé.
pub fn pause(&self, name: &str, paused: bool) -> bool {
let Some(e) = self.map().get(name).cloned() else {
return false;
};
let running = e.with(|s| {
if s.finished {
return false;
}
s.paused = paused;
if paused {
s.speed = 0;
}
true
});
if running {
let _ = e.pause.send_replace(paused);
}
running
}
fn request(&self, env: &DlEnv, url: &str, range: Option<&str>) -> reqwest::RequestBuilder {
let mut rb = self
.client
@@ -722,6 +779,7 @@ impl Downloads {
);
}
let (tx, rx) = watch::channel(false);
let (pause_tx, pause_rx) = watch::channel(false);
let entry = Arc::new(Entry {
state: Mutex::new(DlState {
filename: name.clone(),
@@ -732,12 +790,14 @@ impl Downloads {
..Default::default()
}),
cancel: tx,
pause: pause_tx,
});
map.insert(name.clone(), entry.clone());
drop(map);
let parts = todo.len();
let me = self.clone();
tokio::spawn(async move { me.run_set(env, entry, Stop(rx), todo).await });
let gate = Gate(pause_rx);
tokio::spawn(async move { me.run_set(env, entry, Stop(rx), gate, todo).await });
(
StatusCode::OK,
json!({ "ok": true, "filename": name, "parts": parts }),
@@ -752,6 +812,7 @@ impl Downloads {
env: DlEnv,
entry: Arc<Entry>,
stop: Stop,
gate: Gate,
todo: Vec<(String, String)>,
) {
let done = Arc::new(AtomicI64::new(0));
@@ -812,7 +873,9 @@ impl Downloads {
let dt = now.duration_since(last_at).as_secs_f64();
entry.with(|s| {
s.done = cur;
if dt > 0.0 {
if s.paused {
s.speed = 0;
} else if dt > 0.0 {
let inst = ((cur - last) as f64 / dt) as i64;
s.speed = if s.speed == 0 {
inst
@@ -829,7 +892,7 @@ impl Downloads {
for (i, ((u, dest), (total, ranged))) in todo.iter().zip(sizes).enumerate() {
entry.with(|s| s.part = i as i64 + 1);
if let Err(h) = self
.one_part(&env, &entry, &stop, u, dest, total, ranged, &done)
.one_part(&env, &entry, &stop, &gate, u, dest, total, ranged, &done)
.await
{
ticker.abort();
@@ -851,6 +914,7 @@ impl Downloads {
env: &DlEnv,
entry: &Entry,
stop: &Stop,
gate: &Gate,
url: &str,
dest: &str,
total: i64,
@@ -877,7 +941,7 @@ impl Downloads {
path: tmp.clone(),
});
let res = if conns <= 1 {
self.chunk(env, stop, &file, url, 0, total - 1, total <= 0, done)
self.chunk(env, stop, gate, &file, url, 0, total - 1, total <= 0, done)
.await
} else {
let size = total / conns as i64;
@@ -888,7 +952,7 @@ impl Downloads {
} else {
start + size - 1
};
self.chunk(env, stop, &file, url, start, end, false, done)
self.chunk(env, stop, gate, &file, url, start, end, false, done)
});
futures_util::future::join_all(parts)
.await
@@ -918,6 +982,7 @@ impl Downloads {
&self,
env: &DlEnv,
stop: &Stop,
gate: &Gate,
file: &Arc<PartFile>,
url: &str,
start: i64,
@@ -927,7 +992,13 @@ impl Downloads {
) -> Result<(), Halt> {
let mut pos = start;
let mut last_err = String::new();
for attempt in 0..DL_ATTEMPTS {
let mut attempt: u64 = 0;
while attempt < DL_ATTEMPTS {
// Une pause n'est pas un échec : on attend la reprise sans
// consommer d'essai.
if !gate.wait(stop).await {
return Err(Halt::Canceled);
}
if stop.is_set() {
return Err(Halt::Canceled);
}
@@ -948,13 +1019,16 @@ impl Downloads {
None
};
let resp = tokio::select! {
r = self.request(env, url, range.as_deref()).send() => r,
r = self.request(env, url, range.as_deref()).send() => r.map_err(|e| e.to_string()),
_ = stop.wait() => return Err(Halt::Canceled),
_ = gate.paused() => Err("en pause".to_string()),
};
let resp = match resp {
Ok(r) => r,
Err(_) if gate.is_paused() && !stop.is_set() => continue,
Err(e) => {
last_err = e.to_string();
last_err = e;
attempt += 1;
continue;
}
};
@@ -967,7 +1041,7 @@ impl Downloads {
done.fetch_add(start - pos, Ordering::Relaxed);
pos = start;
}
match copy_at(stop, file, resp, pos, done).await {
match copy_at(stop, gate, file, resp, pos, done).await {
(n, Ok(())) => {
let _ = n;
return Ok(());
@@ -975,7 +1049,12 @@ impl Downloads {
(_, Err(Halt::Canceled)) => return Err(Halt::Canceled),
(n, Err(Halt::Err(e))) => {
pos += n;
// coupé par la pause : la reprise repart de pos
if gate.is_paused() && !stop.is_set() {
continue;
}
last_err = e;
attempt += 1;
}
}
}
@@ -987,6 +1066,7 @@ impl Downloads {
/// même en cas d'erreur (pour reprendre).
async fn copy_at(
stop: &Stop,
gate: &Gate,
file: &Arc<PartFile>,
resp: reqwest::Response,
off: i64,
@@ -999,6 +1079,17 @@ async fn copy_at(
let next = tokio::select! {
n = stream.next() => n,
_ = stop.wait() => return (written, Err(Halt::Canceled)),
_ = gate.paused() => {
// pause : ce qui est reçu est écrit, le flux est coupé
return match flush(file, &mut buf, off + written).await {
Ok(k) => {
written += k;
done.fetch_add(k, Ordering::Relaxed);
(written, Err(Halt::Err("en pause".into())))
}
Err(we) => (written, Err(Halt::Err(we))),
};
}
};
let eof = next.is_none();
match next {
@@ -1186,6 +1277,22 @@ pub async fn serve(
)
}
}
"/api/models/download/pause" => {
let v = match body_json(body).await {
Ok(v) => v,
Err(e) => return bad(e),
};
let name = filepath_base(str_of(&v, "filename").trim());
let paused = v.get("pause").and_then(Value::as_bool).unwrap_or(false);
if dl.pause(&name, paused) {
send_json(StatusCode::OK, &json!({ "ok": true, "paused": paused }))
} else {
send_json(
StatusCode::NOT_FOUND,
&json!({ "ok": false, "error": format!("aucun téléchargement en cours pour {name}") }),
)
}
}
"/api/models/download" => {
let v = match body_json(body).await {
Ok(v) => v,
+4 -1
View File
@@ -345,7 +345,7 @@ impl Strata {
"mmap": mode == "disk" || mode == "arena", "drop": mode == "drop",
"experts": experts, "mode_label": experts_label(&mode), "mode": mode,
"main_gpu": main_gpu,
"has_helper": false, "helper_on": false,
"has_helper": false, "helper_on": false, "split_layers": false,
});
let kvr = val("--kv-resident");
if !kvr.is_empty() {
@@ -356,6 +356,7 @@ impl Strata {
d["has_helper"] = json!(true);
if helper_on(&pc) {
d["helper_on"] = json!(true);
d["split_layers"] = json!(layer_split(&pc));
let hg = gpu_name(h);
if !hg.is_empty() {
d["helper_gpu"] = json!(hg);
@@ -825,6 +826,7 @@ pub async fn serve(
("vision", Kind::Bool),
("helper", Kind::Bool),
("experts", Kind::Str),
("split", Kind::Str),
],
);
if let Some(e) = d.err.clone() {
@@ -838,6 +840,7 @@ pub async fn serve(
vision: d.bool("vision"),
helper: d.bool("helper"),
experts: d.str("experts"),
split: d.str("split"),
};
if r.preset.contains(['/', '\\']) || r.preset.is_empty() {
return fail(StatusCode::BAD_REQUEST, "preset invalide");
+67 -12
View File
@@ -19,24 +19,27 @@ use std::path::{Path, PathBuf};
use serde_json::{Map, Value, json};
/// Paquet figé (release moe-v1.0 d'AJEAN).
pub const VERSION: &str = "1.0";
pub const RELEASE_BASE: &str = "https://github.com/nathaninline/ajean/releases/download/moe-v1.0/";
pub const SRC_ASSET: &str = "ajean-moe-src-1.0.tar.gz";
pub const ENGINE_ASSET: &str = "ajean-moe-engine-1.0-linux-x64-cuda12.zip";
/// Paquet figé (release moe-v1.1 d'AJEAN : experts de la carte d'aide
/// calculés par elle à la lecture des prompts). Un modèle installé avec la
/// 1.0 passe à la 1.1 à son premier lancement (`strataMigrate`, côté Go :
/// `loki serve` d'un preset Strata reste au Go).
pub const VERSION: &str = "1.1";
pub const RELEASE_BASE: &str = "https://github.com/nathaninline/ajean/releases/download/moe-v1.1/";
pub const SRC_ASSET: &str = "ajean-moe-src-1.1.tar.gz";
pub const ENGINE_ASSET: &str = "ajean-moe-engine-1.1-linux-x64-cuda12.zip";
/// Le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
pub const ENGINE_LOCAL: &str = "strata-linux-x64-cuda12.zip";
/// `strataAssetSHA`.
/// `strataAssetSHA` (celles qu'AJEAN publie pour moe-v1.1).
pub fn default_asset_sha() -> HashMap<String, String> {
HashMap::from([
(
SRC_ASSET.to_string(),
"5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b".to_string(),
"5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115".to_string(),
),
(
ENGINE_ASSET.to_string(),
"b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c".to_string(),
"8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3".to_string(),
),
])
}
@@ -137,7 +140,11 @@ pub fn quant(id: &str) -> Option<Quant> {
}
pub const LOW_RAM_HEADROOM_GB: f64 = 10.0;
pub const DROP_HEADROOM_GB: f64 = 7.0;
/// `strataDropHeadroomGB` (le moteur 1.1 prend ~1 Go de plus que la 1.0).
pub const DROP_HEADROOM_GB: f64 = 8.0;
/// `strataDropMaxCtx` : contexte maximal en mode hors RAM (le cache KV
/// au-delà de la part en VRAM est verrouillé en RAM).
pub const DROP_MAX_CTX: i64 = 131072;
/// `strataHelperExpertsGB`.
pub fn helper_experts_gb(vram_gb: f64) -> f64 {
@@ -280,6 +287,13 @@ pub fn helper_on(cfg: &Cfg) -> bool {
!h.is_empty() && h != "-1" && cfg_get(cfg, "STRATA_HELPER") != "0"
}
/// `strataLayerSplit` : les deux cartes se partagent les couches
/// (`STRATA_SPLIT=layers`) au lieu que la seconde n'aide que pour une part
/// des experts. Un choix, jamais imposé : le mode d'aide reste le défaut.
pub fn layer_split(cfg: &Cfg) -> bool {
helper_on(cfg) && cfg_get(cfg, "STRATA_SPLIT") == "layers"
}
/// `strataResolveExperts` : le mode lancé (drop, ram, arena, disk), d'après
/// le choix du preset et la RAM disponible.
pub fn resolve_experts(
@@ -290,7 +304,9 @@ pub fn resolve_experts(
helper_gb: f64,
) -> &'static str {
let bin_ready = pack_has_experts_bin(args) && paths.engine_installed() == ENGINE_ASSET;
let drop_ready = bin_ready && helper_on(cfg) && cfg_get(cfg, "STRATA_DROP") != "0";
// (jamais en partage des couches : la seconde carte n'y garde pas d'experts)
let drop_ready =
bin_ready && helper_on(cfg) && !layer_split(cfg) && cfg_get(cfg, "STRATA_DROP") != "0";
let arena = arena_gb(cfg);
match cfg_get(cfg, "STRATA_EXPERTS")
.trim()
@@ -404,7 +420,18 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
args = set_arg(args, flag, v);
}
let helper = cfg_get(cfg, "STRATA_HELPER_GPU").trim();
let has_helper = helper_on(cfg);
let mut has_helper = helper_on(cfg);
if layer_split(cfg) {
// Partage des couches : la clé « gpu » liste les deux cartes (numéros
// nvidia-smi), le serveur de Strata en déduit --layer-split.
let main = cfg_get(cfg, "STRATA_MAIN_GPU")
.trim()
.parse::<i64>()
.unwrap_or(0);
let second = helper.parse::<i64>().unwrap_or(0);
out.insert("gpu".into(), json!([main, second]));
has_helper = false;
}
if has_helper {
args = set_arg(args, "--expert-cache-device1", "auto");
if !has_arg(&args, "--remote-expert-opt") {
@@ -431,6 +458,8 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
env.insert("STRATA_REMOTE_DROP".into(), json!("1"));
env.insert("STRATA_STAGER_THREADS".into(), json!("8"));
env.insert("STRATA_STAGER_RING".into(), json!("32"));
// VRAM libre réservée de la carte d'aide : 640 Mio (défaut 1024)
env.insert("STRATA_REMOTE_DROP_RESERVE_MIB".into(), json!("640"));
}
"arena" => {
args = set_arg(args, "--pcie-frac", "0");
@@ -445,10 +474,23 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
for (k, v) in ENV_TUNING {
env.insert(k.into(), json!(v));
}
if has_helper {
// Lecture d'un prompt (moteur 1.1) : les experts de la carte d'aide
// sont calculés par elle (source « helper ») ; le chemin direct ne
// garde que les ajouts sous 48 jetons.
env.insert("STRATA_PREFILL_HELPER_SOURCE".into(), json!("1"));
args = set_arg(args, "--short-read", "48");
}
let drop_on = env.get("STRATA_REMOTE_DROP").and_then(Value::as_str) == Some("1");
out.insert("env".into(), Value::Object(env));
let ctx = cfg_get(cfg, "CTX").trim();
if !ctx.is_empty() {
args = set_arg(args, "--max-context", ctx);
// hors RAM, le cache KV verrouillé au-delà de DROP_MAX_CTX ne tient plus
let capped = match ctx.parse::<i64>() {
Ok(n) if n > DROP_MAX_CTX && drop_on => DROP_MAX_CTX.to_string(),
_ => ctx.to_string(),
};
args = set_arg(args, "--max-context", &capped);
}
for (key, flag) in [
("STRATA_KV", "--kv"),
@@ -766,6 +808,8 @@ pub struct SettingsReq {
pub vision: bool,
pub helper: bool,
pub experts: String,
/// « helper » ou « layers » ; vide = inchangé (anciens clients).
pub split: String,
}
/// `strataApplySettings` : réécrit les clés du preset. Pure.
@@ -786,6 +830,9 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
{
return Err(format!("placement des experts inconnu : {}", r.experts));
}
if !r.split.is_empty() && r.split != "helper" && r.split != "layers" {
return Err(format!("répartition inconnue : {}", r.split));
}
let mut set: Vec<(&str, String)> = vec![
("CTX", r.ctx.to_string()),
("STRATA_KV", r.kv.clone()),
@@ -796,6 +843,11 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
if !r.experts.is_empty() {
set.push(("STRATA_EXPERTS", r.experts.clone()));
}
if !r.split.is_empty() {
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
let v = if r.split == "layers" { "layers" } else { "" };
set.push(("STRATA_SPLIT", v.into()));
}
let mut lines: Vec<String> = Vec::new();
for l in content.trim_end_matches('\n').split('\n') {
let k = l.trim().split_once('=').map_or(l.trim(), |(k, _)| k);
@@ -805,6 +857,9 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
lines.push(l.to_string());
}
for (k, v) in set {
if k == "STRATA_SPLIT" && v.is_empty() {
continue;
}
lines.push(format!("{k}={v}"));
}
Ok(lines.join("\n") + "\n")
+119
View File
@@ -369,6 +369,125 @@ fn modes_des_experts() {
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
}
// Moteur 1.1 (`TestStrataBuildConfigHelperPrompt`).
#[test]
fn lecture_des_prompts_par_la_carte_d_aide() {
let base = json!({ "args": ["--pack", "/p"] });
let out = build_config(&base, &cfg(&[("STRATA_HELPER_GPU", "0")]), "", "ram").unwrap();
assert_eq!(arg_after(&args_of(&out), "--short-read"), "48");
assert_eq!(out["env"]["STRATA_PREFILL_HELPER_SOURCE"], "1");
for c in [
Cfg::new(),
cfg(&[("STRATA_HELPER_GPU", "0"), ("STRATA_HELPER", "0")]),
] {
let out = build_config(&base, &c, "", "ram").unwrap();
assert_eq!(arg_after(&args_of(&out), "--short-read"), "150", "{c:?}");
assert!(out["env"].get("STRATA_PREFILL_HELPER_SOURCE").is_none());
}
}
// `TestStrataBuildConfigDropReserveAndCtx`.
#[test]
fn hors_ram_reserve_et_contexte() {
let base = json!({ "args": ["--pack", "/p"], "env": {} });
let c = cfg(&[("STRATA_HELPER_GPU", "0"), ("CTX", "167936")]);
let out = build_config(&base, &c, "", "drop").unwrap();
assert_eq!(out["env"]["STRATA_REMOTE_DROP_RESERVE_MIB"], "640");
assert_eq!(arg_after(&args_of(&out), "--max-context"), "131072");
for mode in ["ram", "arena", "disk"] {
let out = build_config(&base, &c, "", mode).unwrap();
assert_eq!(
arg_after(&args_of(&out), "--max-context"),
"167936",
"{mode}"
);
assert!(out["env"].get("STRATA_REMOTE_DROP_RESERVE_MIB").is_none());
}
}
// `TestStrataBuildConfigLayerSplit`.
#[test]
fn partage_des_couches() {
let base = json!({
"args": ["--pack", "/p", "--vram-reserve-mib", "900"],
"vision": { "exe": "/v" },
});
let mut c = cfg(&[
("STRATA_MAIN_GPU", "1"),
("STRATA_HELPER_GPU", "0"),
("STRATA_SPLIT", "layers"),
]);
let out = build_config(&base, &c, "", "drop").unwrap();
assert_eq!(out["gpu"], json!([1, 0]));
let a = args_of(&out);
assert!(!has_arg(&a, "--remote-expert-opt") && !has_arg(&a, "--expert-cache-device1"));
assert_eq!(arg_after(&a, "--vram-reserve-mib"), "900");
assert_eq!(arg_after(&a, "--short-read"), "150");
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
assert!(out["env"].get("STRATA_PREFILL_HELPER_SOURCE").is_none());
assert!(out["vision"].get("cuda_device").is_none());
c.remove("STRATA_SPLIT");
let out = build_config(&base, &c, "", "drop").unwrap();
assert!(out.get("gpu").is_none());
assert!(has_arg(&args_of(&out), "--remote-expert-opt"));
c.insert("STRATA_SPLIT".into(), "layers".into());
c.insert("STRATA_HELPER".into(), "0".into());
assert!(!layer_split(&c));
}
// `TestStrataResolveExpertsLayerSplit`.
#[test]
fn partage_des_couches_sans_hors_ram() {
let d = tempfile::tempdir().unwrap();
let p = Paths {
home: d.path().to_path_buf(),
};
let pack = d.path().join("pack");
std::fs::create_dir_all(&pack).unwrap();
std::fs::write(pack.join("experts.bin"), "x").unwrap();
std::fs::create_dir_all(p.src_dir().join("engine-cuda12")).unwrap();
std::fs::write(p.engine_marker(), format!("{ENGINE_ASSET}\n")).unwrap();
let args = vec!["--pack".to_string(), pack.to_string_lossy().into_owned()];
let mut c = cfg(&[
("STRATA_CONFIG", "/x/strata-swift-iq3_xxs.json"),
("STRATA_HELPER_GPU", "1"),
("STRATA_DROP", "1"),
]);
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "drop");
c.insert("STRATA_SPLIT".into(), "layers".into());
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "arena");
c.insert("STRATA_EXPERTS".into(), "ram".into());
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "ram");
}
// `TestStrataApplySettingsSplit`.
#[test]
fn reglages_de_la_seconde_carte() {
let input = "ENGINE=strata\nSTRATA_HELPER_GPU=0\n";
let mut r = SettingsReq {
ctx: 131072,
kv: "fp16".into(),
spec: 3,
vision: true,
helper: true,
split: "layers".into(),
..SettingsReq::default()
};
let out = apply_settings(input, &r).unwrap();
assert!(out.contains("STRATA_SPLIT=layers\n"), "{out:?}");
r.split = "helper".into();
let out = apply_settings(&out, &r).unwrap();
assert!(!out.contains("STRATA_SPLIT"), "{out:?}");
r.split = String::new();
let out = apply_settings("STRATA_SPLIT=layers\n", &r).unwrap();
assert!(out.contains("STRATA_SPLIT=layers"), "{out:?}");
r.split = "nimporte".into();
assert_eq!(
apply_settings(input, &r).unwrap_err(),
"répartition inconnue : nimporte"
);
}
#[test]
fn reglages_des_experts() {
let input = "ENGINE=strata\nSTRATA_EXPERTS=auto\n";
+61
View File
@@ -48,6 +48,10 @@ static MANIFEST: Asset = Asset::new(
"application/manifest+json",
"public, max-age=3600",
);
/// KaTeX (formules LaTeX des réponses) : mêmes en-têtes que `web_katex.go`.
const KATEX_CACHE: &str = "public, max-age=86400";
static KATEX_JS: Asset = Asset::new(assets::KATEX_JS, "application/javascript", KATEX_CACHE);
static KATEX_CSS: Asset = Asset::new(assets::KATEX_CSS, "text/css; charset=utf-8", KATEX_CACHE);
/// Réponse statique pour cette requête, ou `None` si la route n'est pas une
/// ressource de l'UI (elle part alors au Go, qui rendra son 404 habituel).
@@ -76,6 +80,9 @@ pub fn serve(method: &Method, path: &str, headers: &HeaderMap) -> Option<Respons
}
"/manifest.webmanifest" => asset(&MANIFEST, accepts_gzip),
p if p.starts_with("/fonts/") => font(p)?,
"/katex/katex.min.js" => asset(&KATEX_JS, accepts_gzip),
"/katex/katex.min.css" => asset(&KATEX_CSS, accepts_gzip),
p if p.starts_with("/katex/") => katex_font(p)?,
_ => return None,
};
if accepts_gzip {
@@ -130,6 +137,20 @@ fn font(path: &str) -> Option<Response<Body>> {
Some(r)
}
/// `/katex/fonts/<nom>.woff2` — chemin exact (pas le dernier segment seul,
/// contrairement à `/fonts/`), comme `katexFile` dans `web_katex.go`. Le
/// reste de `/katex/` part au Go, qui rend son 404.
fn katex_font(path: &str) -> Option<Response<Body>> {
let rel = path.strip_prefix("/katex/")?;
let (_, body) = assets::KATEX_FONTS.iter().find(|(n, _)| *n == rel)?;
let mut r = Response::new(full(Bytes::from_static(body)));
let h = r.headers_mut();
h.insert(header::CONTENT_TYPE, HeaderValue::from_static("font/woff2"));
h.insert(header::CACHE_CONTROL, HeaderValue::from_static(KATEX_CACHE));
h.insert(header::CONTENT_LENGTH, HeaderValue::from(body.len()));
Some(r)
}
fn compress(b: &[u8]) -> Vec<u8> {
let mut enc = GzEncoder::new(Vec::with_capacity(b.len() / 3), Compression::default());
enc.write_all(b).expect("gzip en mémoire");
@@ -193,6 +214,46 @@ mod tests {
assert!(get("/fonts/inter.ttf", false).is_none());
}
#[test]
fn katex() {
let r = get("/katex/katex.min.js", true).unwrap();
assert_eq!(hv(&r, "content-type"), "application/javascript");
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
assert_eq!(hv(&r, "content-encoding"), "gzip");
assert!(
String::from_utf8_lossy(assets::KATEX_JS).contains("version:\"0.16.11\""),
"KaTeX 0.16.11 attendu"
);
let r = get("/katex/katex.min.css", false).unwrap();
assert_eq!(hv(&r, "content-type"), "text/css; charset=utf-8");
// Chaque police woff2 demandée par la feuille est servie, non compressée.
let css = String::from_utf8_lossy(assets::KATEX_CSS);
let polices: Vec<&str> = css
.split("url(")
.skip(1)
.filter_map(|s| s.split(')').next())
.filter(|u| u.ends_with(".woff2"))
.collect();
assert!(polices.len() >= 10, "{} polices", polices.len());
for p in polices {
let r = get(&format!("/katex/{p}"), true).unwrap_or_else(|| panic!("{p}"));
assert_eq!(hv(&r, "content-type"), "font/woff2");
assert_eq!(hv(&r, "content-encoding"), "");
assert_eq!(hv(&r, "cache-control"), "public, max-age=86400");
}
// Le reste part au Go (404) : ni index, ni licence, ni dernier segment seul.
for p in [
"/katex/",
"/katex/LICENSE",
"/katex/fonts/",
"/katex/fonts/absente.woff2",
"/katex/KaTeX_Main-Regular.woff2",
"/katex/x/fonts/KaTeX_Main-Regular.woff2",
] {
assert!(get(p, false).is_none(), "{p}");
}
}
#[test]
fn gzip_si_accepte() {
let r = get("/", true).unwrap();
+11 -4
View File
@@ -469,12 +469,19 @@ async fn bail_travail_annexe_et_trace() {
*bn.go.side_jobs.lock().unwrap(),
vec!["begin 1".to_string(), "end 1 true".to_string()]
);
// La longueur du prompt suit le corpus de code, c'est-à-dire l'UI
// embarquée : la vérifier en dur cassait le test à chaque retouche de
// l'interface. On vérifie la forme de la trace.
let perfs = bn.go.bench_perfs.lock().unwrap().clone();
assert_eq!(perfs.len(), 1, "{perfs:?}");
let n = perfs[0]["prompt_n"].as_i64().unwrap_or(0);
assert!(n > 0, "{perfs:?}");
assert_eq!(
*bn.go.bench_perfs.lock().unwrap(),
vec![json!({
"prompt_n": 2036, "prompt_ms": 1018.0, "prompt_per_second": 2000.0,
perfs[0],
json!({
"prompt_n": n, "prompt_ms": n as f64 / 2.0, "prompt_per_second": 2000.0,
"predicted_n": 300, "predicted_per_second": 50.0,
})]
})
);
// La clé de l'API locale accompagne les requêtes du bench.
let auth = bn.fake.lock().unwrap().auth.clone();
+59
View File
@@ -260,6 +260,13 @@ async fn wait_finished(dl: &Downloads, name: &str) -> DlState {
panic!("téléchargement {name} jamais terminé : {:?}", dl.status());
}
fn state_of(dl: &Downloads, name: &str) -> DlState {
dl.status()
.into_iter()
.find(|s| s.filename == name)
.unwrap()
}
fn models(home: &Path) -> PathBuf {
home.join("models")
}
@@ -537,6 +544,58 @@ async fn annulation_efface_le_part() {
assert_eq!(entries(&models(tmp.path())), Vec::<String>::new());
}
// `TestRunDownloadPauseResume` : la pause coupe le flux et garde ce qui est
// écrit ; la reprise repart de là par une plage et termine un fichier
// identique à la source.
#[tokio::test]
async fn pause_puis_reprise() {
let (addr, hf) = fake_hf().await;
let data = payload(8 << 20, 5);
hf.files
.lock()
.unwrap()
.insert("/m.gguf".into(), Arc::new(data.clone()));
*hf.slow.lock().unwrap() = true;
let tmp = tempfile::tempdir().unwrap();
let dl = Arc::new(Downloads::new());
let (code, _) = dl.start(
env(),
&dirs_at(tmp.path()),
&format!("http://{addr}/m.gguf"),
"",
);
assert_eq!(code, StatusCode::OK);
assert!(!dl.pause("autre.gguf", true));
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(dl.pause("m.gguf", true));
tokio::time::sleep(Duration::from_millis(1500)).await;
let a = state_of(&dl, "m.gguf");
tokio::time::sleep(Duration::from_millis(1500)).await;
let b = state_of(&dl, "m.gguf");
assert!(
a.paused && !b.finished && a.done == b.done && a.done > 0 && b.speed == 0,
"pause inopérante : {a:?} puis {b:?}"
);
let before = hf.log.lock().unwrap().len();
assert!(dl.pause("m.gguf", false));
let end = wait_finished(&dl, "m.gguf").await;
assert!(
end.err.is_empty() && !end.canceled && !end.paused,
"{end:?}"
);
let got = std::fs::read(models(tmp.path()).join("m.gguf")).unwrap();
assert!(got == data, "fichier différent de la source après reprise");
// La reprise demande la suite, pas le début.
let log = hf.log.lock().unwrap().clone();
let resumed = log[before..]
.iter()
.filter_map(|l| l.strip_prefix("/m.gguf bytes="))
.any(|r| !r.starts_with("0-"));
assert!(resumed, "{log:?}");
// Terminé : plus rien à mettre en pause.
assert!(!dl.pause("m.gguf", true));
}
#[tokio::test]
async fn refus_avant_de_lancer() {
let tmp = tempfile::tempdir().unwrap();
+125 -125
View File
@@ -231,8 +231,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -260,7 +260,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -304,7 +304,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -335,8 +335,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -571,7 +571,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -615,7 +615,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -855,8 +855,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1022,7 +1022,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -1066,7 +1066,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -1103,8 +1103,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1270,7 +1270,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -1314,7 +1314,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -1554,8 +1554,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 16400.5,
"prompt_n": 32801,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1715,7 +1715,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -1759,7 +1759,7 @@
"max_tokens": 4096,
"messages": [
{
"content": "<131209 octets a079a2b6a1db>",
"content": "<131191 octets 64f00ccc3d63>",
"role": "user"
}
],
@@ -1790,8 +1790,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16403,
"prompt_n": 32806,
"prompt_ms": 16400.5,
"prompt_n": 32801,
"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": 16400.5,
"prompt_n": 32801,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1882,7 +1882,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -2049,7 +2049,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -2093,7 +2093,7 @@
"max_tokens": 16,
"messages": [
{
"content": "<3745 octets 5578a8f2779c>",
"content": "<3744 octets dff015dced82>",
"role": "user"
}
],
@@ -2187,8 +2187,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -2216,7 +2216,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -2260,7 +2260,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -2291,8 +2291,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2368,7 +2368,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -2420,8 +2420,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2497,7 +2497,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -2541,7 +2541,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -2572,8 +2572,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2649,7 +2649,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -2693,7 +2693,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -2747,8 +2747,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2824,7 +2824,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -2868,7 +2868,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -2992,8 +2992,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3069,7 +3069,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -3121,8 +3121,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3218,7 +3218,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -3287,7 +3287,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -3319,8 +3319,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3412,7 +3412,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -3457,7 +3457,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -3489,8 +3489,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3608,7 +3608,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -3652,7 +3652,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -3737,8 +3737,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3823,7 +3823,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163781 octets 608b16075e1c>"
},
"method": "POST",
"path": "/tokenize"
@@ -3867,7 +3867,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8103 octets e6352865574a>",
"role": "user"
}
],
@@ -3952,8 +3952,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"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": 1014.5,
"prompt_n": 2029,
"prompt_per_second": 2000,
"protocol": 2
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -318,6 +318,7 @@ async fn parite_de_strata() {
"CTX",
"STRATA_KV",
"STRATA_HELPER",
"STRATA_SPLIT",
] {
if let Some(v) = bn.go.get("config", k).filter(|v| !v.is_empty()) {
after.insert(format!("config/{k}"), Value::String(bn.mask(&v)));