Compare commits

..
Author SHA1 Message Date
Claude 8305f4e24c Presets : bouton d'ajout enfin lisible, et suppression branchée
Le bouton d'ajout existait mais était introuvable : l'en-tête des
Réglages n'avait ni flex-wrap ni flex-none, donc les commandes
s'écrasaient les unes sur les autres. « + Preset » se retrouvait
compressé sur deux lignes dans 62 px, et le bouton d'enregistrement
chevauchait le texte de description.

- En-tête : les commandes ne se compriment plus (flex-none,
  whitespace-nowrap) et passent à la ligne si la largeur manque.
- Le bouton devient « + Nouveau preset », explicite, sur une ligne.
- Le preset appliqué reste sélectionné, avec un bouton de suppression :
  deletePreset existait côté API mais n'était appelé nulle part.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
2026-08-23 12:39:01 +00:00
922 changed files with 19002 additions and 438225 deletions

No files matched your search

+24 -9
View File
@@ -1,11 +1,26 @@
# Contrôle de version
.git
.github
data/
models/
docs/
dist/
*.md
!README.md
.gitignore
# Python
**/__pycache__
**/*.pyc
backend/.venv
backend/venv
# Node / build
frontend/node_modules
frontend/dist
**/.vite
frontend/tsconfig.tsbuildinfo
# Données & workspace de runtime (montés en volume)
data
workspace
# Environnement / IDE
.env
.env.*
rust/target/
.env.local
.idea
.vscode
**/.DS_Store
+24 -12
View File
@@ -1,14 +1,26 @@
# ── Loki — configuration du build/run Docker ────────────────────────────
# (fork d'AJEAN : https://github.com/nathaninline/ajean)
# ── Loki — configuration ────────────────────────────────────────────────
# Hôte Ollama. En Docker, host.docker.internal pointe vers la machine hôte.
# En local (hors Docker), utilise http://localhost:11434
OLLAMA_HOST=http://host.docker.internal:11434
# Port de l'interface web (identique dedans/dehors).
LOKI_WEB_PORT=8090
# Modèle sélectionné par défaut au démarrage
DEFAULT_MODEL=llama3.1:8b
# Optionnel — semé au PREMIER démarrage seulement (modifiable ensuite dans
# l'UI, qui garde la main) :
# Modèle initial : nom d'un .gguf présent dans ./models ou chemin complet.
LOKI_MODEL=
# Taille de contexte (défaut moteur : 32768)
LOKI_CTX=
# Couches déportées sur le GPU (défaut : 999 = tout)
LOKI_NGL=
# Dossier de travail de l'agent (monté en volume dans le conteneur)
WORKSPACE_DIR=/workspace
# Base SQLite (sessions, messages, config)
DATA_DIR=/data
# Port de l'application (identique dedans/dehors). 8717 par défaut.
PORT=8717
# Optionnel : URL d'une instance SearxNG pour l'outil web_search.
# Si vide, web_search utilise DuckDuckGo (sans clé d'API).
SEARX_URL=
# Optionnel : VRAM du GPU (en Mo) pour l'auto-réglage, si Ollama tourne sur une
# autre machine (la détection nvidia-smi ne voit pas un GPU distant).
# Ex. 12000 pour une carte 12 Go. 0 = détection automatique.
GPU_VRAM_MB=0
GPU_NAME=
-1
View File
@@ -1 +0,0 @@
*.go text eol=lf
-69
View File
@@ -1,69 +0,0 @@
# CI : build + vet + gofmt + tests sur chaque push/PR.
name: ci
on:
push:
branches: ["**"]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: gofmt
run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go build ./...
- run: go test ./...
# staticcheck : le projet est à zéro avertissement, et doit le rester.
# Les règles écartées et leur raison vivent dans staticcheck.conf.
- name: staticcheck
run: go run honnef.co/go/tools/cmd/staticcheck@latest ./...
- name: cross-compile (cibles release Linux + Windows)
run: |
# darwin n'est pas ici : depuis v0.5.4 l'icône de barre de menus passe
# par Cocoa (CGO) → les cibles macOS se compilent sur un runner Apple
# (job « macos » ci-dessous, comme dans release.yml).
for t in linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
GOOS=${t%/*} GOARCH=${t#*/} CGO_ENABLED=0 go build -o /dev/null ./cmd/loki
done
# Façade Rust (rust/loki-rs) : même exigence que le Go, zéro avertissement.
rust:
runs-on: ubuntu-latest
defaults:
run:
working-directory: rust
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --locked -- -D warnings
- run: cargo test --locked
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go vet ./...
# Les tests tournent AUSSI ici : certains chemins ne s'exécutent que sur un
# vrai système de fichiers Unix (extraction des liens symboliques des
# archives llama.cpp, que Windows résout par une copie de repli).
- run: go test ./...
- name: build macOS (CGO, arm64 + amd64)
run: |
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o /dev/null ./cmd/loki
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \
CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \
go build -o /dev/null ./cmd/loki
-68
View File
@@ -15,17 +15,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
# L'image runtime CUDA de llama.cpp (~4 Go) et Chromium/Playwright
# (~1 Go) tiennent large dans les ~14 Go libres d'un runner neuf, mais
# ces trois répertoires ne servent à rien ici et rendent une dizaine de
# gigaoctets en moins d'une minute. Gardé depuis l'époque où l'image
# compilait whisper.cpp avec CUDA : la marge reste bonne à prendre.
- name: Libérer de l'espace disque
run: |
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h /
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
@@ -54,64 +43,7 @@ jobs:
push: true
build-args: |
LOKI_VERSION=${{ github.sha }}
LOKI_BUILD=${{ github.run_number }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
# binaire de dictée) tant que ses lignes du Dockerfile ne bougent
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
# Release « rolling » : les binaires que le conteneur installe lui-même au
# démarrage (loki-rs self-update, voir docker-entrypoint.sh). Même numéro de
# run que l'image ci-dessus : c'est lui qui ordonne les versions.
# manifest.json part EN DERNIER — tant qu'il annonce l'ancien build, un
# conteneur qui démarre pendant l'envoi garde ce qu'il a ; s'il lit des
# binaires à moitié remplacés, l'empreinte ne correspond pas et il n'installe
# rien. Pré-release : `loki update` (hors conteneur, /releases/latest) ne la
# voit pas.
rolling:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
concurrency:
group: rolling-release
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-musl
- uses: Swatinem/rust-cache@v2
with:
workspaces: rust
- name: Outils musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends musl-tools
- name: Binaires
run: |
mkdir -p dist
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags "-s -w" -o dist/loki-linux ./cmd/loki
(cd rust && cargo build --release --locked --target x86_64-unknown-linux-musl)
cp rust/target/x86_64-unknown-linux-musl/release/loki-rs dist/loki-rs-linux
file dist/loki-rs-linux
(cd dist && sha256sum loki-linux loki-rs-linux > SHA256SUMS)
abi=$(tr -d '[:space:]' < IMAGE_ABI)
printf '{"build":%s,"abi":%s,"commit":"%s"}\n' \
"${{ github.run_number }}" "$abi" "$GITHUB_SHA" > dist/manifest.json
cat dist/manifest.json dist/SHA256SUMS
- name: Publier
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view rolling >/dev/null 2>&1 || \
gh release create rolling --prerelease --target "$GITHUB_SHA" \
--title "rolling (mise à jour au démarrage)" \
--notes "Binaires installés automatiquement par le conteneur à son démarrage. Ne pas télécharger à la main."
gh release upload rolling dist/loki-linux dist/loki-rs-linux dist/SHA256SUMS --clobber
gh release upload rolling dist/manifest.json --clobber
-52
View File
@@ -1,52 +0,0 @@
# Build macOS à la demande, SANS rien publier : produit Loki.app en artifact
# téléchargeable depuis l'onglet Actions (visible seulement par le dépôt).
# Sert à tester le bundle avant de décider d'une vraie release (release.yml).
name: mac-build
on:
workflow_dispatch:
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: build + bundle Loki.app (arm64)
run: |
mkdir -p dist
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \
go build -trimpath -ldflags="-s -w" -o dist/loki-macos-arm ./cmd/loki
mkdir -p /tmp/loki.iconset
sips -s format png 'cmd/loki/icon.ico' --out /tmp/icon.png >/dev/null 2>&1 || true
for s in 16 32 128 256 512; do
sips -z $s $s /tmp/icon.png --out "/tmp/loki.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true
done
iconutil -c icns /tmp/loki.iconset -o /tmp/loki.icns >/dev/null 2>&1 || true
app="dist/Loki.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp dist/loki-macos-arm "$app/Contents/MacOS/loki"
chmod +x "$app/Contents/MacOS/loki"
cp /tmp/loki.icns "$app/Contents/Resources/loki.icns" 2>/dev/null || true
printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \
'<key>CFBundleName</key><string>Loki</string>' \
'<key>CFBundleDisplayName</key><string>Loki</string>' \
'<key>CFBundleExecutable</key><string>loki</string>' \
'<key>CFBundleIdentifier</key><string>link.loki.app</string>' \
'<key>CFBundleIconFile</key><string>loki</string>' \
'<key>CFBundlePackageType</key><string>APPL</string>' \
'<key>LSMinimumSystemVersion</key><string>11.0</string>' \
'<key>LSUIElement</key><true/>' \
'<key>NSHighResolutionCapable</key><true/>' \
'</dict></plist>' > "$app/Contents/Info.plist"
codesign --force --deep -s - "$app" || true
(cd dist && zip -qry loki-macos-arm.zip Loki.app && rm -rf Loki.app)
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: loki-macos-arm
path: dist/loki-macos-arm.zip
-138
View File
@@ -1,138 +0,0 @@
# Release : sur tag vX.Y.Z, compile les 6 cibles et publie la release GitHub
# avec les binaires (loki-linux, loki-macos, loki-windows.exe et leurs
# variantes -arm) ET le fichier SHA256SUMS que `loki update` vérifie avant
# d'installer. Ces noms doivent rester en phase avec assetNameFor (sys_update.go),
# sinon plus aucune machine ne se met à jour.
# ⚠️ NE PAS uploader de binaires à la main en plus : ce workflow écrase les
# assets du même nom (softprops remplace), et des sommes qui ne correspondent
# plus aux binaires font échouer `loki update` (vécu sur v0.4.7).
#
# Deux jobs de build : macOS a besoin d'un vrai runner Apple (l'icône de barre de
# menus passe par Cocoa → CGO, pas cross-compilable depuis Linux) ; Linux et
# Windows restent CGO_ENABLED=0 sur ubuntu.
name: release
on:
push:
tags: ["v*"]
permissions:
contents: write
jobs:
macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: build macOS (CGO, arm64 + amd64)
run: |
mkdir -p dist
# arm64 = natif ; amd64 = cross avec le SDK universel (-arch x86_64).
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \
go build -trimpath -ldflags="-s -w" -o dist/loki-macos-arm ./cmd/loki
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \
CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \
go build -trimpath -ldflags="-s -w" -o dist/loki-macos ./cmd/loki
file dist/loki-macos*
- name: bundles Loki.app
run: |
# Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc
# aussi un vrai bundle Loki.app (zippé pour garder le bit exécutable) :
# au clic, le Finder lance Contents/MacOS/loki, qui détecte le bundle
# et bascule en mode « application » (UI web + navigateur + icône dans
# la barre de menus), comme le double-clic sur loki.exe sous Windows.
# LSUIElement : app de barre de menus, aucune icône dans le Dock.
# L'icône part du PNG 1024 généré avec le reste de la marque
# (cmd/loki/icon.png, voir tools/gen-icon) : plus de conversion depuis
# le .ico, dont l'échec silencieux donnait une app sans icône.
mkdir -p /tmp/loki.iconset
for s in 16 32 128 256 512; do
sips -z $s $s 'cmd/loki/icon.png' --out "/tmp/loki.iconset/icon_${s}x${s}.png" >/dev/null
done
iconutil -c icns /tmp/loki.iconset -o /tmp/loki.icns
v="${GITHUB_REF_NAME#v}"
for bin in loki-macos loki-macos-arm; do
app="dist/bundle-$bin/Loki.app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
cp "dist/$bin" "$app/Contents/MacOS/loki"
chmod +x "$app/Contents/MacOS/loki"
cp /tmp/loki.icns "$app/Contents/Resources/loki.icns" 2>/dev/null || true
printf '%s\n' \
'<?xml version="1.0" encoding="UTF-8"?>' \
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
'<plist version="1.0"><dict>' \
'<key>CFBundleName</key><string>Loki</string>' \
'<key>CFBundleDisplayName</key><string>Loki</string>' \
'<key>CFBundleExecutable</key><string>loki</string>' \
'<key>CFBundleIdentifier</key><string>link.loki.app</string>' \
'<key>CFBundleIconFile</key><string>loki</string>' \
'<key>CFBundlePackageType</key><string>APPL</string>' \
"<key>CFBundleShortVersionString</key><string>$v</string>" \
"<key>CFBundleVersion</key><string>$v</string>" \
'<key>LSMinimumSystemVersion</key><string>11.0</string>' \
'<key>LSUIElement</key><true/>' \
'<key>NSHighResolutionCapable</key><true/>' \
'</dict></plist>' > "$app/Contents/Info.plist"
# Signature ad-hoc : sans elle, macOS refuse purement et simplement
# de lancer un binaire arm64 non signé (pas juste un avertissement).
codesign --force --deep -s - "$app" || true
(cd "dist/bundle-$bin" && zip -qry "../$bin.zip" Loki.app)
rm -rf "dist/bundle-$bin"
done
ls -l dist
- uses: actions/upload-artifact@v4
with:
name: macos
path: dist/*
release:
needs: macos
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: build Linux + Windows
run: |
mkdir -p dist
# Noms d'asset : loki-<os>[-arm][.exe]. Volontairement lisibles, et
# volontairement DIFFÉRENTS du schéma loki-<GOOS>-<GOARCH> des 0.7 :
# leur mise à jour automatique ne trouve alors aucun asset et échoue
# sans rien remplacer, au lieu d'installer un binaire 0.8 sur une
# machine encore agencée en 0.7 (voir updateAssetName, sys_update.go).
for t in linux/amd64:loki-linux linux/arm64:loki-linux-arm \
windows/amd64:loki-windows.exe windows/arm64:loki-windows-arm.exe; do
target=${t%%:*}; name=${t#*:}
os=${target%/*}; arch=${target#*/}; ldflags="-s -w"
# Windows : sous-système CONSOLE, pour que `cmd` attende la fin du
# programme et que redirections, tubes et commandes interactives
# fonctionnent. La console créée au double-clic est refermée par le
# programme lui-même (voir setupConsole, sys_console_windows.go).
GOOS=$os GOARCH=$arch CGO_ENABLED=0 \
go build -trimpath -ldflags="$ldflags" -o "dist/$name" ./cmd/loki
done
- uses: actions/download-artifact@v4
with:
name: macos
path: dist
- name: SHA256SUMS
run: |
chmod +x dist/loki-macos dist/loki-macos-arm
# Tous les assets commencent par « loki- », zips compris : un seul
# motif suffit. Ajouter « *.zip » les compterait DEUX fois, et un
# SHA256SUMS à doublons est exactement ce que `loki update` vérifie.
(cd dist && sha256sum loki-* > SHA256SUMS)
ls -l dist && cat dist/SHA256SUMS
- name: publier la release
uses: softprops/action-gh-release@v2
with:
files: dist/*
# Notes rédigées à la main, versionnées avec le code : le tag emporte
# donc ses notes, au lieu de publier une liste de commits auto-générée
# qu'il fallait ensuite remplacer à la main dans l'interface GitHub.
# ⚠️ Réécrire RELEASE_NOTES.md AVANT de poser le tag.
body_path: RELEASE_NOTES.md
generate_release_notes: false
+21 -21
View File
@@ -1,27 +1,27 @@
# Binaires compilés
/loki
/loki.exe
/dist/
# Python
__pycache__/
*.pyc
.venv/
venv/
*.egg-info/
# Artefacts de build Go
*.exe
*.test
*.out
# Node
node_modules/
frontend/dist/
.vite/
# OS / éditeurs
# Data & workspace (runtime)
data/*.db
data/*.sqlite3
workspace/*
!workspace/.gitkeep
# Env / IDE
.env
.env.local
.DS_Store
Thumbs.db
.idea/
.vscode/
# Ancienne application Python/React (fork pré-Go), plus utilisée : elle traîne
# encore sur certains postes de développement et ne doit JAMAIS revenir dans le
# dépôt — un « git add -A » distrait y a déjà versé 94 Mo de node_modules.
/backend/
/frontend/
.pytest_cache/
__pycache__/
node_modules/
# Rust (façade loki-rs)
rust/target/
# TS build cache
frontend/tsconfig.tsbuildinfo
+36 -176
View File
@@ -1,187 +1,47 @@
# ── Loki — image GPU (fork d'AJEAN, https://github.com/nathaninline/ajean)
#
# Deux étapes seulement :
# 1. compilation du binaire Go `loki` (UI embarquée via go:embed) ;
# 2. runtime = l'image serveur CUDA OFFICIELLE de llama.cpp — llama-server
# y est précompilé et maintenu par l'équipe amont (base nvidia/cuda
# runtime, backends .so dans /app, architectures GPU courantes).
# Aucune compilation CUDA ici : le build complet prend quelques minutes.
#
# Épingler une version : --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda-b10423
# Variante CPU (test sans GPU) : --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server
# Pré-requis d'exécution GPU : NVIDIA Container Toolkit sur l'hôte.
# ── Étape 1 : build du frontend ─────────────────────────────────────────
FROM node:20-alpine AS frontend
WORKDIR /app/frontend
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
# ── Étape 2 : runtime backend (sert aussi le front statique) ────────────
FROM python:3.12-slim AS runtime
WORKDIR /app
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
# appartient à l'étape où il apparaît ; les « ARG SHERPA_* » nus de l'étape
# asrfetch héritent du scope GLOBAL — c'est-à-dire d'ici. Placés plus bas, ils
# hériteraient d'une valeur VIDE, et l'étape téléchargerait une URL tronquée.
# Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
#
# La version est ÉPINGLÉE, et l'empreinte avec : le binaire de dictée est
# exécuté sur la machine de l'utilisateur, il ne se prend pas « au dernier ».
ARG SHERPA_ONNX_VERSION=v1.13.7
ARG SHERPA_ONNX_SHA256=2aa3965b37aa235e56aa3ebbb94942c20a23960059a006b4442b4bf893967983
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
WORKSPACE_DIR=/workspace \
DATA_DIR=/data \
PORT=8717
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
FROM golang:1.25 AS gobuild
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ cmd/
COPY internal/ internal/
COPY tools/ tools/
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# gopls : serveur LSP Go, consommé par le vérificateur de code du mode code
# (lsp.go). Compilé ici parce que l'image finale n'a pas de toolchain Go.
# GOTOOLCHAIN=auto : gopls@latest exige régulièrement un Go plus récent que
# celui de l'image (v0.23 veut ≥ 1.26 quand l'image est en 1.25) ; en local,
# l'installation échouait net. Auto télécharge le toolchain requis — coût
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 ter : façade Rust loki-rs ───────────────────────────────────
# Premier lot de la migration Go → Rust (rust/, voir docs/superpowers/specs/).
# Alpine = musl : binaire STATIQUE, indépendant de la glibc de l'image runtime —
# le même que celui publié dans la release « rolling » et installé à chaud
# dans /data/bin par `loki-rs self-update`.
FROM rust:1-alpine AS rustbuild
RUN apk add --no-cache musl-dev gcc
WORKDIR /src
# L'UI est embarquée par build.rs depuis la même source que le go:embed : les
# deux binaires d'une image servent toujours la même interface.
COPY internal/loki/ui/ internal/loki/ui/
# Catalogue MCP embarqué par loki-rs (lot 20), le même que le Go.
COPY internal/loki/mcp_catalog.json internal/loki/mcp_catalog.json
COPY rust/ rust/
RUN cd rust && cargo build --release --locked \
&& mkdir -p /out && cp target/release/loki-rs /out/loki-rs
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
# compilation (dont une CUDA de ~200 fichiers nvcc, tuée par l'OOM du runner
# plus d'une fois) laissent place à une extraction de 35 Mo.
#
# Le build « static-no-tts » lie sherpa-onnx ET onnxruntime en statique : rien à
# fournir côté image en dehors de la libc et de libstdc++, déjà présentes. Il
# n'existe qu'en variante CPU — c'est assumé : un modèle de 0,6 B en int8 sur
# des tranches de quelques secondes n'a pas besoin de la carte, qui reste au
# moteur de chat. La variante CUDA existe mais tirerait onnxruntime-gpu et
# cuDNN dans l'image, pour un gain sans intérêt à cette taille de tranche.
#
# L'empreinte est vérifiée : le binaire s'exécute sur la machine de
# l'utilisateur, une release remplacée en amont ne doit pas passer en silence.
FROM debian:12-slim AS asrfetch
ARG SHERPA_ONNX_VERSION
ARG SHERPA_ONNX_SHA256
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates bzip2 \
# curl pour le HEALTHCHECK ; git pour le moteur code (commits Aider) ;
# nodejs/npm pour les serveurs MCP lancés via npx (Playwright, Context7…)
RUN apt-get update && apt-get install -y --no-install-recommends curl git nodejs npm \
&& rm -rf /var/lib/apt/lists/*
RUN set -eu; \
nom="sherpa-onnx-${SHERPA_ONNX_VERSION}-linux-x64-static-no-tts"; \
curl -fsSL -o /tmp/sherpa.tar.bz2 \
"https://github.com/k2-fsa/sherpa-onnx/releases/download/${SHERPA_ONNX_VERSION}/${nom}.tar.bz2"; \
echo "${SHERPA_ONNX_SHA256} /tmp/sherpa.tar.bz2" | sha256sum -c -; \
mkdir -p /out; \
tar -xjf /tmp/sherpa.tar.bz2 -C /out --strip-components=2 \
"${nom}/bin/sherpa-onnx-offline-websocket-server"; \
chmod +x /out/sherpa-onnx-offline-websocket-server; \
/out/sherpa-onnx-offline-websocket-server --help >/dev/null 2>&1 || true
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt mcp-server-fetch
# Marqueur de build (sha court), injecté par le workflow GitHub.
COPY backend/ ./backend/
# Frontend compilé servi en statique par FastAPI
COPY --from=frontend /app/frontend/dist ./backend/static
RUN mkdir -p /workspace /data
# Marqueur de version (git sha court), injecté par le workflow de build.
ARG LOKI_VERSION=dev
LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
org.opencontainers.image.source="https://github.com/R0m1k3/Loki" \
org.opencontainers.image.description="Loki — fork conteneurisé d'AJEAN (github.com/nathaninline/ajean, MIT)"
ENV LOKI_VERSION=${LOKI_VERSION}
# git pour les outils de l'agent ; nodejs/npm pour les serveurs MCP lancés
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
# et la plupart des serveurs MCP réclament Node ≥ 20.
# python3 + python3-venv : l'installeur et le serveur du moteur Strata
# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans
# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés
# à la demande depuis Paramètres → Moteur.
RUN apt-get update && apt-get install -y --no-install-recommends \
git tini ca-certificates curl gnupg python3 python3-venv \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# NB : on tourne en root par défaut pour rester compatible avec les volumes
# montés d'Unraid (/mnt/user/appdata/...), souvent détenus par root. Pour
# durcir, surcharge l'utilisateur côté compose (ex. user: "99:100").
# Serveurs LSP du mode code (lsp.go) : TypeScript/JavaScript et Python via
# npm, Go via gopls compilé à l'étape 1. Un serveur absent désactive juste son
# langage — mais autant livrer l'image complète.
RUN npm install -g --no-audit --no-fund typescript typescript-language-server pyright && npm cache clean --force
EXPOSE 8717
WORKDIR /app/backend
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD curl -fsS "http://localhost:${PORT}/api/health" || exit 1
# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
# légère — l'outil se désactive alors tout seul (le binaire absent est détecté).
ARG PLAYWRIGHT=1
ARG PLAYWRIGHT_VERSION=latest
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/pw-browsers
# Les polices sont INDISPENSABLES : sans elles, Chromium rend les pages sans
# AUCUN texte — boutons et titres sortent vides, seuls les images et les aplats
# apparaissent. Noto couvre l'essentiel des écritures, Liberation fournit les
# substituts d'Arial/Times que réclament la plupart des sites.
RUN if [ "$PLAYWRIGHT" = "1" ]; then \
npm i -g playwright@${PLAYWRIGHT_VERSION} \
&& playwright install --with-deps chromium \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
fonts-liberation fonts-dejavu-core fonts-noto-core \
fonts-noto-cjk fonts-noto-color-emoji \
&& fc-cache -f \
&& rm -rf /root/.npm /var/lib/apt/lists/* ; \
fi
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
COPY --from=rustbuild /out/loki-rs /usr/local/bin/loki-rs
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
# llama-server et ses .so vivent dans /app (convention de l'image amont).
ENV LOKI_CONTAINER=1 \
LOKI_HOME=/data \
LOKI_MODEL_DIRS=/models \
LOKI_ENGINE_BIN=/app/llama-server \
LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \
LD_LIBRARY_PATH=/app \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
# Mise à jour au démarrage (loki-rs self-update) : IMAGE_ABI dit quelles
# dépendances d'image les binaires exigent — un binaire publié pour une autre
# ABI n'est jamais installé. LOKI_BUILD = numéro de run CI de cette image : un
# binaire de /data/bin n'est pris que s'il est PLUS RÉCENT. Posés ici, en fin
# d'image, pour ne pas invalider le cache des couches lourdes à chaque build.
COPY IMAGE_ABI /etc/loki/IMAGE_ABI
ARG LOKI_BUILD=0
ENV LOKI_BUILD=${LOKI_BUILD}
# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au
# conteneur — il n'est pas authentifié par défaut.
EXPOSE 8090
# Remplace le HEALTHCHECK de l'image amont (qui vise le moteur sur :8080).
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD curl -fsS "http://localhost:${LOKI_WEB_PORT:-8090}/" >/dev/null || exit 1
WORKDIR /data
# Remplace l'ENTRYPOINT de l'image amont (/app/llama-server).
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"]
-1
View File
@@ -1 +0,0 @@
1
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Jean contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-80
View File
@@ -1,80 +0,0 @@
# Avis d'attribution
**Loki est un fork de [AJEAN](https://github.com/nathaninline/ajean)**, créé
par [nathaninline](https://github.com/nathaninline) et publié sous licence MIT.
L'essentiel du code de ce dépôt — moteur d'assistant IA en Go, interface web
embarquée, mémoire persistante, accès internet, outils MCP, accès distant
chiffré — est l'œuvre du projet AJEAN. Merci à son auteur.
La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
## Modifications apportées par le fork
- **Renommage** AJEAN → Loki (binaire `loki`, `LOKI_HOME`, `/etc/loki`,
units `loki-engine` / `loki-ui`).
- **Conteneurisation** : image Docker autonome (llama.cpp compilé CUDA +
binaire Go), `docker-compose` et variante Unraid, publication GHCR.
- **Supervision sans systemd** (`sys_service_container.go`) : en conteneur,
le moteur est piloté par fichier PID au lieu de systemctl — indispensable
pour que l'UI puisse redémarrer le moteur (changement de modèle).
- **`loki config get/set`** : lecture/écriture non interactive de la
configuration, utilisée par l'entrypoint Docker.
- **Mode code** (`internal/loki/code_*.go`, `lsp.go`, `agents/`) : agent de
code avec critères d'acceptation, passe de vérification indépendante,
outils read/grep/glob, politique d'exécution, diagnostics LSP, outils git,
jobs d'arrière-plan et relance sur appel d'outil textuel. La conception
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
patterns, table languages.json) est reprise
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
ajoutent depuis d'autres idées reprises d'OpenFox et réimplémentées ici :
les **sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans
son propre contexte), la **vérification déclenchée quand le builder a
fini** (statut `completed`, relance sur critères ouverts), le **pré-vol**
qui refuse une écriture dès son chemin (`tool_schema_order.go`), la
**réparation des noms d'outils hallucinés** (`code_alias.go`), les
**consignes du dépôt** AGENTS.md / CLAUDE.md (`code_instructions.go`),
l'**hygiène du terminal** (`chat_shell_hygiene.go`) et un **compactage**
qui garde l'état du mode Code.
## Reprises depuis l'amont après le fork
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
présent dans l'image) et la **préparation des images** envoyées au modèle
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
**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.
La **compréhension des vidéos** est reprise d'AJEAN (PR #100 de Trombo38 et
son correctif « vidéo envoyée seulement si le moteur sait la lire ») :
l'outil `see_video` et les vidéos jointes (`chat_video.go`,
`chat_video_tool.go`), lues par llama.cpp avec `--video-fps` et ffmpeg ;
ailleurs, la vidéo reste un fichier, avec la marche à suivre (ffmpeg puis
`see_image`). Adaptée à Loki : vidéo de `see_video` toujours éphémère, vidéos
jointes chiffrées comme le fil et retirées d'un fil rouvert, outil tenu aussi
par la façade Rust (`imagetools.rs`).
## 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.1 »,
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
release `moe-v1.1` (sources de Strata et binaire Linux CUDA 12 compilé, avec
les modifications d'AJEAN), vérifié par SHA-256 avec les empreintes publiées
par AJEAN. La reprise automatique d'une installation 1.0 (`strataMigrate`) est
reprise d'AJEAN (`moeMigrate`). Ni Strata ni le modèle ne sont inclus dans
l'image : ils sont téléchargés à la demande.
## Services externes
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
opéré par l'auteur d'AJEAN ; le catalogue de modèles intégré est également
servi par ajean.link. Ces services appartiennent au projet amont.
+262 -1196
View File
File diff suppressed because it is too large. Load diff
-149
View File
@@ -1,149 +0,0 @@
# Loki 0.15.0
Une version entièrement consacrée à la vitesse, avec une règle tenue de bout en
bout : **ne jamais dénaturer le modèle**. Rien ne touche à la quantification
des poids, à l'échantillonnage, à la température, au budget de raisonnement ni
au contenu du contexte. Ce qui est sans perte est actif d'office ; tout ce qui
changerait ce que voit le modèle, le placement des poids ou les slots du moteur
attend une clé. Sans clé, la requête et la ligne de commande restent celles
d'avant.
La section **Performance** du README rassemble toutes les clés, leur défaut et
leur prix.
## Actif d'office, sans perte
**La conversation n'est plus recalculée pour rien.**
- **Cache de prompts dimensionné** : le moteur garde en RAM une copie exacte des
conversations qu'il quitte. Son défaut ne tenait pas une conversation de 30 à
65 k jetons à côté d'un vérificateur ou d'un sous-agent : il l'évinçait, et
tout était recalculé (30 à 80 s sur un 27B). Il est maintenant agrandi pour un
modèle tout-GPU, et le slot est effacé après chaque travail annexe pour que la
conversation revienne du cache.
- **Préfixe stable** : les rappels de Loki entrent dans l'historique tels
qu'envoyés, la relance après un 500 ne change plus les outils, la ligne MCP ne
manque plus au premier tour après un démarrage, l'ordre des trackers ne bouge
plus. Chacun de ces écarts faisait recalculer toute la boucle d'outils.
- **Compaction moins tôt** : le raisonnement que Loki ne renvoie jamais ne
compte plus dans le contexte. La compaction (qui perd de l'information)
partait vers 44-47 k au lieu de 49 k sur une fenêtre de 65 k. Son résumé n'est
plus amputé de l'état d'avancement.
- **Hybrides (Qwen3.5/3.6) sur un moteur ancien** : `--checkpoint-min-step 2048`
d'office quand la RAM le permet, pour ne plus retomber jusqu'à ~8 k jetons en
arrière à chaque reprise.
**Le matériel est mieux employé.**
- **Threads CPU** : « auto » veut dire les cœurs physiques, plus tous les
threads logiques — c'est le décodage des experts MoE sur CPU qui payait.
- **Deux GPU** : file de lancements CUDA élargie quand le pipeline entre cartes
est possible (même calcul ; seul le prompt peut gagner).
**Loki lui-même pèse moins.**
- Écrire un gros fichier ne coûte plus un temps quadratique côté Go (jusqu'à
8 s de CPU volées au décodage sur un MoE).
- L'enregistrement de la discussion ne retarde plus le premier jeton.
- Un seul `nvidia-smi` pour tous les onglets, aucun pour un onglet caché, un
seul à l'ouverture de l'éditeur de preset.
- Unraid : un `/data` ou `/models` servi par la couche FUSE est signalé, avec
le remède.
**On peut enfin mesurer.**
- Chaque réponse dit ce qu'elle a repris du cache, recalculé et accepté du
brouillon ; `GET /api/perf/summary` en fait la synthèse par nature de requête
(`lost_events`, `lost_tokens` : le cache perdu, et après quoi).
- **Bench** honnête, en tâche de fond : mode complet à profondeur réelle, tours
qui reprennent le cache, rien d'enregistré sans timings réels.
- Une sonde du gabarit de chat dit, sans rien envoyer au modèle, si le rendu
reste stable d'un message à l'autre.
**Garde-fous.** Un cache KV quantifié, `--context-shift` ou `--cache-reuse`
posés à la main sont signalés. Un mode de chargement résident voué à l'échec
(poids restés en RAM au-delà de 90 %) est refusé avec la raison en clair —
`LOAD_GUARD=off` pour passer outre ; le refus n'est plus relancé en boucle par
systemd, et un moteur Vulkan sur cartes mixtes n'est plus refusé à tort.
## En opt-in : à essayer, à mesurer
| Clé | Ce qu'elle fait |
|---|---|
| `PREWARM=on` | prépare le prochain tour pendant que tu lis (`full` : aussi la discussion qu'on ouvre) |
| `COMPACT_CONTINUATION=on` | le résumé de compaction prolonge le prompt en cache au lieu d'être calculé à froid |
| `PROJ_SNAPSHOT=on` | bloc projet figé par discussion, changements livrés à part ; date et dossier sortent du système |
| `REASONING_ECHO=on` | renvoie au moteur local la réflexion du modèle, au format entraîné |
| `SPEC=auto` / `mtp` | décodage spéculatif MTP (tête détectée dans le fichier, garde-fous VRAM) |
| `SPEC=ngram` / `mtp+ngram` | n-grammes du contexte, utiles en mode Code |
| `SIDE_SLOT=on` | second slot pour les travaux annexes (VRAM en double) |
| `SLOT_PERSIST=on` | état du slot gardé à la bascule de preset |
| `SPLIT_MODE=tensor` | parallélisme de tenseurs entre cartes (expérimental) |
| `FIT_TARGET`, `OP_OFFLOAD_MIN_BATCH`, `CUDA_GRAPH_OPT` | réglages d'expert pour cartes inégales et MoE |
| `KEEP_TURN_IMAGES=on` | **zone grise** : garde les images des outils dans l'historique |
| `NUDGE_IN_TOOL=on` | **zone grise** : rappel de budget dans le résultat d'outil |
Et deux outils qui ne font rien d'eux-mêmes :
- **Optimiseur** (`loki tune`, bouton « Optimiser… ») : cherche lots, threads,
marges et placement sur un moteur d'essai isolé ; rien n'est écrit sans un
clic, et l'application est vérifiée puis défaite si le moteur ne tient pas.
- **« Dupliquer en placement auto… »** pour un MoE aux experts placés à la
main : une copie du preset où `--fit` les répartit.
## Ordre de test conseillé
Une étape à la fois, mesurée avant la suivante :
1. **Bench complet** du preset tel quel : la référence.
2. **`PREWARM` + `COMPACT_CONTINUATION`**, une vraie session de travail, puis
`/api/perf/summary` : le cache perdu (`lost`) doit baisser.
3. **Optimiseur** sur le preset en service.
4. **MoE** : « Dupliquer en placement auto… », bench complet des deux.
5. **Mise à jour du moteur** vers la version recommandée (encart du panneau
Moteur).
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP.
## Moteur : version recommandée et retour arrière
Quand le moteur officiel est antérieur à `b10864`, le panneau Moteur dit ce
qu'une mise à jour apporte (points de reprise des hybrides, MTP rapide) et la
propose — sur clic seulement, version vérifiée sur le registre. La version
quittée est gardée : **Revenir à la version précédente** y ramène sans réseau.
Depuis `b10763`, llama-server garde par défaut la réflexion des tours passés :
un gabarit comme Qwen3.6 la rendrait vide. Avant de basculer, Loki le détecte et
propose `REASONING_PRESERVE=off` ; après, il compare le rendu de l'ancien et du
nouveau moteur et signale le premier écart.
## Ce qui a été écarté
- **`--cache-reuse`** : réutilise du cache calculé sous un autre contexte, donc
change les sorties.
- **`--context-shift`** : retire des jetons du contexte, et ne marche pas sur
les hybrides.
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
mesurablement, pour ~2 Gio gagnés sur un 27B hybride. Reste un choix
explicite (`KV_TYPE`).
## Autres corrections
- Une compaction en plein tour ne fait plus disparaître le rappel des pages
mémoire lues.
- Un `loki tune` tué net ne laisse plus le moteur arrêté ; lancé avec un autre
`LOKI_HOME` que l'interface (`sudo`), il refuse au lieu de charger un essai à
côté d'un moteur vivant.
- L'ancienne option « n-grammes (mod) » de l'éditeur rejoint `SPEC=ngram`, avec
une migration proposée (jamais faite d'office).
- Accès refusé : l'interface dit pourquoi et comment le lever.
## Mise à jour
```
docker compose pull && docker compose up -d
```
Installation systemd (`loki install`) : l'unité du moteur gagne
`RestartPreventExitStatus=78`. `loki update` ne réécrit pas les unités ;
`sudo loki install`, ou `sudo systemctl edit loki-engine` avec cette ligne dans
`[Service]`, la met à jour.
View File
Whitespace-only changes.
+517
View File
@@ -0,0 +1,517 @@
"""Boucle agentique : tool-calling itératif au-dessus d'Ollama.
L'agent dialogue avec le modèle ; quand celui-ci demande un outil, on l'exécute,
on réinjecte le résultat, et on reboucle jusqu'à une réponse finale (ou la
limite d'itérations). La fonction est un générateur asynchrone d'événements
relayés tels quels au client via SSE.
Événements émis :
token {content} — fragment de texte de l'agent
tool_call {name, args} — début d'exécution d'un outil
tool_result {name, args, summary, status}
final {content, tools} — réponse complète + récap des outils
"""
from __future__ import annotations
import asyncio
import json
import re
from typing import AsyncIterator
import httpx
from . import coder
from .ollama_client import OllamaError, ollama
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
MAX_ITERATIONS = 6
MAX_TOOL_REPAIR_ATTEMPTS = 2
# Coupe-circuit de réflexion : au-delà de cette taille de pensée SANS aucun
# contenu ni appel d'outil, on interrompt la génération en cours au lieu
# d'attendre l'épuisement du budget num_predict (minutes sur un gros modèle).
_MAX_THINKING_CHARS = 12_000
# Élagage : au-delà de cette taille, un résultat d'outil des itérations
# passées est tronqué. Les gros payloads (MCP 8 Ko, shell 4 Ko) saturaient le
# contexte en un seul tour long — Ollama tronquait alors silencieusement le
# DÉBUT de la conversation, faisant « oublier » la consigne au modèle.
_PRUNE_KEEP_CHARS = 350
def _prune_old_tool_results(convo: list[dict], before_index: int) -> None:
"""Compacte les résultats d'outils déjà consommés par le modèle.
Seuls les messages ``tool`` antérieurs à ``before_index`` (donc traités
lors d'une itération précédente) sont tronqués ; le dernier lot reste
intact, c'est celui auquel le modèle répond.
"""
for msg in convo[:before_index]:
content = msg.get("content", "")
if msg.get("role") == "tool" and len(content) > _PRUNE_KEEP_CHARS:
msg["content"] = (
content[:_PRUNE_KEEP_CHARS]
+ "… [résultat archivé — déjà traité, ne pas redemander]"
)
def _tools_not_supported(exc: OllamaError) -> bool:
"""Détecte un modèle incapable de function calling.
Deux cas :
- Ollama refuse explicitement (« does not support tools ») ;
- Ollama n'arrive pas à dériver un parseur d'appels d'outils du template
du modèle (« Unable to generate parser for this template ») — fréquent
sur des modèles exotiques dont le template Jinja lève une exception.
Dans les deux cas, on retombe sur une conversation simple, sans outils.
"""
message = str(exc).lower()
return (
"does not support tools" in message
or "does not support tool" in message
or "unable to generate parser for this template" in message
or "automatic parser generation failed" in message
)
def _thinking_not_supported(exc: OllamaError) -> bool:
"""Le modèle (ou son template) refuse le paramètre ``think``."""
message = str(exc).lower()
return "does not support thinking" in message or "thinking is not supported" in message
def _invalid_tool_arguments(exc: OllamaError) -> bool:
message = str(exc).lower()
return (
"invalid tool call arguments" in message
or "unexpected end of json" in message
or "failed to parse tool" in message
)
def _parse_args(raw) -> dict:
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
return {}
# Marqueur d'avancement du plan émis par le modèle (« ✅ Étape 2 terminée »).
# Tolérant : coche/croix optionnelle, mot « étape » optionnel, numéro requis.
_STEP_DONE = re.compile(
r"(?:✅|✔|☑|\[x\])\s*(?:étape|etape|step)?\s*(\d{1,2})"
r"|(?:étape|etape|step)\s*(\d{1,2})\s*(?:terminée|terminee|faite|ok|✅|✔)",
re.I,
)
def _scan_plan_done(text: str, plan_len: int, already: set[int]) -> list[int]:
"""Indices (0-based) de nouvelles étapes annoncées terminées dans ``text``."""
fresh: list[int] = []
for m in _STEP_DONE.finditer(text):
num = m.group(1) or m.group(2)
idx = int(num) - 1
if 0 <= idx < plan_len and idx not in already:
already.add(idx)
fresh.append(idx)
return fresh
async def run_agent(
model: str,
convo: list[dict],
*,
options: dict | None = None,
enabled_tools: list[str] | None = None,
confirm_shell: bool = True,
think: bool = True,
keep_alive: str | None = None,
mcp_tools: list[dict] | None = None,
plan: list[str] | None = None,
) -> AsyncIterator[dict]:
# enabled_tools=None -> tous les outils ; liste vide -> aucun outil.
if enabled_tools is None:
tools = list(TOOL_DEFINITIONS)
elif enabled_tools:
tools = [
t for t in TOOL_DEFINITIONS if t["function"]["name"] in enabled_tools
]
else:
tools = []
# Outils MCP des serveurs activés (déjà résolus par l'appelant).
tools.extend(mcp_tools or [])
if not tools:
tools = None
collected: list[dict] = []
text_parts: list[str] = []
thinking_parts: list[str] = []
active_tools = tools
tool_fallback_used = False
tool_repair_attempts = 0
request_options = dict(options or {})
# On n'envoie ``think`` que pour le DÉSACTIVER (False) ; laissé à None, le
# modèle garde son comportement par défaut. Repli si le modèle le refuse.
request_think: bool | None = None if think else False
# Mode réflexion : la pensée consomme le budget num_predict. Sans marge,
# le modèle « pense » tout son quota et s'arrête sans agir ni répondre.
if request_think is None and think:
request_options["num_predict"] = max(
int(request_options.get("num_predict") or 0), 6144
)
# Compteur d'itérations où le modèle n'a produit QUE du raisonnement.
thinking_only_strikes = 0
# Suivi de l'avancement du plan : étapes déjà annoncées terminées.
plan_len = len(plan or [])
plan_done: set[int] = set()
# Métriques cumulées sur tous les appels Ollama du tour agentique : Ollama
# les renvoie dans le chunk final (done=true) de chaque génération.
stats = {"eval_count": 0, "eval_duration": 0, "prompt_eval_count": 0}
def _accumulate(chunk: dict) -> None:
stats["eval_count"] += chunk.get("eval_count") or 0
stats["eval_duration"] += chunk.get("eval_duration") or 0
stats["prompt_eval_count"] += chunk.get("prompt_eval_count") or 0
# Index du début du dernier lot de résultats d'outils (à préserver).
last_batch_start = 0
try:
for _ in range(MAX_ITERATIONS):
content_buf = ""
thinking_buf = ""
thinking_status_sent = False
tool_calls: list[dict] = []
# Compacte les résultats d'outils des itérations antérieures :
# garde le contexte court, la consigne système jamais tronquée.
if last_batch_start:
_prune_old_tool_results(convo, last_batch_start)
# Un modèle peut savoir discuter sans supporter les outils. Ollama
# refuse alors la requête entière : on retente une fois en chat simple.
while True:
try:
async for chunk in ollama.chat(
model,
convo,
tools=active_tools,
options=request_options,
think=request_think,
keep_alive=keep_alive,
stream=True,
):
msg = chunk.get("message", {})
token = msg.get("content", "")
thinking = msg.get("thinking", "")
if thinking:
thinking_buf += thinking
# Diffuse le raisonnement en direct pour l'afficher
# dans le panneau repliable du chat.
yield {"type": "thinking", "content": thinking}
if not thinking_status_sent:
thinking_status_sent = True
yield {"type": "status", "message": "Réflexion…"}
# Pensée interminable sans production : on coupe la
# génération maintenant — la relance (plus bas)
# remet le modèle au travail immédiatement.
if (
len(thinking_buf) > _MAX_THINKING_CHARS
and not content_buf
and not tool_calls
):
break
if token:
content_buf += token
yield {"type": "token", "content": token}
# Coche les étapes du plan annoncées terminées, en
# direct. Scan borné : uniquement quand le token
# porte un marqueur plausible.
if plan_len and any(
c in token for c in ("✅", "✔", "☑", "tape", "step", "]")
):
for idx in _scan_plan_done(content_buf, plan_len, plan_done):
yield {"type": "plan_step", "index": idx, "status": "done"}
if msg.get("tool_calls"):
tool_calls.extend(msg["tool_calls"])
if chunk.get("done"):
_accumulate(chunk)
break
break
except OllamaError as exc:
if (
active_tools
and tool_repair_attempts < MAX_TOOL_REPAIR_ATTEMPTS
and not content_buf
and not tool_calls
and _invalid_tool_arguments(exc)
):
tool_repair_attempts += 1
request_options["num_predict"] = max(
int(request_options.get("num_predict", 0)), 4096
)
thinking_buf = ""
# NB : on réinjecte ce rappel en `user`, pas en `system`.
# Beaucoup de templates (Gemma, Mistral…) lèvent
# « System message must be at the beginning » dès qu'un
# message system apparaît ailleurs qu'en tête, ce qui
# ferait échouer toute la requête en 400.
convo.append(
{
"role": "user",
"content": (
"L'appel d'outil précédent contenait un JSON "
"tronqué. Réessaie immédiatement avec des arguments "
"JSON valides. Pour un fichier long, utilise write_file "
"en plusieurs appels : overwrite puis append, avec des "
"morceaux courts et complets."
),
}
)
yield {
"type": "notice",
"message": (
"Appel d'outil tronqué : nouvelle tentative "
f"{tool_repair_attempts}/{MAX_TOOL_REPAIR_ATTEMPTS}."
),
}
continue
if (
active_tools
and not tool_fallback_used
and not content_buf
and not tool_calls
and _tools_not_supported(exc)
):
active_tools = None
tool_fallback_used = True
yield {
"type": "notice",
"message": (
"Ce modèle ne supporte pas les outils ; "
"réponse en mode conversation simple."
),
}
continue
if request_think is not None and _thinking_not_supported(exc):
# Le modèle n'accepte pas qu'on désactive sa réflexion :
# on retire le paramètre et on relance.
request_think = None
continue
raise
# Itération « réflexion seule » : ni contenu, ni appel d'outil —
# le modèle a brûlé sa génération à penser. Sans relance, la
# boucle s'arrêtait là et la tâche restait inachevée.
if thinking_buf and not content_buf.strip() and not tool_calls:
thinking_parts.append(thinking_buf)
thinking_only_strikes += 1
if thinking_only_strikes == 1:
convo.append({
"role": "user",
"content": (
"Tu n'as produit que du raisonnement, sans réponse "
"ni action. Continue la tâche MAINTENANT : appelle "
"l'outil suivant ou donne ta réponse finale, sans "
"réfléchir davantage."
),
})
yield {"type": "status", "message": "Relance après réflexion…"}
continue
# Deuxième fois : la réflexion est coupée pour finir la tâche.
request_think = False
yield {
"type": "notice",
"message": (
"Réflexion désactivée pour ce tour : le modèle "
"n'avançait plus."
),
}
continue
assistant_turn: dict = {"role": "assistant", "content": content_buf}
if thinking_buf:
# Gardée pour l'affichage (panneau repliable), mais JAMAIS
# renvoyée au modèle : la re-soumettre gonflait le contexte à
# chaque itération des longues tâches.
thinking_parts.append(thinking_buf)
if tool_calls:
assistant_turn["tool_calls"] = tool_calls
convo.append(assistant_turn)
if content_buf.strip():
text_parts.append(content_buf.strip())
if not tool_calls:
break
# Les résultats du lot qui suit commencent ici : ils restent
# intacts au prochain tour, les précédents seront compactés.
last_batch_start = len(convo)
# Exécution des outils demandés, puis réinjection des résultats.
awaiting_confirmation = False
for tc in tool_calls:
fn = tc.get("function", {})
name = fn.get("name", "")
args = _parse_args(fn.get("arguments"))
yield {"type": "tool_call", "name": name, "args": args}
# run_shell est sensible : on demande validation au lieu d'exécuter.
if name == "run_shell" and confirm_shell:
command = args.get("command", "")
record = {
"name": name,
"args": args,
"summary": "validation requise",
"status": "pending",
}
collected.append(record)
yield {"type": "tool_confirm", "name": name, "command": command}
convo.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(
{
"ok": False,
"status": "pending",
"message": "Commande en attente de validation "
"de l'utilisateur. N'exécute rien d'autre.",
},
ensure_ascii=False,
),
}
)
awaiting_confirmation = True
continue
# code_task : délégué au moteur code (Aider), long -> thread.
if name == "code_task":
from .tools import active_root
code_model = await coder.pick_code_model(model)
result = await asyncio.to_thread(
coder.run_code_task,
args.get("instruction", ""),
code_model,
args.get("files") or [],
active_root(),
)
summary = result.get("summary", "terminé")
status = "ok" if result.get("ok") else "error"
record = {"name": name, "args": {"instruction": args.get("instruction", "")},
"summary": summary, "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append({
"role": "tool",
"tool_name": name,
"content": json.dumps(
{k: result.get(k) for k in ("ok", "summary", "files", "text")},
ensure_ascii=False,
),
})
continue
# Outils MCP : dispatch asynchrone vers le serveur concerné.
if name.startswith("mcp_"):
from .mcp_client import manager as mcp_manager
mcp_res = await mcp_manager.call_tool(name, args)
status = "ok" if mcp_res["ok"] else "error"
record = {"name": name, "args": args,
"summary": mcp_res["summary"], "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append({
"role": "tool",
"tool_name": name,
"content": json.dumps(
{"ok": mcp_res["ok"], "content": mcp_res["content"]},
ensure_ascii=False,
),
})
continue
try:
result = run_tool(name, args)
summary = result.get("summary", "terminé")
status = result.get("_status", "ok")
except ToolError as exc:
result = {"ok": False, "error": str(exc)}
summary = str(exc)
status = "error"
record = {"name": name, "args": args, "summary": summary, "status": status}
collected.append(record)
yield {"type": "tool_result", **record}
convo.append(
{
"role": "tool",
"tool_name": name,
"content": json.dumps(result, ensure_ascii=False),
}
)
# Une commande shell attend une validation : on interrompt la boucle.
if awaiting_confirmation:
# Laisse le modèle conclure son tour (message d'attente).
final_chunk = ""
async for chunk in ollama.chat(
model, convo, options=request_options,
think=request_think, keep_alive=keep_alive, stream=True
):
tok = chunk.get("message", {}).get("content", "")
if tok:
final_chunk += tok
yield {"type": "token", "content": tok}
if chunk.get("done"):
_accumulate(chunk)
break
if final_chunk.strip():
text_parts.append(final_chunk.strip())
break
except OllamaError as exc:
# Échec signalé par Ollama (HTTP ou ligne d'erreur dans le flux) :
# souvent un débordement mémoire / contexte trop grand. On l'expose.
yield {"type": "error", "message": f"Ollama : {exc}"}
return
except (httpx.HTTPError, OSError) as exc:
yield {
"type": "error",
"message": f"Impossible de joindre Ollama ({ollama.host}) : {exc}",
}
return
final_content = "\n\n".join(text_parts).strip()
if not final_content and not collected:
yield {
"type": "error",
"message": (
"Le modèle a terminé sans renvoyer de texte (il n'a produit que "
"du raisonnement). Désactive « Mode réflexion » dans les Réglages, "
"ou essaie un modèle de chat plus récent."
),
}
return
eval_secs = stats["eval_duration"] / 1e9
final_stats = {
"eval_count": stats["eval_count"],
"prompt_eval_count": stats["prompt_eval_count"],
"tokens_per_sec": (
round(stats["eval_count"] / eval_secs, 1) if eval_secs > 0 else None
),
}
yield {
"type": "final",
"content": final_content,
"tools": collected,
"stats": final_stats,
"thinking": "\n\n".join(thinking_parts).strip(),
}
+309
View File
@@ -0,0 +1,309 @@
"""Profil de configuration de l'agent : invite système, génération, outils.
Persisté en base sous la clé `agent`. Fournit les valeurs par défaut et la
fusion avec ce qui est stocké, pour rester robuste aux montées de version.
"""
from __future__ import annotations
import re
from . import db
CONFIG_KEY = "agent"
MODEL_PROFILES_KEY = "model_profiles"
PROFILE_STATE_KEY = "model_profiles_state"
PROFILE_VERSION = 7
DEFAULT_SYSTEM_PROMPT = (
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
"d'outils pour lire, écrire et lister des fichiers dans le workspace, et "
"d'un moteur code (code_task) pour toute création ou modification de code "
"multi-fichiers : privilégie code_task pour les tâches de programmation. "
"Utilise les outils pour accomplir les tâches concrètement, puis réponds "
"de façon concise en français. Pour MODIFIER un fichier existant, utilise "
"edit_file (search/replace) plutôt que de tout réécrire : lis d'abord le "
"fichier avec read_file, puis copie dans `search` l'extrait EXACT à changer "
"(quelques lignes suffisent, l'indentation est tolérée). N'emploie "
"write_file en overwrite QUE pour créer un nouveau fichier ou en cas de "
"refonte complète. Quand tu appelles write_file, fournis toujours `path` et "
"`content` ; pour un fichier long, appelle write_file en plusieurs morceaux "
"(overwrite puis append) afin de toujours produire un JSON valide. Tu ne "
"peux écrire QUE dans le workspace : jamais de chemin absolu ni de `../` "
"qui en sortent. Après avoir écrit un fichier, propose un aperçu. "
"Formate TOUJOURS tes réponses en Markdown : titres, listes, gras pour les "
"points clés, tableaux si pertinent, et surtout des blocs de code avec le "
"langage indiqué (```python, ```html…) pour tout extrait de code ou commande."
)
# Outils disponibles. Les sensibles (web_search, run_shell) sont désactivés
# par défaut, conformément à la maquette.
AVAILABLE_TOOLS = [
"read_file", "write_file", "edit_file", "list_dir", "grep_search",
"run_check", "code_task", "web_search", "run_shell",
]
SENSITIVE_TOOLS = {"run_shell"}
DEFAULT_TOOL_STATE = {
"read_file": True,
"write_file": True,
"edit_file": True,
"list_dir": True,
"grep_search": True,
"run_check": True,
"code_task": True,
"web_search": False,
"run_shell": False,
}
GENERATION_FIELDS = {
"temperature",
"top_p",
"top_k",
"max_tokens",
"num_ctx",
"num_gpu",
"num_batch",
}
PROFILE_FIELDS = {
"system_prompt",
"tools",
"confirm_shell",
"think",
"code_model",
"plan_mode",
"self_review",
"rag_enabled",
"embed_model",
"memory_mode",
"skills_enabled",
"ponytail",
"keep_alive",
*GENERATION_FIELDS,
}
DEFAULT_GENERATION: dict = {
"temperature": 0.7,
"top_p": 0.9,
"top_k": 40,
"max_tokens": 2048,
# 16k : les tâches longues (recherche, multi-fichiers) saturaient 4-8k en
# un seul tour et Ollama tronquait la consigne. Le cache KV d'un 16k reste
# raisonnable (~1-3 Go selon modèle) ; réduis num_ctx dans Configuration
# si la VRAM déborde, ou active OLLAMA_KV_CACHE_TYPE=q8_0 côté Ollama.
"num_ctx": 16384,
"num_gpu": -1,
"num_batch": 256,
}
RTX_3060_GEMMA4_PROFILE: dict = {
**DEFAULT_GENERATION,
"max_tokens": 4096,
"num_ctx": 16384,
# num_gpu = -1 : laisse Ollama placer le plus de couches possible sur le GPU
# (auto-fit, comme `ollama run`). Forcer un nombre de couches qui ne tient pas
# en VRAM fait basculer toute l'inférence sur le CPU.
"num_gpu": -1,
}
DEFAULT_CONFIG: dict = {
"system_prompt": DEFAULT_SYSTEM_PROMPT,
**DEFAULT_GENERATION,
"tools": dict(DEFAULT_TOOL_STATE),
# Demander une validation utilisateur avant toute commande shell.
"confirm_shell": True,
# Mode réflexion des modèles « thinking ». Désactiver (False) évite qu'un
# modèle ne renvoie que du raisonnement sans réponse finale.
"think": True,
# Modèle utilisé par le moteur code : "auto" = meilleur modèle code installé
# (qwen-coder, deepseek-coder…), sinon le modèle de chat courant.
"code_model": "auto",
# Plan-puis-exécute : décompose les demandes complexes en étapes.
"plan_mode": True,
# Auto-critique : une passe de relecture/révision avant la réponse finale.
"self_review": False,
# Mémoire long-terme (RAG) ENTRE sessions, via un modèle d'embedding.
# Désactivée par défaut : chaque discussion ne se souvient que d'elle-même
# (résumé + messages récents). Sinon une ancienne demande sans rapport (ex.
# « appli sport ») ressurgit dans une nouvelle discussion (ex. « jeu
# d'échecs ») et embrouille les petits modèles. Réactivable dans Réglages.
"rag_enabled": False,
"embed_model": "auto",
# Mémoire en notes Markdown écrites par l'agent (voir memory_notes.py).
# "ondemand" : les outils existent, mais rien n'est injecté sans que le
# modèle le demande — aucun risque de voir ressurgir une demande sans
# rapport, contrairement à l'ancien RAG vectoriel.
"memory_mode": "ondemand",
# Skills : méthodes expertes injectées automatiquement selon la tâche.
"skills_enabled": True,
# Ponytail : méthode « code minimal » (anti sur-ingénierie) injectée pour
# toute tâche de code. Voir github.com/DietrichGebert/ponytail.
"ponytail": True,
# Durée de maintien du modèle en VRAM (préchargement). "0" = décharge
# aussitôt, "-1" = jamais, "30m" = 30 minutes.
"keep_alive": "30m",
}
def _default_generation(model: str | None) -> dict:
if model and model.split(":", 1)[0].lower() == "gemma4":
return dict(RTX_3060_GEMMA4_PROFILE)
return dict(DEFAULT_GENERATION)
def _migrate_profiles() -> None:
state = db.get_config_value(PROFILE_STATE_KEY) or {}
if state.get("version", 0) >= PROFILE_VERSION:
return
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
gemma_profile = {
**RTX_3060_GEMMA4_PROFILE,
**profiles.get("gemma4:12b", {}),
}
if gemma_profile.get("max_tokens", 0) <= 2048:
gemma_profile["max_tokens"] = 4096
profiles["gemma4:12b"] = gemma_profile
# v4 : un ancien profil pouvait forcer num_gpu sur un nombre de couches codé
# en dur (ex. 49), ce qui basculait l'inférence sur le CPU quand ça ne tenait
# pas en VRAM. On repasse en auto (-1) pour laisser Ollama placer les couches.
for prof in profiles.values():
if prof.get("num_gpu", -1) is not None and prof.get("num_gpu", -1) > 0:
prof["num_gpu"] = -1
# v6 : contexte 16k par défaut. On ne touche qu'aux profils restés sur un
# ancien défaut (4096/8192) — une valeur personnalisée est respectée.
for prof in profiles.values():
if prof.get("num_ctx") in (4096, 8192):
prof["num_ctx"] = 16384
db.set_config_value(MODEL_PROFILES_KEY, profiles)
# v7 : la mémoire inter-sessions (RAG) faisait ressurgir d'anciennes
# demandes sans rapport dans une nouvelle discussion. On la désactive une
# fois sur les installs existantes ; réactivable manuellement dans Réglages.
stored = db.get_config_value(CONFIG_KEY)
if stored and stored.get("rag_enabled"):
stored["rag_enabled"] = False
db.set_config_value(CONFIG_KEY, stored)
db.set_config_value(PROFILE_STATE_KEY, {"version": PROFILE_VERSION})
def _clean_tools(value: dict | None, fallback: dict | None = None) -> dict:
fallback = fallback or DEFAULT_TOOL_STATE
return {
name: bool((value or {}).get(name, fallback.get(name, DEFAULT_TOOL_STATE[name])))
for name in AVAILABLE_TOOLS
}
def get_config(model: str | None = None) -> dict:
"""Configuration complète, avec surcharge sauvegardée par modèle."""
_migrate_profiles()
stored = db.get_config_value(CONFIG_KEY) or {}
cfg = {**DEFAULT_CONFIG, **stored}
cfg["tools"] = _clean_tools(stored.get("tools"))
if model:
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
profile = profiles.get(model, {})
cfg.update({**_default_generation(model), **profile})
cfg["tools"] = _clean_tools(profile.get("tools"), cfg["tools"])
return cfg
def save_config(patch: dict, model: str | None = None) -> dict:
"""Sauvegarde tous les réglages, globalement ou pour un modèle."""
clean = {k: v for k, v in patch.items() if v is not None}
cfg = {**get_config(model), **clean}
if "tools" in patch and patch["tools"]:
cfg["tools"] = _clean_tools(patch["tools"], cfg["tools"])
if model:
profiles = db.get_config_value(MODEL_PROFILES_KEY) or {}
profiles[model] = {field: cfg[field] for field in PROFILE_FIELDS}
db.set_config_value(MODEL_PROFILES_KEY, profiles)
else:
db.set_config_value(CONFIG_KEY, {field: cfg[field] for field in PROFILE_FIELDS})
return get_config(model)
def runner_options(cfg: dict) -> dict:
"""Sous-ensemble d'options qui détermine l'identité du runner Ollama.
Ollama choisit son runner (processus de chargement du modèle) d'après
num_ctx / num_batch / num_gpu. TOUT appel au même modèle (plan, résumé,
agent…) doit envoyer ces mêmes valeurs, sinon Ollama recharge le modèle en
plein milieu d'un message — la cause principale des lenteurs observées.
"""
opts: dict = {"num_batch": cfg["num_batch"]}
# num_gpu n'est transmis que si l'utilisateur force explicitement un nombre
# de couches (≥ 0). En -1 (défaut), on laisse Ollama auto-ajuster l'offload
# GPU comme `ollama run` ; lui imposer une valeur peut le forcer sur le CPU.
if cfg.get("num_gpu", -1) >= 0:
opts["num_gpu"] = cfg["num_gpu"]
opts["main_gpu"] = 0
# num_ctx n'est envoyé que s'il est défini (> 0), sinon défaut du modèle.
if cfg.get("num_ctx"):
opts["num_ctx"] = cfg["num_ctx"]
return opts
def ollama_options(cfg: dict) -> dict:
"""Traduit la config en options de génération Ollama."""
return {
**runner_options(cfg),
"temperature": cfg["temperature"],
"top_p": cfg["top_p"],
"top_k": cfg["top_k"],
"num_predict": cfg["max_tokens"],
}
# ── Presets : jeux de réglages nommés ────────────────────────────────────
PRESETS_KEY = "config_presets"
_PRESET_NAME = re.compile(r"^[\w \-]{1,40}$")
def list_presets() -> list[str]:
return sorted((db.get_config_value(PRESETS_KEY) or {}).keys())
def save_preset(name: str, model: str | None = None) -> list[str]:
"""Fige la configuration courante sous ce nom (écrase si déjà pris)."""
name = (name or "").strip()
if not _PRESET_NAME.match(name):
raise ValueError("nom de preset invalide (lettres, chiffres, espaces, -)")
presets = db.get_config_value(PRESETS_KEY) or {}
cfg = get_config(model)
presets[name] = {field: cfg[field] for field in PROFILE_FIELDS if field in cfg}
db.set_config_value(PRESETS_KEY, presets)
return sorted(presets)
def apply_preset(name: str, model: str | None = None) -> dict | None:
"""Applique un preset ; None s'il n'existe pas."""
preset = (db.get_config_value(PRESETS_KEY) or {}).get((name or "").strip())
if preset is None:
return None
return save_config(dict(preset), model)
def delete_preset(name: str) -> list[str]:
presets = db.get_config_value(PRESETS_KEY) or {}
presets.pop((name or "").strip(), None)
db.set_config_value(PRESETS_KEY, presets)
return sorted(presets)
def enabled_tool_names(cfg: dict) -> list[str]:
"""Outils réellement proposables au modèle.
Un outil activé dans la config mais INUTILISABLE (dépendance absente) ne
doit pas apparaître dans le contexte : sinon le modèle l'appelle, échoue,
et gâche un tour. Mieux vaut qu'il ne sache pas qu'il existe et emploie
directement write_file / edit_file.
"""
names = [name for name, on in cfg["tools"].items() if on]
if "code_task" in names:
from . import coder
if not coder.available():
names.remove("code_task")
return names
+267
View File
@@ -0,0 +1,267 @@
"""Auto-réglage : exploite au mieux chaque modèle selon le GPU disponible.
Principe :
1. on détecte la VRAM du GPU (nvidia-smi, sinon rocm-smi, sinon override env) ;
2. on lit les métadonnées du modèle via Ollama (/api/show + /api/tags) :
contexte max, architecture (couches, têtes KV, dimension), taille sur disque ;
3. on calcule la fenêtre de contexte (num_ctx) la plus grande qui tient en VRAM,
via une estimation du cache KV, puis un nombre de jetons de sortie cohérent.
Tout est best-effort : si une info manque, on retombe sur des paliers prudents.
"""
from __future__ import annotations
import shutil
import subprocess
import httpx
from .config import settings
from .ollama_client import ollama
# Paliers de contexte « ronds » proposés (bornés par le contexte du modèle).
CTX_STEPS = [2048, 4096, 8192, 12288, 16384, 24576, 32768, 49152, 65536, 131072]
# Marges VRAM (Mo) : OS/driver + buffers de calcul d'Ollama.
VRAM_OVERHEAD_MB = 1024
VRAM_COMPUTE_BUFFER_MB = 768
# ── Détection GPU ────────────────────────────────────────────────────────
def detect_gpu() -> dict:
"""Renvoie {available, name, vram_total_mb, source}."""
# 1) Override explicite (env) — prioritaire, utile en conteneur sans GPU.
if settings.gpu_vram_mb > 0:
return {
"available": True,
"name": settings.gpu_name or "GPU (configuré)",
"vram_total_mb": settings.gpu_vram_mb,
"source": "env",
}
# 2) NVIDIA
if shutil.which("nvidia-smi"):
try:
out = subprocess.run(
["nvidia-smi",
"--query-gpu=name,memory.total",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=5,
)
if out.returncode == 0 and out.stdout.strip():
line = out.stdout.strip().splitlines()[0]
name, total = [p.strip() for p in line.split(",")]
return {
"available": True,
"name": name,
"vram_total_mb": int(float(total)),
"source": "nvidia-smi",
}
except (OSError, ValueError, subprocess.SubprocessError):
pass
# 3) AMD (ROCm) — best-effort
if shutil.which("rocm-smi"):
try:
out = subprocess.run(
["rocm-smi", "--showmeminfo", "vram", "--csv"],
capture_output=True, text=True, timeout=5,
)
if out.returncode == 0:
# Cherche le plus grand entier (octets) -> Mo
nums = [int(x) for x in out.stdout.replace(",", " ").split()
if x.isdigit()]
if nums:
return {
"available": True,
"name": "GPU AMD",
"vram_total_mb": max(nums) // (1024 * 1024),
"source": "rocm-smi",
}
except (OSError, ValueError, subprocess.SubprocessError):
pass
return {"available": False, "name": "CPU", "vram_total_mb": 0, "source": "none"}
# ── Métadonnées modèle ───────────────────────────────────────────────────
async def model_profile(model: str) -> dict:
"""Extrait contexte max, archi (couches/têtes/dim), taille disque (Mo)."""
profile = {
"context_length": None,
"block_count": None,
"head_count": None,
"head_count_kv": None,
"embedding_length": None,
"size_mb": None,
"parameter_size": None,
"quantization": None,
}
try:
show = await ollama.show(model)
except (httpx.HTTPError, OSError):
return profile
info = show.get("model_info", {}) or {}
arch = info.get("general.architecture", "")
def pick(*suffixes):
for s in suffixes:
key = f"{arch}.{s}" if arch else s
if key in info:
return info[key]
for k, v in info.items():
if k.endswith(s):
return v
return None
profile["context_length"] = pick("context_length")
profile["block_count"] = pick("block_count")
profile["head_count"] = pick("attention.head_count")
profile["head_count_kv"] = pick("attention.head_count_kv")
profile["embedding_length"] = pick("embedding_length")
details = show.get("details", {}) or {}
profile["parameter_size"] = details.get("parameter_size")
profile["quantization"] = details.get("quantization_level")
# Taille sur disque via /api/tags
try:
for m in await ollama.list_models():
if m.get("name") == model:
profile["size_mb"] = int(m.get("size", 0)) // (1024 * 1024)
break
except (httpx.HTTPError, OSError):
pass
return profile
def _kv_bytes_per_token(p: dict) -> int | None:
"""Estimation des octets de cache KV par jeton (KV en f16)."""
blocks = p.get("block_count")
kv_heads = p.get("head_count_kv")
heads = p.get("head_count")
emb = p.get("embedding_length")
if not all((blocks, kv_heads, heads, emb)):
return None
head_dim = emb / heads
# 2 (clé+valeur) * couches * têtes_kv * dim_tête * 2 octets (f16)
return int(2 * blocks * kv_heads * head_dim * 2)
def _round_ctx(candidate: int, ctx_max: int | None) -> int:
cap = ctx_max or CTX_STEPS[-1]
best = CTX_STEPS[0]
for step in CTX_STEPS:
if step <= candidate and step <= cap:
best = step
# Si le modèle plafonne bas, respecte son contexte max.
return min(best, cap)
# ── Recommandation ───────────────────────────────────────────────────────
async def recommend(model: str) -> dict:
gpu = detect_gpu()
prof = await model_profile(model)
ctx_max = prof.get("context_length")
rationale: list[str] = []
if not gpu["available"]:
# GPU non détecté CÔTÉ LOKI : Ollama tourne probablement sur une autre
# machine. On ne force surtout PAS num_ctx (0 = défaut du modèle), sinon
# Ollama chargerait une instance distincte qui déborderait sur le CPU.
num_ctx = 0
rationale.append(
"GPU non détecté côté Loki (Ollama distant ?) — contexte laissé au "
"défaut du modèle pour rester sur le GPU. Déclare GPU_VRAM_MB pour "
"un réglage précis."
)
else:
vram = gpu["vram_total_mb"]
model_mb = prof.get("size_mb") or _fallback_model_mb(prof)
budget = vram - model_mb - VRAM_OVERHEAD_MB - VRAM_COMPUTE_BUFFER_MB
kv_per_tok = _kv_bytes_per_token(prof)
if budget <= 256:
num_ctx = _round_ctx(2048, ctx_max)
rationale.append("VRAM insuffisante pour les poids — contexte minimal.")
elif kv_per_tok:
tokens_fit = int(budget * 1024 * 1024 / kv_per_tok)
num_ctx = _round_ctx(tokens_fit, ctx_max)
rationale.append(
f"{vram} Mo VRAM − {model_mb} Mo poids → budget KV "
f"{budget} Mo (~{kv_per_tok // 1024} Ko/jeton)."
)
else:
# Archi inconnue : paliers selon le budget VRAM restant.
num_ctx = _round_ctx(_tier_ctx(budget), ctx_max)
rationale.append("Archi modèle incomplète — estimation par paliers.")
# Jetons de sortie : moitié du contexte, borné. Si num_ctx=0 (auto), on
# garde une valeur raisonnable sans la réduire.
max_tokens = 2048 if num_ctx == 0 else max(512, min(num_ctx // 2, 8192))
return {
"gpu": gpu,
"model": model,
"model_profile": {
"context_length": ctx_max,
"parameter_size": prof.get("parameter_size"),
"quantization": prof.get("quantization"),
"size_mb": prof.get("size_mb"),
},
"recommended": {"num_ctx": num_ctx, "max_tokens": max_tokens},
"rationale": " ".join(rationale),
}
async def placement(model: str) -> dict:
"""Lit /api/ps : le modèle est-il chargé sur GPU, CPU, ou un mix ?"""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return {"loaded": False}
for m in loaded:
if m.get("name") == model or m.get("model") == model:
size = m.get("size", 0) or 0
size_vram = m.get("size_vram", 0) or 0
if size <= 0:
where = "inconnu"
elif size_vram >= size * 0.99:
where = "gpu"
elif size_vram <= size * 0.01:
where = "cpu"
else:
where = "mixte"
pct = int(size_vram / size * 100) if size else 0
return {"loaded": True, "where": where, "gpu_percent": pct,
"size_mb": size // (1024 * 1024)}
return {"loaded": False}
def _fallback_model_mb(prof: dict) -> int:
"""Estime la taille des poids si /api/tags n'a rien donné."""
ps = (prof.get("parameter_size") or "").upper().replace("B", "")
try:
billions = float(ps)
except ValueError:
billions = 8.0
# ~0.6 Go/milliard en Q4, approximation prudente.
return int(billions * 600)
def _tier_ctx(budget_mb: int) -> int:
if budget_mb >= 12000:
return 32768
if budget_mb >= 8000:
return 16384
if budget_mb >= 5000:
return 12288
if budget_mb >= 3000:
return 8192
if budget_mb >= 1500:
return 4096
return 2048
+222
View File
@@ -0,0 +1,222 @@
"""Benchmark intégré : évalue objectivement chaque modèle installé.
Cinq mini-épreuves (~30-60 s au total) qui mesurent ce qui compte pour Loki :
appel d'outil, code exécutable, respect des consignes, extraction JSON,
respect d'un format. Score /100, stocké en base et affiché dans l'UI.
"""
from __future__ import annotations
import asyncio
import json
import re
import subprocess
import sys
import tempfile
import time
from typing import AsyncIterator
import httpx
from . import db
from .ollama_client import OllamaError, ollama
BENCH_KEY = "bench" # config[bench] = {model: {score, details, at}}
async def _ask(model: str, prompt: str, *, system: str = "",
tools: list | None = None, num_predict: int = 400) -> dict:
"""Un appel modèle ; renvoie {text, tool_calls}."""
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
text, calls = "", []
async for chunk in ollama.chat(
model, messages, tools=tools,
options={"temperature": 0, "num_predict": num_predict}, stream=True,
):
msg = chunk.get("message", {})
text += msg.get("content", "")
if msg.get("tool_calls"):
calls.extend(msg["tool_calls"])
if chunk.get("done"):
break
return {"text": text.strip(), "tool_calls": calls}
def _extract_code(text: str) -> str:
m = re.search(r"```(?:python)?\s*(.*?)```", text, re.S)
return (m.group(1) if m else text).strip()
def _run_python(code: str, test: str) -> bool:
"""Exécute code+test dans un sous-processus isolé (timeout 8 s)."""
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as f:
f.write(code + "\n" + test)
path = f.name
try:
proc = subprocess.run(
[sys.executable, "-I", path],
capture_output=True, timeout=8,
)
return proc.returncode == 0
except (subprocess.SubprocessError, OSError):
return False
# ── Les 5 épreuves (score 0-20 chacune) ──────────────────────────────────
async def _task_tool_call(model: str) -> tuple[int, str]:
tools = [{
"type": "function",
"function": {
"name": "write_file",
"description": "Écrire un fichier",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
},
"required": ["path", "content"],
},
},
}]
try:
r = await _ask(
model,
"Crée le fichier bonjour.txt contenant exactement le texte : salut",
system="Utilise l'outil write_file pour créer le fichier demandé.",
tools=tools, num_predict=200,
)
except httpx.HTTPStatusError as exc:
if "does not support tools" in exc.response.text.lower():
return 0, "outils non supportés par ce modèle"
raise
for tc in r["tool_calls"]:
fn = tc.get("function", {})
if fn.get("name") == "write_file":
args = fn.get("arguments") or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
return 8, "appel d'outil aux arguments illisibles"
ok_path = "bonjour" in str(args.get("path", "")).lower()
ok_content = "salut" in str(args.get("content", "")).lower()
score = 10 + 5 * ok_path + 5 * ok_content
return score, "appel d'outil correct" if score == 20 else "appel partiel"
return 0, "aucun appel d'outil émis"
async def _task_code(model: str) -> tuple[int, str]:
r = await _ask(
model,
"Écris une fonction Python `somme_pairs(nombres)` qui renvoie la somme "
"des nombres pairs de la liste. Réponds UNIQUEMENT avec le code.",
num_predict=300,
)
code = _extract_code(r["text"])
if "def somme_pairs" not in code:
return 0, "fonction absente"
test = (
"assert somme_pairs([1,2,3,4]) == 6\n"
"assert somme_pairs([]) == 0\n"
"assert somme_pairs([7,9]) == 0\n"
)
return (20, "code correct (3/3 tests)") if _run_python(code, test) \
else (6, "code présent mais tests échoués")
async def _task_instruction(model: str) -> tuple[int, str]:
r = await _ask(
model,
"Quelle est la capitale de la France ? Réponds en 3 mots maximum.",
num_predict=30,
)
text = r["text"]
has_answer = "paris" in text.lower()
short = len(text.split()) <= 6
score = 12 * has_answer + 8 * short
return score, f"réponse « {text[:40]} »"
async def _task_json(model: str) -> tuple[int, str]:
r = await _ask(
model,
'Extrait les informations en JSON strict {"nom": ..., "ville": ...} '
"depuis : « Marie habite à Lyon ». Réponds UNIQUEMENT avec le JSON.",
num_predict=80,
)
m = re.search(r"\{.*\}", r["text"], re.S)
if not m:
return 0, "pas de JSON"
try:
data = json.loads(m.group(0))
except json.JSONDecodeError:
return 5, "JSON invalide"
ok_nom = "marie" in str(data.get("nom", "")).lower()
ok_ville = "lyon" in str(data.get("ville", "")).lower()
score = 10 + 5 * ok_nom + 5 * ok_ville
detail = "extraction correcte" if ok_nom and ok_ville else "extraction partielle"
return score, detail
async def _task_format(model: str) -> tuple[int, str]:
r = await _ask(
model,
"Liste exactement 3 fruits, un par ligne, chaque ligne préfixée par « - ».",
num_predict=60,
)
lines = [l for l in r["text"].splitlines() if l.strip().startswith("-")]
if len(lines) == 3:
return 20, "format exact"
if len(lines) >= 2:
return 10, f"{len(lines)} lignes au lieu de 3"
return 0, "format non respecté"
TASKS = [
("Appel d'outil", _task_tool_call),
("Code exécutable", _task_code),
("Consigne courte", _task_instruction),
("Extraction JSON", _task_json),
("Respect du format", _task_format),
]
async def run_bench(model: str) -> AsyncIterator[dict]:
"""Exécute les 5 épreuves en streamant la progression, stocke le score."""
total = 0
details = []
for name, fn in TASKS:
yield {"type": "task_start", "task": name}
task = asyncio.create_task(fn(model))
try:
while not task.done():
done, _ = await asyncio.wait({task}, timeout=10)
if not done:
# Empêche OpenResty/Nginx de fermer le SSE pendant une longue
# génération d'un gros modèle.
yield {"type": "heartbeat", "task": name}
score, detail = await task
except (OllamaError, httpx.HTTPError, OSError) as exc:
score, detail = 0, f"erreur : {str(exc)[:80]}"
except Exception as exc:
# Une épreuve défaillante ne doit pas couper silencieusement le SSE :
# elle vaut zéro et les autres épreuves continuent.
score, detail = 0, f"épreuve interrompue : {str(exc)[:80]}"
finally:
if not task.done():
task.cancel()
total += score
details.append({"task": name, "score": score, "detail": detail})
yield {"type": "task_done", "task": name, "score": score, "detail": detail}
results = db.get_config_value(BENCH_KEY) or {}
results[model] = {"score": total, "details": details, "at": time.time()}
db.set_config_value(BENCH_KEY, results)
yield {"type": "done", "score": total, "details": details}
def get_scores() -> dict:
return db.get_config_value(BENCH_KEY) or {}
+160
View File
@@ -0,0 +1,160 @@
"""Moteur code : enveloppe Aider pour l'édition multi-fichiers fiable.
Aider apporte ce qui fait la force de Claude Code/Codex : formats d'édition
diff/search-replace robustes (même avec de petits modèles), repo map, et
commits git automatiques dans le workspace.
L'API Python d'Aider n'étant pas officiellement stable, la version est FIGÉE
dans requirements.txt (aider-chat==0.86.2) et tout l'import est local à la
fonction (l'app démarre même si Aider est absent).
"""
from __future__ import annotations
import os
import subprocess
import threading
from .config import settings
# Aider utilise le répertoire courant : un seul run à la fois.
_RUN_LOCK = threading.Lock()
def _confine(root: str, files: list[str] | None) -> list[str]:
"""Résout les fichiers demandés en restant confiné au workspace.
Écarte silencieusement tout chemin absolu ou toute remontée `../` qui
sortirait du workspace : le moteur code ne doit jamais toucher au disque
hors du workspace de la discussion.
"""
confined: list[str] = []
for f in files or []:
target = os.path.abspath(os.path.join(root, f))
if target == root or target.startswith(root + os.sep):
confined.append(target)
return confined
def ensure_git(root: str) -> None:
"""Initialise un dépôt git dans le workspace (requis pour les commits Aider)."""
os.makedirs(root, exist_ok=True)
if os.path.isdir(os.path.join(root, ".git")):
return
subprocess.run(["git", "init", "-q"], cwd=root, check=False)
subprocess.run(["git", "config", "user.email", "loki@local"], cwd=root, check=False)
subprocess.run(["git", "config", "user.name", "Loki"], cwd=root, check=False)
def available() -> bool:
"""Aider est-il installé ?"""
try:
import aider # noqa: F401
return True
except ImportError:
return False
# Familles spécialisées code, par ordre de préférence.
_CODE_MODEL_HINTS = (
"qwen3-coder", "qwen2.5-coder", "deepseek-coder", "codestral", "devstral",
"codegemma", "codellama", "starcoder", "coder",
)
async def pick_code_model(current: str, preference: str | None = None) -> str:
"""Choisit le modèle pour les tâches de code.
- préférence explicite (config code_model != "auto") -> respectée ;
- sinon, si un modèle spécialisé code est installé, on le prend (le plus
gros d'abord) : un petit modèle code bat un généraliste sur ce terrain ;
- sinon, on garde le modèle courant.
"""
if preference and preference != "auto":
return preference
from .ollama_client import ollama
try:
installed = await ollama.list_models_cached()
except Exception:
return current
candidates: list[tuple[int, int, str]] = [] # (rang_hint, -taille, nom)
for m in installed:
name = (m.get("name") or "").lower()
for rank, hint in enumerate(_CODE_MODEL_HINTS):
if hint in name:
candidates.append((rank, -(m.get("size") or 0), m["name"]))
break
if not candidates:
return current
candidates.sort()
return candidates[0][2]
def run_code_task(
instruction: str,
model: str,
files: list[str] | None = None,
root: str | None = None,
) -> dict:
"""Exécute une tâche de code via Aider (synchrone — lancer dans un thread).
Renvoie {ok, text, files, commit, commit_message, summary}.
"""
if not available():
return {
"ok": False,
"summary": "moteur code indisponible (aider non installé)",
"text": "", "files": [], "commit": None,
}
from aider.coders import Coder
from aider.io import InputOutput
from aider.models import Model
root = os.path.abspath(root or settings.workspace_dir)
ensure_git(root)
# Aider parle à Ollama via litellm : on pointe vers notre instance.
os.environ["OLLAMA_API_BASE"] = settings.ollama_host
with _RUN_LOCK:
prev_cwd = os.getcwd()
os.chdir(root)
try:
io = InputOutput(yes=True, pretty=False, fancy_input=False)
coder = Coder.create(
main_model=Model(f"ollama_chat/{model}"),
io=io,
fnames=_confine(root, files),
auto_commits=True,
stream=False,
use_git=True,
suggest_shell_commands=False,
detect_urls=False,
)
text = coder.run(instruction) or ""
edited = sorted(coder.aider_edited_files or [])
commit = getattr(coder, "last_aider_commit_hash", None)
commit_msg = getattr(coder, "last_aider_commit_message", None)
except Exception as exc: # aider peut lever des erreurs variées
return {
"ok": False,
"summary": f"échec moteur code : {exc}",
"text": "", "files": [], "commit": None,
}
finally:
os.chdir(prev_cwd)
summary = f"{len(edited)} fichier(s) modifié(s)"
if commit:
summary += f" · commit {str(commit)[:7]}"
return {
"ok": True,
"text": text.strip(),
"files": edited,
"commit": commit,
"commit_message": commit_msg,
"summary": summary,
}
+26
View File
@@ -0,0 +1,26 @@
"""Configuration de l'application, chargée depuis l'environnement."""
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Réglages globaux de Loki (surchargés par variables d'environnement)."""
ollama_host: str = "http://host.docker.internal:11434"
default_model: str = "gemma4:12b"
workspace_dir: str = "/workspace"
data_dir: str = "/data"
port: int = 8080
# Marqueur de build injecté à la construction de l'image (git sha court).
# Permet de vérifier que l'image déployée est bien à jour.
loki_version: str = "dev"
# Override manuel de la VRAM (Mo) si la détection GPU échoue dans le
# conteneur (utile quand Ollama tourne sur une autre machine).
gpu_vram_mb: int = 0
gpu_name: str = ""
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()
+208
View File
@@ -0,0 +1,208 @@
"""Persistance SQLite : sessions et messages.
On utilise sqlite3 de la bibliothèque standard (zéro dépendance). Les écritures
sont rapides ; un verrou protège l'accès concurrent depuis les routes async.
"""
from __future__ import annotations
import json
import os
import sqlite3
import threading
import time
import uuid
from .config import settings
_LOCK = threading.Lock()
_DB_PATH = os.path.join(settings.data_dir, "loki.db")
def _connect() -> sqlite3.Connection:
os.makedirs(settings.data_dir, exist_ok=True)
conn = sqlite3.connect(_DB_PATH, check_same_thread=False)
conn.row_factory = sqlite3.Row
return conn
def init_db() -> None:
"""Crée les tables si elles n'existent pas."""
with _LOCK, _connect() as conn:
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
model TEXT,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
role TEXT NOT NULL,
content TEXT NOT NULL,
model TEXT,
meta TEXT,
created_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id, created_at);
"""
)
# Migration douce : ajoute la colonne meta aux bases antérieures.
cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")}
if "meta" not in cols:
conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
# Migration douce : résumé de conversation (mémoire compressée).
scols = {r["name"] for r in conn.execute("PRAGMA table_info(sessions)")}
if "summary" not in scols:
conn.execute("ALTER TABLE sessions ADD COLUMN summary TEXT")
# Migration douce : projet (sous-dossier de travail) de la session.
if "project" not in scols:
conn.execute("ALTER TABLE sessions ADD COLUMN project TEXT")
# Table clé/valeur pour la configuration de l'agent.
conn.execute(
"CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)"
)
def _now() -> float:
return time.time()
# ── Sessions ─────────────────────────────────────────────────────────────
def create_session(
title: str, model: str | None, project: str | None = None
) -> dict:
sid = uuid.uuid4().hex
now = _now()
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO sessions (id, title, model, project, created_at, updated_at)"
" VALUES (?, ?, ?, ?, ?, ?)",
(sid, title, model, project, now, now),
)
return {"id": sid, "title": title, "model": model, "project": project,
"created_at": now, "updated_at": now, "message_count": 0}
def set_session_project(sid: str, project: str | None) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET project = ? WHERE id = ?", (project, sid)
)
def list_sessions() -> list[dict]:
with _LOCK, _connect() as conn:
rows = conn.execute(
"""
SELECT s.*, COUNT(m.id) AS message_count
FROM sessions s
LEFT JOIN messages m ON m.session_id = s.id
GROUP BY s.id
ORDER BY s.updated_at DESC
"""
).fetchall()
return [dict(r) for r in rows]
def get_session(sid: str) -> dict | None:
with _LOCK, _connect() as conn:
row = conn.execute("SELECT * FROM sessions WHERE id = ?", (sid,)).fetchone()
return dict(row) if row else None
def rename_session(sid: str, title: str) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?",
(title, _now(), sid),
)
def delete_session(sid: str) -> None:
with _LOCK, _connect() as conn:
conn.execute("DELETE FROM messages WHERE session_id = ?", (sid,))
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
def touch_session(sid: str) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?", (_now(), sid)
)
# ── Messages ─────────────────────────────────────────────────────────────
def add_message(
sid: str,
role: str,
content: str,
model: str | None,
meta: dict | None = None,
) -> dict:
mid = uuid.uuid4().hex
now = _now()
meta_json = json.dumps(meta, ensure_ascii=False) if meta else None
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO messages (id, session_id, role, content, model, meta, created_at)"
" VALUES (?, ?, ?, ?, ?, ?, ?)",
(mid, sid, role, content, model, meta_json, now),
)
conn.execute(
"UPDATE sessions SET updated_at = ? WHERE id = ?", (now, sid)
)
return {"id": mid, "session_id": sid, "role": role, "content": content,
"model": model, "meta": meta, "created_at": now}
def _row_to_message(row: sqlite3.Row) -> dict:
msg = dict(row)
msg["meta"] = json.loads(msg["meta"]) if msg.get("meta") else None
return msg
def list_messages(sid: str) -> list[dict]:
with _LOCK, _connect() as conn:
rows = conn.execute(
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at",
(sid,),
).fetchall()
return [_row_to_message(r) for r in rows]
def list_messages_for_model(sid: str) -> list[dict]:
"""Historique épuré (role/content) destiné au contexte du modèle."""
return [
{"role": m["role"], "content": m["content"]}
for m in list_messages(sid)
]
def set_session_summary(sid: str, summary: str) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET summary = ? WHERE id = ?", (summary, sid)
)
# ── Configuration (clé/valeur JSON) ──────────────────────────────────────
def get_config_value(key: str) -> dict | None:
with _LOCK, _connect() as conn:
row = conn.execute(
"SELECT value FROM config WHERE key = ?", (key,)
).fetchone()
return json.loads(row["value"]) if row else None
def set_config_value(key: str, value: dict) -> None:
payload = json.dumps(value, ensure_ascii=False)
with _LOCK, _connect() as conn:
conn.execute(
"INSERT INTO config (key, value) VALUES (?, ?)"
" ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, payload),
)
+169
View File
@@ -0,0 +1,169 @@
"""Boosters de qualité pour petits modèles : plan-puis-exécute et auto-critique.
- make_plan : décompose une demande complexe en 3-5 étapes courtes. Un petit
modèle qui suit un plan écrit réussit bien mieux qu'en improvisant.
- self_review : une passe de critique éclair sur la réponse, puis une révision
si des défauts sont trouvés (activable : coûte un peu de latence).
"""
from __future__ import annotations
import logging
import re
import httpx
from .ollama_client import OllamaError, ollama
logger = logging.getLogger(__name__)
_PLAN_PROMPT = (
"Découpe la demande en 3 à 5 étapes courtes et concrètes, une par ligne, "
"numérotées « 1. », « 2. »… Pas d'introduction, pas de conclusion, "
"UNIQUEMENT les étapes, en français."
)
_PLAN_CODE_PROMPT = (
"Tu es architecte logiciel. Établis un plan d'IMPLÉMENTATION concret pour "
"cette application, en 3 à 5 étapes courtes (une par ligne, numérotées "
"« 1. », « 2. »…).\n"
"- Étape 1 = ARCHITECTURE : privilégie UN SEUL fichier autonome (ex. "
"index.html avec le CSS et le JS intégrés, sans dépendance externe) ; ne "
"sépare en plusieurs fichiers que si c'est vraiment indispensable.\n"
"- Étapes suivantes = construire UNE fonctionnalité concrète et testable à "
"la fois (structure/affichage, puis interactions, puis logique).\n"
"Reste RÉALISTE et réalisable en une passe : pas de dépendance externe, pas "
"de bibliothèque à installer, pas de « moteur IA » complexe si ce n'est pas "
"explicitement demandé — une logique simple en JavaScript suffit. "
"Pas d'introduction ni de conclusion, UNIQUEMENT les étapes, en français."
)
_CRITIQUE_PROMPT = (
"Tu es un relecteur exigeant. Voici une demande et la réponse d'un "
"assistant. Si la réponse est correcte et complète, réponds exactement "
"PARFAIT. Sinon, liste au plus 3 défauts concrets (erreurs, oublis, "
"incohérences), un par ligne."
)
_REVISE_PROMPT = (
"Réécris la réponse en corrigeant les défauts listés. Donne UNIQUEMENT la "
"réponse finale corrigée, sans commentaire sur la révision."
)
def needs_plan(message: str) -> bool:
"""Une demande assez longue/composée mérite un plan explicite."""
if len(message) < 120:
return False
connectors = len(re.findall(
r"\b(puis|ensuite|après|avec|ainsi que|et aussi|également)\b",
message, re.I,
))
return len(message) > 240 or connectors >= 2
async def _ask(
model: str,
system: str,
user: str,
*,
num_predict: int,
options: dict | None = None,
keep_alive: str | None = None,
) -> str:
"""Appel court au modèle.
``options`` doit contenir les options runner (num_ctx, num_batch…) du chat
principal : un appel avec des options divergentes force Ollama à recharger
le modèle en plein message.
"""
messages = [{"role": "system", "content": system},
{"role": "user", "content": user}]
opts = {**(options or {}), "temperature": 0.2, "num_predict": num_predict}
# think=False : un appel utilitaire (plan, critique) ne doit jamais
# « réfléchir » — sur un modèle thinking, la pensée dévore le budget et
# multiplie la latence. Repli sans le paramètre si le modèle le refuse.
think: bool | None = False
while True:
text = ""
try:
async for chunk in ollama.chat(
model, messages, options=opts, think=think,
keep_alive=keep_alive, stream=True,
):
text += chunk.get("message", {}).get("content", "")
if chunk.get("done"):
break
return text.strip()
except OllamaError as exc:
if think is False and "think" in str(exc).lower():
think = None
continue
raise
async def make_plan(
model: str,
message: str,
*,
code: bool = False,
options: dict | None = None,
keep_alive: str | None = None,
) -> list[str]:
"""Renvoie la liste des étapes (vide si échec — jamais bloquant).
``code=True`` bascule sur un plan d'ARCHITECTURE/implémentation concret
(fichiers, structure, fonctionnalités) plutôt qu'une liste d'objectifs.
"""
try:
raw = await _ask(
model, _PLAN_CODE_PROMPT if code else _PLAN_PROMPT,
message[:1200], num_predict=260,
options=options, keep_alive=keep_alive,
)
except (httpx.HTTPError, OSError) as exc:
logger.warning("Plan impossible : %s", exc)
return []
steps = []
for line in raw.splitlines():
line = line.strip()
m = re.match(r"^\d+[.)]\s*(.+)$", line)
if m:
steps.append(m.group(1).strip())
return steps[:5] if len(steps) >= 2 else []
async def self_review(
model: str,
request: str,
answer: str,
*,
options: dict | None = None,
keep_alive: str | None = None,
) -> str | None:
"""Critique puis révise la réponse. None si rien à corriger / échec."""
if len(answer) < 80:
return None
try:
critique = await _ask(
model,
_CRITIQUE_PROMPT,
f"Demande :\n{request[:800]}\n\nRéponse :\n{answer[:2500]}",
num_predict=180,
options=options, keep_alive=keep_alive,
)
if not critique or "PARFAIT" in critique.upper()[:40]:
return None
revised = await _ask(
model,
_REVISE_PROMPT,
f"Demande :\n{request[:800]}\n\nRéponse initiale :\n{answer[:2500]}"
f"\n\nDéfauts :\n{critique[:600]}",
num_predict=1500,
options=options, keep_alive=keep_alive,
)
# Garde-fou : une révision vide ou minuscule ne remplace rien.
return revised if len(revised) > len(answer) // 3 else None
except (httpx.HTTPError, OSError) as exc:
logger.warning("Auto-critique impossible : %s", exc)
return None
+122
View File
@@ -0,0 +1,122 @@
"""Point d'entrée FastAPI de Loki.
Sert l'API (/api/*) et, en production, le frontend React compilé (static/).
"""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from . import agent_config, coder, db, rag
from .config import settings
from .ollama_client import ollama
from .routes import (
benchmark, chat, config, files, git, mcp, models, projects, sessions,
shell, system,
)
async def _warm_default_model() -> None:
"""Précharge le modèle par défaut en VRAM au démarrage (best-effort)."""
import asyncio
import logging
await asyncio.sleep(2) # laisse le service démarrer
try:
cfg = agent_config.get_config(settings.default_model)
models.start_model_warm(
settings.default_model, cfg.get("keep_alive", "30m")
)
logging.getLogger(__name__).info(
"Préchargement du modèle %s lancé", settings.default_model
)
except Exception as exc: # best-effort
logging.getLogger(__name__).info(
"Préchargement au démarrage ignoré : %s", exc
)
@asynccontextmanager
async def lifespan(_: FastAPI):
import asyncio
db.init_db()
rag.init_table()
# Workspace en dépôt git : requis pour les commits du moteur code (Aider).
coder.ensure_git(settings.workspace_dir)
# Préchargement du modèle par défaut, sans bloquer le démarrage.
asyncio.create_task(_warm_default_model())
yield
# Ferme le pool HTTP partagé vers Ollama et les sessions MCP.
await ollama.aclose()
from .mcp_client import manager as mcp_manager
await mcp_manager.aclose()
app = FastAPI(
title="Loki", description="Agent IA local sur Ollama", lifespan=lifespan
)
# En dev, le front tourne sur Vite (5173). On autorise le CORS large ;
# en prod le front est servi par le même origin, donc sans impact.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(models.router)
app.include_router(sessions.router)
app.include_router(chat.router)
app.include_router(files.router)
app.include_router(config.router)
app.include_router(shell.router)
app.include_router(system.router)
app.include_router(benchmark.router)
app.include_router(git.router)
app.include_router(mcp.router)
app.include_router(projects.router)
@app.get("/api/health")
async def health() -> dict:
return {"status": "ok", "service": "loki", "version": settings.loki_version}
@app.get("/api/version")
async def version() -> dict:
"""Marqueur de build — pour vérifier que l'image déployée est à jour."""
return {"version": settings.loki_version}
# ── Service du frontend compilé (présent uniquement en image Docker) ─────
_STATIC_DIR = os.path.join(os.path.dirname(__file__), "..", "static")
if os.path.isdir(_STATIC_DIR):
app.mount(
"/assets",
StaticFiles(directory=os.path.join(_STATIC_DIR, "assets")),
name="assets",
)
@app.get("/{full_path:path}")
async def spa_fallback(full_path: str):
"""Fichier statique racine s'il existe (favicon…), sinon index.html.
Sans ce test, /favicon.svg recevait index.html : aucun favicon ne
s'affichait en production.
"""
static_root = os.path.abspath(_STATIC_DIR)
candidate = os.path.abspath(os.path.join(static_root, full_path))
if (
full_path
and candidate.startswith(static_root + os.sep)
and os.path.isfile(candidate)
):
return FileResponse(candidate)
return FileResponse(os.path.join(static_root, "index.html"))
+341
View File
@@ -0,0 +1,341 @@
"""Client MCP : catalogue préconfiguré + sessions vers les serveurs activés.
Un serveur désactivé n'est jamais démarré et n'expose aucun outil au modèle
(chaque outil injecté coûte du contexte). Connexion lazy au premier message,
session réutilisée ensuite. Toute panne est non bloquante pour le chat.
"""
from __future__ import annotations
import asyncio
import logging
import re
import shlex
from contextlib import AsyncExitStack
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from . import db
logger = logging.getLogger(__name__)
MCP_KEY = "mcp"
# Catalogue embarqué. command=None => serveur "custom" (commande utilisateur).
CATALOG: dict[str, dict] = {
"playwright": {
"label": "Playwright (navigateur)",
"description": "Pilote un vrai navigateur : naviguer, cliquer, lire "
"la console, captures. L'agent teste réellement ses pages.",
"command": ["npx", "@playwright/mcp@latest", "--headless"],
"url_param": False,
"env_params": [],
# Limite le nombre d'outils injectés dans le prompt.
"expose": [
"browser_navigate", "browser_click", "browser_type",
"browser_snapshot", "browser_console_messages",
"browser_take_screenshot",
],
},
"context7": {
"label": "Context7 (documentation)",
"description": "Documentation à jour de n'importe quelle librairie ou "
"framework (React, FastAPI, Tailwind…).",
"command": ["npx", "-y", "@upstash/context7-mcp"],
"url_param": False,
"env_params": [],
"expose": None,
},
"fetch": {
"label": "Fetch (lecture web)",
"description": "Lit proprement n'importe quelle URL (markdown épuré).",
"command": ["python", "-m", "mcp_server_fetch"],
"url_param": False,
"env_params": [],
"expose": None,
},
"searxng": {
"label": "SearxNG (recherche web)",
"description": "Vraie recherche web via une instance SearxNG "
"(renseigner SEARXNG_URL).",
"command": ["npx", "-y", "mcp-searxng"],
"url_param": False,
"env_params": ["SEARXNG_URL"],
"expose": None,
},
"custom": {
"label": "Personnalisé",
"description": "N'importe quel serveur MCP : colle une commande "
"(stdio) ou une URL (streamable HTTP).",
"command": None,
"url_param": True,
"env_params": [],
"expose": None,
},
}
def get_mcp_state() -> dict:
"""État activé/params de chaque serveur du catalogue (défaut : désactivé)."""
stored = db.get_config_value(MCP_KEY) or {}
return {
sid: {
"enabled": bool(stored.get(sid, {}).get("enabled", False)),
"params": dict(stored.get(sid, {}).get("params", {})),
}
for sid in CATALOG
}
def set_mcp_state(sid: str, *, enabled: bool, params: dict) -> dict:
if sid not in CATALOG:
raise KeyError(sid)
state = get_mcp_state()
state[sid] = {"enabled": enabled, "params": dict(params)}
db.set_config_value(MCP_KEY, state)
return state
_CALL_TIMEOUT = 30.0
# Généreux : le premier lancement d'un serveur npx (Playwright, Context7…)
# télécharge le paquet — souvent bien plus de 20 s. Les démarrages suivants
# sont instantanés (cache npm).
_CONNECT_TIMEOUT = 90.0
_MAX_RESULT_CHARS = 8000
def _safe_tool_name(name: str) -> str:
"""Nom d'outil compatible function-calling (lettres/chiffres/underscore).
Les noms MCP peuvent contenir des tirets (« resolve-library-id ») que les
grammaires de tool-calling et les modèles mélangent avec des underscores —
source de « Tool not found ». On expose une version assainie et on garde
la correspondance vers le vrai nom.
"""
return re.sub(r"[^a-zA-Z0-9_]", "_", name)
class _ServerConn:
"""Session vivante vers un serveur MCP (process stdio + handshake)."""
def __init__(self, sid: str) -> None:
self.sid = sid
self.stack = AsyncExitStack()
self.session: ClientSession | None = None
self.tools: list[dict] = [] # définitions format Ollama
# nom exposé au modèle -> vrai nom d'outil MCP
self.name_map: dict[str, str] = {}
async def start(self) -> None:
entry = CATALOG[self.sid]
params = get_mcp_state()[self.sid]["params"]
spec = params.get("command", "").strip()
if entry["command"] is None and spec.startswith(("http://", "https://")):
# Serveur "custom" en streamable HTTP (URL collée par l'utilisateur).
from mcp.client.streamable_http import streamablehttp_client
read, write, _ = await self.stack.enter_async_context(
streamablehttp_client(spec)
)
else:
if entry["command"] is None:
# Serveur "custom" : commande stdio saisie par l'utilisateur.
command = shlex.split(spec)
if not command:
raise ValueError("commande du serveur personnalisé vide")
else:
command = list(entry["command"])
# Paramètres obligatoires (ex. SEARXNG_URL) : refus clair AVANT le
# lancement, plutôt qu'un échec cryptique à chaque appel d'outil.
missing = [k for k in entry["env_params"] if not params.get(k)]
if missing:
raise ValueError(
f"{', '.join(missing)} requis — renseigne ce champ dans la "
"carte du serveur (Configuration → Serveurs MCP)"
)
env = {k: params[k] for k in entry["env_params"] if params.get(k)}
server = StdioServerParameters(
command=command[0], args=command[1:], env=env or None
)
read, write = await self.stack.enter_async_context(
stdio_client(server)
)
self.session = await self.stack.enter_async_context(
ClientSession(read, write)
)
await asyncio.wait_for(self.session.initialize(), _CONNECT_TIMEOUT)
listed = await asyncio.wait_for(self.session.list_tools(), _CONNECT_TIMEOUT)
expose = entry.get("expose")
self.tools = []
self.name_map = {}
for t in listed.tools:
if expose is not None and t.name not in expose:
continue
exposed = f"mcp_{self.sid}_{_safe_tool_name(t.name)}"
self.name_map[exposed] = t.name
self.tools.append({
"type": "function",
"function": {
"name": exposed,
"description": (t.description or t.name)[:400],
"parameters": t.inputSchema
or {"type": "object", "properties": {}},
},
})
async def close(self) -> None:
try:
await self.stack.aclose()
except Exception: # process déjà mort : sans importance
pass
class McpManager:
"""Sessions MCP lazy + dispatch d'appels d'outils, jamais bloquant."""
def __init__(self) -> None:
self._conns: dict[str, _ServerConn] = {}
self._errors: dict[str, str] = {}
self._notices: list[str] = []
self._lock = asyncio.Lock()
async def _ensure(self, sid: str) -> _ServerConn | None:
if sid in self._conns:
return self._conns[sid]
try:
conn = _ServerConn(sid)
await conn.start()
except Exception as exc:
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)):
msg = (
"délai de démarrage dépassé — premier lancement d'un "
"serveur npx (téléchargement) ou paquet absent de l'image. "
"Réessaie au prochain message."
)
else:
msg = str(exc)[:300] or exc.__class__.__name__
logger.warning("Serveur MCP %s indisponible : %s", sid, msg)
self._errors[sid] = msg
self._notices.append(
f"Serveur MCP « {CATALOG[sid]['label']} » indisponible : {msg}"
)
return None
self._errors.pop(sid, None)
self._conns[sid] = conn
return conn
async def tool_definitions(self) -> list[dict]:
"""Outils des serveurs activés (connexion lazy, pannes ignorées)."""
defs: list[dict] = []
async with self._lock:
state = get_mcp_state()
# Ferme les serveurs désactivés entre-temps.
for sid in [s for s in self._conns if not state[s]["enabled"]]:
await self._conns.pop(sid).close()
for sid, st in state.items():
if not st["enabled"]:
continue
conn = await self._ensure(sid)
if conn:
defs.extend(conn.tools)
return defs
def _resolve(self, prefixed_name: str) -> tuple[str, str] | None:
"""(sid, vrai nom d'outil) depuis le nom exposé au modèle.
Résolution par table de correspondance, avec tolérance : les modèles
confondent parfois tirets et underscores dans les noms d'outils.
"""
wanted = _safe_tool_name(prefixed_name)
for sid, conn in self._conns.items():
for exposed, real in conn.name_map.items():
if exposed == prefixed_name or exposed == wanted:
return sid, real
# Repli : découpage mcp_<sid>_<outil> (serveur pas encore connecté).
try:
_, sid, tool = prefixed_name.split("_", 2)
return sid, tool
except ValueError:
return None
async def call_tool(self, prefixed_name: str, args: dict) -> dict:
resolved = self._resolve(prefixed_name)
if resolved is None:
return {"ok": False, "content": "", "summary": "nom d'outil invalide"}
sid, tool = resolved
async with self._lock:
conn = self._conns.get(sid) or await self._ensure(sid)
if conn is None or conn.session is None:
return {"ok": False, "content": "",
"summary": f"serveur MCP {sid} indisponible"}
# Serveur (re)connecté après le repli : re-résout via sa table.
if conn.name_map:
tool = conn.name_map.get(prefixed_name) or conn.name_map.get(
_safe_tool_name(prefixed_name), tool
)
try:
result = await asyncio.wait_for(
conn.session.call_tool(tool, args or {}), _CALL_TIMEOUT
)
except Exception as exc:
# Session probablement morte : on la ferme, retry au prochain tour.
async with self._lock:
dead = self._conns.pop(sid, None)
if dead:
await dead.close()
return {"ok": False, "content": "",
"summary": f"échec MCP : {str(exc)[:200]}"}
parts = [
c.text for c in result.content
if getattr(c, "type", "") == "text" and getattr(c, "text", "")
]
content = "\n".join(parts)[:_MAX_RESULT_CHARS]
ok = not bool(getattr(result, "isError", False))
return {
"ok": ok,
"content": content,
"summary": (content.splitlines()[0][:120] if content else "terminé")
if ok else (content[:120] or "erreur outil MCP"),
}
def statuses(self) -> dict[str, dict]:
state = get_mcp_state()
out = {}
for sid, st in state.items():
if sid in self._conns:
s = "connected"
elif sid in self._errors:
s = "error"
else:
s = "inactive"
out[sid] = {
"state": s if st["enabled"] else "inactive",
"error": self._errors.get(sid),
"tools": len(self._conns[sid].tools) if sid in self._conns else 0,
}
return out
def notices(self) -> list[str]:
out, self._notices = self._notices, []
return out
async def test_server(self, sid: str) -> dict:
"""Connexion d'essai indépendante (n'altère pas les sessions)."""
conn = _ServerConn(sid)
try:
await conn.start()
return {"ok": True,
"tools": [d["function"]["name"] for d in conn.tools],
"error": None}
except Exception as exc:
return {"ok": False, "tools": [],
"error": str(exc)[:300] or exc.__class__.__name__}
finally:
await conn.close()
async def aclose(self) -> None:
for conn in list(self._conns.values()):
await conn.close()
self._conns.clear()
manager = McpManager()
+110
View File
@@ -0,0 +1,110 @@
"""Mémoire de conversation compressée — le vrai levier des petits modèles.
Un petit modèle se noie dans un long historique : il oublie la consigne, part
en boucle, et un grand num_ctx le fait déborder du GPU. On garde donc :
[invite système] + [résumé compact des anciens tours] + [N derniers messages]
Le résumé est régénéré en arrière-plan (après la réponse, sans latence pour
l'utilisateur) dès que l'historique dépasse le seuil.
"""
from __future__ import annotations
import logging
import httpx
from . import db
from .ollama_client import OllamaError, ollama
logger = logging.getLogger(__name__)
# Nombre de messages récents passés tels quels au modèle.
KEEP_RECENT = 10
# Au-delà de ce total, les anciens tours sont compressés dans le résumé.
SUMMARIZE_AFTER = KEEP_RECENT + 6
_SUMMARY_PROMPT = (
"Résume la conversation ci-dessous en français, en 10 lignes maximum. "
"Conserve impérativement : l'objectif de l'utilisateur, les décisions "
"prises, les fichiers créés/modifiés et leur rôle, et les points encore "
"ouverts. Réponds UNIQUEMENT par le résumé."
)
def build_convo(sid: str, system_prompt: str) -> list[dict]:
"""Construit le contexte : système + résumé éventuel + messages récents.
Le résumé est fusionné DANS l'invite système plutôt qu'ajouté comme second
message système : de nombreux templates (Gemma, Mistral…) rejettent tout
message système qui n'est pas le premier de la liste.
"""
session = db.get_session(sid) or {}
summary = (session.get("summary") or "").strip()
messages = db.list_messages_for_model(sid)
if summary and len(messages) > KEEP_RECENT:
system_prompt = (
f"{system_prompt}\n\nRésumé des échanges précédents :\n{summary}"
)
messages = messages[-KEEP_RECENT:]
return [{"role": "system", "content": system_prompt}, *messages]
async def maybe_summarize(
sid: str,
model: str,
*,
options: dict | None = None,
keep_alive: str | None = None,
) -> None:
"""Compresse les anciens tours dans le résumé (tâche d'arrière-plan).
``options`` doit reprendre les options runner du chat (num_ctx…) pour ne
pas déclencher un rechargement du modèle après chaque réponse.
"""
try:
messages = db.list_messages_for_model(sid)
if len(messages) <= SUMMARIZE_AFTER:
return
session = db.get_session(sid) or {}
previous = (session.get("summary") or "").strip()
old = messages[:-KEEP_RECENT]
transcript = "\n".join(
f"[{m['role']}] {m['content'][:600]}" for m in old
)[-8000:]
if previous:
transcript = f"[résumé existant] {previous}\n{transcript}"
messages = [
{"role": "system", "content": _SUMMARY_PROMPT},
{"role": "user", "content": transcript},
]
opts = {**(options or {}), "temperature": 0.2, "num_predict": 350}
# think=False : le résumé d'arrière-plan ne doit pas « réfléchir »
# (latence ×3 sur un modèle thinking). Repli si paramètre refusé.
think: bool | None = False
while True:
text = ""
try:
async for chunk in ollama.chat(
model, messages, options=opts, think=think,
keep_alive=keep_alive, stream=True,
):
text += chunk.get("message", {}).get("content", "")
if chunk.get("done"):
break
break
except OllamaError as exc:
if think is False and "think" in str(exc).lower():
think = None
continue
raise
if text.strip():
db.set_session_summary(sid, text.strip())
except (httpx.HTTPError, OSError) as exc:
# Best-effort : un échec de résumé ne doit jamais gêner le chat.
logger.warning("Résumé de session %s impossible : %s", sid, exc)
+154
View File
@@ -0,0 +1,154 @@
"""Mémoire en notes Markdown : l'agent écrit lui-même ce qu'il veut retenir.
Repris d'AJEAN (github.com/nathaninline/jean) : des notes lisibles rangées
dans ``$DATA_DIR/MEMORY/``, que le modèle consulte et enregistre EXPLICITEMENT.
Face au RAG vectoriel, trois avantages décisifs :
- aucun modèle d'embedding requis (marche dès l'installation) ;
- le contenu est inspectable et modifiable à la main ;
- rien n'est mémorisé « à l'insu » : pas de vieille demande sans rapport qui
ressurgit au milieu d'une nouvelle discussion.
Trois modes (config ``memory_mode``) :
off — rien du tout ;
ondemand — les outils memory_search / memory_save sont proposés au modèle ;
always — idem, plus l'injection automatique des notes pertinentes.
"""
from __future__ import annotations
import os
import re
import time
import unicodedata
from .config import settings
MODES = ("off", "ondemand", "always")
# Bornes : une note reste une note, pas une archive.
_MAX_NOTE_CHARS = 4000
_MAX_NOTES = 200
_EXCERPT_CHARS = 400
def _dir() -> str:
path = os.path.join(os.path.abspath(settings.data_dir), "MEMORY")
os.makedirs(path, exist_ok=True)
return path
def _slug(title: str) -> str:
"""Nom de fichier sûr dérivé du titre (confiné au dossier MEMORY)."""
text = unicodedata.normalize("NFKD", title or "").encode("ascii", "ignore").decode()
text = re.sub(r"[^a-zA-Z0-9]+", "-", text).strip("-").lower()
return (text or "note")[:60]
def _tokens(text: str) -> set[str]:
"""Mots significatifs, sans accents ni casse (>= 3 lettres)."""
text = unicodedata.normalize("NFKD", text or "").encode("ascii", "ignore").decode()
return {w for w in re.findall(r"[a-z0-9]{3,}", text.lower())}
def save_note(title: str, content: str) -> dict:
"""Crée ou remplace une note. Renvoie {ok, summary}."""
title = (title or "").strip()
content = (content or "").strip()
if not title:
return {"ok": False, "summary": "titre vide"}
if not content:
return {"ok": False, "summary": "contenu vide"}
content = content[:_MAX_NOTE_CHARS]
path = os.path.join(_dir(), f"{_slug(title)}.md")
existed = os.path.isfile(path)
with open(path, "w", encoding="utf-8") as f:
f.write(f"# {title}\n\n{content}\n")
_prune()
verb = "mise à jour" if existed else "enregistrée"
return {"ok": True, "summary": f"note {verb} : {title}"}
def _prune() -> None:
"""Garde les notes les plus récentes (borne dure, jamais de purge totale)."""
files = [os.path.join(_dir(), n) for n in os.listdir(_dir()) if n.endswith(".md")]
if len(files) <= _MAX_NOTES:
return
files.sort(key=lambda p: os.path.getmtime(p), reverse=True)
for path in files[_MAX_NOTES:]:
try:
os.remove(path)
except OSError:
pass
def _read_all() -> list[tuple[str, str]]:
"""[(titre, corps)] de toutes les notes."""
out = []
try:
names = sorted(os.listdir(_dir()))
except OSError:
return out
for name in names:
if not name.endswith(".md"):
continue
try:
with open(os.path.join(_dir(), name), encoding="utf-8", errors="replace") as f:
raw = f.read()
except OSError:
continue
first, _, body = raw.partition("\n")
title = first.lstrip("# ").strip() or name[:-3]
out.append((title, body.strip()))
return out
def search_notes(query: str, limit: int = 3) -> list[dict]:
"""Notes les plus proches de la requête (recouvrement de mots-clés)."""
wanted = _tokens(query)
if not wanted:
return []
scored: list[tuple[int, str, str]] = []
for title, body in _read_all():
score = len(wanted & _tokens(f"{title} {body}"))
if score:
scored.append((score, title, body))
scored.sort(key=lambda item: -item[0])
return [
{"title": title, "content": body[:_EXCERPT_CHARS]}
for _, title, body in scored[:limit]
]
def list_notes() -> list[dict]:
"""Inventaire des notes (pour l'UI / l'inspection)."""
notes = []
for title, body in _read_all():
path = os.path.join(_dir(), f"{_slug(title)}.md")
notes.append({
"title": title,
"chars": len(body),
"updated_at": os.path.getmtime(path) if os.path.isfile(path) else time.time(),
})
return sorted(notes, key=lambda n: -n["updated_at"])
def delete_note(title: str) -> bool:
path = os.path.join(_dir(), f"{_slug(title)}.md")
if not os.path.isfile(path):
return False
os.remove(path)
return True
def recall_block(message: str) -> str | None:
"""Bloc de contexte à injecter en mode ``always`` (None si rien de net)."""
hits = search_notes(message, limit=2)
if not hits:
return None
body = "\n\n".join(f"## {h['title']}\n{h['content']}" for h in hits)
return (
"Notes que tu avais enregistrées et qui semblent liées à cette demande "
"(ignore-les si elles ne s'appliquent pas) :\n" + body
)
+251
View File
@@ -0,0 +1,251 @@
"""Client HTTP léger pour l'API Ollama.
On utilise httpx directement (plutôt que le SDK) pour garder le contrôle
total sur le streaming et n'embarquer aucune dépendance superflue.
"""
from __future__ import annotations
import asyncio
import json
import time
from typing import AsyncIterator
import httpx
from .config import settings
class OllamaError(RuntimeError):
"""Erreur renvoyée par Ollama (statut HTTP ≥ 400 ou champ ``error`` dans le flux).
Ollama signale certains échecs *au milieu* d'un flux streaming (HTTP 200)
via une ligne JSON ``{"error": "..."}`` — typiquement un débordement mémoire
ou un contexte trop grand. On lève alors cette exception pour que l'appelant
la remonte à l'utilisateur au lieu de l'avaler silencieusement.
"""
# Connexion rapide à échouer si Ollama est injoignable, mais lecture sans limite :
# une génération longue (ou un chargement de modèle sur CPU) ne doit pas couper.
_STREAM_TIMEOUT = httpx.Timeout(connect=10.0, read=None, write=30.0, pool=10.0)
# Un chat déclenche plusieurs appels Ollama (routage, embed, plan, agent…) :
# un pool keep-alive partagé évite un handshake TCP à chaque appel.
_LIMITS = httpx.Limits(
max_connections=20, max_keepalive_connections=10, keepalive_expiry=30.0
)
async def _raise_for_stream_status(resp: httpx.Response) -> None:
"""Lève une ``OllamaError`` détaillée si la réponse streaming est en erreur.
Sur une réponse en flux, ``raise_for_status`` n'inclut pas le corps ; on le
lit explicitement pour exposer le message d'Ollama (modèle absent, etc.).
"""
if resp.status_code < 400:
return
body = await resp.aread()
detail = body.decode(errors="replace").strip()
try:
detail = json.loads(detail).get("error", detail)
except (json.JSONDecodeError, AttributeError):
pass
raise OllamaError(f"Ollama a renvoyé {resp.status_code} : {detail[:500]}")
class OllamaClient:
"""Enveloppe asynchrone autour de l'API REST d'Ollama."""
def __init__(self, host: str | None = None) -> None:
self.host = (host or settings.ollama_host).rstrip("/")
self._client: httpx.AsyncClient | None = None
# Cache court de /api/tags : la liste des modèles installés change
# rarement mais est consultée par plusieurs modules à chaque message.
self._tags_at = 0.0
self._tags: list[dict] = []
def _http(self) -> httpx.AsyncClient:
"""Client partagé (pool keep-alive), créé paresseusement.
Un seul worker uvicorn / une seule boucle : la création lazy est sûre.
Le garde ``is_closed`` recrée le client si un arrêt l'a fermé.
"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(
timeout=_STREAM_TIMEOUT, follow_redirects=True, limits=_LIMITS
)
return self._client
async def aclose(self) -> None:
"""Ferme le pool partagé (appelé au shutdown de l'app)."""
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
async def ping(self) -> dict:
"""Vérifie la connexion et renvoie la version d'Ollama."""
resp = await self._http().get(f"{self.host}/api/version", timeout=5.0)
resp.raise_for_status()
return resp.json()
async def list_models(self) -> list[dict]:
"""Liste les modèles installés localement (/api/tags), sans cache."""
resp = await self._http().get(f"{self.host}/api/tags", timeout=10.0)
resp.raise_for_status()
models = resp.json().get("models", [])
self._tags, self._tags_at = models, time.monotonic()
return models
async def list_models_cached(self, ttl: float = 30.0) -> list[dict]:
"""Comme ``list_models`` mais avec un cache court partagé.
Utilisé par les chemins chauds (routage code, résolution embed, route
/api/models) pour ne pas marteler /api/tags à chaque message.
"""
if self._tags and time.monotonic() - self._tags_at < ttl:
return self._tags
return await self.list_models()
def invalidate_tags_cache(self) -> None:
"""Force un rafraîchissement après un pull ou une suppression de modèle."""
self._tags_at = 0.0
self._tags = []
async def embed(
self, model: str, texts: list[str], keep_alive: str = "30m"
) -> list[list[float]]:
"""Vecteurs d'embedding pour une liste de textes (/api/embed).
``keep_alive`` long : le modèle d'embedding est minuscule (<0,5 Go) et
sollicité à chaque message (recall + indexation) — le laisser chargé
évite un aller-retour VRAM permanent avec le modèle de chat.
"""
resp = await self._http().post(
f"{self.host}/api/embed",
json={"model": model, "input": texts, "keep_alive": keep_alive},
timeout=30.0,
)
resp.raise_for_status()
return resp.json().get("embeddings", [])
async def ps(self) -> list[dict]:
"""Modèles actuellement chargés et leur répartition VRAM/CPU (/api/ps)."""
resp = await self._http().get(f"{self.host}/api/ps", timeout=5.0)
resp.raise_for_status()
return resp.json().get("models", [])
async def show(self, name: str) -> dict:
"""Métadonnées détaillées d'un modèle (/api/show)."""
resp = await self._http().post(
f"{self.host}/api/show", json={"name": name}, timeout=15.0
)
resp.raise_for_status()
return resp.json()
async def delete_model(self, name: str) -> dict:
"""Supprime un modèle installé (/api/delete)."""
resp = await self._http().request(
"DELETE", f"{self.host}/api/delete", json={"model": name}, timeout=30.0
)
resp.raise_for_status()
self.invalidate_tags_cache()
return resp.json() if resp.content else {"status": "success"}
async def pull_model(self, name: str) -> AsyncIterator[dict]:
"""Télécharge un modèle en streamant la progression (/api/pull)."""
async with self._http().stream(
"POST",
f"{self.host}/api/pull",
json={"name": name},
timeout=_STREAM_TIMEOUT,
) as resp:
await _raise_for_stream_status(resp)
async for line in resp.aiter_lines():
if not line.strip():
continue
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
if isinstance(chunk, dict) and chunk.get("error"):
raise OllamaError(str(chunk["error"]))
yield chunk
self.invalidate_tags_cache()
async def warm(
self, model: str, keep_alive: str = "30m", options: dict | None = None
) -> dict:
"""Précharge un modèle en VRAM sans générer (/api/generate sans prompt).
Le paramètre keep_alive fixe la durée de rétention en mémoire. Les
`options` (num_ctx, num_batch, num_gpu…) DOIVENT correspondre à celles du
chat : sinon Ollama chargerait un runner distinct puis en rechargerait un
autre au premier message — un double chargement très coûteux pour un gros
modèle.
"""
payload: dict = {"model": model, "keep_alive": keep_alive, "stream": False}
if options:
payload["options"] = options
# Le chargement se fait désormais en tâche de fond côté API Loki. On lui
# laisse jusqu'à dix minutes pour les gros modèles ou un stockage lent.
timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
for attempt in range(2):
resp = await self._http().post(
f"{self.host}/api/generate", json=payload, timeout=timeout
)
try:
# Inclut le corps JSON d'Ollama dans l'erreur (OOM, runner…),
# contrairement à raise_for_status qui ne montrait que « 500 ».
await _raise_for_stream_status(resp)
except OllamaError:
if resp.status_code >= 500 and attempt == 0:
await asyncio.sleep(2)
continue
raise
return resp.json()
raise OllamaError("préchargement interrompu sans réponse")
async def chat(
self,
model: str,
messages: list[dict],
*,
tools: list[dict] | None = None,
options: dict | None = None,
think: bool | None = None,
keep_alive: str | None = None,
stream: bool = True,
) -> AsyncIterator[dict]:
"""Conversation avec le modèle, en streaming token par token."""
payload: dict = {"model": model, "messages": messages, "stream": stream}
if tools:
payload["tools"] = tools
if options:
payload["options"] = options
# think=False désactive le raisonnement des modèles « thinking ».
if think is not None:
payload["think"] = think
# keep_alive : durée de maintien du modèle en VRAM après la réponse.
if keep_alive is not None:
payload["keep_alive"] = keep_alive
async with self._http().stream(
"POST", f"{self.host}/api/chat", json=payload, timeout=_STREAM_TIMEOUT
) as resp:
await _raise_for_stream_status(resp)
async for line in resp.aiter_lines():
if not line.strip():
continue
try:
chunk = json.loads(line)
except json.JSONDecodeError:
# Ligne partielle / non-JSON : on l'ignore plutôt que de
# faire planter tout le flux.
continue
# Échec en cours de génération (OOM, contexte trop grand…) :
# Ollama l'émet dans le flux avec HTTP 200. On le remonte.
if isinstance(chunk, dict) and chunk.get("error"):
raise OllamaError(str(chunk["error"]))
yield chunk
ollama = OllamaClient()
+155
View File
@@ -0,0 +1,155 @@
"""Mémoire long-terme (RAG) : l'agent se souvient des anciennes sessions.
Chaque échange (question + réponse) est vectorisé via /api/embed d'Ollama et
stocké en SQLite. À chaque nouveau message, on recherche les souvenirs les
plus proches (cosinus) dans les AUTRES sessions et on les injecte en contexte.
Tout est best-effort : sans modèle d'embedding installé, le RAG se désactive
silencieusement (aucun impact sur le chat).
"""
from __future__ import annotations
import asyncio
import json
import logging
import math
import time
import uuid
import httpx
from . import db
from .ollama_client import ollama
logger = logging.getLogger(__name__)
# Modèles d'embedding reconnus, par ordre de préférence.
_EMBED_HINTS = ("nomic-embed", "mxbai-embed", "bge-", "snowflake-arctic-embed",
"all-minilm", "embed")
_TOP_K = 3
# Seuil de similarité volontairement élevé : une mémoire n'est rappelée que si
# elle est FORTEMENT proche de la demande. Trop bas (0.45), un « jeu d'échecs »
# rappelait une « appli sport » et déroutait le modèle.
_MIN_SCORE = 0.6
_MAX_MEMORIES = 2000 # au-delà, on élague les plus anciens
_embed_model_cache: dict = {"value": None, "checked_at": 0.0}
def init_table() -> None:
with db._LOCK, db._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
content TEXT NOT NULL,
embedding TEXT NOT NULL,
created_at REAL NOT NULL
)
"""
)
async def resolve_embed_model(preference: str | None = None) -> str | None:
"""Trouve le modèle d'embedding à utiliser (None = RAG indisponible)."""
if preference and preference != "auto":
return preference
# Cache 60 s pour ne pas marteler /api/tags.
now = time.time()
if now - _embed_model_cache["checked_at"] < 60:
return _embed_model_cache["value"]
value = None
try:
for m in await ollama.list_models_cached():
name = (m.get("name") or "").lower()
if any(h in name for h in _EMBED_HINTS):
value = m["name"]
break
except (httpx.HTTPError, OSError):
value = None
_embed_model_cache.update(value=value, checked_at=now)
return value
def _cosine(a: list[float], b: list[float]) -> float:
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(x * x for x in b))
return dot / (na * nb) if na and nb else 0.0
def _score_rows(qvec: list[float], rows: list) -> list[str]:
"""Scoring cosinus sur toutes les mémoires — CPU pur, à lancer via to_thread.
Jusqu'à _MAX_MEMORIES vecteurs : la boucle Python bloquerait l'event loop
(et donc tous les SSE en cours) pendant plusieurs dizaines de ms.
"""
scored: list[tuple[float, str]] = []
for row in rows:
try:
score = _cosine(qvec, json.loads(row["embedding"]))
except (ValueError, TypeError):
continue
if score >= _MIN_SCORE:
scored.append((score, row["content"]))
scored.sort(reverse=True)
return [c for _, c in scored[:_TOP_K]]
async def index_exchange(
sid: str, user_text: str, assistant_text: str, *, embed_model: str | None
) -> None:
"""Indexe un échange terminé (tâche d'arrière-plan, best-effort)."""
model = await resolve_embed_model(embed_model)
if not model:
return
content = f"Q: {user_text[:500]}\nR: {assistant_text[:800]}"
try:
vectors = await ollama.embed(model, [content])
if not vectors:
return
with db._LOCK, db._connect() as conn:
conn.execute(
"INSERT INTO memories (id, session_id, content, embedding, created_at)"
" VALUES (?, ?, ?, ?, ?)",
(uuid.uuid4().hex, sid, content,
json.dumps(vectors[0]), time.time()),
)
# Élagage des souvenirs les plus anciens.
conn.execute(
"DELETE FROM memories WHERE id IN ("
" SELECT id FROM memories ORDER BY created_at DESC"
f" LIMIT -1 OFFSET {_MAX_MEMORIES})"
)
except (httpx.HTTPError, OSError) as exc:
logger.warning("Indexation RAG impossible : %s", exc)
async def recall(
sid: str, query: str, *, embed_model: str | None
) -> list[str]:
"""Souvenirs pertinents issus des AUTRES sessions (top-k, score minimal)."""
model = await resolve_embed_model(embed_model)
if not model:
return []
try:
vectors = await ollama.embed(model, [query[:800]])
if not vectors:
return []
qvec = vectors[0]
with db._LOCK, db._connect() as conn:
rows = conn.execute(
"SELECT content, embedding FROM memories WHERE session_id != ?",
(sid,),
).fetchall()
return await asyncio.to_thread(_score_rows, qvec, rows)
except (httpx.HTTPError, OSError) as exc:
logger.warning("Rappel RAG impossible : %s", exc)
return []
+69
View File
@@ -0,0 +1,69 @@
"""Routeur automatique : moteur code (Aider) ou boucle agent classique ?
Invisible pour l'utilisateur : chaque message est classé par une heuristique
lexicale instantanée. Les cas ambigus partent vers la boucle agent, qui garde
l'outil `code_task` en secours — plus d'appel LLM bloquant avant le premier
token (l'ancien micro-classifieur coûtait un aller-retour modèle complet et
chargeait un runner divergent).
"""
from __future__ import annotations
import re
# Vocabulaire fortement lié au code / au développement.
_STRONG = re.compile(
r"\b(code|coder?|script|fonction|classe|refactor|bug|d[ée]bug|html|css|"
r"javascript|js|python|typescript|react|api|composant|page|site|landing|"
r"formulaire|d[ée]veloppe|impl[ée]mente|programme|widget|frontend|backend)\b",
re.I,
)
# Extensions de fichiers mentionnées explicitement.
_FILE_EXT = re.compile(
r"\.(py|js|ts|tsx|jsx|html?|css|json|md|sh|sql|ya?ml|go|rs|java|php|vue)\b", re.I
)
# Verbes d'action de création/modification.
_ACTION = re.compile(
r"\b(cr[ée]e[rs]?|modifie[rs]?|corrige[rs]?|ajoute[rs]?|[ée]cri[st]|refai[st]|"
r"am[ée]liore[rs]?|fixe?|update|change[rs]?|construis|g[ée]n[èe]re[rs]?)\b",
re.I,
)
def score_code_task(message: str) -> int:
"""Score heuristique : >= 3 -> code, <= 2 -> agent."""
score = 0
if _STRONG.search(message):
score += 2
if _FILE_EXT.search(message):
score += 2
if _ACTION.search(message):
score += 1
if "```" in message:
score += 2
return score
def is_code_task(message: str) -> bool:
"""Décision : heuristique pure, aucune requête modèle."""
return score_code_task(message) >= 3
# Formulations de « suite de travail » : courtes, sans vocabulaire code
# explicite, mais qui prolongent clairement la tâche en cours.
_FOLLOWUP = re.compile(
r"\b(continue[rs]?|reprend[s]?|poursui[st]|termine[rs]?|finis|"
r"rajoute[rs]?|enl[èe]ve[rs]?|retire[rs]?|supprime[rs]?|d[ée]place[rs]?|"
r"agrandi[st]|r[ée]dui[st]|remet[s]?|remplace[rs]?|inverse[rs]?|"
r"plut[ôo]t|aussi|encore|pareil|m[êe]me chose|comme avant)\b",
re.I,
)
def is_code_followup(message: str) -> bool:
"""Reprise probable d'un travail de code en cours.
À n'utiliser que si le tour précédent de la session était du code : un
« ajoute un bouton rouge » isolé n'est pas du code, mais après un
code_task, si.
"""
return bool(_FOLLOWUP.search(message)) or score_code_task(message) >= 1
View File
Whitespace-only changes.
+42
View File
@@ -0,0 +1,42 @@
"""Routes du benchmark de modèles."""
from __future__ import annotations
import json
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import bench
router = APIRouter(prefix="/api/bench", tags=["bench"])
class BenchRequest(BaseModel):
model: str
@router.get("")
async def scores() -> dict:
return {"scores": bench.get_scores()}
@router.post("")
async def run(req: BenchRequest) -> StreamingResponse:
async def event_stream():
try:
async for ev in bench.run_bench(req.model):
etype = ev.pop("type")
yield f"event: {etype}\ndata: {json.dumps(ev, ensure_ascii=False)}\n\n"
except Exception as exc:
payload = json.dumps(
{"message": f"benchmark interrompu : {str(exc)[:200]}"},
ensure_ascii=False,
)
yield f"event: error\ndata: {payload}\n\n"
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"},
)
+746
View File
@@ -0,0 +1,746 @@
"""Route de conversation : routage automatique agent / moteur code + SSE.
Flux :
1. on enregistre le message utilisateur ;
2. le routeur classe la demande : tâche de code -> moteur code (Aider),
sinon -> boucle agentique classique (qui peut elle-même appeler code_task) ;
3. on relaie tokens et événements d'outils au client (SSE) ;
4. on enregistre la réponse finale de l'assistant + le récap des outils.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import re
import time
from contextlib import suppress
import httpx
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import (
agent_config, coder, db, enhance, memory, memory_notes, rag, skills, tools,
)
from .. import router as msg_router
from ..tools import check_html, _safe_path
from ..agent import run_agent
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api", tags=["chat"])
logger = logging.getLogger(__name__)
class ChatRequest(BaseModel):
session_id: str
content: str
model: str | None = None
# Mode d'exécution : "plan" (lecture seule), "build" (normal), "yolo" (auto).
mode: str = "build"
# Outils autorisés en mode Plan : lecture/analyse uniquement.
_READONLY_TOOLS = {"read_file", "list_dir", "grep_search", "run_check"}
def _apply_mode(cfg: dict, mode: str) -> dict:
"""Adapte la config au mode d'exécution choisi dans le composer."""
cfg = dict(cfg)
if mode == "plan":
# Lecture seule : aucune écriture, aucun shell, aucun moteur code.
cfg["tools"] = {
name: (on and name in _READONLY_TOOLS)
for name, on in cfg["tools"].items()
}
cfg["plan_mode"] = True
elif mode == "yolo":
# Autonomie maximale : plus de validation shell.
cfg["confirm_shell"] = False
# "build" : comportement par défaut (inchangé).
return cfg
async def _placement_notice(model: str) -> str | None:
"""Avertit si le modèle tourne (au moins en partie) hors du GPU.
C'est LA cause d'un débit ridicule (quelques jetons/s) : Ollama a placé
tout ou partie des couches en RAM. Loki ne peut pas le corriger — le
placement appartient à Ollama — mais il peut le NOMMER, au lieu de
laisser croire à un blocage de l'application.
"""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return None
for m in loaded:
if (m.get("name") or m.get("model")) != model:
continue
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
if size <= 0 or vram >= size * 0.99:
return None
pct = int(vram / size * 100)
in_ram = (size - vram) / 1e9
where = "entièrement en RAM" if pct == 0 else f"{pct} % en VRAM"
return (
f"⚠️ {model} tourne {where} ({in_ram:.1f} Go hors GPU) : "
"le débit sera de quelques jetons par seconde. Réduis le contexte, "
"prends un modèle plus petit, ou répartis sur tes GPU côté Ollama."
)
return None
def _loading_message(model: str, seconds: float) -> str:
"""Message d'attente avant le premier jeton (chargement du modèle)."""
waited = int(seconds)
delay = f"{waited // 60} min" if waited >= 60 else f"{waited} s"
text = f"Chargement de {model} en mémoire… ({delay})"
if waited >= 90:
# On CONSTATE l'attente sans présumer de la cause : selon la machine,
# un gros modèle peut se répartir sur plusieurs GPU, ou déborder sur
# le CPU. Le panneau Matériel donne le placement réel.
text += " — placement réel visible dans Réglages › Matériel."
return text
def _merge_system(convo: list[dict], extras: list[str]) -> None:
"""Garantit UN SEUL message système, en première position.
Beaucoup de templates Ollama (Gemma, Mistral…) contiennent un garde
« {% if role == 'system' and not loop.first %}{{ raise_exception(...) }} » :
tout message système qui n'est pas le premier fait échouer la requête
entière en 400 (« System message must be at the beginning »), y compris
la génération du parseur d'outils.
On fusionne donc, dans l'ordre : l'invite système, les éventuels systèmes
égarés (résumé de session…) puis les consignes du tour. Modifie ``convo``
sur place.
"""
head = ""
stray: list[str] = []
rest: list[dict] = []
for i, msg in enumerate(convo):
if msg.get("role") == "system":
text = (msg.get("content") or "").strip()
if i == 0:
head = text
elif text:
stray.append(text)
else:
rest.append(msg)
blocks = [b for b in (head, *stray, *(e.strip() for e in extras if e)) if b]
convo[:] = ([{"role": "system", "content": "\n\n".join(blocks)}] if blocks else []) + rest
def _sse(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
def _prev_was_code(history: list[dict]) -> bool:
"""Le dernier tour assistant de la session était-il un travail de code ?"""
for m in reversed(history):
if m["role"] != "assistant":
continue
meta = m.get("meta") or {}
if meta.get("engine") == "code":
return True
tools = meta.get("tools") or []
return any(
t.get("name") in ("code_task", "write_file", "edit_file")
for t in tools
)
return False
def _session_code_context(history: list[dict]) -> tuple[str, list[str]]:
"""Récap compact du travail en cours + fichiers touchés dans la session.
Le moteur code ne reçoit que le message courant : sur une reprise
(« corrige les bugs »), sans ce récap il ignore quel fichier, quel projet
et quelle demande d'origine. Les fichiers touchés servent aussi de cible
par défaut pour Aider.
"""
root = tools.active_root()
files: list[str] = []
for m in history:
if m["role"] != "assistant":
continue
for t in (m.get("meta") or {}).get("tools") or []:
candidates: list[str] = []
path = (t.get("args") or {}).get("path")
if t.get("name") in ("write_file", "edit_file") and path:
candidates.append(str(path))
for f in t.get("files") or []:
candidates.append(str(f))
for c in candidates:
rel = c.replace("\\", "/").lstrip("./")
if rel not in files and os.path.isfile(os.path.join(root, rel)):
files.append(rel)
user_msgs = [m["content"].strip() for m in history if m["role"] == "user"]
lines: list[str] = []
if user_msgs:
lines.append(f"- Demande initiale : {user_msgs[0][:200]}")
for prev in user_msgs[-2:]:
if prev != user_msgs[0]:
lines.append(f"- Puis : {prev[:200]}")
if files:
lines.append(f"- Fichiers déjà créés/modifiés : {', '.join(files[:8])}")
recap = (
"Contexte de la session (travail en cours) :\n" + "\n".join(lines)
if lines else ""
)
return recap, files
def _workspace_listing(limit: int = 40) -> list[str]:
"""Chemins relatifs des fichiers de la racine active (aperçu compact)."""
root = tools.active_root()
out: list[str] = []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
for name in sorted(filenames):
if name.startswith("."):
continue
rel = os.path.relpath(os.path.join(dirpath, name), root)
out.append(rel.replace("\\", "/"))
if len(out) >= limit:
return out
return out
_FILE_MENTION = re.compile(r"[\w][\w./\\-]*\.[a-z0-9]{1,5}\b", re.I)
def _mentioned_files(text: str) -> list[str]:
"""Fichiers du workspace explicitement cités dans le message.
Transmis au moteur code pour qu'Aider travaille directement sur les bons
fichiers au lieu de deviner via la repo map.
"""
root = tools.active_root()
out: list[str] = []
for raw in _FILE_MENTION.findall(text):
rel = raw.replace("\\", "/").lstrip("./")
if os.path.isfile(os.path.join(root, rel)) and rel not in out:
out.append(rel)
return out[:8]
async def _run_aider_keepalive(
instruction: str, model: str, files: list[str] | None = None
):
"""Lance Aider dans un thread en gardant le flux SSE vivant."""
# Racine résolue AVANT le thread : la contextvar projet ne suit pas
# dans asyncio.to_thread.
root = tools.active_root()
task = asyncio.create_task(
asyncio.to_thread(coder.run_code_task, instruction, model, files, root)
)
while not task.done():
await asyncio.sleep(10)
if not task.done():
yield None # signal keepalive
yield await task
async def _code_stream(
req: ChatRequest,
model: str,
*,
extra: str = "",
plan: list[str] | None = None,
files: list[str] | None = None,
):
"""Chemin « moteur code » : Aider + vérification HTML avec auto-correction."""
instruction = req.content + (extra or "")
yield _sse("tool_call", {"name": "code_task", "args": {"instruction": req.content}})
result = None
async for item in _run_aider_keepalive(instruction, model, files):
if item is None:
yield ": keepalive\n\n"
else:
result = item
status = "ok" if result.get("ok") else "error"
record = {
"name": "code_task",
"args": {"instruction": req.content},
"summary": result.get("summary", "terminé"),
"status": status,
}
tools_meta = [record]
yield _sse("tool_result", record)
all_files = list(result.get("files") or [])
# Vérification des pages HTML produites + une passe d'auto-correction.
html_issues: list[str] = []
for f in all_files:
if f.lower().endswith((".html", ".htm")):
try:
issues = check_html(_safe_path(f))
except Exception:
issues = []
if issues:
html_issues.append(f"{f} : " + " ; ".join(issues))
if html_issues and result.get("ok"):
yield _sse("tool_call", {"name": "html_check", "args": {"path": "vérification"}})
yield _sse("tool_result", {
"name": "html_check", "args": {"path": "vérification"},
"summary": " | ".join(html_issues)[:200], "status": "error",
})
tools_meta.append({
"name": "html_check", "args": {},
"summary": " | ".join(html_issues)[:200], "status": "error",
})
fix_instruction = (
"Corrige ces problèmes détectés dans les fichiers HTML, sans rien "
"casser d'autre :\n" + "\n".join(html_issues)
)
fix = None
async for item in _run_aider_keepalive(fix_instruction, model):
if item is None:
yield ": keepalive\n\n"
else:
fix = item
fix_rec = {
"name": "code_task",
"args": {"instruction": "auto-correction HTML"},
"summary": fix.get("summary", "terminé"),
"status": "ok" if fix.get("ok") else "error",
}
tools_meta.append(fix_rec)
yield _sse("tool_call", {"name": "code_task", "args": fix_rec["args"]})
yield _sse("tool_result", fix_rec)
for f in fix.get("files") or []:
if f not in all_files:
all_files.append(f)
if fix.get("text"):
result["text"] = (result.get("text") or "") + "\n\n" + fix["text"]
# Cartes par fichier modifié (réutilise le rendu write_file de l'UI).
for f in all_files:
file_rec = {
"name": "write_file",
"args": {"path": f},
"summary": "modifié par le moteur code",
"status": "ok",
}
tools_meta.append(file_rec)
yield _sse("tool_call", {"name": "write_file", "args": {"path": f}})
yield _sse("tool_result", file_rec)
text = result.get("text") or (
"" if result.get("ok") else f"⚠️ {result.get('summary', 'échec du moteur code')}"
)
if text:
yield _sse("token", {"content": text})
meta: dict = {"tools": tools_meta, "engine": "code"}
if plan:
meta["plan"] = plan
db.add_message(req.session_id, "assistant", text, model, meta=meta)
yield _sse("done", {"content": text, "tools": tools_meta})
@router.post("/chat")
async def chat(req: ChatRequest) -> StreamingResponse:
session = db.get_session(req.session_id)
if not session:
raise HTTPException(404, "session introuvable")
model = req.model or session.get("model") or settings.default_model
cfg = _apply_mode(agent_config.get_config(model), req.mode)
# Projet de la session : re-racine outils, shell, Aider et aides de
# contexte pour tout le tour. Projet disparu -> retour racine + notice.
project = session.get("project") or None
project_missing = False
if project:
proj_dir = os.path.join(os.path.abspath(settings.workspace_dir), project)
if not os.path.isdir(proj_dir):
project_missing, project = True, None
tools.set_project(project)
history = db.list_messages(req.session_id)
# Premier message : titre la session avec un extrait.
if not history:
title = req.content.strip().split("\n")[0][:60] or "Nouvelle session"
db.rename_session(req.session_id, title)
db.add_message(req.session_id, "user", req.content, None)
# Routage automatique : moteur code si la demande est une tâche de code —
# ou la SUITE d'un travail de code (« ajoute un bouton », « continue »…),
# que l'heuristique seule classerait à tort en discussion.
prev_code = _prev_was_code(history)
use_code = (
cfg["tools"].get("code_task", True)
and coder.available()
and (
msg_router.is_code_task(req.content)
or (prev_code and msg_router.is_code_followup(req.content))
)
)
# Mémoire compressée : système + résumé des anciens tours + messages récents.
convo = memory.build_convo(req.session_id, cfg["system_prompt"])
# Options runner partagées par TOUS les appels au modèle de chat (plan,
# résumé, agent) : indispensables pour qu'Ollama garde le même runner.
run_opts = agent_config.runner_options(cfg)
keep = cfg.get("keep_alive", "30m")
async def event_stream():
yield _sse("start", {"model": model, "engine": "code" if use_code else "agent"})
if project_missing:
yield _sse("notice", {"message": (
"Projet de la session introuvable sur le disque — retour au "
"workspace."
)})
# Préparation du contexte APRÈS le start SSE et en PARALLÈLE : rappel
# RAG, plan et choix du modèle code partent ensemble au lieu de
# s'enchaîner en bloquant le premier token.
want_rag = cfg.get("rag_enabled", False)
# Un plan « from scratch » n'a de sens que pour une NOUVELLE construction.
# Pour une correction ou la suite d'un travail (l'appli existe déjà,
# session code en cours, formulation de suivi), il est hors-sujet et
# perturbe : on ne replanifie pas l'architecture à chaque message.
is_modification = prev_code or bool(_workspace_listing())
new_build = use_code and not is_modification
want_plan = cfg.get("plan_mode", True) and (
new_build or (not use_code and enhance.needs_plan(req.content))
)
if want_rag or want_plan or use_code:
yield _sse("status", {"message": "Préparation du contexte…"})
from ..mcp_client import manager as mcp_manager
# La préparation peut déclencher un CHARGEMENT de modèle (plan, embed)
# qui dure plusieurs minutes sur un gros modèle. Sans battement de
# cœur, le silence SSE fait couper la connexion par le reverse proxy.
prep = asyncio.ensure_future(asyncio.gather(
rag.recall(req.session_id, req.content, embed_model=cfg.get("embed_model"))
if want_rag else asyncio.sleep(0, result=[]),
enhance.make_plan(model, req.content, code=use_code,
options=run_opts, keep_alive=keep)
if want_plan else asyncio.sleep(0, result=[]),
coder.pick_code_model(model, cfg.get("code_model"))
if use_code else asyncio.sleep(0, result=model),
mcp_manager.tool_definitions(),
))
while True:
try:
memories, plan, code_model, mcp_tools = await asyncio.wait_for(
asyncio.shield(prep), timeout=10.0
)
break
except TimeoutError:
yield _sse("ping", {"status": "waiting"})
# Pannes MCP éventuelles : notice non bloquante dans le fil.
for mcp_notice in mcp_manager.notices():
yield _sse("notice", {"message": mcp_notice})
# Placement du modèle : dire tout de suite si l'inférence est hors GPU,
# au lieu de laisser l'utilisateur attribuer la lenteur à Loki.
placement = await _placement_notice(model)
if placement:
yield _sse("notice", {"message": placement})
# ── Consignes additionnelles du tour ──────────────────────────────
# Elles sont COLLECTÉES ici puis fusionnées dans l'UNIQUE message
# système (voir _merge_system plus bas). Beaucoup de templates
# (Gemma, Mistral…) lèvent « System message must be at the beginning »
# dès qu'un second message system apparaît : les empiler faisait
# échouer toute la requête en 400.
extras: list[str] = []
# La demande touche-t-elle au code / aux fichiers ? Calculé AVANT les
# injections : tout ce qui suit est conditionné à cette réponse.
is_code_like = use_code or msg_router.is_code_task(req.content)
needs_workspace = is_code_like or prev_code
if memories:
extras.append(
"Souvenirs pertinents d'anciennes sessions :\n"
+ "\n---\n".join(memories)
)
# État du workspace : indispensable pour travailler sur les fichiers,
# mais inutile pour une simple discussion. On l'injectait à CHAQUE
# message — un « bonjour » partait avec des milliers de jetons de
# contexte, donc une longue phase de traitement avant le 1er jeton.
listing = _workspace_listing() if needs_workspace else []
if listing:
recap, session_files = _session_code_context(history)
parts = ["Fichiers du workspace : " + ", ".join(listing)]
if session_files:
parts.append(
"Fichiers de la tâche en cours : " + ", ".join(session_files[:8])
)
extras.append("\n".join(parts))
# Session code restée en chemin agent : pousse le modèle à AGIR sur
# les fichiers au lieu de décrire les changements — cause fréquente de
# « l'agent s'arrête sans rien modifier » sur une reprise de code.
if prev_code and not use_code:
extras.append(
"Cette session travaille sur du code existant du workspace. "
"Pour toute demande de modification ou d'ajout : AGIS avec "
"les outils — code_task pour un changement multi-fichiers, "
"edit_file pour un changement ciblé, write_file pour un "
"nouveau fichier. Lis le fichier concerné avant de le "
"modifier, puis modifie-le RÉELLEMENT. Ne colle JAMAIS le "
"code corrigé dans ta réponse sans l'avoir écrit dans le "
"fichier."
)
# Skill : méthode experte injectée pour ce tour (jamais persistée).
if cfg.get("skills_enabled", True):
skill = skills.pick_skill(req.content)
if skill:
extras.append("Méthode à suivre pour cette tâche :\n" + skill["body"])
yield _sse("notice", {"message": f"📘 Méthode : {skill['title']}"})
# Ponytail : méthode « code minimal » injectée pour toute tâche de code
# (les deux chemins). Contre la sur-ingénierie qui casse les rendus.
if cfg.get("ponytail", True) and is_code_like:
extras.append(skills.PONYTAIL_GUIDANCE)
# Appli web : contraintes dures (fichier autonome, zéro dépendance
# externe, rendu réel) pour que ça marche vraiment hors-ligne.
want_web = is_code_like and skills.is_web_task(req.content)
if want_web:
extras.append(skills.WEBAPP_GUIDANCE)
# Mémoire en notes : en mode « always », on injecte les notes liées à la
# demande. Rien n'est deviné — ce sont des notes que l'agent a
# lui-même écrites, et le bloc dit explicitement de les ignorer si
# elles ne s'appliquent pas.
memory_mode = cfg.get("memory_mode", "ondemand")
# Mode off : les outils n'existent pas pour le modèle (principe AJEAN).
# En mode Plan (lecture seule), la consultation reste permise mais pas
# l'écriture d'une note.
memory_tools = (
[] if memory_mode == "off"
else ["memory_search"] if req.mode == "plan"
else ["memory_search", "memory_save"]
)
if memory_mode == "always":
block = memory_notes.recall_block(req.content)
if block:
extras.append(block)
if plan:
yield _sse("plan", {"steps": plan})
# Chemin « moteur code » : Aider gère la tâche de bout en bout.
if use_code:
instruction_plan = (
"\n\nPlan à suivre :\n"
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
if plan else ""
)
# Reprise : Aider ne voit que le message courant — on lui donne le
# récap de session et, à défaut de fichiers cités, ceux déjà
# touchés (« corrige les bugs » => il ouvre le bon fichier).
recap, session_files = _session_code_context(history)
extra = instruction_plan
if recap:
extra = f"\n\n{recap}" + extra
# Le moteur code (Aider) ne voit pas convo : on lui redonne Ponytail
# (et les contraintes web) directement dans la consigne.
if want_web:
extra = "\n\n" + skills.WEBAPP_GUIDANCE + extra
if cfg.get("ponytail", True):
extra = "\n\n" + skills.PONYTAIL_GUIDANCE + extra
code_files = _mentioned_files(req.content) or session_files[:8]
async for chunk in _code_stream(
req, code_model, extra=extra, plan=plan, files=code_files,
):
yield chunk
asyncio.create_task(memory.maybe_summarize(
req.session_id, model, options=run_opts, keep_alive=keep,
))
if cfg.get("rag_enabled", False):
last = db.list_messages(req.session_id)
answer = last[-1]["content"] if last else ""
asyncio.create_task(rag.index_exchange(
req.session_id, req.content, answer,
embed_model=cfg.get("embed_model"),
))
return
if plan:
extras.append(
"Plan à suivre pour cette demande, étape par étape :\n"
+ "\n".join(f"{i+1}. {s}" for i, s in enumerate(plan))
+ "\n\nTraite les étapes DANS L'ORDRE. Dès qu'une étape est "
"réellement accomplie, écris sur une ligne seule "
"« ✅ Étape N terminée » (N = son numéro) avant de passer à "
"la suivante. N'annonce jamais une étape terminée à l'avance."
)
# Fusion : un SEUL message système, en tête. Indispensable pour les
# templates qui refusent tout system ailleurs qu'en première position.
_merge_system(convo, extras)
final_content = ""
tools_meta: list[dict] = []
stats_meta: dict | None = None
thinking_meta = ""
error_message = ""
queue: asyncio.Queue[dict | None] = asyncio.Queue()
async def produce_events() -> None:
try:
async for event in run_agent(
model,
convo,
options=agent_config.ollama_options(cfg),
enabled_tools=agent_config.enabled_tool_names(cfg) + memory_tools,
confirm_shell=cfg.get("confirm_shell", True),
think=cfg.get("think", True),
keep_alive=cfg.get("keep_alive", "30m"),
mcp_tools=mcp_tools,
plan=plan,
):
await queue.put(event)
except Exception as exc:
logger.exception("Échec inattendu du flux de chat")
await queue.put(
{
"type": "error",
"message": f"Erreur interne du chat : {exc}",
}
)
finally:
await queue.put(None)
producer = asyncio.create_task(produce_events())
started = time.monotonic()
got_event = False
try:
while True:
try:
ev = await asyncio.wait_for(queue.get(), timeout=15.0)
except TimeoutError:
# Empêche OpenResty/Nginx/Cloudflare de fermer le SSE pendant
# le chargement parfois long d'un modèle Ollama.
yield _sse("ping", {"status": "waiting"})
# …et DIT ce qui se passe : sans ça, l'interface restait sur
# « Connexion à Ollama… » pendant tout le chargement d'un
# gros modèle, sans moyen de distinguer lenteur et blocage.
if not got_event:
yield _sse("status", {
"message": _loading_message(
model, time.monotonic() - started
)
})
continue
got_event = True
if ev is None:
break
etype = ev.pop("type")
if etype in (
"token",
"thinking",
"status",
"notice",
"tool_call",
"tool_result",
"tool_confirm",
"plan_step",
):
yield _sse(etype, ev)
elif etype == "error":
error_message = ev.get("message", "Erreur Ollama inconnue")
yield _sse("error", {"message": error_message})
elif etype == "final":
final_content = ev["content"]
tools_meta = ev["tools"]
stats_meta = ev.get("stats")
thinking_meta = ev.get("thinking", "")
finally:
if not producer.done():
producer.cancel()
with suppress(asyncio.CancelledError):
await producer
# Auto-critique : relecture éclair puis révision (option « Qualité + »).
if (
cfg.get("self_review", False)
and final_content
and not error_message
and not tools_meta
):
yield _sse("status", {"message": "Relecture de la réponse…"})
revised = await enhance.self_review(
model, req.content, final_content,
options=run_opts, keep_alive=keep,
)
if revised:
final_content = revised
yield _sse("revision", {"content": revised})
yield _sse("notice", {"message": "Réponse révisée après auto-critique ✓"})
if final_content or tools_meta:
meta: dict = {}
if tools_meta:
meta["tools"] = tools_meta
if stats_meta:
meta["stats"] = stats_meta
if thinking_meta:
meta["thinking"] = thinking_meta
if plan:
meta["plan"] = plan
db.add_message(
req.session_id,
"assistant",
final_content,
model,
meta=meta or None,
)
yield _sse(
"done",
{
"content": final_content,
"tools": tools_meta,
"stats": stats_meta,
"error": error_message or None,
},
)
# Tâches d'arrière-plan : compression de l'historique + mémoire RAG.
asyncio.create_task(memory.maybe_summarize(
req.session_id, model, options=run_opts, keep_alive=keep,
))
if cfg.get("rag_enabled", False) and final_content:
asyncio.create_task(rag.index_exchange(
req.session_id, req.content, final_content,
embed_model=cfg.get("embed_model"),
))
return StreamingResponse(
event_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no",
},
)
+86
View File
@@ -0,0 +1,86 @@
"""Routes de lecture et mise à jour de la configuration de l'agent."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import agent_config
router = APIRouter(prefix="/api/config", tags=["config"])
class ConfigPatch(BaseModel):
"""Champs modifiables. DOIT couvrir agent_config.PROFILE_FIELDS.
Pydantic ignore silencieusement un champ non déclaré : un oubli ici fait
« sauter » le réglage sans la moindre erreur (c'était le cas de
keep_alive, skills_enabled et ponytail).
"""
system_prompt: str | None = None
temperature: float | None = None
top_p: float | None = None
top_k: int | None = None
max_tokens: int | None = None
num_ctx: int | None = None
num_gpu: int | None = None
num_batch: int | None = None
tools: dict[str, bool] | None = None
confirm_shell: bool | None = None
think: bool | None = None
code_model: str | None = None
plan_mode: bool | None = None
self_review: bool | None = None
rag_enabled: bool | None = None
embed_model: str | None = None
memory_mode: str | None = None
skills_enabled: bool | None = None
ponytail: bool | None = None
keep_alive: str | None = None
@router.get("")
async def get_config(model: str | None = None) -> dict:
return {
"config": agent_config.get_config(model),
"available_tools": agent_config.AVAILABLE_TOOLS,
}
@router.put("")
async def put_config(patch: ConfigPatch, model: str | None = None) -> dict:
cfg = agent_config.save_config(patch.model_dump(exclude_none=True), model)
return {"config": cfg}
# ── Presets : jeux de réglages nommés, commutables (idée reprise d'AJEAN) ──
class PresetBody(BaseModel):
name: str
@router.get("/presets")
async def get_presets() -> dict:
return {"presets": agent_config.list_presets()}
@router.post("/presets")
async def post_preset(body: PresetBody, model: str | None = None) -> dict:
"""Enregistre la configuration courante sous un nom."""
try:
return {"presets": agent_config.save_preset(body.name, model)}
except ValueError as exc:
raise HTTPException(400, str(exc)) from exc
@router.post("/presets/apply")
async def apply_preset(body: PresetBody, model: str | None = None) -> dict:
"""Applique un preset à la configuration courante."""
cfg = agent_config.apply_preset(body.name, model)
if cfg is None:
raise HTTPException(404, "preset introuvable")
return {"config": cfg}
@router.delete("/presets")
async def remove_preset(name: str) -> dict:
return {"presets": agent_config.delete_preset(name)}
+105
View File
@@ -0,0 +1,105 @@
"""Routes du workspace : arborescence, contenu, téléchargement, suppression.
Toutes les routes acceptent un paramètre optionnel ``project`` : la requête
est alors re-racinée sur ``workspace/<projet>`` (même confinement).
"""
from __future__ import annotations
import os
import shutil
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from .. import tools as tools_mod
from ..tools import ToolError, _safe_path
router = APIRouter(prefix="/api/files", tags=["files"])
def _activate(project: str | None) -> None:
"""Pose le projet actif pour la requête (400 si nom invalide)."""
try:
tools_mod.set_project(project or None)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
def _tree(path: str, root: str) -> list[dict]:
"""Arborescence triée (dossiers d'abord) de la racine active."""
items = []
for name in sorted(os.listdir(path)):
if name.startswith("."):
continue
full = os.path.join(path, name)
rel = os.path.relpath(full, root)
if os.path.isdir(full):
items.append({"name": name, "path": rel, "type": "dir",
"children": _tree(full, root)})
else:
items.append({"name": name, "path": rel, "type": "file",
"size": os.path.getsize(full)})
# Dossiers avant fichiers
items.sort(key=lambda x: (x["type"] != "dir", x["name"]))
return items
@router.get("")
async def list_files(project: str | None = None) -> dict:
_activate(project)
root = tools_mod.active_root()
return {"tree": _tree(root, root)}
@router.get("/content")
async def file_content(path: str, project: str | None = None) -> dict:
_activate(project)
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc))
if not os.path.isfile(target):
raise HTTPException(404, "fichier introuvable")
with open(target, "r", encoding="utf-8", errors="replace") as f:
return {"path": path, "content": f.read()}
@router.get("/download")
async def download_file(path: str, project: str | None = None) -> FileResponse:
"""Télécharge un fichier en conservant le confinement au workspace."""
_activate(project)
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
if not os.path.isfile(target):
raise HTTPException(404, "fichier introuvable")
return FileResponse(
target,
filename=os.path.basename(target),
media_type="application/octet-stream",
)
@router.delete("")
async def delete_file(path: str, project: str | None = None) -> dict:
"""Supprime un fichier ou un dossier (récursif) de la racine active.
Même confinement que le téléchargement (`_safe_path`) ; la racine active
est refusée. Les dotfiles (.git…) ne sont jamais listés par
l'arborescence, donc inaccessibles depuis l'UI.
"""
_activate(project)
try:
target = _safe_path(path)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
if os.path.abspath(target) == tools_mod.active_root():
raise HTTPException(400, "suppression de la racine refusée")
if os.path.isdir(target):
shutil.rmtree(target)
elif os.path.isfile(target):
os.remove(target)
else:
raise HTTPException(404, "fichier introuvable")
return {"deleted": path}
+106
View File
@@ -0,0 +1,106 @@
"""Routes Git du workspace : historique, diff, retour arrière.
Le workspace est un dépôt git (Aider commite chaque modification). Ces routes
donnent à l'UI un panneau Git : voir les commits, leur diff, et annuler une
modification. Toutes les commandes sont exécutées DANS le workspace.
"""
from __future__ import annotations
import os
import subprocess
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import tools
from ..coder import ensure_git
from ..config import settings
router = APIRouter(prefix="/api/git", tags=["git"])
def _git(
*args: str, timeout: int = 15, project: str | None = None
) -> subprocess.CompletedProcess:
"""Exécute une commande git dans le workspace ou le projet demandé."""
root = os.path.abspath(settings.workspace_dir)
if project:
if not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
root = os.path.join(root, project)
ensure_git(root)
return subprocess.run(
["git", *args],
cwd=root,
capture_output=True,
text=True,
timeout=timeout,
)
@router.get("/log")
async def git_log(limit: int = 40, project: str | None = None) -> dict:
"""Historique des commits (hash court, message, auteur, date, nb fichiers)."""
fmt = "%h%x1f%s%x1f%an%x1f%ar%x1f%H"
proc = _git(
"log", f"-{max(1, min(limit, 200))}", f"--pretty=format:{fmt}",
project=project,
)
if proc.returncode != 0:
# Dépôt sans commit encore.
return {"commits": []}
commits = []
for line in proc.stdout.splitlines():
parts = line.split("\x1f")
if len(parts) != 5:
continue
short, subject, author, when, full = parts
# Nombre de fichiers touchés par ce commit.
stat = _git("show", "--stat", "--oneline", "--format=", full,
project=project)
files = [l for l in stat.stdout.splitlines() if "|" in l]
commits.append({
"hash": short,
"full_hash": full,
"subject": subject,
"author": author,
"when": when,
"files_changed": len(files),
})
return {"commits": commits}
@router.get("/diff")
async def git_diff(hash: str | None = None, project: str | None = None) -> dict:
"""Diff d'un commit (hash) ou des modifications non commitées si absent."""
if hash:
if not hash.replace("-", "").isalnum():
raise HTTPException(400, "hash invalide")
proc = _git("show", "--no-color", hash, project=project)
else:
proc = _git("diff", "--no-color", "HEAD", project=project)
if proc.returncode != 0:
raise HTTPException(404, "diff introuvable")
# Borne la taille pour ne pas noyer l'UI.
return {"diff": proc.stdout[:200_000]}
class RevertRequest(BaseModel):
hash: str
project: str | None = None
@router.post("/revert")
async def git_revert(req: RevertRequest) -> dict:
"""Annule un commit en créant un commit inverse (git revert)."""
h = req.hash.strip()
if not h.replace("-", "").isalnum():
raise HTTPException(400, "hash invalide")
proc = _git("revert", "--no-edit", h, timeout=30, project=req.project)
if proc.returncode != 0:
# Conflit ou commit introuvable : on nettoie un éventuel revert en cours.
_git("revert", "--abort", project=req.project)
detail = (proc.stderr or proc.stdout).strip()[:300]
raise HTTPException(409, f"revert impossible : {detail}")
return {"ok": True, "message": f"commit {h[:7]} annulé"}
+54
View File
@@ -0,0 +1,54 @@
"""Routes de configuration des serveurs MCP (catalogue + toggles)."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..mcp_client import CATALOG, get_mcp_state, manager, set_mcp_state
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
def _payload() -> dict:
state = get_mcp_state()
statuses = manager.statuses()
return {
"servers": [
{
"id": sid,
"label": entry["label"],
"description": entry["description"],
"url_param": entry["url_param"],
"env_params": entry["env_params"],
"enabled": state[sid]["enabled"],
"params": state[sid]["params"],
**statuses[sid],
}
for sid, entry in CATALOG.items()
]
}
@router.get("")
async def list_servers() -> dict:
return _payload()
class McpUpdate(BaseModel):
enabled: bool
params: dict = {}
@router.put("/{sid}")
async def update_server(sid: str, req: McpUpdate) -> dict:
if sid not in CATALOG:
raise HTTPException(404, "serveur inconnu")
set_mcp_state(sid, enabled=req.enabled, params=req.params)
return _payload()
@router.post("/{sid}/test")
async def test_server(sid: str) -> dict:
if sid not in CATALOG:
raise HTTPException(404, "serveur inconnu")
return await manager.test_server(sid)
+197
View File
@@ -0,0 +1,197 @@
"""Routes liées à Ollama : statut de connexion, modèles, téléchargement."""
from __future__ import annotations
import asyncio
import json
import httpx
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from .. import agent_config
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api", tags=["ollama"])
# État en mémoire des préchargements. Loki est lancé avec un worker unique ; ce
# suivi permet au navigateur d'interroger une route courte pendant que le long
# chargement Ollama continue sans maintenir la requête HTTP initiale ouverte.
_warm_states: dict[str, dict[str, str]] = {}
_warm_tasks: dict[str, asyncio.Task[None]] = {}
@router.get("/status")
async def status() -> dict:
"""État de la connexion Ollama (point vert/rouge de la barre supérieure)."""
try:
version = await ollama.ping()
return {
"connected": True,
"host": ollama.host,
"version": version.get("version"),
"default_model": settings.default_model,
}
except (httpx.HTTPError, OSError) as exc:
return {
"connected": False,
"host": ollama.host,
"error": str(exc),
"default_model": settings.default_model,
}
@router.get("/models")
async def list_models() -> dict:
"""Modèles installés localement, formatés pour le sélecteur de l'UI."""
try:
raw = await ollama.list_models_cached()
except (httpx.HTTPError, OSError):
return {"models": []}
models = []
for m in raw:
details = m.get("details", {})
size_go = round(m.get("size", 0) / 1_000_000_000, 1)
models.append(
{
"name": m.get("name"),
"size_go": size_go,
"parameter_size": details.get("parameter_size"),
"quantization": details.get("quantization_level"),
"family": details.get("family"),
}
)
return {"models": models, "default": settings.default_model}
class WarmRequest(BaseModel):
name: str
keep_alive: str = "30m"
async def _placement_of(name: str) -> dict:
"""Où le modèle vient-il d'être chargé ? (GPU / CPU / mixte), via /api/ps."""
try:
for m in await ollama.ps():
if m.get("name") == name or m.get("model") == name:
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
if size <= 0:
return {}
pct = int(vram / size * 100)
where = "gpu" if pct >= 99 else "cpu" if pct <= 1 else "mixte"
return {"processor": where, "gpu_percent": str(pct)}
except (httpx.HTTPError, OSError):
pass
return {}
async def _warm_in_background(name: str, keep_alive: str, options: dict | None) -> None:
try:
await ollama.warm(name, keep_alive, options)
state = {"state": "loaded"}
state.update(await _placement_of(name))
_warm_states[name] = state
except Exception as exc:
_warm_states[name] = {
"state": "error",
"error": f"préchargement impossible : {str(exc)[:500]}",
}
def start_model_warm(name: str, keep_alive: str) -> None:
"""Démarre au plus une tâche de préchargement par modèle.
Les options de génération du modèle (num_ctx, num_batch, num_gpu…) sont
envoyées au préchargement pour qu'Ollama charge exactement le runner que le
chat utilisera — évite un rechargement complet au premier message.
"""
current = _warm_tasks.get(name)
if current and not current.done():
return
cfg = agent_config.get_config(name)
options = agent_config.ollama_options(cfg)
_warm_states[name] = {"state": "loading"}
task = asyncio.create_task(_warm_in_background(name, keep_alive, options))
_warm_tasks[name] = task
def forget(done: asyncio.Task[None]) -> None:
if _warm_tasks.get(name) is done:
_warm_tasks.pop(name, None)
task.add_done_callback(forget)
@router.post("/models/warm", status_code=202)
async def warm_model(req: WarmRequest) -> dict:
"""Démarre le préchargement sans exposer sa durée au reverse proxy."""
name = req.name.strip()
if not name:
raise HTTPException(400, "nom de modèle vide")
start_model_warm(name, req.keep_alive)
return {"warming": name, "state": _warm_states[name]["state"]}
@router.get("/models/warm/status")
async def warm_status(name: str = Query(min_length=1)) -> dict:
"""État court du préchargement : idle, loading, loaded ou error."""
return _warm_states.get(name.strip(), {"state": "idle"})
@router.get("/models/loaded")
async def loaded_models() -> dict:
"""Modèles actuellement chargés en mémoire + placement GPU/CPU (/api/ps)."""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return {"loaded": []}
result = []
for m in loaded:
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
result.append({
"name": m.get("name") or m.get("model"),
"on_gpu": bool(size and vram >= size * 0.99),
"gpu_percent": int(vram / size * 100) if size else 0,
})
return {"loaded": result}
class PullRequest(BaseModel):
name: str
class DeleteModelRequest(BaseModel):
name: str
@router.delete("/models")
async def delete_model(req: DeleteModelRequest) -> dict:
"""Supprime explicitement un modèle de l'instance Ollama."""
if not req.name.strip():
raise HTTPException(400, "nom de modèle vide")
try:
await ollama.delete_model(req.name.strip())
except httpx.HTTPStatusError as exc:
detail = exc.response.text[:500] or str(exc)
raise HTTPException(502, f"Ollama : {detail}") from exc
except (httpx.HTTPError, OSError) as exc:
raise HTTPException(502, f"Ollama injoignable : {exc}") from exc
return {"deleted": req.name.strip()}
@router.post("/models/pull")
async def pull_model(req: PullRequest) -> StreamingResponse:
"""Télécharge un modèle en streamant la progression (SSE)."""
async def event_stream():
try:
async for chunk in ollama.pull_model(req.name):
yield f"data: {json.dumps(chunk)}\n\n"
except (httpx.HTTPError, OSError) as exc:
yield f"data: {json.dumps({'error': str(exc)})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
+60
View File
@@ -0,0 +1,60 @@
"""Routes des projets : sous-dossiers de premier niveau du workspace."""
from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import coder, tools
from ..config import settings
router = APIRouter(prefix="/api/projects", tags=["projects"])
def _root() -> str:
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
return root
def _count_files(path: str) -> int:
total = 0
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
total += sum(1 for f in filenames if not f.startswith("."))
return total
@router.get("")
async def list_projects() -> dict:
root = _root()
projects = []
root_files = 0
for name in sorted(os.listdir(root)):
full = os.path.join(root, name)
if name.startswith("."):
continue
if os.path.isdir(full):
projects.append({"name": name, "files": _count_files(full)})
else:
root_files += 1
return {"projects": projects, "root_files": root_files}
class CreateProject(BaseModel):
name: str
@router.post("", status_code=201)
async def create_project(req: CreateProject) -> dict:
name = req.name.strip()
if not tools.PROJECT_NAME.match(name):
raise HTTPException(400, "nom de projet invalide (a-z, 0-9, - et _)")
target = os.path.join(_root(), name)
if os.path.exists(target):
raise HTTPException(400, "ce projet existe déjà")
os.makedirs(target)
# Dépôt git par projet : commits Aider + onglet Git propres au projet.
coder.ensure_git(target)
return {"name": name}
+60
View File
@@ -0,0 +1,60 @@
"""Routes de gestion des sessions de conversation."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import db, tools
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
class CreateSession(BaseModel):
title: str = "Nouvelle session"
model: str | None = None
project: str | None = None
class UpdateSession(BaseModel):
title: str | None = None
project: str | None = None # "" = retour à la racine du workspace
@router.get("")
async def get_sessions() -> dict:
return {"sessions": db.list_sessions()}
@router.post("")
async def post_session(req: CreateSession) -> dict:
if req.project and not tools.PROJECT_NAME.match(req.project):
raise HTTPException(400, "nom de projet invalide")
return db.create_session(req.title, req.model, req.project or None)
@router.get("/{sid}")
async def get_one(sid: str) -> dict:
session = db.get_session(sid)
if not session:
raise HTTPException(404, "session introuvable")
return {"session": session, "messages": db.list_messages(sid)}
@router.patch("/{sid}")
async def patch_session(sid: str, req: UpdateSession) -> dict:
if not db.get_session(sid):
raise HTTPException(404, "session introuvable")
if req.title is not None:
db.rename_session(sid, req.title)
if req.project is not None:
project = req.project or None
if project and not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
db.set_session_project(sid, project)
return {"ok": True}
@router.delete("/{sid}")
async def remove_session(sid: str) -> dict:
db.delete_session(sid)
return {"ok": True}
+33
View File
@@ -0,0 +1,33 @@
"""Route d'exécution d'une commande shell validée par l'utilisateur.
La boucle agentique n'exécute jamais run_shell elle-même quand la confirmation
est active : elle émet un événement `tool_confirm`. Le client affiche la
commande, et c'est seulement après clic explicite de l'utilisateur que cette
route exécute la commande (confinée au workspace).
"""
from __future__ import annotations
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from ..tools import ToolError, run_shell
router = APIRouter(prefix="/api/shell", tags=["shell"])
class ShellRequest(BaseModel):
command: str
@router.post("/run")
async def run(req: ShellRequest) -> dict:
"""Exécute la commande validée et renvoie sa sortie."""
try:
result = run_shell(req.command)
except ToolError as exc:
raise HTTPException(400, str(exc))
return {
"command": req.command,
"exit_code": result["exit_code"],
"output": result["output"],
}
+232
View File
@@ -0,0 +1,232 @@
"""Statistiques système temps réel : CPU, RAM, GPU/VRAM (barre supérieure)."""
from __future__ import annotations
import asyncio
import shutil
import time
import httpx
import psutil
from fastapi import APIRouter
from ..config import settings
from ..ollama_client import ollama
router = APIRouter(prefix="/api/system", tags=["system"])
_NVIDIA_SMI = shutil.which("nvidia-smi")
# Cache court : le front interroge /stats en continu ; relancer un sous-processus
# nvidia-smi à chaque tick charge la machine qui héberge aussi Ollama.
# TTL volontairement > à la cadence de sondage, sinon le cache n'absorbe rien.
_GPU_CACHE_TTL = 12.0
_gpu_cache: dict = {"at": 0.0, "value": None}
# Même logique pour la vue matériel (sous-processus nvidia-smi plus lourd).
_HW_CACHE_TTL = 12.0
_hw_cache: dict = {"at": 0.0, "value": None}
async def _gpu_stats() -> dict | None:
"""Utilisation GPU/VRAM via nvidia-smi ; None si absent (pas de GPU NVIDIA).
Résultat mis en cache ~5 s pour limiter les sous-processus.
"""
if not _NVIDIA_SMI:
return None
if time.monotonic() - _gpu_cache["at"] < _GPU_CACHE_TTL:
return _gpu_cache["value"]
try:
proc = await asyncio.create_subprocess_exec(
_NVIDIA_SMI,
"--query-gpu=utilization.gpu,memory.used,memory.total,name",
"--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
line = out.decode().strip().splitlines()[0]
util, used, total, name = (p.strip() for p in line.split(","))
value = {
"name": name,
"util_pct": float(util),
"vram_used_mb": float(used),
"vram_total_mb": float(total),
}
except Exception:
value = None
_gpu_cache.update(at=time.monotonic(), value=value)
return value
@router.get("/stats")
async def stats() -> dict:
"""CPU %, RAM et GPU/VRAM courants."""
cpu_pct = psutil.cpu_percent(interval=None)
mem = psutil.virtual_memory()
gpu = await _gpu_stats()
return {
"cpu_pct": cpu_pct,
"ram_used_go": round(mem.used / 1_000_000_000, 1),
"ram_total_go": round(mem.total / 1_000_000_000, 1),
"ram_pct": mem.percent,
"gpu": gpu,
}
# Battement groupé : absorbe les rafales (plusieurs onglets, reconnexions)
# sans multiplier les sous-processus ni les connexions vers Ollama.
_PULSE_TTL = 2.5
_pulse_cache: dict = {"at": 0.0, "value": None}
async def _loaded_models() -> list[dict]:
"""Modèles chargés en mémoire + placement GPU (vide si Ollama muet)."""
try:
loaded = await ollama.ps()
except (httpx.HTTPError, OSError):
return []
out = []
for m in loaded:
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
out.append({
"name": m.get("name") or m.get("model"),
"on_gpu": bool(size and vram >= size * 0.99),
"gpu_percent": int(vram / size * 100) if size else 0,
})
return out
@router.get("/pulse")
async def pulse() -> dict:
"""Battement unique : statut Ollama + ressources + modèles chargés.
Remplace trois sondages séparés (statut, stats, modèles chargés) par UNE
requête : moins de réveils, moins de sous-processus et moins de connexions
vers Ollama quand l'application est simplement ouverte sans être utilisée.
"""
if time.monotonic() - _pulse_cache["at"] < _PULSE_TTL and _pulse_cache["value"]:
return _pulse_cache["value"]
# Les deux appels Ollama partent ensemble : latence = le plus lent, pas la
# somme (important quand Ollama est sur une autre machine).
version, loaded = await asyncio.gather(
ollama.ping(), _loaded_models(), return_exceptions=True
)
connected = not isinstance(version, BaseException)
mem = psutil.virtual_memory()
value = {
"status": {
"connected": connected,
"host": ollama.host,
"version": version.get("version") if connected else None,
"default_model": settings.default_model,
**({} if connected else {"error": str(version)[:200]}),
},
"stats": {
"cpu_pct": psutil.cpu_percent(interval=None),
"ram_used_go": round(mem.used / 1_000_000_000, 1),
"ram_total_go": round(mem.total / 1_000_000_000, 1),
"ram_pct": mem.percent,
"gpu": await _gpu_stats(),
},
"loaded": [] if isinstance(loaded, BaseException) else loaded,
}
_pulse_cache.update(at=time.monotonic(), value=value)
return value
async def _all_local_gpus() -> list[dict]:
"""Tous les GPU NVIDIA visibles depuis le CONTENEUR Loki (peut être vide).
Mis en cache : le panneau Matériel se rafraîchit en continu tant qu'il est
ouvert, et chaque appel lançait un sous-processus nvidia-smi.
"""
if not _NVIDIA_SMI:
return []
if time.monotonic() - _hw_cache["at"] < _HW_CACHE_TTL:
return _hw_cache["value"] or []
try:
proc = await asyncio.create_subprocess_exec(
_NVIDIA_SMI,
"--query-gpu=index,name,memory.total,memory.used,utilization.gpu",
"--format=csv,noheader,nounits",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await asyncio.wait_for(proc.communicate(), timeout=3)
gpus = []
for line in out.decode().strip().splitlines():
idx, name, total, used, util = (p.strip() for p in line.split(","))
gpus.append({
"index": int(idx), "name": name,
"vram_total_mb": float(total), "vram_used_mb": float(used),
"util_pct": float(util),
})
_hw_cache.update(at=time.monotonic(), value=gpus)
return gpus
except Exception:
_hw_cache.update(at=time.monotonic(), value=[])
return []
@router.get("/hardware")
async def hardware() -> dict:
"""Vue matériel : GPU vu par Loki vs GPU réellement utilisé par Ollama."""
# 1) Ce que voit le conteneur Loki (nvidia-smi) + override éventuel.
local_gpus = await _all_local_gpus()
override = None
if settings.gpu_vram_mb > 0:
override = {
"name": settings.gpu_name or "GPU déclaré (GPU_VRAM_MB)",
"vram_total_mb": settings.gpu_vram_mb,
}
# 2) Ce qu'Ollama utilise réellement (modèles chargés + placement VRAM/CPU).
ollama_info: dict = {"host": ollama.host, "connected": False, "running": []}
try:
version = await ollama.ping()
ollama_info["connected"] = True
ollama_info["version"] = version.get("version")
except (httpx.HTTPError, OSError) as exc:
ollama_info["error"] = str(exc)[:200]
if ollama_info["connected"]:
try:
for m in await ollama.ps():
size = m.get("size", 0) or 0
vram = m.get("size_vram", 0) or 0
if size <= 0:
where = "inconnu"
elif vram >= size * 0.99:
where = "GPU"
elif vram <= size * 0.01:
where = "CPU"
else:
where = "mixte"
ollama_info["running"].append({
"name": m.get("name") or m.get("model"),
"processor": where,
"gpu_percent": int(vram / size * 100) if size else 0,
"size_mb": round(size / 1_000_000),
"vram_mb": round(vram / 1_000_000),
})
except (httpx.HTTPError, OSError):
pass
# Ollama est-il sur la même machine que Loki ? (heuristique sur l'hôte)
host = ollama.host.lower()
is_local = any(h in host for h in ("localhost", "127.0.0.1", "host.docker.internal"))
return {
"loki_gpus": local_gpus,
"gpu_override": override,
"ollama": ollama_info,
"ollama_is_local": is_local,
"note": (
"Loki ne voit pas directement le GPU d'un Ollama distant : il déduit "
"le placement (GPU/CPU) depuis les modèles chargés. Déclare GPU_VRAM_MB "
"pour l'auto-réglage si Ollama tourne sur une autre machine."
),
}
+117
View File
@@ -0,0 +1,117 @@
"""Skills : méthodes expertes injectées automatiquement selon la tâche.
Sélection lexicale instantanée (aucun appel LLM) : au plus UNE skill par
message, injectée en message système pour ce tour uniquement.
"""
from __future__ import annotations
import os
import re
_SKILLS_DIR = os.path.join(os.path.dirname(__file__), "..", "skills")
# Ponytail (https://github.com/DietrichGebert/ponytail) : philosophie de code
# « paresseux » (anti sur-ingénierie). Adaptée en méthode transverse injectée
# pour toute tâche de code — indépendante du sélecteur de skill (mono-skill).
PONYTAIL_TITLE = "Ponytail · code minimal"
PONYTAIL_GUIDANCE = (
"Méthode Ponytail — code minimal, anti sur-ingénierie. Avant d'écrire du "
"code, descends l'échelle de décision et arrête-toi au premier échelon qui "
"suffit :\n"
"1. NE PAS coder ce qui n'est pas explicitement demandé (YAGNI) ;\n"
"2. RÉUTILISER l'existant (fichiers/fonctions déjà présents) ;\n"
"3. UTILISER les fonctions natives du langage / du navigateur ;\n"
"4. en DERNIER recours seulement, écrire le minimum de code nécessaire.\n"
"Livre la solution la plus simple qui fonctionne : aucune dépendance ni "
"bibliothèque à installer, aucune abstraction prématurée, aucune "
"fonctionnalité en plus non demandée (pas de « moteur IA », d'options ou de "
"configuration superflues). Préfère un seul fichier clair à une "
"architecture élaborée."
)
# Seuil : nombre minimal de mots-clés distincts trouvés dans le message.
_MIN_HITS = 2
def _load() -> dict[str, dict]:
out: dict[str, dict] = {}
if not os.path.isdir(_SKILLS_DIR):
return out
for fname in sorted(os.listdir(_SKILLS_DIR)):
if not fname.endswith(".md"):
continue
with open(os.path.join(_SKILLS_DIR, fname), encoding="utf-8") as f:
raw = f.read()
m = re.match(r"^---\n(.*?)\n---\n(.*)$", raw, re.S)
if not m:
continue
meta: dict[str, str] = {}
for line in m.group(1).splitlines():
if ":" in line:
key, _, value = line.partition(":")
meta[key.strip()] = value.strip()
keywords = [
k.strip().lower()
for k in meta.get("keywords", "").split(",")
if k.strip()
]
out[meta.get("name", fname[:-3])] = {
"name": meta.get("name", fname[:-3]),
"title": meta.get("title", fname[:-3]),
"keywords": keywords,
"body": m.group(2).strip(),
}
return out
_WEB_RE = re.compile(
r"\b(html|css|javascript|js|page|site|web|appli|application|interface|"
r"bouton|formulaire|canvas|animation|jeu|game|échiquier|echiquier|"
r"dashboard|landing|maquette|ui|front)\b",
re.I,
)
def is_web_task(message: str) -> bool:
"""La demande porte-t-elle sur une page / appli web ?"""
return bool(_WEB_RE.search(message or ""))
# Contraintes « appli web » : le sandbox de prévisualisation est HORS-LIGNE et
# isolé (pas de CDN, pas de WASM téléchargé). Sans ces règles, les modèles
# promettent des libs (Stockfish, React…) qui ne se chargent jamais, éclatent
# le code en plusieurs fichiers incohérents, puis déclarent « ça marche ».
WEBAPP_GUIDANCE = (
"Contraintes STRICTES pour une page/appli web :\n"
"1. UN SEUL fichier index.html AUTONOME : tout le CSS dans <style>, tout le "
"JS dans <script> à la fin du <body>. Pas de fichiers .css/.js séparés.\n"
"2. AUCUNE ressource externe : pas de CDN, pas de <script src=\"https://…\">, "
"pas de bibliothèque à télécharger (jQuery, React, chess.js, Stockfish, "
".wasm…). Le sandbox est hors-ligne : ça ne se charge JAMAIS. Si une "
"fonctionnalité a besoin d'une lib, écris une version simple toi-même en "
"JavaScript natif.\n"
"3. Le rendu doit s'afficher AU CHARGEMENT : construis réellement le DOM "
"dans un script qui s'exécute (ex. génère les 64 cases de l'échiquier en "
"boucle). Pas de fonction jamais appelée, pas de conteneur laissé vide.\n"
"4. Ne prétends JAMAIS qu'une fonctionnalité marche sans l'avoir codée, et "
"ne « simule » pas un moteur en le faisant passer pour réel : dis "
"clairement ce qui est réel et ce qui ne l'est pas.\n"
"5. Design sobre, lisible et moderne — mais la priorité est que ça "
"FONCTIONNE vraiment."
)
ALL_SKILLS: dict[str, dict] = _load()
def pick_skill(message: str) -> dict | None:
"""Meilleure skill pour ce message, ou None si rien d'assez net."""
low = message.lower()
best, best_hits = None, 0
for skill in ALL_SKILLS.values():
hits = sum(1 for kw in skill["keywords"] if kw in low)
if hits > best_hits:
best, best_hits = skill, hits
if best is None or best_hits < _MIN_HITS:
return None
return {"name": best["name"], "title": best["title"], "body": best["body"]}
+944
View File
@@ -0,0 +1,944 @@
"""Outils de l'agent, exécutés côté serveur et confinés au workspace.
Chaque outil expose :
- une définition JSON (format function-calling Ollama/OpenAI) ;
- une implémentation Python qui renvoie un dict {ok, summary, ...}.
Toutes les opérations fichier sont strictement confinées à WORKSPACE_DIR :
toute tentative de sortie (../, chemin absolu hors workspace) est rejetée.
"""
from __future__ import annotations
import html
import json
import os
import re
import shutil
import subprocess
from contextvars import ContextVar
import httpx
from .config import settings
class ToolError(Exception):
"""Erreur d'exécution d'un outil (message destiné au modèle)."""
# Projet actif pour la requête en cours : re-racine tous les outils sur
# workspace/<projet>. None = racine du workspace (comportement historique).
_ACTIVE_PROJECT: ContextVar[str | None] = ContextVar("loki_project", default=None)
PROJECT_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,40}$")
def set_project(name: str | None) -> None:
"""Fixe le projet actif de la requête (None = racine)."""
if name is not None and not PROJECT_NAME.match(name):
raise ToolError(f"nom de projet invalide : {name!r}")
_ACTIVE_PROJECT.set(name)
def active_root() -> str:
"""Racine effective (workspace ou projet), créée si nécessaire."""
return _workspace_root()
def _workspace_root() -> str:
root = os.path.abspath(settings.workspace_dir)
project = _ACTIVE_PROJECT.get()
if project:
root = os.path.join(root, project)
os.makedirs(root, exist_ok=True)
return root
def _safe_path(rel: str) -> str:
"""Résout un chemin relatif en restant confiné au workspace."""
root = _workspace_root()
target = os.path.abspath(os.path.join(root, rel or "."))
if target != root and not target.startswith(root + os.sep):
raise ToolError(f"chemin hors du workspace refusé : {rel}")
return target
# ── Implémentations ──────────────────────────────────────────────────────
# Fenêtrage de lecture : un gros fichier entier engloutit le contexte du
# modèle. Au-delà du seuil, on renvoie une fenêtre + la marche à suivre.
_READ_WINDOW_LINES = 200
_READ_MAX_CHARS = 12_000
def read_file(path: str, start_line: int = 1) -> dict:
target = _safe_path(path)
if os.path.isdir(target):
raise ToolError(
f"{path} est un dossier — utilise list_dir pour voir son contenu"
)
if not os.path.isfile(target):
raise ToolError(f"fichier introuvable : {path}")
with open(target, "r", encoding="utf-8", errors="replace") as f:
lines = f.read().splitlines()
total = len(lines)
if total == 0:
return {"ok": True, "content": "", "summary": "fichier vide (0 octet)"}
start = max(1, int(start_line or 1))
window = lines[start - 1 : start - 1 + _READ_WINDOW_LINES]
content = "\n".join(window)
truncated_by_chars = False
if len(content) > _READ_MAX_CHARS:
content = content[:_READ_MAX_CHARS]
truncated_by_chars = True
end = start + len(window) - 1
if start == 1 and end >= total and not truncated_by_chars:
return {"ok": True, "content": content, "summary": f"{total} lignes lues"}
# Fenêtre partielle : le modèle sait où il en est et comment continuer.
note = (
f"[fichier {path} : {total} lignes — fenêtre {start}-{end}. "
f"Pour la suite : read_file(path, start_line={end + 1}). "
"Pour cibler un passage précis : grep_search puis edit_file.]"
)
return {
"ok": True,
"content": content + "\n" + note,
"summary": f"lignes {start}-{end} sur {total}",
}
def check_html(target: str) -> list[str]:
"""Contrôles rapides d'une page HTML : références locales et balises.
Renvoie une liste de problèmes (vide = OK).
"""
issues: list[str] = []
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except OSError:
return issues
base_dir = os.path.dirname(target)
# Références href/src : cassées (fichier local absent) ou externes
# (CDN/WASM qui ne se chargeront pas dans un sandbox hors-ligne).
for attr, ref in re.findall(r"""(href|src)=["']([^"'#]+)["']""", content, re.I):
low = ref.lower()
if low.startswith(("data:", "mailto:", "tel:", "javascript:")):
continue
if re.match(r"^(https?:)?//", ref, re.I):
base = low.split("?")[0]
if attr.lower() == "src" and base.endswith((".js", ".mjs", ".wasm")):
issues.append(f"script externe (hors-ligne : ne se charge pas) : {ref}")
elif attr.lower() == "href" and base.endswith(".css"):
issues.append(f"style externe (hors-ligne : ne se charge pas) : {ref}")
continue
ref_path = os.path.normpath(os.path.join(base_dir, ref.split("?")[0]))
if not os.path.exists(ref_path):
issues.append(f"référence cassée : {ref}")
# Équilibre des balises structurantes.
for tag in ("html", "head", "body", "div", "section", "script", "style"):
opened = len(re.findall(rf"<{tag}[\s>]", content, re.I))
closed = len(re.findall(rf"</{tag}>", content, re.I))
if opened != closed:
issues.append(f"balise <{tag}> : {opened} ouverte(s) / {closed} fermée(s)")
return issues[:6]
def _verify_written(target: str) -> str | None:
"""Vérification immédiate après écriture (py/json/html).
Renvoyer l'erreur au modèle tout de suite lui permet de se corriger dans
le même tour, au lieu de livrer un fichier cassé.
"""
ext = os.path.splitext(target)[1].lower()
try:
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
if ext == ".json":
import json as _json
_json.loads(content)
elif ext == ".py":
compile(content, target, "exec")
elif ext in (".html", ".htm"):
problems = check_html(target)
if problems:
return " ; ".join(problems)
elif ext in (".js", ".mjs"):
node = shutil.which("node")
if node:
proc = subprocess.run(
[node, "--check", target], capture_output=True, text=True,
timeout=15,
)
if proc.returncode != 0:
return (proc.stderr or proc.stdout)[:300]
except SyntaxError as exc:
return f"SyntaxError ligne {exc.lineno}: {exc.msg}"
except ValueError as exc:
return f"JSON invalide : {exc}"
except OSError:
return None
return None
def write_file(path: str, content: str, mode: str = "overwrite") -> dict:
target = _safe_path(path)
if mode not in {"overwrite", "append"}:
raise ToolError("mode write_file invalide : utilise overwrite ou append")
os.makedirs(os.path.dirname(target) or _workspace_root(), exist_ok=True)
existed = os.path.isfile(target)
with open(target, "a" if mode == "append" else "w", encoding="utf-8") as f:
f.write(content)
lines = len(content.splitlines())
verb = "complété" if mode == "append" else "modifié" if existed else "écrit"
result = {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
problem = _verify_written(target)
if problem:
result["verification"] = problem
result["summary"] += f" · ⚠ {problem}"
return result
def _leading_ws(line: str) -> str:
"""Renvoie l'indentation (blancs de gauche) d'une ligne."""
return line[: len(line) - len(line.lstrip())]
def _reindent(search_lines: list[str], window: list[str], replace: str) -> list[str]:
"""Réaligne le texte de remplacement sur l'indentation réelle du fichier.
Quand la correspondance a été trouvée en tolérant l'indentation (le modèle
a copié l'extrait « à plat »), on réapplique au remplacement le décalage
d'indentation observé entre le fichier et la recherche, pour ne pas casser
la mise en forme (Python surtout).
"""
src_indent = next((_leading_ws(s) for s in search_lines if s.strip()), "")
file_indent = next((_leading_ws(w) for w in window if w.strip()), "")
replace_lines = replace.splitlines()
if file_indent == src_indent:
return replace_lines
out: list[str] = []
for line in replace_lines:
if not line.strip():
out.append(line)
elif src_indent and line.startswith(src_indent):
out.append(file_indent + line[len(src_indent):])
elif not src_indent:
out.append(file_indent + line)
else:
out.append(line)
return out
def _apply_edit(content: str, search: str, replace: str) -> tuple[str, str]:
"""Applique un remplacement search→replace, du plus strict au plus tolérant.
1. correspondance exacte (unique) ;
2. correspondance ligne à ligne en ignorant les espaces de fin / de début
(indentation) — cas le plus fréquent où un petit modèle recopie l'extrait
sans reproduire fidèlement les blancs.
Renvoie (nouveau_contenu, note). Lève ToolError si introuvable ou ambigu.
"""
# 1. Correspondance exacte.
count = content.count(search)
if count == 1:
return content.replace(search, replace, 1), ""
if count > 1:
raise ToolError(
f"extrait présent {count} fois : ajoute du contexte "
"autour pour le rendre unique."
)
# 2. Correspondance tolérante (ligne à ligne, espaces normalisés).
file_lines = content.splitlines(keepends=True)
search_lines = search.splitlines()
if not any(s.strip() for s in search_lines):
raise ToolError("extrait vide après normalisation")
norm_search = [s.strip() for s in search_lines]
n = len(search_lines)
hits = [
i
for i in range(len(file_lines) - n + 1)
if [w.strip() for w in file_lines[i:i + n]] == norm_search
]
if not hits:
raise ToolError("introuvable")
if len(hits) > 1:
raise ToolError(
f"extrait présent {len(hits)} fois : ajoute du contexte "
"autour pour le rendre unique."
)
i = hits[0]
window = file_lines[i:i + n]
newline = "\r\n" if window and window[0].endswith("\r\n") else "\n"
adjusted = _reindent(search_lines, window, replace)
rep_text = newline.join(adjusted)
if window and window[-1].endswith("\n"):
rep_text += newline
new_content = "".join(file_lines[:i]) + rep_text + "".join(file_lines[i + n:])
return new_content, "correspondance tolérante (indentation/espaces ignorés)"
def edit_file(path: str, search: str, replace: str) -> dict:
"""Modification chirurgicale : remplace un extrait du fichier.
Bien plus fiable que réécrire tout le fichier avec un petit modèle : seul le
fragment visé change, le reste est garanti intact. La correspondance tolère
les différences d'espaces / d'indentation, pour ne pas bloquer quand le
modèle recopie l'extrait de façon approximative.
"""
target = _safe_path(path)
if not os.path.isfile(target):
raise ToolError(f"fichier introuvable : {path}")
if not search:
raise ToolError("search vide : fournis l'extrait à remplacer")
with open(target, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
try:
new_content, note = _apply_edit(content, search, replace)
except ToolError as exc:
reason = str(exc)
if reason == "introuvable":
preview = search.strip().splitlines()[0][:60] if search.strip() else ""
raise ToolError(
f"extrait introuvable dans {path} (cherché : « {preview}… »). "
"Relis le fichier avec read_file et copie l'extrait EXACT, "
"ou utilise write_file pour réécrire le fichier."
) from exc
raise ToolError(f"{reason.rstrip('.')} dans {path}.") from exc
with open(target, "w", encoding="utf-8") as f:
f.write(new_content)
delta = len(replace.splitlines()) - len(search.splitlines())
summary = f"remplacé · {delta:+d} ligne(s)"
if note:
summary += f" · {note}"
result = {"ok": True, "summary": summary}
problem = _verify_written(target)
if problem:
result["verification"] = problem
result["summary"] += f" · ⚠ {problem}"
return result
def memory_search(query: str) -> dict:
"""Cherche dans les notes que l'agent a lui-même enregistrées."""
from . import memory_notes
hits = memory_notes.search_notes(query, limit=3)
if not hits:
return {"ok": True, "notes": [], "summary": "aucune note correspondante"}
return {
"ok": True,
"notes": hits,
"summary": f"{len(hits)} note(s) trouvée(s)",
}
def memory_save(title: str, content: str) -> dict:
"""Enregistre une note durable (préférence, décision, fait à retenir)."""
from . import memory_notes
result = memory_notes.save_note(title, content)
if not result["ok"]:
raise ToolError(result["summary"])
return result
_GREP_SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "dist"}
_MAX_GREP_MATCHES = 50
def grep_search(pattern: str, path: str = ".") -> dict:
"""Recherche un motif (regex) dans les fichiers du workspace."""
if not pattern:
raise ToolError("pattern vide")
try:
rx = re.compile(pattern, re.I)
except re.error as exc:
raise ToolError(f"regex invalide : {exc}")
root = _safe_path(path)
matches: list[str] = []
files_hit: set[str] = set()
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d not in _GREP_SKIP_DIRS]
for fname in sorted(filenames):
full = os.path.join(dirpath, fname)
if os.path.getsize(full) > 1_000_000:
continue
rel = os.path.relpath(full, _workspace_root())
try:
with open(full, "r", encoding="utf-8", errors="replace") as f:
for lineno, line in enumerate(f, 1):
if rx.search(line):
matches.append(f"{rel}:{lineno}: {line.strip()[:160]}")
files_hit.add(rel)
if len(matches) >= _MAX_GREP_MATCHES:
break
except OSError:
continue
if len(matches) >= _MAX_GREP_MATCHES:
break
if len(matches) >= _MAX_GREP_MATCHES:
break
summary = (
f"{len(matches)} correspondance(s) dans {len(files_hit)} fichier(s)"
if matches else "aucune correspondance"
)
return {"ok": True, "matches": matches, "summary": summary}
def list_dir(path: str = ".") -> dict:
target = _safe_path(path)
if not os.path.isdir(target):
raise ToolError(f"répertoire introuvable : {path}")
entries = []
for name in sorted(os.listdir(target)):
full = os.path.join(target, name)
entries.append({"name": name, "type": "dir" if os.path.isdir(full) else "file"})
return {
"ok": True,
"entries": entries,
"summary": f"{len(entries)} élément(s)",
}
def web_search(query: str, max_results: int = 5) -> dict:
"""Recherche web (DuckDuckGo HTML, sans clé d'API).
Optionnellement, si SEARX_URL est défini, interroge une instance SearxNG.
Renvoie une liste de résultats {title, url, snippet}.
"""
query = (query or "").strip()
if not query:
raise ToolError("requête de recherche vide")
searx = os.environ.get("SEARX_URL")
try:
if searx:
results = _search_searx(searx, query, max_results)
else:
results = _search_duckduckgo(query, max_results)
except httpx.HTTPError as exc:
raise ToolError(f"recherche web indisponible : {exc}") from exc
summary = f"{len(results)} résultat(s)" if results else "aucun résultat"
return {"ok": True, "results": results, "summary": summary}
def _search_searx(base: str, query: str, n: int) -> list[dict]:
with httpx.Client(timeout=10.0) as client:
r = client.get(
base.rstrip("/") + "/search",
params={"q": query, "format": "json"},
)
r.raise_for_status()
data = r.json().get("results", [])[:n]
return [
{"title": d.get("title", ""), "url": d.get("url", ""),
"snippet": d.get("content", "")}
for d in data
]
_DDG_RESULT = re.compile(
r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>'
r'.*?class="result__snippet"[^>]*>(.*?)</a>',
re.DOTALL,
)
_TAGS = re.compile(r"<[^>]+>")
def _clean(text: str) -> str:
return html.unescape(_TAGS.sub("", text)).strip()
def _search_duckduckgo(query: str, n: int) -> list[dict]:
with httpx.Client(timeout=10.0, follow_redirects=True) as client:
r = client.post(
"https://html.duckduckgo.com/html/",
data={"q": query},
headers={"User-Agent": "Mozilla/5.0 (Loki agent)"},
)
r.raise_for_status()
results = []
for url, title, snippet in _DDG_RESULT.findall(r.text)[:n]:
results.append({
"title": _clean(title),
"url": html.unescape(url),
"snippet": _clean(snippet),
})
return results
# Lignes porteuses de signal dans une sortie de commande en échec.
_ERROR_LINE = re.compile(
r"error|erreur|fail|except|traceback|fatal|warn|undefined|cannot|"
r"not found|introuvable|refus|denied|invalid|missing|panic",
re.I,
)
def _dedupe_lines(lines: list[str]) -> list[str]:
"""Compacte les répétitions consécutives (« ligne ×N »)."""
out: list[str] = []
for line in lines:
if out:
base, _, count = out[-1].partition(" ×")
if base == line:
n = int(count) if count.isdigit() else 1
out[-1] = f"{line} ×{n + 1}"
continue
out.append(line)
return out
def _compact_output(output: str, exit_code: int) -> str:
"""Filtre la sortie shell façon rtk : le signal, pas le déroulé.
- succès : dernières lignes seulement (le détail n'apporte rien) ;
- échec : lignes d'erreur + fin de sortie, dédupliquées.
Tronquer bêtement à N caractères gardait le bruit et coupait l'erreur.
"""
lines = [l.rstrip() for l in output.splitlines() if l.strip()]
lines = _dedupe_lines(lines)
if exit_code == 0:
kept = lines[-12:]
text = "\n".join(kept)
if len(lines) > 12:
text = f"[…{len(lines) - 12} lignes omises]\n" + text
return text[:1200]
error_lines = [l for l in lines if _ERROR_LINE.search(l)]
tail = lines[-10:]
kept = error_lines[:20] + [l for l in tail if l not in error_lines[:20]]
text = "\n".join(kept)
if len(lines) > len(kept):
text = f"[…sortie filtrée : {len(kept)}/{len(lines)} lignes]\n" + text
return text[:2500]
# Pseudo-fichiers absolus inoffensifs, tolérés dans les commandes shell.
_ALLOWED_ABS = ("/dev/null", "/dev/stdout", "/dev/stderr", "/dev/zero", "/dev/tty")
# Jetons ressemblant à un chemin (absolu, ~ ou contenant ../).
_PATH_TOKEN = re.compile(r"""(?:^|[\s=:><|&(])((?:~|/)[^\s'"|&;><)]*|[^\s'"|&;><)]*\.\.[^\s'"|&;><)]*)""")
def _guard_shell(command: str) -> None:
"""Refuse toute commande qui référence un chemin hors du workspace.
Barrière de confinement (best-effort) : le shell est trop puissant pour être
totalement bridé, mais on bloque les cas concrets d'évasion — chemins
absolus hors workspace (`/config/...`, `~/...`) et remontées `../` qui
sortent du workspace. Combiné à la validation utilisateur, ça empêche le
modèle d'écrire ailleurs que dans son workspace.
"""
# Racine autorisée = base du workspace (pas le sous-dossier projet) : ainsi
# `ls /workspace` reste permis même quand la session cible un projet.
root = os.path.abspath(settings.workspace_dir)
for token in _PATH_TOKEN.findall(command):
token = token.strip()
if not token:
continue
# Jeton commençant par `//` = autorité d'URL (http://, ws://…) ou chemin
# réseau, jamais une évasion du système de fichiers : on l'ignore.
if token.startswith("//"):
continue
if token.startswith("~"):
raise ToolError(
f"chemin hors du workspace refusé : {token}. "
"Utilise uniquement des chemins relatifs au workspace."
)
if token.startswith("/"):
if any(token == a or token.startswith(a + "/") for a in _ALLOWED_ABS):
continue
resolved = os.path.abspath(token)
if resolved != root and not resolved.startswith(root + os.sep):
raise ToolError(
f"chemin absolu hors du workspace refusé : {token}. "
"Utilise uniquement des chemins relatifs au workspace."
)
elif ".." in token.split("/"):
resolved = os.path.abspath(os.path.join(root, token))
if resolved != root and not resolved.startswith(root + os.sep):
raise ToolError(
f"remontée hors du workspace refusée : {token}. "
"Reste dans le workspace."
)
def run_shell(command: str, timeout: int = 60) -> dict:
"""Exécute une commande shell dans le workspace (outil sensible).
L'exécution effective n'a lieu qu'après validation utilisateur (gérée par
la boucle agentique / la route /api/shell). Confinée au workspace.
"""
command = (command or "").strip()
if not command:
raise ToolError("commande vide")
_guard_shell(command)
try:
proc = subprocess.run(
command,
shell=True,
cwd=_workspace_root(),
capture_output=True,
text=True,
timeout=timeout,
)
except subprocess.TimeoutExpired as exc:
raise ToolError(f"délai dépassé ({timeout}s)") from exc
out = (proc.stdout or "") + (proc.stderr or "")
# Filtrage signal/bruit (façon rtk) plutôt que troncature aveugle.
out = _compact_output(out, proc.returncode)
status = "ok" if proc.returncode == 0 else "error"
return {
"ok": proc.returncode == 0,
"exit_code": proc.returncode,
"output": out,
"summary": f"code {proc.returncode}",
"_status": status,
}
def run_check(path: str) -> dict:
"""Vérification STATIQUE d'un fichier de code — n'exécute jamais rien.
.py -> py_compile ; .js/.mjs -> node --check (si node présent) ;
.html -> check_html ; .json -> parse. Autres types : ok sans contrôle.
"""
target = _safe_path(path)
if not os.path.isfile(target):
raise ToolError(f"fichier introuvable : {path}")
ext = os.path.splitext(target)[1].lower()
issues: list[str] = []
if ext == ".py":
import py_compile
try:
py_compile.compile(target, doraise=True)
except py_compile.PyCompileError as exc:
issues.append(str(exc.msg)[:500])
elif ext in (".js", ".mjs"):
node = shutil.which("node")
if node:
proc = subprocess.run(
[node, "--check", target], capture_output=True, text=True,
timeout=15,
)
if proc.returncode != 0:
issues.append((proc.stderr or proc.stdout)[:500])
elif ext in (".html", ".htm"):
issues.extend(check_html(target))
elif ext == ".json":
try:
with open(target, encoding="utf-8") as f:
json.load(f)
except json.JSONDecodeError as exc:
issues.append(f"JSON invalide : {exc}")
ok = not issues
return {
"ok": ok,
"issues": issues,
"summary": "aucun problème" if ok else f"{len(issues)} problème(s)",
"_status": "ok" if ok else "error",
}
# ── Registre & définitions exposées au modèle ────────────────────────────
TOOL_IMPL = {
"read_file": read_file,
"write_file": write_file,
"edit_file": edit_file,
"list_dir": list_dir,
"grep_search": grep_search,
"web_search": web_search,
"run_shell": run_shell,
"run_check": run_check,
"memory_search": memory_search,
"memory_save": memory_save,
}
TOOL_DEFINITIONS = [
{
"type": "function",
"function": {
"name": "read_file",
"description": (
"Lire le contenu d'un fichier du workspace. Les gros fichiers "
"sont renvoyés par fenêtres de 200 lignes : utilise start_line "
"pour lire la suite, ou grep_search pour cibler un passage."
),
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"},
"start_line": {
"type": "integer",
"description": "Première ligne de la fenêtre (défaut 1)",
},
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "write_file",
"description": "Créer ou modifier un fichier du workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"},
"content": {
"type": "string",
"description": "Contenu complet ou morceau court du fichier",
},
"mode": {
"type": "string",
"enum": ["overwrite", "append"],
"description": (
"overwrite pour le premier morceau, append pour les suivants"
),
},
},
"required": ["path", "content"],
},
},
},
{
"type": "function",
"function": {
"name": "edit_file",
"description": (
"Modifier UN extrait précis d'un fichier existant (recherche/"
"remplacement exact). Préférable à write_file pour toute "
"modification partielle : le reste du fichier reste intact."
),
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"},
"search": {
"type": "string",
"description": "Extrait EXACT à remplacer (copie fidèle, unique dans le fichier)",
},
"replace": {"type": "string", "description": "Nouveau texte"},
},
"required": ["path", "search", "replace"],
},
},
},
{
"type": "function",
"function": {
"name": "list_dir",
"description": "Lister le contenu d'un répertoire du workspace.",
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Répertoire (défaut : racine)"}
},
},
},
},
{
"type": "function",
"function": {
"name": "grep_search",
"description": (
"Chercher un motif (regex, insensible à la casse) dans tous les "
"fichiers du workspace. Renvoie fichier:ligne:texte."
),
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "Motif à chercher"},
"path": {"type": "string", "description": "Sous-répertoire (défaut : racine)"},
},
"required": ["pattern"],
},
},
},
{
"type": "function",
"function": {
"name": "code_task",
"description": (
"Confier une tâche de programmation au moteur code (édition "
"multi-fichiers fiable, commits git). À utiliser pour créer ou "
"modifier du code."
),
"parameters": {
"type": "object",
"properties": {
"instruction": {
"type": "string",
"description": "La tâche de code, précise et complète",
},
"files": {
"type": "array",
"items": {"type": "string"},
"description": "Fichiers concernés (optionnel)",
},
},
"required": ["instruction"],
},
},
},
{
"type": "function",
"function": {
"name": "web_search",
"description": "Rechercher sur le web et renvoyer les meilleurs résultats.",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Termes de recherche"}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "run_shell",
"description": (
"Exécuter une commande shell dans le workspace. Outil sensible :"
" l'utilisateur doit valider la commande avant exécution."
),
"parameters": {
"type": "object",
"properties": {
"command": {"type": "string", "description": "Commande à exécuter"}
},
"required": ["command"],
},
},
},
{
"type": "function",
"function": {
"name": "run_check",
"description": (
"Vérifier statiquement un fichier de code du workspace "
"(syntaxe Python/JS/JSON, structure HTML). N'exécute rien."
),
"parameters": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "Chemin relatif au workspace"}
},
"required": ["path"],
},
},
},
{
"type": "function",
"function": {
"name": "memory_search",
"description": (
"Chercher dans tes notes durables (préférences de l'utilisateur, "
"décisions, faits appris lors d'anciennes discussions)."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Ce que tu cherches"}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "memory_save",
"description": (
"Enregistrer une note durable, réutilisable dans les prochaines "
"discussions. À réserver aux informations stables et utiles "
"(préférence, choix technique, contrainte). Jamais le détail "
"d'une tâche en cours."
),
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "Titre court et parlant"},
"content": {"type": "string", "description": "La note, concise"},
},
"required": ["title", "content"],
},
},
},
]
def _normalize_args(name: str, args: dict | None) -> dict:
args = dict(args or {})
if name == "write_file":
if "path" not in args:
for alias in ("file_path", "filepath", "filename", "file", "name"):
if args.get(alias):
args["path"] = args[alias]
break
if "content" not in args:
for alias in ("text", "body", "data", "contents"):
if alias in args:
args["content"] = args[alias]
break
elif name in {"read_file", "list_dir", "edit_file"} and "path" not in args:
for alias in ("file_path", "filepath", "filename", "file", "dir", "directory"):
if args.get(alias):
args["path"] = args[alias]
break
if name == "edit_file":
if "search" not in args and "old" in args:
args["search"] = args.pop("old")
if "replace" not in args and "new" in args:
args["replace"] = args.pop("new")
elif name == "grep_search" and "pattern" not in args:
for alias in ("query", "search", "text", "regex"):
if args.get(alias):
args["pattern"] = args[alias]
break
return args
def run_tool(name: str, args: dict) -> dict:
"""Exécute un outil par son nom ; lève ToolError si inconnu/invalide."""
impl = TOOL_IMPL.get(name)
if impl is None:
raise ToolError(f"outil inconnu : {name}")
args = _normalize_args(name, args)
if name == "write_file":
missing = [key for key in ("path", "content") if key not in args]
if missing:
raise ToolError(
"arguments invalides pour write_file : "
f"{', '.join(missing)} requis. Utilise par exemple "
'{"path":"index.html","content":"...","mode":"overwrite"}.'
)
try:
return impl(**args)
except ToolError:
raise
except TypeError as exc:
raise ToolError(f"arguments invalides pour {name} : {exc}") from exc
except OSError as exc:
raise ToolError(f"erreur système ({name}) : {exc}") from exc
+2
View File
@@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto
+13
View File
@@ -0,0 +1,13 @@
# Aider fige ses dépendances partagées (fastapi, pydantic, httpx…) : il est
# listé en premier et le reste est aligné sur ses versions pour éviter tout
# conflit de résolution pip.
aider-chat==0.86.2
fastapi==0.128.8
uvicorn[standard]==0.34.0
httpx==0.28.1
pydantic==2.12.5
pydantic-settings==2.7.1
psutil==7.2.2
mcp>=1.9
pytest>=8.3
pytest-asyncio>=0.25
+11
View File
@@ -0,0 +1,11 @@
---
name: analyse-donnees
title: Analyse de données
keywords: csv, json, données, tableau, statistique, moyenne, analyse, colonnes, tri, filtre, graphique, export
---
Méthode d'analyse de données :
1. EXAMINER : lis un échantillon du fichier réel (read_file) AVANT tout traitement. Identifie séparateur, encodage, en-têtes, types de colonnes.
2. VALIDER : repère valeurs manquantes, doublons, incohérences de type. Signale-les explicitement au lieu de les masquer.
3. TRANSFORMER : script clair et borné (pas de dépendance exotique) ; garde les données d'origine intactes, écris le résultat dans un nouveau fichier.
4. PRÉSENTER : résumé chiffré (compte, min/max, moyennes pertinentes) + limites de l'analyse (données ignorées, hypothèses faites).
Interdit : supposer le format sans avoir lu le fichier ; modifier les données sources ; présenter des chiffres sans dire comment ils sont calculés.
+11
View File
@@ -0,0 +1,11 @@
---
name: creation-web
title: Création web
keywords: site, page, html, css, landing, formulaire, portfolio, interface, responsive, boutique, vitrine, menu, header, footer, animation
---
Méthode de création web, dans cet ordre :
1. STRUCTURE : écris d'abord le HTML sémantique COMPLET (header, main, sections, footer). Contenu réel, pas de lorem ipsum si le sujet est connu.
2. STYLE : CSS cohérent — palette limitée (3-4 couleurs), typographie lisible, espacements réguliers. Mobile-first, responsive (flexbox/grid, max-width sur images).
3. INTERACTIVITÉ : JavaScript minimal et sans dépendance externe. Chaque interaction doit fonctionner hors ligne.
4. VÉRIFIER : run_check sur chaque fichier produit ; contrôle les liens internes. Si les outils navigateur (mcp_playwright_*) sont disponibles, ouvre la page et lis la console pour vérifier qu'elle est propre.
Interdit : livrer sans vérification ; référencer des images ou CDN externes non demandés ; produire un fichier tronqué (utilise write_file en plusieurs morceaux).
+12
View File
@@ -0,0 +1,12 @@
---
name: debogage-systematique
title: Débogage systématique
keywords: bug, plante, erreur, exception, traceback, crash, échoue, marche pas, fonctionne pas, cassé, debug, débogue, corrige le bug, ne s'affiche pas, undefined, null, NaN
---
Méthode de débogage à suivre STRICTEMENT, étape par étape :
1. REPRODUIRE : identifie l'entrée exacte et le comportement observé vs attendu. Si le message d'erreur est fourni, cite-le et pars de là.
2. LOCALISER : lis le code concerné (read_file / grep_search) AVANT toute modification. Trouve la ligne qui produit le symptôme.
3. HYPOTHÈSE : formule UNE cause précise. Vérifie-la en lisant le code, pas en devinant.
4. CORRIGER : modification minimale et ciblée (edit_file). Ne réécris pas tout le fichier. Ne corrige qu'une cause à la fois.
5. VÉRIFIER : relis le code modifié (run_check) et explique pourquoi le symptôme disparaît. Signale tout autre problème repéré sans le corriger.
Interdit : proposer une correction sans avoir lu le code ; corriger plusieurs choses à la fois ; conclure « ça devrait marcher » sans vérification.
+10
View File
@@ -0,0 +1,10 @@
---
name: redaction-structuree
title: Rédaction structurée
keywords: rédige, écris un texte, article, documentation, readme, rapport, résumé, lettre, mail, présentation, plan
---
Méthode de rédaction :
1. PLAN : annonce la structure (titres/sections) avant de rédiger. Adapte la longueur à la demande — court par défaut.
2. RÉDACTION : paragraphes courts, une idée par paragraphe, français précis. Concret d'abord, généralités interdites. Markdown propre (titres, listes, gras parcimonieux).
3. RELECTURE : traque répétitions, phrases creuses, incohérences de ton. Coupe tout ce qui n'apporte rien.
Interdit : remplissage, formules toutes faites (« dans un monde où… »), conclusion qui répète l'introduction.
+11
View File
@@ -0,0 +1,11 @@
---
name: refactor-sur
title: Refactor sûr
keywords: refactor, refactorise, réorganise, nettoie, simplifie, renomme, découpe, extrait, duplication, dette
---
Méthode de refactoring, comportement STRICTEMENT identique avant/après :
1. COMPRENDRE : lis TOUT le code concerné et ses usages (grep_search sur chaque symbole touché) avant de modifier quoi que ce soit.
2. PETITS PAS : une seule transformation à la fois (renommage, extraction, déplacement). Jamais plusieurs changements mélangés.
3. VÉRIFIER après CHAQUE pas : run_check sur les fichiers modifiés ; re-grep pour confirmer qu'aucun usage n'est resté sur l'ancien nom.
4. RÉCAPITULER : liste ce qui a changé et pourquoi le comportement est inchangé.
Interdit : changer le comportement ou l'API publique sans le signaler ; renommer sans vérifier tous les usages ; réécrire un fichier entier quand edit_file suffit.
View File
Whitespace-only changes.
+124
View File
@@ -0,0 +1,124 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import agent # noqa: E402
def _convo():
return [
{"role": "system", "content": "consigne"},
{"role": "user", "content": "fais la tâche"},
]
@pytest.mark.asyncio
async def test_relance_apres_reflexion_seule(monkeypatch):
"""1er appel : réflexion seule -> relance ; 2e appel : réponse finale."""
calls: list[list[dict]] = []
async def fake_chat(model, convo, **kwargs):
calls.append([dict(m) for m in convo])
if len(calls) == 1:
yield {"message": {"thinking": "hmm, je réfléchis longuement…"},
"done": False}
yield {"message": {}, "done": True}
else:
yield {"message": {"content": "réponse finale"}, "done": True}
monkeypatch.setattr(agent.ollama, "chat", fake_chat)
events = [
e async for e in agent.run_agent("test", _convo(), enabled_tools=[])
]
final = [e for e in events if e["type"] == "final"]
assert final and "réponse finale" in final[0]["content"]
# La relance a bien injecté la consigne de reprise.
assert any(
"Continue la tâche" in m["content"]
for m in calls[1] if m["role"] == "user"
)
@pytest.mark.asyncio
async def test_reflexion_pas_renvoyee_au_modele(monkeypatch):
"""La pensée est affichée mais jamais réinjectée dans l'historique."""
calls: list[list[dict]] = []
async def fake_chat(model, convo, **kwargs):
calls.append([dict(m) for m in convo])
if len(calls) == 1:
yield {"message": {"thinking": "je planifie",
"content": "étape 1",
"tool_calls": [{"function": {
"name": "list_dir",
"arguments": {"path": "."}}}]},
"done": True}
else:
yield {"message": {"content": "terminé"}, "done": True}
monkeypatch.setattr(agent.ollama, "chat", fake_chat)
events = [
e async for e in agent.run_agent("test", _convo(), enabled_tools=None)
]
final = [e for e in events if e["type"] == "final"]
assert final and "terminé" in final[0]["content"]
assert "je planifie" in final[0]["thinking"] # gardée pour l'UI
# Aucun message assistant réinjecté ne contient la clé thinking.
assistant_turns = [m for m in calls[1] if m["role"] == "assistant"]
assert assistant_turns
assert all("thinking" not in m for m in assistant_turns)
@pytest.mark.asyncio
async def test_coupe_circuit_pensee_interminable(monkeypatch):
"""Pensée sans fin -> génération coupée en vol, puis relance qui aboutit."""
calls: list[int] = []
async def fake_chat(model, convo, **kwargs):
calls.append(1)
if len(calls) == 1:
# Flux de pensée « infini » : jamais de done, jamais de contenu.
for _ in range(10_000):
yield {"message": {"thinking": "x" * 200}, "done": False}
else:
yield {"message": {"content": "réponse après coupe"}, "done": True}
monkeypatch.setattr(agent.ollama, "chat", fake_chat)
events = [
e async for e in agent.run_agent("test", _convo(), enabled_tools=[])
]
final = [e for e in events if e["type"] == "final"]
assert final and "réponse après coupe" in final[0]["content"]
assert len(calls) == 2 # coupé puis relancé, pas d'épuisement du flux
@pytest.mark.asyncio
async def test_reflexion_coupee_apres_deux_impasses(monkeypatch):
"""Deux itérations de pensée pure -> think désactivé, tâche finie."""
seen_think: list = []
async def fake_chat(model, convo, think=None, **kwargs):
seen_think.append(think)
if len(seen_think) <= 2:
yield {"message": {"thinking": "boucle de pensée"}, "done": True}
else:
yield {"message": {"content": "fini sans réfléchir"}, "done": True}
monkeypatch.setattr(agent.ollama, "chat", fake_chat)
events = [
e async for e in agent.run_agent(
"test", _convo(), enabled_tools=[], think=True
)
]
final = [e for e in events if e["type"] == "final"]
assert final and "fini sans réfléchir" in final[0]["content"]
assert seen_think[-1] is False # think coupé pour l'appel final
assert any(e["type"] == "notice" for e in events)
+62
View File
@@ -0,0 +1,62 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import agent, db, mcp_client # noqa: E402
db.init_db()
def test_prune_compacte_les_anciens_resultats():
big = "x" * 5000
convo = [
{"role": "system", "content": "consigne"},
{"role": "user", "content": "tâche"},
{"role": "assistant", "content": "", "tool_calls": [{}]},
{"role": "tool", "tool_name": "read_file", "content": big},
{"role": "assistant", "content": "", "tool_calls": [{}]},
{"role": "tool", "tool_name": "grep_search", "content": big},
]
# Le dernier lot (index 5) commence à 5 : seul l'index 3 est compacté.
agent._prune_old_tool_results(convo, before_index=5)
assert len(convo[3]["content"]) < 500
assert "archivé" in convo[3]["content"]
assert convo[5]["content"] == big # lot courant intact
assert convo[0]["content"] == "consigne" # système intact
def test_prune_ignore_les_petits_resultats():
convo = [{"role": "tool", "tool_name": "list_dir", "content": "court"}]
agent._prune_old_tool_results(convo, before_index=1)
assert convo[0]["content"] == "court"
@pytest.mark.asyncio
async def test_searxng_sans_url_erreur_claire():
mgr = mcp_client.McpManager()
try:
result = await mgr.test_server("searxng")
assert result["ok"] is False
assert "SEARXNG_URL" in (result["error"] or "")
assert "requis" in (result["error"] or "")
finally:
await mgr.aclose()
def test_migration_v6_monte_les_anciens_defauts():
from app import agent_config
profiles = db.get_config_value(agent_config.MODEL_PROFILES_KEY) or {}
profiles["testmodel:7b"] = {"num_ctx": 8192}
profiles["custom:7b"] = {"num_ctx": 5000}
db.set_config_value(agent_config.MODEL_PROFILES_KEY, profiles)
db.set_config_value(agent_config.PROFILE_STATE_KEY, {"version": 5})
agent_config._migrate_profiles()
migrated = db.get_config_value(agent_config.MODEL_PROFILES_KEY)
assert migrated["testmodel:7b"]["num_ctx"] == 16384 # ancien défaut monté
assert migrated["custom:7b"]["num_ctx"] == 5000 # valeur perso respectée
+39
View File
@@ -0,0 +1,39 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import db # noqa: E402
from app import mcp_client # noqa: E402
db.init_db()
def test_catalog_has_preconfigured_servers():
for sid in ("playwright", "context7", "fetch", "searxng", "custom"):
assert sid in mcp_client.CATALOG
assert mcp_client.CATALOG[sid]["label"]
def test_state_defaults_disabled():
state = mcp_client.get_mcp_state()
assert set(state) == set(mcp_client.CATALOG)
assert all(not s["enabled"] for s in state.values())
def test_toggle_persists():
mcp_client.set_mcp_state("fetch", enabled=True, params={})
assert mcp_client.get_mcp_state()["fetch"]["enabled"] is True
mcp_client.set_mcp_state("fetch", enabled=False, params={})
assert mcp_client.get_mcp_state()["fetch"]["enabled"] is False
def test_custom_params_persist():
mcp_client.set_mcp_state(
"custom", enabled=False, params={"command": "npx -y some-mcp"}
)
assert (
mcp_client.get_mcp_state()["custom"]["params"]["command"]
== "npx -y some-mcp"
)
+96
View File
@@ -0,0 +1,96 @@
import os
import sys
import tempfile
import textwrap
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import db, mcp_client # noqa: E402
db.init_db()
# Serveur MCP minimal : un outil "echo" + un outil au nom À TIRETS
# (comme Context7 « resolve-library-id »).
_FAKE_SERVER = textwrap.dedent("""
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("fake")
@mcp.tool()
def echo(text: str) -> str:
\"\"\"Répète le texte fourni.\"\"\"
return "echo:" + text
@mcp.tool(name="dash-tool-name")
def dash_tool(text: str) -> str:
\"\"\"Outil au nom à tirets.\"\"\"
return "dash:" + text
mcp.run()
""")
@pytest.fixture()
def fake_server_cmd(tmp_path):
path = tmp_path / "fake_mcp.py"
path.write_text(_FAKE_SERVER, encoding="utf-8")
return [sys.executable, str(path)]
@pytest.mark.asyncio
async def test_tools_exposed_and_called(fake_server_cmd, monkeypatch):
monkeypatch.setitem(
mcp_client.CATALOG, "fake",
{"label": "Fake", "description": "", "command": fake_server_cmd,
"url_param": False, "env_params": [], "expose": None},
)
mcp_client.set_mcp_state("fake", enabled=True, params={})
mgr = mcp_client.McpManager()
try:
defs = await mgr.tool_definitions()
names = [d["function"]["name"] for d in defs]
assert "mcp_fake_echo" in names
# Nom à tirets exposé assaini (compatibilité function-calling).
assert "mcp_fake_dash_tool_name" in names
result = await mgr.call_tool("mcp_fake_echo", {"text": "bonjour"})
assert result["ok"] is True
assert "echo:bonjour" in result["content"]
# Appel via le nom assaini -> résolu vers le vrai nom à tirets.
dash = await mgr.call_tool("mcp_fake_dash_tool_name", {"text": "x"})
assert dash["ok"] is True and "dash:x" in dash["content"]
# Tolérance : le modèle répond avec des tirets au lieu d'underscores.
mixed = await mgr.call_tool("mcp_fake_dash-tool-name", {"text": "y"})
assert mixed["ok"] is True and "dash:y" in mixed["content"]
finally:
await mgr.aclose()
mcp_client.set_mcp_state("fake", enabled=False, params={})
@pytest.mark.asyncio
async def test_disabled_server_exposes_nothing():
mgr = mcp_client.McpManager()
try:
assert await mgr.tool_definitions() == []
finally:
await mgr.aclose()
@pytest.mark.asyncio
async def test_broken_server_never_raises(monkeypatch):
monkeypatch.setitem(
mcp_client.CATALOG, "broken",
{"label": "Broken", "description": "",
"command": [sys.executable, "-c", "import sys; sys.exit(3)"],
"url_param": False, "env_params": [], "expose": None},
)
mcp_client.set_mcp_state("broken", enabled=True, params={})
mgr = mcp_client.McpManager()
try:
assert await mgr.tool_definitions() == []
assert mgr.statuses()["broken"]["state"] == "error"
assert any("broken" in n.lower() or "Broken" in n for n in mgr.notices())
finally:
await mgr.aclose()
mcp_client.set_mcp_state("broken", enabled=False, params={})
+62
View File
@@ -0,0 +1,62 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import db, tools # noqa: E402
from app.config import settings # noqa: E402
db.init_db()
_ROOT = os.path.abspath(settings.workspace_dir)
@pytest.fixture(autouse=True)
def _reset_project():
tools.set_project(None)
yield
tools.set_project(None)
def test_racine_par_defaut():
assert tools.active_root() == _ROOT
def test_set_project_reracine():
tools.set_project("demo")
root = tools.active_root()
assert root == os.path.join(_ROOT, "demo")
assert os.path.isdir(root) # créée à la volée
def test_confinement_conserve():
tools.set_project("demo")
with pytest.raises(tools.ToolError):
tools._safe_path("../hors-projet")
def test_nom_projet_invalide():
for bad in ("../x", "UPPER", "a b", "", "x" * 50):
with pytest.raises(tools.ToolError):
tools.set_project(bad)
def test_session_porte_son_projet():
s = db.create_session("t", None, project="demo")
assert db.get_session(s["id"])["project"] == "demo"
db.set_session_project(s["id"], None)
assert db.get_session(s["id"])["project"] is None
def test_aides_contexte_suivent_le_projet():
from app.routes.chat import _mentioned_files, _workspace_listing
tools.set_project("ctxdemo")
root = tools.active_root()
with open(os.path.join(root, "app.py"), "w", encoding="utf-8") as f:
f.write("x = 1")
assert "app.py" in _workspace_listing()
assert _mentioned_files("corrige app.py") == ["app.py"]
tools.set_project(None)
assert _mentioned_files("corrige app.py") == [] # absent de la racine
+46
View File
@@ -0,0 +1,46 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from fastapi.testclient import TestClient # noqa: E402
from app.main import app # noqa: E402
from app.config import settings # noqa: E402
client = TestClient(app)
_ROOT = os.path.abspath(settings.workspace_dir)
def test_creation_et_liste():
r = client.post("/api/projects", json={"name": "routedemo"})
assert r.status_code == 201
assert os.path.isdir(os.path.join(_ROOT, "routedemo", ".git"))
names = [p["name"] for p in client.get("/api/projects").json()["projects"]]
assert "routedemo" in names
def test_nom_invalide_400():
assert client.post("/api/projects", json={"name": "../x"}).status_code == 400
assert client.post("/api/projects", json={"name": "Demo"}).status_code == 400
def test_existant_400():
client.post("/api/projects", json={"name": "dup"})
assert client.post("/api/projects", json={"name": "dup"}).status_code == 400
def test_files_re_racine():
client.post("/api/projects", json={"name": "scoped"})
with open(os.path.join(_ROOT, "scoped", "a.txt"), "w") as f:
f.write("x")
with open(os.path.join(_ROOT, "racine.txt"), "w") as f:
f.write("y")
tree = client.get("/api/files", params={"project": "scoped"}).json()["tree"]
names = [n["name"] for n in tree]
assert "a.txt" in names and "racine.txt" not in names
def test_files_projet_invalide_400():
assert client.get("/api/files", params={"project": "../x"}).status_code == 400
+96
View File
@@ -0,0 +1,96 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import router # noqa: E402
from app.routes.chat import ( # noqa: E402
_mentioned_files,
_prev_was_code,
_session_code_context,
_workspace_listing,
)
from app.config import settings # noqa: E402
def test_message_code_explicite():
assert router.is_code_task("crée une page html avec un formulaire") is True
def test_message_banal_pas_code():
assert router.is_code_task("quelle heure est-il ?") is False
def test_followup_courts():
# Suites typiques d'un travail de code, sans vocabulaire code explicite.
assert router.is_code_followup("ajoute un bouton rouge en haut") is True
assert router.is_code_followup("continue") is True
assert router.is_code_followup("enlève le menu et agrandis le titre") is True
assert router.is_code_followup("merci beaucoup !") is False
def test_prev_was_code_via_engine():
history = [
{"role": "user", "content": "crée un site", "meta": None},
{"role": "assistant", "content": "fait", "meta": {"engine": "code"}},
]
assert _prev_was_code(history) is True
def test_prev_was_code_via_write_file():
history = [
{"role": "assistant", "content": "ok",
"meta": {"tools": [{"name": "write_file", "args": {}}]}},
]
assert _prev_was_code(history) is True
def test_prev_was_code_discussion():
history = [
{"role": "assistant", "content": "voici la réponse", "meta": None},
]
assert _prev_was_code(history) is False
def test_mentioned_files_existants_seulement():
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "index.html"), "w", encoding="utf-8") as f:
f.write("<html></html>")
found = _mentioned_files("modifie index.html et style.css")
assert found == ["index.html"] # style.css n'existe pas
def test_session_code_context_recap_et_fichiers():
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "jeu.html"), "w", encoding="utf-8") as f:
f.write("<html>jeu</html>")
history = [
{"role": "user", "content": "crée un jeu snake dans jeu.html",
"meta": None},
{"role": "assistant", "content": "fait",
"meta": {"engine": "code",
"tools": [{"name": "write_file",
"args": {"path": "jeu.html"}}]}},
{"role": "user", "content": "il y a des bugs", "meta": None},
]
recap, files = _session_code_context(history)
assert "crée un jeu snake" in recap
assert files == ["jeu.html"]
def test_session_code_context_vide_sans_historique():
recap, files = _session_code_context([])
assert recap == ""
assert files == []
def test_workspace_listing_contient_fichiers():
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
with open(os.path.join(root, "jeu.html"), "w", encoding="utf-8") as f:
f.write("x")
listing = _workspace_listing()
assert "jeu.html" in listing
+43
View File
@@ -0,0 +1,43 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import tools # noqa: E402
from app.config import settings # noqa: E402
# Le workspace effectif peut avoir été fixé par un autre fichier de test
# importé avant celui-ci : on écrit là où les outils lisent réellement.
_WS = os.path.abspath(settings.workspace_dir)
os.makedirs(_WS, exist_ok=True)
def _write(name: str, content: str) -> str:
path = os.path.join(_WS, name)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
return name
def test_python_valide():
rel = _write("ok.py", "x = 1\nprint(x)\n")
assert tools.run_check(rel)["ok"] is True
def test_python_casse():
rel = _write("ko.py", "def broken(:\n")
result = tools.run_check(rel)
assert result["ok"] is False
assert result["issues"]
def test_json_casse():
rel = _write("ko.json", "{invalid")
assert tools.run_check(rel)["ok"] is False
def test_fichier_inconnu_type():
rel = _write("notes.txt", "bonjour")
result = tools.run_check(rel)
assert result["ok"] is True # type non vérifiable = pas d'erreur
+26
View File
@@ -0,0 +1,26 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import skills # noqa: E402
def test_five_skills_loaded():
assert len(skills.ALL_SKILLS) == 5
assert all(s["title"] and s["body"] for s in skills.ALL_SKILLS.values())
def test_bug_message_picks_debug():
s = skills.pick_skill("mon script plante avec une erreur TypeError au démarrage")
assert s and s["name"] == "debogage-systematique"
def test_web_message_picks_web():
s = skills.pick_skill("crée une page html responsive pour ma boutique")
assert s and s["name"] == "creation-web"
def test_banal_message_picks_nothing():
assert skills.pick_skill("bonjour, quelle heure est-il ?") is None
+69
View File
@@ -0,0 +1,69 @@
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from app import tools # noqa: E402
from app.config import settings # noqa: E402
_WS = os.path.abspath(settings.workspace_dir)
os.makedirs(_WS, exist_ok=True)
# ── read_file fenêtré ────────────────────────────────────────────────────
def _write(name: str, content: str) -> str:
with open(os.path.join(_WS, name), "w", encoding="utf-8") as f:
f.write(content)
return name
def test_read_file_petit_entier():
rel = _write("petit.txt", "a\nb\nc\n")
result = tools.read_file(rel)
assert result["content"] == "a\nb\nc"
assert "3 lignes" in result["summary"]
def test_read_file_gros_fenetre():
rel = _write("gros.txt", "\n".join(f"ligne {i}" for i in range(1, 501)))
result = tools.read_file(rel)
assert "ligne 200" in result["content"]
assert "ligne 201" not in result["content"].replace("start_line=201", "")
assert "start_line=201" in result["content"] # marche à suivre
assert "1-200 sur 500" in result["summary"]
def test_read_file_fenetre_suivante():
rel = _write("gros2.txt", "\n".join(f"ligne {i}" for i in range(1, 501)))
result = tools.read_file(rel, start_line=201)
assert "ligne 201" in result["content"]
assert "201-400 sur 500" in result["summary"]
# ── sortie shell filtrée ─────────────────────────────────────────────────
def test_compact_succes_garde_la_fin():
out = "\n".join(f"étape {i}" for i in range(1, 101))
compact = tools._compact_output(out, 0)
assert "étape 100" in compact
assert "étape 1\n" not in compact
assert "lignes omises" in compact
def test_compact_echec_garde_les_erreurs():
out = "\n".join(
["compilation démarrée"]
+ [f"module {i} ok" for i in range(50)]
+ ["ERROR: variable x undefined", "build failed"]
)
compact = tools._compact_output(out, 1)
assert "ERROR: variable x undefined" in compact
assert "build failed" in compact
assert "module 3 ok" not in compact
def test_dedupe_repetitions():
lines = ["warn: deprecated"] * 5 + ["fin"]
assert tools._dedupe_lines(lines) == ["warn: deprecated ×5", "fin"]
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

-17
View File
@@ -1,17 +0,0 @@
// Point d'entrée du binaire loki. Tout le code vit dans internal/loki ; ce
// dossier ne porte que le main() et les ressources Windows (.syso, icône,
// versioninfo) qui doivent résider dans le dossier du package main.
//
// Les directives ci-dessous embarquent les métadonnées Windows (éditeur, version,
// description) dans le .exe pour réduire les faux positifs antivirus. Régénère
// les .syso après avoir bumpé la version : `go generate ./...`
// (nécessite : go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest)
//
//go:generate go run github.com/R0m1k3/Loki/tools/gen-icon icon.ico
//go:generate goversioninfo -64 -icon=icon.ico -o resource_windows_amd64.syso versioninfo.json
//go:generate goversioninfo -64 -arm -icon=icon.ico -o resource_windows_arm64.syso versioninfo.json
package main
import "github.com/R0m1k3/Loki/internal/loki"
func main() { loki.Main() }
Binary file not shown.
Binary file not shown.
-37
View File
@@ -1,37 +0,0 @@
{
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 15,
"Patch": 0,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 15,
"Patch": 0,
"Build": 0
},
"FileFlagsMask": "3f",
"FileFlags": "00",
"FileOS": "040004",
"FileType": "01",
"FileSubType": "00"
},
"StringFileInfo": {
"CompanyName": "Loki contributors",
"FileDescription": "Loki — gestionnaire mono-binaire de serveurs llama.cpp auto-hébergés",
"InternalName": "loki",
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
"OriginalFilename": "loki.exe",
"ProductName": "Loki",
"ProductVersion": "0.15.0",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
"Translation": {
"LangID": "040C",
"CharsetID": "04B0"
}
}
}
View File
Whitespace-only changes.
+29 -69
View File
@@ -1,85 +1,45 @@
# ───────────────────────────────────────────────────────────────────────────
# Loki — docker-compose pour Unraid (image préconstruite par GitHub Actions)
# Loki — docker-compose pour Unraid (image préconstruite par GitHub)
#
# Loki est un fork conteneurisé d'AJEAN (github.com/nathaninline/ajean, MIT) :
# moteur llama.cpp CUDA + interface web dans UN conteneur autonome.
# Aucun build ni git nécessaire sur Unraid — l'image est publiée sur GHCR à
# chaque push sur main.
# Aucun build ni git nécessaire sur Unraid : l'image est publiée automatiquement
# sur GHCR par GitHub Actions à chaque push sur main.
#
# MISE EN PLACE :
# 1) Plugin « Nvidia Driver » (Apps) installé, GPU visible (nvidia-smi).
# 2) Crée les dossiers de données (terminal Unraid) :
# mkdir -p /mnt/user/appdata/loki/data /mnt/user/appdata/loki/models
# 3) Plugin « Compose Manager » -> nouvelle stack -> colle ce fichier.
# 4) Compose Up. Interface : http://<ip-unraid>:8090
# 1) Crée les dossiers de données (terminal Unraid) :
# mkdir -p /mnt/user/appdata/loki/workspace /mnt/user/appdata/loki/data
# 2) Plugin « Compose Manager » -> nouvelle stack -> colle ce fichier.
# 3) Adapte OLLAMA_HOST avec l'IP de ton serveur Unraid (Ollama sur :11434).
# 4) Compose Up. Interface : http://<ip-unraid>:8717
#
# Les modèles se téléchargent directement depuis l'UI (catalogue intégré),
# ou dépose tes .gguf dans /mnt/user/appdata/loki/models.
# Mise à jour : Compose Down puis Up (l'option « pull image » récupère la
# dernière version), ou « docker compose pull » avant Up.
#
# Mise à jour : AUTOMATIQUE au redémarrage du conteneur (binaires Loki de
# la release « rolling », vérifiés SHA-256 ; LOKI_AUTO_UPDATE=0 pour couper).
# Si le journal demande une nouvelle image : Compose Down puis Up avec
# « pull image », ou « docker compose pull » avant Up.
# NB : si l'image ghcr.io/r0m1k3/loki est privée, rends-la publique une fois
# (GitHub -> Packages -> loki -> Package settings -> Change visibility),
# ou connecte Unraid à GHCR avec un token.
# ───────────────────────────────────────────────────────────────────────────
services:
loki:
image: ghcr.io/r0m1k3/loki:latest
container_name: loki
runtime: nvidia
ports:
# Pour changer de port : modifie 8090 à gauche ET LOKI_WEB_PORT… ou
# seulement à gauche (ex. "8717:8090") si tu veux garder l'ancien port.
- "8090:8090"
# Un seul numéro de port, identique dedans/dehors (évite tout décalage).
# Pour changer de port : modifie 8717 AUX DEUX endroits + PORT ci-dessous.
- "8717:8717"
environment:
- NVIDIA_VISIBLE_DEVICES=all
# Optionnel : modèle initial (sinon, choisis-le dans l'UI au premier
# lancement). Ex. LOKI_MODEL=Qwen3-14B-Q4_K_M.gguf après dépôt du
# fichier dans /mnt/user/appdata/loki/models.
- LOKI_MODEL=
# Accès par NOM DE DOMAINE (reverse proxy, ex. loki.mondomaine.fr) sans
# clé de pilotage : liste ici le(s) nom(s), séparés par des virgules —
# sinon l'API répond 403 (protection contre les sites tiers / DNS
# rebinding). Inutile pour un accès par IP ou nom local.
- LOKI_TRUSTED_HOSTS=
# Conversation tenue par la façade Rust (défaut). « go » la rend au Go si
# quelque chose ne va pas — puis Compose Down / Up.
# - LOKI_CHAT=go
# >>> À ADAPTER : IP de ton serveur Unraid (Ollama écoute sur :11434) <<<
- OLLAMA_HOST=http://192.168.1.10:11434
- DEFAULT_MODEL=gemma4:12b
- WORKSPACE_DIR=/workspace
- DATA_DIR=/data
- PORT=8717
# Optionnel : instance SearxNG pour l'outil web_search (sinon DuckDuckGo)
- SEARX_URL=
volumes:
# PERFORMANCE — /mnt/user/… passe par la couche FUSE des partages
# Unraid (shfs) : chaque écriture de la base et chaque page d'un gros
# modèle relue depuis le disque (modèle plus gros que la RAM) traverse un
# démon en espace utilisateur. Loki le signale au démarrage (encart
# d'information, rien n'est perdu). Deux remèdes, au choix :
# - Recommandé (Unraid 6.12+) : partages en « Exclusive access ».
# Ce n'est pas une case mais un état : Settings -> Global Share
# Settings -> « Permit exclusive shares » = Yes, puis le partage
# appdata (et celui des modèles) entièrement sur UN pool — mover
# d'abord vers le pool, ensuite Secondary storage = None. La page du
# partage affiche alors « Exclusive access : Yes ». Redémarre le
# conteneur : le chemin /mnt/user reste le même, FUSE est
# court-circuité.
# - Avancé : mapper /mnt/<ton-pool>/appdata/loki/… (ex. /mnt/cache si
# ton pool s'appelle « cache »). Migration MANUELLE obligatoire :
# Compose Down, rsync -a de l'ancien dossier vers le nouveau, puis
# changer les deux lignes ci-dessous. Un /mnt/<pool> qui n'existe
# pas atterrit dans la RAM d'Unraid : données perdues au redémarrage,
# et un modèle de 80 Go peut saturer la mémoire du serveur.
# Ne change JAMAIS ce chemin sans migrer : /data apparaîtrait vide.
# /data : config, base, mémoire, workspace, modèles téléchargés par l'UI
- /mnt/user/appdata/loki/workspace:/workspace
- /mnt/user/appdata/loki/data:/data
# /models : dossier de .gguf déposés à la main (gros fichiers)
- /mnt/user/appdata/loki/models:/models
# Strata sur un SSD dédié (optionnel) : tout ce que Strata installe
# (moteur, Python, modèle de 66 à 84 Go, experts lus en mmap) va dans
# /data/strata. Monter un SSD à cet endroit l'y envoie entier. Le SSD
# doit être un pool Unraid (Main -> Add Pool, ex. « strata ») : chemin
# /mnt/<pool>/…, PAS /mnt/user/… (FUSE ralentit le mmap des experts).
# Vérifie que /mnt/strata existe avant Compose Up : sinon Docker crée
# le dossier dans la RAM d'Unraid. Détails : README, « Strata sur un
# SSD dédié ».
# - /mnt/strata/loki:/data/strata
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
extra_hosts:
# Permet aussi OLLAMA_HOST=http://host.docker.internal:11434
- "host.docker.internal:host-gateway"
# Le HEALTHCHECK est défini dans l'image (utilise automatiquement le bon port).
restart: unless-stopped
+40 -29
View File
@@ -1,35 +1,47 @@
# Loki — fork conteneurisé d'AJEAN (https://github.com/nathaninline/ajean, MIT)
# Un seul service : le conteneur embarque le moteur llama.cpp (CUDA) ET l'UI.
#
# cp .env.example .env # ajuste si besoin
# docker compose up --build # build local en quelques minutes (llama-server
# # vient précompilé de l'image officielle llama.cpp)
#
# Interface : http://localhost:8090
# Pré-requis GPU : NVIDIA Container Toolkit sur l'hôte.
services:
loki:
build:
context: .
# Pour épingler la version du moteur ou passer en CPU/Vulkan :
# args: { LLAMACPP_IMAGE: "ghcr.io/ggml-org/llama.cpp:server-cuda-b10423" }
build: .
container_name: loki
ports:
- "${LOKI_WEB_PORT:-8090}:${LOKI_WEB_PORT:-8090}"
# Un seul numéro de port, identique dedans/dehors.
- "${PORT:-8717}:${PORT:-8717}"
environment:
- LOKI_WEB_PORT=${LOKI_WEB_PORT:-8090}
# Semé UNE fois au premier démarrage (modifiable ensuite dans l'UI) :
- LOKI_MODEL=${LOKI_MODEL:-}
- LOKI_CTX=${LOKI_CTX:-}
- LOKI_NGL=${LOKI_NGL:-}
# Accès par nom de domaine (reverse proxy) sans clé de pilotage : nom(s)
# autorisé(s), séparés par des virgules — sinon l'API répond 403.
- LOKI_TRUSTED_HOSTS=${LOKI_TRUSTED_HOSTS:-}
- OLLAMA_HOST=${OLLAMA_HOST:-http://host.docker.internal:11434}
- DEFAULT_MODEL=${DEFAULT_MODEL:-gemma4:12b}
- WORKSPACE_DIR=/workspace
- DATA_DIR=/data
- PORT=${PORT:-8717}
# Optionnel : instance SearxNG pour web_search (sinon DuckDuckGo)
- SEARX_URL=${SEARX_URL:-}
volumes:
# /data = LOKI_HOME : config, base bbolt, mémoire, workspace, modèles téléchargés
- ./workspace:/workspace
- ./data:/data
# /models : dossier(s) de modèles GGUF supplémentaires (LOKI_MODEL_DIRS)
- ./models:/models
extra_hosts:
# Permet d'atteindre un Ollama installé sur la machine hôte
- "host.docker.internal:host-gateway"
# Le HEALTHCHECK est défini dans l'image (utilise automatiquement $PORT).
restart: unless-stopped
# ── Ollama optionnel ──────────────────────────────────────────────────
# Par défaut, Loki se connecte à un Ollama déjà présent sur l'hôte.
# Pour embarquer Ollama dans la stack, lance : docker compose --profile ollama up
# et règle OLLAMA_HOST=http://ollama:11434
ollama:
image: ollama/ollama:latest
container_name: loki-ollama
profiles: ["ollama"]
environment:
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- OLLAMA_FLASH_ATTENTION=1
- OLLAMA_KV_CACHE_TYPE=q8_0
ports:
- "11434:11434"
volumes:
- ollama-models:/root/.ollama
# Accès GPU NVIDIA : sans ceci, l'Ollama embarqué tourne en CPU (très lent).
# Nécessite le NVIDIA Container Toolkit installé sur l'hôte. Si tu n'as pas
# de GPU NVIDIA, commente tout le bloc `deploy:` ci-dessous.
deploy:
resources:
reservations:
@@ -37,8 +49,7 @@ services:
- driver: nvidia
count: all
capabilities: ["gpu"]
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
ulimits:
memlock: -1
restart: unless-stopped
volumes:
ollama-models:
-98
View File
@@ -1,98 +0,0 @@
#!/bin/sh
# Entrypoint du conteneur Loki : met à jour les binaires Loki, sème la
# configuration au premier démarrage, lance le moteur (supervision PID, sans
# systemd), puis l'UI au premier plan derrière la façade Rust (loki-rs).
set -eu
: "${LOKI_HOME:=/data}"
export LOKI_HOME # les sous-commandes loki doivent voir le MEME dossier
mkdir -p "$LOKI_HOME"
# Garde-fou persistance : si /data n'est pas un point de montage, tout ce que
# l'utilisateur fera (modèles, discussions, fichiers) mourra avec le conteneur.
# On le dit au premier écran du journal — l'UI l'affiche aussi en bandeau.
if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
echo "[entrypoint] ⚠️ $LOKI_HOME n'est pas un volume monté : les données seront PERDUES à la recréation du conteneur."
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
fi
# ── Mise à jour au démarrage ───────────────────────────────────────────────
# Le loki-rs DE L'IMAGE télécharge la dernière release « rolling » (binaires
# vérifiés par SHA-256) dans $LOKI_HOME/bin/<build>/ : un simple redémarrage
# du conteneur suffit à passer à la dernière version. C'est toujours celui de
# l'image qui s'en charge — un binaire publié défectueux ne peut donc pas
# empêcher le correctif suivant d'arriver. Jamais bloquant (réseau coupé,
# empreinte fausse : l'état précédent reste). LOKI_AUTO_UPDATE=0 le coupe.
/usr/local/bin/loki-rs self-update || true
# loki et loki-rs viennent TOUJOURS du même dossier : /data/bin/<build> s'il
# est plus récent que l'image et de même ABI, sinon l'image.
BIN_DIR=$(/usr/local/bin/loki-rs which-bin 2>/dev/null || true)
if [ -z "$BIN_DIR" ] || [ ! -x "$BIN_DIR/loki" ] || [ ! -x "$BIN_DIR/loki-rs" ]; then
BIN_DIR=/usr/local/bin
fi
echo "[entrypoint] binaires Loki : $BIN_DIR"
# Tous les appels « loki » ci-dessous visent le binaire choisi.
loki() { "$BIN_DIR/loki" "$@"; }
# Et `docker exec loki loki …` aussi : /usr/local/sbin passe AVANT
# /usr/local/bin dans le PATH, un lien y suffit sans toucher aux binaires de
# l'image (celui de loki-rs doit rester intact, c'est lui qui met à jour).
if [ "$BIN_DIR" != /usr/local/bin ]; then
ln -sf "$BIN_DIR/loki" /usr/local/sbin/loki 2>/dev/null || true
else
rm -f /usr/local/sbin/loki 2>/dev/null || true
fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
#
# SAUF si l'utilisateur a mis le moteur à jour depuis l'interface : celui-là
# vit sous $LOKI_HOME/engine/<version>/ (volume de données, il survit au
# conteneur) et c'est lui qu'il a choisi. Le reposer sur /app à chaque
# redémarrage ramenait silencieusement l'ancien moteur — un modèle qui
# chargeait la veille ne chargeait plus le lendemain, « unknown model
# architecture », sans que rien n'ait été touché. On ne le garde que s'il
# existe encore : un dossier supprimé à la main ne doit pas laisser BIN dans
# le vide.
cur=$(loki config get BIN)
case "$cur" in
"$LOKI_HOME"/engine/*/llama-server) [ -x "$cur" ] || cur="" ;;
*) cur="" ;;
esac
if [ -n "$cur" ]; then
echo "[entrypoint] moteur mis à jour conservé : $cur"
else
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
fi
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
seed() { # seed CLÉ VALEUR
[ -n "$2" ] || return 0
cur=$(loki config get "$1")
[ -n "$cur" ] || loki config set "$1=$2"
}
seed HOST "${LOKI_ENGINE_HOST:-127.0.0.1}"
seed PORT "${LOKI_ENGINE_PORT:-8080}"
seed MODEL "${LOKI_MODEL:-}"
seed CTX "${LOKI_CTX:-}"
seed NGL "${LOKI_NGL:-}"
# Moteur : démarrage best-effort. Sans MODEL configuré, on laisse l'UI monter
# quand même — le moteur partira au premier modèle choisi/téléchargé dans l'UI.
if [ -n "$(loki config get MODEL)" ]; then
loki start || echo "[entrypoint] moteur non démarré (voir loki logs) — l'UI reste accessible"
else
echo "[entrypoint] aucun MODEL configuré — télécharge un modèle depuis l'UI pour démarrer le moteur"
fi
# 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
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,954 @@
# Projets + aperçu réductible — 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:** l'agent travaille dans des projets (sous-dossiers du workspace) choisis par session depuis le composer, et le panneau d'aperçu se replie.
**Architecture:** une contextvar dans `tools.py` re-racine `_safe_path`/`_workspace_root` sur `workspace/<projet>` pour toute la requête (chat, outils, shell, Aider) ; les routes fichiers/git prennent un paramètre `project` optionnel et posent la même contextvar. La session porte son projet (colonne SQLite). Frontend : chip 📁 dans le composer, store re-racine les appels fichiers.
**Tech Stack:** FastAPI, SQLite (migrations douces ALTER TABLE), contextvars, React/zustand.
**Spec:** `docs/superpowers/specs/2026-07-19-projets-apercu-design.md`
## Global Constraints
- Nom de projet : `^[a-z0-9][a-z0-9_-]{0,40}$` — sinon 400.
- Session sans projet (NULL) = racine du workspace, comportement actuel intact.
- Confinement `_safe_path` inchangé dans sa logique (re-raciné seulement).
- Un dépôt git PAR projet (`ensure_git` à la création et au premier usage git).
- Projet disparu du disque → chat retombe sur la racine + notice SSE.
- UI française, styles existants. Tests : `python -m pytest` depuis `backend/`.
- Après chaque tâche frontend : `cd frontend && npx tsc -b` passe.
---
### Task 1: Racine active (contextvar) + colonne project + routes sessions
**Files:**
- Modify: `backend/app/tools.py` (contextvar + validation nom)
- Modify: `backend/app/db.py` (migration + create/set project)
- Modify: `backend/app/routes/sessions.py` (create + PATCH avec project)
- Test: `backend/tests/test_projects.py`
**Interfaces:**
- Produces: `tools.set_project(name: str | None) -> None` (valide le nom, pose la contextvar ; `ToolError` si nom invalide) ; `tools.active_root() -> str` (racine effective, créée si absente) ; `tools.PROJECT_NAME` (regex compilée) ; `db.create_session(title, model, project=None)` ; `db.set_session_project(sid, project: str | None)` ; PATCH `/api/sessions/{sid}` accepte `{title?, project?}` (`project: ""` = retour racine).
- [ ] **Step 1: Écrire les tests qui échouent**
`backend/tests/test_projects.py` :
```python
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
import pytest # noqa: E402
from app import db, tools # noqa: E402
from app.config import settings # noqa: E402
db.init_db()
_ROOT = os.path.abspath(settings.workspace_dir)
@pytest.fixture(autouse=True)
def _reset_project():
tools.set_project(None)
yield
tools.set_project(None)
def test_racine_par_defaut():
assert tools.active_root() == _ROOT
def test_set_project_reracine():
tools.set_project("demo")
root = tools.active_root()
assert root == os.path.join(_ROOT, "demo")
assert os.path.isdir(root) # créée à la volée
def test_confinement_conserve():
tools.set_project("demo")
with pytest.raises(tools.ToolError):
tools._safe_path("../hors-projet")
def test_nom_projet_invalide():
for bad in ("../x", "UPPER", "a b", "", "x" * 50):
with pytest.raises(tools.ToolError):
tools.set_project(bad)
def test_session_porte_son_projet():
s = db.create_session("t", None, project="demo")
assert db.get_session(s["id"])["project"] == "demo"
db.set_session_project(s["id"], None)
assert db.get_session(s["id"])["project"] is None
```
- [ ] **Step 2: Vérifier l'échec**
Run: `cd backend && python -m pytest tests/test_projects.py -q`
Expected: FAIL — `AttributeError: module 'app.tools' has no attribute 'set_project'`
- [ ] **Step 3: tools.py — contextvar**
En tête de `backend/app/tools.py` (après l'import `settings`) :
```python
from contextvars import ContextVar
# Projet actif pour la requête en cours : re-racine tous les outils sur
# workspace/<projet>. None = racine du workspace (comportement historique).
_ACTIVE_PROJECT: ContextVar[str | None] = ContextVar("loki_project", default=None)
PROJECT_NAME = re.compile(r"^[a-z0-9][a-z0-9_-]{0,40}$")
def set_project(name: str | None) -> None:
"""Fixe le projet actif de la requête (None = racine)."""
if name is not None and not PROJECT_NAME.match(name):
raise ToolError(f"nom de projet invalide : {name!r}")
_ACTIVE_PROJECT.set(name)
def active_root() -> str:
"""Racine effective (workspace ou projet), créée si nécessaire."""
return _workspace_root()
```
Remplacer `_workspace_root` :
```python
def _workspace_root() -> str:
root = os.path.abspath(settings.workspace_dir)
project = _ACTIVE_PROJECT.get()
if project:
root = os.path.join(root, project)
os.makedirs(root, exist_ok=True)
return root
```
(`_safe_path` ne change pas : il s'appuie sur `_workspace_root()`.)
- [ ] **Step 4: db.py — colonne + accesseurs**
Dans `init_db`, à côté de la migration `summary` :
```python
if "project" not in scols:
conn.execute("ALTER TABLE sessions ADD COLUMN project TEXT")
```
`create_session` — ajouter le paramètre et la colonne (adapter l'INSERT
existant) :
```python
def create_session(title: str, model: str | None, project: str | None = None) -> dict:
```
et inclure `project` dans l'INSERT et le dict renvoyé. Ajouter :
```python
def set_session_project(sid: str, project: str | None) -> None:
with _LOCK, _connect() as conn:
conn.execute(
"UPDATE sessions SET project = ? WHERE id = ?", (project, sid)
)
```
Vérifier que `list_sessions`/`get_session` renvoient la colonne (elles font
`dict(row)` — la colonne suit automatiquement).
- [ ] **Step 5: sessions.py — create + PATCH**
```python
class CreateSession(BaseModel):
title: str = "Nouvelle session"
model: str | None = None
project: str | None = None
class UpdateSession(BaseModel):
title: str | None = None
project: str | None = None # "" = retour à la racine du workspace
```
```python
@router.post("")
async def post_session(req: CreateSession) -> dict:
if req.project and not tools.PROJECT_NAME.match(req.project):
raise HTTPException(400, "nom de projet invalide")
return db.create_session(req.title, req.model, req.project or None)
@router.patch("/{sid}")
async def patch_session(sid: str, req: UpdateSession) -> dict:
if not db.get_session(sid):
raise HTTPException(404, "session introuvable")
if req.title is not None:
db.rename_session(sid, req.title)
if req.project is not None:
project = req.project or None
if project and not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
db.set_session_project(sid, project)
return {"ok": True}
```
(import : `from .. import db, tools`)
- [ ] **Step 6: Vérifier + commit**
Run: `cd backend && python -m pytest tests/ -q` — tout PASS.
```bash
git add backend/app/tools.py backend/app/db.py backend/app/routes/sessions.py backend/tests/test_projects.py
git commit -m "feat(projets): racine active par contextvar + projet par session"
```
---
### Task 2: Routes /api/projects + files/git re-racinés
**Files:**
- Create: `backend/app/routes/projects.py`
- Modify: `backend/app/routes/files.py` (param `project` sur les 4 routes)
- Modify: `backend/app/routes/git.py` (param `project` sur les 3 routes)
- Modify: `backend/app/main.py` (include projects.router)
- Test: `backend/tests/test_projects_routes.py`
**Interfaces:**
- Consumes: `tools.set_project`, `tools.active_root`, `tools.PROJECT_NAME`, `coder.ensure_git` (existant).
- Produces: `GET /api/projects` → `{projects: [{name, files}], root_files}` ; `POST /api/projects {name}` → `{name}` (201) ; toutes les routes files/git acceptent `?project=`.
- [ ] **Step 1: Tests qui échouent**
`backend/tests/test_projects_routes.py` :
```python
import os
import tempfile
os.environ.setdefault("DATA_DIR", tempfile.mkdtemp())
os.environ.setdefault("WORKSPACE_DIR", tempfile.mkdtemp())
from fastapi.testclient import TestClient # noqa: E402
from app import tools # noqa: E402
from app.main import app # noqa: E402
from app.config import settings # noqa: E402
client = TestClient(app)
_ROOT = os.path.abspath(settings.workspace_dir)
def test_creation_et_liste():
r = client.post("/api/projects", json={"name": "demo"})
assert r.status_code == 201
assert os.path.isdir(os.path.join(_ROOT, "demo", ".git"))
names = [p["name"] for p in client.get("/api/projects").json()["projects"]]
assert "demo" in names
def test_nom_invalide_400():
assert client.post("/api/projects", json={"name": "../x"}).status_code == 400
assert client.post("/api/projects", json={"name": "Demo"}).status_code == 400
def test_existant_400():
client.post("/api/projects", json={"name": "dup"})
assert client.post("/api/projects", json={"name": "dup"}).status_code == 400
def test_files_re_racine():
client.post("/api/projects", json={"name": "scoped"})
with open(os.path.join(_ROOT, "scoped", "a.txt"), "w") as f:
f.write("x")
with open(os.path.join(_ROOT, "racine.txt"), "w") as f:
f.write("y")
tree = client.get("/api/files", params={"project": "scoped"}).json()["tree"]
names = [n["name"] for n in tree]
assert "a.txt" in names and "racine.txt" not in names
def test_files_projet_invalide_400():
assert client.get("/api/files", params={"project": "../x"}).status_code == 400
```
Run: `cd backend && python -m pytest tests/test_projects_routes.py -q`
Expected: FAIL (404 sur /api/projects).
Note : `TestClient` requiert `httpx` (déjà présent). Si `starlette` réclame
un extra, `python -m pip install "httpx<1"` est déjà satisfait.
- [ ] **Step 2: routes/projects.py**
```python
"""Routes des projets : sous-dossiers de premier niveau du workspace."""
from __future__ import annotations
import os
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from .. import coder, tools
from ..config import settings
router = APIRouter(prefix="/api/projects", tags=["projects"])
def _root() -> str:
root = os.path.abspath(settings.workspace_dir)
os.makedirs(root, exist_ok=True)
return root
def _count_files(path: str) -> int:
total = 0
for dirpath, dirnames, filenames in os.walk(path):
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
total += sum(1 for f in filenames if not f.startswith("."))
return total
@router.get("")
async def list_projects() -> dict:
root = _root()
projects = []
root_files = 0
for name in sorted(os.listdir(root)):
full = os.path.join(root, name)
if name.startswith("."):
continue
if os.path.isdir(full):
projects.append({"name": name, "files": _count_files(full)})
else:
root_files += 1
return {"projects": projects, "root_files": root_files}
class CreateProject(BaseModel):
name: str
@router.post("", status_code=201)
async def create_project(req: CreateProject) -> dict:
name = req.name.strip()
if not tools.PROJECT_NAME.match(name):
raise HTTPException(400, "nom de projet invalide (a-z, 0-9, - et _)")
target = os.path.join(_root(), name)
if os.path.exists(target):
raise HTTPException(400, "ce projet existe déjà")
os.makedirs(target)
# Dépôt git par projet : commits Aider + onglet Git propres au projet.
coder.ensure_git(target)
return {"name": name}
```
`main.py` : ajouter `projects` à l'import des routes et
`app.include_router(projects.router)`.
- [ ] **Step 3: files.py — param project**
Chaque route gagne `project: str | None = None` et commence par :
```python
try:
tools_mod.set_project(project or None)
except ToolError as exc:
raise HTTPException(400, str(exc)) from exc
```
avec en tête du fichier `from .. import tools as tools_mod` (l'import existant
`from ..tools import ToolError, _safe_path` reste). Puis remplacer chaque
usage de `os.path.abspath(settings.workspace_dir)` par `tools_mod.active_root()` :
- `list_files` : `root = tools_mod.active_root()` (le `os.makedirs` est déjà
fait par `active_root`).
- `_tree` : signature `_tree(path: str, root: str)` — le `rel` se calcule
contre `root` passé en argument (adapter l'appel récursif et l'appel
depuis `list_files`).
- `delete_file` : le refus de racine devient
`if os.path.abspath(target) == tools_mod.active_root():`.
- `file_content` / `download_file` : seulement `set_project` en tête
(`_safe_path` suit la contextvar).
IMPORTANT : remettre `tools_mod.set_project(None)` n'est pas nécessaire —
la contextvar est par-tâche asyncio, chaque requête FastAPI a son contexte.
- [ ] **Step 4: git.py — param project**
`_git` gagne `project: str | None = None` :
```python
def _git(*args: str, timeout: int = 15, project: str | None = None) -> subprocess.CompletedProcess:
root = os.path.abspath(settings.workspace_dir)
if project:
if not tools.PROJECT_NAME.match(project):
raise HTTPException(400, "nom de projet invalide")
root = os.path.join(root, project)
ensure_git(root)
return subprocess.run(
["git", *args], cwd=root, ...
)
```
(reprendre les kwargs existants de l'appel `subprocess.run` du fichier ;
imports : `from .. import tools`, `from fastapi import HTTPException` déjà
présent ou à ajouter, `import os`). Les trois routes (`git_log`, `git_diff`,
`git_revert`) gagnent `project: str | None = None` et le propagent à chaque
appel `_git(..., project=project)`.
- [ ] **Step 5: Vérifier + commit**
Run: `cd backend && python -m pytest tests/ -q` — tout PASS.
```bash
git add backend/app/routes/projects.py backend/app/routes/files.py backend/app/routes/git.py backend/app/main.py backend/tests/test_projects_routes.py
git commit -m "feat(projets): routes /api/projects + files et git re-racinés"
```
---
### Task 3: Chat re-raciné (contexte, Aider, notice projet disparu)
**Files:**
- Modify: `backend/app/routes/chat.py`
- Modify: `backend/app/coder.py` (`run_code_task` paramètre `root`)
- Modify: `backend/app/agent.py` (dispatch `code_task` passe la racine)
- Test: `backend/tests/test_projects.py` (ajout)
**Interfaces:**
- Consumes: `tools.set_project`, `tools.active_root` (Task 1).
- Produces: `coder.run_code_task(instruction, model, files=None, root: str | None = None)`.
- [ ] **Step 1: Test qui échoue (aides de contexte re-racinées)**
Ajouter à `backend/tests/test_projects.py` :
```python
def test_aides_contexte_suivent_le_projet():
from app.routes.chat import _mentioned_files, _workspace_listing
tools.set_project("ctxdemo")
root = tools.active_root()
with open(os.path.join(root, "app.py"), "w", encoding="utf-8") as f:
f.write("x = 1")
assert "app.py" in _workspace_listing()
assert _mentioned_files("corrige app.py") == ["app.py"]
tools.set_project(None)
assert _mentioned_files("corrige app.py") == [] # absent de la racine
```
Run: `cd backend && python -m pytest tests/test_projects.py -q`
Expected: FAIL — `_workspace_listing`/`_mentioned_files` lisent encore
`settings.workspace_dir`.
- [ ] **Step 2: chat.py — racine active partout**
Dans `chat()` juste après la récupération de la session :
```python
# Projet de la session : re-racine outils, shell, Aider et aides de
# contexte pour tout le tour. Projet disparu -> retour racine + notice.
project = session.get("project") or None
project_missing = False
if project:
proj_dir = os.path.join(os.path.abspath(settings.workspace_dir), project)
if not os.path.isdir(proj_dir):
project_missing, project = True, None
tools.set_project(project)
```
avec `from .. import tools` dans les imports du fichier. Dans
`event_stream()`, après le `yield _sse("start", …)` :
```python
if project_missing:
yield _sse("notice", {"message":
"Projet de la session introuvable sur le disque — retour au workspace."})
```
Remplacer dans `_session_code_context`, `_workspace_listing` et
`_mentioned_files` chaque `root = os.path.abspath(settings.workspace_dir)`
par `root = tools.active_root()`.
Chemin code : passer la racine à Aider —
```python
async for chunk in _code_stream(
req, code_model, extra=extra, plan=plan, files=code_files,
):
```
`_code_stream` → `_run_aider_keepalive(instruction, model, files)` →
```python
task = asyncio.create_task(
asyncio.to_thread(
coder.run_code_task, instruction, model, files, tools.active_root()
)
)
```
(`active_root()` évalué AVANT le to_thread : la contextvar ne suit pas dans
le thread.)
- [ ] **Step 3: coder.py — paramètre root**
```python
def run_code_task(
instruction: str,
model: str,
files: list[str] | None = None,
root: str | None = None,
) -> dict:
```
et remplacer `root = os.path.abspath(settings.workspace_dir)` par
`root = os.path.abspath(root or settings.workspace_dir)` (le reste — ensure_git,
chdir, fnames — utilise déjà `root`).
- [ ] **Step 4: agent.py — code_task scoped**
Dans le dispatch `code_task` de `run_agent`, remplacer l'appel :
```python
if name == "code_task":
from .tools import active_root
code_model = await coder.pick_code_model(model)
result = await asyncio.to_thread(
coder.run_code_task,
args.get("instruction", ""),
code_model,
args.get("files") or [],
active_root(),
)
```
- [ ] **Step 5: Vérifier + commit**
Run: `cd backend && python -m pytest tests/ -q` — tout PASS ;
`python -m compileall -q app`.
```bash
git add backend/app/routes/chat.py backend/app/coder.py backend/app/agent.py backend/tests/test_projects.py
git commit -m "feat(projets): chat, aides de contexte et Aider suivent le projet de la session"
```
---
### Task 4: Frontend — API client + store projets
**Files:**
- Modify: `frontend/src/api/client.ts`
- Modify: `frontend/src/store/useStore.ts`
**Interfaces:**
- Consumes: routes Tasks 1-2.
- Produces: `listProjects(): Promise<{projects: {name: string; files: number}[]; root_files: number}>` ; `createProject(name): Promise<void>` ; `setSessionProject(id, project: string | null): Promise<void>` ; store : `currentProject(): string | null` (getter), `setProject(name: string | null)`, `projects: {name: string; files: number}[]`, `refreshProjects()`.
- [ ] **Step 1: client.ts**
Type `Session` : ajouter `project?: string | null;`. Ajouter :
```typescript
export async function listProjects(): Promise<{
projects: { name: string; files: number }[];
root_files: number;
}> {
const res = await fetch("/api/projects");
return res.json();
}
export async function createProject(name: string): Promise<void> {
const res = await fetch("/api/projects", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (!res.ok) throw await apiError(res, "création du projet impossible");
}
export async function setSessionProject(
id: string,
project: string | null
): Promise<void> {
const res = await fetch(`/api/sessions/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project: project ?? "" }),
});
if (!res.ok) throw await apiError(res, "changement de projet impossible");
}
```
Re-raciner les appels fichiers — signatures :
```typescript
const projQuery = (project?: string | null) =>
project ? `&project=${encodeURIComponent(project)}` : "";
export async function listFiles(project?: string | null): Promise<FileNode[]> {
const query = project ? `?project=${encodeURIComponent(project)}` : "";
const res = await fetch(`/api/files${query}`);
return (await res.json()).tree;
}
export async function fileContent(
path: string,
project?: string | null
): Promise<string> {
const res = await fetch(
`/api/files/content?path=${encodeURIComponent(path)}${projQuery(project)}`
);
if (!res.ok) return "";
return (await res.json()).content;
}
export function downloadFile(path: string, project?: string | null): void {
const link = document.createElement("a");
link.href = `/api/files/download?path=${encodeURIComponent(path)}${projQuery(project)}`;
link.download = path.split(/[\\/]/).pop() ?? "fichier";
document.body.appendChild(link);
link.click();
link.remove();
}
export async function deleteFile(
path: string,
project?: string | null
): Promise<void> {
const res = await fetch(
`/api/files?path=${encodeURIComponent(path)}${projQuery(project)}`,
{ method: "DELETE" }
);
if (!res.ok) {
throw await apiError(res, `suppression impossible (${res.status})`);
}
}
```
Idem routes git utilisées par PreviewPanel (`getGitLog`, `getGitDiff`,
`revertCommit`) : paramètre optionnel `project` ajouté en query string de la
même façon.
`createSession` : paramètre `project?: string | null` transmis dans le body.
- [ ] **Step 2: useStore.ts**
État : `projects: { name: string; files: number }[]` (init `[]`). Actions :
```typescript
refreshProjects: async () => {
try {
const { projects } = await listProjects();
set({ projects });
} catch { /* backend indisponible */ }
},
currentProject: () => {
const s = get().sessions.find((x) => x.id === get().currentSessionId);
return s?.project ?? null;
},
setProject: async (name) => {
let sid = get().currentSessionId;
if (!sid) {
const s = await createSession(get().selectedModel || undefined, name);
set({ currentSessionId: s.id, messages: [] });
sid = s.id;
} else {
await setSessionProject(sid, name);
}
await get().refreshSessions();
await get().refreshFiles();
set({ previewPath: null, previewContent: "" });
},
```
(`createSession(model?, project?)` : étendre la fonction client existante.)
`refreshFiles` passe le projet : `listFiles(get().currentProject())`.
`openPreview` : `fileContent(path, get().currentProject())`.
`removeFile` : `deleteFile(path, get().currentProject())`.
`newSession` : `createSession(model, get().currentProject())` — héritage.
`openSession` : après chargement, `await get().refreshFiles()` (l'arbre suit
la session ouverte).
Les composants qui appellent `downloadFile(path)` passent aussi
`useStore.getState().currentProject()` (LeftPanel, ActivityViews,
PreviewPanel).
Types : ajouter `projects`, `refreshProjects`, `currentProject`, `setProject`
à `LokiState`.
- [ ] **Step 3: Vérifier + commit**
Run: `cd frontend && npx tsc -b` — OK (les composants utilisant downloadFile
sont ajustés dans cette tâche pour compiler).
```bash
git add frontend/src/api/client.ts frontend/src/store/useStore.ts frontend/src/panels/LeftPanel.tsx frontend/src/panels/ActivityViews.tsx frontend/src/panels/PreviewPanel.tsx
git commit -m "feat(projets): client API + store re-racinés par projet"
```
---
### Task 5: Chip projet dans le composer + aperçu réductible
**Files:**
- Create: `frontend/src/components/ProjectChip.tsx`
- Modify: `frontend/src/panels/ChatPanel.tsx` (rendu du chip à côté du ModeSelector)
- Modify: `frontend/src/panels/PreviewPanel.tsx` (repli)
**Interfaces:**
- Consumes: store Task 4 (`projects`, `refreshProjects`, `currentProject`, `setProject`).
- [ ] **Step 1: ProjectChip.tsx**
```tsx
import { useEffect, useRef, useState } from "react";
import { useStore } from "../store/useStore";
import { createProject } from "../api/client";
/** Sélecteur de projet du composer : 📁 <projet> + menu (liste, création). */
export function ProjectChip() {
const { projects, refreshProjects, currentProject, setProject } = useStore();
const [open, setOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(null);
const rootRef = useRef<HTMLDivElement>(null);
const active = currentProject();
useEffect(() => {
if (open) void refreshProjects();
}, [open, refreshProjects]);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
setCreating(false);
setError(null);
}
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const choose = async (name: string | null) => {
setOpen(false);
await setProject(name);
};
const create = async () => {
const name = draft.trim();
if (!name) return;
try {
await createProject(name);
setCreating(false);
setDraft("");
await choose(name);
} catch (err) {
setError(err instanceof Error ? err.message : "création impossible");
}
};
return (
<div ref={rootRef} className="relative">
<button
onClick={() => setOpen((o) => !o)}
className="flex h-8 items-center gap-1.5 border-2 border-line bg-base px-2.5 text-[12px] text-ink-2"
title="Projet de travail de cette session"
>
📁 <span className="max-w-[140px] truncate">{active ?? "workspace"}</span>
</button>
{open && (
<div
className="absolute bottom-[calc(100%+6px)] left-0 z-30 w-[240px] border-[3px] border-line bg-card shadow-hard"
style={{ borderRadius: 7 }}
>
<button
onClick={() => choose(null)}
className={`block w-full px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === null ? "bg-base font-bold" : ""
}`}
>
workspace (racine)
</button>
{projects.map((p) => (
<button
key={p.name}
onClick={() => choose(p.name)}
className={`block w-full border-t-2 border-line-soft px-3 py-2 text-left text-[13px] text-ink-2 hover:bg-base ${
active === p.name ? "bg-base font-bold" : ""
}`}
>
📁 {p.name}
<span className="ml-1.5 text-[11px] text-muted-2">
{p.files} fichier{p.files > 1 ? "s" : ""}
</span>
</button>
))}
<div className="border-t-2 border-line-soft p-2">
{creating ? (
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void create();
if (e.key === "Escape") {
setCreating(false);
setError(null);
}
}}
placeholder="nom-du-projet"
className="w-full border-2 border-line bg-base px-2 py-1 text-[12px] text-ink outline-none"
/>
) : (
<button
onClick={() => setCreating(true)}
className="w-full text-left text-[13px] font-bold text-accent"
>
+ Nouveau projet
</button>
)}
{error && <div className="mt-1 text-[11px] text-warn">{error}</div>}
</div>
</div>
)}
</div>
);
}
```
- [ ] **Step 2: ChatPanel — rendu**
Localiser le rendu du sélecteur de mode dans le composer (composant local
`ModeSelector`, utilisé vers le bas du composer). Ajouter `<ProjectChip />`
juste à côté (même conteneur flex), avec
`import { ProjectChip } from "../components/ProjectChip";`.
- [ ] **Step 3: PreviewPanel — repli**
Dans `PreviewPanel` :
```tsx
const [collapsed, setCollapsed] = useState(
() => window.localStorage.getItem("loki.preview.collapsed") === "1"
);
const toggleCollapsed = () => {
setCollapsed((c) => {
window.localStorage.setItem("loki.preview.collapsed", c ? "0" : "1");
return !c;
});
};
```
Rendu replié — AVANT le rendu normal :
```tsx
if (collapsed) {
return (
<div className="flex w-9 flex-none flex-col items-center border-l-[3px] border-line bg-panel pt-3">
<button
onClick={toggleCollapsed}
title="Déplier l'aperçu"
className="text-[15px] text-muted-2 hover:text-accent"
>
⇤
</button>
<div className="mt-3 rotate-90 whitespace-nowrap text-[10px] font-bold tracking-wide text-muted-3">
APERÇU
</div>
</div>
);
}
```
Bouton de repli dans la barre d'onglets existante (à droite des Tabs) :
```tsx
<div className="flex-1" />
<button
onClick={toggleCollapsed}
title="Replier l'aperçu"
className="px-2 text-[15px] text-muted-2 hover:text-accent"
>
⇥
</button>
```
(replié = composant retourne tôt → aucun contenu/iframe monté, conforme spec.)
- [ ] **Step 4: Vérifier + commit**
Run: `cd frontend && npx tsc -b && npm run build` — OK.
```bash
git add frontend/src/components/ProjectChip.tsx frontend/src/panels/ChatPanel.tsx frontend/src/panels/PreviewPanel.tsx
git commit -m "feat(ui): chip projet dans le composer + aperçu réductible"
```
---
### Task 6: Vérification bout-en-bout + docs + push
**Files:**
- Modify: `README.md`
- [ ] **Step 1: README**
Section après « Panneau Git & Diff » :
```markdown
## Projets (répertoires de travail)
Chaque session peut travailler dans un **projet** : un sous-dossier du
workspace choisi via le chip 📁 du composer (« + Nouveau projet » pour en
créer un). L'agent, le shell, le moteur code et l'arborescence sont confinés
au projet ; chaque projet a son propre dépôt git (historique et revert
indépendants). Session sans projet = racine du workspace.
```
- [ ] **Step 2: Suite complète**
```bash
cd backend && python -m pytest tests/ -q && python -m compileall -q app
cd ../frontend && npx tsc -b && npm run build
```
- [ ] **Step 3: Smoke local (uvicorn, sans Ollama)**
```bash
cd backend && python -m uvicorn app.main:app --port 8199 & # env WORKSPACE_DIR/DATA_DIR temporaires
curl -s -X POST http://localhost:8199/api/projects -H "Content-Type: application/json" -d '{"name":"demo"}'
curl -s http://localhost:8199/api/projects
curl -s -X POST http://localhost:8199/api/projects -H "Content-Type: application/json" -d '{"name":"../x"}' -o /dev/null -w "%{http_code}" # 400
SID=$(curl -s -X POST http://localhost:8199/api/sessions -H "Content-Type: application/json" -d '{"title":"t","project":"demo"}' | python -c "import sys,json;print(json.load(sys.stdin)['id'])")
curl -s -X PATCH http://localhost:8199/api/sessions/$SID -H "Content-Type: application/json" -d '{"project":""}' # retour racine
curl -s "http://localhost:8199/api/files?project=demo"
```
Expected : création 201 + `.git` ; liste contient demo ; 400 sur nom
invalide ; PATCH ok ; arbre du projet vide (pas les fichiers racine).
- [ ] **Step 4: Commit + push**
```bash
git add README.md
git commit -m "docs: projets par session"
git push
```
Vérif UI après déploiement : chip 📁 → créer `demo` → message « crée
hello.txt » → fichier dans workspace/demo/ ; session B sans projet → racine ;
onglet Git montre l'historique du projet actif ; bouton ⇥ replie l'aperçu,
état conservé au rechargement.
File diff suppressed because it is too large. Load diff
@@ -1,122 +0,0 @@
# 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,135 @@
# Loki — MCP intégré + Skills automatiques + Boucle code vérifiée
**Date** : 2026-07-18
**Objectif** : augmenter l'intelligence effective des modèles locaux (~17 Go,
répartis sur 5060 Ti + 3060 12 Go) sans changer de modèle, en améliorant le
harnais : outils professionnels via MCP, méthodes expertes injectées (skills),
et vérification réelle du code produit.
**Choix utilisateur validés** :
- Cas d'usage : tout (code + raisonnement + multi-étapes).
- Compromis : équilibré — passes supplémentaires uniquement pour le code
(1 passe de correction max).
- Un seul gros modèle ; pas d'architecture deux-modèles.
- MCP : catalogue préconfiguré des meilleurs serveurs, toggles on/off,
désactivé = zéro coût (aucun outil dans le prompt, aucun process lancé).
- Skills : déclenchement automatique (routeur lexical), invisible, désactivable.
---
## 1. Client MCP intégré
### Backend — `backend/app/mcp_client.py`
- SDK officiel Python `mcp` (ajout à `requirements.txt`).
- Transports : stdio (commande locale, ex. `npx …`) et streamable-http (URL).
- **Gestionnaire** (singleton, même pattern que `ollama_client`) :
- Catalogue embarqué (dict Python) + état activé/désactivé et paramètres
(clé API, URL) persistés en DB (`db.get/set_config_value`, clé `mcp`).
- Connexion **lazy** : un serveur activé n'est démarré qu'au premier message
qui suit ; la session MCP (process + handshake) est réutilisée ensuite.
- Serveur désactivé : process jamais lancé, outils jamais exposés.
- `list_tools()` par serveur → conversion schéma MCP → schéma
function-calling Ollama ; préfixe `mcp_<serveur>_<outil>`.
- `call_tool(name, args)` avec timeout 30 s par appel.
- Panne (crash process, handshake KO, timeout répété) : le serveur est
marqué en erreur, ses outils retirés, notice SSE dans le fil ; nouvelle
tentative au message suivant. Un MCP cassé ne bloque jamais le chat.
- `aclose()` au shutdown (lifespan `main.py`).
### Catalogue préconfiguré (désactivés par défaut)
| ID | Serveur | Commande / transport | Apport |
|---|---|---|---|
| `playwright` | Playwright MCP | `npx @playwright/mcp@latest --headless` | navigateur réel : naviguer, cliquer, lire la console, screenshot |
| `context7` | Context7 | `npx -y @upstash/context7-mcp` | doc à jour de toute librairie/framework |
| `fetch` | Fetch | `python -m mcp_server_fetch` | lecture propre d'URL (markdown) |
| `searxng` | SearxNG | `npx -y mcp-searxng` + `SEARXNG_URL` | vraie recherche web (nécessite instance SearxNG) |
| `custom` | Personnalisé | commande ou URL saisie par l'utilisateur | n'importe quel serveur MCP |
- Filtre d'outils par serveur (liste `expose` optionnelle dans le catalogue)
pour limiter le nombre d'outils injectés (ex. Playwright : navigate, click,
type, snapshot, console, screenshot — pas les 25+ outils complets).
### Intégration boucle agent — `backend/app/agent.py` + `tools.py`
- Les outils MCP actifs sont ajoutés à la liste d'outils envoyée au modèle
(après les outils natifs) et dispatchés vers `mcp_client.call_tool`.
- Résultats tronqués à une taille max (~8 000 caractères) avant retour au
modèle (protège le contexte).
- Rendu frontend : ToolCards existantes (aucun composant nouveau requis).
### UI — Configuration → onglet « MCP » (`SettingsView.tsx`)
- Une carte par serveur du catalogue : nom, description, toggle, statut
(● connecté / ○ inactif / ⚠ erreur + message), nombre d'outils exposés,
champs paramètres (URL/clé) si requis.
- Carte « Personnalisé » : champ commande ou URL + toggle.
- Routes : `GET /api/mcp` (état), `PUT /api/mcp/{id}` (toggle + params),
`POST /api/mcp/{id}/test` (connexion d'essai, renvoie outils découverts).
### Dockerfile
- Ajout Node.js LTS (requis npx) + `pip install mcp mcp-server-fetch`.
- Image : les serveurs npx sont téléchargés au premier lancement (cache
volume npm optionnel).
## 2. Skills automatiques
### Bibliothèque — `backend/skills/*.md`
Fichiers markdown en français, livrés avec l'app :
- `debogage-systematique.md` — reproduire → isoler → hypothèse → corriger → vérifier.
- `creation-web.md` — structure sémantique → style → interactivité → vérification (liens, console, responsive).
- `refactor-sur.md` — comprendre → tests/garde-fous → petits pas → vérifier à chaque pas.
- `analyse-donnees.md` — examiner le format → valider → transformer → présenter.
- `redaction-structuree.md` — plan → rédaction → relecture ciblée.
Format : frontmatter (`name`, `description`, `keywords`) + corps injecté tel quel.
### Routeur de skills — `backend/app/skills.py`
- Sélection **lexicale instantanée** (regex/mots-clés, même style que
`router.py`) : au plus UNE skill par message ; aucun appel LLM.
- Injection : message système supplémentaire
(`"Méthode à suivre pour cette tâche :\n<corps>"`) inséré après l'invite
système, pour ce tour uniquement (non persisté dans l'historique).
- Événement SSE `skill` `{name, title}` → badge dans le fil
(« 📘 Skill : Débogage systématique »).
- Toggle global `skills_enabled` (défaut : activé) dans la config agent
(Configuration → Intelligence).
## 3. Boucle code vérifiée
- Nouvel outil natif `run_check` (`tools.py`) : détection par extension —
`.py` → `python -m py_compile` (analyse seule, JAMAIS d'exécution — toute
exécution passe par `run_shell` et sa validation utilisateur) ;
`.js/.ts` → `node --check` ; `.html` → `check_html` existant ; JSON → parse.
Sortie = erreurs compactées.
- Boucle agent : après un `write_file`/`edit_file` sur du code, le harnais
exécute `run_check` automatiquement et renvoie les erreurs au modèle dans le
même tour — **1 passe de correction maximum** (choix « équilibré »).
- Si le serveur MCP Playwright est actif et que la tâche a produit du HTML :
la skill `creation-web` oriente le modèle vers une vérification réelle dans
le navigateur (console + rendu) au lieu du check statique seul.
## 4. Hors périmètre
- Architecture deux-modèles (architecte/codeur) — rejetée par l'utilisateur.
- Best-of-N / juge — trop coûteux.
- Marketplace/édition de skills dans l'UI (v2 possible ; v1 = fichiers livrés).
## 5. Vérification
1. **MCP** : activer Fetch → demander « résume cette page <url> » → ToolCard
`mcp_fetch_fetch` + résumé correct. Désactiver → l'outil disparaît du
prompt (vérifiable via logs). Serveur cassé (commande invalide) → notice,
chat fonctionnel.
2. **Playwright** : « crée une page X puis vérifie-la dans le navigateur » →
navigation + console lue + correction éventuelle.
3. **Skills** : message « mon script plante avec TypeError » → badge skill
débogage ; message banal → aucun badge.
4. **run_check** : demander un script Python avec bug volontaire induit →
l'erreur est détectée et corrigée dans le même tour (1 passe).
5. **Perf** : aucun serveur MCP actif → latence premier token inchangée
(mesure avant/après).
@@ -0,0 +1,99 @@
# Loki — Projets (répertoires de travail) + aperçu réductible
**Date** : 2026-07-19
**Objectif** : permettre à l'agent de travailler dans des projets entiers —
un projet = un répertoire choisi depuis la carte du chat — et pouvoir replier
le panneau d'aperçu.
**Choix validés** : projet = sous-dossier de premier niveau du workspace
(pas de chemin arbitraire) ; portée par session ; édition de la mémoire
écartée du périmètre.
---
## 1. Projets — backend
### Modèle
- Un projet = un sous-dossier de premier niveau de `WORKSPACE_DIR`
(ex. `workspace/jeu-snake/`). Nom validé : `^[a-z0-9][a-z0-9_-]{0,40}$`.
- Session sans projet (`NULL`) = racine du workspace — compatibilité totale
avec les sessions existantes.
- Colonne `project TEXT` ajoutée à `sessions` (migration douce
`ALTER TABLE`, comme `summary`/`meta`).
### Racine active par requête (contextvar)
- `tools.py` : `_ACTIVE_ROOT: ContextVar[str | None]` + fonctions
`set_project(name | None)` / lecture dans `_workspace_root()`. `_safe_path`
inchangé dans sa logique de confinement — simplement re-raciné sur
`workspace/<projet>` quand un projet est actif.
- `routes/chat.py` : au début de `chat()`, `tools.set_project(session["project"])`
(après validation : dossier existant, sinon retombe sur la racine + notice
SSE « projet introuvable, retour au workspace »).
- Les aides de contexte suivent la même racine : `_workspace_listing`,
`_mentioned_files`, `_session_code_context` (fichiers vérifiés sous la
racine active).
- `run_shell` : cwd = racine active (déjà `_workspace_root()`).
### Moteur code (Aider) par projet
- `coder.run_code_task` reçoit la racine active (cwd du process Aider).
- `coder.ensure_git(dir)` appelé à la création d'un projet → un dépôt git
par projet, historique/revert propres. L'onglet Git du panneau droit opère
sur le projet de la session courante (routes git prennent `project`).
### Routes
- `GET /api/projects` → `{projects: [{name, files: int}], root_files: int}`
(sous-dossiers de premier niveau, dotfiles exclus).
- `POST /api/projects {name}` → mkdir + `ensure_git` ; 400 si nom invalide
ou existant.
- `PATCH /api/sessions/{sid}` accepte `project: str | null` (en plus de
`title`).
- `GET /api/files`, `/api/files/content`, `/api/files/download`,
`DELETE /api/files` : paramètre optionnel `project` — même re-racinage,
même confinement.
- Routes git (`/api/git/*`) : paramètre optionnel `project`.
## 2. Sélecteur projet — frontend (carte du chat)
- **Chip « 📁 <projet> »** dans le composer, à côté du sélecteur de mode
Plan/Build/Yolo. Affiche `workspace` si aucun projet.
- Clic → menu (même style que les menus existants) : liste des projets,
entrée active cochée, **« + Nouveau projet »** avec input inline
(Enter crée + sélectionne, Escape annule).
- Sélection → `PATCH /api/sessions/{sid} {project}` → store met à jour la
session courante → `refreshFiles()` re-racine LeftPanel / FilesView /
PreviewPanel (le store passe `project` de la session courante aux appels
fichiers).
- Nouvelle session : hérite du projet de la session courante (envoyé au
`POST /api/sessions`).
- Session sans projet : comportement actuel inchangé.
## 3. Aperçu réductible — frontend
- Bouton de repli dans l'en-tête du `PreviewPanel` (à côté des onglets) :
replie le panneau en **barre verticale fine (36 px)** ne contenant qu'un
bouton de réouverture (icône ⇤ pivotée) et l'indicateur d'onglet actif.
- État replié persisté en `localStorage` (`loki.preview.collapsed`), la
largeur l'est déjà (`loki.preview.width`).
- Replié, le panneau ne rend pas son contenu (pas d'iframe HTML vivante).
## 4. Hors périmètre
- Édition de la mémoire (RAG/résumés) — écartée par l'utilisateur.
- Chemins hors workspace, suppression/renommage de projets depuis l'UI
(suppression possible via la corbeille de l'arborescence).
- Projet imbriqué (sous-sous-dossier comme projet).
## 5. Vérification
1. `POST /api/projects {"name":"demo"}` → dossier + `.git` créés ;
nom invalide → 400.
2. Session A projet `demo`, session B sans projet : fichiers créés par
l'agent de A atterrissent dans `workspace/demo/`, ceux de B à la racine ;
l'arborescence gauche suit la session ouverte.
3. `curl "…/api/files?project=demo"` → arbre du projet seul ;
`?project=../x` → 400.
4. Reprise : « corrige les bugs » dans une session projet → Aider travaille
dans `workspace/demo/`, commit dans le git du projet, onglet Git montre
l'historique du projet.
5. Aperçu : bouton replie → barre fine, contenu démonté ; réouverture
restaure l'onglet et la largeur ; état conservé après rechargement.
6. pytest : contextvar (re-racinage + confinement), routes projects,
migration colonne, héritage projet à la création de session.
@@ -1,187 +0,0 @@
# Dictée en temps réel, configurable selon le matériel
*Conception validée le 21 août 2026.*
## Le problème
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
défauts la rendent pénible :
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
un rechargement complet du modèle toutes les quelques secondes.
## Ce qu'on construit
Trois choses, dans cet ordre de dépendance :
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
## 1. Le moteur
### whisper-server plutôt que whisper-cli
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
Loki le supervise comme il supervise déjà `llama-server`.
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
modèle ; les suivants sont immédiats.
### Choix du GPU
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
pas de code à maintenir dans ggml :
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
- valeur vide → CPU seul
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
### Compilation : deux binaires, pas un
L'étape `whisperbuild` produit **deux** binaires :
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
CUDA condamnerait donc la variante CPU de l'image.
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
le disant, plutôt que de laisser la dictée morte.
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
## 2. Le découpage
### On coupe dans les silences, jamais à heure fixe
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
l'améliorer.
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
est borné par la coupure forcée.
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
pas voir le texte se figer.
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
dont personne ne connaît l'interaction :
| Réglage | Réserve minimale | Coupure forcée |
|---|---|---|
| Court | 1 s | 5 s |
| Moyen (défaut) | 1,5 s | 8 s |
| Long | 3 s | 15 s |
### La logique vit en Go
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
Et le navigateur redevient bête : capter, envoyer, afficher.
### Contrat de la socket
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
sous les yeux.
### Langue forcée par défaut
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
## 3. Le panneau Dictée
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
« Identité ». Suit le gabarit des panneaux existants.
| Réglage | Contenu |
|---|---|
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
| **Langue** | Français par défaut, auto et autres langues disponibles |
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
progression et suppression, en généralisant le mécanisme mono-modèle existant.
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
le jeton Hugging Face.
## Les pannes, nommées
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
d'échouer sec
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
l'utilisateur est prévenu
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
dans la mauvaise direction.
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
## Tests
En Go, sur ce qui peut réellement casser :
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
modèle, langue
- **l'aller-retour des réglages** dans `bkState`
- **l'extinction sur inactivité**
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
## Ce que les tests ne prouveront pas
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
échantillon de voix n'est disponible côté développement. Les tests établissent que le
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
`small` reste une constatation d'usage.
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
@@ -1,150 +0,0 @@
# 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.
@@ -1,74 +0,0 @@
# Migration Rust — lot 2 : UI statique, authentification, `/api/ping`
Date : 2026-10-13 · Statut : validé · Prérequis : lot 1 (façade `loki-rs`).
## But
Faire passer en Rust les premières routes réelles, et surtout poser le socle
dont toutes les routes `/api/*` suivantes auront besoin : l'authentification.
## Périmètre
1. **UI statique servie par Rust**, embarquée dans le binaire à la
compilation depuis `internal/loki/ui/` (même source que le `go:embed`) :
| Route | Content-Type | Cache-Control | Autre |
|---|---|---|---|
| `/`, `/index.html` | `text/html; charset=utf-8` | `no-store, max-age=0` | |
| `/marked.min.js` | `application/javascript` | `public, max-age=86400` | |
| `/sw.js` | `application/javascript` | `no-store, max-age=0` | `Service-Worker-Allowed: /` |
| `/manifest.webmanifest` | `application/manifest+json` | `public, max-age=3600` | |
| `/fonts/<nom>.woff2` | `font/woff2` | `public, max-age=31536000, immutable` | |
GET et HEAD seulement (les autres méthodes restent relayées au Go).
Compression gzip si le client l'accepte, sauf polices (comme
`web_gzip.go`) ; `Vary: Accept-Encoding` sur les types compressibles.
Version compressée calculée une fois, à la première demande.
2. **API interne Go** `GET /internal/v1/auth` → `{"web_key": "…", "version": "…"}`.
- Servie **uniquement sur l'écoute TCP directe** de `loki web`
(enveloppe autour du mux dans `cmdWeb`) : le tunnel du relais sert le mux
nu et ne la voit jamais.
- Exige l'en-tête `X-Loki-Internal` égal à `LOKI_INTERNAL_TOKEN` (≥ 32
caractères, comparaison en temps constant). Jeton absent, trop court ou
faux → 404 (rien ne révèle l'existence de la route).
- Base illisible → 503 : la façade fermera l'accès (jamais ouvert par
défaut sur une panne d'E/S, même règle que `requireWebAuth`).
- La façade tire un jeton aléatoire de 32 octets à chaque démarrage et le
passe au Go par l'environnement.
3. **Authentification de pilotage en Rust** (`auth.rs`), port à l'identique de
`requireWebAuth` + `crossSiteReject` + `localHostName` + `checkBearer` :
- clé relue à **chaque requête** (API interne) : un changement de clé prend
effet immédiatement ;
- API interne en erreur (Go absent, 503, réponse illisible) → 503
`{"error":"configuration illisible — réessaie dans un instant"}` ;
- refus site tiers / hôte non local sans clé → 403 `{"error":"<raison>"}`
(mêmes messages que le Go) ;
- Bearer absent ou faux → 401 `{"error":"non autorisé"}` +
`WWW-Authenticate: Bearer realm="loki"` ;
- `X-Loki-Via` n'est jamais pris en compte côté Rust (effacé à l'entrée,
le trafic du tunnel ne traverse pas la façade).
4. **`/api/ping` en Rust** : `{"ok":true,"service":"loki","version":"<version Go>"}`
après authentification — première route `/api/*` servie par Rust.
5. **Durcissement de la façade** : `/internal/*` venant de l'extérieur → 404 ;
en-tête `X-Loki-Internal` entrant effacé avant relais.
## Hors périmètre
Toutes les autres routes `/api/*`, `/v1`, le flux chat : lots suivants.
## Tests
- Go : route interne (sans jeton → 404, mauvais jeton → 404, bon jeton → 200
avec clé et version, autres chemins relayés au mux).
- Rust : cas de `TestCrossSiteReject` repris un pour un ; `local_host_name`
(IP, IPv6 entre crochets, nom sans point, suffixes locaux, hôte de
confiance) ; Bearer ; statiques (types, cache, gzip, HEAD, police inconnue
→ relayée) ; intégration façade + faux backend interne : ping 200 sans clé,
401 avec clé sans Bearer, 200 avec bon Bearer, 503 si l'API interne tombe,
403 rebinding, `/internal/v1/auth` depuis l'extérieur → 404.
- Bout en bout local : vrai `loki` Go derrière la façade, `loki set-web-key`,
ping avec et sans clé.
@@ -1,49 +0,0 @@
# Migration Rust — lot 3 : supervision du moteur
Date : 2026-10-14 · Statut : livré · Prérequis : lots 1-2.
## Découpage
Le « moteur » en conteneur, c'est deux couches de taille très différente :
1. **La supervision du processus** (~350 lignes Go : `sys_service_container.go`,
`sys_proctree_linux.go`) — lancer `loki serve` détaché, fichier PID,
journal, arrêter tout l'arbre (Strata compris) et attendre que la VRAM
soit rendue.
2. **La ligne de commande** de llama-server / Strata (~9 000 lignes :
`backend_serve*.go`, presets, MoE, spéculatif, threads, cache…), là où
s'applique la règle « ne jamais dénaturer les modèles ».
Le lot 3 porte la couche 1. La couche 2 reste au Go (`loki serve`) et fera
l'objet d'un lot dédié, parce que chaque option y a été réglée contre un
défaut mesuré.
## Ce qui change
- `loki-rs engine start|stop|restart|status` : port à l'identique de la
supervision Go — chef de session (`setsid`), fichier PID et journal aux
**mêmes chemins** (`$LOKI_HOME/<service>.pid|.log`), contrôle à 2 s avec
les 20 dernières lignes du journal en cas d'échec, arrêt de l'arbre
(session, groupe, filiation ; SIGTERM, 4 s, SIGKILL, 20 s au plus), mêmes
messages.
- Le Go garde ses garde-fous (optimisation en cours via `serviceAction`,
preset externe) puis délègue au `loki-rs` voisin de son binaire.
`loki logs`, `enable`/`disable` restent au Go.
- Porte de sortie : `LOKI_ENGINE_SUPERVISOR=go`, ou absence de `loki-rs`.
- Différence assumée : le contrôle « démarré ? » regarde si l'enfant est
sorti (`try_wait`), pas le signal 0 — un enfant mort reste un zombie, qui
répond encore au signal 0.
- L'arrêt exclut de l'arbre `loki-rs` lui-même ET son parent (le Go qui l'a
appelé), comme le Go excluait son propre PID.
## Tests
- Rust (toutes plateformes) : `/proc/<pid>/stat` avec parenthèses dans le
nom, arbre par session/groupe/filiation avec exclusion de l'appelant.
- Rust (Linux, CI) : faux `loki serve` qui lance un enfant dans sa session et
un « moteur » dans SA PROPRE session (comme Strata) — démarrage, « déjà
démarré », arrêt de tout l'arbre, redémarrage, échec avec journal, PID
nettoyé.
- Go (Linux, CI) : choix du `loki-rs` voisin (absent, non exécutable,
`LOKI_ENGINE_SUPERVISOR=go`), appel réel avec l'environnement attendu,
erreur rendue = dernière ligne d'erreur du superviseur.
@@ -1,93 +0,0 @@
# Migration Rust — lot 6 : le cœur chat
Date : 2026-10-14 · Statut : **livré** (étapes 1 à 6 ; défaut Rust depuis le 2026-10-15, `LOKI_CHAT=go` pour revenir au Go) ·
Prérequis : lots 1-5 (façade, auth, store via API interne, `/v1`).
## Pourquoi c'est un lot à part
Le chat est le cœur de Loki et le seul chemin qu'on ne peut pas casser un
soir de déploiement. ~17 700 lignes Go (`chat_*.go` 11 491, `llm_*.go`
6 245), dont une fonction de 1 469 lignes (`runChatLoop`,
`llm_client.go:1279`) qui mêle protocole du moteur, reprises sur erreur,
compactage en cours de tour et exécution des outils.
## Carte (HEAD b98ee5c)
### Surface HTTP
Toutes derrière `requireWebAuth` (déjà portée).
| Route | Handler | Rôle |
|---|---|---|
| `/api/chat` | `handleChat` web_chat.go:316 | **SSE** : rejoue le journal puis le suit en direct |
| `/api/chat/send` | `handleChatSend` :178 | démarre un tour ou le met en file (409 occupé, 503 pas prêt) |
| `/api/chat/stop`, `/reset`, `/state`, `/compact`, `/tool-result` | web_chat.go | contrôle et lecture |
| `/api/chat/mode`, `/criteria` | code_mode.go | mode Code |
| `/api/chat/export`, `/upload`, `/files*`, `/file*`, `/image`, `/cushot` | divers | pièces jointes, espace de travail, aperçu navigateur |
| `/api/conversations{,/new,/switch,/rename,/delete}` | web_chat.go | index des discussions |
| `/api/e2e/chat` | relay_e2e.go:143 | même flux, chiffré AES-GCM par événement (tunnel) |
Trame SSE : `data: {"choices":[{"delta":…}]}`, battement `: ping` toutes les
4 s, regroupement à 128 Kio ou 256 événements.
### État en mémoire
- `conv *Conversation` (chat_conversation.go:114) : `Messages` (vue du
modèle), `Log []LogEvent{Seq,TS,Delta}` (journal d'affichage, **seule voie
vers l'UI**, via `appendDelta`), `Seq`, `CtxUsed`, `Generating`, `cancel`,
`epoch`, file d'attente, tâche en cours. Un `mu` + `cond`.
- `Generating` est un **verrou global du moteur** partagé avec les tâches
planifiées (`RunAutonomous`), le bench et l'optimiseur (`measureLease`) et
quatre sondes `isGenerating`.
- Persistance : `snapshot()` sérialise Messages ET Log dans un seul JSON,
**chiffré** (`memEncoderNow`, clé en mémoire du Go après déverrouillage),
écrit par un écrivain unique (`persistQ`) en une transaction bbolt —
bucket `chat`, clés `conv:<id>`, `index`, `active`.
### Boucle d'un tour
`handleChatSend` → `EnqueueOrStart` → `StartTurn` → `go generate` →
`turnView` (prompt système, outils, MCP, mémoire, instantané projet) →
`runChatTools` → `runChatLoop` (payload, slot, requête au moteur, analyse du
flux, reprises, compactage réactif, **switch des outils inline**
llm_client.go:2363-2549, compactage en cours de tour) → retour dans
`generate` (validation mode Code `codeVerifyLoop`, compactage de fin de
tour, persistance, file suivante, préchauffage).
## Coupures possibles
| | Coupure | Ce qui traverse | Risque |
|---|---|---|---|
| A | Journal + SSE en Rust, boucle au Go | flux de deltas Go→Rust, epoch, reset | Log persisté DANS le même blob chiffré que Messages |
| B | Client LLM en Rust (protocole, flux, reprises) | appel d'outil Go en RPC, porte du moteur, compactage | fonction de 1 469 lignes, appels récursifs (sous-agents, vérificateur) |
| C | Machine d'état de la conversation en Rust | turnView, compactage, outils, persistance en RPC | verrou `Generating` partagé avec tâches/bench/optimiseur |
| D | Sessions + persistance | — | écrivain unique bbolt, chiffrement, ordre des résultats d'outils |
## Stratégie proposée
Pas de bascule partielle de la discussion de l'utilisateur : le cœur chat
est réécrit **en entier** côté Rust, derrière `LOKI_CHAT=rust` (Go par
défaut), puis comparé au Go sur des conversations rejouées avant de devenir
le défaut. Étapes, chacune livrée et testée :
1. **Go, sans changement de comportement** : extraire le switch des outils en
`execTool(ctx, name, args, caps) → résultat` et l'exposer en API interne
(`/internal/v1/tool/exec`), ainsi que le chiffrement des blobs
(`/internal/v1/crypt/seal|open`, la clé reste au Go) et le bail du moteur
(`Generating` → `/internal/v1/engine/lease`).
2. **Rust, bibliothèque** : protocole du moteur (payload OpenAI, analyse SSE
llama.cpp/Strata, reasoning, appels d'outils natifs et textuels, effort,
reprises réseau), testé contre des flux enregistrés.
3. **Rust, conversation** : Messages + Log + epoch + file + SSE `/api/chat`
(rejeu, pagination `historyCut`, `coalesceReplay`, `compactLog`),
persistance au même format (`conv:<id>`, chiffré via l'étape 1).
4. **Rust, boucle** : `runChatLoop` réécrit en machine d'état explicite,
outils exécutés par le Go (étape 1), compactage porté.
5. **Parité** : rejeu de conversations réelles exportées, comparaison des
journaux produits (Go vs Rust) avec un faux moteur déterministe.
6. **Bascule** `LOKI_CHAT=rust` par défaut ; outils, MCP, mémoire, mode Code
portés ensuite (lot 7) en retirant les appels au Go un par un.
## Questions ouvertes
- Chiffrement : port du format (`memEncoderNow`) en Rust à l'étape 3, ou
RPC au Go jusqu'au lot 9 ?
- Tunnel du relais (`/api/e2e/chat`) : reste au Go jusqu'au portage du
relais ; tant que `LOKI_CHAT=rust`, le flux chiffré doit lire le journal
Rust (API interne inverse) — à concevoir.
@@ -1,44 +0,0 @@
# Lot 10 — réglages partagés tenus par la façade
## Contexte
Quatre routes ne font que lire et écrire des réglages en base, partagés entre
tous les appareils :
| Route | Port de | Ce qu'elle règle |
|---|---|---|
| `GET\|POST /api/prefs` | `web_prefs.go` | thème, affichage, prénom (24 caractères, sans caractère de contrôle), avatars (liste fermée) |
| `GET\|POST /api/sysprompt` | `web_sysprompt.go` | prompt système du preset actif (`state/sysprompt:<id>`), repli sur le global (`state/sysprompt`) |
| `GET\|POST /api/reasoning-effort` | `handleReasoningEffort` | `REASONING_EFFORT` de la configuration vivante (liste blanche) |
| `GET\|POST /api/oai/public` | `web_public_url.go` | adresse publique normalisée en « schéma://hôte[:port] » |
Lot 10 : la façade les sert quand `LOKI_SETTINGS=rust` (`settings.rs`,
expérimental ; sans la variable rien ne change).
Les valeurs restent dans la base du Go, aux mêmes clés. Le Go les relit là
où il s'en sert (tour de chat, accès OpenAI) : son cache de configuration se
périme sur l'horodatage du fichier de base, qu'une écriture de la façade
modifie. `facade_settings.go` remet les routes du tunnel à la façade.
## Écarts assumés
- Message d'un corps JSON syntaxiquement illisible : celui de serde. Une
valeur du mauvais type (`{"text":5}`) garde le message d'`encoding/json`.
- Préférences réécrites clé par clé au lieu d'une transaction.
- Port nommé dans l'adresse publique (`:https`) refusé : le Go le résolvait
par `/etc/services`.
## Parité
`internal/loki/parity_settings_test.go` tire du Go :
- 19 adresses publiques normalisées ou refusées ;
- 11 valeurs d'intensité ;
- 29 appels de route, avec le contenu de la base après chacun :
- fusion des préférences (valeurs refusées, prénom tronqué, vides effacés) ;
- prompt par preset avec repli, effacement, méthode refusée ;
- intensité acceptée ou refusée ;
- adresse publique.
`tests/settings.rs` rejoue le tout par la façade. Une mutation (prénom
tronqué à 25 caractères) fait échouer le rejeu.
@@ -1,63 +0,0 @@
# Lot 11 — fichiers de la discussion tenus par la façade
## Contexte
Les fichiers rangés par discussion vivaient dans le Go :
- `web_upload.go` : dépôt, téléchargement, image ;
- `web_files.go` : panneau Fichiers ;
- `web_files_zip.go` : archive ;
- `web_thumb.go` et `web_upload_orient.go` : vignettes ;
- `chat_convfiles.go` : chemins.
Lot 11 : la façade les sert quand `LOKI_FILES=rust` (`convfiles.rs`,
expérimental ; sans la variable rien ne change).
| Route | Rôle |
|---|---|
| `POST /api/chat/upload` | dépôt en morceaux base64 (8 Mio), fichier de 1 Gio au plus, espace disque vérifié, `.part` renommé à la fin sous un nom sûr et unique |
| `GET /api/chat/files` | panneau : dossiers d'abord puis du plus récent au plus ancien, tailles récursives (liens non suivis), vue « hors discussion » |
| `POST /api/chat/file/delete` | suppression ; refus de la racine et du dossier des discussions |
| `GET /api/chat/file` | fiche, tranche base64 (tunnel E2E) ou brut en pièce jointe (plage d'octets) |
| `GET /api/chat/image` | type reniflé sur le contenu, en ligne sous une CSP fermée ; vignette JPEG `thumb=<px>` redressée (EXIF) et réduite par moyennage |
| `GET /api/chat/files/zip` | archive construite à la fiche, puis servie en tranches ou en brut |
Le dossier de la discussion active reste décidé par le Go : la façade le lui
demande à chaque requête par la nouvelle route `/internal/v1/conv/workspace`
(`{dir, root, id, title}`). Il connaît la discussion active, qu'il tienne la
conversation ou que ce soit la façade. Les liens des anciens messages
(`captures/<id>/…`, `uploads/…` à la racine) restent résolus comme avant.
`facade_files.go` remet les routes du tunnel à la façade. Il ne vide plus les
`.part` d'envois au démarrage : la façade le fait au sien.
## Écarts assumés
- Octets des vignettes JPEG : l'encodeur diffère. Les dimensions, le
redressement et la réduction (algorithme porté pixel pour pixel) sont les
mêmes.
- Horodatage des entrées `.zip` en UTC.
- Une seule plage d'octets par requête de téléchargement : le Go en acceptait
plusieurs, en multipart.
## Parité
`internal/loki/parity_files_test.go` tire du Go :
- 10 noms de dépôt et 6 slugs ;
- 6 en-têtes EXIF ;
- les 8 orientations d'une image de 5×3 et 4 réductions, pixel pour pixel ;
- 10 reniflages ;
- 42 appels de route sur une arborescence fixe, dates comprises :
- panneau, téléchargement, image et vignette ;
- dépôt en un morceau, en deux morceaux, envoi expiré, fichier vide,
base64 illisible ;
- suppressions ;
- archive de la discussion et « hors discussion », contenu comparé
entrée par entrée ;
- les fichiers restants après chaque POST.
`tests/convfiles.rs` rejoue le tout par la façade, plus une plage d'octets,
un lien vers l'extérieur (refusé, non compté) et le nom d'archive tiré du
titre. Deux mutations (ordre du panneau, suffixe des doublons) font échouer
le rejeu.
@@ -1,47 +0,0 @@
# Lot 12 — notifications Web Push tenues par la façade
## Contexte
Les notifications « réponse prête » vivaient dans le Go : `push.go`,
`web_push.go` et webpush-go. Lot 12 : la façade les tient quand
`LOKI_PUSH=rust` (`push.rs`, expérimental ; sans la variable rien ne change).
| Route | Rôle |
|---|---|
| `GET /api/push/key` | clé publique VAPID, générée au premier appel puis stable |
| `POST /api/push/subscribe` | enregistre un abonnement (idempotent par endpoint, clés mises à jour) |
| `POST /api/push/unsubscribe` | le retire |
| `POST /internal/facade/v1/push/send` | (le Go, jeton interne) pousse une notification à tous les abonnés, en tâche de fond |
Les envois du Go passent tous par `sendPushToAll` : fin de tour du chat Go,
fin de tour tenu par la façade (`/internal/v1/turn/done`), fin de tâche. Ils
sont délégués à la façade. Clés et abonnements restent en base aux mêmes
clés (`push_vapid_priv`, `push_vapid_pub`, `push_subs`).
Chiffrement RFC 8291 (`aes128gcm`), enregistrement complété à 4096 octets
comme webpush-go. Signature VAPID RFC 8292 (JWT ES256, 12 h, sujet
« mailto: »). TTL de 120 s, urgence haute. Un abonnement refusé pour de bon
(404/410) est purgé. La cryptographie passe par ring, déjà présent via
rustls. Une façade construite sans TLS répond que les notifications sont
indisponibles.
## Écarts assumés
- Messages des journaux d'envoi au format des crates Rust.
## Parité
- `internal/loki/parity_push_test.go` tire du Go :
- les durées du corps de notification ;
- onze appels de route (clé, abonnements valides et refusés, mise à jour
des clés, désabonnements), avec la liste en base après chacun.
`tests/push.rs` rejoue le tout par la façade. La liste en base est
comparée à l'octet près, et la clé reste la même d'un appel à l'autre.
- Le chiffrement reproduit l'exemple de l'annexe A de la RFC 8291 à l'octet
près.
- Un envoi de bout en bout vers un faux service de push vérifie :
- les en-têtes ;
- la signature VAPID, contrôlée avec la clé rangée ;
- le corps, déchiffré côté navigateur avec la clé de l'abonnement ;
- l'abonnement en 410, purgé.
@@ -1,117 +0,0 @@
# Lot 13 — mémoire tenue par la façade
## Contexte
La mémoire de Loki vivait dans le Go :
- `chat_memory.go`, `mem_index.go`, `mem_io.go` et `mem_fsutil.go` pour les pages ;
- `moveMemPage` (`projects.go`) pour le déplacement entre projets ;
- `tracker.go` et `web_tracker.go` pour les trackers ;
- les routes du mode agent.
Lot 13 : la façade tient ces routes quand `LOKI_MEM=rust` (`memory.rs`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/mem?name=` | contenu d'une page (modèle de page neuve sans nom) |
| `POST /api/mem/save` | crée, écrase ou renomme une page ; l'index suit |
| `POST /api/mem/delete` | supprime une page ; sa ligne d'index part avec elle |
| `POST /api/mem/move` | déplace une page vers un autre projet ; les deux index suivent |
| `GET /api/agent` (alias `/api/tools`, `/api/skills`) | mode agent, compactage, mode mémoire, liste des pages |
| `POST /api/agent/toggle` (et alias) | mode agent (`state/agent`) |
| `POST /api/agent/compact` | compactage automatique (`COMPACT`) |
| `GET|POST /api/memory` | mode mémoire du projet actif, repli sur `MEM_MODE` |
| `GET|POST /api/tracker` | panneau des trackers : liste, détail, ajout, modification, suppression, renommage, déplacement |
### Disque et base
Les pages restent des fichiers `memory/<projet>/<nom>.md`, et l'index
`MEMORY.md` reste en clair. Le Go continue de les lire et de les écrire pour
sa propre boucle : outils `mem_*`, index injecté au modèle. Les écritures
restent sûres, comme dans le Go :
- un `.bak` de la version précédente est gardé ;
- le fichier est écrit dans un temporaire, synchronisé, puis renommé ;
- le contenu est relu et comparé octet par octet.
Les trackers restent dans le bucket `tracker`, sous la clé
`<projet>/<slug>`. Le registre des projets (`state/projects`) est lu tel
quel. Le mode mémoire y est écrit avec l'échappement HTML de
`encoding/json`, pour garder les mêmes octets en base.
### Chiffrement
La clé de la mémoire ne quitte pas le Go :
- une page qui porte le magic `LOKIMEMv1` est ouverte par
`/internal/v1/crypt/open` ;
- toute écriture de page ou de tracker passe par `/internal/v1/crypt/seal`,
qui rend le clair tel quel quand le chiffrement est coupé.
Quand la mémoire est verrouillée :
- les pages restent listées avec le titre « 🔒 (chiffré — mémoire
verrouillée) » ;
- les trackers illisibles sont tus ;
- aucune écriture ne retombe en clair.
### Messages d'erreur JSON
Ces routes renvoient au client le message d'erreur de décodage du Go.
`gojson.rs` reproduit `json.NewDecoder(r.Body).Decode(&req)` :
- seule la première valeur compte ;
- `EOF` et `unexpected EOF` ;
- les erreurs du scanner (`invalid character …`) ;
- les erreurs de type, champs anonymes compris ;
- la casse des noms de champs, et l'allocation des pointeurs.
`gojson.rs` est mutualisé pour les lots suivants.
## Restent au Go
- Le coffre et le chiffrement (`/api/mem/encrypt|decrypt|unlock|lock`).
- La santé, les instantanés, la sauvegarde.
- Le hub des projets (`/api/projects`) : sa bascule recharge la discussion.
- La bascule temporaire de projet d'une tâche planifiée. Elle n'existe qu'en
RAM du Go : la façade sert toujours le projet actif rangé.
## Écarts assumés
- Un corps JSON syntaxiquement valide mais qui fait échouer serde (UTF-8
invalide, nombre hors limites) donne le message de serde.
- Une date d'un tracker qui tombe dans un trou d'heure d'été est résolue par
`mktime`.
## Parité
### Décodage JSON
`internal/loki/parity_gojson_test.go` tire du Go 81 corps décodés : champs
et message d'erreur. `tests/gojson.rs` les rejoue.
### Mémoire
`internal/loki/parity_mem_test.go` tire du Go :
- les noms de pages, les titres, les slugs et les modes mémoire ;
- 36 dates de trackers, lues dans un fuseau fixe (UTC+2) ;
- 102 appels de route, avec après chacun le dossier `memory/` complet
(`.bak` compris) et les clés touchées en base (mode agent, compactage,
mode mémoire, projets, trackers).
Ces appels couvrent :
- les pages : création, renommage, réenregistrement qui perd l'accroche,
noms refusés, fichiers cachés et dossiers ignorés ;
- le déplacement, avec ses six refus ;
- la bascule de projet ;
- le mode agent et le compactage ;
- le mode mémoire, y compris un projet actif inconnu ;
- les trackers : ajout, modification, suppression, renommage, déplacement,
valeurs illisibles et événements nuls.
`tests/memory.rs` rejoue le tout par la façade, avec le même masquage des
identifiants et des instants « maintenant ».
Une mutation de chaque harnais fait échouer son rejeu.
Un essai de bout en bout simule le chiffrement par le faux Go :
- les pages sont écrites scellées et relues en clair ;
- l'index reste en clair ;
- les trackers sont scellés ;
- une fois la mémoire verrouillée, rien n'est réécrit en clair.
@@ -1,97 +0,0 @@
# Lot 14 — `loki serve` composé en Rust
## Contexte
Depuis le lot 3, la supervision du moteur est en Rust : `loki-rs engine
start|stop|restart`. Mais elle lançait encore `loki serve` (Go), qui compose
la ligne de commande de llama-server avant de lui céder la place.
Cette ligne vit dans `backend_serve*.go`, environ 4 000 lignes. Chaque option
y a été réglée contre un défaut mesuré :
- couches GPU et placement automatique (`--fit`, `--fit-target`) ;
- threads et sonde de conteneur ;
- files de lancement CUDA ;
- second slot, parallélisme de tenseurs ;
- cache de prompts, points de reprise des hybrides ;
- décodage spéculatif (MTP, brouillon, n-grammes) ;
- garde-fous de fidélité, garde-fou de chargement (LOAD_GUARD) ;
- état des slots gardé à la bascule.
Lot 14 : `loki-rs engine serve` fait ce travail quand `LOKI_SERVE=rust`
(expérimental). Le superviseur le lance à la place de `loki serve`. Sans la
variable, rien ne change.
## Découpage
- `serve/` : port fonction par fonction. `mod.rs` contient
`build_serve_args` et les outils de drapeaux. Les modules suivent les
fichiers Go :
- `threads`, `queues`, `expert`, `fidelity` ;
- `side`, `split`, `cache`, `ckpt` ;
- `spec`, `moe`, `persist`.
Tout ce qui décide est pur.
- `serve/run.rs` porte `cmdServe`. Il lit la configuration, la clé d'API, les
dossiers de modèles et le jeton de tentative de SPEC=auto par l'API interne
du Go. Il sonde ensuite :
- l'aide du moteur, `--version`, `--list-devices` (bornés dans le temps,
avec les bibliothèques du moteur) ;
- le GGUF ;
- la RAM, la VRAM (nvidia-smi), le cgroup et le cpuset ;
- les variables `LLAMA_ARG_*` et `GGML_*`.
Il vérifie que le port est libre et écrit le marqueur de SLOT_PERSIST. Il
garde la commande en base pour l'interface (`engine_cmdline`), puis fait
`exec` du moteur.
- La façade passe au Go l'adresse de son API interne (`LOKI_GO_ADDR`), à côté
du jeton. Le superviseur, lancé par le Go, en hérite, et `engine serve` aussi.
## Restent au Go
`loki serve` (Go) est lancé à notre place dans ces cas, à l'identique :
- un preset Strata (son propre serveur, lot à venir) ;
- le moteur d'essai de l'optimiseur (`LOKI_TUNE_TRIAL`) ;
- l'absence d'API interne (lancement à la main hors de la façade).
Restent aussi au Go les aperçus de l'éditeur de preset (`/api/preset/cacheram`,
`/api/preset/moe`, « Dupliquer en placement auto »). Ils viendront avec le lot
des presets.
## Écarts assumés
- Le message d'un brouillon (`MODEL_DRAFT`) introuvable cite l'erreur système
au format Rust.
- Pas de cache de l'aide ni du build par binaire : `engine serve` ne compose
qu'une ligne par lancement, le cache du Go ne servait qu'au process web.
- Le jeton de tentative de SPEC=auto est lu puis réécrit par l'API interne,
sans transaction : le Go seul l'efface, une fois le moteur prêt.
## Parité
`internal/loki/parity_serve_test.go` tire du Go 1 872 lancements, chacun
avec sa ligne de commande, son environnement et ses notes. Les lancements
croisent :
- 12 machines (VRAM, RAM, GGUF dense, MoE, hybride, par couches, architecture
exclue, brouillons, mémoire unifiée, conteneur à l'étroit) ;
- 112 presets ;
- 3 aides de moteur (récent, ancien, inconnu) ;
- 13 jeux de variables `LLAMA_ARG_*`, et des variables `GGML_*`.
Le journal comprend aussi les sondes pures :
- listes de CPU, cpuset et quotas, mémoire du cgroup ;
- sortie de nvidia-smi, `--list-devices`, build du moteur ;
- forme copiable de la commande ;
- verdicts de SPEC=auto ;
- drapeaux de chargement, taille d'état par jeton.
`tests/serve.rs` rejoue le tout contre `serve::build_serve_args`, argument
par argument. Une mutation du calcul du cache, des files CUDA ou des bornes
des n-grammes fait échouer le rejeu.
Deux essais de bout en bout lancent le vrai `loki-rs engine serve` contre un
faux Go et un faux llama-server. Ils vérifient :
- la ligne exécutée et le chemin des bibliothèques ;
- la commande gardée en base ;
- le refus d'un port pris, d'un modèle absent ;
- la sortie propre d'un preset externe ;
- la délégation au Go (sans API interne, essai de l'optimiseur, Strata).
@@ -1,86 +0,0 @@
# Lot 15 — presets tenus par la façade
## Contexte
Les presets vivaient dans le Go :
- `backend_presets.go` pour l'écriture ;
- les handlers de `web_api.go` et `web_external.go` ;
- les aperçus de l'éditeur (`cacheRAMPreview`, `sideSlotPreview`,
`moePreview`, `autoPlacementPreset`).
Lot 15 : la façade tient ces routes quand `LOKI_PRESETS=rust`
(`presets_api.rs`, expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/presets` | liste, avec pastilles (contexte, vision, quantification, raisonnement, Strata, externe) et mesure du bench si elle correspond à la configuration actuelle |
| `POST /api/presets/order` | ordre choisi par glisser-déposer |
| `GET /api/preset?id=` | contenu et prompt système ; sans id, la graine d'un preset neuf (BIN personnalisé, HOST, PORT) |
| `POST /api/preset/save` | crée (nom de fichier unique) ou met à jour ; le preset EN SERVICE est appliqué tout de suite |
| `POST /api/preset/delete` | refusé pour le preset actif ; emporte prompt système et mesure, et le modèle sur demande |
| `POST /api/preset/cacheram`, `/api/preset/moe` | aperçus de l'éditeur, calculés comme au lancement (lot 14) |
| `POST /api/preset/autoplace` | copie en placement automatique d'un preset MoE placé à la main |
| `GET /api/preset/external?id=`, `POST /api/preset/external/save`, `/api/preset/external/test` | presets externes ; la clé n'est jamais rendue, le test fait un vrai appel de complétion |
| `POST /api/switch` | bascule de preset |
Les presets restent des fichiers `presets/*.env`, et leur prompt système une
clé de la base. Les aperçus réutilisent le port de `loki serve` (lot 14),
sondes comprises.
## Restent au Go
Tout ce qui touche au moteur reste au Go, jusqu'au lot qui le portera. Le Go
expose deux routes internes pour cela (`facade_presets.go`).
`POST /internal/v1/preset/apply {path, mode}` applique un preset à la
configuration vivante :
- `switch` : l'état du slot est relevé avant (SLOT_PERSIST), puis le moteur
est relancé, ou arrêté pour un preset externe ;
- `save` : application, puis relance ;
- `apply` : la configuration seule (preset externe en service modifié).
`GET /internal/v1/tune/guard` dit si une optimisation tient le moteur. La
réponse est alors un 409 `{ok: false, tuning: true}`, comme le Go.
## Écarts assumés
- La suppression d'un preset retire sa mesure sans réécrire les autres avec
leurs champs à zéro, comme le faisait la struct du Go. Le Go les relit
pareil.
- Le message d'un échec de réseau du test externe est celui de reqwest.
## Parité
`internal/loki/parity_presets_test.go` tire du Go deux séries.
Les fonctions pures :
- nom d'affichage, guillemets, format `.env`, noms refusés ;
- quantification, raisonnement, modèle ;
- URL de complétions, contenu d'un preset externe, message d'erreur d'API ;
- arguments rejoints, clé réécrite dans un preset ;
- huit copies en placement auto ;
- empreinte de mesure, moteur imposé par un preset.
Puis 70 appels de route, avec après chacun les dossiers `presets/` et
`models/`, et les clés touchées en base. Ils couvrent :
- créations, doublons, noms refusés, erreurs JSON ;
- ordre, pastilles et mesures (empreinte, repli sur le modèle, autre
configuration) ;
- preset actif et suppressions (modèle compris, partagé, absent) ;
- presets externes et leur test contre un faux service (succès, 401 au
format OpenAI, 404 brut, 500 vide) ;
- placement auto et aperçus.
`tests/presets_api.rs` rejoue le tout par la façade, et une mutation fait
échouer le rejeu.
La bascule et l'enregistrement du preset en service sont vérifiés de bout en
bout contre le faux Go :
- appels d'application, avec leur mode ;
- refus quand une optimisation tourne ;
- erreur d'application rendue ;
- prompt système.
Côté Go, `TestFacadePresetApply` vérifie `presetApply` : configuration
appliquée, appareils préservés, moteur relancé, chemin hors du dossier
refusé.
@@ -1,90 +0,0 @@
# Lot 16 — pilotage du moteur tenu par la façade
## Contexte
Le Go pilotait le moteur depuis l'interface :
- `svcHandler` (`web_api.go`) pour `/api/start`, `/api/stop` et
`/api/restart` ;
- `handleVramUnload` et `handleVramReload` (`web_vram.go`) pour rendre la
carte graphique à la machine, puis la reprendre ;
- `serviceAction` (`sys_service.go`) : le verrou de l'optimiseur, puis, en
conteneur, le superviseur Rust `loki-rs engine <action>` (lot 3,
`sys_service_rust_linux.go`) ;
- `preflightEngine` : ce sans quoi le moteur ne peut pas démarrer, dit avant
de le lancer.
Lot 16 : la façade tient ces routes quand `LOKI_ENGINE=rust`
(`enginectl.rs`, expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `/api/start`, `/api/stop`, `/api/restart` | pilotage du moteur ; réponse `{ok, out}` comme le Go |
| `POST /api/vram/unload` | arrête le moteur et la dictée, rapporte la VRAM rendue ; refusé pendant une réponse en cours sauf `{force: true}` |
| `POST /api/vram/reload` | relance le moteur après le contrôle d'avant démarrage ; `{already: true}` s'il tourne |
La façade lance elle-même `loki-rs engine <action>`, avec l'environnement que
le Go lui passait (`LOKI_GO_BIN`, `LOKI_HOME`, `LOKI_SERVICE`) et de quoi
joindre l'API interne du Go pour `loki-rs engine serve` (`LOKI_GO_ADDR`,
`LOKI_INTERNAL_TOKEN`). Sa dernière ligne d'erreur devient l'erreur rendue,
sans le préfixe du binaire. Un preset externe ne lance rien ; un redémarrage
arrête alors le moteur local.
Le bilan de VRAM suit `gpuSettle` :
- une lecture ratée est sautée, jamais comptée « 0 Mo » ;
- un palier ne conclut qu'une fois la baisse commencée ;
- les jauges du moniteur sont invalidées après coup.
## Quand la façade ne tient pas ce lot
La façade ne prend ce lot qu'en conteneur, avec son superviseur. Sous
systemd, ou avec `LOKI_ENGINE_SUPERVISOR=go`, `loki-rs` ne passe pas
`LOKI_ENGINE` au Go, qui garde les routes (décidé au démarrage, raison dans
le journal).
## Restent au Go
Le Go expose trois routes internes (`facade_engine.go`) :
- `POST /internal/v1/service/prestart` : avant tout démarrage, refus pendant
une optimisation, puis ménage d'un verrou périmé (essai orphelin arrêté,
application interrompue défaite). Le verrou de l'optimiseur suit le
processus qui le tient, il reste au Go jusqu'au lot de l'optimiseur ;
- `GET /internal/v1/chat/generating` : la réponse en cours, quand le Go tient
la conversation (sinon la façade lit la sienne) ;
- `POST /internal/v1/dictate/stop` : l'arrêt de la dictée, quand le Go la
tient (sinon la façade arrête la sienne).
`GET /internal/v1/tune/guard` (lot 15) sert le refus 409 du rechargement.
Les autres appelants de `serviceAction` côté Go (bascule de preset,
optimiseur, mises à jour du moteur) passent toujours par le Go. Les deux voies
lancent le même superviseur, sur les mêmes fichiers PID et journal.
## Écarts assumés
- `engineNeedsStop` regarde aussi les états transitoires de systemd. En
conteneur, seul cas où la façade tient ce lot, il se réduit au PID vivant.
- Un superviseur tué par un signal est décrit comme par le Go (« signal:
killed ») pour les signaux courants, par son numéro sinon.
## Parité
`internal/loki/parity_engine_test.go` tire du Go :
- dix bilans de VRAM (palier avant la baisse, lectures ratées, VRAM en
hausse…) ;
- la dernière ligne d'erreur du superviseur et le retrait des couleurs ;
- vingt-six installations passées au contrôle d'avant démarrage (preset
externe, Strata, BIN absent ou relatif, MODEL absent, hors des dossiers,
remontant, en tranches incomplètes…) ;
- dix réponses de route qui ne lancent rien (GET refusé avec `Allow: POST`,
optimisation ou réponse en cours, moteur déjà arrêté, contrôle en échec).
`tests/enginectl.rs` rejoue le tout par la façade. Il vérifie aussi de bout en
bout, avec un faux superviseur et un faux nvidia-smi :
- démarrage, arrêt, redémarrage, refus pendant une optimisation ;
- échec du démarrage, preset externe ;
- déchargement chiffré, arrêt en échec, VRAM en hausse ;
- rechargement.
Une mutation de chaque règle fait échouer les tests.
Côté Go, `TestFacadeMoteur` vérifie le relais et les trois routes internes.
@@ -1,92 +0,0 @@
# Lot 17 — interrupteurs système tenus par la façade
## Contexte
Quatre réglages de l'interface vivaient dans le Go :
- l'accès internet de l'IA : `handleInternet`, `chat_internet.go`,
`web_fetch_go.go` (moteur web, serveur Crawl4AI, clé, joignabilité) ;
- le pilotage du navigateur : `handleComputer`, `chromePath`,
`visionEnabled` ;
- la clé de complétion du moteur : `handleAPIKey`, `backend_config.go`,
`oaiBaseURL` ;
- l'écoute réseau du moteur : `handleNetwork`, `sys_network.go`.
Lot 17 : la façade tient ces routes quand `LOKI_SYSTEM=rust` (`system.rs`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET\|POST /api/internet` | interrupteur, moteur web (`go` ou `crawl4ai`), URL et clé du serveur Crawl4AI (la clé n'est jamais rendue, seulement ses quatre derniers caractères), joignabilité |
| `GET\|POST /api/computer` | interrupteur du navigateur, binaire trouvé, vision du modèle |
| `GET\|POST /api/apikey` | clé de complétion : génération, saisie, effacement ; adresse `/v1` à coller dans un client tiers |
| `GET\|POST /api/network` | moteur ouvert au réseau ou à cette machine seulement (`HOST`), adresse de l'endpoint |
Les réglages restent des clés de la base, écrites comme le Go : une chaîne
vide efface la clé, et l'URL comme les clés ne laissent rien traîner dans la
configuration.
La joignabilité de Crawl4AI suit `crawlReachable` :
- `/health`, puis `/`, en moins de deux secondes en tout ;
- clé envoyée en `Authorization: Bearer` ;
- moins de 500 = joignable ;
- cache de 30 s par URL, vidé quand l'URL, la clé ou le moteur changent.
## Restent au Go
Le Go expose trois routes internes (`facade_system.go`) :
- `POST /internal/v1/web/reach/reset` : le cache de joignabilité des outils
web, encore exécutés par le Go, est vidé à chaque changement ;
- `POST /internal/v1/cdp/shutdown` : le Chromium des outils `browser_*` se
ferme quand l'interrupteur se coupe ;
- `POST /internal/v1/service/action {action, if_active}` : une clé neuve ne
s'applique qu'au redémarrage du moteur. La façade le relance elle-même si
elle le pilote (lot 16), sinon le Go, seulement s'il tourne.
`GET /internal/v1/tune/guard` (lot 15) refuse un changement de clé pendant une
optimisation.
Le relais du tunnel vers la façade garde désormais l'hôte et
`X-Forwarded-Proto` du client : l'adresse de la clé de complétion est celle
que le client a tapée, pas celle de la façade.
## Correction en passant
`maskAPIKey` paniquait sur une clé d'un seul octet (`k[len(k)-2:]`).
`/api/apikey` tombait alors à chaque lecture tant que la clé restait en place.
Une clé d'un ou deux octets s'affiche maintenant « … », côté Go comme côté
Rust.
## Écarts assumés
- Hors Windows, aucun pare-feu n'est piloté : la façade ne tourne qu'en
conteneur Linux, où le Go répondait déjà « inconnu ».
- La clé masquée est coupée sur les octets, comme le Go. Un caractère coupé
devient U+FFFD, octet par octet, comme son encodeur JSON.
## Parité
`internal/loki/parity_system_test.go` tire du Go 61 appels de route, avec
après chacun les clés touchées en base et les en-têtes reçus par trois faux
serveurs Crawl4AI (200, 500, 404). Ils couvrent :
- moteurs inconnus, URL avec et sans slashs, injoignable, sans schéma ;
- clés longues, courtes, d'un caractère, résiduelle en configuration ;
- erreurs de type qui laissent passer les autres champs, `null` ;
- vision par projecteur, preset externe ou Strata ;
- clé générée (masquée dans le journal), saisie, de treize octets, repli sur
`config.env`, adresse publique, `X-Forwarded-Proto`, refus pendant une
optimisation ;
- écoute ouverte, fermée, `localhost`, `::1`, port illisible.
Il tire aussi huit dispositions du dossier de Playwright pour le Chromium
retenu.
`tests/system.rs` rejoue le tout par une seule façade, cache de
joignabilité compris. Il vérifie de bout en bout ce qui reste au Go : cache
vidé, navigateur fermé, moteur relancé par le Go, ou par le superviseur
quand la façade pilote le moteur. Une mutation de chaque règle fait échouer
les tests.
Côté Go :
- `TestFacadeSysteme` vérifie le relais et les trois routes internes ;
- `TestFacadeRelaisGardeHote` vérifie l'hôte gardé ;
- `TestMaskAPIKeyCourte` vérifie le masque des clés courtes.
@@ -1,78 +0,0 @@
# Lot 18 — hub des projets tenu par la façade
## Contexte
Le hub des projets vivait dans le Go :
- `handleProjects` et `projectDTOs` (`web_projects.go`) ;
- les mutations de `projects.go` : `createProject`, `renameProject`,
`setProjectDesc`, `deleteProject` ;
- `projectSwitch` (`chat_sessions.go`).
Lot 18 : la façade tient `/api/projects` quand `LOKI_PROJECTS=rust`
(`projects_api.rs`, expérimental). Sans la variable, rien ne change.
| Action | Rôle |
|---|---|
| `GET` | les projets, le plus ancien d'abord, avec leurs discussions, pages (`.md` visibles, index exclu) et trackers comptés ; le projet actif |
| `create` | slug translittéré (`Réseau NAS` → `reseau-nas`), unique (`-2`, `-3`…), `projet` à défaut, 40 octets au plus ; nom de 60 runes au plus ; dossier `memory/<slug>` et son index amorcé |
| `rename` | le libellé seul : slug, chemins et discussions ne bougent pas |
| `desc` | 2 000 runes au plus, vide = effacée |
| `switch` | la conversation passe sur le projet (la plus récente de ses discussions, ou une neuve) |
| `delete` | discussions, trackers et mémoire du projet ; le projet actif est réassigné ; le dernier projet est indestructible |
Le registre reste dans la base (`state/projects`, écrit comme `json.Marshal`
avec le HTML échappé), la mémoire de chaque projet dans `memory/<slug>`.
## Restent au Go
Le projet actif passe toujours par le Go (`/internal/v1/project/activate`),
qui en garde une copie en RAM pour la mémoire, les outils et les tâches.
Les discussions sont à qui tient la conversation.
Quand la façade la tient (`LOKI_CHAT=rust`), elle bascule et supprime
elle-même. Avant de supprimer le projet actif, elle enregistre sa discussion
et en ouvre une du premier projet restant.
Sinon le Go garde deux routes internes (`facade_projects.go`) :
- `POST /internal/v1/project/switch` : `projectSwitch` (discussion du projet
quitté enregistrée, plus récente du projet rejoint ouverte, ou une neuve) ;
- `POST /internal/v1/project/drop-convs` : les discussions du projet
supprimées (`convDelete`, fichiers et résultats compris).
Une génération en cours refuse la bascule et la suppression (409). La façade
lit la sienne, ou celle du Go (`/internal/v1/chat/generating`, lot 16).
## Écarts assumés
- Pendant une tâche planifiée, le Go voit comme actif le projet de la tâche ;
la façade lit le projet actif enregistré.
- Pendant un tour de la façade, une bascule vers un projet inconnu répond 409,
comme quand le Go tient la conversation. Le Go, relayant à la façade,
répondait 400 (« projet introuvable ») dans ce cas.
## Parité
`internal/loki/parity_projects_test.go` part d'une base amorcée :
- pages visibles, cachées, hors `.md`, dans un sous-dossier ;
- discussions de Générale, d'avant les projets, d'un projet à venir et d'un
projet disparu ;
- trackers, dont `alphabet/x` qui ne doit pas tomber avec `alpha`.
Il tire du Go 40 appels, avec après chacun le registre, le projet actif,
l'arbre `memory/`, les trackers et l'index des discussions. Ils couvrent :
- créations (doublons, translittération, sans lettres, longues, vides) ;
- renommages et descriptions (longues, coupées sur une espace, effacées) ;
- bascules (sur l'actif, inconnue, vers un projet sans discussion, pendant
une génération) ;
- suppressions (de l'actif, inconnue, pendant une génération, jusqu'au
dernier projet) ;
- actions inconnues, corps vide ou illisible, erreur de type, méthode
refusée.
`tests/projects_api.rs` rejoue le tout par la façade, contre un faux Go qui
bascule et supprime les discussions comme le vrai. La bascule et la
suppression quand la façade tient la conversation sont vérifiées de bout en
bout. Une mutation de chaque règle fait échouer les tests.
Côté Go, `TestFacadeProjets` vérifie le relais et les deux routes internes.
@@ -1,65 +0,0 @@
# Lot 19 — export, mode Code et critères de la discussion
## Contexte
Avec `LOKI_CHAT=rust`, la façade tient la conversation (lot 6), mais trois
routes de la discussion ouverte restaient au Go :
- `/api/chat/export` (`chat_export.go`). Il relisait la copie enregistrée,
qui peut avoir un temps de retard sur la conversation vivante ;
- `/api/chat/mode` (`code_mode.go`) : mode Chat ou Code de la discussion, et
ses critères ;
- `/api/chat/criteria` : critères édités à la main.
Lot 19 : ces trois routes rejoignent celles de la conversation. La façade
les sert quand elle la tient (`chatx.rs`, `facadeChatRoutes` côté Go), sans
nouvelle variable. Sans `LOKI_CHAT=rust`, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/chat/export` | Markdown (fait pour être lu) ou JSON (fait pour être retraité), tiré de la conversation vivante ; options `format`, `reasoning`, `tools`, `results`, `turns` |
| `GET\|POST /api/chat/mode` | mode de la discussion ouverte (`chat/mode:<id>`, absent = chat) et ses critères ; un changement part sur le fil (`{mode}`) |
| `POST /api/chat/criteria` | critères édités : vides écartés, vingt au plus, ids posés, statuts ramenés à `pending`/`passed`/`failed` ; scellés comme le contenu des discussions (`chat/crit:<id>`), sur le fil (`{criteria, ts}`) |
L'export suit `chat_export.go` :
- Markdown : un bloc par tour, le raisonnement replié dans un `<details>`,
les outils en liste (seul l'état final d'un outil compte, comme au
rejeu), la compaction signalée, un export partiel annoncé en tête ;
- JSON : indenté et HTML échappé comme `json.MarshalIndent`, vue modèle sans
mise à jour de contexte (`PROJ_SNAPSHOT`) ni image en base64 (élidée avec
sa taille), journal filtré par les mêmes cases ;
- les derniers échanges comptent à partir des messages de l'utilisateur, pas
des rappels de Loki.
## Restent au Go
Le reste du mode Code reste au Go et lit les mêmes clés : l'indice « ça
ressemble à du code », les travaux de fond et l'outil `criteria` de l'agent.
## Écarts assumés
- L'export part de la conversation vivante, pas de sa copie enregistrée :
le dernier tour y est, même tout juste fini.
- Les images d'un tour en cours restent dans l'export JSON, élidées. Une
conversation relue par le Go les avait déjà perdues.
## Parité
`internal/loki/parity_chatx_test.go` tire du Go l'export d'une conversation
riche sous 22 jeux d'options lus dans la requête. La conversation contient :
- pièces jointes, raisonnement en deltas ;
- outils finis, en cours, avec et sans sortie, labels piégés pour le
Markdown ;
- statistiques, compaction ;
- images en base64 et en URL, rappel de Loki ;
- mise à jour de contexte marquée, et une tapée par l'utilisateur.
Plus les exports d'une conversation vide.
Il tire aussi 23 appels aux routes du mode et des critères, avec la base et
les événements du fil après chacun : erreurs JSON, y compris celles d'un
critère mal typé (`Criterion.criteria.id`), et au-delà de vingt critères.
`tests/chatx.rs` rejoue l'export octet pour octet et les routes par la
façade. Il vérifie aussi les en-têtes du téléchargement et le maintien des
routes au Go sans la conversation. Une mutation de chaque règle fait échouer
les tests.
@@ -1,93 +0,0 @@
# Lot 20 — serveurs MCP tenus par la façade
## Contexte
Le Go tenait les serveurs MCP (Model Context Protocol) :
- `mcp_config.go` : la configuration, dans `state/mcp` ;
- `mcp_catalog.go` : le catalogue embarqué ;
- `mcp_client.go` : le pool de sessions, avec le SDK MCP Go ;
- les routes `/api/mcp*` de `web_api.go`.
Le pool sert deux usages : l'état affiché dans l'interface, et les outils
`mcp__<serveur>__<outil>` annoncés au modèle et exécutés pour l'agent.
Lot 20 : la façade tient tout cela quand `LOKI_MCP=rust` (`mcp/`,
expérimental). Sans la variable, rien ne change.
| Route | Rôle |
|---|---|
| `GET /api/mcp` | état de chaque serveur, connexion tentée pour les activés (en parallèle) |
| `GET /api/mcp/catalog` | catalogue embarqué (le même fichier que le Go), `{workspace}` résolu, entrées déjà configurées marquées, lanceur (npx, uvx) présent ou non |
| `POST /api/mcp/save` | ajoute ou remplace (actif par défaut) ; nom contrôlé, un seul transport |
| `POST /api/mcp/delete`, `/api/mcp/toggle`, `/api/mcp/tool` | retire, active ou éteint, masque ou démasque un outil |
| `POST /api/mcp/test` | reconnexion forcée |
## Client MCP en Rust
`mcp/client.rs` parle JSON-RPC 2.0 sur deux transports, comme le SDK Go :
- stdio : un processus local, un message par ligne ;
- Streamable HTTP : un POST par message, réponse en JSON ou en flux SSE,
session suivie par `Mcp-Session-Id`, en-têtes configurés ajoutés.
La session s'ouvre par `initialize` puis `notifications/initialized`. Elle
sert `tools/list` (première page, comme le Go) et `tools/call`. Un `ping` du
serveur reçoit sa réponse.
Le pool (`mcp/mod.rs`) suit `mcp_client.go` :
- une session gardée d'un tour à l'autre ;
- une entrée publiée avant la connexion, pour que deux appelants partagent
la même tentative ;
- une session invalidée pendant sa connexion n'est jamais republiée ;
- délai de 3 min pour npx et uvx (premier téléchargement), 20 s sinon ;
- un appel borné à 120 s, une reconnexion si la session est morte ;
- la sortie plafonnée à la fenêtre de contexte (12 000 au moins), une erreur
d'outil signalée, une sortie vide rendue « [ok] (aucune sortie) ».
## Le Go
Un seul pool, celui de la façade. Le Go, qui exécute encore les outils de
l'agent, lui demande les outils à annoncer, leurs appels et le
pré-chauffage (`/internal/facade/v1/mcp/{tools,call,prewarm}`,
`facade_mcp.go`), quelle que soit la conversation qui tourne. Son propre
pool reste vide.
L'image copie `internal/loki/mcp_catalog.json` dans l'étape de compilation
Rust : un seul catalogue pour les deux binaires.
## Écarts assumés
- Les erreurs de transport (connexion refusée, flux coupé) ont les messages
du client Rust. Une erreur JSON-RPC et un binaire introuvable ont ceux du
Go.
- Un contenu « autre » (lien de ressource, ressource intégrée) est rendu en
JSON dans l'ordre des champs du SDK. Les annotations éventuelles sont dans
l'ordre alphabétique.
## Parité
`internal/loki/parity_mcp_test.go` tire du Go :
- les fonctions pures : noms exposés, schémas, libellés d'appel, serveur
d'origine d'un outil, ligne du préambule, délais et leurs messages,
aplatissement de neuf résultats ;
- la lecture de la configuration et seize mutations avec leurs erreurs et la
base après chacune ;
- ce qu'un vrai serveur du SDK MCP Go met sur le fil en Streamable HTTP :
onze outils, et le résultat de chacun ;
- ce que le client Go en tire : état de quatre serveurs (joignable,
binaire introuvable, désactivé, mal configuré), outils annoncés (un
masqué), appels.
`tests/mcp.rs` rejoue les fonctions et la configuration. Un faux serveur
resert ce que le serveur du SDK a répondu (appels en SSE) : l'état, les
outils et les appels tirés par la façade doivent être ceux du Go, sur une
seule session. Sont aussi vérifiés de bout en bout :
- le transport stdio : environnement, ping du serveur, serveur mort en plein
appel puis reconnexion ;
- les routes de configuration, en-têtes compris ;
- le catalogue ;
- une session invalidée pendant sa connexion, sans processus laissé
derrière.
Une mutation de chaque règle fait échouer les tests. Côté Go,
`TestFacadeMCP` vérifie le relais, les outils, les appels, le
pré-chauffage et une façade injoignable.
@@ -1,26 +0,0 @@
# Lot 21 — mise à jour de Loki tenue par la façade
## Contexte
`/api/update` (version disponible) et `/api/update/apply` (téléchargement et
remplacement du binaire) vivaient dans `sys_update.go`. En conteneur
(`LOKI_CONTAINER=1`), le Go n'y fait rien. L'image se met à jour par Docker,
et les deux routes le disent (`containerUpdateNote`) :
- `/api/update` : `{current, available: false, note}` ;
- `/api/update/apply` : 409 `{ok: false, error: note}`.
## Le lot
La façade ne tourne qu'en conteneur. Elle tient ces deux routes quand
`LOKI_UPDATE=rust` (`sysupdate.rs`, expérimental) et `LOKI_CONTAINER=1`.
Sinon `loki-rs` ne passe pas la variable au Go, qui garde le téléchargement
et le remplacement du binaire hors conteneur.
La version annoncée est celle du Go (`/internal/v1/auth`). Les binaires de
la façade suivent toujours `loki-rs self-update`.
## Parité
`internal/loki/parity_update_test.go` tire du Go les quatre réponses en
conteneur (GET et POST sur chaque route). `tests/sysupdate.rs` les rejoue par
la façade, version du Go comprise.
@@ -1,111 +0,0 @@
# Lot 22 — backends llama.cpp et job des moteurs tenus par la façade
## Contexte
Le panneau Moteur s'appuie sur une famille de routes du Go :
- `web_llamacpp.go` : statut, vérification git, bascule fast/opt, job ;
- `backend_prebuilt.go` : binaires précompilés officiels ;
- `web_api.go` : backends custom et leur désinstallation ;
- `web_devices.go` : GPU vus par un moteur.
Elles partagent **un** job (`lcJob`) avec la mise à jour du moteur de l'image
(`web_engine.go`) et l'installation de Strata (`backend_strata.go`). L'entête
du job est en base (`state/build_job`), ses lignes dans
`$LOKI_HOME/loki-build.log`. Au démarrage, un job noté « en cours » est déclaré
interrompu (`lcRestore`).
## Le lot
Avec `LOKI_BACKENDS=rust` (expérimental), la façade (`rust/loki-rs/src/backends`)
sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/llamacpp` | statut : dépôt git, binaire, moteur de l'image, précompilé, moteur téléchargé, plan de build, conseil, job |
| `/api/llamacpp/check` | `git fetch`, puis le retard sur origin |
| `/api/llamacpp/prebuilt/check` | dernière release officielle comparée à la version installée |
| `/api/llamacpp/prebuilt` | job : téléchargement, extraction (liens compris), VERSION, ménage, BIN, service arrêté puis relancé |
| `/api/llamacpp/use` | bascule fast/opt, refusée pendant une optimisation |
| `/api/llamacpp/job` | progression et lignes à partir d'un décalage |
| `/api/llamacpp/uninstall-custom` | désinstallation d'un backend custom |
| `/api/backends`, `/api/backends/custom` | les backends présents |
| `/api/backends/devices` | GPU vus par un moteur, avec un cache mémoire et un cache disque |
**Le job vit dans la façade** (`backends/job.rs`) : même entête en base, même
journal, même reprise au démarrage. Les jobs que le Go fait encore tourner lui
rapportent chaque étape par l'API interne `/internal/facade/v1/build-job/*`
(`start`, `line`, `phase`, `fail`, `done`, `commits`, `end`, `snapshot`).
Ces jobs sont :
- la compilation depuis les sources (`install`, `update`, `install-custom`,
relayées au Go qui garde son refus 409 quand l'image fournit le moteur) ;
- la mise à jour du moteur de l'image ;
- Strata.
Côté Go (`facade_backends.go`), quand la façade tient le lot :
- `startLcJob`, `lcAppend`, `lcPhase`, `lcFail`, `lcDone`, `lcSetCommit` et
`lcJobSnapshot` passent par la façade ;
- le garde de l'optimiseur reste au Go, avant le démarrage ;
- le Go ne restaure plus de job au démarrage ;
- les routes reçues par le tunnel sont relayées à la façade.
Pour arrêter et relancer le service, la façade pilote le moteur si elle le
tient (lot 16), sinon elle le demande au Go (`/internal/v1/service/action`,
avec `if_active`). Les sondes de la machine passent par un PATH et une racine
injectables (`backends/probe.rs`) :
- `nvidia-smi`, `nvcc`, `hipcc`, `/opt/rocm` ;
- `glslc`, `vulkaninfo`, `ldconfig` et les chemins de `libvulkan`.
## Correction du Go
`prebuiltVersionFormat` lisait le format comme 3ᵉ champ du marqueur VERSION.
Hors CUDA Windows, la version CUDA est vide : le marqueur
« `b300 fmt2` » n'a alors que deux champs. Le format n'était donc jamais
reconnu, et chaque vérification retéléchargeait la release sous Linux et
macOS au lieu de dire « déjà à jour ». Le format est désormais le dernier
champ, et la version CUDA ignore ce champ. Le port Rust suit.
## Parité
`internal/loki/parity_backends_test.go` (Linux) tire du Go un journal rejoué
par `tests/backends.rs`. Le journal est sauté si `/usr/local/cuda*` ou
`/opt/rocm` existent sur la machine.
Le journal contient :
- **le statut** dans tous ses états :
- dépôt git reproductible (dates et identité fixées), retard connu ou vérifié, tête détachée ;
- origine perdue, BIN dans un fork ;
- précompilé versionné, moteur téléchargé, moteur de l'image ;
- **les backends custom** et chaque refus de désinstallation ;
- **la bascule fast/opt** ;
- **la dernière release** :
- refus HTTP, tag vide, JSON illisible ;
- aucun asset adapté ;
- **le job précompilé** :
- installation, « déjà à jour », ancien format réextrait ;
- optimisation en cours, GitHub qui refuse ;
- archive absente, archive piégée (`../`), archive sans serveur ;
- **les GPU** :
- liaison PCIe, mémoire complétée par nvidia-smi ;
- cache, mémoire nulle non figée, repli sur le disque ;
- moteur planté, tué ou non exécutable, pilote ancien, cartes de même nom ;
- **après chaque appel** : BIN, entête en base, journal, `devices.json` et l'arborescence de `backends/` et `engine/` ;
- **machine par machine** (dix fausses machines) : le plan de build, le conseil et la variante de release ;
- **le job pas à pas** :
- phases déduites des lignes de build, troncature à 4000 lignes, vues à partir d'un décalage ;
- écritures en base limitées à une toutes les 2 s ;
- refus d'un second job ;
- reprise au démarrage dans tous les cas : absent, illisible, fini, interrompu, journal vide ou trop long.
Autres tests :
- le Go (`TestFacadeBackends`) vérifie que ses jobs rapportent chaque étape à
la façade, dans l'ordre, et que la compilation n'est pas relayée ;
- la façade (`api_interne_du_job`, `service_arrete_puis_relance`) vérifie
l'API interne et le service arrêté puis relancé autour de l'installation.
## Écarts assumés
- Les erreurs réseau (GitHub injoignable) gardent le texte de `reqwest`.
- Quand un asset de la release a un champ de mauvais type, le message n'est
pas celui du Go.
- `git fetch` lancé par `/api/llamacpp/check` pendant un job du Go n'écrit
plus ses lignes dans ce job.
@@ -1,85 +0,0 @@
# Lot 23 — sonde de gabarit portée en Rust
## Contexte
La sonde de gabarit (`chat_tplprobe.go`) dit ce que le gabarit de chat du
modèle chargé fait de ce qu'on lui envoie :
- `preserves_history` : le raisonnement d'un tour passé est-il encore rendu ?
- `prefix_stable` : après un tour d'outil, le rendu précédent reste-t-il un
préfixe exact du suivant ?
- `renders_reasoning` : `reasoning_content` est-il rendu quelque part ?
Le Go la lance après chaque complétion du fil principal, avec les outils et
les `chat_template_kwargs` de la requête. Trois choses lisent son verdict :
- la politique d'écho du raisonnement (`REASONING_ECHO`) ;
- le rappel de budget glissé dans le résultat d'outil (`NUDGE_IN_TOOL`) ;
- le résumé des performances (`/api/perf/summary`).
Le relevé du rendu (`tplRenderPrint`) et sa comparaison (`tplRenderDiff`)
servent le panneau Moteur, autour d'une mise à jour.
Quand la façade tenait la conversation (`LOKI_CHAT=rust`), plus aucune sonde
ne tournait :
- l'écho décidait sans verdict ;
- `NUDGE_IN_TOOL` gardait la conversation au Go (`facadeChatBlocker`).
## Le lot
`rust/loki-rs/src/chat/tplprobe.rs` porte la sonde entière :
- les requêtes au moteur local (`/health`, `/props`, `/apply-template`, avec la
clé du moteur et les relances sur 503) ;
- les rendus synthétiques et leurs verdicts ;
- le cache par build, modèle, gabarit et forme ;
- le dernier verdict, global et par forme ;
- la ligne de journal ;
- le déclenchement borné (une sonde en vol au plus, une par minute) ;
- le relevé du rendu et sa comparaison.
Avec `LOKI_CHAT=rust` :
- **la boucle du tour** signale chaque complétion lue en entier sur le moteur
local, outils ni coupés ni neutralisés (`TurnHost::completed`). L'hôte lance
la sonde pour le fil principal seulement : ni tâche ni sous-agent ;
- **la politique d'écho** lit le dernier verdict ;
- **`NUDGE_IN_TOOL`** est porté (`nudge_in_tool_key`, `nudge_into_tool`,
`TurnHost::prefix_unstable`). Il ne garde plus la conversation au Go ;
- **le résumé des performances**, que sert encore le Go, relit le verdict de la
façade (`/internal/facade/v1/chat/tpl`, `facadeTplCaps`).
## Parité
`internal/loki/parity_tplprobe_test.go` enregistre chaque échange de la sonde
du Go avec un faux moteur : méthode, chemin, clé, corps envoyé, réponse.
Les comportements de moteur couverts :
- gabarit Qwen3, gabarit qui garde tout, raisonnement jamais rendu, amorce
ignorée ;
- 404, exception du gabarit, clé exigée (inconnue ou connue) ;
- 503 relancés ou persistants, 429 ;
- `/props` illisible, moteur pas prêt, réponse sans prompt ;
- rendus incohérents, preset externe.
Le journal contient aussi :
- le verdict, sa ligne de journal, `tplCapsCurrent` et `tplCapsFor` ;
- le cache : même moteur, gabarit changé, build changé ;
- le relevé du rendu sur trois gabarits, et une erreur ;
- neuf comparaisons de relevés ;
- l'empreinte de six formes.
`tests/tplprobe.rs` rejoue chaque échange contre le port. Le faux moteur rend
les réponses enregistrées et exige des requêtes identiques, octet pour octet.
Il vérifie aussi le déclenchement borné.
Autres tests :
- `tests/lot6.rs` vérifie la sonde lancée après un vrai tour de la façade ;
- les tests de la boucle vérifient le rappel dans le résultat d'outil et les
complétions signalées ;
- `TestFacadeSondeDeGabarit` vérifie, côté Go, que le résumé des performances
relit le verdict de la façade.
@@ -1,130 +0,0 @@
# Lot 24 — panneau « moteur de l'image » tenu par la façade
## Contexte
Le panneau Moteur permet de mettre à jour llama.cpp sans reconstruire
l'image. Le moteur est pris dans l'image de conteneur officielle
(`ghcr.io/ggml-org/llama.cpp:server-cuda-b#####`), dont on ne télécharge que
les couches du sommet, celles qui portent `/app`. Trois fichiers du Go le
servent :
- `web_engine.go` : statut, dernier build publié, mise à jour, bascule,
suppression ;
- `web_engine_reco.go` : version recommandée, risque de rendu du
raisonnement (défaut de b10763), retour à la version précédente,
vérification du rendu du gabarit avant/après la bascule ;
- `backend_engine_oci.go` : dialogue avec le registre, extraction des
couches, essai à blanc.
La mise à jour est un job, le job des moteurs que la façade tient depuis le
lot 22. Le risque de rendu et la vérification du rendu reposent sur la sonde
de gabarit, portée au lot 23.
## Le lot
Avec `LOKI_ENGINE_IMAGE=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la
façade (`rust/loki-rs/src/engineimg`) sert les routes suivantes :
| Route | Rôle |
| --- | --- |
| `/api/engine` | statut : binaire, build, source, variante, versions installées, avis, encart « version recommandée », version précédente, relevé du rendu |
| `/api/engine/check` | dernier build publié pour la variante, comparé au courant |
| `/api/engine/update` | job : recherche, téléchargement des couches du sommet, essai à blanc, REASONING_PRESERVE=off si demandé, bascule, ménage, vérification du rendu |
| `/api/engine/use` | bascule vers une version installée ou vers le moteur de l'image |
| `/api/engine/remove` | suppression d'une version installée (refusée pour celle qui tourne) |
| `/api/engine/plan` | sur clic : version visée (dernière ou recommandée) et risque de rendu |
| `/api/engine/rollback` | retour au moteur d'avant la dernière bascule |
Sans les backends, loki-rs ne passe pas la variable au Go : le panneau
reste au Go et le dit au démarrage. `/api/engine/cmdline` reste au Go.
La sonde de gabarit est celle de la conversation quand la façade la tient
(`LOKI_CHAT=rust`) : le relevé du rendu rejoue alors la forme de ses vraies
requêtes. Sinon, la façade a sa propre sonde, qui sonde une requête nue.
Pour REASONING_PRESERVE=off posée dans le preset actif, la façade réutilise
l'enregistrement du lot 15 (`save_applying`). Le service est arrêté puis
relancé comme au lot 22 : par la façade si elle pilote le moteur, sinon par
le Go (`/internal/v1/service/action`).
Côté Go, `facade_engineimg.go` relaie à la façade les routes reçues par le
tunnel.
## Parité
`internal/loki/parity_engineimg_test.go` (Linux) tire du Go un journal
rejoué par `tests/engineimg.rs`. Il tourne contre :
- un faux registre OCI : jeton, index multi-architecture, manifeste annoté,
couches .tar.gz ;
- un faux moteur local (le `fakeTpl` du Go, porté) pour la sonde et le rendu.
Le journal couvre :
- **le statut** : moteur de l'image, téléchargé ou compilé, avec la version
précédente (absente quand c'est le moteur courant), le relevé du rendu,
sans moteur d'image, sans aucun moteur ;
- **le dernier build** : publié, sans numéro, ou refusé (jeton refusé ou
illisible, manifeste refusé ou illisible, aucune image pour
l'architecture) ;
- **le plan** :
- dernière version (avec ou sans numéro) ou recommandée (build trop
ancien, tag figé absent, sans numéro), et déjà à jour ;
- chaque verdict du risque de rendu : moteur arrêté, gabarit vide,
REASONING_PRESERVE posée, EXTRA_ARGS, gabarit sans les variables, gabarit
qui garde la réflexion, build antérieur au défaut, preset externe ;
- **la mise à jour** :
- couches du sommet vers la base ; une couche du dessus masque celle du
dessous ; liens absolus ramenés dans `/app` ; marqueurs de suppression et
sous-dossiers ignorés ; couche sans fichier du moteur ; couche du runtime
ignorée ; phase du téléchargement ;
- essai à blanc ;
- risque de rendu rappelé dans le journal, établi ou impossible à vérifier
(moteur local arrêté) ;
- REASONING_PRESERVE=off : posée dans le preset actif ou dans la
configuration, déjà posée, ou inconnue du moteur ;
- ménage : versions triées par build, dossier sans moteur épargné, version
précédente gardée ;
- rendu comparé avant/après : changé, identique, ou impossible sur le
nouveau moteur ;
- déjà à jour, corps illisible ;
- **les échecs** :
- optimisation en cours, version inconnue, image sans serveur ou sans
version annoncée, manifeste sans couche, runtime au sommet, couche
introuvable, tag illisible ;
- moteur qui ne démarre pas (12 dernières lignes de sa sortie) ou qui ne
voit aucune carte ;
- **les bascules** : vers une version installée, vers l'image, vers une
version absente, pendant une optimisation ; retour, deux fois, et en GET ;
- **la suppression** : version (`../` neutralisé), version absente, moteur
utilisé ;
- **après chaque appel** : BIN, REASONING_PRESERVE, `engine/previous-bin` et
l'arborescence de `engine/`.
Autres tests :
- la façade vérifie le service arrêté puis relancé autour d'une mise à jour
et redémarré par une bascule (`service_arrete_puis_relance`), le relevé
« non vérifié à temps », le 503 sans backends, les variantes et les
vérifications périmées ;
- le Go (`TestFacadeMoteurImage`) vérifie que les sept routes partent à la
façade, et elles seulement.
## Mutations
74 mutations du port Rust, toutes tuées sauf trois, équivalentes :
- la garde « moteur d'image absent » de la source : `same_path` ne
rapproche jamais un chemin vide ;
- l'essai à blanc qui relance l'inventaire des cartes quand l'ancien moteur
est le nouveau : même résultat ;
- une vérification du rendu périmée qui continue d'attendre son moteur : son
relevé est de toute façon écarté au rangement.
## Écarts assumés
- Les erreurs réseau (registre injoignable) gardent le texte de `reqwest`.
- Un manifeste dont un champ a le mauvais type ne donne pas le message du
Go : le nom Go du type anonyme n'est pas reproduit.
- La vérification du rendu attend son délai à la cadence choisie (5 s), sans
le contexte Go qui coupe aussi une requête en cours au bout de 20 min.
@@ -1,121 +0,0 @@
# Lot 25 — Strata tenu par la façade
## Contexte
Strata est le deuxième moteur de Loki. Il répartit les experts d'un MoE de
125 B (famille Qwen3.8-Flash-Next) entre VRAM, RAM et disque. Le moteur est
le paquet **figé** publié par AJEAN : les sources et le binaire Linux CUDA 12,
vérifiés par SHA-256. Un preset le choisit (`ENGINE=strata`).
`backend_strata.go` sert trois routes :
- `/api/strata` : la machine (cartes, RAM, disque, Python), ce qui y tient,
la recommandation par version et les modèles installés, avec leur
configuration de lancement en clair ;
- `/api/strata/settings` : les réglages d'un modèle installé. S'il est en
service, la configuration est réappliquée et le moteur relancé ;
- `/api/strata/install` : un job des moteurs. Il télécharge et vérifie le
paquet, lance l'installeur de Strata, écrit le preset et bascule dessus.
Le lancement (`loki serve` d'un preset Strata) vit aussi dans ce fichier.
## Le lot
Avec `LOKI_STRATA=rust` **et** `LOKI_BACKENDS=rust` (expérimental), la façade
(`rust/loki-rs/src/strata`) sert les trois routes :
- **la décision pure** (`strata/mod.rs`) :
- placement des experts : drop, ram, arena ou disk, selon la RAM
disponible, la carte d'appoint et `experts.bin` ;
- configuration de lancement : réglages validés, carte d'aide, mode des
experts, réglages du preset ;
- ce qui tient sur la machine, la recommandation, les réglages, les noms ;
- **la machine** (`strata/api.rs`) :
- cartes via `nvidia-smi` (la sonde des backends) ;
- Python 3.10+ et son module venv ;
- RAM totale et disponible (`/proc/meminfo`) ;
- RAM du moteur Strata en marche (arbre du service dans `/proc`) ;
- place libre ;
- **l'installation** : le job des moteurs. La sortie de l'installeur est
découpée en lignes comme le `sinkWriter` du Go : une barre `\r` n'est
montrée que toutes les 10 s. La bascule finale passe par le Go
(`/internal/v1/preset/apply`, mode `switch`), qui applique le preset et
relance le service.
Sans les backends, loki-rs ne passe pas la variable au Go et Strata reste au
Go. Le lancement reste au Go (`loki serve`, que `loki-rs engine serve` lui
délègue pour un preset Strata).
Côté Go, `facade_strata.go` relaie à la façade les routes reçues par le
tunnel.
## Correction du Go
`/api/strata/settings` décidait si le preset était en service **après** y
avoir écrit les nouveaux réglages. L'empreinte du nouveau contenu ne
correspondait alors plus à la configuration active : changer un réglage du
modèle en service ne l'appliquait jamais, et le moteur n'était pas relancé.
« En service » se décide désormais avant l'écriture, comme dans
`SavePresetApplying`. Le port Rust suit.
## Parité
`internal/loki/parity_strata_test.go` (Linux x86_64) tire du Go un journal
rejoué par `tests/strata.rs`. Pour le tirer, le Go reçoit des crochets de
test : `strataReleaseBase` (variable), `meminfoPath`, `strataDiskFree` et
`serviceActionOSFn`. Le journal couvre :
- **la machine** :
- sans carte, nvidia-smi en panne ou vide, carte trop ancienne ou trop
petite, carte d'aide trop ancienne ;
- trois cartes de même VRAM : la plus récente devant (capacité de calcul
arrondie au dixième) ;
- Python absent, en panne, illisible, trop ancien, sans venv ;
- 16, 47 ou 64 Go de RAM, meminfo sans MemAvailable ;
- disque presque plein ou juste trop petit (marge de 5 Go), données déjà
présentes ;
- **les réglages** :
- GET, corps illisible, preset vide ou avec chemin, preset absent ou
llama.cpp ;
- hors limites, experts inconnus ;
- preset inactif ; preset actif (appliqué et relancé), et pendant une
optimisation ;
- **l'installation** :
- GET, corps illisible, quant inconnu, optimisation en cours ;
- machine sans carte, modèle qui ne tient pas ;
- release introuvable, moteur altéré (empreinte) ;
- installation complète, puis réinstallation (sources déjà là et
décompressées, moteur téléchargé périmé retéléchargé) ;
- installeur en échec, installeur sans configuration ;
- **les modèles installés** :
- détails de lancement du modèle actif et de modèles inactifs (experts
sur disque ou en RAM, vision coupée, sans carte d'aide) ;
- mode du dernier lancement ;
- `experts.bin` avec le moteur du paquet ou d'un autre paquet ;
- RAM juste sous ou au-dessus du seuil du mode hors RAM, mode hors RAM
coupé dans le preset ;
- configuration de lancement disparue ou illisible ;
- **après chaque appel** : les actions du service, les clés de la
configuration et l'arborescence de `presets/` et `strata/` (sans le
contenu des archives).
Autres tests :
- les tests du Go (`backend_strata_test.go`), portés (`strata/tests.rs`) :
configuration de lancement, placement des experts, recommandation,
réglages, noms ;
- la RAM du moteur en marche, le découpage en lignes et les nombres écrits
à la Go ;
- le Go (`TestFacadeStrata`) : les trois routes partent à la façade, et
elles seules.
## Mutations
71 mutations du port Rust, toutes tuées (les dix survivantes du premier
passage l'ont été par les étapes et les tests ajoutés ci-dessus).
## Écarts assumés
- Les erreurs réseau (release injoignable) gardent le texte de `reqwest`.
- Une copie du moteur qui échoue (`prebuilt/`) ne donne pas exactement le
message du Go, qui ouvre la source puis la destination.
@@ -1,193 +0,0 @@
# Lot 26 — Tâches planifiées tenues par la façade
## Contexte
Une tâche planifiée tourne seule, sans utilisateur en face. Il en existe
deux sortes :
- **une tâche IA** : une consigne, exécutée par l'agent dans un fil
éphémère ;
- **une tâche « script seul »** : un script du dossier protégé
(`$LOKI_HOME/scripts`), lancé sans modèle. Sa sortie devient le
compte-rendu.
Le Go les tient dans plusieurs fichiers :
- `tasks.go` : la base (`bkTasks`) et l'interrupteur maître ;
- `tasks_cron.go` : les fréquences ;
- `tasks_sched.go` : le planificateur, un tic par minute et une tâche due
par tic ;
- `tasks_run.go` et `tasks_script.go` : l'exécution ;
- `web_tasks.go` : sept routes.
Les fréquences prennent quatre formes :
- `@every 2h` : intervalle glissant d'au moins une minute ;
- `@every 3d@9:30` : tous les N jours, à une heure murale ;
- `@once 2026-10-01 16:54` : une seule fois ;
- une expression cron à cinq champs.
L'heure murale est celle du fuseau de la tâche. Sans fuseau, ou avec un
fuseau inconnu, c'est l'heure locale du serveur.
## Le lot
Avec `LOKI_TASKS=rust` (expérimental), la façade (`rust/loki-rs/src/tasks`)
sert les routes `/api/tasks*` et fait tourner le planificateur, que le Go ne
démarre plus. Elle exécute elle-même les tâches script :
- le shell de l'agent (`run_shell`), dans le dossier de la discussion
ouverte ;
- une limite de 10 min ;
- un arrêt par `/api/tasks/stop`.
Une tâche IA passe par le verrou de génération et les outils de l'agent, qui
restent au Go. La façade la lui fait exécuter par `/internal/v1/tasks/run`,
que le Go traite comme son propre planificateur l'aurait fait :
- preset épinglé ;
- projet forcé ;
- `RunAutonomous` ;
- état persisté ;
- notification.
Moteur occupé ou modèle pas prêt : la tâche ne tourne pas, son état ne bouge
pas, et le tic suivant la réessaie. Le Go rend la main à la fin du passage.
Les notifications des tâches script partent par la façade si elle tient les
notifications, sinon par le Go (`/internal/v1/push/send`).
Les fréquences demandent l'heure murale d'un fuseau IANA. `src/tz.rs` porte
ce que le Go fait de la zoneinfo du système :
- lecture des TZif v1 et v2, et de la règle POSIX qui prolonge les
transitions ;
- `time.Local` (`$TZ`, `/etc/localtime`) ;
- `time.Date`, avec la normalisation des débordements ;
- les heures qui sautent ou qui se répètent, résolues comme le Go.
`tasks/cron.rs` porte `time.ParseDuration` du Go, messages d'erreur compris.
Côté Go :
- `facade_tasks.go` : le relais des routes reçues par le tunnel et les deux
routes internes ;
- `web_server.go` : le planificateur n'est pas démarré ;
- `tasksNow` : l'heure des tâches, remplaçable en test.
## Correction de la façade
Les appels de la façade à l'API interne du Go étaient tous bornés à 10 s. Un
outil exécuté par le Go (`/internal/v1/tool/exec`) qui dure plus longtemps
était coupé :
- une page web lente ;
- le navigateur ;
- un serveur MCP tenu par le Go.
Le résultat partait en erreur alors que l'outil continuait. Ces appels sont
désormais bornés à 30 min. Une tâche IA exécutée par le Go est attendue sans
autre limite que 24 h.
## Ce que les mutations ont corrigé dans le port
Le premier passage des mutations a laissé survivre des cas que le journal ne
voyait pas. Confrontés au Go, plusieurs étaient de vrais écarts :
- **dates lointaines** : un instant en nanosecondes sur 64 bits s'arrête en
2262. Le Go accepte `@once 2400-02-29 10:00`, ou un intervalle en jours
qui mène au-delà. Les instants des fréquences passent sur 128 bits ;
- **intervalle en jours démesuré** : `@every 200000d` déborde dans le Go
(`time.Duration`), qui rend une date passée. Le port déborde de même ;
- **l'heure qui saute** : `@every 1d@02:30` à Paris, le jour du passage à
l'heure d'été, tombe à 3 h 30. Le Go avance ensuite de jour en jour depuis
ce candidat (`AddDate`) et reste à 3 h 30. Le port repartait de 2 h 30 ;
- **compte-rendu tronqué** : le Go coupe à 4000 octets, au milieu d'un
caractère s'il le faut, et chaque octet orphelin devient U+FFFD à
l'encodage JSON. Le port reculait à une frontière de caractère ;
- **nom de fuseau** : le Go refuse tout nom qui contient `..`, pas seulement
un composant `..`.
## Parité
`internal/loki/parity_tasks_test.go` (Linux) tire du Go un journal rejoué
par `tests/tasks.rs`. L'heure des tâches est fixée pas à pas ; les
identifiants tirés au hasard deviennent `{{ID1}}`, `{{ID2}}`… dans l'ordre où
ils apparaissent. Le journal couvre :
- **l'écran** :
- scripts du dossier (sous-dossiers compris) ;
- mode agent, interrupteur maître (rangé à « 0 » : pas suspendu) ;
- projets, mémoire coupée, internet armé ;
- la tâche en cours pendant un script ;
- **l'enregistrement** :
- corps illisible, sans nom, sans consigne ;
- script absent, dossier, remontée, chemin absolu, séparateur Windows,
caractère interdit ;
- chaque erreur de fréquence : vide, intervalle trop court, illisible ou
sans unité, jours ou heure invalides, cron à 4 champs, hors bornes, en
lettres, plage inversée, pas nul, jamais satisfait, date illisible ou
déjà passée ;
- chaque forme de fréquence, dans plusieurs fuseaux : Paris (dont l'heure
qui saute), New York, Calcutta, fuseau inconnu ;
- id inconnu gardé, projet inconnu remplacé par l'actif ;
- modification avec ou sans changement de fréquence ;
- **la bascule, l'interrupteur maître, la suppression** ;
- **les lancements** :
- tâche absente ;
- tâche IA avec moteur occupé (409) ou modèle pas prêt (503) ;
- script en succès, en échec, arrêté à la main ;
- script qui échoue après un succès, puis se rétablit ;
- tâche script rangée sans script, au nom refusé, au script disparu ;
- **le planificateur** :
- tic suspendu, tic pendant une optimisation ;
- une tâche IA due qui attend le modèle ;
- une tâche due par tic, dans l'ordre des noms ;
- la tâche « une fois » qui s'éteint ;
- une tâche sans prochain passage (posé sans la lancer) ;
- une fréquence devenue invalide ;
- deux tâches de même nom (rangées par id).
Après chaque étape, le journal enregistre les tâches en base.
Autres tests :
- fuseaux et fréquences confrontés au Go :
- Paris aux deux changements d'heure, New York, Calcutta ;
- avant la première transition, pile à une transition, la table avant la
règle prolongée ;
- `$TZ` (vide, « : », chemin, nom), `/etc/localtime` ;
- noms refusés, « UTC » sans base ;
- mois hors bornes, années bissextiles, mois de 30 jours ;
- `ParseDuration` et ses débordements, cron (plages, pas, bornes, à 78 et
364 jours) ;
- le compte-rendu tronqué, les noms de script ;
- l'amorçage au démarrage (tâches actives sans prochain passage) ;
- la durée d'un passage, à une horloge qui avance ;
- la tâche IA en cours vue par l'écran ;
- la tâche IA lancée à la main part au Go (`tache_ia_executee_par_le_go`) ;
- le planificateur tourne à son pas sans demande ;
- sans `LOKI_TASKS`, les routes partent au Go ;
- le Go (`TestFacadeTaches`) :
- les sept routes partent à la façade, et elles seules ;
- `/internal/v1/tasks/run` sur une tâche IA sans modèle (occupée, rien
d'enregistré) et sur une tâche script ;
- `/internal/v1/push/send`.
## Mutations
79 mutations du port Rust, dont 77 tuées. Les survivantes du premier passage
l'ont été par les tests et les étapes ajoutés ci-dessus. Restent deux
équivalentes :
- le refus d'un nom de script absolu **après** nettoyage : sous Unix, un nom
qui ne commence pas par `/` ne le devient pas (le contrôle du Go vaut pour
Windows) ;
- `web_on` sans `internet_enabled` : la joignabilité refuse déjà un moteur
Crawl4AI sans adresse, seul cas où les deux diffèrent.
## Écarts assumés
- Pendant une tâche IA, le Go force le projet de la tâche. Une tâche
enregistrée sans projet à ce moment-là prend, côté façade, le projet actif
rangé.
Loaded 100 of 922 files, more files were not shown because too many files have changed in this diff. Show more