mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-15 03:08:56 +02:00
Merge branch 'feat/moteur-strata' : moteur Strata, correctifs et presets repris d'AJEAN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
634cfb61f3
37 files changed
+3199
-72
No files matched your search
+5
-1
@@ -87,8 +87,12 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
|
||||
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
|
||||
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
|
||||
# et la plupart des serveurs MCP réclament Node ≥ 20.
|
||||
# python3 + python3-venv : l'installeur et le serveur du moteur Strata
|
||||
# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans
|
||||
# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés
|
||||
# à la demande depuis Paramètres → Moteur.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git tini ca-certificates curl gnupg \
|
||||
git tini ca-certificates curl gnupg python3 python3-venv \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -52,6 +52,16 @@ 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.
|
||||
|
||||
## 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 »,
|
||||
`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.
|
||||
|
||||
## Services externes
|
||||
|
||||
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
|
||||
|
||||
@@ -93,6 +93,33 @@ la recherche qu'au téléchargement. À défaut, la variable d'environnement
|
||||
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
|
||||
n'est envoyé qu'aux adresses Hugging Face.
|
||||
|
||||
## Choisir son moteur : llama.cpp ou Strata
|
||||
|
||||
Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
|
||||
|
||||
- **llama.cpp** (par défaut) — n'importe quel GGUF, réglages complets dans
|
||||
l'éditeur de preset.
|
||||
- **[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
|
||||
SHA-256).
|
||||
|
||||
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
|
||||
disque), propose la version (Classique ou Swift 1.5) et la qualité qui tient,
|
||||
puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go dans
|
||||
`/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).
|
||||
|
||||
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
|
||||
la plus petite qualité, pilote compatible CUDA 12.8. Le conteneur doit pouvoir
|
||||
verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
|
||||
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
|
||||
llama.cpp.
|
||||
|
||||
## Installation sur Unraid
|
||||
|
||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||
|
||||
@@ -64,4 +64,8 @@ services:
|
||||
- /mnt/user/appdata/loki/data:/data
|
||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||
- /mnt/user/appdata/loki/models:/models
|
||||
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
|
||||
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||
ulimits:
|
||||
memlock: -1
|
||||
restart: unless-stopped
|
||||
@@ -37,4 +37,8 @@ services:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: ["gpu"]
|
||||
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
|
||||
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||
ulimits:
|
||||
memlock: -1
|
||||
restart: unless-stopped
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
return applyPresetOrder(out), nil
|
||||
}
|
||||
|
||||
// presetOrderKey : ordre d'affichage choisi par glisser-déposer dans l'UI
|
||||
// (repris d'AJEAN v0.10.9), une liste d'IDs persistée en base.
|
||||
const presetOrderKey = "preset_order"
|
||||
|
||||
func loadPresetOrder() []string {
|
||||
var ids []string
|
||||
getJSON(bkState, presetOrderKey, &ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func savePresetOrder(ids []string) error { return putJSON(bkState, presetOrderKey, ids) }
|
||||
|
||||
// applyPresetOrder réordonne `list` (déjà trié par nom) selon l'ordre stocké :
|
||||
// d'abord les presets classés dans l'ordre choisi, puis les non classés
|
||||
// (fraîchement créés) dans leur ordre alphabétique. Tri STABLE.
|
||||
func applyPresetOrder(list []Preset) []Preset {
|
||||
order := loadPresetOrder()
|
||||
if len(order) == 0 {
|
||||
return list
|
||||
}
|
||||
pos := map[string]int{}
|
||||
for i, id := range order {
|
||||
pos[id] = i
|
||||
}
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
pi, oki := pos[list[i].ID]
|
||||
pj, okj := pos[list[j].ID]
|
||||
if oki && okj {
|
||||
return pi < pj
|
||||
}
|
||||
if oki != okj {
|
||||
return oki // un preset classé passe avant un non classé
|
||||
}
|
||||
return false // deux non classés : l'ordre alphabétique reste
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
// presetCtx : la taille de contexte d'un preset, formatée pour la pastille de
|
||||
// la liste (« 32K », « 128K », « 1M »). CTX absent = 32768, le défaut de serve.
|
||||
func presetCtx(content string) string {
|
||||
raw := strings.TrimSpace(parseEnv(content)["CTX"])
|
||||
if raw == "" {
|
||||
raw = "32768"
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return ""
|
||||
}
|
||||
// Multiple de 1024 (32768, 131072…) : écrit comme on le pense, « 32K »,
|
||||
// « 128K » ; sinon décimal, comme on le tape (40000 → « 40K »).
|
||||
switch {
|
||||
case n >= 1024 && n%1024 == 0 && n < 1<<20:
|
||||
return strconv.Itoa(n/1024) + "K"
|
||||
case n >= 1000000:
|
||||
return strings.TrimSuffix(strconv.FormatFloat(float64(n)/1000000, 'f', 1, 64), ".0") + "M"
|
||||
case n >= 1000:
|
||||
return strconv.Itoa((n+500)/1000) + "K"
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// uniquePresetID derives a unique filename id from a display name, appending
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet
|
||||
// ordre ; les nouveaux (non classés) ensuite, par nom.
|
||||
func TestPresetOrder(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
for _, id := range []string{"alpha", "bravo", "charlie", "delta"} {
|
||||
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ids := func() []string {
|
||||
list, err := ListPresets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for _, p := range list {
|
||||
out = append(out, p.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := ids(); got[0] != "alpha" || got[3] != "delta" {
|
||||
t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got)
|
||||
}
|
||||
if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"charlie", "alpha", "bravo", "delta"}
|
||||
got := ids()
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("ordre %v, attendu %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetCtx(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "32K", // défaut de serve : 32768
|
||||
"CTX=40000\n": "40K",
|
||||
"CTX=\"131072\"\n": "128K",
|
||||
"CTX=1000000\n": "1M",
|
||||
"CTX=1500000\n": "1.5M",
|
||||
"CTX=512\n": "512",
|
||||
"CTX=abc\n": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := presetCtx(in); got != want {
|
||||
t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,20 @@ 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) {
|
||||
if trialDir != "" {
|
||||
return fmt.Errorf("l'optimiseur ne s'applique pas à un preset Strata")
|
||||
}
|
||||
return serveStrata(cfg)
|
||||
}
|
||||
bin := cfg["BIN"]
|
||||
if bin == "" {
|
||||
return fmt.Errorf("BIN non défini — lance « loki edit »")
|
||||
@@ -463,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.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,343 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strataArgs(t *testing.T, cfg map[string]any) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for _, a := range cfg["args"].([]any) {
|
||||
out = append(out, a.(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func argAfter(args []string, flag string) string {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigApplyTuning(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"exe": "/x/engine-cuda12/strata",
|
||||
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
|
||||
"port": 8080.0,
|
||||
}
|
||||
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
for flag, want := range map[string]string{"--prefill": "16384", "--short-read": "150", "--prompt-cache-root": "256", "--max-context": "65536", "--pack": "/p"} {
|
||||
if got := argAfter(a, flag); got != want {
|
||||
t.Errorf("%s = %q, attendu %q (args %v)", flag, got, want, a)
|
||||
}
|
||||
}
|
||||
if strings.Count(strings.Join(a, " "), "--prefill") != 1 {
|
||||
t.Errorf("--prefill dupliqué : %v", a)
|
||||
}
|
||||
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
|
||||
t.Errorf("aide 2e carte sans 2e carte : %v", a)
|
||||
}
|
||||
if out["port"] != 8081 || out["api_key"] != "k" || out["host"] != "127.0.0.1" {
|
||||
t.Errorf("port/clé/hôte : %v %v %v", out["port"], out["api_key"], out["host"])
|
||||
}
|
||||
// la config de l'installeur n'est pas modifiée
|
||||
if base["args"].([]any)[3] != "8192" {
|
||||
t.Error("la config de base a été modifiée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigHelperGPU(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"}, "gpu": 1.0,
|
||||
"vision": map[string]any{"exe": "v", "gpu": true}}
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--expert-cache-device1") != "auto" || !strataHasArg(a, "--remote-expert-opt") {
|
||||
t.Errorf("aide 2e carte absente : %v", a)
|
||||
}
|
||||
if strataHasArg(a, "--vram-reserve-mib") || strataHasArg(a, "700") {
|
||||
t.Errorf("réserve de VRAM gardée : %v", a)
|
||||
}
|
||||
if _, has := out["api_key"]; has {
|
||||
t.Error("api_key posée sans clé")
|
||||
}
|
||||
if _, has := out["gpu"]; has {
|
||||
t.Error("clé gpu gardée avec une carte d'aide (le serveur en ferait un --layer-split)")
|
||||
}
|
||||
if out["vision"].(map[string]any)["cuda_device"] != 0 {
|
||||
t.Errorf("encodeur pas sur la carte d'aide : %v", out["vision"])
|
||||
}
|
||||
if out["env"].(map[string]any)["STRATA_PLE_BATCH"] != "0" {
|
||||
t.Errorf("STRATA_PLE_BATCH absent : %v", out["env"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDropFits(t *testing.T) {
|
||||
q := strataQuants["IQ3_XXS"]
|
||||
env := strataEnv{Main: 1, Helper: 0, RAMGB: 46.9, GPUs: []strataGPU{{Index: 0, VRAMGB: 7.8}, {Index: 1, VRAMGB: 15.9}}}
|
||||
if !strataDropFits(q, env) {
|
||||
t.Error("serveur de test (46 Go + 3070) : le mode hors RAM devrait tenir")
|
||||
}
|
||||
f := strataFitFor(q, env, 0)
|
||||
if f.Mmap || !f.Drop {
|
||||
t.Errorf("fit = mmap %v drop %v, attendu drop", f.Mmap, f.Drop)
|
||||
}
|
||||
env.Helper = -1
|
||||
if strataDropFits(q, env) {
|
||||
t.Error("sans carte d'aide, pas de mode hors RAM")
|
||||
}
|
||||
env.Helper, env.RAMGB = 0, 32
|
||||
if strataDropFits(q, env) {
|
||||
t.Error("32 Go : ne tient pas, doit rester en mmap")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigDrop(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
pack := filepath.Join(home, "pack")
|
||||
_ = os.MkdirAll(pack, 0o755)
|
||||
base := map[string]any{"args": []any{"--pack", pack, "--pcie-frac", "0.3"}, "env": map[string]any{}}
|
||||
cfg := map[string]string{"STRATA_HELPER_GPU": "0", "STRATA_DROP": "1", "STRATA_MMAP": "0"}
|
||||
// sans experts.bin ni moteur à jour : repli mmap (ce lancement écrit experts.bin)
|
||||
out, _ := strataBuildConfig(base, cfg, "")
|
||||
if out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != "1" || out["env"].(map[string]any)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Fatalf("repli mmap attendu : %v", out["env"])
|
||||
}
|
||||
_ = 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)
|
||||
out, _ = strataBuildConfig(base, cfg, "")
|
||||
e := out["env"].(map[string]any)
|
||||
if e["STRATA_REMOTE_DROP"] != "1" || e["STRATA_ARENA_MMAP"] != nil {
|
||||
t.Errorf("mode hors RAM attendu : %v", e)
|
||||
}
|
||||
if a := strataArgs(t, out); strataHasArg(a, "--pcie-frac") {
|
||||
t.Errorf("--pcie-frac gardé (la part PCIe doit être automatique) : %v", a)
|
||||
}
|
||||
// carte d'aide coupée : pas de mode hors RAM
|
||||
cfg["STRATA_HELPER"] = "0"
|
||||
out, _ = strataBuildConfig(base, cfg, "")
|
||||
if out["env"].(map[string]any)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Error("mode hors RAM sans carte d'aide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigMmap(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts"}, "env": map[string]any{"X": "1"}}
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
env := out["env"].(map[string]any)
|
||||
if strataHasArg(a, "--mmap-experts") || argAfter(a, "--pcie-frac") != "0" || env["STRATA_ARENA_MMAP"] != "1" || env["X"] != "1" {
|
||||
t.Errorf("mmap validé non appliqué : %v %v", a, env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataCudaDevices(t *testing.T) {
|
||||
cases := []struct {
|
||||
main, helper, want string
|
||||
}{
|
||||
{"1", "0", "1,0"},
|
||||
{"0", "-1", "0"},
|
||||
{"0", "", "0"},
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": c.main, "STRATA_HELPER_GPU": c.helper})
|
||||
if got != c.want {
|
||||
t.Errorf("main=%q helper=%q : %q, attendu %q", c.main, c.helper, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataRecommend(t *testing.T) {
|
||||
// la machine de test : 5060 Ti 16 Go + 3070 8 Go, 47 Go de RAM, disque large
|
||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 400,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||
if got := strataRecommend("swift", env); got != "IQ3_XXS" {
|
||||
t.Errorf("swift : %q, attendu IQ3_XXS", got)
|
||||
}
|
||||
// IQ3_S demande de garder 50 Go d'experts en RAM : pas sur 47 Go
|
||||
if got := strataRecommend("qwen", env); got != "IQ3_XXS" {
|
||||
t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got)
|
||||
}
|
||||
f := strataFitFor(strataQuants["IQ3_XXS"], env, 0)
|
||||
// trop juste pour tout garder en RAM, mais la 3070 garde les siens : mode hors RAM
|
||||
if !f.OK || f.Mmap || !f.Drop {
|
||||
t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, hors RAM)", f.OK, f.Mmap, f.Drop)
|
||||
}
|
||||
// 64 Go : IQ3_S tient en RAM
|
||||
env.RAMGB = 64
|
||||
if got := strataRecommend("qwen", env); got != "IQ3_S" {
|
||||
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
|
||||
}
|
||||
// 16 Go de RAM et une seule carte de 8 Go : rien ne tient
|
||||
small := strataEnv{Supported: true, Main: 0, Helper: -1, RAMGB: 16, DiskFreeGB: 400,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}}}
|
||||
if got := strataRecommend("swift", small); got != "" {
|
||||
t.Errorf("petite machine : %q, attendu aucune recommandation", got)
|
||||
}
|
||||
// disque trop petit
|
||||
env.DiskFreeGB = 50
|
||||
if f := strataFitFor(strataQuants["Q2_0"], env, 0); f.OK {
|
||||
t.Error("Q2_0 accepté avec 50 Go de disque")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataPresetName(t *testing.T) {
|
||||
if got := strataPresetName("swift", "IQ3_XXS"); got != "STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
if got := strataPresetName("qwen", "Q2_0"); got != "STRATA FLASH NEXT CLASSIQUE Q2_0" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigMmapFromPreset(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p"}}
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_MMAP": "1"}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--pcie-frac") != "0" || out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != "1" {
|
||||
t.Errorf("STRATA_MMAP=1 non appliqué : %v %v", a, out["env"])
|
||||
}
|
||||
out, _ = strataBuildConfig(base, map[string]string{"STRATA_MMAP": "0"}, "")
|
||||
if out["env"].(map[string]any)["STRATA_ARENA_MMAP"] != nil {
|
||||
t.Errorf("mmap appliqué sans STRATA_MMAP : %v", out["env"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataFitCountsExistingData(t *testing.T) {
|
||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 32,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 120); !f.OK {
|
||||
t.Errorf("données déjà présentes, refusé quand même : %s", f.Why)
|
||||
}
|
||||
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 0); f.OK {
|
||||
t.Error("32 Go libres sans données : accepté")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigPresetOverrides(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}}
|
||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "")
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
|
||||
t.Errorf("%v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDetailsFor(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
base := filepath.Join(home, "base.json")
|
||||
if err := os.WriteFile(base, []byte(`{"args":["--pack","/p","--kv","int8","--spec","4","--max-context","131072"],"vision":{"exe":"v"}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_MMAP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\n"
|
||||
if err := os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3070", VRAMGB: 8}, {Index: 1, Name: "NVIDIA GeForce RTX 5060 Ti", VRAMGB: 16}}}
|
||||
d := strataDetailsFor("X", env)
|
||||
if d == nil {
|
||||
t.Fatal("pas de détails")
|
||||
}
|
||||
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" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataHaveGBPerChoice(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
write := func(rel string, n int) {
|
||||
p := filepath.Join(strataDataDir(), rel)
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
if err := os.WriteFile(p, make([]byte, n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("models/swift-IQ3_XXS/a.gguf", 3000)
|
||||
write("packs/swift-iq3_xxs/experts.bin", 2000)
|
||||
write("mtp/rt/x", 500)
|
||||
write("models/IQ2_XS/b.gguf", 7000) // un autre choix : ne compte pas pour swift IQ3_XXS
|
||||
if got := strataHaveGB("swift", "IQ3_XXS") * 1e9; got != 5500 {
|
||||
t.Errorf("swift IQ3_XXS : %v octets, attendu 5500", got)
|
||||
}
|
||||
if got := strataHaveGB("swift", "IQ2_XS") * 1e9; got != 500 {
|
||||
t.Errorf("swift IQ2_XS : %v octets, attendu 500 (seulement la tête MTP commune)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettings(t *testing.T) {
|
||||
in := "# NAME=x\nENGINE=strata\nCTX=131072\nSTRATA_KV=fp16\nSTRATA_SPEC=3\nSTRATA_HELPER_GPU=0\n"
|
||||
out, err := strataApplySettings(in, strataSettingsReq{Ctx: 65536, KV: "int8", Spec: 4, Vision: false, Helper: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := parseEnv(out)
|
||||
if cfg["CTX"] != "65536" || cfg["STRATA_KV"] != "int8" || cfg["STRATA_SPEC"] != "4" || cfg["STRATA_VISION"] != "0" ||
|
||||
cfg["STRATA_HELPER"] != "0" || cfg["STRATA_HELPER_GPU"] != "0" || cfg["ENGINE"] != "strata" {
|
||||
t.Errorf("%q", out)
|
||||
}
|
||||
if strings.Count(out, "CTX=") != 1 {
|
||||
t.Errorf("CTX dupliqué : %q", out)
|
||||
}
|
||||
if strataHelperOn(cfg) || strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}) != "1" {
|
||||
t.Error("carte d'aide coupée mais encore utilisée")
|
||||
}
|
||||
base := map[string]any{"args": []any{"--vision", "--pack", "/p"}, "vision": map[string]any{"exe": "v"}}
|
||||
b, _ := strataBuildConfig(base, cfg, "")
|
||||
if strataHasArg(strataArgs(t, b), "--vision") || b["vision"] != nil || strataHasArg(strataArgs(t, b), "--expert-cache-device1") {
|
||||
t.Errorf("vision / aide coupées mais présentes : %v", b)
|
||||
}
|
||||
for _, bad := range []strataSettingsReq{{Ctx: 1000, KV: "fp16", Spec: 3}, {Ctx: 32768, KV: "q2", Spec: 3}, {Ctx: 32768, KV: "fp16", Spec: 9}} {
|
||||
if _, err := strataApplySettings(in, bad); err == nil {
|
||||
t.Errorf("accepté : %+v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineOf(t *testing.T) {
|
||||
cases := map[string]string{"": engineLlamacpp, "llamacpp": engineLlamacpp, "strata": engineStrata, " Strata ": engineStrata, "moe": engineStrata, "autre": engineLlamacpp}
|
||||
for in, want := range cases {
|
||||
if got := engineOf(map[string]string{"ENGINE": in}); got != want {
|
||||
t.Errorf("ENGINE=%q : %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelLoadErrorStrata(t *testing.T) {
|
||||
ok := strataServeMarker + " (paquet AJEAN MoE 1.0)\nloading the model (the first start takes a minute or two) ...\nready: http://127.0.0.1:8080/v1 (OpenAI: /v1/chat/completions)\n"
|
||||
if got := modelLoadErrorFrom(ok); got != "" {
|
||||
t.Errorf("Strata prêt signalé en échec : %q", got)
|
||||
}
|
||||
crash := strataServeMarker + "\nTraceback (most recent call last):\n File \"server.py\", line 1\nRuntimeError: boom\n"
|
||||
if got := modelLoadErrorFrom(crash); !strings.Contains(got, "Strata") {
|
||||
t.Errorf("trace Python non signalée : %q", got)
|
||||
}
|
||||
// une trace d'un lancement PRÉCÉDENT ne compte pas
|
||||
if got := modelLoadErrorFrom(crash + strataServeMarker + "\nloading the model ...\n"); got != "" {
|
||||
t.Errorf("ancienne trace prise pour l'échec en cours : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,9 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
|
||||
if externalActive() {
|
||||
return errors.New("optimisation indisponible : le preset actif est une API externe")
|
||||
}
|
||||
if strataActive() {
|
||||
return errors.New("optimisation indisponible : le preset actif tourne sur Strata, réglé par sa propre fenêtre")
|
||||
}
|
||||
if err := tuneGuard(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -828,6 +828,12 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben
|
||||
if externalActive() {
|
||||
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
|
||||
}
|
||||
// Le protocole s'appuie sur /tokenize et sur la reprise du cache de
|
||||
// llama-server ; Strata n'a pas le premier et garde sa conversation en cache
|
||||
// malgré cache_prompt:false (la lecture serait comptée deux fois trop vite).
|
||||
if strataActive() {
|
||||
return nil, fmt.Errorf("benchmark indisponible : le preset actif tourne sur Strata (le débit s'affiche sous chaque réponse)")
|
||||
}
|
||||
if err := tuneGuard(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ func oaiHandler() http.Handler {
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())})
|
||||
pr.Out.Host = pr.In.Host
|
||||
// Strata refuse (403) un Host qu'il ne connaît pas (protection contre
|
||||
// le DNS rebinding) : il se voit appelé sous son propre nom.
|
||||
if strataActive() {
|
||||
pr.Out.Host = pr.Out.URL.Host
|
||||
}
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) {
|
||||
sendOAIError(w, http.StatusBadGateway,
|
||||
|
||||
+123
-17
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -472,6 +472,9 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
|
||||
base := lp.Director
|
||||
lp.Director = func(req *http.Request) {
|
||||
base(req)
|
||||
// Le moteur se voit appelé sous son propre nom, pas sous celui du relais :
|
||||
// Strata refuse (403) un Host inconnu (protection DNS rebinding).
|
||||
req.Host = llama.Host
|
||||
// Le client distant n'a pas la clé API de llama-server ; on l'injecte ici
|
||||
// (l'auth réelle est faite par le relais via la clé de liaison du compte).
|
||||
if apiKey != "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -41,6 +41,16 @@ 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)
|
||||
}
|
||||
bin := strings.TrimSpace(cfg["BIN"])
|
||||
if bin == "" {
|
||||
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
|
||||
|
||||
@@ -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
|
||||
|
||||
+472
-19
@@ -205,7 +205,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
|
||||
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
|
||||
@media (min-width:721px){
|
||||
.side{width:380px}
|
||||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px}
|
||||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag,.preset-meta .ctag,.preset-meta .captag{font-size:11px;padding:2px 9px;border-radius:4px}
|
||||
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px}
|
||||
}
|
||||
h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
@@ -301,6 +301,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
(étiquette comprise), plus sur la carte seule. */
|
||||
.gated-group{margin:0 0 10px;transition:opacity .15s}
|
||||
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
|
||||
/* L'aide « ? » reste survolable sur un réglage grisé : c'est justement là qu'on
|
||||
veut savoir à quoi il sert (repris d'AJEAN, #104). */
|
||||
.gated-group.gated .help{pointer-events:auto}
|
||||
.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none}
|
||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
||||
/* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se
|
||||
@@ -398,6 +401,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
|
||||
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
|
||||
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
.ctag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
/* Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
|
||||
(ampoule + niveau). Icônes en em : la bulle a la hauteur des autres tags. */
|
||||
.captag{flex-shrink:0;display:inline-flex;align-items:center;gap:4px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
.captag::before{content:"\200b";margin-right:-4px}
|
||||
.captag svg{display:block;width:1.3em;height:1.3em}
|
||||
/* Réordonnancement des presets (SortableJS) : clone plein qui suit le curseur,
|
||||
emplacement cible discret. */
|
||||
.preset-drag{cursor:grabbing;background:var(--row-bg-on);border:none;border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,.35);outline:none;opacity:1!important}
|
||||
.preset-ghost{opacity:.35}
|
||||
.preset-chosen{cursor:grabbing}
|
||||
/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas
|
||||
un réglage mais une DESTINATION — la réponse part sur internet, pas sur la
|
||||
carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil.
|
||||
@@ -802,6 +816,32 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
gris atténué du thème monochrome et devenait illisible en mode sombre. */
|
||||
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:4px;padding:1px 6px}
|
||||
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
|
||||
/* Strata (10b-strata.js) : liste des qualités, réglages et configuration. */
|
||||
.slist>.lc-modes#lc-engines{border-top:1px solid var(--border)}
|
||||
#lc-strata.moe-off{cursor:default}
|
||||
#lc-strata.moe-off:hover{background:none}
|
||||
#lc-strata.moe-off .lc-mode-t{color:var(--dim)}
|
||||
#strata-quants{overflow:hidden}
|
||||
#strata-quants .lc-mode.moe-sel{background:var(--accent-bg)}
|
||||
#strata-quants .lc-mode.moe-sel .lc-mode-t{color:var(--accent)}
|
||||
#strata-quants .lc-mode.moe-off{cursor:default}
|
||||
#strata-quants .lc-mode.moe-off:hover{background:none}
|
||||
#strata-quants .lc-mode.moe-off .lc-mode-t{color:var(--dim)}
|
||||
.moe-details{padding:4px 10px}
|
||||
.moe-details .moe-kv{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;padding:5px 0}
|
||||
.moe-details .moe-kv .switch{display:block}
|
||||
.moe-details .moe-kv:not(:last-child){border-bottom:1px solid var(--border)}
|
||||
.moe-details .moe-kv span:first-child{color:var(--dim)}
|
||||
.moe-details .moe-kv span:last-child{text-align:right}
|
||||
.moe-ctx{display:flex;align-items:center;gap:10px}
|
||||
.moe-ctx input[type=range]{width:160px;height:16px;margin:0;padding:0;background:transparent;border:none;-webkit-appearance:none;appearance:none;cursor:ew-resize}
|
||||
.moe-ctx input[type=range]:focus{outline:none}
|
||||
.moe-ctx input[type=range]::-webkit-slider-runnable-track{height:4px;border:none;border-radius:999px;background:linear-gradient(to right,var(--accent) var(--p,50%),color-mix(in srgb,var(--dim) 25%,transparent) var(--p,50%))}
|
||||
.moe-ctx input[type=range]::-moz-range-track{height:4px;border:none;border-radius:999px;background:color-mix(in srgb,var(--dim) 25%,transparent)}
|
||||
.moe-ctx input[type=range]::-moz-range-progress{height:4px;border-radius:999px;background:var(--accent)}
|
||||
.moe-ctx input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:none;border-radius:50%;background:var(--accent)}
|
||||
.moe-ctx input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:var(--accent)}
|
||||
.moe-ctx span{min-width:62px;text-align:right;font-variant-numeric:tabular-nums}
|
||||
/* Hauteur réservée : la ligne d'état arrive après l'appel réseau, sans elle
|
||||
les trois cartes grandissaient d'un cran une fois l'état connu. */
|
||||
.lc-mode-state{font-size:11px;min-height:16px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
@@ -2560,7 +2600,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
|
||||
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
|
||||
<div class="set-nav-h">Moteur</div>
|
||||
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
|
||||
<button data-pane="engine" onclick="settingsShow('engine')">Moteur</button>
|
||||
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
|
||||
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
|
||||
<div class="set-nav-h">Application</div>
|
||||
@@ -2762,7 +2802,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div></div>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="engine" id="lc-details">
|
||||
<div class="set-pane-h"><span>Moteur</span></div>
|
||||
<div class="set-pane-h"><span>Moteur</span><span class="muted" style="font-size:12px;font-weight:400">actif : <b id="lc-active-engine">…</b></span></div>
|
||||
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
|
||||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||
étaient des cartes dans une carte). -->
|
||||
@@ -2828,6 +2868,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<div class="lc-mode-state" id="lc-custom-state"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Deuxième moteur, hors des modes llama.cpp (masqués quand le moteur vient
|
||||
de l'image) : Strata, spécialisé Qwen3.8 Flash Next, paquet figé repris
|
||||
d'AJEAN (backend_strata.go). Le choix du moteur se fait par preset :
|
||||
activer un modèle Strata bascule sur Strata, un autre preset revient à
|
||||
llama.cpp. -->
|
||||
<div class="lc-modes" id="lc-engines">
|
||||
<div class="lc-mode" id="lc-strata" onclick="openStrata()">
|
||||
<div class="lc-mode-t">Strata — Qwen3.8 Flash Next</div>
|
||||
<div class="lc-mode-d">moteur spécialisé pour ce MoE de 125 B (experts répartis entre VRAM, RAM et disque), installation en un clic</div>
|
||||
<div class="lc-mode-state" id="lc-strata-state"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lc-job" class="rowbody" style="display:none">
|
||||
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
|
||||
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
|
||||
@@ -3097,6 +3149,46 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="strata-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeStrata()">
|
||||
<div class="modal-card" style="max-width:520px">
|
||||
<div class="modal-head">
|
||||
<strong>Strata — Qwen3.8 Flash Next</strong>
|
||||
<button class="modal-x" onclick="closeStrata()" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<div class="modal-body mform">
|
||||
<div id="strata-machine" class="muted" style="font-size:12px;line-height:1.5"></div>
|
||||
<div class="fld">
|
||||
<label class="fld-l">Version</label>
|
||||
<div class="seg" id="strata-fams"></div>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label class="fld-l">Qualité</label>
|
||||
<div class="slist" id="strata-quants"></div>
|
||||
</div>
|
||||
<div id="strata-disk" class="muted" style="font-size:12px;line-height:1.5"></div>
|
||||
<div class="fld" id="strata-settings-fld" hidden>
|
||||
<label class="fld-l">Réglages</label>
|
||||
<div class="slist moe-details" id="strata-settings"></div>
|
||||
</div>
|
||||
<div class="fld" id="strata-details-fld" hidden>
|
||||
<label class="fld-l">Configuration</label>
|
||||
<div class="slist moe-details" id="strata-details"></div>
|
||||
</div>
|
||||
<div id="strata-note" class="muted" style="font-size:11px;line-height:1.5"></div>
|
||||
<div class="muted" style="font-size:11px;line-height:1.5">Moteur <a href="https://github.com/Niko1221/Strata" target="_blank" rel="noopener">Strata</a> (MIT),
|
||||
paquet figé et vérifié (SHA-256) publié par <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener">AJEAN</a>.
|
||||
Linux + NVIDIA (RTX 20 ou plus récente). Le modèle est téléchargé dans <code>/data/strata</code>.</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<div class="foot-l"></div>
|
||||
<div class="foot-r">
|
||||
<button onclick="closeStrata()">fermer</button>
|
||||
<button id="strata-save" hidden onclick="strataSaveSettings()">Enregistrer</button>
|
||||
<button class="btn-save" id="strata-go" onclick="strataInstall()">Installer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<div class="modal-head">
|
||||
@@ -3368,6 +3460,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
|
||||
</div>
|
||||
<!-- Repris d'AJEAN v0.10.3 : n'apparaît qu'avec un projecteur choisi. -->
|
||||
<div class="pe-row" id="s-mmproj-cpu-row" style="display:none">
|
||||
<span class="pe-row-l">Projecteur vision sur le CPU<span class="pe-sub">garde le mmproj hors du GPU — libère de la VRAM, images lues plus lentement (--no-mmproj-offload)</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mmproj-cpu" onchange="eaToggleFlag('--no-mmproj-offload', this.checked)"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
|
||||
reste là : c'est le mode expert (dépôt privé, fichier exotique,
|
||||
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
|
||||
@@ -3467,7 +3564,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>
|
||||
@@ -3648,6 +3745,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt système DE CE preset (repris d'AJEAN v0.13.0) : il bascule avec
|
||||
le preset. Rangé en base (sysprompt:<id>), pas dans le .env qui ne
|
||||
porte qu'une ligne par clé. Vide = le prompt global s'applique. -->
|
||||
<div id="m-sys-row" class="pe-group" style="display:none">
|
||||
<div class="pe-gh">Prompt système</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row stack">
|
||||
<textarea id="m-sysprompt" class="pe-inp" rows="4" spellcheck="false" placeholder="(optionnel) consigne propre à ce preset — vide = prompt système général"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-raw" class="pe-group">
|
||||
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
||||
<div class="pe-list">
|
||||
@@ -3764,7 +3873,8 @@ async function loadSys(){
|
||||
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('loki.sys', d.text || ''); }
|
||||
}catch(e){}
|
||||
}
|
||||
// ===== Apparence =============================================================
|
||||
/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(tLine truncated
|
||||
// Un seul thème, en deux variantes : claire (par défaut) et sombre. La variante
|
||||
// est portée par l'attribut data-theme de <html> ('' ou 'dark') ; tout le reste
|
||||
// est du CSS (voir la section « Palette »). Persistée dans localStorage pour
|
||||
@@ -4382,6 +4492,27 @@ async function loadCfg(){
|
||||
const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
|
||||
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'"')+'">'+String(v)+'</span></div>';
|
||||
const rows=[];
|
||||
// Strata (backend_strata.go) : la config réelle de lancement, calculée par le
|
||||
// serveur comme au démarrage (/api/strata → details du modèle actif).
|
||||
if(/^(strata|moe)$/i.test((c.ENGINE||'').trim())){
|
||||
const s = await jget('/api/strata').catch(()=>null);
|
||||
let fam=null, fit=null;
|
||||
for(const f of (s&&s.families)||[]){ const x=f.fits.find(x=>x.active); if(x){ fam=f; fit=x; } }
|
||||
const d=fit&&fit.details;
|
||||
rows.push(row('MOTEUR', 'Strata', 'paquet AJEAN MoE '+((s&&s.version)||'')));
|
||||
if(typeof setLoadedModel === 'function') setLoadedModel('');
|
||||
if(fam) rows.push(row('MODEL', 'Qwen3.8 Flash Next '+fam.label+' '+fit.quant.id));
|
||||
if(d){
|
||||
const k=n=>(+n>=1024 ? Math.round(+n/1024)+'K' : n);
|
||||
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));
|
||||
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('') + await engineCmdlineRow();
|
||||
return;
|
||||
}
|
||||
if(c.BIN){
|
||||
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
||||
// toujours le chemin complet, quel que soit le libellé.
|
||||
@@ -4403,14 +4534,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,
|
||||
@@ -4474,7 +4619,9 @@ async function loadPresets(){
|
||||
const row=document.createElement('div');
|
||||
const pend = !x.active && pendingPreset===i+1;
|
||||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
||||
row.onclick=()=>switchTo(i+1, x.name, x.id);
|
||||
row.dataset.id = x.id;
|
||||
// Fin d'un glisser-déposer : le clic qui le termine ne bascule pas le preset.
|
||||
row.onclick=()=>{ if(presetJustDragged) return; switchTo(i+1, x.name, x.id); };
|
||||
const info=document.createElement('div'); info.className='preset-info';
|
||||
const nm=document.createElement('div'); nm.className='preset-name';
|
||||
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
||||
@@ -4502,12 +4649,27 @@ async function loadPresets(){
|
||||
q.textContent=x.quant; q.title='quantization';
|
||||
meta.appendChild(q);
|
||||
}
|
||||
if(x.reasoning){
|
||||
const rt=document.createElement('span'); rt.className='rtag';
|
||||
rt.title='raisonnement actif ('+x.reasoning+')';
|
||||
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>';
|
||||
rt.appendChild(document.createTextNode(x.reasoning));
|
||||
meta.appendChild(rt);
|
||||
if(x.ctx && !x.external){
|
||||
const c=document.createElement('span'); c.className='ctag';
|
||||
c.title='taille de contexte'; c.textContent=x.ctx;
|
||||
meta.appendChild(c);
|
||||
}
|
||||
// Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
|
||||
// (ampoule + niveau), regroupés en un seul tag.
|
||||
if(x.vision || x.reasoning){
|
||||
const cap=document.createElement('span'); cap.className='captag';
|
||||
const tip=[];
|
||||
if(x.vision){
|
||||
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>');
|
||||
tip.push('lit les images');
|
||||
}
|
||||
if(x.reasoning){
|
||||
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 21h4"/><path d="M12 3a6 6 0 0 0-4 10.5c.6.6 1 1.4 1 2.5h6c0-1.1.4-1.9 1-2.5A6 6 0 0 0 12 3z"/></svg>');
|
||||
cap.appendChild(document.createTextNode(x.reasoning));
|
||||
tip.push('raisonnement actif ('+x.reasoning+')');
|
||||
}
|
||||
cap.title=tip.join(' · ');
|
||||
meta.appendChild(cap);
|
||||
}
|
||||
if(x.bench){
|
||||
const bt=document.createElement('span'); bt.className='btag';
|
||||
@@ -4528,10 +4690,44 @@ async function loadPresets(){
|
||||
// Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
|
||||
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
|
||||
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
|
||||
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
|
||||
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : x.strata ? openStrata(x.id) : openPreset(x.id); };
|
||||
row.appendChild(info); row.appendChild(edit);
|
||||
cont.appendChild(row);
|
||||
});
|
||||
initPresetSortable(cont);
|
||||
}
|
||||
// ─── Réordonnancement des presets (SortableJS, repris d'AJEAN) ───────────────
|
||||
// La ligne entière se déplace, sans poignée. L'ordre est gardé côté serveur
|
||||
// (/api/presets/order) : il vaut aussi pour le sélecteur de l'en-tête.
|
||||
let presetJustDragged = false;
|
||||
let presetSortable = null;
|
||||
function initPresetSortable(cont){
|
||||
if(typeof Sortable==='undefined') return; // lib absente : la liste reste simple
|
||||
if(presetSortable) return; // une instance, sur le conteneur qui persiste
|
||||
presetSortable = Sortable.create(cont, {
|
||||
animation: 160,
|
||||
easing: 'cubic-bezier(.2,.7,.3,1)',
|
||||
// Clone géré par Sortable (et non l'image native, semi-transparente), posé
|
||||
// sur <body> : dans la modale (ancêtre transformé), il apparaissait loin
|
||||
// du curseur.
|
||||
forceFallback: true,
|
||||
fallbackTolerance: 3,
|
||||
fallbackOnBody: true,
|
||||
// Sur mobile, il faut MAINTENIR l'appui : un simple défilement attrapait un preset.
|
||||
delay: 220,
|
||||
delayOnTouchOnly: true,
|
||||
touchStartThreshold: 6,
|
||||
ghostClass: 'preset-ghost',
|
||||
chosenClass: 'preset-chosen',
|
||||
dragClass: 'preset-drag',
|
||||
onStart(){ presetJustDragged = true; },
|
||||
onEnd(){
|
||||
setTimeout(()=>{ presetJustDragged = false; }, 60);
|
||||
const ids=[...cont.children].map(r=>r.dataset.id).filter(Boolean);
|
||||
// Re-rendu une fois l'ordre persisté : les positions (pendingPreset) suivent.
|
||||
jpost('/api/presets/order', {ids}).then(()=>loadPresets()).catch(()=>{});
|
||||
},
|
||||
});
|
||||
}
|
||||
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
|
||||
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
|
||||
@@ -5036,7 +5232,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -5200,6 +5396,10 @@ async function openItem(kind, key){
|
||||
const modelRow = document.getElementById('m-model-row');
|
||||
const settingsRow = document.getElementById('m-settings-row');
|
||||
const samplingRow = document.getElementById('m-sampling-row');
|
||||
const sysRow = document.getElementById('m-sys-row');
|
||||
if(sysRow) sysRow.style.display = kind === 'preset' ? '' : 'none';
|
||||
const sysTa = document.getElementById('m-sysprompt');
|
||||
if(sysTa) sysTa.value = '';
|
||||
const rawHead = document.getElementById('m-raw-head');
|
||||
const rawToggle = document.getElementById('m-raw-toggle');
|
||||
const rawBody = document.getElementById('m-raw-body');
|
||||
@@ -5258,6 +5458,7 @@ async function openItem(kind, key){
|
||||
document.getElementById('m-name').value = display;
|
||||
document.getElementById('m-content').value = d.content || '';
|
||||
if(kind === 'preset'){
|
||||
if(sysTa) sysTa.value = d.sysprompt || '';
|
||||
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
|
||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||
populateSettings();
|
||||
@@ -5339,6 +5540,10 @@ async function populateModelPicker(){
|
||||
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
|
||||
const groups = [];
|
||||
for(const m of (list||[])){
|
||||
// Projecteurs vision : ils ont leur propre sélecteur (Vision) et ne se
|
||||
// lancent pas comme modèle (repris d'AJEAN v0.16.2). Gardé seulement s'il
|
||||
// est DÉJÀ le MODEL du preset, pour ne pas masquer une erreur de saisie.
|
||||
if(isMmprojName(m.name) && m !== hit) continue;
|
||||
let g = groups.find(x => x.dir === m.dir);
|
||||
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
|
||||
g.items.push(m);
|
||||
@@ -5641,6 +5846,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) --------------------------------
|
||||
@@ -5660,6 +5879,21 @@ function onPickMmproj(){
|
||||
// et on centralise le choix dans la clé MMPROJ.
|
||||
eaSetValued('--mmproj', '');
|
||||
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
|
||||
syncMmprojCpuRow();
|
||||
}
|
||||
// « Projecteur sur le CPU » n'a de sens qu'avec un mmproj chargé : la ligne est
|
||||
// masquée sans vision. Source de vérité = la config, pas le select (rempli
|
||||
// APRÈS cette synchro à l'ouverture du preset). Sans projecteur, le drapeau
|
||||
// resté dans EXTRA_ARGS est retiré.
|
||||
function syncMmprojCpuRow(){
|
||||
const row = document.getElementById('s-mmproj-cpu-row');
|
||||
if(!row) return;
|
||||
const on = !!currentMmprojInTextarea();
|
||||
row.style.display = on ? '' : 'none';
|
||||
if(!on && eaHasFlag('--no-mmproj-offload')){
|
||||
eaToggleFlag('--no-mmproj-offload', false);
|
||||
const c = document.getElementById('s-mmproj-cpu'); if(c) c.checked = false;
|
||||
}
|
||||
}
|
||||
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
|
||||
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
|
||||
@@ -6055,6 +6289,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'));
|
||||
@@ -6114,6 +6349,8 @@ function populateSettings(){
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
chk('s-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
|
||||
syncMmprojCpuRow();
|
||||
const lm = eaLoadMode();
|
||||
set('s-loadmode', lm);
|
||||
syncLoadModeSub(lm);
|
||||
@@ -6448,7 +6685,7 @@ async function saveItem(){
|
||||
// Presets: keyed by id (filename); duplicate display names are allowed.
|
||||
// Skills: keyed by name, rename via `old`.
|
||||
const payload = editingKind==='preset'
|
||||
? {id: editingKey, name, content}
|
||||
? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
|
||||
: {name, old: editingKey, content};
|
||||
const r = await jpost(K.saveUrl, payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
@@ -8354,7 +8591,7 @@ let lcSeenEnd = false, lcEndShown = false;
|
||||
function lcChipLabel(action){
|
||||
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
||||
prebuilt:'Téléchargement du moteur', engine:'Mise à jour du moteur',
|
||||
custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||||
custom:'Installation du backend', strata:'Installation de Strata'}[action] || 'Installation du moteur';
|
||||
}
|
||||
function lcChipSync(j){
|
||||
const chip = document.getElementById('lc-chip');
|
||||
@@ -8788,6 +9025,206 @@ function lcToggleLog(){
|
||||
if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; }
|
||||
else { pre.setAttribute('hidden',''); bar.classList.remove('open'); }
|
||||
}
|
||||
// Moteur Strata : installation « en un clic » du moteur spécialisé Qwen3.8 Flash
|
||||
// Next (repris d'AJEAN, « AJEAN MoE »). L'utilisateur choisit la version (Swift
|
||||
// ou Classique) et la qualité (quant) ; tout le reste (cartes, RAM, mmap,
|
||||
// réglages) est déduit côté serveur (backend_strata.go). L'installation est une
|
||||
// tâche comme celles de llama.cpp : même suivi (#lc-job), même pastille.
|
||||
let strataState = null, strataFam = '', strataQuant = '', strataTarget = '';
|
||||
|
||||
async function loadStrata(){
|
||||
let s;
|
||||
try{ s = await jget('/api/strata'); }catch(_){ return; }
|
||||
strataState = s;
|
||||
const row = document.getElementById('lc-strata');
|
||||
if(!row) return;
|
||||
// Hors Linux + NVIDIA, la ligne reste visible mais dit pourquoi : choisir son
|
||||
// moteur, c'est aussi savoir pourquoi l'un des deux n'est pas proposé.
|
||||
const env = s.env || {};
|
||||
row.classList.toggle('moe-off', !env.supported);
|
||||
const st = document.getElementById('lc-strata-state');
|
||||
if(st){
|
||||
const n = (s.installed||[]).length;
|
||||
st.textContent = !env.supported ? (env.reason || 'indisponible sur cette machine')
|
||||
: s.active ? 'moteur actif' + (n > 1 ? ' · '+n+' modèles installés' : '')
|
||||
: n ? n+' modèle(s) installé(s)' : '';
|
||||
}
|
||||
const eng = document.getElementById('lc-active-engine');
|
||||
if(eng) eng.textContent = s.active ? 'Strata' : 'llama.cpp';
|
||||
}
|
||||
|
||||
// presetId (optionnel) : ouverte depuis l'engrenage d'un modèle installé, la
|
||||
// fenêtre se place directement sur lui.
|
||||
async function openStrata(presetId){
|
||||
// état relu à chaque ouverture : juste après une installation, l'ancien
|
||||
// proposait encore « Installer »
|
||||
await loadStrata();
|
||||
if(!strataState) return;
|
||||
const s = strataState, env = s.env || {};
|
||||
if(!env.supported){ toast('Strata : '+(env.reason||'indisponible sur cette machine')); return; }
|
||||
strataTarget = typeof presetId === 'string' ? presetId : '';
|
||||
const cards = (env.gpus||[]).filter(g=>g.index===env.main || g.index===env.helper)
|
||||
.sort((a,b)=>(a.index===env.main?-1:1))
|
||||
.map(g=>g.name.replace(/^NVIDIA GeForce /,'')+' '+Math.round(g.vram_gb)+' Go');
|
||||
document.getElementById('strata-machine').textContent =
|
||||
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM, '
|
||||
+Math.round(env.disk_free_gb)+' Go libres. Réglages automatiques.';
|
||||
if(!strataFam) strataFam = (s.families[0]||{}).id || '';
|
||||
if(strataTarget){
|
||||
for(const f of s.families){
|
||||
const hit = f.fits.find(x=>x.preset===strataTarget);
|
||||
if(hit){ strataFam = f.id; strataQuant = hit.quant.id; }
|
||||
}
|
||||
}
|
||||
strataRenderFams();
|
||||
strataRenderQuants(!strataTarget);
|
||||
showModal('strata-modal');
|
||||
}
|
||||
function closeStrata(){ hideModal('strata-modal'); }
|
||||
|
||||
function strataRenderFams(){
|
||||
const box = document.getElementById('strata-fams');
|
||||
box.innerHTML = strataState.families.map(f=>
|
||||
'<label><input type="radio" name="strata-fam" value="'+f.id+'"'+(f.id===strataFam?' checked':'')
|
||||
+' onchange="strataFam=this.value;strataRenderQuants(true)">'
|
||||
+'<span>'+escHtml(f.label)+'</span><span class="be-note">'+escHtml(f.about)+'</span></label>').join('');
|
||||
}
|
||||
|
||||
function strataRenderQuants(pickReco){
|
||||
const f = strataState.families.find(x=>x.id===strataFam);
|
||||
if(!f) return;
|
||||
// à l'ouverture : le modèle actif s'il est de cette version, sinon un installé, sinon le conseillé
|
||||
if(pickReco || !f.fits.some(x=>x.quant.id===strataQuant && (x.ok || x.preset))){
|
||||
const cur = f.fits.find(x=>x.active) || f.fits.find(x=>x.preset);
|
||||
strataQuant = cur ? cur.quant.id : (f.recommend || '');
|
||||
}
|
||||
const box = document.getElementById('strata-quants');
|
||||
box.innerHTML = f.fits.map(x=>{
|
||||
const q = x.quant, sel = q.id===strataQuant;
|
||||
const tag = x.active ? 'actif' : x.preset ? 'installé' : q.id===f.recommend ? 'conseillé' : '';
|
||||
const reco = tag ? ' <span class="lc-reco">'+tag+'</span>' : '';
|
||||
const usable = x.ok || !!x.preset; // installé : activable même si l'espace disque manque
|
||||
const desc = x.preset ? escHtml(q.about)
|
||||
: x.ok ? escHtml(q.about)+' · ~'+Math.round(x.disk_gb)+' Go sur le disque'
|
||||
: escHtml(x.why);
|
||||
return '<div class="lc-mode'+(sel?' moe-sel':'')+(usable?'':' moe-off')+'"'
|
||||
+(usable?' onclick="strataQuant=\''+q.id+'\';strataRenderQuants(false)"':'')+'>'
|
||||
+'<div class="lc-mode-t">'+q.id+reco+'</div><div class="lc-mode-d">'+desc+'</div></div>';
|
||||
}).join('');
|
||||
const fit = f.fits.find(x=>x.quant.id===strataQuant);
|
||||
const go = document.getElementById('strata-go');
|
||||
go.disabled = !fit || !!fit.active;
|
||||
go.textContent = fit && fit.active ? 'Déjà actif' : fit && fit.preset ? 'Activer' : 'Installer';
|
||||
strataRenderDisk(fit);
|
||||
strataRenderSettings(fit);
|
||||
strataRenderDetails(fit);
|
||||
document.getElementById('strata-note').textContent = !fit ? 'Aucune version ne tient sur cette machine.'
|
||||
: fit.active ? 'Modèle actif.' : fit.preset ? 'Modèle installé, prêt à être activé.'
|
||||
: fit.mmap ? 'RAM insuffisante pour tout charger : experts lus depuis le disque (mmap, ~45 Go de plus). Installation et activation automatiques.'
|
||||
: 'Installation et activation automatiques. Premier chargement : quelques minutes.';
|
||||
}
|
||||
|
||||
// Place disque : libre, ce que ce choix occupe, ce qui restera.
|
||||
function strataRenderDisk(fit){
|
||||
const el = document.getElementById('strata-disk');
|
||||
const free = Math.round((strataState.env||{}).disk_free_gb||0);
|
||||
if(!fit){ el.textContent = ''; return; }
|
||||
if(fit.preset){ el.textContent = 'Disque : '+free+' Go libres. Modèle déjà présent.'; return; }
|
||||
const need = Math.round(fit.disk_gb), left = free - need;
|
||||
el.textContent = left >= 5
|
||||
? 'Disque : '+free+' Go libres, ~'+need+' Go requis, ~'+left+' Go restants.'
|
||||
: 'Disque : '+free+' Go libres, ~'+need+' Go requis. Espace insuffisant.';
|
||||
}
|
||||
|
||||
// Réglages modifiables d'un modèle installé.
|
||||
function strataRenderSettings(fit){
|
||||
const fld = document.getElementById('strata-settings-fld'), save = document.getElementById('strata-save');
|
||||
const d = fit && fit.details;
|
||||
fld.hidden = save.hidden = !d;
|
||||
if(!d) return;
|
||||
const sel = (id, opts, cur) => '<select class="ctl" id="'+id+'" style="width:auto">'
|
||||
+ opts.map(([v,l])=>'<option value="'+v+'"'+(String(v)===String(cur)?' selected':'')+'>'+escHtml(l)+'</option>').join('')+'</select>';
|
||||
const sw = (id, on) => '<label class="switch"><input type="checkbox" id="'+id+'"'+(on?' checked':'')+'><span class="slider"></span></label>';
|
||||
const rows = [
|
||||
['Contexte', '<span class="moe-ctx"><input type="range" id="strata-s-ctx" min="32768" max="262144" step="4096" value="'+(+d.ctx||131072)+'" oninput="strataCtxLbl()"><span id="strata-s-ctx-v"></span></span>'],
|
||||
['Cache KV', sel('strata-s-kv', [['fp16', 'fp16 (meilleure qualité)'],['int8', '8 bits (plus léger)']], d.kv)],
|
||||
['Prédiction (MTP)', sel('strata-s-spec', [[2,'2'],[3,'3'],[4,'4']], d.spec)],
|
||||
['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)]);
|
||||
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
|
||||
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
|
||||
strataCtxLbl();
|
||||
}
|
||||
// valeur exacte en jetons (131 072 et non « 128K », source de confusion)
|
||||
function strataCtxLbl(){
|
||||
const r = document.getElementById('strata-s-ctx'), o = document.getElementById('strata-s-ctx-v');
|
||||
if(!r || !o) return;
|
||||
o.textContent = (+r.value).toLocaleString('fr-FR');
|
||||
// partie remplie à gauche de la pastille (la piste est dessinée en CSS)
|
||||
r.style.setProperty('--p', ((r.value - r.min) / (r.max - r.min) * 100) + '%');
|
||||
}
|
||||
|
||||
async function strataSaveSettings(){
|
||||
const f = strataState && strataState.families.find(x=>x.id===strataFam);
|
||||
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
|
||||
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};
|
||||
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; }
|
||||
toast(r.restarted ? 'Réglages enregistrés, redémarrage en cours' : 'Réglages enregistrés');
|
||||
strataTarget = fit.preset;
|
||||
await openStrata(fit.preset);
|
||||
if(typeof loadPresets === 'function') loadPresets();
|
||||
}
|
||||
|
||||
// La config qui tourne (ou tournera) pour un modèle installé.
|
||||
function strataRenderDetails(fit){
|
||||
const fld = document.getElementById('strata-details-fld');
|
||||
const d = fit && fit.details;
|
||||
fld.hidden = !d;
|
||||
if(!d) return;
|
||||
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'],
|
||||
['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.drop ? "verrouillés en RAM, hors ceux de la carte d'appoint" : d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'],
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
|
||||
["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],
|
||||
];
|
||||
document.getElementById('strata-details').innerHTML = rows.map(([a,b])=>
|
||||
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+escHtml(b)+'</span></div>').join('');
|
||||
}
|
||||
|
||||
async function strataInstall(){
|
||||
const f = strataState && strataState.families.find(x=>x.id===strataFam);
|
||||
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
|
||||
if(!fit || fit.active) return;
|
||||
// déjà installé : simple bascule, la même que dans la liste des modèles
|
||||
if(fit.preset){
|
||||
closeStrata();
|
||||
await switchTo(-1, f.label+' '+strataQuant, fit.preset);
|
||||
loadStrata();
|
||||
return;
|
||||
}
|
||||
const msg = 'Installer Qwen3.8 Flash Next '+f.label+' '+strataQuant+' sur Strata ? Téléchargement : ~'
|
||||
+Math.round(fit.disk_gb)+' Go. Le modèle sera activé à la fin.';
|
||||
if(!await askConfirm(msg, {title:'Strata — Qwen3.8 Flash Next', okText:'Installer'})) return;
|
||||
const r = await jpost('/api/strata/install', {family:strataFam, quant:strataQuant});
|
||||
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
|
||||
closeStrata();
|
||||
const det = document.getElementById('lc-details');
|
||||
if(det && 'open' in det) det.open = true;
|
||||
lcStartPolling();
|
||||
}
|
||||
// 11-external.js — presets « externes » : le chat part vers une API
|
||||
// OpenAI-compatible distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre
|
||||
// machine…) au lieu du llama-server local.
|
||||
@@ -10342,7 +10779,8 @@ function renderModelSwitch(presets, active){
|
||||
async function loadModelSwitchFiles(){
|
||||
try{
|
||||
const r = await jget('/api/models');
|
||||
MS_FILES = Array.isArray(r) ? r : [];
|
||||
// Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas.
|
||||
MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||'')));
|
||||
}catch(_){ return; }
|
||||
paintModelSwitch();
|
||||
}
|
||||
@@ -11897,6 +12335,21 @@ const I18N_EN = {
|
||||
'Tâches planifiées': 'Scheduled tasks',
|
||||
'System prompt': 'System prompt',
|
||||
'Moteur llama.cpp': 'llama.cpp engine',
|
||||
// Moteur Strata (10b-strata.js)
|
||||
'Version': 'Version',
|
||||
'Qualité': 'Quality',
|
||||
'Configuration': 'Configuration',
|
||||
'Installer': 'Install',
|
||||
'Activer': 'Activate',
|
||||
'Déjà actif': 'Already active',
|
||||
'Cache KV': 'KV cache',
|
||||
'Prédiction (MTP)': 'Prediction (MTP)',
|
||||
'Lire les images': 'Read images',
|
||||
"Carte d'appoint": 'Helper card',
|
||||
'Carte principale': 'Main card',
|
||||
'conseillé': 'recommended',
|
||||
'installé': 'installed',
|
||||
'actif': 'active',
|
||||
'Journal du moteur': 'Engine log',
|
||||
'Dictée': 'Dictation',
|
||||
'Identité': 'Identity',
|
||||
|
||||
@@ -329,7 +329,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
|
||||
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
|
||||
<div class="set-nav-h">Moteur</div>
|
||||
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
|
||||
<button data-pane="engine" onclick="settingsShow('engine')">Moteur</button>
|
||||
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
|
||||
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
|
||||
<div class="set-nav-h">Application</div>
|
||||
@@ -531,7 +531,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div></div>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="engine" id="lc-details">
|
||||
<div class="set-pane-h"><span>Moteur</span></div>
|
||||
<div class="set-pane-h"><span>Moteur</span><span class="muted" style="font-size:12px;font-weight:400">actif : <b id="lc-active-engine">…</b></span></div>
|
||||
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
|
||||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||
étaient des cartes dans une carte). -->
|
||||
@@ -597,6 +597,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div class="lc-mode-state" id="lc-custom-state"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Deuxième moteur, hors des modes llama.cpp (masqués quand le moteur vient
|
||||
de l'image) : Strata, spécialisé Qwen3.8 Flash Next, paquet figé repris
|
||||
d'AJEAN (backend_strata.go). Le choix du moteur se fait par preset :
|
||||
activer un modèle Strata bascule sur Strata, un autre preset revient à
|
||||
llama.cpp. -->
|
||||
<div class="lc-modes" id="lc-engines">
|
||||
<div class="lc-mode" id="lc-strata" onclick="openStrata()">
|
||||
<div class="lc-mode-t">Strata — Qwen3.8 Flash Next</div>
|
||||
<div class="lc-mode-d">moteur spécialisé pour ce MoE de 125 B (experts répartis entre VRAM, RAM et disque), installation en un clic</div>
|
||||
<div class="lc-mode-state" id="lc-strata-state"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lc-job" class="rowbody" style="display:none">
|
||||
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
|
||||
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
|
||||
@@ -866,6 +878,46 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="strata-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeStrata()">
|
||||
<div class="modal-card" style="max-width:520px">
|
||||
<div class="modal-head">
|
||||
<strong>Strata — Qwen3.8 Flash Next</strong>
|
||||
<button class="modal-x" onclick="closeStrata()" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<div class="modal-body mform">
|
||||
<div id="strata-machine" class="muted" style="font-size:12px;line-height:1.5"></div>
|
||||
<div class="fld">
|
||||
<label class="fld-l">Version</label>
|
||||
<div class="seg" id="strata-fams"></div>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label class="fld-l">Qualité</label>
|
||||
<div class="slist" id="strata-quants"></div>
|
||||
</div>
|
||||
<div id="strata-disk" class="muted" style="font-size:12px;line-height:1.5"></div>
|
||||
<div class="fld" id="strata-settings-fld" hidden>
|
||||
<label class="fld-l">Réglages</label>
|
||||
<div class="slist moe-details" id="strata-settings"></div>
|
||||
</div>
|
||||
<div class="fld" id="strata-details-fld" hidden>
|
||||
<label class="fld-l">Configuration</label>
|
||||
<div class="slist moe-details" id="strata-details"></div>
|
||||
</div>
|
||||
<div id="strata-note" class="muted" style="font-size:11px;line-height:1.5"></div>
|
||||
<div class="muted" style="font-size:11px;line-height:1.5">Moteur <a href="https://github.com/Niko1221/Strata" target="_blank" rel="noopener">Strata</a> (MIT),
|
||||
paquet figé et vérifié (SHA-256) publié par <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener">AJEAN</a>.
|
||||
Linux + NVIDIA (RTX 20 ou plus récente). Le modèle est téléchargé dans <code>/data/strata</code>.</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<div class="foot-l"></div>
|
||||
<div class="foot-r">
|
||||
<button onclick="closeStrata()">fermer</button>
|
||||
<button id="strata-save" hidden onclick="strataSaveSettings()">Enregistrer</button>
|
||||
<button class="btn-save" id="strata-go" onclick="strataInstall()">Installer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<div class="modal-head">
|
||||
@@ -1137,6 +1189,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
|
||||
</div>
|
||||
<!-- Repris d'AJEAN v0.10.3 : n'apparaît qu'avec un projecteur choisi. -->
|
||||
<div class="pe-row" id="s-mmproj-cpu-row" style="display:none">
|
||||
<span class="pe-row-l">Projecteur vision sur le CPU<span class="pe-sub">garde le mmproj hors du GPU — libère de la VRAM, images lues plus lentement (--no-mmproj-offload)</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mmproj-cpu" onchange="eaToggleFlag('--no-mmproj-offload', this.checked)"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
|
||||
reste là : c'est le mode expert (dépôt privé, fichier exotique,
|
||||
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
|
||||
@@ -1236,7 +1293,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>
|
||||
@@ -1417,6 +1474,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt système DE CE preset (repris d'AJEAN v0.13.0) : il bascule avec
|
||||
le preset. Rangé en base (sysprompt:<id>), pas dans le .env qui ne
|
||||
porte qu'une ligne par clé. Vide = le prompt global s'applique. -->
|
||||
<div id="m-sys-row" class="pe-group" style="display:none">
|
||||
<div class="pe-gh">Prompt système</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row stack">
|
||||
<textarea id="m-sysprompt" class="pe-inp" rows="4" spellcheck="false" placeholder="(optionnel) consigne propre à ce preset — vide = prompt système général"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-raw" class="pe-group">
|
||||
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
||||
<div class="pe-list">
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(tLine truncated
|
||||
@@ -315,6 +315,27 @@ async function loadCfg(){
|
||||
const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
|
||||
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'"')+'">'+String(v)+'</span></div>';
|
||||
const rows=[];
|
||||
// Strata (backend_strata.go) : la config réelle de lancement, calculée par le
|
||||
// serveur comme au démarrage (/api/strata → details du modèle actif).
|
||||
if(/^(strata|moe)$/i.test((c.ENGINE||'').trim())){
|
||||
const s = await jget('/api/strata').catch(()=>null);
|
||||
let fam=null, fit=null;
|
||||
for(const f of (s&&s.families)||[]){ const x=f.fits.find(x=>x.active); if(x){ fam=f; fit=x; } }
|
||||
const d=fit&&fit.details;
|
||||
rows.push(row('MOTEUR', 'Strata', 'paquet AJEAN MoE '+((s&&s.version)||'')));
|
||||
if(typeof setLoadedModel === 'function') setLoadedModel('');
|
||||
if(fam) rows.push(row('MODEL', 'Qwen3.8 Flash Next '+fam.label+' '+fit.quant.id));
|
||||
if(d){
|
||||
const k=n=>(+n>=1024 ? Math.round(+n/1024)+'K' : n);
|
||||
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));
|
||||
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('') + await engineCmdlineRow();
|
||||
return;
|
||||
}
|
||||
if(c.BIN){
|
||||
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
||||
// toujours le chemin complet, quel que soit le libellé.
|
||||
@@ -336,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>';
|
||||
}
|
||||
@@ -60,7 +60,9 @@ async function loadPresets(){
|
||||
const row=document.createElement('div');
|
||||
const pend = !x.active && pendingPreset===i+1;
|
||||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
||||
row.onclick=()=>switchTo(i+1, x.name, x.id);
|
||||
row.dataset.id = x.id;
|
||||
// Fin d'un glisser-déposer : le clic qui le termine ne bascule pas le preset.
|
||||
row.onclick=()=>{ if(presetJustDragged) return; switchTo(i+1, x.name, x.id); };
|
||||
const info=document.createElement('div'); info.className='preset-info';
|
||||
const nm=document.createElement('div'); nm.className='preset-name';
|
||||
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
||||
@@ -88,12 +90,27 @@ async function loadPresets(){
|
||||
q.textContent=x.quant; q.title='quantization';
|
||||
meta.appendChild(q);
|
||||
}
|
||||
if(x.reasoning){
|
||||
const rt=document.createElement('span'); rt.className='rtag';
|
||||
rt.title='raisonnement actif ('+x.reasoning+')';
|
||||
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>';
|
||||
rt.appendChild(document.createTextNode(x.reasoning));
|
||||
meta.appendChild(rt);
|
||||
if(x.ctx && !x.external){
|
||||
const c=document.createElement('span'); c.className='ctag';
|
||||
c.title='taille de contexte'; c.textContent=x.ctx;
|
||||
meta.appendChild(c);
|
||||
}
|
||||
// Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
|
||||
// (ampoule + niveau), regroupés en un seul tag.
|
||||
if(x.vision || x.reasoning){
|
||||
const cap=document.createElement('span'); cap.className='captag';
|
||||
const tip=[];
|
||||
if(x.vision){
|
||||
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>');
|
||||
tip.push('lit les images');
|
||||
}
|
||||
if(x.reasoning){
|
||||
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 21h4"/><path d="M12 3a6 6 0 0 0-4 10.5c.6.6 1 1.4 1 2.5h6c0-1.1.4-1.9 1-2.5A6 6 0 0 0 12 3z"/></svg>');
|
||||
cap.appendChild(document.createTextNode(x.reasoning));
|
||||
tip.push('raisonnement actif ('+x.reasoning+')');
|
||||
}
|
||||
cap.title=tip.join(' · ');
|
||||
meta.appendChild(cap);
|
||||
}
|
||||
if(x.bench){
|
||||
const bt=document.createElement('span'); bt.className='btag';
|
||||
@@ -114,10 +131,44 @@ async function loadPresets(){
|
||||
// Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
|
||||
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
|
||||
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
|
||||
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
|
||||
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : x.strata ? openStrata(x.id) : openPreset(x.id); };
|
||||
row.appendChild(info); row.appendChild(edit);
|
||||
cont.appendChild(row);
|
||||
});
|
||||
initPresetSortable(cont);
|
||||
}
|
||||
// ─── Réordonnancement des presets (SortableJS, repris d'AJEAN) ───────────────
|
||||
// La ligne entière se déplace, sans poignée. L'ordre est gardé côté serveur
|
||||
// (/api/presets/order) : il vaut aussi pour le sélecteur de l'en-tête.
|
||||
let presetJustDragged = false;
|
||||
let presetSortable = null;
|
||||
function initPresetSortable(cont){
|
||||
if(typeof Sortable==='undefined') return; // lib absente : la liste reste simple
|
||||
if(presetSortable) return; // une instance, sur le conteneur qui persiste
|
||||
presetSortable = Sortable.create(cont, {
|
||||
animation: 160,
|
||||
easing: 'cubic-bezier(.2,.7,.3,1)',
|
||||
// Clone géré par Sortable (et non l'image native, semi-transparente), posé
|
||||
// sur <body> : dans la modale (ancêtre transformé), il apparaissait loin
|
||||
// du curseur.
|
||||
forceFallback: true,
|
||||
fallbackTolerance: 3,
|
||||
fallbackOnBody: true,
|
||||
// Sur mobile, il faut MAINTENIR l'appui : un simple défilement attrapait un preset.
|
||||
delay: 220,
|
||||
delayOnTouchOnly: true,
|
||||
touchStartThreshold: 6,
|
||||
ghostClass: 'preset-ghost',
|
||||
chosenClass: 'preset-chosen',
|
||||
dragClass: 'preset-drag',
|
||||
onStart(){ presetJustDragged = true; },
|
||||
onEnd(){
|
||||
setTimeout(()=>{ presetJustDragged = false; }, 60);
|
||||
const ids=[...cont.children].map(r=>r.dataset.id).filter(Boolean);
|
||||
// Re-rendu une fois l'ordre persisté : les positions (pendingPreset) suivent.
|
||||
jpost('/api/presets/order', {ids}).then(()=>loadPresets()).catch(()=>{});
|
||||
},
|
||||
});
|
||||
}
|
||||
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
|
||||
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
|
||||
@@ -622,7 +673,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -158,6 +158,10 @@ async function openItem(kind, key){
|
||||
const modelRow = document.getElementById('m-model-row');
|
||||
const settingsRow = document.getElementById('m-settings-row');
|
||||
const samplingRow = document.getElementById('m-sampling-row');
|
||||
const sysRow = document.getElementById('m-sys-row');
|
||||
if(sysRow) sysRow.style.display = kind === 'preset' ? '' : 'none';
|
||||
const sysTa = document.getElementById('m-sysprompt');
|
||||
if(sysTa) sysTa.value = '';
|
||||
const rawHead = document.getElementById('m-raw-head');
|
||||
const rawToggle = document.getElementById('m-raw-toggle');
|
||||
const rawBody = document.getElementById('m-raw-body');
|
||||
@@ -216,6 +220,7 @@ async function openItem(kind, key){
|
||||
document.getElementById('m-name').value = display;
|
||||
document.getElementById('m-content').value = d.content || '';
|
||||
if(kind === 'preset'){
|
||||
if(sysTa) sysTa.value = d.sysprompt || '';
|
||||
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
|
||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||
populateSettings();
|
||||
@@ -297,6 +302,10 @@ async function populateModelPicker(){
|
||||
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
|
||||
const groups = [];
|
||||
for(const m of (list||[])){
|
||||
// Projecteurs vision : ils ont leur propre sélecteur (Vision) et ne se
|
||||
// lancent pas comme modèle (repris d'AJEAN v0.16.2). Gardé seulement s'il
|
||||
// est DÉJÀ le MODEL du preset, pour ne pas masquer une erreur de saisie.
|
||||
if(isMmprojName(m.name) && m !== hit) continue;
|
||||
let g = groups.find(x => x.dir === m.dir);
|
||||
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
|
||||
g.items.push(m);
|
||||
@@ -599,6 +608,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) --------------------------------
|
||||
@@ -618,6 +641,21 @@ function onPickMmproj(){
|
||||
// et on centralise le choix dans la clé MMPROJ.
|
||||
eaSetValued('--mmproj', '');
|
||||
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
|
||||
syncMmprojCpuRow();
|
||||
}
|
||||
// « Projecteur sur le CPU » n'a de sens qu'avec un mmproj chargé : la ligne est
|
||||
// masquée sans vision. Source de vérité = la config, pas le select (rempli
|
||||
// APRÈS cette synchro à l'ouverture du preset). Sans projecteur, le drapeau
|
||||
// resté dans EXTRA_ARGS est retiré.
|
||||
function syncMmprojCpuRow(){
|
||||
const row = document.getElementById('s-mmproj-cpu-row');
|
||||
if(!row) return;
|
||||
const on = !!currentMmprojInTextarea();
|
||||
row.style.display = on ? '' : 'none';
|
||||
if(!on && eaHasFlag('--no-mmproj-offload')){
|
||||
eaToggleFlag('--no-mmproj-offload', false);
|
||||
const c = document.getElementById('s-mmproj-cpu'); if(c) c.checked = false;
|
||||
}
|
||||
}
|
||||
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
|
||||
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
|
||||
@@ -1013,6 +1051,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'));
|
||||
@@ -1072,6 +1111,8 @@ function populateSettings(){
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
chk('s-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
|
||||
syncMmprojCpuRow();
|
||||
const lm = eaLoadMode();
|
||||
set('s-loadmode', lm);
|
||||
syncLoadModeSub(lm);
|
||||
@@ -1406,7 +1447,7 @@ async function saveItem(){
|
||||
// Presets: keyed by id (filename); duplicate display names are allowed.
|
||||
// Skills: keyed by name, rename via `old`.
|
||||
const payload = editingKind==='preset'
|
||||
? {id: editingKey, name, content}
|
||||
? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
|
||||
: {name, old: editingKey, content};
|
||||
const r = await jpost(K.saveUrl, payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
|
||||
@@ -57,7 +57,7 @@ let lcSeenEnd = false, lcEndShown = false;
|
||||
function lcChipLabel(action){
|
||||
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
||||
prebuilt:'Téléchargement du moteur', engine:'Mise à jour du moteur',
|
||||
custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||||
custom:'Installation du backend', strata:'Installation de Strata'}[action] || 'Installation du moteur';
|
||||
}
|
||||
function lcChipSync(j){
|
||||
const chip = document.getElementById('lc-chip');
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
// Moteur Strata : installation « en un clic » du moteur spécialisé Qwen3.8 Flash
|
||||
// Next (repris d'AJEAN, « AJEAN MoE »). L'utilisateur choisit la version (Swift
|
||||
// ou Classique) et la qualité (quant) ; tout le reste (cartes, RAM, mmap,
|
||||
// réglages) est déduit côté serveur (backend_strata.go). L'installation est une
|
||||
// tâche comme celles de llama.cpp : même suivi (#lc-job), même pastille.
|
||||
let strataState = null, strataFam = '', strataQuant = '', strataTarget = '';
|
||||
|
||||
async function loadStrata(){
|
||||
let s;
|
||||
try{ s = await jget('/api/strata'); }catch(_){ return; }
|
||||
strataState = s;
|
||||
const row = document.getElementById('lc-strata');
|
||||
if(!row) return;
|
||||
// Hors Linux + NVIDIA, la ligne reste visible mais dit pourquoi : choisir son
|
||||
// moteur, c'est aussi savoir pourquoi l'un des deux n'est pas proposé.
|
||||
const env = s.env || {};
|
||||
row.classList.toggle('moe-off', !env.supported);
|
||||
const st = document.getElementById('lc-strata-state');
|
||||
if(st){
|
||||
const n = (s.installed||[]).length;
|
||||
st.textContent = !env.supported ? (env.reason || 'indisponible sur cette machine')
|
||||
: s.active ? 'moteur actif' + (n > 1 ? ' · '+n+' modèles installés' : '')
|
||||
: n ? n+' modèle(s) installé(s)' : '';
|
||||
}
|
||||
const eng = document.getElementById('lc-active-engine');
|
||||
if(eng) eng.textContent = s.active ? 'Strata' : 'llama.cpp';
|
||||
}
|
||||
|
||||
// presetId (optionnel) : ouverte depuis l'engrenage d'un modèle installé, la
|
||||
// fenêtre se place directement sur lui.
|
||||
async function openStrata(presetId){
|
||||
// état relu à chaque ouverture : juste après une installation, l'ancien
|
||||
// proposait encore « Installer »
|
||||
await loadStrata();
|
||||
if(!strataState) return;
|
||||
const s = strataState, env = s.env || {};
|
||||
if(!env.supported){ toast('Strata : '+(env.reason||'indisponible sur cette machine')); return; }
|
||||
strataTarget = typeof presetId === 'string' ? presetId : '';
|
||||
const cards = (env.gpus||[]).filter(g=>g.index===env.main || g.index===env.helper)
|
||||
.sort((a,b)=>(a.index===env.main?-1:1))
|
||||
.map(g=>g.name.replace(/^NVIDIA GeForce /,'')+' '+Math.round(g.vram_gb)+' Go');
|
||||
document.getElementById('strata-machine').textContent =
|
||||
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM, '
|
||||
+Math.round(env.disk_free_gb)+' Go libres. Réglages automatiques.';
|
||||
if(!strataFam) strataFam = (s.families[0]||{}).id || '';
|
||||
if(strataTarget){
|
||||
for(const f of s.families){
|
||||
const hit = f.fits.find(x=>x.preset===strataTarget);
|
||||
if(hit){ strataFam = f.id; strataQuant = hit.quant.id; }
|
||||
}
|
||||
}
|
||||
strataRenderFams();
|
||||
strataRenderQuants(!strataTarget);
|
||||
showModal('strata-modal');
|
||||
}
|
||||
function closeStrata(){ hideModal('strata-modal'); }
|
||||
|
||||
function strataRenderFams(){
|
||||
const box = document.getElementById('strata-fams');
|
||||
box.innerHTML = strataState.families.map(f=>
|
||||
'<label><input type="radio" name="strata-fam" value="'+f.id+'"'+(f.id===strataFam?' checked':'')
|
||||
+' onchange="strataFam=this.value;strataRenderQuants(true)">'
|
||||
+'<span>'+escHtml(f.label)+'</span><span class="be-note">'+escHtml(f.about)+'</span></label>').join('');
|
||||
}
|
||||
|
||||
function strataRenderQuants(pickReco){
|
||||
const f = strataState.families.find(x=>x.id===strataFam);
|
||||
if(!f) return;
|
||||
// à l'ouverture : le modèle actif s'il est de cette version, sinon un installé, sinon le conseillé
|
||||
if(pickReco || !f.fits.some(x=>x.quant.id===strataQuant && (x.ok || x.preset))){
|
||||
const cur = f.fits.find(x=>x.active) || f.fits.find(x=>x.preset);
|
||||
strataQuant = cur ? cur.quant.id : (f.recommend || '');
|
||||
}
|
||||
const box = document.getElementById('strata-quants');
|
||||
box.innerHTML = f.fits.map(x=>{
|
||||
const q = x.quant, sel = q.id===strataQuant;
|
||||
const tag = x.active ? 'actif' : x.preset ? 'installé' : q.id===f.recommend ? 'conseillé' : '';
|
||||
const reco = tag ? ' <span class="lc-reco">'+tag+'</span>' : '';
|
||||
const usable = x.ok || !!x.preset; // installé : activable même si l'espace disque manque
|
||||
const desc = x.preset ? escHtml(q.about)
|
||||
: x.ok ? escHtml(q.about)+' · ~'+Math.round(x.disk_gb)+' Go sur le disque'
|
||||
: escHtml(x.why);
|
||||
return '<div class="lc-mode'+(sel?' moe-sel':'')+(usable?'':' moe-off')+'"'
|
||||
+(usable?' onclick="strataQuant=\''+q.id+'\';strataRenderQuants(false)"':'')+'>'
|
||||
+'<div class="lc-mode-t">'+q.id+reco+'</div><div class="lc-mode-d">'+desc+'</div></div>';
|
||||
}).join('');
|
||||
const fit = f.fits.find(x=>x.quant.id===strataQuant);
|
||||
const go = document.getElementById('strata-go');
|
||||
go.disabled = !fit || !!fit.active;
|
||||
go.textContent = fit && fit.active ? 'Déjà actif' : fit && fit.preset ? 'Activer' : 'Installer';
|
||||
strataRenderDisk(fit);
|
||||
strataRenderSettings(fit);
|
||||
strataRenderDetails(fit);
|
||||
document.getElementById('strata-note').textContent = !fit ? 'Aucune version ne tient sur cette machine.'
|
||||
: fit.active ? 'Modèle actif.' : fit.preset ? 'Modèle installé, prêt à être activé.'
|
||||
: fit.mmap ? 'RAM insuffisante pour tout charger : experts lus depuis le disque (mmap, ~45 Go de plus). Installation et activation automatiques.'
|
||||
: 'Installation et activation automatiques. Premier chargement : quelques minutes.';
|
||||
}
|
||||
|
||||
// Place disque : libre, ce que ce choix occupe, ce qui restera.
|
||||
function strataRenderDisk(fit){
|
||||
const el = document.getElementById('strata-disk');
|
||||
const free = Math.round((strataState.env||{}).disk_free_gb||0);
|
||||
if(!fit){ el.textContent = ''; return; }
|
||||
if(fit.preset){ el.textContent = 'Disque : '+free+' Go libres. Modèle déjà présent.'; return; }
|
||||
const need = Math.round(fit.disk_gb), left = free - need;
|
||||
el.textContent = left >= 5
|
||||
? 'Disque : '+free+' Go libres, ~'+need+' Go requis, ~'+left+' Go restants.'
|
||||
: 'Disque : '+free+' Go libres, ~'+need+' Go requis. Espace insuffisant.';
|
||||
}
|
||||
|
||||
// Réglages modifiables d'un modèle installé.
|
||||
function strataRenderSettings(fit){
|
||||
const fld = document.getElementById('strata-settings-fld'), save = document.getElementById('strata-save');
|
||||
const d = fit && fit.details;
|
||||
fld.hidden = save.hidden = !d;
|
||||
if(!d) return;
|
||||
const sel = (id, opts, cur) => '<select class="ctl" id="'+id+'" style="width:auto">'
|
||||
+ opts.map(([v,l])=>'<option value="'+v+'"'+(String(v)===String(cur)?' selected':'')+'>'+escHtml(l)+'</option>').join('')+'</select>';
|
||||
const sw = (id, on) => '<label class="switch"><input type="checkbox" id="'+id+'"'+(on?' checked':'')+'><span class="slider"></span></label>';
|
||||
const rows = [
|
||||
['Contexte', '<span class="moe-ctx"><input type="range" id="strata-s-ctx" min="32768" max="262144" step="4096" value="'+(+d.ctx||131072)+'" oninput="strataCtxLbl()"><span id="strata-s-ctx-v"></span></span>'],
|
||||
['Cache KV', sel('strata-s-kv', [['fp16', 'fp16 (meilleure qualité)'],['int8', '8 bits (plus léger)']], d.kv)],
|
||||
['Prédiction (MTP)', sel('strata-s-spec', [[2,'2'],[3,'3'],[4,'4']], d.spec)],
|
||||
['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)]);
|
||||
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
|
||||
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
|
||||
strataCtxLbl();
|
||||
}
|
||||
// valeur exacte en jetons (131 072 et non « 128K », source de confusion)
|
||||
function strataCtxLbl(){
|
||||
const r = document.getElementById('strata-s-ctx'), o = document.getElementById('strata-s-ctx-v');
|
||||
if(!r || !o) return;
|
||||
o.textContent = (+r.value).toLocaleString('fr-FR');
|
||||
// partie remplie à gauche de la pastille (la piste est dessinée en CSS)
|
||||
r.style.setProperty('--p', ((r.value - r.min) / (r.max - r.min) * 100) + '%');
|
||||
}
|
||||
|
||||
async function strataSaveSettings(){
|
||||
const f = strataState && strataState.families.find(x=>x.id===strataFam);
|
||||
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
|
||||
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};
|
||||
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; }
|
||||
toast(r.restarted ? 'Réglages enregistrés, redémarrage en cours' : 'Réglages enregistrés');
|
||||
strataTarget = fit.preset;
|
||||
await openStrata(fit.preset);
|
||||
if(typeof loadPresets === 'function') loadPresets();
|
||||
}
|
||||
|
||||
// La config qui tourne (ou tournera) pour un modèle installé.
|
||||
function strataRenderDetails(fit){
|
||||
const fld = document.getElementById('strata-details-fld');
|
||||
const d = fit && fit.details;
|
||||
fld.hidden = !d;
|
||||
if(!d) return;
|
||||
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'],
|
||||
['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.drop ? "verrouillés en RAM, hors ceux de la carte d'appoint" : d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'],
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
|
||||
["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],
|
||||
];
|
||||
document.getElementById('strata-details').innerHTML = rows.map(([a,b])=>
|
||||
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+escHtml(b)+'</span></div>').join('');
|
||||
}
|
||||
|
||||
async function strataInstall(){
|
||||
const f = strataState && strataState.families.find(x=>x.id===strataFam);
|
||||
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
|
||||
if(!fit || fit.active) return;
|
||||
// déjà installé : simple bascule, la même que dans la liste des modèles
|
||||
if(fit.preset){
|
||||
closeStrata();
|
||||
await switchTo(-1, f.label+' '+strataQuant, fit.preset);
|
||||
loadStrata();
|
||||
return;
|
||||
}
|
||||
const msg = 'Installer Qwen3.8 Flash Next '+f.label+' '+strataQuant+' sur Strata ? Téléchargement : ~'
|
||||
+Math.round(fit.disk_gb)+' Go. Le modèle sera activé à la fin.';
|
||||
if(!await askConfirm(msg, {title:'Strata — Qwen3.8 Flash Next', okText:'Installer'})) return;
|
||||
const r = await jpost('/api/strata/install', {family:strataFam, quant:strataQuant});
|
||||
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
|
||||
closeStrata();
|
||||
const det = document.getElementById('lc-details');
|
||||
if(det && 'open' in det) det.open = true;
|
||||
lcStartPolling();
|
||||
}
|
||||
@@ -108,7 +108,8 @@ function renderModelSwitch(presets, active){
|
||||
async function loadModelSwitchFiles(){
|
||||
try{
|
||||
const r = await jget('/api/models');
|
||||
MS_FILES = Array.isArray(r) ? r : [];
|
||||
// Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas.
|
||||
MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||'')));
|
||||
}catch(_){ return; }
|
||||
paintModelSwitch();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,21 @@ const I18N_EN = {
|
||||
'Tâches planifiées': 'Scheduled tasks',
|
||||
'System prompt': 'System prompt',
|
||||
'Moteur llama.cpp': 'llama.cpp engine',
|
||||
// Moteur Strata (10b-strata.js)
|
||||
'Version': 'Version',
|
||||
'Qualité': 'Quality',
|
||||
'Configuration': 'Configuration',
|
||||
'Installer': 'Install',
|
||||
'Activer': 'Activate',
|
||||
'Déjà actif': 'Already active',
|
||||
'Cache KV': 'KV cache',
|
||||
'Prédiction (MTP)': 'Prediction (MTP)',
|
||||
'Lire les images': 'Read images',
|
||||
"Carte d'appoint": 'Helper card',
|
||||
'Carte principale': 'Main card',
|
||||
'conseillé': 'recommended',
|
||||
'installé': 'installed',
|
||||
'actif': 'active',
|
||||
'Journal du moteur': 'Engine log',
|
||||
'Dictée': 'Dictation',
|
||||
'Identité': 'Identity',
|
||||
|
||||
@@ -170,7 +170,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
|
||||
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
|
||||
@media (min-width:721px){
|
||||
.side{width:380px}
|
||||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px}
|
||||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag,.preset-meta .ctag,.preset-meta .captag{font-size:11px;padding:2px 9px;border-radius:4px}
|
||||
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px}
|
||||
}
|
||||
h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
@@ -266,6 +266,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
(étiquette comprise), plus sur la carte seule. */
|
||||
.gated-group{margin:0 0 10px;transition:opacity .15s}
|
||||
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
|
||||
/* L'aide « ? » reste survolable sur un réglage grisé : c'est justement là qu'on
|
||||
veut savoir à quoi il sert (repris d'AJEAN, #104). */
|
||||
.gated-group.gated .help{pointer-events:auto}
|
||||
.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none}
|
||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
||||
/* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se
|
||||
@@ -363,6 +366,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
|
||||
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
|
||||
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
.ctag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
/* Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
|
||||
(ampoule + niveau). Icônes en em : la bulle a la hauteur des autres tags. */
|
||||
.captag{flex-shrink:0;display:inline-flex;align-items:center;gap:4px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
.captag::before{content:"\200b";margin-right:-4px}
|
||||
.captag svg{display:block;width:1.3em;height:1.3em}
|
||||
/* Réordonnancement des presets (SortableJS) : clone plein qui suit le curseur,
|
||||
emplacement cible discret. */
|
||||
.preset-drag{cursor:grabbing;background:var(--row-bg-on);border:none;border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,.35);outline:none;opacity:1!important}
|
||||
.preset-ghost{opacity:.35}
|
||||
.preset-chosen{cursor:grabbing}
|
||||
/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas
|
||||
un réglage mais une DESTINATION — la réponse part sur internet, pas sur la
|
||||
carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil.
|
||||
@@ -767,6 +781,32 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
gris atténué du thème monochrome et devenait illisible en mode sombre. */
|
||||
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:4px;padding:1px 6px}
|
||||
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
|
||||
/* Strata (10b-strata.js) : liste des qualités, réglages et configuration. */
|
||||
.slist>.lc-modes#lc-engines{border-top:1px solid var(--border)}
|
||||
#lc-strata.moe-off{cursor:default}
|
||||
#lc-strata.moe-off:hover{background:none}
|
||||
#lc-strata.moe-off .lc-mode-t{color:var(--dim)}
|
||||
#strata-quants{overflow:hidden}
|
||||
#strata-quants .lc-mode.moe-sel{background:var(--accent-bg)}
|
||||
#strata-quants .lc-mode.moe-sel .lc-mode-t{color:var(--accent)}
|
||||
#strata-quants .lc-mode.moe-off{cursor:default}
|
||||
#strata-quants .lc-mode.moe-off:hover{background:none}
|
||||
#strata-quants .lc-mode.moe-off .lc-mode-t{color:var(--dim)}
|
||||
.moe-details{padding:4px 10px}
|
||||
.moe-details .moe-kv{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;padding:5px 0}
|
||||
.moe-details .moe-kv .switch{display:block}
|
||||
.moe-details .moe-kv:not(:last-child){border-bottom:1px solid var(--border)}
|
||||
.moe-details .moe-kv span:first-child{color:var(--dim)}
|
||||
.moe-details .moe-kv span:last-child{text-align:right}
|
||||
.moe-ctx{display:flex;align-items:center;gap:10px}
|
||||
.moe-ctx input[type=range]{width:160px;height:16px;margin:0;padding:0;background:transparent;border:none;-webkit-appearance:none;appearance:none;cursor:ew-resize}
|
||||
.moe-ctx input[type=range]:focus{outline:none}
|
||||
.moe-ctx input[type=range]::-webkit-slider-runnable-track{height:4px;border:none;border-radius:999px;background:linear-gradient(to right,var(--accent) var(--p,50%),color-mix(in srgb,var(--dim) 25%,transparent) var(--p,50%))}
|
||||
.moe-ctx input[type=range]::-moz-range-track{height:4px;border:none;border-radius:999px;background:color-mix(in srgb,var(--dim) 25%,transparent)}
|
||||
.moe-ctx input[type=range]::-moz-range-progress{height:4px;border-radius:999px;background:var(--accent)}
|
||||
.moe-ctx input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:none;border-radius:50%;background:var(--accent)}
|
||||
.moe-ctx input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:var(--accent)}
|
||||
.moe-ctx span{min-width:62px;text-align:right;font-variant-numeric:tabular-nums}
|
||||
/* Hauteur réservée : la ligne d'état arrive après l'appel réseau, sans elle
|
||||
les trois cartes grandissaient d'un cran une fois l'état connu. */
|
||||
.lc-mode-state{font-size:11px;min-height:16px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
|
||||
@@ -109,7 +109,8 @@ func modelLoadErrorFrom(log string) string {
|
||||
for i, l := range lines {
|
||||
// Un lancement refusé par LOAD_GUARD (backend_serve_moe.go) s'arrête
|
||||
// avant tout chargement : c'est lui, la dernière tentative.
|
||||
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) {
|
||||
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) ||
|
||||
strings.Contains(l, strataServeMarker) {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
@@ -117,8 +118,16 @@ func modelLoadErrorFrom(log string) string {
|
||||
for _, l := range lines[start:] {
|
||||
low := strings.ToLower(l)
|
||||
switch {
|
||||
case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"):
|
||||
case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"),
|
||||
strings.HasPrefix(l, "ready: http://"): // serveur de Strata prêt
|
||||
loaded = true
|
||||
// Serveur de Strata (Python) : une trace = il s'est arrêté, pas « il charge encore ».
|
||||
case strings.Contains(l, "Traceback (most recent call last)"):
|
||||
if reason == "" {
|
||||
reason = "le serveur de Strata s'est arrêté (trace Python dans le journal)"
|
||||
}
|
||||
case strings.Contains(l, "[strata] config"):
|
||||
reason = "configuration de Strata refusée (détail dans le journal) — réinstalle le modèle"
|
||||
case strings.Contains(low, "failed to create mtp context"),
|
||||
strings.Contains(low, "failed to load draft model"),
|
||||
strings.Contains(low, "failed to initialize speculative decoding context"):
|
||||
@@ -432,9 +441,27 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
|
||||
if m := strings.TrimSpace(cfg[extKeyModel]); m != "" {
|
||||
item["model"] = m
|
||||
}
|
||||
if strings.TrimSpace(cfg[extKeyVision]) == "1" {
|
||||
item["vision"] = true
|
||||
}
|
||||
out = append(out, item)
|
||||
continue
|
||||
}
|
||||
// Pastilles de la liste : taille de contexte, et l'œil quand le preset
|
||||
// charge un projecteur vision (repris d'AJEAN v0.10.1 / v0.10.9).
|
||||
if c := presetCtx(content); c != "" {
|
||||
item["ctx"] = c
|
||||
}
|
||||
if strings.TrimSpace(parseEnv(content)["MMPROJ"]) != "" {
|
||||
item["vision"] = true
|
||||
}
|
||||
// Strata : réglages dans sa propre fenêtre, pas l'éditeur llama.cpp.
|
||||
if cfg := parseEnv(content); isStrataConfig(cfg) {
|
||||
item["strata"] = true
|
||||
if cfg["STRATA_VISION"] != "0" {
|
||||
item["vision"] = true
|
||||
}
|
||||
}
|
||||
if q := detectQuant(content); q != "" {
|
||||
item["quant"] = q
|
||||
}
|
||||
@@ -465,6 +492,26 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// handlePresetsOrder (POST {ids}) : l'ordre de la liste choisi par glisser-déposer.
|
||||
func handlePresetsOrder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := savePresetOrder(req.IDs); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handlePreset(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
@@ -476,16 +523,20 @@ func handlePreset(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 404, map[string]any{"error": "not found"})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content})
|
||||
sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content,
|
||||
"sysprompt": getStr(bkState, sysPromptKeyPrefix+id)})
|
||||
}
|
||||
|
||||
// presetSaveReq is the preset editor payload. `id` identifies an existing
|
||||
// preset to update ("" creates a new one); `name` is the display name.
|
||||
// SysPrompt : le prompt système DE CE preset, édité dans l'éditeur (repris
|
||||
// d'AJEAN v0.13.0) ; nil = champ absent, on n'y touche pas.
|
||||
type presetSaveReq struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
DeleteModel bool `json:"deleteModel"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
DeleteModel bool `json:"deleteModel"`
|
||||
SysPrompt *string `json:"sysprompt,omitempty"`
|
||||
}
|
||||
|
||||
// saveReq is the skill editor payload (skills keep name-as-identity + rename).
|
||||
@@ -588,6 +639,12 @@ func handlePresetSave(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.SysPrompt != nil {
|
||||
if err := putStr(bkState, sysPromptKeyPrefix+newID, strings.TrimSpace(*req.SysPrompt)); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if applied {
|
||||
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID)
|
||||
fmt.Println(dim("[info] redémarrage du service..."))
|
||||
|
||||
@@ -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 :
|
||||
|
||||
@@ -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)
|
||||
@@ -256,7 +258,11 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée
|
||||
api("/api/engine/plan", handleEnginePlan) // sur clic : version visée + effet sur le rendu du gabarit
|
||||
api("/api/engine/rollback", handleEngineRollback) // revient au moteur d'avant la dernière bascule
|
||||
api("/api/strata", handleStrata) // Strata : machine, catalogue figé, recommandation, modèles installés
|
||||
api("/api/strata/install", handleStrataInstall) // job : paquet figé + installeur de Strata + preset (suivi par /api/llamacpp/job)
|
||||
api("/api/strata/settings", handleStrataSettings) // réglages d'un modèle Strata installé (relance s'il est actif)
|
||||
api("/api/presets", handlePresets)
|
||||
api("/api/presets/order", handlePresetsOrder) // ordre de la liste (glisser-déposer)
|
||||
api("/api/preset", handlePreset)
|
||||
api("/api/preset/save", handlePresetSave)
|
||||
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité
|
||||
|
||||
@@ -180,6 +180,10 @@ func visionEnabled() bool {
|
||||
if externalActive() {
|
||||
return externalVisionActive() // modèle distant déclaré multimodal
|
||||
}
|
||||
// Strata : l'encodeur d'images est installé avec le modèle, sans MMPROJ.
|
||||
if strataVisionActive() {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(ReadConfig()["MMPROJ"]) != ""
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user