mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0be90a6941 | ||
|
|
f86937a1ae | ||
|
|
e9c0555ab7 | ||
|
|
6a45f19831 | ||
|
|
d22a087d8e | ||
|
|
0ef2ed72f5 | ||
|
|
615e58dd2f | ||
|
|
9792953d62 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
|
||||
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
|
||||
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
|
||||
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
|
||||
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par
|
||||
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.1 », vérifié par
|
||||
SHA-256).
|
||||
|
||||
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
|
||||
@@ -120,7 +120,26 @@ puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go d
|
||||
`/data/strata`). Le modèle installé devient un preset comme les autres :
|
||||
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
|
||||
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
|
||||
cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint).
|
||||
cache KV fp16/8 bits, prédiction MTP, lecture des images, seconde carte).
|
||||
|
||||
La seconde carte a trois réglages : **non utilisée**, **aide aux experts** (le
|
||||
défaut : elle garde et calcule une part des experts) ou **partage des couches**
|
||||
(les deux cartes se répartissent les couches du modèle). Le partage est souvent
|
||||
plus rapide avec deux cartes identiques (+50 % en génération mesuré par un
|
||||
utilisateur d'AJEAN sur deux RTX 3060) ; sur deux cartes très différentes,
|
||||
l'aide aux experts reste le meilleur choix. En partage des couches, le mode
|
||||
« hors RAM » n'est jamais choisi : la seconde carte n'y garde pas d'experts.
|
||||
|
||||
Depuis le paquet 1.1, la carte d'appoint calcule elle-même ses experts à la
|
||||
lecture des prompts (au lieu de les recopier vers la principale) : les ajouts
|
||||
de 100 à 200 jetons se lisent ~30 à 40 % plus vite (mesures d'AJEAN). Un modèle
|
||||
installé avec la 1.0 passe à la 1.1 **à son premier lancement**, sans
|
||||
réinstallation : Loki télécharge le nouveau paquet (~340 Mo), reprend
|
||||
l'environnement Python et la configuration de la 1.0 (le dossier
|
||||
`/data/strata/ajean-moe-1.0` doit donc rester en place) et réécrit le preset.
|
||||
En cas d'échec, la 1.0 continue de tourner et la raison est dans le journal du
|
||||
moteur. En mode « hors RAM », le contexte est plafonné à 131 072 jetons (au-delà,
|
||||
le cache KV verrouillé en RAM ne tient plus).
|
||||
|
||||
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
|
||||
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
|
||||
|
||||
@@ -282,10 +282,96 @@ type dlState struct {
|
||||
Part int `json:"part"` // tranche en cours (1-based)
|
||||
Finished bool `json:"finished"`
|
||||
Canceled bool `json:"canceled"`
|
||||
Paused bool `json:"paused"`
|
||||
Err string `json:"error"`
|
||||
StartedAt int64 `json:"started_at"`
|
||||
|
||||
cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
|
||||
gate *dlGate `json:"-"`
|
||||
}
|
||||
|
||||
// dlGate met un téléchargement en pause (repris d'AJEAN v0.18.1) : les
|
||||
// connexions en cours sont coupées (rien ne reste ouvert vers le CDN pendant la
|
||||
// pause), et chaque morceau attend la reprise puis repart de l'octet où il
|
||||
// s'était arrêté, par une requête de plage.
|
||||
type dlGate struct {
|
||||
mu sync.Mutex
|
||||
paused bool
|
||||
wake chan struct{} // fermé à la reprise
|
||||
attempt map[int]context.CancelFunc // requêtes en cours, coupées par la pause
|
||||
next int
|
||||
}
|
||||
|
||||
func newDlGate() *dlGate { return &dlGate{attempt: map[int]context.CancelFunc{}} }
|
||||
|
||||
func (g *dlGate) setPaused(p bool) {
|
||||
if g == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if p == g.paused {
|
||||
return
|
||||
}
|
||||
g.paused = p
|
||||
if p {
|
||||
g.wake = make(chan struct{})
|
||||
for _, c := range g.attempt {
|
||||
c()
|
||||
}
|
||||
return
|
||||
}
|
||||
close(g.wake)
|
||||
}
|
||||
|
||||
// wait bloque tant que le téléchargement est en pause. false = annulé entre-temps.
|
||||
func (g *dlGate) wait(ctx context.Context) bool {
|
||||
if g == nil {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
g.mu.Lock()
|
||||
paused, wake := g.paused, g.wake
|
||||
g.mu.Unlock()
|
||||
if !paused {
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
select {
|
||||
case <-wake:
|
||||
return ctx.Err() == nil
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (g *dlGate) isPaused() bool {
|
||||
if g == nil {
|
||||
return false
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
return g.paused
|
||||
}
|
||||
|
||||
// begin ouvre une requête que la pause pourra couper ; la fonction rendue la referme.
|
||||
func (g *dlGate) begin(ctx context.Context) (context.Context, func()) {
|
||||
if g == nil {
|
||||
return ctx, func() {}
|
||||
}
|
||||
actx, cancel := context.WithCancel(ctx)
|
||||
g.mu.Lock()
|
||||
id := g.next
|
||||
g.next++
|
||||
g.attempt[id] = cancel
|
||||
if g.paused {
|
||||
cancel() // pause tombée entre wait et begin
|
||||
}
|
||||
g.mu.Unlock()
|
||||
return actx, func() {
|
||||
g.mu.Lock()
|
||||
delete(g.attempt, id)
|
||||
g.mu.Unlock()
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -429,7 +515,7 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
st := &dlState{
|
||||
Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
|
||||
Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel, gate: newDlGate(),
|
||||
}
|
||||
dlDownloads[name] = st
|
||||
dlMu.Unlock()
|
||||
@@ -759,7 +845,9 @@ func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
|
||||
dt := now.Sub(lastAt).Seconds()
|
||||
dlMu.Lock()
|
||||
st.Done = cur
|
||||
if dt > 0 {
|
||||
if st.Paused {
|
||||
st.Speed = 0
|
||||
} else if dt > 0 {
|
||||
inst := int64(float64(cur-last) / dt)
|
||||
if st.Speed == 0 {
|
||||
st.Speed = inst
|
||||
@@ -827,7 +915,7 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
|
||||
if err := dlFetch(ctx, st.gate, f, dlURL, total, conns, done); err != nil {
|
||||
f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
@@ -845,9 +933,9 @@ func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64
|
||||
|
||||
// dlFetch writes the whole body into f, either as one stream or as `conns`
|
||||
// parallel byte ranges. done is incremented atomically as bytes land on disk.
|
||||
func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
func dlFetch(ctx context.Context, g *dlGate, f *os.File, dlURL string, total int64, conns int, done *int64) error {
|
||||
if conns <= 1 {
|
||||
return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
|
||||
return dlChunk(ctx, g, f, dlURL, 0, total-1, total <= 0, done)
|
||||
}
|
||||
size := total / int64(conns)
|
||||
var wg sync.WaitGroup
|
||||
@@ -861,7 +949,7 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
wg.Add(1)
|
||||
go func(i int, start, end int64) {
|
||||
defer wg.Done()
|
||||
errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
|
||||
errs[i] = dlChunk(ctx, g, f, dlURL, start, end, false, done)
|
||||
}(i, start, end)
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -876,11 +964,15 @@ func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns i
|
||||
// dlChunk downloads [start,end] into f at the right offset, retrying from where
|
||||
// it stopped if the connection drops mid-chunk. With whole=true it streams the
|
||||
// entire body sequentially (server without range support, unknown size).
|
||||
func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
func dlChunk(ctx context.Context, g *dlGate, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
|
||||
const attempts = 4
|
||||
pos := start
|
||||
var lastErr error
|
||||
for try := 0; try < attempts; try++ {
|
||||
// Une pause n'est pas un échec : on attend la reprise sans consommer d'essai.
|
||||
if !g.wait(ctx) {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err // cancelled: never retry
|
||||
}
|
||||
@@ -900,18 +992,26 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
} else if pos > start {
|
||||
rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
|
||||
}
|
||||
req, err := dlRequest(ctx, dlURL, rng)
|
||||
actx, release := g.begin(ctx)
|
||||
req, err := dlRequest(actx, dlURL, rng)
|
||||
if err != nil {
|
||||
release()
|
||||
return err
|
||||
}
|
||||
resp, err := dlClient.Do(req)
|
||||
if err != nil {
|
||||
release()
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try--
|
||||
continue
|
||||
}
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||
err := dlSourceError(resp, dlURL)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == 200 && pos > start {
|
||||
@@ -921,10 +1021,15 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
}
|
||||
n, cerr := dlCopy(f, resp.Body, pos, done)
|
||||
resp.Body.Close()
|
||||
release()
|
||||
pos += n
|
||||
if cerr == nil {
|
||||
return nil
|
||||
}
|
||||
if g.isPaused() && ctx.Err() == nil {
|
||||
try-- // coupé par la pause : la reprise repart de pos
|
||||
continue
|
||||
}
|
||||
lastErr = cerr
|
||||
}
|
||||
return lastErr
|
||||
@@ -981,6 +1086,35 @@ func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleModelDownloadPause met en pause ({"pause":true}) ou reprend
|
||||
// ({"pause":false}) un téléchargement en cours. Le fichier partiel est gardé.
|
||||
func handleModelDownloadPause(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Filename string `json:"filename"`
|
||||
Pause bool `json:"pause"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
name := filepath.Base(strings.TrimSpace(req.Filename))
|
||||
dlMu.Lock()
|
||||
st, ok := dlDownloads[name]
|
||||
if !ok || st.Finished {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement en cours pour " + name})
|
||||
return
|
||||
}
|
||||
st.Paused = req.Pause
|
||||
if req.Pause {
|
||||
st.Speed = 0
|
||||
}
|
||||
g := st.gate
|
||||
dlMu.Unlock()
|
||||
g.setPaused(req.Pause)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "paused": req.Pause})
|
||||
}
|
||||
|
||||
// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
|
||||
// startup. A download killed by a crash or a service restart can't be resumed
|
||||
// (its state lived in memory), so the partial file would otherwise sit there
|
||||
|
||||
@@ -231,3 +231,96 @@ func TestDLProbeSurfacesExplainedError(t *testing.T) {
|
||||
t.Errorf("message %q sans indication sur le jeton", err)
|
||||
}
|
||||
}
|
||||
|
||||
type slowWriter struct{ http.ResponseWriter }
|
||||
|
||||
func (s slowWriter) Write(b []byte) (int, error) {
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
return s.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
// Une pause coupe le flux, garde ce qui est écrit, et la reprise termine un
|
||||
// fichier identique à la source (repris d'AJEAN v0.18.1).
|
||||
func TestRunDownloadPauseResume(t *testing.T) {
|
||||
data := make([]byte, 48<<20)
|
||||
rand.New(rand.NewSource(2)).Read(data)
|
||||
t.Setenv("LOKI_DL_CONNS", "4")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(slowWriter{w}, r, "m.gguf", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "m.gguf")
|
||||
st := &dlState{Filename: "m.gguf", gate: newDlGate()}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
st.gate.setPaused(true)
|
||||
time.Sleep(1500 * time.Millisecond) // le compteur publié se fige
|
||||
dlMu.Lock()
|
||||
a := st.Done
|
||||
dlMu.Unlock()
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
dlMu.Lock()
|
||||
b, fin := st.Done, st.Finished
|
||||
dlMu.Unlock()
|
||||
if fin || a != b || a == 0 {
|
||||
t.Fatalf("pause inopérante : %d puis %d octets, terminé=%v", a, b, fin)
|
||||
}
|
||||
st.gate.setPaused(false)
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Fatal("pas terminé après la reprise")
|
||||
}
|
||||
if st.Err != "" {
|
||||
t.Fatalf("erreur après reprise : %s", st.Err)
|
||||
}
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("fichier différent de la source après reprise (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// La route : pause puis reprise d'un téléchargement connu, refus pour
|
||||
// l'inconnu et pour un téléchargement terminé.
|
||||
func TestHandleModelDownloadPause(t *testing.T) {
|
||||
dlMu.Lock()
|
||||
saved := dlDownloads
|
||||
dlDownloads = map[string]*dlState{
|
||||
"a.gguf": {Filename: "a.gguf", Speed: 9, gate: newDlGate()},
|
||||
"b.gguf": {Filename: "b.gguf", Finished: true},
|
||||
}
|
||||
dlMu.Unlock()
|
||||
t.Cleanup(func() { dlMu.Lock(); dlDownloads = saved; dlMu.Unlock() })
|
||||
post := func(body string) (int, string) {
|
||||
w := httptest.NewRecorder()
|
||||
handleModelDownloadPause(w, httptest.NewRequest("POST", "/api/models/download/pause", strings.NewReader(body)))
|
||||
return w.Code, strings.TrimSpace(w.Body.String())
|
||||
}
|
||||
if code, out := post(`{"filename":"../a.gguf","pause":true}`); code != 200 || out != `{"ok":true,"paused":true}` {
|
||||
t.Fatalf("pause : %d %s", code, out)
|
||||
}
|
||||
dlMu.Lock()
|
||||
st := dlDownloads["a.gguf"]
|
||||
paused, speed := st.Paused, st.Speed
|
||||
dlMu.Unlock()
|
||||
if !paused || speed != 0 || !st.gate.isPaused() {
|
||||
t.Errorf("état après pause : paused=%v speed=%d porte=%v", paused, speed, st.gate.isPaused())
|
||||
}
|
||||
if code, out := post(`{"filename":"a.gguf","pause":false}`); code != 200 || out != `{"ok":true,"paused":false}` || st.gate.isPaused() {
|
||||
t.Fatalf("reprise : %d %s", code, out)
|
||||
}
|
||||
for _, body := range []string{`{"filename":"b.gguf","pause":true}`, `{"filename":"x.gguf","pause":true}`} {
|
||||
if code, _ := post(body); code != 404 {
|
||||
t.Errorf("%s : %d, attendu 404", body, code)
|
||||
}
|
||||
}
|
||||
if code, _ := post(`[`); code != 400 {
|
||||
t.Errorf("corps illisible : %d", code)
|
||||
}
|
||||
}
|
||||
+167
-16
@@ -5,11 +5,13 @@ package loki
|
||||
// répartit les experts entre VRAM, RAM et disque pour faire tenir le modèle sur
|
||||
// une carte de joueur.
|
||||
//
|
||||
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.0 ») : le moteur est le paquet
|
||||
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.0 — sources de Strata et
|
||||
// Repris d'AJEAN (backend_moe.go, « AJEAN MoE 1.1 ») : le moteur est le paquet
|
||||
// FIGÉ publié par AJEAN sur sa release GitHub moe-v1.1 — sources de Strata et
|
||||
// binaire Linux CUDA 12 compilé et validé par l'auteur d'AJEAN (avec ses
|
||||
// modifications : experts de la carte d'appoint gardés hors RAM). Les deux
|
||||
// fichiers sont vérifiés par SHA-256 avant usage.
|
||||
// modifications : experts de la carte d'appoint gardés hors RAM et, depuis la
|
||||
// 1.1, calculés par elle à la lecture des prompts). Les deux fichiers sont
|
||||
// vérifiés par SHA-256 avant usage. Un modèle installé avec la 1.0 passe à la
|
||||
// 1.1 à son premier lancement (strataMigrate).
|
||||
//
|
||||
// Le choix du moteur se fait par PRESET : ENGINE=strata. Sans la clé (ou
|
||||
// ENGINE=llamacpp), rien ne change, llama-server reste le moteur.
|
||||
@@ -47,11 +49,12 @@ import (
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Paquet figé (release moe-v1.0 d'AJEAN). Le moteur est compilé en CUDA 12.8,
|
||||
// Paquet figé (release moe-v1.1 d'AJEAN). Le moteur est compilé en CUDA 12.8,
|
||||
// mode portable AVX2, sm_75/80/86/89/120 : RTX 20 à 50. Un moteur installé d'un
|
||||
// autre paquet est remplacé au lancement (strataEnsureEngine).
|
||||
// autre paquet est remplacé au lancement (strataEnsureEngine), une installation
|
||||
// d'une version précédente est reprise (strataMigrate).
|
||||
const (
|
||||
strataVersion = "1.0"
|
||||
strataVersion = "1.1"
|
||||
strataSrcAsset = "ajean-moe-src-" + strataVersion + ".tar.gz"
|
||||
strataEngineAsset = "ajean-moe-engine-" + strataVersion + "-linux-x64-cuda12.zip"
|
||||
// strataEngineLocal : le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
|
||||
@@ -69,10 +72,11 @@ var (
|
||||
strataDiskFree = diskFree
|
||||
)
|
||||
|
||||
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage.
|
||||
// strataAssetSHA : empreintes des fichiers de la release, vérifiées avant usage
|
||||
// (celles qu'AJEAN publie dans son backend_moe.go pour moe-v1.1).
|
||||
var strataAssetSHA = map[string]string{
|
||||
strataSrcAsset: "5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b",
|
||||
strataEngineAsset: "b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c",
|
||||
strataSrcAsset: "5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115",
|
||||
strataEngineAsset: "8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3",
|
||||
}
|
||||
|
||||
// Valeurs de la clé ENGINE d'un preset.
|
||||
@@ -132,8 +136,14 @@ const strataLowRAMHeadroomGB = 10
|
||||
|
||||
// strataDropHeadroomGB : RAM à laisser à côté des experts verrouillés quand la
|
||||
// carte d'aide garde les siens hors RAM (mesuré par AJEAN : 46 Go de RAM,
|
||||
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres).
|
||||
const strataDropHeadroomGB = 7
|
||||
// 34,7 Gio d'experts verrouillés, ~2,3 Go encore libres ; le moteur 1.1 prend
|
||||
// ~1 Go de plus que la 1.0).
|
||||
const strataDropHeadroomGB = 8
|
||||
|
||||
// strataDropMaxCtx : contexte maximal en mode hors RAM. Le cache KV au-delà de
|
||||
// la part en VRAM est verrouillé en RAM : à 167 936 jetons, le serveur de test
|
||||
// d'AJEAN tombait à 1,3 Go libres dès la première requête (moteur 1.1).
|
||||
const strataDropMaxCtx = 131072
|
||||
|
||||
// strataHelperExpertsGB : ce que la carte d'aide garde d'experts (sa VRAM moins
|
||||
// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go).
|
||||
@@ -210,7 +220,9 @@ func strataPackHasExpertsBin(args []string) bool {
|
||||
// Pure : la décision se teste en table.
|
||||
func strataResolveExperts(cfg map[string]string, args []string, availGB, helperGB float64) string {
|
||||
binReady := strataPackHasExpertsBin(args) && strataEngineInstalled() == strataEngineAsset
|
||||
dropReady := binReady && strataHelperOn(cfg) && cfg["STRATA_DROP"] != "0"
|
||||
// (experts de la carte d'aide hors RAM : propre au mode d'aide, jamais en
|
||||
// partage des couches, où la seconde carte ne garde pas d'experts)
|
||||
dropReady := binReady && strataHelperOn(cfg) && !strataLayerSplit(cfg) && cfg["STRATA_DROP"] != "0"
|
||||
arena := strataArenaGB(cfg)
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["STRATA_EXPERTS"])) {
|
||||
case strataExpertsDisk:
|
||||
@@ -494,6 +506,7 @@ type strataDetails struct {
|
||||
// pour les réglages : ce que la machine permet et l'état actuel
|
||||
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
|
||||
HelperOn bool `json:"helper_on"`
|
||||
Split bool `json:"split_layers"` // les deux cartes se partagent les couches
|
||||
Vision bool `json:"vision"`
|
||||
}
|
||||
|
||||
@@ -555,6 +568,7 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
|
||||
if strataHelperOn(pc) {
|
||||
d.HelperOn = true
|
||||
d.HelperGPU = gpuName(h)
|
||||
d.Split = strataLayerSplit(pc)
|
||||
}
|
||||
}
|
||||
d.Vision = pc["STRATA_VISION"] != "0"
|
||||
@@ -870,7 +884,8 @@ func strataFindConfig(quant string) (string, error) {
|
||||
//
|
||||
// - --prefill 16384 : +15 % de lecture sur les longs prompts (40K) ;
|
||||
// - --short-read 150 : les petits ajouts (< 150 jetons) sont lus comme en
|
||||
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides ;
|
||||
// génération, sans le coût fixe du chemin par blocs : ~40 % plus rapides
|
||||
// (48 avec une carte d'aide, voir strataBuildConfig) ;
|
||||
// - --prompt-cache-root 256 : point de reprise posé à la fin du prompt système
|
||||
// (défaut 2048) : une nouvelle conversation démarre en ~0,3 s au lieu de 4-5 s.
|
||||
var strataTuning = map[string]string{
|
||||
@@ -957,6 +972,16 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
|
||||
}
|
||||
helper := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
|
||||
hasHelper := strataHelperOn(cfg)
|
||||
if strataLayerSplit(cfg) {
|
||||
// Partage des couches : le serveur de Strata le fait de lui-même quand sa
|
||||
// clé « gpu » liste plusieurs cartes (--layer-split auto, réparti selon la
|
||||
// VRAM libre), numérotées comme nvidia-smi. Pas de chemin « aide » : la
|
||||
// réserve de VRAM de l'installeur et l'encodeur d'images restent en place.
|
||||
main, _ := strconv.Atoi(strings.TrimSpace(cfg["STRATA_MAIN_GPU"]))
|
||||
second, _ := strconv.Atoi(helper)
|
||||
out["gpu"] = []any{main, second}
|
||||
hasHelper = false
|
||||
}
|
||||
// Deuxième carte en aide : elle garde et CALCULE sa part des experts (le
|
||||
// chemin « helper » du moteur), la carte principale reste CUDA0. Les cartes
|
||||
// sont posées par CUDA_VISIBLE_DEVICES (serveStrata), PAS par la clé « gpu » :
|
||||
@@ -1002,6 +1027,10 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
|
||||
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
|
||||
env["STRATA_STAGER_THREADS"] = "8"
|
||||
env["STRATA_STAGER_RING"] = "32"
|
||||
// sa VRAM libre réservée : 640 Mio (défaut 1024) = ~230 experts de plus
|
||||
// hors RAM, l'encodeur d'images y tourne encore ; le moteur refuse moins
|
||||
// de 512
|
||||
env["STRATA_REMOTE_DROP_RESERVE_MIB"] = "640"
|
||||
case "arena":
|
||||
// experts.bin mappé tel quel (variante mesurée par AJEAN), sans part PCIe :
|
||||
// le mappage n'en donne pas l'adresse GPU.
|
||||
@@ -1017,8 +1046,22 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
env[k] = v
|
||||
}
|
||||
if hasHelper {
|
||||
// Lecture d'un prompt (moteur 1.1) : les experts que la carte d'aide garde
|
||||
// sont calculés par elle au lieu d'être recopiés vers la principale par son
|
||||
// lien PCIe (le chemin passe par la source « helper ») ; le chemin par blocs
|
||||
// coûte alors moins cher, les ajouts courts n'en sont exclus que sous 48
|
||||
// jetons. Mesuré par AJEAN : 100 nouveaux jetons 1,19 -> 0,85 s, 200 :
|
||||
// 1,96 -> 1,12 s.
|
||||
env["STRATA_PREFILL_HELPER_SOURCE"] = "1"
|
||||
args = strataSetArg(args, "--short-read", "48")
|
||||
}
|
||||
out["env"] = env
|
||||
if ctx := strings.TrimSpace(cfg["CTX"]); ctx != "" {
|
||||
// hors RAM, le cache KV verrouillé au-delà de strataDropMaxCtx ne tient plus
|
||||
if n, err := strconv.Atoi(ctx); err == nil && n > strataDropMaxCtx && env["STRATA_REMOTE_DROP"] == "1" {
|
||||
ctx = strconv.Itoa(strataDropMaxCtx)
|
||||
}
|
||||
args = strataSetArg(args, "--max-context", ctx)
|
||||
}
|
||||
// réglages imposés par le preset (sinon : ceux de l'installeur)
|
||||
@@ -1162,6 +1205,16 @@ func strataHelperOn(cfg map[string]string) bool {
|
||||
return h != "" && h != "-1" && cfg["STRATA_HELPER"] != "0"
|
||||
}
|
||||
|
||||
// strataLayerSplit : les deux cartes se partagent les COUCHES du modèle (clé de
|
||||
// preset STRATA_SPLIT=layers) au lieu que la seconde n'aide que pour une part
|
||||
// des experts. Mesuré par un utilisateur d'AJEAN sur deux RTX 3060 identiques
|
||||
// (#121 amont) : la carte d'aide ne travaillait qu'à ~15 %, le partage des
|
||||
// couches donnait +50 % en génération. Sur deux cartes très différentes, le
|
||||
// mode d'aide reste le défaut mesuré : le partage est un choix, jamais imposé.
|
||||
func strataLayerSplit(cfg map[string]string) bool {
|
||||
return strataHelperOn(cfg) && cfg["STRATA_SPLIT"] == "layers"
|
||||
}
|
||||
|
||||
// strataCudaDevices : ordre des cartes vu par le moteur, la principale en CUDA0.
|
||||
func strataCudaDevices(cfg map[string]string) string {
|
||||
main := strings.TrimSpace(cfg["STRATA_MAIN_GPU"])
|
||||
@@ -1184,17 +1237,99 @@ func strataPreflight(cfg map[string]string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return fmt.Errorf("configuration de Strata introuvable : %s — réinstalle le modèle", path)
|
||||
}
|
||||
py := filepath.Join(filepath.Dir(path), ".venv", "bin", "python")
|
||||
py := strataVenvPython(filepath.Dir(path))
|
||||
if _, err := os.Stat(py); err != nil {
|
||||
return fmt.Errorf("environnement Python de Strata absent (%s) : réinstalle le modèle", py)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// strataVenvPython : le Python de l'environnement de Strata, dans ses sources.
|
||||
func strataVenvPython(srcDir string) string { return filepath.Join(srcDir, ".venv", "bin", "python") }
|
||||
|
||||
// strataMigrate : un preset installé par une version précédente du paquet
|
||||
// (STRATA_CONFIG dans strataHome()/ajean-moe-<ancienne>) passe à la version
|
||||
// courante au lancement, sans réinstaller : les modèles et packs (data/)
|
||||
// servent tels quels. Les sources de la version courante sont posées (~340 Mo
|
||||
// avec le moteur), l'environnement Python de l'ancienne est repris par un lien
|
||||
// quand les dépendances n'ont pas changé, le moteur compilé est installé par
|
||||
// strataEnsureEngine, la config de l'installeur est recopiée avec ses chemins
|
||||
// réécrits, et les presets qui la citaient comme la config vivante pointent sur
|
||||
// la nouvelle. En cas d'échec rien n'est réécrit : l'ancienne version continue
|
||||
// de tourner. Repris d'AJEAN (moeMigrate, AJEAN MoE 1.1).
|
||||
func strataMigrate(cfg map[string]string) error {
|
||||
old := strings.TrimSpace(cfg["STRATA_CONFIG"])
|
||||
oldDir := filepath.Dir(old)
|
||||
newDir := strataSrcDir()
|
||||
if old == "" || oldDir == newDir || filepath.Dir(oldDir) != strataHome() || !strings.HasPrefix(filepath.Base(oldDir), "ajean-moe-") {
|
||||
return nil
|
||||
}
|
||||
newPath := filepath.Join(newDir, filepath.Base(old))
|
||||
if !isFile(newPath) {
|
||||
raw, err := os.ReadFile(old)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !isFile(filepath.Join(newDir, "setup.py")) {
|
||||
src, err := strataFetch(strataSrcAsset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := extractArchive(src, strataHome()); err != nil {
|
||||
return fmt.Errorf("décompression : %w", err)
|
||||
}
|
||||
}
|
||||
venv := filepath.Join(newDir, ".venv")
|
||||
if _, err := os.Lstat(venv); err != nil {
|
||||
a, _ := os.ReadFile(filepath.Join(oldDir, "requirements.txt"))
|
||||
b, _ := os.ReadFile(filepath.Join(newDir, "requirements.txt"))
|
||||
if len(a) == 0 || string(a) != string(b) || !isFile(strataVenvPython(oldDir)) {
|
||||
return fmt.Errorf("dépendances Python changées : réinstalle le modèle depuis Paramètres → Moteur → Strata")
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(oldDir, ".venv"), venv); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(newDir, "engine-cuda12"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := strataEnsureEngine(); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.ReplaceAll(string(raw), oldDir+string(os.PathSeparator), newDir+string(os.PathSeparator))
|
||||
if err := os.WriteFile(newPath+".new", []byte(body), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(newPath+".new", newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// les presets qui citent l'ancienne config, puis la config vivante (les deux
|
||||
// changent ensemble : l'empreinte du preset actif reste celle de la config)
|
||||
if list, _ := filepath.Glob(filepath.Join(presetsDir(), "*.env")); len(list) > 0 {
|
||||
for _, p := range list {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil || !strings.Contains(string(b), "STRATA_CONFIG="+old+"\n") {
|
||||
continue
|
||||
}
|
||||
_ = os.WriteFile(p, []byte(strings.ReplaceAll(string(b), "STRATA_CONFIG="+old+"\n", "STRATA_CONFIG="+newPath+"\n")), 0o644)
|
||||
}
|
||||
}
|
||||
if err := SetConfigKey("STRATA_CONFIG", newPath); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg["STRATA_CONFIG"] = newPath
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] paquet AJEAN MoE %s : installation reprise depuis %s\n", strataVersion, filepath.Base(oldDir))
|
||||
return nil
|
||||
}
|
||||
|
||||
// serveStrata : la branche Strata de `loki serve`. Écrit la config finale à
|
||||
// côté de celle de l'installeur puis remplace le process par le serveur de
|
||||
// Strata (comme pour llama-server : le superviseur suit directement le moteur).
|
||||
func serveStrata(cfg map[string]string) error {
|
||||
if err := strataMigrate(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] passage au paquet AJEAN MoE %s impossible (%v) : la version installée reste utilisée\n", strataVersion, err)
|
||||
}
|
||||
if err := strataPreflight(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1223,7 +1358,7 @@ func serveStrata(cfg map[string]string) error {
|
||||
return err
|
||||
}
|
||||
srcDir := filepath.Dir(path)
|
||||
py := filepath.Join(srcDir, ".venv", "bin", "python")
|
||||
py := strataVenvPython(srcDir)
|
||||
tag := strataConfigTag(path)
|
||||
final["log"] = filepath.Join(strataHome(), "strata-"+tag+".log")
|
||||
out := filepath.Join(strataHome(), "loki-strata-"+tag+".json")
|
||||
@@ -1326,6 +1461,9 @@ type strataSettingsReq struct {
|
||||
Vision bool `json:"vision"`
|
||||
Helper bool `json:"helper"`
|
||||
Experts string `json:"experts"` // auto, ram ou disk ; vide = inchangé
|
||||
// Split : rôle de la seconde carte, « helper » (aide aux experts) ou
|
||||
// « layers » (partage des couches). Vide = inchangé (anciens clients).
|
||||
Split string `json:"split"`
|
||||
}
|
||||
|
||||
// strataApplySettings réécrit les clés du preset (pure, testable).
|
||||
@@ -1340,6 +1478,8 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
return "", fmt.Errorf("MTP hors limites : %d", r.Spec)
|
||||
case r.Experts != "" && r.Experts != strataExpertsAuto && r.Experts != strataExpertsRAM && r.Experts != strataExpertsDisk:
|
||||
return "", fmt.Errorf("placement des experts inconnu : %s", r.Experts)
|
||||
case r.Split != "" && r.Split != "helper" && r.Split != "layers":
|
||||
return "", fmt.Errorf("répartition inconnue : %s", r.Split)
|
||||
}
|
||||
set := map[string]string{
|
||||
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
|
||||
@@ -1350,6 +1490,14 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
set["STRATA_EXPERTS"] = r.Experts
|
||||
keys = append(keys, "STRATA_EXPERTS")
|
||||
}
|
||||
if r.Split != "" {
|
||||
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
|
||||
set["STRATA_SPLIT"] = ""
|
||||
if r.Split == "layers" {
|
||||
set["STRATA_SPLIT"] = "layers"
|
||||
}
|
||||
keys = append(keys, "STRATA_SPLIT")
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
|
||||
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
|
||||
@@ -1359,6 +1507,9 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if set[k] == "" && k == "STRATA_SPLIT" {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, k+"="+set[k])
|
||||
}
|
||||
return strings.Join(lines, "\n") + "\n", nil
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Moteur 1.1 : avec une carte d'aide, la lecture d'un prompt passe par elle
|
||||
// (source « helper ») et le chemin direct ne garde que les ajouts sous 48 jetons.
|
||||
func TestStrataBuildConfigHelperPrompt(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p"}}
|
||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
|
||||
if argAfter(strataArgs(t, out), "--short-read") != "48" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != "1" {
|
||||
t.Errorf("carte d'aide : lecture courte et source d'aide attendues : %v %v", strataArgs(t, out), out["env"])
|
||||
}
|
||||
// carte d'aide coupée ou absente : réglages d'avant
|
||||
for _, cfg := range []map[string]string{{}, {"STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}} {
|
||||
out, _ = strataBuildConfig(base, cfg, "", "ram")
|
||||
if argAfter(strataArgs(t, out), "--short-read") != "150" || out["env"].(map[string]any)["STRATA_PREFILL_HELPER_SOURCE"] != nil {
|
||||
t.Errorf("une carte (%v) : %v %v", cfg, strataArgs(t, out), out["env"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode hors RAM : réserve de VRAM de la carte d'aide abaissée, contexte plafonné.
|
||||
func TestStrataBuildConfigDropReserveAndCtx(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p"}, "env": map[string]any{}}
|
||||
cfg := map[string]string{"STRATA_HELPER_GPU": "0", "CTX": "167936"}
|
||||
out, _ := strataBuildConfig(base, cfg, "", "drop")
|
||||
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != "640" {
|
||||
t.Errorf("réserve de la carte d'aide : %v", out["env"])
|
||||
}
|
||||
if got := argAfter(strataArgs(t, out), "--max-context"); got != "131072" {
|
||||
t.Errorf("contexte hors RAM non plafonné : %s", got)
|
||||
}
|
||||
// hors du mode hors RAM : le contexte demandé
|
||||
for _, mode := range []string{"ram", "arena", "disk"} {
|
||||
out, _ = strataBuildConfig(base, cfg, "", mode)
|
||||
if got := argAfter(strataArgs(t, out), "--max-context"); got != "167936" {
|
||||
t.Errorf("%s : contexte plafonné sans mode hors RAM : %s", mode, got)
|
||||
}
|
||||
if out["env"].(map[string]any)["STRATA_REMOTE_DROP_RESERVE_MIB"] != nil {
|
||||
t.Errorf("%s : réserve du mode hors RAM posée : %v", mode, out["env"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle installé avec le paquet 1.0 passe au paquet courant à son premier
|
||||
// lancement, sans réinstallation.
|
||||
func TestStrataMigrate(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("lien symbolique : Strata ne tourne que sous Linux")
|
||||
}
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
|
||||
newDir := strataSrcDir()
|
||||
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), filepath.Join(newDir, "engine-cuda12"), presetsDir()} {
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
}
|
||||
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
|
||||
for _, d := range []string{oldDir, newDir} {
|
||||
_ = os.WriteFile(filepath.Join(d, "requirements.txt"), []byte("numpy\n"), 0o644)
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
old := filepath.Join(oldDir, "strata-swift-iq3_xxs.json")
|
||||
_ = os.WriteFile(old, []byte(`{"exe": "`+filepath.Join(oldDir, "engine-cuda12", "strata")+`", "args": []}`), 0o644)
|
||||
preset := filepath.Join(presetsDir(), "P.env")
|
||||
_ = os.WriteFile(preset, []byte("ENGINE=strata\nSTRATA_CONFIG="+old+"\nCTX=1\n"), 0o644)
|
||||
other := filepath.Join(presetsDir(), "L.env")
|
||||
_ = os.WriteFile(other, []byte("MODEL=/m.gguf\n"), 0o644)
|
||||
if err := SetConfigKey("STRATA_CONFIG", old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := map[string]string{"ENGINE": "strata", "STRATA_CONFIG": old}
|
||||
if err := strataMigrate(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(newDir, "strata-swift-iq3_xxs.json")
|
||||
if cfg["STRATA_CONFIG"] != want || ReadConfig()["STRATA_CONFIG"] != want {
|
||||
t.Fatalf("config vivante : %q", cfg["STRATA_CONFIG"])
|
||||
}
|
||||
b, _ := os.ReadFile(want)
|
||||
if strings.Contains(string(b), oldDir) || !strings.Contains(string(b), filepath.Join(newDir, "engine-cuda12")) {
|
||||
t.Errorf("chemins non réécrits : %s", b)
|
||||
}
|
||||
if p, _ := os.ReadFile(preset); !strings.Contains(string(p), "STRATA_CONFIG="+want+"\n") {
|
||||
t.Errorf("preset non réécrit : %s", p)
|
||||
}
|
||||
if p, _ := os.ReadFile(other); string(p) != "MODEL=/m.gguf\n" {
|
||||
t.Errorf("preset d'un autre moteur touché : %s", p)
|
||||
}
|
||||
if !isFile(strataVenvPython(newDir)) {
|
||||
t.Error("environnement Python non repris")
|
||||
}
|
||||
// déjà à jour : rien à faire
|
||||
if err := strataMigrate(cfg); err != nil || cfg["STRATA_CONFIG"] != want {
|
||||
t.Errorf("seconde passe : %v %q", err, cfg["STRATA_CONFIG"])
|
||||
}
|
||||
}
|
||||
|
||||
// Dépendances Python changées : rien n'est réécrit, l'ancienne version tourne.
|
||||
func TestStrataMigrateRequirementsChanged(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
oldDir := filepath.Join(strataHome(), "ajean-moe-1.0")
|
||||
newDir := strataSrcDir()
|
||||
for _, d := range []string{filepath.Join(oldDir, ".venv", "bin"), newDir} {
|
||||
_ = os.MkdirAll(d, 0o755)
|
||||
}
|
||||
_ = os.WriteFile(strataVenvPython(oldDir), []byte("#!/bin/sh\n"), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(oldDir, "requirements.txt"), []byte("numpy\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(newDir, "requirements.txt"), []byte("numpy\ntorch\n"), 0o644)
|
||||
_ = os.WriteFile(filepath.Join(newDir, "setup.py"), []byte(""), 0o644)
|
||||
old := filepath.Join(oldDir, "strata-iq3_xxs.json")
|
||||
_ = os.WriteFile(old, []byte(`{"args": []}`), 0o644)
|
||||
cfg := map[string]string{"STRATA_CONFIG": old}
|
||||
if err := strataMigrate(cfg); err == nil || !strings.Contains(err.Error(), "dépendances") {
|
||||
t.Fatalf("erreur de dépendances attendue : %v", err)
|
||||
}
|
||||
if cfg["STRATA_CONFIG"] != old || isFile(filepath.Join(newDir, "strata-iq3_xxs.json")) {
|
||||
t.Errorf("config réécrite malgré l'échec : %q", cfg["STRATA_CONFIG"])
|
||||
}
|
||||
// un preset hors de strata/ (copié à la main) n'est pas touché
|
||||
cfg = map[string]string{"STRATA_CONFIG": filepath.Join(home, "ailleurs", "ajean-moe-1.0", "strata-x.json")}
|
||||
if err := strataMigrate(cfg); err != nil {
|
||||
t.Errorf("config hors de strata/ : %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Partage des couches (#121 amont) : la clé « gpu » liste les deux cartes (le
|
||||
// serveur de Strata en déduit --layer-split), et rien du mode d'aide ne subsiste.
|
||||
func TestStrataBuildConfigLayerSplit(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"args": []any{"--pack", "/p", "--vram-reserve-mib", "900"},
|
||||
"vision": map[string]any{"exe": "/v"},
|
||||
}
|
||||
cfg := map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_SPLIT": "layers"}
|
||||
out, err := strataBuildConfig(base, cfg, "", "drop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, ok := out["gpu"].([]any)
|
||||
if !ok || len(g) != 2 || g[0] != 1 || g[1] != 0 {
|
||||
t.Fatalf("gpu = %v, attendu [1 0] (principale d'abord, numéros nvidia-smi)", out["gpu"])
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
|
||||
t.Errorf("options du mode d'aide en partage des couches : %v", a)
|
||||
}
|
||||
if argAfter(a, "--vram-reserve-mib") != "900" {
|
||||
t.Errorf("la réserve de VRAM de l'installeur doit rester : %v", a)
|
||||
}
|
||||
if argAfter(a, "--short-read") != "150" {
|
||||
t.Errorf("lecture des prompts par la carte d'aide en partage des couches : %v", a)
|
||||
}
|
||||
env := out["env"].(map[string]any)
|
||||
if env["STRATA_REMOTE_DROP"] != nil || env["STRATA_PREFILL_HELPER_SOURCE"] != nil {
|
||||
t.Errorf("experts hors RAM ou source d'aide (mode d'aide) en partage des couches : %v", env)
|
||||
}
|
||||
if _, has := out["vision"].(map[string]any)["cuda_device"]; has {
|
||||
t.Errorf("encodeur d'images déplacé sur la seconde carte en partage des couches")
|
||||
}
|
||||
|
||||
// Sans la clé, rien ne change : le mode d'aide reste le défaut.
|
||||
delete(cfg, "STRATA_SPLIT")
|
||||
out, _ = strataBuildConfig(base, cfg, "", "drop")
|
||||
if _, has := out["gpu"]; has {
|
||||
t.Errorf("clé gpu posée en mode d'aide : %v", out["gpu"])
|
||||
}
|
||||
if !strataHasArg(strataArgs(t, out), "--remote-expert-opt") {
|
||||
t.Errorf("mode d'aide perdu sans STRATA_SPLIT")
|
||||
}
|
||||
// Seconde carte coupée : pas de partage même si la clé traîne.
|
||||
cfg["STRATA_SPLIT"], cfg["STRATA_HELPER"] = "layers", "0"
|
||||
if strataLayerSplit(cfg) {
|
||||
t.Errorf("partage des couches avec la seconde carte coupée")
|
||||
}
|
||||
}
|
||||
|
||||
// En partage des couches, la seconde carte ne garde pas d'experts : le mode
|
||||
// hors RAM (qui compte sur elle pour en loger une part) n'est jamais choisi.
|
||||
func TestStrataResolveExpertsLayerSplit(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
pack := filepath.Join(home, "pack")
|
||||
_ = os.MkdirAll(pack, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
|
||||
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
args := []string{"--pack", pack}
|
||||
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
|
||||
t.Fatalf("mode d'aide : %s, attendu drop", got)
|
||||
}
|
||||
cfg["STRATA_SPLIT"] = "layers"
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
|
||||
t.Errorf("partage des couches : %s, attendu arena", got)
|
||||
}
|
||||
cfg["STRATA_EXPERTS"] = "ram"
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "ram" {
|
||||
t.Errorf("partage des couches, experts en RAM : %s, attendu ram", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettingsSplit(t *testing.T) {
|
||||
in := "ENGINE=strata\nSTRATA_HELPER_GPU=0\n"
|
||||
req := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Vision: true, Helper: true, Split: "layers"}
|
||||
out, err := strataApplySettings(in, req)
|
||||
if err != nil || !strings.Contains(out, "STRATA_SPLIT=layers\n") {
|
||||
t.Fatalf("STRATA_SPLIT=layers attendu : %q (%v)", out, err)
|
||||
}
|
||||
req.Split = "helper"
|
||||
if out, _ = strataApplySettings(out, req); strings.Contains(out, "STRATA_SPLIT") {
|
||||
t.Errorf("retour au mode d'aide : la clé doit disparaître : %q", out)
|
||||
}
|
||||
// un ancien client n'envoie pas « split » : la clé est conservée
|
||||
req.Split = ""
|
||||
out, _ = strataApplySettings("STRATA_SPLIT=layers\n", req)
|
||||
if !strings.Contains(out, "STRATA_SPLIT=layers") {
|
||||
t.Errorf("clé effacée par un client qui ne la connaît pas : %q", out)
|
||||
}
|
||||
req.Split = "nimporte"
|
||||
if _, err := strataApplySettings(in, req); err == nil {
|
||||
t.Errorf("répartition inconnue acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDetailsForLayerSplit(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
base := filepath.Join(home, "base.json")
|
||||
_ = os.WriteFile(base, []byte(`{"args":["--pack","/p"],"vision":{"exe":"v"}}`), 0o644)
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_SPLIT=layers\n"
|
||||
_ = os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644)
|
||||
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}, {Index: 1, Name: "NVIDIA GeForce RTX 3060", VRAMGB: 12}}}
|
||||
d := strataDetailsFor("X", env)
|
||||
if d == nil {
|
||||
t.Fatal("pas de détails")
|
||||
}
|
||||
if !d.Split || !d.HelperOn || d.HelperGPU == "" || d.VisionGPU != d.MainGPU || d.ShortRead != "150" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func TestStrataDetailsFor(t *testing.T) {
|
||||
}
|
||||
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
|
||||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
|
||||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
|
||||
d.ShortRead != "48" || d.CacheRoot != "256" || d.Prefill != "16384" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -301,7 +301,7 @@ func TestFacadeRelaieLesModeles(t *testing.T) {
|
||||
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
|
||||
|
||||
routes := []string{"/api/models/download", "/api/models/download/probe",
|
||||
"/api/models/download/status", "/api/models/download/cancel", "/api/models",
|
||||
"/api/models/download/status", "/api/models/download/cancel", "/api/models/download/pause", "/api/models",
|
||||
"/api/models/delete", "/api/models/dirs", "/api/hf/search", "/api/hf/files", "/api/hf/token"}
|
||||
for _, p := range routes {
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -49,4 +49,5 @@ var facadeModelRoutes = map[string]bool{
|
||||
"/api/models/download/probe": true,
|
||||
"/api/models/download/status": true,
|
||||
"/api/models/download/cancel": true,
|
||||
"/api/models/download/pause": true,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -330,6 +330,7 @@ async function loadCfg(){
|
||||
rows.push(row('CTX', d.ctx));
|
||||
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
|
||||
rows.push(row('MTP', d.spec));
|
||||
if(d.helper_gpu) rows.push(row('GPU 2', d.split_layers ? 'couches' : 'aide', d.helper_gpu));
|
||||
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
|
||||
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
|
||||
}else if(c.CTX) rows.push(row('CTX', c.CTX));
|
||||
|
||||
@@ -1501,6 +1501,7 @@ function dlEls(){
|
||||
prog: document.getElementById('m-hf-progress'),
|
||||
bar: document.getElementById('m-hf-bar'),
|
||||
cancel: document.getElementById('m-hf-cancel'),
|
||||
pause: document.getElementById('m-hf-pause'),
|
||||
};
|
||||
}
|
||||
// Arrête le poll local sans toucher au téléchargement serveur.
|
||||
@@ -1510,7 +1511,7 @@ function resetDlUI(){
|
||||
const e = dlEls();
|
||||
stopDlPoll(); dlName='';
|
||||
e.prog.style.display='none'; e.bar.style.display='none';
|
||||
e.cancel.style.display='none'; e.btn.disabled=false;
|
||||
e.cancel.style.display='none'; e.pause.style.display='none'; e.btn.disabled=false;
|
||||
}
|
||||
// Remplit le sélecteur de destination avec les dossiers de modèles connus.
|
||||
// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment
|
||||
@@ -1627,6 +1628,22 @@ async function cancelDownload(){
|
||||
await jpost('/api/models/download/cancel', {filename: dlName});
|
||||
// L'état « annulé » est confirmé par le prochain tick de poll.
|
||||
}
|
||||
// Met en pause ou reprend : le fichier partiel est gardé, la reprise repart
|
||||
// de là où le transfert s'était arrêté (repris d'AJEAN v0.18.1).
|
||||
let dlPaused = false;
|
||||
async function togglePauseDownload(){
|
||||
if(!dlName) return;
|
||||
const e = dlEls();
|
||||
e.pause.disabled = true;
|
||||
try{
|
||||
const r = await jpost('/api/models/download/pause', {filename: dlName, pause: !dlPaused});
|
||||
if(r.ok) setDlPaused(r.paused);
|
||||
}finally{ e.pause.disabled = false; }
|
||||
}
|
||||
function setDlPaused(p){
|
||||
dlPaused = !!p;
|
||||
dlEls().pause.textContent = dlPaused ? 'Reprendre' : 'Mettre en pause';
|
||||
}
|
||||
// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte.
|
||||
function watchDownload(fname){
|
||||
const e = dlEls();
|
||||
@@ -1637,7 +1654,10 @@ function watchDownload(fname){
|
||||
e.bar.style.display = 'block';
|
||||
e.cancel.style.display = 'inline';
|
||||
e.cancel.disabled = false;
|
||||
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; };
|
||||
e.pause.style.display = 'inline';
|
||||
e.pause.disabled = false;
|
||||
setDlPaused(false);
|
||||
const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; e.pause.style.display='none'; };
|
||||
const tick = async ()=>{
|
||||
const list = await jget('/api/models/download/status');
|
||||
const st = (list||[]).find(d=>d.filename===fname);
|
||||
@@ -1661,6 +1681,7 @@ function watchDownload(fname){
|
||||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||||
return;
|
||||
}
|
||||
if(!!st.paused !== dlPaused) setDlPaused(st.paused);
|
||||
const pct = st.total>0 ? st.done*100/st.total : 0;
|
||||
if(st.total>0){
|
||||
e.bar.className = 'pe-bar';
|
||||
@@ -1670,7 +1691,8 @@ function watchDownload(fname){
|
||||
}
|
||||
const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : '';
|
||||
let extra = '';
|
||||
if(st.speed>0){
|
||||
if(st.paused) extra += ' — en pause';
|
||||
else if(st.speed>0){
|
||||
extra += ' — '+fmtSize(st.speed)+'/s';
|
||||
if(st.total>0){
|
||||
const eta = Math.max(0, Math.round((st.total-st.done)/st.speed));
|
||||
|
||||
@@ -128,7 +128,11 @@ function strataRenderSettings(fit){
|
||||
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
|
||||
['Lire les images', sw('strata-s-vision', d.vision)],
|
||||
];
|
||||
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
|
||||
// Seconde carte : aide aux experts (défaut mesuré) ou partage des couches,
|
||||
// souvent plus rapide avec deux cartes identiques (#121 d'AJEAN).
|
||||
if(d.has_helper) rows.push(['Seconde carte', sel('strata-s-helper',
|
||||
[['off', 'non utilisée'], ['helper', 'aide aux experts'], ['layers', 'partage des couches']],
|
||||
!d.helper_on ? 'off' : d.split_layers ? 'layers' : 'helper')]);
|
||||
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
|
||||
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
|
||||
strataCtxLbl();
|
||||
@@ -148,8 +152,11 @@ async function strataSaveSettings(){
|
||||
if(!fit || !fit.preset) return;
|
||||
const v = id => document.getElementById(id);
|
||||
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
|
||||
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
|
||||
vision: v('strata-s-vision').checked,
|
||||
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
|
||||
const second = v('strata-s-helper') ? v('strata-s-helper').value : 'off';
|
||||
body.helper = second !== 'off';
|
||||
if(body.helper) body.split = second;
|
||||
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
|
||||
const r = await jpost('/api/strata/settings', body);
|
||||
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
|
||||
@@ -168,14 +175,16 @@ function strataRenderDetails(fit){
|
||||
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
|
||||
const rows = [
|
||||
['Carte principale', d.main_gpu],
|
||||
["Carte d'appoint", d.helper_gpu || 'aucune'],
|
||||
['Seconde carte', d.helper_gpu ? d.helper_gpu + (d.split_layers ? ' · partage des couches' : ' · aide aux experts') : 'aucune'],
|
||||
['Lecture des images', d.vision_gpu || 'aucune'],
|
||||
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
|
||||
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
|
||||
['Prédiction (MTP)', d.spec+' jetons'],
|
||||
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
|
||||
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
|
||||
// paquet 1.1 : la carte d'appoint calcule ses experts à la lecture d'un prompt
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'
|
||||
+ (d.helper_gpu && !d.split_layers ? ", experts de la carte d'appoint calculés par elle" : '')],
|
||||
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
|
||||
['Version du moteur', 'paquet AJEAN MoE '+d.version],
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -345,7 +345,7 @@ impl Strata {
|
||||
"mmap": mode == "disk" || mode == "arena", "drop": mode == "drop",
|
||||
"experts": experts, "mode_label": experts_label(&mode), "mode": mode,
|
||||
"main_gpu": main_gpu,
|
||||
"has_helper": false, "helper_on": false,
|
||||
"has_helper": false, "helper_on": false, "split_layers": false,
|
||||
});
|
||||
let kvr = val("--kv-resident");
|
||||
if !kvr.is_empty() {
|
||||
@@ -356,6 +356,7 @@ impl Strata {
|
||||
d["has_helper"] = json!(true);
|
||||
if helper_on(&pc) {
|
||||
d["helper_on"] = json!(true);
|
||||
d["split_layers"] = json!(layer_split(&pc));
|
||||
let hg = gpu_name(h);
|
||||
if !hg.is_empty() {
|
||||
d["helper_gpu"] = json!(hg);
|
||||
@@ -825,6 +826,7 @@ pub async fn serve(
|
||||
("vision", Kind::Bool),
|
||||
("helper", Kind::Bool),
|
||||
("experts", Kind::Str),
|
||||
("split", Kind::Str),
|
||||
],
|
||||
);
|
||||
if let Some(e) = d.err.clone() {
|
||||
@@ -838,6 +840,7 @@ pub async fn serve(
|
||||
vision: d.bool("vision"),
|
||||
helper: d.bool("helper"),
|
||||
experts: d.str("experts"),
|
||||
split: d.str("split"),
|
||||
};
|
||||
if r.preset.contains(['/', '\\']) || r.preset.is_empty() {
|
||||
return fail(StatusCode::BAD_REQUEST, "preset invalide");
|
||||
|
||||
@@ -19,24 +19,27 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// Paquet figé (release moe-v1.0 d'AJEAN).
|
||||
pub const VERSION: &str = "1.0";
|
||||
pub const RELEASE_BASE: &str = "https://github.com/nathaninline/ajean/releases/download/moe-v1.0/";
|
||||
pub const SRC_ASSET: &str = "ajean-moe-src-1.0.tar.gz";
|
||||
pub const ENGINE_ASSET: &str = "ajean-moe-engine-1.0-linux-x64-cuda12.zip";
|
||||
/// Paquet figé (release moe-v1.1 d'AJEAN : experts de la carte d'aide
|
||||
/// calculés par elle à la lecture des prompts). Un modèle installé avec la
|
||||
/// 1.0 passe à la 1.1 à son premier lancement (`strataMigrate`, côté Go :
|
||||
/// `loki serve` d'un preset Strata reste au Go).
|
||||
pub const VERSION: &str = "1.1";
|
||||
pub const RELEASE_BASE: &str = "https://github.com/nathaninline/ajean/releases/download/moe-v1.1/";
|
||||
pub const SRC_ASSET: &str = "ajean-moe-src-1.1.tar.gz";
|
||||
pub const ENGINE_ASSET: &str = "ajean-moe-engine-1.1-linux-x64-cuda12.zip";
|
||||
/// Le nom sous lequel l'installeur attend le moteur (CUDA12_ASSET).
|
||||
pub const ENGINE_LOCAL: &str = "strata-linux-x64-cuda12.zip";
|
||||
|
||||
/// `strataAssetSHA`.
|
||||
/// `strataAssetSHA` (celles qu'AJEAN publie pour moe-v1.1).
|
||||
pub fn default_asset_sha() -> HashMap<String, String> {
|
||||
HashMap::from([
|
||||
(
|
||||
SRC_ASSET.to_string(),
|
||||
"5747f69426973d957e4f9894a8e3333c6ef0d6ac8565e26ddb8d36f52211cb3b".to_string(),
|
||||
"5a72a90f2182ff0ccab2bdbd7cff15598a21ad94501aa19de2b1b18e2dbc8115".to_string(),
|
||||
),
|
||||
(
|
||||
ENGINE_ASSET.to_string(),
|
||||
"b5851bc138ab32ca3ffefc16bf91c8f557e4d503503473a22f2b6c9e9b8fd34c".to_string(),
|
||||
"8c717206ee90120f967445169dc39bb11beffdad3d592c75ae08f75bec72f0d3".to_string(),
|
||||
),
|
||||
])
|
||||
}
|
||||
@@ -137,7 +140,11 @@ pub fn quant(id: &str) -> Option<Quant> {
|
||||
}
|
||||
|
||||
pub const LOW_RAM_HEADROOM_GB: f64 = 10.0;
|
||||
pub const DROP_HEADROOM_GB: f64 = 7.0;
|
||||
/// `strataDropHeadroomGB` (le moteur 1.1 prend ~1 Go de plus que la 1.0).
|
||||
pub const DROP_HEADROOM_GB: f64 = 8.0;
|
||||
/// `strataDropMaxCtx` : contexte maximal en mode hors RAM (le cache KV
|
||||
/// au-delà de la part en VRAM est verrouillé en RAM).
|
||||
pub const DROP_MAX_CTX: i64 = 131072;
|
||||
|
||||
/// `strataHelperExpertsGB`.
|
||||
pub fn helper_experts_gb(vram_gb: f64) -> f64 {
|
||||
@@ -280,6 +287,13 @@ pub fn helper_on(cfg: &Cfg) -> bool {
|
||||
!h.is_empty() && h != "-1" && cfg_get(cfg, "STRATA_HELPER") != "0"
|
||||
}
|
||||
|
||||
/// `strataLayerSplit` : les deux cartes se partagent les couches
|
||||
/// (`STRATA_SPLIT=layers`) au lieu que la seconde n'aide que pour une part
|
||||
/// des experts. Un choix, jamais imposé : le mode d'aide reste le défaut.
|
||||
pub fn layer_split(cfg: &Cfg) -> bool {
|
||||
helper_on(cfg) && cfg_get(cfg, "STRATA_SPLIT") == "layers"
|
||||
}
|
||||
|
||||
/// `strataResolveExperts` : le mode lancé (drop, ram, arena, disk), d'après
|
||||
/// le choix du preset et la RAM disponible.
|
||||
pub fn resolve_experts(
|
||||
@@ -290,7 +304,9 @@ pub fn resolve_experts(
|
||||
helper_gb: f64,
|
||||
) -> &'static str {
|
||||
let bin_ready = pack_has_experts_bin(args) && paths.engine_installed() == ENGINE_ASSET;
|
||||
let drop_ready = bin_ready && helper_on(cfg) && cfg_get(cfg, "STRATA_DROP") != "0";
|
||||
// (jamais en partage des couches : la seconde carte n'y garde pas d'experts)
|
||||
let drop_ready =
|
||||
bin_ready && helper_on(cfg) && !layer_split(cfg) && cfg_get(cfg, "STRATA_DROP") != "0";
|
||||
let arena = arena_gb(cfg);
|
||||
match cfg_get(cfg, "STRATA_EXPERTS")
|
||||
.trim()
|
||||
@@ -404,7 +420,18 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
|
||||
args = set_arg(args, flag, v);
|
||||
}
|
||||
let helper = cfg_get(cfg, "STRATA_HELPER_GPU").trim();
|
||||
let has_helper = helper_on(cfg);
|
||||
let mut has_helper = helper_on(cfg);
|
||||
if layer_split(cfg) {
|
||||
// Partage des couches : la clé « gpu » liste les deux cartes (numéros
|
||||
// nvidia-smi), le serveur de Strata en déduit --layer-split.
|
||||
let main = cfg_get(cfg, "STRATA_MAIN_GPU")
|
||||
.trim()
|
||||
.parse::<i64>()
|
||||
.unwrap_or(0);
|
||||
let second = helper.parse::<i64>().unwrap_or(0);
|
||||
out.insert("gpu".into(), json!([main, second]));
|
||||
has_helper = false;
|
||||
}
|
||||
if has_helper {
|
||||
args = set_arg(args, "--expert-cache-device1", "auto");
|
||||
if !has_arg(&args, "--remote-expert-opt") {
|
||||
@@ -431,6 +458,8 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
|
||||
env.insert("STRATA_REMOTE_DROP".into(), json!("1"));
|
||||
env.insert("STRATA_STAGER_THREADS".into(), json!("8"));
|
||||
env.insert("STRATA_STAGER_RING".into(), json!("32"));
|
||||
// VRAM libre réservée de la carte d'aide : 640 Mio (défaut 1024)
|
||||
env.insert("STRATA_REMOTE_DROP_RESERVE_MIB".into(), json!("640"));
|
||||
}
|
||||
"arena" => {
|
||||
args = set_arg(args, "--pcie-frac", "0");
|
||||
@@ -445,10 +474,23 @@ pub fn build_config(base: &Value, cfg: &Cfg, api_key: &str, mode: &str) -> Resul
|
||||
for (k, v) in ENV_TUNING {
|
||||
env.insert(k.into(), json!(v));
|
||||
}
|
||||
if has_helper {
|
||||
// Lecture d'un prompt (moteur 1.1) : les experts de la carte d'aide
|
||||
// sont calculés par elle (source « helper ») ; le chemin direct ne
|
||||
// garde que les ajouts sous 48 jetons.
|
||||
env.insert("STRATA_PREFILL_HELPER_SOURCE".into(), json!("1"));
|
||||
args = set_arg(args, "--short-read", "48");
|
||||
}
|
||||
let drop_on = env.get("STRATA_REMOTE_DROP").and_then(Value::as_str) == Some("1");
|
||||
out.insert("env".into(), Value::Object(env));
|
||||
let ctx = cfg_get(cfg, "CTX").trim();
|
||||
if !ctx.is_empty() {
|
||||
args = set_arg(args, "--max-context", ctx);
|
||||
// hors RAM, le cache KV verrouillé au-delà de DROP_MAX_CTX ne tient plus
|
||||
let capped = match ctx.parse::<i64>() {
|
||||
Ok(n) if n > DROP_MAX_CTX && drop_on => DROP_MAX_CTX.to_string(),
|
||||
_ => ctx.to_string(),
|
||||
};
|
||||
args = set_arg(args, "--max-context", &capped);
|
||||
}
|
||||
for (key, flag) in [
|
||||
("STRATA_KV", "--kv"),
|
||||
@@ -766,6 +808,8 @@ pub struct SettingsReq {
|
||||
pub vision: bool,
|
||||
pub helper: bool,
|
||||
pub experts: String,
|
||||
/// « helper » ou « layers » ; vide = inchangé (anciens clients).
|
||||
pub split: String,
|
||||
}
|
||||
|
||||
/// `strataApplySettings` : réécrit les clés du preset. Pure.
|
||||
@@ -786,6 +830,9 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
|
||||
{
|
||||
return Err(format!("placement des experts inconnu : {}", r.experts));
|
||||
}
|
||||
if !r.split.is_empty() && r.split != "helper" && r.split != "layers" {
|
||||
return Err(format!("répartition inconnue : {}", r.split));
|
||||
}
|
||||
let mut set: Vec<(&str, String)> = vec![
|
||||
("CTX", r.ctx.to_string()),
|
||||
("STRATA_KV", r.kv.clone()),
|
||||
@@ -796,6 +843,11 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
|
||||
if !r.experts.is_empty() {
|
||||
set.push(("STRATA_EXPERTS", r.experts.clone()));
|
||||
}
|
||||
if !r.split.is_empty() {
|
||||
// « helper » est le défaut : la clé disparaît plutôt que d'être écrite
|
||||
let v = if r.split == "layers" { "layers" } else { "" };
|
||||
set.push(("STRATA_SPLIT", v.into()));
|
||||
}
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
for l in content.trim_end_matches('\n').split('\n') {
|
||||
let k = l.trim().split_once('=').map_or(l.trim(), |(k, _)| k);
|
||||
@@ -805,6 +857,9 @@ pub fn apply_settings(content: &str, r: &SettingsReq) -> Result<String, String>
|
||||
lines.push(l.to_string());
|
||||
}
|
||||
for (k, v) in set {
|
||||
if k == "STRATA_SPLIT" && v.is_empty() {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{k}={v}"));
|
||||
}
|
||||
Ok(lines.join("\n") + "\n")
|
||||
|
||||
@@ -369,6 +369,125 @@ fn modes_des_experts() {
|
||||
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
|
||||
}
|
||||
|
||||
// Moteur 1.1 (`TestStrataBuildConfigHelperPrompt`).
|
||||
#[test]
|
||||
fn lecture_des_prompts_par_la_carte_d_aide() {
|
||||
let base = json!({ "args": ["--pack", "/p"] });
|
||||
let out = build_config(&base, &cfg(&[("STRATA_HELPER_GPU", "0")]), "", "ram").unwrap();
|
||||
assert_eq!(arg_after(&args_of(&out), "--short-read"), "48");
|
||||
assert_eq!(out["env"]["STRATA_PREFILL_HELPER_SOURCE"], "1");
|
||||
for c in [
|
||||
Cfg::new(),
|
||||
cfg(&[("STRATA_HELPER_GPU", "0"), ("STRATA_HELPER", "0")]),
|
||||
] {
|
||||
let out = build_config(&base, &c, "", "ram").unwrap();
|
||||
assert_eq!(arg_after(&args_of(&out), "--short-read"), "150", "{c:?}");
|
||||
assert!(out["env"].get("STRATA_PREFILL_HELPER_SOURCE").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// `TestStrataBuildConfigDropReserveAndCtx`.
|
||||
#[test]
|
||||
fn hors_ram_reserve_et_contexte() {
|
||||
let base = json!({ "args": ["--pack", "/p"], "env": {} });
|
||||
let c = cfg(&[("STRATA_HELPER_GPU", "0"), ("CTX", "167936")]);
|
||||
let out = build_config(&base, &c, "", "drop").unwrap();
|
||||
assert_eq!(out["env"]["STRATA_REMOTE_DROP_RESERVE_MIB"], "640");
|
||||
assert_eq!(arg_after(&args_of(&out), "--max-context"), "131072");
|
||||
for mode in ["ram", "arena", "disk"] {
|
||||
let out = build_config(&base, &c, "", mode).unwrap();
|
||||
assert_eq!(
|
||||
arg_after(&args_of(&out), "--max-context"),
|
||||
"167936",
|
||||
"{mode}"
|
||||
);
|
||||
assert!(out["env"].get("STRATA_REMOTE_DROP_RESERVE_MIB").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
// `TestStrataBuildConfigLayerSplit`.
|
||||
#[test]
|
||||
fn partage_des_couches() {
|
||||
let base = json!({
|
||||
"args": ["--pack", "/p", "--vram-reserve-mib", "900"],
|
||||
"vision": { "exe": "/v" },
|
||||
});
|
||||
let mut c = cfg(&[
|
||||
("STRATA_MAIN_GPU", "1"),
|
||||
("STRATA_HELPER_GPU", "0"),
|
||||
("STRATA_SPLIT", "layers"),
|
||||
]);
|
||||
let out = build_config(&base, &c, "", "drop").unwrap();
|
||||
assert_eq!(out["gpu"], json!([1, 0]));
|
||||
let a = args_of(&out);
|
||||
assert!(!has_arg(&a, "--remote-expert-opt") && !has_arg(&a, "--expert-cache-device1"));
|
||||
assert_eq!(arg_after(&a, "--vram-reserve-mib"), "900");
|
||||
assert_eq!(arg_after(&a, "--short-read"), "150");
|
||||
assert!(out["env"].get("STRATA_REMOTE_DROP").is_none());
|
||||
assert!(out["env"].get("STRATA_PREFILL_HELPER_SOURCE").is_none());
|
||||
assert!(out["vision"].get("cuda_device").is_none());
|
||||
c.remove("STRATA_SPLIT");
|
||||
let out = build_config(&base, &c, "", "drop").unwrap();
|
||||
assert!(out.get("gpu").is_none());
|
||||
assert!(has_arg(&args_of(&out), "--remote-expert-opt"));
|
||||
c.insert("STRATA_SPLIT".into(), "layers".into());
|
||||
c.insert("STRATA_HELPER".into(), "0".into());
|
||||
assert!(!layer_split(&c));
|
||||
}
|
||||
|
||||
// `TestStrataResolveExpertsLayerSplit`.
|
||||
#[test]
|
||||
fn partage_des_couches_sans_hors_ram() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = Paths {
|
||||
home: d.path().to_path_buf(),
|
||||
};
|
||||
let pack = d.path().join("pack");
|
||||
std::fs::create_dir_all(&pack).unwrap();
|
||||
std::fs::write(pack.join("experts.bin"), "x").unwrap();
|
||||
std::fs::create_dir_all(p.src_dir().join("engine-cuda12")).unwrap();
|
||||
std::fs::write(p.engine_marker(), format!("{ENGINE_ASSET}\n")).unwrap();
|
||||
let args = vec!["--pack".to_string(), pack.to_string_lossy().into_owned()];
|
||||
let mut c = cfg(&[
|
||||
("STRATA_CONFIG", "/x/strata-swift-iq3_xxs.json"),
|
||||
("STRATA_HELPER_GPU", "1"),
|
||||
("STRATA_DROP", "1"),
|
||||
]);
|
||||
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "drop");
|
||||
c.insert("STRATA_SPLIT".into(), "layers".into());
|
||||
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "arena");
|
||||
c.insert("STRATA_EXPERTS".into(), "ram".into());
|
||||
assert_eq!(resolve_experts(&p, &c, &args, 44.0, 12.0), "ram");
|
||||
}
|
||||
|
||||
// `TestStrataApplySettingsSplit`.
|
||||
#[test]
|
||||
fn reglages_de_la_seconde_carte() {
|
||||
let input = "ENGINE=strata\nSTRATA_HELPER_GPU=0\n";
|
||||
let mut r = SettingsReq {
|
||||
ctx: 131072,
|
||||
kv: "fp16".into(),
|
||||
spec: 3,
|
||||
vision: true,
|
||||
helper: true,
|
||||
split: "layers".into(),
|
||||
..SettingsReq::default()
|
||||
};
|
||||
let out = apply_settings(input, &r).unwrap();
|
||||
assert!(out.contains("STRATA_SPLIT=layers\n"), "{out:?}");
|
||||
r.split = "helper".into();
|
||||
let out = apply_settings(&out, &r).unwrap();
|
||||
assert!(!out.contains("STRATA_SPLIT"), "{out:?}");
|
||||
r.split = String::new();
|
||||
let out = apply_settings("STRATA_SPLIT=layers\n", &r).unwrap();
|
||||
assert!(out.contains("STRATA_SPLIT=layers"), "{out:?}");
|
||||
r.split = "nimporte".into();
|
||||
assert_eq!(
|
||||
apply_settings(input, &r).unwrap_err(),
|
||||
"répartition inconnue : nimporte"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reglages_des_experts() {
|
||||
let input = "ENGINE=strata\nSTRATA_EXPERTS=auto\n";
|
||||
|
||||
@@ -469,12 +469,19 @@ async fn bail_travail_annexe_et_trace() {
|
||||
*bn.go.side_jobs.lock().unwrap(),
|
||||
vec!["begin 1".to_string(), "end 1 true".to_string()]
|
||||
);
|
||||
// La longueur du prompt suit le corpus de code, c'est-à-dire l'UI
|
||||
// embarquée : la vérifier en dur cassait le test à chaque retouche de
|
||||
// l'interface. On vérifie la forme de la trace.
|
||||
let perfs = bn.go.bench_perfs.lock().unwrap().clone();
|
||||
assert_eq!(perfs.len(), 1, "{perfs:?}");
|
||||
let n = perfs[0]["prompt_n"].as_i64().unwrap_or(0);
|
||||
assert!(n > 0, "{perfs:?}");
|
||||
assert_eq!(
|
||||
*bn.go.bench_perfs.lock().unwrap(),
|
||||
vec![json!({
|
||||
"prompt_n": 2036, "prompt_ms": 1018.0, "prompt_per_second": 2000.0,
|
||||
perfs[0],
|
||||
json!({
|
||||
"prompt_n": n, "prompt_ms": n as f64 / 2.0, "prompt_per_second": 2000.0,
|
||||
"predicted_n": 300, "predicted_per_second": 50.0,
|
||||
})]
|
||||
})
|
||||
);
|
||||
// La clé de l'API locale accompagne les requêtes du bench.
|
||||
let auth = bn.fake.lock().unwrap().auth.clone();
|
||||
|
||||
@@ -260,6 +260,13 @@ async fn wait_finished(dl: &Downloads, name: &str) -> DlState {
|
||||
panic!("téléchargement {name} jamais terminé : {:?}", dl.status());
|
||||
}
|
||||
|
||||
fn state_of(dl: &Downloads, name: &str) -> DlState {
|
||||
dl.status()
|
||||
.into_iter()
|
||||
.find(|s| s.filename == name)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn models(home: &Path) -> PathBuf {
|
||||
home.join("models")
|
||||
}
|
||||
@@ -537,6 +544,58 @@ async fn annulation_efface_le_part() {
|
||||
assert_eq!(entries(&models(tmp.path())), Vec::<String>::new());
|
||||
}
|
||||
|
||||
// `TestRunDownloadPauseResume` : la pause coupe le flux et garde ce qui est
|
||||
// écrit ; la reprise repart de là par une plage et termine un fichier
|
||||
// identique à la source.
|
||||
#[tokio::test]
|
||||
async fn pause_puis_reprise() {
|
||||
let (addr, hf) = fake_hf().await;
|
||||
let data = payload(8 << 20, 5);
|
||||
hf.files
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert("/m.gguf".into(), Arc::new(data.clone()));
|
||||
*hf.slow.lock().unwrap() = true;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dl = Arc::new(Downloads::new());
|
||||
let (code, _) = dl.start(
|
||||
env(),
|
||||
&dirs_at(tmp.path()),
|
||||
&format!("http://{addr}/m.gguf"),
|
||||
"",
|
||||
);
|
||||
assert_eq!(code, StatusCode::OK);
|
||||
assert!(!dl.pause("autre.gguf", true));
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert!(dl.pause("m.gguf", true));
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
let a = state_of(&dl, "m.gguf");
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
let b = state_of(&dl, "m.gguf");
|
||||
assert!(
|
||||
a.paused && !b.finished && a.done == b.done && a.done > 0 && b.speed == 0,
|
||||
"pause inopérante : {a:?} puis {b:?}"
|
||||
);
|
||||
let before = hf.log.lock().unwrap().len();
|
||||
assert!(dl.pause("m.gguf", false));
|
||||
let end = wait_finished(&dl, "m.gguf").await;
|
||||
assert!(
|
||||
end.err.is_empty() && !end.canceled && !end.paused,
|
||||
"{end:?}"
|
||||
);
|
||||
let got = std::fs::read(models(tmp.path()).join("m.gguf")).unwrap();
|
||||
assert!(got == data, "fichier différent de la source après reprise");
|
||||
// La reprise demande la suite, pas le début.
|
||||
let log = hf.log.lock().unwrap().clone();
|
||||
let resumed = log[before..]
|
||||
.iter()
|
||||
.filter_map(|l| l.strip_prefix("/m.gguf bytes="))
|
||||
.any(|r| !r.starts_with("0-"));
|
||||
assert!(resumed, "{log:?}");
|
||||
// Terminé : plus rien à mettre en pause.
|
||||
assert!(!dl.pause("m.gguf", true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refus_avant_de_lancer() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -231,8 +231,8 @@
|
||||
"predicted_ms": 6000,
|
||||
"predicted_n": 300,
|
||||
"predicted_per_second": 50,
|
||||
"prompt_ms": 1018,
|
||||
"prompt_n": 2036,
|
||||
"prompt_ms": 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
@@ -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)));
|
||||
|
||||
Reference in new issue
Block a user