From 127013b32b45242ae94425d9990e05e7f9aef313 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Wed, 7 Oct 2026 09:28:42 +0200 Subject: [PATCH] Correctifs repris d'AJEAN : chiffrement, preset externe, heartbeat, commande du moteur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- internal/loki/backend_external_serve_test.go | 43 ++++++ internal/loki/backend_serve.go | 10 ++ internal/loki/backend_strata.go | 1 + internal/loki/engine_cmdline.go | 87 ++++++++++++ internal/loki/mem_migrate.go | 140 ++++++++++++++++--- internal/loki/mem_migrate_test.go | 82 +++++++++++ internal/loki/mem_store.go | 40 ++++-- internal/loki/sys_install_linux.go | 3 + internal/loki/sys_service.go | 6 + internal/loki/sys_service_container.go | 6 + internal/loki/ui/index.html | 37 ++++- internal/loki/ui/src/index.tmpl.html | 2 +- internal/loki/ui/src/js/05-status.js | 20 ++- internal/loki/ui/src/js/07-models.js | 15 ++ internal/loki/web_chat.go | 19 ++- internal/loki/web_server.go | 2 + 16 files changed, 479 insertions(+), 34 deletions(-) create mode 100644 internal/loki/backend_external_serve_test.go create mode 100644 internal/loki/engine_cmdline.go diff --git a/internal/loki/backend_external_serve_test.go b/internal/loki/backend_external_serve_test.go new file mode 100644 index 0000000..2364627 --- /dev/null +++ b/internal/loki/backend_external_serve_test.go @@ -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) + } +} diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index 76bb3e3..58715a3 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -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. diff --git a/internal/loki/backend_strata.go b/internal/loki/backend_strata.go index a402078..f45f03d 100644 --- a/internal/loki/backend_strata.go +++ b/internal/loki/backend_strata.go @@ -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) } diff --git a/internal/loki/engine_cmdline.go b/internal/loki/engine_cmdline.go new file mode 100644 index 0000000..0edae87 --- /dev/null +++ b/internal/loki/engine_cmdline.go @@ -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 = "" + } + 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}) +} diff --git a/internal/loki/mem_migrate.go b/internal/loki/mem_migrate.go index 56e3244..7d9202b 100644 --- a/internal/loki/mem_migrate.go +++ b/internal/loki/mem_migrate.go @@ -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) } } } diff --git a/internal/loki/mem_migrate_test.go b/internal/loki/mem_migrate_test.go index e56e89d..e79357d 100644 --- a/internal/loki/mem_migrate_test.go +++ b/internal/loki/mem_migrate_test.go @@ -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) + } +} diff --git a/internal/loki/mem_store.go b/internal/loki/mem_store.go index 37bc302..db39837 100644 --- a/internal/loki/mem_store.go +++ b/internal/loki/mem_store.go @@ -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 } diff --git a/internal/loki/sys_install_linux.go b/internal/loki/sys_install_linux.go index aa1dfb9..db8aef8 100644 --- a/internal/loki/sys_install_linux.go +++ b/internal/loki/sys_install_linux.go @@ -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 diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index ff12968..b1a093f 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -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) diff --git a/internal/loki/sys_service_container.go b/internal/loki/sys_service_container.go index 1288ff3..4c59ac1 100644 --- a/internal/loki/sys_service_container.go +++ b/internal/loki/sys_service_container.go @@ -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 diff --git a/internal/loki/ui/index.html b/internal/loki/ui/index.html index 5ccaf8d..ada3851 100644 --- a/internal/loki/ui/index.html +++ b/internal/loki/ui/index.html @@ -3545,7 +3545,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
Couches sur GPU999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer - +
Experts MoE sur CPUvide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent) @@ -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 '
COMMANDE
'; } // --- 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')); diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html index b6033fd..8c73f55 100644 --- a/internal/loki/ui/src/index.tmpl.html +++ b/internal/loki/ui/src/index.tmpl.html @@ -1288,7 +1288,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
Couches sur GPU999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer - +
Experts MoE sur CPUvide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent) diff --git a/internal/loki/ui/src/js/05-status.js b/internal/loki/ui/src/js/05-status.js index 138b795..c19b8a7 100644 --- a/internal/loki/ui/src/js/05-status.js +++ b/internal/loki/ui/src/js/05-status.js @@ -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 '
COMMANDE
'; } diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js index 40478d3..8b5f696 100644 --- a/internal/loki/ui/src/js/07-models.js +++ b/internal/loki/ui/src/js/07-models.js @@ -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')); diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go index 7a1f5f0..e441491 100644 --- a/internal/loki/web_chat.go +++ b/internal/loki/web_chat.go @@ -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 : diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 57e9eb4..cfb6de9 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -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)