Compare commits

..
22 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
MichaelandClaude Opus 5.5 502f23e833 staticcheck : decryptChatStores retirée (remplacée par decryptChatStoresSkipping)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:58:40 +02:00
MichaelandClaude Opus 5.5 634cfb61f3 Merge branch 'feat/moteur-strata' : moteur Strata, correctifs et presets repris d'AJEAN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:40:10 +02:00
MichaelandClaude Opus 5.5 ecff7de5a3 Presets : glisser-déposer, pastilles contexte et capacités, prompt système dans l'éditeur
Repris d'AJEAN et adapté à Loki :
- liste réordonnable par glisser-déposer (SortableJS 1.15.6, MIT), ordre
  gardé côté serveur (/api/presets/order) et suivi par le sélecteur de
  l'en-tête ; le clic de fin de glissement ne bascule pas de preset
- pastille de contexte (32K, 128K, 40K…) et pastille « capacités » : œil
  quand le preset lit les images (MMPROJ, API externe déclarée multimodale,
  Strata), ampoule + niveau quand il raisonne
- éditeur : prompt système DE CE preset (rangé en base, sysprompt:<id>),
  projecteur vision sur le CPU (--no-mmproj-offload, visible seulement avec
  un mmproj)
- les projecteurs mmproj ne sont plus proposés comme modèle (éditeur et
  sélecteur de l'en-tête)
- l'aide « ? » reste survolable sur un réglage grisé

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:28:42 +02:00
MichaelandClaude Opus 5.5 4a9a5a1712 Moteur Strata : un second moteur, choisi par preset (ENGINE=strata)
Strata (github.com/Niko1221/Strata, MIT) fait tourner Qwen3.8 Flash Next
(MoE 125 B) sur une carte de joueur en répartissant les experts entre VRAM,
RAM et disque. Intégration reprise d'AJEAN (backend_moe.go, « AJEAN MoE
1.0 ») : paquet figé de la release moe-v1.0, vérifié par SHA-256.

- backend_strata.go : détection de la machine, quant conseillé, installation
  en tâche (même suivi que llama.cpp), preset ENGINE=strata, lancement du
  serveur de Strata par `loki serve`, réglages d'un modèle installé
- pré-vol, vision, liste des presets, journal de chargement : Strata reconnu
- proxy OpenAI et relais : Host local pour Strata (il refuse un Host inconnu)
- benchmark et optimiseur refusés sur Strata (protocole propre à llama-server)
- UI : ligne Strata dans Paramètres → Moteur (visible aussi avec le moteur de
  l'image), fenêtre d'installation et de réglages, moteur actif affiché
- image : python3 + python3-venv ; compose : memlock illimité

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 09:03:22 +02:00
81 changed files with 7352 additions and 146 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/
+26 -1
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
@@ -87,8 +98,12 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
# et la plupart des serveurs MCP réclament Node ≥ 20.
# python3 + python3-venv : l'installeur et le serveur du moteur Strata
# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans
# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés
# à la demande depuis Paramètres → Moteur.
RUN apt-get update && apt-get install -y --no-install-recommends \
git tini ca-certificates curl gnupg \
git tini ca-certificates curl gnupg python3 python3-venv \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
@@ -127,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
@@ -143,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
+10
View File
@@ -52,6 +52,16 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
expose à la place un export/import de paquet chiffré en fichier local.
## Moteur Strata
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
inclus dans l'image : ils sont téléchargés à la demande.
## Services externes
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
+101 -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)
@@ -93,6 +99,70 @@ la recherche qu'au téléchargement. À défaut, la variable d'environnement
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
n'est envoyé qu'aux adresses Hugging Face.
## Choisir son moteur : llama.cpp ou Strata
Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
- **llama.cpp** (par défaut) — n'importe quel GGUF, réglages complets dans
l'éditeur de preset.
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par
SHA-256).
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
disque), propose la version (Classique ou Swift 1.5) et la qualité qui tient,
puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go dans
`/data/strata`). Le modèle installé devient un preset comme les autres :
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint).
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
la plus petite qualité, pilote compatible CUDA 12.8. Le conteneur doit pouvoir
verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
llama.cpp.
### 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
@@ -108,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
@@ -125,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
@@ -998,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 :
+17 -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,4 +66,17 @@ 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:
memlock: -1
restart: unless-stopped
+4
View File
@@ -37,4 +37,8 @@ services:
- driver: nvidia
count: all
capabilities: ["gpu"]
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
+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)
}
}
@@ -0,0 +1,43 @@
package loki
import "testing"
// Preset externe actif : `loki serve` n'a aucun moteur local à lancer. Il
// sortait en erreur « MODEL non défini », que systemd (Restart=on-failure)
// relançait en boucle (amont #95).
func TestServeExitsCleanlyForExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1", "PORT": "8080"}); err != nil {
t.Fatal(err)
}
if err := cmdServe(nil); err != nil {
t.Fatalf("loki serve sur un preset externe : %v", err)
}
}
// Le pré-vol refusait `loki start` / `loki restart` sur un preset externe,
// en invitant à réinstaller llama.cpp.
func TestPreflightAcceptsExternalPreset(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{extKeyFlag: "1"}); err != nil {
t.Fatal(err)
}
if err := preflightEngine(); err != nil {
t.Fatalf("pré-vol refusé sur un preset externe : %v", err)
}
}
// Strata : le pré-vol vérifie sa configuration à lui, pas BIN/MODEL.
func TestPreflightStrataWithoutConfig(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{"ENGINE": "strata"}); err != nil {
t.Fatal(err)
}
err := preflightEngine()
if err == nil {
t.Fatal("un preset Strata sans STRATA_CONFIG devrait être refusé")
}
if want := "STRATA_CONFIG"; !contains(err.Error(), want) {
t.Fatalf("message attendu sur %s : %v", want, err)
}
}
+63 -1
View File
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out, nil
return applyPresetOrder(out), nil
}
// presetOrderKey : ordre d'affichage choisi par glisser-déposer dans l'UI
// (repris d'AJEAN v0.10.9), une liste d'IDs persistée en base.
const presetOrderKey = "preset_order"
func loadPresetOrder() []string {
var ids []string
getJSON(bkState, presetOrderKey, &ids)
return ids
}
func savePresetOrder(ids []string) error { return putJSON(bkState, presetOrderKey, ids) }
// applyPresetOrder réordonne `list` (déjà trié par nom) selon l'ordre stocké :
// d'abord les presets classés dans l'ordre choisi, puis les non classés
// (fraîchement créés) dans leur ordre alphabétique. Tri STABLE.
func applyPresetOrder(list []Preset) []Preset {
order := loadPresetOrder()
if len(order) == 0 {
return list
}
pos := map[string]int{}
for i, id := range order {
pos[id] = i
}
sort.SliceStable(list, func(i, j int) bool {
pi, oki := pos[list[i].ID]
pj, okj := pos[list[j].ID]
if oki && okj {
return pi < pj
}
if oki != okj {
return oki // un preset classé passe avant un non classé
}
return false // deux non classés : l'ordre alphabétique reste
})
return list
}
// presetCtx : la taille de contexte d'un preset, formatée pour la pastille de
// la liste (« 32K », « 128K », « 1M »). CTX absent = 32768, le défaut de serve.
func presetCtx(content string) string {
raw := strings.TrimSpace(parseEnv(content)["CTX"])
if raw == "" {
raw = "32768"
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return ""
}
// Multiple de 1024 (32768, 131072…) : écrit comme on le pense, « 32K »,
// « 128K » ; sinon décimal, comme on le tape (40000 → « 40K »).
switch {
case n >= 1024 && n%1024 == 0 && n < 1<<20:
return strconv.Itoa(n/1024) + "K"
case n >= 1000000:
return strings.TrimSuffix(strconv.FormatFloat(float64(n)/1000000, 'f', 1, 64), ".0") + "M"
case n >= 1000:
return strconv.Itoa((n+500)/1000) + "K"
}
return strconv.Itoa(n)
}
// uniquePresetID derives a unique filename id from a display name, appending
@@ -0,0 +1,60 @@
package loki
import (
"os"
"path/filepath"
"testing"
)
// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet
// ordre ; les nouveaux (non classés) ensuite, par nom.
func TestPresetOrder(t *testing.T) {
testHome(t)
_ = os.MkdirAll(presetsDir(), 0o755)
for _, id := range []string{"alpha", "bravo", "charlie", "delta"} {
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil {
t.Fatal(err)
}
}
ids := func() []string {
list, err := ListPresets()
if err != nil {
t.Fatal(err)
}
var out []string
for _, p := range list {
out = append(out, p.ID)
}
return out
}
if got := ids(); got[0] != "alpha" || got[3] != "delta" {
t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got)
}
if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil {
t.Fatal(err)
}
want := []string{"charlie", "alpha", "bravo", "delta"}
got := ids()
for i := range want {
if got[i] != want[i] {
t.Fatalf("ordre %v, attendu %v", got, want)
}
}
}
func TestPresetCtx(t *testing.T) {
cases := map[string]string{
"": "32K", // défaut de serve : 32768
"CTX=40000\n": "40K",
"CTX=\"131072\"\n": "128K",
"CTX=1000000\n": "1M",
"CTX=1500000\n": "1.5M",
"CTX=512\n": "512",
"CTX=abc\n": "",
}
for in, want := range cases {
if got := presetCtx(in); got != want {
t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want)
}
}
}
+19
View File
@@ -272,6 +272,20 @@ func cmdServe(args []string) error {
}
cfg = c
}
// API externe : aucun moteur local. On sort SANS erreur, sinon systemd
// (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95).
if isExternalConfig(cfg) {
fmt.Println("[info] preset externe actif : pas de moteur local à lancer")
return nil
}
// Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son
// propre serveur. L'optimiseur ne règle que llama-server.
if isStrataConfig(cfg) {
if trialDir != "" {
return fmt.Errorf("l'optimiseur ne s'applique pas à un preset Strata")
}
return serveStrata(cfg)
}
bin := cfg["BIN"]
if bin == "" {
return fmt.Errorf("BIN non défini — lance « loki edit »")
@@ -463,6 +477,10 @@ func cmdServe(args []string) error {
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
bin, filepath.Base(model), port)
// Ligne de commande réellement lancée, gardée pour l'interface (amont #108).
if trialDir == "" {
recordEngineCmdline(env, llmArgs)
}
// Hand off to the llama-server process. On Unix this replaces the current
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
@@ -509,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)
}
}
File diff suppressed because it is too large. Load diff
+379
View File
@@ -0,0 +1,379 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
func strataArgs(t *testing.T, cfg map[string]any) []string {
t.Helper()
var out []string
for _, a := range cfg["args"].([]any) {
out = append(out, a.(string))
}
return out
}
func argAfter(args []string, flag string) string {
for i := 0; i+1 < len(args); i++ {
if args[i] == flag {
return args[i+1]
}
}
return ""
}
func TestStrataBuildConfigApplyTuning(t *testing.T) {
base := map[string]any{
"exe": "/x/engine-cuda12/strata",
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
"port": 8080.0,
}
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k", "ram")
if err != nil {
t.Fatal(err)
}
a := strataArgs(t, out)
for flag, want := range map[string]string{"--prefill": "16384", "--short-read": "150", "--prompt-cache-root": "256", "--max-context": "65536", "--pack": "/p"} {
if got := argAfter(a, flag); got != want {
t.Errorf("%s = %q, attendu %q (args %v)", flag, got, want, a)
}
}
if strings.Count(strings.Join(a, " "), "--prefill") != 1 {
t.Errorf("--prefill dupliqué : %v", a)
}
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
t.Errorf("aide 2e carte sans 2e carte : %v", a)
}
if out["port"] != 8081 || out["api_key"] != "k" || out["host"] != "127.0.0.1" {
t.Errorf("port/clé/hôte : %v %v %v", out["port"], out["api_key"], out["host"])
}
// la config de l'installeur n'est pas modifiée
if base["args"].([]any)[3] != "8192" {
t.Error("la config de base a été modifiée")
}
}
func TestStrataBuildConfigHelperGPU(t *testing.T) {
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"}, "gpu": 1.0,
"vision": map[string]any{"exe": "v", "gpu": true}}
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
if err != nil {
t.Fatal(err)
}
a := strataArgs(t, out)
if argAfter(a, "--expert-cache-device1") != "auto" || !strataHasArg(a, "--remote-expert-opt") {
t.Errorf("aide 2e carte absente : %v", a)
}
if strataHasArg(a, "--vram-reserve-mib") || strataHasArg(a, "700") {
t.Errorf("réserve de VRAM gardée : %v", a)
}
if _, has := out["api_key"]; has {
t.Error("api_key posée sans clé")
}
if _, has := out["gpu"]; has {
t.Error("clé gpu gardée avec une carte d'aide (le serveur en ferait un --layer-split)")
}
if out["vision"].(map[string]any)["cuda_device"] != 0 {
t.Errorf("encodeur pas sur la carte d'aide : %v", out["vision"])
}
if out["env"].(map[string]any)["STRATA_PLE_BATCH"] != "0" {
t.Errorf("STRATA_PLE_BATCH absent : %v", out["env"])
}
}
func TestStrataCudaDevices(t *testing.T) {
cases := []struct {
main, helper, want string
}{
{"1", "0", "1,0"},
{"0", "-1", "0"},
{"0", "", "0"},
{"", "", ""},
}
for _, c := range cases {
got := strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": c.main, "STRATA_HELPER_GPU": c.helper})
if got != c.want {
t.Errorf("main=%q helper=%q : %q, attendu %q", c.main, c.helper, got, c.want)
}
}
}
func TestStrataRecommend(t *testing.T) {
// la machine de test : 5060 Ti 16 Go + 3070 8 Go, 47 Go de RAM, disque large
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, 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)
}
// IQ3_S demande de garder 50 Go d'experts en RAM : pas sur 47 Go
if got := strataRecommend("qwen", env); got != "IQ3_XXS" {
t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got)
}
f := strataFitFor(strataQuants["IQ3_XXS"], env, 0)
// trop juste pour tout garder en RAM : 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, env.AvailGB = 64, 64
if got := strataRecommend("qwen", env); got != "IQ3_S" {
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
}
// 16 Go de RAM et une seule carte de 8 Go : rien ne tient
small := strataEnv{Supported: true, Main: 0, Helper: -1, RAMGB: 16, DiskFreeGB: 400,
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}}}
if got := strataRecommend("swift", small); got != "" {
t.Errorf("petite machine : %q, attendu aucune recommandation", got)
}
// disque trop petit
env.DiskFreeGB = 50
if f := strataFitFor(strataQuants["Q2_0"], env, 0); f.OK {
t.Error("Q2_0 accepté avec 50 Go de disque")
}
}
func TestStrataPresetName(t *testing.T) {
if got := strataPresetName("swift", "IQ3_XXS"); got != "STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS" {
t.Errorf("%q", got)
}
if got := strataPresetName("qwen", "Q2_0"); got != "STRATA FLASH NEXT CLASSIQUE Q2_0" {
t.Errorf("%q", got)
}
}
func TestStrataFitCountsExistingData(t *testing.T) {
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 32,
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 120); !f.OK {
t.Errorf("données déjà présentes, refusé quand même : %s", f.Why)
}
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 0); f.OK {
t.Error("32 Go libres sans données : accepté")
}
}
func TestStrataBuildConfigPresetOverrides(t *testing.T) {
base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}}
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "", "ram")
a := strataArgs(t, out)
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
t.Errorf("%v", a)
}
}
func TestStrataDetailsFor(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
base := filepath.Join(home, "base.json")
if err := os.WriteFile(base, []byte(`{"args":["--pack","/p","--kv","int8","--spec","4","--max-context","131072"],"vision":{"exe":"v"}}`), 0o644); err != nil {
t.Fatal(err)
}
_ = os.MkdirAll(presetsDir(), 0o755)
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_MMAP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\n"
if err := os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644); err != nil {
t.Fatal(err)
}
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3070", VRAMGB: 8}, {Index: 1, Name: "NVIDIA GeForce RTX 5060 Ti", VRAMGB: 16}}}
d := strataDetailsFor("X", env)
if d == nil {
t.Fatal("pas de détails")
}
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
t.Errorf("%+v", *d)
}
}
func TestStrataHaveGBPerChoice(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
write := func(rel string, n int) {
p := filepath.Join(strataDataDir(), rel)
_ = os.MkdirAll(filepath.Dir(p), 0o755)
if err := os.WriteFile(p, make([]byte, n), 0o644); err != nil {
t.Fatal(err)
}
}
write("models/swift-IQ3_XXS/a.gguf", 3000)
write("packs/swift-iq3_xxs/experts.bin", 2000)
write("mtp/rt/x", 500)
write("models/IQ2_XS/b.gguf", 7000) // un autre choix : ne compte pas pour swift IQ3_XXS
if got := strataHaveGB("swift", "IQ3_XXS") * 1e9; got != 5500 {
t.Errorf("swift IQ3_XXS : %v octets, attendu 5500", got)
}
if got := strataHaveGB("swift", "IQ2_XS") * 1e9; got != 500 {
t.Errorf("swift IQ2_XS : %v octets, attendu 500 (seulement la tête MTP commune)", got)
}
}
func TestStrataApplySettings(t *testing.T) {
in := "# NAME=x\nENGINE=strata\nCTX=131072\nSTRATA_KV=fp16\nSTRATA_SPEC=3\nSTRATA_HELPER_GPU=0\n"
out, err := strataApplySettings(in, strataSettingsReq{Ctx: 65536, KV: "int8", Spec: 4, Vision: false, Helper: false})
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(out)
if cfg["CTX"] != "65536" || cfg["STRATA_KV"] != "int8" || cfg["STRATA_SPEC"] != "4" || cfg["STRATA_VISION"] != "0" ||
cfg["STRATA_HELPER"] != "0" || cfg["STRATA_HELPER_GPU"] != "0" || cfg["ENGINE"] != "strata" {
t.Errorf("%q", out)
}
if strings.Count(out, "CTX=") != 1 {
t.Errorf("CTX dupliqué : %q", out)
}
if strataHelperOn(cfg) || strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}) != "1" {
t.Error("carte d'aide coupée mais encore utilisée")
}
base := map[string]any{"args": []any{"--vision", "--pack", "/p"}, "vision": map[string]any{"exe": "v"}}
b, _ := strataBuildConfig(base, cfg, "", "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)
}
for _, bad := range []strataSettingsReq{{Ctx: 1000, KV: "fp16", Spec: 3}, {Ctx: 32768, KV: "q2", Spec: 3}, {Ctx: 32768, KV: "fp16", Spec: 9}} {
if _, err := strataApplySettings(in, bad); err == nil {
t.Errorf("accepté : %+v", bad)
}
}
}
func TestEngineOf(t *testing.T) {
cases := map[string]string{"": engineLlamacpp, "llamacpp": engineLlamacpp, "strata": engineStrata, " Strata ": engineStrata, "moe": engineStrata, "autre": engineLlamacpp}
for in, want := range cases {
if got := engineOf(map[string]string{"ENGINE": in}); got != want {
t.Errorf("ENGINE=%q : %q, attendu %q", in, got, want)
}
}
}
func TestModelLoadErrorStrata(t *testing.T) {
ok := strataServeMarker + " (paquet AJEAN MoE 1.0)\nloading the model (the first start takes a minute or two) ...\nready: http://127.0.0.1:8080/v1 (OpenAI: /v1/chat/completions)\n"
if got := modelLoadErrorFrom(ok); got != "" {
t.Errorf("Strata prêt signalé en échec : %q", got)
}
crash := strataServeMarker + "\nTraceback (most recent call last):\n File \"server.py\", line 1\nRuntimeError: boom\n"
if got := modelLoadErrorFrom(crash); !strings.Contains(got, "Strata") {
t.Errorf("trace Python non signalée : %q", got)
}
// une trace d'un lancement PRÉCÉDENT ne compte pas
if got := modelLoadErrorFrom(crash + strataServeMarker + "\nloading the model ...\n"); got != "" {
t.Errorf("ancienne trace prise pour l'échec en cours : %q", got)
}
}
// 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
+3
View File
@@ -180,6 +180,9 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
if externalActive() {
return errors.New("optimisation indisponible : le preset actif est une API externe")
}
if strataActive() {
return errors.New("optimisation indisponible : le preset actif tourne sur Strata, réglé par sa propre fenêtre")
}
if err := tuneGuard(); err != nil {
return err
}
+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)
}
}
}
+87
View File
@@ -0,0 +1,87 @@
package loki
// engine_cmdline.go — ce que l'interface montre du dernier lancement du moteur
// (repris d'AJEAN, #108 et #43) :
//
// /api/engine/cmdline la commande exacte lancée par `loki serve` (variables
// d'environnement posées par Loki comprises), copiable
// pour la vérifier ou la partager ; clé API masquée
// /api/model/layers le nombre de couches du modèle, lu dans l'en-tête
// GGUF : la valeur de NGL qui met tout sur le GPU
import (
"net/http"
"sort"
"strings"
)
// engineCmdlineKey : dernière commande du moteur (bkState), lue par l'UI.
const engineCmdlineKey = "engine_cmdline"
// engineCmdline met les arguments sous forme copiable dans un terminal :
// variables d'environnement en tête (KEY=VAL, triées), guillemets autour de ce
// qui contient un espace, clé API masquée.
func engineCmdline(env map[string]string, args []string) string {
quote := func(a string) string {
if a == "" || strings.ContainsAny(a, " \t\"'") {
return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"`
}
return a
}
var out []string
keys := make([]string, 0, len(env))
for k := range env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
out = append(out, k+"="+quote(env[k]))
}
for i, a := range args {
if i > 0 && (args[i-1] == "--api-key" || args[i-1] == "--api-key-file") {
a = "<clé masquée>"
}
out = append(out, quote(a))
}
return strings.Join(out, " ")
}
// recordEngineCmdline garde la commande du lancement en cours. Jamais pour un
// essai de l'optimiseur : il écraserait celle du vrai moteur.
func recordEngineCmdline(env map[string]string, args []string) {
_ = putBytes(bkState, engineCmdlineKey, []byte(engineCmdline(env, args)))
}
// handleEngineCmdline (GET) : la commande du dernier lancement du moteur local.
func handleEngineCmdline(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true, "cmdline": string(getBytes(bkState, engineCmdlineKey))})
}
// handleModelLayers (GET ?model=…, sinon MODEL de la config) : nombre de blocs
// du modèle. llama.cpp en offloade block_count + 1 (la couche de sortie en
// plus) : c'est la valeur de -ngl qui met tout le modèle sur le GPU.
func handleModelLayers(w http.ResponseWriter, r *http.Request) {
model := strings.TrimSpace(r.URL.Query().Get("model"))
if model == "" {
model = strings.TrimSpace(ReadConfig()["MODEL"])
}
if model == "" {
sendJSON(w, 200, map[string]any{"ok": false, "error": "aucun modèle"})
return
}
path, err := resolveServeModelPath(model)
if err != nil {
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
return
}
info, err := ggufMeta(path)
if err != nil || info.BlockCount <= 0 {
msg := "nombre de couches absent de l'en-tête"
if err != nil {
msg = err.Error()
}
sendJSON(w, 200, map[string]any{"ok": false, "error": msg})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "layers": info.BlockCount, "ngl_max": info.BlockCount + 1})
}
+6
View File
@@ -828,6 +828,12 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben
if externalActive() {
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
}
// Le protocole s'appuie sur /tokenize et sur la reprise du cache de
// llama-server ; Strata n'a pas le premier et garde sa conversation en cache
// malgré cache_prompt:false (la lecture serait comptée deux fois trop vite).
if strataActive() {
return nil, fmt.Errorf("benchmark indisponible : le preset actif tourne sur Strata (le débit s'affiche sous chaque réponse)")
}
if err := tuneGuard(); err != nil {
return nil, err
}
+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)}
+5
View File
@@ -96,6 +96,11 @@ func oaiHandler() http.Handler {
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())})
pr.Out.Host = pr.In.Host
// Strata refuse (403) un Host qu'il ne connaît pas (protection contre
// le DNS rebinding) : il se voit appelé sous son propre nom.
if strataActive() {
pr.Out.Host = pr.Out.URL.Host
}
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) {
sendOAIError(w, http.StatusBadGateway,
+123 -17
View File
@@ -259,52 +259,158 @@ func DisableMemEncryption() error {
// page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption
// laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore
// levé, donc DisableMemEncryption peut être relancé).
if err := decryptAllPages(); err != nil {
return finishDecryption()
}
// finishDecryption : fichiers (pages, images des conversations), puis
// conversations, puis retrait de la clé, dans cet ordre et seulement si tout ce
// qui précède a réussi. Sûr à rejouer. Partagé avec la reprise au démarrage, qui
// retirait la clé après les seules pages : les conversations restaient
// chiffrées sans plus aucun moyen de les rouvrir (amont cac0cda).
func finishDecryption() error {
bad, err := decryptAllPagesSkipping()
if err != nil {
return err
}
if err := decryptChatStores(); err != nil {
badKV, err := decryptChatStoresSkipping()
if err != nil {
return err
}
// Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau,
// retirer le keyvault et purger la DEK.
bad = append(bad, badKV...)
if len(bad) > 0 {
// Valeurs illisibles avec la clé actuelle : déjà inaccessibles avant ce
// déchiffrement. On les met en quarantaine (toujours chiffrées) avec une
// copie du keyvault, pour pouvoir encore les rouvrir plus tard, puis on
// les retire de la base pour que le reste fonctionne en clair.
if err := quarantineUnreadable(bad); err != nil {
return fmt.Errorf("mise en quarantaine des valeurs illisibles impossible, déchiffrement interrompu (rien n'est perdu, il peut être relancé) : %w", err)
}
for _, u := range bad {
if u.Bucket != "fichier" { // un fichier reste en place, sa copie est en quarantaine
_ = putBytes(u.Bucket, u.Key, nil)
}
}
}
// Tout est en clair et vérifié : on peut baisser le drapeau, retirer le
// keyvault et purger la DEK.
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
return err
}
removeVault()
clearMemDEK()
clearMigrationJournal()
if n := scrubEncryptedResidue(); n > 0 {
fmt.Fprintf(os.Stderr, "[mémoire] %d copie(s) .bak chiffrée(s), illisibles sans la clé, retirée(s)\n", n)
}
return nil
}
// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES
// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent).
func decryptAllPages() error {
for _, p := range allMemPageFiles() {
// decryptAllPagesSkipping réécrit en clair tous les fichiers encore chiffrés de
// la mémoire (tous projets) et des images de conversation, en vérifiant chacun.
// Exige la DEK en RAM. Sûr à rejouer. Un fichier indéchiffrable est laissé tel
// quel et renvoyé au lieu de tout arrêter.
func decryptAllPagesSkipping() ([]unreadableValue, error) {
var bad []unreadableValue
for _, p := range allMemFilesForDecrypt() {
raw, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("lecture %s : %w", p, err)
return bad, fmt.Errorf("lecture %s : %w", p, err)
}
if !looksEncrypted(raw) {
continue // déjà en clair
}
plain, err := decodeMemContent(raw)
if err != nil {
return fmt.Errorf("déchiffrement %s impossible, on n'écrase rien : %w", p, err)
bad = append(bad, unreadableValue{Bucket: "fichier", Key: p, Blob: raw, Error: err.Error()})
continue // on n'écrase rien
}
if old, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(p+".bak", old, 0o600)
}
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
return fmt.Errorf("écriture claire %s : %w", p, err)
return bad, fmt.Errorf("écriture claire %s : %w", p, err)
}
back, err := os.ReadFile(p)
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
return fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
return bad, fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
}
}
return bad, nil
}
// allMemFilesForDecrypt : TOUS les fichiers de la mémoire, sous-dossiers compris,
// et les images de conversation (chatimg/, chiffrées comme le fil), sauf les
// copies .bak et les écritures en cours (.tmp). Ne déchiffrer que les pages .md
// laissait le reste chiffré sans plus aucune clé une fois le keyvault retiré.
func allMemFilesForDecrypt() []string {
var out []string
for _, root := range []string{projectsRoot(), chatImgDir()} {
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || strings.HasSuffix(d.Name(), ".bak") || strings.HasSuffix(d.Name(), ".tmp") {
return nil
}
out = append(out, p)
return nil
})
}
return out
}
// quarantineUnreadable écrit les valeurs indéchiffrables, encore chiffrées, dans
// un dossier daté de LOKI_HOME, avec une copie du keyvault à côté : elles
// restent récupérables si l'ancienne clé est retrouvée.
func quarantineUnreadable(bad []unreadableValue) error {
dir := filepath.Join(LokiHome(), "chiffre-illisible-"+time.Now().Format("20060102-150405"))
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
raw, err := json.MarshalIndent(bad, "", " ")
if err != nil {
return err
}
if err := memWriteFileAtomic(filepath.Join(dir, "valeurs.json"), raw, 0o600); err != nil {
return err
}
for _, p := range []string{vaultPathPrimary(), vaultPathBackup()} {
if b, err := os.ReadFile(p); err == nil {
_ = memWriteFileAtomic(filepath.Join(dir, filepath.Base(p)), b, 0o600)
}
}
if b := getBytes(bkState, vaultDBKey); len(b) > 0 {
_ = memWriteFileAtomic(filepath.Join(dir, "keyvault-base.json"), b, 0o600)
}
for _, u := range bad {
fmt.Fprintf(os.Stderr, "[mémoire] illisible avec la clé actuelle, mis en quarantaine : %s / %s (%s)\n", u.Bucket, u.Key, u.Error)
}
fmt.Fprintf(os.Stderr, "[mémoire] %d valeur(s) en quarantaine dans %s\n", len(bad), dir)
return nil
}
// scrubEncryptedResidue retire les .bak encore CHIFFRÉS de la mémoire une fois
// le chiffrement désactivé : la clé est partie avec le keyvault, ils ne pourront
// plus jamais être lus, et ce sont des copies de pages qui viennent d'être
// remises en clair. Pendant symétrique de scrubPlaintextResidue. Les valeurs en
// quarantaine et les snapshots vivent hors de memory/ : jamais touchés. Rien
// n'est fait tant que le chiffrement est actif.
func scrubEncryptedResidue() int {
if memEncActive() {
return 0
}
n := 0
for _, root := range []string{projectsRoot(), chatImgDir()} {
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
return nil
}
if b, err := os.ReadFile(p); err == nil && looksEncrypted(b) && os.Remove(p) == nil {
n++
}
return nil
})
}
return n
}
// resumeMemMigration reprend une migration interrompue au démarrage. Pour une
// migration "encrypt", si la DEK est déjà en RAM (déverrouillée), on rejoue le
// (re)chiffrement des pages claires ; sinon on laisse le journal en place pour un
@@ -328,12 +434,12 @@ func resumeMemMigration() {
// encore levé, on termine proprement (écrit le clair restant), puis on
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
// et l'écran de santé signalera le mélange.
// Même chemin complet que DisableMemEncryption : la reprise retirait la clé
// après les seules pages, en laissant les conversations chiffrées sans
// plus aucun moyen de les rouvrir.
if memEncActive() && memUnlocked() {
if err := decryptAllPages(); err == nil {
_ = SetConfigKey("MEM_ENCRYPTED", "")
removeVault()
clearMemDEK()
clearMigrationJournal()
if err := finishDecryption(); err != nil {
fmt.Fprintf(os.Stderr, "[mémoire] reprise du déchiffrement : %v\n", err)
}
}
}
+82
View File
@@ -2,6 +2,7 @@ package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -261,3 +262,84 @@ func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
t.Fatalf("pointeur chiffré non réparé : %q", got)
}
}
// Une seule valeur indéchiffrable bloquait toute la désactivation, et les images
// de conversation (chatimg/) restaient chiffrées sans clé une fois le keyvault
// retiré. Désormais : tout ce qui se lit est déchiffré, l'illisible part en
// quarantaine, et la désactivation aboutit.
func TestDisableMemEncryptionSurvitAuxValeursIllisibles(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pass-de-test-1234"); err != nil {
t.Fatal(err)
}
img := []byte("\x89PNG octets d'image de test")
ref, err := storeChatImage(img, "image/png")
if err != nil {
t.Fatal(err)
}
imgPath := filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme))
if raw, _ := os.ReadFile(imgPath); !looksEncrypted(raw) {
t.Fatal("l'image devrait être chiffrée")
}
other := make([]byte, 32)
other[0] = 7
alien, err := encPage(other, []byte("conversation chiffrée avec une autre clé"))
if err != nil {
t.Fatal(err)
}
if err := putBytes(bkChat, "vieille", alien); err != nil {
t.Fatal(err)
}
if err := DisableMemEncryption(); err != nil {
t.Fatalf("la désactivation aurait dû aboutir : %v", err)
}
if memEncActive() {
t.Fatal("le chiffrement devrait être désactivé")
}
if b, _ := os.ReadFile(imgPath); string(b) != string(img) {
t.Fatalf("image pas déchiffrée : %q", b)
}
if v := getBytes(bkChat, "vieille"); len(v) != 0 {
t.Fatal("la valeur illisible aurait dû quitter la base")
}
q, _ := filepath.Glob(filepath.Join(LokiHome(), "chiffre-illisible-*", "valeurs.json"))
if len(q) != 1 {
t.Fatalf("quarantaine attendue, trouvée : %v", q)
}
if qb, _ := os.ReadFile(q[0]); !strings.Contains(string(qb), "vieille") {
t.Fatal("la quarantaine doit contenir la valeur illisible")
}
if baks, _ := filepath.Glob(filepath.Join(chatImgDir(), "*.bak")); len(baks) != 0 {
t.Fatalf(".bak chiffrés restés derrière : %v", baks)
}
}
// Déchiffrement interrompu puis repris au démarrage : la reprise retirait la clé
// après les seules pages, et les conversations restaient chiffrées pour toujours.
func TestResumeInterruptedDecryptDechiffreLesConversations(t *testing.T) {
testHome(t)
clearMemDEK()
seedPages(t)
if _, err := EnableMemEncryption("pw"); err != nil {
t.Fatal(err)
}
enc, err := encodeMemContent([]byte(`{"titre":"conversation"}`))
if err != nil {
t.Fatal(err)
}
if err := putBytes(bkChat, "conv:x", enc); err != nil {
t.Fatal(err)
}
if err := writeMigrationJournal("decrypt"); err != nil {
t.Fatal(err)
}
resumeMemMigration()
if memEncActive() {
t.Fatal("la reprise aurait dû terminer le déchiffrement")
}
if v := getBytes(bkChat, "conv:x"); string(v) != `{"titre":"conversation"}` {
t.Fatalf("conversation restée chiffrée après la reprise : %q", v)
}
}
+29 -10
View File
@@ -186,20 +186,38 @@ func reencryptChatStores() error {
return nil
}
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
func decryptChatStores() error {
// unreadableValue : une valeur chiffrée qui ne se déchiffre pas avec la clé
// actuelle (écrite avec une autre clé, ou abîmée). Gardée telle quelle.
type unreadableValue struct {
Bucket string `json:"bucket"`
Key string `json:"key"`
Blob []byte `json:"blob"` // base64 en JSON
Error string `json:"error"`
}
// decryptChatStoresSkipping remet en clair les buckets de conversation (exige
// la DEK) : déchiffre tout ce qui peut l'être et RENVOIE ce qui
// ne le peut pas, au lieu de s'arrêter au premier échec : une seule valeur
// illisible bloquait toute la désactivation du chiffrement, sans dire laquelle
// (amont, 2026-10-03).
func decryptChatStoresSkipping() ([]unreadableValue, error) {
persistQ.flush() // même raison que reencryptChatStores
var bad []unreadableValue
for _, b := range encryptedBuckets {
if err := decryptBucket(b); err != nil {
return err
u, err := decryptBucket(b)
if err != nil {
return bad, err
}
bad = append(bad, u...)
}
return nil
return bad, nil
}
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
// la DEK en RAM. Sûr à rejouer.
func decryptBucket(bucket string) error {
// la DEK en RAM. Sûr à rejouer. Une valeur indéchiffrable est laissée telle
// quelle et renvoyée ; seule une erreur d'écriture est fatale.
func decryptBucket(bucket string) ([]unreadableValue, error) {
var bad []unreadableValue
for k, v := range allKV(bucket) {
raw := []byte(v)
if !looksEncrypted(raw) {
@@ -207,11 +225,12 @@ func decryptBucket(bucket string) error {
}
plain, err := decodeMemContent(raw)
if err != nil {
return err
bad = append(bad, unreadableValue{Bucket: bucket, Key: k, Blob: raw, Error: err.Error()})
continue
}
if err := putBytes(bucket, k, plain); err != nil {
return err
return bad, err
}
}
return nil
return bad, nil
}
+3
View File
@@ -472,6 +472,9 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
base := lp.Director
lp.Director = func(req *http.Request) {
base(req)
// Le moteur se voit appelé sous son propre nom, pas sous celui du relais :
// Strata refuse (403) un Host inconnu (protection DNS rebinding).
req.Host = llama.Host
// Le client distant n'a pas la clé API de llama-server ; on l'injecte ici
// (l'auth réelle est faite par le relais via la clé de liaison du compte).
if apiKey != "" {
+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) {}
+3
View File
@@ -32,6 +32,9 @@ WorkingDirectory=%s
ExecStart=%s
Restart=on-failure
RestartSec=3
# llama-server ignore SIGTERM en pleine génération : sans borne, une bascule de
# preset restait bloquée 90 s (défaut systemd) avant le SIGKILL.
TimeoutStopSec=5
# Lancement refusé d'avance par LOAD_GUARD (le mode de chargement ne tient pas
# en RAM) : relancer n'y changerait rien.
RestartPreventExitStatus=78
+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")
}
}
+10
View File
@@ -41,6 +41,16 @@ func serviceAction(action string) error {
// visible que dans le journal.
func preflightEngine() error {
cfg := ReadConfig()
// Preset externe : aucun llama-server local à lancer. Exiger BIN/MODEL
// refusait `loki start` et `loki restart` avec un message invitant à
// réinstaller llama.cpp (amont #95) ; cmdServe sort déjà proprement.
if isExternalConfig(cfg) {
return nil
}
// Strata n'a ni BIN ni MODEL : son installeur a tout rangé (backend_strata.go).
if isStrataConfig(cfg) {
return strataPreflight(cfg)
}
bin := strings.TrimSpace(cfg["BIN"])
if bin == "" {
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
+18 -12
View File
@@ -65,6 +65,12 @@ func userSvcAction(action string) error {
}
func userSvcStart() error {
// Preset externe : `loki serve` sortirait aussitôt (rien à lancer), et
// userCheckStarted le prendrait pour un plantage.
if isExternalConfig(ReadConfig()) {
fmt.Printf("%s preset externe actif : pas de moteur local à démarrer\n", dim("[info]"))
return nil
}
if pid := readServicePID(); pid > 0 && processAlive(pid) {
fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid)
return nil
@@ -119,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é")
}
+630 -33
View File
@@ -205,7 +205,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){
.side{width:380px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag,.preset-meta .ctag,.preset-meta .captag{font-size:11px;padding:2px 9px;border-radius:4px}
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px}
}
h1{margin:0;font-size:18px;color:var(--accent)}
@@ -301,6 +301,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
(étiquette comprise), plus sur la carte seule. */
.gated-group{margin:0 0 10px;transition:opacity .15s}
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
/* L'aide « ? » reste survolable sur un réglage grisé : c'est justement là qu'on
veut savoir à quoi il sert (repris d'AJEAN, #104). */
.gated-group.gated .help{pointer-events:auto}
.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none}
.switch input{position:absolute;opacity:0;width:0;height:0}
/* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se
@@ -398,6 +401,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
.ctag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
/* Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
(ampoule + niveau). Icônes en em : la bulle a la hauteur des autres tags. */
.captag{flex-shrink:0;display:inline-flex;align-items:center;gap:4px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
.captag::before{content:"\200b";margin-right:-4px}
.captag svg{display:block;width:1.3em;height:1.3em}
/* Réordonnancement des presets (SortableJS) : clone plein qui suit le curseur,
emplacement cible discret. */
.preset-drag{cursor:grabbing;background:var(--row-bg-on);border:none;border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,.35);outline:none;opacity:1!important}
.preset-ghost{opacity:.35}
.preset-chosen{cursor:grabbing}
/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas
un réglage mais une DESTINATION — la réponse part sur internet, pas sur la
carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil.
@@ -802,6 +816,32 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
gris atténué du thème monochrome et devenait illisible en mode sombre. */
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:4px;padding:1px 6px}
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
/* Strata (10b-strata.js) : liste des qualités, réglages et configuration. */
.slist>.lc-modes#lc-engines{border-top:1px solid var(--border)}
#lc-strata.moe-off{cursor:default}
#lc-strata.moe-off:hover{background:none}
#lc-strata.moe-off .lc-mode-t{color:var(--dim)}
#strata-quants{overflow:hidden}
#strata-quants .lc-mode.moe-sel{background:var(--accent-bg)}
#strata-quants .lc-mode.moe-sel .lc-mode-t{color:var(--accent)}
#strata-quants .lc-mode.moe-off{cursor:default}
#strata-quants .lc-mode.moe-off:hover{background:none}
#strata-quants .lc-mode.moe-off .lc-mode-t{color:var(--dim)}
.moe-details{padding:4px 10px}
.moe-details .moe-kv{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;padding:5px 0}
.moe-details .moe-kv .switch{display:block}
.moe-details .moe-kv:not(:last-child){border-bottom:1px solid var(--border)}
.moe-details .moe-kv span:first-child{color:var(--dim)}
.moe-details .moe-kv span:last-child{text-align:right}
.moe-ctx{display:flex;align-items:center;gap:10px}
.moe-ctx input[type=range]{width:160px;height:16px;margin:0;padding:0;background:transparent;border:none;-webkit-appearance:none;appearance:none;cursor:ew-resize}
.moe-ctx input[type=range]:focus{outline:none}
.moe-ctx input[type=range]::-webkit-slider-runnable-track{height:4px;border:none;border-radius:999px;background:linear-gradient(to right,var(--accent) var(--p,50%),color-mix(in srgb,var(--dim) 25%,transparent) var(--p,50%))}
.moe-ctx input[type=range]::-moz-range-track{height:4px;border:none;border-radius:999px;background:color-mix(in srgb,var(--dim) 25%,transparent)}
.moe-ctx input[type=range]::-moz-range-progress{height:4px;border-radius:999px;background:var(--accent)}
.moe-ctx input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:none;border-radius:50%;background:var(--accent)}
.moe-ctx input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:var(--accent)}
.moe-ctx span{min-width:62px;text-align:right;font-variant-numeric:tabular-nums}
/* Hauteur réservée : la ligne d'état arrive après l'appel réseau, sans elle
les trois cartes grandissaient d'un cran une fois l'état connu. */
.lc-mode-state{font-size:11px;min-height:16px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
@@ -2265,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
@@ -2560,7 +2614,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
<div class="set-nav-h">Moteur</div>
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
<button data-pane="engine" onclick="settingsShow('engine')">Moteur</button>
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
<div class="set-nav-h">Application</div>
@@ -2669,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
@@ -2762,7 +2816,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div></div>
</section>
<section class="set-pane" data-pane="engine" id="lc-details">
<div class="set-pane-h"><span>Moteur</span></div>
<div class="set-pane-h"><span>Moteur</span><span class="muted" style="font-size:12px;font-weight:400">actif : <b id="lc-active-engine">…</b></span></div>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
@@ -2828,6 +2882,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
</div>
<!-- Deuxième moteur, hors des modes llama.cpp (masqués quand le moteur vient
de l'image) : Strata, spécialisé Qwen3.8 Flash Next, paquet figé repris
d'AJEAN (backend_strata.go). Le choix du moteur se fait par preset :
activer un modèle Strata bascule sur Strata, un autre preset revient à
llama.cpp. -->
<div class="lc-modes" id="lc-engines">
<div class="lc-mode" id="lc-strata" onclick="openStrata()">
<div class="lc-mode-t">Strata — Qwen3.8 Flash Next</div>
<div class="lc-mode-d">moteur spécialisé pour ce MoE de 125 B (experts répartis entre VRAM, RAM et disque), installation en un clic</div>
<div class="lc-mode-state" id="lc-strata-state"></div>
</div>
</div>
<div id="lc-job" class="rowbody" style="display:none">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
@@ -3097,6 +3163,46 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
</div>
</div>
<div id="strata-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeStrata()">
<div class="modal-card" style="max-width:520px">
<div class="modal-head">
<strong>Strata — Qwen3.8 Flash Next</strong>
<button class="modal-x" onclick="closeStrata()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
<div id="strata-machine" class="muted" style="font-size:12px;line-height:1.5"></div>
<div class="fld">
<label class="fld-l">Version</label>
<div class="seg" id="strata-fams"></div>
</div>
<div class="fld">
<label class="fld-l">Qualité</label>
<div class="slist" id="strata-quants"></div>
</div>
<div id="strata-disk" class="muted" style="font-size:12px;line-height:1.5"></div>
<div class="fld" id="strata-settings-fld" hidden>
<label class="fld-l">Réglages</label>
<div class="slist moe-details" id="strata-settings"></div>
</div>
<div class="fld" id="strata-details-fld" hidden>
<label class="fld-l">Configuration</label>
<div class="slist moe-details" id="strata-details"></div>
</div>
<div id="strata-note" class="muted" style="font-size:11px;line-height:1.5"></div>
<div class="muted" style="font-size:11px;line-height:1.5">Moteur <a href="https://github.com/Niko1221/Strata" target="_blank" rel="noopener">Strata</a> (MIT),
paquet figé et vérifié (SHA-256) publié par <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener">AJEAN</a>.
Linux + NVIDIA (RTX 20 ou plus récente). Le modèle est téléchargé dans <code>/data/strata</code>.</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="closeStrata()">fermer</button>
<button id="strata-save" hidden onclick="strataSaveSettings()">Enregistrer</button>
<button class="btn-save" id="strata-go" onclick="strataInstall()">Installer</button>
</div>
</div>
</div>
</div>
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
@@ -3368,6 +3474,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div>
<!-- Repris d'AJEAN v0.10.3 : n'apparaît qu'avec un projecteur choisi. -->
<div class="pe-row" id="s-mmproj-cpu-row" style="display:none">
<span class="pe-row-l">Projecteur vision sur le CPU<span class="pe-sub">garde le mmproj hors du GPU — libère de la VRAM, images lues plus lentement (--no-mmproj-offload)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mmproj-cpu" onchange="eaToggleFlag('--no-mmproj-offload', this.checked)"><span class="pe-sl"></span></label></span>
</div>
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
reste là : c'est le mode expert (dépôt privé, fichier exotique,
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
@@ -3467,7 +3578,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
<div class="pe-row">
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"><span class="pe-unit" id="s-ngl-max"></span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
@@ -3648,6 +3759,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</div>
</div>
<!-- Prompt système DE CE preset (repris d'AJEAN v0.13.0) : il bascule avec
le preset. Rangé en base (sysprompt:<id>), pas dans le .env qui ne
porte qu'une ligne par clé. Vide = le prompt global s'applique. -->
<div id="m-sys-row" class="pe-group" style="display:none">
<div class="pe-gh">Prompt système</div>
<div class="pe-list">
<div class="pe-row stack">
<textarea id="m-sysprompt" class="pe-inp" rows="4" spellcheck="false" placeholder="(optionnel) consigne propre à ce preset — vide = prompt système général"></textarea>
</div>
</div>
</div>
<div id="m-raw" class="pe-group">
<div class="pe-gh" id="m-raw-head">Configuration</div>
<div class="pe-list">
@@ -3764,7 +3887,8 @@ async function loadSys(){
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('loki.sys', d.text || ''); }
}catch(e){}
}
// ===== Apparence =============================================================
/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(tLine truncated
// Un seul thème, en deux variantes : claire (par défaut) et sombre. La variante
// est portée par l'attribut data-theme de <html> ('' ou 'dark') ; tout le reste
// est du CSS (voir la section « Palette »). Persistée dans localStorage pour
@@ -4382,6 +4506,27 @@ async function loadCfg(){
const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'&quot;')+'">'+String(v)+'</span></div>';
const rows=[];
// Strata (backend_strata.go) : la config réelle de lancement, calculée par le
// serveur comme au démarrage (/api/strata → details du modèle actif).
if(/^(strata|moe)$/i.test((c.ENGINE||'').trim())){
const s = await jget('/api/strata').catch(()=>null);
let fam=null, fit=null;
for(const f of (s&&s.families)||[]){ const x=f.fits.find(x=>x.active); if(x){ fam=f; fit=x; } }
const d=fit&&fit.details;
rows.push(row('MOTEUR', 'Strata', 'paquet AJEAN MoE '+((s&&s.version)||'')));
if(typeof setLoadedModel === 'function') setLoadedModel('');
if(fam) rows.push(row('MODEL', 'Qwen3.8 Flash Next '+fam.label+' '+fit.quant.id));
if(d){
const k=n=>(+n>=1024 ? Math.round(+n/1024)+'K' : n);
rows.push(row('CTX', d.ctx));
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
rows.push(row('MTP', d.spec));
rows.push(row('EXPERTS', ({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();
return;
}
if(c.BIN){
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
// toujours le chemin complet, quel que soit le libellé.
@@ -4403,14 +4548,28 @@ async function loadCfg(){
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
// source qui le connaisse quand aucun preset ne correspond à la configuration.
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
// Nombre de couches du modèle (amont #43) : NGL au-delà de ngl_max ne change
// rien, en dessous on libère de la VRAM pour le contexte et le cache KV.
const ly=c.MODEL ? await jget('/api/model/layers').catch(()=>null) : null;
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
let v=c[k]; if(k==='MODEL') v=v.split(/[\\/]/).pop();
if(k==='NGL' && ly && ly.ok){ rows.push(row(k, v+' / '+ly.ngl_max, ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)')); return; }
rows.push(row(k, v));
});
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
if(m) rows.push(row('N-CPU-MOE', m[1]));
document.getElementById('cfg').innerHTML = rows.join('');
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
}
// Ligne de commande exacte du dernier lancement du moteur (amont #108),
// copiable pour la vérifier ou la partager. Clé API masquée côté serveur.
let ENGINE_CMDLINE='';
async function engineCmdlineRow(){
const cl=await jget('/api/engine/cmdline').catch(()=>null);
ENGINE_CMDLINE=(cl&&cl.cmdline)||'';
if(!ENGINE_CMDLINE) return '';
return '<div class="kv"><span>COMMANDE</span><span><button class="pe-link" onclick="copyText(ENGINE_CMDLINE, \'commande copiée\')" title="'
+escHtml(ENGINE_CMDLINE)+'">copier la commande du moteur</button></span></div>';
}
// --- Réserve de hauteur des blocs peuplés par le réseau ---------------------
// Ces blocs sont vides (ou réduits à « … ») tant que le serveur n'a pas répondu,
@@ -4474,7 +4633,9 @@ async function loadPresets(){
const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name, x.id);
row.dataset.id = x.id;
// Fin d'un glisser-déposer : le clic qui le termine ne bascule pas le preset.
row.onclick=()=>{ if(presetJustDragged) return; switchTo(i+1, x.name, x.id); };
const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
@@ -4502,12 +4663,27 @@ async function loadPresets(){
q.textContent=x.quant; q.title='quantization';
meta.appendChild(q);
}
if(x.reasoning){
const rt=document.createElement('span'); rt.className='rtag';
rt.title='raisonnement actif ('+x.reasoning+')';
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>';
rt.appendChild(document.createTextNode(x.reasoning));
meta.appendChild(rt);
if(x.ctx && !x.external){
const c=document.createElement('span'); c.className='ctag';
c.title='taille de contexte'; c.textContent=x.ctx;
meta.appendChild(c);
}
// Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
// (ampoule + niveau), regroupés en un seul tag.
if(x.vision || x.reasoning){
const cap=document.createElement('span'); cap.className='captag';
const tip=[];
if(x.vision){
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>');
tip.push('lit les images');
}
if(x.reasoning){
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 21h4"/><path d="M12 3a6 6 0 0 0-4 10.5c.6.6 1 1.4 1 2.5h6c0-1.1.4-1.9 1-2.5A6 6 0 0 0 12 3z"/></svg>');
cap.appendChild(document.createTextNode(x.reasoning));
tip.push('raisonnement actif ('+x.reasoning+')');
}
cap.title=tip.join(' · ');
meta.appendChild(cap);
}
if(x.bench){
const bt=document.createElement('span'); bt.className='btag';
@@ -4528,10 +4704,44 @@ async function loadPresets(){
// Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : x.strata ? openStrata(x.id) : openPreset(x.id); };
row.appendChild(info); row.appendChild(edit);
cont.appendChild(row);
});
initPresetSortable(cont);
}
// ─── Réordonnancement des presets (SortableJS, repris d'AJEAN) ───────────────
// La ligne entière se déplace, sans poignée. L'ordre est gardé côté serveur
// (/api/presets/order) : il vaut aussi pour le sélecteur de l'en-tête.
let presetJustDragged = false;
let presetSortable = null;
function initPresetSortable(cont){
if(typeof Sortable==='undefined') return; // lib absente : la liste reste simple
if(presetSortable) return; // une instance, sur le conteneur qui persiste
presetSortable = Sortable.create(cont, {
animation: 160,
easing: 'cubic-bezier(.2,.7,.3,1)',
// Clone géré par Sortable (et non l'image native, semi-transparente), posé
// sur <body> : dans la modale (ancêtre transformé), il apparaissait loin
// du curseur.
forceFallback: true,
fallbackTolerance: 3,
fallbackOnBody: true,
// Sur mobile, il faut MAINTENIR l'appui : un simple défilement attrapait un preset.
delay: 220,
delayOnTouchOnly: true,
touchStartThreshold: 6,
ghostClass: 'preset-ghost',
chosenClass: 'preset-chosen',
dragClass: 'preset-drag',
onStart(){ presetJustDragged = true; },
onEnd(){
setTimeout(()=>{ presetJustDragged = false; }, 60);
const ids=[...cont.children].map(r=>r.dataset.id).filter(Boolean);
// Re-rendu une fois l'ordre persisté : les positions (pendingPreset) suivent.
jpost('/api/presets/order', {ids}).then(()=>loadPresets()).catch(()=>{});
},
});
}
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
@@ -5036,7 +5246,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
@@ -5200,6 +5410,10 @@ async function openItem(kind, key){
const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-row');
const samplingRow = document.getElementById('m-sampling-row');
const sysRow = document.getElementById('m-sys-row');
if(sysRow) sysRow.style.display = kind === 'preset' ? '' : 'none';
const sysTa = document.getElementById('m-sysprompt');
if(sysTa) sysTa.value = '';
const rawHead = document.getElementById('m-raw-head');
const rawToggle = document.getElementById('m-raw-toggle');
const rawBody = document.getElementById('m-raw-body');
@@ -5258,6 +5472,7 @@ async function openItem(kind, key){
document.getElementById('m-name').value = display;
document.getElementById('m-content').value = d.content || '';
if(kind === 'preset'){
if(sysTa) sysTa.value = d.sysprompt || '';
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
@@ -5339,6 +5554,10 @@ async function populateModelPicker(){
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
const groups = [];
for(const m of (list||[])){
// Projecteurs vision : ils ont leur propre sélecteur (Vision) et ne se
// lancent pas comme modèle (repris d'AJEAN v0.16.2). Gardé seulement s'il
// est DÉJÀ le MODEL du preset, pour ne pas masquer une erreur de saisie.
if(isMmprojName(m.name) && m !== hit) continue;
let g = groups.find(x => x.dir === m.dir);
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
g.items.push(m);
@@ -5641,6 +5860,20 @@ function onPickModel(){
ta.value = 'MODEL="'+val+'"\n' + ta.value;
}
toast('MODEL='+val);
showNglMax(val);
}
// Nombre de couches du modèle du preset (amont #43), lu dans son en-tête GGUF :
// affiché à côté du champ NGL (placeholder, « / N » et survol).
async function showNglMax(model){
const e=document.getElementById('s-ngl'), u=document.getElementById('s-ngl-max'); if(!e) return;
e.placeholder='999'; e.title=''; if(u) u.textContent='';
if(!model) return;
const ly=await jget('/api/model/layers?model='+encodeURIComponent(model)).catch(()=>null);
if(!ly || !ly.ok) return;
e.placeholder=String(ly.ngl_max);
if(u) u.textContent='/ '+ly.ngl_max;
e.title=ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)';
}
// --- Vision (projecteur multimodal --mmproj) --------------------------------
@@ -5660,6 +5893,21 @@ function onPickMmproj(){
// et on centralise le choix dans la clé MMPROJ.
eaSetValued('--mmproj', '');
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
syncMmprojCpuRow();
}
// « Projecteur sur le CPU » n'a de sens qu'avec un mmproj chargé : la ligne est
// masquée sans vision. Source de vérité = la config, pas le select (rempli
// APRÈS cette synchro à l'ouverture du preset). Sans projecteur, le drapeau
// resté dans EXTRA_ARGS est retiré.
function syncMmprojCpuRow(){
const row = document.getElementById('s-mmproj-cpu-row');
if(!row) return;
const on = !!currentMmprojInTextarea();
row.style.display = on ? '' : 'none';
if(!on && eaHasFlag('--no-mmproj-offload')){
eaToggleFlag('--no-mmproj-offload', false);
const c = document.getElementById('s-mmproj-cpu'); if(c) c.checked = false;
}
}
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
@@ -6055,6 +6303,7 @@ function populateSettings(){
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
set('s-ctx', cfgReadKey('CTX'));
set('s-ngl', cfgReadKey('NGL'));
showNglMax(cfgReadKey('MODEL'));
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
@@ -6114,6 +6363,8 @@ function populateSettings(){
}
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
syncMmprojCpuRow();
const lm = eaLoadMode();
set('s-loadmode', lm);
syncLoadModeSub(lm);
@@ -6384,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;
@@ -6394,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();
@@ -6448,7 +6704,7 @@ async function saveItem(){
// Presets: keyed by id (filename); duplicate display names are allowed.
// Skills: keyed by name, rename via `old`.
const payload = editingKind==='preset'
? {id: editingKey, name, content}
? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
: {name, old: editingKey, content};
const r = await jpost(K.saveUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
@@ -7155,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';
@@ -7167,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
@@ -7200,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'},
@@ -7598,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 ------------------------------------------------
@@ -7701,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
@@ -8050,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;
@@ -8059,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,
@@ -8096,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
@@ -8354,7 +8706,7 @@ let lcSeenEnd = false, lcEndShown = false;
function lcChipLabel(action){
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
prebuilt:'Téléchargement du moteur', engine:'Mise à jour du moteur',
custom:'Installation du backend'}[action] || 'Installation du moteur';
custom:'Installation du backend', strata:'Installation de Strata'}[action] || 'Installation du moteur';
}
function lcChipSync(j){
const chip = document.getElementById('lc-chip');
@@ -8750,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');
@@ -8788,6 +9151,212 @@ function lcToggleLog(){
if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; }
else { pre.setAttribute('hidden',''); bar.classList.remove('open'); }
}
// Moteur Strata : installation « en un clic » du moteur spécialisé Qwen3.8 Flash
// Next (repris d'AJEAN, « AJEAN MoE »). L'utilisateur choisit la version (Swift
// ou Classique) et la qualité (quant) ; tout le reste (cartes, RAM, mmap,
// réglages) est déduit côté serveur (backend_strata.go). L'installation est une
// tâche comme celles de llama.cpp : même suivi (#lc-job), même pastille.
let strataState = null, strataFam = '', strataQuant = '', strataTarget = '';
async function loadStrata(){
let s;
try{ s = await jget('/api/strata'); }catch(_){ return; }
strataState = s;
const row = document.getElementById('lc-strata');
if(!row) return;
// Hors Linux + NVIDIA, la ligne reste visible mais dit pourquoi : choisir son
// moteur, c'est aussi savoir pourquoi l'un des deux n'est pas proposé.
const env = s.env || {};
row.classList.toggle('moe-off', !env.supported);
const st = document.getElementById('lc-strata-state');
if(st){
const n = (s.installed||[]).length;
st.textContent = !env.supported ? (env.reason || 'indisponible sur cette machine')
: s.active ? 'moteur actif' + (n > 1 ? ' · '+n+' modèles installés' : '')
: n ? n+' modèle(s) installé(s)' : '';
}
const eng = document.getElementById('lc-active-engine');
if(eng) eng.textContent = s.active ? 'Strata' : 'llama.cpp';
}
// presetId (optionnel) : ouverte depuis l'engrenage d'un modèle installé, la
// fenêtre se place directement sur lui.
async function openStrata(presetId){
// état relu à chaque ouverture : juste après une installation, l'ancien
// proposait encore « Installer »
await loadStrata();
if(!strataState) return;
const s = strataState, env = s.env || {};
if(!env.supported){ toast('Strata : '+(env.reason||'indisponible sur cette machine')); return; }
strataTarget = typeof presetId === 'string' ? presetId : '';
const cards = (env.gpus||[]).filter(g=>g.index===env.main || g.index===env.helper)
.sort((a,b)=>(a.index===env.main?-1:1))
.map(g=>g.name.replace(/^NVIDIA GeForce /,'')+' '+Math.round(g.vram_gb)+' Go');
document.getElementById('strata-machine').textContent =
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM 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){
const hit = f.fits.find(x=>x.preset===strataTarget);
if(hit){ strataFam = f.id; strataQuant = hit.quant.id; }
}
}
strataRenderFams();
strataRenderQuants(!strataTarget);
showModal('strata-modal');
}
function closeStrata(){ hideModal('strata-modal'); }
function strataRenderFams(){
const box = document.getElementById('strata-fams');
box.innerHTML = strataState.families.map(f=>
'<label><input type="radio" name="strata-fam" value="'+f.id+'"'+(f.id===strataFam?' checked':'')
+' onchange="strataFam=this.value;strataRenderQuants(true)">'
+'<span>'+escHtml(f.label)+'</span><span class="be-note">'+escHtml(f.about)+'</span></label>').join('');
}
function strataRenderQuants(pickReco){
const f = strataState.families.find(x=>x.id===strataFam);
if(!f) return;
// à l'ouverture : le modèle actif s'il est de cette version, sinon un installé, sinon le conseillé
if(pickReco || !f.fits.some(x=>x.quant.id===strataQuant && (x.ok || x.preset))){
const cur = f.fits.find(x=>x.active) || f.fits.find(x=>x.preset);
strataQuant = cur ? cur.quant.id : (f.recommend || '');
}
const box = document.getElementById('strata-quants');
box.innerHTML = f.fits.map(x=>{
const q = x.quant, sel = q.id===strataQuant;
const tag = x.active ? 'actif' : x.preset ? 'installé' : q.id===f.recommend ? 'conseillé' : '';
const reco = tag ? ' <span class="lc-reco">'+tag+'</span>' : '';
const usable = x.ok || !!x.preset; // installé : activable même si l'espace disque manque
const desc = x.preset ? escHtml(q.about)
: x.ok ? escHtml(q.about)+' · ~'+Math.round(x.disk_gb)+' Go sur le disque'
: escHtml(x.why);
return '<div class="lc-mode'+(sel?' moe-sel':'')+(usable?'':' moe-off')+'"'
+(usable?' onclick="strataQuant=\''+q.id+'\';strataRenderQuants(false)"':'')+'>'
+'<div class="lc-mode-t">'+q.id+reco+'</div><div class="lc-mode-d">'+desc+'</div></div>';
}).join('');
const fit = f.fits.find(x=>x.quant.id===strataQuant);
const go = document.getElementById('strata-go');
go.disabled = !fit || !!fit.active;
go.textContent = fit && fit.active ? 'Déjà actif' : fit && fit.preset ? 'Activer' : 'Installer';
strataRenderDisk(fit);
strataRenderSettings(fit);
strataRenderDetails(fit);
document.getElementById('strata-note').textContent = !fit ? 'Aucune version ne tient sur cette machine.'
: fit.active ? 'Modèle actif.' : fit.preset ? 'Modèle installé, prêt à être activé.'
: fit.mmap ? 'RAM 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.';
}
// Place disque : libre, ce que ce choix occupe, ce qui restera.
function strataRenderDisk(fit){
const el = document.getElementById('strata-disk');
const free = Math.round((strataState.env||{}).disk_free_gb||0);
if(!fit){ el.textContent = ''; return; }
if(fit.preset){ el.textContent = 'Disque : '+free+' Go libres. Modèle déjà présent.'; return; }
const need = Math.round(fit.disk_gb), left = free - need;
el.textContent = left >= 5
? 'Disque : '+free+' Go libres, ~'+need+' Go requis, ~'+left+' Go restants.'
: 'Disque : '+free+' Go libres, ~'+need+' Go requis. Espace insuffisant.';
}
// Réglages modifiables d'un modèle installé.
function strataRenderSettings(fit){
const fld = document.getElementById('strata-settings-fld'), save = document.getElementById('strata-save');
const d = fit && fit.details;
fld.hidden = save.hidden = !d;
if(!d) return;
const sel = (id, opts, cur) => '<select class="ctl" id="'+id+'" style="width:auto">'
+ opts.map(([v,l])=>'<option value="'+v+'"'+(String(v)===String(cur)?' selected':'')+'>'+escHtml(l)+'</option>').join('')+'</select>';
const sw = (id, on) => '<label class="switch"><input type="checkbox" id="'+id+'"'+(on?' checked':'')+'><span class="slider"></span></label>';
const rows = [
['Contexte', '<span class="moe-ctx"><input type="range" id="strata-s-ctx" min="32768" max="262144" step="4096" value="'+(+d.ctx||131072)+'" oninput="strataCtxLbl()"><span id="strata-s-ctx-v"></span></span>'],
['Cache KV', sel('strata-s-kv', [['fp16', 'fp16 (meilleure qualité)'],['int8', '8 bits (plus léger)']], d.kv)],
['Prédiction (MTP)', sel('strata-s-spec', [[2,'2'],[3,'3'],[4,'4']], d.spec)],
// 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)]);
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
strataCtxLbl();
}
// valeur exacte en jetons (131 072 et non « 128K », source de confusion)
function strataCtxLbl(){
const r = document.getElementById('strata-s-ctx'), o = document.getElementById('strata-s-ctx-v');
if(!r || !o) return;
o.textContent = (+r.value).toLocaleString('fr-FR');
// partie remplie à gauche de la pastille (la piste est dessinée en CSS)
r.style.setProperty('--p', ((r.value - r.min) / (r.max - r.min) * 100) + '%');
}
async function strataSaveSettings(){
const f = strataState && strataState.families.find(x=>x.id===strataFam);
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
if(!fit || !fit.preset) return;
const v = id => document.getElementById(id);
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
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; }
toast(r.restarted ? 'Réglages enregistrés, redémarrage en cours' : 'Réglages enregistrés');
strataTarget = fit.preset;
await openStrata(fit.preset);
if(typeof loadPresets === 'function') loadPresets();
}
// La config qui tourne (ou tournera) pour un modèle installé.
function strataRenderDetails(fit){
const fld = document.getElementById('strata-details-fld');
const d = fit && fit.details;
fld.hidden = !d;
if(!d) return;
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
const rows = [
['Carte principale', d.main_gpu],
["Carte d'appoint", d.helper_gpu || 'aucune'],
['Lecture des images', d.vision_gpu || 'aucune'],
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
['Prédiction (MTP)', d.spec+' jetons'],
['Experts hors GPU', (d.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],
];
document.getElementById('strata-details').innerHTML = rows.map(([a,b])=>
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+escHtml(b)+'</span></div>').join('');
}
async function strataInstall(){
const f = strataState && strataState.families.find(x=>x.id===strataFam);
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
if(!fit || fit.active) return;
// déjà installé : simple bascule, la même que dans la liste des modèles
if(fit.preset){
closeStrata();
await switchTo(-1, f.label+' '+strataQuant, fit.preset);
loadStrata();
return;
}
const msg = 'Installer Qwen3.8 Flash Next '+f.label+' '+strataQuant+' sur Strata ? Téléchargement : ~'
+Math.round(fit.disk_gb)+' Go. Le modèle sera activé à la fin.';
if(!await askConfirm(msg, {title:'Strata — Qwen3.8 Flash Next', okText:'Installer'})) return;
const r = await jpost('/api/strata/install', {family:strataFam, quant:strataQuant});
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
closeStrata();
const det = document.getElementById('lc-details');
if(det && 'open' in det) det.open = true;
lcStartPolling();
}
// 11-external.js — presets « externes » : le chat part vers une API
// OpenAI-compatible distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre
// machine…) au lieu du llama-server local.
@@ -9456,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','#');
@@ -10342,7 +10923,8 @@ function renderModelSwitch(presets, active){
async function loadModelSwitchFiles(){
try{
const r = await jget('/api/models');
MS_FILES = Array.isArray(r) ? r : [];
// Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas.
MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||'')));
}catch(_){ return; }
paintModelSwitch();
}
@@ -11897,6 +12479,21 @@ const I18N_EN = {
'Tâches planifiées': 'Scheduled tasks',
'System prompt': 'System prompt',
'Moteur llama.cpp': 'llama.cpp engine',
// Moteur Strata (10b-strata.js)
'Version': 'Version',
'Qualité': 'Quality',
'Configuration': 'Configuration',
'Installer': 'Install',
'Activer': 'Activate',
'Déjà actif': 'Already active',
'Cache KV': 'KV cache',
'Prédiction (MTP)': 'Prediction (MTP)',
'Lire les images': 'Read images',
"Carte d'appoint": 'Helper card',
'Carte principale': 'Main card',
'conseillé': 'recommended',
'installé': 'installed',
'actif': 'active',
'Journal du moteur': 'Engine log',
'Dictée': 'Dictation',
'Identité': 'Identity',
+73 -4
View File
@@ -329,7 +329,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
<div class="set-nav-h">Moteur</div>
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
<button data-pane="engine" onclick="settingsShow('engine')">Moteur</button>
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
<div class="set-nav-h">Application</div>
@@ -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
@@ -531,7 +531,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div></div>
</section>
<section class="set-pane" data-pane="engine" id="lc-details">
<div class="set-pane-h"><span>Moteur</span></div>
<div class="set-pane-h"><span>Moteur</span><span class="muted" style="font-size:12px;font-weight:400">actif : <b id="lc-active-engine">…</b></span></div>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
@@ -597,6 +597,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div class="lc-mode-state" id="lc-custom-state"></div>
</div>
</div>
<!-- Deuxième moteur, hors des modes llama.cpp (masqués quand le moteur vient
de l'image) : Strata, spécialisé Qwen3.8 Flash Next, paquet figé repris
d'AJEAN (backend_strata.go). Le choix du moteur se fait par preset :
activer un modèle Strata bascule sur Strata, un autre preset revient à
llama.cpp. -->
<div class="lc-modes" id="lc-engines">
<div class="lc-mode" id="lc-strata" onclick="openStrata()">
<div class="lc-mode-t">Strata — Qwen3.8 Flash Next</div>
<div class="lc-mode-d">moteur spécialisé pour ce MoE de 125 B (experts répartis entre VRAM, RAM et disque), installation en un clic</div>
<div class="lc-mode-state" id="lc-strata-state"></div>
</div>
</div>
<div id="lc-job" class="rowbody" style="display:none">
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
@@ -866,6 +878,46 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div>
</div>
<div id="strata-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeStrata()">
<div class="modal-card" style="max-width:520px">
<div class="modal-head">
<strong>Strata — Qwen3.8 Flash Next</strong>
<button class="modal-x" onclick="closeStrata()" aria-label="Fermer">×</button>
</div>
<div class="modal-body mform">
<div id="strata-machine" class="muted" style="font-size:12px;line-height:1.5"></div>
<div class="fld">
<label class="fld-l">Version</label>
<div class="seg" id="strata-fams"></div>
</div>
<div class="fld">
<label class="fld-l">Qualité</label>
<div class="slist" id="strata-quants"></div>
</div>
<div id="strata-disk" class="muted" style="font-size:12px;line-height:1.5"></div>
<div class="fld" id="strata-settings-fld" hidden>
<label class="fld-l">Réglages</label>
<div class="slist moe-details" id="strata-settings"></div>
</div>
<div class="fld" id="strata-details-fld" hidden>
<label class="fld-l">Configuration</label>
<div class="slist moe-details" id="strata-details"></div>
</div>
<div id="strata-note" class="muted" style="font-size:11px;line-height:1.5"></div>
<div class="muted" style="font-size:11px;line-height:1.5">Moteur <a href="https://github.com/Niko1221/Strata" target="_blank" rel="noopener">Strata</a> (MIT),
paquet figé et vérifié (SHA-256) publié par <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener">AJEAN</a>.
Linux + NVIDIA (RTX 20 ou plus récente). Le modèle est téléchargé dans <code>/data/strata</code>.</div>
</div>
<div class="modal-foot">
<div class="foot-l"></div>
<div class="foot-r">
<button onclick="closeStrata()">fermer</button>
<button id="strata-save" hidden onclick="strataSaveSettings()">Enregistrer</button>
<button class="btn-save" id="strata-go" onclick="strataInstall()">Installer</button>
</div>
</div>
</div>
</div>
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
<div class="modal-card" style="max-width:560px">
<div class="modal-head">
@@ -1137,6 +1189,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
</div>
<!-- Repris d'AJEAN v0.10.3 : n'apparaît qu'avec un projecteur choisi. -->
<div class="pe-row" id="s-mmproj-cpu-row" style="display:none">
<span class="pe-row-l">Projecteur vision sur le CPU<span class="pe-sub">garde le mmproj hors du GPU — libère de la VRAM, images lues plus lentement (--no-mmproj-offload)</span></span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mmproj-cpu" onchange="eaToggleFlag('--no-mmproj-offload', this.checked)"><span class="pe-sl"></span></label></span>
</div>
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
reste là : c'est le mode expert (dépôt privé, fichier exotique,
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
@@ -1236,7 +1293,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div class="pe-row">
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"><span class="pe-unit" id="s-ngl-max"></span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
@@ -1417,6 +1474,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div>
<!-- Prompt système DE CE preset (repris d'AJEAN v0.13.0) : il bascule avec
le preset. Rangé en base (sysprompt:<id>), pas dans le .env qui ne
porte qu'une ligne par clé. Vide = le prompt global s'applique. -->
<div id="m-sys-row" class="pe-group" style="display:none">
<div class="pe-gh">Prompt système</div>
<div class="pe-list">
<div class="pe-row stack">
<textarea id="m-sysprompt" class="pe-inp" rows="4" spellcheck="false" placeholder="(optionnel) consigne propre à ce preset — vide = prompt système général"></textarea>
</div>
</div>
</div>
<div id="m-raw" class="pe-group">
<div class="pe-gh" id="m-raw-head">Configuration</div>
<div class="pe-list">
+2
View File
@@ -0,0 +1,2 @@
/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(tLine truncated
+37 -2
View File
@@ -315,6 +315,27 @@ async function loadCfg(){
const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'&quot;')+'">'+String(v)+'</span></div>';
const rows=[];
// Strata (backend_strata.go) : la config réelle de lancement, calculée par le
// serveur comme au démarrage (/api/strata → details du modèle actif).
if(/^(strata|moe)$/i.test((c.ENGINE||'').trim())){
const s = await jget('/api/strata').catch(()=>null);
let fam=null, fit=null;
for(const f of (s&&s.families)||[]){ const x=f.fits.find(x=>x.active); if(x){ fam=f; fit=x; } }
const d=fit&&fit.details;
rows.push(row('MOTEUR', 'Strata', 'paquet AJEAN MoE '+((s&&s.version)||'')));
if(typeof setLoadedModel === 'function') setLoadedModel('');
if(fam) rows.push(row('MODEL', 'Qwen3.8 Flash Next '+fam.label+' '+fit.quant.id));
if(d){
const k=n=>(+n>=1024 ? Math.round(+n/1024)+'K' : n);
rows.push(row('CTX', d.ctx));
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
rows.push(row('MTP', d.spec));
rows.push(row('EXPERTS', ({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();
return;
}
if(c.BIN){
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
// toujours le chemin complet, quel que soit le libellé.
@@ -336,12 +357,26 @@ async function loadCfg(){
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
// source qui le connaisse quand aucun preset ne correspond à la configuration.
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
// Nombre de couches du modèle (amont #43) : NGL au-delà de ngl_max ne change
// rien, en dessous on libère de la VRAM pour le contexte et le cache KV.
const ly=c.MODEL ? await jget('/api/model/layers').catch(()=>null) : null;
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
let v=c[k]; if(k==='MODEL') v=v.split(/[\\/]/).pop();
if(k==='NGL' && ly && ly.ok){ rows.push(row(k, v+' / '+ly.ngl_max, ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)')); return; }
rows.push(row(k, v));
});
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
if(m) rows.push(row('N-CPU-MOE', m[1]));
document.getElementById('cfg').innerHTML = rows.join('');
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
}
// Ligne de commande exacte du dernier lancement du moteur (amont #108),
// copiable pour la vérifier ou la partager. Clé API masquée côté serveur.
let ENGINE_CMDLINE='';
async function engineCmdlineRow(){
const cl=await jget('/api/engine/cmdline').catch(()=>null);
ENGINE_CMDLINE=(cl&&cl.cmdline)||'';
if(!ENGINE_CMDLINE) return '';
return '<div class="kv"><span>COMMANDE</span><span><button class="pe-link" onclick="copyText(ENGINE_CMDLINE, \'commande copiée\')" title="'
+escHtml(ENGINE_CMDLINE)+'">copier la commande du moteur</button></span></div>';
}
+60 -9
View File
@@ -60,7 +60,9 @@ async function loadPresets(){
const row=document.createElement('div');
const pend = !x.active && pendingPreset===i+1;
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
row.onclick=()=>switchTo(i+1, x.name, x.id);
row.dataset.id = x.id;
// Fin d'un glisser-déposer : le clic qui le termine ne bascule pas le preset.
row.onclick=()=>{ if(presetJustDragged) return; switchTo(i+1, x.name, x.id); };
const info=document.createElement('div'); info.className='preset-info';
const nm=document.createElement('div'); nm.className='preset-name';
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
@@ -88,12 +90,27 @@ async function loadPresets(){
q.textContent=x.quant; q.title='quantization';
meta.appendChild(q);
}
if(x.reasoning){
const rt=document.createElement('span'); rt.className='rtag';
rt.title='raisonnement actif ('+x.reasoning+')';
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>';
rt.appendChild(document.createTextNode(x.reasoning));
meta.appendChild(rt);
if(x.ctx && !x.external){
const c=document.createElement('span'); c.className='ctag';
c.title='taille de contexte'; c.textContent=x.ctx;
meta.appendChild(c);
}
// Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
// (ampoule + niveau), regroupés en un seul tag.
if(x.vision || x.reasoning){
const cap=document.createElement('span'); cap.className='captag';
const tip=[];
if(x.vision){
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>');
tip.push('lit les images');
}
if(x.reasoning){
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 21h4"/><path d="M12 3a6 6 0 0 0-4 10.5c.6.6 1 1.4 1 2.5h6c0-1.1.4-1.9 1-2.5A6 6 0 0 0 12 3z"/></svg>');
cap.appendChild(document.createTextNode(x.reasoning));
tip.push('raisonnement actif ('+x.reasoning+')');
}
cap.title=tip.join(' · ');
meta.appendChild(cap);
}
if(x.bench){
const bt=document.createElement('span'); bt.className='btag';
@@ -114,10 +131,44 @@ async function loadPresets(){
// Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : x.strata ? openStrata(x.id) : openPreset(x.id); };
row.appendChild(info); row.appendChild(edit);
cont.appendChild(row);
});
initPresetSortable(cont);
}
// ─── Réordonnancement des presets (SortableJS, repris d'AJEAN) ───────────────
// La ligne entière se déplace, sans poignée. L'ordre est gardé côté serveur
// (/api/presets/order) : il vaut aussi pour le sélecteur de l'en-tête.
let presetJustDragged = false;
let presetSortable = null;
function initPresetSortable(cont){
if(typeof Sortable==='undefined') return; // lib absente : la liste reste simple
if(presetSortable) return; // une instance, sur le conteneur qui persiste
presetSortable = Sortable.create(cont, {
animation: 160,
easing: 'cubic-bezier(.2,.7,.3,1)',
// Clone géré par Sortable (et non l'image native, semi-transparente), posé
// sur <body> : dans la modale (ancêtre transformé), il apparaissait loin
// du curseur.
forceFallback: true,
fallbackTolerance: 3,
fallbackOnBody: true,
// Sur mobile, il faut MAINTENIR l'appui : un simple défilement attrapait un preset.
delay: 220,
delayOnTouchOnly: true,
touchStartThreshold: 6,
ghostClass: 'preset-ghost',
chosenClass: 'preset-chosen',
dragClass: 'preset-drag',
onStart(){ presetJustDragged = true; },
onEnd(){
setTimeout(()=>{ presetJustDragged = false; }, 60);
const ids=[...cont.children].map(r=>r.dataset.id).filter(Boolean);
// Re-rendu une fois l'ordre persisté : les positions (pendingPreset) suivent.
jpost('/api/presets/order', {ids}).then(()=>loadPresets()).catch(()=>{});
},
});
}
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
@@ -622,7 +673,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+49 -3
View File
@@ -158,6 +158,10 @@ async function openItem(kind, key){
const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-row');
const samplingRow = document.getElementById('m-sampling-row');
const sysRow = document.getElementById('m-sys-row');
if(sysRow) sysRow.style.display = kind === 'preset' ? '' : 'none';
const sysTa = document.getElementById('m-sysprompt');
if(sysTa) sysTa.value = '';
const rawHead = document.getElementById('m-raw-head');
const rawToggle = document.getElementById('m-raw-toggle');
const rawBody = document.getElementById('m-raw-body');
@@ -216,6 +220,7 @@ async function openItem(kind, key){
document.getElementById('m-name').value = display;
document.getElementById('m-content').value = d.content || '';
if(kind === 'preset'){
if(sysTa) sysTa.value = d.sysprompt || '';
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
document.getElementById('m-quant').value = currentQuantInTextarea();
populateSettings();
@@ -297,6 +302,10 @@ async function populateModelPicker(){
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
const groups = [];
for(const m of (list||[])){
// Projecteurs vision : ils ont leur propre sélecteur (Vision) et ne se
// lancent pas comme modèle (repris d'AJEAN v0.16.2). Gardé seulement s'il
// est DÉJÀ le MODEL du preset, pour ne pas masquer une erreur de saisie.
if(isMmprojName(m.name) && m !== hit) continue;
let g = groups.find(x => x.dir === m.dir);
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
g.items.push(m);
@@ -599,6 +608,20 @@ function onPickModel(){
ta.value = 'MODEL="'+val+'"\n' + ta.value;
}
toast('MODEL='+val);
showNglMax(val);
}
// Nombre de couches du modèle du preset (amont #43), lu dans son en-tête GGUF :
// affiché à côté du champ NGL (placeholder, « / N » et survol).
async function showNglMax(model){
const e=document.getElementById('s-ngl'), u=document.getElementById('s-ngl-max'); if(!e) return;
e.placeholder='999'; e.title=''; if(u) u.textContent='';
if(!model) return;
const ly=await jget('/api/model/layers?model='+encodeURIComponent(model)).catch(()=>null);
if(!ly || !ly.ok) return;
e.placeholder=String(ly.ngl_max);
if(u) u.textContent='/ '+ly.ngl_max;
e.title=ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)';
}
// --- Vision (projecteur multimodal --mmproj) --------------------------------
@@ -618,6 +641,21 @@ function onPickMmproj(){
// et on centralise le choix dans la clé MMPROJ.
eaSetValued('--mmproj', '');
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
syncMmprojCpuRow();
}
// « Projecteur sur le CPU » n'a de sens qu'avec un mmproj chargé : la ligne est
// masquée sans vision. Source de vérité = la config, pas le select (rempli
// APRÈS cette synchro à l'ouverture du preset). Sans projecteur, le drapeau
// resté dans EXTRA_ARGS est retiré.
function syncMmprojCpuRow(){
const row = document.getElementById('s-mmproj-cpu-row');
if(!row) return;
const on = !!currentMmprojInTextarea();
row.style.display = on ? '' : 'none';
if(!on && eaHasFlag('--no-mmproj-offload')){
eaToggleFlag('--no-mmproj-offload', false);
const c = document.getElementById('s-mmproj-cpu'); if(c) c.checked = false;
}
}
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
@@ -1013,6 +1051,7 @@ function populateSettings(){
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
set('s-ctx', cfgReadKey('CTX'));
set('s-ngl', cfgReadKey('NGL'));
showNglMax(cfgReadKey('MODEL'));
set('s-threads', cfgReadKey('THREADS'));
set('s-batch', cfgReadKey('BATCH'));
set('s-ubatch', cfgReadKey('UBATCH'));
@@ -1072,6 +1111,8 @@ function populateSettings(){
}
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
chk('s-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
syncMmprojCpuRow();
const lm = eaLoadMode();
set('s-loadmode', lm);
syncLoadModeSub(lm);
@@ -1342,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;
@@ -1352,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();
@@ -1406,7 +1452,7 @@ async function saveItem(){
// Presets: keyed by id (filename); duplicate display names are allowed.
// Skills: keyed by name, rename via `old`.
const payload = editingKind==='preset'
? {id: editingKey, name, content}
? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
: {name, old: editingKey, content};
const r = await jpost(K.saveUrl, payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
+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
+13 -2
View File
@@ -57,7 +57,7 @@ let lcSeenEnd = false, lcEndShown = false;
function lcChipLabel(action){
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
prebuilt:'Téléchargement du moteur', engine:'Mise à jour du moteur',
custom:'Installation du backend'}[action] || 'Installation du moteur';
custom:'Installation du backend', strata:'Installation de Strata'}[action] || 'Installation du moteur';
}
function lcChipSync(j){
const chip = document.getElementById('lc-chip');
@@ -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');
+206
View File
@@ -0,0 +1,206 @@
// Moteur Strata : installation « en un clic » du moteur spécialisé Qwen3.8 Flash
// Next (repris d'AJEAN, « AJEAN MoE »). L'utilisateur choisit la version (Swift
// ou Classique) et la qualité (quant) ; tout le reste (cartes, RAM, mmap,
// réglages) est déduit côté serveur (backend_strata.go). L'installation est une
// tâche comme celles de llama.cpp : même suivi (#lc-job), même pastille.
let strataState = null, strataFam = '', strataQuant = '', strataTarget = '';
async function loadStrata(){
let s;
try{ s = await jget('/api/strata'); }catch(_){ return; }
strataState = s;
const row = document.getElementById('lc-strata');
if(!row) return;
// Hors Linux + NVIDIA, la ligne reste visible mais dit pourquoi : choisir son
// moteur, c'est aussi savoir pourquoi l'un des deux n'est pas proposé.
const env = s.env || {};
row.classList.toggle('moe-off', !env.supported);
const st = document.getElementById('lc-strata-state');
if(st){
const n = (s.installed||[]).length;
st.textContent = !env.supported ? (env.reason || 'indisponible sur cette machine')
: s.active ? 'moteur actif' + (n > 1 ? ' · '+n+' modèles installés' : '')
: n ? n+' modèle(s) installé(s)' : '';
}
const eng = document.getElementById('lc-active-engine');
if(eng) eng.textContent = s.active ? 'Strata' : 'llama.cpp';
}
// presetId (optionnel) : ouverte depuis l'engrenage d'un modèle installé, la
// fenêtre se place directement sur lui.
async function openStrata(presetId){
// état relu à chaque ouverture : juste après une installation, l'ancien
// proposait encore « Installer »
await loadStrata();
if(!strataState) return;
const s = strataState, env = s.env || {};
if(!env.supported){ toast('Strata : '+(env.reason||'indisponible sur cette machine')); return; }
strataTarget = typeof presetId === 'string' ? presetId : '';
const cards = (env.gpus||[]).filter(g=>g.index===env.main || g.index===env.helper)
.sort((a,b)=>(a.index===env.main?-1:1))
.map(g=>g.name.replace(/^NVIDIA GeForce /,'')+' '+Math.round(g.vram_gb)+' Go');
document.getElementById('strata-machine').textContent =
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM 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){
const hit = f.fits.find(x=>x.preset===strataTarget);
if(hit){ strataFam = f.id; strataQuant = hit.quant.id; }
}
}
strataRenderFams();
strataRenderQuants(!strataTarget);
showModal('strata-modal');
}
function closeStrata(){ hideModal('strata-modal'); }
function strataRenderFams(){
const box = document.getElementById('strata-fams');
box.innerHTML = strataState.families.map(f=>
'<label><input type="radio" name="strata-fam" value="'+f.id+'"'+(f.id===strataFam?' checked':'')
+' onchange="strataFam=this.value;strataRenderQuants(true)">'
+'<span>'+escHtml(f.label)+'</span><span class="be-note">'+escHtml(f.about)+'</span></label>').join('');
}
function strataRenderQuants(pickReco){
const f = strataState.families.find(x=>x.id===strataFam);
if(!f) return;
// à l'ouverture : le modèle actif s'il est de cette version, sinon un installé, sinon le conseillé
if(pickReco || !f.fits.some(x=>x.quant.id===strataQuant && (x.ok || x.preset))){
const cur = f.fits.find(x=>x.active) || f.fits.find(x=>x.preset);
strataQuant = cur ? cur.quant.id : (f.recommend || '');
}
const box = document.getElementById('strata-quants');
box.innerHTML = f.fits.map(x=>{
const q = x.quant, sel = q.id===strataQuant;
const tag = x.active ? 'actif' : x.preset ? 'installé' : q.id===f.recommend ? 'conseillé' : '';
const reco = tag ? ' <span class="lc-reco">'+tag+'</span>' : '';
const usable = x.ok || !!x.preset; // installé : activable même si l'espace disque manque
const desc = x.preset ? escHtml(q.about)
: x.ok ? escHtml(q.about)+' · ~'+Math.round(x.disk_gb)+' Go sur le disque'
: escHtml(x.why);
return '<div class="lc-mode'+(sel?' moe-sel':'')+(usable?'':' moe-off')+'"'
+(usable?' onclick="strataQuant=\''+q.id+'\';strataRenderQuants(false)"':'')+'>'
+'<div class="lc-mode-t">'+q.id+reco+'</div><div class="lc-mode-d">'+desc+'</div></div>';
}).join('');
const fit = f.fits.find(x=>x.quant.id===strataQuant);
const go = document.getElementById('strata-go');
go.disabled = !fit || !!fit.active;
go.textContent = fit && fit.active ? 'Déjà actif' : fit && fit.preset ? 'Activer' : 'Installer';
strataRenderDisk(fit);
strataRenderSettings(fit);
strataRenderDetails(fit);
document.getElementById('strata-note').textContent = !fit ? 'Aucune version ne tient sur cette machine.'
: fit.active ? 'Modèle actif.' : fit.preset ? 'Modèle installé, prêt à être activé.'
: fit.mmap ? 'RAM 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.';
}
// Place disque : libre, ce que ce choix occupe, ce qui restera.
function strataRenderDisk(fit){
const el = document.getElementById('strata-disk');
const free = Math.round((strataState.env||{}).disk_free_gb||0);
if(!fit){ el.textContent = ''; return; }
if(fit.preset){ el.textContent = 'Disque : '+free+' Go libres. Modèle déjà présent.'; return; }
const need = Math.round(fit.disk_gb), left = free - need;
el.textContent = left >= 5
? 'Disque : '+free+' Go libres, ~'+need+' Go requis, ~'+left+' Go restants.'
: 'Disque : '+free+' Go libres, ~'+need+' Go requis. Espace insuffisant.';
}
// Réglages modifiables d'un modèle installé.
function strataRenderSettings(fit){
const fld = document.getElementById('strata-settings-fld'), save = document.getElementById('strata-save');
const d = fit && fit.details;
fld.hidden = save.hidden = !d;
if(!d) return;
const sel = (id, opts, cur) => '<select class="ctl" id="'+id+'" style="width:auto">'
+ opts.map(([v,l])=>'<option value="'+v+'"'+(String(v)===String(cur)?' selected':'')+'>'+escHtml(l)+'</option>').join('')+'</select>';
const sw = (id, on) => '<label class="switch"><input type="checkbox" id="'+id+'"'+(on?' checked':'')+'><span class="slider"></span></label>';
const rows = [
['Contexte', '<span class="moe-ctx"><input type="range" id="strata-s-ctx" min="32768" max="262144" step="4096" value="'+(+d.ctx||131072)+'" oninput="strataCtxLbl()"><span id="strata-s-ctx-v"></span></span>'],
['Cache KV', sel('strata-s-kv', [['fp16', 'fp16 (meilleure qualité)'],['int8', '8 bits (plus léger)']], d.kv)],
['Prédiction (MTP)', sel('strata-s-spec', [[2,'2'],[3,'3'],[4,'4']], d.spec)],
// 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)]);
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
strataCtxLbl();
}
// valeur exacte en jetons (131 072 et non « 128K », source de confusion)
function strataCtxLbl(){
const r = document.getElementById('strata-s-ctx'), o = document.getElementById('strata-s-ctx-v');
if(!r || !o) return;
o.textContent = (+r.value).toLocaleString('fr-FR');
// partie remplie à gauche de la pastille (la piste est dessinée en CSS)
r.style.setProperty('--p', ((r.value - r.min) / (r.max - r.min) * 100) + '%');
}
async function strataSaveSettings(){
const f = strataState && strataState.families.find(x=>x.id===strataFam);
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
if(!fit || !fit.preset) return;
const v = id => document.getElementById(id);
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
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; }
toast(r.restarted ? 'Réglages enregistrés, redémarrage en cours' : 'Réglages enregistrés');
strataTarget = fit.preset;
await openStrata(fit.preset);
if(typeof loadPresets === 'function') loadPresets();
}
// La config qui tourne (ou tournera) pour un modèle installé.
function strataRenderDetails(fit){
const fld = document.getElementById('strata-details-fld');
const d = fit && fit.details;
fld.hidden = !d;
if(!d) return;
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
const rows = [
['Carte principale', d.main_gpu],
["Carte d'appoint", d.helper_gpu || 'aucune'],
['Lecture des images', d.vision_gpu || 'aucune'],
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
['Prédiction (MTP)', d.spec+' jetons'],
['Experts hors GPU', (d.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],
];
document.getElementById('strata-details').innerHTML = rows.map(([a,b])=>
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+escHtml(b)+'</span></div>').join('');
}
async function strataInstall(){
const f = strataState && strataState.families.find(x=>x.id===strataFam);
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
if(!fit || fit.active) return;
// déjà installé : simple bascule, la même que dans la liste des modèles
if(fit.preset){
closeStrata();
await switchTo(-1, f.label+' '+strataQuant, fit.preset);
loadStrata();
return;
}
const msg = 'Installer Qwen3.8 Flash Next '+f.label+' '+strataQuant+' sur Strata ? Téléchargement : ~'
+Math.round(fit.disk_gb)+' Go. Le modèle sera activé à la fin.';
if(!await askConfirm(msg, {title:'Strata — Qwen3.8 Flash Next', okText:'Installer'})) return;
const r = await jpost('/api/strata/install', {family:strataFam, quant:strataQuant});
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
closeStrata();
const det = document.getElementById('lc-details');
if(det && 'open' in det) det.open = true;
lcStartPolling();
}
+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','#');
+2 -1
View File
@@ -108,7 +108,8 @@ function renderModelSwitch(presets, active){
async function loadModelSwitchFiles(){
try{
const r = await jget('/api/models');
MS_FILES = Array.isArray(r) ? r : [];
// Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas.
MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||'')));
}catch(_){ return; }
paintModelSwitch();
}
+15
View File
@@ -38,6 +38,21 @@ const I18N_EN = {
'Tâches planifiées': 'Scheduled tasks',
'System prompt': 'System prompt',
'Moteur llama.cpp': 'llama.cpp engine',
// Moteur Strata (10b-strata.js)
'Version': 'Version',
'Qualité': 'Quality',
'Configuration': 'Configuration',
'Installer': 'Install',
'Activer': 'Activate',
'Déjà actif': 'Already active',
'Cache KV': 'KV cache',
'Prédiction (MTP)': 'Prediction (MTP)',
'Lire les images': 'Read images',
"Carte d'appoint": 'Helper card',
'Carte principale': 'Main card',
'conseillé': 'recommended',
'installé': 'installed',
'actif': 'active',
'Journal du moteur': 'Engine log',
'Dictée': 'Dictation',
'Identité': 'Identity',
+55 -1
View File
@@ -170,7 +170,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
@media (min-width:721px){
.side{width:380px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px}
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag,.preset-meta .ctag,.preset-meta .captag{font-size:11px;padding:2px 9px;border-radius:4px}
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px}
}
h1{margin:0;font-size:18px;color:var(--accent)}
@@ -266,6 +266,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
(étiquette comprise), plus sur la carte seule. */
.gated-group{margin:0 0 10px;transition:opacity .15s}
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
/* L'aide « ? » reste survolable sur un réglage grisé : c'est justement là qu'on
veut savoir à quoi il sert (repris d'AJEAN, #104). */
.gated-group.gated .help{pointer-events:auto}
.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none}
.switch input{position:absolute;opacity:0;width:0;height:0}
/* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se
@@ -363,6 +366,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
.ctag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
/* Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
(ampoule + niveau). Icônes en em : la bulle a la hauteur des autres tags. */
.captag{flex-shrink:0;display:inline-flex;align-items:center;gap:4px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
.captag::before{content:"\200b";margin-right:-4px}
.captag svg{display:block;width:1.3em;height:1.3em}
/* Réordonnancement des presets (SortableJS) : clone plein qui suit le curseur,
emplacement cible discret. */
.preset-drag{cursor:grabbing;background:var(--row-bg-on);border:none;border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,.35);outline:none;opacity:1!important}
.preset-ghost{opacity:.35}
.preset-chosen{cursor:grabbing}
/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas
un réglage mais une DESTINATION — la réponse part sur internet, pas sur la
carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil.
@@ -767,6 +781,32 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
gris atténué du thème monochrome et devenait illisible en mode sombre. */
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:4px;padding:1px 6px}
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
/* Strata (10b-strata.js) : liste des qualités, réglages et configuration. */
.slist>.lc-modes#lc-engines{border-top:1px solid var(--border)}
#lc-strata.moe-off{cursor:default}
#lc-strata.moe-off:hover{background:none}
#lc-strata.moe-off .lc-mode-t{color:var(--dim)}
#strata-quants{overflow:hidden}
#strata-quants .lc-mode.moe-sel{background:var(--accent-bg)}
#strata-quants .lc-mode.moe-sel .lc-mode-t{color:var(--accent)}
#strata-quants .lc-mode.moe-off{cursor:default}
#strata-quants .lc-mode.moe-off:hover{background:none}
#strata-quants .lc-mode.moe-off .lc-mode-t{color:var(--dim)}
.moe-details{padding:4px 10px}
.moe-details .moe-kv{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;padding:5px 0}
.moe-details .moe-kv .switch{display:block}
.moe-details .moe-kv:not(:last-child){border-bottom:1px solid var(--border)}
.moe-details .moe-kv span:first-child{color:var(--dim)}
.moe-details .moe-kv span:last-child{text-align:right}
.moe-ctx{display:flex;align-items:center;gap:10px}
.moe-ctx input[type=range]{width:160px;height:16px;margin:0;padding:0;background:transparent;border:none;-webkit-appearance:none;appearance:none;cursor:ew-resize}
.moe-ctx input[type=range]:focus{outline:none}
.moe-ctx input[type=range]::-webkit-slider-runnable-track{height:4px;border:none;border-radius:999px;background:linear-gradient(to right,var(--accent) var(--p,50%),color-mix(in srgb,var(--dim) 25%,transparent) var(--p,50%))}
.moe-ctx input[type=range]::-moz-range-track{height:4px;border:none;border-radius:999px;background:color-mix(in srgb,var(--dim) 25%,transparent)}
.moe-ctx input[type=range]::-moz-range-progress{height:4px;border-radius:999px;background:var(--accent)}
.moe-ctx input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:none;border-radius:50%;background:var(--accent)}
.moe-ctx input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:var(--accent)}
.moe-ctx span{min-width:62px;text-align:right;font-variant-numeric:tabular-nums}
/* Hauteur réservée : la ligne d'état arrive après l'appel réseau, sans elle
les trois cartes grandissaient d'un cran une fois l'état connu. */
.lc-mode-state{font-size:11px;min-height:16px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
@@ -2230,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}}
+77 -8
View File
@@ -107,9 +107,17 @@ 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) {
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) ||
strings.Contains(l, strataServeMarker) {
start = i
}
}
@@ -117,8 +125,16 @@ func modelLoadErrorFrom(log string) string {
for _, l := range lines[start:] {
low := strings.ToLower(l)
switch {
case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"):
case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"),
strings.HasPrefix(l, "ready: http://"): // serveur de Strata prêt
loaded = true
// Serveur de Strata (Python) : une trace = il s'est arrêté, pas « il charge encore ».
case strings.Contains(l, "Traceback (most recent call last)"):
if reason == "" {
reason = "le serveur de Strata s'est arrêté (trace Python dans le journal)"
}
case strings.Contains(l, "[strata] config"):
reason = "configuration de Strata refusée (détail dans le journal) — réinstalle le modèle"
case strings.Contains(low, "failed to create mtp context"),
strings.Contains(low, "failed to load draft model"),
strings.Contains(low, "failed to initialize speculative decoding context"):
@@ -150,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)"
@@ -187,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
@@ -432,9 +453,27 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
if m := strings.TrimSpace(cfg[extKeyModel]); m != "" {
item["model"] = m
}
if strings.TrimSpace(cfg[extKeyVision]) == "1" {
item["vision"] = true
}
out = append(out, item)
continue
}
// Pastilles de la liste : taille de contexte, et l'œil quand le preset
// charge un projecteur vision (repris d'AJEAN v0.10.1 / v0.10.9).
if c := presetCtx(content); c != "" {
item["ctx"] = c
}
if strings.TrimSpace(parseEnv(content)["MMPROJ"]) != "" {
item["vision"] = true
}
// Strata : réglages dans sa propre fenêtre, pas l'éditeur llama.cpp.
if cfg := parseEnv(content); isStrataConfig(cfg) {
item["strata"] = true
if cfg["STRATA_VISION"] != "0" {
item["vision"] = true
}
}
if q := detectQuant(content); q != "" {
item["quant"] = q
}
@@ -465,6 +504,26 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, out)
}
// handlePresetsOrder (POST {ids}) : l'ordre de la liste choisi par glisser-déposer.
func handlePresetsOrder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
return
}
var req struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := savePresetOrder(req.IDs); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
func handlePreset(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
@@ -476,16 +535,20 @@ func handlePreset(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 404, map[string]any{"error": "not found"})
return
}
sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content})
sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content,
"sysprompt": getStr(bkState, sysPromptKeyPrefix+id)})
}
// presetSaveReq is the preset editor payload. `id` identifies an existing
// preset to update ("" creates a new one); `name` is the display name.
// SysPrompt : le prompt système DE CE preset, édité dans l'éditeur (repris
// d'AJEAN v0.13.0) ; nil = champ absent, on n'y touche pas.
type presetSaveReq struct {
ID string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
DeleteModel bool `json:"deleteModel"`
ID string `json:"id"`
Name string `json:"name"`
Content string `json:"content"`
DeleteModel bool `json:"deleteModel"`
SysPrompt *string `json:"sysprompt,omitempty"`
}
// saveReq is the skill editor payload (skills keep name-as-identity + rename).
@@ -588,6 +651,12 @@ func handlePresetSave(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if req.SysPrompt != nil {
if err := putStr(bkState, sysPromptKeyPrefix+newID, strings.TrimSpace(*req.SysPrompt)); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
}
if applied {
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID)
fmt.Println(dim("[info] redémarrage du service..."))
+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)
}
}
+18 -1
View File
@@ -53,7 +53,9 @@ func capsFromBody(body chatReq) Caps {
func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) {
mu := &sync.Mutex{}
done := make(chan struct{})
exited := make(chan struct{})
go func() {
defer close(exited)
// 4 s (et non 15) : borne le temps qu'un dernier bout de flux peut rester
// coincé dans un buffer proxy (Cloudflare) faute d'octets pour le pousser.
t := time.NewTicker(4 * time.Second)
@@ -64,6 +66,15 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
return
case <-t.C:
mu.Lock()
// Arrêt demandé pendant l'attente du verrou : le handler peut déjà
// être revenu, et écrire dans son ResponseWriter fait planter le
// process (amont #105).
select {
case <-done:
mu.Unlock()
return
default:
}
_, err := w.Write([]byte(": ping\n\n"))
if flusher != nil {
flusher.Flush()
@@ -75,7 +86,13 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
}
}
}()
return mu, func() { close(done) }
var once sync.Once
// stop attend la fin de la goroutine : après son retour, plus aucune
// écriture ne peut partir vers w.
return mu, func() {
once.Do(func() { close(done) })
<-exited
}
}
// runChatStream est désormais un pur ABONNÉ au journal de la conversation serveur :
+19 -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.
@@ -220,6 +231,8 @@ func newWebMux() *http.ServeMux {
api("/api/vram/reload", handleVramReload) // relance le moteur (le modèle se recharge)
api("/api/ram", handleRam)
api("/api/config", handleConfigEnv)
api("/api/engine/cmdline", handleEngineCmdline) // commande du dernier lancement du moteur (amont #108)
api("/api/model/layers", handleModelLayers) // nombre de couches du modèle, lu dans le GGUF (amont #43)
api("/api/paths", handlePaths)
api("/api/update", handleUpdateCheck)
api("/api/update/apply", handleUpdateApply)
@@ -256,7 +269,11 @@ func newWebMux() *http.ServeMux {
api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée
api("/api/engine/plan", handleEnginePlan) // sur clic : version visée + effet sur le rendu du gabarit
api("/api/engine/rollback", handleEngineRollback) // revient au moteur d'avant la dernière bascule
api("/api/strata", handleStrata) // Strata : machine, catalogue figé, recommandation, modèles installés
api("/api/strata/install", handleStrataInstall) // job : paquet figé + installeur de Strata + preset (suivi par /api/llamacpp/job)
api("/api/strata/settings", handleStrataSettings) // réglages d'un modèle Strata installé (relance s'il est actif)
api("/api/presets", handlePresets)
api("/api/presets/order", handlePresetsOrder) // ordre de la liste (glisser-déposer)
api("/api/preset", handlePreset)
api("/api/preset/save", handlePresetSave)
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité
@@ -356,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)")
}
}
+19
View File
@@ -180,6 +180,10 @@ func visionEnabled() bool {
if externalActive() {
return externalVisionActive() // modèle distant déclaré multimodal
}
// Strata : l'encodeur d'images est installé avec le modèle, sans MMPROJ.
if strataVisionActive() {
return true
}
return strings.TrimSpace(ReadConfig()["MMPROJ"]) != ""
}
@@ -357,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());
}