Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5.5 0be90a6941 Banc d'essai : journal de parité régénéré après le changement de l'UI
La profondeur du banc lit l'interface embarquée (ui/index.html, côté Go
comme côté façade via build.rs) : la recherche « partout » de la barre
latérale en décale les tailles et les empreintes. Régénéré sous Linux par
LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteBench ;
cargo test --test bench le relit sans écart. Aucun changement de
comportement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:38:41 +02:00
MichaelandClaude Opus 5.5 f86937a1ae Discussions : « rechercher partout » dans la barre latérale, avec un extrait sous le titre
Le filtre des titres reste immédiat, côté navigateur ; après une pause de
frappe, /api/conversations/search complète la liste avec les discussions
trouvées par leur texte, dans l'ordre de pertinence, et l'extrait du
passage trouvé sous le titre. Une ligne d'état dit quand la mémoire est
verrouillée (titres seulement), quand l'index est incomplet, quand seuls
les plus pertinents sont montrés et quand la recherche a échoué. Une
réponse périmée (saisie changée, liste rechargée) est jetée ; les
résultats restent affichés pendant qu'une recherche est refaite.
Libellé repris d'AJEAN v0.17.15 (b3e2a164).

index.html régénéré par tools/assemble-ui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:18 +02:00
MichaelandClaude Opus 5.5 e9c0555ab7 Discussions : la recherche trouve aussi les mots du texte, pas seulement les titres (index plein-texte chiffré, repris d'AJEAN 558d3b4d, #98)
Index inversé dans le bucket chatsearch (chat_search.go) : une fiche
légère par discussion (« d| » : version, date d'indexation, aperçu), sa
liste de mots (« t| ») et, par mot, la liste des discussions (« p| »).
Mots repliés sans accents, correspondance par début de mot, titre compté
double, comme chez l'amont. Chiffré quand la mémoire l'est
(encryptedBuckets) ; verrouillée, la recherche le dit (state=locked).

Adapté au modèle de Loki : toutes les discussions vivent dans
bkChat/conv:<id>. L'index suit chaque écriture de l'écrivain différé
(chat_persist.go, avant que le lot soit marqué traité : une suppression
passe toujours après) et ne touche que les mots ajoutés ou retirés. Il
suit aussi les écritures de la façade Rust, qui passent par
/internal/v1/store : le Go reste le seul à tenir l'index, la route
/api/conversations/search est servie ici et relayée par la façade comme
toute route qu'elle ne connaît pas (test de garde des deux côtés). Pas de
second écrivain, donc pas de test de parité à tenir.

Rattrapage en fond au démarrage, au déverrouillage et quand une recherche
trouve l'index incomplet : discussions absentes ou plus anciennes que leur
dernière écriture. La discussion ouverte est cherchée dans son journal en
RAM quand ce process la tient. L'extrait est pris autour du mot trouvé,
relu dans le corps des seuls résultats de la page.

Tests repris d'AJEAN (chat_search_test.go), adaptés, plus les écritures
de la façade, l'extrait en contexte, la mise à jour incrémentale et
l'index périmé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 11:23:18 +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
38 changed files with 4044 additions and 547 deletions

No files matched your search

+14 -4
View File
@@ -52,15 +52,25 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
La **recherche plein-texte des discussions** (`chat_search.go`) reprend celle
de l'historique d'AJEAN (commit `558d3b4d`, issue amont #98, PR #99 d'Olioli4) :
index inversé chiffré comme les discussions, tokens repliés sans accents,
correspondance par début de mot, titre compté double. Adaptée au modèle de
Loki (toutes les discussions dans `bkChat`, index tenu à chaque écriture, y
compris celles de la façade Rust) et à sa barre latérale (« rechercher
partout », extrait autour du passage trouvé).
## 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)
}
}
+3
View File
@@ -173,6 +173,9 @@ func (p *convPersister) run() {
sort.Slice(snaps, func(i, j int) bool { return snaps[i].seq < snaps[j].seq })
wrote, err := persistCommitByPath(snaps, res)
// Index plein-texte (chat_search.go) AVANT de marquer le lot traité : un
// flush attend donc aussi l'index, et une suppression passe toujours après.
histIndexWritten(wrote)
p.mu.Lock()
for _, j := range res {
+852
View File
@@ -0,0 +1,852 @@
package loki
// chat_search.go — recherche PLEIN-TEXTE dans les discussions (reprise d'AJEAN,
// commit 558d3b4d, issue amont #98 / PR #99).
//
// Avant, la barre latérale ne filtrait que les TITRES, côté navigateur. Relire
// le corps de chaque discussion à chaque frappe est exclu : une discussion pèse
// vite des centaines de Ko, chiffrée de surcroît. On tient donc un INDEX INVERSÉ
// dans bkChatSearch, à trois familles de clés :
//
// « d|<id> » → histSearchDoc : la fiche d'une discussion (version, date
// d'indexation, aperçu). Petite : la couverture et l'aperçu se
// lisent sans charger la liste des mots.
// « t|<id> » → []string : les mots DISTINCTS de la discussion, repliés et
// triés. Sert seulement à la mise à jour (différence avec
// l'état précédent) et à la suppression.
// « p|<token> » → []string d'identifiants triés (sémantique d'ENSEMBLE).
//
// Écart avec l'amont : AJEAN range la liste des mots dans la fiche « d| » et
// lit TOUTES les fiches à chaque requête pour la couverture. Ici la fiche reste
// petite, et une mise à jour ne touche que les mots ajoutés ou retirés depuis la
// fois précédente — la discussion ouverte est réindexée à chaque écriture, pas
// seulement à l'archivage comme chez l'amont.
//
// QUI ÉCRIT L'INDEX. Ce process, toujours, même quand la façade Rust tient la
// conversation (LOKI_CHAT=rust) : elle n'a pas accès à la base et écrit ses
// discussions par /internal/v1/store (web_internal.go), qui indexe au passage
// (histIndexStoreWrite). L'index suit donc la même discussion quel que soit le
// process qui la tient, sans second écrivain ni test de parité à tenir : la
// route de recherche (/api/conversations/search) est servie ICI, la façade la
// relaie comme toute route qu'elle ne connaît pas (proxy.rs, forward).
//
// ACCENTS : l'index stocke des tokens REPLIÉS (« résumé » → « resume »). C'est
// délibérément différent de la sortie de recallTokens, que l'outil recall_search
// du modèle consomme telle quelle — ne pas « corriger » cette apparente
// incohérence : replier l'index (et la requête, symétriquement) est ce qui fait
// qu'une recherche « resume » trouve « résumé » et inversement.
import (
"bytes"
"encoding/json"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
"unicode"
"unicode/utf8"
bolt "go.etcd.io/bbolt"
)
const (
// histSearchIndexVersion : une fiche dont la version diffère est traitée
// comme non indexée et reconstruite en entier.
histSearchIndexVersion = 1
// histSearchMaxTerms borne le nombre de postings lus par requête.
histSearchMaxTerms = 16
// histSearchDefaultLimit / histSearchMaxLimit : taille d'une page de
// résultats. L'extrait en contexte relit le corps de CHAQUE résultat de la
// page : la borne protège la requête d'un « limit=100000 ».
histSearchDefaultLimit = 30
histSearchMaxLimit = 100
)
// histSearchDoc = la fiche d'index d'une discussion.
type histSearchDoc struct {
V int `json:"v"`
At int64 `json:"at"` // indexée à (Unix, s) — comparée à convMeta.Updated
Apercu string `json:"apercu,omitempty"` // début du texte visible, borné (recallSnippet)
}
// histSearchHit = une discussion trouvée, telle que la reçoit l'UI.
type histSearchHit struct {
ID string `json:"id"`
Title string `json:"title"`
Created int64 `json:"created"`
Updated int64 `json:"updated"`
Turns int `json:"turns"`
Score int `json:"score"`
InTitle bool `json:"in_title"`
Snippet string `json:"snippet"`
}
// histSearchResult = le résultat complet d'une recherche.
type histSearchResult struct {
State string `json:"state"` // ok | locked | no_terms
Hits []histSearchHit `json:"conversations"`
Total int `json:"total"` // correspondances AVANT pagination
Indexed int `json:"indexed"` // discussions du projet réellement indexées
IndexedTotal int `json:"indexed_total"` // discussions indexables du projet
Terms []string `json:"terms"` // termes cherchés (repliés, triés)
}
func histDocKey(id string) string { return "d|" + id }
func histToksKey(id string) string { return "t|" + id }
func histPostKey(tok string) string { return "p|" + tok }
// foldHistAccents replie les diacritiques d'un token. Appliqué SYMÉTRIQUEMENT
// à l'indexation et à la requête (voir le commentaire de tête).
func foldHistAccents(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch r {
case 'à', 'â', 'ä', 'á', 'ã', 'å':
b.WriteByte('a')
case 'é', 'è', 'ê', 'ë':
b.WriteByte('e')
case 'î', 'ï', 'í', 'ì':
b.WriteByte('i')
case 'ô', 'ö', 'ó', 'ò', 'õ':
b.WriteByte('o')
case 'ù', 'û', 'ü', 'ú':
b.WriteByte('u')
case 'ç':
b.WriteByte('c')
case 'ÿ':
b.WriteByte('y')
case 'œ':
b.WriteString("oe")
case 'æ':
b.WriteString("ae")
default:
b.WriteRune(r)
}
}
return b.String()
}
// histTokens dérive les mots d'index d'une chaîne : recallTokens (réutilisé,
// jamais modifié), puis repli des accents et dédoublonnage, triés. Deux tokens
// distincts peuvent se replier sur le même (« cote » et « côté »).
func histTokens(s string) []string {
set := recallTokens(s)
seen := make(map[string]bool, len(set))
out := make([]string, 0, len(set))
for t := range set {
f := foldHistAccents(t)
if seen[f] {
continue
}
seen[f] = true
out = append(out, f)
}
sort.Strings(out)
return out
}
// histTokenSet : variante « ensemble » de histTokens (titre, discussion ouverte).
func histTokenSet(s string) map[string]bool {
out := map[string]bool{}
for _, t := range histTokens(s) {
out[t] = true
}
return out
}
// histTermMatches est LA règle de correspondance : un token correspond à un
// terme quand il COMMENCE par lui, jamais au milieu du mot (« eepseek » ne
// trouve pas « deepseek »). Servie au titre, à la discussion ouverte et à
// l'extrait ; le scan des postings applique la même règle au niveau de la clé
// (préfixe « p|<terme> »).
func histTermMatches(token, term string) bool {
return strings.HasPrefix(token, term)
}
// histSetMatches : un terme correspond-il à l'un des tokens d'un ensemble ?
// Vrai au plus UNE fois par terme : le score compte les TERMES, pas les tokens.
func histSetMatches(set map[string]bool, term string) bool {
for tok := range set {
if histTermMatches(tok, term) {
return true
}
}
return false
}
// histQueryTerms replie, trie et plafonne les termes de la requête.
func histQueryTerms(q string) []string {
terms := histTokens(q)
if len(terms) > histSearchMaxTerms {
terms = terms[:histSearchMaxTerms]
}
return terms
}
// histVisibleText concatène le texte VISIBLE d'un journal d'affichage, seule
// source indexée. On part du journal (vue UI) et NON des Messages : leur
// contenu peut être multimodal et porte le CORPS des sorties d'outil, qu'on ne
// veut pas indexer. On retient le message de l'utilisateur (« user »), la
// réponse (« content ») et le libellé d'outil (« tool_used.label »). Exclus :
// raisonnement, résultat d'outil, fichiers, deltas de contrôle.
func histVisibleText(log []LogEvent) string {
var b strings.Builder
for _, ev := range log {
if s, ok := ev.Delta["user"].(string); ok {
b.WriteString(s)
b.WriteByte('\n')
}
if s, ok := ev.Delta["content"].(string); ok {
b.WriteString(s)
b.WriteByte('\n')
}
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
if l, ok := tu["label"].(string); ok {
b.WriteString(l)
b.WriteByte('\n')
}
}
}
return b.String()
}
// histFirstVisible : premier texte visible d'un journal (aperçu de la fiche).
func histFirstVisible(log []LogEvent) string {
for _, ev := range log {
if s, ok := ev.Delta["user"].(string); ok && strings.TrimSpace(s) != "" {
return s
}
if s, ok := ev.Delta["content"].(string); ok && strings.TrimSpace(s) != "" {
return s
}
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
if l, ok := tu["label"].(string); ok && strings.TrimSpace(l) != "" {
return l
}
}
}
return ""
}
// histLogOf extrait le journal d'affichage du JSON d'une Conversation, sans
// rien décoder d'autre.
func histLogOf(body []byte) ([]LogEvent, bool) {
var c struct {
Log []LogEvent `json:"log"`
}
if json.Unmarshal(body, &c) != nil {
return nil, false
}
return c.Log, true
}
// histIsWordRune : même découpage que recallTokens (sur la chaîne en minuscules).
func histIsWordRune(r rune) bool {
return r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ'
}
// histContextSnippet renvoie un extrait du texte autour du PREMIER mot qui
// correspond à l'un des termes (même règle de préfixe), ou "" sans
// correspondance. L'aperçu de la fiche ne montre que le début d'une
// discussion — c'est-à-dire, le plus souvent, son titre.
func histContextSnippet(text string, terms []string) string {
const before, after = 60, 140
runes := []rune(text)
lower := make([]rune, len(runes)) // rune par rune : les positions restent alignées
for i, r := range runes {
lower[i] = unicode.ToLower(r)
}
for i := 0; i < len(lower); {
if !histIsWordRune(lower[i]) {
i++
continue
}
j := i
for j < len(lower) && histIsWordRune(lower[j]) {
j++
}
if j-i >= 2 {
w := foldHistAccents(string(lower[i:j]))
for _, t := range terms {
if !histTermMatches(w, t) {
continue
}
from, to := max(0, i-before), min(len(runes), j+after)
// Couper sur une espace pour ne pas commencer ni finir au milieu d'un mot.
for from > 0 && from < i && runes[from-1] != ' ' && runes[from-1] != '\n' {
from++
}
for to < len(runes) && to > j && runes[to] != ' ' && runes[to] != '\n' {
to--
}
s := strings.Join(strings.Fields(string(runes[from:to])), " ")
if from > 0 {
s = "…" + s
}
if to < len(runes) {
s += "…"
}
return s
}
}
i = j
}
return ""
}
// --- accès bas niveau au bucket (chiffré comme les autres) --------------------
// histTxGetJSON lit et déchiffre une valeur dans une transaction ouverte.
// decodeMemContent est sûr ici : sur du clair il ne lit rien, sur du chiffré
// il n'appelle que currentDEK (mutex en RAM, jamais la base).
func histTxGetJSON(b *bolt.Bucket, key string, dst any) bool {
raw := b.Get([]byte(key))
if raw == nil {
return false
}
plain, err := decodeMemContent(raw)
if err != nil {
return false // chiffré + verrouillé : illisible, pas « absent »
}
return json.Unmarshal(plain, dst) == nil
}
// histTxPutJSON sérialise, chiffre (encodeur résolu HORS transaction : bbolt
// n'est pas rentrant, voir memEncoderNow) et écrit.
func histTxPutJSON(b *bolt.Bucket, key string, v any, encode func([]byte) ([]byte, error)) error {
raw, err := json.Marshal(v)
if err != nil {
return err
}
enc, err := encode(raw)
if err != nil {
return err
}
return b.Put([]byte(key), enc)
}
// histPost ajoute sid à la liste du token (ensemble trié, idempotent).
func histPost(b *bolt.Bucket, tok, sid string, encode func([]byte) ([]byte, error)) error {
var ids []string
_ = histTxGetJSON(b, histPostKey(tok), &ids)
i := sort.SearchStrings(ids, sid)
if i < len(ids) && ids[i] == sid {
return nil
}
ids = append(ids, "")
copy(ids[i+1:], ids[i:])
ids[i] = sid
return histTxPutJSON(b, histPostKey(tok), ids, encode)
}
// histUnpost retire sid de la liste du token ; la clé disparaît si elle se vide.
func histUnpost(b *bolt.Bucket, tok, sid string, encode func([]byte) ([]byte, error)) error {
var ids []string
if !histTxGetJSON(b, histPostKey(tok), &ids) {
return nil
}
out := make([]string, 0, len(ids))
for _, x := range ids {
if x != sid {
out = append(out, x)
}
}
if len(out) == len(ids) {
return nil
}
if len(out) == 0 {
return b.Delete([]byte(histPostKey(tok)))
}
return histTxPutJSON(b, histPostKey(tok), out, encode)
}
// --- maintenance : indexation / désindexation ---------------------------------
// histIndexAt (ré)indexe une discussion dans la base path, en UNE transaction :
// mots retirés depuis la dernière fois, fiche, mots ajoutés. Une fiche d'une
// autre version (ou absente) fait tout reposer. Les sémantiques d'ensemble
// rendent l'opération idempotente. Mémoire chiffrée mais verrouillée : erreur,
// rien n'est écrit en clair (le rattrapage reprendra au déverrouillage).
func histIndexAt(path, id string, log []LogEvent) error {
if id == "" {
return nil
}
encode, err := memEncoderNow() // HORS transaction (voir memEncoderNow)
if err != nil {
return err
}
toks := histTokens(histVisibleText(log))
doc := histSearchDoc{V: histSearchIndexVersion, At: time.Now().Unix(),
Apercu: recallSnippet(histFirstVisible(log), 200)}
cacheBust(bkChatSearch)
return withDBAt(path, func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(bkChatSearch))
if err != nil {
return err
}
var prevDoc histSearchDoc
var prev []string
full := !histTxGetJSON(b, histDocKey(id), &prevDoc) || prevDoc.V != histSearchIndexVersion
_ = histTxGetJSON(b, histToksKey(id), &prev)
had := make(map[string]bool, len(prev))
for _, t := range prev {
had[t] = true
}
now := make(map[string]bool, len(toks))
for _, t := range toks {
now[t] = true
}
for _, t := range prev {
if !now[t] {
if err := histUnpost(b, t, id, encode); err != nil {
return err
}
}
}
for _, t := range toks {
if full || !had[t] {
if err := histPost(b, t, id, encode); err != nil {
return err
}
}
}
if err := histTxPutJSON(b, histToksKey(id), toks, encode); err != nil {
return err
}
return histTxPutJSON(b, histDocKey(id), doc, encode)
})
})
}
// histUnindexAt retire une discussion de l'index. Si sa liste de mots est
// lisible, ses postings partent avec ; sinon (verrouillé) seules ses clés
// « d| » et « t| » sont supprimées, en laissant des identifiants ORPHELINS
// dans les postings — tolérés : la recherche écarte tout identifiant absent de
// l'index des discussions.
func histUnindexAt(path, id string) {
if id == "" {
return
}
encode, err := memEncoderNow()
cacheBust(bkChatSearch)
_ = withDBAt(path, func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte(bkChatSearch))
if b == nil {
return nil
}
var prev []string
if err == nil && histTxGetJSON(b, histToksKey(id), &prev) {
for _, t := range prev {
if err := histUnpost(b, t, id, encode); err != nil {
return err
}
}
}
if err := b.Delete([]byte(histToksKey(id))); err != nil {
return err
}
return b.Delete([]byte(histDocKey(id)))
})
})
}
// histIndexWritten indexe les instantanés que l'écrivain vient de poser
// (chat_persist.go). Appelé DANS la boucle de l'écrivain, avant qu'elle ne
// marque le lot comme traité : un flush attend donc aussi l'index — c'est ce
// qui garantit qu'une suppression (forget, flush, puis histUnindexAt) passe
// toujours après la dernière indexation de la discussion.
func histIndexWritten(snaps []*convSnap) {
for _, s := range snaps {
if log, ok := histLogOf(s.body); ok {
_ = histIndexAt(s.path, s.id, log) // erreur avalée : le rattrapage reprendra
}
}
}
// histIndexStoreWrite suit les écritures de la façade dans /internal/v1/store :
// une discussion écrite (bkChat/conv:<id>) est réindexée, une discussion
// effacée (DELETE, ou valeur vide) est retirée. Le contenu arrive scellé :
// decodeMemContent l'ouvre, la clé ne quitte pas ce process. Synchrone : la
// façade n'envoie la suppression d'une discussion qu'après la réponse à son
// écriture, l'ordre est donc tenu sans file.
func histIndexStoreWrite(bucket, key string, val []byte, deleted bool) {
if bucket != bkChat || !strings.HasPrefix(key, "conv:") {
return
}
id := strings.TrimPrefix(key, "conv:")
if deleted || len(val) == 0 {
histUnindexAt(dbPath(), id)
return
}
plain, err := decodeMemContent(val)
if err != nil {
return
}
if log, ok := histLogOf(plain); ok {
_ = histIndexAt(dbPath(), id, log)
}
}
// --- rattrapage ---------------------------------------------------------------
var histBackfillRunning atomic.Bool
// startHistSearchBackfill lance le rattrapage en tâche de fond, au démarrage
// (après un délai : laisser moteur et lien démarrer d'abord) et au
// déverrouillage de la mémoire. Un seul à la fois.
func startHistSearchBackfill() { startHistSearchBackfillAfter(20 * time.Second) }
func startHistSearchBackfillAfter(d time.Duration) {
if !histBackfillRunning.CompareAndSwap(false, true) {
return
}
path := dbPath()
go func() {
defer histBackfillRunning.Store(false)
time.Sleep(d)
// La base visée au lancement, et elle seule : sous les tests, LOKI_HOME
// change d'un test à l'autre — on ne rattrape pas dans la base d'un autre,
// ni ne recrée celle d'un test terminé.
if _, err := os.Stat(path); err != nil || dbPath() != path {
return
}
histSearchIndexBackfill()
}()
}
// histNeedsIndex : la discussion manque-t-elle à l'index, ou y est-elle plus
// ancienne que sa dernière écriture (une indexation ratée après l'écriture) ?
func histNeedsIndex(docs map[string]histSearchDoc, id string, updated int64) bool {
d, ok := docs[id]
return !ok || d.V != histSearchIndexVersion || d.At < updated
}
// histReadDocs lit toutes les fiches « d| » lisibles (petites : version, date,
// aperçu).
func histReadDocs() map[string]histSearchDoc {
out := map[string]histSearchDoc{}
_ = view(bkChatSearch, func(b *bolt.Bucket) error {
c := b.Cursor()
pfx := []byte("d|")
for k, v := c.Seek(pfx); k != nil && bytes.HasPrefix(k, pfx); k, v = c.Next() {
plain, err := decodeMemContent(v)
if err != nil {
continue
}
var d histSearchDoc
if json.Unmarshal(plain, &d) == nil {
out[string(k[len(pfx):])] = d
}
}
return nil
})
return out
}
// histConvIDs : identifiants des discussions qui ont un CORPS en base (clés
// « conv:<id> » de bkChat), sans lire les valeurs.
func histConvIDs() map[string]bool {
out := map[string]bool{}
_ = view(bkChat, func(b *bolt.Bucket) error {
c := b.Cursor()
pfx := []byte("conv:")
for k, _ := c.Seek(pfx); k != nil && bytes.HasPrefix(k, pfx); k, _ = c.Next() {
out[string(k[len(pfx):])] = true
}
return nil
})
return out
}
// histSearchIndexBackfill indexe les discussions absentes de l'index, ou dont
// la fiche est plus ancienne que leur dernière écriture. S'arrête net
// verrouillé (corps illisibles) : il reprendra au déverrouillage.
func histSearchIndexBackfill() {
if memLocked() {
return
}
updated := map[string]int64{}
for _, m := range convIndex() {
updated[m.ID] = m.Updated
}
docs := histReadDocs()
ids := make([]string, 0)
for id := range histConvIDs() {
if histNeedsIndex(docs, id, updated[id]) {
ids = append(ids, id)
}
}
sort.Strings(ids)
for _, id := range ids {
b, err := getStoreBytesErr(bkChat, convKey(id))
if err != nil {
if memLocked() {
return
}
continue // illisible pour une autre raison (abîmée) : on saute
}
log, ok := histLogOf(b)
if !ok {
continue
}
if err := histIndexAt(dbPath(), id, log); err != nil {
return
}
time.Sleep(30 * time.Millisecond) // ne pas monopoliser la base
}
}
// --- recherche ----------------------------------------------------------------
// histSearchProject : le projet dont la barre latérale montre les discussions.
// Lu en base et non dans le cache de ce process : quand la façade tient la
// conversation, c'est elle qui change de projet.
func histSearchProject() string {
if s := strings.TrimSpace(getStr(bkState, stActiveProjectKey)); s != "" {
return s
}
return defaultProjectSlug
}
// histSearch est le cœur de la recherche, sur les discussions du projet
// `project` ("" = projet actif). Pertinence d'abord (score = 2 × termes du titre
// + termes du texte, même ratio que recall_search), puis plus récente, puis
// identifiant. offset/limit découpent APRÈS le tri ; limit <= 0 = tout.
func histSearch(q, project string, offset, limit int) histSearchResult {
res := histSearchResult{State: "ok", Hits: make([]histSearchHit, 0), Terms: make([]string, 0)}
terms := histQueryTerms(q)
if len(terms) == 0 {
res.State = "no_terms"
return res
}
// Verrouillé décidé AVANT toute lecture : « illisible » et « absent » se
// confondent au fond. Pour une recherche, un vide serait un mensonge.
if memLocked() {
res.State = "locked"
return res
}
res.Terms = terms
if project == "" {
project = histSearchProject()
}
metaByID := map[string]convMeta{}
for _, m := range convIndex() {
if convProjectOf(m) == project {
metaByID[m.ID] = m
}
}
// Discussion ouverte : quand CE process la tient, son journal en RAM est
// plus frais que sa dernière écriture (un tour en cours). Copié sous c.mu
// puis relâché : on tokenise hors verrou, jamais en bloquant une génération.
// Quand la façade la tient, le journal d'ici est périmé : l'index (alimenté
// par ses écritures) fait foi.
var liveID string
var liveLog []LogEvent
if !facadeChat() {
liveID = convActiveCached()
conv.mu.Lock()
liveLog = append([]LogEvent(nil), conv.Log...)
conv.mu.Unlock()
if _, ok := metaByID[liveID]; !ok || len(liveLog) == 0 {
liveID, liveLog = "", nil
}
}
titleHits := map[string]int{}
for id, m := range metaByID {
set := histTokenSet(m.Title)
n := 0
for _, t := range terms {
if histSetMatches(set, t) {
n++
}
}
if n > 0 {
titleHits[id] = n
}
}
// UNE vue sur l'index : postings des termes (scan PRÉFIXE : les clés sont
// triées, toutes celles qui commencent par « p|<terme> » se suivent) et
// fiches des discussions du projet (couverture, aperçu). Aucun corps lu.
bodyHits := map[string]int{}
docs := map[string]histSearchDoc{}
_ = view(bkChatSearch, func(b *bolt.Bucket) error {
for _, t := range terms {
prefix := []byte(histPostKey(t))
matched := map[string]bool{} // un terme ne compte qu'UNE fois par discussion
c := b.Cursor()
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
plain, err := decodeMemContent(v)
if err != nil {
continue
}
var ids []string
if json.Unmarshal(plain, &ids) != nil {
continue
}
for _, sid := range ids {
matched[sid] = true
}
}
for sid := range matched {
bodyHits[sid]++
}
}
for id := range metaByID {
var d histSearchDoc
if histTxGetJSON(b, histDocKey(id), &d) && d.V == histSearchIndexVersion {
docs[id] = d
}
}
return nil
})
// Discussion ouverte : son journal en RAM REMPLACE sa copie indexée (et non
// +=) — une seule entrée par identifiant, jamais de double compte.
if liveID != "" {
set := histTokenSet(histVisibleText(liveLog))
n := 0
for _, t := range terms {
if histSetMatches(set, t) {
n++
}
}
bodyHits[liveID] = n
}
score := func(id string) int { return 2*titleHits[id] + bodyHits[id] }
hits := make([]histSearchHit, 0)
for id, m := range metaByID {
if sc := score(id); sc > 0 {
hits = append(hits, histSearchHit{ID: id, Title: m.Title, Created: m.Created,
Updated: m.Updated, Turns: m.Turns, Score: sc, InTitle: titleHits[id] > 0})
}
}
// Les identifiants des postings absents de l'index des discussions (supprimées
// verrouillé, ou d'un autre projet) ne sont jamais parcourus : seul metaByID l'est.
sort.Slice(hits, func(i, j int) bool {
if hits[i].Score != hits[j].Score {
return hits[i].Score > hits[j].Score
}
if hits[i].Updated != hits[j].Updated {
return hits[i].Updated > hits[j].Updated
}
return hits[i].ID < hits[j].ID
})
res.Total = len(hits)
if limit > 0 {
off := max(0, min(offset, len(hits)))
hits = hits[off:min(off+limit, len(hits))]
}
histFillSnippets(hits, terms, docs, liveID, liveLog)
res.Hits = hits
// Couverture : le dénominateur est l'ensemble des discussions RÉELLEMENT
// indexables du projet — une entrée d'index sans corps en base (discussion
// toute neuve, jamais écrite) ne le sera jamais, la compter laisserait
// l'UI annoncer « indexation en cours » pour toujours (constat amont).
// Invariant : Indexed <= IndexedTotal.
bodies := histConvIDs()
for id, m := range metaByID {
if !bodies[id] && id != liveID {
continue
}
res.IndexedTotal++
if id == liveID {
res.Indexed++
continue
}
if !histNeedsIndex(docs, id, m.Updated) {
res.Indexed++
}
}
return res
}
// histFillSnippets pose l'extrait de chaque résultat de la PAGE : le passage
// autour du premier mot trouvé, relu dans le corps (une seule ouverture de la
// base pour toute la page) ; à défaut — corps illisible, mot trouvé seulement
// dans le titre — l'aperçu de la fiche. Le classement, lui, ne dépend jamais
// des corps.
func histFillSnippets(hits []histSearchHit, terms []string, docs map[string]histSearchDoc, liveID string, liveLog []LogEvent) {
if len(hits) == 0 {
return
}
texts := map[string]string{}
if liveID != "" {
texts[liveID] = histVisibleText(liveLog)
}
_ = view(bkChat, func(b *bolt.Bucket) error {
for _, h := range hits {
if _, ok := texts[h.ID]; ok {
continue
}
raw := b.Get([]byte(convKey(h.ID)))
if raw == nil {
continue
}
plain, err := decodeMemContent(raw)
if err != nil {
continue
}
if log, ok := histLogOf(plain); ok {
texts[h.ID] = histVisibleText(log)
}
}
return nil
})
for i := range hits {
s := ""
if txt, ok := texts[hits[i].ID]; ok {
s = histContextSnippet(txt, terms)
}
if s == "" {
s = docs[hits[i].ID].Apercu
}
if s == "" && liveID == hits[i].ID {
s = recallSnippet(histFirstVisible(liveLog), 200)
}
hits[i].Snippet = s
}
}
// handleConvSearch : GET /api/conversations/search?q=&offset=&limit=.
// Discussions du projet actif dont le titre OU le texte contient les mots
// cherchés (préfixes), avec un extrait. Toujours servie par ce process, même
// quand la façade tient la conversation (voir le commentaire de tête).
func handleConvSearch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Allow", http.MethodGet)
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "GET attendu"})
return
}
q := r.URL.Query()
if utf8.RuneCountInString(q.Get("q")) > 500 {
sendJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "requête trop longue"})
return
}
offset, _ := strconv.Atoi(q.Get("offset"))
limit, err := strconv.Atoi(q.Get("limit"))
if err != nil || limit <= 0 {
limit = histSearchDefaultLimit
}
limit = min(limit, histSearchMaxLimit)
res := histSearch(q.Get("q"), "", max(0, offset), limit)
// Index incomplet (discussions d'avant la fonctionnalité, indexation ratée) :
// on relance le rattrapage plutôt que d'attendre le prochain démarrage. L'UI
// annonce la couverture partielle en attendant.
if res.State == "ok" && res.Indexed < res.IndexedTotal {
startHistSearchBackfillAfter(time.Second)
}
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "state": res.State, "terms": res.Terms,
"conversations": res.Hits, "total": res.Total,
"indexed": res.Indexed, "indexed_total": res.IndexedTotal})
}
+728
View File
@@ -0,0 +1,728 @@
package loki
// chat_search_test.go — recherche plein-texte dans les discussions. Repris
// d'AJEAN (chat_search_test.go, commit 558d3b4d, issue amont #98) et adaptés au
// modèle de Loki : pas d'archives à part, toutes les discussions vivent dans
// bkChat/conv:<id> et l'index suit chaque écriture de l'écrivain différé
// (chat_persist.go) — ou de la façade, par /internal/v1/store. S'y ajoutent les
// cas propres à Loki : écritures de la façade, extrait en contexte, mise à jour
// incrémentale, index périmé.
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// --- aides --------------------------------------------------------------------
// histSetup isole $LOKI_HOME et neutralise la discussion ouverte du process :
// chaque test pose explicitement celle qu'il veut (histSetLive).
func histSetup(t *testing.T) {
t.Helper()
testHome(t)
clearMemDEK()
conv.mu.Lock()
prevLog := conv.Log
conv.Log = nil
conv.mu.Unlock()
convActiveCache.Store(nil)
t.Cleanup(func() {
conv.mu.Lock()
conv.Log = prevLog
conv.mu.Unlock()
convActiveCache.Store(nil)
clearMemDEK()
})
}
func histUser(ts int64, s string) LogEvent {
return LogEvent{TS: ts, Delta: map[string]any{"user": s}}
}
func histContent(ts int64, s string) LogEvent {
return LogEvent{TS: ts, Delta: map[string]any{"content": s}}
}
func histBody(t *testing.T, log []LogEvent) []byte {
t.Helper()
b, err := json.Marshal(map[string]any{"messages": []Message{}, "log": log})
if err != nil {
t.Fatal(err)
}
return b
}
// histConv écrit une discussion PAR LE CHEMIN DE PRODUCTION (l'écrivain
// différé, qui pose contenu, entrée d'index et index plein-texte), puis fixe
// son titre et sa date dans l'index des discussions.
func histConv(t *testing.T, id, project, title string, updated int64, log []LogEvent) {
t.Helper()
if project == "" {
project = defaultProjectSlug
}
turns := 0
for _, ev := range log {
if _, ok := ev.Delta["user"]; ok {
turns++
}
}
persistQ.wait(persistQ.enqueueSnap(&convSnap{path: dbPath(), id: id, seq: persistSeq.Add(1),
body: histBody(t, log), title: title, turns: turns, project: project}))
histSetMeta(t, convMeta{ID: id, Title: title, Created: updated, Updated: updated, Turns: turns, Project: project})
}
// histSetMeta pose (ou remplace) l'entrée d'une discussion dans l'index des
// discussions, sans toucher à son contenu.
func histSetMeta(t *testing.T, m convMeta) {
t.Helper()
convIndexMu.Lock()
defer convIndexMu.Unlock()
idx := convIndex()
for i := range idx {
if idx[i].ID == m.ID {
idx[i] = m
convIndexSave(idx)
return
}
}
convIndexSave(append(idx, m))
}
// histSetLive fait de id la discussion ouverte, avec ce journal en RAM.
func histSetLive(t *testing.T, id string, log []LogEvent) {
t.Helper()
convActiveRemember(id)
conv.mu.Lock()
conv.Log = append([]LogEvent(nil), log...)
conv.mu.Unlock()
}
func histHasHit(res histSearchResult, id string) bool {
for _, h := range res.Hits {
if h.ID == id {
return true
}
}
return false
}
func histHitIDs(res histSearchResult) string {
var ids []string
for _, h := range res.Hits {
ids = append(ids, h.ID)
}
return strings.Join(ids, ",")
}
func histDocExists(t *testing.T, id string) bool {
t.Helper()
_, ok := getStoreBytes(bkChatSearch, histDocKey(id))
return ok
}
func histPosting(t *testing.T, tok string) []string {
t.Helper()
b, ok := getStoreBytes(bkChatSearch, histPostKey(tok))
if !ok {
return nil
}
var ids []string
if err := json.Unmarshal(b, &ids); err != nil {
t.Fatalf("posting %q illisible : %v", tok, err)
}
return ids
}
// --- 1. texte trouvé même si le titre ne contient rien ------------------------
func TestHistSearchBodyMatch(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Voyage en Bretagne", 100, []LogEvent{histUser(1, "parlons du phare de Brest")})
histConv(t, "B", "", "Recette", 200, []LogEvent{histUser(1, "préparons une tarte")})
res := histSearch("phare", "", 0, 0)
if res.State != "ok" {
t.Fatalf("état attendu ok, obtenu %q", res.State)
}
if res.Total != 1 || len(res.Hits) != 1 || res.Hits[0].ID != "A" {
t.Fatalf("A attendu seul, obtenu total=%d %+v", res.Total, res.Hits)
}
if res.Hits[0].Snippet == "" {
t.Fatal("extrait attendu non vide pour A")
}
if res.Hits[0].InTitle {
t.Fatal("A est trouvée par son texte, pas par son titre")
}
}
// --- 2. un mot du titre pèse plus qu'un mot du texte --------------------------
// Le titre compte double (+2/+1, comme recall_search). B est plus RÉCENTE que
// A : un tri par date seule inverserait l'ordre.
func TestHistSearchTitleBeatsBody(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Le phare de Brest", 100, []LogEvent{histUser(1, "rien de spécial")})
histConv(t, "B", "", "Discussion diverse", 200, []LogEvent{histUser(1, "on a vu un phare hier")})
res := histSearch("phare", "", 0, 0)
if got := histHitIDs(res); got != "A,B" {
t.Fatalf("ordre attendu A,B, obtenu %s", got)
}
if !res.Hits[0].InTitle || res.Hits[1].InTitle {
t.Fatalf("in_title attendu vrai pour A seulement : %+v", res.Hits)
}
}
// --- 3. pertinence, puis date, puis identifiant -------------------------------
func TestHistSearchRelevanceThenDate(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Sans rapport", 200, []LogEvent{histUser(1, "un phare dans le texte")})
histConv(t, "B", "", "Phare ancien", 100, []LogEvent{histUser(1, "texte neutre")})
histConv(t, "C", "", "Phare récent", 300, []LogEvent{histUser(1, "texte neutre")})
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "C,B,A" {
t.Fatalf("ordre attendu C,B,A, obtenu %s", got)
}
}
// --- 4. la discussion OUVERTE est cherchée dans sa version en RAM -------------
// En plein tour, le journal en mémoire est plus frais que la dernière écriture :
// le mot vient d'arriver, il n'est pas encore dans l'index.
func TestHistSearchIncludesActiveSession(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Autre sujet", 100, []LogEvent{histUser(1, "on parle boussole")})
histConv(t, "live1", "", "salut", 200, []LogEvent{histUser(1, "salut")})
histSetLive(t, "live1", []LogEvent{histUser(1, "salut"), histContent(2, "le phare en direct")})
res := histSearch("phare", "", 0, 0)
if res.Total != 1 || len(res.Hits) != 1 || res.Hits[0].ID != "live1" {
t.Fatalf("la discussion ouverte devait ressortir seule : %+v", res.Hits)
}
if !strings.Contains(res.Hits[0].Snippet, "phare") {
t.Fatalf("extrait tiré du journal en RAM attendu, obtenu %q", res.Hits[0].Snippet)
}
}
// --- 5. chercher n'écrit RIEN -------------------------------------------------
func TestHistSearchDoesNotWrite(t *testing.T) {
histSetup(t)
histConv(t, "live1", "", "salut", 100, []LogEvent{histUser(1, "salut")})
histSetLive(t, "live1", []LogEvent{histUser(1, "le phare en direct")})
beforeChat, beforeIdx := allKV(bkChat), allKV(bkChatSearch)
_ = histSearch("phare", "", 0, 0)
if after := allKV(bkChat); len(after) != len(beforeChat) || after[convKey("live1")] != beforeChat[convKey("live1")] || after[ckIndex] != beforeChat[ckIndex] {
t.Fatal("la recherche a réécrit une discussion ou l'index des discussions")
}
after := allKV(bkChatSearch)
if len(after) != len(beforeIdx) {
t.Fatalf("la recherche a modifié l'index plein-texte (%d → %d clés)", len(beforeIdx), len(after))
}
for k, v := range beforeIdx {
if after[k] != v {
t.Fatalf("la recherche a réécrit %q", k)
}
}
}
// --- 6. cloisonnement par projet ----------------------------------------------
func TestHistSearchProjectScope(t *testing.T) {
histSetup(t)
histConv(t, "A", "alpha", "Projet alpha", 100, []LogEvent{histUser(1, "le phare")})
histConv(t, "B", "beta", "Projet beta", 100, []LogEvent{histUser(1, "le phare")})
if got := histHitIDs(histSearch("phare", "alpha", 0, 0)); got != "A" {
t.Fatalf("projet alpha : A attendu seul, obtenu %s", got)
}
if got := histHitIDs(histSearch("phare", "beta", 0, 0)); got != "B" {
t.Fatalf("projet beta : B attendu seul, obtenu %s", got)
}
// Sans projet donné : celui de la barre latérale, lu en base.
if err := setActiveProject("beta"); err != nil {
t.Fatal(err)
}
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "B" {
t.Fatalf("projet actif beta : B attendu seul, obtenu %s", got)
}
}
// --- 7. mémoire verrouillée = état explicite, pas « rien trouvé » -------------
func TestHistSearchLockedState(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Secret", 100, []LogEvent{histUser(1, "le phare secret")})
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatal(err)
}
clearMemDEK()
res := histSearch("phare", "", 0, 0)
if res.State != "locked" {
t.Fatalf("état attendu locked, obtenu %q", res.State)
}
if len(res.Hits) != 0 || res.Total != 0 {
t.Fatalf("aucun résultat attendu verrouillé, obtenu %+v", res.Hits)
}
}
// --- 8. requête sans terme exploitable ----------------------------------------
func TestHistSearchNoUsableTerms(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Phare", 100, []LogEvent{histUser(1, "le phare")})
res := histSearch("a!", "", 0, 0)
if res.State != "no_terms" {
t.Fatalf("état attendu no_terms, obtenu %q", res.State)
}
if res.Terms == nil || len(res.Terms) != 0 || res.Total != 0 {
t.Fatalf("aucun terme (tableau vide, jamais null) ni résultat attendu : terms=%#v total=%d", res.Terms, res.Total)
}
}
// --- 9. cycle de vie : écriture puis suppression de la discussion -------------
func TestHistSearchIndexLifecycle(t *testing.T) {
histSetup(t)
histConv(t, "lifeA", "", "A", 100, []LogEvent{histUser(1, "le phare")})
histConv(t, "lifeB", "", "B", 200, []LogEvent{histUser(1, "le phare")})
if !histDocExists(t, "lifeA") || !histDocExists(t, "lifeB") {
t.Fatal("les deux fiches d| devaient exister après l'écriture")
}
if ids := histPosting(t, "phare"); len(ids) != 2 {
t.Fatalf("posting phare attendu de 2, obtenu %v", ids)
}
if err := convDelete("lifeA"); err != nil {
t.Fatal(err)
}
if histDocExists(t, "lifeA") {
t.Fatal("d|lifeA devait disparaître avec la discussion")
}
if _, ok := getStoreBytes(bkChatSearch, histToksKey("lifeA")); ok {
t.Fatal("t|lifeA devait disparaître avec la discussion")
}
if ids := histPosting(t, "phare"); len(ids) != 1 || ids[0] != "lifeB" {
t.Fatalf("posting phare attendu [lifeB], obtenu %v", ids)
}
}
// --- 9 bis. mise à jour incrémentale ------------------------------------------
// Réécrire une discussion ne touche que les mots qui ont changé — et un mot
// disparu (journal compacté, discussion vidée) quitte bien l'index.
func TestHistSearchIncrementalUpdate(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "phare boussole")})
if ids := histPosting(t, "boussole"); len(ids) != 1 {
t.Fatalf("boussole attendu indexé, obtenu %v", ids)
}
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "phare compas")})
if ids := histPosting(t, "boussole"); len(ids) != 0 {
t.Fatalf("boussole devait quitter l'index, obtenu %v", ids)
}
if ids := histPosting(t, "compas"); len(ids) != 1 || ids[0] != "A" {
t.Fatalf("compas attendu [A], obtenu %v", ids)
}
if ids := histPosting(t, "phare"); len(ids) != 1 {
t.Fatalf("phare attendu une fois, obtenu %v", ids)
}
}
// --- 10. l'index est chiffré ----------------------------------------------------
func TestHistSearchBucketEncrypted(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Titre secret", 100, []LogEvent{histUser(1, "le phare")})
if raw := getBytes(bkChatSearch, histDocKey("A")); looksEncrypted(raw) {
t.Fatal("avant activation, la valeur ne devait pas être chiffrée")
}
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatal(err)
}
for _, k := range []string{histDocKey("A"), histToksKey("A"), histPostKey("phare")} {
if !looksEncrypted(getBytes(bkChatSearch, k)) {
t.Fatalf("%s devait être chiffré (bucket absent d'encryptedBuckets ?)", k)
}
}
// Écrite après l'activation : chiffrée d'emblée, et toujours trouvable.
histConv(t, "B", "", "Autre", 200, []LogEvent{histUser(1, "le phare encore")})
if !looksEncrypted(getBytes(bkChatSearch, histPostKey("encore"))) {
t.Fatal("posting écrit mémoire chiffrée resté en clair")
}
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "B,A" {
t.Fatalf("B,A attendus mémoire déverrouillée, obtenu %s", got)
}
clearMemDEK()
if _, ok := getStoreBytes(bkChatSearch, histDocKey("A")); ok {
t.Fatal("index relisible alors que la mémoire est verrouillée")
}
}
// --- 11. couverture : jamais présenter un index partiel comme complet ---------
func TestHistSearchCoverageContract(t *testing.T) {
histSetup(t)
log := []LogEvent{histUser(1, "le phare")}
// Écrites À LA MAIN, sans passer par l'index : des discussions d'avant la
// fonctionnalité.
for _, id := range []string{"a", "b", "c"} {
if err := putStoreBytes(bkChat, convKey(id), histBody(t, log)); err != nil {
t.Fatal(err)
}
histSetMeta(t, convMeta{ID: id, Title: id, Updated: 100, Project: defaultProjectSlug})
}
if err := histIndexAt(dbPath(), "a", log); err != nil {
t.Fatal(err)
}
res := histSearch("phare", "", 0, 0)
if res.IndexedTotal != 3 || res.Indexed != 1 {
t.Fatalf("couverture attendue 1/3, obtenue %d/%d", res.Indexed, res.IndexedTotal)
}
if res.Total != 1 {
t.Fatalf("1 seule trouvable avant rattrapage, obtenu %d", res.Total)
}
histSearchIndexBackfill()
res = histSearch("phare", "", 0, 0)
if res.Indexed != 3 || res.IndexedTotal != 3 || res.Total != 3 {
t.Fatalf("après rattrapage : %d/%d, %d trouvées (3 attendues)", res.Indexed, res.IndexedTotal, res.Total)
}
}
// --- 11 bis. une fiche plus ancienne que la discussion est à refaire ----------
// L'écriture a réussi, l'indexation qui suit a raté (base occupée, process
// coupé) : la fiche existe mais décrit un état antérieur. Elle ne doit pas
// compter comme à jour, et le rattrapage doit la refaire.
func TestHistSearchStaleDocReindexed(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "le phare")})
future := histReadDocs()["A"].At + 10
if err := putStoreBytes(bkChat, convKey("A"), histBody(t, []LogEvent{histUser(1, "le phare"), histContent(2, "une boussole")})); err != nil {
t.Fatal(err)
}
histSetMeta(t, convMeta{ID: "A", Title: "Neutre", Updated: future, Project: defaultProjectSlug})
res := histSearch("boussole", "", 0, 0)
if res.Total != 0 || res.Indexed != 0 || res.IndexedTotal != 1 {
t.Fatalf("fiche périmée : 0 trouvée et couverture 0/1 attendues, obtenu %d trouvée(s), %d/%d", res.Total, res.Indexed, res.IndexedTotal)
}
histSearchIndexBackfill()
// Le rattrapage date la fiche de MAINTENANT : on recale la discussion dessus.
histSetMeta(t, convMeta{ID: "A", Title: "Neutre", Updated: histReadDocs()["A"].At, Project: defaultProjectSlug})
res = histSearch("boussole", "", 0, 0)
if res.Total != 1 || res.Indexed != 1 {
t.Fatalf("après rattrapage : boussole attendue trouvée et index à jour, obtenu %d trouvée(s), %d/%d", res.Total, res.Indexed, res.IndexedTotal)
}
}
// --- 12. le classement vient de l'index, pas des corps ------------------------
// Corps disparu, fiche et postings restés : la discussion reste trouvable, et
// l'extrait se rabat sur l'aperçu de la fiche.
func TestHistSearchRanksFromIndexNotBodies(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Titre", 100, []LogEvent{histUser(1, "le phare")})
if err := putBytes(bkChat, convKey("A"), nil); err != nil {
t.Fatal(err)
}
res := histSearch("phare", "", 0, 0)
if res.Total != 1 || res.Hits[0].ID != "A" {
t.Fatalf("le résultat devait venir de l'index, obtenu %+v", res.Hits)
}
if res.Hits[0].Snippet != "le phare" {
t.Fatalf("aperçu de la fiche attendu, obtenu %q", res.Hits[0].Snippet)
}
}
// --- 13. la discussion ouverte n'apparaît qu'une fois, son score n'est pas SOMMÉ
// Ouverte ET indexée : une seule entrée, et le journal en RAM REMPLACE la copie
// indexée au lieu de s'y ajouter. Requête « alpha beta », A ouverte :
//
// A : titre « alpha » (1) ; texte « alpha beta » (2) → 2*1+2 = 4 ; sommé → 6.
// B : titre « alpha beta » (2) ; texte « alpha » (1) → 2*2+1 = 5.
//
// Remplacement : [B A]. Somme : [A B] — l'ordre s'inverse.
func TestHistSearchDeduplicatesActiveAgainstIndex(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "alpha", 100, []LogEvent{histUser(1, "alpha beta")})
histConv(t, "B", "", "alpha beta", 200, []LogEvent{histUser(1, "alpha")})
histSetLive(t, "A", []LogEvent{histUser(1, "alpha beta")})
res := histSearch("alpha beta", "", 0, 0)
if got := histHitIDs(res); got != "B,A" {
t.Fatalf("une entrée par discussion, score remplacé : B,A attendus, obtenu %s (%+v)", got, res.Hits)
}
}
// --- 14. pagination : total AVANT découpage -----------------------------------
func TestHistSearchPagination(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Phare A", 100, []LogEvent{histUser(1, "le phare")})
histConv(t, "B", "", "Phare B", 200, []LogEvent{histUser(1, "le phare")})
histConv(t, "C", "", "Phare C", 300, []LogEvent{histUser(1, "le phare")})
page := histSearch("phare", "", 1, 1)
if len(page.Hits) != 1 || page.Total != 3 || page.Hits[0].ID != "B" {
t.Fatalf("page 2 de 1 : B et total 3 attendus, obtenu %+v total=%d", page.Hits, page.Total)
}
beyond := histSearch("phare", "", 10, 5)
if len(beyond.Hits) != 0 || beyond.Total != 3 {
t.Fatalf("au-delà de la fin : 0 résultat et total 3 attendus, obtenu %d / %d", len(beyond.Hits), beyond.Total)
}
}
// --- 15. repli des accents, dans les DEUX sens --------------------------------
func TestHistSearchAccentFolding(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Notes", 100, []LogEvent{histUser(1, "voici le résumé du rapport")})
histConv(t, "B", "", "Notes", 200, []LogEvent{histUser(1, "voici le resume du dossier")})
if n := len(histSearch("resume", "", 0, 0).Hits); n != 2 {
t.Fatalf("resume devait trouver les 2, obtenu %d", n)
}
if n := len(histSearch("RÉSUMÉ", "", 0, 0).Hits); n != 2 {
t.Fatalf("RÉSUMÉ devait trouver les 2, obtenu %d", n)
}
}
// --- 16. seul le texte VISIBLE est indexé -------------------------------------
func TestHistSearchIndexesVisibleTextOnly(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histContent(1, "voici un zèbre dans la réponse")})
histConv(t, "B", "", "Neutre", 200, []LogEvent{{TS: 1, Delta: map[string]any{"tool_used": map[string]any{
"name": "bash", "label": "rg zèbre", "result": "peu importe", "done": true}}}})
histConv(t, "C", "", "Neutre", 300, []LogEvent{{TS: 1, Delta: map[string]any{"reasoning_content": "je pense au zèbre"}}})
histConv(t, "D", "", "Neutre", 400, []LogEvent{{TS: 1, Delta: map[string]any{"tool_used": map[string]any{
"name": "bash", "label": "ls", "result": "le zèbre est dans la sortie", "done": true}}}})
res := histSearch("zèbre", "", 0, 0)
if !histHasHit(res, "A") || !histHasHit(res, "B") {
t.Fatalf("réponse et libellé d'outil devaient être indexés : %+v", res.Hits)
}
if histHasHit(res, "C") || histHasHit(res, "D") {
t.Fatalf("raisonnement et sortie d'outil ne devaient PAS être indexés : %+v", res.Hits)
}
}
// --- 17. correspondance par PRÉFIXE -------------------------------------------
func TestHistSearchPrefixMatchesBody(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Sujet neutre", 100, []LogEvent{histUser(1, "on passe du temps à compiling le binaire")})
histConv(t, "B", "", "Autre", 200, []LogEvent{histUser(1, "rien à voir ici")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "A" {
t.Fatalf("compil devait trouver A (« compiling »), obtenu %s", got)
}
}
// Garde-fous inverses : un préfixe qui ne commence aucun mot ne rend rien, et le
// MILIEU d'un mot ne correspond pas. « deepseek » est dans un TITRE à dessein :
// le scan des postings, borné au préfixe « p|eepseek », ne peut pas atteindre
// « deepseek » ; seul le titre verrait une bascule HasPrefix → Contains.
func TestHistSearchPrefixRejectsNonPrefix(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "compiling", 100, []LogEvent{histUser(1, "compiling le binaire")})
histConv(t, "B", "", "deepseek", 200, []LogEvent{histUser(1, "on parle deepseek ici")})
for _, q := range []string{"compilo", "eepseek"} {
if res := histSearch(q, "", 0, 0); res.Total != 0 {
t.Fatalf("%q ne devait rien trouver, obtenu %s", q, histHitIDs(res))
}
}
}
func TestHistSearchPrefixMatchesTitle(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Compilation annuelle", 100, []LogEvent{histUser(1, "texte neutre")})
histConv(t, "B", "", "Sujet divers", 200, []LogEvent{histUser(1, "on finit le compiling")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "A,B" {
t.Fatalf("le titre (+2) doit primer le texte (+1) : A,B attendus, obtenu %s", got)
}
}
// Un préfixe qui touche plusieurs mots d'une même discussion ne compte qu'une
// fois : à un incrément par mot, A (3 variantes, plus ancienne) passerait devant.
func TestHistSearchPrefixCountsTermOnce(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "compiling compilation compiled")})
histConv(t, "B", "", "Neutre", 200, []LogEvent{histUser(1, "on a fini de compiling")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "B,A" {
t.Fatalf("score par TERME (égalité, B plus récente) : B,A attendus, obtenu %s", got)
}
}
// La discussion ouverte suit la même règle de préfixe que l'index.
func TestHistSearchPrefixMatchesLiveSession(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Autre", 100, []LogEvent{histUser(1, "rien de spécial")})
histConv(t, "live1", "", "salut", 200, []LogEvent{histUser(1, "salut")})
histSetLive(t, "live1", []LogEvent{histUser(1, "salut"), histContent(2, "on parle de compilation ici")})
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "live1" {
t.Fatalf("la discussion ouverte devait ressortir sur « compil », obtenu %s", got)
}
}
// --- 18. couverture : une entrée d'index SANS corps n'est pas « à indexer » ---
// Une discussion toute neuve est dans l'index des discussions avant d'avoir
// jamais été écrite : elle ne recevra pas de fiche tant qu'elle est vide. La
// compter au dénominateur laissait l'UI annoncer « indexation en cours » pour
// toujours (constat amont, 13/14 stable).
func TestHistSearchCoverageIgnoresMetaWithoutBody(t *testing.T) {
histSetup(t)
histConv(t, "normale", "", "Sujet", 100, []LogEvent{histUser(1, "le phare")})
histSetMeta(t, convMeta{ID: "neuve", Title: "", Updated: 200, Project: defaultProjectSlug})
res := histSearch("phare", "", 0, 0)
if res.IndexedTotal != 1 || res.Indexed != 1 {
t.Fatalf("couverture 1/1 attendue, obtenue %d/%d", res.Indexed, res.IndexedTotal)
}
if !histHasHit(res, "normale") || histHasHit(res, "neuve") {
t.Fatalf("la discussion écrite attendue, la neuve exclue : %+v", res.Hits)
}
}
// --- 19. l'extrait montre le passage trouvé, pas le début -----------------------
// Le début d'une discussion est son titre (convSummary) : l'aperçu seul
// n'apprendrait rien. L'extrait est pris autour du mot trouvé, loin dans le fil.
func TestHistSearchSnippetInContext(t *testing.T) {
histSetup(t)
long := strings.Repeat("du texte sans rapport ", 40)
histConv(t, "A", "", "Question de départ", 100, []LogEvent{
histUser(1, "Question de départ"),
histContent(2, long+"et voici enfin le Phare du Créac'h, au bout de l'île. "+long),
})
res := histSearch("phare", "", 0, 0)
if len(res.Hits) != 1 {
t.Fatalf("A attendue, obtenu %+v", res.Hits)
}
sn := res.Hits[0].Snippet
if !strings.Contains(sn, "Phare du Créac'h") {
t.Fatalf("extrait autour du mot attendu, obtenu %q", sn)
}
if !strings.HasPrefix(sn, "…") || !strings.HasSuffix(sn, "…") {
t.Fatalf("extrait coupé des deux côtés attendu (…), obtenu %q", sn)
}
if n := len([]rune(sn)); n > 260 {
t.Fatalf("extrait trop long (%d runes) : %q", n, sn)
}
}
// --- 20. écritures de la FAÇADE (LOKI_CHAT=rust) ------------------------------
// La façade écrit ses discussions par /internal/v1/store, scellées par ce
// process : l'index doit les suivre — écriture, réécriture, suppression — sans
// que la façade ait rien à en savoir.
func TestHistSearchFacadeStoreWrites(t *testing.T) {
histSetup(t)
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
t.Fatal(err)
}
histSetMeta(t, convMeta{ID: "r1", Title: "Écrite par la façade", Updated: 100, Project: defaultProjectSlug})
put := func(log []LogEvent) {
t.Helper()
sealed, err := encodeMemContent(histBody(t, log))
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/internal/v1/store?bucket=chat&key=conv:r1", strings.NewReader(string(sealed)))
handleInternalStore(rr, req)
if rr.Code != http.StatusNoContent {
t.Fatalf("PUT : %d %s", rr.Code, rr.Body.String())
}
}
put([]LogEvent{histUser(1, "le phare vu par la façade")})
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "r1" {
t.Fatalf("discussion de la façade attendue, obtenu %q", got)
}
if !looksEncrypted(getBytes(bkChatSearch, histPostKey("phare"))) {
t.Fatal("index tenu pour la façade resté en clair")
}
put([]LogEvent{histUser(1, "maintenant une boussole")})
if res := histSearch("phare", "", 0, 0); res.Total != 0 {
t.Fatalf("réécrite sans « phare » : plus rien attendu, obtenu %s", histHitIDs(res))
}
rr := httptest.NewRecorder()
handleInternalStore(rr, httptest.NewRequest(http.MethodDelete, "/internal/v1/store?bucket=chat&key=conv:r1", nil))
if rr.Code != http.StatusNoContent {
t.Fatalf("DELETE : %d", rr.Code)
}
if histDocExists(t, "r1") || len(histPosting(t, "boussole")) != 0 {
t.Fatal("discussion supprimée par la façade restée dans l'index")
}
// Une autre clé du même bucket (l'index des discussions) n'est pas une discussion.
histIndexStoreWrite(bkChat, ckIndex, []byte("[]"), false)
if histDocExists(t, "index") {
t.Fatal("l'index des discussions a été pris pour une discussion")
}
}
// La route reste à ce process : la façade ne la sert pas (elle la relaie), et
// le tunnel du relais ne la lui remet pas.
func TestHistSearchRouteNotDelegated(t *testing.T) {
if facadeChatRoutes["/api/conversations/search"] {
t.Fatal("/api/conversations/search ne doit pas être remise à la façade : l'index est tenu ici")
}
}
// --- 21. la route HTTP --------------------------------------------------------
func TestHandleConvSearch(t *testing.T) {
histSetup(t)
histConv(t, "A", "", "Le phare", 100, []LogEvent{histUser(1, "Le phare")})
rr := httptest.NewRecorder()
handleConvSearch(rr, httptest.NewRequest(http.MethodGet, "/api/conversations/search?q=phare", nil))
var got struct {
OK bool `json:"ok"`
State string `json:"state"`
Conversations []histSearchHit `json:"conversations"`
Total int `json:"total"`
Indexed int `json:"indexed"`
IndexedTotal int `json:"indexed_total"`
Terms []string `json:"terms"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
t.Fatalf("réponse illisible : %v (%s)", err, rr.Body.String())
}
if !got.OK || got.State != "ok" || got.Total != 1 || len(got.Conversations) != 1 || !got.Conversations[0].InTitle {
t.Fatalf("réponse inattendue : %+v", got)
}
if got.Indexed != 1 || got.IndexedTotal != 1 || strings.Join(got.Terms, ",") != "phare" {
t.Fatalf("couverture ou termes inattendus : %+v", got)
}
rr = httptest.NewRecorder()
handleConvSearch(rr, httptest.NewRequest(http.MethodGet, "/api/conversations/search?q=", nil))
if !strings.Contains(rr.Body.String(), `"conversations":[]`) || !strings.Contains(rr.Body.String(), `"no_terms"`) {
t.Fatalf("requête vide : tableau vide et no_terms attendus, obtenu %s", rr.Body.String())
}
rr = httptest.NewRecorder()
handleConvSearch(rr, httptest.NewRequest(http.MethodPost, "/api/conversations/search", nil))
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("POST : 405 attendu, obtenu %d", rr.Code)
}
}
+1
View File
@@ -330,6 +330,7 @@ func convDelete(id string) error {
convIndexSave(next)
convIndexMu.Unlock()
_ = putBytes(bkChat, convKey(id), nil)
histUnindexAt(dbPath(), id) // son index plein-texte part avec elle (chat_search.go)
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
// elle, et ses critères/mode/puce partent avec ses messages.
stopConvJobs(id)
+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,
}
+4 -3
View File
@@ -163,14 +163,15 @@ func bucketFullyEncrypted(bucket string) bool {
// chiffrement est actif. Ici : les discussions (bkChat porte l'index, la
// discussion active et le journal de chacune), les blocs archivés au compactage
// (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et
// les trackers (bkTracker, 3ᵉ type de mémoire), et les résultats complets des
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil).
// les trackers (bkTracker, 3ᵉ type de mémoire), les résultats complets des
// outils gardés pour « voir plus » (bkToolRes — des extraits du fil) et l'index
// plein-texte des discussions (bkChatSearch — leurs mots, chat_search.go).
//
// Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils
// portent des réglages, pas des données personnelles — et surtout le coffre
// lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une
// boucle : plus moyen de déverrouiller quoi que ce soit.
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes}
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes, bkChatSearch}
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
func reencryptChatStores() error {
+3
View File
@@ -79,6 +79,9 @@ func memUnlocked() bool {
return memDEK != nil
}
// memLocked : mémoire chiffrée MAIS verrouillée (DEK absente).
func memLocked() bool { return memEncActive() && !memUnlocked() }
// setMemDEK charge la DEK en RAM (copie défensive).
func setMemDEK(dek []byte) {
memKeyMu.Lock()
+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)
+5
View File
@@ -42,6 +42,11 @@ const (
// bkTracker : les trackers, 3e type de mémoire (tracker.go). Clé
// « <slug-projet>/<slug-tracker> » — le cloisonnement par projet est dans la clé.
bkTracker = "tracker"
// bkChatSearch : index PLEIN-TEXTE des discussions (chat_search.go) — fiche
// et liste de mots par discussion, liste de discussions par mot. Chiffré
// comme bkChat : un sac de mots en clair à côté de discussions chiffrées
// serait une fuite.
bkChatSearch = "chatsearch"
)
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
+130 -17
View File
@@ -1797,6 +1797,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
/* Extrait d'un résultat de recherche plein-texte : sous le titre, à la place
de .preset-meta, une seule ligne tronquée comme le titre. */
.side .preset .conv-snip{font-size:12px;color:var(--dim);margin-top:3px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
/* Ligne à extrait : deux lignes de texte. L'heure et l'anneau se calent sur
la ligne de base du TITRE au lieu de se centrer entre les deux (les boutons,
en position absolue, restent centrés sur la ligne entière). */
.side .preset.has-snip{align-items:baseline}
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
border:0;padding:8px 11px;text-align:left;cursor:pointer}
.conv-more:hover{color:var(--text)}
@@ -2351,11 +2359,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
actif est partagé par tous les appareils (comme la conversation unique
d'origine) : basculer ici bascule partout.
La recherche filtre CÔTÉ CLIENT : la liste est déjà entièrement chargée
(métadonnées seules, sans les messages), un aller-retour serveur par
frappe n'apporterait rien. -->
La recherche filtre d'abord les titres CÔTÉ CLIENT (la liste est déjà
chargée, réponse immédiate), puis demande au serveur les discussions dont
le TEXTE contient les mots (index plein-texte, chat_search.go) — avec un
extrait sous le titre. -->
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion" aria-label="nouvelle discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
<input id="conv-search" type="search" placeholder="rechercher une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
<input id="conv-search" type="search" placeholder="rechercher partout…" oninput="renderConversations()" aria-label="rechercher dans les titres et le texte des discussions">
<div id="conv-list" class="slist"></div>
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
@@ -3500,6 +3509,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-pause" class="pe-link" onclick="togglePauseDownload()" style="display:none">Mettre en pause</button>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
<div class="pe-row">
@@ -4521,6 +4531,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 +6764,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 +6774,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 +6891,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 +6917,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 +6944,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 +6954,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 +9314,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 +9338,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 +9361,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],
];
@@ -10495,8 +10537,11 @@ function fmtWhen(sec){
// balisage ni les gestionnaires d'événement.
//
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
// titre, dates, nombre d'échanges — jamais les messages.
// les TITRES sans aller-retour serveur : elle ne porte que des métadonnées —
// identifiant, titre, dates, nombre d'échanges — jamais les messages. Le TEXTE
// des discussions, lui, est cherché par le serveur (/api/conversations/search,
// index plein-texte chiffré comme les discussions) : sa réponse arrive après
// une courte pause de frappe et complète la liste, avec un extrait.
let CONVS = [], CONV_ACTIVE = '';
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
@@ -10510,11 +10555,65 @@ let CONV_BUSY = false;
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
const CONV_PAGE = 40;
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
// Recherche plein-texte : dernière réponse du serveur (pour la requête q), et
// minuterie de la pause de frappe. Une réponse arrivée pour une requête qui
// n'est plus celle du champ est jetée.
// convSrvGen numérote les requêtes : un rechargement de la liste en relance
// une pour la même saisie, la réponse d'avant ne doit plus s'afficher.
let CONV_SRV = null, convSrvTimer = 0, convSrvWanted = '', convSrvGen = 0;
const CONV_SRV_PAUSE = 250;
function convSearchSchedule(q){
if(q === convSrvWanted) return;
convSrvWanted = q;
clearTimeout(convSrvTimer);
const gen = ++convSrvGen;
if(!q) return;
convSrvTimer = setTimeout(async () => {
let r;
try{ r = await jget('/api/conversations/search?limit=100&q=' + encodeURIComponent(q)); }catch(_){ r = null; }
if(gen !== convSrvGen) return; // saisie changée, ou liste rechargée depuis
CONV_SRV = {q, r: (r && r.ok) ? r : null, failed: !(r && r.ok)};
renderConversations();
}, CONV_SRV_PAUSE);
}
// Liste affichée pour une requête : les résultats du serveur dans leur ordre
// (pertinence : titre puis texte), puis les titres trouvés ici que le serveur
// n'a pas (il cherche des débuts de mots, le filtre local une sous-chaîne).
// Rend aussi les extraits et la ligne d'état éventuelle.
function convSearchList(q){
const byTitle = CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q));
const snips = new Map();
const cur = CONV_SRV && CONV_SRV.q === q ? CONV_SRV : null;
const srv = cur ? cur.r : null;
if(!srv) return {list: byTitle, snips, note: cur && cur.failed ? 'recherche dans le texte indisponible : titres seulement' : ''};
let note = '';
const got = (srv.conversations || []).length;
if(srv.state === 'locked') note = 'mémoire verrouillée : recherche dans les titres seulement';
else if(srv.indexed < srv.indexed_total) note = 'indexation en cours (' + srv.indexed + '/' + srv.indexed_total + ') : résultats partiels';
else if(srv.total > got) note = 'les ' + got + ' plus pertinentes sur ' + srv.total + ' : précisez la recherche';
const byID = new Map(CONVS.map(c => [c.id, c]));
const list = [], seen = new Set();
for(const h of (srv.conversations || [])){
const c = byID.get(h.id);
if(!c || seen.has(c.id)) continue;
seen.add(c.id);
list.push(c);
if(h.snippet && h.snippet !== (c.title || '')) snips.set(c.id, h.snippet);
}
for(const c of byTitle) if(!seen.has(c.id)) list.push(c);
return {list, snips, note};
}
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
// La liste a changé (fin de tour, bascule, suppression) : une recherche en
// cours est refaite. Ses résultats restent affichés d'ici la nouvelle
// réponse — les effacer ferait clignoter la liste à chaque tour.
convSrvWanted = '';
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
@@ -10533,9 +10632,14 @@ function renderConversations(){
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
convSearchSchedule(q);
const {list, snips, note} = q ? convSearchList(q) : {list: CONVS, snips: new Map(), note: ''};
if(note){
const e = document.createElement('div');
e.className = 'conv-empty';
e.textContent = note;
cont.appendChild(e);
}
if(!list.length){
const e = document.createElement('div');
e.className = 'conv-empty';
@@ -10550,7 +10654,8 @@ function renderConversations(){
const shown = Math.max(CONV_SHOWN, ai + 1);
for(const c of list.slice(0, shown)){
const row = document.createElement('div');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
const sn = snips.get(c.id);
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
// qui travaille sans redessiner la liste (un redessin relancerait
// l'animation de l'anneau à chaque événement du flux).
@@ -10563,6 +10668,14 @@ function renderConversations(){
nm.appendChild(document.createTextNode(title));
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
info.appendChild(nm);
// Recherche : le passage du texte où les mots ont été trouvés.
if(sn){
const s = document.createElement('div');
s.className = 'conv-snip';
s.textContent = sn;
s.title = sn;
info.appendChild(s);
}
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
+6 -4
View File
@@ -66,11 +66,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
actif est partagé par tous les appareils (comme la conversation unique
d'origine) : basculer ici bascule partout.
La recherche filtre CÔTÉ CLIENT : la liste est déjà entièrement chargée
(métadonnées seules, sans les messages), un aller-retour serveur par
frappe n'apporterait rien. -->
La recherche filtre d'abord les titres CÔTÉ CLIENT (la liste est déjà
chargée, réponse immédiate), puis demande au serveur les discussions dont
le TEXTE contient les mots (index plein-texte, chat_search.go) — avec un
extrait sous le titre. -->
<details id="conv-details" open><summary><span class="stitle">Discussions</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();convNew()" title="nouvelle discussion" aria-label="nouvelle discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
<input id="conv-search" type="search" placeholder="rechercher une discussion…" oninput="renderConversations()" aria-label="rechercher une discussion">
<input id="conv-search" type="search" placeholder="rechercher partout…" oninput="renderConversations()" aria-label="rechercher dans les titres et le texte des discussions">
<div id="conv-list" class="slist"></div>
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
@@ -1215,6 +1216,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div id="m-hf-bar" class="pe-bar" style="display:none"><i></i></div>
<div id="m-hf-progress" class="pe-note" style="display:none"></div>
<button id="m-hf-pause" class="pe-link" onclick="togglePauseDownload()" style="display:none">Mettre en pause</button>
<button id="m-hf-cancel" class="pe-link" onclick="cancelDownload()" style="display:none">Annuler le téléchargement</button>
</div>
<div class="pe-row">
+1
View File
@@ -330,6 +330,7 @@ async function loadCfg(){
rows.push(row('CTX', d.ctx));
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
rows.push(row('MTP', d.spec));
if(d.helper_gpu) rows.push(row('GPU 2', d.split_layers ? 'couches' : 'aide', d.helper_gpu));
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
}else if(c.CTX) rows.push(row('CTX', c.CTX));
+25 -3
View File
@@ -1501,6 +1501,7 @@ function dlEls(){
prog: document.getElementById('m-hf-progress'),
bar: document.getElementById('m-hf-bar'),
cancel: document.getElementById('m-hf-cancel'),
pause: document.getElementById('m-hf-pause'),
};
}
// Arrête le poll local sans toucher au téléchargement serveur.
@@ -1510,7 +1511,7 @@ function resetDlUI(){
const e = dlEls();
stopDlPoll(); dlName='';
e.prog.style.display='none'; e.bar.style.display='none';
e.cancel.style.display='none'; e.btn.disabled=false;
e.cancel.style.display='none'; e.pause.style.display='none'; e.btn.disabled=false;
}
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
@@ -1627,6 +1628,22 @@ async function cancelDownload(){
await jpost('/api/models/download/cancel', {filename: dlName});
// L'état « annulé » est confirmé par le prochain tick de poll.
}
// Met en pause ou reprend : le fichier partiel est gardé, la reprise repart
// de là où le transfert s'était arrêté (repris d'AJEAN v0.18.1).
let dlPaused = false;
async function togglePauseDownload(){
if(!dlName) return;
const e = dlEls();
e.pause.disabled = true;
try{
const r = await jpost('/api/models/download/pause', {filename: dlName, pause: !dlPaused});
if(r.ok) setDlPaused(r.paused);
}finally{ e.pause.disabled = false; }
}
function setDlPaused(p){
dlPaused = !!p;
dlEls().pause.textContent = dlPaused ? 'Reprendre' : 'Mettre en pause';
}
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
function watchDownload(fname){
const e = dlEls();
@@ -1637,7 +1654,10 @@ function watchDownload(fname){
e.bar.style.display = 'block';
e.cancel.style.display = 'inline';
e.cancel.disabled = false;
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
e.pause.style.display = 'inline';
e.pause.disabled = false;
setDlPaused(false);
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; e.pause.style.display='none'; };
const tick = async ()=>{
const list = await jget('/api/models/download/status');
const st = (list||[]).find(d=>d.filename===fname);
@@ -1661,6 +1681,7 @@ function watchDownload(fname){
if(dlQueue.length) startDownloadURL(dlQueue.shift());
return;
}
if(!!st.paused !== dlPaused) setDlPaused(st.paused);
const pct = st.total>0 ? st.done*100/st.total : 0;
if(st.total>0){
e.bar.className = 'pe-bar';
@@ -1670,7 +1691,8 @@ function watchDownload(fname){
}
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
let extra = '';
if(st.speed>0){
if(st.paused) extra += ' — en pause';
else if(st.speed>0){
extra += ' — '+fmtSize(st.speed)+'/s';
if(st.total>0){
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
+13 -4
View File
@@ -128,7 +128,11 @@ function strataRenderSettings(fit){
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
['Lire les images', sw('strata-s-vision', d.vision)],
];
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
// Seconde carte : aide aux experts (défaut mesuré) ou partage des couches,
// souvent plus rapide avec deux cartes identiques (#121 d'AJEAN).
if(d.has_helper) rows.push(['Seconde carte', sel('strata-s-helper',
[['off', 'non utilisée'], ['helper', 'aide aux experts'], ['layers', 'partage des couches']],
!d.helper_on ? 'off' : d.split_layers ? 'layers' : 'helper')]);
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
strataCtxLbl();
@@ -148,8 +152,11 @@ async function strataSaveSettings(){
if(!fit || !fit.preset) return;
const v = id => document.getElementById(id);
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
vision: v('strata-s-vision').checked,
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
const second = v('strata-s-helper') ? v('strata-s-helper').value : 'off';
body.helper = second !== 'off';
if(body.helper) body.split = second;
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
const r = await jpost('/api/strata/settings', body);
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
@@ -168,14 +175,16 @@ function strataRenderDetails(fit){
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
const rows = [
['Carte principale', d.main_gpu],
["Carte d'appoint", d.helper_gpu || 'aucune'],
['Seconde carte', d.helper_gpu ? d.helper_gpu + (d.split_layers ? ' · partage des couches' : ' · aide aux experts') : 'aucune'],
['Lecture des images', d.vision_gpu || 'aucune'],
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
['Prédiction (MTP)', d.spec+' jetons'],
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
// paquet 1.1 : la carte d'appoint calcule ses experts à la lecture d'un prompt
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'
+ (d.helper_gpu && !d.split_layers ? ", experts de la carte d'appoint calculés par elle" : '')],
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
['Version du moteur', 'paquet AJEAN MoE '+d.version],
];
+77 -6
View File
@@ -10,8 +10,11 @@
// balisage ni les gestionnaires d'événement.
//
// La liste chargée est GARDÉE en mémoire (CONVS) pour que la recherche filtre
// sans aller-retour serveur : elle ne porte que des métadonnées — identifiant,
// titre, dates, nombre d'échanges — jamais les messages.
// les TITRES sans aller-retour serveur : elle ne porte que des métadonnées —
// identifiant, titre, dates, nombre d'échanges — jamais les messages. Le TEXTE
// des discussions, lui, est cherché par le serveur (/api/conversations/search,
// index plein-texte chiffré comme les discussions) : sa réponse arrive après
// une courte pause de frappe et complète la liste, avec un extrait.
let CONVS = [], CONV_ACTIVE = '';
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
@@ -25,11 +28,65 @@ let CONV_BUSY = false;
// recherche, elle, filtre toujours TOUTE la liste (CONVS est en mémoire).
const CONV_PAGE = 40;
let CONV_SHOWN = CONV_PAGE, CONV_LAST_Q = '', convMoreObs = null;
// Recherche plein-texte : dernière réponse du serveur (pour la requête q), et
// minuterie de la pause de frappe. Une réponse arrivée pour une requête qui
// n'est plus celle du champ est jetée.
// convSrvGen numérote les requêtes : un rechargement de la liste en relance
// une pour la même saisie, la réponse d'avant ne doit plus s'afficher.
let CONV_SRV = null, convSrvTimer = 0, convSrvWanted = '', convSrvGen = 0;
const CONV_SRV_PAUSE = 250;
function convSearchSchedule(q){
if(q === convSrvWanted) return;
convSrvWanted = q;
clearTimeout(convSrvTimer);
const gen = ++convSrvGen;
if(!q) return;
convSrvTimer = setTimeout(async () => {
let r;
try{ r = await jget('/api/conversations/search?limit=100&q=' + encodeURIComponent(q)); }catch(_){ r = null; }
if(gen !== convSrvGen) return; // saisie changée, ou liste rechargée depuis
CONV_SRV = {q, r: (r && r.ok) ? r : null, failed: !(r && r.ok)};
renderConversations();
}, CONV_SRV_PAUSE);
}
// Liste affichée pour une requête : les résultats du serveur dans leur ordre
// (pertinence : titre puis texte), puis les titres trouvés ici que le serveur
// n'a pas (il cherche des débuts de mots, le filtre local une sous-chaîne).
// Rend aussi les extraits et la ligne d'état éventuelle.
function convSearchList(q){
const byTitle = CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q));
const snips = new Map();
const cur = CONV_SRV && CONV_SRV.q === q ? CONV_SRV : null;
const srv = cur ? cur.r : null;
if(!srv) return {list: byTitle, snips, note: cur && cur.failed ? 'recherche dans le texte indisponible : titres seulement' : ''};
let note = '';
const got = (srv.conversations || []).length;
if(srv.state === 'locked') note = 'mémoire verrouillée : recherche dans les titres seulement';
else if(srv.indexed < srv.indexed_total) note = 'indexation en cours (' + srv.indexed + '/' + srv.indexed_total + ') : résultats partiels';
else if(srv.total > got) note = 'les ' + got + ' plus pertinentes sur ' + srv.total + ' : précisez la recherche';
const byID = new Map(CONVS.map(c => [c.id, c]));
const list = [], seen = new Set();
for(const h of (srv.conversations || [])){
const c = byID.get(h.id);
if(!c || seen.has(c.id)) continue;
seen.add(c.id);
list.push(c);
if(h.snippet && h.snippet !== (c.title || '')) snips.set(c.id, h.snippet);
}
for(const c of byTitle) if(!seen.has(c.id)) list.push(c);
return {list, snips, note};
}
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
// La liste a changé (fin de tour, bascule, suppression) : une recherche en
// cours est refaite. Ses résultats restent affichés d'ici la nouvelle
// réponse — les effacer ferait clignoter la liste à chaque tour.
convSrvWanted = '';
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
@@ -48,9 +105,14 @@ function renderConversations(){
const q = (document.getElementById('conv-search')?.value || '').trim().toLowerCase();
cont.textContent = '';
if(q !== CONV_LAST_Q){ CONV_LAST_Q = q; CONV_SHOWN = CONV_PAGE; }
const list = q
? CONVS.filter(c => (c.title || 'Nouvelle discussion').toLowerCase().includes(q))
: CONVS;
convSearchSchedule(q);
const {list, snips, note} = q ? convSearchList(q) : {list: CONVS, snips: new Map(), note: ''};
if(note){
const e = document.createElement('div');
e.className = 'conv-empty';
e.textContent = note;
cont.appendChild(e);
}
if(!list.length){
const e = document.createElement('div');
e.className = 'conv-empty';
@@ -65,7 +127,8 @@ function renderConversations(){
const shown = Math.max(CONV_SHOWN, ai + 1);
for(const c of list.slice(0, shown)){
const row = document.createElement('div');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
const sn = snips.get(c.id);
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '') + (sn ? ' has-snip' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
// qui travaille sans redessiner la liste (un redessin relancerait
// l'animation de l'anneau à chaque événement du flux).
@@ -78,6 +141,14 @@ function renderConversations(){
nm.appendChild(document.createTextNode(title));
nm.title = title + (c.turns ? ' · ' + c.turns + ' échange' + (c.turns > 1 ? 's' : '') : '');
info.appendChild(nm);
// Recherche : le passage du texte où les mots ont été trouvés.
if(sn){
const s = document.createElement('div');
s.className = 'conv-snip';
s.textContent = sn;
s.title = sn;
info.appendChild(s);
}
// Heure à droite du titre, sur la même ligne (maquette). Elle s'efface au
// survol : renommer et supprimer prennent sa place, la ligne ne s'allonge
+8
View File
@@ -1762,6 +1762,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
color:var(--dim);letter-spacing:-.01em;overflow:visible}
.side .preset:hover .conv-when{opacity:0}
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
/* Extrait d'un résultat de recherche plein-texte : sous le titre, à la place
de .preset-meta, une seule ligne tronquée comme le titre. */
.side .preset .conv-snip{font-size:12px;color:var(--dim);margin-top:3px;white-space:nowrap;
overflow:hidden;text-overflow:ellipsis}
/* Ligne à extrait : deux lignes de texte. L'heure et l'anneau se calent sur
la ligne de base du TITRE au lieu de se centrer entre les deux (les boutons,
en position absolue, restent centrés sur la ligne entière). */
.side .preset.has-snip{align-items:baseline}
.conv-more{display:block;width:100%;font:inherit;font-size:12px;color:var(--dim);background:none;
border:0;padding:8px 11px;text-align:left;cursor:pointer}
.conv-more:hover{color:var(--text)}
+1
View File
@@ -951,6 +951,7 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) {
if memEncActive() && memUnlocked() {
_ = reencryptChatStores()
}
startHistSearchBackfill() // index plein-texte : il attendait la clé (chat_search.go)
sendJSON(w, 200, map[string]any{"ok": true, "kind": kind, "health": memHealth()})
}
+5
View File
@@ -228,6 +228,10 @@ func handleInternalStore(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
// Une discussion écrite par la façade : l'index plein-texte la suit
// (chat_search.go). Avant la réponse, pour que sa suppression, qui
// n'arrive qu'après, ne soit jamais doublée par cette indexation.
histIndexStoreWrite(bucket, key, val, false)
w.WriteHeader(http.StatusNoContent)
case http.MethodDelete:
err := update(bucket, func(b *bolt.Bucket) error { return b.Delete([]byte(key)) })
@@ -235,6 +239,7 @@ func handleInternalStore(w http.ResponseWriter, r *http.Request) {
sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()})
return
}
histIndexStoreWrite(bucket, key, nil, true)
w.WriteHeader(http.StatusNoContent)
default:
w.Header().Set("Allow", "GET, PUT, DELETE")
+7
View File
@@ -163,6 +163,9 @@ func newWebMux() *http.ServeMux {
// orphelin arrêté, moteur relancé s'il tournait avant (backend_tune_lock.go)
// — au démarrage, puis toutes les 30 s (un stat sans verrou).
go tuneRecoverWatch(time.NewTicker(tuneRecoverEvery).C)
// Index plein-texte des discussions : rattrape celles d'avant la
// fonctionnalité (ou dont l'indexation a échoué), en tâche de fond.
startHistSearchBackfill()
mux := http.NewServeMux()
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
@@ -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
@@ -398,6 +402,9 @@ func newWebMux() *http.ServeMux {
api("/api/conversations/switch", handleConvSwitch)
api("/api/conversations/rename", handleConvRename)
api("/api/conversations/delete", handleConvDelete)
// Recherche plein-texte (chat_search.go) : servie ici même quand la façade
// tient la conversation — l'index est tenu par ce process.
api("/api/conversations/search", handleConvSearch)
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
+7
View File
@@ -499,4 +499,11 @@ mod tests {
);
assert!(is_internal("/internal/facade/v1/delta") && !is_internal("/internal/v1/auth"));
}
/// La recherche plein-texte reste au Go, qui tient l'index (chat_search.go) :
/// la façade la relaie, elle ne doit pas la servir.
#[test]
fn recherche_relayee_au_go() {
assert!(!handles("/api/conversations/search"));
}
}
+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";
+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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -260,7 +260,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -304,7 +304,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -335,8 +335,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -571,7 +571,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -615,7 +615,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -855,8 +855,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1022,7 +1022,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -1066,7 +1066,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -1103,8 +1103,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1270,7 +1270,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -1314,7 +1314,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -1554,8 +1554,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 16403.5,
"prompt_n": 32807,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -1715,7 +1715,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -1759,7 +1759,7 @@
"max_tokens": 4096,
"messages": [
{
"content": "<131209 octets a079a2b6a1db>",
"content": "<131212 octets f427338939c0>",
"role": "user"
}
],
@@ -1790,8 +1790,8 @@
"predicted_ms": 81920,
"predicted_n": 4096,
"predicted_per_second": 50,
"prompt_ms": 16403,
"prompt_n": 32806,
"prompt_ms": 16403.5,
"prompt_n": 32807,
"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": 16403.5,
"prompt_n": 32807,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -1882,7 +1882,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -2049,7 +2049,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -2093,7 +2093,7 @@
"max_tokens": 16,
"messages": [
{
"content": "<3745 octets 5578a8f2779c>",
"content": "<3745 octets f904cd9392eb>",
"role": "user"
}
],
@@ -2187,8 +2187,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -2216,7 +2216,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -2260,7 +2260,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -2291,8 +2291,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2368,7 +2368,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2497,7 +2497,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -2541,7 +2541,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -2572,8 +2572,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2649,7 +2649,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -2693,7 +2693,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -2747,8 +2747,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -2824,7 +2824,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -2868,7 +2868,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -2992,8 +2992,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3069,7 +3069,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3218,7 +3218,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -3287,7 +3287,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -3319,8 +3319,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3412,7 +3412,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -3457,7 +3457,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -3489,8 +3489,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
},
@@ -3608,7 +3608,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -3652,7 +3652,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -3737,8 +3737,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"prompt_per_second": 2000,
"protocol": 2
}
@@ -3823,7 +3823,7 @@
},
{
"body": {
"content": "<163664 octets b7452851885a>"
"content": "<163821 octets 62a166d2e838>"
},
"method": "POST",
"path": "/tokenize"
@@ -3867,7 +3867,7 @@
"max_tokens": 300,
"messages": [
{
"content": "<8129 octets ae9cc838495d>",
"content": "<8113 octets 6818f5613608>",
"role": "user"
}
],
@@ -3952,8 +3952,8 @@
"predicted_ms": 6000,
"predicted_n": 300,
"predicted_per_second": 50,
"prompt_ms": 1018,
"prompt_n": 2036,
"prompt_ms": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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": 1016,
"prompt_n": 2032,
"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)));