mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-24 07:28:13 +02:00
Compare commits
22
Commits
5dd14c9de5
...
rolling
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b859c4eb6 | ||
|
|
0a6914c936 | ||
|
|
342f787eff | ||
|
|
f1f73cfc50 | ||
|
|
6ea661d705 | ||
|
|
e236069c92 | ||
|
|
6afe07a823 | ||
|
|
30ce3a4e86 | ||
|
|
a463ccbd9f | ||
|
|
393004cb06 | ||
|
|
7d614324a0 | ||
|
|
853e59c085 | ||
|
|
e7fb63b1dd | ||
|
|
6aaf9360b4 | ||
|
|
c2a2cc5748 | ||
|
|
ac6d8ad144 | ||
|
|
5016396998 | ||
|
|
502f23e833 | ||
|
|
634cfb61f3 | ||
|
|
ecff7de5a3 | ||
|
|
127013b32b | ||
|
|
4a9a5a1712 |
No files matched your search
@@ -8,3 +8,4 @@ dist/
|
|||||||
!README.md
|
!README.md
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
rust/target/
|
||||||
@@ -31,6 +31,24 @@ jobs:
|
|||||||
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
|
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
|
||||||
done
|
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:
|
macos:
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
build-args: |
|
build-args: |
|
||||||
LOKI_VERSION=${{ github.sha }}
|
LOKI_VERSION=${{ github.sha }}
|
||||||
|
LOKI_BUILD=${{ github.run_number }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
|
# 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.
|
# pas : seul le code Go et l'assemblage de l'image se refont.
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
cache-to: type=gha,mode=max
|
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
|
||||||
@@ -22,3 +22,6 @@ Thumbs.db
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
# Rust (façade loki-rs)
|
||||||
|
rust/target/
|
||||||
+26
-1
@@ -42,6 +42,17 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
|
|||||||
# limité à cette étape de build.
|
# limité à cette étape de build.
|
||||||
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
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) ──────────
|
# ── É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
|
# 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
|
# 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.
|
# 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
|
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
|
||||||
# et la plupart des serveurs MCP réclament Node ≥ 20.
|
# 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 \
|
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 - \
|
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||||
&& apt-get install -y --no-install-recommends nodejs \
|
&& apt-get install -y --no-install-recommends nodejs \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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/loki /usr/local/bin/loki
|
||||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
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
|
# 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/.
|
# 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
|
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_VISIBLE_DEVICES=all \
|
||||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
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
|
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
|
||||||
# conteneur — il n'est pas authentifié par défaut.
|
# conteneur — il n'est pas authentifié par défaut.
|
||||||
EXPOSE 8090
|
EXPOSE 8090
|
||||||
|
|||||||
@@ -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
|
**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.
|
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
|
## Services externes
|
||||||
|
|
||||||
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
|
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ seule image.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌────────────────── conteneur loki ──────────────────┐
|
┌────────────────── 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) │
|
│ │ pilote (fichier PID — pas de systemd) │
|
||||||
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
│ loki serve ──exec──► llama-server (CUDA, :8080) │
|
||||||
│ ▲ modèles .gguf │
|
│ ▲ modèles .gguf │
|
||||||
@@ -35,6 +37,10 @@ seule image.
|
|||||||
redémarre le moteur normalement.
|
redémarre le moteur normalement.
|
||||||
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
|
- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ;
|
||||||
seule l'UI (8090) l'est.
|
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)
|
## 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
|
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
|
||||||
n'est envoyé qu'aux adresses Hugging Face.
|
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
|
## Installation sur Unraid
|
||||||
|
|
||||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
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**.
|
`docker-compose.unraid.yml` → **Compose Up**.
|
||||||
4. Interface : `http://<ip-unraid>:8090`.
|
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
|
## Configuration
|
||||||
|
|
||||||
Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux
|
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_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_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_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
|
**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
|
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 |
|
| 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 |
|
| 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 |
|
| 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.
|
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
|
||||||
Pour récupérer les évolutions de l'amont :
|
Pour récupérer les évolutions de l'amont :
|
||||||
|
|||||||
@@ -16,8 +16,10 @@
|
|||||||
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
|
# 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.
|
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
|
||||||
#
|
#
|
||||||
# Mise à jour : Compose Down puis Up avec « pull image », ou
|
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
|
||||||
# « docker compose pull » avant Up.
|
# 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:
|
services:
|
||||||
loki:
|
loki:
|
||||||
@@ -64,4 +66,17 @@ services:
|
|||||||
- /mnt/user/appdata/loki/data:/data
|
- /mnt/user/appdata/loki/data:/data
|
||||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||||
- /mnt/user/appdata/loki/models:/models
|
- /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
|
restart: unless-stopped
|
||||||
@@ -37,4 +37,8 @@ services:
|
|||||||
- driver: nvidia
|
- driver: nvidia
|
||||||
count: all
|
count: all
|
||||||
capabilities: ["gpu"]
|
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
|
restart: unless-stopped
|
||||||
+39
-3
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# Entrypoint du conteneur Loki : sème la configuration au premier démarrage,
|
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
|
||||||
# lance le moteur (supervision PID, sans systemd), puis l'UI au premier plan.
|
# 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
|
set -eu
|
||||||
|
|
||||||
: "${LOKI_HOME:=/data}"
|
: "${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."
|
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
|
||||||
fi
|
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
|
# 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,
|
# 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.
|
# 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"
|
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
|
||||||
fi
|
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.
|
||||||
@@ -44,20 +44,45 @@ func emitBuildLine(line string) {
|
|||||||
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
|
// 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)
|
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
|
||||||
type sinkWriter struct {
|
type sinkWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
buf []byte
|
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) {
|
func (s *sinkWriter) Write(p []byte) (int, error) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
s.buf = append(s.buf, p...)
|
s.buf = append(s.buf, p...)
|
||||||
for {
|
for {
|
||||||
i := strings.IndexByte(string(s.buf), '\n')
|
i := strings.IndexAny(string(s.buf), "\r\n")
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
break
|
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:]
|
s.buf = s.buf[i+1:]
|
||||||
}
|
}
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
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
|
// uniquePresetID derives a unique filename id from a display name, appending
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet
|
||||||
|
// ordre ; les nouveaux (non classés) ensuite, par nom.
|
||||||
|
func TestPresetOrder(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||||
|
for _, id := range []string{"alpha", "bravo", "charlie", "delta"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ids := func() []string {
|
||||||
|
list, err := ListPresets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, p := range list {
|
||||||
|
out = append(out, p.ID)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if got := ids(); got[0] != "alpha" || got[3] != "delta" {
|
||||||
|
t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got)
|
||||||
|
}
|
||||||
|
if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []string{"charlie", "alpha", "bravo", "delta"}
|
||||||
|
got := ids()
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("ordre %v, attendu %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPresetCtx(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "32K", // défaut de serve : 32768
|
||||||
|
"CTX=40000\n": "40K",
|
||||||
|
"CTX=\"131072\"\n": "128K",
|
||||||
|
"CTX=1000000\n": "1M",
|
||||||
|
"CTX=1500000\n": "1.5M",
|
||||||
|
"CTX=512\n": "512",
|
||||||
|
"CTX=abc\n": "",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := presetCtx(in); got != want {
|
||||||
|
t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -272,6 +272,20 @@ func cmdServe(args []string) error {
|
|||||||
}
|
}
|
||||||
cfg = c
|
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"]
|
bin := cfg["BIN"]
|
||||||
if bin == "" {
|
if bin == "" {
|
||||||
return fmt.Errorf("BIN non défini — lance « loki edit »")
|
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",
|
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||||
bin, filepath.Base(model), port)
|
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
|
// 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.
|
// 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
|
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
|
||||||
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
||||||
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
|
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
|
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
|
||||||
|
|
||||||
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
si.Draft = p
|
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 {
|
if g, err := ggufMeta(p); err == nil {
|
||||||
si.DraftGGUF = &g
|
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).
|
// 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.
|
// 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) {
|
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)
|
mode := specMode(cfg)
|
||||||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||||
switch mode {
|
switch mode {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package loki
|
package loki
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
|
|||||||
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
||||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
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",
|
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
|
||||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
|
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"},
|
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)
|
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
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -146,6 +146,15 @@ func tuneActive() (tuneOwner, bool) {
|
|||||||
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
||||||
var tuneHeld sync.Map
|
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) }
|
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
|
// 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)
|
key := tuneHeldKey(l.owner)
|
||||||
tuneHeld.Store(key, true)
|
tuneHeld.Store(key, true)
|
||||||
for attempt := 0; attempt < 3; attempt++ {
|
for attempt := 0; attempt < 3; attempt++ {
|
||||||
|
tuneFileMu.Lock()
|
||||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
b, _ := json.Marshal(l.owner)
|
b, _ := json.Marshal(l.owner)
|
||||||
@@ -209,11 +219,14 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
|||||||
if werr != nil || cerr != nil {
|
if werr != nil || cerr != nil {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
tuneHeld.Delete(key)
|
tuneHeld.Delete(key)
|
||||||
|
tuneFileMu.Unlock()
|
||||||
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
||||||
}
|
}
|
||||||
l.held = true
|
l.held = true
|
||||||
|
tuneFileMu.Unlock()
|
||||||
return l, nil
|
return l, nil
|
||||||
}
|
}
|
||||||
|
tuneFileMu.Unlock()
|
||||||
if !os.IsExist(err) {
|
if !os.IsExist(err) {
|
||||||
tuneHeld.Delete(key)
|
tuneHeld.Delete(key)
|
||||||
return nil, err
|
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
|
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
||||||
// lecteur ne voit jamais un JSON à moitié écrit.
|
// lecteur ne voit jamais un JSON à moitié écrit.
|
||||||
func (l *tuneLock) write() error {
|
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)
|
b, err := json.Marshal(l.owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -291,6 +311,8 @@ func (l *tuneLock) setBackup(backup, id, name string) {
|
|||||||
func (l *tuneLock) release() {
|
func (l *tuneLock) release() {
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
defer l.mu.Unlock()
|
defer l.mu.Unlock()
|
||||||
|
tuneFileMu.Lock()
|
||||||
|
defer tuneFileMu.Unlock()
|
||||||
if l.held {
|
if l.held {
|
||||||
// Un fichier absent un instant (renommé par un ménage qui le remet en
|
// 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.
|
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
|
||||||
@@ -305,7 +327,7 @@ func (l *tuneLock) release() {
|
|||||||
if ours {
|
if ours {
|
||||||
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
||||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
||||||
_ = l.write()
|
_ = l.writeLocked()
|
||||||
for i := 0; i < 5; i++ {
|
for i := 0; i < 5; i++ {
|
||||||
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
||||||
break
|
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é). 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.
|
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
|
||||||
func tuneReapStale() (mainWasActive bool) {
|
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()
|
path := tuneLockPath()
|
||||||
o, ok := readTuneOwner(path)
|
o, ok := readTuneOwner(path)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -338,14 +378,14 @@ func tuneReapStale() (mainWasActive bool) {
|
|||||||
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
||||||
_ = os.Remove(path)
|
_ = os.Remove(path)
|
||||||
}
|
}
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
if tuneOwnerAlive(o) {
|
if tuneOwnerAlive(o) {
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
||||||
if os.Rename(path, stale) != nil {
|
if os.Rename(path, stale) != nil {
|
||||||
return false
|
return tuneOwner{}, false
|
||||||
}
|
}
|
||||||
defer os.Remove(stale)
|
defer os.Remove(stale)
|
||||||
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
|
// 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 {
|
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)
|
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) {
|
return o, true
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
||||||
|
|||||||
@@ -180,6 +180,9 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
|
|||||||
if externalActive() {
|
if externalActive() {
|
||||||
return errors.New("optimisation indisponible : le preset actif est une API externe")
|
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 {
|
if err := tuneGuard(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ func cmdChat(args []string) error {
|
|||||||
case "edit":
|
case "edit":
|
||||||
icon = "✏️"
|
icon = "✏️"
|
||||||
verb = "édition"
|
verb = "édition"
|
||||||
case "web_search", "web_open", "web_read", "web_grep":
|
case "web_search", "web_images", "web_open", "web_read", "web_grep":
|
||||||
icon = "🌐"
|
icon = "🌐"
|
||||||
verb = "web"
|
verb = "web"
|
||||||
case "web_screenshot":
|
case "web_screenshot":
|
||||||
|
|||||||
@@ -354,6 +354,17 @@ func compactLog(log []LogEvent) []LogEvent {
|
|||||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||||
flush()
|
flush()
|
||||||
if done, _ := tu["done"].(bool); done {
|
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)
|
out = append(out, ev)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -945,6 +956,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
|||||||
if ev.ToolUsed.Image != "" {
|
if ev.ToolUsed.Image != "" {
|
||||||
tu["image"] = 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 ».
|
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
||||||
if ev.ToolUsed.ResultChars > 0 {
|
if ev.ToolUsed.ResultChars > 0 {
|
||||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ func cmdInternet(args []string) error {
|
|||||||
if err := setInternetEnabled(true); err != nil {
|
if err := setInternetEnabled(true); err != nil {
|
||||||
return err
|
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":
|
case "off":
|
||||||
if err := setInternetEnabled(false); err != nil {
|
if err := setInternetEnabled(false); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -352,7 +352,7 @@ func cmdInternet(args []string) error {
|
|||||||
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
||||||
if webEngine() == engineGo {
|
if webEngine() == engineGo {
|
||||||
fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
|
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
|
||||||
@@ -366,7 +366,7 @@ func cmdInternet(args []string) error {
|
|||||||
reach = green("joignable")
|
reach = green("joignable")
|
||||||
}
|
}
|
||||||
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
|
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:
|
default:
|
||||||
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
|
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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  in your answer.")
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -225,6 +225,7 @@ func cdpLaunch() (*cdpSession, error) {
|
|||||||
args = append([]string{"--headless=new"}, args...)
|
args = append([]string{"--headless=new"}, args...)
|
||||||
}
|
}
|
||||||
cmd := exec.Command(bin, args...)
|
cmd := exec.Command(bin, args...)
|
||||||
|
prepareBoundChild(cmd) // tué avec Loki, même brutalement (sys_childbind_*.go)
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
os.RemoveAll(userDir)
|
os.RemoveAll(userDir)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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)})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
// engine_cmdline.go — ce que l'interface montre du dernier lancement du moteur
|
||||||
|
// (repris d'AJEAN, #108 et #43) :
|
||||||
|
//
|
||||||
|
// /api/engine/cmdline la commande exacte lancée par `loki serve` (variables
|
||||||
|
// d'environnement posées par Loki comprises), copiable
|
||||||
|
// pour la vérifier ou la partager ; clé API masquée
|
||||||
|
// /api/model/layers le nombre de couches du modèle, lu dans l'en-tête
|
||||||
|
// GGUF : la valeur de NGL qui met tout sur le GPU
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// engineCmdlineKey : dernière commande du moteur (bkState), lue par l'UI.
|
||||||
|
const engineCmdlineKey = "engine_cmdline"
|
||||||
|
|
||||||
|
// engineCmdline met les arguments sous forme copiable dans un terminal :
|
||||||
|
// variables d'environnement en tête (KEY=VAL, triées), guillemets autour de ce
|
||||||
|
// qui contient un espace, clé API masquée.
|
||||||
|
func engineCmdline(env map[string]string, args []string) string {
|
||||||
|
quote := func(a string) string {
|
||||||
|
if a == "" || strings.ContainsAny(a, " \t\"'") {
|
||||||
|
return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"`
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
keys := make([]string, 0, len(env))
|
||||||
|
for k := range env {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
out = append(out, k+"="+quote(env[k]))
|
||||||
|
}
|
||||||
|
for i, a := range args {
|
||||||
|
if i > 0 && (args[i-1] == "--api-key" || args[i-1] == "--api-key-file") {
|
||||||
|
a = "<clé masquée>"
|
||||||
|
}
|
||||||
|
out = append(out, quote(a))
|
||||||
|
}
|
||||||
|
return strings.Join(out, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordEngineCmdline garde la commande du lancement en cours. Jamais pour un
|
||||||
|
// essai de l'optimiseur : il écraserait celle du vrai moteur.
|
||||||
|
func recordEngineCmdline(env map[string]string, args []string) {
|
||||||
|
_ = putBytes(bkState, engineCmdlineKey, []byte(engineCmdline(env, args)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleEngineCmdline (GET) : la commande du dernier lancement du moteur local.
|
||||||
|
func handleEngineCmdline(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true, "cmdline": string(getBytes(bkState, engineCmdlineKey))})
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleModelLayers (GET ?model=…, sinon MODEL de la config) : nombre de blocs
|
||||||
|
// du modèle. llama.cpp en offloade block_count + 1 (la couche de sortie en
|
||||||
|
// plus) : c'est la valeur de -ngl qui met tout le modèle sur le GPU.
|
||||||
|
func handleModelLayers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
model := strings.TrimSpace(r.URL.Query().Get("model"))
|
||||||
|
if model == "" {
|
||||||
|
model = strings.TrimSpace(ReadConfig()["MODEL"])
|
||||||
|
}
|
||||||
|
if model == "" {
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": false, "error": "aucun modèle"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path, err := resolveServeModelPath(model)
|
||||||
|
if err != nil {
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := ggufMeta(path)
|
||||||
|
if err != nil || info.BlockCount <= 0 {
|
||||||
|
msg := "nombre de couches absent de l'en-tête"
|
||||||
|
if err != nil {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": false, "error": msg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true, "layers": info.BlockCount, "ngl_max": info.BlockCount + 1})
|
||||||
|
}
|
||||||
@@ -828,6 +828,12 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben
|
|||||||
if externalActive() {
|
if externalActive() {
|
||||||
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
|
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 {
|
if err := tuneGuard(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ func EnabledTools(caps Caps) []Tool {
|
|||||||
// caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
|
// 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é.
|
// donc prompt et outils restent cohérents — pas de web_search halluciné.
|
||||||
if caps.Agent && caps.Internet {
|
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
|
// 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
|
// 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
|
// 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.
|
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
|
||||||
Image string
|
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
|
// 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.
|
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle.
|
||||||
ResultChars int
|
ResultChars int
|
||||||
@@ -1065,6 +1069,12 @@ func friendlyLLMError(err error) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func errEngineDown() 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())
|
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 {
|
func toolCallLabel(name string, args map[string]any) string {
|
||||||
label := ""
|
label := ""
|
||||||
switch name {
|
switch name {
|
||||||
case "mem_search", "web_search", "recall_search":
|
case "mem_search", "web_search", "web_images", "recall_search":
|
||||||
label, _ = args["query"].(string)
|
label, _ = args["query"].(string)
|
||||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
|
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
|
||||||
label, _ = args["file"].(string)
|
label, _ = args["file"].(string)
|
||||||
@@ -1876,7 +1886,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
|||||||
if cur := toolCalls[0]; cur != nil {
|
if cur := toolCalls[0]; cur != nil {
|
||||||
key := "command"
|
key := "command"
|
||||||
switch cur.Function.Name {
|
switch cur.Function.Name {
|
||||||
case "mem_search", "web_search", "recall_search":
|
case "mem_search", "web_search", "web_images", "recall_search":
|
||||||
key = "query"
|
key = "query"
|
||||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
||||||
key = "file"
|
key = "file"
|
||||||
@@ -2494,6 +2504,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
|||||||
}
|
}
|
||||||
case "web_search":
|
case "web_search":
|
||||||
result = capWebOutput(toolWebSearch(args))
|
result = capWebOutput(toolWebSearch(args))
|
||||||
|
case "web_images":
|
||||||
|
result = capWebOutput(toolWebImages(args))
|
||||||
case "web_open":
|
case "web_open":
|
||||||
result = capWebOutput(toolWebOpen(args))
|
result = capWebOutput(toolWebOpen(args))
|
||||||
case "web_read":
|
case "web_read":
|
||||||
@@ -2535,8 +2547,14 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
|||||||
result = "[erreur] outil inconnu: " + tc.Function.Name
|
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]") {
|
if !strings.HasPrefix(result, "[erreur]") {
|
||||||
doneCalls[callKey] = result
|
doneCalls[callKey] = result
|
||||||
|
if cuAutoShotTool(tc.Function.Name) {
|
||||||
|
liveShot = cuAutoShot()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Capture d'écran : l'image part avec l'événement pour être
|
// 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.
|
// 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" {
|
if tc.Function.Name == "web_screenshot" {
|
||||||
shot = capturedRelPath(result)
|
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
|
// Plafond pour le MODÈLE seulement : l'UI vient de recevoir le
|
||||||
// résultat complet (aperçu + « voir plus »).
|
// résultat complet (aperçu + « voir plus »).
|
||||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)}
|
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)}
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ func oaiHandler() http.Handler {
|
|||||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||||
pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())})
|
pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())})
|
||||||
pr.Out.Host = pr.In.Host
|
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) {
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) {
|
||||||
sendOAIError(w, http.StatusBadGateway,
|
sendOAIError(w, http.StatusBadGateway,
|
||||||
|
|||||||
+123
-17
@@ -259,52 +259,158 @@ func DisableMemEncryption() error {
|
|||||||
// page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption
|
// 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
|
// laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore
|
||||||
// levé, donc DisableMemEncryption peut être relancé).
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
if err := decryptChatStores(); err != nil {
|
badKV, err := decryptChatStoresSkipping()
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau,
|
bad = append(bad, badKV...)
|
||||||
// retirer le keyvault et purger la DEK.
|
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 {
|
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
removeVault()
|
removeVault()
|
||||||
clearMemDEK()
|
clearMemDEK()
|
||||||
clearMigrationJournal()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES
|
// decryptAllPagesSkipping réécrit en clair tous les fichiers encore chiffrés de
|
||||||
// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent).
|
// la mémoire (tous projets) et des images de conversation, en vérifiant chacun.
|
||||||
func decryptAllPages() error {
|
// Exige la DEK en RAM. Sûr à rejouer. Un fichier indéchiffrable est laissé tel
|
||||||
for _, p := range allMemPageFiles() {
|
// 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)
|
raw, err := os.ReadFile(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("lecture %s : %w", p, err)
|
return bad, fmt.Errorf("lecture %s : %w", p, err)
|
||||||
}
|
}
|
||||||
if !looksEncrypted(raw) {
|
if !looksEncrypted(raw) {
|
||||||
continue // déjà en clair
|
continue // déjà en clair
|
||||||
}
|
}
|
||||||
plain, err := decodeMemContent(raw)
|
plain, err := decodeMemContent(raw)
|
||||||
if err != nil {
|
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 {
|
if old, err := os.ReadFile(p); err == nil {
|
||||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||||
}
|
}
|
||||||
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
|
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)
|
back, err := os.ReadFile(p)
|
||||||
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
|
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
|
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
|
// 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
|
// 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
|
// (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
|
// encore levé, on termine proprement (écrit le clair restant), puis on
|
||||||
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
|
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
|
||||||
// et l'écran de santé signalera le mélange.
|
// 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 memEncActive() && memUnlocked() {
|
||||||
if err := decryptAllPages(); err == nil {
|
if err := finishDecryption(); err != nil {
|
||||||
_ = SetConfigKey("MEM_ENCRYPTED", "")
|
fmt.Fprintf(os.Stderr, "[mémoire] reprise du déchiffrement : %v\n", err)
|
||||||
removeVault()
|
|
||||||
clearMemDEK()
|
|
||||||
clearMigrationJournal()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package loki
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
@@ -261,3 +262,84 @@ func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
|
|||||||
t.Fatalf("pointeur chiffré non réparé : %q", got)
|
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
@@ -186,20 +186,38 @@ func reencryptChatStores() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
|
// unreadableValue : une valeur chiffrée qui ne se déchiffre pas avec la clé
|
||||||
func decryptChatStores() error {
|
// 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
|
persistQ.flush() // même raison que reencryptChatStores
|
||||||
|
var bad []unreadableValue
|
||||||
for _, b := range encryptedBuckets {
|
for _, b := range encryptedBuckets {
|
||||||
if err := decryptBucket(b); err != nil {
|
u, err := decryptBucket(b)
|
||||||
return err
|
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
|
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
|
||||||
// la DEK en RAM. Sûr à rejouer.
|
// la DEK en RAM. Sûr à rejouer. Une valeur indéchiffrable est laissée telle
|
||||||
func decryptBucket(bucket string) error {
|
// 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) {
|
for k, v := range allKV(bucket) {
|
||||||
raw := []byte(v)
|
raw := []byte(v)
|
||||||
if !looksEncrypted(raw) {
|
if !looksEncrypted(raw) {
|
||||||
@@ -207,11 +225,12 @@ func decryptBucket(bucket string) error {
|
|||||||
}
|
}
|
||||||
plain, err := decodeMemContent(raw)
|
plain, err := decodeMemContent(raw)
|
||||||
if err != nil {
|
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 {
|
if err := putBytes(bucket, k, plain); err != nil {
|
||||||
return err
|
return bad, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return bad, nil
|
||||||
}
|
}
|
||||||
@@ -472,6 +472,9 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
|
|||||||
base := lp.Director
|
base := lp.Director
|
||||||
lp.Director = func(req *http.Request) {
|
lp.Director = func(req *http.Request) {
|
||||||
base(req)
|
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
|
// 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).
|
// (l'auth réelle est faite par le relais via la clé de liaison du compte).
|
||||||
if apiKey != "" {
|
if apiKey != "" {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
@@ -32,6 +32,9 @@ WorkingDirectory=%s
|
|||||||
ExecStart=%s
|
ExecStart=%s
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=3
|
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
|
# Lancement refusé d'avance par LOAD_GUARD (le mode de chargement ne tient pas
|
||||||
# en RAM) : relancer n'y changerait rien.
|
# en RAM) : relancer n'y changerait rien.
|
||||||
RestartPreventExitStatus=78
|
RestartPreventExitStatus=78
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,16 @@ func serviceAction(action string) error {
|
|||||||
// visible que dans le journal.
|
// visible que dans le journal.
|
||||||
func preflightEngine() error {
|
func preflightEngine() error {
|
||||||
cfg := ReadConfig()
|
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"])
|
bin := strings.TrimSpace(cfg["BIN"])
|
||||||
if bin == "" {
|
if bin == "" {
|
||||||
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
|
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ func userSvcAction(action string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func userSvcStart() error {
|
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) {
|
if pid := readServicePID(); pid > 0 && processAlive(pid) {
|
||||||
fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid)
|
fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid)
|
||||||
return nil
|
return nil
|
||||||
@@ -119,25 +125,25 @@ func userCheckStarted(pid int) error {
|
|||||||
|
|
||||||
func userSvcStop(verbose bool) error {
|
func userSvcStop(verbose bool) error {
|
||||||
pid := readServicePID()
|
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())
|
_ = os.Remove(pidFilePath())
|
||||||
if verbose {
|
if verbose {
|
||||||
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
|
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// Setsid a fait de l'enfant un chef de groupe : le PID négatif vise le
|
// Setsid a fait de l'enfant un chef de session : tout ce qu'il a lancé
|
||||||
// groupe entier, donc llama-server s'arrête avec lui.
|
// (llama-server, serveur et moteur de Strata) s'arrête avec lui, et on
|
||||||
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
|
// attend qu'il soit VRAIMENT parti — la relance qui suit en a besoin pour
|
||||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
// trouver la VRAM et la RAM libres.
|
||||||
}
|
left := stopProcTree(pid, 4*time.Second, 20*time.Second)
|
||||||
for i := 0; i < 40 && processAlive(pid); i++ {
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if processAlive(pid) {
|
|
||||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
|
||||||
}
|
|
||||||
_ = os.Remove(pidFilePath())
|
_ = 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 {
|
if verbose {
|
||||||
fmt.Println(green("[ok]") + " arrêté")
|
fmt.Println(green("[ok]") + " arrêté")
|
||||||
}
|
}
|
||||||
|
|||||||
+630
-33
@@ -205,7 +205,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
|
|||||||
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
|
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
|
||||||
@media (min-width:721px){
|
@media (min-width:721px){
|
||||||
.side{width:380px}
|
.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}
|
.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)}
|
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. */
|
(étiquette comprise), plus sur la carte seule. */
|
||||||
.gated-group{margin:0 0 10px;transition:opacity .15s}
|
.gated-group{margin:0 0 10px;transition:opacity .15s}
|
||||||
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
|
.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{position:relative;display:inline-block;width:36px;height:20px;flex:none}
|
||||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
.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
|
/* 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}
|
.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}
|
.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}
|
.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
|
/* 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
|
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.
|
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. */
|
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-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}
|
.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
|
/* 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. */
|
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}
|
.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}
|
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:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
|
||||||
#chat .history-more button:disabled{opacity:.6;cursor:default}
|
#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>
|
</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>
|
<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
|
<!-- 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="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>
|
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
|
||||||
<div class="set-nav-h">Moteur</div>
|
<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="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
|
||||||
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
|
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
|
||||||
<div class="set-nav-h">Application</div>
|
<div class="set-nav-h">Application</div>
|
||||||
@@ -2669,7 +2723,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="net-block" class="gated-group">
|
<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">
|
<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>
|
<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
|
<!-- 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>
|
</div></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="set-pane" data-pane="engine" id="lc-details">
|
<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
|
<!-- 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
|
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||||
étaient des cartes dans une carte). -->
|
é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 class="lc-mode-state" id="lc-custom-state"></div>
|
||||||
</div>
|
</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" class="rowbody" style="display:none">
|
||||||
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
|
<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>
|
<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>
|
||||||
</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 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-card" style="max-width:560px">
|
||||||
<div class="modal-head">
|
<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-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>
|
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
|
||||||
</div>
|
</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
|
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
|
||||||
reste là : c'est le mode expert (dépôt privé, fichier exotique,
|
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
|
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>
|
||||||
<div class="pe-row">
|
<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-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>
|
||||||
<div class="pe-row">
|
<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>
|
<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>
|
||||||
</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 id="m-raw" class="pe-group">
|
||||||
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
||||||
<div class="pe-list">
|
<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 || ''); }
|
if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('loki.sys', d.text || ''); }
|
||||||
}catch(e){}
|
}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
|
// 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 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
|
// 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 [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
|
||||||
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'"')+'">'+String(v)+'</span></div>';
|
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'"')+'">'+String(v)+'</span></div>';
|
||||||
const rows=[];
|
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){
|
if(c.BIN){
|
||||||
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
||||||
// toujours le chemin complet, quel que soit le libellé.
|
// 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
|
// 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.
|
// source qui le connaisse quand aucun preset ne correspond à la configuration.
|
||||||
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
|
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=>{
|
['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));
|
rows.push(row(k, v));
|
||||||
});
|
});
|
||||||
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
|
// 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+)/);
|
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
|
||||||
if(m) rows.push(row('N-CPU-MOE', m[1]));
|
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 ---------------------
|
// --- 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,
|
// 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 row=document.createElement('div');
|
||||||
const pend = !x.active && pendingPreset===i+1;
|
const pend = !x.active && pendingPreset===i+1;
|
||||||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
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 info=document.createElement('div'); info.className='preset-info';
|
||||||
const nm=document.createElement('div'); nm.className='preset-name';
|
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
|
// 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';
|
q.textContent=x.quant; q.title='quantization';
|
||||||
meta.appendChild(q);
|
meta.appendChild(q);
|
||||||
}
|
}
|
||||||
if(x.reasoning){
|
if(x.ctx && !x.external){
|
||||||
const rt=document.createElement('span'); rt.className='rtag';
|
const c=document.createElement('span'); c.className='ctag';
|
||||||
rt.title='raisonnement actif ('+x.reasoning+')';
|
c.title='taille de contexte'; c.textContent=x.ctx;
|
||||||
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>';
|
meta.appendChild(c);
|
||||||
rt.appendChild(document.createTextNode(x.reasoning));
|
}
|
||||||
meta.appendChild(rt);
|
// 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){
|
if(x.bench){
|
||||||
const bt=document.createElement('span'); bt.className='btag';
|
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
|
// 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.
|
// 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.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);
|
row.appendChild(info); row.appendChild(edit);
|
||||||
cont.appendChild(row);
|
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.
|
// « 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 ».
|
// 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
|
// 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
|
// 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.
|
// 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
|
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); }
|
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 modelRow = document.getElementById('m-model-row');
|
||||||
const settingsRow = document.getElementById('m-settings-row');
|
const settingsRow = document.getElementById('m-settings-row');
|
||||||
const samplingRow = document.getElementById('m-sampling-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 rawHead = document.getElementById('m-raw-head');
|
||||||
const rawToggle = document.getElementById('m-raw-toggle');
|
const rawToggle = document.getElementById('m-raw-toggle');
|
||||||
const rawBody = document.getElementById('m-raw-body');
|
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-name').value = display;
|
||||||
document.getElementById('m-content').value = d.content || '';
|
document.getElementById('m-content').value = d.content || '';
|
||||||
if(kind === 'preset'){
|
if(kind === 'preset'){
|
||||||
|
if(sysTa) sysTa.value = d.sysprompt || '';
|
||||||
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
|
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
|
||||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||||
populateSettings();
|
populateSettings();
|
||||||
@@ -5339,6 +5554,10 @@ async function populateModelPicker(){
|
|||||||
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
|
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
|
||||||
const groups = [];
|
const groups = [];
|
||||||
for(const m of (list||[])){
|
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);
|
let g = groups.find(x => x.dir === m.dir);
|
||||||
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
|
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
|
||||||
g.items.push(m);
|
g.items.push(m);
|
||||||
@@ -5641,6 +5860,20 @@ function onPickModel(){
|
|||||||
ta.value = 'MODEL="'+val+'"\n' + ta.value;
|
ta.value = 'MODEL="'+val+'"\n' + ta.value;
|
||||||
}
|
}
|
||||||
toast('MODEL='+val);
|
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) --------------------------------
|
// --- Vision (projecteur multimodal --mmproj) --------------------------------
|
||||||
@@ -5660,6 +5893,21 @@ function onPickMmproj(){
|
|||||||
// et on centralise le choix dans la clé MMPROJ.
|
// et on centralise le choix dans la clé MMPROJ.
|
||||||
eaSetValued('--mmproj', '');
|
eaSetValued('--mmproj', '');
|
||||||
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
|
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
|
// 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à —
|
// 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; };
|
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
|
||||||
set('s-ctx', cfgReadKey('CTX'));
|
set('s-ctx', cfgReadKey('CTX'));
|
||||||
set('s-ngl', cfgReadKey('NGL'));
|
set('s-ngl', cfgReadKey('NGL'));
|
||||||
|
showNglMax(cfgReadKey('MODEL'));
|
||||||
set('s-threads', cfgReadKey('THREADS'));
|
set('s-threads', cfgReadKey('THREADS'));
|
||||||
set('s-batch', cfgReadKey('BATCH'));
|
set('s-batch', cfgReadKey('BATCH'));
|
||||||
set('s-ubatch', cfgReadKey('UBATCH'));
|
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-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||||
|
chk('s-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
|
||||||
|
syncMmprojCpuRow();
|
||||||
const lm = eaLoadMode();
|
const lm = eaLoadMode();
|
||||||
set('s-loadmode', lm);
|
set('s-loadmode', lm);
|
||||||
syncLoadModeSub(lm);
|
syncLoadModeSub(lm);
|
||||||
@@ -6384,7 +6635,10 @@ async function populateDraft(){
|
|||||||
if(!sel) return;
|
if(!sel) return;
|
||||||
const list = await jget('/api/models');
|
const list = await jget('/api/models');
|
||||||
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
|
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);
|
const hit = matchModel(cur, items);
|
||||||
let html = '<option value="">— aucun —</option>';
|
let html = '<option value="">— aucun —</option>';
|
||||||
let matched = false;
|
let matched = false;
|
||||||
@@ -6394,7 +6648,9 @@ async function populateDraft(){
|
|||||||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||||
}
|
}
|
||||||
if(cur && !matched){
|
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;
|
sel.innerHTML = html;
|
||||||
syncSpecRow();
|
syncSpecRow();
|
||||||
@@ -6448,7 +6704,7 @@ async function saveItem(){
|
|||||||
// Presets: keyed by id (filename); duplicate display names are allowed.
|
// Presets: keyed by id (filename); duplicate display names are allowed.
|
||||||
// Skills: keyed by name, rename via `old`.
|
// Skills: keyed by name, rename via `old`.
|
||||||
const payload = editingKind==='preset'
|
const payload = editingKind==='preset'
|
||||||
? {id: editingKey, name, content}
|
? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
|
||||||
: {name, old: editingKey, content};
|
: {name, old: editingKey, content};
|
||||||
const r = await jpost(K.saveUrl, payload);
|
const r = await jpost(K.saveUrl, payload);
|
||||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
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
|
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
|
||||||
// casserait les rendus suivants qui réutilisent la même entrée.
|
// casserait les rendus suivants qui réutilisent la même entrée.
|
||||||
const IMG_CACHE = new Map();
|
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){
|
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 => {
|
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||||||
if(img.dataset.hydrated) return;
|
if(img.dataset.hydrated) return;
|
||||||
img.dataset.hydrated = '1';
|
img.dataset.hydrated = '1';
|
||||||
@@ -7167,15 +7463,21 @@ function hydrateImages(root){
|
|||||||
const m = /[?&]path=([^&]*)/.exec(src);
|
const m = /[?&]path=([^&]*)/.exec(src);
|
||||||
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
||||||
}
|
}
|
||||||
bindZoom(img, name);
|
const full = src.startsWith('/') ? src : '/' + src;
|
||||||
const cached = IMG_CACHE.get(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; }
|
if(cached){ img.src = cached; return; }
|
||||||
try{
|
try{
|
||||||
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
img.src = await chatImgQueue(()=>chatImgBlob(thumb));
|
||||||
if(!r.ok) throw new Error(r.status);
|
|
||||||
const url = URL.createObjectURL(await r.blob());
|
|
||||||
IMG_CACHE.set(src, url);
|
|
||||||
img.src = url;
|
|
||||||
}catch(e){
|
}catch(e){
|
||||||
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
||||||
// tronquée en cours de frappe (404 transitoire) et le prochain delta
|
// 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'},
|
write: {lbl:'fichier', head:'écriture'},
|
||||||
edit: {lbl:'édition', head:'édition'},
|
edit: {lbl:'édition', head:'édition'},
|
||||||
web_search: {lbl:'recherche', head:'recherche web'},
|
web_search: {lbl:'recherche', head:'recherche web'},
|
||||||
|
web_images: {lbl:'images', head:"recherche d'images"},
|
||||||
web_open: {lbl:'page web', head:'ouverture'},
|
web_open: {lbl:'page web', head:'ouverture'},
|
||||||
web_read: {lbl:'page web', head:'lecture'},
|
web_read: {lbl:'page web', head:'lecture'},
|
||||||
web_grep: {lbl:'page web', head:'recherche'},
|
web_grep: {lbl:'page web', head:'recherche'},
|
||||||
@@ -7598,7 +7901,7 @@ function confirmPending(text){
|
|||||||
let REPLAYING=true;
|
let REPLAYING=true;
|
||||||
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
||||||
let T=null;
|
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();
|
newTurn();
|
||||||
|
|
||||||
// --- Temps de travail du tour ------------------------------------------------
|
// --- Temps de travail du tour ------------------------------------------------
|
||||||
@@ -7701,6 +8004,54 @@ function paintStats(el, speed){
|
|||||||
}
|
}
|
||||||
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
||||||
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
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
|
// 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
|
// 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
|
// é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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// le temps réellement passé et non continuer d'avancer.
|
||||||
T.doneTs = d.ts || T.doneTs || 0;
|
T.doneTs = d.ts || T.doneTs || 0;
|
||||||
@@ -8059,7 +8410,7 @@ function handleDelta(d){
|
|||||||
paintTurnClock(); // fige le total au-dessus de la carte
|
paintTurnClock(); // fige le total au-dessus de la carte
|
||||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
||||||
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
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.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||||
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
// 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
|
// 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
|
// 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.
|
// 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(tu.done){
|
||||||
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
||||||
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
// 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){
|
function lcChipLabel(action){
|
||||||
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
||||||
prebuilt:'Téléchargement du moteur', engine:'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){
|
function lcChipSync(j){
|
||||||
const chip = document.getElementById('lc-chip');
|
const chip = document.getElementById('lc-chip');
|
||||||
@@ -8750,10 +9102,21 @@ function lcStartPolling(){
|
|||||||
lcPollJob();
|
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){
|
async function lcPollJob(quiet){
|
||||||
|
if(lcPollInflight) return;
|
||||||
|
lcPollInflight = true;
|
||||||
|
const from = lcLogNext;
|
||||||
let j;
|
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;
|
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');
|
const phaseEl = document.getElementById('lc-job-phase');
|
||||||
if(j.lines && j.lines.length){
|
if(j.lines && j.lines.length){
|
||||||
const pre = document.getElementById('lc-log');
|
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; }
|
if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; }
|
||||||
else { pre.setAttribute('hidden',''); bar.classList.remove('open'); }
|
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
|
// 11-external.js — presets « externes » : le chat part vers une API
|
||||||
// OpenAI-compatible distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre
|
// OpenAI-compatible distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre
|
||||||
// machine…) au lieu du llama-server local.
|
// machine…) au lieu du llama-server local.
|
||||||
@@ -9456,6 +10025,18 @@ function markFileLinks(root){
|
|||||||
for(const a of root.querySelectorAll('a[href]')){
|
for(const a of root.querySelectorAll('a[href]')){
|
||||||
const p=fileLinkPath(a.getAttribute('href'));
|
const p=fileLinkPath(a.getAttribute('href'));
|
||||||
if(!p) continue;
|
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.classList.add('filelink');
|
||||||
a.removeAttribute('target');
|
a.removeAttribute('target');
|
||||||
a.setAttribute('href','#');
|
a.setAttribute('href','#');
|
||||||
@@ -10342,7 +10923,8 @@ function renderModelSwitch(presets, active){
|
|||||||
async function loadModelSwitchFiles(){
|
async function loadModelSwitchFiles(){
|
||||||
try{
|
try{
|
||||||
const r = await jget('/api/models');
|
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; }
|
}catch(_){ return; }
|
||||||
paintModelSwitch();
|
paintModelSwitch();
|
||||||
}
|
}
|
||||||
@@ -11897,6 +12479,21 @@ const I18N_EN = {
|
|||||||
'Tâches planifiées': 'Scheduled tasks',
|
'Tâches planifiées': 'Scheduled tasks',
|
||||||
'System prompt': 'System prompt',
|
'System prompt': 'System prompt',
|
||||||
'Moteur llama.cpp': 'llama.cpp engine',
|
'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',
|
'Journal du moteur': 'Engine log',
|
||||||
'Dictée': 'Dictation',
|
'Dictée': 'Dictation',
|
||||||
'Identité': 'Identity',
|
'Identité': 'Identity',
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
|
<button data-pane="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>
|
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
|
||||||
<div class="set-nav-h">Moteur</div>
|
<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="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
|
||||||
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
|
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
|
||||||
<div class="set-nav-h">Application</div>
|
<div class="set-nav-h">Application</div>
|
||||||
@@ -438,7 +438,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="net-block" class="gated-group">
|
<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">
|
<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>
|
<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
|
<!-- 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>
|
</div></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="set-pane" data-pane="engine" id="lc-details">
|
<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
|
<!-- 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
|
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||||
étaient des cartes dans une carte). -->
|
é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 class="lc-mode-state" id="lc-custom-state"></div>
|
||||||
</div>
|
</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" class="rowbody" style="display:none">
|
||||||
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
|
<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>
|
<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>
|
||||||
</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 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-card" style="max-width:560px">
|
||||||
<div class="modal-head">
|
<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-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>
|
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
|
||||||
</div>
|
</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
|
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
|
||||||
reste là : c'est le mode expert (dépôt privé, fichier exotique,
|
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
|
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>
|
||||||
<div class="pe-row">
|
<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-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>
|
||||||
<div class="pe-row">
|
<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>
|
<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>
|
||||||
</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 id="m-raw" class="pe-group">
|
||||||
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
||||||
<div class="pe-list">
|
<div class="pe-list">
|
||||||
|
|||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
|
||||||
|
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(tLine truncated
|
||||||
@@ -315,6 +315,27 @@ async function loadCfg(){
|
|||||||
const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
|
const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
|
||||||
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'"')+'">'+String(v)+'</span></div>';
|
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'"')+'">'+String(v)+'</span></div>';
|
||||||
const rows=[];
|
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){
|
if(c.BIN){
|
||||||
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
||||||
// toujours le chemin complet, quel que soit le libellé.
|
// 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
|
// 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.
|
// source qui le connaisse quand aucun preset ne correspond à la configuration.
|
||||||
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
|
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=>{
|
['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));
|
rows.push(row(k, v));
|
||||||
});
|
});
|
||||||
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
|
// 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+)/);
|
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
|
||||||
if(m) rows.push(row('N-CPU-MOE', m[1]));
|
if(m) rows.push(row('N-CPU-MOE', m[1]));
|
||||||
document.getElementById('cfg').innerHTML = rows.join('');
|
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
|
||||||
|
}
|
||||||
|
// Ligne de commande exacte du dernier lancement du moteur (amont #108),
|
||||||
|
// copiable pour la vérifier ou la partager. Clé API masquée côté serveur.
|
||||||
|
let ENGINE_CMDLINE='';
|
||||||
|
async function engineCmdlineRow(){
|
||||||
|
const cl=await jget('/api/engine/cmdline').catch(()=>null);
|
||||||
|
ENGINE_CMDLINE=(cl&&cl.cmdline)||'';
|
||||||
|
if(!ENGINE_CMDLINE) return '';
|
||||||
|
return '<div class="kv"><span>COMMANDE</span><span><button class="pe-link" onclick="copyText(ENGINE_CMDLINE, \'commande copiée\')" title="'
|
||||||
|
+escHtml(ENGINE_CMDLINE)+'">copier la commande du moteur</button></span></div>';
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,9 @@ async function loadPresets(){
|
|||||||
const row=document.createElement('div');
|
const row=document.createElement('div');
|
||||||
const pend = !x.active && pendingPreset===i+1;
|
const pend = !x.active && pendingPreset===i+1;
|
||||||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
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 info=document.createElement('div'); info.className='preset-info';
|
||||||
const nm=document.createElement('div'); nm.className='preset-name';
|
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
|
// 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';
|
q.textContent=x.quant; q.title='quantization';
|
||||||
meta.appendChild(q);
|
meta.appendChild(q);
|
||||||
}
|
}
|
||||||
if(x.reasoning){
|
if(x.ctx && !x.external){
|
||||||
const rt=document.createElement('span'); rt.className='rtag';
|
const c=document.createElement('span'); c.className='ctag';
|
||||||
rt.title='raisonnement actif ('+x.reasoning+')';
|
c.title='taille de contexte'; c.textContent=x.ctx;
|
||||||
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>';
|
meta.appendChild(c);
|
||||||
rt.appendChild(document.createTextNode(x.reasoning));
|
}
|
||||||
meta.appendChild(rt);
|
// 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){
|
if(x.bench){
|
||||||
const bt=document.createElement('span'); bt.className='btag';
|
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
|
// 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.
|
// 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.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);
|
row.appendChild(info); row.appendChild(edit);
|
||||||
cont.appendChild(row);
|
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.
|
// « 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 ».
|
// 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
|
// 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
|
// 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.
|
// 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
|
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); }
|
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||||
@@ -158,6 +158,10 @@ async function openItem(kind, key){
|
|||||||
const modelRow = document.getElementById('m-model-row');
|
const modelRow = document.getElementById('m-model-row');
|
||||||
const settingsRow = document.getElementById('m-settings-row');
|
const settingsRow = document.getElementById('m-settings-row');
|
||||||
const samplingRow = document.getElementById('m-sampling-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 rawHead = document.getElementById('m-raw-head');
|
||||||
const rawToggle = document.getElementById('m-raw-toggle');
|
const rawToggle = document.getElementById('m-raw-toggle');
|
||||||
const rawBody = document.getElementById('m-raw-body');
|
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-name').value = display;
|
||||||
document.getElementById('m-content').value = d.content || '';
|
document.getElementById('m-content').value = d.content || '';
|
||||||
if(kind === 'preset'){
|
if(kind === 'preset'){
|
||||||
|
if(sysTa) sysTa.value = d.sysprompt || '';
|
||||||
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
|
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
|
||||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||||
populateSettings();
|
populateSettings();
|
||||||
@@ -297,6 +302,10 @@ async function populateModelPicker(){
|
|||||||
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
|
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
|
||||||
const groups = [];
|
const groups = [];
|
||||||
for(const m of (list||[])){
|
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);
|
let g = groups.find(x => x.dir === m.dir);
|
||||||
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
|
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
|
||||||
g.items.push(m);
|
g.items.push(m);
|
||||||
@@ -599,6 +608,20 @@ function onPickModel(){
|
|||||||
ta.value = 'MODEL="'+val+'"\n' + ta.value;
|
ta.value = 'MODEL="'+val+'"\n' + ta.value;
|
||||||
}
|
}
|
||||||
toast('MODEL='+val);
|
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) --------------------------------
|
// --- Vision (projecteur multimodal --mmproj) --------------------------------
|
||||||
@@ -618,6 +641,21 @@ function onPickMmproj(){
|
|||||||
// et on centralise le choix dans la clé MMPROJ.
|
// et on centralise le choix dans la clé MMPROJ.
|
||||||
eaSetValued('--mmproj', '');
|
eaSetValued('--mmproj', '');
|
||||||
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
|
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
|
// 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à —
|
// 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; };
|
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
|
||||||
set('s-ctx', cfgReadKey('CTX'));
|
set('s-ctx', cfgReadKey('CTX'));
|
||||||
set('s-ngl', cfgReadKey('NGL'));
|
set('s-ngl', cfgReadKey('NGL'));
|
||||||
|
showNglMax(cfgReadKey('MODEL'));
|
||||||
set('s-threads', cfgReadKey('THREADS'));
|
set('s-threads', cfgReadKey('THREADS'));
|
||||||
set('s-batch', cfgReadKey('BATCH'));
|
set('s-batch', cfgReadKey('BATCH'));
|
||||||
set('s-ubatch', cfgReadKey('UBATCH'));
|
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-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||||
|
chk('s-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
|
||||||
|
syncMmprojCpuRow();
|
||||||
const lm = eaLoadMode();
|
const lm = eaLoadMode();
|
||||||
set('s-loadmode', lm);
|
set('s-loadmode', lm);
|
||||||
syncLoadModeSub(lm);
|
syncLoadModeSub(lm);
|
||||||
@@ -1342,7 +1383,10 @@ async function populateDraft(){
|
|||||||
if(!sel) return;
|
if(!sel) return;
|
||||||
const list = await jget('/api/models');
|
const list = await jget('/api/models');
|
||||||
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
|
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);
|
const hit = matchModel(cur, items);
|
||||||
let html = '<option value="">— aucun —</option>';
|
let html = '<option value="">— aucun —</option>';
|
||||||
let matched = false;
|
let matched = false;
|
||||||
@@ -1352,7 +1396,9 @@ async function populateDraft(){
|
|||||||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||||
}
|
}
|
||||||
if(cur && !matched){
|
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;
|
sel.innerHTML = html;
|
||||||
syncSpecRow();
|
syncSpecRow();
|
||||||
@@ -1406,7 +1452,7 @@ async function saveItem(){
|
|||||||
// Presets: keyed by id (filename); duplicate display names are allowed.
|
// Presets: keyed by id (filename); duplicate display names are allowed.
|
||||||
// Skills: keyed by name, rename via `old`.
|
// Skills: keyed by name, rename via `old`.
|
||||||
const payload = editingKind==='preset'
|
const payload = editingKind==='preset'
|
||||||
? {id: editingKey, name, content}
|
? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
|
||||||
: {name, old: editingKey, content};
|
: {name, old: editingKey, content};
|
||||||
const r = await jpost(K.saveUrl, payload);
|
const r = await jpost(K.saveUrl, payload);
|
||||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||||
|
|||||||
@@ -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
|
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
|
||||||
// casserait les rendus suivants qui réutilisent la même entrée.
|
// casserait les rendus suivants qui réutilisent la même entrée.
|
||||||
const IMG_CACHE = new Map();
|
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){
|
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 => {
|
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||||||
if(img.dataset.hydrated) return;
|
if(img.dataset.hydrated) return;
|
||||||
img.dataset.hydrated = '1';
|
img.dataset.hydrated = '1';
|
||||||
@@ -242,15 +282,21 @@ function hydrateImages(root){
|
|||||||
const m = /[?&]path=([^&]*)/.exec(src);
|
const m = /[?&]path=([^&]*)/.exec(src);
|
||||||
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
||||||
}
|
}
|
||||||
bindZoom(img, name);
|
const full = src.startsWith('/') ? src : '/' + src;
|
||||||
const cached = IMG_CACHE.get(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; }
|
if(cached){ img.src = cached; return; }
|
||||||
try{
|
try{
|
||||||
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
img.src = await chatImgQueue(()=>chatImgBlob(thumb));
|
||||||
if(!r.ok) throw new Error(r.status);
|
|
||||||
const url = URL.createObjectURL(await r.blob());
|
|
||||||
IMG_CACHE.set(src, url);
|
|
||||||
img.src = url;
|
|
||||||
}catch(e){
|
}catch(e){
|
||||||
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
||||||
// tronquée en cours de frappe (404 transitoire) et le prochain delta
|
// 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'},
|
write: {lbl:'fichier', head:'écriture'},
|
||||||
edit: {lbl:'édition', head:'édition'},
|
edit: {lbl:'édition', head:'édition'},
|
||||||
web_search: {lbl:'recherche', head:'recherche web'},
|
web_search: {lbl:'recherche', head:'recherche web'},
|
||||||
|
web_images: {lbl:'images', head:"recherche d'images"},
|
||||||
web_open: {lbl:'page web', head:'ouverture'},
|
web_open: {lbl:'page web', head:'ouverture'},
|
||||||
web_read: {lbl:'page web', head:'lecture'},
|
web_read: {lbl:'page web', head:'lecture'},
|
||||||
web_grep: {lbl:'page web', head:'recherche'},
|
web_grep: {lbl:'page web', head:'recherche'},
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function confirmPending(text){
|
|||||||
let REPLAYING=true;
|
let REPLAYING=true;
|
||||||
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
||||||
let T=null;
|
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();
|
newTurn();
|
||||||
|
|
||||||
// --- Temps de travail du tour ------------------------------------------------
|
// --- Temps de travail du tour ------------------------------------------------
|
||||||
@@ -187,6 +187,54 @@ function paintStats(el, speed){
|
|||||||
}
|
}
|
||||||
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
||||||
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
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
|
// 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
|
// 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
|
// é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
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// le temps réellement passé et non continuer d'avancer.
|
||||||
T.doneTs = d.ts || T.doneTs || 0;
|
T.doneTs = d.ts || T.doneTs || 0;
|
||||||
@@ -545,7 +593,7 @@ function handleDelta(d){
|
|||||||
paintTurnClock(); // fige le total au-dessus de la carte
|
paintTurnClock(); // fige le total au-dessus de la carte
|
||||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
||||||
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
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.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||||
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
// 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
|
// 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
|
// 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.
|
// 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(tu.done){
|
||||||
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
||||||
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ let lcSeenEnd = false, lcEndShown = false;
|
|||||||
function lcChipLabel(action){
|
function lcChipLabel(action){
|
||||||
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
||||||
prebuilt:'Téléchargement du moteur', engine:'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){
|
function lcChipSync(j){
|
||||||
const chip = document.getElementById('lc-chip');
|
const chip = document.getElementById('lc-chip');
|
||||||
@@ -453,10 +453,21 @@ function lcStartPolling(){
|
|||||||
lcPollJob();
|
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){
|
async function lcPollJob(quiet){
|
||||||
|
if(lcPollInflight) return;
|
||||||
|
lcPollInflight = true;
|
||||||
|
const from = lcLogNext;
|
||||||
let j;
|
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;
|
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');
|
const phaseEl = document.getElementById('lc-job-phase');
|
||||||
if(j.lines && j.lines.length){
|
if(j.lines && j.lines.length){
|
||||||
const pre = document.getElementById('lc-log');
|
const pre = document.getElementById('lc-log');
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -215,6 +215,18 @@ function markFileLinks(root){
|
|||||||
for(const a of root.querySelectorAll('a[href]')){
|
for(const a of root.querySelectorAll('a[href]')){
|
||||||
const p=fileLinkPath(a.getAttribute('href'));
|
const p=fileLinkPath(a.getAttribute('href'));
|
||||||
if(!p) continue;
|
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.classList.add('filelink');
|
||||||
a.removeAttribute('target');
|
a.removeAttribute('target');
|
||||||
a.setAttribute('href','#');
|
a.setAttribute('href','#');
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ function renderModelSwitch(presets, active){
|
|||||||
async function loadModelSwitchFiles(){
|
async function loadModelSwitchFiles(){
|
||||||
try{
|
try{
|
||||||
const r = await jget('/api/models');
|
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; }
|
}catch(_){ return; }
|
||||||
paintModelSwitch();
|
paintModelSwitch();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,21 @@ const I18N_EN = {
|
|||||||
'Tâches planifiées': 'Scheduled tasks',
|
'Tâches planifiées': 'Scheduled tasks',
|
||||||
'System prompt': 'System prompt',
|
'System prompt': 'System prompt',
|
||||||
'Moteur llama.cpp': 'llama.cpp engine',
|
'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',
|
'Journal du moteur': 'Engine log',
|
||||||
'Dictée': 'Dictation',
|
'Dictée': 'Dictation',
|
||||||
'Identité': 'Identity',
|
'Identité': 'Identity',
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
|
|||||||
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
|
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
|
||||||
@media (min-width:721px){
|
@media (min-width:721px){
|
||||||
.side{width:380px}
|
.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}
|
.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)}
|
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. */
|
(étiquette comprise), plus sur la carte seule. */
|
||||||
.gated-group{margin:0 0 10px;transition:opacity .15s}
|
.gated-group{margin:0 0 10px;transition:opacity .15s}
|
||||||
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
|
.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{position:relative;display:inline-block;width:36px;height:20px;flex:none}
|
||||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
.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
|
/* 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}
|
.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}
|
.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}
|
.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
|
/* 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
|
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.
|
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. */
|
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-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}
|
.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
|
/* 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. */
|
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}
|
.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}
|
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:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
|
||||||
#chat .history-more button:disabled{opacity:.6;cursor:default}
|
#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}}
|
||||||
@@ -107,9 +107,17 @@ func modelLoadErrorFrom(log string) string {
|
|||||||
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
|
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
|
||||||
start := 0
|
start := 0
|
||||||
for i, l := range lines {
|
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
|
// Un lancement refusé par LOAD_GUARD (backend_serve_moe.go) s'arrête
|
||||||
// avant tout chargement : c'est lui, la dernière tentative.
|
// 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
|
start = i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,8 +125,16 @@ func modelLoadErrorFrom(log string) string {
|
|||||||
for _, l := range lines[start:] {
|
for _, l := range lines[start:] {
|
||||||
low := strings.ToLower(l)
|
low := strings.ToLower(l)
|
||||||
switch {
|
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
|
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"),
|
case strings.Contains(low, "failed to create mtp context"),
|
||||||
strings.Contains(low, "failed to load draft model"),
|
strings.Contains(low, "failed to load draft model"),
|
||||||
strings.Contains(low, "failed to initialize speculative decoding context"):
|
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, "cudamalloc failed"),
|
||||||
strings.Contains(low, "failed to allocate"):
|
strings.Contains(low, "failed to allocate"):
|
||||||
// Prioritaire sur l'échec générique : la cause mémoire doit gagner.
|
// 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"):
|
case strings.Contains(low, "ggml_cuda error"), strings.Contains(low, "cuda error"):
|
||||||
if reason == "" {
|
if reason == "" {
|
||||||
reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"
|
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
|
// 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
|
// 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
|
// (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 != "" {
|
if m := strings.TrimSpace(cfg[extKeyModel]); m != "" {
|
||||||
item["model"] = m
|
item["model"] = m
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(cfg[extKeyVision]) == "1" {
|
||||||
|
item["vision"] = true
|
||||||
|
}
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
continue
|
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 != "" {
|
if q := detectQuant(content); q != "" {
|
||||||
item["quant"] = q
|
item["quant"] = q
|
||||||
}
|
}
|
||||||
@@ -465,6 +504,26 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 200, out)
|
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) {
|
func handlePreset(w http.ResponseWriter, r *http.Request) {
|
||||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||||
if id == "" {
|
if id == "" {
|
||||||
@@ -476,16 +535,20 @@ func handlePreset(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 404, map[string]any{"error": "not found"})
|
sendJSON(w, 404, map[string]any{"error": "not found"})
|
||||||
return
|
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
|
// presetSaveReq is the preset editor payload. `id` identifies an existing
|
||||||
// preset to update ("" creates a new one); `name` is the display name.
|
// 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 {
|
type presetSaveReq struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
DeleteModel bool `json:"deleteModel"`
|
DeleteModel bool `json:"deleteModel"`
|
||||||
|
SysPrompt *string `json:"sysprompt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveReq is the skill editor payload (skills keep name-as-identity + rename).
|
// 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()})
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
return
|
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 {
|
if applied {
|
||||||
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID)
|
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID)
|
||||||
fmt.Println(dim("[info] redémarrage du service..."))
|
fmt.Println(dim("[info] redémarrage du service..."))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,7 +53,9 @@ func capsFromBody(body chatReq) Caps {
|
|||||||
func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) {
|
func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) {
|
||||||
mu := &sync.Mutex{}
|
mu := &sync.Mutex{}
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
exited := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
|
defer close(exited)
|
||||||
// 4 s (et non 15) : borne le temps qu'un dernier bout de flux peut rester
|
// 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.
|
// coincé dans un buffer proxy (Cloudflare) faute d'octets pour le pousser.
|
||||||
t := time.NewTicker(4 * time.Second)
|
t := time.NewTicker(4 * time.Second)
|
||||||
@@ -64,6 +66,15 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
|
|||||||
return
|
return
|
||||||
case <-t.C:
|
case <-t.C:
|
||||||
mu.Lock()
|
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"))
|
_, err := w.Write([]byte(": ping\n\n"))
|
||||||
if flusher != nil {
|
if flusher != nil {
|
||||||
flusher.Flush()
|
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 :
|
// runChatStream est désormais un pur ABONNÉ au journal de la conversation serveur :
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func cmdWeb(args []string) error {
|
|||||||
port = n
|
port = n
|
||||||
}
|
}
|
||||||
mux := newWebMux()
|
mux := newWebMux()
|
||||||
addr := fmt.Sprintf("0.0.0.0:%d", port)
|
addr := webBindAddr(port)
|
||||||
|
|
||||||
ln, err := net.Listen("tcp", addr)
|
ln, err := net.Listen("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -100,6 +100,17 @@ func cmdWeb(args []string) error {
|
|||||||
return srv.Serve(ln)
|
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
|
// 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
|
// réutilisé par `loki link`, qui sert ce même mux à travers le tunnel sans
|
||||||
// repasser par un écouteur TCP local.
|
// 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/vram/reload", handleVramReload) // relance le moteur (le modèle se recharge)
|
||||||
api("/api/ram", handleRam)
|
api("/api/ram", handleRam)
|
||||||
api("/api/config", handleConfigEnv)
|
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/paths", handlePaths)
|
||||||
api("/api/update", handleUpdateCheck)
|
api("/api/update", handleUpdateCheck)
|
||||||
api("/api/update/apply", handleUpdateApply)
|
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/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/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/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", handlePresets)
|
||||||
|
api("/api/presets/order", handlePresetsOrder) // ordre de la liste (glisser-déposer)
|
||||||
api("/api/preset", handlePreset)
|
api("/api/preset", handlePreset)
|
||||||
api("/api/preset/save", handlePresetSave)
|
api("/api/preset/save", handlePresetSave)
|
||||||
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité
|
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/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/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/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/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/tool-result", handleToolResult) // résultat COMPLET d'un outil (« voir plus »)
|
||||||
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -180,6 +180,10 @@ func visionEnabled() bool {
|
|||||||
if externalActive() {
|
if externalActive() {
|
||||||
return externalVisionActive() // modèle distant déclaré multimodal
|
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"]) != ""
|
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"})
|
sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"})
|
||||||
return
|
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)
|
f, err := os.Open(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
|||||||
Generated
+670
@@ -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"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = ["loki-rs"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = "thin"
|
||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
Reference in new issue
Block a user