Compare commits

...
Author SHA1 Message Date
Claude 30ce3a4e86 MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
Choisir le modèle lui-même comme brouillon dans l'éditeur de preset
écrivait MODEL_DRAFT=<le modèle>, et Loki passait -md vers ce fichier :
common_speculative_init_result charge alors un second exemplaire complet
du modèle, au lieu de greffer le contexte MTP sur le modèle déjà en VRAM.
Swift 27B : « allocating 9628.34 MiB on device 0 » pour le brouillon,
moteur mort ; quand ça passait, couches renvoyées sur le CPU et 5 t/s au
lieu de 22 (prefill 290 t/s au lieu de 890).

probeSpec reconnaît ce cas (os.SameFile), specArgs l'ignore et le dit :
la tête MTP intégrée sert de brouillon sans -md. L'éditeur ne propose
plus le modèle principal dans la liste des brouillons.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 09:13:56 +00:00
LogiFlow a463ccbd9f Merge branch 'claude/wizardly-lamport-jfgrq5' : arrêt du moteur sans course avec la relance, VRAM rendue
Arrêt du moteur : attendre tout l'arbre (VRAM rendue avant la relance)
2026-10-08 10:08:13 +02:00
Claude 393004cb06 Arrêt du moteur : attendre tout l'arbre, pas seulement le chef
En conteneur, userSvcStop n'attendait que le processus `loki serve`. Avec
Strata, le moteur qui tient la VRAM (cache d'experts, ~9 Go) est un autre
processus qui finit de s'arrêter après le serveur Python : la relance
suivante démarrait llama-server sur une carte encore pleine (« cudaMalloc
failed: out of memory » à l'encodeur de vision) et Strata mesurait la RAM
disponible avec l'ancien moteur encore chargé (mode mmap choisi à tort).

stopProcTree relève l'arbre (session, groupe et descendants) avant tout
signal, SIGTERM puis SIGKILL aux survivants, et ne revient qu'une fois
tout parti.

Diagnostic : une pile d'appels citant load_model ne passe plus pour une
nouvelle tentative (la cause mémoire était perdue), et le chat donne
l'échec de chargement au lieu de « il démarre, réessaie ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014wfx8uW1un1WwWJ6xzAbkV
2026-10-08 07:18:53 +00:00
MichaelandClaude Opus 5.5 7d614324a0 Merge branch 'fix/tune-lock-race' : CI verte, verrou de l'optimiseur sans course
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:11 +02:00
MichaelandClaude Opus 5.5 853e59c085 Optimiseur : le ménage du verrou ne croise plus sa prise ni son rendu
TestTuneRecoverNeverReapsOwnLock échouait au hasard sous Linux (2 fois sur
5 déjà en v0.15.0, CI rouge). Le ménage (tuneReapStale) déplaçait un verrou
vivant, le relisait puis le remettait en place ; si release() passait entre
les deux, il ne trouvait plus le fichier et rendait l'inscription. Le verrou
remis en place devenait orphelin, et le ménage suivant le prenait pour une
optimisation interrompue : moteur relancé, preset « rétabli » à tort.

Un mutex de processus (tuneFileMu) sérialise désormais les opérations sur
le fichier — prise, réécriture, rendu, déplacement-relecture du ménage.
L'arrêt d'un essai orphelin et le retour du preset restent hors du mutex.
Vérifié sous Linux : 40 passages sans échec, suite complète verte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 15:29:08 +02:00
MichaelandClaude Opus 5.5 e7fb63b1dd Merge branch 'fix/strata-ram-mmap' : experts de Strata selon la RAM disponible, mode mmap
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:26:54 +02:00
MichaelandClaude Opus 5.5 6aaf9360b4 Strata : experts placés selon la RAM disponible, mode « depuis le disque » (mmap)
Vécu : 62 Go de RAM dont 18 déjà pris par le reste du serveur. Le mode
« carte d'appoint hors RAM » avait été choisi sur la RAM TOTALE, et le
premier lancement, chargé d'écrire experts.bin, a monté le moteur à ~41 Go
de mémoire anonyme : le noyau l'a tué (OOM).

- Placement décidé AU LANCEMENT sur la RAM disponible (MemAvailable), plus
  sur la RAM totale ; le mode retenu est écrit dans le journal du moteur.
- Nouveau mode « disque » : --mmap-experts, les experts sont lus dans les
  fichiers du modèle par le cache système, rien n'est chargé au démarrage.
  Choisi d'office quand la RAM disponible ne suffit pas.
- Plus de premier lancement qui écrit experts.bin : les modes « hors RAM »
  et « experts.bin mappé » ne servent que si le fichier existe déjà.
- Réglage « Experts » dans la fenêtre de Strata : auto, en RAM, ou depuis le
  disque ; la configuration montre le mode du dernier lancement et la RAM
  disponible de la machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:26:50 +02:00
MichaelandClaude Opus 5.5 c2a2cc5748 Merge branch 'feat/ajean-lot-images' : recherche d'images, aperçu du navigateur, vignettes, journal d'installation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 11:04:54 +02:00
MichaelandClaude Opus 5.5 ac6d8ad144 Journal d'installation : la progression de Strata visible, plus de lignes en double
- Les barres de progression qui se redessinent avec \r (téléchargement du
  modèle par l'installeur de Strata) n'apparaissaient jamais : le journal ne
  découpait que sur \n et restait figé pendant tout le téléchargement. Une
  version toutes les 10 s est montrée ; \r\n reste une fin de ligne.
- Le suivi du job relançait une requête chaque seconde sans attendre la
  précédente : quand le serveur était occupé, deux réponses ajoutaient les
  mêmes lignes au journal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:54:09 +02:00
MichaelandClaude Opus 5.5 5016396998 Repris d'AJEAN : recherche d'images, aperçu du navigateur, vignettes, liens d'images
- web_images (amont v0.17.7) : recherche d'images DuckDuckGo, pendant de
  web_search ; les images du web s'affichent dans le fil avec la loupe, sans
  Referer, et disparaissent si le site les refuse. Schéma minimal pour tenir
  le budget du préambule.
- Aperçu en direct du navigateur piloté (cu_autoshot.go) : une capture après
  chaque action browser_* qui change la page, gardée en RAM, jamais envoyée au
  modèle, retirée du journal en fin de tour ; une carte dans le fil, en fondu.
- Vignettes calculées par le serveur (?thumb=, amont v0.17.9) : ~560 px au
  lieu de l'original (7 Ko au lieu de 550 Ko sur une capture), chargées deux
  par deux avec un nouvel essai ; la loupe ouvre l'original.
- Un lien vers une image ([graphique](graph.png)) l'affiche au lieu d'un
  bouton de téléchargement.
- Chrome du computer use tué avec Loki sous Linux (Pdeathsig), même en cas
  d'arrêt brutal : plus de navigateur orphelin dans le conteneur.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 10:30:05 +02:00
36 changed files with 1660 additions and 239 deletions

No files matched your search

+29 -4
View File
@@ -44,20 +44,45 @@ func emitBuildLine(line string) {
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
type sinkWriter struct {
mu sync.Mutex
buf []byte
mu sync.Mutex
buf []byte
lastProg time.Time // dernière ligne de progression (\r) émise
}
// sinkProgressEvery : une barre de progression (tqdm, téléchargements de
// l'installeur de Strata) se redessine sur place avec \r, sans jamais finir sa
// ligne. On en montre une version toutes les 10 s : sans elle, un téléchargement
// de 76 Go laissait le journal figé pendant une heure ; toutes, elles le noieraient.
const sinkProgressEvery = 10 * time.Second
func (s *sinkWriter) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.buf = append(s.buf, p...)
for {
i := strings.IndexByte(string(s.buf), '\n')
i := strings.IndexAny(string(s.buf), "\r\n")
if i < 0 {
break
}
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
if s.buf[i] == '\n' {
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
s.buf = s.buf[i+1:]
continue
}
// \r : fin de ligne Windows (\r\n) ou barre qui se redessine. Le \n peut
// arriver dans l'écriture suivante : on attend de le savoir.
if i+1 >= len(s.buf) {
break
}
if s.buf[i+1] == '\n' {
emitBuildLine(string(s.buf[:i]))
s.buf = s.buf[i+2:]
continue
}
if prog := strings.TrimSpace(string(s.buf[:i])); prog != "" && time.Since(s.lastProg) >= sinkProgressEvery {
s.lastProg = time.Now()
emitBuildLine(prog)
}
s.buf = s.buf[i+1:]
}
return len(p), nil
+29
View File
@@ -0,0 +1,29 @@
package loki
import (
"reflect"
"testing"
"time"
)
// Une barre de progression (\r sans \n) doit apparaître dans le journal, sans
// le noyer ; \r\n reste une fin de ligne ordinaire, même coupé entre deux écritures.
func TestSinkWriterProgression(t *testing.T) {
var got []string
setBuildSink(func(l string) { got = append(got, l) })
defer setBuildSink(nil)
s := &sinkWriter{}
s.Write([]byte("Step 5\r"))
s.Write([]byte("\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r"))
s.Write([]byte(" modèle : 3 / 76 GB\r"))
s.lastProg = time.Now().Add(-time.Minute) // 10 s écoulées
s.Write([]byte(" modèle : 40 / 76 GB\r[ok] fini\n"))
// « 3 » finissait sa propre écriture : il attendait de savoir si un saut de
// ligne suivait, et part donc avec l'écriture suivante.
want := []string{"Step 5", "modèle : 1 / 76 GB", "modèle : 3 / 76 GB", "[ok] fini"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("lignes %q, attendu %q", got, want)
}
}
+1
View File
@@ -527,6 +527,7 @@ type serveSysInfo struct {
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
// Cartes listées par le moteur (--list-devices), lues seulement pour
+22
View File
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
return
}
si.Draft = p
if a, err := os.Stat(p); err == nil {
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
si.DraftIsModel = true
}
}
if g, err := ggufMeta(p); err == nil {
si.DraftGGUF = &g
}
@@ -241,6 +246,23 @@ func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) str
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
// (common_speculative_init_result charge tout fichier -md comme un modèle
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
// lieu de 22. On l'ignore : même tête, sans les poids en double.
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
c := make(map[string]string, len(cfg))
for k, v := range cfg {
c[k] = v
}
delete(c, "MODEL_DRAFT")
args, notes, auto = specArgs(c, extra, si)
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
}
mode := specMode(cfg)
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
switch mode {
+31
View File
@@ -1,6 +1,7 @@
package loki
import (
"os"
"reflect"
"slices"
"strings"
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
}},
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
si: func(s *serveSysInfo) {
s.Draft = s.Model
s.DraftGGUF = s.GGUF
s.DraftIsModel = true
}},
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
@@ -493,3 +501,26 @@ func TestSynthAcceptanceWarned(t *testing.T) {
t.Errorf("variable : %q", n)
}
}
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
func TestProbeSpecDraftIsModel(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
dir := t.TempDir()
model := dir + "/Swift-27B.gguf"
other := dir + "/mtp-Swift.gguf"
for _, p := range []string{model, other} {
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
t.Fatal(err)
}
}
si := serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
if !si.DraftIsModel {
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
}
si = serveSysInfo{Model: model}
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
if si.DraftIsModel || si.Draft != other {
t.Errorf("tête à part prise pour le modèle : %+v", si)
}
}
+276 -74
View File
@@ -129,9 +129,120 @@ const strataDropHeadroomGB = 7
// le contexte CUDA, l'encodeur d'images et la marge : 5,3 Gio sur une 3070 8 Go).
func strataHelperExpertsGB(vramGB float64) float64 { return max(vramGB-2.5, 0) }
// strataMmapExtraGB : le mode mmap écrit les experts dans un fichier à part
// (experts.bin), à compter en plus du téléchargement.
const strataMmapExtraGB = 45
// Où vivent les experts que les cartes ne gardent pas (clé STRATA_EXPERTS du
// preset : auto, ram ou disk), et le mode effectivement lancé :
//
// drop experts de la carte d'appoint hors RAM, le reste verrouillé en RAM
// (le plus rapide mesuré par AJEAN) — demande un experts.bin existant
// ram tous les experts chargés et verrouillés en RAM
// arena experts.bin existant mappé tel quel (cache système)
// disk experts lus dans les fichiers du modèle (--mmap-experts) : rien
// n'est chargé au démarrage, le cache système garde ce que la RAM
// permet. Un peu plus lent, mais jamais tué faute de RAM.
//
// Vécu (2026-10) : 62 Go de RAM dont 18 déjà pris par le reste du serveur, le
// mode « hors RAM » choisi sur la RAM TOTALE, et un premier lancement qui
// écrivait experts.bin en chargeant ~41 Go en mémoire anonyme : le noyau a tué
// le moteur. D'où : décision sur la RAM DISPONIBLE au lancement, et plus jamais
// de premier lancement qui écrit experts.bin.
const (
strataExpertsAuto = "auto"
strataExpertsRAM = "ram"
strataExpertsDisk = "disk"
)
// memAvailableGB : la RAM réellement disponible (MemAvailable : libre + cache
// récupérable), en Go. Hors Linux ou illisible : la RAM totale.
func memAvailableGB() float64 {
b, err := os.ReadFile("/proc/meminfo")
if err != nil {
return totalRAMGB()
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "MemAvailable:"); ok {
f := strings.Fields(v)
if len(f) > 0 {
if kb, err := strconv.ParseFloat(f[0], 64); err == nil {
return kb * 1024 / 1e9
}
}
}
}
return totalRAMGB()
}
// strataArenaGB : la taille des experts du quant d'un preset (lue dans le nom
// de sa config d'installeur : strata-swift-iq3_xxs.json → IQ3_XXS). 0 = inconnu.
func strataArenaGB(cfg map[string]string) float64 {
tag := strings.ToUpper(strataConfigTag(strings.TrimSpace(cfg["STRATA_CONFIG"])))
tag = strings.TrimPrefix(tag, "SWIFT-")
if q, ok := strataQuants[tag]; ok {
return q.ArenaGB
}
return 0
}
// strataPackHasExpertsBin : experts.bin est déjà dans le pack (écrit par un
// lancement précédent ou par l'installeur en mode peu de RAM).
func strataPackHasExpertsBin(args []string) bool {
for i := 0; i+1 < len(args); i++ {
if args[i] == "--pack" {
if _, err := os.Stat(filepath.Join(args[i+1], "experts.bin")); err == nil {
return true
}
}
}
return false
}
// strataResolveExperts : le mode lancé, d'après le choix du preset et la RAM
// disponible (availGB) ; helperGB = VRAM de la carte d'appoint (0 sans).
// Pure : la décision se teste en table.
func strataResolveExperts(cfg map[string]string, args []string, availGB, helperGB float64) string {
binReady := strataPackHasExpertsBin(args) && strataEngineInstalled() == strataEngineAsset
dropReady := binReady && strataHelperOn(cfg) && cfg["STRATA_DROP"] != "0"
arena := strataArenaGB(cfg)
switch strings.ToLower(strings.TrimSpace(cfg["STRATA_EXPERTS"])) {
case strataExpertsDisk:
if binReady {
return "arena"
}
return "disk"
case strataExpertsRAM:
if dropReady {
return "drop"
}
return "ram"
}
if arena <= 0 { // quant inconnu : on ne parie pas sur la RAM
return "disk"
}
if dropReady && availGB >= arena-strataHelperExpertsGB(helperGB)+strataDropHeadroomGB {
return "drop"
}
if availGB >= arena+strataLowRAMHeadroomGB {
return "ram"
}
if binReady {
return "arena"
}
return "disk"
}
// strataExpertsLabel : le mode en clair, pour l'interface et le journal.
func strataExpertsLabel(mode string) string {
switch mode {
case "drop":
return "verrouillés en RAM, hors ceux de la carte d'appoint"
case "ram":
return "chargés et verrouillés en RAM"
case "arena":
return "experts.bin mappé (cache système)"
case "disk":
return "lus depuis le disque (mmap)"
}
return mode
}
// strataMaxCtx : contexte natif maximal de Qwen3.8 Flash Next.
const strataMaxCtx = 262144
@@ -162,6 +273,7 @@ type strataEnv struct {
Main int `json:"main"` // index nvidia-smi de la carte principale
Helper int `json:"helper"` // index de la carte d'aide, -1 sans
RAMGB float64 `json:"ram_gb"`
AvailGB float64 `json:"avail_gb"` // RAM disponible (+ celle du moteur Strata en marche)
DiskFreeGB float64 `json:"disk_free_gb"`
Python string `json:"python,omitempty"`
}
@@ -171,6 +283,7 @@ const strataMinArch = 75
func strataDetect() strataEnv {
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
if f := diskFree(LokiHome()); f > 0 {
env.DiskFreeGB = float64(f) / 1e9
}
@@ -281,32 +394,60 @@ func strataHaveGB(family, quant string) float64 {
strataDirGB(filepath.Join(strataDataDir(), "mtp"))
}
// strataNeedsMmap : les experts ne tiennent pas en RAM avec la marge
// nécessaire, ils seront lus en mmap depuis un fichier à part (experts.bin).
func strataNeedsMmap(q strataQuant, ramGB float64) bool {
return ramGB < q.ArenaGB+strataLowRAMHeadroomGB
}
// strataDropFits : avec une carte d'aide, les experts qu'elle garde ne sont pas
// en RAM ; le reste est verrouillé en RAM (plus rapide que le mmap : génération
// ~64 au lieu de ~56 tok/s, lecture +17 à +38 %). Il faut que ce reste tienne.
func strataDropFits(q strataQuant, env strataEnv) bool {
if env.Helper < 0 {
return false
// strataEngineRSSGB : la RAM tenue par le moteur Strata qui tourne (serveur et
// moteur, arbre du service), en Go. Ajoutée à la RAM disponible quand on prédit
// pour CETTE machine : sinon le moteur en marche ferait croire qu'il ne tient pas.
func strataEngineRSSGB() float64 {
if !strataActive() {
return 0
}
for _, g := range env.GPUs {
if g.Index == env.Helper {
return env.RAMGB >= q.ArenaGB-strataHelperExpertsGB(g.VRAMGB)+strataDropHeadroomGB
root := readServicePID()
if root <= 0 {
return 0
}
entries, err := os.ReadDir("/proc")
if err != nil {
return 0
}
parent := map[int]int{}
rss := map[int]float64{}
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
b, err := os.ReadFile(filepath.Join("/proc", e.Name(), "status"))
if err != nil {
continue
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "PPid:"); ok {
parent[pid], _ = strconv.Atoi(strings.TrimSpace(v))
} else if v, ok := strings.CutPrefix(line, "VmRSS:"); ok {
if f := strings.Fields(v); len(f) > 0 {
kb, _ := strconv.ParseFloat(f[0], 64)
rss[pid] = kb * 1024 / 1e9
}
}
}
}
return false
var total float64
for pid, r := range rss {
for p, n := pid, 0; p > 0 && n < 64; p, n = parent[p], n+1 {
if p == root {
total += r
break
}
}
}
return total
}
// strataFit : ce qu'un quant demande sur cette machine, et s'il y tient.
type strataFit struct {
Quant strataQuant `json:"quant"`
DiskGB float64 `json:"disk_gb"`
Mmap bool `json:"mmap"`
Mmap bool `json:"mmap"` // en mode auto, les experts seraient lus depuis le disque
Drop bool `json:"drop"` // experts de la carte d'aide hors RAM, le reste verrouillé
OK bool `json:"ok"`
Why string `json:"why,omitempty"`
@@ -329,9 +470,15 @@ type strataDetails struct {
CacheRoot string `json:"cache_root"`
Mmap bool `json:"mmap"`
Drop bool `json:"drop"`
MainGPU string `json:"main_gpu"`
HelperGPU string `json:"helper_gpu,omitempty"`
VisionGPU string `json:"vision_gpu,omitempty"`
// Experts : le choix du preset (auto, ram, disk) ; Mode : le mode lancé
// (drop, ram, arena, disk) — celui du dernier lancement pour le preset
// actif, sinon celui que la RAM disponible donnerait maintenant.
Experts string `json:"experts"`
Mode string `json:"mode"`
ModeLabel string `json:"mode_label"`
MainGPU string `json:"main_gpu"`
HelperGPU string `json:"helper_gpu,omitempty"`
VisionGPU string `json:"vision_gpu,omitempty"`
// pour les réglages : ce que la machine permet et l'état actuel
HasHelper bool `json:"has_helper"` // une carte d'aide est disponible
HelperOn bool `json:"helper_on"`
@@ -353,7 +500,13 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
if json.Unmarshal(raw, &base) != nil {
return nil
}
out, err := strataBuildConfig(base, pc, "")
mode := strataResolveExperts(pc, strataArgList(base), env.AvailGB, strataHelperVRAM(pc, env.GPUs))
if active, _ := strataPresetActive(presetID); active {
if m := getStr(bkState, strataLaunchModeKey); m != "" {
mode = m
}
}
out, err := strataBuildConfig(base, pc, "", mode)
if err != nil {
return nil
}
@@ -379,9 +532,11 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
Spec: val("--spec"), Prefill: val("--prefill"), ShortRead: val("--short-read"),
CacheRoot: val("--prompt-cache-root"), MainGPU: gpuName(pc["STRATA_MAIN_GPU"]),
}
if e, ok := out["env"].(map[string]any); ok {
d.Mmap = fmt.Sprint(e["STRATA_ARENA_MMAP"]) == "1"
d.Drop = fmt.Sprint(e["STRATA_REMOTE_DROP"]) == "1"
d.Mode, d.ModeLabel = mode, strataExpertsLabel(mode)
d.Mmap, d.Drop = mode == "disk" || mode == "arena", mode == "drop"
d.Experts = strings.ToLower(strings.TrimSpace(pc["STRATA_EXPERTS"]))
if d.Experts != strataExpertsRAM && d.Experts != strataExpertsDisk {
d.Experts = strataExpertsAuto
}
if h := strings.TrimSpace(pc["STRATA_HELPER_GPU"]); h != "" && h != "-1" {
d.HasHelper = true
@@ -402,15 +557,11 @@ func strataDetailsFor(presetID string, env strataEnv) *strataDetails {
}
func strataFitFor(q strataQuant, env strataEnv, haveGB float64) strataFit {
f := strataFit{Quant: q, Mmap: strataNeedsMmap(q, env.RAMGB)}
if f.Mmap && strataDropFits(q, env) {
f.Mmap, f.Drop = false, true
}
// Prédiction du mode auto sur la RAM DISPONIBLE (pas la RAM totale) : sous
// la marge, les experts seront lus depuis le disque. Le mode « hors RAM » de
// la carte d'appoint demande un experts.bin, qu'une installation neuve n'a pas.
f := strataFit{Quant: q, Mmap: env.AvailGB < q.ArenaGB+strataLowRAMHeadroomGB}
f.DiskGB = q.DownloadGB
// les deux modes lisent les experts dans experts.bin, écrit au premier lancement
if f.Mmap || f.Drop {
f.DiskGB += strataMmapExtraGB
}
// ce qui est déjà là ne se retélécharge pas ; il reste toujours les
// environnements (Python, bibliothèques CUDA, moteur) : ~10 Go
f.DiskGB = max(f.DiskGB-haveGB, 10)
@@ -646,8 +797,9 @@ func strataRunInstall(req strataInstallReq) {
"STRATA_MAIN_GPU=" + strconv.Itoa(env.Main),
"STRATA_HELPER_GPU=" + strconv.Itoa(env.Helper),
"STRATA_VISION=1",
"STRATA_MMAP=" + strataBool(fit.Mmap),
"STRATA_DROP=" + strataBool(fit.Drop),
// experts placés au lancement selon la RAM disponible (strataResolveExperts)
"STRATA_EXPERTS=" + strataExpertsAuto,
"STRATA_DROP=1",
// la meilleure qualité validée par AJEAN : cache KV en fp16 (32K en
// VRAM, le reste en RAM pour ne pas prendre la place des experts) et 3
// jetons de brouillon MTP (mesuré meilleur que 4)
@@ -773,8 +925,9 @@ func strataArgList(cfg map[string]any) []string {
}
// strataBuildConfig lit la config de l'installeur et y applique les réglages
// du preset. Pure (testable) : ne lance rien.
func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string) (map[string]any, error) {
// du preset, avec les experts placés selon `mode` (strataResolveExperts). Pure
// (testable) : ne lance rien.
func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey, mode string) (map[string]any, error) {
raw, _ := json.Marshal(base)
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
@@ -811,32 +964,39 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string
}
}
}
// Mode « peu de RAM » : l'installeur écrit --mmap-experts / --resident-experts.
// On applique à la place la variante mesurée par AJEAN : le pack experts.bin
// mappé tel quel (STRATA_ARENA_MMAP, le système garde en cache ce que la RAM
// permet), sans part PCIe (le mappage n'en donne pas l'adresse GPU). Le
// moteur écrit experts.bin lui-même au premier démarrage s'il manque.
// Experts (strataResolveExperts) : les drapeaux de l'installeur
// (--mmap-experts / --resident-experts) sont remplacés par ceux du mode.
env := map[string]any{}
if e, ok := out["env"].(map[string]any); ok {
env = e
}
if hasHelper && cfg["STRATA_DROP"] == "1" && strataDropReady(args) {
// Les experts de la carte d'aide hors RAM, le reste verrouillé en RAM
// (strataDropFits) : la principale relit aussi par PCIe une part de ce
// qui lui manque (part automatique) ; la réserve de la carte d'aide est figée.
args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts")
args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts")
delete(env, "STRATA_ARENA_MMAP")
delete(env, "STRATA_REMOTE_DROP")
switch mode {
case "drop":
if !hasHelper {
break // sans carte d'appoint, c'est le mode « ram »
}
// Les experts de la carte d'aide hors RAM, le reste verrouillé en RAM :
// la principale relit aussi par PCIe une part de ce qui lui manque (part
// automatique) ; la réserve de la carte d'aide est figée.
args = strataDropFlagValue(args, "--pcie-frac")
delete(env, "STRATA_ARENA_MMAP")
env["STRATA_REMOTE_DROP"] = "1"
// ses experts sont relus de sa VRAM à la lecture d'un prompt, par les fils
// de copie : 8 fils et 32 tampons (le moteur en prendrait 32 et 128)
env["STRATA_STAGER_THREADS"] = "8"
env["STRATA_STAGER_RING"] = "32"
} else if cfg["STRATA_MMAP"] == "1" || cfg["STRATA_DROP"] == "1" || strataHasArg(args, "--mmap-experts") || strataHasArg(args, "--resident-experts") {
// (STRATA_DROP sans experts.bin : ce lancement l'écrit, en mmap)
args = strataDropFlag(strataDropFlag(args, "--mmap-experts"), "--resident-experts")
case "arena":
// experts.bin mappé tel quel (variante mesurée par AJEAN), sans part PCIe :
// le mappage n'en donne pas l'adresse GPU.
args = strataSetArg(args, "--pcie-frac", "0")
env["STRATA_ARENA_MMAP"] = "1"
case "disk":
// Lus dans les fichiers du modèle par le cache système : rien n'est
// chargé au démarrage. Pas de part PCIe non plus (mémoire non épinglée).
args = append(args, "--mmap-experts")
args = strataSetArg(args, "--pcie-frac", "0")
}
for _, kv := range strataEnvTuning {
k, v, _ := strings.Cut(kv, "=")
@@ -874,23 +1034,50 @@ func strataBuildConfig(base map[string]any, cfg map[string]string, apiKey string
return out, nil
}
// strataDropReady : le mode « experts de la carte d'aide hors RAM » lit les
// experts dans experts.bin (écrit au premier lancement, en mmap) et demande le
// moteur du paquet.
func strataDropReady(args []string) bool {
pack := ""
for i := 0; i+1 < len(args); i++ {
if args[i] == "--pack" {
pack = args[i+1]
// strataLaunchModeKey : le mode des experts du dernier lancement (bkState),
// montré dans la fenêtre de Strata pour le preset actif.
const strataLaunchModeKey = "strata_experts_mode"
// strataHelperVRAM : VRAM (Go) de la carte d'appoint du preset, 0 sans.
func strataHelperVRAM(cfg map[string]string, gpus []strataGPU) float64 {
if !strataHelperOn(cfg) {
return 0
}
h := strings.TrimSpace(cfg["STRATA_HELPER_GPU"])
for _, g := range gpus {
if strconv.Itoa(g.Index) == h {
return g.VRAMGB
}
}
if pack == "" {
return false
return 0
}
// strataPresetActive : le preset id est-il celui en service ?
func strataPresetActive(id string) (bool, error) {
list, err := ListPresets()
if err != nil {
return false, err
}
if _, err := os.Stat(filepath.Join(pack, "experts.bin")); err != nil {
return false
for _, p := range list {
if p.ID == id {
return p.Active, nil
}
}
return strataEngineInstalled() == strataEngineAsset
return false, nil
}
// strataGPUs : les cartes NVIDIA vues par nvidia-smi (nil si indisponible).
func strataGPUs() []strataGPU {
gpus, err := detectGPUs()
if err != nil {
return nil
}
var out []strataGPU
for _, g := range gpus {
mib, _ := strconv.ParseFloat(g.MemTotal, 64)
out = append(out, strataGPU{Index: g.Index, Name: g.Name, VRAMGB: mib / 1024})
}
return out
}
// strataEngineMarker : le paquet dont vient le moteur installé.
@@ -1009,7 +1196,14 @@ func serveStrata(cfg map[string]string) error {
fmt.Fprintf(os.Stderr, "[loki serve] moteur %s non installé (%v) : l'ancien reste en place\n", strataEngineAsset, err)
}
apiKey, _ := effectiveAPIKeyErr()
final, err := strataBuildConfig(base, cfg, apiKey)
// Experts : décidé ICI, sur la RAM disponible au moment du lancement (l'ancien
// moteur est déjà arrêté), pas sur la RAM totale.
avail := memAvailableGB()
mode := strataResolveExperts(cfg, strataArgList(base), avail, strataHelperVRAM(cfg, strataGPUs()))
fmt.Fprintf(os.Stderr, "%s experts : %s (choix %s, %.0f Go de RAM disponibles, %.0f Go d'experts)\n",
strataServeMarker, strataExpertsLabel(mode), firstNonEmpty(cfg["STRATA_EXPERTS"], strataExpertsAuto), avail, strataArenaGB(cfg))
_ = putStr(bkState, strataLaunchModeKey, mode)
final, err := strataBuildConfig(base, cfg, apiKey, mode)
if err != nil {
return err
}
@@ -1110,12 +1304,13 @@ func handleStrata(w http.ResponseWriter, r *http.Request) {
// strataSettingsReq : les réglages modifiables d'un modèle installé.
type strataSettingsReq struct {
Preset string `json:"preset"`
Ctx int `json:"ctx"`
KV string `json:"kv"`
Spec int `json:"spec"`
Vision bool `json:"vision"`
Helper bool `json:"helper"`
Preset string `json:"preset"`
Ctx int `json:"ctx"`
KV string `json:"kv"`
Spec int `json:"spec"`
Vision bool `json:"vision"`
Helper bool `json:"helper"`
Experts string `json:"experts"` // auto, ram ou disk ; vide = inchangé
}
// strataApplySettings réécrit les clés du preset (pure, testable).
@@ -1128,11 +1323,18 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
return "", fmt.Errorf("cache KV inconnu : %s", r.KV)
case r.Spec < 2 || r.Spec > 4:
return "", fmt.Errorf("MTP hors limites : %d", r.Spec)
case r.Experts != "" && r.Experts != strataExpertsAuto && r.Experts != strataExpertsRAM && r.Experts != strataExpertsDisk:
return "", fmt.Errorf("placement des experts inconnu : %s", r.Experts)
}
set := map[string]string{
"CTX": strconv.Itoa(r.Ctx), "STRATA_KV": r.KV, "STRATA_SPEC": strconv.Itoa(r.Spec),
"STRATA_VISION": strataBool(r.Vision), "STRATA_HELPER": strataBool(r.Helper),
}
keys := []string{"CTX", "STRATA_KV", "STRATA_SPEC", "STRATA_VISION", "STRATA_HELPER"}
if r.Experts != "" {
set["STRATA_EXPERTS"] = r.Experts
keys = append(keys, "STRATA_EXPERTS")
}
var lines []string
for _, l := range strings.Split(strings.TrimRight(content, "\n"), "\n") {
k, _, _ := strings.Cut(strings.TrimSpace(l), "=")
@@ -1141,7 +1343,7 @@ func strataApplySettings(content string, r strataSettingsReq) (string, error) {
}
lines = append(lines, l)
}
for _, k := range []string{"CTX", "STRATA_KV", "STRATA_SPEC", "STRATA_VISION", "STRATA_HELPER"} {
for _, k := range keys {
lines = append(lines, k+"="+set[k])
}
return strings.Join(lines, "\n") + "\n", nil
+125 -89
View File
@@ -31,7 +31,7 @@ func TestStrataBuildConfigApplyTuning(t *testing.T) {
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
"port": 8080.0,
}
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k")
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k", "ram")
if err != nil {
t.Fatal(err)
}
@@ -59,7 +59,7 @@ func TestStrataBuildConfigApplyTuning(t *testing.T) {
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"}, "")
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
if err != nil {
t.Fatal(err)
}
@@ -84,70 +84,6 @@ func TestStrataBuildConfigHelperGPU(t *testing.T) {
}
}
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
@@ -167,7 +103,7 @@ func TestStrataCudaDevices(t *testing.T) {
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,
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, AvailGB: 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)
@@ -177,12 +113,13 @@ func TestStrataRecommend(t *testing.T) {
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)
// trop juste pour tout garder en RAM : experts lus depuis le disque (le mode
// hors RAM demande un experts.bin, qu'une installation neuve n'a pas)
if !f.OK || !f.Mmap || f.Drop {
t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, disque)", f.OK, f.Mmap, f.Drop)
}
// 64 Go : IQ3_S tient en RAM
env.RAMGB = 64
env.RAMGB, env.AvailGB = 64, 64
if got := strataRecommend("qwen", env); got != "IQ3_S" {
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
}
@@ -208,22 +145,6 @@ func TestStrataPresetName(t *testing.T) {
}
}
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}}}
@@ -237,7 +158,7 @@ func TestStrataFitCountsExistingData(t *testing.T) {
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"}, "")
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "", "ram")
a := strataArgs(t, out)
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
t.Errorf("%v", a)
@@ -307,7 +228,7 @@ func TestStrataApplySettings(t *testing.T) {
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, "")
b, _ := strataBuildConfig(base, cfg, "", "ram")
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)
}
@@ -341,3 +262,118 @@ func TestModelLoadErrorStrata(t *testing.T) {
t.Errorf("ancienne trace prise pour l'échec en cours : %q", got)
}
}
// Où vont les experts : décidé sur la RAM DISPONIBLE. Vécu : 62 Go dont 18 pris
// ailleurs, mode « hors RAM » choisi sur la RAM totale, moteur tué par le noyau.
func TestStrataResolveExperts(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
pack := filepath.Join(home, "pack")
_ = os.MkdirAll(pack, 0o755)
args := []string{"--pack", pack}
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
// IQ3_XXS : 42,9 Go d'experts
cases := []struct {
experts string
avail float64
want string
}{
{"", 44, "disk"}, // le cas vécu : 44 Go disponibles, pas de experts.bin
{"", 60, "ram"}, // assez de RAM pour tout charger avec la marge
{"auto", 52.8, "disk"},
{"disk", 60, "disk"}, // choix de l'utilisateur
{"ram", 20, "ram"}, // choix de l'utilisateur, même à l'étroit
}
for _, c := range cases {
cfg["STRATA_EXPERTS"] = c.experts
if got := strataResolveExperts(cfg, args, c.avail, 12); got != c.want {
t.Errorf("experts=%q dispo=%.0f : %s, attendu %s", c.experts, c.avail, got, c.want)
}
}
// experts.bin présent (moteur du paquet) : le mode hors RAM et le mappage deviennent possibles
_ = 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)
cfg["STRATA_EXPERTS"] = ""
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
t.Errorf("44 Go + carte d'appoint 12 Go + experts.bin : %s, attendu drop", got)
}
if got := strataResolveExperts(cfg, args, 30, 12); got != "arena" {
t.Errorf("30 Go + experts.bin : %s, attendu arena", got)
}
cfg["STRATA_HELPER"] = "0"
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
t.Errorf("carte d'appoint coupée : %s, attendu arena", got)
}
cfg["STRATA_EXPERTS"] = "disk"
if got := strataResolveExperts(cfg, args, 60, 12); got != "arena" {
t.Errorf("disque demandé avec experts.bin : %s, attendu arena", got)
}
// quant inconnu : on ne parie pas sur la RAM
if got := strataResolveExperts(map[string]string{"STRATA_CONFIG": "/x/autre.json"}, nil, 500, 0); got != "disk" {
t.Errorf("quant inconnu : %s, attendu disk", got)
}
}
func TestStrataBuildConfigModes(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p", "--resident-experts", "--pcie-frac", "0.3"}, "env": map[string]any{"X": "1"}}
cfg := map[string]string{"STRATA_HELPER_GPU": "1"}
env := func(m map[string]any) map[string]any { return m["env"].(map[string]any) }
out, _ := strataBuildConfig(base, cfg, "", "disk")
a := strataArgs(t, out)
if !strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || argAfter(a, "--pcie-frac") != "0" ||
env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["X"] != "1" {
t.Errorf("disk : %v %v", a, env(out))
}
out, _ = strataBuildConfig(base, cfg, "", "ram")
a = strataArgs(t, out)
if strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["STRATA_REMOTE_DROP"] != nil {
t.Errorf("ram : %v %v", a, env(out))
}
out, _ = strataBuildConfig(base, cfg, "", "arena")
if a = strataArgs(t, out); argAfter(a, "--pcie-frac") != "0" || env(out)["STRATA_ARENA_MMAP"] != "1" {
t.Errorf("arena : %v %v", a, env(out))
}
out, _ = strataBuildConfig(base, cfg, "", "drop")
if a = strataArgs(t, out); strataHasArg(a, "--pcie-frac") || env(out)["STRATA_REMOTE_DROP"] != "1" {
t.Errorf("drop : la part PCIe doit être automatique : %v %v", a, env(out))
}
// drop sans carte d'appoint : rien de spécial, c'est le mode ram
out, _ = strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "-1"}, "", "drop")
if env(out)["STRATA_REMOTE_DROP"] != nil {
t.Error("mode hors RAM sans carte d'appoint")
}
}
func TestStrataApplySettingsExperts(t *testing.T) {
in := "ENGINE=strata\nSTRATA_EXPERTS=auto\n"
r := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Experts: "disk"}
out, err := strataApplySettings(in, r)
if err != nil || parseEnv(out)["STRATA_EXPERTS"] != "disk" {
t.Fatalf("%v %q", err, out)
}
r.Experts = "nvme"
if _, err := strataApplySettings(in, r); err == nil {
t.Error("placement inconnu accepté")
}
r.Experts = ""
if out, _ := strataApplySettings(in, r); parseEnv(out)["STRATA_EXPERTS"] != "auto" {
t.Errorf("vide doit laisser le choix en place : %q", out)
}
}
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
}
}
+46 -11
View File
@@ -146,6 +146,15 @@ func tuneActive() (tuneOwner, bool) {
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
var tuneHeld sync.Map
// tuneFileMu sérialise, DANS CE PROCESSUS, les opérations sur le fichier de
// verrou : prise, réécriture, rendu, et le déplacement-relecture du ménage
// (tuneReapStale). Sans lui, le ménage pouvait déplacer un verrou vivant
// pendant que release() le cherchait : release, ne le trouvant plus, rendait
// l'inscription ; le ménage le remettait ensuite en place, désormais orphelin,
// et le ménage suivant le prenait pour une optimisation interrompue (moteur
// relancé, preset « rétabli »). Vu en CI : TestTuneRecoverNeverReapsOwnLock.
var tuneFileMu sync.Mutex
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
@@ -201,6 +210,7 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
key := tuneHeldKey(l.owner)
tuneHeld.Store(key, true)
for attempt := 0; attempt < 3; attempt++ {
tuneFileMu.Lock()
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err == nil {
b, _ := json.Marshal(l.owner)
@@ -209,11 +219,14 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
if werr != nil || cerr != nil {
_ = os.Remove(path)
tuneHeld.Delete(key)
tuneFileMu.Unlock()
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
}
l.held = true
tuneFileMu.Unlock()
return l, nil
}
tuneFileMu.Unlock()
if !os.IsExist(err) {
tuneHeld.Delete(key)
return nil, err
@@ -232,6 +245,13 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
// lecteur ne voit jamais un JSON à moitié écrit.
func (l *tuneLock) write() error {
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
return l.writeLocked()
}
// writeLocked : write, tuneFileMu déjà tenu par l'appelant.
func (l *tuneLock) writeLocked() error {
b, err := json.Marshal(l.owner)
if err != nil {
return err
@@ -291,6 +311,8 @@ func (l *tuneLock) setBackup(backup, id, name string) {
func (l *tuneLock) release() {
l.mu.Lock()
defer l.mu.Unlock()
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
if l.held {
// Un fichier absent un instant (renommé par un ménage qui le remet en
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
@@ -305,7 +327,7 @@ func (l *tuneLock) release() {
if ours {
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
_ = l.write()
_ = l.writeLocked()
for i := 0; i < 5; i++ {
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
break
@@ -328,6 +350,24 @@ func (l *tuneLock) release() {
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
func tuneReapStale() (mainWasActive bool) {
o, ok := tuneReapClaim()
if !ok {
return false
}
if o.Trial != nil && tuneProcAlive(*o.Trial) {
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
tuneKillTree(o.Trial.PID, nil)
}
tuneUndoApply(o)
return o.MainWasActive
}
// tuneReapClaim : la partie de tuneReapStale qui touche au fichier, sous
// tuneFileMu — lecture, déplacement, relecture, remise en place. Rend le
// verrou périmé effectivement retiré (ok=false : rien à écarter).
func tuneReapClaim() (tuneOwner, bool) {
tuneFileMu.Lock()
defer tuneFileMu.Unlock()
path := tuneLockPath()
o, ok := readTuneOwner(path)
if !ok {
@@ -338,14 +378,14 @@ func tuneReapStale() (mainWasActive bool) {
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
_ = os.Remove(path)
}
return false
return tuneOwner{}, false
}
if tuneOwnerAlive(o) {
return false
return tuneOwner{}, false
}
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
if os.Rename(path, stale) != nil {
return false
return tuneOwner{}, false
}
defer os.Remove(stale)
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
@@ -358,14 +398,9 @@ func tuneReapStale() (mainWasActive bool) {
if err := os.Link(stale, path); err != nil {
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
}
return false
return tuneOwner{}, false
}
if o.Trial != nil && tuneProcAlive(*o.Trial) {
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
tuneKillTree(o.Trial.PID, nil)
}
tuneUndoApply(o)
return o.MainWasActive
return o, true
}
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
+1 -1
View File
@@ -115,7 +115,7 @@ func cmdChat(args []string) error {
case "edit":
icon = "✏️"
verb = "édition"
case "web_search", "web_open", "web_read", "web_grep":
case "web_search", "web_images", "web_open", "web_read", "web_grep":
icon = "🌐"
verb = "web"
case "web_screenshot":
+14
View File
@@ -354,6 +354,17 @@ func compactLog(log []LogEvent) []LogEvent {
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
flush()
if done, _ := tu["done"].(bool); done {
// L'aperçu du navigateur (shot) n'est que du direct : on ne le
// garde pas une fois le tour fini (voir cu_autoshot.go).
if _, has := tu["shot"]; has {
cp := make(map[string]any, len(tu))
for k, v := range tu {
if k != "shot" {
cp[k] = v
}
}
ev.Delta = map[string]any{"tool_used": cp}
}
out = append(out, ev)
}
continue
@@ -945,6 +956,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
if ev.ToolUsed.Image != "" {
tu["image"] = ev.ToolUsed.Image
}
if ev.ToolUsed.Shot != "" {
tu["shot"] = ev.ToolUsed.Shot
}
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
if ev.ToolUsed.ResultChars > 0 {
tu["result_chars"] = ev.ToolUsed.ResultChars
+3 -3
View File
@@ -297,7 +297,7 @@ func cmdInternet(args []string) error {
if err := setInternetEnabled(true); err != nil {
return err
}
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_open/web_read/web_grep (si le mode agent est actif)")
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_images/web_open/web_read/web_grep (si le mode agent est actif)")
case "off":
if err := setInternetEnabled(false); err != nil {
return err
@@ -352,7 +352,7 @@ func cmdInternet(args []string) error {
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
if webEngine() == engineGo {
fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
return nil
}
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
@@ -366,7 +366,7 @@ func cmdInternet(args []string) error {
reach = green("joignable")
}
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
default:
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
}
+139
View File
@@ -0,0 +1,139 @@
package loki
// web_images : la recherche d'images DuckDuckGo, pendant de web_search. Sans
// elle, « montre-moi des photos de X » obligeait le modèle à ouvrir des pages
// dans le navigateur et à en renvoyer des captures (lent, et des images de
// pages au lieu de photos).
//
// DuckDuckGo sert les images par i.js, qui exige un jeton « vqd » lu dans la
// page de recherche. Même client HTTP que le moteur intégré, quel que soit le
// moteur web actif : Crawl4AI n'apporterait rien à un appel JSON.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
)
func webImagesTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
// Schéma minimal : le préambule a un budget (chat_prompt_size_test.go).
// limit n'est pas annoncé ; toolWebImages l'accepte encore (6 par défaut).
Name: "web_images",
Description: "Find images.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string"},
},
"required": []string{"query"},
},
}}
}
type imageResult struct {
Image string `json:"image"`
Title string `json:"title"`
URL string `json:"url"`
Width int `json:"width"`
Height int `json:"height"`
}
var ddgVqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`)
func ddgGet(ctx context.Context, target, referer string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", goFetchUA)
req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")
if referer != "" {
req.Header.Set("Referer", referer)
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
}
resp, err := goHTTPClient.Do(req)
if err != nil {
return nil, fmt.Errorf("échec de la requête : %v", err)
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, goFetchMaxBytes))
if err != nil {
return nil, err
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
}
return b, nil
}
func duckduckgoImages(query string, limit int) ([]imageResult, error) {
ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout)
defer cancel()
page := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
body, err := ddgGet(ctx, page, "")
if err != nil {
return nil, err
}
m := ddgVqdRe.FindSubmatch(body)
if m == nil {
return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)")
}
api := "https://duckduckgo.com/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1])
body, err = ddgGet(ctx, api, "https://duckduckgo.com/")
if err != nil {
return nil, err
}
var r struct {
Results []imageResult `json:"results"`
}
if err := json.Unmarshal(body, &r); err != nil {
return nil, fmt.Errorf("réponse illisible : %v", err)
}
var out []imageResult
seen := map[string]bool{}
for _, it := range r.Results {
if it.Image == "" || seen[it.Image] || !strings.HasPrefix(it.Image, "http") {
continue
}
seen[it.Image] = true
out = append(out, it)
if len(out) >= limit {
break
}
}
return out, nil
}
func toolWebImages(args map[string]any) string {
query, _ := args["query"].(string)
limit := 6
if v, ok := args["limit"].(float64); ok {
limit = int(v)
}
if limit < 1 {
limit = 1
}
if limit > 20 {
limit = 20
}
results, err := duckduckgoImages(query, limit)
if err != nil {
return "❌ Recherche d'images échouée : " + err.Error()
}
if len(results) == 0 {
return fmt.Sprintf("Aucune image pour « %s »", query)
}
var b strings.Builder
fmt.Fprintf(&b, "# Images : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results))
for i, r := range results {
fmt.Fprintf(&b, "%d. %s (%dx%d)\n image : %s\n page : %s\n\n", i+1, r.Title, r.Width, r.Height, r.Image, r.URL)
}
b.WriteString("To show an image to the user, write ![title](image URL) in your answer.")
return b.String()
}
+1
View File
@@ -225,6 +225,7 @@ func cdpLaunch() (*cdpSession, error) {
args = append([]string{"--headless=new"}, args...)
}
cmd := exec.Command(bin, args...)
prepareBoundChild(cmd) // tué avec Loki, même brutalement (sys_childbind_*.go)
if err := cmd.Start(); err != nil {
os.RemoveAll(userDir)
return nil, err
+92
View File
@@ -0,0 +1,92 @@
package loki
// cu_autoshot.go — aperçu EN DIRECT du navigateur piloté (computer use). Après
// chaque action browser_*, on capture la page pour que l'UTILISATEUR voie ce que
// fait l'IA. C'est de l'interface seulement :
// - jamais envoyé au modèle (zéro token, vision non requise) ;
// - jamais écrit sur le disque : un petit anneau en mémoire vive ;
// - retiré du journal en fin de tour (compactLogLocked), et l'UI efface la carte
// à la fin du tour. Une capture à GARDER passe toujours par
// browser_screenshot, qui l'enregistre dans le dossier de travail.
import (
"bytes"
"encoding/base64"
"fmt"
"image"
"image/jpeg"
_ "image/png"
"net/http"
"os"
"strings"
"sync"
"time"
)
const cuShotRing = 8 // captures gardées en RAM (l'UI n'affiche que la dernière)
var (
cuShotMu sync.Mutex
cuShotData = map[string][]byte{}
cuShotIDs []string
)
// cuAutoShotTool : outils après lesquels on capture (ceux qui changent la page).
func cuAutoShotTool(name string) bool {
switch name {
case "browser_open", "browser_click", "browser_click_xy", "browser_type", "browser_key", "browser_scroll", "browser_find":
return true
}
return false
}
func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n", a...) }
// cuAutoShot capture la page en pleine résolution (JPEG de bonne qualité) et
// renvoie l'identifiant de la capture en mémoire ("" si impossible : jamais
// bloquant pour l'outil).
func cuAutoShot() string {
s, err := cdpGet()
if err != nil {
cuLog("aperçu : navigateur indisponible : %v", err)
return ""
}
raw, err := s.screenshot()
if err != nil {
cuLog("aperçu : capture impossible : %v", err)
return ""
}
img, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
cuLog("aperçu : image illisible (%d octets) : %v", len(raw), err)
return ""
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
return ""
}
id := fmt.Sprintf("%x", time.Now().UnixNano())
cuShotMu.Lock()
cuShotData[id] = buf.Bytes()
cuShotIDs = append(cuShotIDs, id)
for len(cuShotIDs) > cuShotRing {
delete(cuShotData, cuShotIDs[0])
cuShotIDs = cuShotIDs[1:]
}
cuShotMu.Unlock()
return id
}
// handleCUShot (GET ?id=) : une capture en base64 (JSON, pour traverser aussi le
// tunnel chiffré du relais). 404 si elle est déjà sortie de l'anneau.
func handleCUShot(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
cuShotMu.Lock()
b := cuShotData[id]
cuShotMu.Unlock()
if b == nil {
sendJSON(w, 404, map[string]any{"ok": false, "error": "capture expirée"})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(b)})
}
+39
View File
@@ -0,0 +1,39 @@
package loki
import "testing"
// L'aperçu du navigateur (shot) n'est que du direct : la compaction de fin de
// tour le retire du journal, le reste de l'événement d'outil est gardé.
func TestCompactLogRetireLAperçuNavigateur(t *testing.T) {
log := []LogEvent{
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": false}}},
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": true, "result": "ok", "shot": "abc"}}},
}
out := compactLog(log)
if len(out) != 1 {
t.Fatalf("un seul événement d'outil attendu, %d", len(out))
}
tu := out[0].Delta["tool_used"].(map[string]any)
if _, has := tu["shot"]; has {
t.Fatal("l'aperçu aurait dû quitter le journal")
}
if tu["result"] != "ok" || tu["name"] != "browser_click" {
t.Fatalf("événement abîmé : %v", tu)
}
if _, has := log[1].Delta["tool_used"].(map[string]any)["shot"]; !has {
t.Fatal("le journal d'origine ne doit pas être modifié en place")
}
}
func TestCUAutoShotTool(t *testing.T) {
for _, n := range []string{"browser_open", "browser_click", "browser_type", "browser_scroll"} {
if !cuAutoShotTool(n) {
t.Errorf("%s devrait déclencher l'aperçu", n)
}
}
for _, n := range []string{"browser_screenshot", "browser_snapshot", "bash", "web_images"} {
if cuAutoShotTool(n) {
t.Errorf("%s ne devrait pas déclencher l'aperçu", n)
}
}
}
+22 -4
View File
@@ -494,7 +494,7 @@ func EnabledTools(caps Caps) []Tool {
// caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
// donc prompt et outils restent cohérents — pas de web_search halluciné.
if caps.Agent && caps.Internet {
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
tools = append(tools, webSearchTool(), webImagesTool(), webOpenTool(), webReadTool(), webGrepTool())
}
// Capture d'écran : seulement si Playwright est réellement présent dans
// l'image (annoncer un outil absent enverrait le modèle en boucle de
@@ -602,6 +602,10 @@ type ToolUsedEvent struct {
// pensait à recopier la ligne markdown rendue par l'outil : un petit modèle
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
Image string
// Shot : identifiant de la capture du navigateur prise après une action
// browser_* (cu_autoshot.go). Aperçu EN DIRECT seulement : gardé en RAM,
// jamais envoyé au modèle, retiré du journal en fin de tour.
Shot string
// ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle.
ResultChars int
@@ -1065,6 +1069,12 @@ func friendlyLLMError(err error) error {
}
func errEngineDown() error {
// Un refus de connexion ne veut pas toujours dire « il charge encore » : si
// le journal montre que le dernier lancement a échoué, c'est cela qu'il faut
// dire — réessayer dans quelques secondes n'y changera rien.
if msg := modelLoadError(); msg != "" {
return fmt.Errorf("⚠️ %s", msg)
}
return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort())
}
@@ -1103,7 +1113,7 @@ func isNetTimeout(err error) bool {
func toolCallLabel(name string, args map[string]any) string {
label := ""
switch name {
case "mem_search", "web_search", "recall_search":
case "mem_search", "web_search", "web_images", "recall_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
label, _ = args["file"].(string)
@@ -1876,7 +1886,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
if cur := toolCalls[0]; cur != nil {
key := "command"
switch cur.Function.Name {
case "mem_search", "web_search", "recall_search":
case "mem_search", "web_search", "web_images", "recall_search":
key = "query"
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
key = "file"
@@ -2494,6 +2504,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
}
case "web_search":
result = capWebOutput(toolWebSearch(args))
case "web_images":
result = capWebOutput(toolWebImages(args))
case "web_open":
result = capWebOutput(toolWebOpen(args))
case "web_read":
@@ -2535,8 +2547,14 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
result = "[erreur] outil inconnu: " + tc.Function.Name
}
}
// Aperçu en direct du navigateur piloté (repris d'AJEAN) : une
// capture après chaque action qui change la page, pour l'UI seule.
liveShot := ""
if !strings.HasPrefix(result, "[erreur]") {
doneCalls[callKey] = result
if cuAutoShotTool(tc.Function.Name) {
liveShot = cuAutoShot()
}
}
// Capture d'écran : l'image part avec l'événement pour être
// affichée dans la bulle, quoi que le modèle en fasse ensuite.
@@ -2544,7 +2562,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
if tc.Function.Name == "web_screenshot" {
shot = capturedRelPath(result)
}
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result)})
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot, Shot: liveShot}, result)})
// Plafond pour le MODÈLE seulement : l'UI vient de recevoir le
// résultat complet (aperçu + « voir plus »).
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)}
+20
View File
@@ -0,0 +1,20 @@
//go:build linux
package loki
// Lier le Chrome du computer use à la vie de Loki (repris d'AJEAN) : le noyau
// lui envoie SIGKILL quand Loki meurt, même tué brutalement. Sans ça, un Chrome
// orphelin restait dans le conteneur après un redémarrage de l'UI. Ses
// sous-processus suivent la mort du processus principal de Chrome.
import (
"os/exec"
"syscall"
)
func prepareBoundChild(cmd *exec.Cmd) {
if cmd.SysProcAttr == nil {
cmd.SysProcAttr = &syscall.SysProcAttr{}
}
cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package loki
import "os/exec"
// Hors Linux, pas d'équivalent simple à Pdeathsig : le navigateur est fermé à
// l'arrêt propre de Loki. (AJEAN passe par un job object sous Windows.)
func prepareBoundChild(cmd *exec.Cmd) {}
+166
View File
@@ -0,0 +1,166 @@
//go:build linux
package loki
import (
"os"
"strconv"
"strings"
"syscall"
"time"
)
// procRef : un processus repéré par son PID ET son instant de démarrage — un
// PID recyclé pendant l'attente ne passe pas pour le même processus.
type procRef struct {
pid int
start string
}
// procStat : les champs de /proc/<pid>/stat utiles ici.
type procStat struct {
state string
ppid, pgrp, sid int
start string
}
// readProcStat lit /proc/<pid>/stat. Le nom (champ 2, entre parenthèses) peut
// contenir des blancs et des parenthèses : on repart de la DERNIÈRE fermante.
func readProcStat(pid int) (procStat, bool) {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return procStat{}, false
}
return parseProcStat(string(b))
}
func parseProcStat(s string) (procStat, bool) {
i := strings.LastIndexByte(s, ')')
if i < 0 {
return procStat{}, false
}
// f[0] est le champ 3 (état), f[1] ppid, f[2] groupe, f[3] session, f[19]
// l'instant de démarrage (champ 22).
f := strings.Fields(s[i+1:])
if len(f) < 20 {
return procStat{}, false
}
ppid, _ := strconv.Atoi(f[1])
pgrp, _ := strconv.Atoi(f[2])
sid, _ := strconv.Atoi(f[3])
return procStat{state: f[0], ppid: ppid, pgrp: pgrp, sid: sid, start: f[19]}, true
}
// procTable : tous les processus vivants (les zombies ont fini de tourner : leur
// mémoire, VRAM comprise, est déjà rendue).
func procTable() map[int]procStat {
ents, err := os.ReadDir("/proc")
if err != nil {
return nil
}
t := make(map[int]procStat, len(ents))
for _, e := range ents {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
if st, ok := readProcStat(pid); ok && st.state != "Z" {
t[pid] = st
}
}
return t
}
// procTreeOf : le chef root et tout ce qui en descend — sa session et son
// groupe (Setsid au lancement du service), plus les descendants par la
// filiation, pour un enfant qui aurait pris sa propre session (le serveur
// Python de Strata lance son moteur dans un processus à part). À relever AVANT
// d'envoyer le moindre signal : une fois le chef mort, ses orphelins sont
// rattachés à PID 1 et la filiation est perdue.
func procTreeOf(t map[int]procStat, root, self int) []procRef {
in := map[int]bool{}
for pid, st := range t {
if pid != self && (pid == root || st.sid == root || st.pgrp == root) {
in[pid] = true
}
}
// Fermeture par la filiation, jusqu'à ce que plus rien ne s'ajoute.
for grew := true; grew; {
grew = false
for pid, st := range t {
if !in[pid] && pid != self && in[st.ppid] {
in[pid] = true
grew = true
}
}
}
refs := make([]procRef, 0, len(in))
for pid := range in {
refs = append(refs, procRef{pid: pid, start: t[pid].start})
}
return refs
}
// procRefAlive : le processus tourne encore (ni disparu, ni zombie, ni
// remplacé par un autre sous le même PID).
func procRefAlive(r procRef) bool {
st, ok := readProcStat(r.pid)
return ok && st.state != "Z" && st.start == r.start
}
func procRefsAlive(refs []procRef) []procRef {
var alive []procRef
for _, r := range refs {
if procRefAlive(r) {
alive = append(alive, r)
}
}
return alive
}
// stopProcTree arrête le service lancé avec Setsid (pid = chef de session) et
// N'EN REVIENT QU'UNE FOIS TOUT L'ARBRE PARTI.
//
// Attendre le seul chef ne suffit pas : avec Strata, le chef est le serveur
// Python, et le moteur qui tient la VRAM (cache d'experts, plusieurs Go) est un
// autre processus qui finit de s'arrêter après lui. La relance qui suivait
// 500 ms plus tard démarrait alors llama-server sur une carte encore pleine —
// « cudaMalloc failed: out of memory » sur un modèle qui tient pourtant — et
// Strata mesurait la RAM disponible avec l'ancien moteur encore chargé.
//
// SIGTERM à tous, grace pour finir proprement, SIGKILL aux survivants, puis
// settle au plus pour que le noyau les ait vraiment retirés (un processus
// coincé dans une lecture disque ne meurt qu'à la sortie de l'appel système).
// Renvoie ceux qui tournent encore au-delà (rien à faire de plus, mais le dire).
func stopProcTree(pid int, grace, settle time.Duration) []procRef {
refs := procTreeOf(procTable(), pid, os.Getpid())
if len(refs) == 0 {
return nil
}
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
for _, r := range refs {
_ = syscall.Kill(r.pid, syscall.SIGTERM)
}
if left := waitProcRefs(refs, grace); len(left) == 0 {
return nil
}
_ = syscall.Kill(-pid, syscall.SIGKILL)
for _, r := range procRefsAlive(refs) {
_ = syscall.Kill(r.pid, syscall.SIGKILL)
}
return waitProcRefs(refs, settle)
}
// waitProcRefs attend que tous aient disparu, au plus wait. Renvoie les survivants.
func waitProcRefs(refs []procRef, wait time.Duration) []procRef {
deadline := time.Now().Add(wait)
for {
left := procRefsAlive(refs)
if len(left) == 0 || time.Now().After(deadline) {
return left
}
time.Sleep(100 * time.Millisecond)
}
}
+83
View File
@@ -0,0 +1,83 @@
//go:build linux
package loki
import (
"os"
"os/exec"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
func TestParseProcStat(t *testing.T) {
st, ok := parseProcStat("4242 (python3 (x) y) S 4200 4100 4000 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 987654 0 0")
if !ok || st.state != "S" || st.ppid != 4200 || st.pgrp != 4100 || st.sid != 4000 || st.start != "987654" {
t.Fatalf("lecture fausse : %+v ok=%v", st, ok)
}
if _, ok := parseProcStat("4242 (tronqué"); ok {
t.Error("ligne sans parenthèse fermante acceptée")
}
}
func TestProcTreeOf(t *testing.T) {
tab := map[int]procStat{
100: {ppid: 1, pgrp: 100, sid: 100, start: "a"}, // chef (Setsid)
101: {ppid: 100, pgrp: 100, sid: 100, start: "b"},
200: {ppid: 101, pgrp: 200, sid: 200, start: "c"}, // moteur dans sa propre session
201: {ppid: 200, pgrp: 200, sid: 200, start: "d"},
300: {ppid: 1, pgrp: 300, sid: 300, start: "e"}, // étranger
400: {ppid: 1, pgrp: 100, sid: 100, start: "f"}, // orphelin rattaché à PID 1
500: {ppid: 100, pgrp: 100, sid: 100, start: "g"}, // nous-mêmes
}
got := map[int]bool{}
for _, r := range procTreeOf(tab, 100, 500) {
got[r.pid] = true
}
for _, pid := range []int{100, 101, 200, 201, 400} {
if !got[pid] {
t.Errorf("PID %d oublié", pid)
}
}
if got[300] || got[500] {
t.Errorf("hors de l'arbre pris : %v", got)
}
}
// Le chef meurt vite, son enfant (dans une autre session, comme le moteur de
// Strata) traîne à s'arrêter : stopProcTree ne revient qu'une fois tout parti.
func TestStopProcTreeWaitsForChildren(t *testing.T) {
// Le chef lance un enfant qui ignore SIGTERM dans sa propre session, écrit
// son PID, puis attend.
pidFile := t.TempDir() + "/child"
script := `setsid sh -c 'trap "" TERM; echo $$ > ` + pidFile + `; while :; do sleep 0.1; done' & wait`
cmd := exec.Command("sh", "-c", script)
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
if err := cmd.Start(); err != nil {
t.Skip("sh indisponible :", err)
}
go cmd.Wait() // pas de zombie pour le chef
var child int
for i := 0; i < 50 && child == 0; i++ {
time.Sleep(50 * time.Millisecond)
if b, err := os.ReadFile(pidFile); err == nil {
child, _ = strconv.Atoi(strings.TrimSpace(string(b)))
}
}
if child == 0 {
t.Fatal("l'enfant n'a pas démarré")
}
st, ok := readProcStat(child)
if !ok || st.sid == cmd.Process.Pid {
t.Fatalf("l'enfant devait avoir sa propre session : %+v", st)
}
ref := procRef{pid: child, start: st.start}
if left := stopProcTree(cmd.Process.Pid, 300*time.Millisecond, 5*time.Second); len(left) != 0 {
t.Fatalf("survivants : %v", left)
}
if procRefAlive(ref) {
t.Fatal("l'enfant tourne encore au retour de stopProcTree")
}
}
+12 -12
View File
@@ -125,25 +125,25 @@ func userCheckStarted(pid int) error {
func userSvcStop(verbose bool) error {
pid := readServicePID()
if pid <= 0 || !processAlive(pid) {
// Le chef parti, ses enfants peuvent tourner encore (moteur de Strata) :
// on ne conclut « rien à arrêter » que si l'arbre entier est vide.
if pid <= 0 || len(procTreeOf(procTable(), pid, os.Getpid())) == 0 {
_ = os.Remove(pidFilePath())
if verbose {
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
}
return nil
}
// Setsid a fait de l'enfant un chef de groupe : le PID négatif vise le
// groupe entier, donc llama-server s'arrête avec lui.
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
_ = syscall.Kill(pid, syscall.SIGTERM)
}
for i := 0; i < 40 && processAlive(pid); i++ {
time.Sleep(100 * time.Millisecond)
}
if processAlive(pid) {
_ = syscall.Kill(-pid, syscall.SIGKILL)
}
// Setsid a fait de l'enfant un chef de session : tout ce qu'il a lancé
// (llama-server, serveur et moteur de Strata) s'arrête avec lui, et on
// attend qu'il soit VRAIMENT parti — la relance qui suit en a besoin pour
// trouver la VRAM et la RAM libres.
left := stopProcTree(pid, 4*time.Second, 20*time.Second)
_ = os.Remove(pidFilePath())
if len(left) > 0 {
fmt.Fprintf(os.Stderr, "%s %d processus du moteur toujours là après SIGKILL (PID %d…) : "+
"la mémoire du GPU peut ne pas être encore rendue\n", yellow("[attention]"), len(left), left[0].pid)
}
if verbose {
fmt.Println(green("[ok]") + " arrêté")
}
+164 -20
View File
@@ -2305,6 +2305,20 @@ html[data-files="1"] #files-btn{color:var(--accent)}
transition:background .15s,color .15s,border-color .15s}
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
#chat .history-more button:disabled{opacity:.6;cursor:default}
/* Aperçu en direct du navigateur piloté (09-stream.js showCuShot, repris d'AJEAN). */
.cu-shot{flex-shrink:0;align-self:flex-start;width:min(640px,100%);margin:2px 0 6px;overflow:hidden;
transition:height .35s cubic-bezier(.2,.7,.3,1),opacity .3s ease,margin .35s ease;animation:cushotcard .3s ease both}
.cu-shot.out{opacity:0;margin:0}
@keyframes cushotcard{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.cu-shot-cap{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dim);margin:0 0 6px 2px;min-width:0}
.cu-shot-cap svg{flex:none}
.cu-shot-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cu-shot-img{position:relative;border-radius:12px;overflow:hidden;background:var(--row-bg);
box-shadow:0 4px 18px rgba(0,0,0,.10);aspect-ratio:16 / 10}
.cu-shot-img img.cu-frame{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top;
opacity:0;transition:opacity .45s ease;cursor:zoom-in}
.cu-shot-img img.cu-frame.in{opacity:1}
@media (prefers-reduced-motion:reduce){.cu-shot,.cu-shot-img img.cu-frame{transition:none;animation:none}}
</style></head><body>
<button id="menubtn" onclick="toggleSide()" title="menu" aria-label="ouvrir le menu"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg></button>
<!-- Installation d'un moteur en cours : le détail vit dans le panneau, qui est
@@ -2709,7 +2723,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
</div>
<div id="net-block" class="gated-group">
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_images / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
<div class="slist">
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
@@ -4507,7 +4521,7 @@ async function loadCfg(){
rows.push(row('CTX', d.ctx));
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
rows.push(row('MTP', d.spec));
rows.push(row('EXPERTS', d.drop ? 'mlock' : d.mmap ? 'mmap' : 'RAM'));
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
}else if(c.CTX) rows.push(row('CTX', c.CTX));
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
@@ -6621,7 +6635,10 @@ async function populateDraft(){
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
const main = matchModel(currentModelInTextarea(), list);
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
@@ -6631,7 +6648,9 @@ async function populateDraft(){
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
const self = main && matchModel(cur, [main]);
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
}
sel.innerHTML = html;
syncSpecRow();
@@ -7392,7 +7411,47 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLink
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
// casserait les rendus suivants qui réutilisent la même entrée.
const IMG_CACHE = new Map();
// Vignettes du fil (repris d'AJEAN) : ~560 px suffisent à l'affichage, le
// serveur les calcule (?thumb=). Chargement DEUX PAR DEUX avec un nouvel essai :
// en accès distant, une rafale de captures saturait le tunnel et certaines
// n'arrivaient jamais.
const CHAT_THUMB_PX = 560;
let chatImgActive = 0;
const chatImgWaiting = [];
function chatImgQueue(job){
return new Promise((resolve, reject)=>{
const run = async ()=>{
chatImgActive++;
try{
try{ resolve(await job()); }
catch(_){ await new Promise(r=>setTimeout(r, 800)); resolve(await job()); }
}catch(e){ reject(e); }
finally{ chatImgActive--; const next = chatImgWaiting.shift(); if(next) next(); }
};
if(chatImgActive < 2) run(); else chatImgWaiting.push(run);
});
}
async function chatImgBlob(url){
const hit = IMG_CACHE.get(url);
if(hit) return hit;
const r = await jfetch(url);
if(!r.ok) throw new Error(r.status);
const obj = URL.createObjectURL(await r.blob());
IMG_CACHE.set(url, obj);
return obj;
}
function hydrateImages(root){
// Images du web (outil web_images, repris d'AJEAN) : même rendu et même
// loupe que les nôtres, sans Referer, et masquées si le site les refuse.
root.querySelectorAll('img[src^="http://"],img[src^="https://"]').forEach(img => {
if(img.dataset.hydrated) return;
img.dataset.hydrated = '1';
img.classList.add('chatimg');
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
bindZoom(img, img.getAttribute('alt') || '');
img.addEventListener('error', ()=>{ img.style.display = 'none'; });
});
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
if(img.dataset.hydrated) return;
img.dataset.hydrated = '1';
@@ -7404,15 +7463,21 @@ function hydrateImages(root){
const m = /[?&]path=([^&]*)/.exec(src);
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
}
bindZoom(img, name);
const cached = IMG_CACHE.get(src);
const full = src.startsWith('/') ? src : '/' + src;
// Loupe : l'ORIGINAL, chargé seulement à l'agrandissement (le fil n'affiche
// qu'une vignette calculée par le serveur).
if(!img.dataset.zoom){
img.dataset.zoom = '1';
img.addEventListener('click', async ()=>{
try{ openLightbox(await chatImgBlob(full), name); }
catch(_){ openLightbox(img.currentSrc || img.src, name); }
});
}
const thumb = full + (full.includes('?') ? '&' : '?') + 'thumb=' + CHAT_THUMB_PX;
const cached = IMG_CACHE.get(thumb);
if(cached){ img.src = cached; return; }
try{
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
if(!r.ok) throw new Error(r.status);
const url = URL.createObjectURL(await r.blob());
IMG_CACHE.set(src, url);
img.src = url;
img.src = await chatImgQueue(()=>chatImgBlob(thumb));
}catch(e){
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
// tronquée en cours de frappe (404 transitoire) et le prochain delta
@@ -7437,6 +7502,7 @@ function renderToolMsg(el, tu){
write: {lbl:'fichier', head:'écriture'},
edit: {lbl:'édition', head:'édition'},
web_search: {lbl:'recherche', head:'recherche web'},
web_images: {lbl:'images', head:"recherche d'images"},
web_open: {lbl:'page web', head:'ouverture'},
web_read: {lbl:'page web', head:'lecture'},
web_grep: {lbl:'page web', head:'recherche'},
@@ -7835,7 +7901,7 @@ function confirmPending(text){
let REPLAYING=true;
// État de rendu du tour courant, délimité par les événements user / turn_done.
let T=null;
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
function newTurn(){ T={ cuShotEl:null, reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
newTurn();
// --- Temps de travail du tour ------------------------------------------------
@@ -7938,6 +8004,54 @@ function paintStats(el, speed){
}
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
// Aperçu EN DIRECT du navigateur piloté (repris d'AJEAN). Après chaque action
// browser_*, le serveur garde une capture en mémoire (tu.shot = son id). Une
// seule carte par tour : la nouvelle image arrive en fondu par-dessus
// l'ancienne. Purement de l'interface : la carte s'efface à la fin du tour et
// rien n'est gardé. Une capture à conserver passe par browser_screenshot.
async function showCuShot(tu){
if(REPLAYING) return;
let card=T.cuShotEl;
if(!card){
card=document.createElement('div'); card.className='cu-shot';
card.innerHTML='<div class="cu-shot-cap"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/></svg><span class="cu-shot-t">navigateur en direct</span></div><div class="cu-shot-img"></div>';
T.cuShotEl=card;
}
if(tu.name==='browser_open' && tu.label) card.querySelector('.cu-shot-t').textContent=tu.label;
// Juste avant l'indicateur d'activité s'il est là, sinon en bas du fil.
const chat=chatEl(), anchor=(T.typingEl && T.typingEl.parentNode===chat) ? T.typingEl : null;
if(card.parentNode!==chat || card.nextSibling!==anchor){ if(anchor) chat.insertBefore(card, anchor); else chat.appendChild(card); }
const my=(card._n=(card._n||0)+1); // ordre d'arrivée des captures
let r; try{ r=await jget('/api/chat/cushot?id='+encodeURIComponent(tu.shot)); }catch(_){ return; }
if(!r || !r.ok || T.cuShotEl!==card) return; // carte déjà retirée (tour fini)
const box=card.querySelector('.cu-shot-img');
const img=new Image(); img.alt=''; img.className='cu-frame';
img.onclick=()=>openLightbox(img.src, card.querySelector('.cu-shot-t').textContent);
img.src='data:'+(r.mime||'image/jpeg')+';base64,'+r.data;
// Décodée AVANT d'entrer dans la page : jamais d'image à moitié peinte.
try{ await img.decode(); }catch(_){ return; }
// Une capture plus récente est déjà affichée (réponses dans le désordre) : on jette.
if(T.cuShotEl!==card || my<(card._shown||0)) return;
card._shown=my;
box.appendChild(img);
if(!box.style.aspectRatio) box.style.aspectRatio=img.naturalWidth+' / '+img.naturalHeight;
setTimeout(()=>img.classList.add('in'), 20);
// Fondu terminé : on retire les images PLUS ANCIENNES seulement.
setTimeout(()=>{ let x=img.previousElementSibling; while(x){ const p=x.previousElementSibling; x.remove(); x=p; } }, 480);
scrollMaybe();
}
// Fin du tour : l'aperçu s'efface en douceur. Toutes les cartes du fil, au cas
// où un tour précédent en aurait laissé une.
function hideCuShot(){
if(T) T.cuShotEl=null;
const chat=chatEl(); if(!chat) return;
chat.querySelectorAll(':scope > .cu-shot').forEach(card=>{
if(card.classList.contains('out')) return;
card.style.height=card.offsetHeight+'px'; void card.offsetHeight;
card.classList.add('out'); card.style.height='0px';
setTimeout(()=>card.remove(), 380);
});
}
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
@@ -8287,7 +8401,7 @@ function handleDelta(d){
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
// replay, qui rejoue tous les tours passés d'un bloc.
if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
if(d.turn_done){ hideCuShot(); settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
// le temps réellement passé et non continuer d'avancer.
T.doneTs = d.ts || T.doneTs || 0;
@@ -8296,7 +8410,7 @@ function handleDelta(d){
paintTurnClock(); // fige le total au-dessus de la carte
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.error){ hideCuShot(); settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
@@ -8333,6 +8447,7 @@ function handleDelta(d){
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
if(tu.done && tu.shot) showCuShot(tu);
if(tu.done){
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
@@ -8987,10 +9102,21 @@ function lcStartPolling(){
lcPollJob();
}
// Une seule requête à la fois : l'intervalle d'une seconde relançait un appel
// avant la réponse du précédent quand le serveur était occupé (téléchargement,
// accès distant), et les deux ajoutaient les MÊMES lignes au journal.
let lcPollInflight = false;
async function lcPollJob(quiet){
if(lcPollInflight) return;
lcPollInflight = true;
const from = lcLogNext;
let j;
try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; }
try{ j = await jget('/api/llamacpp/job?from='+from); }catch(_){ return; }
finally{ lcPollInflight = false; }
if(!j.exists) return;
// Le journal a été remis à zéro ou avancé entre-temps (nouveau job) : ces
// lignes ne suivent plus ce qui est affiché.
if(from !== lcLogNext) return;
const phaseEl = document.getElementById('lc-job-phase');
if(j.lines && j.lines.length){
const pre = document.getElementById('lc-log');
@@ -9067,8 +9193,9 @@ async function openStrata(presetId){
.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.';
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM dont '
+Math.round(env.avail_gb||env.ram_gb)+' disponibles, '
+Math.round(env.disk_free_gb)+' Go de disque libres. Réglages automatiques.';
if(!strataFam) strataFam = (s.families[0]||{}).id || '';
if(strataTarget){
for(const f of s.families){
@@ -9120,7 +9247,7 @@ function strataRenderQuants(pickReco){
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.'
: fit.mmap ? 'RAM disponible insuffisante pour tout charger : les experts seront lus depuis le disque (mmap, un peu plus lent). Installation et activation automatiques.'
: 'Installation et activation automatiques. Premier chargement : quelques minutes.';
}
@@ -9149,6 +9276,9 @@ function strataRenderSettings(fit){
['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)],
// Placement des experts : auto décide AU LANCEMENT sur la RAM disponible ;
// « disque » ne charge rien en RAM au démarrage (jamais tué faute de RAM).
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
['Lire les images', sw('strata-s-vision', d.vision)],
];
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
@@ -9171,7 +9301,8 @@ async function strataSaveSettings(){
if(!fit || !fit.preset) return;
const v = id => document.getElementById(id);
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false};
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
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; }
@@ -9195,7 +9326,8 @@ function strataRenderDetails(fit){
['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'],
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
["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],
@@ -9893,6 +10025,18 @@ function markFileLinks(root){
for(const a of root.querySelectorAll('a[href]')){
const p=fileLinkPath(a.getAttribute('href'));
if(!p) continue;
// Lien vers une IMAGE ([le graphique](graph.png)) : on l'affiche au lieu d'un
// simple bouton de téléchargement (repris d'AJEAN). hydrateImages, appelé
// juste après, la charge en vignette ; un clic ouvre l'original. Formats que
// le navigateur affiche seulement (pas de SVG servi tel quel, ni de HEIC).
if(/\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(p)){
const img=document.createElement('img');
img.setAttribute('src', '/api/chat/image?path='+encodeURIComponent(p));
img.alt=(a.textContent||'').trim() || p.split('/').pop();
img.loading='lazy';
a.replaceWith(img);
continue;
}
a.classList.add('filelink');
a.removeAttribute('target');
a.setAttribute('href','#');
+1 -1
View File
@@ -438,7 +438,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div>
<div id="net-block" class="gated-group">
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_images / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
<div class="slist">
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
+1 -1
View File
@@ -330,7 +330,7 @@ async function loadCfg(){
rows.push(row('CTX', d.ctx));
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
rows.push(row('MTP', d.spec));
rows.push(row('EXPERTS', d.drop ? 'mlock' : d.mmap ? 'mmap' : 'RAM'));
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
}else if(c.CTX) rows.push(row('CTX', c.CTX));
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
+7 -2
View File
@@ -1383,7 +1383,10 @@ async function populateDraft(){
if(!sel) return;
const list = await jget('/api/models');
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
const items = (list||[]).filter(m => !isMmprojName(m.name));
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
const main = matchModel(currentModelInTextarea(), list);
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
const hit = matchModel(cur, items);
let html = '<option value="">— aucun —</option>';
let matched = false;
@@ -1393,7 +1396,9 @@ async function populateDraft(){
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
}
if(cur && !matched){
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
const self = main && matchModel(cur, [main]);
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
}
sel.innerHTML = html;
syncSpecRow();
+54 -7
View File
@@ -230,7 +230,47 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLink
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
// casserait les rendus suivants qui réutilisent la même entrée.
const IMG_CACHE = new Map();
// Vignettes du fil (repris d'AJEAN) : ~560 px suffisent à l'affichage, le
// serveur les calcule (?thumb=). Chargement DEUX PAR DEUX avec un nouvel essai :
// en accès distant, une rafale de captures saturait le tunnel et certaines
// n'arrivaient jamais.
const CHAT_THUMB_PX = 560;
let chatImgActive = 0;
const chatImgWaiting = [];
function chatImgQueue(job){
return new Promise((resolve, reject)=>{
const run = async ()=>{
chatImgActive++;
try{
try{ resolve(await job()); }
catch(_){ await new Promise(r=>setTimeout(r, 800)); resolve(await job()); }
}catch(e){ reject(e); }
finally{ chatImgActive--; const next = chatImgWaiting.shift(); if(next) next(); }
};
if(chatImgActive < 2) run(); else chatImgWaiting.push(run);
});
}
async function chatImgBlob(url){
const hit = IMG_CACHE.get(url);
if(hit) return hit;
const r = await jfetch(url);
if(!r.ok) throw new Error(r.status);
const obj = URL.createObjectURL(await r.blob());
IMG_CACHE.set(url, obj);
return obj;
}
function hydrateImages(root){
// Images du web (outil web_images, repris d'AJEAN) : même rendu et même
// loupe que les nôtres, sans Referer, et masquées si le site les refuse.
root.querySelectorAll('img[src^="http://"],img[src^="https://"]').forEach(img => {
if(img.dataset.hydrated) return;
img.dataset.hydrated = '1';
img.classList.add('chatimg');
img.loading = 'lazy';
img.referrerPolicy = 'no-referrer';
bindZoom(img, img.getAttribute('alt') || '');
img.addEventListener('error', ()=>{ img.style.display = 'none'; });
});
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
if(img.dataset.hydrated) return;
img.dataset.hydrated = '1';
@@ -242,15 +282,21 @@ function hydrateImages(root){
const m = /[?&]path=([^&]*)/.exec(src);
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
}
bindZoom(img, name);
const cached = IMG_CACHE.get(src);
const full = src.startsWith('/') ? src : '/' + src;
// Loupe : l'ORIGINAL, chargé seulement à l'agrandissement (le fil n'affiche
// qu'une vignette calculée par le serveur).
if(!img.dataset.zoom){
img.dataset.zoom = '1';
img.addEventListener('click', async ()=>{
try{ openLightbox(await chatImgBlob(full), name); }
catch(_){ openLightbox(img.currentSrc || img.src, name); }
});
}
const thumb = full + (full.includes('?') ? '&' : '?') + 'thumb=' + CHAT_THUMB_PX;
const cached = IMG_CACHE.get(thumb);
if(cached){ img.src = cached; return; }
try{
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
if(!r.ok) throw new Error(r.status);
const url = URL.createObjectURL(await r.blob());
IMG_CACHE.set(src, url);
img.src = url;
img.src = await chatImgQueue(()=>chatImgBlob(thumb));
}catch(e){
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
// tronquée en cours de frappe (404 transitoire) et le prochain delta
@@ -275,6 +321,7 @@ function renderToolMsg(el, tu){
write: {lbl:'fichier', head:'écriture'},
edit: {lbl:'édition', head:'édition'},
web_search: {lbl:'recherche', head:'recherche web'},
web_images: {lbl:'images', head:"recherche d'images"},
web_open: {lbl:'page web', head:'ouverture'},
web_read: {lbl:'page web', head:'lecture'},
web_grep: {lbl:'page web', head:'recherche'},
+52 -3
View File
@@ -84,7 +84,7 @@ function confirmPending(text){
let REPLAYING=true;
// État de rendu du tour courant, délimité par les événements user / turn_done.
let T=null;
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
function newTurn(){ T={ cuShotEl:null, reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
newTurn();
// --- Temps de travail du tour ------------------------------------------------
@@ -187,6 +187,54 @@ function paintStats(el, speed){
}
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
// Aperçu EN DIRECT du navigateur piloté (repris d'AJEAN). Après chaque action
// browser_*, le serveur garde une capture en mémoire (tu.shot = son id). Une
// seule carte par tour : la nouvelle image arrive en fondu par-dessus
// l'ancienne. Purement de l'interface : la carte s'efface à la fin du tour et
// rien n'est gardé. Une capture à conserver passe par browser_screenshot.
async function showCuShot(tu){
if(REPLAYING) return;
let card=T.cuShotEl;
if(!card){
card=document.createElement('div'); card.className='cu-shot';
card.innerHTML='<div class="cu-shot-cap"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/></svg><span class="cu-shot-t">navigateur en direct</span></div><div class="cu-shot-img"></div>';
T.cuShotEl=card;
}
if(tu.name==='browser_open' && tu.label) card.querySelector('.cu-shot-t').textContent=tu.label;
// Juste avant l'indicateur d'activité s'il est là, sinon en bas du fil.
const chat=chatEl(), anchor=(T.typingEl && T.typingEl.parentNode===chat) ? T.typingEl : null;
if(card.parentNode!==chat || card.nextSibling!==anchor){ if(anchor) chat.insertBefore(card, anchor); else chat.appendChild(card); }
const my=(card._n=(card._n||0)+1); // ordre d'arrivée des captures
let r; try{ r=await jget('/api/chat/cushot?id='+encodeURIComponent(tu.shot)); }catch(_){ return; }
if(!r || !r.ok || T.cuShotEl!==card) return; // carte déjà retirée (tour fini)
const box=card.querySelector('.cu-shot-img');
const img=new Image(); img.alt=''; img.className='cu-frame';
img.onclick=()=>openLightbox(img.src, card.querySelector('.cu-shot-t').textContent);
img.src='data:'+(r.mime||'image/jpeg')+';base64,'+r.data;
// Décodée AVANT d'entrer dans la page : jamais d'image à moitié peinte.
try{ await img.decode(); }catch(_){ return; }
// Une capture plus récente est déjà affichée (réponses dans le désordre) : on jette.
if(T.cuShotEl!==card || my<(card._shown||0)) return;
card._shown=my;
box.appendChild(img);
if(!box.style.aspectRatio) box.style.aspectRatio=img.naturalWidth+' / '+img.naturalHeight;
setTimeout(()=>img.classList.add('in'), 20);
// Fondu terminé : on retire les images PLUS ANCIENNES seulement.
setTimeout(()=>{ let x=img.previousElementSibling; while(x){ const p=x.previousElementSibling; x.remove(); x=p; } }, 480);
scrollMaybe();
}
// Fin du tour : l'aperçu s'efface en douceur. Toutes les cartes du fil, au cas
// où un tour précédent en aurait laissé une.
function hideCuShot(){
if(T) T.cuShotEl=null;
const chat=chatEl(); if(!chat) return;
chat.querySelectorAll(':scope > .cu-shot').forEach(card=>{
if(card.classList.contains('out')) return;
card.style.height=card.offsetHeight+'px'; void card.offsetHeight;
card.classList.add('out'); card.style.height='0px';
setTimeout(()=>card.remove(), 380);
});
}
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
@@ -536,7 +584,7 @@ function handleDelta(d){
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
// replay, qui rejoue tous les tours passés d'un bloc.
if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
if(d.turn_done){ hideCuShot(); settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
// le temps réellement passé et non continuer d'avancer.
T.doneTs = d.ts || T.doneTs || 0;
@@ -545,7 +593,7 @@ function handleDelta(d){
paintTurnClock(); // fige le total au-dessus de la carte
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.error){ hideCuShot(); settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
@@ -582,6 +630,7 @@ function handleDelta(d){
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
if(tu.done && tu.shot) showCuShot(tu);
if(tu.done){
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
+12 -1
View File
@@ -453,10 +453,21 @@ function lcStartPolling(){
lcPollJob();
}
// Une seule requête à la fois : l'intervalle d'une seconde relançait un appel
// avant la réponse du précédent quand le serveur était occupé (téléchargement,
// accès distant), et les deux ajoutaient les MÊMES lignes au journal.
let lcPollInflight = false;
async function lcPollJob(quiet){
if(lcPollInflight) return;
lcPollInflight = true;
const from = lcLogNext;
let j;
try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; }
try{ j = await jget('/api/llamacpp/job?from='+from); }catch(_){ return; }
finally{ lcPollInflight = false; }
if(!j.exists) return;
// Le journal a été remis à zéro ou avancé entre-temps (nouveau job) : ces
// lignes ne suivent plus ce qui est affiché.
if(from !== lcLogNext) return;
const phaseEl = document.getElementById('lc-job-phase');
if(j.lines && j.lines.length){
const pre = document.getElementById('lc-log');
+11 -5
View File
@@ -40,8 +40,9 @@ async function openStrata(presetId){
.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.';
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM dont '
+Math.round(env.avail_gb||env.ram_gb)+' disponibles, '
+Math.round(env.disk_free_gb)+' Go de disque libres. Réglages automatiques.';
if(!strataFam) strataFam = (s.families[0]||{}).id || '';
if(strataTarget){
for(const f of s.families){
@@ -93,7 +94,7 @@ function strataRenderQuants(pickReco){
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.'
: fit.mmap ? 'RAM disponible insuffisante pour tout charger : les experts seront lus depuis le disque (mmap, un peu plus lent). Installation et activation automatiques.'
: 'Installation et activation automatiques. Premier chargement : quelques minutes.';
}
@@ -122,6 +123,9 @@ function strataRenderSettings(fit){
['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)],
// Placement des experts : auto décide AU LANCEMENT sur la RAM disponible ;
// « disque » ne charge rien en RAM au démarrage (jamais tué faute de RAM).
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
['Lire les images', sw('strata-s-vision', d.vision)],
];
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
@@ -144,7 +148,8 @@ async function strataSaveSettings(){
if(!fit || !fit.preset) return;
const v = id => document.getElementById(id);
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false};
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
const r = await jpost('/api/strata/settings', body);
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
@@ -168,7 +173,8 @@ function strataRenderDetails(fit){
['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'],
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
["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],
+12
View File
@@ -215,6 +215,18 @@ function markFileLinks(root){
for(const a of root.querySelectorAll('a[href]')){
const p=fileLinkPath(a.getAttribute('href'));
if(!p) continue;
// Lien vers une IMAGE ([le graphique](graph.png)) : on l'affiche au lieu d'un
// simple bouton de téléchargement (repris d'AJEAN). hydrateImages, appelé
// juste après, la charge en vignette ; un clic ouvre l'original. Formats que
// le navigateur affiche seulement (pas de SVG servi tel quel, ni de HEIC).
if(/\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(p)){
const img=document.createElement('img');
img.setAttribute('src', '/api/chat/image?path='+encodeURIComponent(p));
img.alt=(a.textContent||'').trim() || p.split('/').pop();
img.loading='lazy';
a.replaceWith(img);
continue;
}
a.classList.add('filelink');
a.removeAttribute('target');
a.setAttribute('href','#');
+14
View File
@@ -2270,3 +2270,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
transition:background .15s,color .15s,border-color .15s}
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
#chat .history-more button:disabled{opacity:.6;cursor:default}
/* Aperçu en direct du navigateur piloté (09-stream.js showCuShot, repris d'AJEAN). */
.cu-shot{flex-shrink:0;align-self:flex-start;width:min(640px,100%);margin:2px 0 6px;overflow:hidden;
transition:height .35s cubic-bezier(.2,.7,.3,1),opacity .3s ease,margin .35s ease;animation:cushotcard .3s ease both}
.cu-shot.out{opacity:0;margin:0}
@keyframes cushotcard{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
.cu-shot-cap{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dim);margin:0 0 6px 2px;min-width:0}
.cu-shot-cap svg{flex:none}
.cu-shot-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.cu-shot-img{position:relative;border-radius:12px;overflow:hidden;background:var(--row-bg);
box-shadow:0 4px 18px rgba(0,0,0,.10);aspect-ratio:16 / 10}
.cu-shot-img img.cu-frame{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top;
opacity:0;transition:opacity .45s ease;cursor:zoom-in}
.cu-shot-img img.cu-frame.in{opacity:1}
@media (prefers-reduced-motion:reduce){.cu-shot,.cu-shot-img img.cu-frame{transition:none;animation:none}}
+13 -1
View File
@@ -107,6 +107,13 @@ func modelLoadErrorFrom(log string) string {
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
start := 0
for i, l := range lines {
// Une pile d'appels cite les fonctions traversées
// (…server_context_impl10load_model…) : ce n'est pas une nouvelle
// tentative, et repartir de là perdait la cause écrite juste avant
// (« cudaMalloc failed: out of memory » devenait « a planté »).
if isBacktraceFrame(l) {
continue
}
// 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) ||
@@ -159,7 +166,8 @@ func modelLoadErrorFrom(log string) string {
strings.Contains(low, "cudamalloc failed"),
strings.Contains(low, "failed to allocate"):
// Prioritaire sur l'échec générique : la cause mémoire doit gagner.
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite"
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite, " +
"et vérifie qu'aucun autre programme n'occupe la carte (nvidia-smi)"
case strings.Contains(low, "ggml_cuda error"), strings.Contains(low, "cuda error"):
if reason == "" {
reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"
@@ -196,6 +204,10 @@ func modelLoadErrorFrom(log string) string {
}
}
// isBacktraceFrame : une ligne de pile d'appels de glibc
// (« /chemin/lib.so(symbole+0x1c)[0x14bd…] »).
func isBacktraceFrame(l string) bool { return strings.Contains(l, ")[0x") }
// handleServiceLog (GET /api/service/log) : dernières lignes du journal du
// service, pour que l'UI puisse montrer POURQUOI le modèle ne se charge pas
// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu
+1
View File
@@ -362,6 +362,7 @@ func newWebMux() *http.ServeMux {
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier de la discussion
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
api("/api/chat/cushot", handleCUShot) // aperçu en direct du navigateur piloté (RAM, jamais gardé)
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
api("/api/chat/tool-result", handleToolResult) // résultat COMPLET d'un outil (« voir plus »)
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
+96
View File
@@ -0,0 +1,96 @@
package loki
// Vignettes des images du fil (voir handleChatImage, paramètre thumb ; repris d'AJEAN). Une
// vignette est calculée une fois par fichier et par taille, puis gardée en
// mémoire tant que le fichier ne change pas (date et taille).
import (
"bytes"
"fmt"
"image"
"image/color"
"image/draw"
"image/jpeg"
"os"
"strconv"
"sync"
"time"
_ "image/gif" // décodeurs enregistrés pour image.Decode
_ "image/png"
)
const (
thumbMaxSourceBytes = 40 << 20 // au-delà, pas de vignette : le client prendra l'original
thumbCacheMax = 300
)
type thumbEntry struct {
mod time.Time
size int64
data []byte
}
var (
thumbMu sync.Mutex
thumbCache = map[string]thumbEntry{}
)
// chatThumb renvoie une vignette JPEG dont le grand côté vaut px (borné
// 64..1600). Erreur si le fichier n'est pas une image décodable ici (webp…) :
// le client se rabat alors sur l'original.
func chatThumb(path string, st os.FileInfo, px int) ([]byte, string, error) {
if px < 64 {
px = 64
}
if px > 1600 {
px = 1600
}
if st.Size() > thumbMaxSourceBytes {
return nil, "", fmt.Errorf("image trop lourde pour une vignette")
}
key := path + "\x00" + strconv.Itoa(px)
thumbMu.Lock()
if e, ok := thumbCache[key]; ok && e.mod.Equal(st.ModTime()) && e.size == st.Size() {
thumbMu.Unlock()
return e.data, "image/jpeg", nil
}
thumbMu.Unlock()
raw, err := os.ReadFile(path)
if err != nil {
return nil, "", err
}
src, _, err := image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, "", fmt.Errorf("format non pris en charge pour une vignette")
}
if o := exifOrientation(raw); o >= 2 && o <= 8 {
src = applyOrientation(src, o)
}
if b := src.Bounds(); b.Dx() > px || b.Dy() > px {
src = scaleDownToMax(src, px)
}
// Fond blanc sous la transparence : en JPEG elle deviendrait noire.
flat := image.NewRGBA(src.Bounds())
draw.Draw(flat, flat.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
draw.Draw(flat, flat.Bounds(), src, src.Bounds().Min, draw.Over)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, flat, &jpeg.Options{Quality: 78}); err != nil {
return nil, "", err
}
data := buf.Bytes()
thumbMu.Lock()
if len(thumbCache) >= thumbCacheMax {
for k := range thumbCache { // éviction grossière : la place compte plus que l'ordre
delete(thumbCache, k)
if len(thumbCache) < thumbCacheMax/2 {
break
}
}
}
thumbCache[key] = thumbEntry{mod: st.ModTime(), size: st.Size(), data: data}
thumbMu.Unlock()
return data, "image/jpeg", nil
}
+47
View File
@@ -0,0 +1,47 @@
package loki
import (
"bytes"
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"testing"
)
func TestChatThumbReduitEtCache(t *testing.T) {
dir := t.TempDir()
p := filepath.Join(dir, "capture.png")
src := image.NewNRGBA(image.Rect(0, 0, 2400, 1500))
x := uint32(1)
for i := range src.Pix { // bruit : se comprime comme une photo, pas comme un motif
x ^= x << 13
x ^= x >> 17
x ^= x << 5
src.Pix[i] = byte(x)
}
src.Set(0, 0, color.NRGBA{0, 0, 0, 0})
var buf bytes.Buffer
_ = png.Encode(&buf, src)
_ = os.WriteFile(p, buf.Bytes(), 0o644)
st, _ := os.Stat(p)
data, mime, err := chatThumb(p, st, 560)
if err != nil || mime != "image/jpeg" {
t.Fatalf("vignette : %v %s", err, mime)
}
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil || img.Bounds().Dx() != 560 {
t.Fatalf("taille inattendue : %v %v", err, img.Bounds())
}
if len(data) >= buf.Len() {
t.Fatalf("vignette (%d o) pas plus légère que l'original (%d o)", len(data), buf.Len())
}
again, _, _ := chatThumb(p, st, 560)
if &again[0] != &data[0] {
t.Fatal("la 2e demande aurait dû venir du cache")
}
if _, _, err := chatThumb(filepath.Join(dir, "x.webp"), st, 560); err == nil {
t.Fatal("un fichier illisible doit renvoyer une erreur (repli client sur l'original)")
}
}
+15
View File
@@ -361,6 +361,21 @@ func handleChatImage(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"})
return
}
// Vignette légère (?thumb=<px>) : le fil n'a besoin que de ~560 px, pas
// d'une capture de 3 Mo — en accès distant, chaque image partait en entier
// dans le tunnel chiffré (repris d'AJEAN). Format non décodable ici (webp…) :
// on sert l'original.
if t := r.URL.Query().Get("thumb"); t != "" {
px, _ := strconv.Atoi(t)
if data, mime, err := chatThumb(abs, st, px); err == nil {
w.Header().Set("Content-Type", mime)
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
w.Header().Set("Cache-Control", "private, max-age=3600")
_, _ = w.Write(data)
return
}
}
f, err := os.Open(abs)
if err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})