Correctifs repris d'AJEAN : chiffrement, preset externe, heartbeat, commande du moteur

- Désactivation du chiffrement (amont cac0cda, 19918c6) : une valeur
  illisible ne bloque plus tout (quarantaine datée + copie du keyvault), et la
  reprise au démarrage déchiffre aussi les conversations au lieu de retirer la
  clé après les seules pages. Propre à Loki : les images de conversation
  (chatimg/) sont maintenant déchiffrées elles aussi — elles restaient
  chiffrées sans clé. Les .bak chiffrés devenus illisibles sont retirés.
- Preset externe (amont #95) : `loki serve` sort sans erreur (plus de boucle
  systemd « MODEL non défini »), le pré-vol l'accepte, le superviseur du
  conteneur ne le prend plus pour un plantage.
- Unité systemd du moteur : TimeoutStopSec=5 (bascule de preset bloquée 90 s
  en pleine génération).
- Heartbeat SSE (amont #105) : plus d'écriture après le retour du handler.
- Paramètres → Configuration : commande exacte du moteur copiable (clé API
  masquée, Strata compris) et nombre de couches du modèle à côté de NGL, lu
  dans l'en-tête GGUF (amont #108, #43) ; aussi dans l'éditeur de preset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-07 09:28:42 +02:00
1 parent 4a9a5a1712
commit 127013b32b
16 files changed
+479 -34

No files matched your search

@@ -0,0 +1,43 @@
package loki
import "testing"
// Preset externe actif : `loki serve` n'a aucun moteur local à lancer. Il
// sortait en erreur « MODEL non défini », que systemd (Restart=on-failure)
// relançait en boucle (amont #95).
func TestServeExitsCleanlyForExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1", "PORT": "8080"}); err != nil {
t.Fatal(err)
}
if err := cmdServe(nil); err != nil {
t.Fatalf("loki serve sur un preset externe : %v", err)
}
}
// Le pré-vol refusait `loki start` / `loki restart` sur un preset externe,
// en invitant à réinstaller llama.cpp.
func TestPreflightAcceptsExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1"}); err != nil {
t.Fatal(err)
}
if err := preflightEngine(); err != nil {
t.Fatalf("pré-vol refusé sur un preset externe : %v", err)
}
}
// Strata : le pré-vol vérifie sa configuration à lui, pas BIN/MODEL.
func TestPreflightStrataWithoutConfig(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{"ENGINE": "strata"}); err != nil {
t.Fatal(err)
}
err := preflightEngine()
if err == nil {
t.Fatal("un preset Strata sans STRATA_CONFIG devrait être refusé")
}
if want := "STRATA_CONFIG"; !contains(err.Error(), want) {
t.Fatalf("message attendu sur %s : %v", want, err)
}
}
+10
View File
@@ -272,6 +272,12 @@ func cmdServe(args []string) error {
}
cfg = c
}
// API externe : aucun moteur local. On sort SANS erreur, sinon systemd
// (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95).
if isExternalConfig(cfg) {
fmt.Println("[info] preset externe actif : pas de moteur local à lancer")
return nil
}
// Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son
// propre serveur. L'optimiseur ne règle que llama-server.
if isStrataConfig(cfg) {
@@ -471,6 +477,10 @@ func cmdServe(args []string) error {
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), port)
// Ligne de commande réellement lancée, gardée pour l'interface (amont #108).
if trialDir == "" {
recordEngineCmdline(env, llmArgs)
}
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
+1
View File
@@ -1036,6 +1036,7 @@ func serveStrata(cfg map[string]string) error {
_ = os.Chdir(srcDir)
fmt.Fprintf(os.Stderr, "%s (paquet AJEAN MoE %s) config=%s port=%s gpu=%s\n",
strataServeMarker, strataVersion, filepath.Base(out), port, os.Getenv("CUDA_VISIBLE_DEVICES"))
recordEngineCmdline(map[string]string{"CUDA_VISIBLE_DEVICES": os.Getenv("CUDA_VISIBLE_DEVICES")}, args)
return execServer(py, args)
}
+87
View File
@@ -0,0 +1,87 @@
package loki
// engine_cmdline.go — ce que l'interface montre du dernier lancement du moteur
// (repris d'AJEAN, #108 et #43) :
//
// /api/engine/cmdline la commande exacte lancée par `loki serve` (variables
// d'environnement posées par Loki comprises), copiable
// pour la vérifier ou la partager ; clé API masquée
// /api/model/layers le nombre de couches du modèle, lu dans l'en-tête
// GGUF : la valeur de NGL qui met tout sur le GPU
import (
"net/http"
"sort"
"strings"
)
// engineCmdlineKey : dernière commande du moteur (bkState), lue par l'UI.
const engineCmdlineKey = "engine_cmdline"
// engineCmdline met les arguments sous forme copiable dans un terminal :
// variables d'environnement en tête (KEY=VAL, triées), guillemets autour de ce
// qui contient un espace, clé API masquée.
func engineCmdline(env map[string]string, args []string) string {
quote := func(a string) string {
if a == "" || strings.ContainsAny(a, " \t\"'") {
return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"`
}
return a
}
var out []string
keys := make([]string, 0, len(env))
for k := range env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out = append(out, k+"="+quote(env[k]))
}
for i, a := range args {
if i > 0 && (args[i-1] == "--api-key" || args[i-1] == "--api-key-file") {
a = "<clé masquée>"
}
out = append(out, quote(a))
}
return strings.Join(out, " ")
}
// recordEngineCmdline garde la commande du lancement en cours. Jamais pour un
// essai de l'optimiseur : il écraserait celle du vrai moteur.
func recordEngineCmdline(env map[string]string, args []string) {
_ = putBytes(bkState, engineCmdlineKey, []byte(engineCmdline(env, args)))
}
// handleEngineCmdline (GET) : la commande du dernier lancement du moteur local.
func handleEngineCmdline(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "cmdline": string(getBytes(bkState, engineCmdlineKey))})
}
// handleModelLayers (GET ?model=…, sinon MODEL de la config) : nombre de blocs
// du modèle. llama.cpp en offloade block_count + 1 (la couche de sortie en
// plus) : c'est la valeur de -ngl qui met tout le modèle sur le GPU.
func handleModelLayers(w http.ResponseWriter, r *http.Request) {
model := strings.TrimSpace(r.URL.Query().Get("model"))
if model == "" {
model = strings.TrimSpace(ReadConfig()["MODEL"])
}
if model == "" {
sendJSON(w, 200, map[string]any{"ok": false, "error": "aucun modèle"})
return
}
path, err := resolveServeModelPath(model)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
info, err := ggufMeta(path)
if err != nil || info.BlockCount <= 0 {
msg := "nombre de couches absent de l'en-tête"
if err != nil {
msg = err.Error()
}
sendJSON(w, 200, map[string]any{"ok": false, "error": msg})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "layers": info.BlockCount, "ngl_max": info.BlockCount + 1})
}
+123 -17
View File
@@ -259,52 +259,158 @@ func DisableMemEncryption() error {
// page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption
// laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore
// levé, donc DisableMemEncryption peut être relancé).
if err := decryptAllPages(); err != nil {
return finishDecryption()
}
// finishDecryption : fichiers (pages, images des conversations), puis
// conversations, puis retrait de la clé, dans cet ordre et seulement si tout ce
// qui précède a réussi. Sûr à rejouer. Partagé avec la reprise au démarrage, qui
// retirait la clé après les seules pages : les conversations restaient
// chiffrées sans plus aucun moyen de les rouvrir (amont cac0cda).
func finishDecryption() error {
bad, err := decryptAllPagesSkipping()
if err != nil {
return err
}
if err := decryptChatStores(); err != nil {
badKV, err := decryptChatStoresSkipping()
if err != nil {
return err
}
// Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau,
// retirer le keyvault et purger la DEK.
bad = append(bad, badKV...)
if len(bad) > 0 {
// Valeurs illisibles avec la clé actuelle : déjà inaccessibles avant ce
// déchiffrement. On les met en quarantaine (toujours chiffrées) avec une
// copie du keyvault, pour pouvoir encore les rouvrir plus tard, puis on
// les retire de la base pour que le reste fonctionne en clair.
if err := quarantineUnreadable(bad); err != nil {
return fmt.Errorf("mise en quarantaine des valeurs illisibles impossible, déchiffrement interrompu (rien n'est perdu, il peut être relancé) : %w", err)
}
for _, u := range bad {
if u.Bucket != "fichier" { // un fichier reste en place, sa copie est en quarantaine
_ = putBytes(u.Bucket, u.Key, nil)
}
}
}
// Tout est en clair et vérifié : on peut baisser le drapeau, retirer le
// keyvault et purger la DEK.
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
return err
}
removeVault()
clearMemDEK()
clearMigrationJournal()
if n := scrubEncryptedResidue(); n > 0 {
fmt.Fprintf(os.Stderr, "[mémoire] %d copie(s) .bak chiffrée(s), illisibles sans la clé, retirée(s)\n", n)
}
return nil
}
// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES
// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent).
func decryptAllPages() error {
for _, p := range allMemPageFiles() {
// decryptAllPagesSkipping réécrit en clair tous les fichiers encore chiffrés de
// la mémoire (tous projets) et des images de conversation, en vérifiant chacun.
// Exige la DEK en RAM. Sûr à rejouer. Un fichier indéchiffrable est laissé tel
// quel et renvoyé au lieu de tout arrêter.
func decryptAllPagesSkipping() ([]unreadableValue, error) {
var bad []unreadableValue
for _, p := range allMemFilesForDecrypt() {
raw, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("lecture %s : %w", p, err)
return bad, fmt.Errorf("lecture %s : %w", p, err)
}
if !looksEncrypted(raw) {
continue // déjà en clair
}
plain, err := decodeMemContent(raw)
if err != nil {
return fmt.Errorf("déchiffrement %s impossible, on n'écrase rien : %w", p, err)
bad = append(bad, unreadableValue{Bucket: "fichier", Key: p, Blob: raw, Error: err.Error()})
continue // on n'écrase rien
}
if old, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(p+".bak", old, 0o600)
}
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
return fmt.Errorf("écriture claire %s : %w", p, err)
return bad, fmt.Errorf("écriture claire %s : %w", p, err)
}
back, err := os.ReadFile(p)
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
return fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
return bad, fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
}
}
return bad, nil
}
// allMemFilesForDecrypt : TOUS les fichiers de la mémoire, sous-dossiers compris,
// et les images de conversation (chatimg/, chiffrées comme le fil), sauf les
// copies .bak et les écritures en cours (.tmp). Ne déchiffrer que les pages .md
// laissait le reste chiffré sans plus aucune clé une fois le keyvault retiré.
func allMemFilesForDecrypt() []string {
var out []string
for _, root := range []string{projectsRoot(), chatImgDir()} {
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || strings.HasSuffix(d.Name(), ".bak") || strings.HasSuffix(d.Name(), ".tmp") {
return nil
}
out = append(out, p)
return nil
})
}
return out
}
// quarantineUnreadable écrit les valeurs indéchiffrables, encore chiffrées, dans
// un dossier daté de LOKI_HOME, avec une copie du keyvault à côté : elles
// restent récupérables si l'ancienne clé est retrouvée.
func quarantineUnreadable(bad []unreadableValue) error {
dir := filepath.Join(LokiHome(), "chiffre-illisible-"+time.Now().Format("20060102-150405"))
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
raw, err := json.MarshalIndent(bad, "", " ")
if err != nil {
return err
}
if err := memWriteFileAtomic(filepath.Join(dir, "valeurs.json"), raw, 0o600); err != nil {
return err
}
for _, p := range []string{vaultPathPrimary(), vaultPathBackup()} {
if b, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(filepath.Join(dir, filepath.Base(p)), b, 0o600)
}
}
if b := getBytes(bkState, vaultDBKey); len(b) > 0 {
_ = memWriteFileAtomic(filepath.Join(dir, "keyvault-base.json"), b, 0o600)
}
for _, u := range bad {
fmt.Fprintf(os.Stderr, "[mémoire] illisible avec la clé actuelle, mis en quarantaine : %s / %s (%s)\n", u.Bucket, u.Key, u.Error)
}
fmt.Fprintf(os.Stderr, "[mémoire] %d valeur(s) en quarantaine dans %s\n", len(bad), dir)
return nil
}
// scrubEncryptedResidue retire les .bak encore CHIFFRÉS de la mémoire une fois
// le chiffrement désactivé : la clé est partie avec le keyvault, ils ne pourront
// plus jamais être lus, et ce sont des copies de pages qui viennent d'être
// remises en clair. Pendant symétrique de scrubPlaintextResidue. Les valeurs en
// quarantaine et les snapshots vivent hors de memory/ : jamais touchés. Rien
// n'est fait tant que le chiffrement est actif.
func scrubEncryptedResidue() int {
if memEncActive() {
return 0
}
n := 0
for _, root := range []string{projectsRoot(), chatImgDir()} {
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
return nil
}
if b, err := os.ReadFile(p); err == nil && looksEncrypted(b) && os.Remove(p) == nil {
n++
}
return nil
})
}
return n
}
// resumeMemMigration reprend une migration interrompue au démarrage. Pour une
// migration "encrypt", si la DEK est déjà en RAM (déverrouillée), on rejoue le
// (re)chiffrement des pages claires ; sinon on laisse le journal en place pour un
@@ -328,12 +434,12 @@ func resumeMemMigration() {
// encore levé, on termine proprement (écrit le clair restant), puis on
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
// et l'écran de santé signalera le mélange.
// Même chemin complet que DisableMemEncryption : la reprise retirait la clé
// après les seules pages, en laissant les conversations chiffrées sans
// plus aucun moyen de les rouvrir.
if memEncActive() && memUnlocked() {
if err := decryptAllPages(); err == nil {
_ = SetConfigKey("MEM_ENCRYPTED", "")
removeVault()
clearMemDEK()
clearMigrationJournal()
if err := finishDecryption(); err != nil {
fmt.Fprintf(os.Stderr, "[mémoire] reprise du déchiffrement : %v\n", err)
}
}
}
+82
View File
@@ -2,6 +2,7 @@ package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -261,3 +262,84 @@ func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
t.Fatalf("pointeur chiffré non réparé : %q", got)
}
}
// Une seule valeur indéchiffrable bloquait toute la désactivation, et les images
// de conversation (chatimg/) restaient chiffrées sans clé une fois le keyvault
// retiré. Désormais : tout ce qui se lit est déchiffré, l'illisible part en
// quarantaine, et la désactivation aboutit.
func TestDisableMemEncryptionSurvitAuxValeursIllisibles(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pass-de-test-1234"); err != nil {
t.Fatal(err)
}
img := []byte("\x89PNG octets d'image de test")
ref, err := storeChatImage(img, "image/png")
if err != nil {
t.Fatal(err)
}
imgPath := filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme))
if raw, _ := os.ReadFile(imgPath); !looksEncrypted(raw) {
t.Fatal("l'image devrait être chiffrée")
}
other := make([]byte, 32)
other[0] = 7
alien, err := encPage(other, []byte("conversation chiffrée avec une autre clé"))
if err != nil {
t.Fatal(err)
}
if err := putBytes(bkChat, "vieille", alien); err != nil {
t.Fatal(err)
}
if err := DisableMemEncryption(); err != nil {
t.Fatalf("la désactivation aurait dû aboutir : %v", err)
}
if memEncActive() {
t.Fatal("le chiffrement devrait être désactivé")
}
if b, _ := os.ReadFile(imgPath); string(b) != string(img) {
t.Fatalf("image pas déchiffrée : %q", b)
}
if v := getBytes(bkChat, "vieille"); len(v) != 0 {
t.Fatal("la valeur illisible aurait dû quitter la base")
}
q, _ := filepath.Glob(filepath.Join(LokiHome(), "chiffre-illisible-*", "valeurs.json"))
if len(q) != 1 {
t.Fatalf("quarantaine attendue, trouvée : %v", q)
}
if qb, _ := os.ReadFile(q[0]); !strings.Contains(string(qb), "vieille") {
t.Fatal("la quarantaine doit contenir la valeur illisible")
}
if baks, _ := filepath.Glob(filepath.Join(chatImgDir(), "*.bak")); len(baks) != 0 {
t.Fatalf(".bak chiffrés restés derrière : %v", baks)
}
}
// Déchiffrement interrompu puis repris au démarrage : la reprise retirait la clé
// après les seules pages, et les conversations restaient chiffrées pour toujours.
func TestResumeInterruptedDecryptDechiffreLesConversations(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
enc, err := encodeMemContent([]byte(`{"titre":"conversation"}`))
if err != nil {
t.Fatal(err)
}
if err := putBytes(bkChat, "conv:x", enc); err != nil {
t.Fatal(err)
}
if err := writeMigrationJournal("decrypt"); err != nil {
t.Fatal(err)
}
resumeMemMigration()
if memEncActive() {
t.Fatal("la reprise aurait dû terminer le déchiffrement")
}
if v := getBytes(bkChat, "conv:x"); string(v) != `{"titre":"conversation"}` {
t.Fatalf("conversation restée chiffrée après la reprise : %q", v)
}
}
+32 -8
View File
@@ -188,18 +188,41 @@ func reencryptChatStores() error {
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
func decryptChatStores() error {
_, err := decryptChatStoresSkipping()
return err
}
// unreadableValue : une valeur chiffrée qui ne se déchiffre pas avec la clé
// actuelle (écrite avec une autre clé, ou abîmée). Gardée telle quelle.
type unreadableValue struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
Blob []byte `json:"blob"` // base64 en JSON
Error string `json:"error"`
}
// decryptChatStoresSkipping déchiffre tout ce qui peut l'être et RENVOIE ce qui
// ne le peut pas, au lieu de s'arrêter au premier échec : une seule valeur
// illisible bloquait toute la désactivation du chiffrement, sans dire laquelle
// (amont, 2026-10-03).
func decryptChatStoresSkipping() ([]unreadableValue, error) {
persistQ.flush() // même raison que reencryptChatStores
var bad []unreadableValue
for _, b := range encryptedBuckets {
if err := decryptBucket(b); err != nil {
return err
u, err := decryptBucket(b)
if err != nil {
return bad, err
}
bad = append(bad, u...)
}
return nil
return bad, nil
}
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
// la DEK en RAM. Sûr à rejouer.
func decryptBucket(bucket string) error {
// la DEK en RAM. Sûr à rejouer. Une valeur indéchiffrable est laissée telle
// quelle et renvoyée ; seule une erreur d'écriture est fatale.
func decryptBucket(bucket string) ([]unreadableValue, error) {
var bad []unreadableValue
for k, v := range allKV(bucket) {
raw := []byte(v)
if !looksEncrypted(raw) {
@@ -207,11 +230,12 @@ func decryptBucket(bucket string) error {
}
plain, err := decodeMemContent(raw)
if err != nil {
return err
bad = append(bad, unreadableValue{Bucket: bucket, Key: k, Blob: raw, Error: err.Error()})
continue
}
if err := putBytes(bucket, k, plain); err != nil {
return err
return bad, err
}
}
return nil
return bad, nil
}
+3
View File
@@ -32,6 +32,9 @@ WorkingDirectory=%s
ExecStart=%s
Restart=on-failure
RestartSec=3
# llama-server ignore SIGTERM en pleine génération : sans borne, une bascule de
# preset restait bloquée 90 s (défaut systemd) avant le SIGKILL.
TimeoutStopSec=5
# Lancement refusé d'avance par LOAD_GUARD (le mode de chargement ne tient pas
# en RAM) : relancer n'y changerait rien.
RestartPreventExitStatus=78
+6
View File
@@ -41,6 +41,12 @@ func serviceAction(action string) error {
// visible que dans le journal.
func preflightEngine() error {
cfg := ReadConfig()
// Preset externe : aucun llama-server local à lancer. Exiger BIN/MODEL
// refusait `loki start` et `loki restart` avec un message invitant à
// réinstaller llama.cpp (amont #95) ; cmdServe sort déjà proprement.
if isExternalConfig(cfg) {
return nil
}
// Strata n'a ni BIN ni MODEL : son installeur a tout rangé (backend_strata.go).
if isStrataConfig(cfg) {
return strataPreflight(cfg)
+6
View File
@@ -65,6 +65,12 @@ func userSvcAction(action string) error {
}
func userSvcStart() error {
// Preset externe : `loki serve` sortirait aussitôt (rien à lancer), et
// userCheckStarted le prendrait pour un plantage.
if isExternalConfig(ReadConfig()) {
fmt.Printf("%s preset externe actif : pas de moteur local à démarrer\n", dim("[info]"))
return nil
}
if pid := readServicePID(); pid > 0 && processAlive(pid) {
fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid)
return nil
+33 -4
View File
@@ -3545,7 +3545,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<div class="pe-row">
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"><span class="pe-unit" id="s-ngl-max"></span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
@@ -4478,7 +4478,7 @@ async function loadCfg(){
rows.push(row('EXPERTS', d.drop ? 'mlock' : d.mmap ? 'mmap' : 'RAM'));
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
}else if(c.CTX) rows.push(row('CTX', c.CTX));
document.getElementById('cfg').innerHTML = rows.join('');
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
return;
}
if(c.BIN){
@@ -4502,14 +4502,28 @@ async function loadCfg(){
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
// source qui le connaisse quand aucun preset ne correspond à la configuration.
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
// Nombre de couches du modèle (amont #43) : NGL au-delà de ngl_max ne change
// rien, en dessous on libère de la VRAM pour le contexte et le cache KV.
const ly=c.MODEL ? await jget('/api/model/layers').catch(()=>null) : null;
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
let v=c[k]; if(k==='MODEL') v=v.split(/[\\/]/).pop();
if(k==='NGL' && ly && ly.ok){ rows.push(row(k, v+' / '+ly.ngl_max, ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)')); return; }
rows.push(row(k, v));
});
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
if(m) rows.push(row('N-CPU-MOE', m[1]));
document.getElementById('cfg').innerHTML = rows.join('');
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
}
// Ligne de commande exacte du dernier lancement du moteur (amont #108),
// copiable pour la vérifier ou la partager. Clé API masquée côté serveur.
let ENGINE_CMDLINE='';
async function engineCmdlineRow(){
const cl=await jget('/api/engine/cmdline').catch(()=>null);
ENGINE_CMDLINE=(cl&&cl.cmdline)||'';
if(!ENGINE_CMDLINE) return '';
return '<div class="kv"><span>COMMANDE</span><span><button class="pe-link" onclick="copyText(ENGINE_CMDLINE, \'commande copiée\')" title="'
+escHtml(ENGINE_CMDLINE)+'">copier la commande du moteur</button></span></div>';
}
// --- Réserve de hauteur des blocs peuplés par le réseau ---------------------
// Ces blocs sont vides (ou réduits à « … ») tant que le serveur n'a pas répondu,
@@ -5740,6 +5754,20 @@ function onPickModel(){
ta.value = 'MODEL="'+val+'"\n' + ta.value;
}
toast('MODEL='+val);
showNglMax(val);
}
// Nombre de couches du modèle du preset (amont #43), lu dans son en-tête GGUF :
// affiché à côté du champ NGL (placeholder, « / N » et survol).
async function showNglMax(model){
const e=document.getElementById('s-ngl'), u=document.getElementById('s-ngl-max'); if(!e) return;
e.placeholder='999'; e.title=''; if(u) u.textContent='';
if(!model) return;
const ly=await jget('/api/model/layers?model='+encodeURIComponent(model)).catch(()=>null);
if(!ly || !ly.ok) return;
e.placeholder=String(ly.ngl_max);
if(u) u.textContent='/ '+ly.ngl_max;
e.title=ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)';
}
// --- Vision (projecteur multimodal --mmproj) --------------------------------
@@ -6154,6 +6182,7 @@ function populateSettings(){
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
set('s-ctx', cfgReadKey('CTX'));
set('s-ngl', cfgReadKey('NGL'));
showNglMax(cfgReadKey('MODEL'));
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
+1 -1
View File
@@ -1288,7 +1288,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div class="pe-row">
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"><span class="pe-unit" id="s-ngl-max"></span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
+17 -3
View File
@@ -333,7 +333,7 @@ async function loadCfg(){
rows.push(row('EXPERTS', d.drop ? 'mlock' : d.mmap ? 'mmap' : 'RAM'));
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
}else if(c.CTX) rows.push(row('CTX', c.CTX));
document.getElementById('cfg').innerHTML = rows.join('');
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
return;
}
if(c.BIN){
@@ -357,12 +357,26 @@ async function loadCfg(){
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
// source qui le connaisse quand aucun preset ne correspond à la configuration.
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
// Nombre de couches du modèle (amont #43) : NGL au-delà de ngl_max ne change
// rien, en dessous on libère de la VRAM pour le contexte et le cache KV.
const ly=c.MODEL ? await jget('/api/model/layers').catch(()=>null) : null;
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
let v=c[k]; if(k==='MODEL') v=v.split(/[\\/]/).pop();
if(k==='NGL' && ly && ly.ok){ rows.push(row(k, v+' / '+ly.ngl_max, ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)')); return; }
rows.push(row(k, v));
});
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
if(m) rows.push(row('N-CPU-MOE', m[1]));
document.getElementById('cfg').innerHTML = rows.join('');
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
}
// Ligne de commande exacte du dernier lancement du moteur (amont #108),
// copiable pour la vérifier ou la partager. Clé API masquée côté serveur.
let ENGINE_CMDLINE='';
async function engineCmdlineRow(){
const cl=await jget('/api/engine/cmdline').catch(()=>null);
ENGINE_CMDLINE=(cl&&cl.cmdline)||'';
if(!ENGINE_CMDLINE) return '';
return '<div class="kv"><span>COMMANDE</span><span><button class="pe-link" onclick="copyText(ENGINE_CMDLINE, \'commande copiée\')" title="'
+escHtml(ENGINE_CMDLINE)+'">copier la commande du moteur</button></span></div>';
}
+15
View File
@@ -599,6 +599,20 @@ function onPickModel(){
ta.value = 'MODEL="'+val+'"\n' + ta.value;
}
toast('MODEL='+val);
showNglMax(val);
}
// Nombre de couches du modèle du preset (amont #43), lu dans son en-tête GGUF :
// affiché à côté du champ NGL (placeholder, « / N » et survol).
async function showNglMax(model){
const e=document.getElementById('s-ngl'), u=document.getElementById('s-ngl-max'); if(!e) return;
e.placeholder='999'; e.title=''; if(u) u.textContent='';
if(!model) return;
const ly=await jget('/api/model/layers?model='+encodeURIComponent(model)).catch(()=>null);
if(!ly || !ly.ok) return;
e.placeholder=String(ly.ngl_max);
if(u) u.textContent='/ '+ly.ngl_max;
e.title=ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)';
}
// --- Vision (projecteur multimodal --mmproj) --------------------------------
@@ -1013,6 +1027,7 @@ function populateSettings(){
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
set('s-ctx', cfgReadKey('CTX'));
set('s-ngl', cfgReadKey('NGL'));
showNglMax(cfgReadKey('MODEL'));
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
+18 -1
View File
@@ -53,7 +53,9 @@ func capsFromBody(body chatReq) Caps {
func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) {
mu := &sync.Mutex{}
done := make(chan struct{})
exited := make(chan struct{})
go func() {
defer close(exited)
// 4 s (et non 15) : borne le temps qu'un dernier bout de flux peut rester
// coincé dans un buffer proxy (Cloudflare) faute d'octets pour le pousser.
t := time.NewTicker(4 * time.Second)
@@ -64,6 +66,15 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
return
case <-t.C:
mu.Lock()
// Arrêt demandé pendant l'attente du verrou : le handler peut déjà
// être revenu, et écrire dans son ResponseWriter fait planter le
// process (amont #105).
select {
case <-done:
mu.Unlock()
return
default:
}
_, err := w.Write([]byte(": ping\n\n"))
if flusher != nil {
flusher.Flush()
@@ -75,7 +86,13 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
}
}
}()
return mu, func() { close(done) }
var once sync.Once
// stop attend la fin de la goroutine : après son retour, plus aucune
// écriture ne peut partir vers w.
return mu, func() {
once.Do(func() { close(done) })
<-exited
}
}
// runChatStream est désormais un pur ABONNÉ au journal de la conversation serveur :
+2
View File
@@ -220,6 +220,8 @@ func newWebMux() *http.ServeMux {
api("/api/vram/reload", handleVramReload) // relance le moteur (le modèle se recharge)
api("/api/ram", handleRam)
api("/api/config", handleConfigEnv)
api("/api/engine/cmdline", handleEngineCmdline) // commande du dernier lancement du moteur (amont #108)
api("/api/model/layers", handleModelLayers) // nombre de couches du modèle, lu dans le GGUF (amont #43)
api("/api/paths", handlePaths)
api("/api/update", handleUpdateCheck)
api("/api/update/apply", handleUpdateApply)