Compare commits

...
Author SHA1 Message Date
MichaelandClaude Opus 5.5 4b859c4eb6 Conteneur : façade Rust en frontal, binaires mis à jour au démarrage
L'image embarque loki-rs (étape Alpine, binaire musl statique) et
l'entrypoint en fait la porte d'entrée : `loki-rs serve` sur le port
public, le Go derrière lui sur 127.0.0.1:8091. LOKI_FACADE=0 revient à
l'ancien schéma si la façade pose problème.

Au démarrage, le loki-rs de l'image installe la dernière release
« rolling » dans /data/bin/<build>/ ; l'entrypoint prend ensuite loki et
loki-rs du même dossier (which-bin), et un lien dans /usr/local/sbin fait
que `docker exec loki loki …` vise lui aussi la version installée. C'est
toujours le loki-rs de l'image qui met à jour : un binaire publié
défectueux ne peut pas bloquer le correctif suivant.

CI : job Rust (fmt, clippy -D warnings, tests). Publication : la release
« rolling » part à chaque push sur main avec le même numéro de run que
l'image (LOKI_BUILD) ; IMAGE_ABI, source unique, empêche d'installer des
binaires qui exigent une image plus récente. manifest.json est envoyé en
dernier.

Message corrigé quand l'image est plus récente que la release : rien à
tirer dans ce cas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:33:18 +02:00
MichaelandClaude Opus 5.5 0a6914c936 Façade Rust loki-rs : proxy devant le Go et mise à jour au démarrage
Premier lot de la migration vers Rust. Le binaire loki-rs :

- serve : prend le port public (8090), lance `loki web 8091` en écoute
  locale et lui relaie tout — HTTP, flux SSE trame par trame, WebSocket.
  Les en-têtes de bout en bout passent intacts (Host, Authorization,
  Origin, Sec-Fetch-*, X-Forwarded-Proto : le Go en tire ses contrôles
  d'accès) ; X-Loki-Via entrant est effacé, un client ne doit jamais
  pouvoir se faire passer pour le tunnel. Go mort : relancé avec attente
  croissante, 502 lisible entre-temps.
- self-update : lit la release « rolling », installe loki et loki-rs dans
  $LOKI_HOME/bin/<build>/ après vérification SHA-256, bascule par
  réécriture atomique de release.json. Les versions s'ordonnent par numéro
  de run CI ; une ABI d'image différente n'est jamais installée. Toute
  erreur laisse l'état précédent, le démarrage n'est jamais bloqué.
  Le HTTPS passe par curl (présent dans l'image) : pas de pile TLS ici.
- which-bin : dossier d'où l'entrypoint prend les deux binaires, toujours
  ensemble.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:30:09 +02:00
MichaelandClaude Opus 5.5 342f787eff Web : adresse d'écoute réglable par LOKI_WEB_BIND
La façade Rust du conteneur prend le port public et lance `loki web`
derrière elle : le Go doit alors n'écouter qu'en local, sinon il resterait
joignable en direct sans passer par la façade. Défaut inchangé (0.0.0.0).

Plan du lot 1 de la migration Rust joint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:31:28 +02:00
MichaelandClaude Opus 5.5 f1f73cfc50 Spec : migration Rust, lot 1 — façade et mise à jour au démarrage
La réécriture en Rust se fait par lots, Rust en production dès le premier :
une façade loki-rs prend le port 8090 et proxifie vers le Go, qui passe en
écoute locale sur 8091. Chaque lot suivant lui retire des routes.

Le même lot fait que le conteneur prend la dernière version au démarrage :
release GitHub « rolling » publiée à chaque push sur main, binaires vérifiés
par SHA256SUMS et posés dans /data/bin, garde-fou d'ABI d'image, jamais de
boot bloqué.

La façade efface X-Loki-Via à l'entrée : sur l'écoute directe, l'en-tête
pouvait être forgé pour contourner le contrôle d'hôte anti-rebinding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 00:27:59 +02:00
LogiFlow 6ea661d705 Merge branch 'claude/bold-faraday-11i7lh' : Strata sur un SSD dédié (Unraid)
Strata sur un SSD dédié (Unraid)
2026-10-08 12:30:44 +02:00
Claude e236069c92 Strata sur un SSD dédié : place libre mesurée sur /data/strata, caches pip/HF hors de l'image
- strataDetect mesure l'espace libre de /data/strata (et non /data) : un SSD
  monté à cet endroit est celui qui compte pour l'installation.
- L'installeur reçoit XDG_CACHE_HOME=/data/strata/cache : les caches pip et
  Hugging Face ne remplissent plus /root, c.-à-d. l'image Docker d'Unraid.
- docker-compose.unraid.yml + README : monter un pool SSD en /data/strata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XLbFcZj6mJjDpWvCpcChGA
2026-10-08 09:33:26 +00:00
LogiFlow 6afe07a823 Merge branch 'claude/wizardly-lamport-jfgrq5' : MTP sans recharger le modèle en brouillon
MTP : MODEL_DRAFT égal au modèle principal ignoré (poids chargés deux fois)
2026-10-08 11:18:00 +02:00
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
61 changed files with 4333 additions and 249 deletions

No files matched your search

+1
View File
@@ -8,3 +8,4 @@ dist/
!README.md
.env
.env.*
rust/target/
+18
View File
@@ -31,6 +31,24 @@ jobs:
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
done
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
rust:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
macos:
runs-on: macos-latest
steps:
+54
View File
@@ -54,6 +54,7 @@ jobs:
push: true
build-args: |
LOKI_VERSION=${{ github.sha }}
LOKI_BUILD=${{ github.run_number }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
@@ -61,3 +62,56 @@ jobs:
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
# Release « rolling » : les binaires que le conteneur installe lui-même au
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
# voit pas.
rolling:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: rolling-release
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- name: Outils musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
- name: Binaires
run: |
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
file dist/loki-rs-linux
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
abi=$(tr -d '[:space:]' < IMAGE_ABI)
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
cat dist/manifest.json dist/SHA256SUMS
- name: Publier
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view rolling >/dev/null 2>&1 || \
gh release create rolling --prerelease --target "$GITHUB_SHA" \
--title "rolling (mise à jour au démarrage)" \
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
gh release upload rolling dist/manifest.json --clobber
+3
View File
@@ -22,3 +22,6 @@ Thumbs.db
.pytest_cache/
__pycache__/
node_modules/
# Rust (façade loki-rs)
rust/target/
+21
View File
@@ -42,6 +42,17 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 ter : façade Rust loki-rs ───────────────────────────────────
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
# le même que celui publié dans la release « rolling » et installé à chaud
# dans /data/bin par `loki-rs self-update`.
FROM rust:1-alpine AS rustbuild
RUN apk add --no-cache musl-dev
WORKDIR /src
COPY rust/ ./
RUN cargo build --release --locked && mkdir -p /out && cp target/release/loki-rs /out/loki-rs
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
@@ -131,6 +142,7 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
@@ -147,6 +159,15 @@ ENV LOKI_CONTAINER=1 \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
# dépendances d'image les binaires exigent — un binaire publié pour une autre
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
ARG LOKI_BUILD=0
ENV LOKI_BUILD=${LOKI_BUILD}
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
# conteneur — il n'est pas authentifié par défaut.
EXPOSE 8090
+1
View File
@@ -0,0 +1 @@
1
+74 -2
View File
@@ -16,7 +16,9 @@ seule image.
```
┌────────────────── conteneur loki ──────────────────┐
│ loki web (UI + API, port 8090, premier plan) │
│ loki-rs serve (façade Rust, port 8090) │
│ │ relaie HTTP/SSE/WebSocket │
│ loki web (UI + API Go, 127.0.0.1:8091) │
│ │ pilote (fichier PID — pas de systemd) │
│ loki serve ──exec──► llama-server (CUDA, :8080) │
│ ▲ modèles .gguf │
@@ -35,6 +37,10 @@ seule image.
redémarre le moteur normalement.
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
seule l'UI (8090) l'est.
- **Migration vers Rust en cours**, par lots : la façade `loki-rs`
(`rust/loki-rs`) prend le port public et relaie au serveur Go ; chaque lot
lui fait reprendre des routes. Plan et feuille de route :
`docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Démarrage rapide (Docker, GPU NVIDIA)
@@ -120,6 +126,43 @@ verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
llama.cpp.
### Strata sur un SSD dédié (Unraid)
Strata range **tout** dans `/data/strata` : moteur, environnement Python (~10
Go), modèle (66 à 84 Go), et, quand la RAM ne suffit pas à garder les experts
(mode « lus depuis le disque »), il les relit en continu depuis ce dossier. Un
SSD de 250 Go dédié lui suffit largement (quelle que soit la qualité choisie)
et c'est lui qui fait la vitesse en mode disque : un NVMe vaut mieux qu'un SATA,
et un disque dur de l'array est à proscrire.
1. **Le SSD en pool Unraid.** Array arrêté : *Main → Add Pool*, nom `strata`,
1 emplacement, assigne le SSD. Démarre l'array, clique le pool, *File
system type* = `xfs` (ou `btrfs`), puis *Format* en bas de la page Main.
Le SSD est alors monté en `/mnt/strata`.
(Avec le plugin *Unassigned Devices* à la place : monte-le, il apparaît en
`/mnt/disks/<nom>` — même suite en remplaçant le chemin.)
2. **Le dossier** (terminal Unraid) : `mkdir -p /mnt/strata/loki`.
Vérifie avec `df -h /mnt/strata` que c'est bien le SSD (~230 Go) et pas
`rootfs` : un `/mnt/<pool>` qui n'existe pas est dans la RAM d'Unraid.
3. **Ce qui existe déjà.** Si une installation de Strata avait commencé dans
`/mnt/user/appdata/loki/data/strata`, *Compose Down* puis, au choix :
`rsync -a /mnt/user/appdata/loki/data/strata/ /mnt/strata/loki/` (on garde,
les presets restent valides : le chemin vu par le conteneur ne change pas),
ou supprime ce dossier pour repartir de zéro. Sinon il reste caché sous le
montage et occupe de la place pour rien.
4. **Le compose** : décommente dans `docker-compose.unraid.yml` la ligne
```yaml
- /mnt/strata/loki:/data/strata
```
(sous les deux volumes existants), puis *Compose Up*.
5. **Installer** : *Paramètres → Moteur → Strata*. La fenêtre affiche
maintenant l'espace libre **du SSD** ; choisis la version et la qualité
proposées, puis installe.
Pas de partage `/mnt/user/…` pour ce montage : la couche FUSE d'Unraid
ralentit fortement la lecture des experts en mmap. Et rien à faire côté mover
ou cache : le pool `strata` n'est lié à aucun partage.
## Installation sur Unraid
L'image est construite et publiée par GitHub Actions sur GHCR
@@ -135,6 +178,32 @@ Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose
`docker-compose.unraid.yml` → **Compose Up**.
4. Interface : `http://<ip-unraid>:8090`.
## Mise à jour au démarrage
Un **simple redémarrage du conteneur** (ou du serveur Unraid) suffit à passer
à la dernière version de Loki : pas besoin de `docker compose pull` pour les
changements de code.
- À chaque push sur `main`, la CI publie la release GitHub
[`rolling`](https://github.com/R0m1k3/Loki/releases/tag/rolling) : binaires
`loki` (Go) et `loki-rs` (Rust), `SHA256SUMS`, `manifest.json`.
- Au démarrage, `loki-rs self-update` les télécharge, vérifie leur empreinte
SHA-256 et les installe dans `/data/bin/<build>/`. Le journal du conteneur
dit ce qui s'est passé (`[loki-rs update] …`, puis
`[entrypoint] binaires Loki : …`).
- **Jamais bloquant** : réseau coupé, empreinte fausse, GitHub injoignable →
les binaires déjà en place servent, le conteneur démarre normalement.
- **Quand une nouvelle image est nécessaire** (nouvelle dépendance système,
Python, Node, llama.cpp…), le fichier `IMAGE_ABI` est incrémenté : les
conteneurs plus anciens n'installent pas ces binaires et le journal demande
un `docker compose pull`.
- La mise à jour est faite par le `loki-rs` **de l'image**, jamais par celui
téléchargé : une version publiée défectueuse ne peut pas empêcher le
correctif suivant d'arriver au redémarrage d'après.
- `docker exec loki loki …` utilise aussi la version installée.
- Couper : `LOKI_AUTO_UPDATE=0`. Revenir aux binaires de l'image : supprimer
`/data/bin` et redémarrer avec `LOKI_AUTO_UPDATE=0`.
## Configuration
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
@@ -152,6 +221,9 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
| `LOKI_TRUSTED_HOSTS` | noms de domaine autorisés à servir l'UI **sans** clé de pilotage (reverse proxy), séparés par des virgules | — |
| `LOKI_AUTO_UPDATE` | `0` : pas de mise à jour des binaires au démarrage | `1` |
| `LOKI_FACADE` | `0` : sans façade Rust, le Go sert directement le port public (porte de sortie) | `1` |
| `LOKI_GO_PORT` | port local (127.0.0.1) du serveur Go derrière la façade | `8091` |
**Accès par nom de domaine.** Sans clé de pilotage, l'API n'accepte que les
hôtes locaux (IP, `localhost`, nom sans point, `.local`/`.lan`…) et refuse
@@ -1025,7 +1097,7 @@ Une étape à la fois, mesurée avant la suivante :
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
| Mise à jour | `ajean update` (binaire GitHub) | au redémarrage du conteneur (release `rolling`) ; `docker compose pull` si l'image change |
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
Pour récupérer les évolutions de l'amont :
+13 -2
View File
@@ -16,8 +16,10 @@
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
#
# Mise à jour : Compose Down puis Up avec « pull image », ou
# « docker compose pull » avant Up.
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
# Si le journal demande une nouvelle image : Compose Down puis Up avec
# « pull image », ou « docker compose pull » avant Up.
# ───────────────────────────────────────────────────────────────────────────
services:
loki:
@@ -64,6 +66,15 @@ services:
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
- /mnt/user/appdata/loki/models:/models
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
# SSD dédié ».
# - /mnt/strata/loki:/data/strata
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
+39 -3
View File
@@ -1,6 +1,7 @@
#!/bin/sh
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
# configuration au premier démarrage, lance le moteur (supervision PID, sans
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
set -eu
: "${LOKI_HOME:=/data}"
@@ -15,6 +16,33 @@ if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
fi
# ── Mise à jour au démarrage ───────────────────────────────────────────────
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
/usr/local/bin/loki-rs self-update || true
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
# est plus récent que l'image et de même ABI, sinon l'image.
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
BIN_DIR=/usr/local/bin
fi
echo "[entrypoint] binaires Loki : $BIN_DIR"
# Tous les appels « loki » ci-dessous visent le binaire choisi.
loki() { "$BIN_DIR/loki" "$@"; }
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
if [ "$BIN_DIR" != /usr/local/bin ]; then
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
else
rm -f /usr/local/sbin/loki 2>/dev/null || true
fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
@@ -59,4 +87,12 @@ else
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
fi
exec loki web "${LOKI_WEB_PORT:-8090}"
# Façade Rust (loki-rs) sur le port public ; elle lance `loki web` sur
# 127.0.0.1:${LOKI_GO_PORT:-8091} et lui relaie tout. LOKI_FACADE=0 revient à
# l'ancien schéma (Go seul, directement sur le port public) — porte de sortie
# si la façade pose problème.
export LOKI_WEB_PORT="${LOKI_WEB_PORT:-8090}"
if [ "${LOKI_FACADE:-1}" = "0" ]; then
exec "$BIN_DIR/loki" web "$LOKI_WEB_PORT"
fi
exec "$BIN_DIR/loki-rs" serve
@@ -0,0 +1,122 @@
# Migration Rust lot 1 — façade + mise à jour au démarrage — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Mettre un binaire Rust `loki-rs` en frontal du conteneur (proxy vers le Go) et faire que le conteneur prenne les derniers binaires publiés à chaque démarrage.
**Architecture:** `loki-rs serve` écoute sur `LOKI_WEB_PORT` (8090), lance `loki web 8091` (Go, `LOKI_WEB_BIND=127.0.0.1`) en enfant supervisé et lui proxifie tout (HTTP, SSE, WebSocket). `loki-rs self-update` télécharge la release GitHub `rolling` dans `/data/bin/<build>/`, `loki-rs which-bin` dit à l'entrypoint quel dossier de binaires utiliser.
**Tech Stack:** Rust 2021 (tokio, hyper 1, hyper-util, http-body-util, reqwest rustls, sha2, serde_json), cible `x86_64-unknown-linux-musl` ; Go 1.25 existant ; GitHub Actions.
Spec : `docs/superpowers/specs/2026-10-13-rust-migration-lot1-design.md`.
## Global Constraints
- Port public inchangé : 8090 (`LOKI_WEB_PORT`). Go interne : `127.0.0.1:8091` (`LOKI_GO_PORT`).
- Release : tag `rolling`, pré-release, assets `loki-linux`, `loki-rs-linux`, `SHA256SUMS`, `manifest.json` (uploadé en dernier).
- `manifest.json` = `{"build": <github.run_number>, "abi": <IMAGE_ABI>, "commit": "<sha>"}`.
- Source unique de l'ABI : fichier `IMAGE_ABI` à la racine (contenu `1`), copié dans l'image en `/etc/loki/IMAGE_ABI`. Build de l'image : `ENV LOKI_BUILD=<run_number>` (0 en build local).
- Les binaires de `/data/bin/<build>/` ne sont utilisés que si `abi == IMAGE_ABI` **et** `build > LOKI_BUILD`.
- `LOKI_AUTO_UPDATE=0` : aucune requête réseau. Le boot n'est jamais bloqué (code 0, timeouts bornés).
- La façade efface `X-Loki-Via` entrant ; transmet `Host`, `Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto` intacts ; retire les hop-by-hop ; ne bufferise rien.
- Go : `gofmt -l .` vide + `staticcheck ./...` propre. Rust : `cargo fmt --check`, `cargo clippy --all-targets -- -D warnings`, `cargo test`.
- Git : jamais `git add -A` ni `git stash` ; commits en français, style narratif.
## File Structure
```
IMAGE_ABI ABI d'image (entier)
rust/Cargo.toml workspace
rust/loki-rs/Cargo.toml
rust/loki-rs/src/main.rs CLI : serve | self-update | which-bin | version
rust/loki-rs/src/proxy.rs proxy HTTP/SSE/WS vers le Go
rust/loki-rs/src/child.rs supervision de `loki web`
rust/loki-rs/src/update/mod.rs orchestration self-update + which-bin
rust/loki-rs/src/update/sums.rs parse SHA256SUMS
rust/loki-rs/src/update/state.rs release.json, décision, sélection du dossier
rust/loki-rs/tests/proxy.rs intégration façade
rust/loki-rs/tests/update.rs intégration self-update (faux serveur)
internal/loki/web_server.go LOKI_WEB_BIND
Dockerfile étape rustbuild, IMAGE_ABI, LOKI_BUILD
docker-entrypoint.sh self-update, which-bin, exec loki-rs serve
.github/workflows/ci.yml job rust
.github/workflows/docker-publish.yml LOKI_BUILD + job rolling
README.md section mise à jour au démarrage
```
---
### Task 1: Go — adresse d'écoute configurable
**Files:** Modify `internal/loki/web_server.go` (cmdWeb), Test `internal/loki/web_bind_test.go`
**Interfaces:** Produces `webBindAddr(port int) string` — `LOKI_WEB_BIND` (défaut `0.0.0.0`) + `:port`.
- [ ] Test : `t.Setenv("LOKI_WEB_BIND","")` → `"0.0.0.0:8090"` ; `"127.0.0.1"` → `"127.0.0.1:8091"`.
- [ ] `go test ./internal/loki -run TestWebBindAddr` → FAIL (indéfini).
- [ ] Implémenter, l'utiliser dans `cmdWeb` à la place de `fmt.Sprintf("0.0.0.0:%d", port)`.
- [ ] Test PASS, `gofmt -l .` vide, staticcheck propre. Commit.
### Task 2: Workspace Rust + parse SHA256SUMS + état d'installation
**Files:** Create `rust/Cargo.toml`, `rust/loki-rs/Cargo.toml`, `src/main.rs` (squelette CLI), `src/update/{mod,sums,state}.rs`, `.gitignore` (`rust/target/`).
**Interfaces (produits) :**
- `sums::parse(text:&str) -> HashMap<String,String>` (nom → hex minuscule ; accepte `*nom`, ignore lignes vides/invalides).
- `state::Manifest { build:u64, abi:u32, commit:String }` (serde).
- `state::Installed` = Manifest écrit dans `<dir>/release.json`.
- `state::read_installed(dir:&Path) -> Option<Manifest>`.
- `state::select_dir(data_bin:&Path, image_dir:&Path, image_build:u64, image_abi:u32) -> PathBuf` : `<data_bin>/<build>` si release.json valide, abi égal, build > image_build, et `loki` + `loki-rs` présents dedans ; sinon `image_dir`.
- `state::needs_update(m:&Manifest, image_build:u64, image_abi:u32, installed:Option<&Manifest>) -> Decision` avec `enum Decision { Install, UpToDate, AbiMismatch }`.
- [ ] Tests unitaires : parse (2 espaces, `*`, majuscules → minuscules, ligne cassée ignorée) ; `needs_update` (abi ≠ → AbiMismatch ; build ≤ image → UpToDate ; build ≤ installé → UpToDate ; sinon Install) ; `select_dir` (aucun fichier → image ; abi ≠ → image ; build ≤ image → image ; binaire manquant → image ; ok → data dir).
- [ ] `cargo test` FAIL puis implémentation, PASS, fmt + clippy. Commit.
### Task 3: self-update (téléchargement vérifié, installation atomique)
**Files:** `src/update/mod.rs`, `tests/update.rs`
**Interfaces :** `update::run(cfg:&UpdateConfig) -> Outcome` (jamais d'erreur remontée au CLI : journalise et rend `Outcome::{Installed(u64), UpToDate, AbiMismatch, Failed(String), Disabled}`) ; `UpdateConfig { base_url, data_bin, image_build, image_abi, enabled, timeout }` construit par `UpdateConfig::from_env()` (`LOKI_UPDATE_URL` défaut `https://github.com/R0m1k3/Loki/releases/download/rolling`, `LOKI_HOME/bin`, `LOKI_BUILD`, `/etc/loki/IMAGE_ABI` puis `LOKI_IMAGE_ABI`, `LOKI_AUTO_UPDATE`).
Algorithme : manifeste → décision → `SHA256SUMS` → télécharge `loki-linux`, `loki-rs-linux` dans `<data_bin>/.tmp-<build>/` → vérifie chaque hash → chmod 755 (unix) → renomme en `loki`, `loki-rs` → écrit `release.json` → renomme le dossier en `<data_bin>/<build>` → écrit `<data_bin>/release.json` (tmp + rename) → supprime les autres `<n>/` et `.tmp-*`. Toute erreur : supprime `.tmp-<build>`, `Failed`.
- [ ] Tests d'intégration (faux serveur hyper sur 127.0.0.1:0 servant des fichiers en mémoire) : installation OK (fichiers présents, release.json, select_dir pointe dessus) ; hash faux → rien installé, ancien état intact ; abi ≠ → rien téléchargé hormis le manifeste ; serveur absent → Failed, pas de panic ; `enabled=false` → Disabled sans requête ; seconde exécution → UpToDate et nettoyage des anciens builds.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 4: Façade proxy
**Files:** `src/proxy.rs`, `tests/proxy.rs`
**Interfaces :** `proxy::serve(listener: tokio::net::TcpListener, backend: SocketAddr, shutdown: impl Future) -> io::Result<()>`.
Comportement : hyper http1 + `with_upgrades` ; chaque requête → client hyper-util vers `backend` (URI `http://backend{path_and_query}`), Host d'origine conservé ; retire hop-by-hop (`connection`, `keep-alive`, `proxy-connection`, `te`, `trailer`, `transfer-encoding`, `upgrade`, + noms listés dans `Connection`) et `x-loki-via` ; si requête d'upgrade (`Connection: upgrade` + `Upgrade`), garde `Connection: upgrade` + `Upgrade`, attend 101, puis `copy_bidirectional` entre les deux côtés upgradés ; corps en streaming dans les deux sens ; erreur de connexion → 502 `text/plain` « Loki (Go) redémarre — réessaie dans un instant ».
- [ ] Tests (faux backend hyper) : GET relayé avec Host/Authorization/Origin/X-Forwarded-Proto intacts et `X-Loki-Via` absent côté backend ; POST corps relayé ; SSE : backend envoie 1er événement puis attend 500 ms — le client reçoit le 1er avant 300 ms ; WebSocket écho via tokio-tungstenite ; backend arrêté → 502.
- [ ] FAIL → implémentation → PASS, fmt + clippy. Commit.
### Task 5: Supervision du Go + commande `serve`
**Files:** `src/child.rs`, `src/main.rs`
**Interfaces :** `child::Supervisor::spawn(go_bin:PathBuf, port:u16) -> Supervisor` (relance avec backoff 1 s → 30 s, remis à 1 s après 60 s de vie ; stdin null, stdout/stderr hérités ; env `LOKI_WEB_BIND=127.0.0.1`) ; `Supervisor::shutdown(self, grace:Duration)` (SIGTERM unix / kill windows, puis kill après `grace`).
`main serve` : go_bin = `LOKI_GO_BIN` sinon `loki` à côté de l'exe courant ; attend jusqu'à 30 s que `127.0.0.1:LOKI_GO_PORT` accepte (sinon ouvre quand même, 502 en attendant) ; `proxy::serve` sur `0.0.0.0:LOKI_WEB_PORT` ; SIGTERM/SIGINT → arrêt du proxy + `shutdown(10 s)`.
`main which-bin` imprime `select_dir(...)` ; `main version` imprime version crate + `LOKI_BUILD`.
- [ ] Test unitaire `go_bin` par défaut ; test du backoff (fonction pure `next_backoff(prev, lived) -> Duration`).
- [ ] Test manuel local : `loki-rs serve` avec un `loki.exe` construit → `curl localhost:8090/` renvoie l'UI.
- [ ] Commit.
### Task 6: Image, entrypoint, CI, release rolling, doc
**Files:** `IMAGE_ABI`, `Dockerfile`, `docker-entrypoint.sh`, `.github/workflows/ci.yml`, `.github/workflows/docker-publish.yml`, `README.md`.
- Dockerfile : étape `FROM rust:1-alpine AS rustbuild` (`apk add musl-dev`, `cargo build --release --locked`), copie `/usr/local/bin/loki-rs` ; `ARG LOKI_BUILD=0` → `ENV LOKI_BUILD` ; `COPY IMAGE_ABI /etc/loki/IMAGE_ABI`.
- Entrypoint : `loki-rs self-update` (best-effort) ; `BIN_DIR=$(loki-rs which-bin)` ; `LOKI="$BIN_DIR/loki"` pour tous les appels config/start ; `exec "$BIN_DIR/loki-rs" serve`.
- CI : job `rust` (fmt, clippy, test sur ubuntu).
- docker-publish : `LOKI_BUILD=${{ github.run_number }}` en build-arg ; job `rolling` (contents: write, concurrency `rolling`) : build Go linux CGO=0, build Rust musl (`rustup target add x86_64-unknown-linux-musl`, `musl-tools`), SHA256SUMS, manifest, `gh release create rolling --prerelease` si absent, upload `--clobber` binaires+sums puis manifest.
- README : section « Mise à jour au démarrage » (comportement, `LOKI_AUTO_UPDATE=0`, ABI → pull).
- [ ] `sh -n docker-entrypoint.sh`, YAML relu, commit.
## Self-review
Spec couvert : façade (T4/T5), X-Loki-Via (T4), bind Go (T1), rolling + manifeste + ABI (T2/T3/T6), repli et boot non bloquant (T3/T6), désactivation (T3), tests (T2–T4), CI (T6). Écart assumé vs spec : ordre des versions par `build` (run_number) et non par commit, et installation dans `/data/bin/<build>/` (bascule atomique par `release.json`).
@@ -0,0 +1,150 @@
# Migration de Loki vers Rust — lot 1 : façade Rust + mise à jour au démarrage
Date : 2026-10-13
Statut : validé (design), à planifier
## Contexte
Loki est aujourd'hui un monobinaire Go (`internal/loki`, ~65 000 lignes hors
tests) qui sert une UI embarquée (~26 000 lignes JS/CSS/HTML) et pilote
llama.cpp, Strata, MCP, le mode Code, etc. L'objectif à terme est de le
réécrire en Rust. Une réécriture d'un bloc ne livrerait rien pendant des
semaines : la migration est **progressive**, par lots, avec Rust en production
dès le premier lot.
Second besoin : aujourd'hui, un `docker restart` relance la même image. Le
conteneur doit prendre la dernière version de Loki **au prochain démarrage**,
sans `docker compose pull` manuel.
## Architecture de cohabitation (« strangler fig »)
```
:8090 ──► loki-rs (Rust, axum/tokio)
├─ routes portées → traitées en Rust
└─ tout le reste → proxy HTTP / SSE / WebSocket
→ loki web (Go) sur 127.0.0.1:8091
```
- Nouveau workspace Cargo dans `rust/`, binaire `loki-rs`, compilé en statique
(`x86_64-unknown-linux-musl`) : indépendant de la glibc de l'image.
- Go écoute uniquement en local sur 8091 (nouvelle option d'adresse d'écoute
de `loki web`). Le port public reste 8090 : compose et reverse proxy
inchangés.
- Au lot 1, **aucune route n'est portée** : la façade proxifie tout. Elle sert
de socle aux lots suivants.
- Authentification (clé de pilotage, `LOKI_TRUSTED_HOSTS`) : reste à Go tant
qu'il sert la route. Go ne fonde aucune décision d'accès sur l'IP source
(vérifié : ni `RemoteAddr` ni `X-Forwarded-For` dans `internal/loki`), donc
passer par 127.0.0.1 n'élargit rien. La façade transmet `Host`,
`Authorization`, `Cookie`, `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`
**sans les altérer**, et retire seulement les en-têtes hop-by-hop.
- **Sécurité** : la façade **efface `X-Loki-Via`** de toute requête entrante.
Cet en-tête, posé par le tunnel du relais, dispense du contrôle d'hôte
anti-DNS-rebinding (`crossSiteReject`). Aujourd'hui l'écoute HTTP directe de
Go ne l'efface pas : sans clé de pilotage, une page en DNS rebinding peut le
forger. La façade ferme ce trou dans le conteneur ; un correctif Go séparé
le ferme pour `loki web` hors conteneur. Test dédié.
- Flux : pas de timeout d'écriture, pas de bufferisation des réponses
`text/event-stream` (chat SSE, `/v1` en streaming), upgrade WebSocket relayé
(`/api/node/ws` et autres). Pas de recompression : Go gère déjà gzip.
- Le relais `loki link` reste entièrement côté Go jusqu'à son propre lot.
### Démarrage et arrêt
`loki-rs serve` lance `loki web 8091` (Go) comme processus enfant, attend
qu'il réponde, puis ouvre 8090. Si l'enfant meurt, la façade le relance
(backoff borné) et répond 502 avec un message clair entre-temps. Sur SIGTERM,
la façade transmet le signal à l'enfant et attend sa fin (moteur llama-server
arrêté proprement par Go, comme aujourd'hui).
## Mise à jour au démarrage
### Publication (CI)
À chaque push sur `main`, en plus de l'image GHCR, un workflow publie la
release GitHub **`rolling`** (pré-release, assets écrasés) :
- `loki-linux` — binaire Go (déjà `CGO_ENABLED=0`)
- `loki-rs-linux` — binaire Rust musl
- `SHA256SUMS` — format `sha256sum` (celui que `loki update` sait déjà lire)
- `manifest.json` — `{"commit": "<sha>", "image_abi": <entier>}`
La release `rolling` n'est jamais marquée `latest` : `loki update` (machines
non conteneurisées) continue de suivre les tags `vX.Y.Z`.
### ABI d'image
Le Dockerfile déclare `ENV LOKI_IMAGE_ABI=1`. On l'incrémente (Dockerfile et
source du manifeste dans le même commit) quand le code exige un changement de
l'image : nouvelle dépendance système, Python/Node, chemin du moteur, etc.
Un binaire publié avec une ABI différente de celle de l'image n'est **jamais**
installé : il ne pourrait pas tourner correctement.
### Au boot (`loki-rs self-update`)
Appelé par `docker-entrypoint.sh`, avec le `loki-rs` de l'image :
1. `LOKI_AUTO_UPDATE=0` → ne fait rien.
2. Télécharge `manifest.json` (timeout global 20 s). Erreur réseau → étape 6.
3. `image_abi` ≠ `LOKI_IMAGE_ABI` → journal :
« nouvelle version nécessitant une nouvelle image : docker compose pull »,
étape 6.
4. `commit` identique à `/data/bin/VERSION` → rien à faire, étape 6.
5. Télécharge les deux binaires en `/data/bin/*.part`, vérifie chacun contre
`SHA256SUMS`, `chmod +x`, renomme atomiquement, écrit `VERSION` en dernier.
Toute erreur → `.part` supprimés, rien remplacé.
6. Fin. Code de sortie 0 dans tous les cas : le boot n'est jamais bloqué.
L'entrypoint choisit ensuite les binaires : `/data/bin/loki` et
`/data/bin/loki-rs` s'ils existent, sont exécutables **et** que
`/data/bin/ABI` égale `LOKI_IMAGE_ABI` ; sinon ceux de l'image. Les deux
binaires viennent toujours de la même source (jamais un Go de l'image avec un
Rust de `/data/bin`). Le chemin Go choisi est passé à la façade
(`LOKI_GO_BIN`) et utilisé pour les appels `loki config` de l'entrypoint.
Limite assumée : seuls les binaires Loki se mettent à jour ainsi ; llama.cpp
se met à jour via l'UI (existant), le reste de l'image via un pull signalé par
l'ABI.
## Organisation du code Rust
```
rust/
Cargo.toml workspace
loki-rs/
src/main.rs CLI : serve | self-update | version
src/proxy.rs façade HTTP/SSE/WebSocket
src/child.rs supervision du processus Go
src/update/ manifeste, téléchargement, SHA256SUMS, installation
```
Dépendances : `tokio`, `axum`, `hyper`/`hyper-util`, `reqwest` (rustls,
sans OpenSSL — musl), `sha2`, `serde`/`serde_json`, `clap`.
## Tests
- Unitaires : parse de `SHA256SUMS` (entrées `*nom`, nom absent), décision
d'installation (ABI différente, même commit, `LOKI_AUTO_UPDATE=0`),
installation atomique (échec au milieu → rien remplacé).
- Intégration : façade devant un faux backend axum —
SSE reçu morceau par morceau (pas en bloc), WebSocket écho,
en-têtes `Host`/`Authorization`/`Origin` transmis intacts, `X-Loki-Via`
entrant effacé, 502 si backend absent.
- Mise à jour : faux serveur de release local (URL de base injectable par
variable d'environnement de test).
- CI : `cargo fmt --check`, `cargo clippy -D warnings`, `cargo test` dans un
job dédié ; la CI Go (gofmt + staticcheck + tests) reste inchangée.
## Feuille de route des lots suivants (hors périmètre de ce spec)
| Lot | Contenu |
|---|---|
| 2 | Fichiers statiques de l'UI ; lecture de la config via une API interne Go |
| 3 | Supervision de llama-server |
| 4 | Client LLM, `/v1` OpenAI, flux chat SSE |
| 5 | Outils de l'agent, MCP, mode Code/LSP |
| 6 | Modèles et téléchargements, Strata, optimiseur, bench |
| 7 | Mémoire, tâches, dictée, relais, CDP |
| 8 | Store bbolt → redb, retrait de Go |
Chaque lot aura son propre spec, plan et PR.
+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)
}
}
+283 -76
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,7 +283,10 @@ const strataMinArch = 75
func strataDetect() strataEnv {
env := strataEnv{Main: -1, Helper: -1, RAMGB: totalRAMGB()}
if f := diskFree(LokiHome()); f > 0 {
env.AvailGB = min(memAvailableGB()+strataEngineRSSGB(), env.RAMGB)
// strataHome() et pas LokiHome() : /data/strata peut être un disque à part
// (SSD dédié monté dans le conteneur), c'est sa place libre qui compte.
if f := diskFree(strataHome()); f > 0 {
env.DiskFreeGB = float64(f) / 1e9
}
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
@@ -281,32 +396,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 +472,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 +502,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 +534,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 +559,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)
@@ -623,7 +776,10 @@ func strataRunInstall(req strataInstallReq) {
// réserver sa propre variante (qui ne voit pas un experts.bin présent).
"--low-ram", "off",
}
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1"
// XDG_CACHE_HOME : les caches de pip et de Hugging Face (~/.cache, soit
// /root dans le conteneur, donc l'image Docker — 20 Go sur Unraid) suivent
// le reste de Strata dans /data/strata.
extra := "CUDA_DEVICE_ORDER=PCI_BUS_ID\x00PYTHONUNBUFFERED=1\x00XDG_CACHE_HOME=" + filepath.Join(strataHome(), "cache")
if err := runStepEnv("installation de Strata", strataSrcDir(), extra, "bash", args...); err != nil {
lcFail(fmt.Errorf("l'installation a échoué : %w (voir le journal)", err))
return
@@ -646,8 +802,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 +930,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 +969,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 +1039,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 +1201,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 +1309,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 +1328,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 +1348,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
+16
View File
@@ -0,0 +1,16 @@
package loki
import "testing"
// La façade Rust lance `loki web` derrière elle : le Go ne doit alors écouter
// qu'en local, sinon il resterait joignable en direct, sans la façade.
func TestWebBindAddr(t *testing.T) {
t.Setenv("LOKI_WEB_BIND", "")
if got := webBindAddr(8090); got != "0.0.0.0:8090" {
t.Fatalf("défaut : %q", got)
}
t.Setenv("LOKI_WEB_BIND", "127.0.0.1")
if got := webBindAddr(8091); got != "127.0.0.1:8091" {
t.Fatalf("local : %q", got)
}
}
+13 -1
View File
@@ -33,7 +33,7 @@ func cmdWeb(args []string) error {
port = n
}
mux := newWebMux()
addr := fmt.Sprintf("0.0.0.0:%d", port)
addr := webBindAddr(port)
ln, err := net.Listen("tcp", addr)
if err != nil {
@@ -100,6 +100,17 @@ func cmdWeb(args []string) error {
return srv.Serve(ln)
}
// webBindAddr renvoie l'adresse d'écoute de l'UI. LOKI_WEB_BIND (défaut
// 0.0.0.0) existe pour la façade Rust du conteneur (rust/loki-rs) : elle prend
// le port public et lance `loki web` en local seulement, sur 127.0.0.1.
func webBindAddr(port int) string {
host := strings.TrimSpace(os.Getenv("LOKI_WEB_BIND"))
if host == "" {
host = "0.0.0.0"
}
return net.JoinHostPort(host, strconv.Itoa(port))
}
// newWebMux construit le routeur HTTP de l'UI web. Extrait de cmdWeb pour être
// réutilisé par `loki link`, qui sert ce même mux à travers le tunnel sans
// repasser par un écouteur TCP local.
@@ -362,6 +373,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()})
+670
View File
@@ -0,0 +1,670 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "chacha20"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
"cpufeatures",
"rand_core",
]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-sink",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"rand_core",
]
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]]
name = "hyper"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [
"atomic-waker",
"bytes",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
"want",
]
[[package]]
name = "hyper-util"
version = "0.1.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
dependencies = [
"bytes",
"futures-channel",
"futures-util",
"http",
"http-body",
"httparse",
"hyper",
"libc",
"pin-project-lite",
"socket2",
"tokio",
"tower-service",
"tracing",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.190"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "loki-rs"
version = "0.1.0"
dependencies = [
"bytes",
"futures-util",
"hex",
"http-body-util",
"hyper",
"hyper-util",
"libc",
"serde",
"serde_json",
"sha2",
"tempfile",
"tokio",
"tokio-tungstenite",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mio"
version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
dependencies = [
"chacha20",
"getrandom",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rustix"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "sha2"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "try-lock"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "tungstenite"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22"
dependencies = [
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"sha1",
"thiserror",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "want"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b"
dependencies = [
"try-lock",
]
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+7
View File
@@ -0,0 +1,7 @@
[workspace]
resolver = "2"
members = ["loki-rs"]
[profile.release]
strip = true
lto = "thin"
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "loki-rs"
version = "0.1.0"
edition = "2024"
[dependencies]
bytes = "1.12.1"
hex = "0.4.3"
http-body-util = "0.1.5"
hyper = { version = "1.12.0", features = ["http1", "server", "client"] }
hyper-util = { version = "0.1.21", features = ["tokio", "server", "client-legacy", "http1"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sha2 = "0.11.1"
tokio = { version = "1.53.2", features = ["rt-multi-thread", "macros", "net", "io-util", "time", "signal", "process", "sync", "fs"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
[dev-dependencies]
futures-util = "0.3.34"
tempfile = "3.27.0"
tokio-tungstenite = "0.30.0"
+158
View File
@@ -0,0 +1,158 @@
//! Supervision du serveur Go (`loki web`) lancé derrière la façade.
//!
//! Le Go reste propriétaire de presque tout au lot 1 : s'il meurt, la façade
//! le relance (attente croissante 1 s → 30 s, remise à 1 s après une minute
//! de vie) et répond 502 entre-temps au lieu de tomber avec lui.
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::{Duration, Instant};
use tokio::process::{Child, Command};
use tokio::sync::oneshot;
const MIN_DELAY: Duration = Duration::from_secs(1);
const MAX_DELAY: Duration = Duration::from_secs(30);
const STABLE_AFTER: Duration = Duration::from_secs(60);
/// Attente avant la relance suivante, connaissant la précédente et la durée
/// de vie du processus qui vient de mourir.
pub fn next_delay(prev: Duration, lived: Duration) -> Duration {
if lived >= STABLE_AFTER {
MIN_DELAY
} else {
(prev * 2).clamp(MIN_DELAY, MAX_DELAY)
}
}
/// Binaire Go à lancer : `LOKI_GO_BIN`, sinon `loki` à côté de l'exécutable
/// courant (les deux viennent toujours du même dossier, cf. `which-bin`).
pub fn go_bin(env_value: Option<String>, exe: &Path) -> PathBuf {
if let Some(p) = env_value.filter(|v| !v.trim().is_empty()) {
return PathBuf::from(p);
}
let name = if cfg!(windows) { "loki.exe" } else { "loki" };
exe.parent().unwrap_or(Path::new(".")).join(name)
}
pub struct Supervisor {
stop: Option<oneshot::Sender<Duration>>,
done: tokio::task::JoinHandle<()>,
}
impl Supervisor {
/// Lance `<go_bin> web <port>` en écoute locale et le maintient en vie.
pub fn spawn(go_bin: PathBuf, port: u16) -> Supervisor {
let (tx, mut rx) = oneshot::channel::<Duration>();
let done = tokio::spawn(async move {
let mut delay = Duration::ZERO;
loop {
let started = Instant::now();
let mut child = match launch(&go_bin, port) {
Ok(c) => c,
Err(e) => {
eprintln!(
"[loki-rs] lancement de {} impossible : {e}",
go_bin.display()
);
delay = next_delay(delay, Duration::ZERO);
tokio::select! {
_ = tokio::time::sleep(delay) => continue,
_ = &mut rx => return,
}
}
};
tokio::select! {
status = child.wait() => {
let lived = started.elapsed();
delay = next_delay(delay, lived);
let status = match status {
Ok(s) => s.to_string(),
Err(e) => e.to_string(),
};
eprintln!(
"[loki-rs] serveur Go arrêté ({status}) après {lived:?} — relance dans {delay:?}"
);
tokio::select! {
_ = tokio::time::sleep(delay) => {}
_ = &mut rx => return,
}
}
grace = &mut rx => {
stop_child(&mut child, grace.unwrap_or(MIN_DELAY)).await;
return;
}
}
}
});
Supervisor {
stop: Some(tx),
done,
}
}
/// Arrête le Go : SIGTERM, puis SIGKILL passé `grace`.
pub async fn shutdown(mut self, grace: Duration) {
if let Some(tx) = self.stop.take() {
let _ = tx.send(grace);
}
let _ = (&mut self.done).await;
}
}
fn launch(go_bin: &Path, port: u16) -> std::io::Result<Child> {
Command::new(go_bin)
.arg("web")
.arg(port.to_string())
.env("LOKI_WEB_BIND", "127.0.0.1")
// stdin fermé : `loki web` ne doit jamais attendre une réponse au
// clavier (résolution de conflit de port).
.stdin(Stdio::null())
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
}
async fn stop_child(child: &mut Child, grace: Duration) {
#[cfg(unix)]
if let Some(pid) = child.id() {
// SAFETY: kill(2) sur le PID de notre propre enfant, pas encore récolté.
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
if tokio::time::timeout(grace, child.wait()).await.is_ok() {
return;
}
}
#[cfg(not(unix))]
let _ = grace;
let _ = child.kill().await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn attente_croissante_puis_remise_a_zero() {
let s = Duration::from_secs;
assert_eq!(next_delay(Duration::ZERO, s(0)), s(1));
assert_eq!(next_delay(s(1), s(2)), s(2));
assert_eq!(next_delay(s(16), s(2)), s(30));
assert_eq!(next_delay(s(30), s(2)), s(30));
assert_eq!(next_delay(s(30), s(61)), s(1));
}
#[test]
fn binaire_go_par_defaut_a_cote_de_l_exe() {
let exe = Path::new("/usr/local/bin/loki-rs");
let name = if cfg!(windows) { "loki.exe" } else { "loki" };
let dir = Path::new("/usr/local/bin");
assert_eq!(go_bin(None, exe), dir.join(name));
assert_eq!(go_bin(Some(" ".into()), exe), dir.join(name));
assert_eq!(
go_bin(Some("/x/loki".into()), exe),
PathBuf::from("/x/loki")
);
}
}
+9
View File
@@ -0,0 +1,9 @@
//! loki-rs — façade Rust de Loki (lot 1 de la migration Go → Rust).
//!
//! - `proxy` : façade HTTP/SSE/WebSocket devant le serveur Go ;
//! - `child` : supervision du processus `loki web` (Go) ;
//! - `update` : mise à jour des binaires au démarrage du conteneur.
pub mod child;
pub mod proxy;
pub mod update;
+151
View File
@@ -0,0 +1,151 @@
//! loki-rs — façade Rust de Loki.
//!
//! ```text
//! loki-rs serve façade sur LOKI_WEB_PORT (8090), Go sur 127.0.0.1:LOKI_GO_PORT (8091)
//! loki-rs self-update met à jour les binaires dans $LOKI_HOME/bin (jamais bloquant)
//! loki-rs which-bin dossier d'où prendre loki et loki-rs
//! loki-rs version
//! ```
use loki_rs::{child, proxy, update};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::process::ExitCode;
use std::time::{Duration, Instant};
fn main() -> ExitCode {
let cmd = std::env::args().nth(1).unwrap_or_default();
match cmd.as_str() {
"serve" => serve(),
"self-update" => {
let outcome = update::run(&update::UpdateConfig::from_env());
eprintln!("[loki-rs update] {}", outcome.message());
ExitCode::SUCCESS
}
"which-bin" => {
let cfg = update::UpdateConfig::from_env();
let dir = update::state::select_dir(
&cfg.data_bin,
&image_dir(),
cfg.image_build,
cfg.image_abi,
);
println!("{}", dir.display());
ExitCode::SUCCESS
}
"version" | "--version" | "-V" => {
let cfg = update::UpdateConfig::from_env();
println!(
"loki-rs {} (build image {}, ABI {})",
env!("CARGO_PKG_VERSION"),
cfg.image_build,
cfg.image_abi
);
ExitCode::SUCCESS
}
_ => {
eprintln!("usage : loki-rs serve | self-update | which-bin | version");
ExitCode::from(2)
}
}
}
fn exe() -> PathBuf {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("loki-rs"))
}
fn image_dir() -> PathBuf {
exe()
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
}
fn env_port(key: &str, default: u16) -> u16 {
std::env::var(key)
.ok()
.and_then(|v| v.trim().parse().ok())
.unwrap_or(default)
}
fn serve() -> ExitCode {
let rt = match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
eprintln!("[loki-rs] runtime : {e}");
return ExitCode::FAILURE;
}
};
rt.block_on(async {
let web_port = env_port("LOKI_WEB_PORT", 8090);
let go_port = env_port("LOKI_GO_PORT", 8091);
let go_bin = child::go_bin(std::env::var("LOKI_GO_BIN").ok(), &exe());
let backend = SocketAddr::from(([127, 0, 0, 1], go_port));
eprintln!(
"[loki-rs] serveur Go : {} web {go_port} (local)",
go_bin.display()
);
let sup = child::Supervisor::spawn(go_bin, go_port);
wait_for(backend, Duration::from_secs(30)).await;
let addr = SocketAddr::from(([0, 0, 0, 0], web_port));
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("[loki-rs] écoute sur {addr} impossible : {e}");
sup.shutdown(Duration::from_secs(10)).await;
return ExitCode::FAILURE;
}
};
eprintln!("[loki-rs] façade : http://{addr} → {backend}");
let res = proxy::serve(listener, backend, shutdown_signal()).await;
eprintln!("[loki-rs] arrêt demandé — arrêt du serveur Go");
sup.shutdown(Duration::from_secs(10)).await;
match res {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("[loki-rs] {e}");
ExitCode::FAILURE
}
}
})
}
/// Attend que le Go accepte les connexions, sans bloquer au-delà de `max` :
/// la façade ouvre quand même et répond 502 en attendant.
async fn wait_for(addr: SocketAddr, max: Duration) {
let t0 = Instant::now();
while t0.elapsed() < max {
if tokio::net::TcpStream::connect(addr).await.is_ok() {
return;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
eprintln!("[loki-rs] le serveur Go ne répond pas encore — façade ouverte quand même");
}
async fn shutdown_signal() {
#[cfg(unix)]
{
use tokio::signal::unix::{SignalKind, signal};
match signal(SignalKind::terminate()) {
Ok(mut term) => {
tokio::select! {
_ = term.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
}
Err(_) => {
let _ = tokio::signal::ctrl_c().await;
}
}
}
#[cfg(not(unix))]
{
let _ = tokio::signal::ctrl_c().await;
}
}
+229
View File
@@ -0,0 +1,229 @@
//! Façade HTTP : reçoit tout le trafic public et le relaie au serveur Go
//! (`loki web` sur 127.0.0.1). Les lots suivants de la migration y brancheront
//! les routes réécrites en Rust ; au lot 1, tout est relayé.
//!
//! Règles, héritées de `web_server.go` :
//! - aucun tampon : un flux SSE (chat, `/v1` en streaming) part trame par trame ;
//! - aucun délai d'écriture : un flux reste ouvert tant que la page l'écoute ;
//! - en-têtes de bout en bout transmis intacts (`Host`, `Authorization`,
//! `Origin`, `Sec-Fetch-*`, `X-Forwarded-Proto`…) : le Go s'en sert pour ses
//! contrôles d'accès ; seuls les en-têtes hop-by-hop sont retirés ;
//! - `X-Loki-Via` entrant effacé : il marque le trafic du tunnel du relais et
//! dispense du contrôle d'hôte anti-DNS-rebinding — un client ne doit jamais
//! pouvoir le poser lui-même.
use bytes::Bytes;
use http_body_util::{BodyExt, Full, combinators::BoxBody};
use hyper::body::Incoming;
use hyper::header::{self, HeaderMap, HeaderName, HeaderValue};
use hyper::service::service_fn;
use hyper::{Request, Response, StatusCode, Uri};
use hyper_util::client::legacy::{Client, connect::HttpConnector};
use hyper_util::rt::{TokioExecutor, TokioIo};
use std::convert::Infallible;
use std::future::Future;
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
pub type Body = BoxBody<Bytes, hyper::Error>;
/// En-tête posé par le tunnel du relais côté Go (`viaTunnelHeader`).
pub const VIA_TUNNEL: &str = "x-loki-via";
const HOP_BY_HOP: [&str; 8] = [
"connection",
"keep-alive",
"proxy-connection",
"proxy-authenticate",
"te",
"trailer",
"transfer-encoding",
"upgrade",
];
struct Proxy {
client: Client<HttpConnector, Incoming>,
backend: SocketAddr,
}
/// Sert la façade sur `listener` jusqu'à ce que `shutdown` se termine.
pub async fn serve(
listener: TcpListener,
backend: SocketAddr,
shutdown: impl Future<Output = ()>,
) -> std::io::Result<()> {
let client = Client::builder(TokioExecutor::new()).build_http();
let proxy = Arc::new(Proxy { client, backend });
tokio::pin!(shutdown);
loop {
tokio::select! {
_ = &mut shutdown => return Ok(()),
res = listener.accept() => {
let stream = match res {
Ok((s, _)) => s,
Err(e) => {
// EMFILE & co : on ne lâche pas l'écoute pour autant.
eprintln!("[loki-rs] accept : {e}");
tokio::time::sleep(Duration::from_millis(50)).await;
continue;
}
};
let _ = stream.set_nodelay(true);
let p = proxy.clone();
tokio::spawn(async move {
let svc = service_fn(move |req| {
let p = p.clone();
async move { Ok::<_, Infallible>(p.handle(req).await) }
});
let _ = hyper::server::conn::http1::Builder::new()
.timer(hyper_util::rt::TokioTimer::new())
.header_read_timeout(Duration::from_secs(10))
.serve_connection(TokioIo::new(stream), svc)
.with_upgrades()
.await;
});
}
}
}
}
impl Proxy {
async fn handle(&self, mut req: Request<Incoming>) -> Response<Body> {
let upgrade = upgrade_protocol(req.headers());
strip_hop_by_hop(req.headers_mut());
req.headers_mut().remove(VIA_TUNNEL);
let client_upgrade = upgrade.as_ref().map(|proto| {
let h = req.headers_mut();
h.insert(header::CONNECTION, HeaderValue::from_static("upgrade"));
h.insert(header::UPGRADE, proto.clone());
hyper::upgrade::on(&mut req)
});
let pq = req
.uri()
.path_and_query()
.map(|p| p.as_str())
.unwrap_or("/");
let uri: Uri = match format!("http://{}{}", self.backend, pq).parse() {
Ok(u) => u,
Err(_) => return plain(StatusCode::BAD_REQUEST, "URI invalide"),
};
*req.uri_mut() = uri;
let mut resp = match self.client.request(req).await {
Ok(r) => r,
Err(e) => {
eprintln!("[loki-rs] backend Go injoignable : {e}");
return plain(
StatusCode::BAD_GATEWAY,
"Loki (serveur Go) redémarre — réessaie dans un instant.",
);
}
};
if let Some(client_upgrade) = client_upgrade
&& resp.status() == StatusCode::SWITCHING_PROTOCOLS
{
let backend_upgrade = hyper::upgrade::on(&mut resp);
tokio::spawn(async move {
match (client_upgrade.await, backend_upgrade.await) {
(Ok(a), Ok(b)) => {
let (mut a, mut b) = (TokioIo::new(a), TokioIo::new(b));
let _ = tokio::io::copy_bidirectional(&mut a, &mut b).await;
}
(Err(e), _) | (_, Err(e)) => eprintln!("[loki-rs] upgrade : {e}"),
}
});
// 101 : Connection/Upgrade font partie de la poignée de main.
return resp.map(BodyExt::boxed);
}
strip_hop_by_hop(resp.headers_mut());
resp.map(BodyExt::boxed)
}
}
/// Protocole demandé si la requête est une demande d'upgrade (WebSocket).
fn upgrade_protocol(h: &HeaderMap) -> Option<HeaderValue> {
let wants = h
.get_all(header::CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|s| s.split(','))
.any(|t| t.trim().eq_ignore_ascii_case("upgrade"));
if wants {
h.get(header::UPGRADE).cloned()
} else {
None
}
}
/// Retire les en-têtes propres à une connexion (RFC 9110 §7.6.1), y compris
/// ceux que `Connection` désigne.
pub fn strip_hop_by_hop(h: &mut HeaderMap) {
let listed: Vec<HeaderName> = h
.get_all(header::CONNECTION)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|s| s.split(','))
.filter_map(|t| HeaderName::from_bytes(t.trim().as_bytes()).ok())
.collect();
for n in listed {
h.remove(n);
}
for n in HOP_BY_HOP {
h.remove(n);
}
}
fn plain(status: StatusCode, msg: &'static str) -> Response<Body> {
let mut r = Response::new(
Full::new(Bytes::from_static(msg.as_bytes()))
.map_err(|n| match n {})
.boxed(),
);
*r.status_mut() = status;
r.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/plain; charset=utf-8"),
);
r
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retire_les_hop_by_hop_et_ceux_listes() {
let mut h = HeaderMap::new();
h.insert(
"connection",
HeaderValue::from_static("keep-alive, x-perso"),
);
h.insert("keep-alive", HeaderValue::from_static("timeout=5"));
h.insert("x-perso", HeaderValue::from_static("1"));
h.insert("transfer-encoding", HeaderValue::from_static("chunked"));
h.insert("authorization", HeaderValue::from_static("Bearer k"));
strip_hop_by_hop(&mut h);
assert_eq!(h.len(), 1);
assert!(h.contains_key("authorization"));
}
#[test]
fn detecte_l_upgrade() {
let mut h = HeaderMap::new();
h.insert(
"connection",
HeaderValue::from_static("keep-alive, Upgrade"),
);
h.insert("upgrade", HeaderValue::from_static("websocket"));
assert_eq!(
upgrade_protocol(&h),
Some(HeaderValue::from_static("websocket"))
);
h.insert("connection", HeaderValue::from_static("keep-alive"));
assert_eq!(upgrade_protocol(&h), None);
}
}
+265
View File
@@ -0,0 +1,265 @@
//! Mise à jour des binaires Loki au démarrage du conteneur.
//!
//! La CI publie à chaque push sur `main` une release GitHub `rolling` :
//! `loki-linux`, `loki-rs-linux`, `SHA256SUMS` puis `manifest.json` (en
//! dernier). Au boot, [`run`] compare le manifeste au build en service et, si
//! besoin, installe les deux binaires dans `<LOKI_HOME>/bin/<build>/` (voir
//! [`state`]). Rien ici ne doit bloquer le démarrage : toute erreur se solde
//! par [`Outcome::Failed`] et l'ancien état reste en place.
//!
//! Le transfert HTTPS passe par `curl` (présent dans l'image) : le binaire
//! n'embarque ainsi aucune pile TLS.
pub mod state;
pub mod sums;
use sha2::{Digest, Sha256};
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
use state::{Decision, Manifest};
pub const DEFAULT_URL: &str = "https://github.com/R0m1k3/Loki/releases/download/rolling";
pub const IMAGE_ABI_FILE: &str = "/etc/loki/IMAGE_ABI";
/// Nom de l'asset publié → nom du binaire installé.
pub const ASSETS: [(&str, &str); 2] = [
("loki-linux", state::GO_BIN),
("loki-rs-linux", state::RS_BIN),
];
const TMP_DIR: &str = ".tmp-download";
#[derive(Debug, Clone)]
pub struct UpdateConfig {
pub enabled: bool,
pub base_url: String,
pub data_bin: PathBuf,
pub image_build: u64,
pub image_abi: u32,
/// Durée maximale du téléchargement du manifeste et des sommes.
pub small_timeout: Duration,
/// Durée maximale de chaque binaire (un transfert figé est coupé bien
/// avant, cf. `--speed-limit`).
pub big_timeout: Duration,
}
impl UpdateConfig {
pub fn from_env() -> Self {
let env = |k: &str| std::env::var(k).ok().filter(|v| !v.trim().is_empty());
let home = env("LOKI_HOME").unwrap_or_else(|| "/data".into());
let image_abi = fs::read_to_string(IMAGE_ABI_FILE)
.ok()
.or_else(|| env("LOKI_IMAGE_ABI"))
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0);
UpdateConfig {
enabled: !matches!(
env("LOKI_AUTO_UPDATE").as_deref().map(str::trim),
Some("0" | "false" | "off" | "no")
),
base_url: env("LOKI_UPDATE_URL").unwrap_or_else(|| DEFAULT_URL.into()),
data_bin: Path::new(&home).join("bin"),
image_build: env("LOKI_BUILD")
.and_then(|s| s.trim().parse().ok())
.unwrap_or(0),
image_abi,
small_timeout: Duration::from_secs(20),
big_timeout: Duration::from_secs(600),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Disabled,
UpToDate(u64),
/// Version publiée incompatible avec l'image : il faut tirer l'image.
AbiMismatch {
remote_abi: u32,
image_abi: u32,
},
Installed(u64),
Failed(String),
}
impl Outcome {
/// Ligne de journal pour l'entrypoint.
pub fn message(&self) -> String {
match self {
Outcome::Disabled => "mise à jour au démarrage désactivée (LOKI_AUTO_UPDATE=0)".into(),
Outcome::UpToDate(b) => format!("binaires à jour (build {b})"),
Outcome::AbiMismatch {
remote_abi,
image_abi,
} if remote_abi < image_abi => format!(
"image plus récente que la release publiée (ABI {remote_abi}, image {image_abi}) \
— binaires de l'image conservés"
),
Outcome::AbiMismatch {
remote_abi,
image_abi,
} => format!(
"nouvelle version disponible mais elle exige une nouvelle image \
(ABI {remote_abi}, image {image_abi}) : docker compose pull"
),
Outcome::Installed(b) => format!("build {b} installé — actif dès ce démarrage"),
Outcome::Failed(e) => {
format!("mise à jour impossible ({e}) — binaires actuels conservés")
}
}
}
}
pub fn run(cfg: &UpdateConfig) -> Outcome {
if !cfg.enabled {
return Outcome::Disabled;
}
if let Err(e) = fs::create_dir_all(&cfg.data_bin) {
return Outcome::Failed(format!("{}: {e}", cfg.data_bin.display()));
}
let tmp = cfg.data_bin.join(TMP_DIR);
let _ = fs::remove_dir_all(&tmp);
let res = fs::create_dir_all(&tmp)
.map_err(|e| format!("{}: {e}", tmp.display()))
.and_then(|_| run_in(cfg, &tmp));
let _ = fs::remove_dir_all(&tmp);
res.unwrap_or_else(Outcome::Failed)
}
fn run_in(cfg: &UpdateConfig, tmp: &Path) -> Result<Outcome, String> {
let current = state::current(&cfg.data_bin, cfg.image_build, cfg.image_abi)
.map(|(m, _)| m.build)
.unwrap_or(cfg.image_build);
let man_path = tmp.join("manifest.json");
fetch(&cfg.base_url, "manifest.json", &man_path, cfg.small_timeout)?;
let remote = state::read_manifest(&man_path).ok_or("manifest.json illisible")?;
match state::decide(&remote, current, cfg.image_abi) {
Decision::AbiMismatch => {
return Ok(Outcome::AbiMismatch {
remote_abi: remote.abi,
image_abi: cfg.image_abi,
});
}
Decision::UpToDate => {
prune(&cfg.data_bin, current);
return Ok(Outcome::UpToDate(current));
}
Decision::Install => {}
}
let sums_path = tmp.join("SHA256SUMS");
fetch(&cfg.base_url, "SHA256SUMS", &sums_path, cfg.small_timeout)?;
let sums_text = fs::read_to_string(&sums_path).map_err(|e| format!("SHA256SUMS: {e}"))?;
let sums = sums::parse(&sums_text);
let stage = tmp.join("stage");
fs::create_dir_all(&stage).map_err(|e| e.to_string())?;
for (asset, local) in ASSETS {
let want = sums
.get(asset)
.ok_or(format!("SHA256SUMS sans entrée pour {asset}"))?;
let dst = stage.join(local);
fetch(&cfg.base_url, asset, &dst, cfg.big_timeout)?;
let got = sha256_file(&dst).map_err(|e| format!("{asset}: {e}"))?;
if &got != want {
return Err(format!("empreinte de {asset} invalide"));
}
make_executable(&dst).map_err(|e| format!("{asset}: {e}"))?;
}
write_manifest(&stage.join(state::RELEASE_FILE), &remote)?;
let dest = cfg.data_bin.join(remote.build.to_string());
let _ = fs::remove_dir_all(&dest);
fs::rename(&stage, &dest).map_err(|e| format!("{}: {e}", dest.display()))?;
// Bascule : release.json réécrit en dernier, par renommage atomique.
let pointer = cfg.data_bin.join(state::RELEASE_FILE);
let pointer_tmp = cfg.data_bin.join("release.json.tmp");
write_manifest(&pointer_tmp, &remote)?;
fs::rename(&pointer_tmp, &pointer).map_err(|e| format!("{}: {e}", pointer.display()))?;
prune(&cfg.data_bin, remote.build);
Ok(Outcome::Installed(remote.build))
}
/// Télécharge `<base>/<name>` dans `dst` via curl. `--speed-limit` coupe un
/// transfert figé (moins de 10 Ko/s pendant 20 s) bien avant `timeout`.
fn fetch(base: &str, name: &str, dst: &Path, timeout: Duration) -> Result<(), String> {
let url = format!("{}/{}", base.trim_end_matches('/'), name);
let out = Command::new("curl")
.args([
"-fsSL",
"--connect-timeout",
"10",
"--speed-limit",
"10240",
"--speed-time",
"20",
])
.arg("--max-time")
.arg(timeout.as_secs().max(1).to_string())
.arg("-o")
.arg(dst)
.arg(&url)
.output()
.map_err(|e| format!("curl introuvable : {e}"))?;
if !out.status.success() {
let err = String::from_utf8_lossy(&out.stderr);
return Err(format!("{name} : {}", err.trim()));
}
Ok(())
}
pub fn sha256_file(path: &Path) -> std::io::Result<String> {
let mut f = fs::File::open(path)?;
let mut h = Sha256::new();
let mut buf = [0u8; 64 * 1024];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
Ok(hex::encode(h.finalize()))
}
fn write_manifest(path: &Path, m: &Manifest) -> Result<(), String> {
let text = serde_json::to_string(m).map_err(|e| e.to_string())?;
fs::write(path, text).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(unix)]
fn make_executable(path: &Path) -> std::io::Result<()> {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o755))
}
#[cfg(not(unix))]
fn make_executable(_: &Path) -> std::io::Result<()> {
Ok(())
}
/// Supprime les builds autres que `keep` (un seul build est gardé : celui en
/// service). Ne touche qu'aux dossiers au nom purement numérique.
fn prune(data_bin: &Path, keep: u64) {
let Ok(rd) = fs::read_dir(data_bin) else {
return;
};
for e in rd.flatten() {
let name = e.file_name();
let Some(name) = name.to_str() else { continue };
if let Ok(n) = name.parse::<u64>()
&& n != keep
&& e.path().is_dir()
{
let _ = fs::remove_dir_all(e.path());
}
}
}
+151
View File
@@ -0,0 +1,151 @@
//! État d'installation des binaires mis à jour, sous `<LOKI_HOME>/bin` :
//!
//! ```text
//! bin/release.json manifeste du build actif (écrit en DERNIER)
//! bin/<build>/loki binaire Go
//! bin/<build>/loki-rs binaire Rust
//! ```
//!
//! La bascule d'une version à l'autre se résume à réécrire `release.json` :
//! un arrêt brutal pendant une mise à jour laisse l'ancien build intact.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
pub const RELEASE_FILE: &str = "release.json";
pub const GO_BIN: &str = "loki";
pub const RS_BIN: &str = "loki-rs";
/// Contenu de `manifest.json` (release) et de `release.json` (installé).
///
/// `build` est le numéro de run de la CI : il ne fait que croître, c'est lui
/// qui ordonne les versions (un commit ne s'ordonne pas). `abi` est l'ABI
/// d'image exigée par ces binaires.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Manifest {
pub build: u64,
pub abi: u32,
#[serde(default)]
pub commit: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Decision {
Install,
UpToDate,
AbiMismatch,
}
pub fn read_manifest(path: &Path) -> Option<Manifest> {
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str(&text).ok()
}
/// Build installé dans `data_bin` et utilisable avec cette image : même ABI,
/// plus récent que les binaires de l'image, et les deux binaires présents.
pub fn current(data_bin: &Path, image_build: u64, image_abi: u32) -> Option<(Manifest, PathBuf)> {
let m = read_manifest(&data_bin.join(RELEASE_FILE))?;
if m.abi != image_abi || m.build <= image_build {
return None;
}
let dir = data_bin.join(m.build.to_string());
if !dir.join(GO_BIN).is_file() || !dir.join(RS_BIN).is_file() {
return None;
}
Some((m, dir))
}
/// Dossier dont l'entrypoint doit prendre `loki` et `loki-rs` : toujours les
/// deux du même endroit, jamais un Go d'une version avec un Rust d'une autre.
pub fn select_dir(data_bin: &Path, image_dir: &Path, image_build: u64, image_abi: u32) -> PathBuf {
match current(data_bin, image_build, image_abi) {
Some((_, dir)) => dir,
None => image_dir.to_path_buf(),
}
}
/// Faut-il installer `remote`, sachant que le build en service est `current_build` ?
pub fn decide(remote: &Manifest, current_build: u64, image_abi: u32) -> Decision {
if remote.abi != image_abi {
Decision::AbiMismatch
} else if remote.build <= current_build {
Decision::UpToDate
} else {
Decision::Install
}
}
#[cfg(test)]
mod tests {
use super::*;
fn m(build: u64, abi: u32) -> Manifest {
Manifest {
build,
abi,
commit: "abc".into(),
}
}
fn install(data_bin: &Path, man: &Manifest, bins: &[&str]) {
let dir = data_bin.join(man.build.to_string());
std::fs::create_dir_all(&dir).unwrap();
for b in bins {
std::fs::write(dir.join(b), b"x").unwrap();
}
std::fs::write(
data_bin.join(RELEASE_FILE),
serde_json::to_string(man).unwrap(),
)
.unwrap();
}
#[test]
fn decision() {
assert_eq!(decide(&m(10, 2), 5, 1), Decision::AbiMismatch);
assert_eq!(decide(&m(5, 1), 5, 1), Decision::UpToDate);
assert_eq!(decide(&m(4, 1), 5, 1), Decision::UpToDate);
assert_eq!(decide(&m(6, 1), 5, 1), Decision::Install);
}
#[test]
fn selection_sans_installation() {
let t = tempfile::tempdir().unwrap();
let img = Path::new("/usr/local/bin");
assert_eq!(select_dir(t.path(), img, 3, 1), img);
}
#[test]
fn selection_installation_valide() {
let t = tempfile::tempdir().unwrap();
install(t.path(), &m(7, 1), &[GO_BIN, RS_BIN]);
let img = Path::new("/usr/local/bin");
assert_eq!(select_dir(t.path(), img, 3, 1), t.path().join("7"));
}
#[test]
fn selection_refuse_abi_differente() {
let t = tempfile::tempdir().unwrap();
install(t.path(), &m(7, 2), &[GO_BIN, RS_BIN]);
let img = Path::new("/img");
assert_eq!(select_dir(t.path(), img, 3, 1), img);
}
#[test]
fn selection_refuse_plus_ancien_que_l_image() {
// Image tirée à la main plus récente que /data/bin : l'image gagne.
let t = tempfile::tempdir().unwrap();
install(t.path(), &m(7, 1), &[GO_BIN, RS_BIN]);
let img = Path::new("/img");
assert_eq!(select_dir(t.path(), img, 7, 1), img);
assert_eq!(select_dir(t.path(), img, 9, 1), img);
}
#[test]
fn selection_refuse_binaire_manquant() {
let t = tempfile::tempdir().unwrap();
install(t.path(), &m(7, 1), &[GO_BIN]);
let img = Path::new("/img");
assert_eq!(select_dir(t.path(), img, 3, 1), img);
}
}
+49
View File
@@ -0,0 +1,49 @@
//! Lecture du fichier SHA256SUMS publié avec chaque release (format
//! `sha256sum` : "<hex> <nom>", ou "<hex> *<nom>" en mode binaire).
use std::collections::HashMap;
/// Associe chaque nom de fichier à son empreinte SHA-256 en hexadécimal
/// minuscule. Les lignes vides ou mal formées sont ignorées : une entrée
/// manquante fera échouer la vérification du fichier concerné, pas la lecture.
pub fn parse(text: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
for line in text.lines() {
let mut it = line.split_whitespace();
let (Some(hash), Some(name), None) = (it.next(), it.next(), it.next()) else {
continue;
};
if hash.len() != 64 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
continue;
}
out.insert(
name.trim_start_matches('*').to_string(),
hash.to_ascii_lowercase(),
);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
#[test]
fn lit_les_deux_formats() {
let up = A.to_ascii_uppercase();
let text = format!("{A} loki-linux\n{up} *loki-rs-linux\n");
let m = parse(&text);
assert_eq!(m.get("loki-linux").map(String::as_str), Some(A));
assert_eq!(m.get("loki-rs-linux").map(String::as_str), Some(A));
}
#[test]
fn ignore_les_lignes_cassees() {
let text = format!("\nzz loki-linux\n{A}\n{A} a b\n{A} ok\n");
let m = parse(&text);
assert_eq!(m.len(), 1);
assert!(m.contains_key("ok"));
}
}
+76
View File
@@ -0,0 +1,76 @@
//! Serveur HTTP minimal pour les tests : sert des fichiers en mémoire et
//! journalise les chemins demandés.
#![allow(dead_code)]
use bytes::Bytes;
use http_body_util::Full;
use hyper::{Request, Response, StatusCode, body::Incoming, service::service_fn};
use hyper_util::rt::TokioIo;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
pub struct FileServer {
pub addr: SocketAddr,
pub hits: Arc<Mutex<Vec<String>>>,
_rt: tokio::runtime::Runtime,
}
impl FileServer {
pub fn url(&self) -> String {
format!("http://{}/rolling", self.addr)
}
pub fn hits(&self) -> Vec<String> {
self.hits.lock().unwrap().clone()
}
}
pub fn serve_files(files: HashMap<String, Vec<u8>>) -> FileServer {
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.unwrap();
let files = Arc::new(files);
let hits = Arc::new(Mutex::new(Vec::new()));
let listener = rt
.block_on(tokio::net::TcpListener::bind("127.0.0.1:0"))
.unwrap();
let addr = listener.local_addr().unwrap();
let h = hits.clone();
rt.spawn(async move {
loop {
let Ok((stream, _)) = listener.accept().await else {
return;
};
let files = files.clone();
let h = h.clone();
tokio::spawn(async move {
let svc = service_fn(move |req: Request<Incoming>| {
let files = files.clone();
let h = h.clone();
async move {
let path = req.uri().path().trim_start_matches("/rolling/").to_string();
h.lock().unwrap().push(path.clone());
let resp = match files.get(&path) {
Some(b) => Response::new(Full::new(Bytes::from(b.clone()))),
None => {
let mut r = Response::new(Full::new(Bytes::from_static(b"404")));
*r.status_mut() = StatusCode::NOT_FOUND;
r
}
};
Ok::<_, std::convert::Infallible>(resp)
}
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(stream), svc)
.await;
});
}
});
FileServer {
addr,
hits,
_rt: rt,
}
}
+206
View File
@@ -0,0 +1,206 @@
use bytes::Bytes;
use futures_util::{SinkExt, StreamExt};
use http_body_util::{BodyExt, Empty, Full, StreamBody, combinators::BoxBody};
use hyper::body::{Frame, Incoming};
use hyper::{Request, Response, StatusCode, service::service_fn};
use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo};
use std::convert::Infallible;
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tokio::sync::oneshot;
use tokio_tungstenite::tungstenite::Message;
type B = BoxBody<Bytes, Infallible>;
/// Backend factice : /echo renvoie les en-têtes reçus, /sse émet un événement,
/// attend 500 ms, puis un second ; /post renvoie le corps reçu.
async fn backend() -> SocketAddr {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
loop {
let (s, _) = l.accept().await.unwrap();
tokio::spawn(async move {
let svc = service_fn(|req: Request<Incoming>| async move {
let resp: Response<B> = match req.uri().path() {
"/echo" => {
let mut lines: Vec<String> = req
.headers()
.iter()
.map(|(k, v)| format!("{}: {}", k, v.to_str().unwrap()))
.collect();
lines.push(format!("uri: {}", req.uri()));
Response::new(Full::new(Bytes::from(lines.join("\n"))).boxed())
}
"/post" => {
let body = req.into_body().collect().await.unwrap().to_bytes();
Response::new(Full::new(body).boxed())
}
"/sse" => {
let (tx, rx) = tokio::sync::mpsc::channel::<Bytes>(4);
tokio::spawn(async move {
tx.send(Bytes::from_static(b"data: un\n\n")).await.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
let _ = tx.send(Bytes::from_static(b"data: deux\n\n")).await;
});
let stream = futures_util::stream::unfold(rx, |mut rx| async move {
rx.recv()
.await
.map(|b| (Ok::<_, Infallible>(Frame::data(b)), rx))
});
let mut r = Response::new(BodyExt::boxed(StreamBody::new(stream)));
r.headers_mut()
.insert("content-type", "text/event-stream".parse().unwrap());
r
}
_ => Response::new(Empty::new().boxed()),
};
Ok::<_, Infallible>(resp)
});
let _ = hyper::server::conn::http1::Builder::new()
.serve_connection(TokioIo::new(s), svc)
.await;
});
}
});
addr
}
async fn ws_backend() -> SocketAddr {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
tokio::spawn(async move {
while let Ok((s, _)) = l.accept().await {
tokio::spawn(async move {
let mut ws = tokio_tungstenite::accept_async(s).await.unwrap();
while let Some(Ok(m)) = ws.next().await {
if m.is_text() {
ws.send(m).await.unwrap();
}
}
});
}
});
addr
}
/// Lance la façade devant `backend`, rend son adresse.
async fn facade(backend: SocketAddr) -> (SocketAddr, oneshot::Sender<()>) {
let l = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = l.local_addr().unwrap();
let (tx, rx) = oneshot::channel::<()>();
tokio::spawn(loki_rs::proxy::serve(l, backend, async move {
let _ = rx.await;
}));
(addr, tx)
}
fn client() -> Client<hyper_util::client::legacy::connect::HttpConnector, Full<Bytes>> {
Client::builder(TokioExecutor::new()).build_http()
}
#[tokio::test]
async fn transmet_les_en_tetes_et_efface_x_loki_via() {
let (f, _stop) = facade(backend().await).await;
let req = Request::get(format!("http://{f}/echo?a=1"))
.header("host", "loki.exemple.fr")
.header("authorization", "Bearer secret")
.header("origin", "https://loki.exemple.fr")
.header("x-forwarded-proto", "https")
.header("sec-fetch-site", "same-origin")
.header("x-loki-via", "tunnel")
.body(Full::new(Bytes::new()))
.unwrap();
let resp = client().request(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(
resp.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
for want in [
"host: loki.exemple.fr",
"authorization: Bearer secret",
"origin: https://loki.exemple.fr",
"x-forwarded-proto: https",
"sec-fetch-site: same-origin",
"uri: /echo?a=1",
] {
assert!(
body.lines().any(|l| l == want),
"manque « {want} » dans :\n{body}"
);
}
assert!(
!body.contains("x-loki-via"),
"X-Loki-Via a traversé :\n{body}"
);
}
#[tokio::test]
async fn relaie_le_corps() {
let (f, _stop) = facade(backend().await).await;
let req = Request::post(format!("http://{f}/post"))
.body(Full::new(Bytes::from_static(b"{\"x\":1}")))
.unwrap();
let resp = client().request(req).await.unwrap();
let body = resp.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], b"{\"x\":1}");
}
#[tokio::test]
async fn sse_sans_tampon() {
let (f, _stop) = facade(backend().await).await;
let t0 = Instant::now();
let req = Request::get(format!("http://{f}/sse"))
.body(Full::new(Bytes::new()))
.unwrap();
let mut body = client().request(req).await.unwrap().into_body();
let first = body.frame().await.unwrap().unwrap().into_data().unwrap();
let elapsed = t0.elapsed();
assert_eq!(&first[..], b"data: un\n\n");
assert!(
elapsed < Duration::from_millis(300),
"premier événement reçu après {elapsed:?}"
);
let second = body.frame().await.unwrap().unwrap().into_data().unwrap();
assert_eq!(&second[..], b"data: deux\n\n");
}
#[tokio::test]
async fn websocket_relaye() {
let (f, _stop) = facade(ws_backend().await).await;
let (mut ws, resp) = tokio_tungstenite::connect_async(format!("ws://{f}/api/node/ws"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::SWITCHING_PROTOCOLS);
ws.send(Message::text("bonjour")).await.unwrap();
let got = tokio::time::timeout(Duration::from_secs(5), ws.next())
.await
.unwrap()
.unwrap()
.unwrap();
assert_eq!(got.into_text().unwrap().as_str(), "bonjour");
}
#[tokio::test]
async fn backend_absent_donne_502() {
// Port réservé puis libéré : personne n'y écoute.
let dead = TcpListener::bind("127.0.0.1:0")
.await
.unwrap()
.local_addr()
.unwrap();
let (f, _stop) = facade(dead).await;
let req = Request::get(format!("http://{f}/"))
.body(Full::new(Bytes::new()))
.unwrap();
let resp = client().request(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
}
+148
View File
@@ -0,0 +1,148 @@
mod common;
use loki_rs::update::{self, Outcome, UpdateConfig, sha256_file, state};
use std::collections::HashMap;
use std::path::Path;
use std::time::Duration;
const GO: &[u8] = b"binaire go";
const RS: &[u8] = b"binaire rust";
fn sha(b: &[u8]) -> String {
let t = tempfile::NamedTempFile::new().unwrap();
std::fs::write(t.path(), b).unwrap();
sha256_file(t.path()).unwrap()
}
fn release(
build: u64,
abi: u32,
go: &[u8],
rs: &[u8],
sums_go: Option<&str>,
) -> HashMap<String, Vec<u8>> {
let sums = format!(
"{} loki-linux\n{} loki-rs-linux\n",
sums_go.map(str::to_string).unwrap_or_else(|| sha(go)),
sha(rs)
);
HashMap::from([
(
"manifest.json".into(),
format!(r#"{{"build":{build},"abi":{abi},"commit":"c{build}"}}"#).into_bytes(),
),
("SHA256SUMS".into(), sums.into_bytes()),
("loki-linux".into(), go.to_vec()),
("loki-rs-linux".into(), rs.to_vec()),
])
}
fn cfg(url: String, data_bin: &Path) -> UpdateConfig {
UpdateConfig {
enabled: true,
base_url: url,
data_bin: data_bin.to_path_buf(),
image_build: 3,
image_abi: 1,
small_timeout: Duration::from_secs(10),
big_timeout: Duration::from_secs(10),
}
}
#[test]
fn installe_puis_reconnait_a_jour() {
let srv = common::serve_files(release(7, 1, GO, RS, None));
let t = tempfile::tempdir().unwrap();
let c = cfg(srv.url(), t.path());
assert_eq!(update::run(&c), Outcome::Installed(7));
let dir = state::select_dir(t.path(), Path::new("/img"), 3, 1);
assert_eq!(dir, t.path().join("7"));
assert_eq!(std::fs::read(dir.join("loki")).unwrap(), GO);
assert_eq!(std::fs::read(dir.join("loki-rs")).unwrap(), RS);
assert!(!t.path().join(".tmp-download").exists());
assert_eq!(update::run(&c), Outcome::UpToDate(7));
}
#[test]
fn nouveau_build_remplace_et_nettoie_l_ancien() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(7, 1, GO, RS, None));
assert_eq!(
update::run(&cfg(srv.url(), t.path())),
Outcome::Installed(7)
);
let srv2 = common::serve_files(release(9, 1, b"go 9", b"rs 9", None));
assert_eq!(
update::run(&cfg(srv2.url(), t.path())),
Outcome::Installed(9)
);
assert!(!t.path().join("7").exists());
assert_eq!(
state::select_dir(t.path(), Path::new("/img"), 3, 1),
t.path().join("9")
);
}
#[test]
fn empreinte_fausse_ne_change_rien() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(7, 1, GO, RS, None));
assert_eq!(
update::run(&cfg(srv.url(), t.path())),
Outcome::Installed(7)
);
let bad = "0".repeat(64);
let srv2 = common::serve_files(release(9, 1, b"go 9", b"rs 9", Some(&bad)));
assert!(matches!(
update::run(&cfg(srv2.url(), t.path())),
Outcome::Failed(_)
));
assert_eq!(
state::select_dir(t.path(), Path::new("/img"), 3, 1),
t.path().join("7")
);
assert!(!t.path().join("9").exists());
assert!(!t.path().join(".tmp-download").exists());
}
#[test]
fn abi_differente_ne_telecharge_que_le_manifeste() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(7, 2, GO, RS, None));
assert_eq!(
update::run(&cfg(srv.url(), t.path())),
Outcome::AbiMismatch {
remote_abi: 2,
image_abi: 1
}
);
assert_eq!(srv.hits(), vec!["manifest.json".to_string()]);
}
#[test]
fn image_plus_recente_que_la_release() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(3, 1, GO, RS, None));
assert_eq!(update::run(&cfg(srv.url(), t.path())), Outcome::UpToDate(3));
}
#[test]
fn serveur_absent() {
let t = tempfile::tempdir().unwrap();
let mut c = cfg("http://127.0.0.1:9/rolling".into(), t.path());
c.small_timeout = Duration::from_secs(3);
assert!(matches!(update::run(&c), Outcome::Failed(_)));
}
#[test]
fn desactive_sans_requete() {
let t = tempfile::tempdir().unwrap();
let srv = common::serve_files(release(7, 1, GO, RS, None));
let mut c = cfg(srv.url(), t.path());
c.enabled = false;
assert_eq!(update::run(&c), Outcome::Disabled);
assert!(srv.hits().is_empty());
}