diff --git a/.dockerignore b/.dockerignore index 92a575b..69adb94 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,26 +1,10 @@ -# Contrôle de version .git -.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 +.github +data/ +models/ +docs/ +dist/ +*.md +!README.md .env -.env.local -.idea -.vscode -**/.DS_Store +.env.* diff --git a/.env.example b/.env.example index 6a0b6ab..bdf9094 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,19 @@ -# ── 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 +# ── Loki — configuration du build/run Docker ──────────────────────────── +# (fork d'AJEAN : https://github.com/nathaninline/ajean) -# Modèle sélectionné par défaut au démarrage -DEFAULT_MODEL=llama3.1:8b +# Port de l'interface web (identique dedans/dehors). +LOKI_WEB_PORT=8090 -# Dossier de travail de l'agent (monté en volume dans le conteneur) -WORKSPACE_DIR=/workspace +# Architectures CUDA compilées (build local uniquement). +# 75=Turing (GTX 16xx / RTX 20xx), 86=Ampere (RTX 30xx), 89=Ada (RTX 40xx). +# Restreindre à sa carte divise le temps de compilation. +CUDA_ARCHS=75;86;89 -# 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= +# 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= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d207b18 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.go text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2320f31 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +# 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 + + 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 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 228e87c..614bfc0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,6 +16,16 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # L'étape llama.cpp CUDA a besoin de place : l'image devel pèse ~8 Go et + # un runner ubuntu-latest n'offre que ~14 Go libres. On purge ce qui est + # préinstallé et inutile ici (SDK Android, .NET, Haskell…). + - name: Libérer l'espace disque du runner + run: | + sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc \ + /usr/local/.ghcup /opt/hostedtoolcache/CodeQL + sudo docker image prune -af + df -h / + - name: Setup Buildx uses: docker/setup-buildx-action@v3 @@ -43,7 +53,11 @@ jobs: push: true build-args: | LOKI_VERSION=${{ github.sha }} + CUDA_ARCHS=75;86;89 tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + # Cache limité : les couches CUDA (plusieurs Go) satureraient le + # plafond de 10 Go du cache GitHub Actions — on ne cache que le mode + # min (couches finales), le gain principal restant le cache apt/go. cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: type=gha,mode=min diff --git a/.github/workflows/mac-build.yml b/.github/workflows/mac-build.yml new file mode 100644 index 0000000..2e99451 --- /dev/null +++ b/.github/workflows/mac-build.yml @@ -0,0 +1,52 @@ +# 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' \ + '' \ + '' \ + '' \ + 'CFBundleNameLoki' \ + 'CFBundleDisplayNameLoki' \ + 'CFBundleExecutableloki' \ + 'CFBundleIdentifierlink.loki.app' \ + 'CFBundleIconFileloki' \ + 'CFBundlePackageTypeAPPL' \ + 'LSMinimumSystemVersion11.0' \ + 'LSUIElement' \ + 'NSHighResolutionCapable' \ + '' > "$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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..9cd15e1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,138 @@ +# 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' \ + '' \ + '' \ + '' \ + 'CFBundleNameLoki' \ + 'CFBundleDisplayNameLoki' \ + 'CFBundleExecutableloki' \ + 'CFBundleIdentifierlink.loki.app' \ + 'CFBundleIconFileloki' \ + 'CFBundlePackageTypeAPPL' \ + "CFBundleShortVersionString$v" \ + "CFBundleVersion$v" \ + 'LSMinimumSystemVersion11.0' \ + 'LSUIElement' \ + 'NSHighResolutionCapable' \ + '' > "$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-[-arm][.exe]. Volontairement lisibles, et + # volontairement DIFFÉRENTS du schéma loki-- 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 diff --git a/.gitignore b/.gitignore index 37b8d49..4244b68 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,15 @@ -# Python -__pycache__/ -*.pyc -.venv/ -venv/ -*.egg-info/ +# Binaires compilés +/loki +/loki.exe +/dist/ -# Node -node_modules/ -frontend/dist/ -.vite/ +# Artefacts de build Go +*.exe +*.test +*.out -# Data & workspace (runtime) -data/*.db -data/*.sqlite3 -workspace/* -!workspace/.gitkeep - -# Env / IDE -.env -.env.local +# OS / éditeurs .DS_Store +Thumbs.db .idea/ .vscode/ - -# TS build cache -frontend/tsconfig.tsbuildinfo diff --git a/Dockerfile b/Dockerfile index 3168cf3..19d5a05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,47 +1,91 @@ -# ── É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 +# ── Loki — image GPU autonome (fork d'AJEAN, https://github.com/nathaninline/ajean) +# +# Trois étapes : +# 1. compilation de llama.cpp avec CUDA (mêmes flags que backend_build.go, +# sauf GGML_NATIVE=OFF : l'image est bâtie sur un runner GitHub, pas sur +# la machine qui l'exécutera — des instructions CPU « natives » du runner +# provoqueraient un Illegal instruction ailleurs) ; +# 2. compilation du binaire Go `loki` (UI embarquée via go:embed) ; +# 3. runtime CUDA léger : llama-server + loki + entrypoint. +# +# Pré-requis d'exécution : NVIDIA Container Toolkit sur l'hôte. +# docker build -t loki --build-arg CUDA_ARCHS=86 . +# CUDA_ARCHS : 75=Turing, 86=Ampere, 89=Ada — restreindre à sa carte divise +# le temps de build et la taille de l'image. -# ── Étape 2 : runtime backend (sert aussi le front statique) ──────────── -FROM python:3.12-slim AS runtime -WORKDIR /app +# ── Étape 1 : llama.cpp CUDA ──────────────────────────────────────────── +FROM nvidia/cuda:12.6.3-devel-ubuntu24.04 AS llamacpp -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 \ - WORKSPACE_DIR=/workspace \ - DATA_DIR=/data \ - PORT=8717 - -# 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 \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + git cmake build-essential ca-certificates \ && rm -rf /var/lib/apt/lists/* -COPY backend/requirements.txt ./ -RUN pip install --no-cache-dir -r requirements.txt mcp-server-fetch +# Épingler LLAMACPP_REF sur un tag (ex. b4991) pour des builds reproductibles. +ARG LLAMACPP_REF=master +ARG CUDA_ARCHS=75;86;89 -COPY backend/ ./backend/ -# Frontend compilé servi en statique par FastAPI -COPY --from=frontend /app/frontend/dist ./backend/static +RUN git clone --depth 1 --branch "${LLAMACPP_REF}" \ + https://github.com/ggml-org/llama.cpp /src/llama.cpp -RUN mkdir -p /workspace /data +RUN cmake -S /src/llama.cpp -B /src/llama.cpp/build \ + -DCMAKE_BUILD_TYPE=Release \ + -DGGML_CUDA=ON \ + -DGGML_CUDA_F16=ON \ + -DGGML_NATIVE=OFF \ + -DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \ + -DLLAMA_CURL=OFF \ + -DLLAMA_BUILD_TESTS=OFF \ + -DLLAMA_BUILD_EXAMPLES=OFF \ + -DLLAMA_BUILD_UI=OFF \ + -DLLAMA_USE_PREBUILT_UI=OFF \ + && cmake --build /src/llama.cpp/build --target llama-server -j"$(nproc)" \ + && mkdir -p /opt/llama.cpp \ + && find /src/llama.cpp/build -name 'llama-server' -type f -exec cp {} /opt/llama.cpp/ \; \ + && find /src/llama.cpp/build -name '*.so*' -exec cp -P {} /opt/llama.cpp/ \; -# Marqueur de version (git sha court), injecté par le workflow de build. +# ── Étape 2 : 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 + +# ── Étape 3 : runtime ─────────────────────────────────────────────────── +FROM nvidia/cuda:12.6.3-runtime-ubuntu24.04 AS runtime + +# Marqueur de build (sha court), injecté par le workflow GitHub. ARG LOKI_VERSION=dev -ENV LOKI_VERSION=${LOKI_VERSION} +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)" -# 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"). +# curl pour le HEALTHCHECK ; git pour les outils de l'agent ; nodejs/npm pour +# les serveurs MCP lancés via npx ; libgomp1 pour llama-server ; tini en PID 1. +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git libgomp1 tini nodejs npm \ + && rm -rf /var/lib/apt/lists/* -EXPOSE 8717 -WORKDIR /app/backend +COPY --from=llamacpp /opt/llama.cpp /opt/llama.cpp +COPY --from=gobuild /out/loki /usr/local/bin/loki +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD curl -fsS "http://localhost:${PORT}/api/health" || exit 1 +ENV LOKI_CONTAINER=1 \ + LOKI_HOME=/data \ + LOKI_MODEL_DIRS=/models \ + LD_LIBRARY_PATH=/opt/llama.cpp \ + NVIDIA_VISIBLE_DEVICES=all \ + NVIDIA_DRIVER_CAPABILITIES=compute,utility -CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT}"] +# 8090 : interface web (seule à exposer). Le moteur (8080) reste interne au +# conteneur — il n'est pas authentifié par défaut. +EXPOSE 8090 + +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 +ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..bea6060 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +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. diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..47da96d --- /dev/null +++ b/NOTICE.md @@ -0,0 +1,27 @@ +# 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. + +## 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. diff --git a/README.md b/README.md index 5aa5909..367eece 100644 --- a/README.md +++ b/README.md @@ -1,309 +1,121 @@ -# Loki — Agent IA local sur Ollama +# Loki — assistant IA local en conteneur (fork d'AJEAN) -Loki est un atelier d'agent IA **100 % local**, conçu pour se connecter à -[Ollama](https://ollama.com) et travailler en mode agentique : un tchat, des -outils (lecture/écriture de fichiers, aperçu HTML en direct…) et un workspace -de fichiers. Pensé pour un déploiement **Docker** simple. +> **Loki est un fork de [AJEAN](https://github.com/nathaninline/ajean)** de +> [nathaninline](https://github.com/nathaninline), sous licence MIT — voir +> [`NOTICE.md`](NOTICE.md) et [`LICENSE`](LICENSE). L'essentiel du code et des +> fonctionnalités vient d'AJEAN ; ce fork le rebaptise et le fait tourner dans +> **un conteneur Docker GPU autonome**, là où l'amont s'installe en binaire + +> systemd sur la machine hôte. -Le thème visuel (« atelier café », sombre et chaleureux, accent ambre) est -décliné fidèlement depuis la maquette d'origine. +Loki fait tourner un modèle de langage **100 % en local** : tchat, mémoire +persistante, accès internet, outils MCP, agent (shell, fichiers), accès distant +chiffré — serveur d'inférence llama.cpp compris, dans une seule image. ## Architecture ``` -Frontend (React + Vite + TS + Tailwind) - │ HTTP + SSE -Backend (FastAPI, Python) - ├── /api/status, /api/models, /api/models/pull (Ollama) - ├── /api/sessions (CRUD sessions) - ├── /api/chat (boucle agentique + outils, streaming SSE) - ├── /api/files (arborescence + contenu du workspace) - │ Outils agent : read_file · write_file · list_dir (confinés au workspace) - │ httpx │ volume - Ollama (:11434) /workspace + /data (SQLite) +┌────────────────── conteneur loki ──────────────────┐ +│ loki web (UI + API, port 8090, premier plan) │ +│ │ pilote (fichier PID — pas de systemd) │ +│ loki serve ──exec──► llama-server (CUDA, :8080) │ +│ ▲ modèles .gguf │ +│ /data (config, bbolt, mémoire, workspace) │ +│ /models (GGUF déposés à la main) │ +└────────────────────────────────────────────────────┘ ``` -Un seul conteneur : le backend FastAPI sert l'API **et** le frontend compilé. -Loki se connecte à un **Ollama existant** via `OLLAMA_HOST`. +- **Un seul conteneur** : l'UI joint le moteur sur `localhost` (contrainte + héritée de l'amont), les deux partagent donc le même conteneur. +- **Sans systemd** : l'amont pilote le moteur via systemctl ; en conteneur, + Loki bascule automatiquement sur une supervision par fichier PID + (`internal/loki/sys_service_container.go`). Changer de modèle depuis l'UI + redémarre le moteur normalement. +- Le moteur (port 8080, non authentifié par défaut) **n'est pas exposé** ; + seule l'UI (8090) l'est. -## Démarrage rapide (Docker) +## Démarrage rapide (Docker, GPU NVIDIA) + +Pré-requis : pilote NVIDIA + [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). ```bash -cp .env.example .env # ajuste OLLAMA_HOST si besoin -docker compose up --build +cp .env.example .env # CUDA_ARCHS=86 pour une RTX 30xx, etc. +docker compose up --build # 30-45 min : compilation CUDA de llama.cpp ``` -Application disponible sur http://localhost:8717 - -> **Ollama** : par défaut Loki vise `http://host.docker.internal:11434` -> (un Ollama installé sur la machine hôte). Pour embarquer Ollama dans la -> stack : `docker compose --profile ollama up` puis règle -> `OLLAMA_HOST=http://ollama:11434`. +Interface : http://localhost:8090 — télécharge un modèle depuis le catalogue +intégré (onglet modèles), il démarre tout seul. ## Installation sur Unraid -L'image est **construite et publiée automatiquement par GitHub Actions** sur GHCR -(`ghcr.io/r0m1k3/loki:latest`) à chaque push sur `main`. Aucun build ni `git` -n'est nécessaire sur Unraid — un simple `pull`. Compose prêt à l'emploi : -[`docker-compose.unraid.yml`](docker-compose.unraid.yml). +L'image est construite et publiée par GitHub Actions sur GHCR +(`ghcr.io/r0m1k3/loki:latest`) à chaque push sur `main` — aucun build sur +Unraid. Compose prêt à l'emploi : [`docker-compose.unraid.yml`](docker-compose.unraid.yml). -1. Dans le terminal Unraid, crée les dossiers de données : +1. Installe le plugin **Nvidia Driver** (Apps) et vérifie `nvidia-smi`. +2. Crée les dossiers : ```bash - mkdir -p /mnt/user/appdata/loki/workspace /mnt/user/appdata/loki/data + mkdir -p /mnt/user/appdata/loki/data /mnt/user/appdata/loki/models ``` -2. Installe le plugin **Compose Manager** (Apps), crée une nouvelle stack, et - colle le contenu de `docker-compose.unraid.yml`. -3. **Adapte `OLLAMA_HOST`** : mets l'IP de ton serveur Unraid où tourne le - conteneur Ollama, p. ex. `http://192.168.1.10:11434`. -4. **Compose Up**. Loki est accessible sur `http://:8717` - (change le port à gauche du mapping `8717:8080` s'il est déjà pris). +3. Plugin **Compose Manager** → nouvelle stack → colle + `docker-compose.unraid.yml` → **Compose Up**. +4. Interface : `http://:8090`. -> La première publication de l'image prend quelques minutes (le temps que le -> workflow GitHub se termine). Si l'image est privée, rends le package **public** -> une fois (GitHub → Packages → `loki` → Package settings → Change visibility). -> -> Les modèles déjà présents dans ton Ollama sont **détectés automatiquement** ; -> tu peux en télécharger d'autres depuis l'onglet Configuration. -> Mise à jour : Compose Down/Up (avec pull), ou `docker compose pull`. +## Configuration -## Développement (sans Docker) +Tout se règle **dans l'UI** (modèle, contexte, presets…) et survit aux +redémarrages (volume `/data`). Variables d'environnement du conteneur : + +| Variable | Rôle | Défaut | +|---|---|---| +| `LOKI_WEB_PORT` | port de l'UI | `8090` | +| `LOKI_MODEL` | modèle initial (semé au 1er boot seulement) | — | +| `LOKI_CTX` | taille de contexte initiale | `32768` | +| `LOKI_NGL` | couches GPU initiales | `999` (tout) | +| `LOKI_HOME` | données (volume) | `/data` | +| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` | + +En CLI dans le conteneur : `docker exec -it loki loki status` (aussi : +`logs`, `restart`, `config`, `bench`, `test`…). + +## Fonctionnalités (héritées d'AJEAN) + +- **Tchat** avec streaming, raisonnement visible, pièces jointes, vision + (selon modèle), export de conversations. +- **Mémoire persistante** (`memory off|ondemand|always`). +- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou + [Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS. +- **Agent** : shell, fichiers, workspace (`agent on`). +- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`. +- **Presets** de configuration par modèle, bench, auto-détection GPU. +- **Accès distant chiffré** via le relais [ajean.link](https://ajean.link) + (service opéré par l'auteur de l'amont). +- **API OpenAI-compatible** exposable (`network on`, protégée par clé). + +## Différences avec l'amont + +| | AJEAN (amont) | Loki (ce fork) | +|---|---|---| +| Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` | +| Moteur llama.cpp | compilé sur la machine (`ajean llamacpp install`) | précompilé CUDA dans l'image | +| Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) | +| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` | +| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` | + +Le reste — UI, mémoire, outils, protocole — est celui d'AJEAN. Pour récupérer +les évolutions de l'amont : -**Backend** ```bash -cd backend -pip install -r requirements.txt -OLLAMA_HOST=http://localhost:11434 uvicorn app.main:app --reload --port 8080 +git fetch upstream && git merge upstream/main # conflits de renommage à arbitrer ``` -**Frontend** (proxy `/api` → `:8080`) -```bash -cd frontend -npm install -npm run dev # http://localhost:5173 -``` +## Build sans GPU / autres accélérateurs -## Configuration (variables d'environnement) +L'image par défaut cible CUDA. Pour un essai CPU, remplace dans le +`Dockerfile` les bases `nvidia/cuda:*` par `ubuntu:24.04` et retire les flags +`-DGGML_CUDA=*` (llama.cpp bascule en CPU). Vulkan/ROCm : adapter les flags +comme le fait l'amont (`internal/loki/backend_build.go`). -| Variable | Défaut | Rôle | -| --------------- | ----------------------------------- | ----------------------------- | -| `OLLAMA_HOST` | `http://host.docker.internal:11434` | URL de l'instance Ollama | -| `DEFAULT_MODEL` | `gemma4:12b` | Modèle sélectionné au démarrage | -| `WORKSPACE_DIR` | `/workspace` | Dossier de travail de l'agent | -| `DATA_DIR` | `/data` | Base SQLite (sessions + config) | -| `PORT` | `8717` | Port de l'application (dedans = dehors) | -| `SEARX_URL` | *(vide)* | Instance SearxNG pour `web_search` (sinon DuckDuckGo) | +## Licence -## Utilisation - -1. Vérifie la pastille **Ollama** (verte = connecté) en haut à droite, et choisis - un modèle qui supporte le *function calling* (profil fourni : `gemma4:12b`). -2. Décris une tâche dans le tchat, p. ex. *« Crée une landing page pour un café - nommé Café Lumière, avec menu et horaires »*. -3. L'agent lit/écrit des fichiers dans le **workspace** ; chaque appel d'outil - s'affiche dans le fil, et l'aperçu HTML apparaît à droite (onglets **Aperçu / - Code / Logs**). -4. Règle le comportement dans **Configuration** (modèle, température/top-p/top-k, - jetons max, outils actifs, invite système). - -## Outils de l'agent - -| Outil | Rôle | Par défaut | -| ------------- | -------------------------------------- | ---------- | -| `read_file` | Lire un fichier du workspace | activé | -| `write_file` | Créer / modifier un fichier | activé | -| `list_dir` | Lister un répertoire | activé | -| `web_search` | Recherche web (DuckDuckGo / SearxNG) | désactivé | -| `run_shell` | Exécuter une commande **(sensible)** | désactivé | - -## Préchargement des modèles (réponses instantanées) - -Ollama décharge un modèle de la VRAM après quelques minutes d'inactivité : le -message suivant paie alors un rechargement complet (lent). Loki évite ça : - -- **Préchargement à la sélection** : choisir un modèle le charge immédiatement - en VRAM (`/api/models/warm`). -- **Maintien au chaud** : chaque requête envoie un `keep_alive` (défaut 30 min, - réglable dans Configuration → Intelligence : de « décharger aussitôt » à - « toujours »). -- **Préchargement au démarrage** du modèle par défaut (en arrière-plan). -- **Indicateur d'état** : la pastille du sélecteur de modèle est verte quand le - modèle est chargé sur GPU, orange sur CPU, blanche s'il reste à charger. - -## Performance Ollama (recommandé) - -Réglages **côté serveur Ollama** qui rendent Loki nettement plus fluide -(variables d'environnement du service/conteneur Ollama, redémarrage requis) : - -| Variable | Valeur conseillée | Effet | -| -------- | ----------------- | ----- | -| `OLLAMA_KEEP_ALIVE` | `30m` (ou `-1`) | Durée de rétention par défaut d'un modèle en VRAM. `-1` = jamais déchargé (machine dédiée). Doit être ≥ au `keep_alive` configuré dans Loki. | -| `OLLAMA_MAX_LOADED_MODELS` | `2` | Autorise le modèle de chat **et** le modèle d'embedding (RAG) à résider ensemble en VRAM — supprime les allers-retours de chargement à chaque message. | -| `OLLAMA_NUM_PARALLEL` | `1` | Loki est mono-utilisateur ; chaque slot parallèle multiplie la VRAM du cache KV. | -| `OLLAMA_FLASH_ATTENTION` | `1` | Attention plus rapide et plus sobre en VRAM. | -| `OLLAMA_KV_CACHE_TYPE` | `q8_0` | Cache KV quantifié : ~moitié de VRAM en moins, marge pour un 12B sur 12 Go. | - -Exemple pour un Ollama en Docker (service `ollama` du compose) : - -```yaml - ollama: - image: ollama/ollama - environment: - - OLLAMA_KEEP_ALIVE=30m - - OLLAMA_MAX_LOADED_MODELS=2 - - OLLAMA_NUM_PARALLEL=1 - - OLLAMA_FLASH_ATTENTION=1 - - OLLAMA_KV_CACHE_TYPE=q8_0 -``` - -Côté Loki, tout est déjà optimisé : pool HTTP keep-alive partagé vers Ollama, -options runner identiques sur tous les appels (pas de rechargement du modèle en -plein message), `keep_alive` systématique (chat **et** embeddings), préparation -du contexte (RAG + plan) en parallèle après l'ouverture du flux, et caches -courts (`/api/tags`, stats GPU). - -## Modes d'exécution (Plan / Build / Yolo) - -Un sélecteur dans le composer contrôle le niveau d'autonomie de l'agent : -- **Plan** 🔍 — lecture seule (`read_file`, `list_dir`, `grep_search`) : l'agent - analyse et propose sans jamais modifier de fichier ni exécuter de commande. -- **Build** 🔨 — normal : écrit les fichiers, `run_shell` demande confirmation. -- **Yolo** ⚡ — autonomie maximale : approuve tout, y compris le shell. - -## Panneau Git & Diff - -Le workspace étant un dépôt git (chaque action de l'agent = un commit), l'onglet -**Git** du panneau de droite montre l'historique des commits, le **diff coloré** -de chacun, et un bouton **↶ Annuler** (revert) pour défaire une modification en -un clic. - -## 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. - -## Intelligence augmentée - -- **Plan-puis-exécute** : les demandes complexes sont décomposées en 3-5 étapes - affichées dans le fil ; l'agent (ou le moteur code) suit le plan. -- **Auto-critique « Qualité + »** (Configuration → Intelligence) : la réponse - est relue et révisée avant d'être finalisée. -- **Mémoire long-terme (RAG)** : chaque échange est vectorisé (`/api/embed`) - et les souvenirs pertinents des anciennes sessions sont réinjectés en - contexte. Nécessite un modèle d'embedding installé (ex. - `ollama pull nomic-embed-text`) — sinon désactivé silencieusement. -- **Vérification HTML** : liens locaux cassés et balises déséquilibrées sont - détectés après chaque génération ; le moteur code fait une passe - d'auto-correction. -- **Benchmark intégré** (Configuration → Benchmark) : 5 mini-épreuves notées - /100 (appel d'outil, code exécutable, consignes, extraction JSON, format) - pour comparer objectivement tes modèles installés. - -## Serveurs MCP (outils professionnels) - -Configuration → **Serveurs MCP** : catalogue préconfiguré, tout désactivé par -défaut (un serveur inactif ne coûte rien — aucun process, aucun outil dans le -prompt du modèle). - -| Serveur | Apport | -| ------- | ------ | -| Playwright | l'agent pilote un vrai navigateur : teste ses pages, lit la console | -| Context7 | documentation à jour de n'importe quelle librairie | -| Fetch | lecture propre d'URL (markdown) | -| SearxNG | vraie recherche web (URL d'instance requise) | -| Personnalisé | n'importe quel serveur MCP (commande stdio ou URL) | - -Chaque carte a un bouton **Tester** (connexion d'essai + liste des outils -découverts). Un serveur en panne n'interrompt jamais le chat : notice dans le -fil, nouvelle tentative au message suivant. - -## Skills automatiques - -Cinq méthodes expertes (débogage systématique, création web, refactor sûr, -analyse de données, rédaction structurée) sont injectées automatiquement selon -la tâche détectée — sélection lexicale instantanée, une seule à la fois, badge -« 📘 Méthode : … » dans le fil. Désactivable dans Configuration → Intelligence. - -## Tirer le meilleur des petits modèles - -Loki est conçu pour qu'un modèle local modeste se comporte comme un bon agent : - -- **Mémoire compressée** : au-delà d'un seuil, les anciens tours sont résumés - en arrière-plan et le modèle ne reçoit que « invite + résumé + 10 derniers - messages ». Contexte court = modèle concentré, et qui reste sur le GPU. -- **Outils chirurgicaux** : `edit_file` (recherche/remplacement exact — pas de - réécriture intégrale ratée), `grep_search` (trouver avant de modifier), et - `write_file` par morceaux (overwrite/append). -- **Auto-vérification** : après chaque écriture, la syntaxe (`.py`, `.json`) - est contrôlée ; l'erreur est renvoyée immédiatement au modèle, qui se - corrige dans le même tour. -- **Bon modèle au bon poste** : les tâches de code sont confiées au meilleur - modèle code installé (`qwen-coder`, `deepseek-coder`…), automatiquement - (config `code_model: auto`), même si tu discutes avec un généraliste. -- **Récupération d'appels d'outils malformés** : arguments réparés ou - redemandés, modèles sans function-calling détectés et gérés. - -## Moteur code (façon Claude Code) — routage automatique - -Loki embarque [Aider](https://aider.chat) (Apache-2.0, version figée) comme -**moteur code** : édition multi-fichiers fiable (formats diff/search-replace, -efficaces même avec de petits modèles), repo map, et **commits git -automatiques** dans le workspace. - -**C'est invisible** : un routeur classe chaque message. -- Tâche de code détectée (heuristique lexicale instantanée) → le message part - au **moteur code** ; le fil affiche la carte `code_task`, les fichiers - modifiés et le commit. -- Sinon → **boucle agent** classique ; et l'agent peut lui-même déléguer au - moteur via l'outil `code_task` quand il juge qu'il faut coder. - -Désactivable dans Configuration → Outils (`code_task`). Le workspace est -auto-initialisé en dépôt git : chaque modification de code = un commit -(historique et rollback via `git log` / `git revert` dans le workspace). - -## Profil GPU fourni - -Loki est préconfiguré pour une **RTX 3060 12 Go** avec `gemma4:12b` en Q4 : -contexte 8192, sortie 4096 jetons, batch 256, GPU principal 0 et les 49 couches -du modèle sur le GPU. Les paramètres de génération et de cache/contexte sont -enregistrés séparément pour chaque modèle. - -La quantification du cache KV reste globale dans Ollama. Pour économiser environ -la moitié de sa VRAM, démarre Ollama avec `OLLAMA_FLASH_ATTENTION=1` et -`OLLAMA_KV_CACHE_TYPE=q8_0`. Un redémarrage d'Ollama est requis. Le conteneur -doit également exposer le GPU (`--gpus=all`) ; Loki ne peut pas contourner une -configuration Docker sans accès CUDA. - -## Sécurité - -- **Confinement** : toutes les opérations fichier (`read_file`, `write_file`, - `list_dir`) et `run_shell` sont strictement confinées au `WORKSPACE_DIR`. Toute - tentative de sortie (`../`, chemin absolu) est rejetée. -- **`run_shell`** est désactivé par défaut. Une fois activé, chaque commande - proposée par l'agent demande une **validation explicite** dans l'interface - (option *confirm_shell*, activée par défaut) avant exécution. -- Le conteneur tourne en **utilisateur non-root** et expose un **HEALTHCHECK**. -- Loki est conçu pour un usage **local** : n'expose pas le port publiquement sans - ajouter ta propre couche d'authentification. - -## Feuille de route - -- [x] **Phase 1** — Socle + design system fidèle au thème, layout 3 panneaux -- [x] **Phase 2** — Connexion Ollama : statut, liste des modèles, pull avec progression, sélecteur -- [x] **Phase 3** — Chat streaming (SSE) + persistance des sessions (SQLite) -- [x] **Phase 4** — Boucle agentique & outils fichiers (read/write/list), confinés au workspace, rendu des appels d'outils dans le fil -- [x] **Phase 5** — Aperçu HTML live + onglets Code/Logs + arborescence du workspace -- [x] **Phase 6** — Configuration complète (génération, toggles d'outils, invite système) -- [x] **Phase 7** — Outils avancés : `web_search` (DuckDuckGo/SearxNG) et `run_shell` avec validation utilisateur -- [x] **Phase 8** — Durcissement & documentation Docker - -## Structure - -``` -backend/ FastAPI : routes Ollama, client httpx, config -frontend/ React : design system (tailwind.config), panneaux, store Zustand -workspace/ fichiers créés par l'agent (monté en volume) -data/ base SQLite (monté en volume) -``` +MIT — © les contributeurs d'AJEAN (« Jean contributors ») pour le code amont, +voir [`LICENSE`](LICENSE) et [`NOTICE.md`](NOTICE.md). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md new file mode 100644 index 0000000..0faa042 --- /dev/null +++ b/RELEASE_NOTES.md @@ -0,0 +1,19 @@ +La grande nouveauté de cette version : AJEAN sait enfin voir les images. + +## La vision, configurable par preset + +Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul. + +Le champ ne liste que les projecteurs (les fichiers dont le nom contient `mmproj`), pas les modèles de plusieurs Go, pour que le choix reste lisible. Et s'il te manque un projecteur, le champ « télécharger un modèle » accepte aussi un lien vers un `mmproj` : une fois récupéré, il se place directement dans le champ Vision. + +## Les images arrivent vraiment au modèle + +Avant, une image collée dans le chat était simplement déposée comme un fichier dans le dossier de travail, à charge pour le modèle de l'ouvrir avec ses outils (ce qui ne donnait qu'un tas d'octets illisibles). Désormais, quand un projecteur est configuré, l'image part au modèle en contenu multimodal : il la voit, et peut en parler. + +## Mise à jour + +``` +ajean update +``` + +Non vérifié sur cette version : le résultat final dépend d'un modèle vision et de son projecteur compatibles (Qwen2.5-VL, Gemma 3, etc.). À noter, l'image reste dans l'historique de la conversation et repart au moteur à chaque tour tant que la conversation dure. diff --git a/backend/app/__init__.py b/backend/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/agent.py b/backend/app/agent.py deleted file mode 100644 index c456896..0000000 --- a/backend/app/agent.py +++ /dev/null @@ -1,517 +0,0 @@ -"""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(), - } diff --git a/backend/app/agent_config.py b/backend/app/agent_config.py deleted file mode 100644 index 5c40307..0000000 --- a/backend/app/agent_config.py +++ /dev/null @@ -1,309 +0,0 @@ -"""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 diff --git a/backend/app/autotune.py b/backend/app/autotune.py deleted file mode 100644 index c89e7b3..0000000 --- a/backend/app/autotune.py +++ /dev/null @@ -1,267 +0,0 @@ -"""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 diff --git a/backend/app/bench.py b/backend/app/bench.py deleted file mode 100644 index c0f658d..0000000 --- a/backend/app/bench.py +++ /dev/null @@ -1,222 +0,0 @@ -"""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 {} diff --git a/backend/app/coder.py b/backend/app/coder.py deleted file mode 100644 index d8d70a0..0000000 --- a/backend/app/coder.py +++ /dev/null @@ -1,160 +0,0 @@ -"""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, - } diff --git a/backend/app/config.py b/backend/app/config.py deleted file mode 100644 index 2314578..0000000 --- a/backend/app/config.py +++ /dev/null @@ -1,26 +0,0 @@ -"""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() diff --git a/backend/app/db.py b/backend/app/db.py deleted file mode 100644 index c0afdcd..0000000 --- a/backend/app/db.py +++ /dev/null @@ -1,208 +0,0 @@ -"""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), - ) diff --git a/backend/app/enhance.py b/backend/app/enhance.py deleted file mode 100644 index f3ba615..0000000 --- a/backend/app/enhance.py +++ /dev/null @@ -1,169 +0,0 @@ -"""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 diff --git a/backend/app/main.py b/backend/app/main.py deleted file mode 100644 index fc416ab..0000000 --- a/backend/app/main.py +++ /dev/null @@ -1,122 +0,0 @@ -"""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")) diff --git a/backend/app/mcp_client.py b/backend/app/mcp_client.py deleted file mode 100644 index 15b96bf..0000000 --- a/backend/app/mcp_client.py +++ /dev/null @@ -1,341 +0,0 @@ -"""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__ (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() diff --git a/backend/app/memory.py b/backend/app/memory.py deleted file mode 100644 index 8daa08c..0000000 --- a/backend/app/memory.py +++ /dev/null @@ -1,110 +0,0 @@ -"""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) diff --git a/backend/app/memory_notes.py b/backend/app/memory_notes.py deleted file mode 100644 index f836dd1..0000000 --- a/backend/app/memory_notes.py +++ /dev/null @@ -1,154 +0,0 @@ -"""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 - ) diff --git a/backend/app/ollama_client.py b/backend/app/ollama_client.py deleted file mode 100644 index f7b7135..0000000 --- a/backend/app/ollama_client.py +++ /dev/null @@ -1,251 +0,0 @@ -"""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() diff --git a/backend/app/rag.py b/backend/app/rag.py deleted file mode 100644 index 30f5046..0000000 --- a/backend/app/rag.py +++ /dev/null @@ -1,155 +0,0 @@ -"""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 [] diff --git a/backend/app/router.py b/backend/app/router.py deleted file mode 100644 index 9968dda..0000000 --- a/backend/app/router.py +++ /dev/null @@ -1,69 +0,0 @@ -"""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 diff --git a/backend/app/routes/__init__.py b/backend/app/routes/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/routes/benchmark.py b/backend/app/routes/benchmark.py deleted file mode 100644 index bd00614..0000000 --- a/backend/app/routes/benchmark.py +++ /dev/null @@ -1,42 +0,0 @@ -"""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"}, - ) diff --git a/backend/app/routes/chat.py b/backend/app/routes/chat.py deleted file mode 100644 index 0233b99..0000000 --- a/backend/app/routes/chat.py +++ /dev/null @@ -1,746 +0,0 @@ -"""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", - }, - ) diff --git a/backend/app/routes/config.py b/backend/app/routes/config.py deleted file mode 100644 index 999f94d..0000000 --- a/backend/app/routes/config.py +++ /dev/null @@ -1,86 +0,0 @@ -"""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)} diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py deleted file mode 100644 index 467833b..0000000 --- a/backend/app/routes/files.py +++ /dev/null @@ -1,105 +0,0 @@ -"""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/`` (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} diff --git a/backend/app/routes/git.py b/backend/app/routes/git.py deleted file mode 100644 index 80feba2..0000000 --- a/backend/app/routes/git.py +++ /dev/null @@ -1,106 +0,0 @@ -"""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é"} diff --git a/backend/app/routes/mcp.py b/backend/app/routes/mcp.py deleted file mode 100644 index 5681766..0000000 --- a/backend/app/routes/mcp.py +++ /dev/null @@ -1,54 +0,0 @@ -"""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) diff --git a/backend/app/routes/models.py b/backend/app/routes/models.py deleted file mode 100644 index c9f0ce4..0000000 --- a/backend/app/routes/models.py +++ /dev/null @@ -1,197 +0,0 @@ -"""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") diff --git a/backend/app/routes/projects.py b/backend/app/routes/projects.py deleted file mode 100644 index aae47e5..0000000 --- a/backend/app/routes/projects.py +++ /dev/null @@ -1,60 +0,0 @@ -"""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} diff --git a/backend/app/routes/sessions.py b/backend/app/routes/sessions.py deleted file mode 100644 index 99fa1f3..0000000 --- a/backend/app/routes/sessions.py +++ /dev/null @@ -1,60 +0,0 @@ -"""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} diff --git a/backend/app/routes/shell.py b/backend/app/routes/shell.py deleted file mode 100644 index 8aeb902..0000000 --- a/backend/app/routes/shell.py +++ /dev/null @@ -1,33 +0,0 @@ -"""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"], - } diff --git a/backend/app/routes/system.py b/backend/app/routes/system.py deleted file mode 100644 index e932b75..0000000 --- a/backend/app/routes/system.py +++ /dev/null @@ -1,232 +0,0 @@ -"""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." - ), - } diff --git a/backend/app/skills.py b/backend/app/skills.py deleted file mode 100644 index 4867bf3..0000000 --- a/backend/app/skills.py +++ /dev/null @@ -1,117 +0,0 @@ -"""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 + + + +
+
+
+

+ … +
+ + + + + + +
Machine +
+
…
+
+
+
+
Config active +
…
+
+
Presets +
+
+
Mode agent +
+ +
+
Mémoire?
+
+ +
+ Voir la mémoire + +
+ +
+
+
+
Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep. Le moteur intégré ne demande aucune installation. Le moteur Crawl4AI exige un serveur que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest) mais exécute le JavaScript des pages. Actifs seulement si le mode agent est actif.
+
+ + +
+
+ +
+ + +
+
+
+
+
+
Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
+
+
+ +
(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)
+ +
+
+
+
+
Paramètres
+
+ +
+
+
+
Apparence +
+ +
+
Affichage
+
+ + + + + +
+
+
System prompt +
+ +
+
+
Accès OpenAI +
Endpoint compatible OpenAI?Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
+ +
+
+
Adresse locale (réseau) — modèle loki
+
+ + +
+
+ + + + + +
+
Clé API?Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé redémarre le service.
+
+
+ + + +
+
+ + + +
+
+
+
Accès distant +
ajean.link?Accédez à ce serveur Loki depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
+ +
+

Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.

+ +
+ + +
+
Moteur + +
+ +
+
+
llama.cpp précompilé
+
binaire officiel téléchargé tel quel, prêt en ~2 min
+
+
+
+
llama.cpp compilé
+
compilé ici pour votre machine — plus rapide, plus long à installer
+
+
+
+
llama.cpp personnalisé
+
un fork de llama.cpp pour modèles spéciaux (quant ternaire…)
+
+
+
+ +
+ +
Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
+
+
+ + + +
+
+
+
Actions +
+ +
+ + +
+
+ +
+ + +
+
+
+
+
+
+ +
chargement de la conversation…
+ + + + + +
+
+
+ + + + 0 / 0 + + + + + + +
+ +
+
+ + + +
+
Entrée pour envoyer · Maj+Entrée = nouvelle ligne
+
+
+
+ + + + + + + + + + + diff --git a/internal/loki/ui/marked.min.js b/internal/loki/ui/marked.min.js new file mode 100644 index 0000000..a91afe7 --- /dev/null +++ b/internal/loki/ui/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v12.0.2 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=t[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1");e=x(e.replace(/^ *>[ \t]?/gm,""),"\n");const n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=d(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(const e of n)i.header.push({text:e,tokens:this.lexer.inline(e)});for(const e of r)i.rows.push(d(e,i.header.length).map((e=>({text:e,tokens:this.lexer.inline(e)}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=x(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),b(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return b(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,y=/(?:[*+-]|\d{1,9}[.)])/,$=k(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,y).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),z=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,T=/(?!\s*\])(?:\\.|[^\[\]\\])+/,R=k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",T).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,y).getRegex(),A="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,I=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",S).replace("tag",A).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),E=k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),q={blockquote:k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",E).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:R,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:m,html:I,lheading:$,list:_,newline:/^(?: *(?:\n|$))+/,paragraph:E,table:f,text:/^[^\n]+/},Z=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),L={...q,table:Z,paragraph:k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Z).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex()},P={...q,html:k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:f,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(z).replace("hr",m).replace("heading"," *#{1,6} *[^\n]").replace("lheading",$).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Q=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v=/^( {2,}|\\)\n(?!\s*$)/,B="\\p{P}\\p{S}",C=k(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,B).getRegex(),M=k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,B).getRegex(),O=k("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,B).getRegex(),D=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,B).getRegex(),j=k(/\\([punct])/,"gu").replace(/punct/g,B).getRegex(),H=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),U=k(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),X=k("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",U).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),F=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,N=k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",F).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),G=k(/^!?\[(label)\]\[(ref)\]/).replace("label",F).replace("ref",T).getRegex(),J=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",T).getRegex(),K={_backpedal:f,anyPunctuation:j,autolink:H,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:v,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:f,emStrongLDelim:M,emStrongRDelimAst:O,emStrongRDelimUnd:D,escape:Q,link:N,nolink:J,punctuation:C,reflink:G,reflinkSearch:k("reflink|nolink(?!\\()","g").replace("reflink",G).replace("nolink",J).getRegex(),tag:X,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class se{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='
    ",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new w(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); diff --git a/internal/loki/ui/src/index.tmpl.html b/internal/loki/ui/src/index.tmpl.html new file mode 100644 index 0000000..64db94f --- /dev/null +++ b/internal/loki/ui/src/index.tmpl.html @@ -0,0 +1,813 @@ + + + + + + + + + +Loki + + + + + + + + + + + +
    +
    +
    +

    + … +
    + + + + + + +
    Machine +
    +
    …
    +
    +
    +
    +
    Config active +
    …
    +
    +
    Presets +
    +
    +
    Mode agent +
    + +
    +
    Mémoire?
    +
    + +
    + Voir la mémoire + +
    + +
    +
    +
    +
    Accès internet?Ajoute les outils web_search / web_open / web_read / web_grep. Le moteur intégré ne demande aucune installation. Le moteur Crawl4AI exige un serveur que tu héberges toi-même (ex. docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest) mais exécute le JavaScript des pages. Actifs seulement si le mode agent est actif.
    +
    + + +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    Serveurs MCP?Connecte des serveurs MCP (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… stdio = programme lancé localement (npx/uvx/binaire) · http = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.
    +
    +
    + +
    (aucun serveur — ajoute un serveur fichiers, une base de données, une API…)
    + +
    +
    +
    +
    +
    Paramètres
    +
    + +
    +
    +
    +
    Apparence +
    + +
    +
    Affichage
    +
    + + + + + +
    +
    +
    System prompt +
    + +
    +
    +
    Accès OpenAI +
    Endpoint compatible OpenAI?Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor…) sur ces adresses. Si une clé est définie, le client doit envoyer Authorization: Bearer <clé>.
    + +
    +
    +
    Adresse locale (réseau) — modèle loki
    +
    + + +
    +
    + + + + + +
    +
    Clé API?Protège l'endpoint des complétions (llama-server). Indispensable avant d'exposer le serveur sur internet. Modifier la clé redémarre le service.
    +
    +
    + + + +
    +
    + + + +
    +
    +
    +
    Accès distant +
    ajean.link?Accédez à ce serveur Loki depuis n'importe où, sans ouvrir de port, à travers le relais ajean.link. Le trafic est chiffré de bout en bout : le relais ne voit rien. Abonnement 4,80 €/mois.
    + +
    +

    Ouvrez l'accès distant à ce serveur en quelques clics, sans terminal.

    + +
    + + +
    +
    Moteur + +
    + +
    +
    +
    llama.cpp précompilé
    +
    binaire officiel téléchargé tel quel, prêt en ~2 min
    +
    +
    +
    +
    llama.cpp compilé
    +
    compilé ici pour votre machine — plus rapide, plus long à installer
    +
    +
    +
    +
    llama.cpp personnalisé
    +
    un fork de llama.cpp pour modèles spéciaux (quant ternaire…)
    +
    +
    +
    + +
    + +
    Service du moteur?Démarre, redémarre ou arrête llama-server — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).
    +
    +
    + + + +
    +
    +
    +
    Actions +
    + +
    + + +
    +
    + +
    + + +
    +
    +
    +
    +
    +
    + +
    chargement de la conversation…
    + + + + + +
    +
    +
    + + + + 0 / 0 + + + + + + +
    + +
    +
    + + + +
    +
    Entrée pour envoyer · Maj+Entrée = nouvelle ligne
    +
    +
    +
    + + + + + + + + + + + diff --git a/internal/loki/ui/src/js/00-md.js b/internal/loki/ui/src/js/00-md.js new file mode 100644 index 0000000..5d738c5 --- /dev/null +++ b/internal/loki/ui/src/js/00-md.js @@ -0,0 +1,68 @@ +// marked: GFM on, no auto-breaks (single newlines stay inline). +marked.setOptions({ gfm: true, breaks: false }); +// Pre-process to fix common LLM markdown mistakes before handing to marked: +// - code fences glued to preceding text on the same line ("foo ```ruby ...") +// - fences without a closing newline +// - 3+ consecutive blank lines collapsed to one (LLMs love padding) +function fixMd(s){ + if(!s) return ''; + // Force a newline before a fence that's been glued to preceding text + // (common LLM mistake: "...crée le fichier et```ruby"). We do NOT split + // *after* the fence — the part after the fence is the language identifier. + s = s.replace(/([^\n])(\s*)(```+|~~~+)(?=\w*\s*\n)/g, '$1\n$3'); + // Collapse runs of blank lines that LLMs love to emit. + s = s.replace(/\n{3,}/g, '\n\n'); + return s; +} +function md(src){ return src ? marked.parse(fixMd(src)) : ''; } +// Les garde-fous du serveur ("[stop: trop d'appels d'outils]") sont concaténés +// au texte de la réponse : ils arrivent donc en clair, au milieu du markdown. +// markNotices les sort du fil pour qu'on ne les prenne pas pour une phrase du +// modèle. En cours de streaming, la parenthèse fermante manque encore → aucune +// correspondance, donc pas d'encart qui clignote à chaque token. +const NOTICE_RE=/\[stop\s*:\s*([^\]]+)\]/g; +function markNotices(root){ + const walk=document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + const hits=[]; + while(walk.nextNode()){ + const n=walk.currentNode; + // Un exemple dans un bloc de code reste du code. + if(n.parentElement && n.parentElement.closest('pre,code')) continue; + NOTICE_RE.lastIndex=0; + if(NOTICE_RE.test(n.nodeValue)) hits.push(n); + } + hits.forEach(n=>{ + const frag=document.createDocumentFragment(); + let rest=n.nodeValue, m; + NOTICE_RE.lastIndex=0; + while((m=NOTICE_RE.exec(rest))!==null){ + if(m.index) frag.appendChild(document.createTextNode(rest.slice(0,m.index))); + const tag=document.createElement('span'); + tag.className='stopnote'; tag.textContent=m[1].trim(); + frag.appendChild(tag); + rest=rest.slice(m.index+m[0].length); + NOTICE_RE.lastIndex=0; + } + if(rest) frag.appendChild(document.createTextNode(rest)); + n.parentNode.replaceChild(frag, n); + }); +} +let msgs = []; +let busy = false; +function toggleSide(){ document.getElementById('side').classList.toggle('open'); document.getElementById('backdrop').classList.toggle('open'); document.body.classList.toggle('drawer-open'); } +// Prompt système : persisté CÔTÉ SERVEUR (/api/sysprompt, partagé entre +// appareils, utilisé par la génération serveur). localStorage = simple cache +// d'affichage hors-ligne. Debounce : on n'écrit qu'après une pause de frappe. +let _sysT = null; +function saveSys(){ + const v = document.getElementById('sysprompt').value; + localStorage.setItem('loki.sys', v); + clearTimeout(_sysT); + _sysT = setTimeout(()=>{ jpost('/api/sysprompt', {text:v}).catch(()=>{}); }, 600); +} +async function loadSys(){ + try{ + const d = await jget('/api/sysprompt'); + if(d && d.ok){ document.getElementById('sysprompt').value = d.text || ''; localStorage.setItem('loki.sys', d.text || ''); } + }catch(e){} +} diff --git a/internal/loki/ui/src/js/01-theme.js b/internal/loki/ui/src/js/01-theme.js new file mode 100644 index 0000000..ce29184 --- /dev/null +++ b/internal/loki/ui/src/js/01-theme.js @@ -0,0 +1,52 @@ +// ===== Apparence ============================================================= +// Un seul thème, en deux variantes : claire (par défaut) et sombre. La variante +// est portée par l'attribut data-theme de ('' ou 'dark') ; tout le reste +// est du CSS (voir la section « Palette »). Persistée dans localStorage pour +// être appliquée sans clignotement au chargement, puis alignée sur le serveur +// par loadPrefs() — voir 02-prefs.js. +function applyTheme(id){ + // Tolère les anciens identifiants (loki, gpt-dark, soft…) : seule compte + // désormais la variante, déduite du suffixe. + const dark = id==='dark' || (typeof id==='string' && id.endsWith('-dark')); + document.documentElement.setAttribute('data-theme', dark?'dark':'light'); + try{ localStorage.setItem('loki-theme', dark?'dark':'light'); }catch(e){} + const sw=document.getElementById('theme-dark'); if(sw) sw.checked=dark; +} +function toggleThemeDark(){ + applyTheme(document.getElementById('theme-dark').checked?'dark':'light'); + savePrefs(); +} +function initTheme(){ + let id='light'; try{ id=localStorage.getItem('loki-theme')||'light'; }catch(e){} + applyTheme(id); +} +// ===== Affichage ============================================================ +// Quatre interrupteurs indépendants. Chacun pose un attribut sur ; le CSS +// fait le reste, sauf « replier les bulles » qui est un comportement (voir +// 08-chat-render). +const VIEW_OPTS=[ + {id:'hide-reasoning', label:'masquer le raisonnement'}, + {id:'hide-tools', label:"masquer les appels d'outils"}, + {id:'fold-tools', label:'garder les bulles repliées'}, + {id:'hide-side', label:'barre latérale escamotable'}, + {id:'hide-stats', label:'masquer la vitesse de génération'}, +]; +function viewOn(id){ return document.documentElement.getAttribute('data-'+id)==='1'; } +function applyView(id, on){ + document.documentElement.setAttribute('data-'+id, on?'1':'0'); + try{ localStorage.setItem('loki-'+id, on?'1':'0'); }catch(e){} + const box=document.getElementById('view-'+id); if(box) box.checked=!!on; + // La barre latérale peut rester ouverte quand on repasse en mode fixe. + if(id==='hide-side' && !on){ + document.getElementById('side').classList.remove('open'); + document.getElementById('backdrop').classList.remove('open'); + document.body.classList.remove('drawer-open'); + } +} +function toggleView(id){ applyView(id, document.getElementById('view-'+id).checked); savePrefs(); } +function initView(){ + VIEW_OPTS.forEach(o=>{ + let v=null; try{ v=localStorage.getItem('loki-'+o.id); }catch(e){} + applyView(o.id, v==='1'); + }); +} diff --git a/internal/loki/ui/src/js/02-prefs.js b/internal/loki/ui/src/js/02-prefs.js new file mode 100644 index 0000000..67347d6 --- /dev/null +++ b/internal/loki/ui/src/js/02-prefs.js @@ -0,0 +1,30 @@ +// ===== Persistance côté serveur (partagée entre appareils) ================== +// L'apparence est aussi enregistrée sur la machine loki (/api/prefs) : ainsi le +// thème/affichage choisi sur un appareil se retrouve sur tous les autres. Le +// localStorage reste utilisé pour appliquer instantanément au chargement (sans +// flash), puis loadPrefs() aligne sur la valeur du serveur (source de vérité). +function savePrefs(){ + let theme='light'; + try{ theme=localStorage.getItem('loki-theme')||'light'; }catch(e){} + const p={theme}; + VIEW_OPTS.forEach(o=>{ p[o.id.replace('-','_')] = viewOn(o.id)?'1':'0'; }); + jpost('/api/prefs', p).catch(()=>{}); +} +async function loadPrefs(){ + try{ + const p=await jget('/api/prefs'); + if(p && p.ok && p.prefs){ + // Un thème enregistré par une version précédente (loki, gpt-dark…) est + // normalisé par applyTheme ; on réenregistre pour purger le serveur. + if(p.prefs.theme){ + applyTheme(p.prefs.theme); + if(p.prefs.theme!=='light' && p.prefs.theme!=='dark') savePrefs(); + } + VIEW_OPTS.forEach(o=>{ + const v=p.prefs[o.id.replace('-','_')]; + if(v!==undefined) applyView(o.id, v==='1'); + }); + } + }catch(e){} +} +document.addEventListener('DOMContentLoaded', ()=>{ initTheme(); initView(); document.getElementById('sysprompt').value = localStorage.getItem('loki.sys') || ''; loadSys(); restoreChat(); }); diff --git a/internal/loki/ui/src/js/03-ui-kit.js b/internal/loki/ui/src/js/03-ui-kit.js new file mode 100644 index 0000000..8d5e574 --- /dev/null +++ b/internal/loki/ui/src/js/03-ui-kit.js @@ -0,0 +1,85 @@ +// Pastille d'état d'une section du menu (mode agent, internet, MCP, distant). +// `state` : true = actif (pilule pleine), false = inactif (contour), 'warn'/'err' +// = état signalé, null = pastille masquée. Un seul endroit décide de leur allure. +function setBadge(id, state, text){ + const el = typeof id==='string' ? document.getElementById(id) : id; + if(!el) return; + el.className = 'sbadge' + (state===true ? ' on' : (state==='warn' || state==='err') ? ' '+state : ''); + el.textContent = state===null || state===undefined ? '' : (text||''); +} +// Ouverture/fermeture des modales. Le display seul apparaissait d'un bloc : on +// pose la classe .show une frame APRÈS l'affichage pour que la transition CSS +// parte de son état initial (sans ce reflow forcé, le navigateur applique tout +// d'un coup et l'animation ne joue pas). À la fermeture on attend la fin de la +// transition avant de repasser en display:none. +function showModal(id){ + const el = typeof id==='string' ? document.getElementById(id) : id; + if(!el) return; + el.style.display='flex'; + void el.offsetHeight; + el.classList.add('show'); +} +function hideModal(id){ + const el = typeof id==='string' ? document.getElementById(id) : id; + if(!el) return; + el.classList.remove('show'); + // Rouverte entre-temps ? On ne la cache surtout pas. + setTimeout(()=>{ if(!el.classList.contains('show')) el.style.display='none'; }, 180); +} +function toast(m){ const t=document.getElementById('toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),1800); } +// Modales natives (askConfirm/askPrompt/askAlert) — remplacent confirm()/prompt()/ +// alert() par de vraies boîtes stylées. Chacune renvoie une Promise : askConfirm → +// bool, askPrompt → string|null (null si annulé), askAlert → void. Échap/clic dehors +// = annuler ; Entrée = valider (sur prompt aussi). +let _askResolver=null, _askKind='confirm', _askCheck=false; +// État de la case optionnelle de la DERNIÈRE confirmation (opts.check). Lu par +// l'appelant juste après le await — la Promise, elle, ne renvoie que oui/non. +function askChecked(){ return _askCheck; } +function askResolve(ok){ + if(!_askResolver) return; + const r=_askResolver; _askResolver=null; + _askCheck = ok && document.getElementById('ask-check-input').checked; + hideModal('ask-modal'); + document.removeEventListener('keydown', _askKey, true); + if(_askKind==='prompt') r(ok ? document.getElementById('ask-input').value : null); + else r(ok); +} +function _askKey(e){ + if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); askResolve(false); } + else if(e.key==='Enter'){ e.preventDefault(); e.stopPropagation(); askResolve(true); } +} +function _openAsk(kind, message, opts){ + // Compat 2 conventions : (message, opts) [UI loki] ET l'objet unique + // {title,msg,yes,no,placeholder} passé par e2e.js sur ajean.link (server.html, + // où cette fonction remplace le window.askConfirm du bootstrap boîte noire). + if(message && typeof message==='object'){ const o=message; opts={title:o.title, okText:o.yes, cancelText:o.no, placeholder:o.placeholder, default:o.default, danger:o.danger}; message=o.msg||''; } + opts=opts||{}; + // Une modale déjà ouverte ne doit pas être écrasée en silence : son appelant + // attend une réponse, et l'écraser laissait sa promesse pendante POUR + // TOUJOURS (requête suspendue, bouton figé). On l'annule proprement d'abord. + if(_askResolver){ const prev=_askResolver; _askResolver=null; prev(_askKind==='prompt' ? null : false); } + _askKind=kind; + document.getElementById('ask-title').textContent = opts.title || (kind==='alert'?'Info':kind==='prompt'?'Saisie':'Confirmation'); + document.getElementById('ask-msg').textContent = message||''; + const inp=document.getElementById('ask-input'); + if(kind==='prompt'){ inp.style.display=''; inp.value=opts.default||''; inp.placeholder=opts.placeholder||''; } + else inp.style.display='none'; + // Case facultative (ex. « supprimer aussi le fichier .gguf »). + const chk=document.getElementById('ask-check'); + chk.style.display = opts.check ? 'inline-flex' : 'none'; + document.getElementById('ask-check-label').textContent = opts.check || ''; + document.getElementById('ask-check-input').checked = false; + _askCheck=false; + const cancel=document.getElementById('ask-cancel'), ok=document.getElementById('ask-ok'); + cancel.style.display = kind==='alert' ? 'none' : ''; + cancel.textContent = opts.cancelText || 'Annuler'; + ok.textContent = opts.okText || (kind==='alert'?'OK':kind==='prompt'?'Valider':'Confirmer'); + ok.classList.toggle('danger', !!opts.danger); + showModal('ask-modal'); + document.addEventListener('keydown', _askKey, true); + setTimeout(()=>{ const f = kind==='prompt'?inp:ok; f.focus(); if(kind==='prompt') inp.select(); }, 30); + return new Promise(res=>{ _askResolver=res; }); +} +function askConfirm(message,opts){ return _openAsk('confirm',message,opts); } +function askPrompt(message,opts){ return _openAsk('prompt',message,opts); } +function askAlert(message,opts){ return _openAsk('alert',message,opts); } diff --git a/internal/loki/ui/src/js/04-api.js b/internal/loki/ui/src/js/04-api.js new file mode 100644 index 0000000..da2b611 --- /dev/null +++ b/internal/loki/ui/src/js/04-api.js @@ -0,0 +1,59 @@ +// Clé de pilotage : envoyée en Authorization: Bearer sur chaque appel /api/*. +// Sur un 401 on la (re)demande et on rejoue la requête. Stockée en localStorage. +let TOKEN = localStorage.getItem('loki.key') || ''; +function authHeaders(h){ h = Object.assign({}, h||{}); if(TOKEN) h['Authorization']='Bearer '+TOKEN; return h; } +// Base d'URL : "" en local (page à "/"), "/u/" servie via le relais ajean.link. +// Garde les chemins absolus (/api/…) corrects derrière le tunnel. +const API_BASE = location.pathname.replace(/\/(index\.html)?$/, ''); +// Délai maximal d'un appel /api/* ordinaire. Sans lui, une requête que le +// navigateur met en file d'attente (plafond de ~6 connexions par domaine, atteint +// dès qu'on laisse traîner plusieurs onglets Loki) reste suspendue POUR TOUJOURS : +// ni réponse, ni erreur, et une UI figée sur « chargement… » sans rien à afficher. +// Mieux vaut une erreur franche. Les flux longs (SSE) passent leur propre signal +// et ne sont donc jamais concernés. +const API_TIMEOUT_MS = 30000; +async function jfetch(u, opts){ + opts = opts || {}; + opts.headers = authHeaders(opts.headers); + if(u.charAt(0) === '/') u = API_BASE + u; + let timer=null; + if(!opts.signal){ + const ac=new AbortController(); + opts.signal=ac.signal; + timer=setTimeout(()=>ac.abort(), API_TIMEOUT_MS); + } + let r; + try{ r = await fetch(u, opts); } + catch(e){ + if(e && e.name==='AbortError'){ throw new Error('requête sans réponse après 30 s (trop d\'onglets Loki ouverts ?)'); } + throw e; + } + finally{ if(timer) clearTimeout(timer); } + if(r.status === 401){ + const k = await askKeyOnce(); + if(k){ opts.headers = authHeaders(opts.headers); r = await fetch(u, opts); } + } + return r; +} +// askKeyOnce : UNE seule demande de clé à la fois, partagée par tous les appels. +// +// Le chargement de l'UI lance une dizaine d'appels /api/* en parallèle, et le +// rafraîchissement périodique en relance autant. Sans ce partage, CHAQUE 401 +// rouvrait la modale, laquelle vide son champ à l'ouverture : impossible de +// taper la clé, les caractères disparaissaient au fur et à mesure. Les appels +// concurrents attendent désormais la même promesse, et repartent tous avec la +// clé saisie une seule fois. +let _keyAsk = null; +function askKeyOnce(){ + if(!_keyAsk){ + _keyAsk = askPrompt('Clé de pilotage Loki requise :', {title:'Authentification', placeholder:'clé…'}) + .then(k => { + _keyAsk = null; + if(k){ TOKEN = k.trim(); localStorage.setItem('loki.key', TOKEN); } + return k; + }, e => { _keyAsk = null; throw e; }); + } + return _keyAsk; +} +async function jget(u){ const r=await jfetch(u); return r.json(); } +async function jpost(u,b){ const r=await jfetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); return r.json(); } diff --git a/internal/loki/ui/src/js/05-status.js b/internal/loki/ui/src/js/05-status.js new file mode 100644 index 0000000..acc92af --- /dev/null +++ b/internal/loki/ui/src/js/05-status.js @@ -0,0 +1,175 @@ +async function loadStatus(){ + const s=await jget('/api/status'); + const el=document.getElementById('status-svc'); + // Trois états : service coupé (err) · service actif mais modèle pas encore + // chargé (loading, llama-server renvoie 503) · modèle prêt (ok). + // Pastille COURTE et honnête. Le détail (cause exacte) va dans #model-err + // dessous : la pastille ne doit pas affirmer « incompatible » quand l'échec + // peut être tout autre chose. + let cls='err', txt='arrêté'; + if(s.active && s.health){ cls='ok'; txt='prêt'; } + else if(s.load_error){ cls='err'; txt='erreur'; } + else if(s.active){ cls='loading'; txt='chargement…'; } + el.className='statuspill '+cls; + el.innerHTML=''+txt; + MODEL_READY = !!(s.active && s.health); + // Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un + // message à un modèle qui n'est pas encore chargé (voir syncSendBtn). + STATUS_SEEN = true; + if(typeof syncSendBtn==='function') syncSendBtn(); + if(s.ctx){ CTX_MAX=s.ctx; updateCtxMeter(); } + if(s.version){ + document.getElementById('ver').textContent='v'+s.version; + } + // Avertissement de lancement (App Translocation macOS) : rare, mais il explique + // des symptomes tres deroutants, donc on l'affiche en permanence tant qu'il dure. + const wb=document.getElementById('app-warn'); + if(wb){ + if(s.warn){ wb.textContent='⚠ '+s.warn; wb.style.display=''; } + else { wb.style.display='none'; } + } + // Modèle qui ne charge pas (souvent un moteur incompatible) : message explicite + // plutôt qu'un « chargement… » perpétuel ou un crash-loop muet. + const me=document.getElementById('model-err'); + if(me){ + if(s.load_error){ me.textContent='⚠ '+s.load_error; me.style.display=''; } + else { me.style.display='none'; } + } +} +// Journal du moteur — replié par défaut, on l'ouvre en cliquant la pastille. +function toggleSvcLog(){ + const box=document.getElementById('svc-log-box'); + if(!box) return; + const show = box.style.display==='none'; + box.style.display = show ? '' : 'none'; + if(show){ loadSvcLog(); showPaths(); } +} +async function loadSvcLog(){ + const el=document.getElementById('svc-log'); + if(!el) return; + el.textContent='chargement du journal…'; + try{ + const r=await jget('/api/service/log?n=120'); + el.textContent = (r && r.log && r.log.trim()) ? r.log : 'journal vide — le moteur n\'a encore rien écrit.'; + el.scrollTop = el.scrollHeight; + }catch(e){ el.textContent='journal indisponible : '+e; } +} +async function checkUpdate(){ + const b=document.getElementById('upd-check'), msg=document.getElementById('upd-msg'); + b.disabled=true; msg.textContent='Vérification…'; + try{ + const r=await jget('/api/update'); + if(r.error){ msg.textContent='Erreur : '+r.error; } + else if(r.available){ + msg.innerHTML='Nouvelle version v'+r.latest+' disponible. '; + const btn=document.createElement('button'); btn.textContent='Mettre à jour'; btn.onclick=applyUpdate; + msg.appendChild(btn); + } else { msg.textContent='Loki est à jour ✓'; } + }catch(e){ msg.textContent='Erreur réseau'; } + b.disabled=false; +} +// Emplacements — affichés avec le journal du moteur : c'est le panneau qu'on +// ouvre quand on cherche à comprendre l'état de son installation. +async function showPaths(){ + const el=document.getElementById('paths-msg'); + if(!el) return; + el.textContent='…'; + try{ + const p=await jget('/api/paths'); + const rows=[['Données',p.home],['Base (config, préférences, conversation)',p.database],['Modèles',p.models],['Presets',p.presets],['Mémoire',p.memory],['Fichiers créés par l\'IA',p.workspace],['Moteur llama.cpp',p.backends],['Programme',p.exe]]; + el.innerHTML=rows.map(r=>'
    '+r[0]+'
    '+escHtml(r[1]||'')+'
    ').join(''); + }catch(e){ el.textContent='Erreur'; } +} +async function applyUpdate(){ + const msg=document.getElementById('upd-msg'); + msg.textContent='Téléchargement et installation…'; + try{ + // Signal dédié : le timeout par défaut (30 s) coupe le téléchargement du + // binaire sur une connexion lente et fait croire à un échec alors que la + // mise à jour aboutit côté serveur. + const ac=new AbortController(); const t=setTimeout(()=>ac.abort(), 10*60*1000); + let r; + try{ r=await (await jfetch('/api/update/apply',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}',signal:ac.signal})).json(); } + finally{ clearTimeout(t); } + if(r.ok){ + msg.innerHTML='✓ Installé en v'+r.version+'.
    '+(r.restart||''); + // Redémarrage auto du service côté serveur : le flux va se couper puis + // reconnecter tout seul (connectStream boucle). On rafraîchit l'état après. + if(r.restarting){ toast('mise à jour appliquée — reconnexion…'); setTimeout(loadAll, 6000); } + } + else { msg.textContent='Échec : '+(r.error||'inconnu'); } + }catch(e){ msg.textContent='Erreur pendant la mise à jour (réessaie).'; } +} +// Compteur de contexte : CTX_USED estimé via les stats serveur (prefill+decode +// du dernier tour ≈ taille du prochain prompt). À 90% on propose de compacter. +let CTX_MAX=0, CTX_USED=0, MODEL_READY=false; +// STATUS_SEEN : /api/status a répondu au moins une fois. Avant ça (ou s'il ne +// répond pas), on ne verrouille RIEN — mieux vaut un envoi qui échoue qu'un chat +// bloqué par un état inconnu. +let STATUS_SEEN=false; +function setCtxUsed(n){ CTX_USED=n||0; updateCtxMeter(); } +function updateCtxMeter(){ + if(!CTX_MAX) return; + const pct=Math.min(100, Math.round(CTX_USED*100/CTX_MAX)); + const fill=document.getElementById('ctx-fill'); + fill.style.width=pct+'%'; + fill.style.background = pct>=90 ? 'var(--err,#c44)' : pct>=70 ? 'var(--warn,#c93)' : 'var(--ok,#3a7)'; + // Les chiffres SANS le mot « contexte » : la jauge juste en dessous dit déjà de + // quoi on parle, et le pied de carte est étroit. Le libellé complet reste en + // infobulle. + const ct=document.getElementById('ctx-text'); + ct.textContent=CTX_USED+' / '+CTX_MAX+' ('+pct+'%)'; + ct.title='contexte utilisé'; + // Bouton de compaction MANUELLE : visible dès la moitié du contexte pour qu'on + // puisse compacter à la demande avant que l'auto-compaction (75%) ne s'en charge. + document.getElementById('ctx-compact').style.display = (pct>=50 && CTX_USED>0) ? 'inline-block' : 'none'; +} +async function loadVram(){ + const gpus=await jget('/api/vram'); + // Bloc de statistique : intitulé + valeur sur une ligne, jauge, détail dessous. + // Même gabarit que la RAM (voir .stat dans le CSS) — le HTML libre d'avant + // collait aux bords de la carte. + document.getElementById('vram').innerHTML = (gpus||[]).map(g=>{ + const pct=Math.round(g.used*100/g.total); + return '
    '+g.name+''+ + ''+(g.used/1024).toFixed(1)+' / '+(g.total/1024).toFixed(1)+' GiB
    '+ + '
    '+ + '
    GPU '+g.util+' % · '+g.temp+' °C
    '; + }).join('') || '
    (pas de GPU)
    '; +} +async function loadRam(){ + const m=await jget('/api/ram'); + const box=document.getElementById('ram-details'); + if(!m || !m.total){ if(box) box.style.display='none'; return; } + if(box) box.style.display=''; + const pct=Math.round(m.used*100/m.total); + document.getElementById('ram').innerHTML = + '
    Mémoire vive'+ + ''+(m.used/1024).toFixed(1)+' / '+(m.total/1024).toFixed(1)+' GiB
    '+ + '
    '+ + '
    '+pct+' % utilisée
    '; +} +async function loadCfg(){ + // /api/llamacpp en parallèle : il indique si le BIN de la config correspond au + // précompilé (prebuilt.in_use) ou compilé ici (in_use) — sinon c'est un fork perso. + const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]); + const row=(k,v,title)=>'
    '+k+''+String(v)+'
    '; + const rows=[]; + if(c.BIN){ + // Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde + // toujours le chemin complet, quel que soit le libellé. + let v; + if(lc && lc.prebuilt && lc.prebuilt.in_use) v='llama.cpp précompilé'; + else if(lc && lc.in_use) v='llama.cpp compilé'; + else v='llama.cpp personnalisé : '+c.BIN; + rows.push(row('MOTEUR', v, c.BIN)); + } + ['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{ + let v=c[k]; if(k==='MODEL') v=v.split('/').pop(); + rows.push(row(k, v)); + }); + // n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS. + const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/); + if(m) rows.push(row('N-CPU-MOE', m[1])); + document.getElementById('cfg').innerHTML = rows.join(''); +} diff --git a/internal/loki/ui/src/js/06-settings.js b/internal/loki/ui/src/js/06-settings.js new file mode 100644 index 0000000..2373e56 --- /dev/null +++ b/internal/loki/ui/src/js/06-settings.js @@ -0,0 +1,497 @@ +// --- Réserve de hauteur des blocs peuplés par le réseau --------------------- +// Ces blocs sont vides (ou réduits à « … ») tant que le serveur n'a pas répondu, +// puis prennent leur vraie taille : tout le menu sautait alors d'un cran, ce qui +// se voyait surtout sur les sections du bas (« Moteur »). On mémorise leur +// hauteur d'une session à l'autre et on la réserve au démarrage ; la réserve +// tombe dès que le vrai contenu est là (elle ne fait que TENIR la place, elle +// n'affiche jamais rien de faux). +const HRESERVE=['cfg','vram','ram-details','presets','internet-status','mcp-list']; +function reserveHeights(){ + HRESERVE.forEach(id=>{ + try{ + const h=parseInt(localStorage.getItem('loki-h-'+id)||'0',10); + const el=document.getElementById(id); + if(el && h>0) el.style.minHeight=h+'px'; + }catch(e){} + }); +} +function releaseHeights(){ + HRESERVE.forEach(id=>{ + const el=document.getElementById(id); if(!el) return; + el.style.minHeight=''; + try{ localStorage.setItem('loki-h-'+id, String(el.offsetHeight||0)); }catch(e){} + }); +} +document.addEventListener('DOMContentLoaded', reserveHeights); +// Numéro (1-based) du preset vers lequel on bascule, tant que le serveur n'a pas +// fini de redémarrer le service. Basculer prend plusieurs secondes : sans ça, la +// liste restait FIGÉE sur l'ancien actif et on ne savait pas si le clic avait pris. +// Voir switchTo() dans 07-models.js. +let pendingPreset = 0; +// Dernière sélection PEINTE. La liste est redessinée à chaque rafraîchissement +// (et il y en a beaucoup : sondage de bascule, loadAll…) ; sans ce repère, les +// animations d'entrée (barre qui glisse, puce qui apparaît) repartaient à chaque +// redessin et la puce semblait clignoter en boucle. On ne les rejoue que quand la +// sélection CHANGE vraiment. +let paintedSel = ''; +async function loadPresets(){ + const p=await jget('/api/presets'); + // Bascule terminée : le preset visé est devenu l'actif, on éteint l'attente. + if(pendingPreset && p[pendingPreset-1] && p[pendingPreset-1].active) pendingPreset = 0; + const act = p.find(x=>x.active); + // Build via DOM (not string concat) so preset names can contain anything — + // spaces, accents, quotes, < > & — without breaking markup or handlers. + const sp = document.getElementById('status-preset'); + sp.textContent = act ? act.name : ''; + sp.title = act ? 'preset actif' : ''; + const cont=document.getElementById('presets'); + cont.innerHTML=''; + if(!p.length){ cont.innerHTML='(aucun)'; return; } + // Signature de la sélection : quel preset est actif, et vers lequel on bascule. + const sel = (act?act.id:'')+'|'+pendingPreset; + const moved = sel !== paintedSel; paintedSel = sel; + p.forEach((x,i)=>{ + const row=document.createElement('div'); + const pend = !x.active && pendingPreset===i+1; + row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':''); + row.onclick=()=>switchTo(i+1, x.name); + const info=document.createElement('div'); info.className='preset-info'; + const nm=document.createElement('div'); nm.className='preset-name'; + // Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe + // est dessiné par la police du système — sur Windows il sortait plus petit et + // plus bas que sur macOS. Un disque CSS a la même taille et la même assiette + // partout. + // Puce réservée à l'actif POUR DE BON : pendant la bascule, seule la barre + // orange parle ; la puce apparaît quand la barre passe au blanc. + if(x.active){ const d=document.createElement('i'); d.className='preset-dot'; nm.appendChild(d); } + nm.appendChild(document.createTextNode(x.name)); nm.title=x.name; + // Second row: quant tag + bench perf, so the title row stays full-width. + const meta=document.createElement('div'); meta.className='preset-meta'; + if(x.quant){ + const q=document.createElement('span'); q.className='qtag'; + q.textContent=x.quant; q.title='quantization'; + meta.appendChild(q); + } + if(x.reasoning){ + const rt=document.createElement('span'); rt.className='rtag'; + rt.title='raisonnement actif ('+x.reasoning+')'; + rt.innerHTML=''; + rt.appendChild(document.createTextNode(x.reasoning)); + meta.appendChild(rt); + } + if(x.bench){ + const bt=document.createElement('span'); bt.className='btag'; + bt.title='prefill / decode — dernier bench de ce preset'; + bt.textContent=x.bench.prefill.toFixed(0)+'-'+x.bench.decode.toFixed(0)+' t/s'; + meta.appendChild(bt); + } + info.appendChild(nm); + if(meta.children.length) info.appendChild(meta); + const edit=document.createElement('button'); + // Engrenage (et non plus un crayon) : la silhouette est ronde, donc elle se + // lit comme centrée dans son coin, là où le crayon penché tirait de travers. + edit.className='preset-edit'; edit.title='réglages du preset'; + edit.innerHTML=''; + edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); }; + row.appendChild(info); row.appendChild(edit); + cont.appendChild(row); + }); +} +// « Mode agent » = accès machine + skills réunis en un seul interrupteur. +// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ». +async function loadAgent(){ + const s=await jget('/api/agent'); + const on = s.enabled; + document.getElementById('agent-toggle').checked = on; + document.getElementById('compact-toggle').checked = (s.compact !== false); + setBadge('agent-badge', on, on?'actif':'inactif'); + document.getElementById('brand').classList.toggle('agent', on); + setAgentGate(on); + if(s.mem_mode){ document.getElementById('mem-mode').value = s.mem_mode; renderMemModeDesc(s.mem_mode); } + memPages = (s.pages || s.skills || []).slice().sort((a,b)=>a.name.localeCompare(b.name)); + memShown = MEM_PAGE; + document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : ''; + document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none'; + renderMemList(); +} +// Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni +// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On +// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas. +function setAgentGate(on){ + ['mem-block','net-block','mcp-block','param-block'].forEach(id=>{ + const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on); + }); +} +// Repli/dépli de la liste des pages mémoire (fermée par défaut → gagne de la place). +function toggleMemPages(){ + const body=document.getElementById('mem-pages-body'); + const bar=document.getElementById('mem-pages-bar'); + const open=body.hasAttribute('hidden'); + if(open){ body.removeAttribute('hidden'); bar.classList.add('open'); } + else { body.setAttribute('hidden',''); bar.classList.remove('open'); } +} +// Liste mémoire scalable : recherche + rendu plafonné (les milliers de pages ne +// déroulent plus une barre géante). memShown grimpe par paliers via « voir plus ». +let memPages=[], memShown=0; const MEM_PAGE=50; +function renderMemList(){ + const q=(document.getElementById('mem-search').value||'').trim().toLowerCase(); + const list=document.getElementById('mem-list'); + list.textContent=''; + if(!memPages.length){ list.innerHTML='
    (aucune page mémoire)
    '; return; } + const matches = q ? memPages.filter(x=>(x.name+' '+(x.desc||'')).toLowerCase().includes(q)) : memPages; + if(!matches.length){ list.innerHTML='
    (aucun résultat pour « '+q.replace(/[<>&]/g,'')+' »)
    '; return; } + const shown = matches.slice(0, memShown); + shown.forEach(x=>{ + const row=document.createElement('div'); row.className='preset'; row.style.fontSize='12px'; + row.onclick=()=>openMem(x.name); + const span=document.createElement('span'); + const b=document.createElement('b'); b.style.color='var(--text)'; b.textContent=x.name; span.appendChild(b); + if(x.desc){ const d=document.createElement('span'); d.className='muted'; d.textContent=' — '+x.desc; span.appendChild(d); } + const btn=document.createElement('button'); btn.textContent='edit'; btn.style.cssText='margin:0;padding:2px 8px;font-size:11px'; + btn.onclick=e=>{ e.stopPropagation(); openMem(x.name); }; + row.appendChild(span); row.appendChild(btn); list.appendChild(row); + }); + if(matches.length > shown.length){ + const more=document.createElement('div'); more.className='mem-more'; + more.textContent='+ voir plus ('+(matches.length-shown.length)+' de plus)'; + more.style.cursor='pointer'; + more.onclick=()=>{ memShown+=MEM_PAGE; renderMemList(); }; + list.appendChild(more); + } +} +// alias : plusieurs appelants rafraîchissent juste la liste des pages mémoire +const loadMem = loadAgent; +async function toggleAgent(){ + const on=document.getElementById('agent-toggle').checked; + if(on && !await askConfirm("L'IA aura un accès shell complet au serveur (bash) et pourra lire/écrire sa mémoire.", {title:'Activer le mode agent ?', okText:'Activer', danger:true})){ document.getElementById('agent-toggle').checked=false; return; } + await jpost('/api/agent/toggle',{on}); + loadAgent(); +} +async function toggleCompact(){ + const on=document.getElementById('compact-toggle').checked; + await jpost('/api/agent/compact',{on}); +} +// Mode mémoire (3 états) — indépendant du mode agent. +const MEM_DESC={ + always:'L\'IA cherche dans sa mémoire avant de répondre et sauve d\'elle-même ce qui mérite d\'être retenu.', + ondemand:'Les outils mémoire existent mais l\'IA ne les utilise QUE si tu le demandes (« souviens-toi de… », « qu\'avais-tu retenu sur… »).', + off:'Mémoire coupée : aucun accès en lecture ni écriture, l\'IA répond sans mémoire.' +}; +function renderMemModeDesc(m){ const d=document.getElementById('mem-mode-desc'); if(d) d.textContent=MEM_DESC[m]||''; } +async function setMemMode(){ + const mode=document.getElementById('mem-mode').value; + const r=await jpost('/api/memory',{mode}); + renderMemModeDesc(r.mode||mode); +} +// Accès internet : serveur Crawl4AI + drapeau. Actif ET fonctionnel = pastille verte. +let internetOn=false, webEngine='go'; +function renderInternet(s){ + internetOn = !!s.enabled; + webEngine = s.engine || 'go'; + document.getElementById('internet-toggle').checked = internetOn; + const sel=document.getElementById('web-engine'); + if(sel && document.activeElement!==sel) sel.value = webEngine; + // Les réglages Crawl4AI n'ont aucun sens avec le moteur intégré. + const cf=document.getElementById('crawl-fields'); + if(cf) cf.style.display = (webEngine==='crawl4ai') ? '' : 'none'; + if(document.activeElement !== document.getElementById('crawl-url')) + document.getElementById('crawl-url').value = s.url || ''; + // La pastille ne redit PAS ce que l'interrupteur montre déjà (actif/inactif) : + // elle ne sert qu'à signaler l'anomalie — actif mais serveur injoignable, cas + // où les outils web ne sont pas proposés au modèle. + if(internetOn && !s.reachable) setBadge('internet-badge','warn','injoignable'); + else setBadge('internet-badge', null); + // Clé du serveur Crawl4AI : le champ reste vide (la clé n'est jamais renvoyée), + // on indique seulement si une clé est enregistrée. + const ks=document.getElementById('crawl-key-state'), kc=document.getElementById('crawl-key-clear'); + if(ks){ + ks.textContent = s.key_set ? ('clé enregistrée '+(s.key_hint||'')) : 'aucune clé — serveur ouvert'; + if(kc) kc.style.display = s.key_set ? '' : 'none'; + const ki=document.getElementById('crawl-key'); + if(ki && document.activeElement!==ki) ki.value=''; + if(ki) ki.placeholder = s.key_set ? 'clé enregistrée — saisir pour remplacer' : 'clé API (si le serveur en exige une)'; + } + const st=document.getElementById('internet-status'); + if(webEngine!=='crawl4ai'){ st.innerHTML='✓ moteur intégré — aucune installation requise (pas de rendu JavaScript)'; } + else if(!s.url){ st.textContent='serveur non configuré'; st.style.color=''; } + else if(s.reachable){ st.innerHTML='✓ serveur joignable — outils web actifs'; } + else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; } +} +async function loadInternet(){ renderInternet(await jget('/api/internet')); } +// --- Accès OpenAI (endpoint /v1 + clé API des complétions) ----------------- +let OAI_KEY='', OAI_REVEAL=false; +async function copyText(txt, msg){ + if(!txt){ toast('rien à copier'); return; } + try{ await navigator.clipboard.writeText(txt); } + catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); } + toast(msg||'copié'); +} +function copyApiKey(){ copyText(OAI_KEY, OAI_KEY?'clé copiée':'aucune clé'); } +function renderApiKey(d){ + OAI_KEY = d.key || ''; + // URL de l'endpoint : llama-server tourne sur d.port (≠ port de l'UI web). + // On prend l'hôte annoncé par le serveur (IP LAN détectée côté Go) : dans le + // tunnel ajean.link, location.hostname serait le domaine du relais (faux) — + // l'accès OpenAI reste TOUJOURS l'adresse locale de la machine. + const host = d.host || location.hostname; + document.getElementById('oai-url').value = 'http://'+host+':'+d.port+'/v1'; + // Endpoint PUBLIC (ajean.link) : affiché seulement si l'accès public est activé. + const tg = document.getElementById('oai-public-toggle'); + if(tg) tg.checked = !!d.oai_public; + const pubWrap = document.getElementById('oai-public-wrap'); + if(d.oai_public && d.machine){ + document.getElementById('oai-public-url').value = 'https://'+d.machine+'.oai.ajean.link/v1'; + pubWrap.style.display = ''; + } else { + pubWrap.style.display = 'none'; + } + const inp=document.getElementById('oai-key'); + if(!d.set){ inp.value='(aucune clé — serveur ouvert)'; inp.style.opacity=.6; } + else { inp.style.opacity=1; inp.value = OAI_REVEAL ? OAI_KEY : d.masked; } + document.getElementById('oai-key-eye').style.display = d.set ? '' : 'none'; +} +async function loadApiKey(){ renderApiKey(await jget('/api/apikey')); } +// --- Export de la conversation --------------------------------------------- +// xTurnsTotal : nombre d'échanges du fil, borne haute du curseur. Vient de +// /api/chat/state ; 0 = conversation vide, et la fenêtre n'a alors rien à régler. +let xTurnsTotal = 0; +async function openExportModal(){ + showModal('export-modal'); + try{ + const st = await jget('/api/chat/state'); + xTurnsTotal = (st && st.turns) || 0; + }catch(e){ xTurnsTotal = 0; } + // Rien à exporter : on remplace les réglages par un message, plutôt que de + // proposer de tailler un fichier qui serait vide de toute façon. + const vide = xTurnsTotal === 0; + document.getElementById('x-empty').style.display = vide ? '' : 'none'; + document.getElementById('x-body').style.display = vide ? 'none' : ''; + document.getElementById('x-go').style.display = vide ? 'none' : ''; + if(vide) return; + const el = document.getElementById('x-turns'); + el.max = String(xTurnsTotal); + el.value = el.max; // par défaut : tout le fil + el.disabled = xTurnsTotal < 2; // un seul échange : rien à trancher + onExportFormat(); +} +function closeExportModal(){ hideModal('export-modal'); } +// Le format ne change QUE le contenant : les options de contenu sont les mêmes +// des deux côtés. Il n'y a donc plus rien à montrer ou cacher ici, seulement le +// résumé à rafraîchir. +function onExportFormat(){ onExportPreview(); } +function exportFormat(){ + const r = document.querySelector('input[name=x-fmt]:checked'); + return r ? r.value : 'md'; +} +// Construit la requête d'export à partir des cases. On n'envoie QUE ce qui +// s'écarte du défaut : l'URL reste lisible, et un export complet redevient le +// simple /api/chat/export. +function exportQuery(){ + const p = new URLSearchParams(); + if(exportFormat() === 'json') p.set('format','json'); + const on = id => document.getElementById(id).checked; + if(!on('x-reasoning')) p.set('reasoning','0'); + if(!on('x-tools')) p.set('tools','0'); + if(!on('x-results')) p.set('results','0'); + const t = exportTurns(); + if(t > 0) p.set('turns', String(t)); + const q = p.toString(); + return '/api/chat/export' + (q ? '?'+q : ''); +} +// Valeur de portée à envoyer : 0 = tout le fil. La butée DROITE du curseur vaut +// « toute la conversation », donc on n'envoie rien plutôt qu'un nombre qui se +// périmerait à l'échange suivant. +function exportTurns(){ + const v = parseInt(document.getElementById('x-turns').value, 10) || 0; + if(!xTurnsTotal || v >= xTurnsTotal) return 0; + return v; +} +function onExportPreview(){ + // Sortie d'outil sans bulle d'outil n'a aucun sens : la case suit. + const tools = document.getElementById('x-tools'); + const results = document.getElementById('x-results'); + results.disabled = !tools.checked; + if(!tools.checked) results.checked = false; + paintExportRange(); + const off = []; + if(!document.getElementById('x-reasoning').checked) off.push('raisonnements'); + if(!tools.checked) off.push('outils'); + else if(!results.checked) off.push('sorties d\'outils'); + document.getElementById('x-note').textContent = off.length + ? 'Export sans ' + off.join(' ni ') + '.' + : ''; +} +// Remplit la piste du curseur et son libellé. Purement local : appelé à chaque +// pixel de glissement, il ne doit RIEN demander au serveur. +function paintExportRange(){ + const el = document.getElementById('x-turns'); + const n = parseInt(el.value, 10) || 0; + const max = Math.max(1, xTurnsTotal); + // La pastille ne parcourt pas toute la largeur : elle va de THUMB/2 à + // largeur - THUMB/2. On coupe le remplissage dans SON repère, sinon la piste + // colorée déborde à côté d'elle aux extrémités (même calcul que la barre GPU). + const frac = max > 1 ? (n - 1) / (max - 1) : 1; + const fill = document.getElementById('x-fill'); + if(fill) fill.style.width = 'calc(' + (frac*100).toFixed(2) + '% - ' + ((frac - .5) * GPU_THUMB).toFixed(2) + 'px)'; + const scope = document.getElementById('x-scope'); + if(!scope) return; + scope.textContent = n >= xTurnsTotal + ? 'toute la conversation (' + xTurnsTotal + ' échange' + (xTurnsTotal>1?'s':'') + ')' + : n + ' dernier' + (n>1?'s':'') + ' échange' + (n>1?'s':'') + ' sur ' + xTurnsTotal; +} +async function runExport(){ + const btn = document.getElementById('x-go'); + btn.disabled = true; + try{ await downloadExport(exportQuery()); closeExportModal(); } + finally{ btn.disabled = false; } +} +// On passe par jfetch (et pas par un simple
    ) pour deux raisons : la clé +// de pilotage voyage dans un en-tête Authorization, qu'un lien ne porterait pas, +// et le chemin de base change derrière le tunnel (/u/). +async function downloadExport(url){ + toast('préparation de l\'export…'); + try{ + const r = await jfetch(url); + if(!r.ok){ toast('erreur : HTTP ' + r.status); return; } + // Derrière app.ajean.link, le proxy chiffré réemballe TOUTE réponse en JSON : + // un export Markdown revenait donc comme une chaîne JSON entre guillemets, + // échappements compris. On la déballe. En local, rien à faire — le type de + // contenu n'est pas du JSON, et un export JSON est déjà à sa place. + let text = await r.text(); + if((r.headers.get('Content-Type')||'').includes('json')){ + try{ const v = JSON.parse(text); if(typeof v === 'string') text = v; }catch(_){} + } + const blob = new Blob([text]); + // Nom du fichier : celui proposé par le serveur (horodaté), à défaut un nom + // déduit du format DEMANDÉ — le type renvoyé ne dit plus rien derrière le + // tunnel, où tout arrive en application/json. + const cd = r.headers.get('Content-Disposition') || ''; + const m = cd.match(/filename="([^"]+)"/); + const name = m ? m[1] : 'loki-conversation.' + (url.includes('format=json') ? 'json' : 'md'); + // ⚠️ blobURL, surtout pas `url` : ce nom est déjà celui du paramètre, et le + // redéclarer ici mettrait la ligne `jfetch(url)` ci-dessus dans la zone morte + // du const — l'export échouerait avant même de partir. + const blobURL = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobURL; a.download = name; + document.body.appendChild(a); a.click(); a.remove(); + // Révocation différée : Safari annule le téléchargement si l'URL disparaît + // dans la foulée du clic. + setTimeout(()=>URL.revokeObjectURL(blobURL), 10000); + toast('exporté : ' + name); + }catch(e){ toast('erreur : ' + e.message); } +} +// --- Écoute réseau du moteur (HOST + pare-feu) ------------------------------ +// Le retour d'utilisateur qui a motivé ce réglage : « à part le chat dans le +// navigateur, impossible d'utiliser ton URL dans les logiciels en local ». Le +// moteur écoutait sur 127.0.0.1 et rien ne le disait nulle part. +function renderNetwork(st){ + if(!st) return; + const cb = document.getElementById('lan-toggle'); + if(cb) cb.checked = !!st.exposed; + const warn = document.getElementById('lan-warn'); + if(!warn) return; + if(!st.exposed){ + warn.style.display = ''; + warn.innerHTML = '
    Le moteur n\'écoute que sur cette machine : l\'adresse ci-dessus ne répond pas depuis un autre ordinateur.
    '; + return; + } + // Exposé mais bloqué par le pare-feu : le cas le plus déroutant (« ça écoute + // partout » et pourtant rien ne passe). On donne la commande à coller. + if(st.hint){ + warn.style.display = ''; + // --err et non --warn : la palette est volontairement monochrome et --warn y + // est un gris, illisible comme alerte. Ici il y a une vraie action à faire. + warn.innerHTML = '
    ⚠ ' + + escHtml(st.hint).replace(/\n/g,'
    ') + '
    '; + return; + } + warn.style.display = 'none'; +} +async function loadNetwork(){ + try{ const r = await jget('/api/network'); renderNetwork(r && r.status); }catch(e){} +} +async function toggleLAN(){ + const cb = document.getElementById('lan-toggle'); + const on = cb.checked; + const r = await jpost('/api/network', {exposed:on}); + if(!r || !r.ok){ cb.checked = !on; toast('erreur : ' + ((r&&r.error)||'')); return; } + renderNetwork(r.status); + // llama-server ne lit --host qu'au lancement : sans redémarrage, l'interrupteur + // affiche un état que le moteur en cours ne respecte pas encore. + if(await askConfirm('Le moteur doit redémarrer pour appliquer ce changement (le modèle sera rechargé).', + {title: on ? 'Ouvrir sur le réseau' : 'Fermer sur le réseau', okText:'Redémarrer'})){ + await act('restart'); // même chemin que les boutons du panneau Moteur + } + loadApiKey(); +} +function toggleKeyReveal(){ OAI_REVEAL=!OAI_REVEAL; const inp=document.getElementById('oai-key'); if(OAI_KEY) inp.value = OAI_REVEAL ? OAI_KEY : (OAI_KEY.slice(0,8)+'…'+OAI_KEY.slice(-4)); const eye=document.getElementById('oai-key-eye'); if(eye) eye.textContent = OAI_REVEAL ? 'masquer' : 'afficher'; } +async function apiKeyAction(action){ + if(action==='clear' && !await askConfirm('Retirer la clé rend l\'endpoint OpenAI accessible SANS authentification. Le service va redémarrer.', {title:'Retirer la clé API ?', okText:'Retirer'})) return; + if(action==='generate' && OAI_KEY && !await askConfirm('Générer une nouvelle clé invalide l\'ancienne (les clients devront être mis à jour). Le service va redémarrer.', {title:'Régénérer la clé ?', okText:'Générer'})) return; + OAI_REVEAL=(action==='generate'); // révèle la clé fraîche pour qu'on puisse la copier + toast('application…'); + renderApiKey(await jpost('/api/apikey', {action})); +} +async function toggleOAIPublic(){ + const cb = document.getElementById('oai-public-toggle'); + const on = cb.checked; + // L'accès OpenAI public passe par ajean.link (.oai.ajean.link) : il exige + // que l'accès distant soit activé sur ce serveur. Sinon, on annule et on explique. + if(on){ + let linked = false; + try{ const s = await jget('/api/link/status'); linked = !!(s && s.linked); }catch(e){} + if(!linked){ + cb.checked = false; + await askAlert('Vous devez activer l\'accès distant (ajean.link) pour bénéficier de cette fonctionnalité. Ouvrez le panneau « Accès distant » pour connecter ce serveur.', {title:'Accès distant requis'}); + return; + } + } + await jpost('/api/oai/public', {enabled:on}); + toast(on ? 'accès public activé' : 'accès public coupé'); + loadApiKey(); +} +async function apiKeySet(){ + const k = await askPrompt('Colle ta clé API (ou laisse vide pour annuler) :', {title:'Définir la clé API', placeholder:'sk-…'}); + if(!k || !k.trim()) return; + OAI_REVEAL=true; toast('application…'); + renderApiKey(await jpost('/api/apikey', {action:'set', key:k.trim()})); +} +async function toggleInternet(){ + const on=document.getElementById('internet-toggle').checked; + const url=document.getElementById('crawl-url').value.trim(); + // Crawl4AI sans URL de serveur = rien à joindre. Le moteur intégré, lui, n'a + // besoin d'aucun réglage : on active directement. + if(on && webEngine==='crawl4ai' && !url){ toast('renseigne d\'abord l\'URL du serveur Crawl4AI'); document.getElementById('internet-toggle').checked=false; return; } + renderInternet(await jpost('/api/internet',{enabled:on, url})); +} +async function saveWebEngine(){ + const engine=document.getElementById('web-engine').value; + renderInternet(await jpost('/api/internet',{engine})); + toast(engine==='crawl4ai' ? 'moteur Crawl4AI sélectionné' : 'moteur intégré sélectionné'); +} +async function saveCrawlUrl(){ + const url=document.getElementById('crawl-url').value.trim(); + renderInternet(await jpost('/api/internet',{url})); + toast(url ? 'serveur Crawl4AI enregistré' : 'serveur Crawl4AI retiré'); +} +async function saveCrawlKey(){ + const el=document.getElementById('crawl-key'), key=el.value.trim(); + if(!key){ toast('saisis une clé (ou « retirer » pour l\'enlever)'); return; } + renderInternet(await jpost('/api/internet',{key})); + toast('clé enregistrée'); +} +async function clearCrawlKey(){ + if(!await askConfirm('Retirer la clé du serveur Crawl4AI ?')) return; + document.getElementById('crawl-key').value=''; + renderInternet(await jpost('/api/internet',{key:''})); + toast('clé retirée'); +} +async function loadAll(){ + // allSettled et pas all : un seul chargement en échec (accès distant coupé, + // clé API absente…) ne doit pas empêcher la suite — et surtout pas laisser les + // hauteurs réservées en place pour toujours. + await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadAgent(),loadInternet(),loadMCP(),loadNode(),loadApiKey(),loadNetwork(),loadPrefs(),loadLlamacpp(),loadRemote()]); + releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois +} +async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); } diff --git a/internal/loki/ui/src/js/07-models.js b/internal/loki/ui/src/js/07-models.js new file mode 100644 index 0000000..ca5aa97 --- /dev/null +++ b/internal/loki/ui/src/js/07-models.js @@ -0,0 +1,985 @@ +function openBenchModal(){ showModal('bench-modal'); } +function closeBenchModal(){ hideModal('bench-modal'); } +async function runBenchUI(){ + const btn = document.getElementById('btn-bench'); + const rerun = document.getElementById('bench-rerun'); + const body = document.getElementById('bench-body'); + openBenchModal(); + btn.disabled = true; btn.textContent = '⏳ bench…'; + rerun.disabled = true; + body.innerHTML = + '
    ' + + '
    ⏳
    ' + + '
    prompt 2000 tok + 300 decode
    ~10 secondes…
    ' + + '
    '; + try{ + const r = await jget('/api/bench'); + if(!r.ok){ + body.innerHTML = '
    erreur: '+r.error+'
    '; + return; + } + const x = r.result; + body.innerHTML = + '
    ' + + '
    ' + + '
    Prefill
    ' + + '
    '+x.prompt_per_second.toFixed(0)+'
    ' + + '
    tok/s
    ' + + '
    '+x.prompt_n+' tok · '+(x.prompt_ms/1000).toFixed(2)+'s
    ' + + '
    ' + + '
    ' + + '
    Decode
    ' + + '
    '+x.predicted_per_second.toFixed(1)+'
    ' + + '
    tok/s
    ' + + '
    '+x.predicted_n+' tok · '+(x.predicted_ms/1000).toFixed(2)+'s
    ' + + '
    ' + + '
    ' + + '
    total '+x.elapsed_sec.toFixed(2)+'s
    '; + } finally { + btn.disabled = false; btn.textContent = 'bench'; + rerun.disabled = false; + loadPresets(); + } +} +async function switchTo(n,name){ + if(!await askConfirm('Basculer vers « '+name+' » et redémarrer le service ?', {title:'Changer de preset', okText:'Basculer'})) return; + toast('switching…'); + // Retour visuel IMMÉDIAT : la ligne visée s'allume et clignote. Le serveur met + // plusieurs secondes à redémarrer le service ; sans ça la liste ne bougeait pas + // d'un pouce pendant tout ce temps et le clic semblait sans effet. + pendingPreset = n; loadPresets(); + const r=await jpost('/api/switch',{n:n}); + if(!r.ok){ pendingPreset = 0; toast('erreur'); loadPresets(); return; } + toast('switched'); + // Le preset ACTIF, c'est celui dont l'empreinte est celle de la configuration : le serveur + // l'écrit AVANT de répondre (et relance le service en arrière-plan, voir + // handleSwitch), donc un rafraîchissement immédiat suffit — la barre passe au + // blanc tout de suite. Le chargement du modèle par llama-server continue + // derrière ; il ne conditionne pas la sélection. + try{ await loadPresets(); }catch(e){} + // Filet : si l'empreinte n'a pas encore basculé (bascule lente côté disque), on + // reste en attente le temps qu'il faut, sans dépasser ~60 s. + for(let i=0; i<40 && pendingPreset===n; i++){ + await new Promise(r=>setTimeout(r,1500)); + try{ await loadPresets(); }catch(e){} + } + pendingPreset = 0; + loadAll(); +} +// editingKey = the identifier of the item being edited: a preset id (filename) +// or a skill name. Empty string = creating a new item. +let editingKey = '', editingKind = 'preset'; +const KINDS = { + // presets are keyed by `id` (filename) so several can share a display name; + // skills keep name-as-identity (param 'name'). + // newLabel / nameHint : « Nouveau Page » et « Nom du preset » dans l'éditeur de + // mémoire venaient d'un libellé unique décliné mécaniquement. + preset: {label:'', newLabel:'Nouveau preset', nameHint:'Nom du preset', + param:'id', getUrl:'/api/preset', saveUrl:'/api/preset/save', delUrl:'/api/preset/delete', reload:()=>loadPresets()}, + mem: {label:'Page', newLabel:'Nouvelle page', nameHint:'Nom de la page', + param:'name', getUrl:'/api/mem', saveUrl:'/api/mem/save', delUrl:'/api/mem/delete', reload:()=>loadMem()}, +}; +// ⚠️ La modale s'ouvre AVANT d'aller chercher quoi que ce soit. Elle attendait +// auparavant la fin de 3 requêtes (contenu + backends + modèles disponibles) : +// en accès distant, ça faisait un clic sans réaction pendant une seconde, comme +// si le bouton était mort. On affiche la coquille tout de suite, puis on la +// remplit. `openSeq` protège du cas « deux ouvertures coup sur coup » : une +// réponse en retard ne doit jamais écraser la modale ouverte après elle. +// Pendant ce remplissage la modale est en `.loading` : un rond tourne à la place +// du formulaire, qui n'est révélé qu'une fois TOUT en place (contenu, réglages, +// backends, GPU) — sinon on voyait les champs se peupler un par un. +let openSeq = 0; +async function openItem(kind, key){ + const K = KINDS[kind]; + const seq = ++openSeq; + editingKind = kind; editingKey = key || ''; + document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + key : key) : K.newLabel; + document.getElementById('m-name').value = key || ''; + document.getElementById('m-name').placeholder = K.nameHint; + document.getElementById('m-content').value = ''; + document.getElementById('m-del').style.display = key ? 'inline-flex' : 'none'; + // Model picker is preset-only: it edits the MODEL= line of the preset. + const modelRow = document.getElementById('m-model-row'); + const settingsRow = document.getElementById('m-settings-row'); + const rawHead = document.getElementById('m-raw-head'); + const rawToggle = document.getElementById('m-raw-toggle'); + const rawBody = document.getElementById('m-raw-body'); + const rawCaret = document.getElementById('m-raw-caret'); + if(kind === 'preset'){ + modelRow.style.display = 'flex'; + settingsRow.style.display = 'flex'; + document.getElementById('m-hf-url').value = ''; + resetDlUI(); + // Preset : la config brute est une ligne repliable, fermée par défaut. + rawHead.textContent = 'Configuration'; + rawToggle.style.display = ''; + rawBody.style.display = 'none'; + rawCaret.classList.remove('open'); + // Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture. + document.getElementById('m-dir-path').value = ''; + setModelDirsOpen(false); + } else { + modelRow.style.display = 'none'; + settingsRow.style.display = 'none'; + // Page mémoire : le contenu EST le champ principal — affiché en clair. + rawHead.textContent = 'Contenu'; + rawToggle.style.display = 'none'; + rawBody.style.display = ''; + } + const modalEl=document.getElementById('modal'); + modalEl.classList.add('loading'); + showModal('modal'); + // Le voile s'arrête au bas de l'en-tête : on lui passe sa hauteur réelle. ⚠️ À + // mesurer APRÈS l'affichage — un élément en display:none mesure 0. + const mHead=modalEl.querySelector('.modal-head'); + if(mHead) modalEl.querySelector('.modal-card').style.setProperty('--mh', mHead.offsetHeight+'px'); + // ⚠️ APRÈS showModal, jamais avant : le corps de la modale est un conteneur + // défilant RÉUTILISÉ d'une ouverture à l'autre, et écrire scrollTop sur un + // élément en display:none ne fait RIEN (il n'a pas de boîte de rendu) — le + // navigateur restaurait donc l'ancienne position à l'affichage, et on rouvrait + // la fiche au milieu. On le refait après le remplissage, la hauteur ayant changé. + const topPeBody=()=>{ const b=document.querySelector('#modal .pe-body'); if(b) b.scrollTop=0; }; + topPeBody(); + // --- Remplissage, une fois la modale à l'écran ----------------------------- + const r = await jfetch(K.getUrl + '?' + K.param + '=' + encodeURIComponent(key||'')); + const d = await r.json(); + if(seq !== openSeq) return; // une autre ouverture a pris la main + const display = d.name || key || ''; + document.getElementById('modal-title').textContent = key ? (K.label ? K.label + ' · ' + display : display) : K.newLabel; + document.getElementById('m-name').value = display; + document.getElementById('m-content').value = d.content || ''; + if(kind === 'preset'){ + // Ces deux-là LISENT le contenu : elles doivent passer après son arrivée. + document.getElementById('m-quant').value = currentQuantInTextarea(); + populateSettings(); + attachDownload(); // téléchargement encore en cours côté serveur ? + await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]); + if(seq !== openSeq) return; + } + // On lève le voile SANS transition (voir la note dans styles.css), puis on rend + // les transitions deux frames plus tard, une fois le nouvel état peint. + modalEl.classList.add('no-anim'); + modalEl.classList.remove('loading'); + topPeBody(); + requestAnimationFrame(()=>requestAnimationFrame(()=>modalEl.classList.remove('no-anim'))); +} + +// Pretty-print bytes — handy for the dropdown options. +function fmtSize(b){ + if(b > 1e9) return (b/1e9).toFixed(1)+' GB'; + if(b > 1e6) return (b/1e6).toFixed(0)+' MB'; + if(b > 1e3) return (b/1e3).toFixed(0)+' KB'; + return b+' B'; +} +// Retire les guillemets ENTOURANTS d'une valeur, et seulement eux. Les anciens +// motifs `"?([^"\n]*)"?` s'arrêtaient au premier guillemet INTERNE : une valeur +// comme `--chat-template-file "/etc/loki/tpl.jinja"` était lue tronquée, puis +// réécrite avec des guillemets non appariés (issue #17). +function unquoteVal(v){ + v = String(v==null?'':v).trim(); + const q = v[0]; + if(v.length >= 2 && (q === '"' || q === "'") && v[v.length-1] === q) return v.slice(1,-1); + return v; +} +// Lit la valeur d'une clé KEY= dans un texte au format .env. +function readEnvKey(txt, key){ + const m = String(txt).match(new RegExp('^[ \\t]*'+key+'[ \\t]*=(.*)$','m')); + return m ? unquoteVal(m[1]) : ''; +} +// Read the current MODEL= value out of the textarea, handling quotes / spaces. +function currentModelInTextarea(){ + return readEnvKey(document.getElementById('m-content').value, 'MODEL'); +} +// Échappe une valeur avant de l'injecter dans du HTML (chemins de fichiers : +// on ne contrôle pas ce que l'utilisateur y met). +function escHtml(s){ + return String(s==null?'':s).replace(/[&<>"']/g, c => + ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} +// Compare deux chemins/noms de modèle en neutralisant séparateurs et casse. +function samePath(a, b){ + const n = x => String(x||'').trim().replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase(); + return !!a && !!b && n(a) === n(b); +} +function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); } +async function populateModelPicker(){ + const sel = document.getElementById('m-model'); + const list = await jget('/api/models'); + const cur = currentModelInTextarea(); + // Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les + // regroupe par dossier, LOKI_HOME d'abord (l'API les renvoie dans cet ordre). + const groups = []; + for(const m of (list||[])){ + let g = groups.find(x => x.dir === m.dir); + if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); } + g.items.push(m); + } + let html = ''; + let matched = false; + for(const g of groups){ + let opts = ''; + for(const m of g.items){ + // Le preset peut référencer le chemin complet OU le simple nom de fichier. + const on = samePath(cur, m.value) || samePath(cur, m.path) || + (g.home && samePath(baseName(cur), m.name)) ? ' selected' : ''; + if(on) matched = true; + // Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche + // (les autres ne se lancent pas seules) et la taille de la famille entière. + // On le dit, et surtout on signale les tranches manquantes — sinon le + // modèle se sélectionne sans broncher puis le moteur meurt au démarrage. + let tag = ''; + if(m.shards > 1) tag = ' · ' + m.shards + ' fichiers'; + if(m.missing && m.missing.length) tag += ' · ⚠ ' + m.missing.length + ' manquant' + (m.missing.length>1?'s':''); + opts += ''; + } + html += groups.length > 1 + ? ''+opts+'' + : opts; + } + // MODEL pointe vers un fichier qu'on ne trouve dans aucun dossier déclaré : + // on déplie alors la section des dossiers, c'est là que ça se répare. + if(cur && !matched){ + html += ''; + } + sel.innerHTML = html; + if(cur && !matched) setModelDirsOpen(true); +} +// ---- Dossiers de modèles : LOKI_HOME + dossiers ajoutés (disque externe…) --- +// Réglage rare : replié derrière une ligne, comme l'éditeur de .env brut. +function setModelDirsOpen(open){ + const b = document.getElementById('m-dirs-body'); + const c = document.getElementById('m-dirs-caret'); + if(!b) return; + b.style.display = open ? '' : 'none'; + if(c) c.classList.toggle('open', open); + if(open) populateModelDirs(); +} +function toggleModelDirs(){ + setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none'); +} +async function populateModelDirs(){ + const box = document.getElementById('m-dirs-list'); + if(!box) return; + let d = {}; + try{ d = await jget('/api/models/dirs'); }catch(_){ return; } + // Construit en DOM (pas en HTML concaténé) : les chemins viennent de + // l'utilisateur et se retrouveraient sinon dans un attribut onclick. + box.innerHTML = ''; + for(const x of (d.dirs||[])){ + const row = document.createElement('div'); + const p = document.createElement('span'); + p.textContent = x.path + ' '; + const info = document.createElement('span'); + info.className = 'muted'; + const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible'; + const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : ''; + info.textContent = '(' + n + free + (x.home ? ', dossier loki' : '') + ')'; + row.append(p, info); + if(!x.home){ + const del = document.createElement('button'); + del.className = 'pe-link'; + del.textContent = 'retirer'; + del.style.marginLeft = '8px'; + del.onclick = () => removeModelDir(x.path); + row.append(del); + } + box.append(row); + } +} +async function addModelDir(){ + const inp = document.getElementById('m-dir-path'); + const p = inp.value.trim(); + if(!p){ toast('indique un chemin de dossier'); return; } + const r = await jpost('/api/models/dirs', {path: p, action: 'add'}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + inp.value = ''; + toast('dossier ajouté'); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); +} +async function removeModelDir(p){ + const r = await jpost('/api/models/dirs', {path: p, action: 'remove'}); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + toast('dossier retiré'); + await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]); +} +function onPickModel(){ + const val = document.getElementById('m-model').value; + if(!val) return; + const ta = document.getElementById('m-content'); + if(/^\s*MODEL\s*=.*$/m.test(ta.value)){ + ta.value = ta.value.replace(/^\s*MODEL\s*=.*$/m, 'MODEL="'+val+'"'); + } else { + // No MODEL= line yet — prepend so it's visible at the top. + ta.value = 'MODEL="'+val+'"\n' + ta.value; + } + toast('MODEL='+val); +} + +// --- Vision (projecteur multimodal --mmproj) -------------------------------- +// Le projecteur est un .gguf séparé du modèle : il apparaît donc dans la même +// liste que les modèles (/api/models). On le range dans sa PROPRE clé MMPROJ, +// que backend_serve.go traduit en --mmproj — plutôt que dans EXTRA_ARGS, pour +// que le chemin soit résolu comme celui du modèle (nom simple = cherché dans +// les dossiers déclarés). +function currentMmprojInTextarea(){ + // Clé dédiée d'abord ; à défaut, un --mmproj posé à la main dans EXTRA_ARGS + // (anciens presets), pour que le champ montre la vision déjà configurée. + return readEnvKey(document.getElementById('m-content').value, 'MMPROJ') + || eaGetValued('--mmproj'); +} +function onPickMmproj(){ + // On retire un éventuel --mmproj d'EXTRA_ARGS pour ne pas le charger deux fois, + // et on centralise le choix dans la clé MMPROJ. + eaSetValued('--mmproj', ''); + cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value); +} +// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la +// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là — +// mêler les modèles de plusieurs Go n'aiderait pas à choisir des yeux. +function isMmprojName(name){ return /mmproj/i.test(String(name||'')); } +async function populateMmproj(){ + const sel = document.getElementById('m-mmproj'); + if(!sel) return; + const list = await jget('/api/models'); + const cur = currentMmprojInTextarea(); + const items = (list||[]).filter(m => isMmprojName(m.name)); + let html = ''; + let matched = false; + for(const m of items){ + const on = samePath(cur, m.value) || samePath(cur, m.path) || + samePath(baseName(cur), m.name) ? ' selected' : ''; + if(on) matched = true; + html += ''; + } + // MMPROJ pointe sur un fichier absent des dossiers déclarés (ou nommé hors + // convention) : on le garde affiché plutôt que de l'effacer silencieusement. + if(cur && !matched){ + html += ''; + } + sel.innerHTML = html; +} + +// Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé) +// qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel +// backend fait tourner ce modèle — la barre latérale ne fait qu'installer. +function currentBinInTextarea(){ + return readEnvKey(document.getElementById('m-content').value, 'BIN'); +} +// Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows). +function sameBinPath(a, b){ + const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase(); + return !!a && !!b && n(a) === n(b); +} +// Écrit (ou remplace) la ligne BIN= dans le contenu du preset. +function setBinInTextarea(val){ + const ta = document.getElementById('m-content'); + if(/^\s*BIN\s*=.*$/m.test(ta.value)) ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"'); + else ta.value = 'BIN="'+val+'"\n' + ta.value; +} +// Le moteur précompilé s'installe dans un dossier versionné (…/llama-b10280/) : +// le BIN d'un preset écrit avant une mise à jour ne vaut plus le chemin courant, +// alors qu'il désigne bien ce moteur. On reconnaît donc l'appartenance au +// dossier, sinon tous les presets retombaient en « personnalisé » à chaque MAJ. +function underDir(p, dir){ + const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase(); + return !!p && !!dir && n(p).startsWith(n(dir)+'/'); +} +let beFastPath = '', beOptPath = '', beFastDir = ''; +async function populateBackend(){ + // Chemins des deux moteurs gérés + liste des backends détectés (dossier loki). + let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){} + beFastPath = (lc.prebuilt && lc.prebuilt.bin) || ''; + beFastDir = (lc.prebuilt && lc.prebuilt.dir) || ''; + beOptPath = lc.bin || ''; + // Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans + // le dossier backends de loki (l'utilisateur peut y déposer son propre build). + const detected = await jget('/api/backends'); + const sel = document.getElementById('m-backend-detected'); + let html = ''; + for(const b of (detected||[])) html += ''; + sel.innerHTML = html; + document.getElementById('be-drop-hint').textContent = lc.backends_dir + ? ('Astuce : déposez votre binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : ''; + // Sélectionne l'option correspondant au BIN actuel du preset. + const cur = currentBinInTextarea(); + let mode = 'custom'; + if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast'; + else if(sameBinPath(cur, beOptPath)) mode = 'opt'; + const radio = document.querySelector('input[name=m-be][value='+mode+']'); + if(radio) radio.checked = true; + toggleBackendCustom(mode); + if(mode === 'custom') document.getElementById('m-backend-path').value = cur; + // Attendu (et non lancé dans le vide) : la liste des GPU change la hauteur du + // bloc « moteur ». Sans ce await, elle arrivait APRÈS la levée du voile de + // chargement et on voyait le bloc bouger tout seul. + await loadGpuDevices(); +} +function toggleBackendCustom(mode){ + document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none'; +} +function onBackendMode(mode){ + toggleBackendCustom(mode); + if(mode === 'fast'){ + if(!beFastPath){ toast('installez d\'abord llama.cpp précompilé (section MOTEUR)'); return; } + setBinInTextarea(beFastPath); toast('moteur : llama.cpp précompilé'); + loadGpuDevices(); + } else if(mode === 'opt'){ + if(!beOptPath){ toast('installez d\'abord llama.cpp compilé (section MOTEUR)'); return; } + setBinInTextarea(beOptPath); toast('moteur : llama.cpp compilé'); + loadGpuDevices(); + } + // custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend +} +function onCustomPath(){ + const v = document.getElementById('m-backend-path').value.trim(); + if(v){ setBinInTextarea(v); loadGpuDevices(); } +} +function onPickDetected(){ + const v = document.getElementById('m-backend-detected').value; + if(!v) return; + document.getElementById('m-backend-path').value = v; + setBinInTextarea(v); toast('moteur personnalisé'); + loadGpuDevices(); +} + +// --- Cartes graphiques (--device / --tensor-split) -------------------------- +// Ces réglages vivent dans le PRESET, pas dans une variable d'environnement : +// --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que +// CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles +// qui s'étalaient sur les deux cartes malgré une sélection « GPU 1 ». +// La lecture/écriture des flags passe par eaGetValued/eaSetValued (le tokenizer +// d'EXTRA_ARGS déjà utilisé par les autres réglages) : un seul parseur. + +// Diamètre de la pastille du curseur, en pixels — DOIT rester synchronisé avec +// .gpu-range::-webkit-slider-thumb / ::-moz-range-thumb dans styles.css. +const GPU_THUMB = 16; +let gpuDevices = []; +const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture + +// Interroge le moteur du preset (--list-devices) : les noms de device et leur +// ordre lui appartiennent, une liste issue de nvidia-smi désignerait la mauvaise +// carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la réponse n'est +// pas là, et le reste s'il y a moins de deux cartes : rien à arbitrer, et ça +// évite un encart qui surgit après coup. +// Remplit le cache sans rien peindre (appelé au chargement de la page pour le +// moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt. +async function prefetchGpuDevices(bin){ + if(!bin || gpuCache[bin]) return; + try{ + const r = await jpost('/api/backends/devices', {bin}); + if(r && r.ok) gpuCache[bin] = r.devices || []; + }catch(_){} +} + +async function loadGpuDevices(){ + const group = document.getElementById('m-gpu-group'); + const bin = currentBinInTextarea(); + if(!bin){ group.hidden = true; gpuDevices = []; return; } + if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; } + let r = {}; + try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; } + // Le moteur a pu changer pendant l'appel (clic rapide) : on ne peint que si la + // réponse concerne toujours le moteur affiché. + if(currentBinInTextarea() !== bin) return; + gpuDevices = (r.ok && r.devices) ? r.devices : []; + if(r.ok) gpuCache[bin] = gpuDevices; + renderGpu(); +} + +function selectedGpuIds(){ + const sel = eaGetValued('--device').split(',').filter(Boolean); + const known = gpuDevices.map(d => d.id); + const kept = sel.filter(id => known.includes(id)); + return kept.length ? kept : known; // aucune contrainte = toutes les cartes +} + +function gpuEsc(t){ + return String(t).replace(/[<>&"]/g, c => ({'<':'<','>':'>','&':'&','"':'"'}[c])); +} + +function renderGpu(){ + const group = document.getElementById('m-gpu-group'); + // Une seule carte (ou aucune) : rien à répartir, rien à choisir. + if(gpuDevices.length < 2){ group.hidden = true; return; } + const sel = selectedGpuIds(); + let html = gpuDevices.map(d => { + // Mémoire à 0 = le moteur n'a pas pu la lire (carte déjà saturée par le + // modèle en cours) : on n'affiche pas « 0,0 Go », qui serait un mensonge. + const gb = d.total_mib > 0 ? ' · ' + (d.total_mib/1024).toFixed(1).replace('.', ',') + ' Go' : ''; + return '
    ' + + '' + gpuEsc(gpuLabel(d)) + + '' + gpuEsc(d.id) + gb + '' + + '' + + '
    '; + }).join(''); + if(sel.length > 1) html += splitHtml(sel); + document.getElementById('m-gpu-list').innerHTML = html; + document.getElementById('m-gpu-note').textContent = sel.length === gpuDevices.length + ? 'Toutes les cartes sont utilisées.' + : 'Ce modèle n\'utilisera que : ' + sel.map(id => gpuLabel(gpuById(id))).join(', ') + '.'; + group.hidden = false; + if(sel.length > 1) paintSplit(); +} + +function gpuById(id){ return gpuDevices.find(d => d.id === id) || {id}; } +// Nom lisible : « NVIDIA GeForce RTX 5060 Ti » → « RTX 5060 Ti ». CUDA0/Vulkan1 +// ne disent rien à personne ; le numéro reste en sous-titre pour ceux qui +// veulent savoir ce qui part dans --device. +function gpuLabel(d){ + return String((d && d.name) || (d && d.id) || '') + .replace(/^NVIDIA\s+GeForce\s+/i, '') + .replace(/^NVIDIA\s+/i, '') + .replace(/^AMD\s+(Radeon\s+)?/i, '') + .replace(/^Intel\(R\)\s+/i, '') + .trim() || (d && d.id) || ''; +} + +// Répartition : UNE seule barre, sur laquelle on glisse directement (le curseur +// est transparent, posé par-dessus). Trois cartes ou plus : un pourcentage par +// carte. On n'écrit --tensor-split QUE si l'utilisateur y touche : sans lui, +// llama.cpp répartit tout seul. +function splitHtml(sel){ + const shares = currentShares(sel); + let inner; + if(sel.length === 2){ + inner = '
    ' + + '
    ' + + '
    '; + } else { + inner = '
    ' + + '
    ' + sel.map((id, i) => + '').join('') + '
    '; + } + return '
    ' + + 'Répartitionpart des couches par carte' + + '
    ' + + '
    ' + inner + + '
    '; +} + +// Parts actuelles, normalisées à 1. Sans --tensor-split : proportionnelles à la +// VRAM de chaque carte, ce que llama.cpp fait de son côté — la barre montre donc +// ce qui se passe réellement plutôt qu'un partage égal trompeur. +function currentShares(sel){ + const raw = eaGetValued('--tensor-split').split(',').map(parseFloat).filter(n => !isNaN(n)); + let vals = (raw.length === sel.length) ? raw + : sel.map(id => gpuById(id).total_mib || 0); + // Mémoires inconnues (lecture à 0) : à défaut de proportion crédible, on + // montre un partage égal plutôt qu'une barre pleine sur une seule carte. + if(vals.every(v => !v)) vals = sel.map(() => 1); + const sum = vals.reduce((a, b) => a + b, 0) || 1; + return vals.map(v => v/sum); +} + +// Repeint UNIQUEMENT la barre, la légende et l'état. Surtout PAS le bloc entier : +// remplacer le HTML pendant un glissement détruit le curseur en cours d'usage — +// c'est ce qui faisait qu'il ne répondait qu'au clic. +function paintSplit(){ + const sel = selectedGpuIds(); + if(sel.length < 2) return; + const shares = currentShares(sel); + const bar = document.getElementById('m-split-bar'); + // La pastille du curseur ne parcourt PAS toute la largeur : elle va de + // GPU_THUMB/2 à largeur - GPU_THUMB/2, sinon elle déborderait de la piste. Une + // coupure à « s % » pile tombait donc à côté d'elle (jusqu'à ~5 px d'écart aux + // extrêmes), ce qui laissait voir la piste claire juste à droite de la + // pastille. On coupe dans le repère de la pastille : s*(largeur - THUMB) + THUMB/2. + // Le dernier segment prend le reste (flex:1) : aucun filet résiduel à droite, + // quels que soient les arrondis. + const withThumb = sel.length === 2 && !!document.getElementById('m-split-range'); + if(bar) bar.innerHTML = shares.map((s, i) => { + const cls = i%2 ? 'alt' : ''; + if(i === shares.length - 1) return ''; + const w = withThumb + ? 'calc(' + (s*100).toFixed(3) + '% - ' + ((s - 0.5) * GPU_THUMB).toFixed(2) + 'px)' + : (s*100).toFixed(3) + '%'; + return ''; + }).join(''); + const leg = document.getElementById('m-split-legend'); + if(leg) leg.innerHTML = sel.map((id, i) => + '' + gpuEsc(gpuLabel(gpuById(id))) + + ' ' + (shares[i]*100).toFixed(1).replace('.', ',') + ' %').join(''); + const st = document.getElementById('m-split-state'); + if(st) st.innerHTML = eaGetValued('--tensor-split') + ? '' + : 'automatique'; +} +function writeSplit(shares){ + const sum = shares.reduce((a, b) => a + b, 0) || 1; + eaSetValued('--tensor-split', shares.map(s => (s/sum).toFixed(3)).join(',')); + paintSplit(); +} +function onSplitRange(){ + const v = parseFloat(document.getElementById('m-split-range').value) / 100; + writeSplit([v, 1-v]); +} +function onSplitNumbers(){ + const vals = [...document.querySelectorAll('.gpu-share input')].map(i => parseFloat(i.value) || 0); + if(vals.reduce((a, b) => a + b, 0) <= 0) return; // saisie en cours + writeSplit(vals); +} +function resetSplit(){ + eaSetValued('--tensor-split', ''); + const sel = selectedGpuIds(); + const r = document.getElementById('m-split-range'); + if(r && sel.length === 2) r.value = (currentShares(sel)[0]*100).toFixed(1); + const nums = [...document.querySelectorAll('.gpu-share input')]; + if(nums.length){ + const sh = currentShares(sel); + nums.forEach((inp, i) => { inp.value = (sh[i]*100).toFixed(1); }); + } + paintSplit(); +} + +function onGpuPick(){ + const ids = [...document.querySelectorAll('#m-gpu-list input[type=checkbox]:checked')].map(c => c.value); + if(!ids.length){ toast('gardez au moins une carte'); renderGpu(); return; } + // Toutes cochées = pas de contrainte : on retire le flag plutôt que de figer + // des noms de device qui changeraient avec le moteur. + eaSetValued('--device', ids.length === gpuDevices.length ? '' : ids.join(',')); + // Une répartition écrite pour N cartes n'a plus de sens pour N-1. + if(ids.length < 2) eaSetValued('--tensor-split', ''); + renderGpu(); +} + +// Read/write the QUANT= override line in the preset textarea. +function currentQuantInTextarea(){ + const m = document.getElementById('m-content').value.match(/^\s*#?\s*QUANT\s*=(.*)$/mi); + return m ? unquoteVal(m[1]) : ''; +} +function applyQuant(){ + const val = document.getElementById('m-quant').value.trim(); + const ta = document.getElementById('m-content'); + const re = /^\s*#?\s*QUANT\s*=.*$/mi; + if(val){ + if(re.test(ta.value)) ta.value = ta.value.replace(re, 'QUANT="'+val+'"'); + else ta.value = ta.value.replace(/\s*$/,'') + '\nQUANT="'+val+'"\n'; + } else { + ta.value = ta.value.replace(re, '').replace(/\n{3,}/g,'\n\n'); + } +} +// ---- Réglages simples : champs de formulaire <-> lignes du fichier .env ----- +// Le fichier de config (textarea m-content) reste la source de vérité : chaque +// champ lit/écrit sa ligne KEY=… , comme le font déjà MODEL/BIN/QUANT. +function cfgReadKey(key){ + return readEnvKey(document.getElementById('m-content').value, key); +} +function cfgWriteKey(key, val){ + const ta = document.getElementById('m-content'); + val = String(val==null?'':val).trim(); + const reLine = new RegExp('^[ \\t]*'+key+'[ \\t]*=.*$','m'); + if(val === ''){ // clé vidée → on retire la ligne + ta.value = ta.value.replace(new RegExp('^[ \\t]*'+key+'[ \\t]*=.*\\n?','m'),'').replace(/\n{3,}/g,'\n\n'); + return; + } + const line = key+'='+(/\s/.test(val) ? '"'+val+'"' : val); + if(reLine.test(ta.value)) ta.value = ta.value.replace(reLine, line); + else ta.value = ta.value.replace(/\s*$/,'') + '\n'+line+'\n'; +} +// EXTRA_ARGS : liste de drapeaux passés tels quels à llama-server. On les édite +// jeton par jeton pour les interrupteurs (mlock, flash-attn, n-cpu-moe…), sans +// toucher aux autres drapeaux (--jinja, --device…) qui restent en config brute. +function eaTokens(){ return cfgReadKey('EXTRA_ARGS').split(/\s+/).filter(Boolean); } +function eaSetTokens(t){ cfgWriteKey('EXTRA_ARGS', t.join(' ')); } +function eaHasFlag(flag){ return eaTokens().includes(flag); } +function eaToggleFlag(flag, on){ + const t = eaTokens().filter(x=>x!==flag); + if(on) t.push(flag); + eaSetTokens(t); +} +function eaGetValued(flag){ + const t = eaTokens(), i = t.indexOf(flag); + return (i>=0 && i+1=0){ const hadVal = i+1{ const e=document.getElementById(id); if(e) e.value=v; }; + set('s-ctx', cfgReadKey('CTX')); + set('s-ngl', cfgReadKey('NGL')); + set('s-threads', cfgReadKey('THREADS')); + set('s-batch', cfgReadKey('BATCH')); + set('s-ubatch', cfgReadKey('UBATCH')); + set('s-tbatch', cfgReadKey('THREADS_BATCH')); + set('s-kv', cfgReadKey('KV_TYPE')); + set('s-moe', eaGetValued('--n-cpu-moe')); + set('s-spec-n', eaGetValued('--spec-draft-n-max')); + setSpecType(eaGetValued('--spec-type')); + const chk = (id,v)=>{ const e=document.getElementById(id); if(e) e.checked=v; }; + // Raisonnement : trois états dans le fichier, deux positions sur l'interrupteur. + // « off » est une interdiction explicite passée au moteur ; la clé ABSENTE, elle, + // laisse le moteur suivre le gabarit du modèle — donc un modèle à raisonnement + // réfléchit quand même. L'interrupteur ne peut pas montrer cette nuance, le + // sous-titre la dit. (Les presets créés ou modifiés depuis cette version + // écrivent toujours on ou off, la question ne se pose plus pour eux.) + const rz = cfgReadKey('REASONING'); + chk('s-reasoning', /^(on|1|true|auto|deepseek)$/i.test(rz)); + const rzSub = document.getElementById('s-reasoning-sub'); + if(rzSub){ + rzSub.textContent = rz ? 'réflexion étape par étape' + : 'réflexion étape par étape — non précisé : le modèle décide'; + } + chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn'))); + chk('s-mlock', eaHasFlag('--mlock')); + chk('s-nommap', eaHasFlag('--no-mmap')); +} +// --- Décodage spéculatif (--spec-type / --spec-draft-n-max) ---------------- +// Sélectionne le type courant. --spec-type accepte en réalité une LISTE séparée +// par des virgules ; une valeur composée (ou un type sorti après cette version +// de loki) ne correspondrait à aucune option et serait silencieusement effacée +// au premier changement. On l'ajoute donc telle quelle à la liste plutôt que de +// la perdre. +function setSpecType(v){ + const sel = document.getElementById('s-spec'); + if(!sel) return; + v = String(v || '').trim(); + if(v && ![...sel.options].some(o => o.value === v)){ + const opt = document.createElement('option'); + opt.value = v; opt.textContent = v + ' (dans la configuration)'; + sel.appendChild(opt); + } + sel.value = v; + syncSpecRow(); +} +// Le nombre de jetons anticipés ne veut rien dire sans type : ligne masquée, et +// flag retiré pour ne pas laisser un réglage orphelin dans le preset. +function syncSpecRow(){ + const sel = document.getElementById('s-spec'); + const row = document.getElementById('s-spec-n-row'); + if(!sel || !row) return; + const on = !!sel.value; + row.style.display = on ? '' : 'none'; + return on; +} +function onSpecType(){ + const v = document.getElementById('s-spec').value; + eaSetValued('--spec-type', v); + if(!v){ + eaSetValued('--spec-draft-n-max', ''); + document.getElementById('s-spec-n').value = ''; + } + syncSpecRow(); +} + +// Replie/déplie l'éditeur du fichier .env (config brute) dans le modal preset. +function toggleRaw(){ + const b = document.getElementById('m-raw-body'); + const c = document.getElementById('m-raw-caret'); + const show = b.style.display === 'none'; + b.style.display = show ? '' : 'none'; + if(c) c.classList.toggle('open', show); +} +const openPreset = (id)=>openItem('preset', id); +const openMem = (n)=>openItem('mem', n); +// Fermer la modale n'annule PAS un téléchargement en cours (il vit côté +// serveur) : on arrête juste de l'interroger. +function closeModal(){ hideModal('modal'); document.getElementById('modal').classList.remove('loading'); stopDlPoll(); } +async function saveItem(){ + const K = KINDS[editingKind]; + const name = document.getElementById('m-name').value.trim(); + const content = document.getElementById('m-content').value; + if(!name){ toast('nom requis'); return; } + // Presets: keyed by id (filename); duplicate display names are allowed. + // Skills: keyed by name, rename via `old`. + const payload = editingKind==='preset' + ? {id: editingKey, name, content} + : {name, old: editingKey, content}; + const r = await jpost(K.saveUrl, payload); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + toast('enregistré'); closeModal(); K.reload(); +} +async function delItem(){ + if(!editingKey) return; + const K = KINDS[editingKind]; + const name = document.getElementById('m-name').value.trim() || editingKey; + // Le choix « supprimer aussi le .gguf » est posé DANS la confirmation : il n'a + // de sens qu'au moment de supprimer, et il occupait le pied de l'éditeur en + // permanence. Décoché à chaque ouverture — jamais de modèle effacé parce que + // la case serait restée cochée d'une fois sur l'autre. + const msg = 'Supprimer le ' + K.label.toLowerCase() + ' « ' + name + ' » ?' + + (editingKind==='preset' ? '\n\nLe fichier .gguf du modèle est conservé, sauf si vous cochez ci-dessous (irréversible).' : ''); + const opts = {title:'Suppression', okText:'Supprimer', danger:true}; + if(editingKind==='preset') opts.check = 'supprimer aussi le fichier .gguf'; + if(!await askConfirm(msg, opts)) return; + const delModel = editingKind==='preset' && askChecked(); + const payload = editingKind==='preset' + ? {id: editingKey, deleteModel: delModel} + : {name: editingKey}; + const r = await jpost(K.delUrl, payload); + if(!r.ok){ toast('erreur : ' + (r.error||'')); return; } + if(delModel){ + if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError); + else if(r.modelDeleted) toast('preset + modèle supprimés'); + else toast('supprimé (aucun modèle référencé)'); + } else { toast('supprimé'); } + closeModal(); K.reload(); +} +// Download a .gguf from Hugging Face. Le téléchargement vit côté serveur : fermer +// la modale ne l'interrompt pas, on se rebranche dessus à la réouverture +// (attachDownload) et seul « Annuler » l'arrête vraiment. +let dlPoll = null, dlName = ''; +function dlEls(){ + return { + btn: document.getElementById('m-hf-btn'), + prog: document.getElementById('m-hf-progress'), + bar: document.getElementById('m-hf-bar'), + cancel: document.getElementById('m-hf-cancel'), + }; +} +// Arrête le poll local sans toucher au téléchargement serveur. +function stopDlPoll(){ if(dlPoll){ clearInterval(dlPoll); dlPoll=null; } } +// Remet la zone de téléchargement à zéro (réouverture de la modale). +function resetDlUI(){ + const e = dlEls(); + stopDlPoll(); dlName=''; + e.prog.style.display='none'; e.bar.style.display='none'; + e.cancel.style.display='none'; e.btn.disabled=false; +} +// Remplit le sélecteur de destination avec les dossiers de modèles connus. +// L'espace libre est dans le libellé de l'option : c'est l'info utile au moment +// de choisir, et ça évite une ligne de plus dans une modale déjà chargée. +// Même source que « Dossiers de modèles » : ajouter un disque externe là-bas le +// rend aussitôt disponible ici. +let dlDirList = []; +async function populateDlDirs(){ + const sel = document.getElementById('m-hf-dir'); + if(!sel) return; + let d = {}; + try{ d = await jget('/api/models/dirs'); }catch(_){ return; } + dlDirList = d.dirs || []; + const prev = sel.value; + sel.innerHTML = ''; + for(const x of dlDirList){ + const o = document.createElement('option'); + o.value = x.path; + o.textContent = x.path + (x.free >= 0 ? ' — ' + fmtSize(x.free) + ' libres' : ''); + sel.append(o); + } + if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev; +} +async function startDownload(){ + const url = document.getElementById('m-hf-url').value.trim(); + if(!url){ toast('colle un lien .gguf'); return; } + const dir = (document.getElementById('m-hf-dir')||{}).value || ''; + const e = dlEls(); + e.btn.disabled = true; + e.prog.style.display = 'block'; + e.prog.textContent = 'vérification de la taille et de l’espace disque…'; + e.bar.style.display = 'block'; + e.bar.className = 'pe-bar indet'; + e.bar.firstElementChild.style.width = ''; + // Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant + // AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert. + const p = await jpost('/api/models/download/probe', {url, dir}); + if(!p.ok || !p.enough){ + e.prog.innerHTML = 'erreur : '+escHtml(p.error||'espace insuffisant')+''; + e.bar.style.display = 'none'; e.btn.disabled=false; + await populateDlDirs(); + return; + } + // p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas + // les 15 Go du fichier dont le lien a été collé. + const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : ''; + e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '+fmtSize(p.free)+' libres'; + const r = await jpost('/api/models/download', {url, dir}); + if(!r.ok){ + e.prog.innerHTML = 'erreur : '+(r.error||'')+''; + e.bar.style.display = 'none'; e.btn.disabled=false; return; + } + watchDownload(r.filename); +} +// Annule le téléchargement en cours : le serveur coupe les connexions et +// supprime le fichier partiel. +async function cancelDownload(){ + if(!dlName) return; + const e = dlEls(); + e.cancel.disabled = true; + await jpost('/api/models/download/cancel', {filename: dlName}); + // L'état « annulé » est confirmé par le prochain tick de poll. +} +// Suit un téléchargement (nouveau ou déjà en cours) et pilote barre + texte. +function watchDownload(fname){ + const e = dlEls(); + dlName = fname; + stopDlPoll(); + e.btn.disabled = true; + e.prog.style.display = 'block'; + e.bar.style.display = 'block'; + e.cancel.style.display = 'inline'; + e.cancel.disabled = false; + const stop = ()=>{ stopDlPoll(); dlName=''; e.btn.disabled=false; e.cancel.style.display='none'; }; + const tick = async ()=>{ + const list = await jget('/api/models/download/status'); + const st = (list||[]).find(d=>d.filename===fname); + if(!st) return; + if(st.canceled){ + e.prog.textContent = 'téléchargement annulé — '+fname; + e.bar.style.display = 'none'; stop(); return; + } + if(st.error){ + e.prog.innerHTML = 'erreur : '+st.error+''; + e.bar.style.display = 'none'; stop(); return; + } + if(st.finished){ + e.prog.innerHTML = '✓ '+fname+' téléchargé ('+fmtSize(st.done)+')'; + e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%'; + stop(); + await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]); + // Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans + // le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier + // a pu atterrir hors du dossier loki : l'option porte alors le chemin + // complet, pas le simple nom de fichier. + const pick = (id, cb)=>{ + const s = document.getElementById(id); + const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname)); + if(o){ s.value = o.value; cb(); } + }; + if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj); + else pick('m-model', onPickModel); + return; + } + const pct = st.total>0 ? st.done*100/st.total : 0; + if(st.total>0){ + e.bar.className = 'pe-bar'; + e.bar.firstElementChild.style.width = Math.min(100, pct).toFixed(1)+'%'; + } else { + e.bar.className = 'pe-bar indet'; + } + const tot = st.total>0 ? ' / '+fmtSize(st.total)+' ('+Math.round(pct)+'%)' : ''; + let extra = ''; + if(st.speed>0){ + extra += ' — '+fmtSize(st.speed)+'/s'; + if(st.total>0){ + const eta = Math.max(0, Math.round((st.total-st.done)/st.speed)); + const m = Math.floor(eta/60), s = eta%60; + extra += ' — reste '+(m>0 ? m+' min '+s+' s' : s+' s'); + } + } + // Une seule barre pour tout le modèle ; le compteur de tranches dit où on en + // est, sans quoi un téléchargement en 3 fichiers semble se figer par paliers. + const part = st.parts > 1 ? ' — fichier '+(st.part||1)+'/'+st.parts : ''; + e.prog.textContent = '↓ '+fmtSize(st.done)+tot+extra+part+' — '+fname; + }; + dlPoll = setInterval(tick, 800); + tick(); +} +// À l'ouverture de la modale : se rebrancher sur un téléchargement encore en +// cours côté serveur (modale fermée entre-temps, page rechargée, autre appareil). +async function attachDownload(){ + const list = await jget('/api/models/download/status'); + const st = (list||[]).find(d=>!d.finished); + if(st) watchDownload(st.filename); +} +// Smart autoscroll: follow the bottom while the user hasn't manually scrolled +// up. Re-stick when they scroll back near bottom themselves. diff --git a/internal/loki/ui/src/js/08-chat-render.js b/internal/loki/ui/src/js/08-chat-render.js new file mode 100644 index 0000000..a30cc4a --- /dev/null +++ b/internal/loki/ui/src/js/08-chat-render.js @@ -0,0 +1,356 @@ +let stickyBottom = true; +const chatEl = () => document.getElementById('chat'); +function isNearBottom(){ + const c = chatEl(); + return c.scrollHeight - c.scrollTop - c.clientHeight < 60; +} +// La zone de chat réserve une gouttière de barre de défilement de chaque côté +// (scrollbar-gutter: stable both-edges) ; le composer, lui, n'est pas défilant. +// On mesure la gouttière réelle et on la reporte sur le composer, sinon les +// messages sont en retrait par rapport à la zone de saisie — décalage visible +// surtout quand la barre latérale est escamotée. +function syncGutter(){ + const chat=document.getElementById('chat'); if(!chat) return; + const g=Math.max(0,(chat.offsetWidth-chat.clientWidth)/2); + document.documentElement.style.setProperty('--sbw', g+'px'); +} +addEventListener('resize', syncGutter); +addEventListener('DOMContentLoaded', syncGutter); + +function scrollMaybe(){ + // Pendant le replay initial on NE force AUCUN reflow : lire scrollHeight à chaque + // événement rejoué = un layout synchrone forcé sur un DOM qui grossit → coût + // quadratique (20-30 s de rendu au refresh sur un long fil). Le scroll est fait + // une seule fois à la fin du replay, via jumpBottom() au signal {caught_up}. + if(REPLAYING) return; + if(stickyBottom){ + const c = chatEl(); + c.scrollTop = c.scrollHeight; + } + document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom); +} +function jumpBottom(){ stickyBottom = true; scrollMaybe(); } +document.addEventListener('DOMContentLoaded', ()=>{ + const c = chatEl(); + c.addEventListener('scroll', ()=>{ + stickyBottom = isNearBottom(); + document.getElementById('scrollbtn').classList.toggle('show', !stickyBottom); + }); +}); + +function addMsg(role, text){ + const el=document.createElement('div'); + el.className='msg '+role; + const collapsible = (role==='reasoning' || role==='tool'); + // .body must be a real block so

    /

    /
      margins behave properly. + if(collapsible){ + el.classList.add('collapsible'); + el.innerHTML=''+role+'
      '; + el.querySelector('.label').onclick=()=>toggleCollapse(el); + } else { + el.innerHTML=''+role+'
      '; + } + el.querySelector('.body').textContent=text; + chatEl().appendChild(el); + scrollMaybe(); + return el; +} +// Bulle « … » animée affichée dès l'envoi, retirée au 1er token/outil/erreur. +function addTyping(){ + const el=document.createElement('div'); + el.className='msg assistant typing'; + el.innerHTML=''; + chatEl().appendChild(el); scrollMaybe(); + return el; +} +// Replie/déplie en douceur les bulles reasoning/tool. Hauteur animée en JS : +// on fige scrollHeight puis on va à 0 (fermeture) ou de 0 vers scrollHeight +// (ouverture), sans jamais dépasser. overflow:hidden clippe pendant l'animation. +function collapseBody(el){ + const bw=el.querySelector('.bodywrap'); if(!bw || el.classList.contains('collapsed')) return; + bw.style.height = bw.scrollHeight+'px'; // fige les dimensions courantes + bw.style.width = bw.scrollWidth+'px'; + void bw.offsetHeight; // reflow pour que la transition parte de là + el.classList.add('collapsed'); + bw.style.height = '0px'; // → anime height ET width vers 0 + bw.style.width = '0px'; +} +function expandBody(el){ + const bw=el.querySelector('.bodywrap'); if(!bw) return; + el.classList.remove('collapsed'); + bw.style.height=''; bw.style.width=''; // mesure les dimensions naturelles… + const h=bw.scrollHeight, w=bw.scrollWidth; + bw.style.height='0px'; bw.style.width='0px';// …repart de 0 (pas de flash, même frame) + void bw.offsetHeight; + bw.style.height=h+'px'; bw.style.width=w+'px'; + const done=e=>{ if(e.propertyName!=='height') return; bw.style.height=''; bw.style.width=''; bw.removeEventListener('transitionend',done); }; + bw.addEventListener('transitionend',done); +} +function toggleCollapse(el){ el.classList.contains('collapsed') ? expandBody(el) : collapseBody(el); } +// Replie toutes les bulles d'un tour une fois la réponse finale entamée. +function collapseAll(list){ for(const el of list){ if(el) collapseBody(el); } list.length=0; } +// Replie une bulle INSTANTANÉMENT (sans animation) — utilisé pendant le replay au +// chargement pour que les vieilles bulles apparaissent déjà fermées. La classe +// 'collapsed' seule ne gère que l'opacité ; la hauteur est en style inline, donc +// on la met à 0 transition désactivée. +function collapseInstant(el){ + const bw=el.querySelector('.bodywrap'); if(!bw) return; + el.classList.add('collapsed'); + // Pas de `void bw.offsetHeight` ici : la bulle vient d'être créée et n'a jamais + // été peinte dépliée, donc poser height:0 n'anime pas — inutile de forcer un + // reflow par bulle (ce qui, multiplié par le replay, coûtait très cher). + bw.style.transition='none'; + bw.style.height='0px'; bw.style.width='0px'; + requestAnimationFrame(()=>{ bw.style.transition=''; }); +} +function setLabel(el, text){ el.querySelector('.label').textContent = text; } +// Ajoute « +N -N » colorés à l'étiquette d'une bulle. L'étiquette reste visible +// une fois la bulle repliée : c'est le seul endroit où le volume d'une écriture +// survit au repli, donc on le met là plutôt que dans le corps seul. +function setLabelCounts(el, add, del){ + const lab=el.querySelector('.label'); + const cnt=document.createElement('span'); cnt.className='diff-count'; + if(add) cnt.appendChild(Object.assign(document.createElement('span'),{className:'a',textContent:'+'+add})); + if(add && del) cnt.appendChild(document.createTextNode(' ')); + if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del})); + lab.appendChild(cnt); +} +// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki +// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait +// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée +// par la préférence d'affichage « masquer la vitesse de génération » (CSS). +function setStats(el, text){ + if(!el) return; + let s = el.querySelector(':scope > .statline'); + if(!s){ + s=document.createElement('div'); s.className='statline'; + // Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois + // la réponse finie, un surgissement sec accrochait l'œil. Les mises à jour + // suivantes ne rejouent pas l'animation (elle clignoterait), et le rejeu du + // journal au chargement n'anime rien du tout. + if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in'); + el.appendChild(s); + } + s.textContent = text; +} +function bodyOf(el){ return el.querySelector('.body'); } +// Render markdown into a message body in place; safe because md() escapes HTML. +function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLinkSpaces(text)); markNotices(b); addCopyButtons(b); markFileLinks(b); scrollMaybe(); } +// Render a tool call as its own conversation message: the command the model +// wrote, then the response it got back. textContent keeps it injection-safe. +function renderToolMsg(el, tu){ + // Métadonnées d'affichage par outil : nom court + en-tête. Les outils web + // (web_search/open/read/grep) ont leur propre libellé, pas le fallback mémoire. + const META = { + bash: {lbl:'terminal', head:'commande'}, + write: {lbl:'fichier', head:'écriture'}, + edit: {lbl:'édition', head:'édition'}, + web_search: {lbl:'recherche', head:'recherche web'}, + web_open: {lbl:'page web', head:'ouverture'}, + web_read: {lbl:'page web', head:'lecture'}, + web_grep: {lbl:'page web', head:'recherche'}, + }; + // Outils MCP (nom mcp____) : en-tête = nom du serveur, libellé lisible, + // pas le fallback mémoire. On extrait serveur et outil du nom namespacé. + let meta = META[tu.name]; + if(!meta && tu.name && tu.name.indexOf('mcp__')===0){ + const parts = tu.name.slice(5).split('__'); + const server = parts.shift() || 'mcp'; + const tool = parts.join('__') || tu.name; + meta = {lbl: tool, head: server}; + } + meta = meta || {lbl:'mémoire', head:'mémoire'}; + let lbl = meta.lbl; + // Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car). + if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; } + setLabel(el, lbl); + // Volume de l'écriture (final si le diff est là, provisoire pendant la frappe) + // reporté sur l'étiquette, pour rester lisible bulle repliée. + let add=0, del=0; + if(tu.diff && tu.diff.length){ tu.diff.forEach(l=>{ if(l.op==='+') add++; else if(l.op==='-') del++; }); } + else if(tu.body){ add=tu.body.split('\n').length; } + if(add||del) setLabelCounts(el, add, del); + const body=bodyOf(el); body.innerHTML=''; + const head=document.createElement('div'); head.className='tool-head'; + head.textContent = meta.head; + body.appendChild(head); + if(tu.label){ + const pre=document.createElement('pre'); pre.className='tool-cmd'; + const code=document.createElement('code'); code.textContent=tu.label; + if(tu.typing){ const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; code.appendChild(car); } + pre.appendChild(code); body.appendChild(pre); + } + // Écriture EN COURS : le modèle tape encore le contenu. On l'affiche ligne à + // ligne, dans la même forme que le diff final, pour que la bulle se remplisse + // sous les yeux au lieu de rester vide puis de s'ouvrir d'un coup. Seule la + // dernière ligne est « fraîche » (fondu) : réanimer tout à chaque événement + // ferait clignoter le bloc entier. + if(tu.body && !(tu.diff && tu.diff.length)){ + const lines=tu.body.split('\n'); + const sub=document.createElement('div'); sub.className='tool-sub'; + sub.textContent='écriture en cours'; // le +N vit sur l'étiquette (visible repliée) + body.appendChild(sub); + const pre=document.createElement('pre'); pre.className='diff live'; + lines.forEach((t,i)=>{ + const ln=document.createElement('span'); + ln.className='dl add'+(i===lines.length-1?' fresh':''); + ln.textContent='+ '+t; + if(i===lines.length-1 && tu.typing){ + const car=document.createElement('span'); car.className='tool-caret'; car.textContent='▋'; + ln.appendChild(car); + } + pre.appendChild(ln); + }); + body.appendChild(pre); + // Le bloc est re-créé à chaque événement : on le recale en bas pour suivre + // la ligne en cours (max-height côté CSS l'empêche de pousser le fil). + pre.scrollTop = pre.scrollHeight; + } + // Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert, + // retirées en rouge, contexte en gris — comme un diff de terminal. + if(tu.diff && tu.diff.length){ + const sub=document.createElement('div'); sub.className='tool-sub'; + sub.textContent='modifications'; // le +N -N vit sur l'étiquette (visible repliée) + body.appendChild(sub); + const pre=document.createElement('pre'); pre.className='diff'; + tu.diff.forEach(l=>{ + const ln=document.createElement('span'); + ln.className='dl'+(l.op==='+'?' add':l.op==='-'?' del':''); + ln.textContent=(l.op==='+'?'+':l.op==='-'?'-':' ')+' '+l.text; + pre.appendChild(ln); + }); + body.appendChild(pre); + } + const hasResult = tu.result!==undefined && tu.result!==''; + if(hasResult){ + const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse'; + body.appendChild(sub); + const pre=document.createElement('pre'); + const code=document.createElement('code'); code.textContent=tu.result; + pre.appendChild(code); body.appendChild(pre); + } else if(!tu.done && !tu.typing){ + const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='exécution en cours…'; + body.appendChild(wait); + } + addCopyButtons(body); scrollMaybe(); +} +// Inject a "copier" button into every
       code block (idempotent).
      +function addCopyButtons(root){
      +  root.querySelectorAll('pre').forEach(pre=>{
      +    if(pre.querySelector('.copybtn')) return;
      +    // Pas de bouton copier sur un diff : on copierait les préfixes + / - .
      +    if(pre.classList.contains('diff')) return;
      +    const btn=document.createElement('button');
      +    btn.className='copybtn'; btn.type='button'; btn.textContent='copier';
      +    btn.onclick=async(e)=>{
      +      e.stopPropagation();
      +      const code=pre.querySelector('code'), txt=(code||pre).innerText;
      +      try{ await navigator.clipboard.writeText(txt); }
      +      catch(_){ const ta=document.createElement('textarea'); ta.value=txt; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
      +      btn.textContent='copié ✓'; btn.classList.add('done');
      +      setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
      +    };
      +    pre.appendChild(btn);
      +  });
      +}
      +// Nouvelle conversation POUR TOUS LES APPAREILS : le serveur vide le fil et
      +// diffuse un {reset} ; le flux d'abonnement nettoie alors l'affichage.
      +function resetChat(){ jfetch('/api/chat/reset',{method:'POST'}).catch(()=>{}); toast('nouvelle conversation'); }
      +// Compaction : on demande à l'IA un résumé de la conversation destiné à la
      +// reprendre dans une session neuve, puis on repart d'un contexte propre seedé
      +// avec ce résumé. Réduit drastiquement les tokens tout en gardant le fil.
      +// Compaction MANUELLE : le compactage est automatique (façon Hermes) quand le
      +// contexte se remplit, mais ce bouton permet de le déclencher à la demande. Le
      +// serveur possède la conversation : on lance la compaction côté serveur et la
      +// progression (bannière « compactage en cours », résultat) arrive par le flux
      +// d'abonnement, comme pour la génération — donc visible sur tous les appareils.
      +async function compactContext(){
      +  if(!await askConfirm('Résumer les anciens tours pour libérer du contexte ? La conversation continue normalement.', {title:'Compacter le contexte', okText:'Compacter'})) return;
      +  const btn=document.getElementById('ctx-compact'); btn.disabled=true;
      +  try{
      +    const r=await jfetch('/api/chat/compact',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});
      +    const j=await r.json().catch(()=>({}));
      +    if(!j.ok) toast(j.error||'compaction indisponible');
      +  }catch(e){ toast('erreur : '+(e.message||e)); }
      +  btn.disabled=false;
      +}
      +// Persistance de la conversation : on garde user+assistant en localStorage pour
      +// survivre à un refresh (les bulles tool/reasoning sont éphémères, non stockées).
      +function saveChat(){ try{ localStorage.setItem('loki.chat', JSON.stringify(msgs)); }catch(e){} }
      +// Source de vérité = SERVEUR. Au chargement on ouvre le flux d'abonnement
      +// permanent (connectStream), qui rejoue tout le fil depuis le serveur — texte,
      +// appels d'outils, vitesses, raisonnement — puis suit le direct. Plus de
      +// localStorage : le même contexte est partagé par tous les appareils.
      +// Source de vérité = SERVEUR : on ouvre le flux d'abonnement permanent qui rejoue
      +// tout le fil (texte, outils, vitesses via les horodatages serveur, raisonnement)
      +// puis suit le direct. Partagé par tous les appareils.
      +// Voile de chargement du fil. setChatLoading(null) le masque, setChatLoading(txt)
      +// l'affiche avec ce libellé (« chargement… » au départ, « connexion au serveur… »
      +// si le flux tombe). Sans lui, une connexion lente affiche un chat vide qu'on ne
      +// distingue pas d'une conversation réellement vide.
      +function setChatLoading(msg){
      +  const el=document.getElementById('chat-loading');
      +  if(!el) return;
      +  if(!msg){ el.classList.remove('show'); return; }
      +  document.getElementById('chat-loading-text').textContent=msg;
      +  el.classList.add('show');
      +}
      +// --- Accueil du fil vide ---------------------------------------------------
      +// Le logo n'est pas dupliqué dans le HTML : on clone celui de la barre latérale
      +// (#brand) en retirant ses id (un id ne peut exister qu'une fois) et le numéro
      +// de version. Les couleurs sont reprises par les classes .ce-*.
      +function fillEmptyLogo(){
      +  const box=document.getElementById('ce-logo'), brand=document.getElementById('brand');
      +  if(!box || !brand || box.childElementCount) return;
      +  ['brand-a','brand-word'].forEach(id=>{
      +    const src=brand.querySelector('#'+id); if(!src) return;
      +    const el=src.cloneNode(true); el.removeAttribute('id');
      +    if(id==='brand-word') el.classList.add('ce-word');
      +    box.appendChild(el);
      +  });
      +}
      +// Affiché seulement quand le fil ne contient AUCUNE bulle et que le replay est
      +// terminé — sinon il apparaîtrait une fraction de seconde à chaque chargement,
      +// juste avant que les messages rejoués n'arrivent.
      +function syncChatEmpty(){
      +  const box=document.getElementById('chat-empty'); if(!box) return;
      +  fillEmptyLogo();
      +  const empty = !REPLAYING && !chatEl().querySelector('.msg');
      +  box.classList.toggle('show', empty);
      +}
      +document.addEventListener('DOMContentLoaded', ()=>{
      +  const c=chatEl(); if(!c) return;
      +  // Le fil est peuplé par des dizaines de chemins différents (replay, direct,
      +  // reset, effacement). On observe donc le DOM plutôt que d'appeler la synchro
      +  // depuis chacun d'eux — le coût est nul, le callback est groupé et sort tout
      +  // de suite pendant le replay.
      +  new MutationObserver(()=>syncChatEmpty()).observe(c, {childList:true});
      +  syncChatEmpty();
      +});
      +function restoreChat(){
      +  // On masque le chat le temps du replay pour ne pas voir défiler le haut puis
      +  // sauter en bas (effet de clignotement). Il est révélé, positionné en bas, au
      +  // signal {caught_up}. Filet de sécurité : révélé quoi qu'il arrive après 2s.
      +  const c=chatEl(); c.style.opacity='0';
      +  setChatLoading('chargement de la conversation…');
      +  // Si {caught_up} tarde au-delà de 2s (replay anormalement long), on révèle quand
      +  // même — et on saute en bas DIRECTEMENT (scrollMaybe est neutralisé tant que
      +  // REPLAYING, donc on force ici le positionnement). Le voile, lui, RESTE : tant
      +  // que le replay n'est pas fini, ce qui est affiché est incomplet et il faut le
      +  // dire. Il finit de toute façon par tomber au {caught_up} ou au filet de 15s.
      +  setTimeout(()=>{ c.style.transition='opacity .15s'; c.style.opacity='1'; c.scrollTop=c.scrollHeight; }, 2000);
      +  setTimeout(()=>{ setChatLoading(null); }, 15000);
      +  connectStream();
      +}
      +function onKey(e){ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); send(); } }
      +// La zone de saisie s'ajuste à son contenu : une ligne au repos, puis elle
      +// grandit jusqu'à sa max-height (au-delà, elle défile). Appelée à la frappe, à
      +// l'envoi et au chargement.
      +function autoGrow(ta){
      +  ta = ta || document.getElementById('input');
      +  if(!ta) return;
      +  ta.style.height='auto';
      +  const max=parseInt(getComputedStyle(ta).maxHeight,10)||200;
      +  ta.style.height=Math.min(ta.scrollHeight, max)+'px';
      +  ta.style.overflowY = ta.scrollHeight>max ? 'auto' : 'hidden';
      +}
      diff --git a/internal/loki/ui/src/js/09-stream.js b/internal/loki/ui/src/js/09-stream.js
      new file mode 100644
      index 0000000..e3dfc77
      --- /dev/null
      +++ b/internal/loki/ui/src/js/09-stream.js
      @@ -0,0 +1,304 @@
      +// ===== Conversation SERVEUR (source de vérité, partagée entre appareils) =====
      +// L'historique et la génération vivent sur le serveur loki. Le client ouvre un
      +// flux d'ABONNEMENT permanent (SSE) qui rejoue le journal depuis lastSeq puis
      +// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
      +// serveur) ; se reconnecter rejoue tout le fil, détails compris.
      +let lastSeq=0, streamAbort=null;
      +// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
      +// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
      +// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
      +// l'événement `user` revient par le flux — preuve que le serveur l'a bien
      +// enregistré. En cas d'échec d'envoi, elle est retirée et le texte est rendu.
      +let PENDING=null;
      +// Retire aussi la rangée de pièces jointes, qui vit JUSTE AVANT la bulle : sans
      +// ça, un envoi échoué laissait les fichiers seuls dans le fil, sans message.
      +function clearPending(){
      +  if(!PENDING) return;
      +  const f=PENDING.previousElementSibling;
      +  if(f&&f.classList.contains('msg-files')) f.remove();
      +  PENDING.remove(); PENDING=null;
      +}
      +function addPending(text){
      +  clearPending();
      +  PENDING=addMsg('user', text);
      +  PENDING.classList.add('pending');
      +  const l=PENDING.querySelector('.label'); if(l) l.textContent='envoi…';
      +  jumpBottom();
      +  return PENDING;
      +}
      +// Le serveur confirme le message : on réutilise la bulle grise au lieu d'en
      +// ajouter une seconde (sinon le message clignoterait en double).
      +function confirmPending(text){
      +  if(!PENDING) return false;
      +  const b=PENDING.querySelector('.body');
      +  if(!b || b.textContent!==text) return false;
      +  PENDING.classList.remove('pending');
      +  const l=PENDING.querySelector('.label'); if(l) l.textContent='user';
      +  PENDING=null;
      +  return true;
      +}
      +// REPLAYING = on est dans le replay initial (rejeu du journal au chargement).
      +// Pendant ce temps, les bulles raisonnement/outil sont créées DÉJÀ repliées →
      +// pas d'animation d'ouverture/fermeture au refresh. Le serveur envoie {caught_up}
      +// quand le replay est fini, on repasse alors en direct.
      +let REPLAYING=true;
      +// État de rendu du tour courant, délimité par les événements user / turn_done.
      +let T=null;
      +function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; }
      +newTurn();
      +const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
      +function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
      +// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
      +// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
      +// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
      +// recrée, en le marquant pour le reprendre quand le compactage est fini.
      +function setCompacting(on){
      +  if(on){
      +    if(!T.typingEl){ T.typingEl=addTyping(); T.typingEl.dataset.forCompact='1'; }
      +    T.typingEl.classList.add('compacting');
      +    if(!T.typingEl.querySelector('.tlabel')){
      +      const s=document.createElement('span');
      +      s.className='tlabel';
      +      s.textContent='compactage du contexte…';
      +      T.typingEl.appendChild(s);
      +    }
      +    scrollMaybe();
      +    return;
      +  }
      +  if(!T.typingEl) return;
      +  if(T.typingEl.dataset.forCompact){ removeTyping(); return; }
      +  T.typingEl.classList.remove('compacting');
      +  const s=T.typingEl.querySelector('.tlabel'); if(s) s.remove();
      +}
      +// Séparateur laissé dans le fil à l'endroit exact de la coupure.
      +function addCompactMark(){
      +  const el=document.createElement('div');
      +  el.className='compact-mark';
      +  el.textContent='contexte compacté — anciens tours résumés';
      +  // Devant l'indicateur de frappe s'il est encore là (compactage en cours de
      +  // tour) : la suite de la réponse doit rester APRÈS la marque de coupure.
      +  if(T.typingEl && T.typingEl.parentNode===chatEl()) chatEl().insertBefore(el, T.typingEl);
      +  else chatEl().appendChild(el);
      +  scrollMaybe();
      +}
      +// L'indicateur « … » est retiré dès que quelque chose de VISIBLE le remplace.
      +// Il doit donc survivre quand la bulle qui arrive ne sera pas affichée : mode
      +// simplifié, mais aussi raisonnement/outils masqués par les préférences — sinon
      +// le fil reste totalement vide pendant que le modèle travaille (rien à voir, et
      +// aucun signe que ça tourne).
      +const typingKept=(kind)=>simpleMode()
      +  || (kind==='reasoning' && viewOn('hide-reasoning'))
      +  || (kind==='tool' && viewOn('hide-tools'));
      +function killTyping(kind){ if(!T.typingEl) return; if(typingKept(kind)) return; removeTyping(); }
      +function showTyping(kind){ if(!typingKept(kind)) return; const c=document.getElementById('chat');
      +  if(!T.typingEl){ T.typingEl=addTyping(); } else if(c.lastElementChild!==T.typingEl){ c.appendChild(T.typingEl); } }
      +// Label de vitesse rendu depuis les valeurs (fonctionne aussi bien en direct
      +// qu'au replay — pas de timer performance.now, qui n'a pas de sens hors-ligne).
      +function renderStats(el, s){
      +  if(!el||!s) return;
      +  const parts=[];
      +  const pt=s.prompt_tokens||s.prompt_tokens_total;
      +  if(pt) parts.push('prefill '+pt+' tok · '+(s.prompt_per_second||0).toFixed(0)+' tok/s');
      +  if(s.gen_tokens) parts.push('decode '+s.gen_tokens+' tok · '+(s.gen_per_second||0).toFixed(1)+' tok/s');
      +  if(!parts.length) return;
      +  // Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
      +  // est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
      +  // bouton de repli, on y écrit comme avant.
      +  if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(parts).join('  ·  '));
      +  else setStats(el, parts.join('  ·  '));
      +}
      +// Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à
      +// partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération,
      +// donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un
      +// bloc côté client, mais leurs ts serveur restent espacés du vrai temps écoulé).
      +function labelTokens(el, role, n, firstTs, lastTs){
      +  if(!el) return;
      +  const secs=(lastTs-firstTs)/1000;
      +  if(secs>0.05 && n>1){ setLabel(el, role+'  ·  '+n+' tok  ·  '+(n/secs).toFixed(1)+' tok/s'); }
      +  else { setLabel(el, role+'  ·  '+n+' tok'); }
      +}
      +// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
      +// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
      +// L'état final est appliqué une seule fois au caught_up via syncSendBtn().
      +function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
      +function syncSendBtn(){
      +  const sb=document.getElementById('send');
      +  sb.style.display=busy?'none':'inline-block';
      +  document.getElementById('stop').style.display=busy?'inline-block':'none';
      +  // Tant que le moteur n'a pas fini de charger le modèle, envoyer ne mène à rien :
      +  // on bloque le bouton et l'Entrée, et on le DIT sous le champ. `STATUS_SEEN`
      +  // évite de verrouiller le chat quand /api/status n'a pas encore répondu (ou ne
      +  // répond pas du tout) — dans le doute on laisse la main.
      +  const ready = !STATUS_SEEN || MODEL_READY;
      +  sb.disabled = !ready;
      +  sb.title = ready ? '' : 'le modèle n\'est pas encore chargé';
      +  const hint=document.getElementById('sendhint');
      +  if(hint){
      +    hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
      +                             : 'Le modèle charge — envoi possible dès qu\'il est prêt.';
      +    hint.classList.toggle('waiting', !ready);
      +  }
      +}
      +// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
      +// piloté par le serveur et rejouable à l'identique.
      +function handleDelta(d){
      +  if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
      +  if(d.caught_up){
      +    // Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
      +    // sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
      +    setChatLoading(null);
      +    if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
      +    // Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra
      +    // déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
      +    syncChatEmpty();
      +    return; }
      +  if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); return; }
      +  if(d.user!==undefined){
      +    newTurn();
      +    let el=PENDING;
      +    if(!confirmPending(d.user)) el=addMsg('user', d.user);
      +    // Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
      +    // porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
      +    // bulle neuve — sinon elles apparaîtraient en double.
      +    if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
      +    setBusy(true); T.typingEl=addTyping(); return; }
      +  if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); return; }
      +  if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
      +  if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
      +  if(d.compacted){ setCompacting(false); addCompactMark(); return; }
      +  // Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
      +  // donc une notification persistée se re-déclenchait à chaque rafraîchissement
      +  // (« rien à compacter » qui revient sans raison). C'est un événement ponctuel,
      +  // il n'a de sens qu'en direct — contrairement à la marque `compacted`, qui est
      +  // une trace du fil et DOIT être rejouée.
      +  if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
      +  if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
      +  if(d.stats){ T.serverStats=d.stats;
      +    if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
      +    if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
      +  if(d.tool_used){
      +    killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
      +    if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
      +    renderToolMsg(T.pendingToolEl, tu);
      +    // Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
      +    // tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
      +    if(!tu.done || viewOn('hide-tools')) showTyping('tool');
      +    if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
      +    return; }
      +  if(d.drop_reasoning){
      +    if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; }
      +    return; }
      +  if(d.reasoning_content){
      +    killTyping('reasoning');
      +    if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
      +    // d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
      +    // le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
      +    // au lieu de concaténer, sinon le texte apparaît en double.
      +    if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; }
      +    showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason);
      +    // d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs
      +    // tokens d'un coup. Sinon (direct), 1 token, ts0=ts.
      +    if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1);
      +    labelTokens(T.reasonEl, 'reasoning', T.reasonTok, T.reasonFirstTs, T.reasonLastTs);
      +    return; }
      +  if(d.content){
      +    removeTyping();
      +    if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; }
      +    if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; }
      +    T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent);
      +    if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1);
      +    labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);
      +    return; }
      +}
      +// Flux d'abonnement permanent + reconnexion auto (from=lastSeq → pas de
      +// re-téléchargement complet après une coupure / bascule d'appareil).
      +// Un onglet caché RELÂCHE son flux SSE. Sans ça, chaque onglet Loki laissé
      +// ouvert monopolise une des ~6 connexions simultanées autorisées par domaine :
      +// au-delà, toute requête (journal, installation du moteur…) reste en file
      +// d'attente sans jamais partir ni échouer — un blocage silencieux très
      +// déroutant. Au retour de l'onglet on se reconnecte, et le replay depuis
      +// lastSeq rattrape tout ce qui s'est passé entre-temps.
      +let streamPaused=false;
      +document.addEventListener('visibilitychange', ()=>{
      +  if(document.hidden){
      +    streamPaused=true;
      +    if(streamAbort) try{ streamAbort.abort(); }catch(e){}
      +  } else if(streamPaused){
      +    streamPaused=false;
      +  }
      +});
      +async function connectStream(){
      +  while(true){
      +    // Onglet en arrière-plan : on n'ouvre aucune connexion, on attend le retour.
      +    while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
      +    streamAbort=new AbortController();
      +    try{
      +      const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
      +      if(REPLAYING) setChatLoading('chargement de la conversation…');
      +      const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
      +      while(true){
      +        const {done,value}=await reader.read(); if(done) break;
      +        buf+=dec.decode(value,{stream:true}); let i;
      +        while((i=buf.indexOf('\n\n'))>=0){
      +          const chunk=buf.slice(0,i); buf=buf.slice(i+2);
      +          for(const line of chunk.split('\n')){
      +            if(!line.startsWith('data:')) continue;
      +            const data=line.slice(5).trim(); if(data===''||data==='[DONE]') continue;
      +            try{ const o=JSON.parse(data); const d=(o.choices&&o.choices[0]&&o.choices[0].delta)||{}; handleDelta(d); }catch(e){}
      +          }
      +        }
      +      }
      +    }catch(e){ /* coupure : on reconnecte silencieusement */ }
      +    // Le flux s'est arrêté (coupure ou fin prématurée). Si le fil n'a JAMAIS fini
      +    // de charger, le silence est trompeur — un chat vide sans explication. On le
      +    // dit dans le voile ; il disparaîtra au {caught_up} de la reconnexion.
      +    if(REPLAYING) setChatLoading('connexion au serveur…');
      +    await new Promise(res=>setTimeout(res, 600));
      +  }
      +}
      +// Interrompt la génération en cours côté serveur (la goroutine détachée est
      +// annulée). Le serveur émet alors turn_done → le bouton repasse en « send ».
      +function stopGen(){ jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{}); toast('stop'); }
      +// Envoi RÉSILIENT : sur le tunnel E2E, un aller-retour peut échouer transitoirement
      +// alors qu'il a en fait abouti (la génération démarre). On réessaie, et un 409
      +// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
      +// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
      +// « network error » alarmiste alors que l'IA répond quand même.
      +async function send(){
      +  if(busy) return;
      +  // Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
      +  if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
      +  const ta=document.getElementById('input'); const text=ta.value.trim();
      +  // Un envoi sans texte est légitime s'il porte une pièce jointe (« tiens, regarde »).
      +  if(!text && !ATTACH.length) return;
      +  ta.value=''; autoGrow(ta);
      +  // Le message s'affiche TOUT DE SUITE, en gris : il ne disparaît plus le temps
      +  // de l'aller-retour. Il s'éclaircit quand le flux le confirme (confirmPending).
      +  addPending(text);
      +  const fail=(m)=>{ clearPending(); toast(m); ta.value=text; autoGrow(ta); };
      +  // C'est ici que les fichiers partent vers le serveur — pas avant. Les pastilles
      +  // ne sont retirées qu'une fois le message accepté : tant qu'il n'est pas parti,
      +  // on doit pouvoir en enlever une, et un échec doit rester visible.
      +  const files=await attachPaths();
      +  if(!text && !files.length){ fail('aucun fichier n\'a pu être déposé'); return; }
      +  // Les pastilles passent dans la bulle en attente : le message porte ses
      +  // fichiers dès l'envoi, sans attendre l'aller-retour.
      +  if(PENDING) addMsgFiles(PENDING, attachSent());
      +  for(let attempt=0; attempt<3; attempt++){
      +    try{
      +      const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED})});
      +      if(r.status===409 || r.ok) clearAttach();
      +      if(r.status===409) return;               // déjà en cours (notre envoi a abouti) → OK
      +      if(r.ok) return;                          // la bulle + les tokens arrivent par le flux
      +      if(r.status<500){ let m='erreur'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
      +    }catch(e){ /* réseau : on retente */ }
      +    await new Promise(res=>setTimeout(res, 600));
      +  }
      +  // Après plusieurs échecs : le serveur a peut-être quand même reçu le message.
      +  try{ const s=await (await jfetch('/api/chat/state')).json(); if(s.generating) return; }catch(_){}
      +  fail('échec de l\'envoi — réessaie');
      +}
      +loadAll();
      +setInterval(loadStatus, 5000);
      +setInterval(loadVram, 3000);
      +setInterval(loadRam, 3000);
      diff --git a/internal/loki/ui/src/js/10-llamacpp.js b/internal/loki/ui/src/js/10-llamacpp.js
      new file mode 100644
      index 0000000..6a91ffc
      --- /dev/null
      +++ b/internal/loki/ui/src/js/10-llamacpp.js
      @@ -0,0 +1,282 @@
      +// Backend llama.cpp — la barre latérale sert UNIQUEMENT à installer les moteurs.
      +//   « llama.cpp précompilé »   = binaires officiels, aucune compilation
      +//   « llama.cpp compilé »      = compilé ici, pour cette machine
      +//   « llama.cpp personnalisé » = un fork (modèles à quant spéciale)
      +// Le CHOIX du moteur utilisé se fait par modèle (preset), pas ici — voir la
      +// section « Moteur » dans l'éditeur de modèle. Ça évite que la barre latérale
      +// et les presets se battent pour la ligne BIN.
      +let lcState = null, lcPoll = null, lcLogNext = 0;
      +
      +async function loadLlamacpp(){
      +  let s;
      +  try{ s = await jget('/api/llamacpp'); }catch(_){ return; }
      +  lcState = s;
      +  const pb = s.prebuilt || {};
      +  const fastInstalled = !!pb.bin;
      +  const optInstalled  = !!s.bin;
      +
      +  lcRenderReco(s.reco);
      +  lcRenderMode('fast', fastInstalled);
      +  lcRenderMode('opt', optInstalled);
      +  lcRenderCustomCard();
      +
      +  // Job en cours (page rechargée pendant une install) → on raccroche l'affichage.
      +  if(s.job && s.job.exists && s.job.running && !lcPoll){
      +    document.getElementById('lc-details').open = true;
      +    lcStartPolling();
      +  }
      +  // Job terminé/interrompu qu'on n'a pas encore montré (rechargement APRÈS coup,
      +  // typiquement quand le service a redémarré) : on l'affiche sans polling.
      +  else if(s.job && s.job.exists && !s.job.running && !lcPoll && s.job.error && !lcEndShown){
      +    document.getElementById('lc-details').open = true;
      +    document.getElementById('lc-job').style.display = '';
      +    lcLogNext = 0;
      +    document.getElementById('lc-log').textContent = '';
      +    lcEndShown = true;
      +    lcPollJob(true); // quiet : simple rattrapage, pas de toast ni de rechargement
      +  }
      +  lcChipSync(s.job);
      +  // Préchauffe la liste des cartes du moteur actif : interroger le moteur prend
      +  // 1 à 3 s (init CUDA/Vulkan), et sans ça l'encart « cartes graphiques » de
      +  // l'éditeur apparaissait après coup, une fois le reste déjà affiché.
      +  if(typeof prefetchGpuDevices === 'function') prefetchGpuDevices(s.config_bin);
      +}
      +
      +// --- Pastille de rappel hors panneau ---------------------------------------
      +// Le détail de l'installation vit dans le panneau latéral, qui est un tiroir
      +// FERMÉ sur téléphone : après un rechargement, rien ne disait qu'une
      +// compilation tournait encore. La pastille le dit, et y ramène en un clic.
      +let lcSeenEnd = false, lcEndShown = false;
      +function lcChipLabel(action){
      +  return {install:'Compilation du moteur', update:'Mise à jour du moteur',
      +          prebuilt:'Téléchargement du moteur', custom:'Installation du backend'}[action] || 'Installation du moteur';
      +}
      +function lcChipSync(j){
      +  const chip = document.getElementById('lc-chip');
      +  if(!chip) return;
      +  if(!j || !j.exists || (!j.running && (!j.error || lcSeenEnd))){ chip.hidden = true; return; }
      +  chip.hidden = false;
      +  chip.classList.toggle('failed', !j.running && !!j.error);
      +  chip.textContent = j.running
      +    ? '⏳ ' + lcChipLabel(j.action) + ' — ' + (j.phase || '…')
      +    : '✗ ' + lcChipLabel(j.action) + ' interrompue';
      +}
      +// Clic sur la pastille : ouvrir le tiroir sur la section Moteur.
      +function lcChipOpen(){
      +  const side = document.getElementById('side');
      +  if(!side.classList.contains('open')) toggleSide();
      +  const det = document.getElementById('lc-details');
      +  det.open = true;
      +  det.scrollIntoView({block:'center'});
      +  if(!document.getElementById('lc-chip').classList.contains('failed')) return;
      +  lcSeenEnd = true;
      +  lcChipSync(null);
      +}
      +
      +// Place la pastille « conseillé » sur la carte que le SERVEUR recommande pour
      +// cette machine (voir recommendedMode) : le précompilé partout, sauf sur Linux
      +// avec une carte NVIDIA où il ne donnerait que du Vulkan. La raison est ajoutée
      +// à la description de la carte, pour expliquer plutôt que d'imposer.
      +function lcRenderReco(reco){
      +  const mode = (reco && reco.mode) || 'fast';
      +  for(const m of ['fast','opt']){
      +    const badge = document.getElementById('lc-reco-'+m);
      +    if(badge) badge.hidden = (m !== mode);
      +  }
      +  // La raison REMPLACE la description générique de la carte conseillée : elle
      +  // dit déjà ce que fait l'option et pourquoi c'est le bon choix ici. (loadAll
      +  // repasse par là, donc pas d'accumulation possible.)
      +  const desc = document.getElementById('lc-desc-'+mode);
      +  if(desc && reco && reco.why) desc.textContent = reco.why;
      +}
      +
      +function lcRenderMode(mode, installed){
      +  const card  = document.getElementById(mode==='fast' ? 'lc-mode-fast' : 'lc-mode-opt');
      +  const state = document.getElementById(mode==='fast' ? 'lc-fast-state' : 'lc-opt-state');
      +  card.classList.toggle('installed', installed);
      +  // Lien de vérification : interroge la dernière version SANS rien installer.
      +  // event.stopPropagation empêche le clic de la carte (qui lance l'install).
      +  const check = 'vérifier la version';
      +  if(installed){
      +    state.innerHTML = '✓ installée'
      +      + '↻ mettre à jour'
      +      + check;
      +  } else {
      +    state.innerHTML = '→ cliquer pour installer' + check;
      +  }
      +}
      +
      +// lcCheck vérifie s'il existe une version plus récente AVANT toute installation
      +// (endpoints de check dédiés, sans effet de bord). Résultat affiché en toast.
      +async function lcCheck(mode){
      +  toast('vérification…');
      +  try{
      +    if(mode === 'fast'){
      +      const r = await jpost('/api/llamacpp/prebuilt/check', {});
      +      if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
      +      if(r.update) toast('nouvelle version disponible : '+r.latest+(r.current ? ' (installée : '+r.current+')' : ''));
      +      else toast('llama.cpp précompilé à jour ✓'+(r.latest ? ' ('+r.latest+')' : ''));
      +    } else {
      +      const r = await jpost('/api/llamacpp/check', {});
      +      if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
      +      if(r.behind > 0) toast(r.behind+' nouveau(x) commit(s) disponible(s) — utilisez « mettre à jour »');
      +      else toast('llama.cpp compilé à jour ✓');
      +    }
      +  }catch(_){ toast('erreur réseau'); }
      +}
      +
      +// Clic sur une carte : installer le moteur (s'il ne l'est pas déjà).
      +async function lcPick(mode){
      +  const s = lcState || {}, pb = s.prebuilt || {};
      +  const installed = mode==='fast' ? !!pb.bin : !!s.bin;
      +  if(installed){
      +    toast('déjà installée — choisissez-la dans l\'édition d\'un modèle (⚙)');
      +    return;
      +  }
      +  if(mode === 'fast'){
      +    if(!await askConfirm('Télécharger le binaire officiel de llama.cpp, prêt à l\'emploi (~2 min, aucune compilation).', {title:'llama.cpp précompilé', okText:'Installer'})) return;
      +    const r = await jpost('/api/llamacpp/prebuilt', {});
      +    if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
      +  } else {
      +    if(!await askConfirm('Compiler llama.cpp pour votre machine. Ça peut prendre de longues minutes (surtout avec une carte NVIDIA).', {title:'llama.cpp compilé', okText:'Compiler'})) return;
      +    const r = await jpost('/api/llamacpp/install', {});
      +    if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
      +  }
      +  lcStartPolling();
      +}
      +
      +// « Mettre à jour » sur une carte installée.
      +async function lcUpdate(mode){
      +  if(mode === 'fast'){
      +    if(!await askConfirm('Vérifier et installer le dernier binaire précompilé.', {title:'Mettre à jour', okText:'Mettre à jour'})) return;
      +    const r = await jpost('/api/llamacpp/prebuilt', {});
      +    if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
      +  } else {
      +    if(!await askConfirm('Vérifier et installer la dernière version compilée (recompilation si besoin).', {title:'Mettre à jour', okText:'Mettre à jour'})) return;
      +    const r = await jpost('/api/llamacpp/update', {clean:false});
      +    if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
      +  }
      +  lcStartPolling();
      +}
      +
      +// --- Backends personnalisés (3e carte + modal de gestion) ------------------
      +let lcCustomBackends = [];
      +
      +// État affiché sur la carte « Backend personnalisé » : nombre d'installés.
      +async function lcRenderCustomCard(){
      +  const state = document.getElementById('lc-custom-state');
      +  if(!state) return;
      +  try{ lcCustomBackends = await jget('/api/backends/custom') || []; }catch(_){ lcCustomBackends = []; }
      +  const n = lcCustomBackends.length;
      +  document.getElementById('lc-mode-custom').classList.toggle('installed', n>0);
      +  state.innerHTML = n>0
      +    ? '✓ '+n+' installé'+(n>1?'s':'')+'→ gérer'
      +    : '→ voir / installer';
      +}
      +
      +function openCustomBackends(){
      +  showModal('lc-custom-modal');
      +  document.getElementById('lc-custom-url').value = '';
      +  document.getElementById('lc-custom-name').value = '';
      +  loadCustomBackends();
      +}
      +function closeCustomBackends(){ hideModal('lc-custom-modal'); }
      +
      +// Liste les backends custom dans le modal, avec un bouton supprimer par ligne.
      +async function loadCustomBackends(){
      +  const box = document.getElementById('lc-custom-list');
      +  box.innerHTML = 'chargement…';
      +  let list = [];
      +  try{ list = await jget('/api/backends/custom') || []; }catch(_){}
      +  lcCustomBackends = list;
      +  if(!list.length){ box.innerHTML = 'aucun backend personnalisé pour l\'instant.'; return; }
      +  box.innerHTML = list.map(b=>{
      +    const nm = String(b.name).replace(/[<>&]/g,'');
      +    const used = b.in_use ? 'utilisé' : '';
      +    return '
      ' + + '' + + '
      '+nm+'
      ' + + '
      '+used+''+String(b.path).split('/').slice(-3).join('/').replace(/[<>&]/g,'')+'
      ' + + '' + + '
      '; + }).join(''); +} + +// Installer un backend CUSTOM depuis une URL de dépôt Git (fork llama.cpp). +// Cloné + compilé dans backends/, SANS toucher au moteur global : il +// apparaît ensuite dans le menu « backend détecté » de l'éditeur de modèle. +async function lcInstallCustom(){ + const url = (document.getElementById('lc-custom-url').value||'').trim(); + if(!url){ toast('collez l\'URL d\'un dépôt Git'); return; } + const name = (document.getElementById('lc-custom-name').value||'').trim(); + if(!await askConfirm('Cloner et compiler ce backend depuis :\n'+url+'\n\nCela peut prendre de longues minutes (surtout avec une carte NVIDIA). Il ne remplace pas le moteur global — vous le choisirez par modèle.', {title:'Backend personnalisé', okText:'Installer'})) return; + const r = await jpost('/api/llamacpp/install-custom', {repo:url, name}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + closeCustomBackends(); + document.getElementById('lc-details').open = true; + lcStartPolling(); +} + +// Désinstaller (supprime le dossier backends/). Le serveur refuse si le +// backend sert de moteur au modèle actif. +async function lcUninstallCustom(name){ + if(!await askConfirm('Supprimer le backend « '+name+' » ? Son dossier compilé sera effacé. Les modèles qui l\'utilisent devront être repointés sur un autre moteur.', {title:'Supprimer le backend', okText:'Supprimer', danger:true})) return; + const r = await jpost('/api/llamacpp/uninstall-custom', {name}); + if(!r.ok){ toast('erreur : '+(r.error||'')); return; } + toast('backend supprimé'); + loadCustomBackends(); + lcRenderCustomCard(); +} + +// --- Progression de l'installation (téléchargement / compilation) ---------- +function lcBusy(on){ document.querySelector('.lc-modes').classList.toggle('busy', on); } + +function lcStartPolling(){ + document.getElementById('lc-job').style.display = ''; + document.getElementById('lc-log').textContent = ''; + lcLogNext = 0; + lcSeenEnd = false; lcEndShown = false; + lcBusy(true); + if(lcPoll) clearInterval(lcPoll); + lcPoll = setInterval(lcPollJob, 1000); + lcPollJob(); +} + +async function lcPollJob(quiet){ + let j; + try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; } + if(!j.exists) return; + const phaseEl = document.getElementById('lc-job-phase'); + if(j.lines && j.lines.length){ + const pre = document.getElementById('lc-log'); + const stick = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 20; + pre.textContent += j.lines.join('\n') + '\n'; + if(stick) pre.scrollTop = pre.scrollHeight; + } + if(typeof j.next === 'number') lcLogNext = j.next; + lcChipSync(j); + if(j.running){ + phaseEl.innerHTML = '⏳ '+String(j.phase||'…').replace(/[<>&]/g,'')+''; + return; + } + if(lcPoll){ clearInterval(lcPoll); lcPoll = null; } + lcBusy(false); + if(j.error){ + phaseEl.innerHTML = '✗ '+String(j.error).replace(/[<>&]/g,'')+''; + const pre = document.getElementById('lc-log'); + if(pre.hasAttribute('hidden')) lcToggleLog(); + pre.scrollTop = pre.scrollHeight; + toast('échec — voir les détails'); + } else { + phaseEl.innerHTML = '✓ '+String(j.phase||'terminé').replace(/[<>&]/g,'')+''; + toast('c\'est prêt ✓'); + } + loadAll(); +} + +function lcToggleLog(){ + const pre = document.getElementById('lc-log'); + const bar = document.querySelector('.lc-logbar'); + if(pre.hasAttribute('hidden')){ pre.removeAttribute('hidden'); bar.classList.add('open'); pre.scrollTop = pre.scrollHeight; } + else { pre.setAttribute('hidden',''); bar.classList.remove('open'); } +} diff --git a/internal/loki/ui/src/js/12-mcp.js b/internal/loki/ui/src/js/12-mcp.js new file mode 100644 index 0000000..da5a746 --- /dev/null +++ b/internal/loki/ui/src/js/12-mcp.js @@ -0,0 +1,191 @@ +// ─── Serveurs MCP ──────────────────────────────────────────────────────────── +// Gestion des serveurs MCP (Model Context Protocol) : liste, ajout/édition via +// une modale, activation/désactivation, test de connexion. Sous-réglage du mode +// agent (grisé quand l'agent est off, comme mémoire/internet). +let mcpServers = []; +let mcpEditing = null; // nom du serveur en cours d'édition ('' ou null = nouveau) + +async function loadMCP(){ renderMCP(await jget('/api/mcp')); } + +function renderMCP(r){ + mcpServers = (r && r.servers) || []; + // Pas de pastille de comptage : chaque serveur affiche déjà son propre état + // dans la liste juste en dessous. + setBadge('mcp-badge', null); + const list = document.getElementById('mcp-list'); + list.textContent = ''; + if(!mcpServers.length){ + list.innerHTML = '
      (aucun serveur — ajoute un serveur fichiers, une base de données, une API…)
      '; + return; + } + mcpServers.forEach(s=>{ + const row = document.createElement('div'); + row.className = 'mcp-row'+(s.enabled?'':' off'); + + // Pastille d'état : bleu accent = connecté, rouge = erreur, gris = éteint. + const dot = document.createElement('span'); + dot.className = 'mcp-dot'; + if(!s.enabled) dot.classList.add('mcp-dot-off'); + else if(s.connected) dot.classList.add('mcp-dot-ok'); + else dot.classList.add('mcp-dot-err'); + dot.title = !s.enabled ? 'désactivé' : (s.connected ? 'connecté' : 'erreur de connexion'); + + const info = document.createElement('div'); + info.className = 'mcp-info'; + const nm = document.createElement('div'); nm.className = 'mcp-name'; + nm.textContent = s.name; nm.title = s.name; + const meta = document.createElement('div'); meta.className = 'mcp-meta'; + const tr = document.createElement('span'); tr.className = 'mcp-tag'; + tr.textContent = s.transport || '?'; meta.appendChild(tr); + if(s.enabled && s.connected){ + const total = s.tools ? s.tools.length : 0; + const off = s.disabled ? s.disabled.length : 0; + const active = total - off; + const tc = document.createElement('span'); tc.className = 'mcp-tools'; + tc.textContent = off ? (active+'/'+total+' outils') : (total+' outil'+(total>1?'s':'')); + if(s.tools && s.tools.length) tc.title = s.tools.join(', '); + meta.appendChild(tc); + } else if(s.enabled && s.error){ + const er = document.createElement('span'); er.className = 'mcp-err'; + er.textContent = 'erreur'; er.title = s.error; + meta.appendChild(er); + } + info.appendChild(nm); info.appendChild(meta); + + // Interrupteur activer/désactiver. stopPropagation sur le label ENTIER (pas + // seulement la checkbox) : sinon un clic sur le slider remonte à la ligne et + // ouvre la modale en même temps que le toggle. + const sw = document.createElement('label'); sw.className = 'switch mcp-switch'; + sw.onclick = (e)=>e.stopPropagation(); + const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!s.enabled; + cb.onchange = ()=>toggleMcp(s.name, cb.checked); + const sl = document.createElement('span'); sl.className = 'slider'; + sw.appendChild(cb); sw.appendChild(sl); + + const edit = document.createElement('button'); + edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎'; + edit.onclick = (e)=>{ e.stopPropagation(); openMcp(s.name); }; + + row.appendChild(dot); row.appendChild(info); row.appendChild(sw); row.appendChild(edit); + row.onclick = ()=>openMcp(s.name); + list.appendChild(row); + }); +} + +async function toggleMcp(name, on){ + renderMCP(await jpost('/api/mcp/toggle', {name, on})); + toast(on ? 'serveur activé' : 'serveur désactivé'); +} + +// Ouvre la modale d'ajout (name='') ou d'édition (nom existant). +function openMcp(name){ + mcpEditing = name || ''; + const s = name ? mcpServers.find(x=>x.name===name) : null; + document.getElementById('mcp-modal-title').textContent = s ? 'Éditer « '+s.name+' »' : 'Nouveau serveur MCP'; + document.getElementById('mcp-name').value = s ? s.name : ''; + document.getElementById('mcp-name').disabled = !!s; // le nom identifie le serveur, pas de renommage + const transport = s ? (s.transport||'stdio') : 'stdio'; + document.querySelector('input[name="mcp-transport"][value="'+transport+'"]').checked = true; + mcpTransportUI(transport); + document.getElementById('mcp-command').value = s ? (s.command||'') : ''; + document.getElementById('mcp-args').value = s && s.args ? s.args.join('\n') : ''; + document.getElementById('mcp-env').value = s && s.env ? Object.entries(s.env).map(([k,v])=>k+'='+v).join('\n') : ''; + document.getElementById('mcp-url').value = s ? (s.url||'') : ''; + document.getElementById('mcp-headers').value = s && s.headers ? Object.entries(s.headers).map(([k,v])=>k+': '+v).join('\n') : ''; + const st = document.getElementById('mcp-modal-status'); + st.textContent = ''; st.style.color = ''; + if(s && s.enabled && s.error){ st.textContent = '⚠ '+s.error; st.style.color = 'var(--err)'; } + else if(s && s.connected){ st.innerHTML = '✓ connecté — '+(s.tools?s.tools.length:0)+' outil(s)'; } + document.getElementById('mcp-del').style.display = s ? '' : 'none'; + renderMcpTools(s); + showModal('mcp-modal'); +} + +// Liste des outils découverts du serveur, avec une case par outil pour le +// masquer/exposer à l'IA. Visible seulement pour un serveur connecté. +function renderMcpTools(s){ + const sec = document.getElementById('mcp-tools-section'); + const list = document.getElementById('mcp-tools-list'); + list.textContent = ''; + if(!s || !s.connected || !s.tools || !s.tools.length){ sec.style.display = 'none'; return; } + sec.style.display = ''; + const off = new Set(s.disabled || []); + document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)'; + s.tools.slice().sort().forEach(tool=>{ + const row = document.createElement('label'); row.className = 'mcp-tool-row'; + const sw = document.createElement('span'); sw.className = 'switch mcp-tool-switch'; + const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !off.has(tool); + cb.onchange = ()=>toggleMcpTool(s.name, tool, cb.checked); + const sl = document.createElement('span'); sl.className = 'slider'; + sw.appendChild(cb); sw.appendChild(sl); + const nm = document.createElement('code'); nm.className = 'mcp-tool-name'; nm.textContent = tool; + row.appendChild(sw); row.appendChild(nm); + list.appendChild(row); + }); +} + +async function toggleMcpTool(server, tool, on){ + const r = await jpost('/api/mcp/tool', {name: server, tool, on}); + renderMCP(r); + // Rafraîchit le compteur de la modale à partir de l'état à jour. + const s = (r.servers||[]).find(x=>x.name===server); + const off = s && s.disabled ? new Set(s.disabled) : new Set(); + if(s && s.tools) document.getElementById('mcp-tools-count').textContent = '('+(s.tools.length-off.size)+'/'+s.tools.length+' actifs)'; +} + +function closeMcp(){ hideModal('mcp-modal'); mcpEditing = null; } + +function mcpTransportUI(t){ + document.getElementById('mcp-stdio-fields').style.display = t==='stdio' ? '' : 'none'; + document.getElementById('mcp-http-fields').style.display = t==='http' ? '' : 'none'; +} + +// Parse un textarea "KEYvaleur" par ligne → objet. sep = '=' (env) ou ':' (headers). +function mcpParseKV(text, sep){ + const out = {}; + (text||'').split('\n').forEach(line=>{ + const s = line.trim(); + if(!s) return; + const i = s.indexOf(sep); + if(i <= 0) return; + out[s.slice(0,i).trim()] = s.slice(i+1).trim(); + }); + return Object.keys(out).length ? out : undefined; +} + +async function saveMcp(){ + const name = document.getElementById('mcp-name').value.trim(); + if(!name){ toast('donne un nom au serveur'); return; } + const transport = document.querySelector('input[name="mcp-transport"]:checked').value; + const body = {name}; + if(transport==='stdio'){ + body.command = document.getElementById('mcp-command').value.trim(); + if(!body.command){ toast('renseigne la commande'); return; } + body.args = document.getElementById('mcp-args').value.split('\n').map(x=>x.trim()).filter(Boolean); + body.env = mcpParseKV(document.getElementById('mcp-env').value, '='); + body.url = ''; body.headers = undefined; + } else { + body.url = document.getElementById('mcp-url').value.trim(); + if(!body.url){ toast("renseigne l'URL"); return; } + body.headers = mcpParseKV(document.getElementById('mcp-headers').value, ':'); + body.command = ''; body.args = undefined; body.env = undefined; + } + const st = document.getElementById('mcp-modal-status'); + st.textContent = 'connexion…'; st.style.color = ''; + const r = await jpost('/api/mcp/save', body); + if(!r.ok){ st.textContent = '⚠ '+(r.error||'échec'); st.style.color = 'var(--err)'; return; } + renderMCP(r); + const saved = (r.servers||[]).find(x=>x.name===name); + if(saved && saved.connected){ toast('serveur connecté — '+(saved.tools?saved.tools.length:0)+' outil(s)'); closeMcp(); } + else if(saved && saved.error){ st.textContent = '⚠ enregistré mais connexion échouée : '+saved.error; st.style.color = 'var(--err)'; } + else { toast('enregistré'); closeMcp(); } +} + +async function deleteMcp(){ + if(!mcpEditing) return; + if(!await askConfirm('Retirer le serveur « '+mcpEditing+' » ? Ses outils ne seront plus proposés à l\'IA.', {title:'Supprimer le serveur MCP ?', okText:'Supprimer', danger:true})) return; + await jpost('/api/mcp/delete', {name: mcpEditing}); + closeMcp(); + loadMCP(); + toast('serveur supprimé'); +} diff --git a/internal/loki/ui/src/js/13-remote.js b/internal/loki/ui/src/js/13-remote.js new file mode 100644 index 0000000..890fa6d --- /dev/null +++ b/internal/loki/ui/src/js/13-remote.js @@ -0,0 +1,96 @@ +// Accès distant (ajean.link) — piloté depuis l'UI, sans terminal. +// « Connecter » ouvre une popup app.ajean.link/connect.html qui gère compte + +// abonnement, puis renvoie une clé de liaison par postMessage. On la POSTe à +// l'agent local (/api/link/connect) qui fait le `loki link` (token + service). + +const LOKI_APP_ORIGIN = 'https://app.ajean.link'; + +function renderRemote(d){ + const off = document.getElementById('remote-off'), on = document.getElementById('remote-on'); + const badge = document.getElementById('remote-badge'); + if(!d || !d.linked){ + // Serveur jamais lié : on l'affiche explicitement plutôt que de laisser la + // pastille vide, qui se lisait comme « je ne sais pas ». + off.style.display=''; on.style.display='none'; + setBadge(badge, false, 'non connecté'); + return; + } + off.style.display='none'; on.style.display=''; + document.getElementById('remote-url').value = d.machineURL || ''; + const st = document.getElementById('remote-status'); + if(d.active){ st.textContent='● en ligne — accessible à distance'; st.style.color='var(--accent)'; } + else { st.textContent='○ tunnel arrêté (l\'accès distant ne répondra pas)'; st.style.color='var(--warn)'; } + const sb = document.getElementById('remote-start'); + if(sb){ sb.style.display = d.active ? 'none' : ''; } + setBadge(badge, d.active ? true : 'warn', d.active ? 'connecté' : 'arrêté'); +} + +async function loadRemote(){ + // Le panneau ne vaut qu'en LOCAL : il pilote /api/link/* que le tunnel bloque + // (boîte noire). Sur le portail distant (app.ajean.link/server.html), on le cache. + const det = document.getElementById('remote-details'); + if(location.hostname === 'app.ajean.link'){ if(det) det.style.display='none'; return; } + // Statut injoignable : on retombe sur « non connecté » plutôt que de garder + // l'affichage précédent, qui pourrait annoncer « connecté » à tort. + try{ renderRemote(await jget('/api/link/status')); }catch(e){ renderRemote(null); } +} + +// Ouvre la popup de connexion et attend la clé renvoyée par postMessage. +function remoteConnect(){ + const params = new URLSearchParams({ origin: window.location.origin, host: window.location.hostname || 'ce serveur' }); + const url = LOKI_APP_ORIGIN + '/connect.html?' + params.toString(); + const pop = window.open(url, 'loki-connect', 'width=440,height=640'); + if(!pop){ toast('autorisez les popups pour connecter ajean.link'); return; } + + async function onMsg(ev){ + // Anti-usurpation : n'accepte QUE des messages du portail ajean.link. + if(ev.origin !== LOKI_APP_ORIGIN) return; + const d = ev.data || {}; + if(d.type !== 'loki-link' || !d.token) return; + window.removeEventListener('message', onMsg); + try{ + const r = await jpost('/api/link/connect', { token: d.token }); + if(r && r.linked){ + toast('✓ accès distant connecté'); + if(r.serviceErr){ toast('token enregistré (service : '+r.serviceErr+')'); } + loadRemote(); + } else { + toast((r && r.error) || 'échec de la connexion'); + } + }catch(e){ toast('erreur : '+e); } + } + window.addEventListener('message', onMsg); +} + +// Relance le tunnel avec la clé déjà enregistrée (sans repasser par la popup). +async function remoteStart(){ + const b = document.getElementById('remote-start'); + if(b){ b.disabled = true; b.textContent = 'démarrage…'; } + try{ + const r = await jpost('/api/link/start', {}); + if(r && r.active){ toast('✓ tunnel démarré'); } + else { toast((r && r.error) || 'le tunnel n\'a pas démarré'); } + }catch(e){ toast('erreur : '+e); } + if(b){ b.disabled = false; b.textContent = 'démarrer le tunnel'; } + loadRemote(); +} + +async function remoteDisconnect(){ + const ok = await askConfirm('Couper l\'accès distant et oublier la clé de liaison de ce serveur ?', {title:'Accès distant', okLabel:'Déconnecter'}); + if(!ok) return; + try{ await jpost('/api/link/disconnect', {}); toast('accès distant coupé'); loadRemote(); } + catch(e){ toast('erreur : '+e); } +} + +async function remotePairCode(){ + const box = document.getElementById('remote-pair'); + box.style.display=''; box.textContent='génération du code…'; + try{ + const r = await jpost('/api/link/paircode', {}); + if(r && r.code){ + box.innerHTML = 'Empreinte : '+(r.fingerprint||'—')+'
      Code d\'appairage (valable 10 min, usage unique) : '+r.code+''; + } else { + box.textContent = (r && r.error) || 'code indisponible'; + } + }catch(e){ box.textContent='erreur : '+e; } +} diff --git a/internal/loki/ui/src/js/14-attach.js b/internal/loki/ui/src/js/14-attach.js new file mode 100644 index 0000000..cf4c665 --- /dev/null +++ b/internal/loki/ui/src/js/14-attach.js @@ -0,0 +1,324 @@ +// ===== Pièces jointes du chat ============================================== +// Un fichier joint n'est PAS lu par le navigateur ni injecté dans le contexte : +// il est déposé tel quel dans le dossier de travail de l'IA (uploads/, côté +// serveur), et seul son chemin est annoncé dans le message. Le modèle l'ouvre +// s'il en a besoin, avec ses outils — ce qui marche pour n'importe quel type de +// fichier sans que l'UI ait à en connaître le format. +// +// Le transfert passe par du base64 dans du JSON (et non du multipart) : c'est la +// seule forme qui traverse le tunnel E2E d'app.ajean.link, donc l'accès distant +// fonctionne sans code particulier. +// Doivent rester alignés sur uploadMaxBytes / uploadChunkMax (web_upload.go). +// Le fichier part par MORCEAUX : ni le navigateur ni le serveur n'en tiennent +// plus d'un à la fois en mémoire, ce qui rend le gigaoctet possible. Envoyer +// 1 Go en un seul corps JSON demanderait ~1,4 Go de base64 de chaque côté. +const ATTACH_MAX = 1024*1024*1024; +const ATTACH_CHUNK = 8*1024*1024; +// Délai par MORCEAU (et pas les 30 s d'un appel /api/* ordinaire) : une tranche de +// 8 Mo, base64 + tunnel E2E, peut dépasser 30 s sur une liaison lente. +const UPLOAD_CHUNK_TIMEOUT_MS = 120000; +let ATTACH = []; +let ATTACH_SEQ = 0; + +function fmtSize(n){ + if(n >= 1024*1024*1024) return (n/(1024*1024*1024)).toFixed(1)+' Go'; + if(n >= 1024*1024) return (n/(1024*1024)).toFixed(n >= 10*1024*1024 ? 0 : 1)+' Mo'; + if(n >= 1024) return Math.round(n/1024)+' Ko'; + return n+' o'; +} +// Pastille de fichier, partagée par le composeur et les bulles du fil : même +// objet visuel des deux côtés, seul le contexte (CSS) change. +function fileChip(name, size, opts){ + opts=opts||{}; + const chip=document.createElement('div'); + chip.className='chip-file'+(opts.cls?' '+opts.cls:''); + const n=document.createElement('span'); + n.className='cf-name'; n.textContent=name; n.title=opts.title||name; + const s=document.createElement('span'); + s.className='cf-size'; s.textContent=opts.sizeText||fmtSize(size); + chip.appendChild(n); chip.appendChild(s); + if(opts.onRemove){ + const x=document.createElement('button'); + x.type='button'; x.textContent='×'; x.title='retirer'; + x.setAttribute('aria-label','retirer '+name); + x.onclick=opts.onRemove; + chip.appendChild(x); + } + return chip; +} +// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans +// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait +// un bloc bâtard, moitié texte moitié pastilles. +function addMsgFiles(el, files){ + if(!el || !files || !files.length) return; + const box=document.createElement('div'); + box.className='msg-files'; + for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name})); + el.parentNode.insertBefore(box, el); + // Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles. + markEmptyMsg(el); + return box; +} +// Une bulle sans texte n'a rien à montrer — seules ses pièces jointes parlent. +function markEmptyMsg(el){ + const b=el&&el.querySelector('.body'); + if(b) el.classList.toggle('empty', b.textContent===''); +} +// La rangée de fichiers précède la bulle : c'est là qu'on vérifie sa présence +// avant d'en ajouter une seconde (replay + bulle en attente). +function hasMsgFiles(el){ + const p=el&&el.previousElementSibling; + return !!(p&&p.classList.contains('msg-files')); +} +// ===== Fichiers remis par l'IA ============================================ +// Aucun outil, aucune syntaxe maison : l'IA écrit un lien Markdown ordinaire +// vers un fichier de son dossier de travail — [Télécharger](rapport.pdf) — et +// c'est l'UI qui le transforme en téléchargement. Le modèle n'a rien à savoir +// de plus que ce qu'il fait déjà naturellement. +// +// Un simple href ne suffirait pas : la clé de pilotage voyage dans un en-tête +// Authorization que le navigateur ne mettrait pas sur une navigation, et le +// chemin de base change derrière le tunnel (/u/). D'où le fetch + blob. +const DL_CHUNK = 8*1024*1024; // aligné sur downloadChunkMax (web_upload.go) +// Décode une tranche base64 en octets. +function b64ToBytes(s){ + const bin=atob(s), out=new Uint8Array(bin.length); + for(let i=0;i({})); + if(!m.ok || !meta.ok){ + toast('téléchargement impossible : '+(meta.error||('HTTP '+m.status))); return; + } + let blob; + if(meta.e2e){ + blob=await fetchFileB64(path, meta.size||0, a); + } else { + const r=await jfetch('/api/chat/file?path='+encodeURIComponent(path)); + if(!r.ok){ + let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} + toast('téléchargement impossible : '+msg); return; + } + blob=await r.blob(); + } + const blobURL=URL.createObjectURL(blob); + const link=document.createElement('a'); + link.href=blobURL; link.download=name||meta.name||'fichier'; + document.body.appendChild(link); link.click(); link.remove(); + // Révocation différée : Safari annule le téléchargement si l'URL disparaît + // dans la foulée du clic. + setTimeout(()=>URL.revokeObjectURL(blobURL), 10000); + }catch(e){ toast('téléchargement impossible : '+((e&&e.message)||'erreur')); } + finally{ if(a){ a.classList.remove('busy'); a.title=wasTitle; } } +} +// Markdown refuse les espaces NON échappés dans la cible d'un lien : le modèle +// écrit [le rapport](mon rapport.pdf), et rien n'est rendu du tout — même pas un +// lien mort. Or les noms de fichiers à espaces sont la règle, pas l'exception. On +// les encode donc avant le rendu (fileLinkPath les redécode). +// +// Les cibles avec un titre — [x](chemin "Titre") — sont laissées tranquilles : +// leurs espaces sont syntaxiques. +function encodeMdLinkSpaces(text){ + return String(text).replace(/\]\(([^)\n]*)\)/g, (whole, target)=>{ + if(target.indexOf(' ')<0 || /["']/.test(target)) return whole; + if(/^[a-z][a-z0-9+.-]*:\/\//i.test(target)) return whole; // URL : pas notre affaire + return '](' + target.replace(/ /g, '%20') + ')'; + }); +} +// Chemin visé par un lien de la réponse, ramené au dossier de travail — ou '' +// si le lien pointe ailleurs (page web, ancre, mailto…). +// +// Le modèle écrit indifféremment un chemin relatif, un chemin absolu du serveur, +// ou le préfixe `sandbox:` qu'il a vu ailleurs : les trois doivent marcher, on +// ne va pas lui demander d'être rigoureux sur un détail pareil. +function fileLinkPath(href){ + if(!href) return ''; + let h=href.trim(); + if(/^(https?:|mailto:|tel:|data:|blob:|#)/i.test(h)) return ''; + h=h.replace(/^sandbox:/i,'').replace(/^file:\/\//i,''); + try{ h=decodeURI(h); }catch(_){} + h=h.replace(/\\/g,'/'); + // Chemin absolu : on ne garde que ce qui suit le dossier de travail. Le serveur + // revérifie de toute façon — c'est lui qui fait autorité sur le périmètre. + const m=h.match(/(?:^|\/)workspace\/(.+)$/); + if(m) h=m[1]; + h=h.replace(/^\/+/,'').replace(/^\.\//,''); + if(!h || h.indexOf('..')>=0) return ''; + return h; +} +// Transforme en téléchargements les liens d'une réponse qui visent un fichier. +function markFileLinks(root){ + for(const a of root.querySelectorAll('a[href]')){ + const p=fileLinkPath(a.getAttribute('href')); + if(!p) continue; + a.classList.add('filelink'); + a.removeAttribute('target'); + a.setAttribute('href','#'); + const name=p.split('/').pop(); + a.title='télécharger '+name; + a.onclick=(e)=>{ e.preventDefault(); downloadWorkspaceFile(p, name, a); }; + } +} +function attachListEl(){ return document.getElementById('attach-list'); } +function renderAttach(){ + const el = attachListEl(); if(!el) return; + el.innerHTML=''; + el.classList.toggle('show', ATTACH.length>0); + for(const a of ATTACH){ + el.appendChild(fileChip(a.name, a.size, { + cls: a.state==='up' ? 'up' : (a.state==='err' ? 'err' : ''), + title: a.error || a.name, + // Un gros fichier prend du temps : on montre l'avancement plutôt qu'un + // anneau qui tourne sans rien dire. Sous un morceau, il n'y a rien à suivre. + sizeText: a.state==='err' ? 'échec' + : (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %' + : fmtSize(a.size), + onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); } + })); + } +} +function clearAttach(){ ATTACH=[]; renderAttach(); } +// Ce qui part avec le message, pour l'afficher dans sa bulle. +function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); } + +// Lecture en base64. readAsDataURL préfixe par "data:;base64," — le +// serveur sait retirer cet en-tête, on n'a donc rien à découper ici. +function readAsBase64(file){ + return new Promise((res,rej)=>{ + const fr=new FileReader(); + fr.onload=()=>res(String(fr.result||'')); + fr.onerror=()=>rej(new Error('lecture impossible')); + fr.readAsDataURL(file); + }); +} +// Envoie UN morceau. VIA jfetch, pas un fetch brut : derrière app.ajean.link, +// jfetch est réécrit pour traverser la boîte noire E2E — un POST direct vers +// /api/chat/upload n'atteint PAS ton serveur (le relais est aveugle) et l'envoi +// échouait « impossible d'envoyer ». On fournit notre PROPRE signal pour ne pas +// hériter du plafond de 30 s de jfetch (calibré pour de petits appels), un morceau +// pouvant être plus long ; le download passe déjà par jfetch, on symétrise. +async function sendChunk(payload){ + const ac=new AbortController(); + const timer=setTimeout(()=>ac.abort(), UPLOAD_CHUNK_TIMEOUT_MS); + try{ + const r=await jfetch('/api/chat/upload',{ + method:'POST', headers:{'Content-Type':'application/json'}, + body:JSON.stringify(payload), signal:ac.signal + }); + const j=await r.json().catch(()=>({})); + if(!r.ok || !j.ok) throw new Error(j.error||('erreur '+r.status)); + return j; + } finally { clearTimeout(timer); } +} +// Le dépôt n'a lieu qu'à l'ENVOI du message, jamais à l'ajout : tant qu'on n'a +// pas cliqué, rien ne doit atterrir dans le dossier de travail de l'IA. Retirer +// un fichier de la liste ne laisse donc aucune trace sur le disque. +// +// Découpage en morceaux de 8 Mo : le navigateur ne lit qu'une tranche à la fois +// (Blob.slice), le serveur l'écrit et l'oublie. Un fichier d'un gigaoctet passe +// donc sans que personne ne le tienne entier en mémoire — et on sait dire où on +// en est, ce qu'un envoi monolithique ne permettait pas. +async function uploadAttach(a){ + a.state='up'; a.sent=0; renderAttach(); + try{ + let id=''; + // `do…while` et non `while` : un fichier plus petit qu'un morceau doit quand + // même faire un tour de boucle, sinon rien ne part. + let off=0; + do{ + const end=Math.min(off+ATTACH_CHUNK, a.size); + const data=await readAsBase64(a.file.slice(off, end)); + const last=end>=a.size; + // `size` au premier morceau : le serveur vérifie la place disque AVANT + // d'entamer un envoi d'un gigaoctet, plutôt que de le découvrir à la fin. + const j=await sendChunk({name:a.name, data:data, id:id, more:!last, size:(id?0:a.size)}); + if(j.id) id=j.id; + off=end; + a.sent=off; renderAttach(); + if(last){ a.path=j.path; } + } while(offATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; } + if(!f.size){ toast('« '+f.name+' » est vide'); continue; } + ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'}); + } + renderAttach(); +} +function onAttachPick(e){ + addFiles(e.target.files); + e.target.value=''; // sinon re-choisir le même fichier ne déclenche pas change +} +// Coller un fichier (capture d'écran, fichier copié depuis l'explorateur). On ne +// touche à rien quand le presse-papier ne contient que du texte : le collage +// normal dans la zone de saisie doit rester intact. +function onPaste(e){ + const items=(e.clipboardData&&e.clipboardData.files)||[]; + if(!items.length) return; + e.preventDefault(); + addFiles(items); +} +// Glisser-déposer sur toute la fenêtre. Le compteur de profondeur est nécessaire : +// dragenter/dragleave se déclenchent aussi au passage d'un enfant à l'autre, et +// sans lui le voile clignote dès que le curseur traverse une bulle du fil. +let DRAG_DEPTH=0; +function dropVeil(on){ + const v=document.getElementById('dropveil'); if(v) v.classList.toggle('show', on); +} +function hasFiles(e){ + const dt=e.dataTransfer; if(!dt) return false; + if(dt.types) for(const t of dt.types){ if(t==='Files') return true; } + return false; +} +window.addEventListener('dragenter', e=>{ if(!hasFiles(e)) return; e.preventDefault(); DRAG_DEPTH++; dropVeil(true); }); +window.addEventListener('dragover', e=>{ if(hasFiles(e)) e.preventDefault(); }); +window.addEventListener('dragleave', e=>{ if(!hasFiles(e)) return; DRAG_DEPTH=Math.max(0,DRAG_DEPTH-1); if(!DRAG_DEPTH) dropVeil(false); }); +window.addEventListener('drop', e=>{ + if(!hasFiles(e)) return; + e.preventDefault(); DRAG_DEPTH=0; dropVeil(false); + addFiles(e.dataTransfer.files); +}); +// Appelé par send() : c'est ICI que les fichiers partent réellement vers le +// serveur. Ceux qui échouent sont ignorés — leur pastille reste à l'écran en +// rouge, elle dit déjà ce qui s'est passé. +async function attachPaths(){ + await Promise.all(ATTACH.filter(a=>a.state==='queued').map(uploadAttach)); + return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path); +} +renderAttach(); diff --git a/internal/loki/ui/src/js/15-node.js b/internal/loki/ui/src/js/15-node.js new file mode 100644 index 0000000..e6d2379 --- /dev/null +++ b/internal/loki/ui/src/js/15-node.js @@ -0,0 +1,187 @@ +// ─── Postes distants ───────────────────────────────────────────────────────── +// Gestion des « postes » : d'autres PC qui se connectent au serveur pour que +// l'IA puisse agir dessus. Le propriétaire génère un code d'appairage, choisit +// les capacités autorisées et le dossier racine, puis peut lister/révoquer les +// postes. Sous-réglage du mode agent (grisé quand l'agent est off). +let nodeList = []; +let nodeTarget = ''; +let nodeAllCaps = ['shell','read','write','list']; +const nodeCapLabels = {shell:'shell', read:'lire', write:'écrire', list:'lister'}; + +async function loadNode(){ renderNode(await jget('/api/node')); } + +// Ouvre la modale de gestion (rafraîchit d'abord pour un état à jour). Appelée +// par le bouton PC/wifi du composeur. +function openNodeHub(){ loadNode(); showModal('node-hub-modal'); } + +// Le bouton du composeur reflète SUR QUOI l'IA agit : écran = ce serveur, ondes +// wifi (teinté accent) = un poste distant est la cible active. +function updateNodeBtn(){ + const btn = document.getElementById('node-btn'); + if(!btn) return; + const remote = !!nodeTarget; + btn.classList.toggle('remote', remote); + const n = remote ? nodeList.find(x=>x.slug===nodeTarget) : null; + btn.title = remote ? ('Postes distants — l\'IA agit sur « '+(n?n.name:nodeTarget)+' »') : 'Postes distants — l\'IA agit sur ce serveur'; +} + +function renderNode(r){ + nodeList = (r && r.nodes) || []; + nodeTarget = (r && r.target) || ''; + if(r && r.all_caps && r.all_caps.length) nodeAllCaps = r.all_caps; + updateNodeBtn(); + const list = document.getElementById('node-hub-list'); + if(!list) return; + list.textContent = ''; + if(!nodeList.length){ + list.innerHTML = '
      (aucun poste appairé — appaire un PC pour que l\'IA puisse agir dessus)
      '; + return; + } + const connected = nodeList.filter(n=>n.connected).length; + // Sélecteur de CIBLE : sur quelle machine l'IA agit (ses outils bash/write/edit). + renderNodeTarget(list, connected); + nodeList.forEach(n=>{ + const row = document.createElement('div'); row.className = 'mcp-row'+(n.connected?'':' off'); + const dot = document.createElement('span'); dot.className = 'mcp-dot '+(n.connected?'mcp-dot-ok':'mcp-dot-off'); + dot.title = n.connected ? 'connecté' : 'hors ligne'; + + const info = document.createElement('div'); info.className = 'mcp-info'; + const nm = document.createElement('div'); nm.className = 'mcp-name'; nm.textContent = n.name; nm.title = n.os||''; + const meta = document.createElement('div'); meta.className = 'mcp-meta'; + const os = document.createElement('span'); os.className = 'mcp-tag'; os.textContent = n.os||'?'; meta.appendChild(os); + const caps = document.createElement('span'); caps.className = 'mcp-tools'; + caps.textContent = (n.caps&&n.caps.length) ? n.caps.map(c=>nodeCapLabels[c]||c).join(', ') : 'aucune capacité'; + meta.appendChild(caps); + if(n.root){ const rt = document.createElement('span'); rt.className='mcp-tools'; rt.textContent = '📁 '+n.root; rt.title=n.root; meta.appendChild(rt); } + info.appendChild(nm); info.appendChild(meta); + + const edit = document.createElement('button'); edit.className='mcp-edit'; edit.title='régler'; edit.textContent='✎'; + edit.onclick = (e)=>{ e.stopPropagation(); openNodeEdit(n.id); }; + row.appendChild(dot); row.appendChild(info); row.appendChild(edit); + row.onclick = ()=>openNodeEdit(n.id); + list.appendChild(row); + }); +} + +// Sélecteur « L'IA agit sur : Ce serveur / ». Les mêmes outils +// (bash/write/edit) s'exécutent sur la machine choisie ; un poste hors ligne ne +// peut pas être cible (fail-closed côté serveur). +function renderNodeTarget(list, connected){ + const wrap = document.createElement('div'); wrap.className = 'node-target'; + const lab = document.createElement('div'); lab.className = 'node-target-l'; + lab.textContent = "L'IA agit sur :"; wrap.appendChild(lab); + const opts = [{slug:'', name:'Ce serveur'}].concat(nodeList.filter(n=>n.connected).map(n=>({slug:n.slug, name:n.name}))); + // Cible sélectionnée mais hors ligne : on l'ajoute quand même, marquée, pour ne + // pas la perdre silencieusement. + if(nodeTarget && !opts.some(o=>o.slug===nodeTarget)){ + const n = nodeList.find(x=>x.slug===nodeTarget); + opts.push({slug:nodeTarget, name:(n?n.name:nodeTarget)+' (hors ligne)', off:true}); + } + opts.forEach(o=>{ + const l = document.createElement('label'); l.className = 'node-target-opt'+(o.slug===nodeTarget?' on':'')+(o.off?' off':''); + const rb = document.createElement('input'); rb.type='radio'; rb.name='node-target'; rb.checked = o.slug===nodeTarget; + rb.onchange = ()=>setNodeTarget(o.slug); + const sp = document.createElement('span'); sp.textContent = o.name; + l.appendChild(rb); l.appendChild(sp); wrap.appendChild(l); + }); + list.appendChild(wrap); +} + +async function setNodeTarget(slug){ + const r = await jpost('/api/node/target', {slug}); + nodeTarget = (r && r.target) || ''; + toast(slug ? ('l\'IA agit sur « '+slug+' »') : 'l\'IA agit sur ce serveur'); + loadNode(); +} + +// ── Appairage : génère un code que le poste échange contre sa clé ──────────── +function nodeCapCheckboxes(containerId, selected){ + const box = document.getElementById(containerId); + box.textContent = ''; + const sel = new Set(selected||[]); + nodeAllCaps.forEach(c=>{ + const lab = document.createElement('label'); lab.className='node-cap'; + const cb = document.createElement('input'); cb.type='checkbox'; cb.value=c; cb.checked = sel.has(c); + const sp = document.createElement('span'); sp.textContent = nodeCapLabels[c]||c; + lab.appendChild(cb); lab.appendChild(sp); + box.appendChild(lab); + }); +} +function nodeCapValues(containerId){ + return [...document.querySelectorAll('#'+containerId+' input:checked')].map(cb=>cb.value); +} + +function openNodePair(){ + nodeCapCheckboxes('node-pair-caps', ['read','list']); // défaut prudent + document.getElementById('node-pair-root').value = ''; + document.getElementById('node-pair-out').style.display = 'none'; + showModal('node-pair-modal'); +} + +async function genNodeCode(){ + const caps = nodeCapValues('node-pair-caps'); + if(!caps.length){ toast('choisis au moins une capacité'); return; } + const root = document.getElementById('node-pair-root').value.trim(); + const r = await jpost('/api/node/pair', {caps, root}); + if(!r.ok){ toast(r.error||'échec'); return; } + document.getElementById('node-pair-code').textContent = r.code; + document.getElementById('node-pair-ttl').textContent = r.ttl_min || 10; + const allow = caps.join(','); + const rootArg = root ? (' --root "'+root+'"') : ''; + // Commande d'accès À DISTANCE (partout) via ajean.link : chiffrée de bout en + // bout, le relais reste aveugle. --key = clé publique de l'agent (le poste + // scelle vers elle), --machine = quelle machine joindre. + const remote = 'loki remote install https://ajean.link --machine '+r.machine+ + ' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg; + // Variante RÉSEAU LOCAL (connexion directe, sans passer par le relais). + const lan = 'loki remote install '+location.origin+ + ' --key '+r.agent_pub+' --code '+r.code+' --allow '+allow+rootArg; + document.getElementById('node-pair-cmd').textContent = remote; + const lanEl = document.getElementById('node-pair-cmd-lan'); + if(lanEl) lanEl.textContent = lan; + document.getElementById('node-pair-out').style.display = ''; + loadNode(); +} + +// ── Édition d'un poste appairé : capacités, dossier, révocation ────────────── +let nodeEditing = null; +function openNodeEdit(id){ + const n = nodeList.find(x=>x.id===id); if(!n) return; + nodeEditing = id; + nodeCapCheckboxes('node-pair-caps', n.caps); + document.getElementById('node-pair-root').value = n.root||''; + document.getElementById('node-pair-out').style.display = 'none'; + // Réutilise la modale d'appairage en mode édition : on remplace le pied. + document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Poste « '+n.name+' »'+(n.connected?' (connecté)':''); + const footR = document.querySelector('#node-pair-modal .foot-r'); + footR.innerHTML = ''; + const cancel = document.createElement('button'); cancel.textContent='fermer'; cancel.onclick=()=>{ hideModal('node-pair-modal'); resetNodeFoot(); }; + const save = document.createElement('button'); save.className='btn-save'; save.textContent='enregistrer'; save.onclick=saveNodeCaps; + footR.appendChild(cancel); footR.appendChild(save); + const footL = document.querySelector('#node-pair-modal .foot-l'); + footL.innerHTML = ''; + const del = document.createElement('button'); del.className='btn-danger'; del.textContent='révoquer'; del.onclick=()=>revokeNode(id); + footL.appendChild(del); + showModal('node-pair-modal'); +} +function resetNodeFoot(){ + document.querySelector('#node-pair-modal .modal-head strong').textContent = 'Appairer un poste distant'; + document.querySelector('#node-pair-modal .foot-l').innerHTML = ''; + const footR = document.querySelector('#node-pair-modal .foot-r'); + footR.innerHTML = ''; + nodeEditing = null; +} +async function saveNodeCaps(){ + if(!nodeEditing) return; + const caps = nodeCapValues('node-pair-caps'); + const root = document.getElementById('node-pair-root').value.trim(); + const r = await jpost('/api/node/caps', {id:nodeEditing, caps, root}); + if(!r.ok){ toast(r.error||'échec'); return; } + toast('poste mis à jour'); hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); +} +async function revokeNode(id){ + const n = nodeList.find(x=>x.id===id); + if(!await askConfirm('Révoquer le poste « '+(n?n.name:'')+' » ? Sa clé sera oubliée et il sera déconnecté.', {title:'Révoquer le poste ?', okText:'Révoquer', danger:true})) return; + await jpost('/api/node/revoke', {id}); + hideModal('node-pair-modal'); resetNodeFoot(); loadNode(); toast('poste révoqué'); +} diff --git a/internal/loki/ui/src/styles.css b/internal/loki/ui/src/styles.css new file mode 100644 index 0000000..b409a9b --- /dev/null +++ b/internal/loki/ui/src/styles.css @@ -0,0 +1,1171 @@ +/* ===== Palette ===================================================== + Un seul thème (« Clean ») : noir et blanc, sobre. Deux variantes — claire par + défaut, sombre via data-theme="dark" sur . Toutes les couleurs de l'UI + passent par ces variables ; rien d'autre ne code une couleur en dur. */ +:root{color-scheme:light;--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Inter,Helvetica,Arial,sans-serif;--mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--bubble:#f0f0f0;--bg:#ffffff;--panel:#fafafa;--border:#e0e0e2;--text:#0a0a0a;--dim:#6b6b70;--accent:#0a0a0a;--accent-bg:rgba(0,0,0,.05);--row-bg:rgba(0,0,0,.03);--row-bg-on:rgba(0,0,0,.042);--ok:#0a0a0a;--warn:#6b6b6b;--err:#c0392b;--mag:#6b6b6b;--code-bg:#f5f5f5;--danger-bg:#fbeae8;--warn-bg:#f2f2f2;--add:#1a7f37;--add-bg:rgba(26,127,55,.10);--del:#cf222e;--del-bg:rgba(207,34,46,.10)} +[data-theme="dark"]{color-scheme:dark;--bubble:#232326;--bg:#0b0b0c;--panel:#161618;--border:#33333a;--text:#f2f2f4;--dim:#a0a0a8;--accent:#f5f5f5;--accent-bg:rgba(255,255,255,.07);--row-bg:rgba(255,255,255,.04);--row-bg-on:rgba(255,255,255,.055);--ok:#f5f5f5;--warn:#8e8e8e;--err:#f2777a;--mag:#8e8e8e;--code-bg:#000000;--danger-bg:#2e1a19;--warn-bg:#1c1c1c;--add:#4ac26b;--add-bg:rgba(74,194,107,.13);--del:#f85149;--del-bg:rgba(248,81,73,.13)} +/* Safari iOS peint un rectangle gris/noir sur TOUT élément touché (bouton, lien, + résumé de section, bulle repliable…). C'est un retour tactile par défaut, hors + de notre charte et impossible à styler : on le coupe. Nos états :active/:focus + restent, eux, gérés élément par élément. */ +*{box-sizing:border-box;-webkit-tap-highlight-color:transparent} +/* ⚠️ overflow:hidden sur html/body est INTERDIT EN PWA (tenté puis retiré le + 2026-07-28) : en PWA iOS, si le document ne peut plus défiler, WebKit décale + TOUTE la mise en page à l'ouverture du clavier pour montrer le champ actif — + la carte de saisie se retrouve plaquée en haut de l'écran. Le rebond + élastique du document est le prix à payer ; il est cosmétique, le clavier + ne l'est pas. + Laisser le document défiler se paie cher, et EN PWA AUSSI : la coque y fait + 956 pt (obligatoire pour que la carte atteigne le bas de l'écran) alors que la + fenêtre de mise en page n'en fait que 894 → il reste 62 pt de document + défilable en permanence, même avec un fil VIDE, et la carte de saisie — posée + en bottom:0 dans le body — part avec au lieu de rester collée en bas. + On coupe donc le défilement du document PARTOUT (2026-07-29). Le fil, lui, + défile toujours dans #chat, qui borne son rebond (overscroll:contain). + ⚠️ SI LE CLAVIER iOS SE REMET À TOUT DÉCALER EN PWA, c'est ce overflow:hidden + qu'il faut rouvrir en premier — c'était le compromis d'origine. */ +/* Hauteur de la coque : elle DIFFÈRE entre PWA et navigateur, il faut les deux. + - PWA (écran d'accueil) : 100vh, et SURTOUT ni 100dvh ni 100%. Mesuré sur + iPhone (2026-07-28) : la vue web couvre tout l'écran (956 pt — le contenu + passe bien sous l'horloge), mais la fenêtre de mise en page ne fait que + 894 pt et reste calée en haut. Or 100dvh ET 100% valent tous deux 894 → il + restait une bande morte de 62 pt (= la zone sûre du haut) en bas de l'écran, + sous la carte de saisie. 100vh se réfère au GRAND viewport (956) : la seule + unité juste dans ce cas. + - Navigateur (barre d'URL + barre d'outils visibles) : 100vh vaut justement ce + GRAND viewport, donc la coque dépasse la zone réellement visible → le + document défile alors que le fil est vide, et le bas de la carte de saisie + (modèle / contexte) est coupé. Ici c'est 100dvh qu'il faut : la hauteur + visible du moment. + D'où : 100dvh par défaut, et 100vh UNIQUEMENT en PWA. + ⚠️ Le tri se fait sur l'attribut html[data-pwa] (pose par le script du ), + PAS sur le media `display-mode:standalone` : WebKit adosse ce media au `display` + du MANIFESTE, or cette app n'en a pas (elle vit en apple-mobile-web-app-capable). + En PWA iOS il repond donc `browser`, la branche navigateur s'appliquait, et la + bande morte de 62 pt sous la carte de saisie etait de retour (constate le + 2026-07-29). + ⚠️ Ne pas y ajouter overflow:hidden : voir plus haut, ça casse le clavier iOS. */ +body{margin:0;font-family:var(--font);background:var(--bg);color:var(--text);height:100vh;height:100dvh;overscroll-behavior:none;display:flex;font-size:14px} +html[data-pwa="1"] body{height:100vh} +/* Seul #chat défile ; le document, lui, est figé (cf. l'avertissement ci-dessus). + Exception PWA : on lui rend son défilement, sinon le clavier iOS casse tout. */ +html,body{overflow:hidden} +/* PWA plein écran (viewport-fit=cover) : on décale le contenu des bords pour ne + pas passer sous l'encoche / la barre système / l'indicateur d'accueil. Les + env(safe-area-inset-*) valent 0 hors PWA, donc aucun impact ailleurs. + ⚠️ PAS de padding-bas ici : le composer est posé en bottom:0 DANS le body, donc + une marge basse le décollerait du bord de l'écran et laisserait une bande de + fond visible sous la carte (visible seulement en PWA, où l'inset vaut ~34 px). + L'indicateur d'accueil est esquivé bloc par bloc, en padding INTERNE. */ +body{padding:env(safe-area-inset-top) env(safe-area-inset-right) 0 env(safe-area-inset-left)} +/* Listes déroulantes : la LISTE ouverte n'hérite pas du fond de son est posé par-dessus, piste transparente : + sinon on voyait deux barres superposées (la barre de proportion + la piste + blanche native du navigateur). Deux teintes du MÊME accent, pas deux + couleurs — la barre exprime une proportion, pas deux catégories. */ +.gpu-split{position:relative;display:flex;align-items:center;height:22px} +.gpu-bar{display:flex;width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)} +.gpu-bar>span{background:var(--accent);flex:none;min-width:0;transition:width .12s} +.gpu-bar>span.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)} +.gpu-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize} +.gpu-range:focus{outline:none} +.gpu-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none} +.gpu-range::-moz-range-track{height:22px;background:transparent;border:none} +.gpu-range::-moz-range-progress{background:transparent} +.gpu-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.gpu-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.gpu-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)} +.gpu-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)} +.gpu-legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:11.5px;color:var(--dim)} +.gpu-legend i{display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:5px} +.gpu-legend i.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)} +.gpu-shares{display:flex;flex-wrap:wrap;gap:10px} +.gpu-share{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--dim)} +.gpu-share input{width:62px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:7px;padding:5px 7px;font:inherit;text-align:right} +.gpu-share input:focus{outline:none;border-color:var(--accent)} +.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:9px 11px;font:inherit;transition:border-color .12s,box-shadow .12s} +.pe-inp:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)} +.pe-row.stack.plain .pe-inp{border:none;background:transparent;padding:6px 0;font-size:15px} +.pe-row.stack.plain .pe-inp:focus{box-shadow:none} +/* Champ + bouton accolé (téléchargement) — même trait que les autres champs, + sans pastille colorée qui ressort. */ +.pe-inpbtn{display:flex;align-items:stretch} +.pe-inpbtn .pe-inp{border-top-right-radius:0;border-bottom-right-radius:0;border-right:0} +/* Le bouton reprend EXACTEMENT le cadre du champ (mêmes coins, même bordure) : + sans ça, un thème qui arrondit tous les boutons (ChatGPT) le détachait du + champ, avec un coin rond au milieu de la paire. */ +.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-radius:0 8px 8px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s,background .12s} +.pe-inpbtn button:hover:not(:disabled){color:var(--bg);background:var(--accent);border-color:var(--accent)} +.pe-inpbtn:focus-within .pe-inp,.pe-inpbtn:focus-within button{border-color:var(--accent)} +.pe-inpbtn button:disabled{opacity:.5;cursor:default} +/* Contrôle segmenté (moteur) façon iOS. */ +.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:9px;padding:3px;width:100%} +.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:7px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s} +.pe-seg>label:has(input:checked){background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.22)} +.pe-seg input{display:none} +.pe-seg .be-note{display:none} +.pe-seg-note{font-size:11px;color:var(--dim);padding:0 2px;min-height:14px} +.pe-note{font-size:11px;color:var(--dim);line-height:1.45} +/* Barre de progression (téléchargement de modèle). Sans total connu, elle + défile en boucle via .indet. */ +.pe-bar{height:4px;border-radius:999px;background:color-mix(in srgb,var(--dim) 26%,transparent);overflow:hidden} +.pe-bar>i{display:block;height:100%;width:0;border-radius:999px;background:var(--accent);transition:width .35s linear} +.pe-bar.indet>i{width:35%;animation:pe-bar-slide 1.2s ease-in-out infinite;transition:none} +.pe-bar.done>i{width:100%} +@keyframes pe-bar-slide{0%{margin-left:-35%}100%{margin-left:100%}} +/* Action discrète en texte (annuler un téléchargement…). */ +.pe-link{align-self:flex-start;border:none;background:none;padding:0;font:inherit;font-size:11px;color:var(--dim);text-decoration:underline;cursor:pointer} +.pe-link:hover{color:var(--text)} +.pe-link:disabled{opacity:.5;cursor:default;text-decoration:none} +/* Interrupteur à glissière. */ +.pe-switch{position:relative;flex:none;width:40px;height:24px} +.pe-switch input{position:absolute;opacity:0;width:0;height:0} +.pe-sl{position:absolute;inset:0;background:color-mix(in srgb,var(--dim) 55%,transparent);border-radius:999px;transition:background .18s} +.pe-sl::before{content:"";position:absolute;width:18px;height:18px;left:3px;top:3px;background:#fff;border-radius:50%;transition:transform .18s;box-shadow:0 1px 3px rgba(0,0,0,.25)} +.pe-switch input:checked+.pe-sl{background:var(--accent)} +.pe-switch input:checked+.pe-sl::before{transform:translateX(16px);background:var(--bg)} +.pe-switch input:focus-visible+.pe-sl{box-shadow:0 0 0 3px var(--accent-bg)} +/* Repli « avancé » / config brute : une ligne cliquable dans un groupe. */ +.pe-adv{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden} +.pe-adv>summary{cursor:pointer;font-size:14px;color:var(--text);padding:13px 15px;list-style:none;display:flex;align-items:center;user-select:none} +.pe-adv>summary::-webkit-details-marker{display:none} +.pe-adv>summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:translateY(-2px) rotate(45deg);transition:transform .15s} +.pe-adv[open]>summary::after{transform:translateY(1px) rotate(-135deg)} +.pe-adv[open]>summary{border-bottom:1px solid var(--border)} +.pe-adv .pe-adv-body{padding:14px 15px;display:flex;flex-direction:column;gap:10px} +.pe-adv textarea.pe-inp{min-height:210px;resize:vertical;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:hidden} +/* Ligne « éditer le .env » : chevron de disclosure + textarea de config brute. */ +.pe-caret{display:inline-block;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:rotate(-45deg);transition:transform .15s} +.pe-caret.open{transform:rotate(45deg)} +#m-content{min-height:220px;resize:vertical;line-height:1.5;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;overflow-x:hidden} +/* Nombres avancés : chaque paire label/valeur sur sa propre ligne (réutilise pe-row). */ +.pe-adv .pe-list{margin:0} +/* Variante « champ en clair » : page mémoire → le contenu est le champ principal. */ +.pe-adv.pe-raw-plain{border:none;background:transparent} +.pe-adv.pe-raw-plain>summary{display:none} +.pe-adv.pe-raw-plain .pe-adv-body{padding:0} +/* Pied de modale. */ +.pe-foot{padding:9px 12px;border-top:1px solid var(--border);display:flex;justify-content:space-between; + align-items:center;gap:8px;flex-wrap:nowrap} +.pe-foot .foot-l{display:flex;align-items:center;gap:8px;min-width:0} +.pe-foot .foot-r{display:flex;gap:8px;flex:none} +.pe-btn{margin:0;padding:7px 15px;font-size:12.5px;border-radius:8px;font-weight:600;cursor:pointer; + border:1px solid var(--border);background:var(--panel);color:var(--text);white-space:nowrap} +.pe-btn:hover{border-color:var(--dim)} +.pe-btn.primary{border-color:var(--accent);background:var(--accent);color:var(--bg)} +.pe-btn.primary:hover{filter:brightness(1.08)} +/* Suppression : icône seule, en retrait. Un bouton plein « supprimer » pesait + autant que « enregistrer » alors que c'est l'action rare — et à trois boutons + larges, le pied passait à deux lignes sur téléphone. */ +.pe-del{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px; + margin:0;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--err);cursor:pointer} +.pe-del svg{width:15px;height:15px;display:block} +.pe-del:hover{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 40%,transparent)} +/* Option d'une confirmation : une PASTILLE à cocher, pas une case système suivie + d'un bout de texte. Éteinte : contour discret. Allumée : elle passe au rouge, + comme l'action destructrice qu'elle étend. Vit dans la boîte de confirmation + (#ask-check), plus dans le pied de l'éditeur. */ +.pe-delmodel{align-self:flex-start;margin-top:14px;display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:600; + letter-spacing:.02em;color:var(--dim);cursor:pointer;white-space:nowrap; + padding:5px 11px;border:1px solid var(--border);border-radius:999px;background:var(--panel); + transition:color .12s,border-color .12s,background .12s} +.pe-delmodel input{position:absolute;opacity:0;width:0;height:0} +/* Le point tient lieu de case à cocher : creux au repos, plein une fois coché. */ +.pe-delmodel::before{content:"";width:6px;height:6px;border-radius:50%;flex:none; + border:1px solid currentColor;background:transparent} +.pe-delmodel:hover{color:var(--text);border-color:var(--dim)} +.pe-delmodel:has(input:checked){color:var(--err);border-color:color-mix(in srgb,var(--err) 45%,transparent);background:var(--danger-bg)} +.pe-delmodel:has(input:checked)::before{background:currentColor} +/* Éditeur de preset/mémoire — mise en forme propre du modale */ +.modal-ov{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px} +/* Ouverture/fermeture animées. L'état par défaut est l'état FERMÉ ; la classe + .show (posée par showModal/hideModal) fait jouer la transition dans les deux + sens — une @keyframes n'aurait animé que l'ouverture. */ +.modal-ov,.ask-overlay{opacity:0;transition:opacity .16s ease} +.modal-ov.show,.ask-overlay.show{opacity:1} +.modal-card,.ask-box{transform:translateY(10px) scale(.985);opacity:0; + transition:transform .18s cubic-bezier(.2,.8,.3,1),opacity .18s ease} +.modal-ov.show .modal-card,.ask-overlay.show .ask-box{transform:none;opacity:1} +@media (prefers-reduced-motion:reduce){ + .modal-ov,.ask-overlay,.modal-card,.ask-box{transition:none} +} +.modal-card{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:12px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.18)} +.modal-head{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center} +.modal-head strong{font-size:14px} +/* Croix de fermeture : un CARRÉ, pas un rectangle. Avec un padding asymétrique + (3px vertical / 10px horizontal) le glyphe paraissait écrasé et la cible de + clic était basse. 32px, glyphe centré, taille de cible correcte au doigt. */ +.modal-x{margin:0;padding:0;width:32px;height:32px;flex:none;display:inline-flex;align-items:center;justify-content:center;font-size:19px;line-height:1;color:var(--dim)} +.modal-body{padding:16px;overflow:auto;flex:1} +/* Modale ouverte mais pas encore remplie : on affiche le voile de chargement À LA + PLACE du formulaire et du pied. Estomper le formulaire ne suffisait pas — on + voyait quand même les champs s'écrire un par un derrière le voile. */ +/* Voile OPAQUE, posé sous l'en-tête (le titre reste lisible) : `visibility:hidden` + sur le formulaire suffirait en théorie, mais un fond plein garantit qu'aucun + reste du preset précédent (moteur coché, réglages) ne puisse apparaître une + fraction de seconde. --mh = hauteur mesurée de l'en-tête, posée à l'ouverture. */ +/* ⚠️ Aucune transition tant que le formulaire est caché, NI pendant la frame où + il réapparaît : WebKit diffère les transitions des éléments non rendus et les + joue à leur retour à l'écran. Le sélecteur de moteur glissait donc de l'ancien + preset au nouveau SOUS LES YEUX, une fois le chargement terminé. */ +.modal-ov.loading .pe-body *,.modal-ov.no-anim .pe-body *{transition:none!important} +.modal-load{display:none;position:absolute;inset:var(--mh,52px) 0 0 0;z-index:2; + align-items:center;justify-content:center;background:var(--bg);border-radius:0 0 12px 12px} +.modal-ov.loading .modal-load{display:flex} +/* visibility (et non display) : le formulaire garde sa place, donc la carte a + déjà sa taille finale — la révéler ne provoque plus d'à-coup de hauteur. */ +.modal-ov.loading .modal-body, +.modal-ov.loading .pe-body, +.modal-ov.loading .pe-foot, +.modal-ov.loading .modal-foot{visibility:hidden} +/* Rond qui tourne — les trois points sont la signature du CHAT, pas des modales. */ +.spinner{width:22px;height:22px;border-radius:50%;border:2px solid var(--border); + border-top-color:var(--dim);animation:spin .7s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} +@media (prefers-reduced-motion:reduce){.spinner{animation-duration:2s}} +.modal-foot{padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap} +.foot-l{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.foot-r{display:flex;gap:8px} +.mform{display:flex;flex-direction:column;gap:14px} +.fld{display:flex;flex-direction:column;gap:5px} +.fld-l{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--dim)} +.fld-l .opt{text-transform:none;letter-spacing:0;font-weight:400;opacity:.65} +.fld-hint{font-size:11px;color:var(--dim);margin-top:2px} +.ctl{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:7px;padding:9px 11px;font:inherit} +.ctl:focus{outline:none;border-color:var(--accent)} +.ta{min-height:220px;resize:vertical;line-height:1.5} +.card{border:1px solid var(--border);border-radius:10px;padding:13px;display:flex;flex-direction:column;gap:13px} +.card-h{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--accent)} +.row{display:flex;gap:6px} +.row .ctl{flex:1;min-width:0} +.btn-dl{flex-shrink:0;margin:0} +.input-btn{display:flex} +.input-btn .ctl{flex:1;min-width:0;border-top-right-radius:0;border-bottom-right-radius:0} +.input-btn .btn-in{flex-shrink:0;margin:0;padding:9px 13px;border-left:0;border-top-left-radius:0;border-bottom-left-radius:0} +.input-btn .btn-in:hover{border-left:0} +.seg{display:flex;gap:6px} +.seg>label{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;min-height:46px;padding:6px 4px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:12px;background:var(--panel);transition:border-color .12s,background .12s,color .12s} +.seg>label:hover{border-color:var(--accent)} +.seg>label:has(input:checked){border-color:var(--accent);background:var(--accent-bg);color:var(--accent)} +.seg input{display:none} +.seg .be-note{font-size:10px;color:var(--dim);margin:0} +.btn-danger{background:var(--danger-bg);border-color:var(--err);color:var(--err);margin:0} +.btn-save{border-color:var(--accent);color:var(--accent);margin:0} +.del-model{align-items:center;gap:5px;font-size:11px;color:var(--dim)} +.del-model input{margin:0} +.lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:12px;color:var(--dim);font-weight:600;padding:6px 0;margin-top:2px} +.lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block} +.lc-logbar.open #lc-log-caret{transform:rotate(90deg)} +#lc-log{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0} +.lc-spin{display:inline-block;animation:spin 1s linear infinite} +/* Serveurs MCP */ +.mcp-list{display:flex;flex-direction:column;gap:4px} + +.mcp-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel);cursor:pointer} +.mcp-row:hover{border-color:var(--accent)} +.mcp-row.off{opacity:.6} +.mcp-dot{flex:none;width:8px;height:8px;border-radius:50%} +.mcp-dot-ok{background:var(--accent)} +.mcp-dot-err{background:var(--err)} +.mcp-dot-off{background:var(--dim)} +.mcp-info{flex:1;min-width:0} +.mcp-name{font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.mcp-meta{display:flex;align-items:center;gap:6px;margin-top:2px} +.mcp-tag{font-size:10px;padding:1px 7px;border-radius:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em} +.mcp-tools{font-size:10px;color:var(--accent)} +.mcp-err{font-size:10px;color:var(--err)} +.mcp-switch{flex:none} +.mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5} +.mcp-edit:hover{opacity:1;color:var(--accent)} +/* Liste des outils MCP dans la modale */ +.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px} +.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer} +.mcp-tool-row:hover{background:var(--panel)} +.mcp-tool-switch{flex:none;transform:scale(.85)} +.mcp-tool-name{font-size:12px;color:var(--text);word-break:break-all} + +/* Accès OpenAI / Accès distant : champs lecture seule (URL, clé) + libellés + compacts, pour une présentation homogène et sans surcharge visuelle. */ +.io-input{flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:9px;padding:8px 10px;font:inherit;font-size:13px} + +/* Modale MCP (ajout/édition) : les champs des groupes stdio/http n'avaient aucun + espacement vertical (le conteneur n'est pas un flex à gap) → ils se touchaient. + On aligne leur rythme sur celui du reste du formulaire (.mform). */ +#mcp-stdio-fields,#mcp-http-fields{display:flex;flex-direction:column;gap:14px} + +/* ===== Mise en page de la conversation ============================= + Colonne de lecture centrée, réponses SANS bulle (texte nu sur le fond), + question de l'utilisateur en pastille arrondie à droite, composer en + « pilule », pas d'étiquettes VOUS/Loki. Ces règles surchargent la mise en + page générique plus haut : elles doivent donc rester APRÈS elle. */ +body{font-size:15px} +h1{color:var(--text);font-weight:600;letter-spacing:-.01em} +/* Barre latérale : plus sombre/atténuée que la zone de conversation. */ +.side{background:var(--panel);border-right-color:transparent} +@media (max-width:720px){.side{background:var(--panel)}} +/* Colonne de lecture centrée (comme le fil ChatGPT), pleine largeur sur mobile. */ +#chat{padding:28px max(16px,calc(50% - 380px)) 8px;gap:22px;scrollbar-gutter:stable both-edges} +/* Réponses de l'assistant : pas de cadre, pas de fond — juste du texte. */ +#chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7} +/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */ +#chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6} +#chat .msg.user .label{color:var(--dim)} +/* Étiquettes VOUS / Loki : inutiles ici (l'alignement suffit), sauf pour les + bulles repliables (raisonnement / outils) où le label est le bouton de repli. */ +#chat .msg .label{display:none} +#chat .msg.collapsible .label{display:block} +/* Mesures de génération sous la réponse (prefill / decode). Assez discrètes pour + ne pas concurrencer le texte, assez lisibles pour être utiles. Escamotables + depuis Apparence → Affichage. */ +#chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em; + color:var(--dim);opacity:.75} +#chat .msg .statline:empty{display:none} +html[data-hide-stats="1"] #chat .msg .statline{display:none} +/* Arrivée en fondu montant plutôt qu'un surgissement sec. L'opacité finale (.75) + est celle de la règle ci-dessus : l'animation doit y ATTERRIR, sinon la ligne + clignote en repassant à sa valeur de repos. */ +#chat .msg .statline-in{animation:statin .28s cubic-bezier(.2,.7,.3,1) both} +@keyframes statin{from{opacity:0;transform:translateY(3px)}to{opacity:.75;transform:none}} +@media (prefers-reduced-motion:reduce){#chat .msg .statline-in{animation:none}} +/* Raisonnement / outils : encart gris discret plutôt qu'un cadre pointillé. */ +#chat .msg.reasoning,#chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:14px;color:var(--dim);padding:12px 16px} +#chat .msg.reasoning{color:var(--dim)} +#chat .msg.tool .tool-head{color:var(--text)} +#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{border-radius:12px;padding:12px 14px} +#chat .msg.assistant .body a{text-decoration:underline;text-underline-offset:2px} +/* Composer = UNE seule carte posée PAR-DESSUS le fil : la conversation + continue de défiler DERRIÈRE elle et s'efface en fondu (dégradé vers le fond) + au lieu de s'arrêter net au-dessus. Dans la carte, de haut en bas : la saisie + et ses boutons, un pied discret (préréglage · contexte · compacter), puis la + jauge de contexte qui FORME le bord bas — elle épouse les coins arrondis + (overflow:hidden sur le rayon). Les trois blocs sont des frères dans le HTML : + on les recolle en dessinant les bordures par morceaux et l'ordre visuel vient + de l'ordre flex — aucun changement de markup. */ +/* Le padding BAS dégage l'indicateur d'accueil de l'iPhone en PWA : la carte + flotte au-dessus de lui, son arrondi bas reste donc bien visible. Hors PWA + l'inset vaut 0 → strictement les 8 px d'origine. */ +#composer{position:absolute;left:0;right:0;bottom:0;z-index:4;border-top:none;display:flex;flex-direction:column;padding:34px 0 calc(8px + env(safe-area-inset-bottom));--col:calc(max(16px,calc(50% - 380px)) + var(--sbw,0px));background:linear-gradient(to bottom,transparent 0,var(--bg) 34%)} +#chat{padding-bottom:calc(170px + env(safe-area-inset-bottom))} +#scrollbtn{bottom:calc(180px + env(safe-area-inset-bottom));border-radius:999px} +#inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0} +#input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px} +/* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même + bordure, seule la saisie change (curseur). */ +#input:focus{border-color:transparent} +#send,#stop{border-radius:999px;height:36px;min-height:36px;padding:0 18px;margin:0} +#send{background:var(--text);color:var(--bg);border-color:var(--text);font-weight:600} +#send:hover:not(:disabled){opacity:.85;color:var(--bg);border-color:var(--text)} +/* Pied de carte : c'est LUI qui porte l'arrondi bas (26 px, comme en haut) et + qui clippe la jauge. Un bandeau de 7 px ne peut pas dessiner un rayon de 26 px + (le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte + découper la jauge dans sa propre forme — la barre est bien épaisse ET rognée + exactement sur la courbe, symétrique du haut. */ +#ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 26px 26px;overflow:hidden} +/* ⚠️ NE PAS piloter la position du composer en JS quand le clavier s'ouvre + (tenté puis retiré le 2026-07-28, deux fois) : ni asservir la hauteur de la + coque à visualViewport.height, ni décaler le composer de la hauteur du + clavier. Dans les deux cas la carte part en haut de l'écran en PWA iOS. + iOS gère déjà correctement le clavier tout seul — on le laisse faire. */ +#ctx-text,#status-preset{color:var(--dim);font-size:10px;letter-spacing:.02em;white-space:nowrap} +#ctx-compact{border-radius:999px;padding:2px 10px;font-size:10px;background:transparent;border-color:var(--border);color:var(--dim)} +#ctx-compact:hover{border-color:var(--dim);color:var(--text)} +/* Jauge = bord bas de la carte : épaisse, plaquée au ras du bord, rognée par + l'arrondi du pied de carte. */ +#ctxtrack{top:auto;bottom:0;height:7px;background:var(--border)} +#ctx-fill{background:var(--text);opacity:.75} +#sendhint{order:4;padding:12px var(--col) 2px} + +/* ===== Pièces jointes ============================================== + Un fichier joint n'est jamais lu par la page : il est déposé dans le dossier + de travail de l'IA et seul son nom revient. L'affichage se limite donc à une + pastille — pas d'aperçu, pas de vignette, rien qui promette plus que ça. + + Le trombone vit dans le PIED de la carte, à droite avec le décompte de contexte + et « compacter ». Il est à LEUR échelle (une ligne d'outils de 10 px), pas à + celle de « send » : joindre un fichier est un geste occasionnel. */ +#attach{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0; + display:flex;align-items:center;justify-content:center;border-radius:999px; + background:transparent;border:1px solid transparent;color:var(--dim)} +#attach:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)} +#attach:active{transform:scale(.9)} +#attach svg{display:block} +/* Bouton « Postes distants » du composeur : même gabarit que le trombone. L'icône + dépend de la cible active — écran (local) par défaut, ondes wifi quand un poste + distant est sélectionné, alors teinté en accent pour signaler « l'IA agit + ailleurs ». Les deux SVG coexistent ; on n'en montre qu'un via la classe. */ +#node-btn{width:18px;height:18px;min-height:18px;flex:none;margin:0;padding:0; + display:flex;align-items:center;justify-content:center;border-radius:999px; + background:transparent;border:1px solid transparent;color:var(--dim)} +#node-btn:hover{background:var(--row-bg-on);border-color:transparent;color:var(--text)} +#node-btn:active{transform:scale(.9)} +#node-btn svg{display:block} +#node-btn .ic-remote{display:none} +#node-btn.remote{color:var(--accent)} +#node-btn.remote .ic-here{display:none} +#node-btn.remote .ic-remote{display:block} +/* Fichiers en attente : de petites bulles POSÉES AU-DESSUS de la carte. + Le composeur réserve déjà 34 px transparents au-dessus de lui (la bande de + dégradé qui fond vers le fil) : les pastilles s'y logent, et on retire d'autant + le rembourrage. La carte ne bouge donc pas — c'est la bande vide qui se + remplit, au lieu du composeur qui grandit vers le haut. + Elles ne vont PAS dans le pied : cette ligne appartient au nom du modèle. */ +#attach-list{order:0;display:none;gap:6px;margin:0 var(--col);padding:0 2px 8px; + overflow-x:auto;scrollbar-width:none} +#attach-list::-webkit-scrollbar{display:none} +#attach-list.show{display:flex} +#composer:has(#attach-list.show){padding-top:6px} +/* Posées sur le fil (et non sur la carte) : il leur faut un fond opaque et un + cadre, sinon le texte de la conversation transparaît derrière. */ +#attach-list .chip-file{flex:0 0 auto;background:var(--panel);border-color:var(--border); + box-shadow:0 2px 10px rgba(0,0,0,.10)} +/* Pastille de fichier. Le × n'apparaît qu'au survol : au repos la pastille doit + rester lisible d'un coup d'œil (nom + poids), pas encombrée d'un bouton. */ +.chip-file{display:flex;align-items:center;gap:8px;max-width:230px;min-width:0; + padding:4px 12px;border-radius:999px;background:var(--row-bg-on); + border:1px solid transparent;font-size:12px;line-height:1.3} +/* Avec un × à droite, la pastille se ferme SUR lui : le rembourrage de droite + revient à l'épaisseur du rond, sinon le bouton flotte loin du bord et la + pastille paraît de travers. */ +.chip-file:has(button){padding-right:4px} +.chip-file .cf-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.chip-file .cf-size{flex:none;color:var(--dim);font-size:11px} +/* Le × est TOUJOURS visible : le révéler au survol le rendait inatteignable sur + mobile, où il n'y a pas de survol — donc impossible de retirer un fichier. */ +.chip-file button{flex:none;margin:0;padding:0;width:20px;height:20px;font-size:14px; + display:flex;align-items:center;justify-content:center;line-height:1; + border-radius:50%;border:none;background:transparent;color:var(--dim)} +.chip-file button:hover{background:var(--bubble);color:var(--text)} +/* Transfert en cours : un liseré qui tourne, plutôt qu'une pastille grisée dont + on ne sait pas si elle travaille ou si elle a échoué. */ +.chip-file.up .cf-size::after{content:"";display:inline-block;vertical-align:-2px; + margin-left:7px;width:10px;height:10px;border-radius:50%; + border:1.5px solid var(--border);border-top-color:var(--text);animation:spin .7s linear infinite} +.chip-file.err{background:var(--danger-bg);color:var(--err)} +.chip-file.err .cf-size{color:var(--err)} +@media (prefers-reduced-motion:reduce){.chip-file.up .cf-size::after{animation:none}} +/* Fichiers d'un message envoyé : leur propre rangée AU-DESSUS de la bulle, + alignée à droite comme elle. Rangée et bulle restent deux objets distincts, + séparés par l'espacement normal du fil — les rapprocher les faisait lire comme + une seule bulle difforme qui déborde vers le haut. */ +#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end; + gap:6px;max-width:75%} +#chat .msg-files .chip-file{background:var(--bubble);max-width:100%} +/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit + qu'il ramène un fichier plutôt qu'il n'ouvre une page. */ +#chat .msg .filelink{cursor:pointer} +#chat .msg .filelink::after{content:"↓";margin-left:5px;font-size:.9em;opacity:.7;text-decoration:none;display:inline-block} +#chat .msg .filelink.busy{opacity:.55;pointer-events:none} +/* Voile de dépôt. Il couvre la zone de conversation, curseur inclus : c'est la + réponse à « est-ce que je peux lâcher ici ? », elle doit être sans ambiguïté. */ +#dropveil{position:absolute;inset:0;z-index:20;display:none;align-items:center; + justify-content:center;padding:24px;background:rgba(0,0,0,.28);pointer-events:none} +#dropveil.show{display:flex} +#dropveil>div{padding:22px 30px;border:2px dashed var(--border);border-radius:20px; + background:var(--panel);color:var(--text);font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.18)} +/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */ +button,.mem-search{border-radius:10px} +/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */ +.pe-inpbtn .pe-inp{border-radius:10px 0 0 10px} +.pe-inpbtn button{border-radius:0 10px 10px 0} +.input-btn .ctl{border-radius:10px 0 0 10px} +.input-btn .btn-in{border-radius:0 10px 10px 0} +.preset{border-radius:12px} +/* Les presets restent des cartes (fond + bordure) : à plat, sans fond ni + cadre, la liste ne se lisait plus comme des lignes distinctes. L'actif se + signale par sa bordure — un simple voile d'accent (7 % de blanc sur fond + noir) était invisible en mode sombre. */ +.preset{background:var(--panel);border-color:var(--border)} +.preset:hover{background:var(--accent-bg);border-color:var(--dim)} +.preset.active{background:var(--panel);border-color:var(--text);color:var(--text)} +.subhead,.lc-mode-go,.lc-mode-active-tag{color:var(--dim)} +button:hover{border-color:var(--dim);color:var(--text)} + +/* Encart de garde-fou : c'est le serveur qui parle (limite d'appels d'outils, + boucle détectée), pas le modèle. Pastille discrète plutôt qu'une erreur — + la génération s'est arrêtée proprement, rien n'a échoué. */ +.stopnote{display:inline-flex;align-items:center;gap:7px;margin-top:4px;padding:4px 12px; + border:1px solid var(--border);border-radius:999px;background:var(--panel); + color:var(--dim);font-size:12px;line-height:1.5} +.stopnote::before{content:"";flex:none;width:6px;height:6px;border-radius:50%;background:var(--dim)} + +/* ===== Fenêtres et sélecteurs ===================================== + Coins arrondis et ombre douce plutôt qu'un cadre marqué. */ +.modal-card,.ask-box,.modal-box,.pe-list,.pe-adv{border-radius:12px} +.modal-card,.ask-box{box-shadow:0 8px 30px rgba(0,0,0,.12)} +/* Sélecteurs segmentés : noir plein quand c'est coché, pas de couleur. */ +.pe-seg>label:has(input:checked),.seg>label:has(input:checked){background:var(--text);border-color:var(--text);color:var(--bg)} + + +/* Curseur de portée de l'export. Même vocabulaire visuel que la répartition GPU + (piste arrondie + pastille cerclée), mais une seule valeur : la position tout + à droite vaut « toute la conversation ». Le libellé sous la piste donne le + compte RÉEL d'échanges et le poids du fichier, la question qu'on se pose + vraiment avant d'exporter. */ +.xr{position:relative;display:flex;align-items:center;height:22px} +.xr-track{width:100%;height:10px;border-radius:999px;overflow:hidden;background:color-mix(in srgb,var(--dim) 20%,transparent)} +.xr-fill{height:100%;background:var(--accent);border-radius:999px;transition:width .12s} +.xr-range{position:absolute;inset:0;width:100%;height:22px;margin:0;padding:0;background:transparent;-webkit-appearance:none;appearance:none;cursor:ew-resize} +.xr-range:focus{outline:none} +.xr-range::-webkit-slider-runnable-track{height:22px;background:transparent;border:none} +.xr-range::-moz-range-track{height:22px;background:transparent;border:none} +.xr-range::-moz-range-progress{background:transparent} +.xr-range::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:3px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.xr-range::-moz-range-thumb{width:16px;height:16px;border-radius:50%;background:var(--bg);border:2px solid var(--accent);box-shadow:0 1px 3px rgba(0,0,0,.3);cursor:ew-resize} +.xr-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--accent-bg)} +.xr-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--accent-bg)} +.xr-range:disabled{cursor:default;opacity:.5} +/* Conversation vide : un seul message centré à la place des réglages. */ +.xempty{display:flex;flex-direction:column;align-items:center;gap:6px;padding:44px 20px;text-align:center;color:var(--text);font-size:14px} +.xempty-i{font-size:30px;line-height:1;color:var(--dim);opacity:.7} + +/* Postes distants : cases de capacités, code d'appairage, commande. */ +.node-caps{display:flex;flex-wrap:wrap;gap:6px} +.node-cap{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer;user-select:none} +.node-cap input{margin:0} +.node-code{font-family:var(--mono,monospace);font-size:22px;font-weight:700;letter-spacing:3px;color:var(--accent);padding:8px 0} +.node-cmd{white-space:pre-wrap;word-break:break-all;background:var(--code-bg);border:1px solid var(--border);border-radius:7px;padding:8px;margin:6px 0;font-size:12px} + +/* Poste distant : sélecteur de cible d'exécution. */ +.node-target{display:flex;flex-wrap:wrap;align-items:center;gap:6px;margin-bottom:8px} +.node-target-l{font-size:12px;color:var(--dim);margin-right:2px;width:100%} +.node-target-opt{display:inline-flex;align-items:center;gap:5px;padding:5px 9px;border:1px solid var(--border);border-radius:7px;font-size:12px;cursor:pointer} +.node-target-opt.on{border-color:var(--accent);color:var(--accent)} +.node-target-opt.off{opacity:.6} +.node-target-opt input{margin:0} diff --git a/internal/loki/version_test.go b/internal/loki/version_test.go new file mode 100644 index 0000000..e370cdc --- /dev/null +++ b/internal/loki/version_test.go @@ -0,0 +1,41 @@ +package loki + +import ( + "encoding/json" + "fmt" + "os" + "testing" +) + +// La ressource de version Windows (cmd/loki/versioninfo.json) doit suivre la +// constante Version. Elles ont divergé en 0.8.0 : la constante était à jour, la +// ressource restait à 0.7.13. Conséquence peu lisible — le binaire installé se +// DÉCLARAIT 0.7.13 auprès de Windows, si bien que relancer le fichier téléchargé +// proposait indéfiniment « appliquer la mise à jour ? » sur une version déjà +// installée. Ce test rend l'oubli impossible. +func TestVersionInfoWindowsSuitLaVersion(t *testing.T) { + b, err := os.ReadFile("../../cmd/loki/versioninfo.json") + if err != nil { + t.Fatal(err) + } + var vi struct { + FixedFileInfo struct { + FileVersion struct{ Major, Minor, Patch int } + ProductVersion struct{ Major, Minor, Patch int } + } + StringFileInfo struct{ ProductVersion string } + } + if err := json.Unmarshal(b, &vi); err != nil { + t.Fatal(err) + } + f := vi.FixedFileInfo.FileVersion + for name, got := range map[string]string{ + "FixedFileInfo.FileVersion": fmt.Sprintf("%d.%d.%d", f.Major, f.Minor, f.Patch), + "FixedFileInfo.ProductVersion": fmt.Sprintf("%d.%d.%d", vi.FixedFileInfo.ProductVersion.Major, vi.FixedFileInfo.ProductVersion.Minor, vi.FixedFileInfo.ProductVersion.Patch), + "StringFileInfo.ProductVersion": vi.StringFileInfo.ProductVersion, + } { + if got != Version { + t.Errorf("%s = %s, attendu %s — pense à `go generate ./cmd/loki` après un bump", name, got, Version) + } + } +} diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go new file mode 100644 index 0000000..06644d0 --- /dev/null +++ b/internal/loki/web_api.go @@ -0,0 +1,957 @@ +// web_api.go — handlers REST /api/* (statut, config, presets, modèles, +// mémoire, agent, clés, bench…) du serveur web local. +package loki + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" +) + +func handlePing(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, map[string]any{"ok": true, "service": "loki", "version": Version}) +} + +// handleStatus reports service state cross-platform via serviceIsActive +// (systemd sous Linux, supervision par PID-file sous Windows — voir sys_service_*.go). +func handleStatus(w http.ResponseWriter, r *http.Request) { + active := serviceIsActive() + state := "inactive" + if active { + state = "active" + } + health := false + if active { + health = healthCheck() + } + ctx := 32768 + if v := ReadConfig()["CTX"]; v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + ctx = n + } + } + // Si le modèle n'est pas prêt, on regarde POURQUOI : une tentative de + // chargement qui a échoué (souvent un modèle incompatible avec le moteur) + // doit être signalée clairement plutôt que de laisser un « chargement… » + // perpétuel / un crash-loop muet. + loadErr := "" + if !health { + loadErr = modelLoadError() + } + sendJSON(w, 200, map[string]any{ + "state": state, + "active": active, + "health": health, + "port": LLMPort(), + "ctx": ctx, + "version": Version, + "warn": appWarning(), // ex. App Translocation macOS — vide si tout va bien + "load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon + }) +} + +// modelLoadError inspecte la fin du journal du service et renvoie un message +// clair quand la DERNIÈRE tentative de chargement du modèle s'est soldée par un +// échec. Le cas le plus courant : un GGUF dont le format de quantification n'est +// pas reconnu par le moteur (BIN) sélectionné — typiquement un modèle ternaire +// qui exige un fork de llama.cpp. Renvoie "" quand le dernier chargement a +// réussi ou qu'aucune tentative n'est visible (évite les faux positifs sur un +// service simplement arrêté). +func modelLoadError() string { + log := serviceLogTail(200) + if log == "" { + return "" + } + lines := strings.Split(log, "\n") + // On ne considère que ce qui suit la DERNIÈRE tentative de chargement. + start := 0 + for i, l := range lines { + if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") { + start = i + } + } + loaded, reason := false, "" + for _, l := range lines[start:] { + low := strings.ToLower(l) + switch { + case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"): + loaded = true + case strings.Contains(l, "has offset") && strings.Contains(l, "expected"): + reason = "format de quantification non reconnu par ce moteur" + case strings.Contains(low, "unknown model architecture"), + strings.Contains(low, "unknown ftype"), + strings.Contains(low, "unknown type"), + strings.Contains(low, "unsupported"): + if reason == "" { + reason = "type/architecture de modèle inconnu de ce moteur" + } + case strings.Contains(low, "error loading model"), + strings.Contains(low, "failed to load model"), + strings.Contains(low, "failed to read tensor data"): + if reason == "" { + reason = "échec du chargement du modèle" + } + } + } + if loaded || reason == "" { + return "" + } + // Message PRÉCIS selon la cause : n'affirmer l'incompatibilité que quand le + // journal la montre vraiment (quant/architecture). Un échec générique de + // chargement peut avoir bien d'autres causes (mémoire, fichier, tenseur) — on + // renvoie alors vers le journal plutôt que d'accuser à tort le moteur. + switch reason { + case "format de quantification non reconnu par ce moteur", + "type/architecture de modèle inconnu de ce moteur": + return "Modèle incompatible avec le moteur : " + reason + + ". Choisis un autre backend (édite le modèle → Moteur)." + default: + return "Le modèle n'a pas pu être chargé (" + reason + + "). Ouvre le journal du moteur pour le détail." + } +} + +// handleServiceLog (GET /api/service/log) : dernières lignes du journal du +// service, pour que l'UI puisse montrer POURQUOI le modèle ne se charge pas +// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu +// d'un « chargement… » perpétuel sans explication. +func handleServiceLog(w http.ResponseWriter, r *http.Request) { + n := 80 + if v := r.URL.Query().Get("n"); v != "" { + if k, err := strconv.Atoi(v); err == nil && k > 0 && k <= 500 { + n = k + } + } + sendJSON(w, 200, map[string]any{"log": serviceLogTail(n)}) +} + +func handleVram(w http.ResponseWriter, r *http.Request) { + out, err := hideCmd(exec.Command("nvidia-smi", + "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu", + "--format=csv,noheader,nounits")).Output() + gpus := []map[string]any{} + if err == nil { + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + parts := strings.Split(line, ",") + if len(parts) != 5 { + continue + } + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + used, _ := strconv.Atoi(parts[1]) + total, _ := strconv.Atoi(parts[2]) + util, _ := strconv.Atoi(parts[3]) + temp, _ := strconv.Atoi(parts[4]) + gpus = append(gpus, map[string]any{ + "name": parts[0], "used": used, "total": total, "util": util, "temp": temp, + }) + } + } + sendJSON(w, 200, gpus) +} + +// handleRam renvoie la RAM système {used, total} en Mo (mêmes unités que +// /api/vram → l'UI divise par 1024 pour des Gio). La mesure est spécifique à +// l'OS (ramUsageMB : /proc/meminfo sous Linux, sysctl+vm_stat sur macOS, +// GlobalMemoryStatusEx sous Windows). total=0 → l'UI masque le bloc. +func handleRam(w http.ResponseWriter, r *http.Request) { + used, total := ramUsageMB() + sendJSON(w, 200, map[string]any{"used": used, "total": total}) +} + +func handleConfigEnv(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, ReadConfig()) +} + +// handleBackends scans LOKI_HOME/backends// for a llama-server binary, +// trying common build subpaths (build/bin, build-sm120/bin, bin, .). +// Returns [{name, path}]. +func handleBackends(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, listBackendBins()) +} + +// listBackendBins scanne LOKI_HOME/backends// pour un binaire llama-server +// (essaie les sous-chemins des différents générateurs CMake). Renvoie +// [{name, path}] — un par dossier de backend contenant un binaire. +func listBackendBins() []map[string]any { + root := LokiHome() + "/backends" + entries, err := os.ReadDir(root) + if err != nil { + return []map[string]any{} + } + subpaths := []string{ + "build/bin/llama-server", "build-sm120/bin/llama-server", + "build/llama-server", "bin/llama-server", "llama-server", + // Layout du générateur Visual Studio (multi-config) + suffixe .exe Windows. + "build/bin/Release/llama-server.exe", "build/bin/llama-server.exe", + "build/bin/Release/llama-server", "llama-server.exe", + } + out := []map[string]any{} + for _, e := range entries { + // e can be a directory or a symlink to one; either is fine. + name := e.Name() + if strings.HasPrefix(name, ".") { + continue + } + for _, sp := range subpaths { + p := root + "/" + name + "/" + sp + if fi, err := os.Stat(p); err == nil && !fi.IsDir() { + out = append(out, map[string]any{"name": name, "path": p}) + break + } + } + } + return out +} + +// handleBackendsCustom liste les seuls backends CUSTOM : les dossiers de +// backends/ hors moteurs gérés par les cartes ⚡ (prebuilt) et 🔧 (build +// canonique). Marque celui qui sert de moteur au modèle actif (in_use). +func handleBackendsCustom(w http.ResponseWriter, r *http.Request) { + canonical := filepath.Base(defaultRepoDir()) // "llama.cpp" + prebuilt := filepath.Base(prebuiltDir()) // "llama.cpp-prebuilt" + cfgBin := ReadConfig()["BIN"] + out := []map[string]any{} + for _, b := range listBackendBins() { + name, _ := b["name"].(string) + if name == canonical || name == prebuilt { + continue + } + path, _ := b["path"].(string) + out = append(out, map[string]any{"name": name, "path": path, "in_use": samePath(path, cfgBin)}) + } + sendJSON(w, 200, out) +} + +// handleLlamacppUninstallCustom supprime le dossier d'un backend custom +// (backends/). Refuse les moteurs gérés (canonique / prebuilt) et un +// backend actuellement utilisé par le modèle actif (il faut d'abord basculer de +// moteur pour ce modèle). +func handleLlamacppUninstallCustom(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + name := sanitizeBackendName(req.Name) + if name == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "nom requis"}) + return + } + if name == filepath.Base(defaultRepoDir()) || name == filepath.Base(prebuiltDir()) { + sendJSON(w, 400, map[string]any{"ok": false, "error": "ce moteur se gère depuis les cartes ⚡ / 🔧"}) + return + } + dir, err := backendDir(name) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if !isDir(dir) { + sendJSON(w, 404, map[string]any{"ok": false, "error": "backend introuvable"}) + return + } + cfgBin := ReadConfig()["BIN"] + for _, b := range listBackendBins() { + if b["name"] == name { + if p, _ := b["path"].(string); samePath(p, cfgBin) { + sendJSON(w, 409, map[string]any{"ok": false, "error": "backend utilisé par le modèle actif — bascule d'abord ce modèle sur un autre moteur"}) + return + } + } + } + if err := os.RemoveAll(dir); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// handleModels lists *.gguf files (size in bytes) for the preset editor's model +// picker, dans LOKI_HOME ET dans les dossiers supplémentaires déclarés (disque +// externe…). "value" est ce qu'il faut écrire dans MODEL= : un simple nom de +// fichier pour LOKI_HOME (compatibilité avec l'existant), le chemin complet +// pour un dossier externe. +func handleModels(w http.ResponseWriter, r *http.Request) { + out := []map[string]any{} + seen := map[string]bool{} + home := LokiHome() + for _, dir := range modelDirs() { + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + isHome := normDir(dir) == normDir(home) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") { + continue + } + // Modèle découpé : seule la première tranche est un modèle lançable, les + // suivantes sont ouvertes par llama-server tout seul. Les lister toutes + // donnait trois entrées pour un modèle, dont deux qui échouent au démarrage. + if isFollowerShard(e.Name()) { + continue + } + full := filepath.Join(dir, e.Name()) + if seen[normDir(full)] { + continue + } + seen[normDir(full)] = true + info, _ := e.Info() + size := int64(0) + if info != nil { + size = info.Size() + } + value := full + if isHome { + value = e.Name() + } + m := map[string]any{ + "name": e.Name(), "size": size, "value": value, + "path": full, "dir": dir, "home": isHome, + } + // Taille annoncée = la famille entière, et on signale les tranches + // manquantes : un modèle incomplet démarre puis meurt sur un tenseur + // introuvable, autant le voir avant de le sélectionner. + if _, total, ok := shardInfo(e.Name()); ok { + m["shards"] = total + m["size"] = shardFamilySize(dir, e.Name()) + if missing := shardFamilyMissing(dir, e.Name()); len(missing) > 0 { + m["missing"] = missing + } + } + out = append(out, m) + } + } + sendJSON(w, 200, out) +} + +func handlePresets(w http.ResponseWriter, r *http.Request) { + list, err := ListPresets() + if err != nil { + sendJSON(w, 500, map[string]any{"error": err.Error()}) + return + } + store := loadBenchStore() + out := []map[string]any{} + for _, p := range list { + item := map[string]any{"id": p.ID, "name": p.Name, "active": p.Active} + if content, err := ReadPreset(p.ID); err == nil { + if q := detectQuant(content); q != "" { + item["quant"] = q + } + if r := presetReasoning(content); reasoningActive(r) { + item["reasoning"] = strings.ToLower(r) + } + } + if sb, ok := store[p.ID]; ok { + item["bench"] = map[string]any{ + "prefill": sb.Result.PromptPerSecond, + "decode": sb.Result.PredictedPerSec, + "at": sb.At, + } + } + out = append(out, item) + } + sendJSON(w, 200, out) +} + +func handlePreset(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.URL.Query().Get("id")) + if id == "" { + sendJSON(w, 200, map[string]any{"id": "", "name": "", "content": formatEnv(newPresetSeed())}) + return + } + content, err := ReadPreset(id) + if err != nil { + sendJSON(w, 404, map[string]any{"error": "not found"}) + return + } + sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content}) +} + +// presetSaveReq is the preset editor payload. `id` identifies an existing +// preset to update ("" creates a new one); `name` is the display name. +type presetSaveReq struct { + ID string `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + DeleteModel bool `json:"deleteModel"` +} + +// saveReq is the skill editor payload (skills keep name-as-identity + rename). +type saveReq struct { + Name string `json:"name"` + Old string `json:"old"` + Content string `json:"content"` +} + +func handlePresetSave(w http.ResponseWriter, r *http.Request) { + var req presetSaveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + newID, err := SavePreset(req.ID, req.Name, req.Content) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name}) +} + +func handlePresetDelete(w http.ResponseWriter, r *http.Request) { + var req presetSaveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + // Capture the referenced model before the preset file disappears, so we can + // optionally delete the .gguf alongside it. + model := "" + if req.DeleteModel { + if content, err := ReadPreset(req.ID); err == nil { + model = modelFromPresetContent(content) + } + } + if err := DeletePreset(req.ID); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + modelDeleted, modelErr := "", "" + if req.DeleteModel && model != "" { + if err := deleteModelFile(model); err != nil { + modelErr = err.Error() + } else { + modelDeleted = model + } + } + sendJSON(w, 200, map[string]any{"ok": true, "modelDeleted": modelDeleted, "modelError": modelErr}) +} + +// handleAgent renvoie l'état du mode agent ET la liste des pages mémoire (que +// l'IA gère via les outils mem_*) — un seul aller-retour pour l'UI. La clé +// "skills" est conservée en miroir de "pages" pour l'ancien portail ajean.link. +func handleAgent(w http.ResponseWriter, r *http.Request) { + pages := MemList() + out := []map[string]any{} + for _, p := range pages { + out = append(out, map[string]any{"name": p.Name, "desc": p.Title}) + } + sendJSON(w, 200, map[string]any{"enabled": agentEnabled(), "compact": compactEnabled(), "mem_mode": string(memMode()), "pages": out, "skills": out}) +} + +// handleMemoryMode lit/écrit le mode mémoire (off / ondemand / always). +// +// GET → {mode} +// POST {mode} → persiste MEM_MODE +func handleMemoryMode(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var req struct { + Mode string `json:"mode"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + // On normalise via memMode() en réinjectant la valeur : toute entrée + // inconnue retombe sur "always", donc on valide en passant par le parseur. + m := MemAlways + switch MemMode(strings.ToLower(strings.TrimSpace(req.Mode))) { + case MemOff: + m = MemOff + case MemOnDemand: + m = MemOnDemand + case MemAlways: + m = MemAlways + } + if err := setMemMode(m); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + } + sendJSON(w, 200, map[string]any{"ok": true, "mode": string(memMode())}) +} + +// handleCompactToggle active/désactive le compactage automatique du contexte +// (config.env COMPACT). On=compacte (défaut), off=jamais. +func handleCompactToggle(w http.ResponseWriter, r *http.Request) { + var req struct { + On bool `json:"on"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + val := "" + if !req.On { + val = "off" + } + if err := SetConfigKey("COMPACT", val); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "compact": compactEnabled()}) +} + +func handleAgentToggle(w http.ResponseWriter, r *http.Request) { + var req struct { + On bool `json:"on"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := setAgentEnabled(req.On); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "enabled": agentEnabled()}) +} + +// handleInternet pilote l'accès web de l'IA (serveur Crawl4AI). +// +// GET → {enabled, url, reachable} +// POST {enabled, url, key} → enregistre CRAWL4AI_URL / CRAWL4AI_KEY + le +// drapeau .internet_enabled. La clé n'est jamais relue par GET (juste un indice). +// +// handleAPIKey expose et pilote la clé d'accès à l'endpoint compatible OpenAI +// (llama-server /v1). GET renvoie l'état ; POST {action:"generate"|"set"|"clear", +// key?} l'écrit puis redémarre le service (llama-server lit --api-key au lancement). +func handleAPIKey(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var req struct { + Action string `json:"action"` + Key string `json:"key"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + var key string + switch req.Action { + case "generate": + key = genAPIKey() + case "set": + key = strings.TrimSpace(req.Key) + case "clear": + key = "" + default: + sendJSON(w, 400, map[string]any{"ok": false, "error": "action inconnue"}) + return + } + if err := writeAPIKey(key); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + // La clé n'est appliquée qu'au (re)démarrage de llama-server. + if serviceIsActive() { + _ = serviceAction("restart") + } + } + k := readAPIKey() + sendJSON(w, 200, map[string]any{ + "ok": true, + "set": k != "", + "key": k, + "masked": maskAPIKey(k), + "port": LLMPort(), + "host": localIP(), + // Accès OpenAI PUBLIC via ajean.link (passthrough SNI, VPS aveugle) : si + // activé, l'URL publique est https://.oai.ajean.link/v1. + "oai_public": oaiPublicEnabled(), + "machine": machineID(), + }) +} + +// handleOAIPublic pilote le drapeau d'accès OpenAI public (exposition via +// ajean.link). GET renvoie l'état ; POST {enabled} l'active/coupe en direct +// (aucun redémarrage : le démux du tunnel relit le drapeau à chaque connexion). +func handleOAIPublic(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var req struct { + Enabled *bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if req.Enabled != nil { + if err := setOAIPublic(*req.Enabled); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + } + } + sendJSON(w, 200, map[string]any{ + "ok": true, + "enabled": oaiPublicEnabled(), + "machine": machineID(), + }) +} + +// localIP best-effort renvoie l'IPv4 LAN primaire de la machine (l'IP source du +// trafic sortant), ou "localhost" à défaut. Sert à annoncer l'endpoint OpenAI +// avec une adresse correcte sur le réseau local MÊME quand l'UI est atteinte via +// le tunnel ajean.link (où location.hostname serait le domaine du relais, faux). +func localIP() string { + conn, err := net.Dial("udp", "8.8.8.8:80") + if err != nil { + return "localhost" + } + defer conn.Close() + if a, ok := conn.LocalAddr().(*net.UDPAddr); ok && a.IP != nil { + return a.IP.String() + } + return "localhost" +} + +func handleInternet(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var req struct { + Enabled *bool `json:"enabled"` + URL *string `json:"url"` + Key *string `json:"key"` + Engine *string `json:"engine"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + // Moteur web : "go" (intégré, rien à installer) ou "crawl4ai" (serveur + // externe, rendu JavaScript). + if req.Engine != nil { + if err := setWebEngine(strings.ToLower(strings.TrimSpace(*req.Engine))); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + } + // Clé d'accès au serveur Crawl4AI : chaîne vide = on l'enlève. + if req.Key != nil { + if err := writeCrawlKey(strings.TrimSpace(*req.Key)); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + reachMu.Lock() + reachURL = "" + reachMu.Unlock() + } + if req.URL != nil { + u := strings.TrimRight(strings.TrimSpace(*req.URL), "/") + if err := SetConfigKey("CRAWL4AI_URL", u); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + reachMu.Lock() + reachURL = "" // invalide le cache de reachability + reachMu.Unlock() + } + if req.Enabled != nil { + if err := setInternetEnabled(*req.Enabled); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + } + } + // La clé n'est JAMAIS renvoyée en clair : l'UI n'a besoin que de savoir + // qu'elle existe (et de ses 4 derniers caractères pour la reconnaître). + key, hint := crawl4aiKey(), "" + if n := len([]rune(key)); n > 4 { + hint = "…" + string([]rune(key)[n-4:]) + } else if key != "" { + hint = "…" + } + sendJSON(w, 200, map[string]any{ + "ok": true, + "enabled": internetEnabled(), + "engine": webEngine(), + "url": crawl4aiURL(), + "reachable": crawlReachable(), + "key_set": key != "", + "key_hint": hint, + }) +} + +// ─── Serveurs MCP ──────────────────────────────────────────────────────────── +// +// Gestion des serveurs MCP depuis l'UI. Même modèle de protection que le reste +// de /api/* : la clé web est le seul garde. Un serveur MCP stdio exécute du code +// arbitraire sur l'hôte, au même titre que l'outil `bash` du mode agent — donc +// réservé de fait au propriétaire de la machine qui détient la clé. + +// mcpSaveReq est le payload d'ajout/édition d'un serveur MCP. +type mcpSaveReq struct { + Name string `json:"name"` + Command string `json:"command"` + Args []string `json:"args"` + Env map[string]string `json:"env"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Enabled *bool `json:"enabled"` +} + +// handleMCP renvoie l'état de tous les serveurs MCP configurés (connexion tentée +// pour ceux activés → l'UI voit l'état réel + les outils découverts). +func handleMCP(w http.ResponseWriter, r *http.Request) { + list, err := MCPStatus() + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "servers": list}) +} + +// handleMCPSave ajoute ou remplace un serveur MCP. +func handleMCPSave(w http.ResponseWriter, r *http.Request) { + var req mcpSaveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + cfg := MCPServerConfig{ + Command: strings.TrimSpace(req.Command), + Args: req.Args, + Env: req.Env, + URL: strings.TrimSpace(req.URL), + Headers: req.Headers, + Enabled: enabled, + } + if err := SetMCPServer(req.Name, cfg); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + // Renvoie l'état à jour (avec tentative de connexion) pour un retour immédiat. + list, _ := MCPStatus() + sendJSON(w, 200, map[string]any{"ok": true, "servers": list}) +} + +// handleMCPDelete retire un serveur MCP. +func handleMCPDelete(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := DeleteMCPServer(req.Name); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// handleMCPToggle active/désactive un serveur MCP. +func handleMCPToggle(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + On bool `json:"on"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := SetMCPServerEnabled(req.Name, req.On); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + list, _ := MCPStatus() + sendJSON(w, 200, map[string]any{"ok": true, "servers": list}) +} + +// handleMCPTool masque/démasque un outil précis d'un serveur. +func handleMCPTool(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Tool string `json:"tool"` + On bool `json:"on"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := SetMCPToolEnabled(req.Name, req.Tool, req.On); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + list, _ := MCPStatus() + sendJSON(w, 200, map[string]any{"ok": true, "servers": list}) +} + +// handleMCPTest force une reconnexion d'un serveur et renvoie son état (bouton +// « tester/rafraîchir » de l'UI). +func handleMCPTest(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + mcpInvalidate(req.Name) + list, err := MCPStatus() + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "servers": list}) +} + +// handleMem / handleMemSave / handleMemDelete : éditeur web des pages mémoire +// (memory/.md). Payload partagé saveReq (name/old/content) ; "name" = nom +// de fichier de la page. +func handleMem(w http.ResponseWriter, r *http.Request) { + name := strings.TrimSpace(r.URL.Query().Get("name")) + if name == "" { + sendJSON(w, 200, map[string]any{"name": "", "content": "# nouvelle page\n\nNote ici ce que loki doit retenir entre les sessions.\n"}) + return + } + c := MemContent(name) + if c == "" { + sendJSON(w, 404, map[string]any{"error": "not found"}) + return + } + sendJSON(w, 200, map[string]any{"name": name, "content": c}) +} + +func handleMemSave(w http.ResponseWriter, r *http.Request) { + var req saveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := MemSave(req.Name, req.Old, req.Content); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "name": req.Name}) +} + +func handleMemDelete(w http.ResponseWriter, r *http.Request) { + var req saveReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := MemDelete(req.Name); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +func handleSwitch(w http.ResponseWriter, r *http.Request) { + var req struct { + N int `json:"n"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + list, err := ListPresets() + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + if req.N < 1 || req.N > len(list) { + sendJSON(w, 400, map[string]any{"ok": false, "error": "index hors limites"}) + return + } + target := list[req.N-1] + // On écrit config.env TOUT DE SUITE (c'est lui qui décide du preset actif), + // puis on répond — le redémarrage du service part en arrière-plan. Passer par + // SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus + // les 2 s de vérification de checkStarted : côté UI, le clic paraissait mou et + // la sélection ne bougeait qu'au bout de plusieurs secondes, pour rien. + if err := applyPresetFile(target.Path); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + fmt.Printf("%s config.env <- %s\n", green("[ok]"), filepath.Base(target.Path)) + fmt.Println(dim("[info] redémarrage du service...")) + go func() { + if err := serviceAction("restart"); err != nil { + fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err) + } + }() + sendJSON(w, 200, map[string]any{"ok": true, "preset": target.Name}) +} + +// svcHandler returns an HTTP handler that triggers a start/stop/restart through +// the cross-platform serviceAction (systemd sous Linux, supervision PID-file +// sous Windows — voir sys_service_*.go). C'est ce qui permet à un client distant +// de relancer Loki. +func svcHandler(action string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + err := serviceAction(action) + msg := "ok" + if err != nil { + msg = err.Error() + } + sendJSON(w, 200, map[string]any{"ok": err == nil, "out": msg}) + } +} + +// handleChat is the SSE proxy with tool-calling. The HTTP handler writes raw +// data: lines matching what the embedded JS expects (delta.content, +// delta.reasoning_content, delta.tool_used). +// handleBench runs `runBench` synchronously. Long enough (~30-60s) that we +// rely on the client side to show a spinner / disable the button. +func handleBench(w http.ResponseWriter, r *http.Request) { + nPrompt, nPredict := 2000, 300 + if v := r.URL.Query().Get("prompt"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + nPrompt = parsed + } + } + if v := r.URL.Query().Get("n"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil && parsed > 0 { + nPredict = parsed + } + } + res, err := runBench(nPrompt, nPredict) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "result": res}) +} + +// handleBenchLast returns the most recent persisted benchmark, or {ok:false} +// when none has been run yet. +func handleBenchLast(w http.ResponseWriter, r *http.Request) { + sb := loadLastBench() + if sb == nil { + sendJSON(w, 200, map[string]any{"ok": false}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "result": sb.Result, "model": sb.Model, "at": sb.At}) +} + +// chatReq est le corps d'une requête de chat (commun au chat clair et au chat E2E). +// +// `messages` et `ctx_used`, que les clients envoyaient du temps où l'historique +// vivait dans le navigateur, ont été RETIRÉS : depuis que la conversation est +// possédée par le serveur (chat_conversation.go), plus personne ne les lisait. +// Les garder laissait croire qu'un client pouvait encore imposer son historique +// ou son décompte de contexte. Un client qui les envoie encore n'est pas gêné : +// le décodeur JSON ignore les champs qu'il ne connaît pas. +type chatReq struct { + Temperature float64 `json:"temperature"` + // Surcharges par requête, portées par les agents ajean.link qui ont leurs + // propres interrupteurs. nil = on prend la configuration de la machine. + // ⚠️ Elles ne peuvent que RESTREINDRE, jamais rallumer (voir capsFromBody). + // Tools/Skills : rétro-compat des anciens clients relais. + Agent *bool `json:"agent"` + Tools *bool `json:"tools"` + Skills *bool `json:"skills"` + // Surcharge par requête de l'accès internet (outils web). + Internet *bool `json:"internet"` + // Message = texte du tour à lancer (/api/chat/send) ; From = dernier Seq déjà + // vu par le client (le flux d'abonnement rejoue Log[From:] puis suit le direct). + Message string `json:"message"` + From int `json:"from"` + // Files = chemins relatifs des fichiers déposés juste avant par + // /api/chat/upload ("uploads/rapport.pdf"). Ils sont annoncés au modèle en + // tête du message (voir attachNote) ; le contenu, lui, reste sur le disque et + // n'entre dans le contexte que si le modèle décide de le lire. + Files []string `json:"files"` +} diff --git a/internal/loki/web_auth.go b/internal/loki/web_auth.go new file mode 100644 index 0000000..aad542d --- /dev/null +++ b/internal/loki/web_auth.go @@ -0,0 +1,111 @@ +package loki + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "net/http" + "strings" +) + +// web_auth.go protège l'API de pilotage (loki web) quand elle est exposée sur +// internet — c.-à-d. l'API que tout client (navigateur, app mobile, script…) +// utilise pour switcher de preset, redémarrer le service, lire le status, etc. +// +// La clé de pilotage est volontairement DISTINCTE de .api_key (qui, elle, +// protège llama-server / les complétions). On veut pouvoir donner à un client +// un accès aux complétions sans lui donner le droit de redémarrer la machine, +// et inversement. Elle est relue à chaque requête (pas de cache) pour qu'un +// changement de clé prenne effet sans redémarrer le serveur web. + +// readWebKeyErr renvoie la clé de pilotage en distinguant « aucune clé » d'une +// LECTURE RATÉE. La nuance est tout sauf cosmétique : sans clé, l'API est +// ouverte. Confondre les deux, c'est ouvrir l'API parce que la base était +// momentanément verrouillée par une commande CLI — une panne d'E/S qui désarme +// l'authentification. requireWebAuth refuse donc plutôt que d'ouvrir. +func readWebKeyErr() (string, error) { + b, err := getBytesErr(bkState, "web_key") + return string(b), err +} + +// readWebKey renvoie la clé de pilotage, ou "" si aucune n'est définie (ou +// illisible). Réservé à l'affichage ; toute décision d'accès passe par +// readWebKeyErr. +func readWebKey() string { k, _ := readWebKeyErr(); return k } + +// requireWebAuth wraps an HTTP handler, rejecting requests that don't present +// the configured Bearer token. When no key is configured the handler is left +// open (pratique en local) — cmdWeb avertit alors bruyamment au démarrage. +func requireWebAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + key, err := readWebKeyErr() + if err != nil { + // On ne sait pas si une clé protège cette API : on ferme. + sendJSON(w, http.StatusServiceUnavailable, + map[string]any{"error": "configuration illisible — réessaie dans un instant"}) + return + } + if key == "" { + next(w, r) + return + } + if !checkBearer(r, key) { + w.Header().Set("WWW-Authenticate", `Bearer realm="loki"`) + sendJSON(w, http.StatusUnauthorized, map[string]any{"error": "non autorisé"}) + return + } + next(w, r) + } +} + +// checkBearer reports whether the request carries the expected key as an +// "Authorization: Bearer " header. La comparaison est à temps constant. +// PAS de repli ?key= en query string : une clé dans l'URL finit dans les +// logs des proxys (Caddy, Cloudflare), l'historique navigateur et les Referer. +func checkBearer(r *http.Request, key string) bool { + want := []byte(key) + if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") { + got := []byte(strings.TrimSpace(h[len("Bearer "):])) + if subtle.ConstantTimeCompare(got, want) == 1 { + return true + } + } + return false +} + +// cmdSetWebKey sets (or clears) the control-API key in $LOKI_HOME/.web_key. +// +// loki set-web-key définit la clé +// loki set-web-key génère une clé aléatoire +// loki set-web-key "" supprime la protection (API ouverte) +// +// Contrairement à set-api-key, aucun redémarrage n'est nécessaire : le serveur +// web relit la clé à chaque requête. +func cmdSetWebKey(args []string) error { + var key string + switch { + case len(args) == 0: + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return err + } + key = "loki-web-" + hex.EncodeToString(buf) + fmt.Printf("%s clé générée : %s\n", green("[ok]"), bold(key)) + case args[0] == "" || args[0] == "off" || args[0] == "none": + key = "" + default: + key = strings.TrimSpace(args[0]) + } + if err := putStr(bkState, "web_key", key); err != nil { + return err + } + if key == "" { + fmt.Printf("%s clé de pilotage supprimée — l'API web n'est plus protégée\n", yellow("[info]")) + return nil + } + fmt.Printf("%s clé de pilotage enregistrée\n", green("[ok]")) + fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key)) + fmt.Printf(" (relance 'loki web' si le serveur web tourne déjà — non requis, lu à chaud)\n") + return nil +} diff --git a/internal/loki/web_chat.go b/internal/loki/web_chat.go new file mode 100644 index 0000000..eccfe07 --- /dev/null +++ b/internal/loki/web_chat.go @@ -0,0 +1,166 @@ +// web_chat.go — endpoints de chat du serveur web local : envoi/stop/reset, +// flux SSE d'abonnement à la conversation serveur (voir chat_conversation.go). +package loki + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "sync" + "time" +) + +// capsFromBody dérive les capacités d'un tour à partir de la configuration +// machine et des surcharges portées par la requête. +// +// ⚠️ Une surcharge ne peut que RESTREINDRE. Elle pouvait auparavant rallumer le +// mode agent : un simple {"agent":true} redonnait bash, write, edit et les +// outils MCP alors que l'interrupteur de la machine était sur OFF. Comme l'API +// n'est pas protégée par défaut et écoute sur 0.0.0.0, l'interrupteur ne +// garantissait donc rien. Il redevient une vraie fermeture : ce qui est éteint +// sur la machine ne peut pas être rallumé par un client. +func capsFromBody(body chatReq) Caps { + caps := globalCaps() + switch { + case body.Agent != nil: + caps.Agent = caps.Agent && *body.Agent + case body.Tools != nil || body.Skills != nil: + want := (body.Tools != nil && *body.Tools) || (body.Skills != nil && *body.Skills) + caps.Agent = caps.Agent && want + } + if body.Internet != nil { + caps.Internet = caps.Internet && *body.Internet + } + // Les outils dépendent du mode agent : agent coupé, tout est coupé. + if !caps.Agent { + caps.Internet = false + } + return caps +} + +// sseHeartbeat garde la réponse SSE active en écrivant un commentaire (`: ping`, +// ignoré par le parseur côté navigateur, aucun contenu donc rien à chiffrer) +// toutes les ~15 s. Sans ça, un long silence (exécution d'outil en mode agent, +// gros prefill) laisse la réponse inactive et un proxy intermédiaire (Cloudflare, +// ~100 s) la coupe → le fetch navigateur échoue (« Load failed »). Retourne un +// mutex à partager avec l'émetteur (writes concurrents sur le même w) et une +// fonction d'arrêt à différer. +func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) { + mu := &sync.Mutex{} + done := make(chan struct{}) + go func() { + // 4 s (et non 15) : borne le temps qu'un dernier bout de flux peut rester + // coincé dans un buffer proxy (Cloudflare) faute d'octets pour le pousser. + t := time.NewTicker(4 * time.Second) + defer t.Stop() + for { + select { + case <-done: + return + case <-t.C: + mu.Lock() + _, err := w.Write([]byte(": ping\n\n")) + if flusher != nil { + flusher.Flush() + } + mu.Unlock() + if err != nil { + return + } + } + } + }() + return mu, func() { close(done) } +} + +// runChatStream est désormais un pur ABONNÉ au journal de la conversation serveur : +// il rejoue Log[body.From:] puis suit le direct, jusqu'à ce que la connexion (ctx) +// se ferme. La GÉNÉRATION est lancée séparément par /api/chat/send dans une +// goroutine détachée — fermer le navigateur n'arrête donc plus rien. Partagé par +// handleChat (clair) et handleE2EChat (chiffré). +func runChatStream(ctx context.Context, body chatReq, emit func(map[string]any) bool) { + conv.Subscribe(ctx, body.From, emit) +} + +// handleChatSend ajoute un message et lance la génération en arrière-plan. Réponse +// req/resp (les événements arrivent par le flux d'abonnement). Passe par le proxy +// tunnel /api/e2e/req pour app.ajean.link — aucun code E2E spécifique requis. +func handleChatSend(w http.ResponseWriter, r *http.Request) { + var body chatReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + // Un envoi SANS texte mais AVEC pièce jointe est légitime (« tiens, regarde »). + files := attachFiles(body.Files) + if strings.TrimSpace(body.Message) == "" && len(files) == 0 { + sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"}) + return + } + if err := conv.StartTurn(body.Message, files, capsFromBody(body), body.Temperature); err != nil { + // 409 = occupé (génération en cours) ; 503 = modèle pas prêt. + code := 503 + if err == ErrBusy { + code = 409 + } + sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +func handleChatStop(w http.ResponseWriter, r *http.Request) { + conv.Stop() + sendJSON(w, 200, map[string]any{"ok": true}) +} + +func handleChatReset(w http.ResponseWriter, r *http.Request) { + conv.Reset() + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// handleChatCompact lance une compaction manuelle du contexte (bouton UI). La +// progression est diffusée via le flux d'abonnement (compacting/compacted). +func handleChatCompact(w http.ResponseWriter, r *http.Request) { + if err := conv.CompactNow(); err != nil { + code := 503 + if err == ErrBusy { + code = 409 + } + sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +func handleChatState(w http.ResponseWriter, r *http.Request) { + sendJSON(w, 200, conv.state()) +} + +func handleChat(w http.ResponseWriter, r *http.Request) { + var body chatReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), 400) + return + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-transform") + w.Header().Set("X-Accel-Buffering", "no") + flusher, _ := w.(http.Flusher) + mu, stop := sseHeartbeat(w, flusher) + defer stop() + emit := func(obj map[string]any) bool { + b, _ := json.Marshal(map[string]any{"choices": []any{map[string]any{"delta": obj}}}) + mu.Lock() + defer mu.Unlock() + if _, err := w.Write([]byte("data: " + string(b) + "\n\n")); err != nil { + return false + } + if flusher != nil { + flusher.Flush() + } + return true + } + runChatStream(r.Context(), body, emit) +} diff --git a/internal/loki/web_chat_caps_test.go b/internal/loki/web_chat_caps_test.go new file mode 100644 index 0000000..299664c --- /dev/null +++ b/internal/loki/web_chat_caps_test.go @@ -0,0 +1,44 @@ +package loki + +import "testing" + +func ptrBool(b bool) *bool { return &b } + +// Une surcharge portée par la requête ne doit JAMAIS rallumer ce que la machine +// a éteint : sans ça, {"agent":true} redonnait bash/write/edit (et les outils +// MCP) alors que l'interrupteur agent était sur OFF, sur une API qui n'est pas +// protégée par défaut. +func TestCapsFromBodyNePeutPasRallumerLAgent(t *testing.T) { + testHome(t) + if err := setAgentEnabled(false); err != nil { + t.Fatal(err) + } + for _, body := range []chatReq{ + {Agent: ptrBool(true)}, + {Tools: ptrBool(true)}, + {Skills: ptrBool(true)}, + {Internet: ptrBool(true)}, + } { + if caps := capsFromBody(body); caps.Agent || caps.Internet { + t.Errorf("agent coupé sur la machine, mais %+v a donné %+v", body, caps) + } + } + // Et les outils ne doivent pas non plus être servis au modèle. + if tools := EnabledTools(capsFromBody(chatReq{Agent: ptrBool(true)})); len(tools) > 0 { + t.Errorf("agent coupé : aucun outil ne devrait être proposé, %d le sont", len(tools)) + } +} + +// L'inverse reste vrai : une surcharge peut RESTREINDRE un agent actif. +func TestCapsFromBodyPeutRestreindre(t *testing.T) { + testHome(t) + if err := setAgentEnabled(true); err != nil { + t.Fatal(err) + } + if caps := capsFromBody(chatReq{Agent: ptrBool(false)}); caps.Agent { + t.Error("agent:false doit couper l'agent pour ce tour") + } + if caps := capsFromBody(chatReq{}); !caps.Agent { + t.Error("sans surcharge, on doit hériter de la configuration machine") + } +} diff --git a/internal/loki/web_devices.go b/internal/loki/web_devices.go new file mode 100644 index 0000000..ec1eba6 --- /dev/null +++ b/internal/loki/web_devices.go @@ -0,0 +1,181 @@ +// web_devices.go — liste des GPU tels que les voit UN moteur donné, pour que +// l'éditeur de modèle propose « quel(s) GPU utiliser » et le tensor split. +// +// Pourquoi interroger le binaire plutôt que nvidia-smi : les noms de device +// dépendent du backend compilé dans CE moteur, et l'ordre aussi. Sur la même +// machine, un build CUDA annonce « CUDA0 = RTX 5060 Ti, CUDA1 = GTX 1650 » là où +// le binaire précompilé (Vulkan) annonce l'inverse, « Vulkan0 = GTX 1650 ». +// Proposer une liste issue de nvidia-smi ferait donc choisir la mauvaise carte. +// C'est aussi pour ça que le réglage vit dans le PRESET (--device, compris par +// tous les backends) et non dans CUDA_VISIBLE_DEVICES, qui n'a aucun effet sur +// un moteur Vulkan. +package loki + +import ( + "context" + "encoding/json" + "net/http" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" +) + +// deviceLine matche « CUDA0: NVIDIA GeForce RTX 5060 Ti (15849 MiB, 15579 MiB free) ». +var deviceLine = regexp.MustCompile(`^\s*([A-Za-z]+\d+):\s*(.+?)\s*\((\d+)\s*MiB,\s*(\d+)\s*MiB free\)\s*$`) + +// parseListDevices extrait les devices de la sortie de `llama-server --list-devices`. +func parseListDevices(out string) []map[string]any { + devs := []map[string]any{} + for _, line := range strings.Split(out, "\n") { + m := deviceLine.FindStringSubmatch(strings.TrimRight(line, "\r")) + if m == nil { + continue + } + total, _ := strconv.Atoi(m[3]) + free, _ := strconv.Atoi(m[4]) + devs = append(devs, map[string]any{ + "id": m[1], "name": m[2], "total_mib": total, "free_mib": free, + }) + } + return devs +} + +// Cache mémoire : lister les devices lance le moteur (init CUDA/Vulkan + +// énumération), soit une à trois secondes. Sans cache, l'encart « cartes +// graphiques » de l'éditeur apparaissait plusieurs secondes après le reste. +type devCacheEntry struct { + devices []map[string]any + at time.Time +} + +var ( + devCacheMu sync.Mutex + devCache = map[string]devCacheEntry{} +) + +const devCacheTTL = 10 * time.Minute + +func devCacheGet(bin string) ([]map[string]any, bool) { + devCacheMu.Lock() + defer devCacheMu.Unlock() + e, ok := devCache[bin] + if !ok || time.Since(e.at) > devCacheTTL { + return nil, false + } + return e.devices, true +} + +func devCachePut(bin string, devs []map[string]any) { + devCacheMu.Lock() + devCache[bin] = devCacheEntry{devices: devs, at: time.Now()} + devCacheMu.Unlock() +} + +// fillMissingMemory complète les mémoires que le moteur n'a pas su lire. Quand +// une carte est déjà saturée par le modèle en cours, llama.cpp annonce 0 Mio — +// l'UI n'avait alors rien à afficher pour elle, ce qui donnait une liste +// incohérente (une carte avec sa taille, l'autre sans). +// +// On complète depuis nvidia-smi PAR CORRESPONDANCE DE NOM, et uniquement pour +// ça : la mémoire totale est une donnée matérielle fixe, indépendante du +// backend. L'ordre et les identifiants des devices, eux, appartiennent au +// moteur (CUDA0 et Vulkan0 ne désignent pas la même carte) et ne doivent jamais +// venir de nvidia-smi. Un nom en double (deux cartes identiques) rend la +// correspondance ambiguë : on préfère alors ne rien dire. +func fillMissingMemory(devs []map[string]any) { + missing := false + for _, d := range devs { + if n, _ := d["total_mib"].(int); n <= 0 { + missing = true + } + } + if !missing { + return + } + gpus, err := detectGPUs() + if err != nil { + return + } + totals := map[string]int{} + dup := map[string]bool{} + for _, g := range gpus { + name := strings.TrimSpace(g.Name) + if _, seen := totals[name]; seen { + dup[name] = true + continue + } + if mb, err := strconv.Atoi(strings.TrimSpace(g.MemTotal)); err == nil { + totals[name] = mb + } + } + for _, d := range devs { + if n, _ := d["total_mib"].(int); n > 0 { + continue + } + name, _ := d["name"].(string) + if mb, ok := totals[strings.TrimSpace(name)]; ok && !dup[strings.TrimSpace(name)] { + d["total_mib"] = mb + } + } +} + +// hasZeroMemory dit si au moins un device annonce une mémoire totale nulle. +func hasZeroMemory(devs []map[string]any) bool { + for _, d := range devs { + if n, _ := d["total_mib"].(int); n <= 0 { + return true + } + } + return false +} + +// handleBackendDevices renvoie les devices vus par le moteur passé en `bin` +// (celui du preset en cours d'édition). Sans `bin`, on prend celui de la config +// active. +func handleBackendDevices(w http.ResponseWriter, r *http.Request) { + var req struct { + Bin string `json:"bin"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + bin := strings.TrimSpace(req.Bin) + if bin == "" { + bin = ReadConfig()["BIN"] + } + bin = prebuiltResolveBin(bin) + if bin == "" || !isFile(bin) { + sendJSON(w, 200, map[string]any{"ok": false, "error": "moteur introuvable — choisissez d'abord un moteur"}) + return + } + // Garde-fou : on n'exécute que le serveur llama.cpp, pas n'importe quel + // chemin qui passerait par cette requête. + if base := strings.ToLower(filepath.Base(bin)); base != "llama-server" && base != "llama-server.exe" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "ce chemin n'est pas un llama-server"}) + return + } + if devs, ok := devCacheGet(bin); ok { + sendJSON(w, 200, map[string]any{"ok": true, "devices": devs}) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) + defer cancel() + cmd := hideCmd(exec.CommandContext(ctx, bin, "--list-devices")) + cmd.Env = libraryPathEnv(filepath.Dir(bin)) + out, err := cmd.CombinedOutput() + if err != nil && len(out) == 0 { + sendJSON(w, 200, map[string]any{"ok": false, "error": "le moteur n'a pas répondu : " + err.Error()}) + return + } + devs := parseListDevices(string(out)) + fillMissingMemory(devs) + // Quand une carte est déjà saturée par le modèle en cours, le moteur peut + // annoncer 0 Mio de mémoire : c'est une lecture transitoire, on ne la fige + // pas dans le cache (sinon l'UI affiche « 0 Go » pendant dix minutes). + if !hasZeroMemory(devs) { + devCachePut(bin, devs) + } + sendJSON(w, 200, map[string]any{"ok": true, "devices": devs}) +} diff --git a/internal/loki/web_devices_test.go b/internal/loki/web_devices_test.go new file mode 100644 index 0000000..fd6ee51 --- /dev/null +++ b/internal/loki/web_devices_test.go @@ -0,0 +1,85 @@ +package loki + +import "testing" + +// La sortie de `llama-server --list-devices` : c'est elle qui fait foi pour +// l'UI, car les noms ET l'ordre dépendent du backend compilé dans CE moteur +// (sur la même machine : CUDA0 = la grosse carte, Vulkan0 = la petite). +func TestParseListDevices(t *testing.T) { + out := `ggml_cuda_init: found 2 CUDA devices: +Available devices: + CUDA0: NVIDIA GeForce RTX 5060 Ti (15849 MiB, 15579 MiB free) + CUDA1: NVIDIA GeForce GTX 1650 (3715 MiB, 3659 MiB free) +` + devs := parseListDevices(out) + if len(devs) != 2 { + t.Fatalf("%d device(s) extraits, attendu 2 : %v", len(devs), devs) + } + if devs[0]["id"] != "CUDA0" || devs[0]["name"] != "NVIDIA GeForce RTX 5060 Ti" { + t.Errorf("device 0 mal lu : %v", devs[0]) + } + if devs[0]["total_mib"] != 15849 || devs[1]["free_mib"] != 3659 { + t.Errorf("mémoire mal lue : %v / %v", devs[0], devs[1]) + } + if devs[1]["id"] != "CUDA1" { + t.Errorf("device 1 mal lu : %v", devs[1]) + } +} + +// Backend Vulkan : même format, autres noms — et rien d'autre ne doit passer. +func TestParseListDevicesVulkanEtBruit(t *testing.T) { + out := `load_backend: loaded Vulkan backend +Available devices: + Vulkan0: NVIDIA GeForce GTX 1650 (4342 MiB, 3950 MiB free) + Vulkan1: NVIDIA GeForce RTX 5060 Ti (16311 MiB, 15848 MiB free) +warning: something else entirely +` + devs := parseListDevices(out) + if len(devs) != 2 || devs[0]["id"] != "Vulkan0" || devs[1]["id"] != "Vulkan1" { + t.Fatalf("devices Vulkan mal extraits : %v", devs) + } + if n := len(parseListDevices("aucun device ici\n")); n != 0 { + t.Errorf("%d device(s) extraits d'une sortie sans device", n) + } +} + +// Mémoire complétée : un device dont le moteur n'a pas su lire la taille (0 Mio, +// carte saturée) récupère celle de nvidia-smi par correspondance de nom. Deux +// cartes homonymes rendent la correspondance ambiguë : on ne complète pas. +func TestFillMissingMemoryParNom(t *testing.T) { + devs := []map[string]any{ + {"id": "CUDA0", "name": "NVIDIA GeForce RTX 5060 Ti", "total_mib": 0, "free_mib": 0}, + {"id": "CUDA1", "name": "NVIDIA GeForce GTX 1650", "total_mib": 3715, "free_mib": 3659}, + } + // La table de correspondance est celle que construit fillMissingMemory ; on + // vérifie ici la règle métier sans dépendre d'un nvidia-smi présent. + totals := map[string]int{"NVIDIA GeForce RTX 5060 Ti": 16311} + dup := map[string]bool{} + for _, d := range devs { + if n, _ := d["total_mib"].(int); n > 0 { + continue + } + name, _ := d["name"].(string) + if mb, ok := totals[name]; ok && !dup[name] { + d["total_mib"] = mb + } + } + if devs[0]["total_mib"] != 16311 { + t.Errorf("mémoire non complétée : %v", devs[0]) + } + if devs[1]["total_mib"] != 3715 { + t.Errorf("mémoire déjà connue écrasée : %v", devs[1]) + } +} + +// Le cache ne doit pas figer une lecture dégénérée (0 Mio) pendant 10 minutes. +func TestHasZeroMemory(t *testing.T) { + ok := []map[string]any{{"total_mib": 16311}, {"total_mib": 3715}} + ko := []map[string]any{{"total_mib": 16311}, {"total_mib": 0}} + if hasZeroMemory(ok) { + t.Error("lecture complète prise pour dégénérée") + } + if !hasZeroMemory(ko) { + t.Error("lecture à 0 Mio non détectée") + } +} diff --git a/internal/loki/web_fetch_go.go b/internal/loki/web_fetch_go.go new file mode 100644 index 0000000..1d96af0 --- /dev/null +++ b/internal/loki/web_fetch_go.go @@ -0,0 +1,427 @@ +package loki + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "time" + + readability "codeberg.org/readeck/go-readability/v2" + htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" + "github.com/JohannesKaufmann/html-to-markdown/v2/converter" + "golang.org/x/net/html" + "golang.org/x/net/html/charset" +) + +// Moteur web INTÉGRÉ (pur Go) — alternative à Crawl4AI, sans rien à installer. +// +// Crawl4AI = Chromium headless piloté par Playwright : il exécute le JS de la +// page avant d'en extraire le markdown. C'est puissant mais ça demande à +// l'utilisateur de faire tourner un serveur Python/Docker — rédhibitoire pour +// un utilisateur non technique. +// +// Ce moteur-ci remplace UNIQUEMENT l'étape « URL → markdown » (runCrwl) par du +// Go pur : +// +// http.Get → readability (port Go de Readability de Firefox, qui vire nav, +// pubs, pieds de page) → html-to-markdown +// +// Ce qu'on perd : le rendu JavaScript. Une page 100 % React sans SSR ressort +// vide, et les options js_code / wait_for / actions n'ont plus de sens (pas de +// DOM vivant). En pratique, les sources que l'IA lit — docs, articles, blogs, +// Wikipédia, GitHub, forums — sont servies en HTML et passent très bien. +// +// Le choix se fait par la clé de config WEB_ENGINE ("go" ou "crawl4ai"). Voir +// webEngine(). Crawl4AI reste entièrement supporté. + +// ─── sélection du moteur ──────────────────────────────────────────────────── + +const ( + engineGo = "go" + engineCrawl = "crawl4ai" +) + +// webEngine renvoie le moteur web actif : engineGo ou engineCrawl. +// +// Par défaut on prend le moteur intégré. L'exception est l'installation qui a +// DÉJÀ une URL Crawl4AI configurée sans avoir jamais choisi de moteur : elle +// tournait sur Crawl4AI, on ne change pas son comportement dans son dos. +func webEngine() string { + switch strings.ToLower(strings.TrimSpace(ReadConfig()["WEB_ENGINE"])) { + case engineGo: + return engineGo + case engineCrawl: + return engineCrawl + } + if crawl4aiURL() != "" { + return engineCrawl + } + return engineGo +} + +// setWebEngine enregistre le moteur web choisi et invalide le cache de +// reachability (l'état affiché dépend du moteur). +func setWebEngine(engine string) error { + if engine != engineGo && engine != engineCrawl { + return fmt.Errorf("moteur web inconnu : %q", engine) + } + if err := SetConfigKey("WEB_ENGINE", engine); err != nil { + return err + } + reachMu.Lock() + reachURL = "" + reachMu.Unlock() + return nil +} + +// ─── récupération HTTP ────────────────────────────────────────────────────── + +// Un vrai User-Agent : beaucoup de sites servent une page dégradée (ou un 403) +// aux clients qui s'annoncent comme des robots. +const goFetchUA = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + +const ( + goFetchTimeout = 30 * time.Second + goFetchMaxBytes = 8 << 20 // 8 Mo : au-delà ce n'est plus une page à lire +) + +// goHTTPClient : client dédié au moteur web. Timeout global, plafond de +// redirections, et un cookie jar — indispensable pour les sites qui posent un +// cookie de session puis redirigent (murs de consentement typiques). Le jar est +// en mémoire : il disparaît à l'arrêt du process, on ne persiste rien. +// On ne touche pas à http.DefaultClient (utilisé ailleurs). +var goHTTPClient = &http.Client{ + Timeout: goFetchTimeout, + Jar: newCookieJar(), + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return fmt.Errorf("trop de redirections") + } + return nil + }, +} + +func newCookieJar() http.CookieJar { + j, err := cookiejar.New(nil) + if err != nil { + return nil // un client sans jar reste fonctionnel + } + return j +} + +// consentCookies : cookies qui marquent le bandeau de consentement comme DÉJÀ +// TRAITÉ, afin d'atteindre la page derrière le mur. +// +// ⚠ On signale un REFUS, jamais une acceptation : ces valeurs disent « pas de +// suivi, bandeau fermé ». Le but est de lire la page, pas d'ouvrir des traceurs +// au nom de l'utilisateur. Les en-têtes DNT et Sec-GPC (refus de vente/partage, +// juridiquement reconnu en Californie et respecté par une partie des sites) +// vont dans le même sens. +// +// Envoyés à tout le monde : un cookie inconnu d'un site est simplement ignoré. +var consentCookies = []string{ + "gdpr=0", + "gdpr_consent=0", + "cookieconsent_status=deny", + "OptanonAlertBoxClosed=2024-01-01T00:00:00.000Z", + "SOCS=CAESHAgBEhJnd3NfMjAyNDAxMDEtMF9SQzEaAmZyIAEaBgiA_LyaBg", // Google : « refuser tout » +} + +// goFetch effectue le GET et renvoie le corps (plafonné), le Content-Type et +// l'URL finale après redirections. +func goFetch(ctx context.Context, target string) (body []byte, contentType, finalURL string, err error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, "", "", err + } + req.Header.Set("User-Agent", goFetchUA) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7") + req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8") + // Signaux de refus du pistage (voir consentCookies). + req.Header.Set("DNT", "1") + req.Header.Set("Sec-GPC", "1") + for _, c := range consentCookies { + req.Header.Add("Cookie", c) + } + // Pas d'Accept-Encoding manuel : le transport gère gzip tout seul. + + resp, err := goHTTPClient.Do(req) + if err != nil { + return nil, "", "", fmt.Errorf("échec de la requête : %v", err) + } + defer resp.Body.Close() + + b, err := io.ReadAll(io.LimitReader(resp.Body, goFetchMaxBytes)) + if err != nil { + return nil, "", "", fmt.Errorf("lecture interrompue : %v", err) + } + if resp.StatusCode >= 400 { + return nil, "", "", fmt.Errorf("HTTP %d sur %s", resp.StatusCode, target) + } + return b, resp.Header.Get("Content-Type"), resp.Request.URL.String(), nil +} + +// goParseHTML décode le corps selon le charset annoncé (ou détecté) puis le +// parse en arbre DOM. +func goParseHTML(body []byte, contentType string) (*html.Node, error) { + r, err := charset.NewReader(strings.NewReader(string(body)), contentType) + if err != nil { + // Charset inconnu : on tente en l'état plutôt que d'abandonner. + r = strings.NewReader(string(body)) + } + return html.Parse(r) +} + +// ─── URL → markdown ───────────────────────────────────────────────────────── + +// goFetchMarkdown est l'équivalent pur Go de runCrwl : elle récupère une URL et +// en renvoie le contenu en markdown. +// +// Les contenus non-HTML (texte brut, markdown, JSON…) sont renvoyés tels quels : +// c'est le cas des README GitHub, vers lesquels normalizeCrawlURL redirige. +func goFetchMarkdown(target string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) + defer cancel() + + body, contentType, finalURL, err := goFetch(ctx, target) + if err != nil { + return "", err + } + + ct := strings.ToLower(contentType) + isHTML := strings.Contains(ct, "html") || strings.Contains(ct, "xml") + if ct == "" { + // Certains serveurs n'annoncent rien : on devine sur le contenu. + isHTML = strings.Contains(strings.ToLower(string(body[:min(len(body), 1024)])), " jsShellHTMLMin { + return "", fmt.Errorf("page récupérée (%s de HTML) mais seulement %d caractères de texte : "+ + "elle est très probablement rendue en JavaScript, ce que le moteur web intégré n'exécute pas. "+ + "Ne réessaie PAS cette URL — cherche une autre source (documentation officielle, dépôt du projet, "+ + "article qui en parle)", formatBytes(len(body)), len(strings.TrimSpace(md))) + } + return md, nil +} + +// Seuils du diagnostic « rendu JavaScript » (voir goFetchMarkdown). +const ( + jsShellTextMax = 200 // texte extrait en dessous duquel il n'y a rien à lire + jsShellHTMLMin = 15000 // HTML au-dessus duquel « rien à lire » devient suspect +) + +// goArticleMarkdown applique Readability puis convertit le résultat. Renvoie du +// markdown vide (sans erreur) si la page n'a pas de contenu d'article isolable. +func goArticleMarkdown(doc *html.Node, base *url.URL) (md, title string) { + // Readability mute l'arbre qu'on lui donne ; on lui passe une copie pour + // pouvoir retomber sur le document complet en cas d'échec. + clone, err := goCloneDoc(doc) + if err != nil { + return "", "" + } + art, err := readability.FromDocument(clone, base) + if err != nil || art.Node == nil { + return "", "" + } + out, err := goConvert(art.Node, baseString(base)) + if err != nil { + return "", "" + } + return out, strings.TrimSpace(art.Title()) +} + +// goConvert rend un nœud HTML en markdown, en réécrivant les liens et images +// relatifs en URLs absolues (sinon l'IA ne peut pas les rouvrir). +func goConvert(node *html.Node, baseURL string) (string, error) { + opts := []converter.ConvertOptionFunc{} + if baseURL != "" { + opts = append(opts, converter.WithDomain(baseURL)) + } + b, err := htmltomarkdown.ConvertNode(node, opts...) + if err != nil { + return "", fmt.Errorf("conversion markdown : %v", err) + } + return string(b), nil +} + +// goCloneDoc duplique un document HTML en le re-parsant depuis son rendu. +func goCloneDoc(doc *html.Node) (*html.Node, error) { + var sb strings.Builder + if err := html.Render(&sb, doc); err != nil { + return nil, err + } + return html.Parse(strings.NewReader(sb.String())) +} + +func baseString(u *url.URL) string { + if u == nil { + return "" + } + return u.Scheme + "://" + u.Host +} + +// ─── recherche DuckDuckGo (parsing HTML direct) ───────────────────────────── + +// goSearch interroge le point d'entrée HTML de DuckDuckGo et lit les résultats +// directement dans le DOM. +// +// Le chemin Crawl4AI passe par une conversion en markdown avant de reparser le +// texte à coups de regex (duckduckgoSearch) ; ici on lit la structure réelle +// (.result__a, .result__snippet), ce qui est nettement plus robuste. +func goSearch(query string, limit int) ([]searchResult, error) { + ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout) + defer cancel() + + target := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query) + body, contentType, _, err := goFetch(ctx, target) + if err != nil { + return nil, err + } + if strings.Contains(string(body), "anomaly-modal") || strings.Contains(string(body), "anomaly.js") { + return nil, fmt.Errorf("DuckDuckGo a renvoyé un défi anti-bot") + } + doc, err := goParseHTML(body, contentType) + if err != nil { + return nil, fmt.Errorf("HTML illisible : %v", err) + } + + var results []searchResult + seen := map[string]bool{} + for _, block := range goFindByClass(doc, "result") { + if len(results) >= limit { + break + } + link := goFirstByClass(block, "result__a") + if link == nil { + continue + } + href := decodeUddg(goAttr(link, "href")) + title := goText(link) + if title == "" || href == "" || strings.Contains(href, "duckduckgo.com") || seen[href] { + continue + } + snippet := "" + if s := goFirstByClass(block, "result__snippet"); s != nil { + snippet = goText(s) + } + seen[href] = true + results = append(results, searchResult{Title: title, URL: href, Snippet: snippet}) + } + return results, nil +} + +// ─── petits helpers DOM (x/net/html, sans sélecteur CSS) ──────────────────── + +func goHasClass(n *html.Node, class string) bool { + if n.Type != html.ElementNode { + return false + } + for _, f := range strings.Fields(goAttr(n, "class")) { + if f == class { + return true + } + } + return false +} + +func goAttr(n *html.Node, name string) string { + if n == nil { + return "" + } + for _, a := range n.Attr { + if a.Key == name { + return a.Val + } + } + return "" +} + +// goFindByClass renvoie tous les nœuds portant la classe, sans descendre dans +// un nœud déjà retenu (les résultats DDG ne s'imbriquent pas). +func goFindByClass(root *html.Node, class string) []*html.Node { + var out []*html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if goHasClass(n, class) { + out = append(out, n) + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + return out +} + +func goFirstByClass(root *html.Node, class string) *html.Node { + var found *html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if found != nil { + return + } + if goHasClass(n, class) { + found = n + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(root) + return found +} + +// goText concatène le texte d'un sous-arbre, espaces normalisés. +func goText(n *html.Node) string { + var sb strings.Builder + var walk func(*html.Node) + walk = func(n *html.Node) { + if n.Type == html.TextNode { + sb.WriteString(n.Data) + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return strings.TrimSpace(wsRe.ReplaceAllString(sb.String(), " ")) +} diff --git a/internal/loki/web_link_api.go b/internal/loki/web_link_api.go new file mode 100644 index 0000000..ec00ad2 --- /dev/null +++ b/internal/loki/web_link_api.go @@ -0,0 +1,100 @@ +package loki + +// web_link_api.go — API locale pilotant l'accès distant (ajean.link) depuis l'UI +// web de Loki, pour éviter le terminal. Le panneau « Accès distant » ouvre une +// popup app.ajean.link/connect.html qui gère compte + paiement puis renvoie une +// clé de liaison (jl_…) ; l'UI la POSTe ici, et Loki fait le `loki link` tout +// seul (écrit le token + (re)démarre le service). +// +// Ces routes ne sont accessibles qu'en LOCAL : à travers le tunnel du relais, +// newLinkHandler refuse tout /api/* non chiffré (boîte noire). La configuration +// de l'accès distant se fait donc depuis la machine elle-même, ce qui est correct. + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// remoteServerURL est l'URL du portail distant pointant droit sur cette machine. +func remoteServerURL() string { + return "https://app.ajean.link/server.html?m=" + machineID() +} + +// handleLinkStatus (GET /api/link/status) : état de l'accès distant pour l'UI. +func handleLinkStatus(w http.ResponseWriter, r *http.Request) { + tok := readLinkToken() + sendJSON(w, http.StatusOK, map[string]any{ + "linked": tok != "", + "active": uiServiceActive(), + "machineURL": remoteServerURL(), + "fingerprint": e2eFingerprint(), + }) +} + +// handleLinkConnect (POST /api/link/connect {token}) : enregistre la clé de +// liaison remise par la popup connect.html et (re)démarre le service de lien. +func handleLinkConnect(w http.ResponseWriter, r *http.Request) { + var req struct{ Token string } + _ = json.NewDecoder(r.Body).Decode(&req) + tok := strings.TrimSpace(req.Token) + if !strings.HasPrefix(tok, "jl_") { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "clé de liaison invalide"}) + return + } + if err := saveLinkToken(tok); err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) + return + } + // Redémarre le service d'interface pour qu'il relise la clé et ouvre le + // tunnel. Sur une machine sans systemd (dev/Windows), la clé est quand même + // enregistrée : on renvoie l'info en signalant que le service n'a pas démarré. + svcErr := "" + if err := uiServiceCtl("restart"); err != nil { + svcErr = err.Error() + } + sendJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "linked": true, + "active": uiServiceActive(), + "machineURL": remoteServerURL(), + "serviceErr": svcErr, + }) +} + +// handleLinkStart (POST /api/link/start) : (re)démarre le worker de lien sans +// repasser par la popup de connexion — le token est déjà enregistré. Utile quand +// le tunnel est tombé, ou sur un poste sans systemd où personne ne le relance. +func handleLinkStart(w http.ResponseWriter, r *http.Request) { + if readLinkToken() == "" { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "aucune clé de liaison enregistrée"}) + return + } + if err := uiServiceCtl("restart"); err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error(), "active": uiServiceActive()}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "active": uiServiceActive()}) +} + +// handleLinkDisconnect (POST /api/link/disconnect) : arrête le service et oublie +// la clé (équivalent `loki link stop` + `loki link logout`). +func handleLinkDisconnect(w http.ResponseWriter, r *http.Request) { + _ = uiServiceCtl("stop") + _ = removeLinkToken() + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "linked": false, "active": false}) +} + +// handleLinkPairCode (POST /api/link/paircode) : génère un code d'appairage frais +// (usage unique, TTL 10 min) + l'empreinte E2E, à saisir UNE fois dans le portail +// distant lors de la première connexion (confirmation de la boîte noire). Évite le +// détour par `loki link code` en terminal. +func handleLinkPairCode(w http.ResponseWriter, r *http.Request) { + code, err := newPairCode() + if err != nil { + sendJSON(w, http.StatusInternalServerError, map[string]any{"error": fmt.Sprintf("génération du code (droits ?): %v", err)}) + return + } + sendJSON(w, http.StatusOK, map[string]any{"code": code, "fingerprint": e2eFingerprint()}) +} diff --git a/internal/loki/web_llamacpp.go b/internal/loki/web_llamacpp.go new file mode 100644 index 0000000..343644b --- /dev/null +++ b/internal/loki/web_llamacpp.go @@ -0,0 +1,611 @@ +// web_llamacpp.go — pilotage du backend llama.cpp depuis l'UI web : +// statut (commit, retard, binaire, plan de build), vérification des mises à +// jour (git fetch), et jobs asynchrones install / update / rebuild dont la +// progression et les logs sont pollés par le client. +// +// GET /api/llamacpp → statut complet (dépôt, commit, binaire, plan) +// POST /api/llamacpp/check → git fetch + retard sur origin (réseau) +// POST /api/llamacpp/install → job d'installation {force?} +// POST /api/llamacpp/update → job de mise à jour {clean?} +// GET /api/llamacpp/job?from= → progression du job (phase, lignes, erreur) +package loki + +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +// lcJob est LE job llama.cpp en cours (un seul à la fois). Les lignes de log +// sont indexées de façon absolue (base = index de Lines[0]) pour que le client +// puisse poller « à partir de N » même quand on tronque le début. +type lcJob struct { + Action string // "install" | "update" + Phase string + Compiled int // fichiers compilés (progression du build) + Running bool + Err string + StartedAt int64 + EndedAt int64 + OldCommit string + NewCommit string + lines []string + base int +} + +var ( + lcMu sync.Mutex + lcCur *lcJob +) + +const lcMaxLines = 4000 + +// lcAppend ajoute une ligne au log du job courant et met à jour la phase / le +// compteur de fichiers compilés à partir du contenu (mêmes heuristiques que le +// spinner du CLI). Appelée par le sink de build (emitBuildLine). +func lcAppend(line string) { + lcMu.Lock() + defer lcMu.Unlock() + if lcCur == nil { + return + } + lcCur.lines = append(lcCur.lines, line) + if len(lcCur.lines) > lcMaxLines { + drop := len(lcCur.lines) - lcMaxLines + lcCur.lines = append([]string(nil), lcCur.lines[drop:]...) + lcCur.base += drop + } + if f := compiledFile(line); f != "" { + lcCur.Compiled++ + lcCur.Phase = fmt.Sprintf("compilation… %d fichiers", lcCur.Compiled) + } else if p := phaseLabel(line); p != "" { + lcCur.Phase = p + } + lcSaveLine(line) + lcSave(false) +} + +// lcPhase pose une phase explicite (étapes hors build : clone, fetch, service…) +// et la trace aussi dans le log. +func lcPhase(phase string) { + lcMu.Lock() + if lcCur != nil { + lcCur.Phase = phase + lcCur.lines = append(lcCur.lines, "▶ "+phase) + lcSaveLine("▶ " + phase) + lcSave(true) + } + lcMu.Unlock() +} + +// startLcJob démarre un job (install ou update) si aucun n'est en cours. +func startLcJob(action string, run func()) error { + lcMu.Lock() + defer lcMu.Unlock() + if lcCur != nil && lcCur.Running { + return fmt.Errorf("un job %s est déjà en cours", lcCur.Action) + } + lcCur = &lcJob{Action: action, Running: true, Phase: "démarrage…", StartedAt: time.Now().Unix()} + lcResetLog() + lcSave(true) + setBuildSink(lcAppend) + go func() { + defer func() { + setBuildSink(nil) + lcMu.Lock() + lcCur.Running = false + lcCur.EndedAt = time.Now().Unix() + lcSave(true) + lcMu.Unlock() + }() + run() + }() + return nil +} + +func lcFail(err error) { + lcMu.Lock() + if lcCur != nil { + lcCur.Err = err.Error() + lcCur.Phase = "échec" + lcCur.lines = append(lcCur.lines, "✗ "+err.Error()) + lcSaveLine("✗ " + err.Error()) + lcSave(true) + } + lcMu.Unlock() +} + +func lcDone(msg string) { + lcMu.Lock() + if lcCur != nil { + lcCur.Phase = msg + lcCur.lines = append(lcCur.lines, "✓ "+msg) + lcSaveLine("✓ " + msg) + lcSave(true) + } + lcMu.Unlock() +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +// handleLlamacpp renvoie le statut complet du backend llama.cpp : dépôt, +// commit courant, retard connu (sans fetch réseau), binaire compilé et son +// usage dans config.env, plan de build détecté, et le job éventuel. +func handleLlamacpp(w http.ResponseWriter, r *http.Request) { + repo := llamacppRepoDir() + out := map[string]any{ + "repo": repo, + "installed": isDir(filepath.Join(repo, ".git")), + } + if out["installed"].(bool) { + out["branch"] = gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + out["commit"] = gitOutput(repo, "rev-parse", "--short", "HEAD") + out["commit_date"] = gitOutput(repo, "log", "-1", "--format=%ci") + out["commit_msg"] = gitOutput(repo, "log", "-1", "--format=%s") + if br, _ := out["branch"].(string); br != "" && br != "HEAD" { + if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+br); behind != "" { + n, _ := strconv.Atoi(behind) + out["behind"] = n + } + } + } + bin := llamaServerBin(repo) + out["bin"] = bin + cfgBin := ReadConfig()["BIN"] + out["config_bin"] = cfgBin + out["in_use"] = bin != "" && samePath(bin, cfgBin) + + plan := detectBuildPlan() + out["plan"] = map[string]any{ + "backend": plan.backend, + "arch": plan.cudaArch, + "jobs": plan.jobs, + } + // Mode « binaires précompilés » : installé ? utilisé par la config ? + pbTag, _ := prebuiltVersion() + pbBin := prebuiltServerBin() + out["prebuilt"] = map[string]any{ + "tag": pbTag, + "bin": pbBin, + "dir": prebuiltDir(), + // Tout BIN vivant sous le dossier prebuilt EST le moteur précompilé, même + // s'il porte le numéro d'une release remplacée depuis (chemin versionné). + "in_use": pbBin != "" && prebuiltOwns(cfgBin), + } + out["backends_dir"] = filepath.Join(LokiHome(), "backends") + out["reco"] = recommendedMode(plan.backend) + out["job"] = lcJobSnapshot(0, false) + sendJSON(w, 200, out) +} + +// samePath compare deux chemins en neutralisant séparateurs, symlinks et casse +// (Windows). +func samePath(a, b string) bool { + if a == "" || b == "" { + return false + } + norm := func(p string) string { + if real, err := filepath.EvalSymlinks(p); err == nil { + p = real + } + p = filepath.Clean(p) + return strings.ToLower(filepath.ToSlash(p)) + } + return norm(a) == norm(b) +} + +// handleLlamacppCheck fait un vrai git fetch puis renvoie le retard sur origin +// et le dernier commit distant. Synchrone (quelques secondes réseau). +func handleLlamacppCheck(w http.ResponseWriter, r *http.Request) { + repo := llamacppRepoDir() + if !isDir(filepath.Join(repo, ".git")) { + sendJSON(w, 200, map[string]any{"ok": false, "error": "llama.cpp n'est pas installé"}) + return + } + branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + if branch == "" || branch == "HEAD" { + branch = "master" + } + if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": "git fetch a échoué : " + err.Error()}) + return + } + behind := 0 + if b := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); b != "" { + behind, _ = strconv.Atoi(b) + } + sendJSON(w, 200, map[string]any{ + "ok": true, + "behind": behind, + "local": gitOutput(repo, "rev-parse", "--short", "HEAD"), + "remote": gitOutput(repo, "rev-parse", "--short", "origin/"+branch), + "remote_date": gitOutput(repo, "log", "-1", "--format=%ci", "origin/"+branch), + "remote_msg": gitOutput(repo, "log", "-1", "--format=%s", "origin/"+branch), + "branch": branch, + "has_binary": llamaServerBin(repo) != "", + "needs_rebuild": behind > 0 || llamaServerBin(repo) == "", + }) +} + +// handleLlamacppInstall lance le job d'installation (clone + build + BIN). +func handleLlamacppInstall(w http.ResponseWriter, r *http.Request) { + var req struct { + Force bool `json:"force"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := startLcJob("install", func() { lcRunInstall(req.Force) }); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// handleLlamacppInstallCustom lance un job d'installation d'un backend CUSTOM +// (fork llama.cpp) depuis une URL de dépôt Git : cloné dans backends/ et +// compilé pour la machine, SANS toucher au BIN global. Il apparaît ensuite dans +// /api/backends et se choisit par modèle (éditeur de preset → Moteur). +func handleLlamacppInstallCustom(w http.ResponseWriter, r *http.Request) { + var req struct { + Repo string `json:"repo"` + Name string `json:"name"` + Ref string `json:"ref"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if strings.TrimSpace(req.Repo) == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "URL du dépôt requise"}) + return + } + if err := startLcJob("custom", func() { lcRunCustomInstall(req.Repo, req.Name, req.Ref) }); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// lcRunCustomInstall : corps du job d'install d'un backend custom (sans bascule +// de BIN — c'est volontaire, il se rattache par preset). +func lcRunCustomInstall(url, name, ref string) { + bin, err := installCustomBackend(url, name, ref, lcPhase) + if err != nil { + lcFail(err) + return + } + lcAppend("binaire compilé : " + bin) + lcAppend("→ pour l'utiliser : édite un modèle → section Moteur → « backend détecté » et choisis-le.") + lcDone("backend custom installé") +} + +// handleLlamacppUpdate lance le job de mise à jour (pull + rebuild + restart). +// {clean:true} force une recompilation from scratch même sans nouveau commit. +func handleLlamacppUpdate(w http.ResponseWriter, r *http.Request) { + var req struct { + Clean bool `json:"clean"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + if err := startLcJob("update", func() { lcRunUpdate(req.Clean) }); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// handleLlamacppPrebuiltCheck interroge la dernière release officielle de +// llama.cpp et la compare à la version précompilée installée. Synchrone. +func handleLlamacppPrebuiltCheck(w http.ResponseWriter, r *http.Request) { + tag, assets, err := fetchLlamaLatest() + if err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + main, _, label, _, err := pickPrebuilt(assets) + if err != nil { + sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()}) + return + } + cur, _ := prebuiltVersion() + sendJSON(w, 200, map[string]any{ + "ok": true, + "latest": tag, + "current": cur, + "variant": label, + "size_mb": main.Size / 1_000_000, + "update": cur != tag || prebuiltServerBin() == "", + }) +} + +// handleLlamacppPrebuilt lance le job de téléchargement / mise à jour des +// binaires précompilés officiels (pas de compilation). +func handleLlamacppPrebuilt(w http.ResponseWriter, r *http.Request) { + if err := startLcJob("prebuilt", lcRunPrebuilt); err != nil { + sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true}) +} + +// lcRunPrebuilt : télécharge les binaires officiels, pointe BIN dessus, en +// stoppant le service pendant le remplacement (binaire verrouillé en cours +// d'exécution) puis en le relançant. +func lcRunPrebuilt() { + svcWasUp := serviceIsActive() + if svcWasUp { + lcPhase("arrêt du service le temps de l'installation…") + if err := serviceAction("stop"); err != nil { + lcAppend("[warn] impossible d'arrêter le service : " + err.Error()) + } + } + bin, err := prebuiltInstall(lcAppend, lcPhase) + if err == nil { + if serr := SetConfigKey("BIN", bin); serr != nil { + err = fmt.Errorf("binaires installés mais échec d'écriture de BIN : %w", serr) + } else { + lcAppend("BIN mis à jour") + } + } + if svcWasUp { + lcPhase("redémarrage du service…") + if serr := serviceAction("start"); serr != nil { + lcAppend("[warn] redémarrage du service échoué : " + serr.Error()) + } + } + if err != nil { + lcFail(err) + return + } + tag, _ := prebuiltVersion() + lcDone("binaires précompilés installés (" + tag + ")") +} + +// handleLlamacppUse bascule BIN entre deux versions DÉJÀ installées, sans +// rien recompiler : "fast" = binaires précompilés, "opt" = build local. Le +// service redémarre pour prendre le nouveau binaire. L'UI n'appelle ceci que +// quand la version cible existe déjà (sinon elle lance un job d'installation). +func handleLlamacppUse(w http.ResponseWriter, r *http.Request) { + var req struct { + Mode string `json:"mode"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + var bin string + switch req.Mode { + case "fast": + bin = prebuiltServerBin() + case "opt": + bin = llamaServerBin(llamacppRepoDir()) + default: + sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu"}) + return + } + if bin == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "cette version n'est pas installée"}) + return + } + if err := SetConfigKey("BIN", bin); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + if serviceIsActive() { + _ = serviceAction("restart") + } + sendJSON(w, 200, map[string]any{"ok": true, "bin": bin}) +} + +// handleLlamacppJob renvoie l'état du job courant + les lignes de log depuis +// l'offset absolu ?from=N (le client mémorise `next` et enchaîne). +func handleLlamacppJob(w http.ResponseWriter, r *http.Request) { + from, _ := strconv.Atoi(r.URL.Query().Get("from")) + sendJSON(w, 200, lcJobSnapshot(from, true)) +} + +// lcJobSnapshot construit la vue JSON du job courant. withLines=false renvoie +// juste l'entête (imbriquée dans /api/llamacpp). +func lcJobSnapshot(from int, withLines bool) map[string]any { + lcMu.Lock() + defer lcMu.Unlock() + if lcCur == nil { + return map[string]any{"exists": false} + } + j := lcCur + out := map[string]any{ + "exists": true, + "action": j.Action, + "running": j.Running, + "phase": j.Phase, + "compiled": j.Compiled, + "error": j.Err, + "started_at": j.StartedAt, + "ended_at": j.EndedAt, + "old": j.OldCommit, + "new": j.NewCommit, + } + if withLines { + start := from - j.base + if start < 0 { + start = 0 + } + if start > len(j.lines) { + start = len(j.lines) + } + out["lines"] = append([]string(nil), j.lines[start:]...) + out["next"] = j.base + len(j.lines) + } + return out +} + +// --------------------------------------------------------------------------- +// Corps des jobs (miroir web de llamacppInstall / llamacppUpdate, sans stdout +// interactif : tout passe par lcPhase / le sink de build) +// --------------------------------------------------------------------------- + +func lcRunInstall(force bool) { + repo := defaultRepoDir() + + lcPhase("vérification des outils (git, cmake, compilateur)…") + if err := requireTools("git", "cmake"); err != nil { + lcFail(err) + return + } + if err := ensureCompiler(); err != nil { + lcFail(err) + return + } + ensureAccelerator() + + if isDir(filepath.Join(repo, ".git")) { + if !force { + // Dépôt déjà là : on bascule sur une mise à jour (même intention). + lcPhase("dépôt déjà présent — bascule en mise à jour") + lcRunUpdate(false) + return + } + lcPhase("suppression du dépôt existant (--force)…") + if err := os.RemoveAll(repo); err != nil { + lcFail(err) + return + } + } + if err := os.MkdirAll(filepath.Dir(repo), 0o755); err != nil { + lcFail(err) + return + } + + lcPhase("clone de llama.cpp…") + if err := runStep("git clone", "", "git", "clone", "--depth=1", llamacppRepoURL, repo); err != nil { + lcFail(fmt.Errorf("git clone a échoué : %w", err)) + return + } + + if !lcBuildAndSwitch(repo, true) { + return + } + lcDone("installation terminée") +} + +func lcRunUpdate(clean bool) { + lcPhase("vérification des outils (git, cmake, compilateur)…") + if err := requireTools("git", "cmake"); err != nil { + lcFail(err) + return + } + if err := ensureCompiler(); err != nil { + lcFail(err) + return + } + ensureAccelerator() + + repo := llamacppRepoDir() + if !isDir(filepath.Join(repo, ".git")) { + lcFail(fmt.Errorf("aucun dépôt llama.cpp (%s) — lance d'abord l'installation", repo)) + return + } + + oldCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") + lcMu.Lock() + lcCur.OldCommit = oldCommit + lcMu.Unlock() + + branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD") + if branch == "" || branch == "HEAD" { + branch = "master" + } + + lcPhase("git fetch origin…") + if err := runStep("git fetch", repo, "git", "fetch", "origin", "--quiet"); err != nil { + lcFail(fmt.Errorf("git fetch a échoué : %w", err)) + return + } + localRev := gitOutput(repo, "rev-parse", "HEAD") + remoteRev := gitOutput(repo, "rev-parse", "origin/"+branch) + if localRev != "" && localRev == remoteRev && !clean && llamaServerBin(repo) != "" { + lcDone("déjà à jour (" + oldCommit + ") — rien à faire") + return + } + if localRev != remoteRev { + lcPhase("git pull origin/" + branch + "…") + if err := runStep("git pull --ff-only", repo, "git", "pull", "--ff-only", "origin", branch); err != nil { + lcFail(fmt.Errorf("git pull a échoué (modifs locales ?) : %w", err)) + return + } + } + newCommit := gitOutput(repo, "rev-parse", "--short", "HEAD") + lcMu.Lock() + lcCur.NewCommit = newCommit + lcMu.Unlock() + + // Le binaire en cours d'exécution ne peut pas être réécrit → stop du service + // pendant le build, redémarrage après (même en échec). + svcWasUp := serviceIsActive() + if svcWasUp { + lcPhase("arrêt du service le temps du build…") + if err := serviceAction("stop"); err != nil { + lcAppend("[warn] impossible d'arrêter le service : " + err.Error()) + } + } + + ok := lcBuildAndSwitch(repo, clean) + if svcWasUp { + lcPhase("redémarrage du service…") + if err := serviceAction("start"); err != nil { + lcAppend("[warn] redémarrage du service échoué : " + err.Error()) + } + } + if !ok { + return + } + if oldCommit == newCommit { + lcDone("recompilé (" + newCommit + ")") + } else { + lcDone("mis à jour : " + oldCommit + " → " + newCommit) + } +} + +// lcBuildAndSwitch détecte le plan, compile llama-server et pointe BIN dessus. +// Renvoie false (job en échec) si une étape casse. +func lcBuildAndSwitch(repo string, clean bool) bool { + plan := detectBuildPlan() + lcAppend(fmt.Sprintf("plan de build : backend=%s arch=%s jobs=%d", plan.backend, plan.cudaArch, plan.jobs)) + lcPhase("configuration CMake…") + if err := buildLlamacpp(repo, plan, clean); err != nil { + lcAppendLogTail(filepath.Join(repo, "configure.log"), filepath.Join(repo, "build.log")) + lcFail(err) + return false + } + bin := llamaServerBin(repo) + if bin == "" { + lcFail(fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build"))) + return false + } + lcAppend("binaire compilé : " + bin) + if err := SetConfigKey("BIN", bin); err != nil { + lcFail(fmt.Errorf("build ok mais échec d'écriture de BIN : %w", err)) + return false + } + lcAppend("BIN mis à jour") + return true +} + +// lcAppendLogTail remonte la fin des logs de build dans le job pour que +// l'erreur réelle soit visible dans l'UI sans aller chercher les fichiers. +func lcAppendLogTail(paths ...string) { + for _, p := range paths { + b, err := os.ReadFile(p) + if err != nil { + continue + } + lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n") + if len(lines) > 30 { + lines = lines[len(lines)-30:] + } + lcAppend("--- fin de " + filepath.Base(p) + " ---") + for _, l := range lines { + lcAppend(l) + } + } +} diff --git a/internal/loki/web_llamacpp_job.go b/internal/loki/web_llamacpp_job.go new file mode 100644 index 0000000..f9b0efd --- /dev/null +++ b/internal/loki/web_llamacpp_job.go @@ -0,0 +1,126 @@ +// web_llamacpp_job.go — persistance du job llama.cpp (install / update / +// backend custom). +// +// Le job vivait UNIQUEMENT en mémoire du process web. Conséquence : au moindre +// redémarrage du service (mise à jour du binaire, crash, reboot) l'état +// disparaissait — la page rechargée n'affichait plus rien du tout, sans dire si +// la compilation continuait ou pas. Or elle ne continue pas : le build est un +// processus ENFANT, il meurt avec le service. On écrit donc l'état sur disque +// pour pouvoir l'annoncer honnêtement au lieu de laisser l'utilisateur devant +// un écran muet. +package loki + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// L'ENTÊTE du job vit en base ; ses LIGNES restent un fichier, écrit en append. +// Une compilation en produit des milliers : les faire transiter par une +// transaction chacune coûterait un fsync par ligne. +func lcJobLogPath() string { return filepath.Join(LokiHome(), "loki-build.log") } + +// lcPersisted est la forme sur disque : l'entête du job, sans les lignes (elles +// vivent dans le .log à côté, en append). +type lcPersisted struct { + Action string `json:"action"` + Phase string `json:"phase"` + Compiled int `json:"compiled"` + Running bool `json:"running"` + Err string `json:"error"` + StartedAt int64 `json:"started_at"` + EndedAt int64 `json:"ended_at"` + OldCommit string `json:"old"` + NewCommit string `json:"new"` +} + +var ( + lcLastSave time.Time + lcRestoreOnce sync.Once +) + +// lcSave écrit l'état courant. Appelée sous lcMu. Les changements d'état (début, +// fin, échec) sont écrits tout de suite ; la progression est throttlée à 2 s +// pour ne pas marteler le disque pendant une compilation (des milliers de +// lignes). +func lcSave(force bool) { + if lcCur == nil { + return + } + if !force && time.Since(lcLastSave) < 2*time.Second { + return + } + lcLastSave = time.Now() + b, err := json.Marshal(lcPersisted{ + Action: lcCur.Action, Phase: lcCur.Phase, Compiled: lcCur.Compiled, + Running: lcCur.Running, Err: lcCur.Err, + StartedAt: lcCur.StartedAt, EndedAt: lcCur.EndedAt, + OldCommit: lcCur.OldCommit, NewCommit: lcCur.NewCommit, + }) + if err == nil { + _ = putBytes(bkState, "build_job", b) + } +} + +// lcSaveLine ajoute une ligne au journal sur disque (append, pas de réécriture). +func lcSaveLine(line string) { + f, err := os.OpenFile(lcJobLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return + } + _, _ = f.WriteString(line + "\n") + _ = f.Close() +} + +// lcResetLog repart d'un journal vide au démarrage d'un nouveau job. +func lcResetLog() { _ = os.Remove(lcJobLogPath()) } + +// lcRestore recharge le dernier job connu au démarrage du process. Un job noté +// « en cours » ne peut pas être le nôtre (on vient de démarrer) : sa +// compilation est morte avec le process précédent, on le marque interrompu +// plutôt que de le faire passer pour vivant ou de l'effacer en silence. +func lcRestore() { + var p lcPersisted + if !getJSON(bkState, "build_job", &p) || p.Action == "" { + return + } + j := &lcJob{ + Action: p.Action, Phase: p.Phase, Compiled: p.Compiled, + Running: false, Err: p.Err, + StartedAt: p.StartedAt, EndedAt: p.EndedAt, + OldCommit: p.OldCommit, NewCommit: p.NewCommit, + } + if p.Running { + j.Err = "interrompu : le service a redémarré pendant l'opération — relancez-la" + j.Phase = "interrompu" + j.EndedAt = time.Now().Unix() + } + if logb, err := os.ReadFile(lcJobLogPath()); err == nil { + lines := strings.Split(strings.TrimRight(string(logb), "\n"), "\n") + if len(lines) == 1 && lines[0] == "" { + lines = nil + } + if len(lines) > lcMaxLines { + j.base = len(lines) - lcMaxLines + lines = lines[j.base:] + } + j.lines = lines + } + if p.Running { + j.lines = append(j.lines, "✗ interrompu : le service a redémarré (la compilation ne survit pas au redémarrage)") + } + lcMu.Lock() + if lcCur == nil { + lcCur = j + } + lcMu.Unlock() + if p.Running { + lcMu.Lock() + lcSave(true) + lcMu.Unlock() + } +} diff --git a/internal/loki/web_prefs.go b/internal/loki/web_prefs.go new file mode 100644 index 0000000..a65ffdd --- /dev/null +++ b/internal/loki/web_prefs.go @@ -0,0 +1,73 @@ +package loki + +import ( + "encoding/json" + "net/http" + "strings" + "sync" +) + +// Préférences d'apparence de l'UI web (thème, mode d'affichage). Stockées côté +// serveur — sur la machine Loki — pour être partagées entre tous les appareils +// qui pilotent la même instance (téléphone, laptop, accès distant ajean.link). +// Avant ça, elles ne vivaient qu'en localStorage, donc par navigateur/appareil. + +var webPrefsMu sync.Mutex + +// webPrefsAllowed liste les clés de préférence acceptées et, pour chacune, les +// valeurs valides. On ne stocke que ce qui est connu (pas de champ libre). +var webPrefsAllowed = map[string]map[string]bool{ + "theme": {"light": true, "dark": true}, + "hide_reasoning": {"0": true, "1": true}, + "hide_tools": {"0": true, "1": true}, + "fold_tools": {"0": true, "1": true}, + "hide_side": {"0": true, "1": true}, + "hide_stats": {"0": true, "1": true}, +} + +// loadWebPrefs lit les préférences enregistrées (map vide si aucune). +func loadWebPrefs() map[string]string { + out := map[string]string{} + for k, v := range allKV(bkPrefs) { + out[k] = v + } + return out +} + +// saveWebPrefs fusionne les valeurs valides de `in` puis enregistre le tout. +func saveWebPrefs(in map[string]string) (map[string]string, error) { + webPrefsMu.Lock() + defer webPrefsMu.Unlock() + prefs := loadWebPrefs() + for k, v := range in { + allowed, ok := webPrefsAllowed[k] + if !ok { + continue + } + v = strings.ToLower(strings.TrimSpace(v)) + if !allowed[v] { + continue + } + prefs[k] = v + } + return prefs, replaceKV(bkPrefs, prefs) +} + +// handleWebPrefs expose les préférences d'apparence partagées entre appareils. +// +// GET → {ok, prefs:{theme, hide_*, fold_*}} +// POST {theme?, hide_*?, fold_*?} → fusionne puis renvoie {ok, prefs} +func handleWebPrefs(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + var req map[string]string + _ = json.NewDecoder(r.Body).Decode(&req) + prefs, err := saveWebPrefs(req) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "prefs": prefs}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "prefs": loadWebPrefs()}) +} diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go new file mode 100644 index 0000000..5a5e164 --- /dev/null +++ b/internal/loki/web_server.go @@ -0,0 +1,350 @@ +package loki + +import ( + "bufio" + "embed" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "os/exec" + "regexp" + "runtime" + "strconv" + "strings" + "sync" + "time" +) + +//go:generate go run ../../tools/assemble-ui ui +//go:embed ui/index.html ui/marked.min.js +var uiFS embed.FS + +// cmdWeb starts the HTTP server on the given port (default 8090). +func cmdWeb(args []string) error { + port := 8090 + if len(args) > 0 && args[0] != "" { + n, err := strconv.Atoi(args[0]) + if err != nil { + return fmt.Errorf("port invalide: %s", args[0]) + } + port = n + } + mux := newWebMux() + addr := fmt.Sprintf("0.0.0.0:%d", port) + + ln, err := net.Listen("tcp", addr) + if err != nil { + // Port occupé : on identifie le process qui le tient et on propose de + // le terminer pour relancer à sa place. + if !resolvePortConflict(port) { + return err + } + if ln, err = net.Listen("tcp", addr); err != nil { + return err + } + } + fmt.Printf("[loki web] http://%s (Ctrl-C pour arrêter)\n", addr) + if readWebKey() == "" { + fmt.Printf("%s API de pilotage NON protégée (aucune clé). Avant de l'exposer sur internet :\n", yellow("[!]")) + fmt.Printf(" %s\n", bold("loki set-web-key")) + } else { + fmt.Printf("%s API protégée par clé (Authorization: Bearer …)\n", green("[ok]")) + } + + // Accès distant : le tunnel vers le relais est ouvert ICI, dans le process qui + // sert déjà l'UI, et avec le MÊME mux. C'est la condition d'une conversation + // unique — elle vit en mémoire (voir chat_conversation.go), donc deux process + // qui la servent, c'est deux fils qui divergent et s'écrasent l'un l'autre. + // Sans jeton enregistré, startAppLink ne fait rien : l'UI locale suffit. + appOwnsLink = true + appWebMux = mux + if readLinkToken() != "" { + killForeignUIWorker() // rescapé d'une version antérieure ou d'une autre copie + fmt.Printf("%s connexion au relais %s …\n", cyan("[link]"), relayURL()) + if fp := e2eFingerprint(); fp != "" { + fmt.Printf("%s empreinte E2E : %s (code d'appairage : loki link code)\n", green("[e2e]"), bold(fp)) + } + fmt.Printf("%s front OpenAI public prêt (activation en direct via l'UI ; état: %v)\n", green("[oai]"), oaiPublicEnabled()) + startAppLink(mux) + } + // ReadHeaderTimeout : sans lui, une connexion qui n'envoie jamais sa requête + // immobilise une goroutine pour toujours — et ce port écoute sur 0.0.0.0. + // Surtout PAS de WriteTimeout ici : il couperait les flux SSE du chat, qui + // restent ouverts aussi longtemps que l'utilisateur regarde la page. + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + return srv.Serve(ln) +} + +// newWebMux construit le routeur HTTP de l'UI web. Extrait de cmdWeb pour être +// réutilisé par `loki link`, qui sert ce même mux à travers le tunnel sans +// repasser par un écouteur TCP local. +var convLoadOnce sync.Once + +func newWebMux() *http.ServeMux { + // Charge l'état de conversation persisté (une fois par process : loki web ET + // loki link serve appellent newWebMux). + convLoadOnce.Do(LoadConversation) + // Pré-chauffe les serveurs MCP en tâche de fond : sinon le handshake (plusieurs + // secondes pour un serveur lancé via npx) est payé par le premier message. + MCPPrewarm() + // Un téléchargement de modèle coupé net (crash, restart du service) laisse un + // .part orphelin non reprenable : on nettoie au démarrage. + cleanStalePartFiles() + // Idem pour un envoi de fichier coupé en plein transfert : les sessions vivent + // en mémoire, aucun .part ne survit utilement à l'arrêt du process. + cleanStaleUploadParts() + // Idem pour une installation de moteur : elle meurt avec le process. On + // recharge son état pour l'annoncer « interrompue » au lieu de n'afficher + // plus rien du tout. + lcRestoreOnce.Do(lcRestore) + mux := http.NewServeMux() + // Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la + // donnée et toutes les actions passent par /api/* qui, lui, exige la clé. + mux.HandleFunc("/", handleIndex) + // Poste distant : deux routes PUBLIQUES (pas derrière la clé de pilotage). + // L'enrôlement est authentifié par le code d'appairage à usage unique, et le + // WebSocket par la clé d'appareil (Bearer) — le poste ne connaît pas la clé + // de pilotage. Voir node_api.go / node_server.go. + mux.HandleFunc("/api/node/enroll", handleNodeEnroll) + mux.HandleFunc("/api/node/ws", handleNodeWS) + mux.HandleFunc("/marked.min.js", func(w http.ResponseWriter, r *http.Request) { + b, _ := uiFS.ReadFile("ui/marked.min.js") + w.Header().Set("Content-Type", "application/javascript") + w.Header().Set("Cache-Control", "public, max-age=86400") + w.Write(b) + }) + // api enregistre une route /api/* protégée par la clé de pilotage (web_auth.go). + api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) } + api("/api/ping", handlePing) + api("/api/status", handleStatus) + api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas + api("/api/vram", handleVram) + api("/api/ram", handleRam) + api("/api/config", handleConfigEnv) + api("/api/catalog", handleCatalog) + api("/api/paths", handlePaths) + api("/api/update", handleUpdateCheck) + api("/api/update/apply", handleUpdateApply) + api("/api/models", handleModels) + api("/api/models/delete", handleModelDelete) + api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…) + api("/api/models/download", handleModelDownload) + api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer + api("/api/models/download/status", handleModelDownloadStatus) + api("/api/models/download/cancel", handleModelDownloadCancel) + api("/api/backends", handleBackends) + api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧) + api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend) + api("/api/llamacpp", handleLlamacpp) // statut du backend llama.cpp + api("/api/llamacpp/check", handleLlamacppCheck) // git fetch + retard sur origin + api("/api/llamacpp/install", handleLlamacppInstall) // job : clone + build + BIN + api("/api/llamacpp/install-custom", handleLlamacppInstallCustom) // job : clone d'un fork depuis une URL Git (par preset, sans BIN global) + api("/api/llamacpp/uninstall-custom", handleLlamacppUninstallCustom) // supprime un backend custom (backends/) + api("/api/llamacpp/update", handleLlamacppUpdate) // job : pull + rebuild + restart + api("/api/llamacpp/job", handleLlamacppJob) // progression + logs du job + api("/api/llamacpp/prebuilt", handleLlamacppPrebuilt) // job : binaires officiels précompilés + api("/api/llamacpp/prebuilt/check", handleLlamacppPrebuiltCheck) // dernière release officielle vs installée + api("/api/llamacpp/use", handleLlamacppUse) // bascule BIN entre versions déjà installées + api("/api/presets", handlePresets) + api("/api/preset", handlePreset) + api("/api/preset/save", handlePresetSave) + api("/api/preset/delete", handlePresetDelete) + api("/api/agent", handleAgent) + api("/api/agent/toggle", handleAgentToggle) + api("/api/agent/compact", handleCompactToggle) + api("/api/apikey", handleAPIKey) + api("/api/oai/public", handleOAIPublic) + api("/api/link/status", handleLinkStatus) // état de l'accès distant (ajean.link) + api("/api/link/connect", handleLinkConnect) // clé de liaison remise par connect.html → loki link + api("/api/link/start", handleLinkStart) // (re)démarre le tunnel avec la clé déjà enregistrée + api("/api/link/disconnect", handleLinkDisconnect) // arrête le lien + oublie la clé + api("/api/link/paircode", handleLinkPairCode) // code d'appairage + empreinte pour la 1re connexion + // Postes distants : gestion réservée au propriétaire (clé de pilotage). + api("/api/node", handleNodes) // liste + état de connexion + api("/api/node/pair", handleNodePair) // génère un code d'appairage + api("/api/node/caps", handleNodeCaps) // règle capacités + dossier racine + api("/api/node/target", handleNodeTarget) // choisit la machine cible de l'agent + api("/api/node/revoke", handleNodeRevoke) // oublie la clé + déconnecte + api("/api/internet", handleInternet) + api("/api/mcp", handleMCP) + api("/api/mcp/save", handleMCPSave) + api("/api/mcp/delete", handleMCPDelete) + api("/api/mcp/toggle", handleMCPToggle) + api("/api/mcp/tool", handleMCPTool) + api("/api/mcp/test", handleMCPTest) + api("/api/memory", handleMemoryMode) + api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows) + api("/api/prefs", handleWebPrefs) + api("/api/sysprompt", handleSysPrompt) + // Alias rétro-compat : l'ancien portail ajean.link (dépôt loki-relay) pilote + // encore l'agent via /api/tools* et /api/skills/toggle à travers le tunnel E2E. + // On les mappe sur le mode agent unifié le temps que le portail soit mis à jour. + api("/api/tools", handleAgent) + api("/api/tools/toggle", handleAgentToggle) + api("/api/skills", handleAgent) + api("/api/skills/toggle", handleAgentToggle) + api("/api/mem", handleMem) + api("/api/mem/save", handleMemSave) + api("/api/mem/delete", handleMemDelete) + api("/api/switch", handleSwitch) + api("/api/start", svcHandler("start")) + api("/api/stop", svcHandler("stop")) + api("/api/restart", svcHandler("restart")) + api("/api/bench", handleBench) + api("/api/bench/last", handleBenchLast) + api("/api/chat", handleChat) // flux d'ABONNEMENT (SSE) : rejoue + suit le fil + api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée) + api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant) + api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only) + api("/api/chat/stop", handleChatStop) // interrompt la génération en cours + api("/api/chat/reset", handleChatReset) // nouvelle conversation (pour tous les appareils) + api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte + api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used} + api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json) + api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais) + return mux +} + +// resolvePortConflict identifies the process listening on `port`, asks the user +// whether to terminate it, and (on yes) kills it and waits for the port to free. +// Returns true if the caller should retry binding. +func resolvePortConflict(port int) bool { + pid, name := pidOnPort(port) + if pid == 0 { + fmt.Printf("%s port %d déjà utilisé, mais le process n'a pas pu être identifié (essaie en root ?)\n", red("[err]"), port) + return false + } + fmt.Printf("%s le port %d est déjà utilisé par %s (PID %d).\n", yellow("[!]"), port, bold(name), pid) + fmt.Print(dim(" terminer ce process et relancer ? [Y/n] ")) + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") { + fmt.Println(dim(" annulé.")) + return false + } + // Arrêt poli d'abord, puis forcé si le port ne se libère pas. + killPid(pid, false) + for i := 0; i < 15; i++ { + time.Sleep(200 * time.Millisecond) + if p, _ := pidOnPort(port); p == 0 { + fmt.Printf("%s process %d terminé, redémarrage…\n", green("[ok]"), pid) + return true + } + } + killPid(pid, true) + time.Sleep(500 * time.Millisecond) + if p, _ := pidOnPort(port); p != 0 { + fmt.Printf("%s impossible de libérer le port %d (PID %d toujours présent)\n", red("[err]"), port, p) + return false + } + fmt.Printf("%s process %d terminé (forcé), redémarrage…\n", green("[ok]"), pid) + return true +} + +// killPid termine un process : kill TERM/KILL sous Unix, taskkill sous Windows +// (où il n'existe pas d'arrêt « poli » générique — taskkill sans /F échoue sur +// les process console, donc le second essai passe en forcé). +func killPid(pid int, force bool) { + if runtime.GOOS == "windows" { + args := []string{"/PID", strconv.Itoa(pid)} + if force { + args = append(args, "/F") + } + _ = hideCmd(exec.Command("taskkill", args...)).Run() + return + } + sig := "-TERM" + if force { + sig = "-KILL" + } + _ = exec.Command("kill", sig, strconv.Itoa(pid)).Run() +} + +// pidOnPort returns the PID and command name of the process listening on the +// given TCP port, via `ss` (Linux) with an `lsof` fallback, or `netstat -ano` +// on Windows. Returns 0 if none is found or if the tools can't see it (e.g. +// owned by another user). +func pidOnPort(port int) (int, string) { + if runtime.GOOS == "windows" { + // netstat -ano : " TCP 0.0.0.0:8090 0.0.0.0:0 LISTENING 1234" + out, err := hideCmd(exec.Command("netstat", "-ano", "-p", "tcp")).Output() + if err != nil { + return 0, "" + } + suffix := ":" + strconv.Itoa(port) + for _, line := range strings.Split(string(out), "\n") { + f := strings.Fields(line) + if len(f) >= 5 && f[0] == "TCP" && strings.HasSuffix(f[1], suffix) && f[3] == "LISTENING" { + if pid, err := strconv.Atoi(f[4]); err == nil && pid > 0 { + return pid, processName(pid) + } + } + } + return 0, "" + } + redir := regexp.MustCompile(`pid=(\d+)`) + if out, err := exec.Command("ss", "-ltnHp", fmt.Sprintf("sport = :%d", port)).Output(); err == nil { + if m := redir.FindStringSubmatch(string(out)); m != nil { + pid, _ := strconv.Atoi(m[1]) + return pid, processName(pid) + } + } + if out, err := exec.Command("lsof", "-ti", fmt.Sprintf("tcp:%d", port), "-sTCP:LISTEN").Output(); err == nil { + for _, line := range strings.Fields(string(out)) { + if pid, err := strconv.Atoi(strings.TrimSpace(line)); err == nil { + return pid, processName(pid) + } + } + } + return 0, "" +} + +// processName returns a short command name for a PID, or "?" if unknown. +func processName(pid int) string { + if runtime.GOOS == "windows" { + // tasklist CSV : "loki.exe","1234","Console","1","12 345 K" + out, err := hideCmd(exec.Command("tasklist", "/FI", "PID eq "+strconv.Itoa(pid), "/FO", "CSV", "/NH")).Output() + if err == nil { + if f := strings.SplitN(strings.TrimSpace(string(out)), "\",\"", 2); len(f) == 2 { + return strings.TrimPrefix(f[0], "\"") + } + } + return "?" + } + if b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid)); err == nil { + if n := strings.TrimSpace(string(b)); n != "" { + return n + } + } + if out, err := exec.Command("ps", "-o", "comm=", "-p", strconv.Itoa(pid)).Output(); err == nil { + if n := strings.TrimSpace(string(out)); n != "" { + return n + } + } + return "?" +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" && r.URL.Path != "/index.html" { + http.NotFound(w, r) + return + } + b, err := uiFS.ReadFile("ui/index.html") + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store, max-age=0") + w.Write(b) +} + +func sendJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + json.NewEncoder(w).Encode(v) +} + +// handlePing is a lightweight authenticated endpoint a client hits to verify +// connectivity AND that its key is valid (200 = bonne clé, 401 = mauvaise clé). diff --git a/internal/loki/web_sysprompt.go b/internal/loki/web_sysprompt.go new file mode 100644 index 0000000..be87280 --- /dev/null +++ b/internal/loki/web_sysprompt.go @@ -0,0 +1,47 @@ +// web_sysprompt.go — prompt système personnalisé de l'utilisateur, persisté +// CÔTÉ SERVEUR (en base) et partagé entre appareils, comme la conversation +// elle-même. Historique : avant la conversation serveur (v0.4.x), +// l'UI envoyait son prompt système dans chaque requête /api/chat ; depuis, +// /api/chat/send ne porte que le message → le champ de l'UI n'avait plus aucun +// effet. Il est maintenant lu ici par la génération (chat_conversation.go), et +// InjectSkills (llm_client.go) le fusionne avec le préambule agent. +package loki + +import ( + "encoding/json" + "net/http" + "strings" +) + +// readSysPrompt renvoie le prompt système personnalisé ("" si absent). +func readSysPrompt() string { return getStr(bkState, "sysprompt") } + +func saveSysPrompt(text string) error { + return putStr(bkState, "sysprompt", strings.TrimSpace(text)) +} + +// handleSysPrompt : +// +// GET → {ok, text} +// POST {text} → enregistre ("" = efface) puis renvoie {ok, text} +func handleSysPrompt(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + sendJSON(w, 200, map[string]any{"ok": true, "text": readSysPrompt()}) + case http.MethodPost: + var body struct { + Text string `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()}) + return + } + if err := saveSysPrompt(body.Text); err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{"ok": true, "text": readSysPrompt()}) + default: + sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode non autorisée"}) + } +} diff --git a/internal/loki/web_upload.go b/internal/loki/web_upload.go new file mode 100644 index 0000000..a5e6635 --- /dev/null +++ b/internal/loki/web_upload.go @@ -0,0 +1,559 @@ +// web_upload.go — dépôt de fichiers depuis le chat. +// +// L'utilisateur glisse un fichier dans le composeur ; il est écrit dans +// uploads/ à l'intérieur du workspace de l'agent (chat_workspace.go), et son +// chemin RELATIF est joint au message. Le modèle en fait ce qu'il veut avec ses +// outils (read, bash, edit) — on ne tente ni extraction ni interprétation ici. +// +// Le transport est du JSON base64, et non du multipart : c'est la seule forme +// qui traverse le tunnel E2E d'app.ajean.link (relay_e2e.go ne dispatche que des +// corps JSON), donc l'accès distant marche sans code spécifique. +package loki + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + "unicode" +) + +const ( + // uploadMaxBytes borne la taille d'UN fichier. Le fichier n'est jamais tenu en + // mémoire (voir upSession) : cette limite protège le disque, pas la RAM. + uploadMaxBytes = 1 << 30 // 1 Go + + // uploadChunkMax borne UN morceau, et c'est LUI qui décide de la mémoire du + // process : un morceau est reçu en JSON, donc entièrement en RAM, puis décodé. + // 8 Mo décodés ≈ 11 Mo de base64. C'est aussi la taille demandée au client ; + // un morceau plus gros est refusé plutôt que d'être avalé. + uploadChunkMax = 8 << 20 + + // downloadChunkMax borne UNE tranche de téléchargement encodée en base64 (voir + // handleChatFile). Même logique que pour l'envoi : c'est la mémoire du process + // qu'on protège, pas la taille du fichier. + downloadChunkMax = 8 << 20 +) + +// uploadsDir renvoie (en le créant) le dossier de dépôt, dans le workspace agent. +func uploadsDir() (string, error) { + dir := filepath.Join(agentWorkspace(), "uploads") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return dir, nil +} + +// safeUploadName réduit un nom fourni par le client à un nom de fichier simple : +// pas de dossier, pas de "..", pas de caractères de contrôle ni de séparateurs. +// C'est la seule barrière entre un client hostile et une écriture arbitraire sur +// le disque — l'API écoute sur 0.0.0.0 et n'a pas forcément de clé. +func safeUploadName(name string) string { + // Coupe tout ce qui ressemble à un chemin, dans les deux conventions : un nom + // Windows arrive tel quel sur un serveur Linux, où filepath.Base ne verrait + // pas les antislashs. + name = strings.ReplaceAll(name, "\\", "/") + name = filepath.Base(filepath.FromSlash(name)) + var b strings.Builder + for _, r := range name { + switch { + case unicode.IsControl(r), r == '/', r == '\\', r == ':', r == '*', + r == '?', r == '"', r == '<', r == '>', r == '|': + b.WriteRune('_') + default: + b.WriteRune(r) + } + } + name = strings.TrimSpace(b.String()) + name = strings.Trim(name, ".") // ".." et les noms cachés/vides + if name == "" { + name = "fichier" + } + // Un nom démesuré casse l'écriture sur certains systèmes de fichiers ; on + // tronque la BASE en gardant l'extension, qui porte le sens. + if len(name) > 120 { + ext := filepath.Ext(name) + if len(ext) > 16 { + ext = "" + } + name = name[:120-len(ext)] + ext + } + return name +} + +// uniqueUploadPath évite d'écraser un dépôt précédent : rapport.pdf, +// rapport-2.pdf, rapport-3.pdf… +func uniqueUploadPath(dir, name string) string { + p := filepath.Join(dir, name) + if _, err := os.Stat(p); os.IsNotExist(err) { + return p + } + ext := filepath.Ext(name) + base := strings.TrimSuffix(name, ext) + for i := 2; i < 1000; i++ { + p = filepath.Join(dir, fmt.Sprintf("%s-%d%s", base, i, ext)) + if _, err := os.Stat(p); os.IsNotExist(err) { + return p + } + } + return p +} + +// attachInfo décrit une pièce jointe retenue : ce que le modèle lira dans le +// texte, et ce que l'UI affiche dans la bulle. +type attachInfo struct { + Name string `json:"name"` // nom seul, pour l'affichage + Path string `json:"path"` // "uploads/", ce que le modèle reçoit + Size int64 `json:"size"` +} + +// attachFiles valide les chemins envoyés par le CLIENT : on les re-normalise en +// uploads/ et on jette ceux qui ne désignent pas un dépôt existant, +// plutôt que d'annoncer au modèle un fichier qu'il ne trouvera pas — ou de le +// laisser pointer ailleurs sur le disque. +func attachFiles(files []string) []attachInfo { + dir, err := uploadsDir() + if err != nil { + return nil + } + var out []attachInfo + for _, f := range files { + name := safeUploadName(f) + st, err := os.Stat(filepath.Join(dir, name)) + if err != nil || st.IsDir() { + continue + } + out = append(out, attachInfo{Name: name, Path: "uploads/" + name, Size: st.Size()}) + } + return out +} + +// attachNote est la phrase ajoutée en tête du message pour le MODÈLE. Elle ne +// s'affiche pas dans le chat : la bulle porte des pastilles de fichier (delta +// `files`), parce qu'une consigne interne recopiée dans le fil se lit comme un +// message que l'utilisateur n'a pas écrit. +func attachNote(files []attachInfo) string { + if len(files) == 0 { + return "" + } + head := "Fichier joint à ce message, déposé dans ton dossier de travail :" + if len(files) > 1 { + head = "Fichiers joints à ce message, déposés dans ton dossier de travail :" + } + var lines []string + for _, f := range files { + lines = append(lines, fmt.Sprintf("- %s (%s)", f.Path, humanBytes(f.Size))) + } + return head + "\n" + strings.Join(lines, "\n") + "\n\n" +} + +// imageMimes : les extensions qu'on peut ENVOYER AU MODÈLE comme image (contenu +// multimodal), quand la vision est active. Un format hors de cette liste reste +// un simple fichier du workspace, que le modèle ouvre avec ses outils. +var imageMimes = map[string]string{ + ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", +} + +// imageMime renvoie le type MIME image d'un nom de fichier, ou "" si ce n'est +// pas une image qu'on sait montrer au modèle. +func imageMime(name string) string { + return imageMimes[strings.ToLower(filepath.Ext(name))] +} + +// visionEnabled dit si un projecteur multimodal est configuré (clé MMPROJ) — +// c'est la condition pour que backend_serve.go passe --mmproj au moteur, donc la +// seule où envoyer une image AU MODÈLE a un sens. Sans projecteur, llama-server +// rejetterait un contenu image ; on s'en tient alors au dépôt-fichier. +func visionEnabled() bool { + return strings.TrimSpace(ReadConfig()["MMPROJ"]) != "" +} + +// userMessageContent construit le champ Content du message utilisateur. Cas +// courant : une simple chaîne (note des fichiers joints + texte). Quand la vision +// est active ET qu'au moins une pièce jointe est une image, on renvoie le format +// multimodal d'OpenAI — une partie `text` suivie d'une partie `image_url` par +// image (data URI base64) — que llama-server comprend une fois --mmproj chargé : +// le modèle VOIT alors l'image au lieu de devoir l'ouvrir comme un fichier binaire. +// Les images ainsi intégrées sortent de la note textuelle (inutile de dire au +// modèle d'aller ouvrir un fichier qu'il a déjà sous les yeux) ; les autres +// fichiers, eux, restent annoncés comme avant. +func userMessageContent(files []attachInfo, prompt string) any { + if !visionEnabled() { + return attachNote(files) + prompt + } + dir, err := uploadsDir() + if err != nil { + return attachNote(files) + prompt + } + var imgParts []map[string]any + var textFiles []attachInfo + for _, f := range files { + mime := imageMime(f.Name) + if mime == "" { + textFiles = append(textFiles, f) + continue + } + b, err := os.ReadFile(filepath.Join(dir, f.Name)) + if err != nil { + textFiles = append(textFiles, f) // illisible ici : au moins l'annoncer comme fichier + continue + } + imgParts = append(imgParts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b), + }, + }) + } + if len(imgParts) == 0 { + return attachNote(files) + prompt + } + parts := []map[string]any{{"type": "text", "text": attachNote(textFiles) + prompt}} + return append(parts, imgParts...) +} + +// workspaceRel dit si `abs` se trouve DANS le dossier de travail de l'agent et, +// si oui, renvoie son chemin relatif en séparateurs '/'. +// +// C'est le seul périmètre téléchargeable. L'agent peut écrire n'importe où quand +// on lui donne un chemin absolu (voir resolveAgentPath) ; ouvrir le téléchargement +// à ces fichiers-là ferait de /api/chat/file un « lis-moi ce fichier du serveur » +// à usage général — l'API n'a pas forcément de clé et écoute sur 0.0.0.0. +func workspaceRel(abs string) (string, bool) { + root := agentWorkspace() + // EvalSymlinks des deux côtés : sans ça, un lien qui sort du dossier passerait + // le test de préfixe. + if r, err := filepath.EvalSymlinks(root); err == nil { + root = r + } + if a, err := filepath.EvalSymlinks(abs); err == nil { + abs = a + } + rel, err := filepath.Rel(root, abs) + if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", false + } + return filepath.ToSlash(rel), true +} + +// e2eInnerHeader marque une requête dispatchée depuis le proxy chiffré +// (relay_e2e.go). Cf. handleChatFile : c'est le seul moyen pour un handler de +// savoir que sa réponse sera réemballée en JSON. +const e2eInnerHeader = "X-Loki-E2E" + +// handleChatFile sert un fichier du dossier de travail, en pièce jointe. Le +// client passe le chemin RELATIF que le modèle a écrit dans sa réponse. +// +// Trois formes de réponse, pour une raison de transport : +// - par défaut, le fichier brut — le chemin direct, en local ou sur le LAN ; +// - `meta=1`, une fiche {name, size, e2e} : le client y apprend s'il est +// derrière le tunnel, donc quelle forme demander ensuite ; +// - `b64=1&offset=&len=`, une tranche encodée en base64. +// +// La raison : à travers app.ajean.link, TOUTE réponse est réemballée en JSON par +// le proxy chiffré. Du binaire n'y survit pas — les octets non-UTF8 sont +// massacrés, et on téléchargeait une enveloppe JSON au lieu du fichier. Le +// base64 traverse, et le découpage en tranches évite de tenir un gigaoctet en +// mémoire pour le transporter. +func handleChatFile(w http.ResponseWriter, r *http.Request) { + rel := r.URL.Query().Get("path") + if strings.TrimSpace(rel) == "" { + sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin manquant"}) + return + } + // Un chemin ABSOLU fourni par le client ne doit pas être suivi : on le traite + // comme relatif au dossier de travail, et le contrôle ci-dessous tranche. + abs := filepath.Join(agentWorkspace(), filepath.FromSlash(rel)) + if _, ok := workspaceRel(abs); !ok { + sendJSON(w, 403, map[string]any{"ok": false, "error": "hors du dossier de travail"}) + return + } + st, err := os.Stat(abs) + if err != nil || st.IsDir() { + sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"}) + return + } + name := filepath.Base(abs) + q := r.URL.Query() + if q.Get("meta") != "" { + sendJSON(w, 200, map[string]any{ + "ok": true, "name": name, "size": st.Size(), + // Le client ne peut pas deviner seul qu'il passe par le tunnel : le + // proxy lui rend des réponses JSON parfaitement ordinaires. + "e2e": r.Header.Get(e2eInnerHeader) != "", + }) + return + } + if q.Get("b64") != "" { + off, _ := strconv.ParseInt(q.Get("offset"), 10, 64) + length, _ := strconv.ParseInt(q.Get("len"), 10, 64) + if length <= 0 || length > downloadChunkMax { + length = downloadChunkMax + } + if off < 0 || off > st.Size() { + sendJSON(w, 400, map[string]any{"ok": false, "error": "position hors du fichier"}) + return + } + if off+length > st.Size() { + length = st.Size() - off + } + f, err := os.Open(abs) + if err != nil { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + defer f.Close() + buf := make([]byte, length) + // ReadAt : positionne et lit en une fois, et remplit tout le tampon (ce + // qu'un simple Read ne garantit pas). io.EOF sur la dernière tranche est + // normal, pas une erreur. + n, err := f.ReadAt(buf, off) + if err != nil && err != io.EOF { + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{ + "ok": true, "name": name, "size": st.Size(), "offset": off, + "data": base64.StdEncoding.EncodeToString(buf[:n]), + "eof": off+int64(n) >= st.Size(), + }) + return + } + // Toujours en TÉLÉCHARGEMENT, jamais rendu : un .html écrit par le modèle ne + // doit pas s'exécuter dans l'origine de l'UI (il y lirait la clé de pilotage). + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(name)) + http.ServeFile(w, r, abs) +} + +type uploadReq struct { + Name string `json:"name"` + Data string `json:"data"` // base64 d'UN morceau (accepte un data: URL complet) + // Envoi en plusieurs morceaux. Le premier appel n'a pas d'ID et reçoit celui + // que le serveur attribue ; les suivants le rappellent. `More` à false ferme + // le fichier. Un envoi en un seul morceau (More absent) reste valable. + ID string `json:"id"` + More bool `json:"more"` + // Size = taille totale annoncée au PREMIER morceau, pour vérifier l'espace + // disque avant d'entamer un envoi d'un gigaoctet. Purement indicatif : le + // vrai plafond reste vérifié morceau par morceau. + Size int64 `json:"size"` +} + +// upSession = un envoi en cours, adossé à un fichier .part sur le disque. +// +// Rien n'est accumulé en mémoire : chaque morceau est décodé puis écrit tout de +// suite, et seul le descripteur reste ouvert. C'est ce qui permet de passer à +// 1 Go — la version d'avant gardait le fichier entier en RAM, deux fois (le +// base64 reçu et le binaire décodé), ce qui plafonnait l'envoi à quelques Mo +// utilisables sans faire gonfler le process. +type upSession struct { + // mu sérialise les écritures d'UNE session. Le verrou global ne couvre que la + // table : le tenir pendant l'écriture disque mettait tous les envois à la + // queue leu leu, alors que le client en lance plusieurs de front (un par + // fichier joint). + mu sync.Mutex + busy bool // écriture en cours : le ménage doit passer son tour + f *os.File + name string + written int64 + last time.Time +} + +var ( + upMu sync.Mutex + upSessions = map[string]*upSession{} +) + +// uploadSpaceMargin : place qu'on refuse d'entamer sur le disque. Remplir le +// volume de la machine qui fait tourner le modèle est autrement plus grave que +// de refuser un envoi — llama-server, la base et les journaux vivent dessus. +const uploadSpaceMargin = 512 << 20 + +// upSessionTTL : au-delà, un envoi interrompu (onglet fermé, réseau coupé) est +// abandonné et son .part supprimé. Sans ça, un fichier à moitié transféré +// resterait ouvert et occuperait le disque indéfiniment. +const upSessionTTL = 10 * time.Minute + +// sweepUploadSessions ferme et efface les envois abandonnés. Appelé à chaque +// nouveau morceau : pas de goroutine de ménage à faire vivre. +func sweepUploadSessions() { + now := time.Now() + for id, s := range upSessions { + // `busy` : une écriture est en cours dans cette session. La balayer + // fermerait le fichier sous les pieds de la requête qui l'écrit. + if !s.busy && now.Sub(s.last) > upSessionTTL { + name := s.f.Name() + s.f.Close() + os.Remove(name) + delete(upSessions, id) + } + } +} + +// handleChatUpload reçoit un fichier, en un ou plusieurs morceaux, et renvoie +// son chemin relatif — celui que le client joindra au message (/api/chat/send, +// champ files). +func handleChatUpload(w http.ResponseWriter, r *http.Request) { + var body uploadReq + // Le plafond porte sur UN morceau, pas sur le fichier : c'est la seule borne + // qui compte pour la mémoire du process. + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2*uploadChunkMax)).Decode(&body); err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": "morceau trop gros ou requête invalide"}) + return + } + data := body.Data + // Les clients qui passent par FileReader.readAsDataURL envoient + // "data:application/pdf;base64,JVBER…" : on ne garde que la charge utile. + if strings.HasPrefix(data, "data:") { + if i := strings.Index(data, ","); i >= 0 { + data = data[i+1:] + } + } + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(data)) + if err != nil { + sendJSON(w, 400, map[string]any{"ok": false, "error": "contenu illisible (base64 attendu)"}) + return + } + + // Le verrou global ne protège QUE la table des sessions. L'écriture, elle, se + // fait sous le verrou de la session — sinon deux fichiers envoyés en même + // temps (le client les lance de front) s'attendraient l'un l'autre. + upMu.Lock() + sweepUploadSessions() + s := upSessions[body.ID] + if s == nil { + if body.ID != "" { + // L'envoi a expiré ou le serveur a redémarré en cours de route : le dire, + // plutôt que de recommencer un fichier à partir de son milieu. + upMu.Unlock() + sendJSON(w, 409, map[string]any{"ok": false, "error": "envoi expiré — recommence le fichier"}) + return + } + dir, err := uploadsDir() + if err != nil { + upMu.Unlock() + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + // Espace disque : refuser franchement vaut mieux que remplir le volume de + // la machine qui fait tourner le modèle. La taille annoncée par le client + // n'engage que lui — le plafond réel reste vérifié morceau par morceau. + if free := diskFree(dir); free > 0 && body.Size > 0 && free < body.Size+uploadSpaceMargin { + upMu.Unlock() + sendJSON(w, 507, map[string]any{"ok": false, + "error": fmt.Sprintf("espace insuffisant : %s libres, %s nécessaires", humanBytes(free), humanBytes(body.Size+uploadSpaceMargin))}) + return + } + f, err := os.CreateTemp(dir, ".upload-*.part") + if err != nil { + upMu.Unlock() + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + s = &upSession{f: f, name: safeUploadName(body.Name)} + body.ID = filepath.Base(f.Name()) + upSessions[body.ID] = s + } + s.last = time.Now() + s.busy = true + upMu.Unlock() + + s.mu.Lock() + defer func() { + s.mu.Unlock() + upMu.Lock() + s.busy = false + upMu.Unlock() + }() + + // drop ferme et oublie la session : sur erreur, et à la fin de l'envoi. + drop := func() string { + name := s.f.Name() + s.f.Close() + upMu.Lock() + delete(upSessions, body.ID) + upMu.Unlock() + return name + } + abort := func(code int, msg string) { + os.Remove(drop()) + sendJSON(w, code, map[string]any{"ok": false, "error": msg}) + } + if s.written+int64(len(raw)) > uploadMaxBytes { + abort(413, fmt.Sprintf("fichier trop gros (max %s)", humanBytes(uploadMaxBytes))) + return + } + if len(raw) > 0 { + if _, err := s.f.Write(raw); err != nil { + abort(500, err.Error()) + return + } + s.written += int64(len(raw)) + } + if body.More { + // Morceau intermédiaire : on rend l'ID pour la suite et l'avancement, qui + // alimente la barre de progression côté client. + sendJSON(w, 200, map[string]any{"ok": true, "id": body.ID, "received": s.written}) + return + } + + // Dernier morceau : on ferme et on donne au fichier son vrai nom. Le Close est + // dans drop() ; son erreur éventuelle est celle d'un tampon non vidé, donc + // d'un fichier incomplet — on ne le publie pas dans ce cas. + part := s.f.Name() + syncErr := s.f.Sync() + drop() + if syncErr != nil { + os.Remove(part) + sendJSON(w, 500, map[string]any{"ok": false, "error": syncErr.Error()}) + return + } + if s.written == 0 { + os.Remove(part) + sendJSON(w, 400, map[string]any{"ok": false, "error": "fichier vide"}) + return + } + dest := uniqueUploadPath(filepath.Dir(part), s.name) + if err := os.Rename(part, dest); err != nil { + os.Remove(part) + sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) + return + } + sendJSON(w, 200, map[string]any{ + "ok": true, + "path": "uploads/" + filepath.Base(dest), + "abs": dest, // affiché à l'utilisateur, jamais renvoyé au serveur + "size": s.written, + }) +} + +// cleanStaleUploadParts efface les .part laissés par un envoi que le process n'a +// pas pu terminer (arrêt du service, crash). Appelé au démarrage : les sessions +// vivent en mémoire, aucun de ces fichiers n'est reprenable. +func cleanStaleUploadParts() { + dir, err := uploadsDir() + if err != nil { + return + } + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + n := e.Name() + if !e.IsDir() && strings.HasPrefix(n, ".upload-") && strings.HasSuffix(n, ".part") { + os.Remove(filepath.Join(dir, n)) + } + } +} diff --git a/internal/loki/web_upload_test.go b/internal/loki/web_upload_test.go new file mode 100644 index 0000000..de3a4f5 --- /dev/null +++ b/internal/loki/web_upload_test.go @@ -0,0 +1,357 @@ +package loki + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// Le nom vient du client : aucun d'eux ne doit permettre d'écrire hors du +// dossier de dépôt. +func TestSafeUploadNameNeverEscapes(t *testing.T) { + for _, in := range []string{ + "../../evil.sh", `..\..\evil.sh`, "/etc/passwd", `C:\Windows\system32\x.dll`, + "..", ".", "", " ", "a\x00b", "nul\nname", + } { + got := safeUploadName(in) + if got == "" || got == "." || got == ".." { + t.Fatalf("%q → %q : nom inutilisable", in, got) + } + if strings.ContainsAny(got, `/\`) { + t.Fatalf("%q → %q : contient un séparateur de chemin", in, got) + } + if filepath.Base(got) != got { + t.Fatalf("%q → %q : n'est pas un nom simple", in, got) + } + } +} + +func TestSafeUploadNameKeepsOrdinaryNames(t *testing.T) { + for in, want := range map[string]string{ + "rapport.pdf": "rapport.pdf", + "Compte rendu (final).odt": "Compte rendu (final).odt", + "données-été.csv": "données-été.csv", + `C:\Users\x\notes.md`: "notes.md", + } { + if got := safeUploadName(in); got != want { + t.Fatalf("safeUploadName(%q) = %q, attendu %q", in, got, want) + } + } +} + +func TestChatUploadWritesFileAndAttachNote(t *testing.T) { + body, _ := json.Marshal(map[string]any{ + "name": "../notes.txt", + "data": "data:text/plain;base64," + base64.StdEncoding.EncodeToString([]byte("bonjour")), + }) + rec := httptest.NewRecorder() + handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(body)))) + if rec.Code != 200 { + t.Fatalf("code %d : %s", rec.Code, rec.Body.String()) + } + var resp struct { + OK bool `json:"ok"` + Path string `json:"path"` + Abs string `json:"abs"` + Size int `json:"size"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + defer os.Remove(resp.Abs) + if !resp.OK || resp.Size != 7 { + t.Fatalf("réponse inattendue: %+v", resp) + } + // Le fichier atterrit DANS uploads/, malgré le "../" du nom. + dir, err := uploadsDir() + if err != nil { + t.Fatal(err) + } + if filepath.Dir(resp.Abs) != dir { + t.Fatalf("écrit hors du dossier de dépôt : %s", resp.Abs) + } + if b, err := os.ReadFile(resp.Abs); err != nil || string(b) != "bonjour" { + t.Fatalf("contenu = %q, err=%v", b, err) + } + // Le chemin renvoyé est celui que le client rejoue dans /api/chat/send. + got := attachFiles([]string{resp.Path}) + if len(got) != 1 || got[0].Path != resp.Path || got[0].Size != 7 { + t.Fatalf("attachFiles = %+v", got) + } + if note := attachNote(got); !strings.Contains(note, resp.Path) { + t.Fatalf("note sans le fichier : %q", note) + } + // Un chemin inventé par le client n'atteint jamais le modèle. + if n := attachFiles([]string{"uploads/jamais-deposé.bin", "../../etc/passwd"}); len(n) != 0 { + t.Fatalf("fichiers absents retenus : %+v", n) + } + if attachNote(nil) != "" { + t.Fatal("note non vide sans pièce jointe") + } +} + +// Le téléchargement est le point sensible : il lit des fichiers du serveur à +// partir d'un chemin fourni par le client. +func TestChatFileStaysInsideWorkspace(t *testing.T) { + dir, err := uploadsDir() + if err != nil { + t.Fatal(err) + } + p := filepath.Join(dir, "produit.txt") + if err := os.WriteFile(p, []byte("resultat"), 0o644); err != nil { + t.Fatal(err) + } + defer os.Remove(p) + + rec := httptest.NewRecorder() + handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path=uploads/produit.txt", nil)) + if rec.Code != 200 || rec.Body.String() != "resultat" { + t.Fatalf("code %d, corps %q", rec.Code, rec.Body.String()) + } + if cd := rec.Header().Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment;") { + t.Fatalf("pas en pièce jointe : %q", cd) + } + // Un .html produit par le modèle ne doit jamais s'exécuter dans l'origine de + // l'UI : il y lirait la clé de pilotage en localStorage. + if ct := rec.Header().Get("Content-Type"); ct != "application/octet-stream" { + t.Fatalf("Content-Type = %q", ct) + } + + // Le chemin vient du texte du modèle : il peut pointer n'importe où. + for _, bad := range []string{ + "../loki.db", "../../../etc/passwd", `..\..\secret`, + "/etc/passwd", "C:/Windows/win.ini", "", "uploads/absent.txt", + } { + rec := httptest.NewRecorder() + handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(bad), nil)) + if rec.Code == 200 { + t.Fatalf("chemin %q servi (code 200)", bad) + } + } +} + +func TestChatUploadRejectsEmptyAndBadBase64(t *testing.T) { + for _, data := range []string{"", "pas du base64 !!"} { + body, _ := json.Marshal(map[string]any{"name": "x.bin", "data": data}) + rec := httptest.NewRecorder() + handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(body)))) + if rec.Code != 400 { + t.Fatalf("data=%q : code %d, attendu 400", data, rec.Code) + } + } +} + +// L'export doit garder la trace des pièces jointes : un tour envoyé SANS texte +// donnait sinon une section « Vous » vide, sans dire qu'un fichier était passé. +func TestExportFileNamesBothForms(t *testing.T) { + live := []attachInfo{{Name: "photo.jpg", Path: "uploads/photo.jpg", Size: 10}} + if got := exportFileNames(live); len(got) != 1 || got[0] != "photo.jpg" { + t.Fatalf("forme vivante : %v", got) + } + // Forme relue du journal persisté (JSON → []any de maps). + replayed := []any{map[string]any{"name": "photo.jpg", "path": "uploads/photo.jpg"}} + if got := exportFileNames(replayed); len(got) != 1 || got[0] != "photo.jpg" { + t.Fatalf("forme rejouée : %v", got) + } + if got := exportFileNames(nil); len(got) != 0 { + t.Fatalf("sans pièce jointe : %v", got) + } +} + +// upload envoie un morceau et renvoie la réponse décodée. +func uploadChunk(t *testing.T, body map[string]any) (int, map[string]any) { + t.Helper() + b, _ := json.Marshal(body) + rec := httptest.NewRecorder() + handleChatUpload(rec, httptest.NewRequest("POST", "/api/chat/upload", strings.NewReader(string(b)))) + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + return rec.Code, out +} + +// Un fichier arrive en plusieurs morceaux et doit être reconstitué à l'octet +// près. C'est ce découpage qui permet le gigaoctet : sans lui, tout le fichier +// passait dans un seul corps JSON, gardé en mémoire des deux côtés. +func TestChatUploadChunked(t *testing.T) { + part1 := strings.Repeat("A", 5000) + part2 := strings.Repeat("B", 3000) + part3 := "fin" + + code, resp := uploadChunk(t, map[string]any{ + "name": "gros.bin", "data": base64.StdEncoding.EncodeToString([]byte(part1)), "more": true}) + if code != 200 || resp["id"] == nil { + t.Fatalf("1er morceau : code %d, resp %+v", code, resp) + } + id := resp["id"].(string) + if got := resp["received"].(float64); got != 5000 { + t.Fatalf("avancement après 1er morceau = %v", got) + } + if code, resp = uploadChunk(t, map[string]any{ + "id": id, "data": base64.StdEncoding.EncodeToString([]byte(part2)), "more": true}); code != 200 { + t.Fatalf("2e morceau : code %d, resp %+v", code, resp) + } + code, resp = uploadChunk(t, map[string]any{ + "id": id, "data": base64.StdEncoding.EncodeToString([]byte(part3))}) + if code != 200 || resp["ok"] != true { + t.Fatalf("dernier morceau : code %d, resp %+v", code, resp) + } + abs := resp["abs"].(string) + defer os.Remove(abs) + if resp["path"] != "uploads/gros.bin" { + t.Fatalf("chemin = %v", resp["path"]) + } + got, err := os.ReadFile(abs) + if err != nil { + t.Fatal(err) + } + if string(got) != part1+part2+part3 { + t.Fatalf("reconstitution : %d octets, attendu %d", len(got), len(part1+part2+part3)) + } + // Aucun .part ne doit traîner une fois l'envoi terminé. + dir, _ := uploadsDir() + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".part") { + t.Fatalf("fichier temporaire laissé : %s", e.Name()) + } + } +} + +// Un identifiant inconnu (envoi expiré, serveur redémarré) ne doit pas produire +// un fichier tronqué qui repart de son milieu. +func TestChatUploadUnknownSessionRefused(t *testing.T) { + code, resp := uploadChunk(t, map[string]any{ + "id": "inconnu-1234.part", "data": base64.StdEncoding.EncodeToString([]byte("x"))}) + if code != 409 { + t.Fatalf("code %d, attendu 409 — resp %+v", code, resp) + } +} + +// Le téléchargement via app.ajean.link : le proxy chiffré réemballe toute +// réponse en JSON, où du binaire ne survit pas. Le handler doit donc annoncer +// qu'on est derrière le tunnel, puis savoir servir des tranches base64. +func TestChatFileB64ForE2E(t *testing.T) { + dir, _ := uploadsDir() + // Des octets NON-UTF8 : c'est exactement ce que le réemballage JSON massacre. + raw := make([]byte, 5000) + for i := range raw { + raw[i] = byte(i % 256) + } + p := filepath.Join(dir, "binaire.bin") + if err := os.WriteFile(p, raw, 0o644); err != nil { + t.Fatal(err) + } + defer os.Remove(p) + + // La fiche dit « tunnel » quand la requête vient du proxy, et pas autrement. + meta := func(viaTunnel bool) map[string]any { + req := httptest.NewRequest("GET", "/api/chat/file?meta=1&path=uploads/binaire.bin", nil) + if viaTunnel { + req.Header.Set(e2eInnerHeader, "1") + } + rec := httptest.NewRecorder() + handleChatFile(rec, req) + var m map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &m) + return m + } + if m := meta(true); m["e2e"] != true || m["size"].(float64) != 5000 { + t.Fatalf("fiche via tunnel : %+v", m) + } + if m := meta(false); m["e2e"] != false { + t.Fatalf("fiche en local annoncée comme tunnel : %+v", m) + } + + // Reconstitution par tranches : l'octet doit revenir intact. + var got []byte + for off, guard := 0, 0; ; guard++ { + if guard > 50 { + t.Fatal("boucle de tranches sans fin") + } + rec := httptest.NewRecorder() + handleChatFile(rec, httptest.NewRequest("GET", + fmt.Sprintf("/api/chat/file?b64=1&len=1024&offset=%d&path=uploads/binaire.bin", off), nil)) + if rec.Code != 200 { + t.Fatalf("tranche à %d : code %d — %s", off, rec.Code, rec.Body.String()) + } + var j struct { + Data string `json:"data"` + EOF bool `json:"eof"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &j); err != nil { + t.Fatal(err) + } + b, err := base64.StdEncoding.DecodeString(j.Data) + if err != nil { + t.Fatal(err) + } + got = append(got, b...) + off += len(b) + if j.EOF { + break + } + } + if !bytes.Equal(got, raw) { + t.Fatalf("fichier reconstitué : %d octets, attendu %d", len(got), len(raw)) + } +} + +// Plusieurs fichiers partent DE FRONT depuis le navigateur (un envoi par pièce +// jointe). Les sessions doivent donc être indépendantes : le verrou global ne +// protège que la table, l'écriture se fait sous le verrou de la session. +func TestChatUploadConcurrentSessions(t *testing.T) { + const files, chunks = 4, 6 + var wg sync.WaitGroup + paths := make([]string, files) + errs := make([]string, files) + for i := 0; i < files; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + id, want := "", "" + for c := 0; c < chunks; c++ { + piece := strings.Repeat(string(rune('A'+i)), 100+c) + want += piece + code, resp := uploadChunk(t, map[string]any{ + "name": fmt.Sprintf("concurrent-%d.txt", i), + "data": base64.StdEncoding.EncodeToString([]byte(piece)), + "id": id, "more": c < chunks-1, + }) + if code != 200 { + errs[i] = fmt.Sprintf("morceau %d : code %d (%v)", c, code, resp["error"]) + return + } + if v, ok := resp["id"].(string); ok && v != "" { + id = v + } + if c == chunks-1 { + abs, _ := resp["abs"].(string) + paths[i] = abs + got, err := os.ReadFile(abs) + if err != nil || string(got) != want { + errs[i] = fmt.Sprintf("contenu melange : %d octets lus, %d attendus (err %v)", len(got), len(want), err) + } + } + } + }(i) + } + wg.Wait() + for _, p := range paths { + if p != "" { + defer os.Remove(p) + } + } + for i, e := range errs { + if e != "" { + t.Fatalf("fichier %d : %s", i, e) + } + } +} diff --git a/internal/nodeclient/client.go b/internal/nodeclient/client.go new file mode 100644 index 0000000..1d80a5b --- /dev/null +++ b/internal/nodeclient/client.go @@ -0,0 +1,438 @@ +// Package nodeclient est le CLIENT léger « poste distant ». Il tourne sur un PC +// secondaire, s'appaire une fois avec le serveur loki, puis ouvre une connexion +// SORTANTE et exécute — sous contrôle local — les demandes de l'IA. +// +// Volontairement autonome : dépend seulement de nodewire (protocole partagé, +// zéro dépendance) et de coder/websocket. AUCUN import d'loki → binaire de +// quelques Mo, sans UI ni moteur. +// +// Sécurité côté poste : allowlist locale des capacités, confinement des fichiers +// à un dossier racine, confirmation des actions à effet de bord (sauf --yes). +package nodeclient + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "time" + + "github.com/R0m1k3/Loki/internal/nodewire" + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" +) + +const ( + defaultTimeoutSec = 30 + maxTimeoutSec = 300 + maxOutput = 8000 // caractères de stdout/stderr renvoyés + readMax = 100000 // caractères max d'une lecture de fichier +) + +// Config persiste l'appairage et les préférences locales du poste. +// +// L'identité du poste = sa paire de clés X25519 (Priv/Pub). Le canal vers l'agent +// est chiffré de bout en bout (relais aveugle). AgentPub est la clé publique de +// l'agent (fournie à l'install, hors-bande, via la commande d'appairage) : le +// poste scelle vers elle et en dérive la clé de canal. MachineID, s'il est +// renseigné, fait passer par le relais ajean.link (/node//). +type Config struct { + ServerURL string `json:"server_url"` + ID string `json:"id"` + Priv string `json:"priv"` // clé privée X25519 du poste (hex) — LE secret + Pub string `json:"pub"` // clé publique X25519 du poste (hex) + AgentPub string `json:"agent_pub"` + MachineID string `json:"machine_id,omitempty"` // non vide = passer par le relais + Name string `json:"name"` + Caps []string `json:"caps"` + Root string `json:"root"` + AutoYes bool `json:"auto_yes"` +} + +// baseURL est la racine à laquelle joindre l'agent : directe (LAN) ou via le +// relais ajean.link quand un MachineID est configuré. +func (c Config) baseURL() string { + u := strings.TrimRight(c.ServerURL, "/") + if c.MachineID != "" { + u += "/node/" + c.MachineID + } + return u +} + +// dataDir est le dossier de données du poste. Sur Windows : C:\ProgramData\ +// loki-remote — MACHINE-WIDE, pour que le service (qui tourne en LocalSystem) lise +// la MÊME config que celle écrite par `install` (le profil utilisateur de +// LocalSystem est différent du tien). Ailleurs : dossier de config utilisateur. +func dataDir() string { + if runtime.GOOS == "windows" { + if pd := os.Getenv("ProgramData"); pd != "" { + return filepath.Join(pd, "loki-remote") + } + } + dir, err := os.UserConfigDir() + if err != nil || dir == "" { + dir, _ = os.Getwd() + } + return filepath.Join(dir, "loki-remote") +} + +// ConfigPath renvoie le chemin du fichier de configuration. +func ConfigPath() string { return filepath.Join(dataDir(), "config.json") } + +// InstalledExePath est l'emplacement stable où `install` copie le binaire, pour +// qu'il survive à la suppression du téléchargement. +func InstalledExePath() string { + name := "loki-remote" + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(dataDir(), name) +} + +// DataDir est exporté pour les installateurs par plateforme. +func DataDir() string { return dataDir() } + +// SanitizeCaps filtre une liste de capacités (réexport de nodewire pour la CLI). +func SanitizeCaps(caps []string) []string { return nodewire.SanitizeCaps(caps) } + +func LoadConfig() (Config, error) { + var c Config + b, err := os.ReadFile(ConfigPath()) + if err != nil { + return c, err + } + return c, json.Unmarshal(b, &c) +} + +func SaveConfig(c Config) error { + p := ConfigPath() + if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil { + return err + } + b, _ := json.MarshalIndent(c, "", " ") + return os.WriteFile(p, b, 0o600) // 0600 : la clé d'appareil est un secret +} + +// Enroll appaire le poste : génère sa paire de clés, SCELLE {pub, code, name, os} +// vers la clé publique de l'agent (le relais ne voit rien), et l'envoie. La clé +// privée ne quitte JAMAIS le poste. +func Enroll(cfg *Config, code string) error { + if cfg.AgentPub == "" { + return errors.New("clé publique de l'agent requise (--key, fournie par la commande d'appairage)") + } + priv, pub, err := nodewire.GenKeyPair() + if err != nil { + return err + } + name, _ := os.Hostname() + inner, _ := json.Marshal(map[string]string{ + "pub": pub, "code": code, "name": name, "os": runtime.GOOS + "/" + runtime.GOARCH, + }) + blob, err := nodewire.SealTo(cfg.AgentPub, inner) + if err != nil { + return fmt.Errorf("scellement: %w", err) + } + body, _ := json.Marshal(map[string]string{"sealed": base64.StdEncoding.EncodeToString(blob)}) + endpoint := cfg.baseURL() + "/api/node/enroll" + req, _ := http.NewRequest("POST", endpoint, strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req) + if err != nil { + return fmt.Errorf("appairage: %w", err) + } + defer resp.Body.Close() + var out struct { + OK bool `json:"ok"` + ID string `json:"id"` + Caps []string `json:"caps"` + Root string `json:"root"` + Name string `json:"name"` + Err string `json:"error"` + } + _ = json.NewDecoder(resp.Body).Decode(&out) + if !out.OK { + if out.Err == "" { + out.Err = "réponse inattendue du serveur (" + resp.Status + ")" + } + return errors.New(out.Err) + } + cfg.Priv, cfg.Pub, cfg.ID, cfg.Caps = priv, pub, out.ID, out.Caps + if out.Root != "" { + cfg.Root = out.Root + } + if out.Name != "" { + cfg.Name = out.Name + } + return nil +} + +// Run maintient la connexion pour toujours (reconnexion auto). quiet coupe les +// impressions (mode service). Bloque jusqu'à ctx.Done(). +func Run(ctx context.Context, cfg Config, quiet bool) { + backoff := time.Second + for { + if ctx.Err() != nil { + return + } + err := session(ctx, cfg, quiet) + if err != nil && !quiet { + fmt.Printf("[node] session terminée: %v — reconnexion dans %s\n", err, backoff) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + if backoff < 30*time.Second { + backoff *= 2 + } + } +} + +func wsURL(cfg Config) string { + s := cfg.baseURL() + switch { + case strings.HasPrefix(s, "https://"): + s = "wss://" + strings.TrimPrefix(s, "https://") + case strings.HasPrefix(s, "http://"): + s = "ws://" + strings.TrimPrefix(s, "http://") + case !strings.Contains(s, "://"): + s = "wss://" + s + } + return s + "/api/node/ws" +} + +func session(ctx context.Context, cfg Config, quiet bool) error { + dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + c, _, err := websocket.Dial(dialCtx, wsURL(cfg), nil) + if err != nil { + return fmt.Errorf("connexion: %w", err) + } + c.SetReadLimit(-1) + defer c.CloseNow() + + // Poignée de main : 1er frame EN CLAIR = notre clé publique (elle est publique). + if err := wsjson.Write(dialCtx, c, map[string]string{"type": "hello_pub", "pub": cfg.Pub}); err != nil { + return fmt.Errorf("hello_pub: %w", err) + } + // Canal chiffré : K = ECDH(notre priv, clé publique agent). Le relais ne l'a pas. + key, err := nodewire.ChannelKey(cfg.Priv, cfg.AgentPub) + if err != nil { + return fmt.Errorf("canal: %w", err) + } + ch, err := nodewire.NewChan(key, true) // poste = côté d'envoi 1 + if err != nil { + return err + } + sendEnc := func(m nodewire.Msg) error { + raw, _ := json.Marshal(m) + return wsjson.Write(ctx, c, ch.Seal(raw)) + } + // 1er frame CHIFFRÉ : le hello (nom/os/capacités). Prouve qu'on a la bonne clé. + if err := sendEnc(nodewire.Msg{Type: "hello", Name: cfg.Name, OS: runtime.GOOS + "/" + runtime.GOARCH, Caps: cfg.Caps}); err != nil { + return fmt.Errorf("hello: %w", err) + } + if !quiet { + fmt.Printf("[node] connecté ✓ (canal chiffré, en attente des demandes de l'IA)\n") + } + for { + var fr nodewire.Frame + if err := wsjson.Read(ctx, c, &fr); err != nil { + return err + } + plain, err := ch.Open(fr) + if err != nil { + return fmt.Errorf("déchiffrement: %w", err) + } + var m nodewire.Msg + if err := json.Unmarshal(plain, &m); err != nil { + return err + } + if m.Type != "call" { + continue + } + result := execute(ctx, cfg, m, quiet) + if err := sendEnc(nodewire.Msg{Type: "result", ID: m.ID, Result: result}); err != nil { + return err + } + } +} + +// execute applique les gardes locales puis exécute la demande. +func execute(ctx context.Context, cfg Config, m nodewire.Msg, quiet bool) string { + if !nodewire.CapAllowed(cfg.Caps, m.Cap) { + return "[refusé] la capacité « " + m.Cap + " » n'est pas activée sur ce poste" + } + if !cfg.AutoYes && (m.Cap == nodewire.CapShell || m.Cap == nodewire.CapWrite) { + if !confirm(m, quiet) { + return "[refusé] l'utilisateur du poste a refusé cette action" + } + } + switch m.Cap { + case nodewire.CapShell: + command, _ := m.Args["command"].(string) + to := 0 + if v, ok := m.Args["timeout"].(float64); ok { + to = int(v) + } + return runShell(ctx, command, to, cfg.Root) + case nodewire.CapRead: + path, _ := m.Args["path"].(string) + return readFile(cfg.Root, path) + case nodewire.CapWrite: + path, _ := m.Args["path"].(string) + content, _ := m.Args["content"].(string) + return writeFile(cfg.Root, path, content) + case nodewire.CapList: + path, _ := m.Args["path"].(string) + return listDir(cfg.Root, path) + } + return "[erreur] capacité inconnue: " + m.Cap +} + +// confirm demande validation sur le terminal du poste. Sans terminal (service) +// et sans --yes → REFUSE : mieux vaut bloquer qu'exécuter sans surveillance. +func confirm(m nodewire.Msg, quiet bool) bool { + fi, _ := os.Stdin.Stat() + interactive := fi != nil && (fi.Mode()&os.ModeCharDevice) != 0 + if !interactive || quiet { + return false + } + label := "" + if c, ok := m.Args["command"].(string); ok { + label = c + } else if p, ok := m.Args["path"].(string); ok { + label = p + } + fmt.Printf("\n[node] l'IA veut %s sur ce poste :\n %s\nAutoriser ? [y/N] ", nodewire.CapLabel(m.Cap), label) + sc := bufio.NewScanner(os.Stdin) + if sc.Scan() { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "y") + } + return false +} + +func runShell(parent context.Context, command string, timeoutSec int, dir string) string { + if strings.TrimSpace(command) == "" { + return "[erreur] commande vide" + } + if timeoutSec <= 0 { + timeoutSec = defaultTimeoutSec + } + if timeoutSec > maxTimeoutSec { + timeoutSec = maxTimeoutSec + } + ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second) + defer cancel() + cmd := shellCmd(ctx, command) + if dir != "" { + if err := os.MkdirAll(dir, 0o755); err == nil { + cmd.Dir = dir + } + } + var stdout, stderr strings.Builder + cmd.Stdout = &stdout + cmd.Stderr = &stderr + cmd.WaitDelay = 2 * time.Second + err := cmd.Run() + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return fmt.Sprintf("[timeout après %ds]", timeoutSec) + } + exit := 0 + if err != nil { + if ee, ok := err.(interface{ ExitCode() int }); ok { + exit = ee.ExitCode() + } else { + return fmt.Sprintf("[erreur: %v]", err) + } + } + parts := []string{fmt.Sprintf("exit: %d", exit)} + if o := tail(stdout.String(), maxOutput); o != "" { + parts = append(parts, "stdout:\n"+o) + } + if e := tail(stderr.String(), maxOutput); e != "" { + parts = append(parts, "stderr:\n"+e) + } + return strings.Join(parts, "\n\n") +} + +func readFile(root, path string) string { + abs, err := nodewire.ResolvePath(root, path) + if err != nil { + return "[erreur] " + err.Error() + } + f, err := os.Open(abs) + if err != nil { + return "[erreur] " + err.Error() + } + defer f.Close() + b, err := io.ReadAll(io.LimitReader(f, readMax+1)) + if err != nil { + return "[erreur] " + err.Error() + } + if len(b) > readMax { + return string(b[:readMax]) + "\n…[tronqué]" + } + return string(b) +} + +func writeFile(root, path, content string) string { + abs, err := nodewire.ResolvePath(root, path) + if err != nil { + return "[erreur] " + err.Error() + } + if d := filepath.Dir(abs); d != "" { + if err := os.MkdirAll(d, 0o755); err != nil { + return "[erreur] " + err.Error() + } + } + if err := os.WriteFile(abs, []byte(content), 0o644); err != nil { + return "[erreur] " + err.Error() + } + return fmt.Sprintf("[ok] %s écrit (%d octets)", abs, len(content)) +} + +func listDir(root, path string) string { + if strings.TrimSpace(path) == "" { + path = "." + } + abs, err := nodewire.ResolvePath(root, path) + if err != nil { + return "[erreur] " + err.Error() + } + entries, err := os.ReadDir(abs) + if err != nil { + return "[erreur] " + err.Error() + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + n := e.Name() + if e.IsDir() { + n += "/" + } + names = append(names, n) + } + sort.Strings(names) + if len(names) == 0 { + return "[vide]" + } + return strings.Join(names, "\n") +} + +func tail(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[len(r)-n:]) +} diff --git a/internal/nodeclient/install_other.go b/internal/nodeclient/install_other.go new file mode 100644 index 0000000..0adb619 --- /dev/null +++ b/internal/nodeclient/install_other.go @@ -0,0 +1,109 @@ +//go:build !windows + +package nodeclient + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// RunService : hors Windows, pas de gestionnaire de services intégré — on tourne +// simplement en avant-plan (le service est géré par systemd/launchd, cf Install). +func RunService(cfg Config) error { + Run(context.Background(), cfg, false) + return nil +} + +// Install installe un service de démarrage automatique : systemd --user (Linux) +// ou launchd (macOS). Le binaire est copié à un emplacement stable. +func Install() error { + self, err := os.Executable() + if err != nil { + return err + } + dst := InstalledExePath() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + if abs, _ := filepath.Abs(self); abs != dst { + if err := copyFile(self, dst); err != nil { + return fmt.Errorf("copie du binaire: %w", err) + } + } + switch runtime.GOOS { + case "linux": + return installSystemd(dst) + case "darwin": + return installLaunchd(dst) + } + return fmt.Errorf("installation auto non gérée sur %s — lance « loki remote connect » via ton propre gestionnaire de démarrage", runtime.GOOS) +} + +func Uninstall() error { + switch runtime.GOOS { + case "linux": + _ = exec.Command("systemctl", "--user", "disable", "--now", "loki-remote.service").Run() + return os.Remove(filepath.Join(os.Getenv("HOME"), ".config/systemd/user/loki-remote.service")) + case "darwin": + p := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents/link.loki.remote.plist") + _ = exec.Command("launchctl", "unload", p).Run() + return os.Remove(p) + } + return fmt.Errorf("désinstallation non gérée sur %s", runtime.GOOS) +} + +func installSystemd(exe string) error { + unit := "[Unit]\nDescription=Loki poste distant\nAfter=network-online.target\n\n" + + "[Service]\nExecStart=" + exe + " remote run\nRestart=always\nRestartSec=5\n\n" + + "[Install]\nWantedBy=default.target\n" + dir := filepath.Join(os.Getenv("HOME"), ".config/systemd/user") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(dir, "loki-remote.service"), []byte(unit), 0o644); err != nil { + return err + } + _ = exec.Command("systemctl", "--user", "daemon-reload").Run() + if err := exec.Command("systemctl", "--user", "enable", "--now", "loki-remote.service").Run(); err != nil { + return fmt.Errorf("systemctl --user enable: %w (astuce : « loginctl enable-linger %s » pour survivre à la déconnexion)", err, os.Getenv("USER")) + } + return nil +} + +func installLaunchd(exe string) error { + plist := ` + + + Labellink.loki.remote + ProgramArguments` + exe + `remoterun + RunAtLoad + KeepAlive + +` + dir := filepath.Join(os.Getenv("HOME"), "Library/LaunchAgents") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + p := filepath.Join(dir, "link.loki.remote.plist") + if err := os.WriteFile(p, []byte(plist), 0o644); err != nil { + return err + } + return exec.Command("launchctl", "load", p).Run() +} + +func copyFile(src, dst string) error { + b, err := os.ReadFile(src) + if err != nil { + return err + } + // Ré-installation : Linux refuse d'ÉCRASER l'image d'un binaire en cours + // (ETXTBSY « text file busy ») mais autorise à le DÉLIER — le service vivant + // garde son inode ouvert, et le nouveau fichier prend la place du nom. Sans ce + // unlink, « loki remote install » relancé échouait tant que le service tournait. + _ = os.Remove(dst) + return os.WriteFile(dst, b, 0o755) +} diff --git a/internal/nodeclient/install_windows.go b/internal/nodeclient/install_windows.go new file mode 100644 index 0000000..8e47f94 --- /dev/null +++ b/internal/nodeclient/install_windows.go @@ -0,0 +1,158 @@ +package nodeclient + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +const serviceName = "LokiRemote" + +// RunService lance la boucle client : en vrai service Windows si on est démarré +// par le gestionnaire de services, sinon en avant-plan (utile pour `run` lancé +// à la main). +func RunService(cfg Config) error { + isSvc, err := svc.IsWindowsService() + if err != nil { + return err + } + if !isSvc { + Run(context.Background(), cfg, false) + return nil + } + return svc.Run(serviceName, &handler{cfg: cfg}) +} + +type handler struct{ cfg Config } + +func (h *handler) Execute(_ []string, r <-chan svc.ChangeRequest, s chan<- svc.Status) (bool, uint32) { + const accepted = svc.AcceptStop | svc.AcceptShutdown + s <- svc.Status{State: svc.StartPending} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go Run(ctx, h.cfg, true) // quiet : un service n'a pas de console + s <- svc.Status{State: svc.Running, Accepts: accepted} + for c := range r { + switch c.Cmd { + case svc.Interrogate: + s <- c.CurrentStatus + case svc.Stop, svc.Shutdown: + s <- svc.Status{State: svc.StopPending} + return false, 0 + } + } + return false, 0 +} + +// Install copie le binaire à un emplacement stable, crée le service (démarrage +// automatique) et le lance. Nécessite les droits administrateur. +func Install() error { + self, err := os.Executable() + if err != nil { + return err + } + dst := InstalledExePath() + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + // Ré-installation : un service LokiRemote déjà présent tient loki-remote.exe + // OUVERT (verrou Windows sur l'image d'un processus en cours) → la copie + // échouerait avec « used by another process » et CreateService avec « déjà + // installé ». On le retire d'abord (best-effort : absent = rien à faire), ce qui + // arrête le service et libère le fichier, rendant « loki remote install » + // ré-exécutable tel quel — sans imposer un « uninstall » manuel préalable. + _ = Uninstall() + if abs, _ := filepath.Abs(self); abs != dst { + if err := replaceExe(self, dst); err != nil { + return fmt.Errorf("copie du binaire: %w", err) + } + } + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("gestionnaire de services (droits admin requis ?): %w", err) + } + defer m.Disconnect() + s, err := m.CreateService(serviceName, dst, mgr.Config{ + StartType: mgr.StartAutomatic, + DisplayName: "Loki — poste distant", + Description: "Pont léger permettant à votre serveur IA Loki d'agir sur ce PC.", + }, "remote", "run") + if err != nil { + return fmt.Errorf("création du service: %w", err) + } + defer s.Close() + if err := s.Start(); err != nil { + return fmt.Errorf("service créé mais démarrage échoué: %w", err) + } + return nil +} + +// Uninstall arrête et supprime le service. +func Uninstall() error { + m, err := mgr.Connect() + if err != nil { + return err + } + defer m.Disconnect() + s, err := m.OpenService(serviceName) + if err != nil { + return fmt.Errorf("service non installé") + } + defer s.Close() + _, _ = s.Control(svc.Stop) + time.Sleep(700 * time.Millisecond) + return s.Delete() +} + +// replaceExe copie src vers dst, y compris quand dst est un exécutable ENCORE en +// cours. Windows refuse d'écraser l'image d'un processus vivant mais accepte de la +// RENOMMER : on décale l'ancien fichier puis on écrit le nouveau à sa place. Utile +// juste après un Uninstall(), le temps que le service en cours d'arrêt relâche le +// fichier — et en dernier recours si un autre processus le tient encore. +func replaceExe(src, dst string) error { + if err := copyFile(src, dst); err == nil { + return nil + } + // Nom unique : un « .old » figé par un ancien remplacement ne doit pas bloquer + // celui-ci. Ces reliquats sont balayés au passage. + for _, old := range oldBinaries(dst) { + _ = os.Remove(old) + } + aside := fmt.Sprintf("%s.old-%d", dst, time.Now().UnixNano()) + if err := os.Rename(dst, aside); err != nil { + return err // ni écrasable ni renommable + } + if err := copyFile(src, dst); err != nil { + _ = os.Rename(aside, dst) // rien ne doit disparaître + return err + } + _ = os.Remove(aside) // échoue tant que l'ancien tourne ; nettoyé au prochain coup + return nil +} + +// oldBinaries liste les reliquats « .old-* » d'un remplacement précédent. +func oldBinaries(dst string) []string { + m, _ := filepath.Glob(dst + ".old-*") + return m +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o755) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, in) + return err +} diff --git a/internal/nodeclient/shell_other.go b/internal/nodeclient/shell_other.go new file mode 100644 index 0000000..bfea77d --- /dev/null +++ b/internal/nodeclient/shell_other.go @@ -0,0 +1,13 @@ +//go:build !windows + +package nodeclient + +import ( + "context" + "os/exec" +) + +// shellCmd construit la commande shell hors Windows : bash -c. +func shellCmd(ctx context.Context, command string) *exec.Cmd { + return exec.CommandContext(ctx, "bash", "-c", command) +} diff --git a/internal/nodeclient/shell_windows.go b/internal/nodeclient/shell_windows.go new file mode 100644 index 0000000..14d8c46 --- /dev/null +++ b/internal/nodeclient/shell_windows.go @@ -0,0 +1,30 @@ +package nodeclient + +import ( + "context" + "os/exec" + "syscall" +) + +// shellCmd construit la commande shell sur Windows : cmd.exe, fenêtre cachée. +// +// ⚠️ On passe la ligne de commande BRUTE via CmdLine plutôt que par Args. Sinon +// Go ré-échappe les guillemets internes (EscapeArg) et cmd.exe reçoit une ligne +// mal formée : `dir "C:\Users\X"` renvoyait « syntaxe du nom de fichier +// incorrecte », alors que la même commande sans guillemets marchait. Avec +// `cmd /S /C ""`, l'option /S dit à cmd de ne retirer QUE le premier et +// le dernier guillemet et d'exécuter le reste tel quel — les chemins entre +// guillemets (avec espaces) passent alors correctement. +func shellCmd(ctx context.Context, command string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "cmd") + // `chcp 65001>nul &` force la sortie de cmd en UTF-8. Sinon cmd émet dans la + // codepage OEM (CP850 en FR) : les accents (« Répertoire », « numéro ») et le + // séparateur de milliers arrivaient en octets non-UTF-8 → rendus « � » une fois + // sérialisés en JSON. Avec /S, cmd ne retire que le 1er et le dernier guillemet, + // donc les guillemets internes de la commande passent intacts. + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CmdLine: `cmd /S /C "chcp 65001>nul & ` + command + `"`, + } + return cmd +} diff --git a/internal/nodeclient/shell_windows_test.go b/internal/nodeclient/shell_windows_test.go new file mode 100644 index 0000000..817c433 --- /dev/null +++ b/internal/nodeclient/shell_windows_test.go @@ -0,0 +1,32 @@ +package nodeclient + +import ( + "context" + "strings" + "testing" +) + +// Un chemin ENTRE GUILLEMETS doit fonctionner (régression : Go ré-échappait les +// guillemets et cmd renvoyait « syntaxe du nom de fichier incorrecte »). +func TestRunShellQuotedPath(t *testing.T) { + out := runShell(context.Background(), `dir "C:\Windows" /b`, 10, "") + if !strings.Contains(out, "exit: 0") { + t.Fatalf("commande avec chemin entre guillemets a échoué:\n%s", out) + } + if !strings.Contains(strings.ToLower(out), "system32") { + t.Fatalf("sortie inattendue (System32 absent):\n%s", out) + } + // La sortie doit être de l'UTF-8 propre : pas de caractère de remplacement + // U+FFFD (le « � » qui apparaissait avec la codepage OEM). + if strings.ContainsRune(out, '\uFFFD') { + t.Fatalf("la sortie contient des caractères mal encodés (�):\n%s", out) + } +} + +// L'accent de « Répertoire » doit être correct (validation de chcp 65001). +func TestRunShellUTF8Accents(t *testing.T) { + out := runShell(context.Background(), `dir C:\Windows`, 10, "") + if strings.ContainsRune(out, '\uFFFD') { + t.Fatalf("caractères mal encodés dans la sortie:\n%s", out) + } +} diff --git a/internal/nodewire/crypto.go b/internal/nodewire/crypto.go new file mode 100644 index 0000000..18e752c --- /dev/null +++ b/internal/nodewire/crypto.go @@ -0,0 +1,201 @@ +package nodewire + +// crypto.go — chiffrement de bout en bout du canal poste↔agent, pour que le +// relais ajean.link ne voie que de l'opaque (« relais aveugle »), exactement +// comme le canal navigateur↔agent. +// +// Deux briques, toutes en stdlib (crypto/ecdh X25519 + AES-GCM) : +// - un SCEAU anonyme vers la clé publique de l'agent (enrôlement) : format +// IDENTIQUE à e2eSeal/e2eOpenSeal de l'agent (ephPub32 || nonce12 || ct, +// clé = SHA256(shared || ephPub || agentPub)) — donc l'agent ouvre le sceau +// du poste avec son code existant, sans dupliquer la crypto ; +// - un CANAL authentifié à clés statiques : K = SHA256(ECDH(postePriv, +// agentPub) || "loki-node-chan-v1"). Le poste et l'agent le calculent tous +// les deux ; le relais, qui n'a aucune des deux clés privées, non. Chaque +// message est scellé AES-GCM avec un nonce à compteur (anti-rejeu / anti- +// réordonnancement à l'intérieur d'une connexion). + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/rand" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" +) + +// GenKeyPair génère une paire X25519 et la renvoie en hex (priv, pub). +func GenKeyPair() (privHex, pubHex string, err error) { + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return "", "", err + } + return hex.EncodeToString(k.Bytes()), hex.EncodeToString(k.PublicKey().Bytes()), nil +} + +// PubFromPriv recalcule la clé publique hex à partir de la privée hex. +func PubFromPriv(privHex string) (string, error) { + raw, err := hex.DecodeString(privHex) + if err != nil { + return "", err + } + k, err := ecdh.X25519().NewPrivateKey(raw) + if err != nil { + return "", err + } + return hex.EncodeToString(k.PublicKey().Bytes()), nil +} + +func newGCM(key []byte) (cipher.AEAD, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +// SealTo scelle plaintext vers recipientPubHex (clé publique de l'agent). Le +// format est celui qu'ouvre e2eOpenSeal de l'agent : ephPub32 || nonce12 || ct. +func SealTo(recipientPubHex string, plaintext []byte) ([]byte, error) { + recBytes, err := hex.DecodeString(recipientPubHex) + if err != nil { + return nil, err + } + recPub, err := ecdh.X25519().NewPublicKey(recBytes) + if err != nil { + return nil, err + } + eph, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + return nil, err + } + shared, err := eph.ECDH(recPub) + if err != nil { + return nil, err + } + key := sealKey(shared, eph.PublicKey().Bytes(), recBytes) + gcm, err := newGCM(key) + if err != nil { + return nil, err + } + nonce := make([]byte, 12) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + ct := gcm.Seal(nil, nonce, plaintext, nil) + out := make([]byte, 0, 32+12+len(ct)) + out = append(out, eph.PublicKey().Bytes()...) + out = append(out, nonce...) + out = append(out, ct...) + return out, nil +} + +// sealKey — identique à sealKey de l'agent (relay_e2e.go). Ne pas modifier sans +// changer les deux : c'est ce qui garantit que l'agent ouvre le sceau du poste. +func sealKey(shared, ephPub, recipientPub []byte) []byte { + h := sha256.New() + h.Write(shared) + h.Write(ephPub) + h.Write(recipientPub) + return h.Sum(nil) +} + +// ChannelKey dérive la clé du canal poste↔agent à partir de la clé PRIVÉE (hex) +// d'un bout et de la clé PUBLIQUE (hex) de l'autre. Symétrique : les deux bouts +// obtiennent le même secret. +func ChannelKey(privHex, peerPubHex string) ([]byte, error) { + pr, err := hex.DecodeString(privHex) + if err != nil { + return nil, err + } + priv, err := ecdh.X25519().NewPrivateKey(pr) + if err != nil { + return nil, err + } + pb, err := hex.DecodeString(peerPubHex) + if err != nil { + return nil, err + } + peer, err := ecdh.X25519().NewPublicKey(pb) + if err != nil { + return nil, err + } + ss, err := priv.ECDH(peer) + if err != nil { + return nil, err + } + h := sha256.New() + h.Write(ss) + h.Write([]byte("loki-node-chan-v1")) + return h.Sum(nil), nil +} + +// Frame est un message chiffré du canal : nonce (side||compteur) + ciphertext. +type Frame struct { + N []byte `json:"n"` + C []byte `json:"c"` +} + +// Chan chiffre/déchiffre les messages d'un canal, avec des nonces à compteur +// pour interdire rejeu et réordonnancement DANS la connexion. Chaque bout a un +// « côté » (1 octet) distinct → les nonces des deux sens ne se croisent jamais. +type Chan struct { + gcm cipher.AEAD + sendSide byte + recvSide byte + sendCtr uint64 + recvNext uint64 +} + +// NewChan construit un canal à partir de la clé partagée. initiator=true pour le +// POSTE (côté d'envoi 1), false pour l'AGENT (côté d'envoi 2). Les côtés doivent +// être opposés des deux bouts, sinon les nonces entreraient en collision. +func NewChan(key []byte, initiator bool) (*Chan, error) { + gcm, err := newGCM(key) + if err != nil { + return nil, err + } + c := &Chan{gcm: gcm} + if initiator { + c.sendSide, c.recvSide = 1, 2 + } else { + c.sendSide, c.recvSide = 2, 1 + } + return c, nil +} + +func (c *Chan) nonce(side byte, ctr uint64) []byte { + n := make([]byte, 12) + n[0] = side + binary.BigEndian.PutUint64(n[1:9], ctr) + return n +} + +// Seal chiffre un message sortant. +func (c *Chan) Seal(plaintext []byte) Frame { + n := c.nonce(c.sendSide, c.sendCtr) + ct := c.gcm.Seal(nil, n, plaintext, nil) + c.sendCtr++ + return Frame{N: n, C: ct} +} + +// Open déchiffre un message entrant et vérifie l'ordre (compteur strictement +// croissant, bon côté) — un frame rejoué ou réordonné par le relais est rejeté. +func (c *Chan) Open(f Frame) ([]byte, error) { + if len(f.N) != 12 || f.N[0] != c.recvSide { + return nil, errors.New("nonce invalide") + } + ctr := binary.BigEndian.Uint64(f.N[1:9]) + if ctr < c.recvNext { + return nil, fmt.Errorf("rejeu détecté (compteur %d < %d)", ctr, c.recvNext) + } + plain, err := c.gcm.Open(nil, f.N, f.C, nil) + if err != nil { + return nil, errors.New("authentification échouée") + } + c.recvNext = ctr + 1 + return plain, nil +} diff --git a/internal/nodewire/crypto_test.go b/internal/nodewire/crypto_test.go new file mode 100644 index 0000000..44ea451 --- /dev/null +++ b/internal/nodewire/crypto_test.go @@ -0,0 +1,63 @@ +package nodewire + +import ( + "bytes" + "testing" +) + +// La clé de canal doit être IDENTIQUE des deux bouts (poste priv + agent pub = +// agent priv + poste pub). +func TestChannelKeySymmetric(t *testing.T) { + aPriv, aPub, _ := GenKeyPair() + bPriv, bPub, _ := GenKeyPair() + ka, err := ChannelKey(aPriv, bPub) + if err != nil { + t.Fatal(err) + } + kb, err := ChannelKey(bPriv, aPub) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(ka, kb) { + t.Fatal("les deux bouts n'obtiennent pas la même clé de canal") + } +} + +// Un aller-retour chiffré passe ; un frame rejoué est rejeté. +func TestChanRoundTripAndReplay(t *testing.T) { + aPriv, aPub, _ := GenKeyPair() + bPriv, bPub, _ := GenKeyPair() + ka, _ := ChannelKey(aPriv, bPub) // poste + kb, _ := ChannelKey(bPriv, aPub) // agent + + poste, _ := NewChan(ka, true) + agent, _ := NewChan(kb, false) + + f := poste.Seal([]byte("dir C:\\")) + got, err := agent.Open(f) + if err != nil || string(got) != "dir C:\\" { + t.Fatalf("aller-retour: %q err=%v", got, err) + } + // Rejeu du même frame → rejeté (compteur non croissant). + if _, err := agent.Open(f); err == nil { + t.Fatal("un frame rejoué aurait dû être rejeté") + } + // Sens inverse. + f2 := agent.Seal([]byte("exit: 0")) + got2, err := poste.Open(f2) + if err != nil || string(got2) != "exit: 0" { + t.Fatalf("retour: %q err=%v", got2, err) + } +} + +// SealTo doit produire le format ephPub32||nonce12||ct que l'agent sait ouvrir. +func TestSealToFormat(t *testing.T) { + _, pub, _ := GenKeyPair() + blob, err := SealTo(pub, []byte("secret")) + if err != nil { + t.Fatal(err) + } + if len(blob) < 32+12+16 { + t.Fatalf("sceau trop court: %d octets", len(blob)) + } +} diff --git a/internal/nodewire/wire.go b/internal/nodewire/wire.go new file mode 100644 index 0000000..f5c5935 --- /dev/null +++ b/internal/nodewire/wire.go @@ -0,0 +1,118 @@ +// Package nodewire porte le protocole PARTAGÉ entre le serveur loki et le +// client léger « poste distant » (loki-node). Zéro dépendance hors stdlib : +// c'est ce qui permet au client de rester minuscule (il n'embarque ni l'UI, ni +// llama.cpp, ni bbolt, ni MCP — juste ce fichier + coder/websocket). +// +// Le format du fil (Msg + capacités) DOIT rester identique des deux côtés : +// centraliser ici évite toute dérive entre serveur et client. +package nodewire + +import ( + "errors" + "fmt" + "path/filepath" + "strings" +) + +// Capacités qu'un poste peut exposer. Chacune est un pouvoir concret donné à +// l'IA sur la machine distante. +const ( + CapShell = "shell" // exécuter une commande shell + CapRead = "read" // lire un fichier (confiné à la racine) + CapWrite = "write" // écrire un fichier (confiné à la racine) + CapList = "list" // lister un dossier (confiné à la racine) +) + +// AllCaps est l'ordre canonique d'affichage/itération. +var AllCaps = []string{CapShell, CapRead, CapWrite, CapList} + +// CapLabel donne un libellé court en français. +func CapLabel(cap string) string { + switch cap { + case CapShell: + return "shell" + case CapRead: + return "lire des fichiers" + case CapWrite: + return "écrire des fichiers" + case CapList: + return "lister des dossiers" + } + return cap +} + +// Msg est l'unique enveloppe échangée sur le WebSocket, dans les deux sens. +// - client→serveur au démarrage : {type:"hello", name, os, caps:[…]} +// - serveur→client : {type:"call", id, cap, args} +// - client→serveur : {type:"result", id, result} +type Msg struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Cap string `json:"cap,omitempty"` + Args map[string]any `json:"args,omitempty"` + Result string `json:"result,omitempty"` + Name string `json:"name,omitempty"` + OS string `json:"os,omitempty"` + Caps []string `json:"caps,omitempty"` +} + +// CapAllowed indique si cap figure dans allowed. +func CapAllowed(allowed []string, cap string) bool { + for _, c := range allowed { + if c == cap { + return true + } + } + return false +} + +// CapIntersect renvoie, dans l'ordre canonique, les capacités présentes dans les +// deux listes — le jeu EFFECTIF exposé à l'IA (propriétaire ∩ poste). +func CapIntersect(allowed, declared []string) []string { + var out []string + for _, c := range AllCaps { + if CapAllowed(allowed, c) && CapAllowed(declared, c) { + out = append(out, c) + } + } + return out +} + +// SanitizeCaps ne garde que des capacités connues, sans doublon, dans l'ordre +// canonique. Toute entrée inconnue est ignorée. +func SanitizeCaps(caps []string) []string { + var out []string + for _, c := range AllCaps { + if CapAllowed(caps, c) { + out = append(out, c) + } + } + return out +} + +// ResolvePath résout un chemin demandé À L'INTÉRIEUR de la racine autorisée et +// REFUSE tout ce qui en sortirait (traversée « .. », absolu hors racine). C'est +// le confinement des outils fichiers. +func ResolvePath(root, p string) (string, error) { + if strings.TrimSpace(root) == "" { + return "", errors.New("aucun dossier racine configuré sur ce poste") + } + if strings.TrimSpace(p) == "" { + return "", errors.New("chemin vide") + } + root = filepath.Clean(root) + var abs string + if filepath.IsAbs(p) { + abs = filepath.Clean(p) + } else { + abs = filepath.Clean(filepath.Join(root, p)) + } + rel, err := filepath.Rel(root, abs) + if err != nil { + return "", fmt.Errorf("chemin invalide: %v", err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("chemin hors du dossier autorisé (%s)", root) + } + return abs, nil +} diff --git a/staticcheck.conf b/staticcheck.conf new file mode 100644 index 0000000..9b77ab6 --- /dev/null +++ b/staticcheck.conf @@ -0,0 +1,9 @@ +# ST1005 exige des messages d'erreur en minuscule et sans ponctuation finale. +# La règle suppose des erreurs anglaises destinées à être enchâssées dans une +# phrase plus longue. Ici, une bonne partie des erreurs sont des messages +# FRANÇAIS affichés tels quels à l'utilisateur, dans le chat ou dans le +# terminal (« ⏳ Le modèle est encore en train de charger — réessaie dans +# quelques secondes. »). Les rendre conformes reviendrait à dégrader le produit +# pour satisfaire l'outil. On désactive donc la règle, plutôt que de laisser +# traîner des avertissements permanents qu'on apprendrait à ignorer. +checks = ["inherit", "-ST1005"] diff --git a/tools/assemble-ui/main.go b/tools/assemble-ui/main.go new file mode 100644 index 0000000..184470d --- /dev/null +++ b/tools/assemble-ui/main.go @@ -0,0 +1,80 @@ +// assemble-ui reconstruit internal/loki/ui/index.html à partir des sources +// internal/loki/ui/src/ (remplace l'ancien ui/assemble.ps1 — multiplateforme). +// +// index.html est un fichier GÉNÉRÉ mais committé : go:embed (web_server.go) et +// loki-app/build-server-ui.ps1 le lisent tel quel. Pour modifier l'UI : éditer +// ui/src/ (index.tmpl.html, styles.css, js/NN-*.js concaténés dans l'ordre +// alphabétique, tout en scope global) puis relancer : +// +// go generate ./internal/loki (ou : go run ./tools/assemble-ui) +// +// Les marqueurs @@CSS@@ / @@JS@@ du template sont remplacés avec LEUR fin de +// ligne (LF ou CRLF) ; chaque source apporte ses propres fins de ligne. +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +func main() { + uiDir := "internal/loki/ui" + if len(os.Args) > 1 { + uiDir = os.Args[1] + } + if err := run(uiDir); err != nil { + fmt.Fprintln(os.Stderr, "assemble-ui:", err) + os.Exit(1) + } +} + +func run(uiDir string) error { + srcDir := filepath.Join(uiDir, "src") + tmpl, err := os.ReadFile(filepath.Join(srcDir, "index.tmpl.html")) + if err != nil { + return err + } + css, err := os.ReadFile(filepath.Join(srcDir, "styles.css")) + if err != nil { + return err + } + entries, err := os.ReadDir(filepath.Join(srcDir, "js")) + if err != nil { + return err + } + var names []string + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".js") { + names = append(names, e.Name()) + } + } + sort.Strings(names) + var js []byte + for _, n := range names { + b, err := os.ReadFile(filepath.Join(srcDir, "js", n)) + if err != nil { + return err + } + js = append(js, b...) + } + + html := string(tmpl) + for marker, content := range map[string]string{"@@CSS@@": string(css), "@@JS@@": string(js)} { + re := regexp.MustCompile(regexp.QuoteMeta(marker) + "\r?\n") + if !re.MatchString(html) { + return fmt.Errorf("marqueur %s introuvable dans index.tmpl.html", marker) + } + html = re.ReplaceAllStringFunc(html, func(string) string { return content }) + } + + dst := filepath.Join(uiDir, "index.html") + if err := os.WriteFile(dst, []byte(html), 0o644); err != nil { + return err + } + fmt.Printf("OK -> %s (%d octets)\n", dst, len(html)) + return nil +} diff --git a/tools/gen-icon/main.go b/tools/gen-icon/main.go new file mode 100644 index 0000000..6281207 --- /dev/null +++ b/tools/gen-icon/main.go @@ -0,0 +1,39 @@ +// gen-icon écrit cmd/loki/icon.ico à partir de l'icône de marque dessinée dans +// internal/loki (sys_brand_icon.go), pour que l'icône du .exe soit toujours la +// même que le favicon de l'UI et que celle de la zone de notification. +// +// Lancé par `go generate ./cmd/loki`, AVANT goversioninfo qui embarque le .ico +// dans les ressources Windows. +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/R0m1k3/Loki/internal/loki" +) + +func main() { + out := "icon.ico" + if len(os.Args) > 1 { + out = os.Args[1] + } + // Les tailles attendues par l'explorateur Windows, de la barre des tâches à + // l'aperçu en grandes icônes. + write(out, loki.BrandICO(16, 32, 48, 64, 128, 256)) + + // PNG 1024 pour le bundle macOS : la CI en tire le .icns via sips/iconutil. + // Elle partait auparavant du .ico, ce qui suppose que sips sache lire un ICO — + // et l'échec y est masqué par « || true », donc une app Mac sans icône passait + // inaperçue. Un PNG ne laisse aucune place au doute. + write(strings.TrimSuffix(out, ".ico")+".png", loki.BrandIconPNG(1024)) +} + +func write(path string, data []byte) { + if err := os.WriteFile(path, data, 0o644); err != nil { + fmt.Fprintln(os.Stderr, "gen-icon:", err) + os.Exit(1) + } + fmt.Println("OK ->", path) +} diff --git a/workspace/.gitkeep b/workspace/.gitkeep deleted file mode 100644 index e69de29..0000000