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..cdd8895
--- /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/ajean
+ 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/ajean
+ CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 \
+ CGO_CFLAGS="-arch x86_64" CGO_LDFLAGS="-arch x86_64" \
+ go build -o /dev/null ./cmd/ajean
diff --git a/.github/workflows/mac-build.yml b/.github/workflows/mac-build.yml
new file mode 100644
index 0000000..424bdf4
--- /dev/null
+++ b/.github/workflows/mac-build.yml
@@ -0,0 +1,52 @@
+# Build macOS à la demande, SANS rien publier : produit AJEAN.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 AJEAN.app (arm64)
+ run: |
+ mkdir -p dist
+ CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 \
+ go build -trimpath -ldflags="-s -w" -o dist/ajean-macos-arm ./cmd/ajean
+ mkdir -p /tmp/ajean.iconset
+ sips -s format png 'cmd/ajean/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/ajean.iconset/icon_${s}x${s}.png" >/dev/null 2>&1 || true
+ done
+ iconutil -c icns /tmp/ajean.iconset -o /tmp/ajean.icns >/dev/null 2>&1 || true
+ app="dist/AJEAN.app"
+ mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
+ cp dist/ajean-macos-arm "$app/Contents/MacOS/ajean"
+ chmod +x "$app/Contents/MacOS/ajean"
+ cp /tmp/ajean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true
+ printf '%s\n' \
+ '' \
+ '' \
+ '' \
+ 'CFBundleNameAJEAN' \
+ 'CFBundleDisplayNameAJEAN' \
+ 'CFBundleExecutableajean' \
+ 'CFBundleIdentifierlink.ajean.app' \
+ 'CFBundleIconFileajean' \
+ 'CFBundlePackageTypeAPPL' \
+ 'LSMinimumSystemVersion11.0' \
+ 'LSUIElement' \
+ 'NSHighResolutionCapable' \
+ '' > "$app/Contents/Info.plist"
+ codesign --force --deep -s - "$app" || true
+ (cd dist && zip -qry ajean-macos-arm.zip AJEAN.app && rm -rf AJEAN.app)
+ ls -l dist
+ - uses: actions/upload-artifact@v4
+ with:
+ name: ajean-macos-arm
+ path: dist/ajean-macos-arm.zip
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..ed84f34
--- /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 (ajean-linux, ajean-macos, ajean-windows.exe et leurs
+# variantes -arm) ET le fichier SHA256SUMS que `ajean 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 `ajean 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/ajean-macos-arm ./cmd/ajean
+ 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/ajean-macos ./cmd/ajean
+ file dist/ajean-macos*
+ - name: bundles AJEAN.app
+ run: |
+ # Un binaire nu double-cliqué ouvrirait le Terminal. On publie donc
+ # aussi un vrai bundle AJEAN.app (zippé pour garder le bit exécutable) :
+ # au clic, le Finder lance Contents/MacOS/ajean, 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 ajean.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/ajean/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/ajean.iconset
+ for s in 16 32 128 256 512; do
+ sips -z $s $s 'cmd/ajean/icon.png' --out "/tmp/ajean.iconset/icon_${s}x${s}.png" >/dev/null
+ done
+ iconutil -c icns /tmp/ajean.iconset -o /tmp/ajean.icns
+ v="${GITHUB_REF_NAME#v}"
+ for bin in ajean-macos ajean-macos-arm; do
+ app="dist/bundle-$bin/AJEAN.app"
+ mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
+ cp "dist/$bin" "$app/Contents/MacOS/ajean"
+ chmod +x "$app/Contents/MacOS/ajean"
+ cp /tmp/ajean.icns "$app/Contents/Resources/ajean.icns" 2>/dev/null || true
+ printf '%s\n' \
+ '' \
+ '' \
+ '' \
+ 'CFBundleNameAJEAN' \
+ 'CFBundleDisplayNameAJEAN' \
+ 'CFBundleExecutableajean' \
+ 'CFBundleIdentifierlink.ajean.app' \
+ 'CFBundleIconFileajean' \
+ '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" AJEAN.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 : ajean-[-arm][.exe]. Volontairement lisibles, et
+ # volontairement DIFFÉRENTS du schéma ajean-- 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:ajean-linux linux/arm64:ajean-linux-arm \
+ windows/amd64:ajean-windows.exe windows/arm64:ajean-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/ajean
+ done
+ - uses: actions/download-artifact@v4
+ with:
+ name: macos
+ path: dist
+ - name: SHA256SUMS
+ run: |
+ chmod +x dist/ajean-macos dist/ajean-macos-arm
+ # Tous les assets commencent par « ajean- », zips compris : un seul
+ # motif suffit. Ajouter « *.zip » les compterait DEUX fois, et un
+ # SHA256SUMS à doublons est exactement ce que `ajean update` vérifie.
+ (cd dist && sha256sum ajean-* > 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..3710cdb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,27 +1,15 @@
-# Python
-__pycache__/
-*.pyc
-.venv/
-venv/
-*.egg-info/
+# Binaires compilés
+/ajean
+/ajean.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/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/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/cmd/ajean/icon.ico b/cmd/ajean/icon.ico
new file mode 100644
index 0000000..febca2c
Binary files /dev/null and b/cmd/ajean/icon.ico differ
diff --git a/cmd/ajean/icon.png b/cmd/ajean/icon.png
new file mode 100644
index 0000000..597bd60
Binary files /dev/null and b/cmd/ajean/icon.png differ
diff --git a/cmd/ajean/main.go b/cmd/ajean/main.go
new file mode 100644
index 0000000..2846d3b
--- /dev/null
+++ b/cmd/ajean/main.go
@@ -0,0 +1,17 @@
+// Point d'entrée du binaire ajean. Tout le code vit dans internal/ajean ; ce
+// dossier ne porte que le main() et les ressources Windows (.syso, icône,
+// versioninfo) qui doivent résider dans le dossier du package main.
+//
+// Les directives ci-dessous embarquent les métadonnées Windows (éditeur, version,
+// description) dans le .exe pour réduire les faux positifs antivirus. Régénère
+// les .syso après avoir bumpé la version : `go generate ./...`
+// (nécessite : go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@latest)
+//
+//go:generate go run github.com/nathaninline/ajean/tools/gen-icon icon.ico
+//go:generate goversioninfo -64 -icon=icon.ico -o resource_windows_amd64.syso versioninfo.json
+//go:generate goversioninfo -64 -arm -icon=icon.ico -o resource_windows_arm64.syso versioninfo.json
+package main
+
+import "github.com/nathaninline/ajean/internal/ajean"
+
+func main() { ajean.Main() }
diff --git a/cmd/ajean/resource_windows_amd64.syso b/cmd/ajean/resource_windows_amd64.syso
new file mode 100644
index 0000000..f07048d
Binary files /dev/null and b/cmd/ajean/resource_windows_amd64.syso differ
diff --git a/cmd/ajean/resource_windows_arm64.syso b/cmd/ajean/resource_windows_arm64.syso
new file mode 100644
index 0000000..c92084b
Binary files /dev/null and b/cmd/ajean/resource_windows_arm64.syso differ
diff --git a/cmd/ajean/versioninfo.json b/cmd/ajean/versioninfo.json
new file mode 100644
index 0000000..63296b7
--- /dev/null
+++ b/cmd/ajean/versioninfo.json
@@ -0,0 +1,37 @@
+{
+ "FixedFileInfo": {
+ "FileVersion": {
+ "Major": 0,
+ "Minor": 9,
+ "Patch": 4,
+ "Build": 0
+ },
+ "ProductVersion": {
+ "Major": 0,
+ "Minor": 9,
+ "Patch": 4,
+ "Build": 0
+ },
+ "FileFlagsMask": "3f",
+ "FileFlags": "00",
+ "FileOS": "040004",
+ "FileType": "01",
+ "FileSubType": "00"
+ },
+ "StringFileInfo": {
+ "CompanyName": "AJEAN contributors",
+ "FileDescription": "AJEAN — gestionnaire mono-binaire de serveurs llama.cpp auto-hébergés",
+ "InternalName": "ajean",
+ "LegalCopyright": "Copyright (c) 2026 AJEAN contributors. MIT License.",
+ "OriginalFilename": "ajean.exe",
+ "ProductName": "AJEAN",
+ "ProductVersion": "0.9.4",
+ "Comments": "https://github.com/nathaninline/ajean — projet open source (MIT)"
+ },
+ "VarFileInfo": {
+ "Translation": {
+ "LangID": "040C",
+ "CharsetID": "04B0"
+ }
+ }
+}
\ No newline at end of file
diff --git a/docs/ui.png b/docs/ui.png
new file mode 100644
index 0000000..8204acd
Binary files /dev/null and b/docs/ui.png differ
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..39cfd71
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,51 @@
+module github.com/nathaninline/ajean
+
+go 1.25.0
+
+require (
+ codeberg.org/readeck/go-readability/v2 v2.1.2
+ github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
+ github.com/caddyserver/certmagic v0.25.4
+ github.com/coder/websocket v1.8.15
+ github.com/getlantern/systray v1.2.2
+ github.com/hashicorp/yamux v0.1.2
+ github.com/modelcontextprotocol/go-sdk v1.6.1
+ go.etcd.io/bbolt v1.5.0
+ golang.org/x/mod v0.35.0
+ golang.org/x/net v0.55.0
+)
+
+require (
+ github.com/JohannesKaufmann/dom v0.3.1 // indirect
+ github.com/andybalholm/cascadia v1.3.4 // indirect
+ github.com/caddyserver/zerossl v0.1.5 // indirect
+ github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
+ github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
+ github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
+ github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
+ github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
+ github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
+ github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
+ github.com/go-stack/stack v1.8.0 // indirect
+ github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
+ github.com/google/jsonschema-go v0.4.3 // indirect
+ github.com/itlightning/dateparse v0.2.1 // indirect
+ github.com/klauspost/cpuid/v2 v2.3.0 // indirect
+ github.com/libdns/libdns v1.1.1 // indirect
+ github.com/mholt/acmez/v3 v3.1.6 // indirect
+ github.com/miekg/dns v1.1.72 // indirect
+ github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
+ github.com/segmentio/asm v1.1.3 // indirect
+ github.com/segmentio/encoding v0.5.4 // indirect
+ github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
+ github.com/zeebo/blake3 v0.2.4 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ go.uber.org/zap v1.27.1 // indirect
+ go.uber.org/zap/exp v0.3.0 // indirect
+ golang.org/x/crypto v0.51.0 // indirect
+ golang.org/x/oauth2 v0.35.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ golang.org/x/tools v0.44.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b8ded5f
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,125 @@
+code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
+code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
+codeberg.org/readeck/go-readability/v2 v2.1.2 h1:JBrdyYJBRPMBbodLM1b5KxCSDH+JqCkGcuVRD7ICBAw=
+codeberg.org/readeck/go-readability/v2 v2.1.2/go.mod h1:Ut31sW4osSrJPR3T8eQslMh4+jbwimXqn0w0ReCT+PU=
+github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PFL3qh0=
+github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
+github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
+github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
+github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
+github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
+github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
+github.com/caddyserver/certmagic v0.25.4/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA=
+github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
+github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
+github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
+github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
+github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
+github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
+github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
+github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
+github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
+github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
+github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
+github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
+github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
+github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
+github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
+github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
+github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
+github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w=
+github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM=
+github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
+github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
+github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
+github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
+github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
+github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
+github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
+github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
+github.com/itlightning/dateparse v0.2.1 h1:AB0NJTyI0HYcerEUMovKZOiQVBg1mBPxgAnWQwzLP6g=
+github.com/itlightning/dateparse v0.2.1/go.mod h1:xHlmL8lT0L9JIBlaKotRwsoDYpKJskXpiU9ZwbbSkNA=
+github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
+github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
+github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
+github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U=
+github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
+github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
+github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
+github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
+github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
+github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
+github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
+github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
+github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
+github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU=
+github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ=
+github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
+github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc=
+github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI=
+github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
+github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
+github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
+github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
+github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
+github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
+github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
+github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
+github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
+github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
+github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
+github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
+github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
+github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
+github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
+go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
+go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
+go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
+go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
+go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
+golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
+golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
+golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/ajean/backend_build.go b/internal/ajean/backend_build.go
new file mode 100644
index 0000000..53e4918
--- /dev/null
+++ b/internal/ajean/backend_build.go
@@ -0,0 +1,776 @@
+// backend_build.go — machinerie de compilation de llama.cpp : détection du
+// plan de build (CUDA/ROCm/Metal/Vulkan/CPU), cmake, suivi de progression, logs.
+package ajean
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// buildSink est un collecteur de lignes optionnel : quand il est posé (jobs
+// web, voir web_llamacpp.go), runStep/runBuildStep y dupliquent leur sortie en
+// plus du terminal / des fichiers de log. nil en usage CLI normal.
+var (
+ buildSinkMu sync.Mutex
+ buildSink func(string)
+)
+
+func setBuildSink(f func(string)) {
+ buildSinkMu.Lock()
+ buildSink = f
+ buildSinkMu.Unlock()
+}
+
+func emitBuildLine(line string) {
+ buildSinkMu.Lock()
+ f := buildSink
+ buildSinkMu.Unlock()
+ if f != nil {
+ f(line)
+ }
+}
+
+// sinkWriter découpe un flux en lignes et les pousse vers emitBuildLine.
+// Sert à téer la sortie des commandes de runStep quand un sink est actif.
+// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
+type sinkWriter struct {
+ mu sync.Mutex
+ buf []byte
+}
+
+func (s *sinkWriter) Write(p []byte) (int, error) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.buf = append(s.buf, p...)
+ for {
+ i := strings.IndexByte(string(s.buf), '\n')
+ if i < 0 {
+ break
+ }
+ emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
+ s.buf = s.buf[i+1:]
+ }
+ return len(p), nil
+}
+
+func detectBuildPlan() buildPlan {
+ p := buildPlan{backend: "cpu", jobs: numJobs()}
+ // Flags communs : Release + tuning natif pour la machine de build.
+ // (libcurl est activé d'office par llama.cpp ; LLAMA_CURL est déprécié.)
+ p.flags = []string{
+ "-DCMAKE_BUILD_TYPE=Release",
+ "-DGGML_NATIVE=ON",
+ // L'UI web embarquée de llama-server exige npm (ou un téléchargement
+ // d'assets pré-compilés depuis HuggingFace) pour générer un service-worker
+ // PWA — une dépendance lourde qui casse le build sur une machine sans node.
+ // ajean fournit sa propre UI, donc on la désactive : build plus rapide et
+ // sans dépendance réseau/npm. BUILD_UI=OFF coupe npm ; USE_PREBUILT_UI=OFF
+ // coupe le téléchargement d'assets pré-compilés depuis HuggingFace (qui
+ // échoue sur un réseau restreint et fait planter l'embed). Sur un checkout
+ // neuf le dist est vide → llama-server embarque une UI vide sans erreur.
+ // Voir scripts/ui-assets.cmake côté llama.cpp.
+ "-DLLAMA_BUILD_UI=OFF",
+ "-DLLAMA_USE_PREBUILT_UI=OFF",
+ }
+
+ // Sur Windows, le générateur CMake par défaut est « NMake Makefiles », qui
+ // suppose un Developer Command Prompt MSVC. On force le générateur Visual
+ // Studio : il localise le toolchain MSVC tout seul via le registre, sans
+ // vcvars, depuis un shell ordinaire.
+ if runtime.GOOS == "windows" {
+ p.gen = msvcGenerator()
+ p.genArch = "x64"
+ if runtime.GOARCH == "arm64" {
+ p.genArch = "ARM64"
+ }
+ }
+
+ if runtime.GOOS == "darwin" {
+ // Metal est activé par défaut sur Apple Silicon ; on l'explicite.
+ p.backend = "metal"
+ p.flags = append(p.flags, "-DGGML_METAL=ON")
+ return p
+ }
+
+ // CUDA : nvcc présent ET un GPU NVIDIA visible.
+ if nvcc := findNvcc(); nvcc != "" && hasNvidiaGPU() {
+ p.backend = "cuda"
+ p.cudaCXX = nvcc
+ // NB : on n'active PAS GGML_CUDA_FA_ALL_QUANTS — il compile les kernels
+ // Flash-Attention pour toutes les combinaisons de quant (des centaines de
+ // .cu), ce qui explose le temps de build pour un gain d'inférence marginal.
+ p.flags = append(p.flags, "-DGGML_CUDA=ON", "-DGGML_CUDA_F16=ON")
+ // Racine du toolkit explicite : sans elle, CMake la déduit du chemin de
+ // nvcc. Avec un nvcc hors toolkit (/usr/bin/nvcc, paquet Ubuntu) il cherche
+ // cuda_runtime.h et cudart dans /usr, ne les trouve pas, et sort « CUDA
+ // Toolkit not found » APRÈS avoir pourtant affiché la version de nvcc.
+ if root := cudaToolkitRoot(nvcc); root != "" && runtime.GOOS != "windows" {
+ p.flags = append(p.flags,
+ "-DCUDAToolkit_ROOT="+root,
+ "-DCMAKE_CUDA_COMPILER="+nvcc)
+ }
+ if arch := detectCudaArch(); arch != "" {
+ p.cudaArch = arch
+ p.flags = append(p.flags, "-DCMAKE_CUDA_ARCHITECTURES="+arch)
+ }
+ return p
+ }
+
+ // AMD ROCm / HIP.
+ if hasTool("hipcc") || isDir("/opt/rocm") {
+ p.backend = "hip"
+ p.flags = append(p.flags, "-DGGML_HIP=ON")
+ return p
+ }
+
+ // Vulkan (GPU générique) — utile sur Intel/AMD sans ROCm.
+ if hasTool("glslc") && (isFile("/usr/lib/x86_64-linux-gnu/libvulkan.so.1") || hasTool("vulkaninfo")) {
+ p.backend = "vulkan"
+ p.flags = append(p.flags, "-DGGML_VULKAN=ON")
+ return p
+ }
+
+ return p // CPU
+}
+
+// buildLlamacpp configures and builds the llama-server target. It handles the
+// "relocated checkout" gotcha: a build/ whose CMake cache was generated under a
+// different source path can't reconfigure in place, so we wipe it. `clean`
+// forces a from-scratch build regardless.
+func buildLlamacpp(repo string, p buildPlan, clean bool) error {
+ build := filepath.Join(repo, "build")
+
+ if clean || cacheStale(build, repo) {
+ if isDir(build) {
+ fmt.Printf("%s reconfiguration propre (suppression de build/)\n", dim("[info]"))
+ old := build + ".old"
+ _ = os.RemoveAll(old)
+ if err := os.Rename(build, old); err != nil {
+ _ = os.RemoveAll(build) // dernier recours
+ }
+ }
+ }
+
+ // nvcc doit être dans le PATH et exposé via CUDACXX pour la config CMake.
+ env := ""
+ if p.backend == "cuda" && p.cudaCXX != "" {
+ cudaBin := filepath.Dir(p.cudaCXX)
+ parts := []string{
+ "CUDACXX=" + p.cudaCXX,
+ "PATH=" + cudaBin + string(os.PathListSeparator) + os.Getenv("PATH"),
+ }
+ // L'intégration MSBuild CUDA (générateur Visual Studio) résout
+ // CudaToolkitDir depuis CUDA_PATH / CUDA_PATH_Vx_y. L'installeur les pose
+ // dans l'environnement persistant, mais pas dans ce process déjà lancé —
+ // on les réinjecte sinon le configure échoue sur « CUDA Toolkit directory '' ».
+ parts = append(parts, cudaPathEnv(filepath.Dir(cudaBin))...)
+ env = strings.Join(parts, "\x00")
+ // Générateur Visual Studio : vérifie (et répare si possible) l'intégration
+ // MSBuild de CUDA AVANT de configurer — sinon CMake échoue sur le cryptique
+ // « No CUDA toolset found » (cf. issue #10 : CUDA dans un chemin custom sans
+ // l'option « Visual Studio Integration », ou VS installé après CUDA).
+ if strings.HasPrefix(p.gen, "Visual Studio") {
+ if err := ensureCudaVSIntegration(filepath.Dir(cudaBin)); err != nil {
+ return err
+ }
+ }
+ }
+
+ cfgArgs := []string{"-B", "build", "-S", "."}
+ if p.gen != "" {
+ cfgArgs = append(cfgArgs, "-G", p.gen)
+ if p.genArch != "" {
+ cfgArgs = append(cfgArgs, "-A", p.genArch)
+ }
+ }
+ cfgArgs = append(cfgArgs, p.flags...)
+ cfgLog := filepath.Join(repo, "configure.log")
+ if err := runBuildStep("cmake configure", repo, env, "cmake", cfgLog, cfgArgs...); err != nil {
+ hintMissingBuildDep(p, cfgLog)
+ return fmt.Errorf("configuration CMake échouée: %w", err)
+ }
+
+ buildArgs := []string{"--build", "build", "--config", "Release",
+ "-j", fmt.Sprintf("%d", p.jobs), "--target", "llama-server"}
+ // Générateur Visual Studio : MSBuild réaffiche par défaut la ligne de commande
+ // nvcc complète de chaque kernel (des pavés illisibles). On le passe en
+ // verbosité minimale via les args natifs après « -- ».
+ if strings.HasPrefix(p.gen, "Visual Studio") {
+ buildArgs = append(buildArgs, "--", "/nologo", "/verbosity:minimal")
+ }
+ if err := runBuildStep("cmake build", repo, env, "cmake", filepath.Join(repo, "build.log"), buildArgs...); err != nil {
+ return fmt.Errorf("compilation échouée: %w", err)
+ }
+ return nil
+}
+
+// hintMissingBuildDep scanne le log de configuration CMake à la recherche de
+// dépendances manquantes CONNUES et affiche un indice d'installation adapté à la
+// distribution, plutôt que de laisser l'utilisateur face à l'erreur CMake brute.
+// Best-effort : silencieux si rien de reconnu. (Issue #6 : backend Vulkan qui
+// échoue sur « Could not find ... SPIRV-Headers ».)
+func hintMissingBuildDep(p buildPlan, cfgLog string) {
+ data, err := os.ReadFile(cfgLog)
+ if err != nil {
+ return
+ }
+ log := string(data)
+ // Backend Vulkan : les en-têtes SPIR-V (paquet SPIRV-Headers) sont requis par
+ // la config CMake de ggml-vulkan, mais absents par défaut sur beaucoup de
+ // distros même quand glslc/libvulkan sont là.
+ // Windows/CUDA : « No CUDA toolset found » = intégration MSBuild de CUDA
+ // absente de Visual Studio. Normalement intercepté AVANT le configure par
+ // ensureCudaVSIntegration ; ce filet sert aux cas où la détection n'a pas pu
+ // conclure (vswhere absent, install VS non standard).
+ if p.backend == "cuda" && strings.Contains(log, "No CUDA toolset found") {
+ fmt.Printf("\n%s l'intégration Visual Studio de CUDA est absente (« No CUDA toolset found »).\n", yellow("[dépendance]"))
+ fmt.Printf(" Relance l'installeur du CUDA Toolkit (installation personnalisée) en cochant « CUDA → Visual Studio Integration »\n")
+ fmt.Printf(" (Visual Studio avec le workload C++ doit déjà être installé), ou copie les fichiers de\n")
+ fmt.Printf(" \\extras\\visual_studio_integration\\MSBuildExtensions vers\n")
+ fmt.Printf(" \\MSBuild\\Microsoft\\VC\\\\BuildCustomizations, puis relance %s.\n", bold("ajean llamacpp install"))
+ }
+ // Linux/CUDA : nvcc trouvé mais en-têtes/cudart introuvables = le toolkit
+ // complet n'est pas installé (seul le paquet nvcc l'est). On passe déjà
+ // CUDAToolkit_ROOT quand un vrai toolkit existe ; si ça échoue quand même,
+ // c'est qu'il manque pour de bon.
+ if p.backend == "cuda" && strings.Contains(log, "CUDA Toolkit not found") {
+ fmt.Printf("\n%s nvcc est présent mais le CUDA Toolkit complet (en-têtes + cudart) est introuvable.\n", yellow("[dépendance]"))
+ fmt.Printf(" Installe le toolkit NVIDIA officiel (il se pose dans /usr/local/cuda), puis relance %s.\n", bold("ajean llamacpp install"))
+ }
+ if p.backend == "vulkan" && strings.Contains(log, "SPIRV-Headers") {
+ fmt.Printf("\n%s dépendance manquante pour le backend %s : les en-têtes SPIR-V (paquet « SPIRV-Headers ») sont introuvables.\n",
+ yellow("[dépendance]"), green("Vulkan"))
+ if cmd := pkgInstallHint("spirv-headers"); cmd != "" {
+ fmt.Printf(" installe-les puis relance %s : %s\n", bold("ajean llamacpp install"), bold(cmd))
+ } else {
+ fmt.Printf(" installe le paquet de développement « SPIRV-Headers » de ta distribution, puis relance %s.\n", bold("ajean llamacpp install"))
+ }
+ }
+}
+
+// pkgInstallHint renvoie la commande d'installation d'un paquet adaptée au
+// gestionnaire de paquets présent sur la machine (best-effort ; "" si aucun
+// gestionnaire connu n'est trouvé). Sert uniquement à afficher un indice — on
+// n'exécute rien automatiquement.
+func pkgInstallHint(pkg string) string {
+ for _, m := range []struct{ bin, cmd string }{
+ {"pacman", "sudo pacman -S " + pkg},
+ {"apt-get", "sudo apt-get install -y " + pkg},
+ {"dnf", "sudo dnf install -y " + pkg},
+ {"zypper", "sudo zypper install -y " + pkg},
+ {"brew", "brew install " + pkg},
+ } {
+ if _, err := exec.LookPath(m.bin); err == nil {
+ return m.cmd
+ }
+ }
+ return ""
+}
+
+// cacheStale reports whether build/CMakeCache.txt was generated for a different
+// source directory than `repo` (the relocated-checkout case).
+func cacheStale(build, repo string) bool {
+ cache := filepath.Join(build, "CMakeCache.txt")
+ b, err := os.ReadFile(cache)
+ if err != nil {
+ return false // pas de cache => configure neuf, rien à nettoyer
+ }
+ absRepo, _ := filepath.Abs(repo)
+ for _, line := range strings.Split(string(b), "\n") {
+ // CMAKE_HOME_DIRECTORY pointe vers le source dir d'origine.
+ if strings.HasPrefix(line, "CMAKE_HOME_DIRECTORY:") {
+ if i := strings.IndexByte(line, '='); i >= 0 {
+ home := strings.TrimSpace(line[i+1:])
+ return home != "" && home != absRepo
+ }
+ }
+ }
+ return false
+}
+
+// ---------------------------------------------------------------------------
+// Sondes matérielles
+// ---------------------------------------------------------------------------
+
+// findNvcc returns the path to nvcc from PATH or a /usr/local/cuda* install,
+// preferring the highest version.
+func findNvcc() string {
+ if runtime.GOOS != "windows" {
+ return findNvccUnix(exec.LookPath)
+ }
+ if p, err := exec.LookPath("nvcc"); err == nil {
+ return p
+ }
+ if runtime.GOOS == "windows" {
+ // CUDA_PATH est posé par l'installeur officiel.
+ if cp := os.Getenv("CUDA_PATH"); cp != "" {
+ if p := filepath.Join(cp, "bin", "nvcc.exe"); isFile(p) {
+ return p
+ }
+ }
+ // Layout standard : …\NVIDIA GPU Computing Toolkit\CUDA\v12.x\bin\nvcc.exe
+ for _, base := range []string{os.Getenv("ProgramFiles"), `C:\Program Files`} {
+ if base == "" {
+ continue
+ }
+ matches, _ := filepath.Glob(filepath.Join(base, "NVIDIA GPU Computing Toolkit", "CUDA", "v*", "bin", "nvcc.exe"))
+ if len(matches) > 0 {
+ sort.Strings(matches) // v12.2 < v12.8 → on prend le plus récent
+ return matches[len(matches)-1]
+ }
+ }
+ return ""
+ }
+ return ""
+}
+
+// findNvccUnix choisit nvcc sous Linux/macOS. L'ORDRE compte : un nvcc rangé
+// dans un vrai toolkit (/usr/local/cuda/bin/nvcc) passe AVANT celui que le PATH
+// expose. Sur Ubuntu, le paquet nvidia-cuda-toolkit pose un shim /usr/bin/nvcc
+// alors que les en-têtes et cudart vivent dans /usr/local/cuda : CMake déduit
+// alors la racine du toolkit depuis le chemin de nvcc (donc /usr), n'y trouve ni
+// cuda_runtime.h ni cudart, et échoue sur « CUDA Toolkit not found » alors que
+// nvcc a bien été détecté. lookPath est injecté pour les tests.
+func findNvccUnix(lookPath func(string) (string, error)) string {
+ if p := "/usr/local/cuda/bin/nvcc"; isFile(p) {
+ return p
+ }
+ matches, _ := filepath.Glob("/usr/local/cuda-*/bin/nvcc")
+ if len(matches) > 0 {
+ sort.Strings(matches) // cuda-12.2 < cuda-12.8 lexicographiquement → on prend le dernier
+ return matches[len(matches)-1]
+ }
+ if p, err := lookPath("nvcc"); err == nil {
+ return p
+ }
+ return ""
+}
+
+// cudaToolkitRoot remonte de /bin/nvcc à . Renvoie "" quand le
+// chemin ne suit pas ce layout, ou quand la racine déduite est /usr : dans ce
+// cas la racine n'apprend rien à CMake (c'est justement le cas qui échoue).
+func cudaToolkitRoot(nvcc string) string {
+ if nvcc == "" {
+ return ""
+ }
+ if filepath.Base(filepath.Dir(nvcc)) != "bin" {
+ return ""
+ }
+ root := filepath.Dir(filepath.Dir(nvcc))
+ switch filepath.ToSlash(root) {
+ case "", ".", "/", "/usr":
+ return ""
+ }
+ return root
+}
+
+func hasNvidiaGPU() bool {
+ if !hasTool("nvidia-smi") {
+ return false
+ }
+ out, err := hideCmd(exec.Command("nvidia-smi", "-L")).Output()
+ return err == nil && strings.Contains(string(out), "GPU")
+}
+
+// detectCudaArch queries every GPU's compute capability via nvidia-smi and
+// returns them as CMake-style arch codes (e.g. "8.6" → "86"), deduped and
+// joined with ';'. Empty when the driver is too old to report it (CMake then
+// falls back to native detection).
+func detectCudaArch() string {
+ out, err := hideCmd(exec.Command("nvidia-smi", "--query-gpu=compute_cap", "--format=csv,noheader")).Output()
+ if err != nil {
+ return ""
+ }
+ seen := map[string]bool{}
+ var archs []string
+ for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+ cap := strings.TrimSpace(line)
+ if cap == "" || strings.Contains(strings.ToLower(cap), "not supported") {
+ continue
+ }
+ code := strings.ReplaceAll(cap, ".", "") // "12.0" → "120"
+ if code != "" && !seen[code] {
+ seen[code] = true
+ archs = append(archs, code)
+ }
+ }
+ return strings.Join(archs, ";")
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+func numJobs() int {
+ n := runtime.NumCPU()
+ if n < 1 {
+ return 1
+ }
+ return n
+}
+
+func isFile(p string) bool {
+ fi, err := os.Stat(p)
+ return err == nil && !fi.IsDir()
+}
+
+func isDir(p string) bool {
+ fi, err := os.Stat(p)
+ return err == nil && fi.IsDir()
+}
+
+// llamaServerBin returns the path to the built llama-server binary under repo,
+// probing the layouts the different CMake generators emit: the Visual Studio
+// multi-config generator nests it under build/bin/Release/ and Windows adds a
+// .exe suffix, whereas the Unix Makefiles generator drops it in build/bin/.
+// Returns "" when no binary is found.
+func llamaServerBin(repo string) string {
+ ext := ""
+ if runtime.GOOS == "windows" {
+ ext = ".exe"
+ }
+ for _, rel := range []string{
+ filepath.Join("build", "bin", "Release", "llama-server"+ext),
+ filepath.Join("build", "bin", "llama-server"+ext),
+ filepath.Join("build", "Release", "llama-server"+ext),
+ filepath.Join("build", "llama-server"+ext),
+ } {
+ if p := filepath.Join(repo, rel); isFile(p) {
+ return p
+ }
+ }
+ return ""
+}
+
+func hasTool(name string) bool {
+ _, err := exec.LookPath(name)
+ return err == nil
+}
+
+func requireTools(tools ...string) error {
+ missing := missingTools(tools)
+ if len(missing) == 0 {
+ return nil
+ }
+
+ // Tentative d'installation automatique (winget sur Windows, apt/brew/dnf sur
+ // Unix). On rafraîchit ensuite le PATH du process car un installeur système
+ // écrit le PATH machine sans toucher l'environnement déjà chargé.
+ fmt.Printf("%s outils manquants: %s — installation automatique…\n", yellow("[info]"), strings.Join(missing, ", "))
+ for _, t := range missing {
+ if err := autoInstallTool(t); err != nil {
+ fmt.Printf(" %s %s: %v\n", dim("•"), t, err)
+ }
+ }
+ refreshToolPath()
+
+ if still := missingTools(tools); len(still) > 0 {
+ return fmt.Errorf("outils toujours manquants après tentative d'install: %s — installe-les à la main puis réessaie", strings.Join(still, ", "))
+ }
+ fmt.Printf("%s outils installés.\n", green("✓"))
+ return nil
+}
+
+func missingTools(tools []string) []string {
+ var missing []string
+ for _, t := range tools {
+ if !hasTool(t) {
+ missing = append(missing, t)
+ }
+ }
+ return missing
+}
+
+// gitOutput runs a git command in `dir` and returns trimmed stdout (or "").
+func gitOutput(dir string, args ...string) string {
+ cmd := hideCmd(exec.Command("git", args...)) // pas de flash console (appelé à chaque refresh de l'UI)
+ cmd.Dir = dir
+ out, err := cmd.Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// runStep runs a command in `dir` streaming output live to the terminal.
+func runStep(name, dir, bin string, args ...string) error {
+ return runStepEnv(name, dir, "", bin, args...)
+}
+
+// runStepEnv is runStep with optional extra env vars (NUL-separated KEY=VAL
+// pairs in `extraEnv`, which override existing ones).
+func runStepEnv(name, dir, extraEnv, bin string, args ...string) error {
+ fmt.Printf("\n%s %s %s\n", cyan("▶"), name, dim(strings.Join(args, " ")))
+ cmd := hideCmd(exec.Command(bin, args...))
+ cmd.Dir = dir
+ // Tee vers le sink de build (jobs web) en plus du terminal.
+ var out io.Writer = os.Stdout
+ var errw io.Writer = os.Stderr
+ buildSinkMu.Lock()
+ sinkOn := buildSink != nil
+ buildSinkMu.Unlock()
+ if sinkOn {
+ sw := &sinkWriter{}
+ out = io.MultiWriter(os.Stdout, sw)
+ errw = io.MultiWriter(os.Stderr, sw)
+ }
+ cmd.Stdout = out
+ cmd.Stderr = errw
+ cmd.Stdin = os.Stdin
+ if extraEnv != "" {
+ env := os.Environ()
+ for _, kv := range strings.Split(extraEnv, "\x00") {
+ if kv == "" {
+ continue
+ }
+ env = upsertEnv(env, kv)
+ }
+ cmd.Env = env
+ }
+ return cmd.Run()
+}
+
+// runBuildStep runs a compile step while keeping the terminal clean: the full
+// output goes to logPath, and the screen shows only a single self-rewriting
+// progress line (spinner + compiled-file count) plus any real compiler
+// diagnostics. The hundreds of per-file nvcc/cl command echoes are hidden. On
+// failure the tail of the log is printed so the actual error is never lost.
+func runBuildStep(name, dir, extraEnv, bin, logPath string, args ...string) error {
+ fmt.Printf("\n%s %s\n", cyan("▶"), name)
+ cmd := hideCmd(exec.Command(bin, args...))
+ cmd.Dir = dir
+ if extraEnv != "" {
+ env := os.Environ()
+ for _, kv := range strings.Split(extraEnv, "\x00") {
+ if kv != "" {
+ env = upsertEnv(env, kv)
+ }
+ }
+ cmd.Env = env
+ }
+
+ var logf *os.File
+ if logPath != "" {
+ if f, err := os.Create(logPath); err == nil {
+ logf = f
+ defer logf.Close()
+ }
+ }
+
+ pr, pw := io.Pipe()
+ cmd.Stdout = pw
+ cmd.Stderr = pw
+ if err := cmd.Start(); err != nil {
+ return err
+ }
+
+ frames := []rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'}
+ var (
+ mu sync.Mutex
+ count int
+ label = "préparation…"
+ fi int
+ )
+ clearLine := func() {
+ if colorOn {
+ fmt.Print("\r\033[K")
+ }
+ }
+ // draw redessine la ligne d'état ; appelé par une horloge pour rester animé
+ // même quand un seul gros fichier compile pendant plusieurs minutes.
+ draw := func() {
+ if !colorOn {
+ return
+ }
+ mu.Lock()
+ fi = (fi + 1) % len(frames)
+ fmt.Printf("\r\033[K %c %s", frames[fi], label)
+ mu.Unlock()
+ }
+
+ done := make(chan struct{})
+ go func() {
+ sc := bufio.NewScanner(pr)
+ sc.Buffer(make([]byte, 1<<20), 1<<20) // les échos de commande sont énormes
+ for sc.Scan() {
+ line := sc.Text()
+ if logf != nil {
+ fmt.Fprintln(logf, line)
+ }
+ emitBuildLine(line)
+ mu.Lock()
+ if f := compiledFile(line); f != "" {
+ count++
+ label = fmt.Sprintf("compilation… %d fichiers %s", count, dim("("+f+")"))
+ mu.Unlock()
+ continue
+ }
+ if p := phaseLabel(line); p != "" {
+ label = p
+ }
+ mu.Unlock()
+ // On ne fait remonter que les vraies ERREURS (les warnings MSVC/linker
+ // d'un projet tiers sont du bruit ; ils restent dans le log). Les CMake
+ // Error de la phase configure sont aussi affichés.
+ if reBuildError.MatchString(line) || strings.HasPrefix(strings.TrimSpace(line), "CMake Error") {
+ mu.Lock()
+ clearLine()
+ fmt.Println(" " + strings.TrimSpace(line))
+ mu.Unlock()
+ }
+ }
+ close(done)
+ }()
+
+ // Horloge d'animation, indépendante du flux de sortie.
+ stop := make(chan struct{})
+ tickerDone := make(chan struct{})
+ go func() {
+ defer close(tickerDone)
+ t := time.NewTicker(120 * time.Millisecond)
+ defer t.Stop()
+ for {
+ select {
+ case <-stop:
+ return
+ case <-t.C:
+ draw()
+ }
+ }
+ }()
+
+ err := cmd.Wait()
+ _ = pw.Close()
+ <-done
+ close(stop)
+ <-tickerDone
+ clearLine()
+ if err == nil && count > 0 {
+ fmt.Printf(" %s %d fichiers compilés\n", green("✓"), count)
+ }
+ if err != nil && logPath != "" {
+ fmt.Printf("%s étape échouée — log complet : %s\n", yellow("[err]"), logPath)
+ printLogTail(logPath, 30)
+ }
+ return err
+}
+
+// phaseLabel maps a non-compile output line to a short status label, or "" to
+// leave the current label unchanged. Keeps the spinner informative during the
+// CMake configure phase and the final link.
+func phaseLabel(line string) string {
+ t := strings.TrimSpace(line)
+ switch {
+ case strings.HasPrefix(t, "-- "):
+ return "configuration… " + truncLabel(strings.TrimPrefix(t, "-- "), 50)
+ case strings.Contains(t, "Linking") || strings.Contains(t, "Build files have been written"):
+ return "édition de liens…"
+ }
+ return ""
+}
+
+func truncLabel(s string, n int) string {
+ s = strings.TrimSpace(s)
+ if len(s) > n {
+ return s[:n-1] + "…"
+ }
+ return s
+}
+
+var (
+ // MSBuild (Windows) : « Compiling CUDA source file …\foo.cu… » ou nom de
+ // source seul « foo.cpp » imprimé par cl.
+ reCompilingCUDA = regexp.MustCompile(`Compiling .*?([\w.\-]+\.cu)\b`)
+ reBareSource = regexp.MustCompile(`^[\w.\-]+\.(c|cc|cpp|cxx|cu|cuh)$`)
+ // Make / Ninja (Linux, macOS) : « [ 45%] Building CXX object …/foo.cpp.o » ou
+ // « [12/345] Building CUDA object …/foo.cu.o ».
+ reBuildingObj = regexp.MustCompile(`Building (?:C|CXX|CUDA|ASM)\w* object .*?/([^/]+?)\.o(?:bj)?\b`)
+ // Vraies erreurs : « foo.cpp(12): error C2065 » (MSVC), « foo.cpp:12:5: error: »
+ // (gcc/clang), « LINK : fatal error LNK1104 ». On exige le « : » devant le
+ // mot-clé pour ne PAS matcher les flags type -D_CRT_SECURE_NO_WARNINGS dans les
+ // lignes de commande. Les warnings (bruit d'un projet tiers) sont exclus.
+ reBuildError = regexp.MustCompile(`(?i):\s*(fatal error|error)\b`)
+)
+
+// compiledFile returns the source filename a build line announces compiling, or
+// "" if the line isn't a compile-progress marker. Handles both the MSBuild
+// (Windows) and Make/Ninja (Unix) output formats.
+func compiledFile(line string) string {
+ t := strings.TrimSpace(line)
+ if m := reCompilingCUDA.FindStringSubmatch(t); m != nil {
+ return m[1]
+ }
+ if m := reBuildingObj.FindStringSubmatch(t); m != nil {
+ return m[1]
+ }
+ if reBareSource.MatchString(t) {
+ return t
+ }
+ return ""
+}
+
+// printLogTail prints the last n lines of the log file (best-effort).
+func printLogTail(path string, n int) {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return
+ }
+ lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
+ if len(lines) > n {
+ lines = lines[len(lines)-n:]
+ }
+ for _, l := range lines {
+ fmt.Println(" " + dim(l))
+ }
+}
+
+// upsertEnv replaces KEY=… in env if present, else appends kv (kv is "KEY=VAL").
+func upsertEnv(env []string, kv string) []string {
+ key := kv
+ if i := strings.IndexByte(kv, '='); i >= 0 {
+ key = kv[:i]
+ }
+ for i, e := range env {
+ if strings.HasPrefix(e, key+"=") {
+ env[i] = kv
+ return env
+ }
+ }
+ return append(env, kv)
+}
+
+func planLabel(p buildPlan) string {
+ switch p.backend {
+ case "cuda":
+ arch := p.cudaArch
+ if arch == "" {
+ arch = "native"
+ }
+ return green("CUDA") + dim(" (arch="+arch+", nvcc="+p.cudaCXX+")")
+ case "hip":
+ return green("ROCm/HIP")
+ case "metal":
+ return green("Metal")
+ case "vulkan":
+ return green("Vulkan")
+ default:
+ return yellow("CPU") + dim(" (aucun accélérateur détecté)")
+ }
+}
+
+func printPlan(p buildPlan, repo string) {
+ fmt.Printf("\n%s configuration du build\n", bold("•"))
+ fmt.Printf(" backend : %s\n", planLabel(p))
+ fmt.Printf(" jobs : %d\n", p.jobs)
+ fmt.Printf(" flags : %s\n", dim(strings.Join(p.flags, " ")))
+}
diff --git a/internal/ajean/backend_build_cuda_test.go b/internal/ajean/backend_build_cuda_test.go
new file mode 100644
index 0000000..fc200be
--- /dev/null
+++ b/internal/ajean/backend_build_cuda_test.go
@@ -0,0 +1,50 @@
+package ajean
+
+import (
+ "errors"
+ "path/filepath"
+ "testing"
+)
+
+// Le shim /usr/bin/nvcc d'Ubuntu ne doit JAMAIS l'emporter sur un vrai toolkit :
+// CMake déduit la racine du toolkit du chemin de nvcc, et /usr ne contient ni
+// cuda_runtime.h ni cudart → « CUDA Toolkit not found » alors que nvcc est là.
+func TestFindNvccUnixIgnoreLeShimQuandUnToolkitExiste(t *testing.T) {
+ if !isFile("/usr/local/cuda/bin/nvcc") {
+ t.Skip("pas de toolkit /usr/local/cuda sur cette machine")
+ }
+ got := findNvccUnix(func(string) (string, error) { return "/usr/bin/nvcc", nil })
+ if got == "/usr/bin/nvcc" {
+ t.Error("le shim /usr/bin/nvcc a été préféré au toolkit /usr/local/cuda")
+ }
+}
+
+// Sans toolkit installé, on retombe sur le PATH plutôt que de renoncer à CUDA.
+func TestFindNvccUnixRetombeSurLePath(t *testing.T) {
+ if isFile("/usr/local/cuda/bin/nvcc") {
+ t.Skip("un toolkit est installé ici : le repli n'est pas exerçable")
+ }
+ if got := findNvccUnix(func(string) (string, error) { return "/usr/bin/nvcc", nil }); got != "/usr/bin/nvcc" {
+ t.Errorf("repli PATH = %q, attendu /usr/bin/nvcc", got)
+ }
+ if got := findNvccUnix(func(string) (string, error) { return "", errors.New("absent") }); got != "" {
+ t.Errorf("aucun nvcc nulle part : %q, attendu \"\"", got)
+ }
+}
+
+// cudaToolkitRoot remonte /bin/nvcc → , et refuse les racines qui
+// n'apprennent rien à CMake (/usr, layout inattendu).
+func TestCudaToolkitRoot(t *testing.T) {
+ cases := map[string]string{
+ "/usr/local/cuda/bin/nvcc": "/usr/local/cuda",
+ "/usr/local/cuda-12.8/bin/nvcc": "/usr/local/cuda-12.8",
+ "/usr/bin/nvcc": "", // racine déduite = /usr : inutile
+ "/opt/nvcc": "", // pas de dossier bin
+ "": "",
+ }
+ for in, want := range cases {
+ if got := filepath.ToSlash(cudaToolkitRoot(filepath.FromSlash(in))); got != want {
+ t.Errorf("cudaToolkitRoot(%q) = %q, attendu %q", in, got, want)
+ }
+ }
+}
diff --git a/internal/ajean/backend_buildfilter_test.go b/internal/ajean/backend_buildfilter_test.go
new file mode 100644
index 0000000..491f147
--- /dev/null
+++ b/internal/ajean/backend_buildfilter_test.go
@@ -0,0 +1,51 @@
+package ajean
+
+import "testing"
+
+func TestCompiledFile(t *testing.T) {
+ cases := map[string]string{
+ ` Compiling CUDA source file ..\..\..\..\ggml\src\ggml-cuda\acc.cu...`: "acc.cu",
+ ` ggml-threading.cpp`: "ggml-threading.cpp",
+ ` ggml-quants.c`: "ggml-quants.c",
+ // Make (Linux) et Ninja.
+ `[ 45%] Building CXX object src/CMakeFiles/llama.dir/llama.cpp.o`: "llama.cpp",
+ `[123/456] Building CUDA object ggml/src/ggml-cuda/CMakeFiles/ggml-cuda.dir/acc.cu.o`: "acc.cu",
+ // La ligne de commande nvcc géante ne doit PAS être prise pour un fichier.
+ ` C:\...\nvcc.exe -x cu ... -o ggml-cuda.dir\Release\acc.obj "C:\...\acc.cu"`: "",
+ `Building Custom Rule C:/ProgramData/ajean/...`: "",
+ `-- UI: running npm install`: "",
+ }
+ for in, want := range cases {
+ if got := compiledFile(in); got != want {
+ t.Errorf("compiledFile(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+func TestBuildError(t *testing.T) {
+ keep := []string{
+ `acc.cu(12): error C2065: 'foo': undeclared identifier`,
+ `LINK : fatal error LNK1104: cannot open file`,
+ // gcc/clang (Linux).
+ `/x/ggml.cpp:42:9: error: 'foo' was not declared in this scope`,
+ }
+ drop := []string{
+ // Les warnings ne doivent PAS remonter (bruit tiers).
+ `ggml.cpp(10): warning C4244: conversion`,
+ `LINK : warning LNK4098: conflit entre la bibliothèque ...`,
+ // Contient -D_CRT_SECURE_NO_WARNINGS mais n'est pas un diagnostic.
+ `nvcc.exe ... -D_CRT_SECURE_NO_WARNINGS -DGGML_SHARED ... -o acc.obj`,
+ `Compiling CUDA source file acc.cu...`,
+ `C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.3\bin\nvcc.exe`,
+ }
+ for _, l := range keep {
+ if !reBuildError.MatchString(l) {
+ t.Errorf("erreur attendue mais ratée: %q", l)
+ }
+ }
+ for _, l := range drop {
+ if reBuildError.MatchString(l) {
+ t.Errorf("fausse erreur: %q", l)
+ }
+ }
+}
diff --git a/internal/ajean/backend_catalog.go b/internal/ajean/backend_catalog.go
new file mode 100644
index 0000000..380bae4
--- /dev/null
+++ b/internal/ajean/backend_catalog.go
@@ -0,0 +1,78 @@
+package ajean
+
+// backend_catalog.go — récupère la liste de modèles curatée servie par ajean.link et la
+// combine avec les infos matérielles locales, pour que l'écran d'accueil (à
+// venir) propose « en un clic » un modèle adapté à la machine.
+//
+// La liste vit sur https://ajean.link/models.json (éditée par l'opérateur, voir
+// ajean-relay/models_catalog.go). Si le réseau échoue, on retombe sur un
+// catalogue minimal embarqué (fallbackCatalogJSON) pour ne jamais bloquer.
+
+import (
+ "encoding/json"
+ "net/http"
+ "runtime"
+ "time"
+)
+
+const catalogURL = "https://ajean.link/models.json"
+
+type catalogModel struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Params string `json:"params"`
+ Quant string `json:"quant"`
+ SizeGB float64 `json:"size_gb"`
+ MinRAMGB float64 `json:"min_ram_gb"`
+ URL string `json:"url"`
+ Note string `json:"note"`
+}
+
+type catalog struct {
+ Version int `json:"version"`
+ Models []catalogModel `json:"models"`
+}
+
+type hardwareInfo struct {
+ OS string `json:"os"`
+ Arch string `json:"arch"`
+ RAMGB float64 `json:"ram_gb"`
+}
+
+// fetchCatalog récupère le catalogue distant, avec repli embarqué.
+func fetchCatalog() catalog {
+ var c catalog
+ client := &http.Client{Timeout: 8 * time.Second}
+ if resp, err := client.Get(catalogURL); err == nil {
+ defer resp.Body.Close()
+ if resp.StatusCode == 200 && json.NewDecoder(resp.Body).Decode(&c) == nil && len(c.Models) > 0 {
+ return c
+ }
+ }
+ _ = json.Unmarshal([]byte(fallbackCatalogJSON), &c)
+ return c
+}
+
+func detectHardware() hardwareInfo {
+ return hardwareInfo{OS: runtime.GOOS, Arch: runtime.GOARCH, RAMGB: totalRAMGB()}
+}
+
+// handleCatalog : renvoie le catalogue + le matériel local. L'UI s'en sert pour
+// marquer chaque modèle « tient / trop lourd » et proposer le bon par défaut.
+func handleCatalog(w http.ResponseWriter, r *http.Request) {
+ resp := struct {
+ Hardware hardwareInfo `json:"hardware"`
+ Models []catalogModel `json:"models"`
+ }{Hardware: detectHardware(), Models: fetchCatalog().Models}
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+// fallbackCatalogJSON — repli minimal si ajean.link/models.json est injoignable.
+const fallbackCatalogJSON = `{
+ "version": 1,
+ "models": [
+ {"id":"qwen2.5-3b-instruct-q4","name":"Qwen2.5 3B Instruct","params":"3B","quant":"Q4_K_M","size_gb":2.1,"min_ram_gb":6,"url":"https://huggingface.co/bartowski/Qwen2.5-3B-Instruct-GGUF/resolve/main/Qwen2.5-3B-Instruct-Q4_K_M.gguf","note":"Léger et rapide — idéal petites machines."},
+ {"id":"qwen2.5-7b-instruct-q4","name":"Qwen2.5 7B Instruct","params":"7B","quant":"Q4_K_M","size_gb":4.7,"min_ram_gb":10,"url":"https://huggingface.co/bartowski/Qwen2.5-7B-Instruct-GGUF/resolve/main/Qwen2.5-7B-Instruct-Q4_K_M.gguf","note":"Plus capable — recommandé avec 16 Go de RAM ou un GPU."}
+ ]
+}`
diff --git a/internal/ajean/backend_config.go b/internal/ajean/backend_config.go
new file mode 100644
index 0000000..a08f83f
--- /dev/null
+++ b/internal/ajean/backend_config.go
@@ -0,0 +1,218 @@
+package ajean
+
+import (
+ "bufio"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// readAPIKey renvoie la clé Bearer de llama-server, ou "" si aucune n'est
+// définie. Elle est rangée hors de la configuration pour survivre aux
+// changements de preset, qui remplacent la configuration en bloc.
+func readAPIKey() string { return getStr(bkState, "api_key") }
+
+// authHeader sets the Authorization: Bearer header on req when an API key is
+// configured, so AJEAN's own internal calls (chat/web/bench/test) authenticate
+// against a protected llama-server. No-op when no key is set.
+func authHeader(req *http.Request) {
+ if k := readAPIKey(); k != "" {
+ req.Header.Set("Authorization", "Bearer "+k)
+ }
+}
+
+// genAPIKey returns a fresh random OpenAI-style completion key.
+func genAPIKey() string {
+ buf := make([]byte, 24)
+ _, _ = rand.Read(buf)
+ return "sk-ajean-" + hex.EncodeToString(buf)
+}
+
+// writeAPIKey enregistre (key != "") ou supprime (key == "") la clé Bearer.
+// Ne redémarre PAS le service : llama-server ne lit --api-key qu'au lancement,
+// c'est à l'appelant de choisir quand appliquer.
+func writeAPIKey(key string) error {
+ _ = SetConfigKey("API_KEY", "") // aucune ambiguïté avec une valeur résiduelle
+ return putStr(bkState, "api_key", key)
+}
+
+// maskAPIKey renders a key for display: keep the "sk-ajean-" prefix and last 4
+// chars, elide the middle. Empty in → empty out.
+func maskAPIKey(k string) string {
+ if k == "" {
+ return ""
+ }
+ if len(k) <= 13 {
+ return "…" + k[len(k)-2:]
+ }
+ return k[:9] + "…" + k[len(k)-4:]
+}
+
+// cmdSetAPIKey définit (ou supprime) la clé Bearer de llama-server. Exposé sur
+// internet, le serveur exige alors « Authorization: Bearer » à chaque
+// appel.
+//
+// ajean set-api-key définit la clé
+// ajean set-api-key génère une clé aléatoire
+// ajean set-api-key "" supprime la protection
+func cmdSetAPIKey(args []string) error {
+ var key string
+ switch {
+ case len(args) == 0:
+ key = genAPIKey()
+ 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 := writeAPIKey(key); err != nil {
+ return err
+ }
+ if key == "" {
+ fmt.Printf("%s API_KEY supprimée — serveur ouvert (pas d'authentification)\n", yellow("[info]"))
+ } else {
+ fmt.Printf("%s API_KEY enregistrée\n", green("[ok]"))
+ fmt.Printf(" les clients doivent envoyer : %s\n", dim("Authorization: Bearer "+key))
+ }
+ fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
+ sc := bufio.NewScanner(os.Stdin)
+ if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
+ fmt.Println(dim("[info] pense à lancer 'ajean restart'"))
+ return nil
+ }
+ return serviceAction("restart")
+}
+
+// ReadConfig renvoie la configuration active de llama-server. Passe par le cache
+// de lecture (store.go) : elle est relue à chaque itération de la boucle
+// d'inférence et après chaque appel d'outil.
+func ReadConfig() map[string]string { return cachedKV(bkConfig) }
+
+// SetConfigKey définit une clé de configuration. Une valeur vide la supprime.
+func SetConfigKey(key, value string) error { return putStr(bkConfig, key, value) }
+
+// WriteConfig remplace TOUTE la configuration en une transaction. C'est ce
+// qu'exige l'application d'un preset : jamais un état mi-ancien mi-nouveau.
+func WriteConfig(m map[string]string) error { return replaceKV(bkConfig, m) }
+
+// unquoteValue retire les guillemets ENTOURANTS d'une valeur, et seulement
+// eux : une paire ouvrante/fermante du même caractère.
+//
+// Un simple Trim(v, `"`) mangeait le guillemet d'un argument interne — par
+// exemple EXTRA_ARGS=--chat-template-file "/etc/ajean/tpl.jinja" perdait son
+// guillemet final et repartait déséquilibré dans le preset (issue #17 : contenu
+// tronqué / guillemets non appariés à la création d'un preset).
+func unquoteValue(v string) string {
+ if len(v) >= 2 {
+ if q := v[0]; (q == '"' || q == '\'') && v[len(v)-1] == q {
+ return v[1 : len(v)-1]
+ }
+ }
+ return v
+}
+
+// parseEnv lit un fichier au format clé=valeur (les presets). Les lignes vides
+// et les commentaires sont ignorés, les guillemets entourants retirés.
+func parseEnv(text string) map[string]string {
+ m := map[string]string{}
+ for _, line := range strings.Split(text, "\n") {
+ s := strings.TrimSpace(line)
+ if s == "" || strings.HasPrefix(s, "#") {
+ continue
+ }
+ k, v, ok := strings.Cut(strings.TrimPrefix(s, "export "), "=")
+ if !ok {
+ continue
+ }
+ m[strings.TrimSpace(k)] = unquoteValue(strings.TrimSpace(v))
+ }
+ return m
+}
+
+// quoteValue rend une valeur telle que parseEnv la relise à l'identique. On
+// n'entoure de guillemets que ce qui en a besoin (espaces), et jamais une valeur
+// qui contient déjà un guillemet : elle est écrite telle quelle, unquoteValue ne
+// touchant qu'à une paire entourante.
+func quoteValue(v string) string {
+ if v == "" || strings.ContainsRune(v, '"') {
+ return v
+ }
+ if strings.ContainsAny(v, " \t") || unquoteValue(v) != v {
+ return `"` + v + `"`
+ }
+ return v
+}
+
+// formatEnv rend une configuration au format des presets, clés triées pour que
+// deux écritures du même contenu donnent le même fichier.
+func formatEnv(m map[string]string) string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ var b strings.Builder
+ for _, k := range keys {
+ fmt.Fprintf(&b, "%s=%s\n", k, quoteValue(m[k]))
+ }
+ return b.String()
+}
+
+// splitArgs découpe EXTRA_ARGS comme le ferait un shell : sur les espaces, mais
+// en respectant les guillemets, pour qu'un chemin qui en contient reste UN seul
+// argument (--chat-template-file "/mes modèles/tpl.jinja"). Le découpage
+// précédent, sur le seul caractère espace, le coupait en deux et llama-server
+// refusait de démarrer.
+func splitArgs(s string) []string {
+ out := []string{}
+ var cur strings.Builder
+ var quote rune
+ flush := func() {
+ if cur.Len() > 0 {
+ out = append(out, cur.String())
+ cur.Reset()
+ }
+ }
+ for _, r := range s {
+ switch {
+ case quote != 0:
+ if r == quote {
+ quote = 0
+ // Guillemets vides ("" ) : on garde l'argument vide explicite.
+ if cur.Len() == 0 {
+ out = append(out, "")
+ }
+ } else {
+ cur.WriteRune(r)
+ }
+ case r == '"' || r == '\'':
+ quote = r
+ case r == ' ' || r == '\t' || r == '\n' || r == '\r':
+ flush()
+ default:
+ cur.WriteRune(r)
+ }
+ }
+ flush()
+ return out
+}
+
+// Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été
+// retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le
+// seul frein.
+
+// LLMPort renvoie le port du serveur (clé PORT), 8080 par défaut.
+func LLMPort() int {
+ if p, ok := ReadConfig()["PORT"]; ok {
+ if n, err := strconv.Atoi(p); err == nil && n > 0 {
+ return n
+ }
+ }
+ return 8080
+}
diff --git a/internal/ajean/backend_config_quote_test.go b/internal/ajean/backend_config_quote_test.go
new file mode 100644
index 0000000..6f23227
--- /dev/null
+++ b/internal/ajean/backend_config_quote_test.go
@@ -0,0 +1,77 @@
+package ajean
+
+import (
+ "reflect"
+ "testing"
+)
+
+// Un guillemet INTERNE (argument cité dans EXTRA_ARGS) ne doit pas être mangé :
+// l'ancien Trim retirait le guillemet final et renvoyait une valeur déséquilibrée
+// dans le preset (issue #17).
+func TestParseEnvKeepsInnerQuotes(t *testing.T) {
+ cases := map[string]string{
+ `EXTRA_ARGS=--jinja --chat-template-file "/etc/ajean/tpl.jinja"`: `--jinja --chat-template-file "/etc/ajean/tpl.jinja"`,
+ `EXTRA_ARGS="--jinja --flash-attn"`: `--jinja --flash-attn`,
+ `MODEL='/mnt/d/x.gguf'`: `/mnt/d/x.gguf`,
+ `MODEL=/mnt/d/x.gguf`: `/mnt/d/x.gguf`,
+ }
+ for line, want := range cases {
+ m := parseEnv(line)
+ var got string
+ for _, v := range m {
+ got = v
+ }
+ if got != want {
+ t.Errorf("parseEnv(%q) = %q, veut %q", line, got, want)
+ }
+ }
+}
+
+// Écrire puis relire doit rendre exactement la même configuration.
+func TestFormatEnvRoundTrip(t *testing.T) {
+ in := map[string]string{
+ "MODEL": "/mnt/mes modèles/x.gguf",
+ "EXTRA_ARGS": `--jinja --chat-template-file "/etc/ajean/tpl.jinja"`,
+ "CTX": "32768",
+ }
+ out := parseEnv(formatEnv(in))
+ if !reflect.DeepEqual(in, out) {
+ t.Fatalf("aller-retour cassé :\n%v\n%v", in, out)
+ }
+}
+
+func TestSplitArgs(t *testing.T) {
+ got := splitArgs(`--jinja --chat-template-file "/etc/mes modèles/tpl.jinja" --flash-attn`)
+ want := []string{"--jinja", "--chat-template-file", "/etc/mes modèles/tpl.jinja", "--flash-attn"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("splitArgs = %q, veut %q", got, want)
+ }
+ if len(splitArgs("")) != 0 {
+ t.Fatal("EXTRA_ARGS vide doit donner zéro argument")
+ }
+}
+
+// L'éditeur doit proposer les clés utiles même quand la configuration est vide,
+// et ne jamais perdre une clé déjà définie.
+func TestConfigEditorText(t *testing.T) {
+ txt := configEditorText(map[string]string{"MODEL": "x.gguf", "INCONNUE": "1"})
+ round := parseEnv(txt)
+ if round["MODEL"] != "x.gguf" || round["INCONNUE"] != "1" {
+ t.Fatalf("clés perdues : %v", round)
+ }
+ if len(round) != 2 {
+ t.Fatalf("les clés non renseignées doivent rester commentées : %v", round)
+ }
+ if !contains(txt, "#BIN=") {
+ t.Fatal("le squelette doit documenter BIN")
+ }
+}
+
+func contains(s, sub string) bool {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/ajean/backend_gpu.go b/internal/ajean/backend_gpu.go
new file mode 100644
index 0000000..b041582
--- /dev/null
+++ b/internal/ajean/backend_gpu.go
@@ -0,0 +1,158 @@
+package ajean
+
+import (
+ "bufio"
+ "fmt"
+ "os"
+ "os/exec"
+ "strconv"
+ "strings"
+)
+
+// backend_gpu.go — sélection du/des GPU utilisés par llama-server.
+//
+// ajean gpu liste les GPU et montre la sélection courante
+// ajean gpu 1 n'utilise que le GPU d'index 1
+// ajean gpu 0 1 utilise les GPU 0 et 1
+// ajean gpu all réinitialise (tous les GPU visibles)
+//
+// La sélection est stockée dans config.env sous CUDA_VISIBLE_DEVICES ; backend_serve.go
+// l'exporte (avec CUDA_DEVICE_ORDER=PCI_BUS_ID pour que les index correspondent
+// à ceux affichés par nvidia-smi).
+
+type gpuInfo struct {
+ Index int
+ Name string
+ MemTotal string // en MiB
+ MemUsed string
+ Cap string // compute capability
+}
+
+func cmdGPU(args []string) error {
+ if len(args) == 0 || args[0] == "list" || args[0] == "ls" {
+ return gpuList()
+ }
+ switch args[0] {
+ case "all", "reset", "none", "auto":
+ return gpuSet("")
+ }
+ // Sinon : une liste d'index (« 1 », « 0 1 », « 0,1 »).
+ gpus, err := detectGPUs()
+ if err != nil {
+ return err
+ }
+ raw := strings.Join(args, ",")
+ var idx []string
+ for _, tok := range strings.Split(raw, ",") {
+ tok = strings.TrimSpace(tok)
+ if tok == "" {
+ continue
+ }
+ n, err := strconv.Atoi(tok)
+ if err != nil {
+ return fmt.Errorf("index GPU invalide: %q (attendu un nombre)", tok)
+ }
+ if n < 0 || n >= len(gpus) {
+ return fmt.Errorf("index GPU %d hors limites (0..%d) — voir « ajean gpu »", n, len(gpus)-1)
+ }
+ idx = append(idx, strconv.Itoa(n))
+ }
+ if len(idx) == 0 {
+ return fmt.Errorf("aucun index fourni")
+ }
+ return gpuSet(strings.Join(idx, ","))
+}
+
+// gpuList affiche les GPU détectés et marque ceux actuellement sélectionnés.
+func gpuList() error {
+ gpus, err := detectGPUs()
+ if err != nil {
+ return err
+ }
+ sel := ReadConfig()["CUDA_VISIBLE_DEVICES"]
+ selected := map[int]bool{}
+ if sel != "" {
+ for _, t := range strings.Split(sel, ",") {
+ if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
+ selected[n] = true
+ }
+ }
+ }
+
+ fmt.Println()
+ for _, g := range gpus {
+ mark := " "
+ line := fmt.Sprintf("[%d] %s — %s/%s MiB (cc %s)", g.Index, g.Name, g.MemUsed, g.MemTotal, g.Cap)
+ active := sel == "" || selected[g.Index]
+ if sel != "" && selected[g.Index] {
+ mark = green("● ")
+ line = green(line)
+ } else if sel == "" {
+ mark = dim("○ ")
+ } else {
+ mark = dim("○ ")
+ line = dim(line)
+ }
+ _ = active
+ fmt.Printf(" %s%s\n", mark, line)
+ }
+ fmt.Println()
+ if sel == "" {
+ fmt.Printf(" Sélection : %s (tous les GPU)\n", bold("auto"))
+ } else {
+ fmt.Printf(" Sélection : %s (CUDA_VISIBLE_DEVICES=%s)\n", bold(sel), sel)
+ }
+ fmt.Printf(" %s ajean gpu pour choisir, ajean gpu all pour réinitialiser\n", dim("→"))
+ return nil
+}
+
+// gpuSet writes (or clears) CUDA_VISIBLE_DEVICES in config.env then offers a
+// restart so the change takes effect.
+func gpuSet(value string) error {
+ if err := SetConfigKey("CUDA_VISIBLE_DEVICES", value); err != nil {
+ return err
+ }
+ if value == "" {
+ fmt.Printf("%s sélection GPU réinitialisée — tous les GPU seront visibles\n", green("[ok]"))
+ } else {
+ fmt.Printf("%s GPU sélectionné(s) : %s\n", green("[ok]"), bold(value))
+ }
+ fmt.Print(dim("[info] redémarrer le service pour appliquer ? [Y/n] "))
+ sc := bufio.NewScanner(os.Stdin)
+ if sc.Scan() && strings.HasPrefix(strings.ToLower(strings.TrimSpace(sc.Text())), "n") {
+ fmt.Println(dim("[info] pense à lancer 'ajean restart'"))
+ return nil
+ }
+ return serviceAction("restart")
+}
+
+// detectGPUs queries nvidia-smi for the list of NVIDIA GPUs.
+func detectGPUs() ([]gpuInfo, error) {
+ if !hasTool("nvidia-smi") {
+ return nil, fmt.Errorf("nvidia-smi introuvable — sélection GPU disponible uniquement sur NVIDIA")
+ }
+ out, err := hideCmd(exec.Command("nvidia-smi",
+ "--query-gpu=index,name,memory.total,memory.used,compute_cap",
+ "--format=csv,noheader,nounits")).Output()
+ if err != nil {
+ return nil, fmt.Errorf("nvidia-smi a échoué: %w", err)
+ }
+ var gpus []gpuInfo
+ 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])
+ }
+ idx, _ := strconv.Atoi(parts[0])
+ gpus = append(gpus, gpuInfo{
+ Index: idx, Name: parts[1], MemTotal: parts[2], MemUsed: parts[3], Cap: parts[4],
+ })
+ }
+ if len(gpus) == 0 {
+ return nil, fmt.Errorf("aucun GPU NVIDIA détecté")
+ }
+ return gpus, nil
+}
diff --git a/internal/ajean/backend_llamacpp.go b/internal/ajean/backend_llamacpp.go
new file mode 100644
index 0000000..25834fc
--- /dev/null
+++ b/internal/ajean/backend_llamacpp.go
@@ -0,0 +1,522 @@
+package ajean
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// backend_llamacpp.go — gestion du backend llama.cpp (clone, build, mise à jour).
+//
+// `ajean llamacpp install` installe un build neuf, détecte automatiquement
+// l'accélérateur (CUDA / ROCm / Metal / CPU) et la
+// compute capability du GPU, puis pointe BIN dessus.
+// `ajean llamacpp update` met à jour le dépôt existant (git pull) et recompile
+// avec la bonne config, sans intervention.
+// `ajean llamacpp status` montre le commit courant, le backend détecté et le
+// retard éventuel sur origin.
+
+const llamacppRepoURL = "https://github.com/ggml-org/llama.cpp.git"
+
+// buildPlan capture les flags CMake adaptés à la machine courante.
+type buildPlan struct {
+ backend string // "cuda" | "hip" | "metal" | "vulkan" | "cpu"
+ cudaArch string // ex. "120" ou "86;89" (vide => détection native par CMake)
+ cudaCXX string // chemin de nvcc quand backend == cuda
+ flags []string // flags -D… passés à `cmake -B build`
+ jobs int // parallélisme du build
+ gen string // générateur CMake (-G), vide => défaut de la plateforme
+ genArch string // architecture du générateur (-A), ex. "x64" (VS uniquement)
+}
+
+func cmdLlamacpp(args []string) error {
+ sub := ""
+ if len(args) > 0 {
+ sub = args[0]
+ args = args[1:]
+ }
+ switch sub {
+ case "install":
+ return llamacppInstall(args)
+ case "update":
+ return llamacppUpdate(args)
+ case "status", "":
+ return llamacppStatus(args)
+ case "prebuilt":
+ // Binaires officiels précompilés (aucune compilation) — voir backend_prebuilt.go.
+ bin, err := prebuiltInstall(
+ func(s string) { fmt.Println(" " + s) },
+ func(s string) { fmt.Printf("%s %s\n", cyan("▶"), s) },
+ )
+ if err != nil {
+ return err
+ }
+ if err := SetConfigKey("BIN", bin); err != nil {
+ return fmt.Errorf("binaires installés mais échec d'écriture de BIN : %w", err)
+ }
+ fmt.Printf("%s BIN mis à jour — %s pour appliquer\n", green("✓"), bold("ajean restart"))
+ return nil
+ default:
+ return fmt.Errorf("sous-commande inconnue: %s (install | update | prebuilt | status)", sub)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Localisation du dépôt
+// ---------------------------------------------------------------------------
+
+// llamacppRepoDir resolves the llama.cpp checkout: derived from config BIN when
+// possible (so `update` targets whatever build the service actually runs),
+// otherwise the default under $AJEAN_HOME/backends/llama.cpp.
+func llamacppRepoDir() string {
+ if bin := ReadConfig()["BIN"]; bin != "" {
+ if real, err := filepath.EvalSymlinks(bin); err == nil {
+ bin = real
+ }
+ if root := findRepoRoot(bin); root != "" {
+ return root
+ }
+ }
+ return defaultRepoDir()
+}
+
+func defaultRepoDir() string {
+ return filepath.Join(backendsDir(), "llama.cpp")
+}
+
+// findRepoRoot walks up from a binary path (…/build/bin/llama-server) looking
+// for the llama.cpp source root (a dir holding .git or CMakeLists.txt).
+func findRepoRoot(binPath string) string {
+ d := filepath.Dir(binPath)
+ for i := 0; i < 6; i++ {
+ if isDir(filepath.Join(d, ".git")) || isFile(filepath.Join(d, "CMakeLists.txt")) {
+ return d
+ }
+ parent := filepath.Dir(d)
+ if parent == d {
+ break
+ }
+ d = parent
+ }
+ return ""
+}
+
+// ---------------------------------------------------------------------------
+// install
+// ---------------------------------------------------------------------------
+
+func llamacppInstall(args []string) error {
+ repo := defaultRepoDir()
+ ref := ""
+ force := false
+ noSwitch := false
+ customURL := ""
+ customName := ""
+ for _, a := range args {
+ switch {
+ case strings.HasPrefix(a, "--dir="):
+ repo = strings.TrimPrefix(a, "--dir=")
+ case strings.HasPrefix(a, "--ref="):
+ ref = strings.TrimPrefix(a, "--ref=")
+ case strings.HasPrefix(a, "--repo="):
+ customURL = strings.TrimPrefix(a, "--repo=")
+ case strings.HasPrefix(a, "--name="):
+ customName = strings.TrimPrefix(a, "--name=")
+ case a == "--force":
+ force = true
+ case a == "--no-switch":
+ noSwitch = true
+ default:
+ return fmt.Errorf("option inconnue: %s", a)
+ }
+ }
+
+ // Backend CUSTOM : fork llama.cpp installé depuis une URL Git dans
+ // backends/. On NE touche PAS au BIN global — un backend custom se
+ // choisit par modèle (preset → section Moteur → « backend détecté »). C'est
+ // exactement le cas d'un moteur ternaire type PrismML qui ne sert qu'à un
+ // seul modèle : le rattacher globalement casserait les autres presets.
+ if customURL != "" {
+ bin, err := installCustomBackend(customURL, customName, ref, func(s string) {
+ fmt.Printf("%s %s\n", cyan("▶"), s)
+ })
+ if err != nil {
+ return err
+ }
+ fmt.Printf("\n%s backend custom compilé : %s\n", green("✓"), bin)
+ fmt.Printf("Pour l'utiliser : édite un modèle → section %s → « backend détecté » et choisis-le.\n", bold("Moteur"))
+ return nil
+ }
+
+ if err := requireTools("git", "cmake"); err != nil {
+ return err
+ }
+ if err := ensureCompiler(); err != nil {
+ return err
+ }
+ ensureAccelerator() // best-effort : installe le toolkit GPU si une carte est détectée
+
+ // Dépôt déjà présent ? On bascule sur update plutôt que de re-cloner.
+ if isDir(filepath.Join(repo, ".git")) {
+ if !force {
+ fmt.Printf("%s dépôt déjà présent dans %s\n", yellow("[info]"), repo)
+ fmt.Printf(" → %s pour le mettre à jour, ou --force pour repartir de zéro\n", bold("ajean llamacpp update"))
+ return nil
+ }
+ fmt.Printf("%s --force : suppression de %s\n", yellow("[info]"), repo)
+ if err := os.RemoveAll(repo); err != nil {
+ return err
+ }
+ }
+
+ if err := os.MkdirAll(filepath.Dir(repo), 0o755); err != nil {
+ return err
+ }
+
+ fmt.Printf("%s clone de llama.cpp dans %s\n", cyan("▶"), repo)
+ if err := runStep("git clone", "", "git", "clone", "--depth=1", llamacppRepoURL, repo); err != nil {
+ return err
+ }
+ if ref != "" {
+ // --depth=1 ne récupère que HEAD ; on approfondit pour atteindre le ref.
+ _ = runStep("git fetch", repo, "git", "fetch", "--unshallow", "origin")
+ if err := runStep("git checkout", repo, "git", "checkout", ref); err != nil {
+ return err
+ }
+ }
+
+ plan := detectBuildPlan()
+ printPlan(plan, repo)
+
+ if err := buildLlamacpp(repo, plan, true); err != nil {
+ return err
+ }
+
+ bin := llamaServerBin(repo)
+ if bin == "" {
+ return fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build"))
+ }
+ fmt.Printf("\n%s binaire compilé : %s\n", green("✓"), bin)
+
+ if noSwitch {
+ fmt.Printf("%s --no-switch : configuration inchangée (BIN à régler manuellement)\n", dim("[info]"))
+ return nil
+ }
+ if err := SetConfigKey("BIN", bin); err != nil {
+ return fmt.Errorf("build ok mais échec d'écriture de BIN : %w", err)
+ }
+ fmt.Printf("%s BIN mis à jour\n", green("✓"))
+ fmt.Printf("\nProchaines étapes :\n 1. renseigne MODEL : %s\n 2. démarre : %s\n",
+ bold("ajean edit"), bold("ajean restart"))
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// update
+// ---------------------------------------------------------------------------
+
+func llamacppUpdate(args []string) error {
+ ref := ""
+ clean := false
+ noRestart := false
+ force := false
+ for _, a := range args {
+ switch {
+ case strings.HasPrefix(a, "--ref="):
+ ref = strings.TrimPrefix(a, "--ref=")
+ case a == "--clean":
+ clean = true
+ case a == "--no-restart":
+ noRestart = true
+ case a == "--force":
+ force = true
+ default:
+ return fmt.Errorf("option inconnue: %s", a)
+ }
+ }
+
+ if err := requireTools("git", "cmake"); err != nil {
+ return err
+ }
+ if err := ensureCompiler(); err != nil {
+ return err
+ }
+ ensureAccelerator() // best-effort : installe le toolkit GPU si une carte est détectée
+
+ repo := llamacppRepoDir()
+ if !isDir(filepath.Join(repo, ".git")) {
+ return fmt.Errorf("aucun dépôt llama.cpp trouvé (%s).\n → lance d'abord %s", repo, bold("ajean llamacpp install"))
+ }
+ fmt.Printf("%s dépôt : %s\n", cyan("▶"), repo)
+
+ oldCommit := gitOutput(repo, "rev-parse", "--short", "HEAD")
+
+ // Détermine la branche à suivre (master par défaut si HEAD détaché).
+ branch := ref
+ if branch == "" {
+ 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 {
+ return err
+ }
+
+ // Déjà à jour ? On s'arrête (sauf --clean / --force qui forcent un rebuild).
+ localRev := gitOutput(repo, "rev-parse", "HEAD")
+ remoteRev := gitOutput(repo, "rev-parse", "origin/"+branch)
+ if localRev != "" && localRev == remoteRev && !clean && !force && llamaServerBin(repo) != "" {
+ fmt.Printf("%s déjà à jour (%s) — rien à faire\n", green("[ok]"), oldCommit)
+ fmt.Printf(" (utilise %s pour forcer une recompilation)\n", dim("--force"))
+ return nil
+ }
+
+ // Met à jour la source.
+ if ref != "" {
+ if err := runStep("git checkout", repo, "git", "checkout", ref); err != nil {
+ return err
+ }
+ } else {
+ if err := runStep("git pull --ff-only", repo, "git", "pull", "--ff-only", "origin", branch); err != nil {
+ return fmt.Errorf("git pull a échoué (modifs locales ? essaie de résoudre à la main): %w", err)
+ }
+ }
+ newCommit := gitOutput(repo, "rev-parse", "--short", "HEAD")
+
+ // On stoppe le service : le binaire en cours d'exécution ne peut pas être
+ // réécrit par l'étape de link (« Text file busy »).
+ svcWasUp := serviceIsActive()
+ if svcWasUp {
+ fmt.Printf("%s arrêt du service %s le temps du build…\n", yellow("[info]"), serviceName())
+ if err := serviceAction("stop"); err != nil {
+ fmt.Printf("%s impossible d'arrêter le service (%v) — le build peut échouer si le binaire est verrouillé\n", yellow("[warn]"), err)
+ }
+ }
+
+ plan := detectBuildPlan()
+ printPlan(plan, repo)
+
+ if err := buildLlamacpp(repo, plan, clean); err != nil {
+ // On tente de remettre le service debout même en cas d'échec.
+ if svcWasUp && !noRestart {
+ _ = serviceAction("start")
+ }
+ return err
+ }
+ bin := llamaServerBin(repo)
+ if bin == "" {
+ return fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(repo, "build"))
+ }
+ if err := SetConfigKey("BIN", bin); err != nil {
+ return fmt.Errorf("build ok mais échec écriture BIN dans config.env: %w", err)
+ }
+
+ fmt.Printf("\n%s mis à jour : %s → %s\n", green("✓"), oldCommit, newCommit)
+
+ if noRestart {
+ fmt.Printf("%s --no-restart : pense à lancer %s\n", dim("[info]"), bold("ajean restart"))
+ return nil
+ }
+ if svcWasUp {
+ fmt.Printf("%s redémarrage du service…\n", cyan("▶"))
+ return serviceAction("start")
+ }
+ fmt.Printf("%s service non démarré auparavant — lance %s quand tu veux\n", dim("[info]"), bold("ajean start"))
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// status
+// ---------------------------------------------------------------------------
+
+func llamacppStatus(args []string) error {
+ repo := llamacppRepoDir()
+ fmt.Printf("%s\n", bold("llama.cpp"))
+ fmt.Printf(" dépôt : %s\n", repo)
+ if !isDir(filepath.Join(repo, ".git")) {
+ fmt.Printf(" %s pas encore installé — %s\n", yellow("état"), bold("ajean llamacpp install"))
+ return nil
+ }
+ commit := gitOutput(repo, "log", "-1", "--format=%h %ci %s")
+ branch := gitOutput(repo, "rev-parse", "--abbrev-ref", "HEAD")
+ fmt.Printf(" branche : %s\n", branch)
+ fmt.Printf(" commit : %s\n", commit)
+
+ if bin := llamaServerBin(repo); bin != "" {
+ fmt.Printf(" binaire : %s\n", green(bin))
+ } else {
+ fmt.Printf(" binaire : %s (pas encore compilé)\n", yellow("absent"))
+ }
+
+ // Retard sur origin (best-effort, sans fetch réseau).
+ if branch != "" && branch != "HEAD" {
+ if behind := gitOutput(repo, "rev-list", "--count", "HEAD..origin/"+branch); behind != "" && behind != "0" {
+ fmt.Printf(" maj : %s commit(s) de retard sur origin/%s — %s\n", yellow(behind), branch, bold("ajean llamacpp update"))
+ }
+ }
+
+ plan := detectBuildPlan()
+ fmt.Printf(" backend : %s\n", planLabel(plan))
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Détection matérielle & build
+// ---------------------------------------------------------------------------
+
+// detectBuildPlan probes the machine and returns the CMake flags for the best
+// available accelerator. Order of preference: CUDA → ROCm/HIP → Metal (macOS)
+// → Vulkan → CPU.
+// (implémentation dans backend_build.go)
+
+// ---------------------------------------------------------------------------
+// Backends custom (fork llama.cpp installé depuis une URL Git)
+// ---------------------------------------------------------------------------
+
+// installCustomBackend clone (ou met à jour) un fork de llama.cpp depuis `url`
+// dans backends/ et compile llama-server avec le plan détecté pour la
+// machine. Il NE touche PAS à BIN : un backend custom se choisit par modèle
+// (éditeur de preset → section Moteur → « backend détecté »). Renvoie le chemin
+// du binaire compilé. `phase` reçoit les étapes de haut niveau (clone, build…) ;
+// la sortie détaillée du build passe par le sink habituel (terminal en CLI,
+// job web sinon).
+func installCustomBackend(url, name, ref string, phase func(string)) (string, error) {
+ if phase == nil {
+ phase = func(string) {}
+ }
+ url = strings.TrimSpace(url)
+ if url == "" {
+ return "", fmt.Errorf("URL du dépôt vide")
+ }
+ if !looksLikeGitURL(url) {
+ return "", fmt.Errorf("URL de dépôt invalide (attendu https://…, git@… ou ssh://…) : %s", url)
+ }
+ if strings.TrimSpace(name) == "" {
+ name = deriveBackendName(url)
+ }
+ dir, err := backendDir(name)
+ if err != nil {
+ return "", err
+ }
+
+ phase("vérification des outils (git, cmake, compilateur)…")
+ if err := requireTools("git", "cmake"); err != nil {
+ return "", err
+ }
+ if err := ensureCompiler(); err != nil {
+ return "", err
+ }
+ ensureAccelerator() // best-effort : installe le toolkit GPU si une carte est détectée
+
+ if isDir(filepath.Join(dir, ".git")) {
+ // Backend déjà cloné : on le met à jour plutôt que de re-cloner.
+ phase("dépôt déjà présent — mise à jour…")
+ _ = runStep("git fetch", dir, "git", "fetch", "origin", "--quiet")
+ if ref != "" {
+ if err := runStep("git checkout", dir, "git", "checkout", ref); err != nil {
+ return "", err
+ }
+ } else if branch := gitOutput(dir, "rev-parse", "--abbrev-ref", "HEAD"); branch != "" && branch != "HEAD" {
+ _ = runStep("git pull --ff-only", dir, "git", "pull", "--ff-only", "origin", branch)
+ }
+ } else {
+ if err := os.MkdirAll(filepath.Dir(dir), 0o755); err != nil {
+ return "", err
+ }
+ phase("clone de " + url + "…")
+ if err := runStep("git clone", "", "git", "clone", "--depth=1", url, dir); err != nil {
+ return "", fmt.Errorf("git clone a échoué : %w", err)
+ }
+ if ref != "" {
+ // --depth=1 ne récupère que HEAD ; on approfondit pour atteindre le ref.
+ _ = runStep("git fetch", dir, "git", "fetch", "--unshallow", "origin")
+ if err := runStep("git checkout", dir, "git", "checkout", ref); err != nil {
+ return "", err
+ }
+ }
+ }
+
+ plan := detectBuildPlan()
+ phase(fmt.Sprintf("compilation (backend=%s)…", plan.backend))
+ if err := buildLlamacpp(dir, plan, false); err != nil {
+ return "", err
+ }
+ bin := llamaServerBin(dir)
+ if bin == "" {
+ return "", fmt.Errorf("build terminé mais binaire introuvable sous %s", filepath.Join(dir, "build"))
+ }
+ return bin, nil
+}
+
+func looksLikeGitURL(u string) bool {
+ for _, p := range []string{"https://", "http://", "git@", "ssh://", "git://"} {
+ if strings.HasPrefix(u, p) {
+ return true
+ }
+ }
+ return false
+}
+
+// deriveBackendName construit un nom de dossier de backend à partir d'une URL de
+// dépôt. On garde le nom du dépôt, préfixé du propriétaire quand ça éviterait
+// une collision avec le backend canonique (backends/llama.cpp).
+func deriveBackendName(url string) string {
+ s := strings.TrimSuffix(strings.TrimSpace(url), ".git")
+ s = strings.TrimRight(s, "/")
+ parts := strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == ':' })
+ repo, owner := "", ""
+ if n := len(parts); n > 0 {
+ repo = parts[n-1]
+ if n > 1 {
+ owner = parts[n-2]
+ }
+ }
+ name := repo
+ if name == "" {
+ name = "backend"
+ }
+ // Un fork nommé « llama.cpp » écraserait le backend optimisé canonique ; on
+ // le distingue par le propriétaire (ex. llama.cpp-prismml-eng).
+ if strings.EqualFold(name, "llama.cpp") && owner != "" {
+ name = name + "-" + owner
+ }
+ return sanitizeBackendName(name)
+}
+
+// sanitizeBackendName réduit un nom à un identifiant de dossier sûr
+// ([a-z0-9._-]), pour qu'il ne puisse jamais s'échapper de backends/.
+func sanitizeBackendName(name string) string {
+ name = strings.ToLower(strings.TrimSpace(name))
+ var b strings.Builder
+ for _, r := range name {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '.', r == '_', r == '-':
+ b.WriteRune(r)
+ case r == ' ' || r == '/' || r == '\\':
+ b.WriteByte('-')
+ }
+ }
+ out := strings.Trim(b.String(), ".-")
+ if out == "" {
+ return "backend"
+ }
+ return out
+}
+
+// backendDir renvoie backends/, en refusant tout nom qui s'échapperait du
+// dossier backends (../, chemin absolu…).
+func backendDir(name string) (string, error) {
+ name = sanitizeBackendName(name)
+ root, err := filepath.Abs(backendsDir())
+ if err != nil {
+ return "", err
+ }
+ p := filepath.Join(root, name)
+ if !strings.HasPrefix(p, root+string(filepath.Separator)) {
+ return "", fmt.Errorf("nom de backend invalide")
+ }
+ return p, nil
+}
diff --git a/internal/ajean/backend_model_dirs.go b/internal/ajean/backend_model_dirs.go
new file mode 100644
index 0000000..b77611d
--- /dev/null
+++ b/internal/ajean/backend_model_dirs.go
@@ -0,0 +1,273 @@
+package ajean
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+)
+
+// Les .gguf vivent dans $AJEAN_HOME/models, mais un modèle de 40 Go tient
+// rarement sur le disque système : on autorise des dossiers supplémentaires
+// (disque dur externe, second SSD, montage réseau…). La liste est persistée en
+// base — PAS dans la configuration, que le changement de preset remplace — et
+// peut aussi venir de $AJEAN_MODEL_DIRS.
+
+// modelDirsListSep sépare les dossiers dans $AJEAN_MODEL_DIRS (':' sous Unix,
+// ';' sous Windows, comme le PATH).
+func modelDirsListSep() string {
+ if runtime.GOOS == "windows" {
+ return ";"
+ }
+ return ":"
+}
+
+// extraModelDirs renvoie les dossiers déclarés par l'utilisateur (base puis
+// variable d'environnement), nettoyés et dédoublonnés, models/ exclu.
+func extraModelDirs() []string {
+ var raw []string
+ getJSON(bkState, "model_dirs", &raw)
+ if v := os.Getenv("AJEAN_MODEL_DIRS"); v != "" {
+ raw = append(raw, strings.Split(v, modelDirsListSep())...)
+ }
+ return dedupDirs(raw, modelsDir())
+}
+
+// dedupDirs nettoie une liste de chemins : vides retirés, chemins absolus
+// normalisés, doublons et `skip` écartés (comparaison insensible à la casse
+// sous Windows, comme le système de fichiers).
+func dedupDirs(list []string, skip string) []string {
+ seen := map[string]bool{normDir(skip): true}
+ out := []string{}
+ for _, d := range list {
+ d = strings.TrimSpace(d)
+ if d == "" {
+ continue
+ }
+ abs, err := filepath.Abs(d)
+ if err != nil {
+ continue
+ }
+ abs = filepath.Clean(abs)
+ k := normDir(abs)
+ if seen[k] {
+ continue
+ }
+ seen[k] = true
+ out = append(out, abs)
+ }
+ return out
+}
+
+// normDir met un chemin sous forme comparable (séparateurs unifiés, casse
+// neutralisée sous Windows, slash final retiré).
+func normDir(p string) string {
+ // Clean d'abord (il réintroduit le séparateur natif), puis on unifie en '/'.
+ p = strings.ReplaceAll(filepath.Clean(strings.TrimSpace(p)), "\\", "/")
+ if runtime.GOOS == "windows" {
+ p = strings.ToLower(p)
+ }
+ return p
+}
+
+// modelDirs renvoie tous les dossiers où chercher un .gguf : models/ d'abord
+// (il reste la destination des téléchargements), puis les dossiers ajoutés.
+func modelDirs() []string {
+ return append([]string{modelsDir()}, extraModelDirs()...)
+}
+
+// saveExtraModelDirs enregistre la liste des dossiers supplémentaires.
+func saveExtraModelDirs(dirs []string) error {
+ return putJSON(bkState, "model_dirs", dedupDirs(dirs, modelsDir()))
+}
+
+// pathWithin dit si le fichier p est dans le dossier dir (ou un sous-dossier).
+func pathWithin(dir, p string) bool {
+ d, f := normDir(dir), normDir(p)
+ return f == d || strings.HasPrefix(f, d+"/")
+}
+
+// baseName renvoie le nom de fichier d'un chemin en acceptant les DEUX
+// séparateurs : un config.env écrit sous Windows (C:\models\x.gguf) peut être
+// relu par un ajean Linux, où filepath.Base ne coupe pas sur '\'.
+func baseName(p string) string {
+ p = strings.TrimRight(strings.TrimSpace(p), `/\`)
+ if i := strings.LastIndexAny(p, `/\`); i >= 0 {
+ return p[i+1:]
+ }
+ return p
+}
+
+// resolveServeModelPath résout MODEL= pour LANCER le moteur. Différence avec
+// resolveModelPath : un chemin absolu qui pointe sur un .gguf existant est
+// accepté même hors des dossiers déclarés. L'allowlist protège des opérations
+// destructrices pilotées depuis l'UI (suppression d'un fichier) ; l'appliquer à
+// « ouvrir ce modèle en lecture » rendait la config éditée à la main
+// inutilisable : MODEL=/home/moi/models/x.gguf renvoyait « dossier non
+// autorisé », le moteur mourait en boucle, et le seul remède connu était
+// d'aller déclarer le dossier dans l'interface web.
+func resolveServeModelPath(name string) (string, error) {
+ p, err := resolveModelPath(name)
+ if err == nil {
+ return p, nil
+ }
+ s := strings.TrimSpace(strings.Trim(strings.TrimSpace(name), `"`))
+ if filepath.IsAbs(s) && strings.HasSuffix(strings.ToLower(s), ".gguf") {
+ abs := filepath.Clean(s)
+ if st, e := os.Stat(abs); e == nil && !st.IsDir() {
+ return abs, nil
+ }
+ }
+ return "", err
+}
+
+// resolveModelPath transforme une valeur MODEL= (nom de fichier OU chemin
+// absolu) en chemin absolu vers un .gguf. Un chemin absolu n'est accepté que
+// s'il est dans un des dossiers déclarés — sinon l'UI web deviendrait un moyen
+// de lire/supprimer n'importe quel fichier de la machine. Un simple nom de
+// fichier est cherché dans chaque dossier, AJEAN_HOME en premier.
+func resolveModelPath(name string) (string, error) {
+ s := strings.TrimSpace(strings.Trim(strings.TrimSpace(name), `"`))
+ if s == "" {
+ return "", fmt.Errorf("nom de modèle invalide")
+ }
+ if !strings.HasSuffix(strings.ToLower(s), ".gguf") {
+ return "", fmt.Errorf("le modèle doit être un fichier .gguf")
+ }
+ if filepath.IsAbs(s) {
+ abs := filepath.Clean(s)
+ for _, d := range modelDirs() {
+ if pathWithin(d, abs) {
+ return abs, nil
+ }
+ }
+ return "", fmt.Errorf("dossier non autorisé : %s — ajoute-le dans « Dossiers de modèles »", filepath.Dir(abs))
+ }
+ base := baseName(s)
+ if base == "" || base == "." {
+ return "", fmt.Errorf("nom de modèle invalide")
+ }
+ for _, d := range modelDirs() {
+ p := filepath.Join(d, base)
+ if st, err := os.Stat(p); err == nil && !st.IsDir() {
+ return p, nil
+ }
+ }
+ return filepath.Join(modelsDir(), base), nil // introuvable : l'appelant décidera
+}
+
+// modelDirCount compte les .gguf lisibles d'un dossier (-1 si illisible).
+func modelDirCount(dir string) int {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return -1
+ }
+ n := 0
+ for _, e := range entries {
+ // Les tranches 2..N d'un modèle découpé ne comptent pas pour un modèle :
+ // sinon « 3 modèles » s'affichait pour un seul, celui qui tient en trois
+ // fichiers. Même règle que la liste du sélecteur (handleModels).
+ if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".gguf") && !isFollowerShard(e.Name()) {
+ n++
+ }
+ }
+ return n
+}
+
+// diskFree renvoie l'espace libre du volume qui porte dir, ou -1 s'il est
+// inconnu. Le dossier n'existe pas forcément encore (models/ au premier
+// lancement) : on remonte les parents jusqu'à en trouver un qui existe.
+func diskFree(dir string) int64 {
+ d, err := filepath.Abs(strings.TrimSpace(dir))
+ if err != nil {
+ return -1
+ }
+ for {
+ if st, err := os.Stat(d); err == nil && st.IsDir() {
+ return diskFreeAt(d)
+ }
+ parent := filepath.Dir(d)
+ if parent == d {
+ return -1
+ }
+ d = parent
+ }
+}
+
+// resolveDownloadDir valide le dossier de destination demandé pour un
+// téléchargement : vide = models/, sinon il doit être un des dossiers
+// déclarés — sans quoi l'UI web permettrait d'écrire n'importe où sur la
+// machine.
+func resolveDownloadDir(dir string) (string, error) {
+ dir = strings.TrimSpace(dir)
+ if dir == "" {
+ return modelsDir(), nil
+ }
+ abs, err := filepath.Abs(dir)
+ if err != nil {
+ return "", fmt.Errorf("chemin invalide")
+ }
+ for _, d := range modelDirs() {
+ if normDir(d) == normDir(abs) {
+ return d, nil
+ }
+ }
+ return "", fmt.Errorf("dossier non autorisé : %s — ajoute-le dans « Dossiers de modèles »", abs)
+}
+
+// handleModelDirs : GET liste les dossiers de modèles (AJEAN_HOME + ajoutés),
+// POST {path, action:"add"|"remove"} en ajoute ou en retire un.
+func handleModelDirs(w http.ResponseWriter, r *http.Request) {
+ if r.Method == "POST" {
+ var req struct {
+ Path string `json:"path"`
+ Action string `json:"action"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ p := strings.TrimSpace(req.Path)
+ if p == "" {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin vide"})
+ return
+ }
+ cur := extraModelDirs()
+ if req.Action == "remove" {
+ kept := []string{}
+ for _, d := range cur {
+ if normDir(d) != normDir(p) {
+ kept = append(kept, d)
+ }
+ }
+ cur = kept
+ } else {
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": "chemin invalide"})
+ return
+ }
+ st, err := os.Stat(abs)
+ if err != nil || !st.IsDir() {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": "dossier introuvable : " + abs})
+ return
+ }
+ cur = append(cur, abs)
+ }
+ if err := saveExtraModelDirs(cur); err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ sendJSON(w, 200, map[string]any{"ok": true})
+ return
+ }
+ out := []map[string]any{{"path": modelsDir(), "home": true, "count": modelDirCount(modelsDir()), "exists": true, "free": diskFree(modelsDir())}}
+ for _, d := range extraModelDirs() {
+ n := modelDirCount(d)
+ out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0, "free": diskFree(d)})
+ }
+ sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("AJEAN_MODEL_DIRS") != ""})
+}
diff --git a/internal/ajean/backend_model_dirs_test.go b/internal/ajean/backend_model_dirs_test.go
new file mode 100644
index 0000000..fbad3f1
--- /dev/null
+++ b/internal/ajean/backend_model_dirs_test.go
@@ -0,0 +1,73 @@
+package ajean
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// Un modèle posé hors de models/ (disque externe) doit être utilisable dès
+// que son dossier est déclaré — et refusé tant qu'il ne l'est pas.
+func TestResolveModelPathExtraDir(t *testing.T) {
+ testHome(t)
+ ext := t.TempDir()
+ t.Setenv("AJEAN_MODEL_DIRS", "")
+
+ extModel := filepath.Join(ext, "gros.gguf")
+ if err := os.WriteFile(extModel, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ if _, err := resolveModelPath(extModel); err == nil {
+ t.Fatal("un dossier non déclaré devrait être refusé")
+ }
+
+ if err := saveExtraModelDirs([]string{ext}); err != nil {
+ t.Fatal(err)
+ }
+ got, err := resolveModelPath(extModel)
+ if err != nil {
+ t.Fatalf("chemin absolu déclaré refusé : %v", err)
+ }
+ if got != filepath.Clean(extModel) {
+ t.Fatalf("chemin = %s, attendu %s", got, extModel)
+ }
+ // Le simple nom de fichier doit aussi être retrouvé dans le dossier ajouté.
+ if got, err := resolveModelPath("gros.gguf"); err != nil || got != extModel {
+ t.Fatalf("nom seul = %q (%v), attendu %s", got, err, extModel)
+ }
+}
+
+// models/ garde la priorité et les non-.gguf restent refusés.
+func TestResolveModelPathHomeFirst(t *testing.T) {
+ testHome(t)
+ ext := t.TempDir()
+ t.Setenv("AJEAN_MODEL_DIRS", ext)
+ if err := os.MkdirAll(modelsDir(), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, d := range []string{modelsDir(), ext} {
+ if err := os.WriteFile(filepath.Join(d, "m.gguf"), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := resolveModelPath("m.gguf")
+ if err != nil || got != filepath.Join(modelsDir(), "m.gguf") {
+ t.Fatalf("got %q (%v), attendu le fichier de models/", got, err)
+ }
+ if _, err := resolveModelPath("/etc/passwd"); err == nil {
+ t.Fatal("un fichier non-.gguf devrait être refusé")
+ }
+}
+
+// La ligne MODEL= garde son chemin complet, mais la quantization se déduit
+// toujours du nom de fichier.
+func TestPresetModelKeepsPath(t *testing.T) {
+ content := "MODEL=\"/mnt/ext/Qwen3-30B-Q5_K_M.gguf\"\nCTX=8192\n"
+ if got := modelFromPresetContent(content); got != "/mnt/ext/Qwen3-30B-Q5_K_M.gguf" {
+ t.Fatalf("MODEL = %q, chemin tronqué", got)
+ }
+ if got := detectQuant(content); got != "Q5_K_M" {
+ t.Fatalf("quant = %q, attendu Q5_K_M", got)
+ }
+}
diff --git a/internal/ajean/backend_models.go b/internal/ajean/backend_models.go
new file mode 100644
index 0000000..39fae47
--- /dev/null
+++ b/internal/ajean/backend_models.go
@@ -0,0 +1,782 @@
+package ajean
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+)
+
+// quantSegRe matches a single name segment that looks like a GGUF quantization
+// token: Q8_0, Q6_K, Q5_K_M, Q4_K_XL, IQ4_XS, IQ3_XXS, Q4, 4bpw, BF16, F16…
+var quantSegRe = regexp.MustCompile(`(?i)^(I?Q\d+(_[A-Za-z0-9]+)*|\d+BPW|BF16|FP16|F16|FP32|F32)$`)
+
+// quantFromName extracts a quantization tag from a model filename by splitting
+// on '-' and '.' and keeping the longest segment that looks like a quant token.
+// Returns "" when nothing matches.
+func quantFromName(name string) string {
+ base := name
+ if dot := strings.LastIndexByte(base, '.'); dot >= 0 && strings.EqualFold(base[dot:], ".gguf") {
+ base = base[:dot]
+ }
+ segs := strings.FieldsFunc(base, func(r rune) bool { return r == '-' || r == '.' })
+ best := ""
+ for _, seg := range segs {
+ if quantSegRe.MatchString(seg) && len(seg) > len(best) {
+ best = seg
+ }
+ }
+ return strings.ToUpper(best)
+}
+
+// presetReasoning returns the raw REASONING= value from a preset's config.env
+// body, or "" if absent.
+func presetReasoning(content string) string {
+ for _, line := range strings.Split(content, "\n") {
+ s := strings.TrimSpace(line)
+ if s == "" || strings.HasPrefix(s, "#") {
+ continue
+ }
+ i := strings.IndexByte(s, '=')
+ if i < 0 {
+ continue
+ }
+ if strings.EqualFold(strings.TrimSpace(s[:i]), "REASONING") {
+ return unquoteValue(strings.TrimSpace(s[i+1:]))
+ }
+ }
+ return ""
+}
+
+// reasoningActive reports whether a REASONING= value enables reasoning. backend_serve.go
+// passes the flag whenever the value is non-empty, but an explicit off/none is
+// treated here as disabled so the UI badge isn't misleading.
+func reasoningActive(v string) bool {
+ switch strings.ToLower(strings.TrimSpace(v)) {
+ case "", "off", "none", "false", "0", "no", "disable", "disabled":
+ return false
+ }
+ return true
+}
+
+// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
+// (manual override, with or without a leading '#') wins; otherwise it is
+// auto-detected from the MODEL= filename. Returns "" when unknown.
+func detectQuant(content string) string {
+ for _, line := range strings.Split(content, "\n") {
+ s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
+ i := strings.IndexByte(s, '=')
+ if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "QUANT") {
+ if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" {
+ return strings.ToUpper(v)
+ }
+ }
+ }
+ return quantFromName(baseName(modelFromPresetContent(content)))
+}
+
+// downloadDestPath resolves the destination of a downloaded model : le dossier
+// demandé (vide = AJEAN_HOME) parmi les dossiers de modèles déclarés, en
+// refusant tout ce qui en sortirait (path traversal).
+func downloadDestPath(name, dir string) (string, error) {
+ base := filepath.Base(strings.TrimSpace(name))
+ if base == "" || base == "." || base == string(filepath.Separator) {
+ return "", fmt.Errorf("nom de modèle invalide")
+ }
+ if !strings.HasSuffix(strings.ToLower(base), ".gguf") {
+ return "", fmt.Errorf("seuls les fichiers .gguf sont acceptés")
+ }
+ d, err := resolveDownloadDir(dir)
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(d, base), nil
+}
+
+// modelFromPresetContent extracts the MODEL= value from a preset's config.env
+// body (nom de fichier ou chemin absolu, tel quel), or "" if absent.
+func modelFromPresetContent(content string) string {
+ for _, line := range strings.Split(content, "\n") {
+ s := strings.TrimSpace(line)
+ if s == "" || strings.HasPrefix(s, "#") {
+ continue
+ }
+ i := strings.IndexByte(s, '=')
+ if i < 0 {
+ continue
+ }
+ if strings.TrimSpace(s[:i]) == "MODEL" {
+ return unquoteValue(strings.TrimSpace(s[i+1:]))
+ }
+ }
+ return ""
+}
+
+// deleteModelFile removes a .gguf file from one of the declared model folders
+// after validating the name. Un modèle découpé emporte TOUTES ses tranches :
+// n'effacer que la première laissait des dizaines de Go de fichiers que plus
+// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les
+// tranches suivantes n'y apparaissent pas).
+func deleteModelFile(name string) error {
+ p, err := resolveModelPath(name)
+ if err != nil {
+ return err
+ }
+ if err := os.Remove(p); err != nil {
+ if os.IsNotExist(err) {
+ return fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
+ }
+ return err
+ }
+ dir := filepath.Dir(p)
+ for _, n := range shardFamily(filepath.Base(p)) {
+ _ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire
+ }
+ return nil
+}
+
+// handleModelDelete deletes a single .gguf from AJEAN_HOME.
+func handleModelDelete(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 := deleteModelFile(req.Name); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ sendJSON(w, 200, map[string]any{"ok": true})
+}
+
+// ---- Hugging Face downloads -------------------------------------------------
+
+// dlState tracks a single in-flight (or finished) model download.
+type dlState struct {
+ Filename string `json:"filename"`
+ URL string `json:"url"`
+ Dir string `json:"dir"` // dossier de destination
+ Total int64 `json:"total"`
+ Done int64 `json:"done"`
+ Speed int64 `json:"speed"` // bytes/s, smoothed over the last samples
+ Conns int `json:"conns"` // parallel connections actually used
+ Parts int `json:"parts"` // nombre de tranches (1 pour un modèle en un seul fichier)
+ Part int `json:"part"` // tranche en cours (1-based)
+ Finished bool `json:"finished"`
+ Canceled bool `json:"canceled"`
+ Err string `json:"error"`
+ StartedAt int64 `json:"started_at"`
+
+ cancel context.CancelFunc `json:"-"` // set while in flight, cleared on finish
+}
+
+var (
+ dlMu sync.Mutex
+ dlDownloads = map[string]*dlState{} // keyed by filename
+)
+
+// dlClient is shared by all download workers so connections to the HF CDN are
+// pooled and reused across chunks instead of re-handshaking TLS each time.
+var dlClient = &http.Client{
+ Timeout: 0, // large files: no overall timeout
+ Transport: &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ MaxIdleConns: 64,
+ MaxIdleConnsPerHost: 64,
+ MaxConnsPerHost: 0,
+ IdleConnTimeout: 90 * time.Second,
+ TLSHandshakeTimeout: 20 * time.Second,
+ ExpectContinueTimeout: 1 * time.Second,
+ // HTTP/1.1: truly parallel sockets, no shared h2 flow-control window.
+ ForceAttemptHTTP2: false,
+ WriteBufferSize: 64 << 10,
+ ReadBufferSize: 256 << 10,
+ },
+}
+
+// dlConns is the number of parallel range requests used per download.
+// Overridable with AJEAN_DL_CONNS (1 disables parallelism).
+func dlConns() int {
+ n := 8
+ if v := os.Getenv("AJEAN_DL_CONNS"); v != "" {
+ if p, err := strconv.Atoi(v); err == nil && p > 0 {
+ n = p
+ }
+ }
+ if n > 16 {
+ n = 16
+ }
+ return n
+}
+
+// dlMinChunk is the smallest slice worth a dedicated connection (16 MiB), so a
+// small file doesn't get split into a swarm of tiny requests.
+const dlMinChunk = 16 << 20
+
+// normalizeHFURL turns a Hugging Face "blob" page URL into a direct "resolve"
+// download URL, and leaves already-direct URLs untouched. Returns the URL to
+// fetch and the target filename.
+func normalizeHFURL(raw string) (string, string, error) {
+ raw = strings.TrimSpace(raw)
+ if raw == "" {
+ return "", "", fmt.Errorf("lien vide")
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ return "", "", fmt.Errorf("lien invalide: %v", err)
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return "", "", fmt.Errorf("lien invalide (http/https attendu)")
+ }
+ // huggingface.co//blob// → /resolve//
+ if strings.Contains(u.Host, "huggingface.co") {
+ u.Path = strings.Replace(u.Path, "/blob/", "/resolve/", 1)
+ }
+ name := path.Base(u.Path)
+ if name == "" || name == "/" || name == "." {
+ return "", "", fmt.Errorf("impossible de déduire le nom du fichier depuis le lien")
+ }
+ if !strings.HasSuffix(strings.ToLower(name), ".gguf") {
+ return "", "", fmt.Errorf("le lien doit pointer vers un fichier .gguf")
+ }
+ return u.String(), name, nil
+}
+
+// handleModelDownload kicks off a background download of a .gguf from a URL
+// (typically Hugging Face) into AJEAN_HOME. Progress is polled via
+// /api/models/download/status.
+func handleModelDownload(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ URL string `json:"url"`
+ Dir string `json:"dir"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ dlURL, name, err := normalizeHFURL(req.URL)
+ if err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ // Modèle découpé : le lien ne désigne qu'une tranche, on rapatrie la famille.
+ // Le modèle porte le nom de sa PREMIÈRE tranche — c'est elle qu'on passe à
+ // llama-server, et c'est donc elle qui identifie le téléchargement.
+ urls, names := shardURLSet(dlURL, name)
+ name = names[0]
+ dests := make([]string, len(names))
+ for i, n := range names {
+ if dests[i], err = downloadDestPath(n, req.Dir); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ }
+ if err := os.MkdirAll(filepath.Dir(dests[0]), 0o755); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": "dossier de destination inaccessible : " + err.Error()})
+ return
+ }
+
+ // Tranches déjà là : on ne les retélécharge pas. C'est aussi ce qui permet de
+ // relancer un téléchargement en plusieurs fichiers interrompu à la deuxième
+ // tranche sans repayer les 15 Go de la première.
+ var todoURLs, todoDests []string
+ for i := range names {
+ if st, err := os.Stat(dests[i]); err == nil && !st.IsDir() {
+ continue
+ }
+ todoURLs = append(todoURLs, urls[i])
+ todoDests = append(todoDests, dests[i])
+ }
+
+ dlMu.Lock()
+ if st, ok := dlDownloads[name]; ok && !st.Finished {
+ dlMu.Unlock()
+ sendJSON(w, 409, map[string]any{"ok": false, "error": "téléchargement déjà en cours pour " + name})
+ return
+ }
+ if len(todoURLs) == 0 {
+ dlMu.Unlock()
+ sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
+ return
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ st := &dlState{
+ Filename: name, URL: urls[0], Dir: filepath.Dir(dests[0]),
+ Parts: len(todoURLs), StartedAt: time.Now().Unix(), cancel: cancel,
+ }
+ dlDownloads[name] = st
+ dlMu.Unlock()
+
+ go runDownloadSet(ctx, st, todoURLs, todoDests)
+ sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "parts": len(todoURLs)})
+}
+
+// dlSpaceMargin est la marge laissée libre après le téléchargement : un disque
+// rempli à ras bord met en danger tout le reste (logs, .part d'un autre
+// modèle, swap).
+const dlSpaceMargin = 256 << 20
+
+// checkDiskSpace refuse le téléchargement si le fichier ne tient pas dans le
+// dossier visé. free < 0 = mesure impossible : on laisse passer plutôt que de
+// bloquer sur un système de fichiers exotique.
+func checkDiskSpace(dir string, size int64) error {
+ free := diskFree(dir)
+ if size <= 0 || free < 0 {
+ return nil
+ }
+ if free < size+dlSpaceMargin {
+ return fmt.Errorf("espace insuffisant sur %s : %s libres, %s nécessaires", dir, humanBytes(free), humanBytes(size+dlSpaceMargin))
+ }
+ return nil
+}
+
+// humanBytes formate une taille en Go/Mo pour les messages d'erreur.
+func humanBytes(n int64) string {
+ switch {
+ case n >= 1<<30:
+ return fmt.Sprintf("%.1f Go", float64(n)/float64(1<<30))
+ case n >= 1<<20:
+ return fmt.Sprintf("%.0f Mo", float64(n)/float64(1<<20))
+ default:
+ return fmt.Sprintf("%d o", n)
+ }
+}
+
+// handleModelDownloadProbe renseigne l'UI avant de lancer quoi que ce soit :
+// taille du fichier distant, espace libre du dossier visé, et si ça tient. Une
+// seule requête d'un octet côté CDN, donc c'est gratuit.
+func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ URL string `json:"url"`
+ Dir string `json:"dir"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ dlURL, name, err := normalizeHFURL(req.URL)
+ if err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ dir, err := resolveDownloadDir(req.Dir)
+ if err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ // Un modèle découpé se sonde EN ENTIER : annoncer les 15 Go de la première
+ // tranche alors qu'il en faut 45 sur le disque, c'est promettre que ça tient
+ // puis échouer au deux tiers du transfert.
+ urls, names := shardURLSet(dlURL, name)
+ name = names[0]
+ ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
+ defer cancel()
+ var total int64
+ for _, u := range urls {
+ n, _, err := dlProbe(ctx, u)
+ if err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ total += n
+ }
+ out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true, "parts": len(urls)}
+ if err := checkDiskSpace(dir, total); err != nil {
+ out["enough"] = false
+ out["error"] = err.Error()
+ }
+ sendJSON(w, 200, out)
+}
+
+// dlRequest builds a GET for the download URL, carrying the HF token when set
+// (gated/private repos) and an optional Range header.
+func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", dlURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ // HF gated/private repos may need a token; reuse the same key store if set.
+ if k := os.Getenv("HF_TOKEN"); k != "" {
+ req.Header.Set("Authorization", "Bearer "+k)
+ }
+ req.Header.Set("User-Agent", "ajean/"+Version)
+ req.Header.Set("Accept-Encoding", "identity") // never gzip a .gguf: it breaks ranges
+ if rng != "" {
+ req.Header.Set("Range", rng)
+ }
+ return req, nil
+}
+
+// contentRangeTotal parses the total size out of a "bytes 0-0/12345" header.
+func contentRangeTotal(v string) int64 {
+ i := strings.LastIndexByte(v, '/')
+ if i < 0 {
+ return 0
+ }
+ n, err := strconv.ParseInt(strings.TrimSpace(v[i+1:]), 10, 64)
+ if err != nil || n <= 0 {
+ return 0
+ }
+ return n
+}
+
+// dlProbe asks the server for the first byte to learn the total size and
+// whether ranges are supported (206 + Content-Range).
+func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err error) {
+ req, err := dlRequest(ctx, dlURL, "bytes=0-0")
+ if err != nil {
+ return 0, false, err
+ }
+ resp, err := dlClient.Do(req)
+ if err != nil {
+ return 0, false, err
+ }
+ defer resp.Body.Close()
+ _, _ = io.Copy(io.Discard, resp.Body)
+ switch resp.StatusCode {
+ case 206:
+ if t := contentRangeTotal(resp.Header.Get("Content-Range")); t > 0 {
+ return t, true, nil
+ }
+ return 0, false, nil
+ case 200:
+ // Server ignored the Range: single stream, ContentLength is the size.
+ return resp.ContentLength, false, nil
+ default:
+ return 0, false, fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
+ }
+}
+
+// runDownloadSet fetches one model, qui peut tenir en plusieurs fichiers (un
+// GGUF découpé en tranches). Les tranches se suivent SÉQUENTIELLEMENT — chacune
+// sature déjà le lien à elle seule grâce aux connexions parallèles, les mener de
+// front ne ferait que multiplier les fichiers à jeter en cas d'annulation.
+// La progression publiée (Done / Total) couvre l'ENSEMBLE : une barre unique du
+// début à la fin, pas trois barres qui repartent de zéro.
+//
+// Annulation ou échec : toutes les tranches déjà écrites par CE téléchargement
+// sont supprimées. Un modèle amputé d'une tranche ne démarre pas ; le laisser
+// sur le disque n'offrirait qu'un modèle mort et des dizaines de Go occupés.
+func runDownloadSet(ctx context.Context, st *dlState, urls, dests []string) {
+ var done int64 // atomique : octets écrits, toutes tranches confondues
+
+ finish := func(e error) {
+ dlMu.Lock()
+ switch {
+ case ctx.Err() != nil:
+ st.Canceled = true
+ st.Speed = 0
+ case e != nil:
+ st.Err = e.Error()
+ st.Speed = 0
+ default:
+ st.Done = atomic.LoadInt64(&done)
+ }
+ st.Finished = true
+ st.cancel = nil
+ dlMu.Unlock()
+ }
+
+ // Sonde de TOUTES les tranches avant d'écrire quoi que ce soit : la place
+ // disque se vérifie sur le total, pas tranche par tranche.
+ totals := make([]int64, len(urls))
+ rangeds := make([]bool, len(urls))
+ var grand int64
+ for i, u := range urls {
+ n, ranged, err := dlProbe(ctx, u)
+ if err != nil {
+ finish(err)
+ return
+ }
+ totals[i], rangeds[i] = n, ranged
+ grand += n
+ }
+ // Vérification serveur : l'UI a déjà prévenu, mais rien ne garantit qu'elle
+ // l'ait fait (autre client, disque rempli entre-temps).
+ if err := checkDiskSpace(filepath.Dir(dests[0]), grand); err != nil {
+ finish(err)
+ return
+ }
+
+ dlMu.Lock()
+ st.Total = grand
+ st.Parts = len(urls)
+ dlMu.Unlock()
+
+ // Publication de la progression + vitesse lissée, une fois par seconde.
+ stop := make(chan struct{})
+ go func() {
+ t := time.NewTicker(time.Second)
+ defer t.Stop()
+ last, lastAt := int64(0), time.Now()
+ for {
+ select {
+ case <-stop:
+ return
+ case now := <-t.C:
+ cur := atomic.LoadInt64(&done)
+ dt := now.Sub(lastAt).Seconds()
+ dlMu.Lock()
+ st.Done = cur
+ if dt > 0 {
+ inst := int64(float64(cur-last) / dt)
+ if st.Speed == 0 {
+ st.Speed = inst
+ } else {
+ st.Speed = (st.Speed*2 + inst) / 3 // EMA, lisse les à-coups du CDN
+ }
+ }
+ dlMu.Unlock()
+ last, lastAt = cur, now
+ }
+ }
+ }()
+
+ var written []string // tranches menées à bien par CE téléchargement
+ for i := range urls {
+ dlMu.Lock()
+ st.Part = i + 1
+ dlMu.Unlock()
+ if err := dlOnePart(ctx, st, urls[i], dests[i], totals[i], rangeds[i], &done); err != nil {
+ close(stop)
+ for _, p := range written {
+ _ = os.Remove(p)
+ }
+ finish(err) // annulation comprise : le .part est supprimé dans tous les cas
+ return
+ }
+ written = append(written, dests[i])
+ }
+ close(stop)
+ finish(nil)
+}
+
+// dlOnePart télécharge UN fichier dans un .part puis le renomme au succès.
+// Quand la source honore les plages d'octets (le CDN de Hugging Face le fait),
+// le fichier est réparti sur plusieurs connexions écrites en place via WriteAt :
+// c'est ce qui fait qu'un .gguf de plusieurs Go sature le lien au lieu de se
+// traîner sur un seul flux TCP. done est incrémenté globalement (il compte pour
+// tout le modèle, pas seulement pour cette tranche).
+func dlOnePart(ctx context.Context, st *dlState, dlURL, dest string, total int64, ranged bool, done *int64) error {
+ tmp := dest + ".part"
+ conns := 1
+ if ranged && total > 0 {
+ conns = dlConns()
+ if max := int((total + dlMinChunk - 1) / dlMinChunk); conns > max {
+ conns = max
+ }
+ if conns < 1 {
+ conns = 1
+ }
+ }
+ dlMu.Lock()
+ st.Conns = conns
+ dlMu.Unlock()
+
+ f, err := os.Create(tmp)
+ if err != nil {
+ return err
+ }
+ if conns > 1 {
+ // Préallocation : le système de fichiers pose le fichier d'un bloc et les
+ // WriteAt concurrents n'ont jamais à l'étendre en même temps.
+ if err := f.Truncate(total); err != nil {
+ f.Close()
+ _ = os.Remove(tmp)
+ return err
+ }
+ }
+ if err := dlFetch(ctx, f, dlURL, total, conns, done); err != nil {
+ f.Close()
+ _ = os.Remove(tmp)
+ return err
+ }
+ if err := f.Close(); err != nil {
+ _ = os.Remove(tmp)
+ return err
+ }
+ if err := os.Rename(tmp, dest); err != nil {
+ _ = os.Remove(tmp)
+ return err
+ }
+ return nil
+}
+
+// dlFetch writes the whole body into f, either as one stream or as `conns`
+// parallel byte ranges. done is incremented atomically as bytes land on disk.
+func dlFetch(ctx context.Context, f *os.File, dlURL string, total int64, conns int, done *int64) error {
+ if conns <= 1 {
+ return dlChunk(ctx, f, dlURL, 0, total-1, total <= 0, done)
+ }
+ size := total / int64(conns)
+ var wg sync.WaitGroup
+ errs := make([]error, conns)
+ for i := 0; i < conns; i++ {
+ start := int64(i) * size
+ end := start + size - 1
+ if i == conns-1 {
+ end = total - 1
+ }
+ wg.Add(1)
+ go func(i int, start, end int64) {
+ defer wg.Done()
+ errs[i] = dlChunk(ctx, f, dlURL, start, end, false, done)
+ }(i, start, end)
+ }
+ wg.Wait()
+ for _, e := range errs {
+ if e != nil {
+ return e
+ }
+ }
+ return nil
+}
+
+// dlChunk downloads [start,end] into f at the right offset, retrying from where
+// it stopped if the connection drops mid-chunk. With whole=true it streams the
+// entire body sequentially (server without range support, unknown size).
+func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, whole bool, done *int64) error {
+ const attempts = 4
+ pos := start
+ var lastErr error
+ for try := 0; try < attempts; try++ {
+ if err := ctx.Err(); err != nil {
+ return err // cancelled: never retry
+ }
+ if try > 0 {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(time.Duration(try) * time.Second):
+ }
+ }
+ rng := ""
+ if !whole {
+ if pos > end {
+ return nil
+ }
+ rng = fmt.Sprintf("bytes=%d-%d", pos, end)
+ } else if pos > start {
+ rng = fmt.Sprintf("bytes=%d-", pos) // best-effort resume
+ }
+ req, err := dlRequest(ctx, dlURL, rng)
+ if err != nil {
+ return err
+ }
+ resp, err := dlClient.Do(req)
+ if err != nil {
+ lastErr = err
+ continue
+ }
+ if resp.StatusCode != 200 && resp.StatusCode != 206 {
+ resp.Body.Close()
+ return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
+ }
+ if resp.StatusCode == 200 && pos > start {
+ // Resume refused: the body restarts from 0, rewind our bookkeeping.
+ atomic.AddInt64(done, start-pos)
+ pos = start
+ }
+ n, cerr := dlCopy(f, resp.Body, pos, done)
+ resp.Body.Close()
+ pos += n
+ if cerr == nil {
+ return nil
+ }
+ lastErr = cerr
+ }
+ return lastErr
+}
+
+// dlCopy streams src into f starting at off, reporting bytes written. It
+// returns the byte count even on error so the caller can resume.
+func dlCopy(f *os.File, src io.Reader, off int64, done *int64) (int64, error) {
+ buf := make([]byte, 1<<20) // 1 MiB
+ var written int64
+ for {
+ n, rerr := src.Read(buf)
+ if n > 0 {
+ if _, werr := f.WriteAt(buf[:n], off+written); werr != nil {
+ return written, werr
+ }
+ written += int64(n)
+ atomic.AddInt64(done, int64(n))
+ }
+ if rerr == io.EOF {
+ return written, nil
+ }
+ if rerr != nil {
+ return written, rerr
+ }
+ }
+}
+
+// handleModelDownloadCancel aborts an in-flight download; runDownload then
+// deletes its .part file, so nothing partial survives.
+func handleModelDownloadCancel(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Filename string `json:"filename"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ name := filepath.Base(strings.TrimSpace(req.Filename))
+ dlMu.Lock()
+ st, ok := dlDownloads[name]
+ var cancel context.CancelFunc
+ if ok {
+ cancel = st.cancel
+ }
+ dlMu.Unlock()
+ if !ok {
+ sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun téléchargement pour " + name})
+ return
+ }
+ if cancel != nil {
+ cancel()
+ }
+ sendJSON(w, 200, map[string]any{"ok": true})
+}
+
+// cleanStalePartFiles removes leftover *.gguf.part files in every model dir at
+// startup. A download killed by a crash or a service restart can't be resumed
+// (its state lived in memory), so the partial file would otherwise sit there
+// forever eating disk.
+func cleanStalePartFiles() {
+ for _, dir := range modelDirs() {
+ matches, err := filepath.Glob(filepath.Join(dir, "*.gguf.part"))
+ if err != nil {
+ continue
+ }
+ for _, p := range matches {
+ if err := os.Remove(p); err == nil {
+ fmt.Printf("[models] téléchargement incomplet supprimé : %s\n", p)
+ }
+ }
+ }
+}
+
+// handleModelDownloadStatus returns the state of all known downloads this run.
+func handleModelDownloadStatus(w http.ResponseWriter, r *http.Request) {
+ dlMu.Lock()
+ out := make([]dlState, 0, len(dlDownloads))
+ for _, st := range dlDownloads {
+ out = append(out, *st)
+ }
+ dlMu.Unlock()
+ sendJSON(w, 200, out)
+}
diff --git a/internal/ajean/backend_models_dest_test.go b/internal/ajean/backend_models_dest_test.go
new file mode 100644
index 0000000..ff45b90
--- /dev/null
+++ b/internal/ajean/backend_models_dest_test.go
@@ -0,0 +1,89 @@
+package ajean
+
+// Destination des téléchargements : le dossier doit être choisi parmi les
+// dossiers de modèles déclarés (sinon l'UI web écrirait n'importe où), et
+// l'espace libre doit être vérifié AVANT de transférer 40 Go.
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestDownloadDestPathDir(t *testing.T) {
+ testHome(t)
+ extra := t.TempDir()
+ t.Setenv("AJEAN_MODEL_DIRS", extra)
+
+ // Dossier vide → models/.
+ got, err := downloadDestPath("m.gguf", "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := filepath.Join(modelsDir(), "m.gguf"); got != want {
+ t.Fatalf("défaut = %s, attendu %s", got, want)
+ }
+
+ // Dossier déclaré → accepté.
+ got, err = downloadDestPath("m.gguf", extra)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := filepath.Join(extra, "m.gguf"); got != want {
+ t.Fatalf("dossier déclaré = %s, attendu %s", got, want)
+ }
+
+ // Dossier non déclaré → refusé.
+ if _, err := downloadDestPath("m.gguf", t.TempDir()); err == nil {
+ t.Fatal("un dossier non déclaré a été accepté")
+ }
+
+ // Le nom reste un simple fichier .gguf : pas de remontée de dossier.
+ if _, err := downloadDestPath(filepath.Join("..", "evil.gguf"), extra); err != nil {
+ t.Fatal(err)
+ } else if p, _ := downloadDestPath(filepath.Join("..", "evil.gguf"), extra); filepath.Dir(p) != extra {
+ t.Fatalf("path traversal : %s", p)
+ }
+}
+
+func TestDiskFreeAndCheck(t *testing.T) {
+ dir := t.TempDir()
+ free := diskFree(dir)
+ if free <= 0 {
+ t.Skipf("espace libre non mesurable ici (%d)", free)
+ }
+ // Un dossier pas encore créé doit être mesuré via son parent.
+ if diskFree(filepath.Join(dir, "pas", "encore")) <= 0 {
+ t.Fatal("diskFree ne remonte pas aux parents existants")
+ }
+ if err := checkDiskSpace(dir, 1<<10); err != nil {
+ t.Fatalf("1 Ko refusé alors que %d octets sont libres : %v", free, err)
+ }
+ err := checkDiskSpace(dir, free)
+ if err == nil {
+ t.Fatal("téléchargement accepté alors qu'il remplit le disque")
+ }
+ if !strings.Contains(err.Error(), "espace insuffisant") {
+ t.Fatalf("message inattendu : %v", err)
+ }
+ // Taille inconnue (serveur sans Content-Length) : on laisse passer.
+ if err := checkDiskSpace(dir, 0); err != nil {
+ t.Fatalf("taille inconnue refusée : %v", err)
+ }
+}
+
+// Les .part orphelins doivent aussi être nettoyés dans les dossiers ajoutés.
+func TestCleanStalePartFilesExtraDirs(t *testing.T) {
+ testHome(t)
+ extra := t.TempDir()
+ t.Setenv("AJEAN_MODEL_DIRS", extra)
+ stale := filepath.Join(extra, "coupe.gguf.part")
+ if err := os.WriteFile(stale, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cleanStalePartFiles()
+ if _, err := os.Stat(stale); !os.IsNotExist(err) {
+ t.Fatal(".part orphelin d'un dossier ajouté non supprimé")
+ }
+}
diff --git a/internal/ajean/backend_models_dl_test.go b/internal/ajean/backend_models_dl_test.go
new file mode 100644
index 0000000..b92e808
--- /dev/null
+++ b/internal/ajean/backend_models_dl_test.go
@@ -0,0 +1,137 @@
+package ajean
+
+import (
+ "bytes"
+ "context"
+ "math/rand"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strconv"
+ "testing"
+ "time"
+)
+
+// serveBlob serves data with (or without) byte-range support.
+func serveBlob(data []byte, ranges bool) *httptest.Server {
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if !ranges {
+ w.Header().Set("Content-Length", strconv.Itoa(len(data)))
+ _, _ = w.Write(data)
+ return
+ }
+ http.ServeContent(w, r, "m.gguf", time.Time{}, bytes.NewReader(data))
+ }))
+}
+
+// Un téléchargement annulé ne doit laisser NI le .gguf final NI le .part.
+func TestRunDownloadCancelLeavesNothing(t *testing.T) {
+ t.Setenv("AJEAN_DL_CONNS", "4")
+ release := make(chan struct{})
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Range") == "bytes=0-0" { // sonde : réponse immédiate
+ w.Header().Set("Content-Range", "bytes 0-0/"+strconv.Itoa(64<<20))
+ w.WriteHeader(206)
+ _, _ = w.Write([]byte{0})
+ return
+ }
+ // Corps qui traîne : l'annulation doit l'interrompre.
+ w.WriteHeader(206)
+ w.(http.Flusher).Flush()
+ select {
+ case <-release:
+ case <-r.Context().Done():
+ }
+ }))
+ defer srv.Close()
+ defer close(release)
+
+ dir := t.TempDir()
+ dest := filepath.Join(dir, "m.gguf")
+ ctx, cancel := context.WithCancel(context.Background())
+ st := &dlState{Filename: "m.gguf", cancel: cancel}
+ done := make(chan struct{})
+ go func() { runDownloadSet(ctx, st, []string{srv.URL + "/m.gguf"}, []string{dest}); close(done) }()
+
+ time.Sleep(300 * time.Millisecond)
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(10 * time.Second):
+ t.Fatal("runDownloadSet n a pas rendu la main apres annulation")
+ }
+
+ if !st.Canceled || !st.Finished {
+ t.Fatalf("état attendu annulé+terminé, got canceled=%v finished=%v err=%q", st.Canceled, st.Finished, st.Err)
+ }
+ if _, err := os.Stat(dest + ".part"); !os.IsNotExist(err) {
+ t.Fatal(".part laissé sur le disque après annulation")
+ }
+ if _, err := os.Stat(dest); !os.IsNotExist(err) {
+ t.Fatal("fichier final créé alors que le téléchargement a été annulé")
+ }
+}
+
+// cleanStalePartFiles doit balayer les .part orphelins sans toucher aux .gguf.
+func TestCleanStalePartFiles(t *testing.T) {
+ testHome(t)
+ dir := modelsDir()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ keep := filepath.Join(dir, "bon.gguf")
+ stale := filepath.Join(dir, "coupe.gguf.part")
+ for _, p := range []string{keep, stale} {
+ if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ cleanStalePartFiles()
+ if _, err := os.Stat(stale); !os.IsNotExist(err) {
+ t.Fatal(".part orphelin non supprimé")
+ }
+ if _, err := os.Stat(keep); err != nil {
+ t.Fatal(".gguf valide supprimé par erreur")
+ }
+}
+
+func TestRunDownloadParallelAndFallback(t *testing.T) {
+ data := make([]byte, 48<<20) // > 3×dlMinChunk so the split actually kicks in
+ rand.New(rand.NewSource(1)).Read(data)
+ t.Setenv("AJEAN_DL_CONNS", "4")
+
+ for _, ranges := range []bool{true, false} {
+ srv := serveBlob(data, ranges)
+ dir := t.TempDir()
+ dest := filepath.Join(dir, "m.gguf")
+ st := &dlState{Filename: "m.gguf"}
+ runDownloadSet(context.Background(), st, []string{srv.URL + "/m.gguf"}, []string{dest})
+ srv.Close()
+
+ if st.Err != "" {
+ t.Fatalf("ranges=%v: erreur %s", ranges, st.Err)
+ }
+ got, err := os.ReadFile(dest)
+ if err != nil {
+ t.Fatalf("ranges=%v: %v", ranges, err)
+ }
+ if len(got) != len(data) {
+ t.Fatalf("ranges=%v: taille %d != %d", ranges, len(got), len(data))
+ }
+ for i := range got {
+ if got[i] != data[i] {
+ t.Fatalf("ranges=%v: octet %d différent", ranges, i)
+ }
+ }
+ if st.Done != int64(len(data)) {
+ t.Fatalf("ranges=%v: done=%d", ranges, st.Done)
+ }
+ if ranges && st.Conns < 2 {
+ t.Fatalf("attendu du parallélisme, conns=%d", st.Conns)
+ }
+ if _, err := os.Stat(dest + ".part"); !os.IsNotExist(err) {
+ t.Fatalf("ranges=%v: .part laissé derrière", ranges)
+ }
+ }
+}
diff --git a/internal/ajean/backend_models_shards.go b/internal/ajean/backend_models_shards.go
new file mode 100644
index 0000000..b9fce69
--- /dev/null
+++ b/internal/ajean/backend_models_shards.go
@@ -0,0 +1,127 @@
+package ajean
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// Modèles découpés en plusieurs fichiers. Au-delà d'une cinquantaine de Go, un
+// dépôt Hugging Face publie son GGUF en tranches nommées par convention
+// llama.cpp : `-00001-of-00003.gguf`, `…-00002-of-00003.gguf`, etc.
+// llama-server n'a besoin QUE de la première : il ouvre les suivantes tout seul,
+// à condition qu'elles soient dans le même dossier.
+//
+// Tout le reste d'AJEAN traitait chaque tranche comme un modèle indépendant :
+// le lien collé ne rapatriait qu'un fichier sur trois (le moteur mourait sur
+// « tensor not found »), la liste du sélecteur montrait trois entrées dont deux
+// ne démarrent pas, et supprimer « le modèle » laissait les deux autres tranches
+// occuper le disque. Ce fichier ramène la tranche au rang de détail : une
+// famille de tranches = UN modèle, désigné par sa première.
+
+// shardRe reconnaît le suffixe de tranche en fin de nom de fichier. Le nombre de
+// chiffres n'est pas figé à 5 : le convertisseur de llama.cpp en pose 5, mais
+// des dépôts en publient avec moins.
+var shardRe = regexp.MustCompile(`(?i)-(\d{2,5})-of-(\d{2,5})\.gguf$`)
+
+// shardInfo décompose un nom de tranche : rang (1-based) et nombre total. ok
+// vaut false pour un modèle d'un seul fichier.
+func shardInfo(name string) (idx, total int, ok bool) {
+ m := shardRe.FindStringSubmatch(baseName(name))
+ if m == nil {
+ return 0, 0, false
+ }
+ idx, _ = strconv.Atoi(m[1])
+ total, _ = strconv.Atoi(m[2])
+ if idx < 1 || total < 1 || idx > total {
+ return 0, 0, false // numérotation incohérente : on le traite en fichier simple
+ }
+ return idx, total, true
+}
+
+// shardNameAt renvoie le nom de la tranche de rang i dans la même famille, en
+// conservant la largeur de la numérotation d'origine (00002, pas 2).
+func shardNameAt(name string, i int) string {
+ m := shardRe.FindStringSubmatchIndex(name)
+ if m == nil {
+ return name
+ }
+ width := m[3] - m[2]
+ totalPart := name[m[4]:m[5]]
+ return name[:m[2]] + fmt.Sprintf("%0*d", width, i) + "-of-" + totalPart + name[m[5]:]
+}
+
+// isFollowerShard : tranche 2..N, celles qu'on ne montre jamais comme un modèle
+// à part entière (elles ne démarrent pas seules).
+func isFollowerShard(name string) bool {
+ idx, _, ok := shardInfo(name)
+ return ok && idx > 1
+}
+
+// shardFamily renvoie les noms de TOUTES les tranches de la famille de name (y
+// compris name), dans l'ordre. Un fichier simple se renvoie lui-même.
+func shardFamily(name string) []string {
+ base := baseName(name)
+ _, total, ok := shardInfo(base)
+ if !ok {
+ return []string{base}
+ }
+ out := make([]string, 0, total)
+ for i := 1; i <= total; i++ {
+ out = append(out, shardNameAt(base, i))
+ }
+ return out
+}
+
+// shardFamilySize additionne la taille des tranches PRÉSENTES dans dir. Sert à
+// afficher « 42 Go » sur la première tranche au lieu des 15 Go qu'elle pèse
+// seule, ce qui laissait croire à un modèle trois fois plus petit.
+func shardFamilySize(dir, name string) int64 {
+ var sum int64
+ for _, n := range shardFamily(name) {
+ if st, err := os.Stat(filepath.Join(dir, n)); err == nil && !st.IsDir() {
+ sum += st.Size()
+ }
+ }
+ return sum
+}
+
+// shardFamilyMissing liste les tranches ABSENTES du dossier. Un modèle dont il
+// manque une tranche démarre puis meurt sur une erreur de tenseur introuvable :
+// autant le dire dans la liste plutôt que dans les logs du moteur.
+func shardFamilyMissing(dir, name string) []string {
+ var missing []string
+ for _, n := range shardFamily(name) {
+ if st, err := os.Stat(filepath.Join(dir, n)); err != nil || st.IsDir() {
+ missing = append(missing, n)
+ }
+ }
+ return missing
+}
+
+// shardURLSet transforme l'URL d'UNE tranche en la liste complète des URLs de la
+// famille, dans l'ordre, avec les noms de fichiers correspondants. Le lien collé
+// peut désigner n'importe quelle tranche (on tombe souvent sur la deuxième en
+// parcourant l'arborescence du dépôt) : on remonte toujours à la première.
+func shardURLSet(dlURL, name string) (urls, names []string) {
+ fam := shardFamily(name)
+ if len(fam) == 1 {
+ return []string{dlURL}, fam
+ }
+ base := baseName(name)
+ for _, n := range fam {
+ // Remplacement du seul nom de fichier, en fin d'URL : le nom peut réapparaître
+ // ailleurs dans le chemin (dossier de quantification portant le même libellé),
+ // et un ReplaceAll y toucherait aussi.
+ i := strings.LastIndex(dlURL, base)
+ if i < 0 {
+ return []string{dlURL}, []string{base} // lien inattendu : on ne devine rien
+ }
+ urls = append(urls, dlURL[:i]+n+dlURL[i+len(base):])
+ names = append(names, n)
+ }
+ return urls, names
+}
diff --git a/internal/ajean/backend_models_shards_test.go b/internal/ajean/backend_models_shards_test.go
new file mode 100644
index 0000000..c7e6477
--- /dev/null
+++ b/internal/ajean/backend_models_shards_test.go
@@ -0,0 +1,120 @@
+package ajean
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestShardInfo(t *testing.T) {
+ cases := []struct {
+ name string
+ idx, total int
+ ok bool
+ }{
+ {"Laguna-S-2.1-UD-IQ4_XS-00001-of-00003.gguf", 1, 3, true},
+ {"Laguna-S-2.1-UD-IQ4_XS-00002-of-00003.gguf", 2, 3, true},
+ {"m-01-of-03.gguf", 1, 3, true},
+ {"Qwen2.5-7B-Instruct-Q4_K_M.gguf", 0, 0, false},
+ {"bidon-00004-of-00003.gguf", 0, 0, false}, // numérotation incohérente
+ {"pas-un-gguf-00001-of-00002.bin", 0, 0, false},
+ }
+ for _, c := range cases {
+ idx, total, ok := shardInfo(c.name)
+ if idx != c.idx || total != c.total || ok != c.ok {
+ t.Errorf("shardInfo(%q) = %d,%d,%v ; attendu %d,%d,%v", c.name, idx, total, ok, c.idx, c.total, c.ok)
+ }
+ }
+}
+
+func TestShardFamilyGardeLaLargeur(t *testing.T) {
+ got := shardFamily("Laguna-00002-of-00003.gguf")
+ want := []string{
+ "Laguna-00001-of-00003.gguf",
+ "Laguna-00002-of-00003.gguf",
+ "Laguna-00003-of-00003.gguf",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("famille = %v ; attendu %v", got, want)
+ }
+ if solo := shardFamily("simple.gguf"); !reflect.DeepEqual(solo, []string{"simple.gguf"}) {
+ t.Fatalf("fichier simple = %v", solo)
+ }
+}
+
+// Le lien collé peut désigner n'importe quelle tranche : on remonte toujours à la
+// première, et on ne réécrit QUE le nom de fichier final (le même libellé peut
+// apparaître plus haut dans le chemin du dépôt).
+func TestShardURLSet(t *testing.T) {
+ base := "https://huggingface.co/unsloth/Laguna-S-2.1-GGUF/resolve/main/UD-IQ4_XS/"
+ in := base + "Laguna-UD-IQ4_XS-00002-of-00003.gguf"
+ urls, names := shardURLSet(in, "Laguna-UD-IQ4_XS-00002-of-00003.gguf")
+ if len(urls) != 3 || len(names) != 3 {
+ t.Fatalf("attendu 3 tranches, got %d/%d", len(urls), len(names))
+ }
+ if names[0] != "Laguna-UD-IQ4_XS-00001-of-00003.gguf" {
+ t.Fatalf("première tranche = %q", names[0])
+ }
+ if urls[0] != base+"Laguna-UD-IQ4_XS-00001-of-00003.gguf" {
+ t.Fatalf("URL de la première tranche = %q", urls[0])
+ }
+ if urls[2] != base+"Laguna-UD-IQ4_XS-00003-of-00003.gguf" {
+ t.Fatalf("URL de la dernière tranche = %q", urls[2])
+ }
+ // Fichier simple : une seule URL, inchangée.
+ u, n := shardURLSet(base+"m.gguf", "m.gguf")
+ if len(u) != 1 || u[0] != base+"m.gguf" || n[0] != "m.gguf" {
+ t.Fatalf("fichier simple mal traité : %v %v", u, n)
+ }
+}
+
+func TestShardFamilyTailleEtManquantes(t *testing.T) {
+ dir := t.TempDir()
+ write := func(n string, size int) {
+ if err := os.WriteFile(filepath.Join(dir, n), make([]byte, size), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("m-00001-of-00003.gguf", 100)
+ write("m-00003-of-00003.gguf", 300)
+
+ if got := shardFamilySize(dir, "m-00001-of-00003.gguf"); got != 400 {
+ t.Fatalf("taille de la famille = %d ; attendu 400", got)
+ }
+ missing := shardFamilyMissing(dir, "m-00001-of-00003.gguf")
+ if !reflect.DeepEqual(missing, []string{"m-00002-of-00003.gguf"}) {
+ t.Fatalf("tranches manquantes = %v", missing)
+ }
+ write("m-00002-of-00003.gguf", 200)
+ if m := shardFamilyMissing(dir, "m-00001-of-00003.gguf"); len(m) != 0 {
+ t.Fatalf("famille complète signalée incomplète : %v", m)
+ }
+}
+
+// Supprimer un modèle découpé doit emporter TOUTES ses tranches, sinon des
+// dizaines de Go restent sur le disque sans que rien ne sache plus les effacer.
+func TestDeleteModelFileEmporteLesTranches(t *testing.T) {
+ testHome(t)
+ dir := modelsDir()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ names := []string{"m-00001-of-00002.gguf", "m-00002-of-00002.gguf", "autre.gguf"}
+ for _, n := range names {
+ if err := os.WriteFile(filepath.Join(dir, n), []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil {
+ t.Fatal(err)
+ }
+ for _, n := range names[:2] {
+ if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) {
+ t.Fatalf("tranche %s non supprimée", n)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(dir, "autre.gguf")); err != nil {
+ t.Fatal("un modèle sans rapport a été supprimé")
+ }
+}
diff --git a/internal/ajean/backend_prebuilt.go b/internal/ajean/backend_prebuilt.go
new file mode 100644
index 0000000..3a4e415
--- /dev/null
+++ b/internal/ajean/backend_prebuilt.go
@@ -0,0 +1,630 @@
+// backend_prebuilt.go — backend llama.cpp SANS compilation : télécharge les
+// binaires officiels précompilés publiés à chaque release de ggml-org/llama.cpp
+// (zip Windows, tar.gz macOS/Linux), choisit l'asset adapté à la machine
+// (CUDA / ROCm / Vulkan / CPU), l'extrait dans backends/llama.cpp-prebuilt et
+// pointe BIN dessus. ~2 minutes au lieu d'une compilation complète.
+//
+// Limites assumées : builds génériques (pas de tuning natif), et pas de build
+// CUDA officiel pour Linux (on retombe sur Vulkan — la compilation locale
+// reste la voie CUDA sous Linux). La compilation locale reste indispensable
+// pour les forks (ex. PrismML).
+package ajean
+
+import (
+ "archive/tar"
+ "archive/zip"
+ "compress/gzip"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const llamaReleasesAPI = "https://api.github.com/repos/ggml-org/llama.cpp/releases/latest"
+
+type ghAsset struct {
+ Name string `json:"name"`
+ URL string `json:"browser_download_url"`
+ Size int64 `json:"size"`
+}
+
+func prebuiltDir() string {
+ return filepath.Join(backendsDir(), "llama.cpp-prebuilt")
+}
+
+// prebuiltVersion lit le marqueur VERSION du dossier prebuilt : "tag cudaVer"
+// (cudaVer vide hors CUDA Windows).
+func prebuiltVersion() (tag, cudaVer string) {
+ b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
+ if err != nil {
+ return "", ""
+ }
+ f := strings.Fields(strings.TrimSpace(string(b)))
+ if len(f) > 0 {
+ tag = f[0]
+ }
+ if len(f) > 1 {
+ cudaVer = f[1]
+ }
+ return
+}
+
+// prebuiltFormat identifie la façon dont l'archive a été extraite. « fmt2 » =
+// extraction qui recrée les liens des archives (indispensable aux .dylib macOS
+// et .so Linux). Une installation sans ce marqueur est réputée incomplète et
+// sera refaite au lieu d'être déclarée « déjà à jour ».
+const prebuiltFormat = "fmt2"
+
+// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent).
+func prebuiltVersionFormat() string {
+ b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION"))
+ if err != nil {
+ return ""
+ }
+ f := strings.Fields(strings.TrimSpace(string(b)))
+ if len(f) > 2 {
+ return f[2]
+ }
+ return ""
+}
+
+// prebuiltServerBin localise llama-server(.exe) sous le dossier prebuilt
+// (l'arborescence interne des archives officielles varie : racine, build/bin…).
+//
+// Chaque release s'extrait dans son propre sous-dossier (llama-b10280/…), donc
+// plusieurs versions peuvent cohabiter — notamment des installations anciennes
+// et incomplètes. On retient donc EN PRIORITÉ le binaire de la version notée
+// dans VERSION, et à défaut le plus récent (numéro de build le plus élevé) :
+// prendre « le premier trouvé » renvoyait le plus ANCIEN dossier, donc un
+// binaire périmé voire cassé.
+func prebuiltServerBin() string {
+ all := prebuiltServerBins()
+ if len(all) == 0 {
+ return ""
+ }
+ if tag, _ := prebuiltVersion(); tag != "" {
+ for _, p := range all {
+ if strings.Contains(filepath.ToSlash(p), "/llama-"+tag+"/") {
+ return p
+ }
+ }
+ }
+ return all[len(all)-1] // ordre lexical = ordre des numéros de build
+}
+
+// prebuiltServerBins liste tous les llama-server présents sous le dossier
+// prebuilt, triés par chemin (donc par numéro de build croissant).
+func prebuiltServerBins() []string {
+ want := "llama-server"
+ if runtime.GOOS == "windows" {
+ want += ".exe"
+ }
+ var found []string
+ _ = filepath.WalkDir(prebuiltDir(), func(p string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() {
+ return nil
+ }
+ if d.Name() == want {
+ found = append(found, p)
+ }
+ return nil
+ })
+ sort.Strings(found)
+ return found
+}
+
+// prebuiltOwns dit si p désigne un binaire du moteur précompilé, quelle que
+// soit la version installée. Les presets enregistrent un chemin versionné :
+// après une mise à jour ce chemin n'est plus celui du moteur courant, mais le
+// preset utilise bien « le moteur précompilé » et doit être reconnu comme tel.
+func prebuiltOwns(p string) bool {
+ if p == "" {
+ return false
+ }
+ norm := func(s string) string {
+ return strings.ToLower(filepath.ToSlash(filepath.Clean(s)))
+ }
+ return strings.HasPrefix(norm(p), norm(prebuiltDir())+"/")
+}
+
+// prebuiltResolveBin fait suivre un BIN de preset aux mises à jour du moteur :
+// un chemin versionné qui n'existe plus (release remplacée) est remplacé par le
+// binaire précompilé courant. Tout autre chemin est renvoyé tel quel.
+func prebuiltResolveBin(bin string) string {
+ if !prebuiltOwns(bin) {
+ return bin
+ }
+ if _, err := os.Stat(bin); err == nil {
+ return bin
+ }
+ if cur := prebuiltServerBin(); cur != "" {
+ return cur
+ }
+ return bin
+}
+
+// prebuiltPrune supprime les extractions d'autres releases : elles ne servent
+// plus à rien (quelques centaines de Mo chacune) et, laissées en place, elles
+// se faisaient élire comme binaire courant.
+func prebuiltPrune(keep string, logf func(string)) {
+ keepDir := ""
+ if keep != "" {
+ keepDir = filepath.ToSlash(filepath.Clean(keep))
+ }
+ entries, err := os.ReadDir(prebuiltDir())
+ if err != nil {
+ return
+ }
+ for _, e := range entries {
+ if !e.IsDir() || !strings.HasPrefix(e.Name(), "llama-b") {
+ continue
+ }
+ d := filepath.Join(prebuiltDir(), e.Name())
+ if keepDir != "" && strings.HasPrefix(keepDir, filepath.ToSlash(filepath.Clean(d))+"/") {
+ continue
+ }
+ if err := os.RemoveAll(d); err == nil && logf != nil {
+ logf("ancienne version supprimée : " + e.Name())
+ }
+ }
+}
+
+// fetchLlamaLatest interroge l'API GitHub pour la dernière release officielle.
+func fetchLlamaLatest() (string, []ghAsset, error) {
+ req, err := http.NewRequest("GET", llamaReleasesAPI, nil)
+ if err != nil {
+ return "", nil, err
+ }
+ req.Header.Set("Accept", "application/vnd.github+json")
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return "", nil, fmt.Errorf("GitHub API : HTTP %d", resp.StatusCode)
+ }
+ var rel struct {
+ TagName string `json:"tag_name"`
+ Assets []ghAsset `json:"assets"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&rel); err != nil {
+ return "", nil, err
+ }
+ if rel.TagName == "" {
+ return "", nil, fmt.Errorf("release invalide (tag vide)")
+ }
+ return rel.TagName, rel.Assets, nil
+}
+
+// driverCudaVersion renvoie la version CUDA max supportée par le pilote NVIDIA
+// (bandeau de nvidia-smi : « CUDA Version: 12.8 »), ou 0 si inconnue.
+func driverCudaVersion() float64 {
+ out, err := hideCmd(exec.Command("nvidia-smi")).Output()
+ if err != nil {
+ return 0
+ }
+ m := regexp.MustCompile(`CUDA Version:\s*([0-9]+\.[0-9]+)`).FindSubmatch(out)
+ if m == nil {
+ return 0
+ }
+ v, _ := strconv.ParseFloat(string(m[1]), 64)
+ return v
+}
+
+// assetMatch garde les assets dont le nom contient TOUS les fragments.
+func assetMatch(assets []ghAsset, frags ...string) []ghAsset {
+ var out []ghAsset
+ for _, a := range assets {
+ ok := true
+ for _, f := range frags {
+ if !strings.Contains(a.Name, f) {
+ ok = false
+ break
+ }
+ }
+ if ok {
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
+var reCudaAssetVer = regexp.MustCompile(`cuda-([0-9]+\.[0-9]+)`)
+
+// pickPrebuilt choisit l'asset principal (+ cudart pour CUDA Windows) adapté à
+// la machine, et renvoie un label lisible du variant retenu.
+func pickPrebuilt(assets []ghAsset) (main *ghAsset, cudart *ghAsset, label, cudaVer string, err error) {
+ pickOne := func(list []ghAsset) *ghAsset {
+ if len(list) == 0 {
+ return nil
+ }
+ return &list[0]
+ }
+ switch runtime.GOOS {
+ case "windows":
+ if runtime.GOARCH == "arm64" {
+ main = pickOne(assetMatch(assets, "llama-", "bin-win-cpu-arm64"))
+ label = "CPU (Windows arm64)"
+ break
+ }
+ if hasNvidiaGPU() {
+ // Plusieurs versions CUDA publiées (ex. 12.4 et 13.3) : on prend la plus
+ // haute supportée par le pilote (sinon la plus basse, la plus compatible).
+ // NB : les archives cudart-llama-bin-win-cuda-… contiennent aussi ces
+ // fragments — on les écarte explicitement du choix du binaire principal.
+ var cand []ghAsset
+ for _, a := range assetMatch(assets, "llama-", "bin-win-cuda-", "-x64.zip") {
+ if !strings.HasPrefix(a.Name, "cudart") {
+ cand = append(cand, a)
+ }
+ }
+ maxV := driverCudaVersion()
+ var best *ghAsset
+ bestV := 0.0
+ for i := range cand {
+ m := reCudaAssetVer.FindStringSubmatch(cand[i].Name)
+ if m == nil {
+ continue
+ }
+ v, _ := strconv.ParseFloat(m[1], 64)
+ ok := maxV == 0 && (best == nil || v < bestV) || // pilote inconnu → la plus basse
+ maxV > 0 && v <= maxV && v > bestV // sinon la plus haute compatible
+ if ok {
+ best = &cand[i]
+ bestV = v
+ cudaVer = m[1]
+ }
+ }
+ if best != nil {
+ main = best
+ cudart = pickOne(assetMatch(assets, "cudart-", "win-cuda-"+cudaVer))
+ label = "CUDA " + cudaVer + " (Windows x64)"
+ break
+ }
+ }
+ if a := pickOne(assetMatch(assets, "llama-", "bin-win-vulkan-x64")); a != nil {
+ main, label = a, "Vulkan (Windows x64)"
+ break
+ }
+ main = pickOne(assetMatch(assets, "llama-", "bin-win-cpu-x64"))
+ label = "CPU (Windows x64)"
+ case "darwin":
+ arch := "x64"
+ if runtime.GOARCH == "arm64" {
+ arch = "arm64"
+ }
+ main = pickOne(assetMatch(assets, "llama-", "bin-macos-"+arch))
+ label = "Metal (macOS " + arch + ")"
+ default: // linux
+ arch := "x64"
+ if runtime.GOARCH == "arm64" {
+ arch = "arm64"
+ }
+ if hasTool("hipcc") || isDir("/opt/rocm") {
+ if a := pickOne(assetMatch(assets, "llama-", "bin-ubuntu-rocm-", arch)); a != nil {
+ main, label = a, "ROCm (Linux "+arch+")"
+ break
+ }
+ }
+ // Pas de build CUDA officiel pour Linux : sur GPU NVIDIA le variant Vulkan
+ // fonctionne via le pilote (moins optimal que le build CUDA local).
+ if hasNvidiaGPU() || hasTool("vulkaninfo") {
+ if a := pickOne(assetMatch(assets, "llama-", "bin-ubuntu-vulkan-"+arch)); a != nil {
+ main, label = a, "Vulkan (Linux "+arch+")"
+ if hasNvidiaGPU() {
+ label += " — pas de build CUDA officiel Linux ; compile localement pour du CUDA natif"
+ }
+ break
+ }
+ }
+ main = pickOne(assetMatch(assets, "llama-", "bin-ubuntu-"+arch+".tar.gz"))
+ label = "CPU (Linux " + arch + ")"
+ }
+ if main == nil {
+ return nil, nil, "", "", fmt.Errorf("aucun binaire précompilé adapté à cette machine dans la release officielle")
+ }
+ return main, cudart, label, cudaVer, nil
+}
+
+// recommendedMode dit laquelle des deux installations conseiller sur CETTE
+// machine, et pourquoi. Le précompilé convient presque partout — sauf dans un
+// cas qui concerne beaucoup de monde : **Linux avec une carte NVIDIA**.
+// llama.cpp ne publie AUCUN binaire CUDA pour Linux (vérifié release b10299 :
+// seuls Windows a bin-win-cuda-*), donc pickPrebuilt y retombe sur Vulkan, qui
+// marche mais laisse une bonne part de la carte inexploitée. Conseiller le
+// précompilé dans ce cas revenait à pousser vers l'option la plus lente.
+//
+// `backend` est celui qu'une compilation LOCALE produirait (detectBuildPlan).
+// Renvoie {mode: "fast"|"opt", why: "…"} — le libellé est affiché sous la carte
+// conseillée, pour que le choix soit justifié plutôt qu'imposé.
+func recommendedMode(backend string) map[string]any {
+ if runtime.GOOS == "linux" && backend == "cuda" {
+ return map[string]any{
+ "mode": "opt",
+ "why": "seule voie vers du CUDA natif : llama.cpp ne publie aucun binaire CUDA pour Linux, le précompilé ne donnerait que du Vulkan. Compte plusieurs minutes de compilation.",
+ }
+ }
+ return map[string]any{"mode": "fast", "why": ""}
+}
+
+// prebuiltInstall télécharge et installe (ou met à jour) les binaires
+// précompilés. logf reçoit chaque ligne de log ; phasef la phase courante.
+// Renvoie le chemin du binaire installé.
+func prebuiltInstall(logf, phasef func(string)) (string, error) {
+ phasef("récupération de la dernière release llama.cpp…")
+ tag, assets, err := fetchLlamaLatest()
+ if err != nil {
+ return "", fmt.Errorf("impossible d'interroger les releases llama.cpp : %w", err)
+ }
+ curTag, curCuda := prebuiltVersion()
+ main, cudart, label, cudaVer, err := pickPrebuilt(assets)
+ if err != nil {
+ return "", err
+ }
+ logf(fmt.Sprintf("release %s — variant retenu : %s", tag, label))
+
+ // Réinstaller à l'identique est inutile SAUF si l'extraction date d'une
+ // version de AJEAN qui ignorait les liens des archives (backend installé mais
+ // bibliothèques introuvables au lancement) : le marqueur de format force alors
+ // une ré-extraction propre au lieu d'un « déjà à jour » trompeur.
+ if cur := prebuiltServerBin(); curTag == tag && prebuiltVersionFormat() == prebuiltFormat && cur != "" {
+ logf("déjà à jour (" + tag + ")")
+ prebuiltPrune(cur, logf) // ménage des versions laissées par les installs précédentes
+ return cur, nil
+ }
+
+ dir := prebuiltDir()
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+
+ // cudart (DLLs runtime CUDA, ~400 Mo) : seulement si absent ou si la version
+ // CUDA du variant a changé depuis la dernière installation.
+ if cudart != nil {
+ haveDLL, _ := filepath.Glob(filepath.Join(dir, "**", "cudart64*.dll"))
+ if len(haveDLL) == 0 {
+ haveDLL, _ = filepath.Glob(filepath.Join(dir, "cudart64*.dll"))
+ }
+ if len(haveDLL) > 0 && curCuda == cudaVer {
+ logf("cudart " + cudaVer + " déjà présent — téléchargement évité")
+ cudart = nil
+ }
+ }
+
+ for _, a := range []*ghAsset{main, cudart} {
+ if a == nil {
+ continue
+ }
+ phasef(fmt.Sprintf("téléchargement de %s (%d Mo)…", a.Name, a.Size/1_000_000))
+ tmp := filepath.Join(dir, a.Name+".part")
+ if err := downloadWithProgress(a.URL, tmp, a.Size, logf); err != nil {
+ _ = os.Remove(tmp)
+ return "", fmt.Errorf("téléchargement de %s : %w", a.Name, err)
+ }
+ phasef("extraction de " + a.Name + "…")
+ if err := extractArchive(tmp, dir); err != nil {
+ _ = os.Remove(tmp)
+ return "", fmt.Errorf("extraction de %s : %w", a.Name, err)
+ }
+ _ = os.Remove(tmp)
+ }
+
+ // Le marqueur est écrit AVANT de localiser le binaire : prebuiltServerBin
+ // s'en sert pour élire la version fraîchement extraite s'il en reste d'autres.
+ if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte(tag+" "+cudaVer+" "+prebuiltFormat+"\n"), 0o644); err != nil {
+ return "", err
+ }
+ bin := prebuiltServerBin()
+ if bin == "" {
+ return "", fmt.Errorf("archives extraites mais llama-server introuvable sous %s", dir)
+ }
+ if runtime.GOOS != "windows" {
+ _ = os.Chmod(bin, 0o755)
+ }
+ prebuiltPrune(bin, logf)
+ logf("binaire installé : " + bin + " (release " + tag + ")")
+ return bin, nil
+}
+
+// downloadWithProgress télécharge url vers dest en journalisant la progression
+// par tranches de ~25 Mo.
+func downloadWithProgress(url, dest string, total int64, logf func(string)) error {
+ client := &http.Client{Timeout: 0}
+ resp, err := client.Get(url)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != 200 {
+ return fmt.Errorf("HTTP %d", resp.StatusCode)
+ }
+ if total <= 0 {
+ total = resp.ContentLength
+ }
+ f, err := os.Create(dest)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ buf := make([]byte, 1<<20)
+ var done, lastLog int64
+ for {
+ n, rerr := resp.Body.Read(buf)
+ if n > 0 {
+ if _, werr := f.Write(buf[:n]); werr != nil {
+ return werr
+ }
+ done += int64(n)
+ if done-lastLog >= 25<<20 {
+ lastLog = done
+ if total > 0 {
+ logf(fmt.Sprintf("⬇ %d / %d Mo (%d%%)", done/1_000_000, total/1_000_000, done*100/total))
+ } else {
+ logf(fmt.Sprintf("⬇ %d Mo", done/1_000_000))
+ }
+ }
+ }
+ if rerr == io.EOF {
+ return nil
+ }
+ if rerr != nil {
+ return rerr
+ }
+ }
+}
+
+// extractArchive extrait un .zip ou un .tar.gz dans dir, en refusant toute
+// entrée qui s'échapperait du dossier (zip-slip).
+func extractArchive(path, dir string) error {
+ safe := func(name string) (string, error) {
+ p := filepath.Join(dir, filepath.FromSlash(name))
+ if rel, err := filepath.Rel(dir, p); err != nil || strings.HasPrefix(rel, "..") {
+ return "", fmt.Errorf("entrée d'archive suspecte : %s", name)
+ }
+ return p, nil
+ }
+ if strings.HasSuffix(path, ".zip") || strings.HasSuffix(path, ".zip.part") {
+ zr, err := zip.OpenReader(path)
+ if err != nil {
+ return err
+ }
+ defer zr.Close()
+ for _, f := range zr.File {
+ p, err := safe(f.Name)
+ if err != nil {
+ return err
+ }
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(p, 0o755); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return err
+ }
+ rc, err := f.Open()
+ if err != nil {
+ return err
+ }
+ w, err := os.Create(p)
+ if err != nil {
+ rc.Close()
+ return err
+ }
+ _, err = io.Copy(w, rc)
+ rc.Close()
+ w.Close()
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+ }
+ // tar.gz
+ f, err := os.Open(path)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ gz, err := gzip.NewReader(f)
+ if err != nil {
+ return err
+ }
+ defer gz.Close()
+ tr := tar.NewReader(gz)
+ // Les liens sont appliqués APRÈS coup : leur cible n'est pas forcément déjà
+ // extraite au moment où on croise l'entrée.
+ var links []archiveLink
+ for {
+ h, err := tr.Next()
+ if err == io.EOF {
+ return applyLinks(links)
+ }
+ if err != nil {
+ return err
+ }
+ p, err := safe(h.Name)
+ if err != nil {
+ return err
+ }
+ switch h.Typeflag {
+ case tar.TypeDir:
+ if err := os.MkdirAll(p, 0o755); err != nil {
+ return err
+ }
+ case tar.TypeReg:
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ return err
+ }
+ w, err := os.Create(p)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(w, tr); err != nil {
+ w.Close()
+ return err
+ }
+ w.Close()
+ _ = os.Chmod(p, os.FileMode(h.Mode)&0o777)
+ case tar.TypeSymlink, tar.TypeLink:
+ // Indispensable sur macOS : les archives llama.cpp livrent les .dylib
+ // sous leur nom versionné (libllama-common.0.0.10107.dylib) PLUS un lien
+ // portant le nom que cherche le binaire (libllama-common.0.dylib). Ignorer
+ // ces entrées donnait un backend installé mais impossible à lancer :
+ // « dyld: Library not loaded: @rpath/libllama-common.0.dylib ».
+ target := h.Linkname
+ if h.Typeflag == tar.TypeLink {
+ // Lien dur : la cible est un chemin dans l'archive, pas un chemin relatif.
+ t, err := safe(h.Linkname)
+ if err != nil {
+ return err
+ }
+ target = t
+ }
+ links = append(links, archiveLink{path: p, target: target})
+ }
+ }
+}
+
+// archiveLink : un lien (symbolique ou dur) relevé dans une archive.
+type archiveLink struct{ path, target string }
+
+// applyLinks crée les liens relevés pendant l'extraction. Sur les systèmes où la
+// création de liens symboliques est refusée (Windows sans mode développeur), on
+// copie le fichier cible : moins élégant, mais fonctionnel.
+func applyLinks(links []archiveLink) error {
+ for _, l := range links {
+ _ = os.Remove(l.path)
+ if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
+ return err
+ }
+ if err := os.Symlink(l.target, l.path); err == nil {
+ continue
+ }
+ // Repli par copie. La cible peut être relative au dossier du lien.
+ src := l.target
+ if !filepath.IsAbs(src) {
+ src = filepath.Join(filepath.Dir(l.path), l.target)
+ }
+ data, err := os.ReadFile(src)
+ if err != nil {
+ continue // cible absente de l'archive : on n'échoue pas l'installation pour ça
+ }
+ if err := os.WriteFile(l.path, data, 0o755); err != nil {
+ return err
+ }
+ }
+ return nil
+}
diff --git a/internal/ajean/backend_prebuilt_reco_test.go b/internal/ajean/backend_prebuilt_reco_test.go
new file mode 100644
index 0000000..497b132
--- /dev/null
+++ b/internal/ajean/backend_prebuilt_reco_test.go
@@ -0,0 +1,39 @@
+package ajean
+
+import (
+ "runtime"
+ "testing"
+)
+
+// Le seul cas où compiler vaut mieux que télécharger : Linux + CUDA. llama.cpp
+// ne publie pas de binaire CUDA pour Linux (seul Windows a bin-win-cuda-*), donc
+// le précompilé y retombe sur Vulkan. Ailleurs, l'asset officiel exploite bien
+// la machine (CUDA sous Windows, Metal sous macOS, ROCm sous Linux).
+func TestRecommendedMode(t *testing.T) {
+ cases := []struct {
+ backend string
+ wantMode string
+ wantWhy bool
+ }{
+ {"cuda", "opt", true}, // le cas qui motive toute la fonction
+ {"rocm", "fast", false},
+ {"vulkan", "fast", false},
+ {"cpu", "fast", false},
+ {"metal", "fast", false},
+ }
+ for _, c := range cases {
+ got := recommendedMode(c.backend)
+ wantMode, wantWhy := c.wantMode, c.wantWhy
+ // La règle ne vise que Linux : sur les autres OS le précompilé reste
+ // conseillé même avec CUDA détecté.
+ if runtime.GOOS != "linux" {
+ wantMode, wantWhy = "fast", false
+ }
+ if got["mode"] != wantMode {
+ t.Errorf("backend %q sur %s : mode = %v, attendu %q", c.backend, runtime.GOOS, got["mode"], wantMode)
+ }
+ if hasWhy := got["why"] != ""; hasWhy != wantWhy {
+ t.Errorf("backend %q sur %s : why non vide = %v, attendu %v", c.backend, runtime.GOOS, hasWhy, wantWhy)
+ }
+ }
+}
diff --git a/internal/ajean/backend_prebuilt_test.go b/internal/ajean/backend_prebuilt_test.go
new file mode 100644
index 0000000..fb4a50e
--- /dev/null
+++ b/internal/ajean/backend_prebuilt_test.go
@@ -0,0 +1,156 @@
+package ajean
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// TestExtractArchiveSymlink : les archives macOS/Linux de llama.cpp livrent les
+// bibliothèques sous leur nom versionné (libllama-common.0.0.10107.dylib) plus
+// un LIEN SYMBOLIQUE portant le nom recherché par l'éditeur de liens
+// (libllama-common.0.dylib). L'extracteur ignorait ces entrées : le backend
+// s'installait mais llama-server mourait sur « Library not loaded ».
+func TestExtractArchiveSymlink(t *testing.T) {
+ dir := t.TempDir()
+ archive := filepath.Join(dir, "backend.tar.gz")
+
+ f, err := os.Create(archive)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gz := gzip.NewWriter(f)
+ tw := tar.NewWriter(gz)
+ body := []byte("faux contenu de bibliothèque")
+ if err := tw.WriteHeader(&tar.Header{
+ Typeflag: tar.TypeReg, Name: "b/libllama-common.0.0.1.dylib",
+ Mode: 0o644, Size: int64(len(body)),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tw.Write(body); err != nil {
+ t.Fatal(err)
+ }
+ if err := tw.WriteHeader(&tar.Header{
+ Typeflag: tar.TypeSymlink, Name: "b/libllama-common.0.dylib",
+ Linkname: "libllama-common.0.0.1.dylib", Mode: 0o777,
+ }); err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range []func() error{tw.Close, gz.Close, f.Close} {
+ if err := c(); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ out := filepath.Join(dir, "out")
+ if err := extractArchive(archive, out); err != nil {
+ t.Fatalf("extractArchive: %v", err)
+ }
+
+ // Le lien doit être RÉSOLVABLE : symlink là où c'est permis, copie sinon
+ // (Windows sans mode développeur). Dans les deux cas os.ReadFile réussit.
+ got, err := os.ReadFile(filepath.Join(out, "b", "libllama-common.0.dylib"))
+ if err != nil {
+ t.Fatalf("lien non extrait : %v", err)
+ }
+ if string(got) != string(body) {
+ t.Fatalf("contenu résolu = %q, attendu %q", got, body)
+ }
+}
+
+// fakePrebuilt monte un dossier prebuilt contenant plusieurs releases extraites,
+// comme sur une machine mise à jour plusieurs fois.
+func fakePrebuilt(t *testing.T, version string, tags ...string) string {
+ t.Helper()
+ home := testHome(t)
+ dir := filepath.Join(home, "backends", "llama.cpp-prebuilt")
+ name := "llama-server"
+ if runtime.GOOS == "windows" {
+ name += ".exe"
+ }
+ for _, tag := range tags {
+ d := filepath.Join(dir, "llama-"+tag)
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(d, name), []byte("bin"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if version != "" {
+ if err := os.WriteFile(filepath.Join(dir, "VERSION"),
+ []byte(version+" - "+prebuiltFormat+"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return dir
+}
+
+// La version notée dans VERSION gagne, même si une extraction plus ancienne
+// vient avant dans l'ordre alphabétique — le bug : on servait b10088, une
+// installation incomplète, alors que b10280 était la version installée.
+func TestPrebuiltServerBinSuitLeMarqueurVersion(t *testing.T) {
+ fakePrebuilt(t, "b10280", "b10088", "b10159", "b10280")
+ got := filepath.ToSlash(prebuiltServerBin())
+ if !strings.Contains(got, "/llama-b10280/") {
+ t.Errorf("binaire élu = %s, attendu sous /llama-b10280/", got)
+ }
+}
+
+// Sans marqueur exploitable, on prend la release la plus récente.
+func TestPrebuiltServerBinSansVersionPrendLePlusRecent(t *testing.T) {
+ fakePrebuilt(t, "", "b10088", "b10280")
+ if got := filepath.ToSlash(prebuiltServerBin()); !strings.Contains(got, "/llama-b10280/") {
+ t.Errorf("binaire élu = %s, attendu la release la plus récente", got)
+ }
+}
+
+// Un BIN de preset écrit avant une mise à jour pointe une release disparue : il
+// doit suivre le moteur courant au lieu d'échouer au lancement (exit 127).
+func TestPrebuiltResolveBinSuitLaMiseAJour(t *testing.T) {
+ dir := fakePrebuilt(t, "b10280", "b10280")
+ stale := filepath.Join(dir, "llama-b10088", "llama-server")
+ if got := filepath.ToSlash(prebuiltResolveBin(stale)); !strings.Contains(got, "/llama-b10280/") {
+ t.Errorf("BIN périmé résolu en %s, attendu la release installée", got)
+ }
+ // Un binaire hors du dossier prebuilt (fork, build maison) n'est jamais touché.
+ ext := filepath.Join(t.TempDir(), "llama-server")
+ if got := prebuiltResolveBin(ext); got != ext {
+ t.Errorf("BIN externe réécrit en %s", got)
+ }
+}
+
+// L'installation fait le ménage : les autres releases sont supprimées.
+func TestPrebuiltPruneGardeLaVersionCourante(t *testing.T) {
+ dir := fakePrebuilt(t, "b10280", "b10088", "b10159", "b10280")
+ prebuiltPrune(prebuiltServerBin(), nil)
+ for _, tag := range []string{"b10088", "b10159"} {
+ if _, err := os.Stat(filepath.Join(dir, "llama-"+tag)); err == nil {
+ t.Errorf("%s aurait dû être supprimé", tag)
+ }
+ }
+ if _, err := os.Stat(filepath.Join(dir, "llama-b10280")); err != nil {
+ t.Errorf("la version courante a été supprimée : %v", err)
+ }
+}
+
+// Un preset pointant une release remplacée désigne toujours « le moteur
+// précompilé » : sans ça, l'UI repassait tous les presets en « personnalisé »
+// après chaque mise à jour du moteur.
+func TestPrebuiltOwns(t *testing.T) {
+ dir := fakePrebuilt(t, "b10280", "b10280")
+ if !prebuiltOwns(filepath.Join(dir, "llama-b10088", "llama-server")) {
+ t.Error("une release périmée du dossier prebuilt doit rester reconnue")
+ }
+ if prebuiltOwns(filepath.Join(t.TempDir(), "llama-server")) {
+ t.Error("un binaire externe ne doit pas être pris pour le moteur précompilé")
+ }
+ if prebuiltOwns("") {
+ t.Error("BIN vide reconnu à tort")
+ }
+}
diff --git a/internal/ajean/backend_presets.go b/internal/ajean/backend_presets.go
new file mode 100644
index 0000000..55d6f41
--- /dev/null
+++ b/internal/ajean/backend_presets.go
@@ -0,0 +1,361 @@
+package ajean
+
+import (
+ "bufio"
+ "crypto/sha1"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// A preset's IDENTITY is its filename (ID, without the .env suffix), which is
+// always unique. Its DISPLAY name lives in an optional `# NAME=` line inside the
+// file, so several presets can share the same display name without overwriting
+// each other (their filenames differ — see uniquePresetID).
+type Preset struct {
+ ID string // filename without .env — stable, unique identity
+ Name string // display name (# NAME= line, falls back to ID)
+ Path string
+ Active bool
+}
+
+var nameLineRe = regexp.MustCompile(`(?mi)^[ \t]*#?[ \t]*NAME[ \t]*=.*$`)
+
+// presetDisplayName extracts the `# NAME=` value from a preset body, falling
+// back to `fallback` (the filename id) when absent — keeps old presets working.
+func presetDisplayName(content, fallback string) string {
+ for _, line := range strings.Split(content, "\n") {
+ s := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "#"))
+ i := strings.IndexByte(s, '=')
+ if i >= 0 && strings.EqualFold(strings.TrimSpace(s[:i]), "NAME") {
+ if v := unquoteValue(strings.TrimSpace(s[i+1:])); v != "" {
+ return v
+ }
+ }
+ }
+ return fallback
+}
+
+// withDisplayName ensures the body carries a `# NAME=` line (replacing an
+// existing one, or prepended otherwise).
+func withDisplayName(content, name string) string {
+ line := "# NAME=" + name
+ if nameLineRe.MatchString(content) {
+ return nameLineRe.ReplaceAllString(content, line)
+ }
+ return line + "\n" + content
+}
+
+// configFingerprint réduit une configuration à l'ENSEMBLE TRIÉ de ses
+// affectations effectives, puis le hache. Les clés « appareil » (preservedKeys,
+// réappliquées par SwitchToPreset) sont ignorées : sans ça, les MEM_MODE /
+// CRAWL4AI_URL injectés rendraient la config active différente de TOUS les
+// presets, et aucun ne serait jamais détecté comme actif.
+func configFingerprint(m map[string]string) string {
+ pairs := make([]string, 0, len(m))
+ for k, v := range m {
+ if isPreservedKey(k) {
+ continue
+ }
+ pairs = append(pairs, k+"="+v)
+ }
+ sort.Strings(pairs)
+ h := sha1.Sum([]byte(strings.Join(pairs, "\n")))
+ return hex.EncodeToString(h[:])
+}
+
+// presetFingerprint est configFingerprint appliqué au contenu d'un preset.
+func presetFingerprint(content []byte) string {
+ return configFingerprint(parseEnv(string(content)))
+}
+
+// isPreservedKey reports whether key is one of the device-level preservedKeys.
+func isPreservedKey(key string) bool {
+ for _, k := range preservedKeys {
+ if key == k {
+ return true
+ }
+ }
+ return false
+}
+
+// ListPresets renvoie tous les presets/*.env, en marquant celui qui correspond
+// à la configuration active (clés « appareil » ignorées), triés par nom.
+func ListPresets() ([]Preset, error) {
+ dir := presetsDir()
+ _ = os.MkdirAll(dir, 0o755)
+ cur := configFingerprint(ReadConfig())
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+ out := []Preset{}
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".env") {
+ continue
+ }
+ p := filepath.Join(dir, e.Name())
+ b, err := os.ReadFile(p)
+ if err != nil {
+ continue
+ }
+ id := strings.TrimSuffix(e.Name(), ".env")
+ out = append(out, Preset{
+ ID: id,
+ Name: presetDisplayName(string(b), id),
+ Path: p,
+ Active: presetFingerprint(b) == cur,
+ })
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out, nil
+}
+
+// uniquePresetID derives a unique filename id from a display name, appending
+// " (2)", " (3)"… on collision so duplicate names never overwrite.
+func uniquePresetID(name string) (string, error) {
+ base := strings.TrimSpace(strings.NewReplacer("/", "-", "\\", "-").Replace(name))
+ if base == "" {
+ base = "preset"
+ }
+ cand := base
+ for n := 2; n < 10000; n++ {
+ p, err := safePresetPath(cand)
+ if err != nil {
+ return "", err
+ }
+ if _, err := os.Stat(p); os.IsNotExist(err) {
+ return cand, nil
+ }
+ cand = fmt.Sprintf("%s (%d)", base, n)
+ }
+ return "", fmt.Errorf("impossible de générer un nom de fichier unique")
+}
+
+// validPresetName accepts any name (spaces, accents, parentheses…) as long as it
+// stays a single safe filename: no path separators, no control chars, and not a
+// reserved directory entry. Path containment is double-checked in safePresetPath.
+func validPresetName(name string) error {
+ if name == "" {
+ return fmt.Errorf("nom vide")
+ }
+ if name == "." || name == ".." {
+ return fmt.Errorf("nom réservé")
+ }
+ if strings.ContainsAny(name, `/\`+"\x00") {
+ return fmt.Errorf(`le nom ne peut pas contenir / ni \`)
+ }
+ for _, r := range name {
+ if r < 0x20 {
+ return fmt.Errorf("le nom contient un caractère de contrôle invalide")
+ }
+ }
+ return nil
+}
+
+// safePresetPath validates name and returns its resolved path inside presetsDir.
+func safePresetPath(name string) (string, error) {
+ name = strings.TrimSpace(name)
+ if err := validPresetName(name); err != nil {
+ return "", err
+ }
+ root, err := filepath.Abs(presetsDir())
+ if err != nil {
+ return "", err
+ }
+ p := filepath.Join(root, name+".env")
+ abs, err := filepath.Abs(p)
+ if err != nil {
+ return "", err
+ }
+ if !strings.HasPrefix(abs, root+string(filepath.Separator)) {
+ return "", fmt.Errorf("path invalide")
+ }
+ return abs, nil
+}
+
+// preservedKeys sont des réglages « appareil » (préférences utilisateur, pas des
+// paramètres de modèle) qui doivent survivre à un changement de preset. Sans ça,
+// écraser config.env avec le preset ré-imposerait le mode mémoire et effacerait
+// l'URL du serveur internet à chaque bascule — ce qui obligeait à tout remettre.
+// HOST y figure depuis la 0.8.4 : c'est un réglage de MACHINE (« le moteur
+// est-il joignable depuis le réseau ? », voir sys_network.go), pas un réglage de
+// modèle. Sans lui dans cette liste, basculer sur un preset écrit avant cette
+// version effaçait la clé, et le moteur repartait sur le défaut « toutes les
+// interfaces » : une machine volontairement fermée se rouvrait toute seule au
+// premier changement de preset.
+var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST"}
+
+// softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit
+// pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un
+// choix de machine (`ajean gpu`) qui doit survivre aux bascules, MAIS un preset
+// a le droit de l'imposer — « QWEN 3.6 27B FABLE 2 GPU » réclame `1,0` pour que
+// son `--tensor-split 0.965,0.035` ait bien deux cartes en face. Le préserver
+// inconditionnellement écrasait cette valeur par l'ancienne (mono-GPU) : le
+// modèle se retrouvait entièrement sur une seule carte et mourait sur
+// « cudaMalloc failed: out of memory ». Le preset explicite gagne.
+var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true}
+
+// applyPresetFile installe le preset comme configuration active, en réinjectant
+// les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être
+// testable sans redémarrer le service (donc sans lancer un vrai llama-server).
+func applyPresetFile(target string) error {
+ src, err := os.ReadFile(target)
+ if err != nil {
+ return err
+ }
+ next := parseEnv(string(src))
+ // Ré-applique les réglages appareil par-dessus le preset — sauf ceux que le
+ // preset revendique explicitement (softPreservedKeys).
+ cur := ReadConfig()
+ for _, k := range preservedKeys {
+ v, ok := cur[k]
+ if !ok {
+ continue
+ }
+ if softPreservedKeys[k] {
+ if _, claimed := next[k]; claimed {
+ continue // le preset a son mot à dire sur cette clé : il gagne
+ }
+ }
+ next[k] = v
+ }
+ return WriteConfig(next)
+}
+
+// SwitchToPreset installe le preset et redémarre le service. Les réglages
+// « appareil » (preservedKeys) sont conservés à travers la bascule.
+func SwitchToPreset(target string) error {
+ if err := applyPresetFile(target); err != nil {
+ return err
+ }
+ fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
+ fmt.Println(dim("[info] redémarrage du service..."))
+ return serviceAction("restart")
+}
+
+func cmdSwitch(args []string) error {
+ list, err := ListPresets()
+ if err != nil {
+ return err
+ }
+ if len(list) == 0 {
+ return fmt.Errorf("aucun preset dans %s", presetsDir())
+ }
+ fmt.Printf("\n %s (%s)\n\n", cyan("Presets disponibles"), presetsDir())
+ for i, p := range list {
+ mark := " "
+ if p.Active {
+ mark = green("●") + " actif"
+ }
+ fmt.Printf(" %2d) %-30s %s\n", i+1, p.Name, mark)
+ }
+ fmt.Println()
+ choice := ""
+ if len(args) > 0 {
+ choice = args[0]
+ } else {
+ fmt.Print("Numéro à activer (vide = annuler) : ")
+ sc := bufio.NewScanner(os.Stdin)
+ if sc.Scan() {
+ choice = strings.TrimSpace(sc.Text())
+ }
+ }
+ if choice == "" {
+ fmt.Println(dim("[info] annulé"))
+ return nil
+ }
+ n, err := strconv.Atoi(choice)
+ if err != nil || n < 1 || n > len(list) {
+ return fmt.Errorf("choix invalide")
+ }
+ return SwitchToPreset(list[n-1].Path)
+}
+
+// newPresetSeedKeys : les SEULES clés reprises de la configuration active quand
+// on crée un preset. Ce sont des réglages de MACHINE (quel moteur, où il écoute),
+// pas des réglages de modèle.
+//
+// Issue #17 : le nouveau preset repartait d'une copie COMPLÈTE de la config
+// active. Les réglages du modèle précédent (EXTRA_ARGS — --n-cpu-moe,
+// --tensor-split, --flash-attn… — mais aussi CTX, NGL, KV_TYPE, MODEL) se
+// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à
+// tout nettoyer à la main. On repart d'une base vide : les valeurs non
+// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…).
+var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
+
+// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
+func newPresetSeed() map[string]string {
+ cur := ReadConfig()
+ seed := map[string]string{}
+ for _, k := range newPresetSeedKeys {
+ if v := cur[k]; v != "" {
+ seed[k] = v
+ }
+ }
+ return seed
+}
+
+// SavePreset writes a preset. When id == "" it creates a NEW preset under a
+// freshly-generated unique filename (so duplicate display names never clash).
+// When id != "" it updates that existing preset in place (filename unchanged —
+// only the body and its `# NAME=` line change). Returns the resulting id.
+func SavePreset(id, name, content string) (string, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "", fmt.Errorf("nom requis")
+ }
+ content = withDisplayName(content, name)
+ if id == "" {
+ newID, err := uniquePresetID(name)
+ if err != nil {
+ return "", err
+ }
+ p, err := safePresetPath(newID)
+ if err != nil {
+ return "", err
+ }
+ _ = os.MkdirAll(filepath.Dir(p), 0o755)
+ return newID, os.WriteFile(p, []byte(content), 0o644)
+ }
+ p, err := safePresetPath(id)
+ if err != nil {
+ return "", err
+ }
+ _ = os.MkdirAll(filepath.Dir(p), 0o755)
+ return id, os.WriteFile(p, []byte(content), 0o644)
+}
+
+// DeletePreset removes a preset by id; refuses if it is the active config.
+func DeletePreset(id string) error {
+ p, err := safePresetPath(id)
+ if err != nil {
+ return err
+ }
+ target, err := os.ReadFile(p)
+ if err != nil {
+ return fmt.Errorf("introuvable")
+ }
+ if presetFingerprint(target) == configFingerprint(ReadConfig()) {
+ return fmt.Errorf("preset actif, switche d'abord")
+ }
+ return os.Remove(p)
+}
+
+// ReadPreset returns the contents of a preset by id (filename).
+func ReadPreset(id string) (string, error) {
+ p, err := safePresetPath(id)
+ if err != nil {
+ return "", err
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
diff --git a/internal/ajean/backend_presets_test.go b/internal/ajean/backend_presets_test.go
new file mode 100644
index 0000000..385bd3a
--- /dev/null
+++ b/internal/ajean/backend_presets_test.go
@@ -0,0 +1,104 @@
+package ajean
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// setConfig installe une configuration décrite au format des presets.
+func setConfig(t *testing.T, body string) {
+ t.Helper()
+ if err := WriteConfig(parseEnv(body)); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// TestPresetFingerprintIgnoresCosmetics vérifie que la détection du preset actif
+// ne dépend QUE de la config effective (ensemble KEY=VALUE), pas de la mise en
+// forme : ordre des lignes, commentaires (dont `# NAME=`), lignes vides, `export`,
+// espaces, ni des clés « appareil » (preservedKeys) réappliquées par SwitchToPreset.
+// C'est la régression « aucun preset en surbrillance après un reformatage de
+// config.env » (toggle mémoire/internet, réordonnancement…).
+func TestPresetFingerprintIgnoresCosmetics(t *testing.T) {
+ base := []byte("# NAME=Mon preset\nMODEL=foo.gguf\nCTX=4096\nNGL=999\n")
+ // Même config effective, présentée autrement.
+ variant := []byte("export NGL=999\nMEM_MODE=off\n\nCTX=4096\n# un commentaire\nMODEL=foo.gguf\n")
+ if presetFingerprint(base) != presetFingerprint(variant) {
+ t.Fatal("empreintes différentes alors que la config effective est identique")
+ }
+ // Un vrai changement de valeur doit, lui, produire une empreinte différente.
+ changed := []byte("MODEL=foo.gguf\nCTX=8192\nNGL=999\n")
+ if presetFingerprint(base) == presetFingerprint(changed) {
+ t.Fatal("empreintes identiques alors que CTX diffère")
+ }
+}
+
+// TestSwitchToPresetGardeLesReglagesMachine : basculer de preset ne doit pas
+// effacer les réglages qui décrivent la MACHINE et non le modèle. Le cas vécu :
+// `ajean gpu 1` écrit CUDA_VISIBLE_DEVICES dans config.env, la bascule de preset
+// suivante l'écrasait, llama.cpp revoyait les deux cartes et réétalait le modèle
+// sur la petite — pendant que `ajean gpu` réaffichait « auto ».
+func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
+ home := testHome(t)
+ write := func(p, body string) {
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Configuration courante : un preset + les réglages machine.
+ setConfig(t, "MODEL=ancien.gguf\nCTX=4096\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\nMEM_MODE=always\n")
+ target := filepath.Join(home, "cible.env")
+ write(target, "MODEL=nouveau.gguf\nCTX=8192\n")
+
+ if err := applyPresetFile(target); err != nil {
+ t.Fatal(err)
+ }
+
+ cfg := ReadConfig()
+ if cfg["MODEL"] != "nouveau.gguf" || cfg["CTX"] != "8192" {
+ t.Fatalf("le preset n'a pas été appliqué : %v", cfg)
+ }
+ for k, want := range map[string]string{
+ "CUDA_VISIBLE_DEVICES": "1", "WEB_ENGINE": "go", "MEM_MODE": "always",
+ } {
+ if cfg[k] != want {
+ t.Errorf("%s = %q après bascule, attendu %q (réglage machine effacé)", k, cfg[k], want)
+ }
+ }
+}
+
+// Un preset qui définit LUI-MÊME la sélection de cartes doit gagner sur celle
+// de la machine : « FABLE 2 GPU » impose CUDA_VISIBLE_DEVICES=1,0 pour que son
+// --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait
+// mourir le chargement sur « cudaMalloc failed: out of memory ».
+func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) {
+ home := testHome(t)
+ write := func(p, body string) {
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ setConfig(t, "MODEL=ancien.gguf\nCUDA_VISIBLE_DEVICES=1\nWEB_ENGINE=go\n")
+ deuxGPU := filepath.Join(home, "deux-gpu.env")
+ write(deuxGPU, "MODEL=fable.gguf\nCUDA_VISIBLE_DEVICES=1,0\n")
+ if err := applyPresetFile(deuxGPU); err != nil {
+ t.Fatal(err)
+ }
+ if got := ReadConfig()["CUDA_VISIBLE_DEVICES"]; got != "1,0" {
+ t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu 1,0 (le preset doit gagner)", got)
+ }
+ // Un preset MUET sur les GPU laisse, lui, la sélection machine en place.
+ muet := filepath.Join(home, "muet.env")
+ write(muet, "MODEL=autre.gguf\n")
+ if err := applyPresetFile(muet); err != nil {
+ t.Fatal(err)
+ }
+ cfg := ReadConfig()
+ if cfg["CUDA_VISIBLE_DEVICES"] != "1,0" {
+ t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu 1,0 conservé", cfg["CUDA_VISIBLE_DEVICES"])
+ }
+ if cfg["WEB_ENGINE"] != "go" {
+ t.Errorf("WEB_ENGINE = %q, attendu go (réglage machine, jamais dicté par un preset)", cfg["WEB_ENGINE"])
+ }
+}
diff --git a/internal/ajean/backend_serve.go b/internal/ajean/backend_serve.go
new file mode 100644
index 0000000..ebab289
--- /dev/null
+++ b/internal/ajean/backend_serve.go
@@ -0,0 +1,189 @@
+package ajean
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+// binSupportsReasoningFlag dit si ce llama-server accepte « --reasoning ».
+//
+// Le drapeau est récent : les moteurs plus anciens, et certains forks, ne le
+// connaissent pas et REFUSENT de démarrer sur un argument inconnu. Comme on ne
+// l'ajoute que pour interdire le raisonnement, mieux vaut demander au binaire
+// que parier : on lit son aide, une fois, au lancement du moteur.
+//
+// L'aide se lit avec le même chemin de bibliothèques que le vrai lancement
+// (setLibraryPath a déjà été appelé) : sans ça un moteur parfaitement valide
+// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
+// tort qu'il ne gère pas le drapeau.
+func binSupportsReasoningFlag(bin string) bool {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, bin, "--help")
+ var out bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &out, &out
+ if err := cmd.Run(); err != nil && out.Len() == 0 {
+ return false // aide illisible : on ne prend pas le risque
+ }
+ return strings.Contains(out.String(), "--reasoning ")
+}
+
+// cmdServe replaces the historic start.sh: read config.env, build the
+// llama-server invocation, and exec it (replacing this process so systemd
+// supervises llama-server directly).
+func cmdServe(args []string) error {
+ cfg := ReadConfig()
+ bin := cfg["BIN"]
+ if bin == "" {
+ return fmt.Errorf("BIN non défini — lance « ajean edit »")
+ }
+ model := cfg["MODEL"]
+ if model == "" {
+ return fmt.Errorf("MODEL non défini — lance « ajean edit »")
+ }
+ // MODEL vaut soit un simple nom de fichier (le .gguf vit dans AJEAN_HOME ou
+ // dans un dossier déclaré — disque externe…), soit un chemin absolu. Sous
+ // systemd/launchd le WorkingDirectory vaut AJEAN_HOME, donc le relatif tombait
+ // juste ; lancé depuis une app de bureau, le répertoire courant est « / » et
+ // llama-server ne trouvait rien. On résout donc explicitement, quel que soit
+ // le contexte de lancement.
+ resolved, err := resolveServeModelPath(model)
+ if err != nil {
+ return err
+ }
+ model = resolved
+ if _, err := os.Stat(model); err != nil {
+ return fmt.Errorf("modèle introuvable : %s", model)
+ }
+ // Modèle découpé en tranches : llama-server ouvre les suivantes tout seul, mais
+ // s'il en manque une il démarre puis meurt sur un tenseur introuvable — message
+ // incompréhensible, et systemd relance en boucle. On le dit ici, en clair.
+ if missing := shardFamilyMissing(filepath.Dir(model), filepath.Base(model)); len(missing) > 0 {
+ return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous",
+ strings.Join(missing, ", "), filepath.Dir(model), len(shardFamily(filepath.Base(model))))
+ }
+ if !filepath.IsAbs(bin) {
+ bin = filepath.Join(AjeanHome(), bin)
+ }
+ // Le moteur précompilé s'installe dans un dossier versionné : un preset écrit
+ // avant une mise à jour pointe sur une release qui n'existe plus. On le fait
+ // suivre au moteur courant plutôt que d'échouer en 127.
+ bin = prebuiltResolveBin(bin)
+
+ // Make sure llama-server can find its bundled shared libraries (the .so/.dll
+ // neighbours of the binary). This is platform-specific: LD_LIBRARY_PATH on
+ // Linux, PATH on Windows — handled inside execServer.
+ setLibraryPath(filepath.Dir(bin))
+
+ // Sélection GPU (ajean gpu) : on filtre les devices visibles par llama-server.
+ // CUDA_DEVICE_ORDER=PCI_BUS_ID garantit que les index correspondent à ceux
+ // affichés par nvidia-smi (sinon CUDA réordonne par "device le plus rapide").
+ if v := cfg["CUDA_VISIBLE_DEVICES"]; v != "" {
+ _ = os.Setenv("CUDA_VISIBLE_DEVICES", v)
+ _ = os.Setenv("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
+ }
+
+ get := func(key, fallback string) string {
+ if v, ok := cfg[key]; ok && v != "" {
+ return v
+ }
+ return fallback
+ }
+ kv := get("KV_TYPE", "")
+ ktv := get("KV_TYPE_K", kv)
+ vtv := get("KV_TYPE_V", kv)
+
+ llmArgs := []string{bin,
+ "-m", model,
+ "-ngl", get("NGL", "999"),
+ "-c", get("CTX", "32768"),
+ "-t", get("THREADS", "0"),
+ "-tb", get("THREADS_BATCH", "0"),
+ "-b", get("BATCH", "2048"),
+ "-ub", get("UBATCH", "512"),
+ "--host", get("HOST", "0.0.0.0"),
+ "--port", get("PORT", "8080"),
+ }
+ if ktv != "" {
+ llmArgs = append(llmArgs, "-ctk", ktv)
+ }
+ if vtv != "" {
+ llmArgs = append(llmArgs, "-ctv", vtv)
+ }
+ // Vision : le projecteur multimodal (mmproj-*.gguf) donne des yeux au modèle.
+ // C'est un fichier .gguf À PART du modèle, chargé via --mmproj. On le résout
+ // comme le modèle (nom simple cherché dans les dossiers déclarés, ou chemin
+ // absolu), pour qu'un preset écrit sous Windows reste lançable ailleurs et que
+ // le champ « Vision » de l'interface n'ait qu'à écrire le nom du fichier.
+ // Introuvable = on préfère le dire clairement plutôt que laisser llama-server
+ // mourir en boucle sur un « failed to load mmproj » cryptique.
+ if mm := strings.TrimSpace(cfg["MMPROJ"]); mm != "" {
+ mmPath, err := resolveServeModelPath(mm)
+ if err != nil {
+ return fmt.Errorf("projecteur vision introuvable : %s (%v)", mm, err)
+ }
+ if _, err := os.Stat(mmPath); err != nil {
+ return fmt.Errorf("projecteur vision introuvable : %s", mmPath)
+ }
+ llmArgs = append(llmArgs, "--mmproj", mmPath)
+ }
+ // Raisonnement. Trois cas, et la nuance compte :
+ //
+ // REASONING=on|auto|deepseek → --reasoning
+ // REASONING=off → --reasoning off (interdiction EXPLICITE)
+ // clé absente → aucun drapeau, le moteur fait son défaut
+ //
+ // L'interface écrivait « off » en EFFAÇANT la ligne, ce qui n'est pas du tout
+ // la même chose : sans drapeau, llama-server suit le gabarit du modèle, et un
+ // modèle à raisonnement raisonne. L'interrupteur affichait donc « désactivé »
+ // pendant que le modèle réfléchissait quand même. Il faut le dire au moteur.
+ if r := strings.TrimSpace(cfg["REASONING"]); r != "" {
+ if reasoningActive(r) {
+ // budget illimité par défaut (-1) : on laisse le modèle réfléchir jusqu'au
+ // bout au lieu de le couper à 2048, ce qui tronquait la vraie réponse (la
+ // réflexion atteignait le plafond et il ne restait plus de marge pour le
+ // contenu). L'anti-boucle côté llm_client.go reste le garde-fou. NE PAS forcer 0 :
+ // sur llama.cpp vanilla, 0 = "immediate end" → coupe tout le raisonnement
+ // (le fork ik_llama.cpp l'ignore). Configurable via REASONING_BUDGET.
+ llmArgs = append(llmArgs, "--reasoning", r, "--reasoning-budget", get("REASONING_BUDGET", "-1"))
+ } else if binSupportsReasoningFlag(bin) {
+ // Pas de budget ici : « off » suffit, et un budget sur un moteur qui
+ // n'attend rien d'autre ne ferait qu'ajouter une occasion d'échouer.
+ llmArgs = append(llmArgs, "--reasoning", "off")
+ } else {
+ // Vieux moteur (ou fork) qui ne connaît pas le drapeau : le lui passer
+ // le ferait sortir en erreur au démarrage, donc boucler. On le dit et on
+ // continue sans — mieux vaut un modèle qui réfléchit qu'un moteur mort.
+ fmt.Fprintf(os.Stderr, "[ajean serve] ce moteur ne connaît pas --reasoning : impossible de désactiver le raisonnement\n")
+ }
+ }
+ // API_KEY protège le serveur quand il est exposé sur internet : llama-server
+ // exige alors l'en-tête "Authorization: Bearer ". La clé est lue depuis
+ // $AJEAN_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
+ // qui réécrivent config.env), avec config.env comme repli rétro-compatible.
+ if k := readAPIKey(); k != "" {
+ llmArgs = append(llmArgs, "--api-key", k)
+ } else if k := cfg["API_KEY"]; k != "" {
+ llmArgs = append(llmArgs, "--api-key", k)
+ }
+ // EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
+ // together so a path with spaces stays one argument.
+ llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
+
+ // Working dir = AJEAN_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
+ // mmproj-F16.gguf) still resolve.
+ _ = os.Chdir(AjeanHome())
+
+ fmt.Fprintf(os.Stderr, "[ajean serve] %s model=%s port=%s\n",
+ bin, filepath.Base(model), get("PORT", "8080"))
+
+ // Hand off to the llama-server process. On Unix this replaces the current
+ // process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
+ return execServer(bin, llmArgs)
+}
diff --git a/internal/ajean/chat_agent.go b/internal/ajean/chat_agent.go
new file mode 100644
index 0000000..4eb6aba
--- /dev/null
+++ b/internal/ajean/chat_agent.go
@@ -0,0 +1,49 @@
+package ajean
+
+import "fmt"
+
+// Le « mode agent » est l'unique interrupteur qui donne à l'IA l'accès à ses
+// outils : le shell (run_shell), l'écriture de fichiers et sa mémoire
+// (mem_search/mem_read/mem_add/mem_edit).
+
+func agentEnabled() bool { return getBool(bkState, "agent") }
+
+func setAgentEnabled(on bool) error { return putBool(bkState, "agent", on) }
+
+func cmdAgent(args []string) error {
+ sub := ""
+ if len(args) > 0 {
+ sub = args[0]
+ }
+ switch sub {
+ case "on":
+ if err := setAgentEnabled(true); err != nil {
+ return err
+ }
+ fmt.Println(green("[ok]") + " mode agent activé — l'IA dispose du shell complet (" + shellName() + "), de l'écriture de fichiers et de sa mémoire")
+ case "off":
+ if err := setAgentEnabled(false); err != nil {
+ return err
+ }
+ fmt.Println(green("[ok]") + " mode agent désactivé")
+ case "", "status":
+ state := dim("off")
+ if agentEnabled() {
+ state = green("on")
+ }
+ fmt.Printf("%s état: %s\n", cyan("Mode agent"), state)
+ fmt.Printf(" outils : %s (timeout %ds, max %ds) + write/edit + mémoire (mem_search/mem_read/mem_add/mem_edit)\n", shellName(), toolDefaultTimeout, toolMaxTimeout)
+ mem := MemList()
+ if len(mem) == 0 {
+ fmt.Printf(" mémoire : aucune page — crée %s/.md\n", memoryDir())
+ return nil
+ }
+ fmt.Printf(" mémoire (%s) :\n", memoryDir())
+ for _, p := range mem {
+ fmt.Printf(" %s %s\n", bold(p.Name), p.Title)
+ }
+ default:
+ return fmt.Errorf("usage: ajean agent [on|off|status]")
+ }
+ return nil
+}
diff --git a/internal/ajean/chat_cmd.go b/internal/ajean/chat_cmd.go
new file mode 100644
index 0000000..653a852
--- /dev/null
+++ b/internal/ajean/chat_cmd.go
@@ -0,0 +1,157 @@
+package ajean
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os"
+ "strings"
+)
+
+// cmdChat is the interactive terminal chat loop.
+// First positional arg (if any) becomes the system prompt.
+func cmdChat(args []string) error {
+ if !healthCheck() {
+ return fmt.Errorf("serveur injoignable sur :%d — ajean start d'abord", LLMPort())
+ }
+ sysPrompt := strings.Join(args, " ")
+ msgs := []Message{}
+ if sysPrompt != "" {
+ msgs = append(msgs, Message{Role: "system", Content: sysPrompt})
+ }
+ fmt.Printf("\n%s — /reset pour vider, /sys pour changer le system, /quit ou Ctrl-D pour sortir\n", cyan("ajean chat"))
+ if sysPrompt != "" {
+ fmt.Println(dim("system: " + sysPrompt))
+ }
+ fmt.Println()
+ sc := bufio.NewScanner(os.Stdin)
+ sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
+ for {
+ fmt.Print(bold("you") + " > ")
+ if !sc.Scan() {
+ fmt.Println()
+ return nil
+ }
+ user := sc.Text()
+ u := strings.TrimSpace(user)
+ if u == "" {
+ continue
+ }
+ if u == "/quit" || u == "/exit" {
+ return nil
+ }
+ if u == "/reset" {
+ kept := []Message{}
+ for _, m := range msgs {
+ if m.Role == "system" {
+ kept = append(kept, m)
+ }
+ }
+ msgs = kept
+ fmt.Println(dim("[contexte vidé]"))
+ continue
+ }
+ if strings.HasPrefix(u, "/sys ") {
+ newSys := strings.TrimSpace(u[5:])
+ msgs = msgs[:0]
+ if newSys != "" {
+ msgs = append(msgs, Message{Role: "system", Content: newSys})
+ }
+ fmt.Println(dim("[system mis à jour]"))
+ continue
+ }
+ msgs = append(msgs, Message{Role: "user", Content: user})
+ full := strings.Builder{}
+ inReason := false
+ var stats *StatsEvent
+ // Print the assistant prefix once; reasoning is shown inline with a tag.
+ fmt.Print(cyan("ajean") + " > ")
+ caps := globalCaps()
+ // Compaction proactive (façon Hermes) : on résume les vieux tours quand
+ // l'historique dépasse le seuil, au lieu d'imposer un /reset.
+ if compacted, changed := MaybeCompact(context.Background(), msgs, caps, 0); changed {
+ msgs = compacted
+ fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
+ }
+ extra, err := runChat(context.Background(), InjectSkills(msgs, caps), 0.7, caps, func(ev StreamEvent) bool {
+ switch {
+ case ev.Err != nil:
+ fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
+ case ev.NewHistory != nil:
+ // Compaction survenue pendant le tour : elle REMPLACE l'historique (elle
+ // contient déjà le tour en cours), préfixe système injecté retiré. Sans
+ // ça le terminal repartirait du fil complet au tour suivant.
+ base := ev.NewHistory
+ for len(base) > 0 && base[0].Role == "system" {
+ base = base[1:]
+ }
+ msgs = append([]Message(nil), base...)
+ fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]"))
+ case ev.Stats != nil:
+ stats = ev.Stats
+ case ev.DropReasoning:
+ // Le tour a « pensé sans agir » : on relance. Impossible d'effacer le
+ // texte déjà imprimé en terminal — on referme juste la ligne reasoning.
+ if inReason {
+ fmt.Print("\n")
+ inReason = false
+ }
+ case ev.ToolUsed != nil:
+ if ev.ToolUsed.Done || ev.ToolUsed.Typing {
+ break // résultat / frappe live affichés côté web ; en terminal on garde l'annonce seule
+ }
+ icon := "🧠"
+ verb := "mémoire"
+ switch ev.ToolUsed.Name {
+ case "bash":
+ icon = "⚙️"
+ verb = "exécution"
+ case "write":
+ icon = "📄"
+ verb = "écriture"
+ case "edit":
+ icon = "✏️"
+ verb = "édition"
+ case "web_search", "web_open", "web_read", "web_grep":
+ icon = "🌐"
+ verb = "web"
+ }
+ if inReason {
+ fmt.Print("\n")
+ inReason = false
+ }
+ fmt.Printf("\n%s %s : %s\n%s ", dim(icon+" "+verb), "", magenta(ev.ToolUsed.Label), cyan("ajean")+" >")
+ case ev.Reasoning != "":
+ if !inReason {
+ fmt.Print(magenta("[reasoning] ") + dim(""))
+ inReason = true
+ }
+ fmt.Print(dim(ev.Reasoning))
+ case ev.Content != "":
+ if inReason {
+ fmt.Print("\n" + cyan("ajean") + " > ")
+ inReason = false
+ }
+ full.WriteString(ev.Content)
+ fmt.Print(ev.Content)
+ }
+ return true
+ })
+ if inReason {
+ fmt.Println()
+ }
+ fmt.Println()
+ if stats != nil {
+ fmt.Printf("%s prefill %d tok · %.0f tok/s decode %d tok · %.1f tok/s\n",
+ dim("→"), stats.PromptTokens, stats.PromptPerSecond, stats.GenTokens, stats.GenPerSecond)
+ }
+ fmt.Println()
+ if err == nil {
+ // Persist the tool turns (skill reads, shell runs) BEFORE the final
+ // answer, so next turn the model remembers it already did them instead
+ // of re-invoking the same skill/command from scratch.
+ msgs = append(msgs, extra...)
+ msgs = append(msgs, Message{Role: "assistant", Content: full.String()})
+ }
+ }
+}
diff --git a/internal/ajean/chat_compact.go b/internal/ajean/chat_compact.go
new file mode 100644
index 0000000..f5903a8
--- /dev/null
+++ b/internal/ajean/chat_compact.go
@@ -0,0 +1,445 @@
+package ajean
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// Compactage du contexte, façon Hermes Agent : au lieu de vider la conversation
+// quand la fenêtre de contexte se remplit, on la scinde en trois zones —
+//
+// Head (tête) : messages système + tout premier message utilisateur. Protégé.
+// Tail (queue) : les tours récents (dans un budget de tokens). Protégé.
+// Torso (torse) : tout le milieu. C'est LA SEULE zone compactée.
+//
+// Le torse est d'abord dégraissé sans IA (les vieux résultats d'outils longs
+// sont remplacés par un marqueur), puis résumé par le modèle local en UN seul
+// appel, et le tout est remplacé par un court résumé. Résultat : des
+// conversations quasi illimitées sans jamais « clear », comme Hermes.
+//
+// La logique vit côté serveur (dans le flux de chat) donc elle profite à TOUS
+// les clients — UI web, terminal, accès distant ajean.link — sans duplication.
+
+const (
+ // Seuil de déclenchement proactif : on compacte quand l'historique estimé
+ // dépasse cette fraction de la fenêtre de contexte.
+ compactTriggerFrac = 0.75
+ // Budget de la queue : fraction de la fenêtre gardée intacte (tours récents).
+ // Plus la queue est petite, plus on compacte de torse d'un coup → le contexte
+ // retombe bas et met longtemps à re-déclencher (au lieu de compacter souvent).
+ compactTailFrac = 0.25
+ // Un résultat d'outil du torse plus long que ça est remplacé par un marqueur
+ // dans le torse DÉGRAISSÉ (repli si le résumé échoue).
+ compactToolPruneLen = 200
+ // Longueur à laquelle on RACCOURCIT (sans l'effacer) un résultat d'outil avant
+ // de le donner au résumeur : assez pour que les faits d'une page web y soient,
+ // assez court pour que dix pages tiennent dans la transcription.
+ compactToolSummaryLen = 1200
+)
+
+// compactPrunedMarker remplace un vieux résultat d'outil dans le torse. Il dit
+// EXPLICITEMENT de ne pas relancer l'outil : le texte précédent (« Old tool
+// result cleared ») se lisait comme une invitation à re-télécharger la page, et
+// le modèle repartait en boucle — page relue, contexte plein, nouveau compactage,
+// résultat re-effacé, et ainsi de suite.
+const compactPrunedMarker = "[Old tool result removed to save context. The important content is in the summary above — do NOT call this tool again to fetch it back.]"
+
+// compactSummaryPrefix ouvre le message `user` synthétique qui porte le résumé.
+// Sert aussi à le reconnaître pour ne pas le confondre avec une vraie demande.
+const compactSummaryPrefix = "[CONTEXT COMPACTED]"
+
+// compactEnabled indique si le compactage automatique du contexte est actif.
+// Défaut : true. Seule une valeur off/false/0/no/non explicite (config.env
+// COMPACT) le désactive — cohérent avec toolLimitEnabled().
+func compactEnabled() bool {
+ switch strings.ToLower(strings.TrimSpace(ReadConfig()["COMPACT"])) {
+ case "off", "false", "0", "no", "non":
+ return false
+ }
+ return true
+}
+
+// ctxWindow renvoie la fenêtre de contexte configurée (config.env CTX), 32768
+// par défaut — la même valeur que celle passée à llama-server au lancement.
+func ctxWindow() int {
+ if v := ReadConfig()["CTX"]; v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ return n
+ }
+ }
+ return 32768
+}
+
+// msgText extrait le texte d'un message (Content est `any`, en pratique string
+// ou nil quand l'assistant n'a que des tool_calls). Un message multimodal
+// (userMessageContent : parties texte + image quand la vision est active) porte
+// un tableau de parties ; on en recolle les segments `text` pour que l'estimation
+// de contexte et le transcript de compaction ne partent pas d'un texte vide.
+func msgText(m Message) string {
+ switch v := m.Content.(type) {
+ case string:
+ return v
+ case []map[string]any:
+ var b strings.Builder
+ for _, part := range v {
+ if part["type"] == "text" {
+ if s, ok := part["text"].(string); ok {
+ b.WriteString(s)
+ }
+ }
+ }
+ return b.String()
+ case []any: // même contenu relu depuis le JSON persisté (map générique)
+ var b strings.Builder
+ for _, p := range v {
+ if part, ok := p.(map[string]any); ok && part["type"] == "text" {
+ if s, ok := part["text"].(string); ok {
+ b.WriteString(s)
+ }
+ }
+ }
+ return b.String()
+ }
+ return ""
+}
+
+// msgTokens estime grossièrement le coût en tokens d'un message (~4 caractères
+// par token, plus un forfait par message pour le rôle et les délimiteurs). C'est
+// volontairement approximatif : le comptage EXACT vient de llama.cpp
+// (PromptTokensTotal) ; ici on veut juste décider quand compacter.
+func msgTokens(m Message) int {
+ n := 4
+ n += len(msgText(m)) / 4
+ for _, tc := range m.ToolCalls {
+ n += (len(tc.Function.Name) + len(tc.Function.Arguments)) / 4
+ }
+ return n
+}
+
+// estimateTokens estime la taille de l'historique en tokens.
+func estimateTokens(msgs []Message) int {
+ total := 0
+ for _, m := range msgs {
+ total += msgTokens(m)
+ }
+ return total
+}
+
+// MaybeCompact compacte l'historique si (et seulement si) il dépasse le seuil
+// proactif. Renvoie l'historique (compacté ou inchangé) et un booléen indiquant
+// s'il a changé. À appeler sur l'historique BRUT (avant InjectSkills) pour que
+// le résultat puisse être renvoyé au client sans le préfixe système injecté.
+//
+// knownTokens = taille RÉELLE du contexte au tour précédent (usage.prompt_tokens
+// + tokens générés), telle que rapportée par llama.cpp et affichée par l'UI. On
+// la préfère à estimateTokens() car cette dernière n'est qu'une heuristique et,
+// surtout, ne « voit » pas le prompt système injecté (machine briefing) ni le
+// gabarit de chat — donc elle sous-estime largement le vrai contexte. 0 = inconnu
+// (clients sans compteur, ex. terminal) → repli sur l'estimation.
+// compactWouldTrigger indique si un tour VA déclencher une compaction proactive
+// (compactage activé ET contexte au-dessus du seuil). Exposé pour que le flux de
+// chat puisse afficher une bannière « compactage en cours » AVANT de lancer le
+// résumé (qui bloque plusieurs secondes), au lieu d'une UI figée sans info.
+func compactWouldTrigger(msgs []Message, knownTokens int) bool {
+ if !compactEnabled() {
+ return false
+ }
+ used := knownTokens
+ if used <= 0 {
+ used = estimateTokens(msgs)
+ }
+ return used >= int(float64(ctxWindow())*compactTriggerFrac)
+}
+
+// logCompact trace UNE ligne par décision de compaction sur la sortie d'erreur
+// (donc dans `journalctl -u ajean-ui`). Sans ça, une compaction qui ne se
+// déclenche pas — ou qui se déclenche et n'enlève rien — est invisible : côté
+// UI on ne voit qu'une jauge qui reste haute, sans savoir si le seuil n'a pas
+// été atteint ou si la réduction a été refusée.
+func logCompact(phase string, used int, before, after []Message, changed bool) {
+ fmt.Fprintf(os.Stderr, "[compact] %s ctx=%d seuil=%d/%d est_avant=%d est_apres=%d msgs=%d→%d changé=%v\n",
+ phase, used, int(float64(ctxWindow())*compactTriggerFrac), ctxWindow(),
+ estimateTokens(before), estimateTokens(after), len(before), len(after), changed)
+}
+
+func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens int) ([]Message, bool) {
+ if !compactWouldTrigger(msgs, knownTokens) {
+ return msgs, false
+ }
+ return compactMessages(ctx, msgs, caps)
+}
+
+// compactMessages exécute la compaction sans tenir compte du seuil (utilisé en
+// secours réactif quand llama-server refuse un prompt trop long). Renvoie
+// l'historique compacté et true s'il a effectivement changé.
+// compactBounds calcule les frontières head/tail pour un historique donné et un
+// budget de queue (en tokens). Fonction pure (pas d'IO) → testable :
+// - head : nb de messages protégés en tête = messages système + 1er message
+// utilisateur (il ancre l'objectif). Un message user est une frontière sûre.
+// - tailStart : index de début de la queue protégée. On remonte depuis la fin
+// jusqu'à remplir le budget, puis on recule jusqu'à une frontière SÛRE :
+// un message `user`, ou un `assistant` (qui, s'il porte des tool_calls, part
+// dans la queue AVEC ses résultats). On ne sépare ainsi jamais un
+// assistant+tool_calls de ses `tool`, et on ne laisse jamais un `tool`
+// orphelin en tête de queue.
+//
+// Reculer jusqu'à un `user` UNIQUEMENT était trop strict et rendait toute
+// 2ᵉ compaction inopérante dans un même tour : pendant une longue boucle
+// d'outils il n'y a AUCUN message `user`, donc la queue avalait toute la
+// séquence d'outils et le torse était vide (« le compactage ne fait rien »
+// alors que ce sont précisément les pages web lues qui remplissent la
+// fenêtre). S'arrêter sur un `assistant` coupe proprement entre deux groupes
+// d'appels d'outils.
+//
+// Le torse à compacter est [head, tailStart). Il est vide (tailStart <= head)
+// quand il n'y a rien à résumer.
+func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
+ for head < len(msgs) && msgs[head].Role == "system" {
+ head++
+ }
+ if head < len(msgs) && msgs[head].Role == "user" {
+ head++
+ }
+ tailStart = len(msgs)
+ acc := 0
+ for i := len(msgs) - 1; i >= head; i-- {
+ acc += msgTokens(msgs[i])
+ tailStart = i
+ if acc >= tailBudget {
+ break
+ }
+ }
+ for tailStart > head && msgs[tailStart].Role != "user" && msgs[tailStart].Role != "assistant" {
+ tailStart--
+ }
+ return head, tailStart
+}
+
+func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message, bool) {
+ // Budget de queue = fraction de la CONVERSATION (pas de la fenêtre). Le lier à
+ // la fenêtre était le bug : une conversation de 25k tokens dans une fenêtre de
+ // 64k gardait 16k (0.25×64k) en queue → torse minuscule → réduction < 20% →
+ // refusée. Lié à la conversation, on garde toujours ~25% des tours récents et
+ // on compacte les ~75% du début, quelle que soit la taille de la fenêtre.
+ tailBudget := int(float64(estimateTokens(msgs)) * compactTailFrac)
+ head, tailStart := compactBounds(msgs, tailBudget)
+
+ // Rien à compacter : le torse [head, tailStart) est vide.
+ if tailStart <= head {
+ return msgs, false
+ }
+
+ torso := msgs[head:tailStart]
+
+ // 3. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
+ // marqueur. On travaille sur une copie pour ne pas muter l'historique amont.
+ // ⚠️ Ce torse dégraissé ne sert QUE de repli si le résumé échoue — surtout
+ // PAS d'entrée au résumeur, cf. juste en dessous.
+ pruned := make([]Message, len(torso))
+ for i, m := range torso {
+ pruned[i] = m
+ if m.Role == "tool" {
+ if t := msgText(m); len(t) > compactToolPruneLen {
+ pruned[i].Content = compactPrunedMarker
+ }
+ }
+ }
+
+ // 4. Résumé du torse par le modèle local (un seul appel).
+ //
+ // Le résumé se fait sur le torse ORIGINAL, pas sur le dégraissé. C'était LE
+ // bug de fond : on effaçait tous les résultats d'outils PUIS on demandait un
+ // résumé de ce qui restait. Le résumeur ne voyait donc que « Tool result:
+ // [Old tool result cleared] » à la place de chaque page web lue — le résumé
+ // ne pouvait contenir AUCUNE des informations trouvées, seulement la trace
+ // que des outils avaient tourné. À chaque compactage, l'IA repartait donc
+ // d'une recherche vide et recommençait à zéro : elle ne s'arrêtait jamais.
+ //
+ // Les résultats d'outils sont seulement RACCOURCIS (leur début, qui porte
+ // l'essentiel : titre, en-tête, premières lignes) pour que la transcription
+ // reste bornée. Les faits survivent, le volume reste maîtrisé.
+ forSummary := make([]Message, len(torso))
+ for i, m := range torso {
+ forSummary[i] = m
+ if m.Role == "tool" {
+ if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
+ forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
+ }
+ }
+ }
+ summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
+ var mid []Message
+ if err != nil || strings.TrimSpace(summary) == "" {
+ mid = pruned
+ } else {
+ // Le résumé est injecté comme un tour utilisateur→assistant (jamais un
+ // message `system` au milieu : certains gabarits, ex. Qwen, exigent que le
+ // system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix).
+ mid = []Message{
+ {Role: "user", Content: compactSummaryPrefix + " The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n" + summary},
+ {Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, without redoing work that is already done."},
+ }
+ }
+
+ // La demande EN COURS ne doit JAMAIS être diluée dans le résumé. Pendant une
+ // longue boucle d'outils (recherche web : dix pages lues d'affilée), la queue
+ // n'est faite que d'appels d'outils : le message `user` qui a lancé la
+ // recherche tombe dans le torse, alors que le TOUT PREMIER message de la
+ // conversation, lui, reste épinglé en tête. Après compaction le modèle voyait
+ // donc, comme seule demande explicite, la question du DÉBUT de la conversation
+ // — et il y répondait en abandonnant la recherche en cours.
+ // On réinjecte donc textuellement la dernière vraie demande du torse, juste
+ // avant la queue (les résultats d'outils qu'elle a produits la suivent, comme
+ // dans l'historique d'origine). Le torse reste entièrement compactable.
+ var pending []Message
+ for i := len(torso) - 1; i >= 0; i-- {
+ if torso[i].Role != "user" {
+ continue
+ }
+ if strings.HasPrefix(msgText(torso[i]), compactSummaryPrefix) {
+ continue // résumé d'une compaction précédente, pas une demande
+ }
+ pending = []Message{torso[i]}
+ break
+ }
+
+ out := make([]Message, 0, head+len(mid)+len(pending)+len(msgs)-tailStart)
+ out = append(out, msgs[:head]...)
+ out = append(out, mid...)
+ out = append(out, pending...)
+ out = append(out, msgs[tailStart:]...)
+
+ // Garantie de réduction : on n'accepte la compaction que si elle enlève au
+ // moins ~20% du contexte estimé. Sinon (torse déjà maigre, résumé peu rentable)
+ // on la refuse — sans ça, ajean « compactait » à presque chaque message sans
+ // vraiment réduire, puis re-déclenchait aussitôt.
+ before, after := estimateTokens(msgs), estimateTokens(out)
+ if after > before*4/5 {
+ return msgs, false
+ }
+ return out, true
+}
+
+// renderTranscript sérialise le torse en texte lisible pour le résumeur.
+func renderTranscript(msgs []Message) string {
+ var b strings.Builder
+ for _, m := range msgs {
+ switch m.Role {
+ case "user":
+ fmt.Fprintf(&b, "User: %s\n", msgText(m))
+ case "assistant":
+ if t := msgText(m); t != "" {
+ fmt.Fprintf(&b, "Assistant: %s\n", t)
+ }
+ for _, tc := range m.ToolCalls {
+ fmt.Fprintf(&b, "Assistant → tool %s(%s)\n", tc.Function.Name, tc.Function.Arguments)
+ }
+ case "tool":
+ fmt.Fprintf(&b, "Tool result: %s\n", msgText(m))
+ case "system":
+ fmt.Fprintf(&b, "System: %s\n", msgText(m))
+ }
+ }
+ s := b.String()
+ // Garde-fou pour les petites fenêtres : le résumeur ne doit pas lui-même
+ // déborder. On plafonne la transcription (~0,7×contexte en tokens ≈ 2,8
+ // caractères/token) en gardant la FIN (la plus récente) et en marquant la
+ // troncature de tête.
+ maxChars := int(float64(ctxWindow()) * 2.8)
+ if maxChars > 0 && len(s) > maxChars {
+ s = "[…start truncated…]\n" + s[len(s)-maxChars:]
+ }
+ return s
+}
+
+// summarizeResp modélise le sous-ensemble utile d'une réponse non-streamée de
+// /v1/chat/completions.
+type summarizeResp struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+}
+
+// summarizeTranscript demande au modèle local un résumé dense et fidèle du torse.
+// Un seul appel NON streamé, sans outils — comme Hermes, on réutilise le modèle
+// principal déjà chargé (aucune dépendance, cohérent avec la fenêtre de contexte).
+func summarizeTranscript(ctx context.Context, transcript string) (string, error) {
+ sys := `You are a context compactor. You are given the transcript of the older turns of a conversation between a user and an AI assistant (with its tools). The PURPOSE of your summary is to let the conversation continue in a fresh, smaller context WITHOUT losing any information that is useful or important to understand what came before and keep working — preserve everything that matters, drop only what is redundant.
+
+The assistant is MID-TASK: it will read your summary and must resume exactly where it left off, WITHOUT redoing work it has already done. Its own internal reasoning is NOT part of the transcript and is lost — your summary is the only memory it keeps.
+
+Summarize densely and faithfully, keeping ONLY the essentials:
+- The user's CURRENT request, goal(s) and constraints
+- FINDINGS: the concrete information already gathered — facts, figures, dates, names, URLs, file paths, values, config. This is the most important part: whatever is not here is lost and will have to be looked up again.
+- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time
+- Decisions made and established facts
+- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
+Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Aim for 300 words MAX — this is a compression summary, not a report.
+Write the summary in the SAME language as the conversation.`
+
+ payload := map[string]any{
+ "model": "ajean",
+ "messages": []Message{
+ {Role: "system", Content: sys},
+ {Role: "user", Content: transcript},
+ },
+ "stream": false,
+ "temperature": 0.2,
+ // Borne dure : sans ça, un modèle bavard (surtout à reasoning) produit un
+ // résumé énorme et lent, donc peu de réduction → re-compaction à chaque tour.
+ "max_tokens": 700,
+ // Pas de réflexion pour un résumé : plus rapide, plus dense, et évite qu'un
+ // modèle hybride gaspille tout le budget en (résumé vide). llama.cpp
+ // passe ces kwargs au gabarit Jinja (--jinja).
+ "chat_template_kwargs": map[string]any{"enable_thinking": false},
+ }
+ body, _ := json.Marshal(payload)
+ url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ authHeader(req)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", friendlyLLMError(err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
+ return "", fmt.Errorf("résumé: llama-server %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
+ }
+ var out summarizeResp
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return "", err
+ }
+ if len(out.Choices) == 0 {
+ return "", fmt.Errorf("résumé: réponse vide")
+ }
+ c := out.Choices[0].Message.Content
+ // Certains modèles à raisonnement préfixent un bloc … : on ne
+ // garde que la réponse finale.
+ if i := strings.LastIndex(c, thinkClose); i >= 0 {
+ c = c[i+len(thinkClose):]
+ }
+ c = strings.TrimSpace(c)
+ // Garde-fou dur : même si le modèle ignore la consigne de longueur, on tronque
+ // pour garantir une vraie compression. 2200 caractères (≈ 550 tokens) et pas
+ // 1500 : le résumé doit désormais porter les FAITS déjà trouvés, pas seulement
+ // l'intention, sinon l'IA repart en recherche après chaque compactage. Coupé
+ // sur une frontière de rune (é, … ne doivent pas devenir des �).
+ if r := []rune(c); len(r) > 2200 {
+ c = strings.TrimSpace(string(r[:2200])) + " […]"
+ }
+ return c, nil
+}
diff --git a/internal/ajean/chat_compact_test.go b/internal/ajean/chat_compact_test.go
new file mode 100644
index 0000000..cd0ae37
--- /dev/null
+++ b/internal/ajean/chat_compact_test.go
@@ -0,0 +1,172 @@
+package ajean
+
+import (
+ "strings"
+ "testing"
+)
+
+// helpers pour construire des historiques de test lisibles.
+func um(s string) Message { return Message{Role: "user", Content: s} }
+func am(s string) Message { return Message{Role: "assistant", Content: s} }
+func atc(name string) Message {
+ return Message{Role: "assistant", ToolCalls: []ToolCall{{ID: "c1", Function: ToolCallFunc{Name: name, Arguments: "{}"}}}}
+}
+func tm(s string) Message { return Message{Role: "tool", ToolCallID: "c1", Content: s} }
+
+func TestCompactBoundsProtectsHead(t *testing.T) {
+ msgs := []Message{
+ {Role: "system", Content: "sys"},
+ um("premier"), am("r1"),
+ um("q2"), am("r2"),
+ um("q3"), am("r3"),
+ }
+ // budget minuscule → queue = juste le dernier tour, tête = system + 1er user.
+ head, tail := compactBounds(msgs, 1)
+ if head != 2 { // system + premier user
+ t.Fatalf("head = %d, attendu 2", head)
+ }
+ // Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin).
+ if r := msgs[tail].Role; r != "user" && r != "assistant" {
+ t.Fatalf("la queue doit démarrer sur un user ou un assistant, obtenu %q", r)
+ }
+ if tail <= head {
+ t.Fatalf("torse vide (tail=%d head=%d) alors qu'il y a du milieu à compacter", tail, head)
+ }
+}
+
+// La queue ne doit jamais démarrer entre un assistant+tool_calls et ses
+// résultats `tool` : elle recule jusqu'à la frontière sûre précédente (user ou
+// assistant), donc l'assistant part dans la queue AVEC ses résultats.
+func TestCompactBoundsKeepsToolPairs(t *testing.T) {
+ msgs := []Message{
+ um("q1"), am("r1"),
+ um("q2"), atc("bash"), tm("sortie longue"), am("r2"),
+ um("q3"), am("r3"),
+ }
+ // budget moyen qui, sans le recul, couperait au milieu du tour outillé.
+ _, tail := compactBounds(msgs, msgTokens(msgs[6])+msgTokens(msgs[7])+msgTokens(msgs[5])+1)
+ if r := msgs[tail].Role; r != "user" && r != "assistant" {
+ t.Fatalf("la queue démarre sur %q : frontière non sûre (orphelin tool possible)", r)
+ }
+ // Vérifie qu'aucun message `tool` de la queue n'a perdu son assistant parent.
+ for i := tail; i < len(msgs); i++ {
+ if msgs[i].Role == "tool" {
+ if i == tail || (msgs[i-1].Role != "assistant" && msgs[i-1].Role != "tool") {
+ t.Fatalf("message tool orphelin à l'index %d de la queue", i)
+ }
+ }
+ }
+}
+
+// Régression : DEUXIÈME compaction à l'intérieur d'un même tour. Une longue
+// boucle d'outils (recherche web : dix pages lues d'affilée) ne contient AUCUN
+// message `user` — reculer jusqu'à un `user` faisait donc avaler toute la
+// séquence par la queue, torse vide, compaction sans effet. La queue doit
+// pouvoir démarrer sur un `assistant`, et les gros résultats d'outils du début
+// doivent se retrouver dans le torse (donc résumés).
+func TestCompactBoundsSplitsLongToolLoop(t *testing.T) {
+ page := func(n int) Message {
+ return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
+ }
+ msgs := []Message{
+ um("premier"), am("ok"), // head : 1er user protégé
+ um("cherche des trucs"), // la dernière demande utilisateur du tour
+ }
+ for i := 0; i < 10; i++ {
+ msgs = append(msgs, atc("web_read"), page(i))
+ }
+ head, tail := compactBounds(msgs, estimateTokens(msgs)/4)
+ if tail <= head {
+ t.Fatalf("torse vide (tail=%d head=%d) : la boucle d'outils n'est pas compactable", tail, head)
+ }
+ if r := msgs[tail].Role; r != "user" && r != "assistant" {
+ t.Fatalf("la queue démarre sur %q : frontière non sûre", r)
+ }
+ // Aucun `tool` orphelin en queue.
+ for i := tail; i < len(msgs); i++ {
+ if msgs[i].Role == "tool" && (i == tail || (msgs[i-1].Role != "assistant" && msgs[i-1].Role != "tool")) {
+ t.Fatalf("message tool orphelin à l'index %d de la queue", i)
+ }
+ }
+ // Le torse doit bien contenir des résultats d'outils (c'est ce qui remplit
+ // la fenêtre) — sinon la compaction ne libérerait rien.
+ tools := 0
+ for _, m := range msgs[head:tail] {
+ if m.Role == "tool" {
+ tools++
+ }
+ }
+ if tools == 0 {
+ t.Fatal("aucun résultat d'outil dans le torse : rien à gagner à compacter")
+ }
+}
+
+// Régression : après compaction pendant une recherche web, la DEMANDE EN COURS
+// doit encore figurer telle quelle dans l'historique. Sans ça, le seul message
+// `user` restant était le tout premier de la conversation (épinglé en tête) et
+// le modèle répondait à celui-là au lieu de continuer la recherche.
+func TestCompactKeepsPendingRequest(t *testing.T) {
+ page := func(n int) Message {
+ return tm("contenu de page web très long " + string(rune('a'+n)) + strings.Repeat("x", 400))
+ }
+ msgs := []Message{
+ um("première question de la conversation"), am("ok"),
+ um("cherche les horaires du train pour Lyon"),
+ }
+ for i := 0; i < 10; i++ {
+ msgs = append(msgs, atc("web_read"), page(i))
+ }
+ // summarizeTranscript échoue (pas de llama-server en test) → torse dégraissé,
+ // ce qui n'enlève rien à ce qu'on vérifie ici : la demande doit survivre.
+ out, _ := compactMessages(t.Context(), msgs, Caps{})
+ found := false
+ for _, m := range out {
+ if m.Role == "user" && strings.Contains(msgText(m), "horaires du train") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("la demande en cours a disparu de l'historique compacté")
+ }
+}
+
+// Régression : ce qu'on envoie au RÉSUMEUR doit encore contenir les résultats
+// d'outils. On effaçait les résultats (dégraissage) AVANT de résumer : le
+// résumeur ne voyait que des marqueurs, le résumé ne pouvait donc porter aucune
+// information trouvée, et l'IA relançait la même recherche après chaque
+// compactage — sans jamais s'arrêter.
+func TestSummaryInputKeepsToolFindings(t *testing.T) {
+ fait := "le train de 14h12 part quai 3"
+ torso := []Message{
+ atc("web_read"),
+ tm(fait + strings.Repeat(" blabla de remplissage", 300)),
+ }
+ // Même transformation que compactMessages avant l'appel au résumeur.
+ forSummary := make([]Message, len(torso))
+ for i, m := range torso {
+ forSummary[i] = m
+ if m.Role == "tool" {
+ if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
+ forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
+ }
+ }
+ }
+ tr := renderTranscript(forSummary)
+ if !strings.Contains(tr, fait) {
+ t.Fatal("le fait trouvé n'atteint pas le résumeur : le résumé sera vide d'information")
+ }
+ if strings.Contains(tr, compactPrunedMarker) {
+ t.Fatal("le résumeur reçoit des marqueurs d'effacement au lieu du contenu")
+ }
+ if len([]rune(tr)) > 4000 {
+ t.Fatalf("transcription non bornée (%d runes) : le résumeur va déborder", len([]rune(tr)))
+ }
+}
+
+func TestEstimateTokensGrows(t *testing.T) {
+ small := estimateTokens([]Message{um("court")})
+ big := estimateTokens([]Message{um("un message nettement plus long que le précédent pour dépasser")})
+ if big <= small {
+ t.Fatalf("estimateTokens ne croît pas avec la taille: small=%d big=%d", small, big)
+ }
+}
diff --git a/internal/ajean/chat_conversation.go b/internal/ajean/chat_conversation.go
new file mode 100644
index 0000000..e5edd14
--- /dev/null
+++ b/internal/ajean/chat_conversation.go
@@ -0,0 +1,776 @@
+package ajean
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// État de conversation CÔTÉ SERVEUR — une seule conversation partagée par tous
+// les appareils. Avant, l'historique vivait dans le localStorage de chaque
+// navigateur : refresh = perte des détails (outils/vitesses/raisonnement),
+// contexte différent par appareil, et fermer l'onglet coupait la génération
+// (liée à r.Context()). Ici l'état est possédé par le serveur, persisté sur
+// disque, et la génération tourne dans une goroutine détachée : fermer le
+// navigateur ne l'arrête plus, et se reconnecter rejoue tout le fil.
+//
+// Idée clé : l'UI reconstruit déjà tout l'affichage à partir d'une suite
+// d'événements SSE `delta` (content, reasoning_content, tool_used, stats…). On
+// JOURNALISE ces événements (Log) horodatés par Seq. Se reconnecter = rejouer
+// Log[from:] puis suivre les événements en direct — aucun code de rendu nouveau.
+
+// maxLogEvents plafonne le journal d'AFFICHAGE (pas la vue modèle). Depuis la
+// coalescence en fin de tour (compactLogLocked), un tour terminé ne pèse plus
+// qu'une poignée d'événements au lieu d'un par token → 20000 couvre des
+// centaines de tours. La marge sert surtout à absorber UN tour en cours (streamé
+// token par token) avant sa coalescence : un très gros tour (long raisonnement +
+// réponse) ne doit pas se faire tronquer le début avant d'être compacté.
+const maxLogEvents = 20000
+
+// LogEvent = un événement d'affichage rejouable (un delta SSE + son numéro de
+// séquence monotone + un horodatage serveur en ms). Le TS permet au client de
+// calculer la vitesse (tok/s) à partir du temps RÉEL de génération — correct
+// aussi bien en direct qu'au replay (où tout arrive d'un bloc côté client).
+type LogEvent struct {
+ Seq int `json:"seq"`
+ TS int64 `json:"ts"`
+ Delta map[string]any `json:"delta"`
+}
+
+// Conversation est le fil unique partagé. Protégé par mu ; cond réveille les
+// abonnés (aucun canal par abonné : les abonnés lisent Log au-delà de leur
+// dernier Seq puis attendent cond — replay et direct sont le même chemin).
+type Conversation struct {
+ mu sync.Mutex
+ cond *sync.Cond
+
+ Messages []Message `json:"messages"` // vue « modèle » (nourrit runChat)
+ Log []LogEvent `json:"log"` // vue « UI » rejouable
+ Seq int `json:"seq"`
+ CtxUsed int `json:"ctx_used"` // taille réelle du contexte au dernier tour
+
+ Generating bool `json:"-"`
+ cancel context.CancelFunc // annule la génération en cours (/stop)
+ epoch int // incrémenté à chaque reset → invalide les abonnés
+}
+
+var conv = func() *Conversation {
+ c := &Conversation{}
+ c.cond = sync.NewCond(&c.mu)
+ return c
+}()
+
+// La conversation est persistée en base, sur la machine qui fait tourner le
+// modèle. En clair : cette machine déchiffre déjà pour lancer le modèle, le
+// relais reste aveugle — la persister ici ne change rien à la posture E2E.
+
+// LoadConversation recharge l'état persisté au démarrage du process. Sans état
+// enregistré (première fois) on part d'une conversation vide.
+func LoadConversation() {
+ b := getBytes(bkChat, "conversation")
+ if len(b) == 0 {
+ return
+ }
+ conv.mu.Lock()
+ defer conv.mu.Unlock()
+ _ = json.Unmarshal(b, conv)
+ // Une génération n'a pas pu survivre à l'arrêt du process : on repart propre.
+ conv.Generating = false
+ conv.cancel = nil
+}
+
+// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
+// delta). L'appelant NE doit PAS détenir mu.
+func (c *Conversation) persist() {
+ c.mu.Lock()
+ b, err := json.Marshal(c)
+ c.mu.Unlock()
+ if err != nil {
+ return
+ }
+ _ = putBytes(bkChat, "conversation", b)
+}
+
+// appendDelta journalise un événement d'affichage et réveille les abonnés.
+// epoch est celui capturé au début du tour : si un Reset est passé entre-temps,
+// l'événement appartient à l'ancienne conversation et est jeté (sinon il
+// polluerait le journal tout neuf avec des Seq repartis de zéro).
+func (c *Conversation) appendDelta(epoch int, delta map[string]any) {
+ c.mu.Lock()
+ if c.epoch != epoch {
+ c.mu.Unlock()
+ return
+ }
+ c.Seq++
+ c.Log = append(c.Log, LogEvent{Seq: c.Seq, TS: time.Now().UnixMilli(), Delta: delta})
+ if len(c.Log) > maxLogEvents {
+ c.Log = c.Log[len(c.Log)-maxLogEvents:]
+ }
+ c.cond.Broadcast()
+ c.mu.Unlock()
+}
+
+// evToks lit le nombre de tokens porté par un événement texte : 1 pour un delta
+// brut (streaming), ou la valeur `toks` accumulée pour un événement déjà coalescé.
+func evToks(d map[string]any) int {
+ switch v := d["toks"].(type) {
+ case int:
+ return v
+ case float64: // relu depuis le JSON persisté
+ return int(v)
+ }
+ return 1
+}
+
+// evTS0 lit l'horodatage de DÉBUT d'un événement texte (présent seulement sur les
+// événements coalescés) ; sinon on retombe sur `fallback` (le TS de l'événement).
+func evTS0(d map[string]any, fallback int64) int64 {
+ switch v := d["ts0"].(type) {
+ case int64:
+ return v
+ case float64:
+ return int64(v)
+ }
+ return fallback
+}
+
+// compactLogLocked coalesce EN PLACE les suites d'événements content /
+// reasoning_content du journal d'affichage en un seul événement chacun (même
+// logique que coalesceReplay). Sans ça, le journal grossit token par token et
+// atteint maxLogEvents en quelques réponses → on perd le DÉBUT de la conversation
+// à l'affichage. On l'appelle en fin de tour (les événements sont alors figés).
+// On préserve `toks` (somme) et `ts0` (premier) pour que le compteur de vitesse
+// (tok/s) reste correct au replay. Verrou détenu par l'appelant.
+func (c *Conversation) compactLogLocked() {
+ if len(c.Log) < 2 {
+ return
+ }
+ textKey := func(d map[string]any) string {
+ if _, ok := d["content"].(string); ok {
+ return "content"
+ }
+ if _, ok := d["reasoning_content"].(string); ok {
+ return "reasoning_content"
+ }
+ return ""
+ }
+ out := make([]LogEvent, 0, len(c.Log))
+ var buf strings.Builder
+ bufKey := ""
+ var cur LogEvent
+ var toks int
+ var ts0 int64
+ var seq0 int
+ flush := func() {
+ if bufKey == "" {
+ return
+ }
+ // seq0 = seq du PREMIER delta fusionné (cur.Seq, lui, vaut celui du dernier).
+ cur.Delta = map[string]any{bufKey: buf.String(), "toks": toks, "ts0": ts0, "seq0": seq0}
+ out = append(out, cur)
+ buf.Reset()
+ bufKey, toks, ts0, seq0 = "", 0, 0, 0
+ }
+ for _, ev := range c.Log {
+ key := textKey(ev.Delta)
+ if key == "" {
+ flush()
+ out = append(out, ev)
+ continue
+ }
+ if bufKey != "" && bufKey != key {
+ flush()
+ }
+ if bufKey == "" {
+ bufKey = key
+ cur = LogEvent{Seq: ev.Seq, TS: ev.TS}
+ ts0 = evTS0(ev.Delta, ev.TS)
+ seq0 = evSeq0(ev.Delta, ev.Seq)
+ }
+ buf.WriteString(ev.Delta[key].(string))
+ cur.Seq, cur.TS = ev.Seq, ev.TS
+ toks += evToks(ev.Delta)
+ }
+ flush()
+ c.Log = out
+}
+
+// compactAndPublish exécute UNE compaction et en publie tout le cycle de vie :
+// bannière de progression, journal, installation du résultat, jauge de contexte.
+// Renvoie l'historique (compacté ou inchangé) et s'il a changé.
+//
+// Les trois compactions (début de tour, fin de tour, bouton manuel) déroulaient
+// la même douzaine de lignes recopiées, y compris le calcul du surcoût fixe :
+// une correction dans l'une ne suivait pas dans les deux autres.
+//
+// Le surcoût, justement : le contexte RÉEL mesuré moins l'estimation des
+// messages donne ce que l'estimation ne voit pas (prompt système injecté,
+// schémas d'outils, gabarit de chat). On le rajoute à l'estimation d'après
+// compaction, sinon la jauge s'effondre puis resaute au tour suivant.
+func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase string, msgs []Message, ctxUsed int, caps Caps) ([]Message, bool) {
+ c.appendDelta(epoch, map[string]any{"compacting": true})
+ compacted, changed := compactMessages(ctx, msgs, caps)
+ c.appendDelta(epoch, map[string]any{"compacting": false})
+ logCompact(phase, ctxUsed, msgs, compacted, changed)
+ if !changed {
+ // Seuil franchi mais compaction sans effet (torse vide, ou réduction sous
+ // le minimum exigé) : on le DIT. Retirer la bannière sans un mot donnait,
+ // vu de l'UI, « le compactage automatique ne fait rien ».
+ c.appendDelta(epoch, map[string]any{"compact_noop": true})
+ return msgs, false
+ }
+ overhead := ctxUsed - estimateTokens(msgs)
+ if overhead < 0 {
+ overhead = 0
+ }
+ est := estimateTokens(compacted) + overhead
+ c.mu.Lock()
+ if c.epoch == epoch {
+ c.Messages = compacted
+ c.CtxUsed = est // le vrai compte reviendra avec les stats du prochain tour
+ }
+ c.mu.Unlock()
+ c.appendDelta(epoch, map[string]any{"compacted": true})
+ c.appendDelta(epoch, map[string]any{"ctx_used": est}) // fait chuter la jauge tout de suite
+ return compacted, true
+}
+
+// convState renvoie un instantané léger (pour /api/chat/state).
+func (c *Conversation) state() map[string]any {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ // `turns` = nombre d'échanges, borne du curseur de portée de l'export (voir
+ // countTurns). Compté ici plutôt que par un appel dédié : c'est un balayage
+ // du journal déjà en main, et l'état est de toute façon relu à l'ouverture.
+ turns := 0
+ for _, ev := range c.Log {
+ if _, ok := ev.Delta["user"]; ok {
+ turns++
+ }
+ }
+ return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
+}
+
+// ErrBusy : une génération est déjà en cours (un seul tour à la fois).
+var ErrBusy = fmt.Errorf("génération en cours")
+
+// errModelLoading est renvoyée telle quelle à l'utilisateur, dans le chat : ce
+// n'est pas un défaut mais une attente, et le message doit le dire.
+var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.")
+
+// StartTurn ajoute le message utilisateur et lance la génération EN ARRIÈRE-PLAN
+// (context.Background, détaché de toute connexion HTTP). Renvoie ErrBusy si un
+// tour est déjà en cours, ou une erreur si le modèle n'est pas prêt.
+// files = pièces jointes déjà déposées (web_upload.go). Elles sont annoncées au
+// MODÈLE en tête du message, mais rendues comme pastilles dans la bulle : le fil
+// doit montrer ce que l'utilisateur a écrit, pas la consigne qu'on ajoute pour lui.
+func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, temperature float64) error {
+ if !healthCheck() {
+ return errModelLoading
+ }
+ c.mu.Lock()
+ if c.Generating {
+ c.mu.Unlock()
+ return ErrBusy
+ }
+ c.Generating = true
+ ctx, cancel := context.WithCancel(context.Background())
+ c.cancel = cancel
+ // Envoi sans un mot, juste un fichier : la bulle reste vide (les pastilles
+ // disent tout), mais le modèle a besoin d'une demande — sans elle il reçoit
+ // une liste de fichiers et rien à en faire.
+ prompt := text
+ if strings.TrimSpace(prompt) == "" {
+ prompt = "Prends-en connaissance."
+ }
+ // Content = simple texte d'ordinaire ; format multimodal (texte + images) quand
+ // la vision est active et qu'une pièce jointe est une image (userMessageContent).
+ c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)})
+ epoch := c.epoch
+ c.mu.Unlock()
+
+ // Borne de tour + bulle utilisateur (rejouables). Persistée tout de suite :
+ // si le process meurt en pleine génération (crash, restart après MAJ), le
+ // message de l'utilisateur survit au lieu de disparaître avec le tour.
+ delta := map[string]any{"user": text}
+ if len(files) > 0 {
+ delta["files"] = files
+ }
+ c.appendDelta(epoch, delta)
+ c.persist()
+ if temperature == 0 {
+ temperature = 0.7
+ }
+ go c.generate(ctx, caps, temperature, epoch)
+ return nil
+}
+
+// generate exécute un tour complet et journalise chaque événement. Détaché : la
+// fermeture du navigateur n'a aucun effet ici, seul /stop (cancel) l'interrompt.
+// epoch est capturé au StartTurn : si un Reset survient pendant la génération,
+// tout ce que ce tour produirait ensuite (deltas, messages, persistance) est
+// abandonné au lieu de ressusciter des morceaux de l'ancienne conversation.
+func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) {
+ defer func() {
+ c.mu.Lock()
+ stale := c.epoch != epoch
+ // ⚠️ Un tour périmé ne touche PAS à l'état courant. Depuis que Reset
+ // débloque lui-même la conversation, un tour abandonné peut se terminer
+ // APRÈS le démarrage du suivant : remettre Generating à false ici
+ // déclarerait « libre » une génération toute neuve, et l'UI afficherait
+ // un fil qui se remplit avec un bouton « envoyer » actif.
+ if !stale {
+ c.Generating = false
+ c.cancel = nil
+ }
+ c.mu.Unlock()
+ if stale {
+ return // Reset pendant le tour : Reset a déjà persisté l'état vide
+ }
+ c.appendDelta(epoch, map[string]any{"turn_done": true})
+ c.mu.Lock()
+ c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit
+ c.mu.Unlock()
+ c.persist()
+ }()
+
+ // Snapshot de la vue modèle.
+ c.mu.Lock()
+ msgs := append([]Message(nil), c.Messages...)
+ ctxUsed := c.CtxUsed
+ c.mu.Unlock()
+
+ // Compaction proactive (façon Hermes) sur la vue MODÈLE uniquement ; le journal
+ // d'affichage garde le fil complet. Le résumé est un appel modèle non streamé :
+ // il bloque plusieurs secondes AVANT que la vraie réponse commence, d'où la
+ // bannière de progression émise par compactAndPublish.
+ if compactWouldTrigger(msgs, ctxUsed) {
+ if out, changed := c.compactAndPublish(ctx, epoch, "début-tour", msgs, ctxUsed, caps); changed {
+ msgs = out
+ }
+ }
+
+ // Prompt système personnalisé (UI → /api/sysprompt, fichier côté serveur).
+ // Injecté seulement dans la vue envoyée au modèle, jamais persisté dans
+ // c.Messages : modifiable à chaud, effet dès le tour suivant.
+ final := msgs
+ if sp := readSysPrompt(); sp != "" {
+ final = append([]Message{{Role: "system", Content: sp}}, msgs...)
+ }
+
+ // newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
+ // Non-nil = elle remplace l'historique (elle contient déjà le tour en cours).
+ var newBase []Message
+ var content strings.Builder
+ extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
+ switch {
+ case ev.Err != nil:
+ c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
+ case ev.ToolUsed != nil:
+ tu := map[string]any{
+ "name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
+ "result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
+ }
+ // Corps en cours de frappe : transitoire (l'état final est le diff), on
+ // ne l'ajoute que quand il est là pour ne pas gonfler chaque événement.
+ if ev.ToolUsed.Body != "" {
+ tu["body"] = ev.ToolUsed.Body
+ }
+ // Lignes +/- d'une écriture (edit / mémoire) : persistées avec le reste
+ // pour que le diff soit encore là après un rafraîchissement.
+ if len(ev.ToolUsed.Diff) > 0 {
+ tu["diff"] = ev.ToolUsed.Diff
+ }
+ c.appendDelta(epoch, map[string]any{"tool_used": tu})
+ case ev.NewHistory != nil:
+ // Compaction faite en cours de tour : elle remplace la base au lieu de
+ // s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire le
+ // préfixe système injecté à la volée (prompt perso + skills, fusionnés en
+ // UN message system en tête) : il n'appartient pas à l'historique persisté
+ // et doit rester modifiable à chaud.
+ base := ev.NewHistory
+ for len(base) > 0 && base[0].Role == "system" {
+ base = base[1:]
+ }
+ newBase = append([]Message(nil), base...)
+ case ev.Compacting != nil:
+ // Compaction déclenchée pendant la boucle d'outils : même bannière que la
+ // compaction de début de tour.
+ c.appendDelta(epoch, map[string]any{"compacting": *ev.Compacting})
+ case ev.Stats != nil:
+ // Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur
+ // et la décision de compactage au tour suivant.
+ if ev.Stats.PromptTokensTotal > 0 {
+ c.mu.Lock()
+ if c.epoch == epoch {
+ c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
+ }
+ c.mu.Unlock()
+ }
+ c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
+ case ev.DropReasoning:
+ c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
+ case ev.Reasoning != "":
+ c.appendDelta(epoch, map[string]any{"reasoning_content": ev.Reasoning})
+ case ev.Content != "":
+ content.WriteString(ev.Content)
+ c.appendDelta(epoch, map[string]any{"content": ev.Content})
+ }
+ return true // génération détachée : on ne s'interrompt jamais sur un abonné
+ })
+
+ // Persiste la vue modèle : messages d'outils (assistant tool_calls + résultats)
+ // PUIS la réponse finale — même ordre que l'ancien client, pour que le modèle
+ // garde la trace de ce qu'il a fait. Sauf si un Reset est passé entre-temps :
+ // la nouvelle conversation vide ne doit pas hériter de la fin de l'ancienne.
+ c.mu.Lock()
+ if c.epoch == epoch {
+ if newBase != nil {
+ c.Messages = newBase
+ }
+ c.Messages = append(c.Messages, extra...)
+ if s := content.String(); strings.TrimSpace(s) != "" {
+ c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
+ }
+ }
+ msgs = append([]Message(nil), c.Messages...)
+ ctxUsed = c.CtxUsed
+ stale := c.epoch != epoch
+ c.mu.Unlock()
+
+ // Compaction de FIN DE TOUR. C'était LE trou : le seuil n'était testé qu'au
+ // DÉBUT d'un tour, avec le contexte du tour précédent. Le tour qui fait
+ // franchir le seuil se termine donc à 81% et… rien. La jauge reste haute, seul
+ // le bouton manuel s'affiche, et l'utilisateur voit un compactage automatique
+ // qui « ne marche pas » — alors qu'il attendait simplement le message suivant.
+ // On compacte donc DÈS que le tour qui a franchi le seuil est fini : la jauge
+ // retombe tout de suite et le tour suivant démarre avec de la marge.
+ // Sauf si un Reset est passé (rien à compacter) ou si le tour a été annulé
+ // (bouton stop) : on n'enchaîne pas plusieurs secondes de résumé sur un stop.
+ if stale || ctx.Err() != nil || !compactWouldTrigger(msgs, ctxUsed) {
+ return
+ }
+ // context.Background() et non ctx : le tour est terminé, son contexte peut
+ // être annulé alors que cette compaction-là doit aller au bout.
+ c.compactAndPublish(context.Background(), epoch, "fin-tour", msgs, ctxUsed, caps)
+}
+
+// CompactNow force une compaction du contexte MAINTENANT, sans attendre le seuil
+// (bouton « compacter » de l'UI). Détaché comme la génération : émet la bannière
+// de progression, résume les anciens tours, remplace le torse et persiste. Les
+// événements passent par le flux d'abonnement, donc tous les appareils voient la
+// progression. Renvoie ErrBusy si un tour est déjà en cours.
+func (c *Conversation) CompactNow() error {
+ if !healthCheck() {
+ return errModelLoading
+ }
+ c.mu.Lock()
+ if c.Generating {
+ c.mu.Unlock()
+ return ErrBusy
+ }
+ c.Generating = true
+ ctx, cancel := context.WithCancel(context.Background())
+ c.cancel = cancel
+ msgs := append([]Message(nil), c.Messages...)
+ lastReal := c.CtxUsed // dernier contexte réel mesuré, pour estimer le surcoût fixe
+ epoch := c.epoch
+ c.mu.Unlock()
+
+ go func() {
+ defer func() {
+ c.mu.Lock()
+ // Même précaution que dans generate : une compaction abandonnée par un
+ // Reset ne doit pas déclarer « libre » le tour qui a démarré depuis.
+ if c.epoch == epoch {
+ c.Generating = false
+ c.cancel = nil
+ }
+ c.mu.Unlock()
+ }()
+ if _, changed := c.compactAndPublish(ctx, epoch, "manuel", msgs, lastReal, Caps{}); changed {
+ c.persist()
+ }
+ }()
+ return nil
+}
+
+// Stop interrompt la génération en cours (le cas échéant).
+func (c *Conversation) Stop() {
+ c.mu.Lock()
+ cancel := c.cancel
+ c.mu.Unlock()
+ if cancel != nil {
+ cancel()
+ }
+}
+
+// Reset démarre une nouvelle conversation (vide) pour TOUS les appareils. On
+// interrompt une éventuelle génération, on vide tout et on bump epoch pour que
+// les abonnés reçoivent l'ordre de nettoyer leur affichage.
+//
+// Reset DÉBLOQUE toujours, et c'est sa deuxième raison d'être. Il se contentait
+// avant de vider le fil : si un tour restait coincé (moteur redémarré sous ses
+// pieds, commande shell accrochée à ses tubes), Generating restait vrai pour
+// toujours et tout message suivant se voyait refusé « génération en cours ».
+// Vider le fil ne changeait rien, rafraîchir non plus : il fallait redémarrer le
+// service. « Nouvelle conversation » est le geste qu'on tente naturellement dans
+// ce cas ; il doit donc rendre la main, quoi qu'il arrive au tour abandonné, que
+// le bump d'epoch réduit de toute façon au silence.
+func (c *Conversation) Reset() {
+ c.Stop()
+ c.mu.Lock()
+ c.Messages = nil
+ c.Log = nil
+ c.Seq = 0
+ c.CtxUsed = 0
+ c.epoch++
+ c.Generating = false
+ c.cancel = nil
+ c.cond.Broadcast()
+ c.mu.Unlock()
+ c.persist()
+}
+
+// evSeq0 lit le seq de DÉBUT d'un bloc texte : sur un événement coalescé (dont le
+// Seq vaut celui du DERNIER delta fusionné), c'est le seq du PREMIER delta du bloc ;
+// sur un delta brut, c'est son propre Seq.
+//
+// ⚠️ Sans ça : un client qui a déjà affiché une PARTIE d'un bloc (streaming en
+// cours) et qui reçoit ensuite ce bloc coalescé — parce que la compaction de fin de
+// tour a remplacé la suite de deltas par un seul événement de Seq plus grand que
+// son dernier seq vu — le concatène à ce qu'il affichait déjà : la réponse
+// apparaissait DEUX FOIS (la 1re copie tronquée à l'endroit exact où le client en
+// était). Cf. `replace` ci-dessous.
+func evSeq0(d map[string]any, fallback int) int {
+ switch v := d["seq0"].(type) {
+ case int:
+ return v
+ case float64: // relu depuis le JSON persisté
+ return int(v)
+ }
+ return fallback
+}
+
+// decorateEvent aplatit un événement du journal pour l'émission SSE et marque
+// `replace` quand le client a DÉJÀ vu le début du bloc (from tombe à l'intérieur) :
+// le texte envoyé est alors le bloc ENTIER, donc le client doit remplacer sa bulle
+// au lieu d'y concaténer.
+func decorateEvent(ev LogEvent, from int) map[string]any {
+ out := map[string]any{"seq": ev.Seq, "ts": ev.TS}
+ for k, v := range ev.Delta {
+ out[k] = v
+ }
+ if isTextDelta(ev.Delta) && evSeq0(ev.Delta, ev.Seq) <= from {
+ out["replace"] = true
+ }
+ return out
+}
+
+// isTextDelta : événement porteur de texte (content / reasoning_content).
+func isTextDelta(d map[string]any) bool {
+ if _, ok := d["content"].(string); ok {
+ return true
+ }
+ _, ok := d["reasoning_content"].(string)
+ return ok
+}
+
+// coalesceReplay fusionne les deltas texte consécutifs (content / reasoning_content)
+// d'un même bloc en UN seul événement, pour que le replay au chargement soit léger
+// (quelques événements par tour au lieu de milliers de tokens). On conserve le
+// nombre de tokens fusionnés (toks) et les bornes d'horodatage (ts0→ts) pour que le
+// client reconstitue le compteur ET la vitesse. Les événements non-texte (user,
+// tool_used, stats, turn_done…) passent tels quels.
+func coalesceReplay(events []LogEvent, from int) []map[string]any {
+ var out []map[string]any
+ var buf strings.Builder
+ bufKey := ""
+ var bufSeq, bufToks, bufSeq0 int
+ var bufTs0, bufTs int64
+ flush := func() {
+ if bufKey == "" {
+ return
+ }
+ m := map[string]any{bufKey: buf.String(), "seq": bufSeq, "ts": bufTs, "ts0": bufTs0, "toks": bufToks, "seq0": bufSeq0}
+ // Le client a déjà affiché le début de ce bloc (from tombe dedans, ce qui
+ // arrive quand la compaction de fin de tour l'a fusionné) : on renvoie le
+ // bloc entier et on lui demande de REMPLACER sa bulle, pas d'y ajouter.
+ if bufSeq0 <= from {
+ m["replace"] = true
+ }
+ out = append(out, m)
+ buf.Reset()
+ bufKey, bufSeq, bufToks, bufTs0, bufTs, bufSeq0 = "", 0, 0, 0, 0, 0
+ }
+ // Coalescence des outils : un appel d'outil génère plein d'événements tool_used
+ // intermédiaires (streaming des arguments) jusqu'à un dernier avec done=true. Au
+ // replay, seul l'état FINAL de chaque bulle compte (les intermédiaires ne servent
+ // qu'à l'affichage progressif en direct). Sans ça, un long fil rejoue des milliers
+ // de tool_used → 2-3 s de rendu inutile sur mobile. On ne garde donc que les
+ // done=true, en mémorisant le dernier non-done pour le cas d'un outil interrompu.
+ var pendingTool map[string]any
+ flushTool := func() {
+ if pendingTool != nil {
+ out = append(out, pendingTool)
+ pendingTool = nil
+ }
+ }
+ for _, ev := range events {
+ if ev.Seq <= from {
+ continue
+ }
+ _, isTool := ev.Delta["tool_used"].(map[string]any)
+ // Tout événement NON-outil clôt une éventuelle bulle d'outil en attente, pour
+ // préserver l'ordre (l'outil non terminé s'affiche avant ce qui le suit).
+ if !isTool {
+ flushTool()
+ }
+ // Delta texte ? (une seule clé content ou reasoning_content, valeur string)
+ key := ""
+ if s, ok := ev.Delta["content"].(string); ok {
+ key, _ = "content", s
+ } else if s, ok := ev.Delta["reasoning_content"].(string); ok {
+ key, _ = "reasoning_content", s
+ }
+ if key != "" {
+ if bufKey != "" && bufKey != key {
+ flush()
+ }
+ if bufKey == "" {
+ bufKey, bufTs0, bufSeq0 = key, evTS0(ev.Delta, ev.TS), evSeq0(ev.Delta, ev.Seq)
+ }
+ buf.WriteString(ev.Delta[key].(string))
+ bufSeq, bufTs = ev.Seq, ev.TS
+ bufToks += evToks(ev.Delta) // 1 pour un delta brut, N pour un événement déjà coalescé
+ continue
+ }
+ flush()
+ m := map[string]any{"seq": ev.Seq, "ts": ev.TS}
+ for k, v := range ev.Delta {
+ m[k] = v
+ }
+ if isTool {
+ tu, _ := ev.Delta["tool_used"].(map[string]any)
+ done, _ := tu["done"].(bool)
+ if done {
+ pendingTool = nil // les intermédiaires de cet outil sont superflus
+ out = append(out, m)
+ } else {
+ pendingTool = m // on retient le dernier état non terminé, sans l'émettre
+ }
+ continue
+ }
+ out = append(out, m)
+ }
+ flush()
+ flushTool()
+ return out
+}
+
+// Subscribe diffuse les événements au client via emit : d'abord un REPLAY coalescé
+// de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement.
+// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
+// continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
+func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
+ // Réveille les attentes de cond quand la connexion se ferme.
+ go func() {
+ <-ctx.Done()
+ c.mu.Lock()
+ c.cond.Broadcast()
+ c.mu.Unlock()
+ }()
+
+ // 0. Amorçage anti-buffering. Le chat E2E d'app.ajean.link traverse Cloudflare
+ // (ajean.link, proxy orange) : tant qu'un proxy intermédiaire n'a pas reçu assez
+ // d'octets, il bufferise la réponse et ne la relaie qu'en retard (symptôme :
+ // derniers messages qui arrivent 20-30 s après le reste, de façon intermittente
+ // sur Safari). Envoyer d'emblée un gros événement de padding force le proxy à
+ // basculer en mode streaming tout de suite. Le client ignore la clé `pad`.
+ if !emit(map[string]any{"pad": strings.Repeat("·", 2048)}) {
+ return
+ }
+
+ // 1. Replay coalescé (snapshot hors verrou pour ne pas bloquer la génération).
+ c.mu.Lock()
+ snapshot := append([]LogEvent(nil), c.Log...)
+ epoch := c.epoch
+ c.mu.Unlock()
+ last := from
+ for _, ev := range coalesceReplay(snapshot, from) {
+ if ctx.Err() != nil {
+ return
+ }
+ if !emit(ev) {
+ return
+ }
+ if s, ok := ev["seq"].(int); ok {
+ last = s
+ }
+ }
+ if !emit(map[string]any{"caught_up": true}) {
+ return
+ }
+ // Le padding doit venir APRÈS caught_up, pas dessus. Un proxy (Cloudflare) garde
+ // toujours le DERNIER bout de flux en tampon jusqu'au prochain flush (~2-3 s). En
+ // envoyant un gros pad juste après, ce sont ses octets — et non le dernier vrai
+ // message — qui deviennent la « queue » qui attend : le dernier message et
+ // caught_up, eux, sont poussés dehors immédiatement. Le client ignore `pad`.
+ // 16 Ko : largement au-dessus du tampon de coalescence d'un proxy courant.
+ if !emit(map[string]any{"pad": strings.Repeat("·", 16384)}) {
+ return
+ }
+
+ // 2. Direct : événements granulaires au-delà de `last`.
+ c.mu.Lock()
+ for {
+ if ctx.Err() != nil {
+ c.mu.Unlock()
+ return
+ }
+ if c.epoch != epoch { // reset → on ordonne au client de nettoyer et on repart
+ epoch = c.epoch
+ last = 0
+ c.mu.Unlock()
+ if !emit(map[string]any{"reset": true}) {
+ return
+ }
+ c.mu.Lock()
+ continue
+ }
+ // Copie des événements en attente SOUS verrou, émission HORS verrou : on
+ // n'itère jamais sur c.Log pendant que la génération peut y écrire ou que
+ // la troncature (maxLogEvents) peut le déplacer.
+ //
+ // c.Log est trié par Seq croissant : on trouve le premier événement neuf
+ // par dichotomie plutôt qu'en relisant tout. Le balayage complet coûtait
+ // la longueur du journal (jusqu'à 20 000) À CHAQUE TOKEN et pour CHAQUE
+ // appareil connecté, verrou tenu — donc au détriment de la génération
+ // elle-même. C'est quadratique sur un long fil.
+ i := sort.Search(len(c.Log), func(i int) bool { return c.Log[i].Seq > last })
+ var pending []LogEvent
+ if i < len(c.Log) {
+ pending = append(pending, c.Log[i:]...)
+ }
+ if len(pending) == 0 {
+ c.cond.Wait()
+ continue
+ }
+ lastEmitted := last
+ last = pending[len(pending)-1].Seq
+ c.mu.Unlock()
+ for _, ev := range pending {
+ // `lastEmitted` (avant mise à jour) sert de repère : si la compaction de
+ // fin de tour vient de fusionner un bloc que le client suivait en direct,
+ // l'événement fusionné arrive avec un Seq supérieur au sien → `replace`.
+ if !emit(decorateEvent(ev, lastEmitted)) {
+ return
+ }
+ }
+ c.mu.Lock()
+ }
+}
diff --git a/internal/ajean/chat_conversation_test.go b/internal/ajean/chat_conversation_test.go
new file mode 100644
index 0000000..214f416
--- /dev/null
+++ b/internal/ajean/chat_conversation_test.go
@@ -0,0 +1,227 @@
+package ajean
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// newTestConv crée une conversation isolée (le global `conv` sert au process réel).
+func newTestConv() *Conversation {
+ c := &Conversation{}
+ c.cond = sync.NewCond(&c.mu)
+ return c
+}
+
+// Subscribe doit rejouer les événements déjà journalisés PUIS suivre le direct,
+// et se terminer quand le contexte (la connexion) est annulé.
+func TestSubscribeReplayAndLive(t *testing.T) {
+ c := newTestConv()
+ c.appendDelta(c.epoch, map[string]any{"user": "salut"})
+ c.appendDelta(c.epoch, map[string]any{"content": "bon"})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ got := make(chan int, 32)
+ go c.Subscribe(ctx, 0, func(m map[string]any) bool {
+ if s, ok := m["seq"].(int); ok {
+ got <- s
+ }
+ return true
+ })
+
+ // Les 2 événements déjà présents (replay).
+ waitSeq(t, got, 1)
+ waitSeq(t, got, 2)
+ // Un événement en direct après abonnement.
+ c.appendDelta(c.epoch, map[string]any{"content": "jour"})
+ waitSeq(t, got, 3)
+}
+
+// Un abonné qui démarre à from=N ne reçoit que ce qui est plus récent que N.
+func TestSubscribeFromOffset(t *testing.T) {
+ c := newTestConv()
+ c.appendDelta(c.epoch, map[string]any{"user": "a"})
+ c.appendDelta(c.epoch, map[string]any{"user": "b"})
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ got := make(chan int, 8)
+ go c.Subscribe(ctx, 1, func(m map[string]any) bool {
+ if s, ok := m["seq"].(int); ok {
+ got <- s
+ }
+ return true
+ })
+ // from=1 → on saute le seq 1, on reçoit 2 en premier.
+ waitSeq(t, got, 2)
+}
+
+// Reset bump l'epoch et pousse un {reset:true} aux abonnés, qui repartent de 0.
+func TestResetNotifiesSubscribers(t *testing.T) {
+ c := newTestConv()
+ c.appendDelta(c.epoch, map[string]any{"user": "x"})
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ resetSeen := make(chan bool, 4)
+ go c.Subscribe(ctx, 0, func(m map[string]any) bool {
+ if _, ok := m["reset"]; ok {
+ resetSeen <- true
+ }
+ return true
+ })
+ // laisse l'abonné consommer le replay initial
+ time.Sleep(20 * time.Millisecond)
+ c.Reset()
+ select {
+ case <-resetSeen:
+ case <-time.After(time.Second):
+ t.Fatal("l'abonné n'a pas reçu l'événement reset")
+ }
+ if c.Seq != 0 || len(c.Log) != 0 {
+ t.Fatalf("après Reset: Seq=%d len(Log)=%d, attendu 0/0", c.Seq, len(c.Log))
+ }
+}
+
+// Un Reset survenu pendant un tour invalide l'epoch : les deltas du tour en
+// cours sont jetés au lieu de polluer la nouvelle conversation (Seq repartis
+// de zéro, messages fantômes).
+func TestAppendDeltaStaleEpochDropped(t *testing.T) {
+ c := newTestConv()
+ epoch := c.epoch // capturé comme au début d'un tour
+ c.appendDelta(epoch, map[string]any{"user": "avant"})
+ c.Reset()
+ c.appendDelta(epoch, map[string]any{"content": "fantôme"}) // tour périmé
+ if c.Seq != 0 || len(c.Log) != 0 {
+ t.Fatalf("delta périmé accepté après Reset: Seq=%d len(Log)=%d", c.Seq, len(c.Log))
+ }
+ c.appendDelta(c.epoch, map[string]any{"user": "nouveau"}) // nouveau tour
+ if c.Seq != 1 || len(c.Log) != 1 {
+ t.Fatalf("delta du nouvel epoch refusé: Seq=%d len(Log)=%d", c.Seq, len(c.Log))
+ }
+}
+
+// Les handlers de contrôle répondent en JSON sans dépendre du modèle.
+func TestChatControlHandlers(t *testing.T) {
+ // reset → conversation vide
+ rr := httptest.NewRecorder()
+ handleChatReset(rr, httptest.NewRequest("POST", "/api/chat/reset", nil))
+ if rr.Code != 200 {
+ t.Fatalf("reset code %d", rr.Code)
+ }
+ // state → seq 0, pas de génération
+ rr = httptest.NewRecorder()
+ handleChatState(rr, httptest.NewRequest("GET", "/api/chat/state", nil))
+ if rr.Code != 200 || !strings.Contains(rr.Body.String(), "\"seq\":0") {
+ t.Fatalf("state inattendu: %d %s", rr.Code, rr.Body.String())
+ }
+ // send message vide → 400
+ rr = httptest.NewRecorder()
+ req := httptest.NewRequest("POST", "/api/chat/send", strings.NewReader(`{"message":""}`))
+ req.Header.Set("Content-Type", "application/json")
+ handleChatSend(rr, req)
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("send vide devrait être 400, obtenu %d", rr.Code)
+ }
+}
+
+func waitSeq(t *testing.T, ch <-chan int, want int) {
+ t.Helper()
+ select {
+ case s := <-ch:
+ if s != want {
+ t.Fatalf("seq reçu %d, attendu %d", s, want)
+ }
+ case <-time.After(time.Second):
+ t.Fatalf("timeout en attendant seq %d", want)
+ }
+}
+
+// Régression : un client qui a déjà affiché une PARTIE d'un bloc de texte et qui se
+// reconnecte APRÈS la compaction de fin de tour recevait le bloc entier sans savoir
+// qu'il en avait déjà le début → la réponse s'affichait en double (1re copie
+// tronquée à l'endroit exact où le client en était). Le bloc coalescé doit porter
+// `seq0` et, quand `from` tombe dedans, l'indicateur `replace`.
+func TestReplayMarksReplaceWhenClientSawPartOfBlock(t *testing.T) {
+ c := newTestConv()
+ c.appendDelta(c.epoch, map[string]any{"user": "salut"}) // seq 1
+ c.appendDelta(c.epoch, map[string]any{"content": "Oui, "}) // seq 2
+ c.appendDelta(c.epoch, map[string]any{"content": "j'ai "}) // seq 3
+ c.appendDelta(c.epoch, map[string]any{"content": "accès"}) // seq 4
+ c.mu.Lock()
+ c.compactLogLocked() // fin de tour : les 3 deltas deviennent UN événement seq 4
+ c.mu.Unlock()
+
+ // Le client avait vu jusqu'au seq 3 (milieu du bloc) : il doit recevoir le bloc
+ // entier AVEC replace, pour remplacer sa bulle au lieu d'y concaténer.
+ out := coalesceReplay(c.Log, 3)
+ if len(out) != 1 {
+ t.Fatalf("attendu 1 événement rejoué, obtenu %d (%v)", len(out), out)
+ }
+ if got := out[0]["content"]; got != "Oui, j'ai accès" {
+ t.Fatalf("texte rejoué = %q, attendu le bloc entier", got)
+ }
+ if r, _ := out[0]["replace"].(bool); !r {
+ t.Fatalf("replace absent alors que le client avait déjà affiché le début du bloc")
+ }
+
+ // Client qui n'a rien vu du bloc : pas de replace (il doit juste l'ajouter).
+ out = coalesceReplay(c.Log, 1)
+ if len(out) != 1 {
+ t.Fatalf("attendu 1 événement, obtenu %d", len(out))
+ }
+ if _, ok := out[0]["replace"]; ok {
+ t.Fatalf("replace ne doit PAS être posé quand le client n'a rien vu du bloc")
+ }
+}
+
+// Le direct sélectionne les événements neufs par dichotomie (c.Log est trié par
+// Seq). Deux cas limites doivent rester justes : un journal TRONQUÉ, dont le
+// premier Seq est très supérieur au `from` du client, et un client déjà à jour.
+func TestLiveSelectionSurJournalTronque(t *testing.T) {
+ c := newTestConv()
+ // Journal amputé de son début, comme après la troncature à maxLogEvents.
+ c.Log = []LogEvent{
+ {Seq: 500, Delta: map[string]any{"user": "a"}},
+ {Seq: 501, Delta: map[string]any{"user": "b"}},
+ {Seq: 502, Delta: map[string]any{"user": "c"}},
+ }
+ c.Seq = 502
+
+ for _, tc := range []struct {
+ from int
+ first int // premier seq attendu
+ }{
+ {from: 0, first: 500}, // client neuf : il reçoit tout ce qui reste
+ {from: 500, first: 501}, // au milieu du journal
+ {from: 501, first: 502},
+ } {
+ ctx, cancel := context.WithCancel(context.Background())
+ got := make(chan int, 8)
+ go c.Subscribe(ctx, tc.from, func(m map[string]any) bool {
+ if s, ok := m["seq"].(int); ok {
+ got <- s
+ }
+ return true
+ })
+ waitSeq(t, got, tc.first)
+ cancel()
+ }
+
+ // Client déjà à jour : rien à rejouer, mais le direct doit suivre.
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ got := make(chan int, 8)
+ go c.Subscribe(ctx, 502, func(m map[string]any) bool {
+ if s, ok := m["seq"].(int); ok {
+ got <- s
+ }
+ return true
+ })
+ c.appendDelta(c.epoch, map[string]any{"content": "suite"})
+ waitSeq(t, got, 503)
+}
diff --git a/internal/ajean/chat_diff.go b/internal/ajean/chat_diff.go
new file mode 100644
index 0000000..bd355a0
--- /dev/null
+++ b/internal/ajean/chat_diff.go
@@ -0,0 +1,121 @@
+package ajean
+
+// chat_diff.go — petit diff ligne à ligne pour l'interface : quand l'IA modifie
+// un fichier (outil `edit`) ou une page de mémoire (`mem_edit`, `mem_add`), on
+// envoie à l'UI le détail des lignes ajoutées et retirées, qu'elle affiche en
+// vert (+) et rouge (-). Aucune dépendance : LCS classique sur les lignes, avec
+// des garde-fous pour ne jamais transformer un gros remplacement en pavé.
+
+import "strings"
+
+const (
+ diffMaxLines = 400 // au-delà, on compare sans détail (trop gros / trop lent)
+ diffMaxShown = 120 // lignes envoyées à l'UI (le reste est résumé)
+)
+
+// DiffLine est une ligne de diff : Op vaut " " (contexte), "-" ou "+".
+type DiffLine struct {
+ Op string `json:"op"`
+ Text string `json:"text"`
+}
+
+// lineDiff compare deux blocs de texte ligne à ligne. Le résultat garde les
+// lignes communes comme contexte : c'est ce qui rend le changement lisible
+// quand l'IA ne touche qu'un mot au milieu d'un paragraphe.
+func lineDiff(oldText, newText string) []DiffLine {
+ a := splitLines(oldText)
+ b := splitLines(newText)
+ // Blocs énormes : on ne calcule pas la LCS (coût quadratique), on montre
+ // simplement l'ancien en retrait et le nouveau en ajout.
+ if len(a) > diffMaxLines || len(b) > diffMaxLines {
+ out := make([]DiffLine, 0, len(a)+len(b))
+ for _, l := range a {
+ out = append(out, DiffLine{Op: "-", Text: l})
+ }
+ for _, l := range b {
+ out = append(out, DiffLine{Op: "+", Text: l})
+ }
+ return capLines(out)
+ }
+
+ // LCS : table des longueurs, puis remontée.
+ n, m := len(a), len(b)
+ lcs := make([][]int, n+1)
+ for i := range lcs {
+ lcs[i] = make([]int, m+1)
+ }
+ for i := n - 1; i >= 0; i-- {
+ for j := m - 1; j >= 0; j-- {
+ if a[i] == b[j] {
+ lcs[i][j] = lcs[i+1][j+1] + 1
+ } else if lcs[i+1][j] >= lcs[i][j+1] {
+ lcs[i][j] = lcs[i+1][j]
+ } else {
+ lcs[i][j] = lcs[i][j+1]
+ }
+ }
+ }
+ var out []DiffLine
+ i, j := 0, 0
+ for i < n && j < m {
+ switch {
+ case a[i] == b[j]:
+ out = append(out, DiffLine{Op: " ", Text: a[i]})
+ i++
+ j++
+ case lcs[i+1][j] >= lcs[i][j+1]:
+ out = append(out, DiffLine{Op: "-", Text: a[i]})
+ i++
+ default:
+ out = append(out, DiffLine{Op: "+", Text: b[j]})
+ j++
+ }
+ }
+ for ; i < n; i++ {
+ out = append(out, DiffLine{Op: "-", Text: a[i]})
+ }
+ for ; j < m; j++ {
+ out = append(out, DiffLine{Op: "+", Text: b[j]})
+ }
+ return capLines(out)
+}
+
+// addedDiff présente un contenu entièrement nouveau (création d'une page).
+func addedDiff(text string) []DiffLine {
+ lines := splitLines(text)
+ out := make([]DiffLine, 0, len(lines))
+ for _, l := range lines {
+ out = append(out, DiffLine{Op: "+", Text: l})
+ }
+ return capLines(out)
+}
+
+func splitLines(s string) []string {
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ if s == "" {
+ return nil
+ }
+ return strings.Split(s, "\n")
+}
+
+// capLines borne la taille envoyée à l'UI et signale ce qui a été coupé.
+func capLines(d []DiffLine) []DiffLine {
+ if len(d) <= diffMaxShown {
+ return d
+ }
+ cut := len(d) - diffMaxShown
+ out := append([]DiffLine{}, d[:diffMaxShown]...)
+ return append(out, DiffLine{Op: " ", Text: "…(" + itoa(cut) + " lignes de plus)"})
+}
+
+func itoa(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var b []byte
+ for n > 0 {
+ b = append([]byte{byte('0' + n%10)}, b...)
+ n /= 10
+ }
+ return string(b)
+}
diff --git a/internal/ajean/chat_export.go b/internal/ajean/chat_export.go
new file mode 100644
index 0000000..3c24789
--- /dev/null
+++ b/internal/ajean/chat_export.go
@@ -0,0 +1,431 @@
+package ajean
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// chat_export.go — sortir la conversation de la base.
+//
+// Jusqu'à la 0.7, l'historique vivait dans un `conversation.json` qu'on pouvait
+// ouvrir, lire et copier pour archive. La 0.8 l'a rangé dans ajean.db (bbolt) :
+// un fichier binaire, et qui porte de surcroît un verrou EXCLUSIF tant que le
+// service tourne — donc impossible ne serait-ce que de le copier sans arrêter
+// AJEAN. Le fil de discussion et les raisonnements sont toujours là, mais plus
+// personne ne peut les récupérer. C'est une régression pour qui relisait ses
+// échanges, et ce fichier la répare.
+//
+// Deux formats pour un même contenu : le Markdown est fait pour être LU, le
+// JSON pour être RETRAITÉ. Les options de contenu (raisonnements, outils,
+// portée) sont les MÊMES pour les deux — le format ne choisit que le
+// contenant. Un fil relu pour retrouver une réponse n'a que faire des mille
+// lignes de raisonnement qui l'ont produite, et ça vaut dans les deux formats.
+
+// exportOpts décrit ce qu'on veut sortir. Le zéro de la structure n'est PAS un
+// défaut utilisable (tout serait à false) : passer par defaultExportOpts.
+type exportOpts struct {
+ Format string // "md" | "json"
+ Reasoning bool // inclure les blocs de raisonnement
+ Tools bool // inclure les appels d'outils
+ Results bool // inclure la sortie des outils (exige Tools)
+ Turns int // 0 = tout le fil ; N = les N derniers échanges
+}
+
+func defaultExportOpts() exportOpts {
+ return exportOpts{Format: "md", Reasoning: true, Tools: true, Results: true}
+}
+
+// exportOptsFromQuery lit les options d'une requête. Toute clé absente garde sa
+// valeur par défaut : `/api/chat/export` tout court reste l'export complet.
+func exportOptsFromQuery(q map[string][]string) exportOpts {
+ o := defaultExportOpts()
+ get := func(k string) (string, bool) {
+ v, ok := q[k]
+ if !ok || len(v) == 0 {
+ return "", false
+ }
+ return v[0], true
+ }
+ flag := func(k string, cur bool) bool {
+ v, ok := get(k)
+ if !ok {
+ return cur
+ }
+ return v == "1" || v == "true" || v == "on"
+ }
+ if v, ok := get("format"); ok && v == "json" {
+ o.Format = "json"
+ }
+ o.Reasoning = flag("reasoning", o.Reasoning)
+ o.Tools = flag("tools", o.Tools)
+ o.Results = flag("results", o.Results)
+ if v, ok := get("turns"); ok {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 {
+ o.Turns = n
+ }
+ }
+ // Sans les bulles d'outils, leur sortie n'a nulle part où aller.
+ if !o.Tools {
+ o.Results = false
+ }
+ return o
+}
+
+// exportPayload est la forme du fichier JSON exporté. `messages` est la vue
+// envoyée au modèle, `log` le journal d'affichage (celui qui porte les
+// raisonnements, les outils et les vitesses).
+type exportPayload struct {
+ Version string `json:"ajean_version"`
+ ExportedAt string `json:"exported_at"`
+ CtxUsed int `json:"ctx_used"`
+ Messages []Message `json:"messages"`
+ Log []LogEvent `json:"log,omitempty"`
+}
+
+// ExportJSON rend la conversation en JSON indenté.
+func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
+ c.mu.Lock()
+ msgs := append([]Message(nil), c.Messages...)
+ log := append([]LogEvent(nil), c.Log...)
+ ctxUsed := c.CtxUsed
+ c.mu.Unlock()
+
+ if o.Turns > 0 {
+ msgs = lastTurnsMessages(msgs, o.Turns)
+ log = lastTurnsLog(log, o.Turns)
+ }
+ p := exportPayload{
+ Version: Version,
+ ExportedAt: time.Now().Format(time.RFC3339),
+ CtxUsed: ctxUsed,
+ Messages: msgs,
+ Log: filterLog(log, o),
+ }
+ return json.MarshalIndent(p, "", " ")
+}
+
+// filterLog applique au journal les MÊMES cases que le Markdown. Les deux
+// formats avaient auparavant des options distinctes (le JSON proposait un
+// obscur « journal d'affichage » à la place des trois autres) : personne ne
+// pouvait deviner ce que ça changeait, et cocher une case n'avait pas le même
+// sens selon le format choisi juste au-dessus. Le format ne décide plus que du
+// contenant ; ce qu'on emporte se règle une fois pour toutes.
+func filterLog(log []LogEvent, o exportOpts) []LogEvent {
+ if o.Reasoning && o.Tools && o.Results {
+ return log
+ }
+ out := make([]LogEvent, 0, len(log))
+ for _, ev := range log {
+ if _, ok := ev.Delta["reasoning_content"]; ok && !o.Reasoning {
+ continue
+ }
+ if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
+ if !o.Tools {
+ continue
+ }
+ if !o.Results {
+ // Copie : l'événement appartient à la conversation vivante, le
+ // modifier en place amputerait le fil affiché dans les navigateurs.
+ cp := make(map[string]any, len(tu))
+ for k, v := range tu {
+ if k != "result" {
+ cp[k] = v
+ }
+ }
+ d := make(map[string]any, len(ev.Delta))
+ for k, v := range ev.Delta {
+ d[k] = v
+ }
+ d["tool_used"] = cp
+ ev.Delta = d
+ }
+ }
+ out = append(out, ev)
+ }
+ return out
+}
+
+// lastTurnsMessages garde les n derniers échanges de la vue modèle, un échange
+// commençant à un message `user`.
+func lastTurnsMessages(msgs []Message, n int) []Message {
+ seen := 0
+ for i := len(msgs) - 1; i >= 0; i-- {
+ if msgs[i].Role != "user" {
+ continue
+ }
+ seen++
+ if seen == n {
+ return msgs[i:]
+ }
+ }
+ return msgs
+}
+
+// lastTurnsLog fait de même sur le journal d'affichage, où un échange commence à
+// un événement `user`.
+func lastTurnsLog(log []LogEvent, n int) []LogEvent {
+ seen := 0
+ for i := len(log) - 1; i >= 0; i-- {
+ if _, ok := log[i].Delta["user"]; !ok {
+ continue
+ }
+ seen++
+ if seen == n {
+ return log[i:]
+ }
+ }
+ return log
+}
+
+// ExportMarkdown rend la conversation en Markdown lisible : un bloc par tour,
+// le raisonnement replié dans un (il est souvent plus long que la
+// réponse), et les outils appelés en liste.
+//
+// On repart du JOURNAL D'AFFICHAGE et non de la vue modèle : c'est lui qui porte
+// les raisonnements et la trace des outils, précisément ce qu'on vient chercher
+// dans un export. coalesceReplay est réutilisé tel quel pour recoller les
+// milliers de deltas d'un tour en un bloc de texte par bulle.
+func (c *Conversation) ExportMarkdown(o exportOpts) string {
+ c.mu.Lock()
+ snapshot := append([]LogEvent(nil), c.Log...)
+ c.mu.Unlock()
+ if o.Turns > 0 {
+ snapshot = lastTurnsLog(snapshot, o.Turns)
+ }
+
+ var b strings.Builder
+ fmt.Fprintf(&b, "# Conversation AJEAN\n\nExportée le %s par AJEAN %s.\n",
+ time.Now().Format("02/01/2006 à 15:04"), Version)
+ // Un export tronqué ou allégé doit le DIRE : relu six mois plus tard, un fil
+ // sans ses raisonnements ne doit pas passer pour le fil complet.
+ if note := exportNote(o); note != "" {
+ fmt.Fprintf(&b, "\n%s\n", note)
+ }
+
+ // openBubble : une bulle assistant est ouverte (au moins un bout de réponse a
+ // été écrit). Sert à ne poser l'en-tête « AJEAN » qu'une fois par tour, même
+ // quand la réponse est entrecoupée d'appels d'outils.
+ openBubble := false
+ head := func() {
+ if !openBubble {
+ b.WriteString("\n## AJEAN\n")
+ openBubble = true
+ }
+ }
+ for _, ev := range coalesceReplay(snapshot, 0) {
+ switch {
+ case ev["user"] != nil:
+ openBubble = false
+ fmt.Fprintf(&b, "\n---\n\n## Vous\n\n%s\n", mdText(ev["user"]))
+ // Pièces jointes du tour. Sans elles, un message envoyé SANS texte
+ // (juste un fichier) donnait une section « Vous » entièrement vide.
+ if names := exportFileNames(ev["files"]); len(names) > 0 {
+ fmt.Fprintf(&b, "\nFichiers joints : %s\n", strings.Join(names, ", "))
+ }
+ case ev["reasoning_content"] != nil:
+ if !o.Reasoning {
+ break
+ }
+ head()
+ fmt.Fprintf(&b, "\n\nRaisonnement\n\n%s\n\n\n",
+ mdText(ev["reasoning_content"]))
+ case ev["content"] != nil:
+ head()
+ fmt.Fprintf(&b, "\n%s\n", mdText(ev["content"]))
+ case ev["tool_used"] != nil:
+ if !o.Tools {
+ break
+ }
+ tu, _ := ev["tool_used"].(map[string]any)
+ if tu == nil {
+ break
+ }
+ head()
+ label, _ := tu["label"].(string)
+ name, _ := tu["name"].(string)
+ if label == "" {
+ label = name
+ }
+ fmt.Fprintf(&b, "\n> 🔧 **%s** — %s\n", mdInline(name), mdInline(label))
+ if res, _ := tu["result"].(string); o.Results && strings.TrimSpace(res) != "" {
+ fmt.Fprintf(&b, "\n```\n%s\n```\n", strings.TrimRight(res, "\n"))
+ }
+ case ev["compacted"] != nil:
+ fmt.Fprintf(&b, "\n_(contexte compacté à cet endroit : les tours précédents ont été résumés pour le modèle, le fil ci-dessus reste complet)_\n")
+ }
+ }
+ if !openBubble && len(snapshot) == 0 {
+ b.WriteString("\n_Conversation vide._\n")
+ }
+ return b.String()
+}
+
+// exportNote résume en une ligne ce que l'export ne contient PAS.
+func exportNote(o exportOpts) string {
+ var parts []string
+ if o.Turns > 0 {
+ parts = append(parts, fmt.Sprintf("%d derniers échanges seulement", o.Turns))
+ }
+ if !o.Reasoning {
+ parts = append(parts, "raisonnements retirés")
+ }
+ if !o.Tools {
+ parts = append(parts, "appels d'outils retirés")
+ } else if !o.Results {
+ parts = append(parts, "sorties d'outils retirées")
+ }
+ if len(parts) == 0 {
+ return ""
+ }
+ return "_Export partiel : " + strings.Join(parts, ", ") + "._"
+}
+
+// mdText rend une valeur d'événement en texte de bloc Markdown.
+func mdText(v any) string {
+ s, _ := v.(string)
+ return strings.TrimRight(s, "\n")
+}
+
+// mdInline neutralise ce qui casserait une ligne Markdown (un label d'outil peut
+// contenir un chemin, des astérisques, un retour à la ligne).
+func mdInline(v any) string {
+ s, _ := v.(string)
+ s = strings.ReplaceAll(s, "\n", " ")
+ r := strings.NewReplacer("*", "\\*", "_", "\\_", "`", "\\`", "[", "\\[", "]", "\\]")
+ return strings.TrimSpace(r.Replace(s))
+}
+
+// exportFilename : nom proposé au téléchargement, horodaté pour que deux exports
+// ne s'écrasent pas dans le dossier de téléchargements.
+func exportFilename(ext string) string {
+ return "ajean-conversation-" + time.Now().Format("2006-01-02-1504") + "." + ext
+}
+
+// exportBody rend la conversation selon les options. Renvoie le corps, son type
+// MIME et l'extension de fichier.
+func exportBody(o exportOpts) ([]byte, string, string, error) {
+ if o.Format == "json" {
+ b, err := conv.ExportJSON(o)
+ return b, "application/json; charset=utf-8", "json", err
+ }
+ return []byte(conv.ExportMarkdown(o)), "text/markdown; charset=utf-8", "md", nil
+}
+
+// cmdExport écrit la conversation dans un fichier (ou sur la sortie standard).
+//
+// ajean export → ajean-conversation-.md dans le dossier courant
+// ajean export --json → idem en JSON
+// ajean export mon-fil.md → nom de fichier imposé (l'extension choisit le format)
+// ajean export - → sur la sortie standard, pour enchaîner un tube
+//
+// Options de contenu, les mêmes que la fenêtre d'export de l'interface :
+//
+// --no-reasoning --no-tools --no-results --last N
+//
+// La conversation est relue depuis la base à chaque appel : la commande marche
+// pendant que le service tourne (bbolt n'est jamais gardé ouvert, voir store.go).
+func cmdExport(args []string) error {
+ o := defaultExportOpts()
+ out := ""
+ for i := 0; i < len(args); i++ {
+ a := args[i]
+ switch a {
+ case "--json", "-j":
+ o.Format = "json"
+ case "--md", "--markdown", "-m":
+ o.Format = "md"
+ case "--no-reasoning":
+ o.Reasoning = false
+ case "--no-tools":
+ o.Tools, o.Results = false, false
+ case "--no-results":
+ o.Results = false
+ case "--last":
+ if i+1 >= len(args) {
+ return fmt.Errorf("--last attend un nombre d'échanges")
+ }
+ i++
+ n, err := strconv.Atoi(args[i])
+ if err != nil || n <= 0 {
+ return fmt.Errorf("--last : nombre d'échanges invalide (%s)", args[i])
+ }
+ o.Turns = n
+ default:
+ if strings.HasPrefix(a, "-") && a != "-" {
+ return fmt.Errorf("option inconnue : %s (voir « ajean help »)", a)
+ }
+ out = a
+ }
+ }
+ // Une extension explicite l'emporte sur le drapeau : `ajean export fil.json`
+ // qui produirait du Markdown serait un piège.
+ if strings.HasSuffix(strings.ToLower(out), ".json") {
+ o.Format = "json"
+ } else if strings.HasSuffix(strings.ToLower(out), ".md") {
+ o.Format = "md"
+ }
+
+ LoadConversation()
+ body, _, ext, err := exportBody(o)
+ if err != nil {
+ return err
+ }
+
+ if out == "-" {
+ _, err := os.Stdout.Write(body)
+ return err
+ }
+ if out == "" {
+ out = exportFilename(ext)
+ }
+ if err := os.WriteFile(out, body, 0o644); err != nil {
+ return err
+ }
+ conv.mu.Lock()
+ n := len(conv.Messages)
+ conv.mu.Unlock()
+ fmt.Printf("%s %s (%d messages, %s)\n", green("[ok]"), out, n, humanBytes(int64(len(body))))
+ return nil
+}
+
+func handleChatExport(w http.ResponseWriter, r *http.Request) {
+ o := exportOptsFromQuery(r.URL.Query())
+ body, ctype, ext, err := exportBody(o)
+ if err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ w.Header().Set("Content-Type", ctype)
+ w.Header().Set("Content-Disposition", `attachment; filename="`+exportFilename(ext)+`"`)
+ w.Header().Set("Content-Length", fmt.Sprint(len(body)))
+ _, _ = w.Write(body)
+}
+
+// exportFileNames lit les noms des pièces jointes portées par un événement `user`.
+//
+// Deux formes possibles pour la même donnée : []attachInfo quand l'événement
+// vient d'être produit, et []any de maps quand il a été relu depuis le journal
+// persisté (JSON). L'export travaille sur les deux, d'où le double cas.
+func exportFileNames(v any) []string {
+ var out []string
+ switch files := v.(type) {
+ case []attachInfo:
+ for _, f := range files {
+ out = append(out, f.Name)
+ }
+ case []any:
+ for _, it := range files {
+ if m, ok := it.(map[string]any); ok {
+ if n, ok := m["name"].(string); ok && n != "" {
+ out = append(out, n)
+ }
+ }
+ }
+ }
+ return out
+}
diff --git a/internal/ajean/chat_export_test.go b/internal/ajean/chat_export_test.go
new file mode 100644
index 0000000..5a446ad
--- /dev/null
+++ b/internal/ajean/chat_export_test.go
@@ -0,0 +1,260 @@
+package ajean
+
+import (
+ "encoding/json"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// convDeTest pose un fil complet (question, raisonnement, outil, réponse) dans la
+// conversation globale et la remet à zéro en fin de test.
+func convDeTest(t *testing.T) {
+ t.Helper()
+ t.Cleanup(func() { conv.Reset() })
+ conv.mu.Lock()
+ conv.Messages = []Message{
+ {Role: "user", Content: "combien font 2+2 ?"},
+ {Role: "assistant", Content: "4"},
+ }
+ conv.Log = []LogEvent{
+ {Seq: 1, TS: 1, Delta: map[string]any{"user": "combien font 2+2 ?"}},
+ {Seq: 2, TS: 2, Delta: map[string]any{"reasoning_content": "addition simple"}},
+ {Seq: 3, TS: 3, Delta: map[string]any{"tool_used": map[string]any{
+ "name": "bash", "label": "echo 4", "result": "4", "done": true}}},
+ {Seq: 4, TS: 4, Delta: map[string]any{"content": "4"}},
+ {Seq: 5, TS: 5, Delta: map[string]any{"turn_done": true}},
+ }
+ conv.Seq = 5
+ conv.CtxUsed = 123
+ conv.mu.Unlock()
+}
+
+func TestExportMarkdownPorteToutLeFil(t *testing.T) {
+ convDeTest(t)
+ md := conv.ExportMarkdown(defaultExportOpts())
+ for _, want := range []string{
+ "## Vous", "combien font 2+2 ?",
+ "## AJEAN",
+ "Raisonnement", "addition simple",
+ "bash", "echo 4",
+ } {
+ if !strings.Contains(md, want) {
+ t.Errorf("Markdown exporté sans %q :\n%s", want, md)
+ }
+ }
+ // L'en-tête AJEAN ne doit apparaître qu'UNE fois pour un tour, même entrecoupé
+ // d'un appel d'outil.
+ if n := strings.Count(md, "\n## AJEAN\n"); n != 1 {
+ t.Errorf("en-tête assistant répété %d fois", n)
+ }
+}
+
+func TestExportJSONRelisible(t *testing.T) {
+ convDeTest(t)
+ b, err := conv.ExportJSON(defaultExportOpts())
+ if err != nil {
+ t.Fatal(err)
+ }
+ var p exportPayload
+ if err := json.Unmarshal(b, &p); err != nil {
+ t.Fatal(err)
+ }
+ if len(p.Messages) != 2 || len(p.Log) != 5 {
+ t.Fatalf("export tronqué : %d messages, %d événements", len(p.Messages), len(p.Log))
+ }
+ if p.CtxUsed != 123 || p.Version != Version {
+ t.Fatalf("métadonnées absentes : ctx=%d version=%q", p.CtxUsed, p.Version)
+ }
+}
+
+// L'endpoint doit se présenter en TÉLÉCHARGEMENT (et pas s'afficher dans
+// l'onglet), avec un nom de fichier horodaté.
+func TestHandleChatExportEnPieceJointe(t *testing.T) {
+ convDeTest(t)
+ for _, tc := range []struct{ format, ctype, ext string }{
+ {"", "text/markdown", ".md"},
+ {"json", "application/json", ".json"},
+ } {
+ rr := httptest.NewRecorder()
+ handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?format="+tc.format, nil))
+ if rr.Code != 200 {
+ t.Fatalf("format=%q : HTTP %d", tc.format, rr.Code)
+ }
+ if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) {
+ t.Errorf("format=%q : Content-Type %q", tc.format, ct)
+ }
+ cd := rr.Header().Get("Content-Disposition")
+ if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, tc.ext) {
+ t.Errorf("format=%q : Content-Disposition %q", tc.format, cd)
+ }
+ if rr.Body.Len() == 0 {
+ t.Errorf("format=%q : corps vide", tc.format)
+ }
+ }
+}
+
+// Les options taillent l'export, et un export tronqué le DIT (sans quoi, relu
+// plus tard, il passerait pour le fil complet).
+func TestExportOptionsTaillentLeMarkdown(t *testing.T) {
+ convDeTest(t)
+ o := defaultExportOpts()
+ o.Reasoning = false
+ md := conv.ExportMarkdown(o)
+ if strings.Contains(md, "addition simple") {
+ t.Error("raisonnement présent malgré --no-reasoning")
+ }
+ if !strings.Contains(md, "Export partiel") || !strings.Contains(md, "raisonnements retirés") {
+ t.Errorf("export allégé non signalé :\n%s", md)
+ }
+ if !strings.Contains(md, "echo 4") {
+ t.Error("les outils ont disparu alors que seule la réflexion était exclue")
+ }
+
+ o = defaultExportOpts()
+ o.Results = false
+ if md := conv.ExportMarkdown(o); strings.Contains(md, "```") {
+ t.Error("sortie d'outil présente malgré --no-results")
+ }
+
+ // Couper les outils coupe forcément leurs sorties : la sortie n'a plus de
+ // bulle où s'accrocher.
+ o = exportOptsFromQuery(map[string][]string{"tools": {"0"}, "results": {"1"}})
+ if o.Results {
+ t.Error("results resté actif alors que tools est coupé")
+ }
+ md = conv.ExportMarkdown(o)
+ if strings.Contains(md, "echo 4") {
+ t.Error("outil présent malgré tools=0")
+ }
+}
+
+// Sans paramètre, l'endpoint reste l'export COMPLET : les options ne doivent pas
+// silencieusement appauvrir ceux qui ne les connaissent pas.
+func TestExportOptsFromQueryDefautComplet(t *testing.T) {
+ o := exportOptsFromQuery(map[string][]string{})
+ if o != defaultExportOpts() {
+ t.Fatalf("défauts modifiés : %+v", o)
+ }
+ o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}})
+ if o.Format != "json" || o.Turns != 2 {
+ t.Fatalf("options mal lues : %+v", o)
+ }
+ // Une valeur de tours absurde ne doit pas vider l'export.
+ if o := exportOptsFromQuery(map[string][]string{"turns": {"-3"}}); o.Turns != 0 {
+ t.Fatalf("turns=-3 accepté : %d", o.Turns)
+ }
+}
+
+func TestExportDerniersEchanges(t *testing.T) {
+ convDeTest(t)
+ // Un deuxième échange s'ajoute au fil de test.
+ conv.mu.Lock()
+ conv.Messages = append(conv.Messages,
+ Message{Role: "user", Content: "et 3+3 ?"}, Message{Role: "assistant", Content: "6"})
+ conv.Log = append(conv.Log,
+ LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}},
+ LogEvent{Seq: 7, TS: 7, Delta: map[string]any{"content": "6"}})
+ conv.mu.Unlock()
+
+ o := defaultExportOpts()
+ o.Turns = 1
+ md := conv.ExportMarkdown(o)
+ if strings.Contains(md, "2+2") {
+ t.Errorf("le premier échange est encore là avec turns=1 :\n%s", md)
+ }
+ if !strings.Contains(md, "3+3") || !strings.Contains(md, "1 derniers échanges") {
+ t.Errorf("dernier échange absent ou non signalé :\n%s", md)
+ }
+ // Demander plus d'échanges qu'il n'en existe rend tout le fil, sans erreur.
+ o.Turns = 99
+ if md := conv.ExportMarkdown(o); !strings.Contains(md, "2+2") {
+ t.Error("turns supérieur au nombre d'échanges a tronqué le fil")
+ }
+ // Même découpe côté JSON.
+ o = defaultExportOpts()
+ o.Turns = 1
+ b, err := conv.ExportJSON(o)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var p exportPayload
+ if err := json.Unmarshal(b, &p); err != nil {
+ t.Fatal(err)
+ }
+ if len(p.Messages) != 2 || p.Messages[0].Content != "et 3+3 ?" {
+ t.Fatalf("messages mal découpés : %+v", p.Messages)
+ }
+ // Les cases de contenu valent AUSSI pour le JSON : le format ne choisit que le
+ // contenant. ⚠️ Structure NEUVE à chaque Unmarshal : les champs absents du JSON
+ // ne sont pas remis à zéro, un `p` réutilisé garderait le journal précédent et
+ // le test passerait pour de mauvaises raisons.
+ o = defaultExportOpts()
+ o.Reasoning = false
+ b, _ = conv.ExportJSON(o)
+ var sansRaison exportPayload
+ if err := json.Unmarshal(b, &sansRaison); err != nil {
+ t.Fatal(err)
+ }
+ for _, ev := range sansRaison.Log {
+ if _, ok := ev.Delta["reasoning_content"]; ok {
+ t.Fatal("raisonnement présent dans le JSON malgré l'option coupée")
+ }
+ }
+ if len(sansRaison.Messages) == 0 || len(sansRaison.Log) == 0 {
+ t.Fatal("le JSON a été vidé alors que seule la réflexion était exclue")
+ }
+}
+
+// Le curseur de portée est borné par le nombre d'échanges publié dans l'état de
+// la conversation : sans lui, l'interface ne saurait pas jusqu'où aller.
+func TestChatStatePublieLeNombreDEchanges(t *testing.T) {
+ convDeTest(t)
+ if n, _ := conv.state()["turns"].(int); n != 1 {
+ t.Fatalf("échanges = %v ; le fil de test en a 1", conv.state()["turns"])
+ }
+ conv.mu.Lock()
+ conv.Log = append(conv.Log, LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}})
+ conv.mu.Unlock()
+ if n, _ := conv.state()["turns"].(int); n != 2 {
+ t.Fatalf("échanges = %v après un second tour", conv.state()["turns"])
+ }
+ // Conversation vide : zéro échange, c'est ce qui déclenche « rien à exporter ».
+ conv.Reset()
+ if n, _ := conv.state()["turns"].(int); n != 0 {
+ t.Fatalf("échanges = %v sur un fil vide", conv.state()["turns"])
+ }
+}
+
+func TestCmdExportEcritLeFichier(t *testing.T) {
+ testHome(t)
+ convDeTest(t)
+ // LoadConversation (appelé par cmdExport) relit la base : on y persiste d'abord
+ // le fil, sinon la commande exporterait une conversation vide.
+ conv.persist()
+
+ dir := t.TempDir()
+ for _, name := range []string{"fil.md", "fil.json"} {
+ p := filepath.Join(dir, name)
+ if err := cmdExport([]string{p}); err != nil {
+ t.Fatalf("%s : %v", name, err)
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(b), "2+2") {
+ t.Fatalf("%s ne contient pas la conversation :\n%s", name, b)
+ }
+ }
+ // L'extension impose le format, même sans drapeau.
+ b, _ := os.ReadFile(filepath.Join(dir, "fil.json"))
+ if !json.Valid(b) {
+ t.Fatal("fil.json n'est pas du JSON valide")
+ }
+ if err := cmdExport([]string{"--zzz"}); err == nil {
+ t.Fatal("option inconnue acceptée")
+ }
+}
diff --git a/internal/ajean/chat_internet.go b/internal/ajean/chat_internet.go
new file mode 100644
index 0000000..061d155
--- /dev/null
+++ b/internal/ajean/chat_internet.go
@@ -0,0 +1,573 @@
+package ajean
+
+import (
+ "bytes"
+ "context"
+ "crypto/md5"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Accès internet de l'IA — port Go de l'extension pi ~/.pi/agent/extensions/web.ts.
+//
+// ajean parle à un serveur Crawl4AI (Chrome headless, endpoint /crawl) dont l'URL
+// est configurée dans config.env (CRAWL4AI_URL). Quand le mode agent ET l'accès
+// internet sont actifs ET que le serveur répond, l'IA dispose de 4 outils :
+//
+// - web_search : recherche DuckDuckGo (via crawl), liste {title, url, snippet}.
+// - web_open : récupère une URL (cache 10 min), renvoie SEULEMENT les métadonnées
+// (nb de lignes, taille, plan des titres) — pas le contenu.
+// - web_read : lit une plage de lignes d'une URL déjà ouverte (offset + limit).
+// - web_grep : recherche regex dans une URL déjà ouverte, lignes + contexte.
+//
+// Workflow attendu : web_open(url) → web_read/web_grep. Même logique que pi.
+
+// ─── configuration & état ───────────────────────────────────────────────────
+
+// crawl4aiURL renvoie l'URL du serveur Crawl4AI (config.env CRAWL4AI_URL), sans
+// slash final. Vide si non configuré.
+func crawl4aiURL() string {
+ u := strings.TrimSpace(ReadConfig()["CRAWL4AI_URL"])
+ return strings.TrimRight(u, "/")
+}
+
+// crawl4aiKey renvoie la clé d'accès au serveur Crawl4AI, vide si le serveur
+// est ouvert. Envoyée en « Authorization: Bearer … », ce qu'attend Crawl4AI
+// quand l'authentification est activée (CRAWL4AI_API_TOKEN côté serveur).
+//
+// Elle est rangée hors de la configuration : celle-ci est ce qu'on copie-colle
+// pour demander de l'aide, et une clé en clair dedans finit par fuiter. Même
+// modèle que la clé de complétion.
+func crawl4aiKey() string { return getStr(bkState, "crawl_key") }
+
+// writeCrawlKey enregistre (clé non vide) ou efface (clé vide) la clé Crawl4AI.
+func writeCrawlKey(key string) error {
+ _ = SetConfigKey("CRAWL4AI_KEY", "") // ne laisse rien traîner dans la config
+ return putStr(bkState, "crawl_key", key)
+}
+
+// crawlAuth pose l'en-tête d'authentification sur une requête vers Crawl4AI.
+// Sans clé configurée, la requête part telle quelle.
+func crawlAuth(req *http.Request) {
+ if k := crawl4aiKey(); k != "" {
+ req.Header.Set("Authorization", "Bearer "+k)
+ }
+}
+
+// internetEnabled : accès internet actif = interrupteur armé ET moteur web
+// utilisable. Avec le moteur intégré (engineGo) il n'y a rien à configurer ;
+// avec Crawl4AI il faut en plus une URL de serveur.
+func internetEnabled() bool {
+ if webEngine() == engineCrawl && crawl4aiURL() == "" {
+ return false
+ }
+ return getBool(bkState, "internet")
+}
+
+func setInternetEnabled(on bool) error { return putBool(bkState, "internet", on) }
+
+// crawlReachable teste que le serveur Crawl4AI répond, avec un cache court (~30 s)
+// pour ne pas ralentir chaque tour de chat. Un serveur configuré mais injoignable
+// => les outils web ne sont pas proposés (« actif ET fonctionnel »).
+var (
+ reachMu sync.Mutex
+ reachOK bool
+ reachURL string
+ reachWhen time.Time
+)
+
+const reachTTL = 30 * time.Second
+
+func crawlReachable() bool {
+ if webEngine() == engineGo {
+ // Moteur intégré : rien à joindre, il tourne dans ce process. On ne
+ // teste pas la connectivité internet ici — un ping à chaque tour de
+ // chat coûterait plus cher que l'échec de la requête réelle.
+ return true
+ }
+ base := crawl4aiURL()
+ if base == "" {
+ return false
+ }
+ reachMu.Lock()
+ defer reachMu.Unlock()
+ if base == reachURL && time.Since(reachWhen) < reachTTL {
+ return reachOK
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ ok := false
+ // Crawl4AI expose /health ; on tolère aussi une simple réponse HTTP sur la racine.
+ for _, path := range []string{"/health", "/"} {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
+ if err != nil {
+ continue
+ }
+ crawlAuth(req)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ continue
+ }
+ resp.Body.Close()
+ if resp.StatusCode < 500 {
+ ok = true
+ break
+ }
+ }
+ reachOK, reachURL, reachWhen = ok, base, time.Now()
+ return ok
+}
+
+// ─── invocation crwl ────────────────────────────────────────────────────────
+
+// autoDismissJS : dismisser conservateur de bandeaux cookies/consentement, exécuté
+// dans la page. N'agit que sur des éléments qui RESSEMBLENT à une UI cookie
+// (position fixed/sticky ou id/class cookie/consent/gdpr). Port direct de web.ts.
+const autoDismissJS = `(() => { try {
+ const TEXT = /^(accept all|accept|i accept|agree|i agree|got it|i understand|j'accepte|tout accepter|accepter|d'accord|allow all|allow|consent|continue|ok)$/i;
+ const isOverlayish = (el) => {
+ try {
+ let cur = el;
+ for (let i = 0; i < 6 && cur; i++) {
+ const s = getComputedStyle(cur);
+ if (s.position === 'fixed' || s.position === 'sticky') return true;
+ const idcls = ((cur.id || '') + ' ' + (cur.className || '')).toLowerCase();
+ if (/cookie|consent|gdpr|cmp|privacy/.test(idcls)) return true;
+ cur = cur.parentElement;
+ }
+ } catch {}
+ return false;
+ };
+ const candidates = document.querySelectorAll('button, [role="button"], input[type="button"], input[type="submit"]');
+ let clicked = 0;
+ for (const b of candidates) {
+ if (clicked >= 2) break;
+ const t = (b.innerText || b.value || b.getAttribute('aria-label') || '').trim();
+ if (!t || !TEXT.test(t)) continue;
+ if (b.offsetParent === null) continue;
+ if (!isOverlayish(b)) continue;
+ try { b.click(); clicked++; } catch {}
+ }
+ document.querySelectorAll('[id*="cookie" i],[id*="consent" i],[class*="cookie" i],[class*="consent" i],[id*="gdpr" i],[class*="gdpr" i],[id*="cmp" i],[class*="cmp" i]')
+ .forEach(el => { try {
+ const s = getComputedStyle(el);
+ if (s.position === 'fixed' || s.position === 'sticky') el.remove();
+ } catch {} });
+ document.documentElement.style.overflow = 'auto';
+ if (document.body) document.body.style.overflow = 'auto';
+} catch {} })();`
+
+type crwlOptions struct {
+ jsCode []string
+ waitFor string
+ pageTimeoutMs int
+ rawMarkdown bool // true => préfère raw_markdown (recherche) ; false => fit_markdown
+}
+
+// runCrwl appelle POST {base}/crawl et renvoie le markdown extrait. Port de web.ts.
+func runCrwl(target string, opts crwlOptions) (string, error) {
+ base := crawl4aiURL()
+ if base == "" {
+ return "", fmt.Errorf("aucun serveur Crawl4AI configuré (CRAWL4AI_URL)")
+ }
+ params := map[string]any{}
+ if len(opts.jsCode) > 0 {
+ params["js_code"] = opts.jsCode
+ }
+ if opts.waitFor != "" {
+ params["wait_for"] = opts.waitFor
+ }
+ if opts.pageTimeoutMs > 0 {
+ params["page_timeout"] = opts.pageTimeoutMs
+ }
+ body := map[string]any{
+ "urls": []string{target},
+ "browser_config": map[string]any{
+ "type": "BrowserConfig",
+ "params": map[string]any{"headless": true},
+ },
+ "crawler_config": map[string]any{
+ "type": "CrawlerRunConfig",
+ "params": params,
+ },
+ }
+ buf, _ := json.Marshal(body)
+
+ timeout := 60 * time.Second
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/crawl", bytes.NewReader(buf))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ crawlAuth(req)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("Crawl4AI injoignable (%s): %v", base, err)
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(resp.Body)
+ return "", fmt.Errorf("Crawl4AI HTTP %d: %s", resp.StatusCode, tailRunes(string(b), 300))
+ }
+ var data struct {
+ Results []crwlResult `json:"results"`
+ }
+ // La réponse peut être soit {results:[...]}, soit un objet unique. On décode
+ // d'abord la forme {results}, sinon on retombe sur un résultat unique.
+ raw, _ := io.ReadAll(resp.Body)
+ if jerr := json.Unmarshal(raw, &data); jerr != nil || len(data.Results) == 0 {
+ var single crwlResult
+ if json.Unmarshal(raw, &single) == nil {
+ data.Results = []crwlResult{single}
+ }
+ }
+ if len(data.Results) == 0 {
+ return "", fmt.Errorf("Crawl4AI : réponse vide")
+ }
+ r := data.Results[0]
+ if !r.Success {
+ msg := r.ErrorMessage
+ if msg == "" {
+ msg = "échec du crawl"
+ }
+ return "", fmt.Errorf("Crawl4AI : %s", tailRunes(msg, 300))
+ }
+ return r.markdown(opts.rawMarkdown), nil
+}
+
+// crwlResult modélise un résultat Crawl4AI. markdown peut être une string ou un
+// objet {raw_markdown, fit_markdown} — on gère les deux via json.RawMessage.
+type crwlResult struct {
+ Success bool `json:"success"`
+ ErrorMessage string `json:"error_message"`
+ Markdown json.RawMessage `json:"markdown"`
+}
+
+func (r crwlResult) markdown(preferRaw bool) string {
+ if len(r.Markdown) == 0 {
+ return ""
+ }
+ var s string
+ if json.Unmarshal(r.Markdown, &s) == nil {
+ return s
+ }
+ var obj struct {
+ Raw string `json:"raw_markdown"`
+ Fit string `json:"fit_markdown"`
+ }
+ if json.Unmarshal(r.Markdown, &obj) == nil {
+ if preferRaw {
+ if obj.Raw != "" {
+ return obj.Raw
+ }
+ return obj.Fit
+ }
+ if obj.Fit != "" {
+ return obj.Fit
+ }
+ return obj.Raw
+ }
+ return ""
+}
+
+// ─── URL & normalisation ────────────────────────────────────────────────────
+
+// normalizeCrawlURL : github.com/owner/repo → README brut. Port de web.ts.
+func normalizeCrawlURL(raw string) string {
+ u, err := url.Parse(raw)
+ if err != nil {
+ return raw
+ }
+ if u.Host == "github.com" {
+ parts := strings.Split(strings.Trim(u.Path, "/"), "/")
+ if len(parts) == 2 {
+ return fmt.Sprintf("https://raw.githubusercontent.com/%s/%s/HEAD/README.md", parts[0], parts[1])
+ }
+ }
+ return raw
+}
+
+// normalizeLines : \r\n → \n, trim trailing, réduit les runs de lignes vides et
+// les doublons consécutifs, retire les vides en tête/queue. Port de web.ts.
+func normalizeLines(raw string) []string {
+ src := strings.Split(strings.ReplaceAll(raw, "\r\n", "\n"), "\n")
+ out := []string{}
+ prev := ""
+ blankRun := 0
+ trailSpace := regexp.MustCompile(`[ \t]+$`)
+ for _, l := range src {
+ l = trailSpace.ReplaceAllString(l, "")
+ if strings.TrimSpace(l) == "" {
+ blankRun++
+ if blankRun > 1 {
+ continue
+ }
+ out = append(out, "")
+ prev = ""
+ continue
+ }
+ blankRun = 0
+ if l == prev {
+ continue
+ }
+ out = append(out, l)
+ prev = l
+ }
+ for len(out) > 0 && out[0] == "" {
+ out = out[1:]
+ }
+ for len(out) > 0 && out[len(out)-1] == "" {
+ out = out[:len(out)-1]
+ }
+ return out
+}
+
+// ─── cache mémoire (TTL 10 min) ─────────────────────────────────────────────
+
+type cacheEntry struct {
+ url string
+ lines []string
+ fetchedAt time.Time
+}
+
+var (
+ pageCacheMu sync.Mutex
+ pageCache = map[string]*cacheEntry{}
+)
+
+const pageCacheTTL = 10 * time.Minute
+
+type fetchOptions struct {
+ force bool
+ actions []string
+ dismissPopups bool
+ waitFor string
+}
+
+func cacheKeyFor(u string, opts fetchOptions) string {
+ fp, _ := json.Marshal(map[string]any{"a": opts.actions, "d": opts.dismissPopups, "w": opts.waitFor})
+ if string(fp) == `{"a":null,"d":true,"w":""}` || string(fp) == `{"a":[],"d":true,"w":""}` {
+ return u
+ }
+ h := md5.Sum(fp)
+ return u + "#" + hex.EncodeToString(h[:])[:8]
+}
+
+// findCached : entrée de cache la plus récente pour une URL (toutes options).
+func findCached(rawURL string) *cacheEntry {
+ u := normalizeCrawlURL(rawURL)
+ pageCacheMu.Lock()
+ defer pageCacheMu.Unlock()
+ var best *cacheEntry
+ for k, v := range pageCache {
+ if k == u || strings.HasPrefix(k, u+"#") {
+ if time.Since(v.fetchedAt) > pageCacheTTL {
+ continue
+ }
+ if best == nil || v.fetchedAt.After(best.fetchedAt) {
+ best = v
+ }
+ }
+ }
+ return best
+}
+
+func getPage(rawURL string, opts fetchOptions) (*cacheEntry, error) {
+ u := normalizeCrawlURL(rawURL)
+ key := cacheKeyFor(u, opts)
+ pageCacheMu.Lock()
+ cached := pageCache[key]
+ pageCacheMu.Unlock()
+ if !opts.force && cached != nil && time.Since(cached.fetchedAt) < pageCacheTTL {
+ return cached, nil
+ }
+
+ jsCode := []string{}
+ if opts.dismissPopups {
+ jsCode = append(jsCode, autoDismissJS)
+ }
+ jsCode = append(jsCode, opts.actions...)
+ pageTimeout := 0
+ if opts.waitFor != "" || len(opts.actions) > 0 {
+ pageTimeout = 45000
+ }
+ var md string
+ var err error
+ if webEngine() == engineGo {
+ // Moteur intégré : pas de DOM vivant, donc jsCode / waitFor / actions
+ // sont sans objet (voir web_fetch_go.go).
+ md, err = goFetchMarkdown(u)
+ } else {
+ md, err = runCrwl(u, crwlOptions{jsCode: jsCode, waitFor: opts.waitFor, pageTimeoutMs: pageTimeout})
+ }
+ if err != nil {
+ return nil, err
+ }
+ // Le diagnostic « probablement du JavaScript » est posé par goFetchMarkdown,
+ // qui seul connaît la taille du HTML brut. Ici on n'attrape que le cas trivial.
+ if strings.TrimSpace(md) == "" {
+ return nil, fmt.Errorf("page vide")
+ }
+ entry := &cacheEntry{url: u, lines: normalizeLines(md), fetchedAt: time.Now()}
+ pageCacheMu.Lock()
+ pageCache[key] = entry
+ pageCacheMu.Unlock()
+ return entry, nil
+}
+
+// ─── helpers de formatage ───────────────────────────────────────────────────
+
+var headingRe = regexp.MustCompile(`^(#{1,6})\s+(.+?)\s*$`)
+
+func extractOutline(lines []string) string {
+ var out []string
+ for i, l := range lines {
+ if m := headingRe.FindStringSubmatch(l); m != nil {
+ indent := strings.Repeat(" ", len(m[1])-1)
+ out = append(out, fmt.Sprintf("%5d | %s%s", i+1, indent, m[2]))
+ }
+ }
+ if len(out) == 0 {
+ return "(aucun titre markdown trouvé)"
+ }
+ return strings.Join(out, "\n")
+}
+
+func formatLines(lines []string, startLine int) string {
+ var b strings.Builder
+ for i, l := range lines {
+ fmt.Fprintf(&b, "%5d | %s\n", startLine+i, l)
+ }
+ return strings.TrimRight(b.String(), "\n")
+}
+
+func formatBytes(n int) string {
+ switch {
+ case n < 1024:
+ return fmt.Sprintf("%d B", n)
+ case n < 1024*1024:
+ return fmt.Sprintf("%.1f KB", float64(n)/1024)
+ default:
+ return fmt.Sprintf("%.2f MB", float64(n)/1024/1024)
+ }
+}
+
+// ─── recherche DuckDuckGo ───────────────────────────────────────────────────
+
+type searchResult struct {
+ Title string
+ URL string
+ Snippet string
+}
+
+var (
+ wsRe = regexp.MustCompile(`\s+`)
+ numEntityRe = regexp.MustCompile(`(\d+);`)
+ ddgHeadRe = regexp.MustCompile(`^##\s+\[([^\]]+)\]\(([^)]+)\)\s*$`)
+ ddgLinkRe = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`)
+ urlishRe = regexp.MustCompile(`^[\w.-]+\.[a-z]{2,}`)
+)
+
+func decodeHTMLEntities(s string) string {
+ s = strings.NewReplacer(
+ "&", "&", "<", "<", ">", ">", """, `"`,
+ "'", "'", "/", "/", " ", " ",
+ ).Replace(s)
+ return numEntityRe.ReplaceAllStringFunc(s, func(m string) string {
+ var n int
+ fmt.Sscanf(m, "%d;", &n)
+ if n > 0 {
+ return string(rune(n))
+ }
+ return m
+ })
+}
+
+// decodeUddg : DDG enrobe les résultats en //duckduckgo.com/l/?uddg=URL_ENCODÉE
+func decodeUddg(raw string) string {
+ s := raw
+ if strings.HasPrefix(s, "//") {
+ s = "https:" + s
+ }
+ u, err := url.Parse(s)
+ if err != nil {
+ return s
+ }
+ if q := u.Query().Get("uddg"); q != "" {
+ if dec, e := url.QueryUnescape(q); e == nil {
+ return dec
+ }
+ }
+ return s
+}
+
+func duckduckgoSearch(query string, limit int) ([]searchResult, error) {
+ if webEngine() == engineGo {
+ // Le moteur intégré lit la structure HTML des résultats plutôt que de
+ // reparser un markdown intermédiaire.
+ return goSearch(query, limit)
+ }
+ searchURL := "https://html.duckduckgo.com/html/?q=" + url.QueryEscape(query)
+ md, err := runCrwl(searchURL, crwlOptions{rawMarkdown: true, pageTimeoutMs: 30000})
+ if err != nil {
+ return nil, err
+ }
+ if strings.Contains(md, "anomaly-modal") || strings.Contains(md, "anomaly.js") {
+ return nil, fmt.Errorf("DuckDuckGo a renvoyé un défi anti-bot")
+ }
+ var results []searchResult
+ lines := strings.Split(md, "\n")
+ for i := 0; i < len(lines) && len(results) < limit; i++ {
+ h := ddgHeadRe.FindStringSubmatch(lines[i])
+ if h == nil {
+ continue
+ }
+ title := strings.TrimSpace(strings.ReplaceAll(decodeHTMLEntities(h[1]), "**", ""))
+ u := decodeUddg(h[2])
+ if title == "" || u == "" || strings.Contains(u, "duckduckgo.com") {
+ continue
+ }
+ snippet := ""
+ for j := i + 1; j < i+6 && j < len(lines); j++ {
+ ln := strings.TrimSpace(lines[j])
+ if ln == "" {
+ continue
+ }
+ for _, lm := range ddgLinkRe.FindAllStringSubmatch(ln, -1) {
+ text := strings.TrimSpace(lm[1])
+ if text == "" || strings.HasPrefix(text, "!") || urlishRe.MatchString(text) {
+ continue
+ }
+ snippet = strings.TrimSpace(strings.ReplaceAll(decodeHTMLEntities(text), "**", ""))
+ break
+ }
+ if snippet != "" {
+ break
+ }
+ }
+ dup := false
+ for _, r := range results {
+ if r.URL == u {
+ dup = true
+ break
+ }
+ }
+ if dup {
+ continue
+ }
+ results = append(results, searchResult{Title: title, URL: u, Snippet: snippet})
+ }
+ return results, nil
+}
+
+// ─── définitions d'outils (schémas OpenAI, comme llm_client.go) ────────────────────
diff --git a/internal/ajean/chat_internet_tools.go b/internal/ajean/chat_internet_tools.go
new file mode 100644
index 0000000..438fd45
--- /dev/null
+++ b/internal/ajean/chat_internet_tools.go
@@ -0,0 +1,374 @@
+// chat_internet_tools.go — les 4 outils web exposés au modèle (web_search,
+// web_open, web_read, web_grep) + la sous-commande `ajean internet`.
+package ajean
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+func webSearchTool() Tool {
+ return Tool{Type: "function", Function: ToolFunction{
+ Name: "web_search",
+ Description: "Recherche web (DuckDuckGo) → liste classée de {title, url, snippet}. " +
+ "Pour une question sans URL ou une information récente. Enchaîner avec web_open + web_read sur le meilleur résultat.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "query": map[string]any{"type": "string", "description": "Requête"},
+ "limit": map[string]any{"type": "integer", "description": "Défaut 8, max 20"},
+ },
+ "required": []string{"query"},
+ },
+ }}
+}
+
+// webOpenTool : le schéma DÉPEND du moteur web actif.
+//
+// Le moteur intégré n'a pas de DOM vivant : actions / wait_for / dismiss_popups
+// seraient acceptés puis ignorés en silence. Les déclarer quand même reviendrait
+// à mentir au modèle — il croirait pouvoir déplier une section ou fermer un
+// bandeau, constaterait que rien ne change, et réessaierait en boucle (le failure
+// mode classique, cf. le garde-fou anti-boucle de chat_agent.go). On ne déclare
+// donc que ce que le moteur sait réellement faire, et on annonce la limite du JS
+// dans la description pour que le modèle change de source au lieu d'insister.
+func webOpenTool() Tool {
+ desc := "Récupère une URL et renvoie SEULEMENT ses métadonnées (taille, nb de lignes, plan des titres), " +
+ "PAS le contenu. Toujours l'appeler avant de lire ; mis en cache 10 min pour les web_read/web_grep suivants."
+ props := map[string]any{
+ "url": map[string]any{"type": "string", "description": "URL complète"},
+ "refresh": map[string]any{"type": "boolean", "description": "Ignore le cache. Défaut false."},
+ }
+ if webEngine() == engineGo {
+ desc += " ⚠ Ce moteur ne lit que le HTML servi, SANS JavaScript : une page rendue côté client " +
+ "ressort vide. Dans ce cas ne réessaie pas la même URL, change de source."
+ } else {
+ props["actions"] = map[string]any{"type": "array", "items": map[string]any{"type": "string"},
+ "description": "Snippets JS à exécuter sur la page AVANT extraction (déplier des sections, cliquer 'voir plus', etc.)."}
+ props["dismiss_popups"] = map[string]any{"type": "boolean", "description": "Ferme auto les bandeaux cookies/overlays. Défaut true."}
+ props["wait_for"] = map[string]any{"type": "string", "description": "Sélecteur CSS ou expr JS à attendre après les actions."}
+ }
+ return Tool{Type: "function", Function: ToolFunction{
+ Name: "web_open",
+ Description: desc,
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": props,
+ "required": []string{"url"},
+ },
+ }}
+}
+
+func webReadTool() Tool {
+ return Tool{Type: "function", Function: ToolFunction{
+ Name: "web_read",
+ Description: "Lit une plage de lignes d'une URL déjà ouverte avec web_open. Lignes 1-indexées, préfixées par leur numéro.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "url": map[string]any{"type": "string", "description": "URL ouverte avec web_open"},
+ "offset": map[string]any{"type": "integer", "description": "Ligne de départ (défaut 1)"},
+ "limit": map[string]any{"type": "integer", "description": "Défaut 80, max 500"},
+ },
+ "required": []string{"url"},
+ },
+ }}
+}
+
+func webGrepTool() Tool {
+ return Tool{Type: "function", Function: ToolFunction{
+ Name: "web_grep",
+ Description: "Recherche regex dans une URL déjà ouverte avec web_open → lignes correspondantes, " +
+ "avec contexte et numéros. Idéal sur une page longue dont on connaît un mot-clé.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "url": map[string]any{"type": "string", "description": "URL ouverte avec web_open"},
+ "pattern": map[string]any{"type": "string", "description": "Regex (insensible à la casse)"},
+ "context": map[string]any{"type": "integer", "description": "Lignes de contexte. Défaut 2."},
+ "max_matches": map[string]any{"type": "integer", "description": "Plafond. Défaut 30."},
+ },
+ "required": []string{"url", "pattern"},
+ },
+ }}
+}
+
+// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
+
+// webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme
+// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) pour MCP. Sans ce
+// plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000
+// caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui
+// déclenchait des compactages en cascade au milieu du raisonnement.
+const webMaxOutput = 8000
+
+// capWebOutput tronque en gardant le DÉBUT (contrairement au shell, où c'est la
+// fin qui porte l'info) et dit au modèle comment lire la suite proprement.
+func capWebOutput(s string) string {
+ if r := []rune(s); len(r) > webMaxOutput {
+ return string(r[:webMaxOutput]) +
+ "\n…[tronqué : réponse trop longue. Relis par tranches avec web_read(offset, limit) ou cible avec web_grep.]"
+ }
+ return s
+}
+
+func toolWebSearch(args map[string]any) string {
+ query, _ := args["query"].(string)
+ limit := 8
+ if v, ok := args["limit"].(float64); ok {
+ limit = int(v)
+ }
+ if limit < 1 {
+ limit = 1
+ }
+ if limit > 20 {
+ limit = 20
+ }
+ results, err := duckduckgoSearch(query, limit)
+ if err != nil {
+ return "❌ Recherche échouée : " + err.Error()
+ }
+ if len(results) == 0 {
+ return fmt.Sprintf("Aucun résultat pour « %s »", query)
+ }
+ var b strings.Builder
+ fmt.Fprintf(&b, "# Recherche : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results))
+ for i, r := range results {
+ fmt.Fprintf(&b, "%d. %s\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)
+ }
+ return strings.TrimRight(b.String(), "\n")
+}
+
+func toolWebOpen(args map[string]any) string {
+ u, _ := args["url"].(string)
+ opts := fetchOptions{dismissPopups: true}
+ if v, ok := args["refresh"].(bool); ok {
+ opts.force = v
+ }
+ if v, ok := args["dismiss_popups"].(bool); ok {
+ opts.dismissPopups = v
+ }
+ if v, ok := args["wait_for"].(string); ok {
+ opts.waitFor = v
+ }
+ if arr, ok := args["actions"].([]any); ok {
+ for _, a := range arr {
+ if s, ok := a.(string); ok {
+ opts.actions = append(opts.actions, s)
+ }
+ }
+ }
+ entry, err := getPage(u, opts)
+ if err != nil {
+ return "❌ " + err.Error()
+ }
+ total := len(entry.lines)
+ chars := total
+ for _, l := range entry.lines {
+ chars += len(l)
+ }
+ return fmt.Sprintf("# Ouvert : %s\nTotal : %d lignes, %s (%d caractères)\nEn cache 10 min. Utilise web_read ou web_grep pour lire.\n\n## Plan (n° de ligne des titres)\n```\n%s\n```",
+ entry.url, total, formatBytes(chars), chars, extractOutline(entry.lines))
+}
+
+func toolWebRead(args map[string]any) string {
+ u, _ := args["url"].(string)
+ entry := findCached(u)
+ if entry == nil {
+ return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
+ }
+ total := len(entry.lines)
+ offset := 1
+ if v, ok := args["offset"].(float64); ok {
+ offset = int(v)
+ }
+ if offset < 1 {
+ offset = 1
+ }
+ limit := 80
+ if v, ok := args["limit"].(float64); ok {
+ limit = int(v)
+ }
+ if limit < 1 {
+ limit = 1
+ }
+ if limit > 500 {
+ limit = 500
+ }
+ start := offset - 1
+ if start > total {
+ start = total
+ }
+ end := start + limit
+ if end > total {
+ end = total
+ }
+ slice := entry.lines[start:end]
+ remaining := total - end
+ tail := " (fin de page)"
+ if remaining > 0 {
+ tail = fmt.Sprintf(" (%d de plus en dessous)", remaining)
+ }
+ return fmt.Sprintf("# %s\nLignes %d–%d sur %d%s\n\n```\n%s\n```",
+ entry.url, offset, end, total, tail, formatLines(slice, offset))
+}
+
+func toolWebGrep(args map[string]any) string {
+ u, _ := args["url"].(string)
+ pattern, _ := args["pattern"].(string)
+ entry := findCached(u)
+ if entry == nil {
+ return fmt.Sprintf("❌ Page absente du cache. Appelle d'abord web_open(\"%s\").", u)
+ }
+ re, err := regexp.Compile("(?i)" + pattern)
+ if err != nil {
+ return "❌ Regex invalide : " + err.Error()
+ }
+ ctx := 2
+ if v, ok := args["context"].(float64); ok {
+ ctx = int(v)
+ }
+ if ctx < 0 {
+ ctx = 0
+ }
+ maxMatches := 30
+ if v, ok := args["max_matches"].(float64); ok {
+ maxMatches = int(v)
+ }
+ if maxMatches < 1 {
+ maxMatches = 1
+ }
+ lines := entry.lines
+ var matchIdx []int
+ for i := 0; i < len(lines) && len(matchIdx) < maxMatches; i++ {
+ if re.MatchString(lines[i]) {
+ matchIdx = append(matchIdx, i)
+ }
+ }
+ if len(matchIdx) == 0 {
+ return fmt.Sprintf("# %s\nAucun match pour /%s/i", entry.url, pattern)
+ }
+ // Fusionne les fenêtres de contexte qui se chevauchent.
+ type rng struct{ s, e int }
+ var ranges []rng
+ for _, i := range matchIdx {
+ s := i - ctx
+ if s < 0 {
+ s = 0
+ }
+ e := i + ctx
+ if e > len(lines)-1 {
+ e = len(lines) - 1
+ }
+ if n := len(ranges); n > 0 && s <= ranges[n-1].e+1 {
+ if e > ranges[n-1].e {
+ ranges[n-1].e = e
+ }
+ } else {
+ ranges = append(ranges, rng{s, e})
+ }
+ }
+ var blocks []string
+ for _, r := range ranges {
+ blocks = append(blocks, "```\n"+formatLines(lines[r.s:r.e+1], r.s+1)+"\n```")
+ }
+ capped := ""
+ if len(matchIdx) == maxMatches {
+ capped = fmt.Sprintf(" (plafonné à %d)", maxMatches)
+ }
+ return fmt.Sprintf("# %s\n%d match(es) pour /%s/i%s\n\n%s",
+ entry.url, len(matchIdx), pattern, capped, strings.Join(blocks, "\n\n---\n\n"))
+}
+
+// ─── CLI : ajean internet [on|off|status|engine|url|key] ────────────────────
+
+func cmdInternet(args []string) error {
+ sub := ""
+ if len(args) > 0 {
+ sub = args[0]
+ }
+ switch sub {
+ case "on":
+ // Le moteur intégré ne demande aucun réglage ; Crawl4AI exige un serveur.
+ if webEngine() == engineCrawl && crawl4aiURL() == "" {
+ return fmt.Errorf("configure d'abord l'URL : ajean internet url (ou bascule sur le moteur intégré : ajean internet engine go)")
+ }
+ if err := setInternetEnabled(true); err != nil {
+ return err
+ }
+ fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_open/web_read/web_grep (si le mode agent est actif)")
+ case "off":
+ if err := setInternetEnabled(false); err != nil {
+ return err
+ }
+ fmt.Println(green("[ok]") + " accès internet désactivé")
+ case "url":
+ if len(args) < 2 {
+ return fmt.Errorf("usage: ajean internet url (ex: http://localhost:11235)")
+ }
+ u := strings.TrimRight(strings.TrimSpace(args[1]), "/")
+ if err := SetConfigKey("CRAWL4AI_URL", u); err != nil {
+ return err
+ }
+ reachMu.Lock()
+ reachURL = "" // invalide le cache de reachability
+ reachMu.Unlock()
+ fmt.Printf("%s serveur Crawl4AI : %s\n", green("[ok]"), bold(u))
+ case "key":
+ if len(args) < 2 {
+ return fmt.Errorf("usage: ajean internet key (vide pour l'enlever : ajean internet key \"\")")
+ }
+ k := strings.TrimSpace(args[1])
+ if err := writeCrawlKey(k); err != nil {
+ return err
+ }
+ reachMu.Lock()
+ reachURL = ""
+ reachMu.Unlock()
+ if k == "" {
+ fmt.Println(green("[ok]") + " clé Crawl4AI retirée")
+ } else {
+ fmt.Println(green("[ok]") + " clé Crawl4AI enregistrée")
+ }
+ case "engine":
+ if len(args) < 2 {
+ return fmt.Errorf("usage: ajean internet engine ")
+ }
+ e := strings.ToLower(strings.TrimSpace(args[1]))
+ if err := setWebEngine(e); err != nil {
+ return err
+ }
+ if e == engineGo {
+ fmt.Println(green("[ok]") + " moteur intégré — aucune installation requise (pas de rendu JavaScript)")
+ } else {
+ fmt.Println(green("[ok]") + " moteur Crawl4AI — configure le serveur : ajean internet url ")
+ }
+ case "", "status", "list":
+ state := dim("off")
+ if internetEnabled() {
+ state = green("on")
+ }
+ fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
+ if webEngine() == engineGo {
+ fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
+ fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
+ return nil
+ }
+ fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
+ u := crawl4aiURL()
+ if u == "" {
+ fmt.Printf(" serveur : %s — configure : ajean internet url \n", dim("(non configuré)"))
+ return nil
+ }
+ reach := red("injoignable")
+ if crawlReachable() {
+ reach = green("joignable")
+ }
+ fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
+ fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
+ default:
+ return fmt.Errorf("usage: ajean internet [on|off|status|engine |url |key ]")
+ }
+ return nil
+}
diff --git a/internal/ajean/chat_memory.go b/internal/ajean/chat_memory.go
new file mode 100644
index 0000000..c3e4cfe
--- /dev/null
+++ b/internal/ajean/chat_memory.go
@@ -0,0 +1,370 @@
+package ajean
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+// La mémoire de ajean = des fichiers Markdown plats sous memory/.md.
+// L'IA y range ce qu'elle veut retenir entre les sessions (préférences,
+// décisions, procédures, infos projet). Quatre outils : mem_search, mem_read,
+// mem_add, mem_edit.
+
+type MemPage struct {
+ Name string // nom de fichier sans le dossier (ex: "docker-notes.md")
+ Title string // 1re ligne non vide, sans les #
+}
+
+type MemHit struct {
+ File string
+ Title string
+ Snippet string
+}
+
+var memNameRe = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
+
+// memFileName normalise un nom de page : ajoute .md si absent et valide.
+func memFileName(name string) (string, error) {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return "", fmt.Errorf("nom vide")
+ }
+ if !strings.HasSuffix(strings.ToLower(name), ".md") {
+ name += ".md"
+ }
+ if !memNameRe.MatchString(name) {
+ return "", fmt.Errorf("nom invalide (alphanum, ._-)")
+ }
+ return name, nil
+}
+
+// safeMemPath valide et renvoie le chemin disque d'une page mémoire.
+func safeMemPath(name string) (string, error) {
+ fn, err := memFileName(name)
+ if err != nil {
+ return "", err
+ }
+ root, err := filepath.Abs(memoryDir())
+ if err != nil {
+ return "", err
+ }
+ abs, err := filepath.Abs(filepath.Join(root, fn))
+ if err != nil {
+ return "", err
+ }
+ if !strings.HasPrefix(abs, root+string(filepath.Separator)) {
+ return "", fmt.Errorf("path invalide")
+ }
+ return abs, nil
+}
+
+// titleOf renvoie la 1re ligne non vide d'un contenu, sans les # de tête.
+func titleOf(content string) string {
+ for _, line := range strings.Split(content, "\n") {
+ s := strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "#"))
+ if s != "" {
+ return s
+ }
+ }
+ return ""
+}
+
+// MemList liste les pages mémoire (nom + titre), triées par nom.
+func MemList() []MemPage {
+ entries, err := os.ReadDir(memoryDir())
+ if err != nil {
+ return nil
+ }
+ out := []MemPage{}
+ for _, e := range entries {
+ if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
+ continue
+ }
+ if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
+ continue
+ }
+ b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name()))
+ out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out
+}
+
+// MemSearch cherche les termes de la requête dans le nom + le contenu de chaque
+// page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de
+// recherche : à compléter par mem_read sur la page la plus pertinente.
+func MemSearch(query string, limit int) []MemHit {
+ if limit <= 0 || limit > 30 {
+ limit = 8
+ }
+ terms := strings.Fields(strings.ToLower(query))
+ type scored struct {
+ hit MemHit
+ score int
+ }
+ var ranked []scored
+ for _, p := range MemList() {
+ b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
+ content := string(b)
+ hay := strings.ToLower(p.Name + "\n" + content)
+ score := 0
+ for _, t := range terms {
+ score += strings.Count(hay, t)
+ if strings.Contains(strings.ToLower(p.Name), t) {
+ score += 3 // bonus si le terme est dans le nom de la page
+ }
+ }
+ if score == 0 && query != "" {
+ continue
+ }
+ ranked = append(ranked, scored{
+ hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)},
+ score: score,
+ })
+ }
+ sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
+ out := []MemHit{}
+ for i, r := range ranked {
+ if i >= limit {
+ break
+ }
+ out = append(out, r.hit)
+ }
+ return out
+}
+
+// snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme.
+func snippetAround(content string, terms []string) string {
+ flat := strings.Join(strings.Fields(content), " ")
+ low := strings.ToLower(flat)
+ idx := -1
+ for _, t := range terms {
+ if i := strings.Index(low, t); i >= 0 && (idx < 0 || i < idx) {
+ idx = i
+ }
+ }
+ if idx < 0 {
+ if len(flat) > 160 {
+ return flat[:160] + "…"
+ }
+ return flat
+ }
+ start := idx - 60
+ if start < 0 {
+ start = 0
+ }
+ end := idx + 100
+ if end > len(flat) {
+ end = len(flat)
+ }
+ s := flat[start:end]
+ if start > 0 {
+ s = "…" + s
+ }
+ if end < len(flat) {
+ s += "…"
+ }
+ return s
+}
+
+// MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du
+// numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500).
+func MemRead(name string, offset, limit int) (string, error) {
+ p, err := safeMemPath(name)
+ if err != nil {
+ return "", err
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return "", fmt.Errorf("page '%s' introuvable", name)
+ }
+ lines := strings.Split(string(b), "\n")
+ if offset <= 0 {
+ offset = 1
+ }
+ if limit <= 0 || limit > 500 {
+ limit = 500
+ }
+ var out strings.Builder
+ for i := offset - 1; i < len(lines) && i < offset-1+limit; i++ {
+ fmt.Fprintf(&out, "%d\t%s\n", i+1, lines[i])
+ }
+ return strings.TrimRight(out.String(), "\n"), nil
+}
+
+// MemAdd crée une nouvelle page mémoire. Refuse d'écraser une page existante
+// (utiliser mem_edit pour modifier).
+func MemAdd(name, content string) error {
+ p, err := safeMemPath(name)
+ if err != nil {
+ return err
+ }
+ if _, err := os.Stat(p); err == nil {
+ return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier")
+ }
+ if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
+ return err
+ }
+ body := strings.TrimRight(content, "\n") + "\n"
+ return os.WriteFile(p, []byte(body), 0o644)
+}
+
+// MemEdit remplace oldText par newText dans une page. oldText doit apparaître
+// EXACTEMENT une fois (sinon erreur), pour une édition sans ambiguïté.
+// errAlreadyApplied signale une édition dont le résultat est DÉJÀ en place :
+// ce n'est pas un échec, la page est dans l'état demandé.
+var errAlreadyApplied = errors.New("déjà à jour — la page contient déjà cette modification")
+
+func MemEdit(name, oldText, newText string) error {
+ p, err := safeMemPath(name)
+ if err != nil {
+ return err
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return fmt.Errorf("page '%s' introuvable", name)
+ }
+ content := string(b)
+ n := strings.Count(content, oldText)
+ if oldText == "" {
+ return fmt.Errorf("old vide")
+ }
+ if n == 0 {
+ // Déjà remplacé (le modèle rejoue souvent la même édition) : ce n'est pas
+ // une erreur, la page est dans l'état demandé.
+ if newText != "" && strings.Contains(content, newText) {
+ return errAlreadyApplied
+ }
+ return fmt.Errorf("old introuvable dans la page")
+ }
+ if n > 1 {
+ return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
+ }
+ updated := strings.Replace(content, oldText, newText, 1)
+ return os.WriteFile(p, []byte(updated), 0o644)
+}
+
+// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par
+// l'éditeur web.
+func MemContent(name string) string {
+ p, err := safeMemPath(name)
+ if err != nil {
+ return ""
+ }
+ b, err := os.ReadFile(p)
+ if err != nil {
+ return ""
+ }
+ return string(b)
+}
+
+// MemSave écrit (crée ou écrase) une page mémoire. Si old est fourni et diffère
+// du nouveau nom, l'ancienne page est renommée (supprimée). Utilisé par l'éditeur
+// web.
+func MemSave(name, old, content string) error {
+ if old != "" {
+ if oldFn, e1 := memFileName(old); e1 == nil {
+ if newFn, e2 := memFileName(name); e2 == nil && oldFn != newFn {
+ if od, e3 := safeMemPath(old); e3 == nil {
+ _ = os.Remove(od)
+ }
+ }
+ }
+ }
+ p, err := safeMemPath(name)
+ if err != nil {
+ return err
+ }
+ if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
+ return err
+ }
+ body := strings.TrimRight(content, "\n") + "\n"
+ return os.WriteFile(p, []byte(body), 0o644)
+}
+
+// MemDelete supprime une page mémoire.
+func MemDelete(name string) error {
+ p, err := safeMemPath(name)
+ if err != nil {
+ return err
+ }
+ if _, err := os.Stat(p); err != nil {
+ return fmt.Errorf("introuvable")
+ }
+ return os.Remove(p)
+}
+
+// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
+// mode agent (shell). Trois modes :
+// - MemOff : mémoire coupée (aucun outil mem_*, aucune consigne).
+// - MemOnDemand : outils mem_* disponibles, mais l'IA ne les utilise QUE si
+// l'utilisateur le demande explicitement (pas de recherche/écriture spontanée).
+// - MemAlways : comportement proactif historique (cherche avant de répondre, sauve d'elle-même).
+type MemMode string
+
+const (
+ MemOff MemMode = "off"
+ MemOnDemand MemMode = "ondemand"
+ MemAlways MemMode = "always"
+)
+
+// memMode lit MEM_MODE dans config.env. Défaut = always (préserve le comportement
+// actuel). Toute valeur inconnue retombe sur always.
+func memMode() MemMode {
+ switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_MODE"])) {
+ case "off":
+ return MemOff
+ case "ondemand":
+ return MemOnDemand
+ default: // "always", "auto", "" et inconnus
+ return MemAlways
+ }
+}
+
+// setMemMode persiste le mode mémoire dans config.env.
+func setMemMode(m MemMode) error {
+ return SetConfigKey("MEM_MODE", string(m))
+}
+
+// cmdMemory : ajean memory [off|ondemand|always|status]
+func cmdMemory(args []string) error {
+ sub := ""
+ if len(args) > 0 {
+ sub = strings.ToLower(strings.TrimSpace(args[0]))
+ }
+ label := map[MemMode]string{
+ MemOff: "désactivée (l'IA n'a aucun accès mémoire)",
+ MemOnDemand: "sur demande (outils dispo, utilisés seulement si tu le demandes)",
+ MemAlways: "auto (l'IA cherche et sauve d'elle-même)",
+ }
+ switch sub {
+ case "off":
+ if err := setMemMode(MemOff); err != nil {
+ return err
+ }
+ case "ondemand", "on-demand", "demand", "manual", "manuel":
+ if err := setMemMode(MemOnDemand); err != nil {
+ return err
+ }
+ case "always":
+ if err := setMemMode(MemAlways); err != nil {
+ return err
+ }
+ case "", "status":
+ m := memMode()
+ fmt.Printf("%s mode: %s — %s\n", cyan("Mémoire"), bold(string(m)), label[m])
+ pages := MemList()
+ fmt.Printf(" %d page(s) sous %s\n", len(pages), memoryDir())
+ return nil
+ default:
+ return fmt.Errorf("usage: ajean memory [off|ondemand|always|status]")
+ }
+ m := memMode()
+ fmt.Printf("%s mémoire : %s — %s\n", green("[ok]"), bold(string(m)), label[m])
+ return nil
+}
diff --git a/internal/ajean/chat_prompt_size_test.go b/internal/ajean/chat_prompt_size_test.go
new file mode 100644
index 0000000..fde8d5c
--- /dev/null
+++ b/internal/ajean/chat_prompt_size_test.go
@@ -0,0 +1,65 @@
+package ajean
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// Budget du préambule envoyé À CHAQUE TOUR : prompt système + schémas des outils.
+// Il est payé sur toute la conversation et sort du contexte utile.
+//
+// Ce test existe pour empêcher le regonflement. Le prompt a déjà été raccourci
+// une fois pour une raison de comportement (un préambule verbeux fait
+// sur-raisonner les modèles à reasoning, qui finissent leur tour sans appeler
+// d'outil), puis une seconde fois pour le coût. Les deux fois, il avait
+// regrossi ligne par ligne, chacune paraissant anodine.
+//
+// Approximation 1 tok ≈ 4 caractères : suffisante pour une alerte, et sans
+// dépendance à un tokenizer.
+const promptCharBudget = 7500 // ~1875 tokens, tout allumé
+
+func TestSystemPromptStaysLean(t *testing.T) {
+ caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
+ sp := baseSystemPrompt(caps)
+ tb, err := json.Marshal(EnabledTools(caps))
+ if err != nil {
+ t.Fatal(err)
+ }
+ total := len(sp) + len(tb)
+ t.Logf("prompt %d car (~%d tok) + outils %d car (~%d tok) = ~%d tok",
+ len(sp), len(sp)/4, len(tb), len(tb)/4, total/4)
+ if total > promptCharBudget {
+ t.Fatalf("préambule à %d car (~%d tok), budget %d car (~%d tok).\n"+
+ "Avant de relever le budget : les schémas d'outils pèsent le double du prompt,\n"+
+ "et une consigne écrite dans un schéma n'a pas à être répétée dans le prompt.",
+ total, total/4, promptCharBudget, promptCharBudget/4)
+ }
+}
+
+// Le prompt ne doit pas redevenir un catalogue d'outils : leurs schémas partent
+// dans la MÊME requête et les décrivent déjà un par un.
+func TestSystemPromptDoesNotRelistTools(t *testing.T) {
+ sp := baseSystemPrompt(Caps{Agent: true, Internet: true, Mem: MemAlways})
+ for _, line := range strings.Split(sp, "\n") {
+ l := strings.TrimSpace(line)
+ if !strings.HasPrefix(l, "- ") {
+ continue
+ }
+ // Une puce qui commence par un nom d'outil = un catalogue qui revient.
+ for _, tool := range []string{"bash ", "write ", "edit ", "mem_", "web_"} {
+ if strings.HasPrefix(l[2:], tool) {
+ t.Fatalf("le prompt réénumère un outil : %q", l)
+ }
+ }
+ }
+}
+
+// Sans agent ni mémoire, aucun préambule : un modèle de chat simple à qui on
+// ordonne d'appeler des outils invente des appels textuels qui fuient dans la
+// réponse.
+func TestSystemPromptEmptyWithoutTools(t *testing.T) {
+ if sp := baseSystemPrompt(Caps{Mem: MemOff}); sp != "" {
+ t.Fatalf("préambule non vide sans outils : %q", sp)
+ }
+}
diff --git a/internal/ajean/chat_stuck_test.go b/internal/ajean/chat_stuck_test.go
new file mode 100644
index 0000000..60fa5a0
--- /dev/null
+++ b/internal/ajean/chat_stuck_test.go
@@ -0,0 +1,112 @@
+package ajean
+
+import (
+ "context"
+ "os"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Le bouton stop doit VRAIMENT arrêter une commande shell. Elle naissait d'un
+// contexte indépendant du tour : arrêter la génération ne l'arrêtait pas, et le
+// tour restait suspendu jusqu'à l'expiration du délai (5 min au maximum).
+func TestRunShellObeitAuStop(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("commande de veille propre à Unix")
+ }
+ testHome(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ go func() {
+ time.Sleep(200 * time.Millisecond)
+ cancel()
+ }()
+ start := time.Now()
+ out := runShell(ctx, "sleep 30", 300)
+ if d := time.Since(start); d > 5*time.Second {
+ t.Fatalf("la commande a survécu à l'annulation (%s) : %q", d, out)
+ }
+ if !strings.Contains(out, "interrompue") {
+ t.Errorf("résultat attendu « interrompue », reçu %q", out)
+ }
+}
+
+// Une commande qui laisse un process en arrière-plan garde les tubes de sortie
+// ouverts. Sans WaitDelay, Wait attend leur fermeture — donc pour toujours — et
+// le tour ne se terminait JAMAIS (seul un redémarrage du service débloquait).
+func TestRunShellNeBloquePasSurUnProcessDetache(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("syntaxe de détachement propre à Unix")
+ }
+ testHome(t)
+ start := time.Now()
+ out := runShell(context.Background(), "sleep 30 & echo lance", 2)
+ if d := time.Since(start); d > 10*time.Second {
+ t.Fatalf("runShell est resté accroché aux tubes du process détaché (%s)", d)
+ }
+ if !strings.Contains(out, "lance") && !strings.Contains(out, "timeout") {
+ t.Errorf("sortie inattendue : %q", out)
+ }
+}
+
+// Le dossier de travail est résolu une fois par process : s'il disparaît, plus
+// AUCUNE commande ne passait (« chdir : no such file or directory ») jusqu'au
+// redémarrage du service. Il doit être recréé au besoin.
+func TestRunShellRecreeLeWorkspaceDisparu(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("echo se comporte différemment sous cmd.exe")
+ }
+ testHome(t)
+ ws := agentWorkspace()
+ if ws == "" {
+ t.Skip("pas de workspace résolu")
+ }
+ if err := os.RemoveAll(ws); err != nil {
+ t.Fatal(err)
+ }
+ out := runShell(context.Background(), "echo vivant", 10)
+ if !strings.Contains(out, "vivant") {
+ t.Fatalf("commande cassée par la disparition du workspace : %q", out)
+ }
+}
+
+// Reset doit TOUJOURS rendre la main, même sur un tour resté coincé : c'est le
+// geste que l'on tente quand le chat est bloqué. Avant, Generating restait vrai
+// et tout envoi suivant était refusé « génération en cours » jusqu'au
+// redémarrage du service.
+func TestResetDebloqueUnTourCoince(t *testing.T) {
+ c := newTestConv()
+ // Simule un tour parti et jamais terminé (commande accrochée, moteur disparu).
+ c.mu.Lock()
+ c.Generating = true
+ c.mu.Unlock()
+
+ c.Reset()
+
+ if st := c.state(); st["generating"] != false {
+ t.Fatal("après Reset, la conversation doit être déclarée libre")
+ }
+}
+
+// Le tour abandonné se termine parfois APRÈS le démarrage du suivant. Sa fin ne
+// doit pas déclarer « libre » une génération toute neuve.
+func TestTourPerimeNeLiberePasLeTourCourant(t *testing.T) {
+ c := newTestConv()
+ oldEpoch := c.epoch
+ c.Reset() // le tour d'epoch `oldEpoch` devient périmé
+ c.mu.Lock()
+ c.Generating = true // un nouveau tour démarre
+ c.mu.Unlock()
+
+ // Fin (tardive) du tour périmé : même code que le defer de generate.
+ c.mu.Lock()
+ if c.epoch == oldEpoch {
+ c.Generating = false
+ }
+ c.mu.Unlock()
+
+ if st := c.state(); st["generating"] != true {
+ t.Fatal("un tour périmé a libéré le tour courant")
+ }
+}
diff --git a/internal/ajean/chat_tools.go b/internal/ajean/chat_tools.go
new file mode 100644
index 0000000..a2c9d42
--- /dev/null
+++ b/internal/ajean/chat_tools.go
@@ -0,0 +1,330 @@
+package ajean
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "os/user"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ toolDefaultTimeout = 30
+ toolMaxTimeout = 300
+ toolMaxOutput = 8000 // characters of stdout/stderr returned to the model
+)
+
+// baseSystemPrompt is the always-on system preamble. Structured like pi's
+// proven prompt (identity → method → guidelines → concision → date): a concrete,
+// procedural prompt gives the model rails so it stops deliberating forever
+// ("Wait, let me check… Wait, I'll just run it…") and commits to an action.
+// The per-tool "Outil disponible" sections live in machine/skills prompts so
+// they only appear when the matching feature is on.
+func baseSystemPrompt(caps Caps) string {
+ hasMem := caps.Mem != MemOff
+ // No tool access at all → no agentic preamble. A plain chat model told to
+ // "call tools immediately" hallucinates textual tool calls (e.g.
+ // default_api:bash) that leak into the answer. Let the user's own system
+ // prompt stand alone. (Internet requiert l'agent, donc pas testé ici.)
+ if !caps.Agent && !hasMem {
+ return ""
+ }
+ var b strings.Builder
+ // Prompt VOLONTAIREMENT court. Un préambule verbeux (longue liste de
+ // « guidelines », surtout des méta-instructions sur la réflexion) fait
+ // sur-raisonner les modèles à reasoning (Qwen3) : ils émettent leur
+ // puis le token de fin SANS appeler d'outil (~25-45 % de tours « morts »
+ // mesurés). Une version courte et directe ramène ça à 0 %. NE PAS regonfler.
+ // « AJEAN » avec une majuscule : c'est un nom propre, et le modèle recopie
+ // littéralement la casse d'ici quand il se présente (« je suis ajean »).
+ b.WriteString("You are AJEAN, an expert assistant operating directly on this machine with real tools.")
+ if caps.Mem == MemAlways {
+ b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
+ }
+ // PAS de catalogue d'outils ici : leurs schémas, envoyés dans la même requête,
+ // les décrivent déjà un par un. Les réénumérer coûtait ~120 tokens à chaque
+ // tour pour répéter ce que le modèle a juste à côté. Ne restent que les
+ // consignes que les schémas ne portent pas — le shell utilisé, et les
+ // politiques d'usage.
+ if caps.Agent {
+ // Le shell de la machine CIBLE (cmd.exe pour un poste Windows), pas celui du
+ // serveur : sinon le modèle écrit du bash là où tourne cmd.exe (et inversement).
+ b.WriteString("\n\nThe shell is " + agentTargetShellName() + ": use its syntax.\n")
+ } else {
+ b.WriteString("\n\n")
+ }
+ // Politique d'usage de la mémoire selon le mode.
+ switch caps.Mem {
+ case MemAlways:
+ b.WriteString("\nManaging your memory is part of the job, not optional:\n")
+ b.WriteString("- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n")
+ b.WriteString("- Before any task or answer, call mem_search first, then mem_read the best page. Do this even when the request has new specifics (a name, a place, a value): your saved method still applies, only the parameter changes.\n")
+ case MemOnDemand:
+ b.WriteString("\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\n")
+ }
+ if caps.Agent {
+ // La règle « write, jamais echo/cat » est INDISPENSABLE (cmd.exe massacre
+ // les guillemets imbriqués) mais elle vit maintenant dans les schémas de
+ // write et bash, là où elle s'applique. Elle était écrite trois fois.
+ b.WriteString("For anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\n")
+ // Un lien Markdown ordinaire, comme dans n'importe quel chat : l'UI en fait
+ // un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale
+ // à connaître pour le modèle — juste [texte](chemin).
+ b.WriteString("To give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\n")
+ if caps.Mem == MemAlways {
+ b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
+ }
+ }
+ if caps.Internet {
+ // Le catalogue des outils web est parti dans leurs schémas ; ne reste ici
+ // que l'ordre d'appel, que les schémas pris isolément ne disent pas.
+ year := time.Now().Format("2006")
+ b.WriteString("\nWeb: web_open first, then web_read/web_grep on it.\n")
+ b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\n")
+ b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
+ }
+ if caps.Agent {
+ if line := mcpPromptLine(); line != "" {
+ b.WriteString(line)
+ }
+ }
+ b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
+ return b.String()
+}
+
+// prevYear returns the year before the given "2006"-formatted year string, used
+// to name explicitly the stale year the model must NOT put in search queries.
+func prevYear(year string) string {
+ n, err := strconv.Atoi(year)
+ if err != nil {
+ return year
+ }
+ return strconv.Itoa(n - 1)
+}
+
+// machineSystemPrompt returns a short briefing about the host the model is
+// running on, so that when machine access is enabled it knows *which* machine
+// run_shell acts upon (and doesn't claim it has no access to "your PC").
+// Returns "" when machine access is off.
+func machineSystemPrompt(caps Caps) string {
+ if !caps.Agent {
+ return ""
+ }
+ // Cible = un poste distant : bash/write/edit s'exécutent LÀ-BAS, pas sur ce
+ // serveur. Le modèle doit le savoir explicitement, sinon il croit agir sur
+ // l'hôte du serveur et se trompe de machine.
+ if tgt, ok := nodeTargetMetaGet(); ok {
+ var b strings.Builder
+ b.WriteString("Machine: you are operating on a REMOTE node named " + tgt.name)
+ if tgt.os != "" {
+ b.WriteString(" (" + tgt.os + ")")
+ }
+ b.WriteString(". Your bash, write and edit tools run on THAT machine — a different computer than this server.")
+ if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
+ b.WriteString(" Its shell is cmd.exe: use cmd syntax (never bash idioms like ls, 2>nul, single quotes, or 'cmd //c'). To create a file, use the write tool, never echo/type into it.")
+ }
+ if tgt.root != "" {
+ b.WriteString(" File paths in write/edit resolve inside its working folder " + tgt.root + "; read/write are confined there.")
+ }
+ // Le poste peut tourner en compte de service (Windows: LocalSystem) : les
+ // variables d'environnement personnelles (%USERPROFILE%, $HOME) ne désignent
+ // PAS forcément l'utilisateur interactif. Utiliser des chemins absolus.
+ b.WriteString(" It may run as a background service account, so %USERPROFILE%/$HOME may not point to the interactive user — prefer absolute paths (e.g. C:\\Users\\\\...).")
+ if !tgt.connected {
+ b.WriteString(" ⚠ It is currently OFFLINE: those tools will fail until it reconnects. Tell the user instead of trying repeatedly.")
+ }
+ return b.String()
+ }
+ host, _ := os.Hostname()
+ if host == "" {
+ host = "unknown"
+ }
+ who := ""
+ if u, err := user.Current(); err == nil {
+ who = u.Username
+ }
+ cwd := agentWorkspace()
+
+ var b strings.Builder
+ b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
+ if who != "" {
+ b.WriteString(", user=" + who)
+ }
+ if cwd != "" {
+ b.WriteString(", cwd=" + cwd)
+ }
+ b.WriteString(".")
+ if cwd != "" {
+ b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder ajean was launched from.")
+ }
+ return b.String()
+}
+
+// runShell executes a command via the platform shell (bash -c on Unix, cmd /C
+// on Windows — see newShellCmd in sys_platform_*.go) with a clamped timeout,
+// returning a single string formatted "exit: N\n\nstdout:\n...\n\nstderr:\n..."
+// truncated to keep tool output bounded.
+//
+// ⚠️ parent est le contexte DU TOUR : c'est lui qui rend le bouton stop utile.
+// La commande naissait auparavant d'un context.Background(), donc arrêter la
+// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du
+// délai (5 minutes au maximum), bouton stop sans effet.
+func runShell(parent context.Context, command string, timeoutSec int) string {
+ if timeoutSec <= 0 {
+ timeoutSec = toolDefaultTimeout
+ }
+ if timeoutSec > toolMaxTimeout {
+ timeoutSec = toolMaxTimeout
+ }
+ ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
+ defer cancel()
+ cmd := newShellCmd(ctx, command)
+ // Le shell démarre dans le workspace, pas dans le dossier d'où ajean a été
+ // lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau.
+ //
+ // Le dossier est résolu UNE fois par process (agentWorkspace), donc s'il
+ // disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même le
+ // supprime — toutes les commandes suivantes échouaient sur un « chdir : no
+ // such file or directory » incompréhensible, et ce jusqu'au redémarrage. On
+ // le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
+ // tout refuser.
+ if ws := agentWorkspace(); ws != "" {
+ if err := os.MkdirAll(ws, 0o755); err == nil {
+ cmd.Dir = ws
+ }
+ }
+ var stdout, stderr strings.Builder
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+ // ⚠️ WaitDelay borne l'attente APRÈS la fin (ou la mise à mort) du process.
+ // Sans elle, Wait attend que les tubes de sortie soient fermés — donc que
+ // TOUS ceux qui les tiennent aient disparu, petits-enfants compris. Une
+ // commande du genre « ./serveur & » rend la main tout de suite mais laisse
+ // un process en arrière-plan accroché aux tubes : runShell ne revenait alors
+ // JAMAIS, ni au délai, ni au stop. Le tour restait bloqué à vie, et la seule
+ // issue connue était de redémarrer ajean-ui.
+ cmd.WaitDelay = 2 * time.Second
+ err := cmd.Run()
+ switch {
+ case errors.Is(ctx.Err(), context.DeadlineExceeded):
+ return fmt.Sprintf("[timeout après %ds]", timeoutSec)
+ case errors.Is(parent.Err(), context.Canceled):
+ return "[commande interrompue]"
+ }
+ exit := 0
+ if err != nil {
+ if ee, ok := err.(*exec.ExitError); ok {
+ exit = ee.ExitCode()
+ } else {
+ return fmt.Sprintf("[erreur: %v]", err)
+ }
+ }
+ out := tailRunes(stdout.String(), toolMaxOutput)
+ errOut := tailRunes(stderr.String(), toolMaxOutput)
+ parts := []string{fmt.Sprintf("exit: %d", exit)}
+ if out != "" {
+ parts = append(parts, "stdout:\n"+out)
+ }
+ if errOut != "" {
+ parts = append(parts, "stderr:\n"+errOut)
+ }
+ return strings.Join(parts, "\n\n")
+}
+
+// shellName is the shell runShell actually spawns on this platform. The model is
+// told this explicitly: advertising the tool as "bash" on Windows made it emit
+// bash quoting into cmd.exe, which mangles it (unterminated string literals, and
+// stray "Commande ECHO activée." landing inside generated files).
+func shellName() string {
+ if runtime.GOOS == "windows" {
+ return "cmd.exe"
+ }
+ return "bash"
+}
+
+// fileWrite writes content to path verbatim, creating parent directories and
+// replacing any existing file. This is the escape hatch from shell quoting: a
+// model with only a shell has to build files with echo/python -c, which is
+// unreliable everywhere and outright broken on cmd.exe.
+func fileWrite(path, content string) string {
+ if strings.TrimSpace(path) == "" {
+ return "[erreur] chemin vide"
+ }
+ path = resolveAgentPath(path)
+ if dir := filepath.Dir(path); dir != "" && dir != "." {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "[erreur] " + err.Error()
+ }
+ }
+ // Préserve les permissions d'origine quand le fichier existe déjà (un script
+ // 0755 réécrit doit rester exécutable).
+ mode := os.FileMode(0o644)
+ existed := false
+ if fi, err := os.Stat(path); err == nil {
+ mode = fi.Mode()
+ existed = true
+ }
+ if err := os.WriteFile(path, []byte(content), mode); err != nil {
+ return "[erreur] " + err.Error()
+ }
+ verb := "créé"
+ if existed {
+ verb = "réécrit"
+ }
+ return fmt.Sprintf("[ok] %s %s (%d octets)", path, verb, len(content))
+}
+
+// fileEdit applies a single exact-text replacement to a file on disk: oldText
+// must appear EXACTLY once (otherwise it errors), so the model can patch a file
+// without rewriting it whole. Returns a short status string for the tool result.
+func fileEdit(path, oldText, newText string) string {
+ if strings.TrimSpace(path) == "" {
+ return "[erreur] chemin vide"
+ }
+ if oldText == "" {
+ return "[erreur] old vide"
+ }
+ path = resolveAgentPath(path)
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return "[erreur] " + err.Error()
+ }
+ content := string(b)
+ n := strings.Count(content, oldText)
+ if n == 0 {
+ // Modification déjà en place : on le dit clairement plutôt que de renvoyer
+ // une erreur, sinon le modèle croit avoir échoué et recommence.
+ if newText != "" && strings.Contains(content, newText) {
+ return "[ok] déjà à jour — le fichier contient déjà cette modification"
+ }
+ return "[erreur] old introuvable dans le fichier"
+ }
+ if n > 1 {
+ return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
+ }
+ updated := strings.Replace(content, oldText, newText, 1)
+ // Préserve les permissions d'origine (un script 0755 doit rester exécutable).
+ mode := os.FileMode(0o644)
+ if fi, err := os.Stat(path); err == nil {
+ mode = fi.Mode()
+ }
+ if err := os.WriteFile(path, []byte(updated), mode); err != nil {
+ return "[erreur] " + err.Error()
+ }
+ return fmt.Sprintf("[ok] %s modifié (1 remplacement)", path)
+}
+
+// tailRunes returns the last n runes of s (used to cap tool output).
+func tailRunes(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return string(r[len(r)-n:])
+}
diff --git a/internal/ajean/chat_tools_write_test.go b/internal/ajean/chat_tools_write_test.go
new file mode 100644
index 0000000..ab07b4d
--- /dev/null
+++ b/internal/ajean/chat_tools_write_test.go
@@ -0,0 +1,50 @@
+package ajean
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// Le contenu doit arriver sur le disque à l'octet près : c'est tout l'intérêt de
+// l'outil write (les guillemets imbriqués d'un script Python ne survivaient pas
+// au passage par le shell).
+func TestFileWriteVerbatim(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "sub", "kpi.py")
+ content := "import sqlite3\nq = \"SELECT * FROM t WHERE n = 'a'\"\nprint(q)\n"
+
+ res := fileWrite(path, content)
+ if !strings.HasPrefix(res, "[ok]") {
+ t.Fatalf("write a échoué: %s", res)
+ }
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("relecture: %v", err)
+ }
+ if string(got) != content {
+ t.Fatalf("contenu altéré:\n%q\nattendu:\n%q", got, content)
+ }
+}
+
+func TestFileWriteOverwrites(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "a.txt")
+ if res := fileWrite(path, "ancien"); !strings.HasPrefix(res, "[ok]") {
+ t.Fatalf("premier write: %s", res)
+ }
+ res := fileWrite(path, "nouveau")
+ if !strings.Contains(res, "réécrit") {
+ t.Fatalf("attendu « réécrit », obtenu: %s", res)
+ }
+ got, _ := os.ReadFile(path)
+ if string(got) != "nouveau" {
+ t.Fatalf("contenu = %q", got)
+ }
+}
+
+func TestFileWriteRejectsEmptyPath(t *testing.T) {
+ if res := fileWrite(" ", "x"); !strings.HasPrefix(res, "[erreur]") {
+ t.Fatalf("attendu une erreur, obtenu: %s", res)
+ }
+}
diff --git a/internal/ajean/chat_workspace.go b/internal/ajean/chat_workspace.go
new file mode 100644
index 0000000..20016fb
--- /dev/null
+++ b/internal/ajean/chat_workspace.go
@@ -0,0 +1,87 @@
+package ajean
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+// Dossier de travail du mode agent.
+//
+// Sans ça, les outils write/edit/bash héritent du répertoire courant du PROCESSUS :
+// le modèle écrit "meteo.json", et le fichier atterrit là d'où l'utilisateur a
+// lancé ajean — le Bureau quand on double-clique l'installateur, C:\ProgramData\ajean\bin
+// quand on lance le binaire installé. Personne ne s'attend à voir un chat déposer
+// des fichiers sur son Bureau. On donne donc à l'agent un dossier à lui : les
+// chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS
+// restent honorés tels quels — quand l'utilisateur demande d'écrire dans un
+// dossier précis, ça doit marcher.
+
+const workspaceEnv = "AJEAN_WORKSPACE"
+
+var (
+ workspaceOnce sync.Once
+ workspacePath string
+)
+
+// agentWorkspace renvoie le dossier de travail de l'agent, créé au besoin. Il
+// essaie plusieurs emplacements car AjeanHome() vaut %ProgramData%\ajean sous
+// Windows, qui n'est pas inscriptible par un utilisateur non administrateur.
+func agentWorkspace() string {
+ workspaceOnce.Do(func() {
+ for _, dir := range workspaceCandidates() {
+ if dir == "" {
+ continue
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ continue
+ }
+ // MkdirAll réussit sur un dossier existant même non inscriptible :
+ // on vérifie l'écriture réelle.
+ f, err := os.CreateTemp(dir, ".probe-*")
+ if err != nil {
+ continue
+ }
+ name := f.Name()
+ f.Close()
+ os.Remove(name)
+ workspacePath = dir
+ return
+ }
+ // Dernier recours : le répertoire courant, comportement historique.
+ workspacePath, _ = os.Getwd()
+ })
+ return workspacePath
+}
+
+func workspaceCandidates() []string {
+ var c []string
+ if v := strings.TrimSpace(os.Getenv(workspaceEnv)); v != "" {
+ c = append(c, v)
+ }
+ c = append(c, workspaceDir())
+ if home, err := os.UserHomeDir(); err == nil && home != "" {
+ c = append(c, filepath.Join(home, "ajean", "workspace"))
+ }
+ c = append(c, filepath.Join(os.TempDir(), "ajean-workspace"))
+ return c
+}
+
+// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
+// "~/x" → dans le home de l'utilisateur ; relatif → dans le workspace.
+func resolveAgentPath(p string) string {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ return p
+ }
+ if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, `~\`) {
+ if home, err := os.UserHomeDir(); err == nil && home != "" {
+ return filepath.Join(home, filepath.FromSlash(strings.TrimPrefix(p[1:], "/")))
+ }
+ }
+ if filepath.IsAbs(p) {
+ return p
+ }
+ return filepath.Join(agentWorkspace(), filepath.FromSlash(p))
+}
diff --git a/internal/ajean/chat_workspace_test.go b/internal/ajean/chat_workspace_test.go
new file mode 100644
index 0000000..ae8c295
--- /dev/null
+++ b/internal/ajean/chat_workspace_test.go
@@ -0,0 +1,35 @@
+package ajean
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le
+// répertoire courant du processus (Bureau, C:\ProgramData\ajean\bin…).
+func TestResolveAgentPathRelative(t *testing.T) {
+ got := resolveAgentPath("meteo.json")
+ want := filepath.Join(agentWorkspace(), "meteo.json")
+ if got != want {
+ t.Fatalf("resolveAgentPath = %q, attendu %q", got, want)
+ }
+}
+
+// Un chemin absolu demandé explicitement reste intouché.
+func TestResolveAgentPathAbsolute(t *testing.T) {
+ abs := filepath.Join(t.TempDir(), "rapport.md")
+ if got := resolveAgentPath(abs); got != abs {
+ t.Fatalf("resolveAgentPath = %q, attendu %q", got, abs)
+ }
+}
+
+func TestResolveAgentPathSubdir(t *testing.T) {
+ got := resolveAgentPath("notes/2026/a.txt")
+ if !strings.HasPrefix(got, agentWorkspace()) {
+ t.Fatalf("%q hors du workspace %q", got, agentWorkspace())
+ }
+ if strings.Contains(got, "/") && filepath.Separator != '/' {
+ t.Fatalf("séparateurs non normalisés : %q", got)
+ }
+}
diff --git a/internal/ajean/cli_app.go b/internal/ajean/cli_app.go
new file mode 100644
index 0000000..cf53a78
--- /dev/null
+++ b/internal/ajean/cli_app.go
@@ -0,0 +1,84 @@
+package ajean
+
+// cli_app.go — expérience « application » de AJEAN.
+//
+// Quand on double-clique sur le binaire (aucun argument, console fraîche), au
+// lieu d'afficher l'aide dans une console qui se ferme, AJEAN démarre son UI web,
+// l'ouvre dans le navigateur ET pose une icône dans la zone de notification
+// Windows (voir sys_tray_windows.go) : on voit que AJEAN tourne et on le pilote
+// (« Ouvrir AJEAN » / « Quitter »).
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "time"
+)
+
+const appPort = 8090
+
+// cmdApp lance l'UI web, l'ouvre dans le navigateur et fait tourner l'icône de
+// la zone de notification (Windows). Sur une machine vierge, il crée d'abord le
+// dossier de données + config.env pour que l'interface s'ouvre quand même
+// (l'écran d'accueil prendra le relais pour le choix du modèle).
+func cmdApp(args []string) error {
+ url := fmt.Sprintf("http://localhost:%d", appPort)
+
+ // Premier lancement : prépare le dossier de données et, sous Windows, propose
+ // explicitement l'installation (voir sys_firstrun_windows.go). Si l'app a été
+ // relancée depuis la copie installée, ce process n'a plus rien à faire.
+ if appFirstRun() {
+ return nil
+ }
+
+ addr := fmt.Sprintf("0.0.0.0:%d", appPort)
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ // AJEAN tourne déjà sur ce port : on ouvre juste l'UI sur l'instance
+ // existante plutôt que d'échouer.
+ fmt.Printf("AJEAN est déjà lancé — ouverture de %s\n", url)
+ return openBrowser(url)
+ }
+
+ // UN SEUL process propriétaire de la conversation, comme le service ajean-ui
+ // sous Linux : l'app sert l'UI locale ET le tunnel avec le MÊME mux. Deux
+ // process qui servent la conversation (objet en mémoire persisté dans
+ // conversation.json) donnent deux fils divergents entre le local et
+ // app.ajean.link — le bug qu'on a vécu avec le worker détaché.
+ appOwnsLink = true
+ appWebMux = newWebMux()
+ go func() { _ = http.Serve(ln, appWebMux) }()
+
+ go func() {
+ if readLinkToken() == "" {
+ return
+ }
+ // Worker détaché rescapé (version antérieure, autre copie de l'app) : il
+ // possède SA propre conversation et écrase la nôtre. On le supprime avant
+ // de prendre la main.
+ killForeignUIWorker()
+ startAppLink(appWebMux)
+ }()
+
+ sp := showSplash("Lancement d'AJEAN en cours…")
+ waitServerReady(url)
+ _ = openBrowser(url)
+ time.Sleep(900 * time.Millisecond) // laisse le navigateur s'afficher par-dessus le splash
+ sp.close()
+
+ runTray(url) // icône zone de notification ; bloque jusqu'à « Quitter »
+ return nil
+}
+
+// waitServerReady attend que l'UI réponde (jusqu'à ~4 s) pour n'ouvrir le
+// navigateur qu'une fois le serveur prêt.
+func waitServerReady(url string) {
+ c := &http.Client{Timeout: 400 * time.Millisecond}
+ for i := 0; i < 20; i++ {
+ if resp, err := c.Get(url); err == nil {
+ resp.Body.Close()
+ return
+ }
+ time.Sleep(150 * time.Millisecond)
+ }
+}
diff --git a/internal/ajean/doc.go b/internal/ajean/doc.go
new file mode 100644
index 0000000..1b31409
--- /dev/null
+++ b/internal/ajean/doc.go
@@ -0,0 +1,38 @@
+// AJEAN — cœur du binaire (package ajean, appelé par cmd/ajean). Go n'autorise
+// pas de sous-dossiers dans un même package : les fichiers sont donc préfixés
+// par domaine.
+//
+// run.go point d'entrée : dispatch des sous-commandes, AjeanHome() et
+// l'arborescence de données
+// store.go LA base (bbolt) : configuration, préférences, conversation,
+// clés, jetons, interrupteurs — tout l'état non éditable à la main
+// cli_* expérience « application » (double-clic : UI + tray + splash)
+// web_* serveur HTTP :8090 (UI embarquée via go:embed ui/, auth, prefs)
+// chat_* chat CLI + conversation serveur partagée, compaction, mémoire,
+// outils de l'agent (dont accès internet)
+// llm_* client llama-server (complétions, endpoint OpenAI, bench, test)
+// backend_* gestion llama.cpp : build, GPU, serve (ExecStart), modèles et
+// téléchargements, catalogue, presets, configuration
+// relay_* accès distant ajean.link : tunnel, chiffrement E2E, appairage
+// sys_* intégration OS : install, services, plateforme, process, tray,
+// splash, tty, auto-update
+//
+// Les suffixes _windows/_linux/_darwin/_unix/_other portent les contraintes de
+// compilation par OS.
+//
+// ⚠️ Les fichiers macOS ne se compilent qu'AVEC CGO (l'icône de barre de menus
+// passe par Cocoa). Une compilation croisée depuis Windows ou Linux, comme les
+// analyseurs lancés sans CGO, ne les voit PAS : un symbole qu'eux seuls
+// utilisent passe pour du code mort, et le supprimer casse le build macOS sans
+// aucun avertissement local. Seul le job « macos » de la CI le détecte. Avant de
+// supprimer un symbole jugé inutilisé, vérifier `grep` sur les fichiers _darwin.
+//
+// DEUX SERVICES à l'exécution, un seul binaire :
+// - ajean-engine (« ajean serve ») exec llama-server ;
+// - ajean-ui (« ajean web ») sert l'UI locale, le tunnel du relais et
+// l'endpoint OpenAI depuis un SEUL process — condition d'une conversation
+// unique, celle-ci vivant en mémoire (voir chat_conversation.go).
+//
+// ui/index.html est GÉNÉRÉ depuis ui/src/ : éditer les sources puis
+// `go generate ./internal/ajean` (voir tools/assemble-ui).
+package ajean
diff --git a/internal/ajean/llm_bench.go b/internal/ajean/llm_bench.go
new file mode 100644
index 0000000..1a29714
--- /dev/null
+++ b/internal/ajean/llm_bench.go
@@ -0,0 +1,221 @@
+package ajean
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// benchResult captures the timings llama.cpp returns from /completion.
+type benchResult struct {
+ PromptN int `json:"prompt_n"`
+ PromptMs float64 `json:"prompt_ms"`
+ PromptPerSecond float64 `json:"prompt_per_second"`
+ PredictedN int `json:"predicted_n"`
+ PredictedMs float64 `json:"predicted_ms"`
+ PredictedPerSec float64 `json:"predicted_per_second"`
+ Elapsed float64 `json:"elapsed_sec"`
+}
+
+// benchCorpus is a varied passage used to defeat speculative decoding
+// (MTP / n-gram draft) — repetitive text inflates decode tok/s because every
+// drafted token gets accepted, which is unlike real chat. We pull a chunk of
+// natural-looking content and tile it to reach the target prompt size.
+const benchCorpus = `In the early hours of an October morning, Camille walked along the canal, watching the cargo barges slip past the iron bridge that spanned the water. She thought about the meeting she had skipped, the unanswered messages on her phone, the way the city always seemed to forget her name after summer ended. Three streets away, a pâtisserie opened its shutters and the smell of warm butter mixed with diesel exhaust from the waiting bus.
+Pendant ce temps, à Marseille, un chercheur en biologie marine prépare son matériel pour une plongée. Il étudie les herbiers de posidonie, ces prairies sous-marines vieilles de plusieurs milliers d'années qui stockent autant de carbone qu'une forêt amazonienne. Le bateau quitte le port à six heures vingt-trois.
+Quantum computers, properly engineered, can solve certain classes of problems exponentially faster than classical machines. The catch is that decoherence ruins everything. Engineers use dilution refrigerators to drop superconducting qubits to fifteen millikelvin, colder than deep space. The wires connecting the chip to room-temperature electronics must dissipate almost no heat, or the qubit state collapses before any useful computation finishes.
+Le boulanger lève la pâte à quatre heures. Il regarde la balance numérique en plissant les yeux : six cent vingt-trois grammes, presque le compte. Son chien dort sur le tapis de farine près du four. Dehors, deux chats se disputent un poisson abandonné par le pêcheur de nuit.
+Consider a recursive descent parser written in Go. The lexer emits tokens; the parser consumes them and produces an abstract syntax tree. Error recovery is hard: after a syntax error, the parser must resynchronize at a known boundary—a semicolon, a closing brace—without losing track of subsequent diagnostics. Tree-sitter solves this with incremental parsing and a glr-like algorithm.
+Le philosophe stoïcien disait : "Ce qui nous trouble, ce n'est pas ce qui nous arrive, mais l'opinion que nous nous en faisons." Vingt siècles plus tard, la phrase apparaît dans un livre de poche au rayon développement personnel d'une librairie d'aéroport, à côté d'un roman policier suédois.
+Mitochondria descended from ancient bacteria engulfed by archaeal cells roughly two billion years ago. They still keep their own ring of DNA, separate from the nuclear genome. Mutations in mitochondrial DNA accumulate with age and have been implicated in everything from Parkinson's disease to ordinary muscle fatigue. Yet they remain stubbornly difficult to repair therapeutically because each cell contains hundreds.
+La marée descend lentement, exposant des rochers couverts d'huîtres et d'algues vertes. Un héron immobile surveille les flaques laissées par l'eau. Plus loin, deux enfants courent avec un cerf-volant rouge qui refuse de monter à cause de l'humidité dans la voile.
+Compilers translate high-level languages into machine code through several intermediate representations. LLVM IR sits in the middle: typed, mostly static-single-assignment, suitable for both aggressive optimization and direct lowering to x86 or ARM. The optimizer runs dozens of passes—dead code elimination, loop-invariant code motion, induction variable simplification—each touching the IR in carefully ordered ways.
+Le cuisinier ferme les yeux pour goûter la sauce. Trop salée. Il ajoute une pomme de terre crue coupée en quartiers, sachant qu'elle absorbera l'excès en mijotant vingt minutes. Sa grand-mère lui a appris ce geste un dimanche de novembre il y a très longtemps.
+`
+
+// runBench fires a prompt of roughly `nPrompt` tokens at /completion with
+// cache_prompt:false (so prefill is actually measured, not cached). The prompt
+// is a varied corpus to keep speculative decoding (MTP / n-gram draft) from
+// inflating decode numbers — what you measure here is close to what you'll
+// see in real chat at the same context length.
+func runBench(nPrompt, nPredict int) (*benchResult, error) {
+ port := LLMPort()
+ if !healthCheck() {
+ return nil, fmt.Errorf("serveur injoignable sur :%d", port)
+ }
+ if nPrompt <= 0 {
+ nPrompt = 2000
+ }
+ // 1 word ≈ 1.3 tokens roughly. Tile the varied corpus until we exceed
+ // nPrompt, then truncate to characters so the server tokenises a passage
+ // close to the requested size.
+ corpusWords := strings.Fields(benchCorpus)
+ target := nPrompt * 5 // ~5 chars/token gives a generous over-estimate
+ var b strings.Builder
+ for b.Len() < target {
+ for _, w := range corpusWords {
+ b.WriteString(w)
+ b.WriteByte(' ')
+ if b.Len() >= target {
+ break
+ }
+ }
+ }
+ prompt := strings.TrimSpace(b.String())
+ // Use the same endpoint your real chat hits, so the comparison is honest
+ // (chat template, reasoning, OpenAI-compat layer all included).
+ payload := map[string]any{
+ "model": "ajean",
+ "messages": []Message{{Role: "user", Content: prompt + "\n\nContinue this passage with another 1000+ words of original varied prose, mixing French and English narrative paragraphs on different topics."}},
+ "max_tokens": nPredict,
+ "stream": false,
+ "temperature": 0.7,
+ "cache_prompt": false,
+ }
+ body, _ := json.Marshal(payload)
+ url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", port)
+ t0 := time.Now()
+ req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ authHeader(req)
+ client := &http.Client{Timeout: 5 * time.Minute}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ var parsed struct {
+ Timings struct {
+ PromptN int `json:"prompt_n"`
+ PromptMs float64 `json:"prompt_ms"`
+ PromptPerSecond float64 `json:"prompt_per_second"`
+ PredictedN int `json:"predicted_n"`
+ PredictedMs float64 `json:"predicted_ms"`
+ PredictedPerSec float64 `json:"predicted_per_second"`
+ } `json:"timings"`
+ Usage struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ } `json:"usage"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
+ return nil, err
+ }
+ // /v1/chat/completions may report timings at top level or omit them; if
+ // missing, fall back to wall-clock derived from `usage` so we always show
+ // numbers comparable to what chat displays in its label.
+ if parsed.Timings.PredictedN == 0 && parsed.Usage.CompletionTokens > 0 {
+ elapsed := time.Since(t0).Seconds()
+ parsed.Timings.PromptN = parsed.Usage.PromptTokens
+ parsed.Timings.PredictedN = parsed.Usage.CompletionTokens
+ // Distribute elapsed time using a rough split (prefill is usually <20% at this size).
+ parsed.Timings.PromptMs = elapsed * 1000 * 0.15
+ parsed.Timings.PredictedMs = elapsed * 1000 * 0.85
+ if parsed.Timings.PromptMs > 0 {
+ parsed.Timings.PromptPerSecond = float64(parsed.Timings.PromptN) / (parsed.Timings.PromptMs / 1000)
+ }
+ if parsed.Timings.PredictedMs > 0 {
+ parsed.Timings.PredictedPerSec = float64(parsed.Timings.PredictedN) / (parsed.Timings.PredictedMs / 1000)
+ }
+ }
+ elapsed := time.Since(t0).Seconds()
+ t := parsed.Timings
+ res := &benchResult{
+ PromptN: t.PromptN, PromptMs: t.PromptMs, PromptPerSecond: t.PromptPerSecond,
+ PredictedN: t.PredictedN, PredictedMs: t.PredictedMs, PredictedPerSec: t.PredictedPerSec,
+ Elapsed: elapsed,
+ }
+ saveLastBench(res)
+ saveBenchForActivePreset(res)
+ return res, nil
+}
+
+// savedBench is a benchResult plus the model it was run against and a timestamp.
+type savedBench struct {
+ Result benchResult `json:"result"`
+ Model string `json:"model"`
+ At int64 `json:"at"`
+}
+
+// saveLastBench enregistre le dernier benchmark (best-effort) pour que l'UI
+// puisse l'afficher sans le relancer.
+func saveLastBench(res *benchResult) {
+ sb := savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()}
+ _ = putJSON(bkState, "last_bench", sb)
+}
+
+// loadLastBench relit le benchmark enregistré, ou nil s'il n'y en a pas.
+func loadLastBench() *savedBench {
+ var sb savedBench
+ if !getJSON(bkState, "last_bench", &sb) {
+ return nil
+ }
+ return &sb
+}
+
+// loadBenchStore renvoie les benchmarks par preset (vide s'il n'y en a pas).
+func loadBenchStore() map[string]savedBench {
+ m := map[string]savedBench{}
+ getJSON(bkState, "bench_presets", &m)
+ if m == nil {
+ m = map[string]savedBench{}
+ }
+ return m
+}
+
+// saveBenchForActivePreset records res under the name of the currently active
+// preset (celui qui correspond à la configuration active). Sans effet si aucun
+// preset ne correspond — le benchmark reste enregistré par saveLastBench.
+func saveBenchForActivePreset(res *benchResult) {
+ list, err := ListPresets()
+ if err != nil {
+ return
+ }
+ id := ""
+ for _, p := range list {
+ if p.Active {
+ id = p.ID
+ break
+ }
+ }
+ if id == "" {
+ return
+ }
+ m := loadBenchStore()
+ m[id] = savedBench{Result: *res, Model: filepath.Base(ReadConfig()["MODEL"]), At: time.Now().Unix()}
+ _ = putJSON(bkState, "bench_presets", m)
+}
+
+func cmdBench(args []string) error {
+ nPredict, nPrompt := 300, 2000
+ if len(args) >= 1 && args[0] != "" {
+ if n, err := strconv.Atoi(args[0]); err == nil {
+ nPredict = n
+ } else {
+ return fmt.Errorf("argument invalide: %s", args[0])
+ }
+ }
+ if len(args) >= 2 && args[1] != "" {
+ if n, err := strconv.Atoi(args[1]); err == nil {
+ nPrompt = n
+ } else {
+ return fmt.Errorf("argument invalide: %s", args[1])
+ }
+ }
+ fmt.Printf("[bench] prompt ~%d tokens, n_predict=%d…\n", nPrompt, nPredict)
+ r, err := runBench(nPrompt, nPredict)
+ if err != nil {
+ return err
+ }
+ fmt.Println()
+ fmt.Printf(" %s %7.1f tok/s (%d tokens en %.2fs)\n", cyan("Prefill"), r.PromptPerSecond, r.PromptN, r.PromptMs/1000)
+ fmt.Printf(" %s %7.1f tok/s (%d tokens en %.2fs)\n", cyan("Decode "), r.PredictedPerSec, r.PredictedN, r.PredictedMs/1000)
+ fmt.Printf(" Total %.2fs\n", r.Elapsed)
+ fmt.Println()
+ return nil
+}
diff --git a/internal/ajean/llm_client.go b/internal/ajean/llm_client.go
new file mode 100644
index 0000000..f6ced71
--- /dev/null
+++ b/internal/ajean/llm_client.go
@@ -0,0 +1,1136 @@
+package ajean
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+ "unicode/utf8"
+)
+
+// Message is one entry in the chat history sent to llama.cpp.
+// `Content` may be nil when an assistant message only contains tool_calls.
+type Message struct {
+ Role string `json:"role"`
+ Content any `json:"content,omitempty"`
+ ToolCalls []ToolCall `json:"tool_calls,omitempty"`
+ ToolCallID string `json:"tool_call_id,omitempty"`
+}
+
+type ToolCall struct {
+ ID string `json:"id"`
+ Type string `json:"type"`
+ Function ToolCallFunc `json:"function"`
+}
+type ToolCallFunc struct {
+ Name string `json:"name"`
+ Arguments string `json:"arguments"`
+}
+
+type Tool struct {
+ Type string `json:"type"`
+ Function ToolFunction `json:"function"`
+}
+type ToolFunction struct {
+ Name string `json:"name"`
+ Description string `json:"description"`
+ Parameters any `json:"parameters"`
+}
+
+// Tool definitions: OpenAI-shaped function schemas advertised to the model when
+// the agent mode is on. The memory tools (mem_*) let the model keep persistent
+// Markdown notes across sessions; bash is its real access to the machine.
+
+func memSearchTool() Tool {
+ return Tool{
+ Type: "function",
+ Function: ToolFunction{
+ Name: "mem_search",
+ Description: "Search your memory (Markdown pages under memory/) → ranked {file, title, snippet}. Use FIRST when the user mentions something you might already know, then mem_read the best page.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "query": map[string]any{"type": "string", "description": "Keywords"},
+ "limit": map[string]any{"type": "integer", "description": "Default 8, max 30"},
+ },
+ "required": []string{"query"},
+ },
+ },
+ }
+}
+
+func memReadTool() Tool {
+ return Tool{
+ Type: "function",
+ Function: ToolFunction{
+ Name: "mem_read",
+ Description: "Read a memory page. Lines prefixed with their 1-indexed number; offset/limit for long pages.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "file": map[string]any{"type": "string", "description": "Page name (e.g. docker-notes.md)"},
+ "offset": map[string]any{"type": "integer", "description": "Start line (default 1)"},
+ "limit": map[string]any{"type": "integer", "description": "Lines (max 500)"},
+ },
+ "required": []string{"file"},
+ },
+ },
+ }
+}
+
+func memAddTool() Tool {
+ return Tool{
+ Type: "function",
+ Function: ToolFunction{
+ Name: "mem_add",
+ Description: "Create a memory page. One topic per page, kebab-case name, first line = title (#). Refuses to overwrite an existing page (use mem_edit).",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "file": map[string]any{"type": "string", "description": "Page name"},
+ "content": map[string]any{"type": "string", "description": "Markdown, first line = title #"},
+ },
+ "required": []string{"file", "content"},
+ },
+ },
+ }
+}
+
+func memEditTool() Tool {
+ return Tool{
+ Type: "function",
+ Function: ToolFunction{
+ Name: "mem_edit",
+ Description: "Patch a memory page: old → new, old unique in the page. To append, put the current end of the page in old and the extended version in new.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "file": map[string]any{"type": "string", "description": "Page name"},
+ "old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"},
+ "new": map[string]any{"type": "string", "description": "Replacement"},
+ },
+ "required": []string{"file", "old", "new"},
+ },
+ },
+ }
+}
+
+func editTool() Tool {
+ return Tool{
+ Type: "function",
+ Function: ToolFunction{
+ Name: "edit",
+ Description: "Patch a file by exact replacement: old → new. old must appear EXACTLY once (add context to make it unique). Prefer this over rewriting a whole file.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "file": map[string]any{"type": "string", "description": "Path"},
+ "old": map[string]any{"type": "string", "description": "Exact text to replace (unique)"},
+ "new": map[string]any{"type": "string", "description": "Replacement"},
+ },
+ "required": []string{"file", "old", "new"},
+ },
+ },
+ }
+}
+
+func writeTool() Tool {
+ return Tool{
+ Type: "function",
+ Function: ToolFunction{
+ Name: "write",
+ Description: "Create or replace a file with the exact content given (parent dirs created, content verbatim, no escaping). ALWAYS use this for a script or any text file — NEVER build one through the shell with echo, cat, python -c or Set-Content: quoting breaks.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "file": map[string]any{"type": "string", "description": "Path"},
+ "content": map[string]any{"type": "string", "description": "Full content"},
+ },
+ "required": []string{"file", "content"},
+ },
+ },
+ }
+}
+
+func bashTool() Tool {
+ return Tool{
+ Type: "function",
+ Function: ToolFunction{
+ Name: "bash",
+ Description: "Run a shell command (" + agentTargetShellName() + " syntax) and return stdout, stderr and exit code: inspect the system, read files and logs, run scripts. To CREATE or REWRITE a file use write instead — never echo/cat/python -c. Avoid destructive commands unless asked.",
+ Parameters: map[string]any{
+ "type": "object",
+ "properties": map[string]any{
+ "command": map[string]any{"type": "string", "description": "The command"},
+ "timeout": map[string]any{"type": "integer", "description": fmt.Sprintf("Timeout s (default %d, max %d)", toolDefaultTimeout, toolMaxTimeout)},
+ },
+ "required": []string{"command"},
+ },
+ },
+ }
+}
+
+// Caps are the per-request capabilities (tool access) for a chat turn. They let
+// a caller (e.g. an ajean.link agent with its own tools/skills toggles) scope
+// what the model can do for this conversation, instead of always inheriting the
+// machine's global config. Use globalCaps() to fall back to the global config.
+type Caps struct {
+ // Agent = mode agent actif : un seul interrupteur qui débloque TOUS les
+ // outils de l'IA (shell + skills). Un skill est un outil comme un autre.
+ Agent bool
+ // Internet = accès web actif (serveur Crawl4AI configuré + joignable) : ajoute
+ // les outils web_search/web_open/web_read/web_grep. Requiert aussi Agent.
+ Internet bool
+ // Mem = mode d'accès à la mémoire persistante (off / ondemand / always),
+ // indépendant du mode agent. Voir MemMode.
+ Mem MemMode
+}
+
+// globalCaps reads the machine-wide config — the default when a request doesn't
+// specify its own capabilities.
+func globalCaps() Caps {
+ // Internet inclut la joignabilité du serveur Crawl4AI : « actif ET fonctionnel ».
+ // Ainsi le prompt système (chat_tools.go) et les outils fournis (EnabledTools) sont
+ // gouvernés par la MÊME condition — sinon le prompt promet web_search alors que
+ // l'outil n'existe pas, et le modèle le tape en bash (command not found).
+ // Mémoire et accès internet sont des sous-réglages du mode agent (cf. UI web) :
+ // sans agent, on ne fournit NI les outils mem_*, NI les outils web. Ça garde le
+ // prompt système et les outils cohérents avec l'interface (blocs grisés quand
+ // l'agent est off) — l'IA en chat pur répond sans mémoire ni web.
+ agent := agentEnabled()
+ if !agent {
+ return Caps{Agent: false, Internet: false, Mem: MemOff}
+ }
+ return Caps{Agent: true, Internet: internetEnabled() && crawlReachable(), Mem: memMode()}
+}
+
+// InjectSkills prepends context system messages to msgs: the decisive-agent
+// preamble + machine briefing (when tools are enabled) and the lightweight
+// skills directory (when skills are enabled). Merges with an existing system
+// message if present.
+func InjectSkills(msgs []Message, caps Caps) []Message {
+ var parts []string
+ // Decisive-agent preamble (anti-loop) — only when the model actually has
+ // tools, otherwise it nudges a plain chat model to "call tools" it doesn't
+ // have, which leaks malformed tool-call text into the answer.
+ if bp := baseSystemPrompt(caps); bp != "" {
+ parts = append(parts, bp)
+ }
+ if mp := machineSystemPrompt(caps); mp != "" {
+ parts = append(parts, mp)
+ }
+ if len(parts) == 0 {
+ return msgs
+ }
+ prefix := strings.Join(parts, "\n\n")
+ if len(msgs) > 0 && msgs[0].Role == "system" {
+ existing, _ := msgs[0].Content.(string)
+ merged := append([]Message{{Role: "system", Content: prefix + "\n\n" + existing}}, msgs[1:]...)
+ return merged
+ }
+ return append([]Message{{Role: "system", Content: prefix}}, msgs...)
+}
+
+// EnabledTools returns the tools to advertise on the next inference call.
+func EnabledTools(caps Caps) []Tool {
+ tools := []Tool{}
+ if caps.Agent {
+ tools = append(tools, bashTool(), writeTool(), editTool())
+ }
+ // Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
+ // que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
+ if caps.Mem != MemOff {
+ tools = append(tools, memSearchTool(), memReadTool(), memAddTool(), memEditTool())
+ }
+ // Outils web : seulement si le mode agent ET l'accès internet sont actifs.
+ // caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
+ // donc prompt et outils restent cohérents — pas de web_search halluciné.
+ if caps.Agent && caps.Internet {
+ tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
+ }
+ // Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
+ // Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
+ // agent. La découverte est paresseuse et cachée (voir mcp_client.go).
+ if caps.Agent {
+ tools = append(tools, mcpTools()...)
+ }
+ // Postes distants : PAS de nouveaux outils. L'IA garde bash/write/edit ; c'est
+ // leur CIBLE D'EXÉCUTION qui change quand un poste est sélectionné (voir le
+ // routage dans la boucle d'outils et agentTargetSlug). Redonner des outils que
+ // le modèle a déjà (node__…__shell alors qu'il a bash) doublonnait le catalogue.
+ return tools
+}
+
+// StreamEvent is what a ChatCallback receives for each piece of streamed output.
+// Exactly one of {Content, Reasoning, ToolUsed, Stats, Err, DropReasoning} is set
+// per call.
+type StreamEvent struct {
+ Content string
+ Reasoning string
+ ToolUsed *ToolUsedEvent
+ Stats *StatsEvent
+ Err error
+ // DropReasoning demande à l'UI de retirer la dernière bulle de raisonnement :
+ // le modèle a « pensé sans agir » et on relance le tour, ce raisonnement-là
+ // est mort-né et ne doit pas rester à l'écran (sinon double raisonnement).
+ DropReasoning bool
+ // Compacting signale une compaction déclenchée EN COURS DE TOUR (boucle
+ // d'outils) : true à l'entrée du résumé, false à la sortie. Même bannière que
+ // la compaction proactive de début de tour, qui elle est émise directement par
+ // Conversation.generate. nil = l'événement ne parle pas de compaction.
+ Compacting *bool
+ // NewHistory publie la vue modèle APRÈS une compaction faite en cours de tour.
+ // Sans elle la compaction est perdue : l'appelant reconstruit l'historique en
+ // ajoutant les messages du tour à sa copie d'AVANT compaction, donc le fil
+ // complet revient et le tour suivant re-déborde aussitôt (43% → 92% en un
+ // message). Cette liste contient DÉJÀ tout le tour en cours : l'appelant doit
+ // REMPLACER son historique par elle (préfixe système injecté retiré), pas l'y
+ // ajouter.
+ NewHistory []Message
+}
+type ToolUsedEvent struct {
+ Name string
+ Label string // user-visible summary (skill name or the command)
+ Result string // tool output (stdout/stderr/exit for run_shell, skill body for read_skill)
+ Done bool // false = call announced (command only); true = result is ready
+ Typing bool // true = command still being written (partial), no spinner yet
+ // Body : contenu en cours d'écriture par un outil d'écriture (write/edit,
+ // mem_add/mem_edit), diffusé ligne à ligne pendant que le modèle le tape,
+ // pour que la bulle se remplisse en direct au lieu de rester figée puis de
+ // s'ouvrir d'un coup. Transitoire : seul Diff (état final) est rejoué.
+ Body string
+ // Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose
+ // (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-).
+ Diff []DiffLine
+}
+
+// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI.
+// Aligné sur le plus haut plafond côté modèle (mcpMaxOutput = 12000 ; shell et
+// web = 8000) : le modèle et l'UI voient donc la même chose, et l'étiquette
+// « ~N tok » de la bulle dit la VRAIE taille du résultat.
+//
+// Avant, cette borne était à 4000 : toute page web un peu longue s'affichait
+// « ~1004 tok » — la valeur du plafond, pas celle de la page. Le compteur
+// mentait, et il mentait toujours avec le même chiffre.
+const shownDisplayMax = 12000
+
+// shownResult prépare un résultat d'outil pour l'affichage.
+func shownResult(s string) string {
+ if r := []rune(s); len(r) > shownDisplayMax {
+ return string(r[:shownDisplayMax]) + "\n…[tronqué]"
+ }
+ return s
+}
+
+// repeatedCallResult construit ce qu'on renvoie quand le modèle redemande un
+// appel RIGOUREUSEMENT identique (même outil, mêmes arguments) dans le même tour.
+// L'appel n'est jamais rejoué — on répond depuis le résultat mémorisé.
+//
+// Le plafond d'itérations et l'anti-boucle ont été retirés en v0.6.3 parce qu'ils
+// coupaient des recherches légitimes ; il ne restait donc plus RIEN pour arrêter
+// un modèle qui redemande dix fois la même page. Et le mécanisme se retournait
+// contre lui-même : la note « déjà exécuté » était collée APRÈS le contenu, donc
+// noyée en fin d'un résultat de plusieurs milliers de caractères — le modèle
+// voyait le contenu, pas l'avertissement, et recommençait.
+//
+// D'où l'escalade : la note passe EN TÊTE, et à partir de la 2ᵉ redemande on ne
+// renvoie plus la charge utile du tout. Le tour n'est pas coupé (le modèle garde
+// la main), mais redemander la même chose ne rapporte plus rien — ni contenu, ni
+// contexte consommé.
+func repeatedCallResult(prev string, repeats int) string {
+ if repeats >= 2 {
+ return "[déjà fait] Cet appel exact a déjà été exécuté " + strconv.Itoa(repeats) +
+ " fois dans ce tour ; son résultat est plus haut dans la conversation. " +
+ "Ne le redemande plus : réponds avec ce que tu as, ou change d'approche " +
+ "(autre URL, autres arguments, web_grep pour cibler)."
+ }
+ return "[déjà fait] Appel identique déjà exécuté dans ce tour — non rejoué. " +
+ "Voici à nouveau son résultat ; ne le redemande pas une troisième fois.\n\n" + prev
+}
+
+// writeBodyKey returns the argument holding the text an écriture tool is about
+// to commit — the part worth showing live in the bubble — or "" for tools that
+// have no such body (bash, lectures, recherches).
+func writeBodyKey(tool string) string {
+ switch tool {
+ case "write", "mem_add":
+ return "content"
+ case "edit", "mem_edit":
+ return "new"
+ }
+ return ""
+}
+
+// previewArg pulls the (possibly incomplete) string value of key out of a
+// streaming tool-call arguments JSON, so the UI can show the command being
+// typed live. Best-effort: it tolerates a truncated tail and basic escapes.
+func previewArg(args, key string) string {
+ i := strings.Index(args, "\""+key+"\"")
+ if i < 0 {
+ return ""
+ }
+ rest := args[i+len(key)+2:]
+ if j := strings.Index(rest, ":"); j >= 0 {
+ rest = rest[j+1:]
+ } else {
+ return ""
+ }
+ q := strings.Index(rest, "\"")
+ if q < 0 {
+ return ""
+ }
+ rest = rest[q+1:]
+ var b strings.Builder
+ for x := 0; x < len(rest); x++ {
+ c := rest[x]
+ if c == '\\' && x+1 < len(rest) {
+ switch rest[x+1] {
+ case 'n':
+ b.WriteByte('\n')
+ case 't':
+ b.WriteByte('\t')
+ case 'r':
+ case '"':
+ b.WriteByte('"')
+ case '\\':
+ b.WriteByte('\\')
+ default:
+ b.WriteByte(rest[x+1])
+ }
+ x++
+ continue
+ }
+ if c == '"' {
+ break
+ }
+ b.WriteByte(c)
+ }
+ return b.String()
+}
+
+// StatsEvent carries llama.cpp's per-completion timing (final chunk).
+type StatsEvent struct {
+ PromptTokens int `json:"prompt_tokens,omitempty"`
+ PromptPerSecond float64 `json:"prompt_per_second,omitempty"`
+ PromptMs float64 `json:"prompt_ms,omitempty"`
+ GenTokens int `json:"gen_tokens,omitempty"`
+ GenPerSecond float64 `json:"gen_per_second,omitempty"`
+ GenMs float64 `json:"gen_ms,omitempty"`
+ // Taille TOTALE du prompt traité ce tour (préfixe caché compris), issue de
+ // `usage.prompt_tokens`. 0 si le backend ne renvoie pas d'usage.
+ PromptTokensTotal int `json:"prompt_tokens_total,omitempty"`
+}
+
+// ChatCallback receives stream events. Return false to abort the stream.
+type ChatCallback func(StreamEvent) bool
+
+// completionResp / streamChunk model the subset of llama.cpp's
+// OpenAI-compatible /v1/chat/completions response that we care about.
+type streamChunk struct {
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ ReasoningContent string `json:"reasoning_content"`
+ ToolCalls []ToolCall `json:"tool_calls"`
+ } `json:"delta"`
+ FinishReason string `json:"finish_reason"`
+ } `json:"choices"`
+ // llama.cpp's "timings" appears on the final chunk and on intermediate
+ // /completion endpoint responses. Snake-case mapping per llama.cpp source.
+ Timings *struct {
+ PromptN int `json:"prompt_n"`
+ PromptMs float64 `json:"prompt_ms"`
+ PromptPerSecond float64 `json:"prompt_per_second"`
+ PredictedN int `json:"predicted_n"`
+ PredictedMs float64 `json:"predicted_ms"`
+ PredictedPerSec float64 `json:"predicted_per_second"`
+ } `json:"timings"`
+ // Chunk final (include_usage) : taille totale du prompt, hors choices.
+ Usage *struct {
+ PromptTokens int `json:"prompt_tokens"`
+ CompletionTokens int `json:"completion_tokens"`
+ TotalTokens int `json:"total_tokens"`
+ } `json:"usage"`
+}
+
+// runChat drives the full inference loop including tool calling.
+// On finish_reason="tool_calls" we execute locally, append a "tool" message
+// and call /v1/chat/completions again — up to 8 iterations as a safety cap.
+const thinkClose = ""
+
+// runChat returns `extra`: the tool-turn messages (assistant-with-tool_calls and
+// their tool results) it appended during the agentic loop. The caller persists
+// these into the durable history BEFORE the final assistant text, so the model
+// keeps the trace of what it already read/ran across user turns — otherwise it
+// re-invokes the same skill/command every turn (it has no memory of having done
+// it) and can confabulate paths/results it can no longer see.
+// friendlyLLMError transforme une erreur de transport vers llama-server en un
+// message clair. Le cas le plus fréquent — « connection refused » (Linux) /
+// « actively refused » (Windows) — arrive quand le moteur redémarre ou charge
+// encore le modèle ; l'erreur brute (« dial tcp 127.0.0.1:8080… ») ne dit rien à
+// l'utilisateur. On garde le silence sur une annulation volontaire (/stop).
+// On classe d'abord sur les erreurs TYPÉES (errors.Is / net.Error) : la
+// reconnaissance par sous-chaîne dépend de la langue et du format des messages
+// de l'OS, et « connexion refusée » d'un Windows français ne ressemble à aucun
+// des motifs anglais. Les sous-chaînes restent en second rideau, pour les
+// erreurs enveloppées par une bibliothèque qui perd le type d'origine.
+func friendlyLLMError(err error) error {
+ if err == nil {
+ return nil
+ }
+ if errors.Is(err, context.Canceled) {
+ return err // /stop : pas d'alarme
+ }
+ switch {
+ case errors.Is(err, syscall.ECONNREFUSED):
+ return errEngineDown()
+ case errors.Is(err, context.DeadlineExceeded), isNetTimeout(err):
+ return fmt.Errorf("⚠️ Le moteur (llama-server) met trop de temps à répondre (port %d) — il est peut-être surchargé ou en plein chargement. Réessaie dans un instant.", LLMPort())
+ case errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF), errors.Is(err, syscall.ECONNRESET):
+ return errEngineReset()
+ }
+ low := strings.ToLower(err.Error())
+ switch {
+ case strings.Contains(low, "context canceled"):
+ return err
+ case strings.Contains(low, "connection refused"), strings.Contains(low, "actively refused"), strings.Contains(low, "connectex"), strings.Contains(low, "no connection could be made"):
+ return errEngineDown()
+ case strings.Contains(low, "timeout"), strings.Contains(low, "deadline exceeded"):
+ return fmt.Errorf("⚠️ Le moteur (llama-server) met trop de temps à répondre (port %d) — il est peut-être surchargé ou en plein chargement. Réessaie dans un instant.", LLMPort())
+ case strings.Contains(low, "eof"), strings.Contains(low, "connection reset"):
+ return errEngineReset()
+ }
+ return err
+}
+
+func errEngineDown() error {
+ return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort())
+}
+
+func errEngineReset() error {
+ return fmt.Errorf("⚠️ Connexion au moteur (llama-server, port %d) interrompue — il a peut-être redémarré. Réessaie.", LLMPort())
+}
+
+// streamCutError explique un flux de complétion coupé en cours de route. Cas à
+// part de friendlyLLMError : ici la requête avait ABOUTI (200 reçu, tokens déjà
+// reçus), c'est la lecture qui a lâché. Le dire autrement qu'un « le moteur ne
+// répond pas » évite d'envoyer l'utilisateur vérifier un moteur qui va bien.
+func streamCutError(err error) error {
+ if errors.Is(err, bufio.ErrTooLong) {
+ return fmt.Errorf("⚠️ Réponse du moteur illisible : une ligne du flux dépasse la taille maximale (%d Mio). C'est presque toujours un appel d'outil démesuré (écriture d'un très gros fichier). Le tour est abandonné pour ne pas exécuter un appel tronqué.", 8)
+ }
+ return fmt.Errorf("⚠️ Le flux de réponse du moteur (llama-server, port %d) a été coupé en cours de route : %v. La réponse est incomplète et le tour est abandonné — réessaie.", LLMPort(), err)
+}
+
+// isNetTimeout : une erreur réseau qui se déclare elle-même comme un délai
+// dépassé (net.Error.Timeout), quel que soit son libellé.
+func isNetTimeout(err error) bool {
+ var ne net.Error
+ return errors.As(err, &ne) && ne.Timeout()
+}
+
+func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) {
+ var extra []Message
+ tools := EnabledTools(caps)
+ // Some backends (vanilla llama.cpp builds) don't populate `reasoning_content`
+ // in streaming mode: the model's block (opened by the chat template)
+ // arrives inline in `content`, terminated by a literal . When
+ // reasoning is enabled we split that out ourselves so the UI's reasoning
+ // bubble works regardless of backend. The ik_llama.cpp fork already sends
+ // reasoning_content, in which case we leave content untouched.
+ reasoningOn := reasoningActive(ReadConfig()["REASONING"])
+ // When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
+ // retry the same turn once with tools removed so the model answers in plain
+ // text from the tool results already gathered, instead of dying mid-chat.
+ disableTools := false
+ // Filet réactif (façon Hermes) : si llama-server refuse le prompt (souvent un
+ // dépassement de la fenêtre de contexte après de gros résultats d'outils), on
+ // compacte l'historique en vol et on rejoue le tour — une seule fois.
+ compactedRetry := false
+ // Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas
+ // rejouer deux fois exactement la même écriture dans un même échange.
+ doneCalls := map[string]string{}
+ // Nombre de fois où le modèle a REDEMANDÉ un appel déjà exécuté. Sert à durcir
+ // la réponse progressivement (voir repeatedCallResult) : rendre le contenu une
+ // fois, puis refuser en le renvoyant vers ce qu'il a déjà.
+ repeatCount := map[string]int{}
+ // Garde-fou « pensé sans agir » : certains modèles à reasoning planifient un
+ // appel d'outil dans leur puis émettent le token de fin SANS l'émettre
+ // (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
+ // explicite au lieu d'afficher « pas de réponse ».
+ nudged := false
+ // Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours
+ // parfaitement légitimes (une recherche enchaîne facilement des dizaines
+ // d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule
+ // le contexte — c'est un choix assumé.
+ for iter := 0; ; iter++ {
+ payload := map[string]any{
+ "model": "ajean",
+ "messages": messages,
+ "stream": true,
+ "temperature": temperature,
+ // include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
+ // du prompt (préfixe caché compris), contrairement à timings.prompt_n qui
+ // ne compte que les tokens nouvellement traités. Sert au comptage exact du
+ // contexte (sinon le system prompt déjà en cache n'est pas recompté).
+ "stream_options": map[string]any{"include_usage": true},
+ }
+ if len(tools) > 0 && !disableTools {
+ payload["tools"] = tools
+ // The model sometimes emits parallel tool calls, which this llama.cpp
+ // build serialises as two concatenated JSON objects in one arguments
+ // string ("{...}{...}") and then fails to parse (HTTP 500). Forcing a
+ // single tool call per turn avoids that.
+ payload["parallel_tool_calls"] = false
+ }
+ body, _ := json.Marshal(payload)
+ url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
+ req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
+ if err != nil {
+ return extra, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ authHeader(req)
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ err = friendlyLLMError(err)
+ cb(StreamEvent{Err: err})
+ return extra, err
+ }
+ // A non-200 here (e.g. context window exceeded after several large tool
+ // outputs) is NOT valid SSE: without this check we'd scan an empty/HTML
+ // body, find no data lines, and return silently — the chat just stops
+ // with no answer. Surface the body so the cause is visible instead.
+ if resp.StatusCode != http.StatusOK {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 2000))
+ resp.Body.Close()
+ msg := strings.TrimSpace(string(b))
+ if msg == "" {
+ msg = resp.Status
+ }
+ // Le prompt a peut-être dépassé la fenêtre de contexte : on tente une
+ // compaction en vol et on rejoue le tour (une seule fois) avant tout le
+ // reste. C'est le filet de secours à la Hermes.
+ if compactEnabled() && !compactedRetry {
+ if c, changed := compactMessages(ctx, messages, caps); changed {
+ compactedRetry = true
+ // ⚠️ Journaliser AVANT d'installer le résultat : l'ancien ordre
+ // passait `messages` déjà remplacé comme état « avant », donc la
+ // ligne comparait le résultat à lui-même et n'apprenait rien.
+ logCompact("réactif", 0, messages, c, changed)
+ messages = c
+ // Même publication qu'en cours de tour : sans elle, la compaction de
+ // secours ne survit pas à la fin du tour et le prompt re-déborde au
+ // message suivant.
+ extra = nil
+ cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
+ continue
+ }
+ }
+ // Most common 500 here: llama.cpp couldn't parse a malformed tool call
+ // the model emitted. Retry the turn once without tools so it answers
+ // in plain text rather than leaving the chat dead.
+ if !disableTools && len(tools) > 0 {
+ disableTools = true
+ // Nudge the model to answer in plain text from what it already
+ // gathered, so it doesn't immediately re-emit a tool call that
+ // llama.cpp would again fail to parse.
+ messages = append(messages, Message{Role: "system", Content: "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues."})
+ continue
+ }
+ err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg)
+ cb(StreamEvent{Err: err})
+ return extra, err
+ }
+ toolCalls := map[int]*ToolCall{}
+ assistantContent := strings.Builder{}
+ finishReason := ""
+ // Accumulateur de stats : timings (prefill/decode) puis usage (total prompt)
+ // arrivent sur des chunks séparés ; on émet une copie complète à chaque MAJ
+ // pour que les consommateurs (terminal, web) aient toujours tout.
+ var stats StatsEvent
+ lastPreview := "" // last command preview emitted (to stream the typing)
+ lastBodyLines := -1 // lignes déjà diffusées du corps en cours d'écriture
+ // Per-completion reasoning-split state (see reasoningOn comment above).
+ sawReasoningField := false
+ thinkOpen := reasoningOn
+ var thinkTail strings.Builder
+ // Scanner à gros tampon : un chunk peut porter un gros JSON d'arguments
+ // (écriture de fichier). 8 Mio et non 1 : au-delà du tampon, le scanner
+ // s'arrête sur « token too long » AU MILIEU du flux, et jusqu'à la 0.8.4
+ // personne ne le voyait (voir sc.Err() plus bas).
+ sc := bufio.NewScanner(resp.Body)
+ sc.Buffer(make([]byte, 0, 64*1024), 8<<20)
+ aborted := false
+ for sc.Scan() {
+ line := strings.TrimSpace(sc.Text())
+ if !strings.HasPrefix(line, "data:") {
+ continue
+ }
+ data := strings.TrimSpace(line[5:])
+ if data == "" || data == "[DONE]" {
+ continue
+ }
+ var chunk streamChunk
+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
+ continue
+ }
+ // Timings ET usage (include_usage) arrivent sur le CHUNK FINAL qui, sur ce
+ // build llama.cpp (MTP/spéculatif), a `choices:[]` — on les traite AVANT le
+ // garde de choices, sinon `gen_tokens`/`gen_per_second` (decode) sont jetés
+ // et l'UI retombe à « 0 tok/s » à la fin de la génération.
+ if chunk.Timings != nil {
+ stats.PromptTokens = chunk.Timings.PromptN
+ stats.PromptPerSecond = chunk.Timings.PromptPerSecond
+ stats.PromptMs = chunk.Timings.PromptMs
+ stats.GenTokens = chunk.Timings.PredictedN
+ stats.GenPerSecond = chunk.Timings.PredictedPerSec
+ stats.GenMs = chunk.Timings.PredictedMs
+ s := stats
+ cb(StreamEvent{Stats: &s})
+ }
+ if chunk.Usage != nil && chunk.Usage.PromptTokens > 0 {
+ stats.PromptTokensTotal = chunk.Usage.PromptTokens
+ s := stats
+ cb(StreamEvent{Stats: &s})
+ }
+ if len(chunk.Choices) == 0 {
+ continue
+ }
+ ch := chunk.Choices[0]
+ if ch.FinishReason != "" {
+ finishReason = ch.FinishReason
+ }
+ if len(ch.Delta.ToolCalls) > 0 {
+ // Un appel d'outil clôt le texte : on vide MAINTENANT le reliquat
+ // retenu par la garde « » (voir plus bas). Sinon il n'était
+ // émis qu'en fin de flux, donc APRÈS l'événement d'outil, et l'UI
+ // (qui coupe la bulle en cours à chaque tool_used) affichait la fin
+ // de la phrase — souvent coupée en plein mot — dans une bulle
+ // séparée sous l'outil.
+ if thinkOpen && thinkTail.Len() > 0 {
+ tail := thinkTail.String()
+ thinkTail.Reset()
+ if !cb(StreamEvent{Content: tail}) {
+ aborted = true
+ break
+ }
+ }
+ for i, tc := range ch.Delta.ToolCalls {
+ // llama.cpp's stream may omit index; fall back to slot i.
+ idx := i
+ cur, ok := toolCalls[idx]
+ if !ok {
+ cur = &ToolCall{Type: "function"}
+ toolCalls[idx] = cur
+ }
+ if tc.ID != "" {
+ cur.ID = tc.ID
+ }
+ if tc.Function.Name != "" {
+ cur.Function.Name = tc.Function.Name
+ }
+ cur.Function.Arguments += tc.Function.Arguments
+ }
+ // Stream the command being typed: extract the partial value and
+ // emit it whenever it grows, so the UI shows it appear live.
+ if cur := toolCalls[0]; cur != nil {
+ key := "command"
+ switch cur.Function.Name {
+ case "mem_search", "web_search":
+ key = "query"
+ case "mem_read", "mem_add", "mem_edit", "edit", "write":
+ key = "file"
+ case "web_open", "web_read", "web_grep":
+ key = "url"
+ }
+ p := previewArg(cur.Function.Arguments, key)
+ // Corps en cours de frappe pour les outils d'écriture : on le diffuse
+ // à la LIGNE, pas au token. Un événement par token republierait tout le
+ // contenu à chaque fois (coût quadratique, et c'est ce flot qui saturait
+ // le rendu mobile) ; à la ligne, le nombre d'événements est celui du
+ // fichier et l'animation reste fluide.
+ body := ""
+ if bk := writeBodyKey(cur.Function.Name); bk != "" {
+ body = previewArg(cur.Function.Arguments, bk)
+ }
+ grew := body != "" && strings.Count(body, "\n") > lastBodyLines
+ if (p != "" && p != lastPreview) || grew {
+ if p != "" {
+ lastPreview = p
+ }
+ if grew {
+ lastBodyLines = strings.Count(body, "\n")
+ }
+ if !cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: cur.Function.Name, Label: lastPreview, Body: body, Typing: true}}) {
+ aborted = true
+ break
+ }
+ }
+ }
+ continue
+ }
+ if ch.Delta.ReasoningContent != "" {
+ // Backend already separates reasoning — trust it, disable our split.
+ sawReasoningField = true
+ thinkOpen = false
+ if !cb(StreamEvent{Reasoning: ch.Delta.ReasoningContent}) {
+ aborted = true
+ break
+ }
+ }
+ if ch.Delta.Content != "" {
+ assistantContent.WriteString(ch.Delta.Content)
+ if !thinkOpen || sawReasoningField {
+ if !cb(StreamEvent{Content: ch.Delta.Content}) {
+ aborted = true
+ break
+ }
+ } else {
+ // The prompt opened a block. Stream `content` LIVE as
+ // the answer, holding back only a short tail that could be the
+ // start of a literal "". A reasoning-aware backend
+ // (llama.cpp with --reasoning-format, Nathan's fork) strips the
+ // think tags server-side, so never appears in content
+ // and the whole answer streams straight through — including
+ // when the model answers WITHOUT thinking (no reasoning_content,
+ // no ), which is exactly what used to get dumped into
+ // the reasoning bubble. A vanilla build that leaves the thinking
+ // inline still gets carved at the below.
+ thinkTail.WriteString(ch.Delta.Content)
+ s := thinkTail.String()
+ if i := strings.Index(s, thinkClose); i >= 0 {
+ // Vanilla inline think: reasoning before , answer
+ // after. Route the reasoning to its bubble, drop the tag.
+ reason := s[:i]
+ after := strings.TrimLeft(s[i+len(thinkClose):], "\r\n")
+ thinkOpen = false
+ thinkTail.Reset()
+ if reason != "" && !cb(StreamEvent{Reasoning: reason}) {
+ aborted = true
+ break
+ }
+ if after != "" && !cb(StreamEvent{Content: after}) {
+ aborted = true
+ break
+ }
+ } else {
+ // No yet: stream as content, holding back a tail
+ // that could be a partial "". Back the cut up to a
+ // UTF-8 rune boundary so a multi-byte char (é, …) is never
+ // split — otherwise the two halves decode as � (mojibake).
+ cut := len(s) - (len(thinkClose) - 1)
+ for cut > 0 && !utf8.RuneStart(s[cut]) {
+ cut--
+ }
+ if cut > 0 {
+ emit := s[:cut]
+ thinkTail.Reset()
+ thinkTail.WriteString(s[cut:])
+ if !cb(StreamEvent{Content: emit}) {
+ aborted = true
+ break
+ }
+ }
+ }
+ }
+ }
+ }
+ // Flush the held-back tail (never part of a ): it's answer text.
+ if !aborted && thinkOpen && thinkTail.Len() > 0 {
+ cb(StreamEvent{Content: strings.TrimLeft(thinkTail.String(), "\r\n")})
+ }
+ // ⚠️ Le flux a-t-il fini, ou CASSÉ ? sc.Scan() renvoie false dans les deux
+ // cas, et l'erreur n'était jamais consultée : une lecture coupée en plein
+ // milieu (connexion réinitialisée, ligne plus longue que le tampon) était
+ // donc indiscernable d'une fin normale. Vécu par l'utilisateur : l'agent
+ // enchaîne quelques commandes puis « s'arrête et rend la main sans avoir
+ // terminé, ni même commenté », pendant que le journal de llama-server
+ // affiche un « stop processing » parfaitement normal (issue #19). Pire,
+ // des appels d'outils accumulés à moitié auraient été EXÉCUTÉS avec des
+ // arguments tronqués. On refuse donc le tour, en le disant.
+ scanErr := sc.Err()
+ resp.Body.Close()
+ if aborted {
+ return extra, nil
+ }
+ if scanErr != nil && ctx.Err() == nil {
+ err := streamCutError(scanErr)
+ cb(StreamEvent{Err: err})
+ return extra, err
+ }
+
+ // Treat any accumulated tool calls as a tool turn even if the backend set
+ // finish_reason to "stop" instead of "tool_calls" (some llama.cpp builds
+ // do this) — otherwise we'd skip execution AND skip answering.
+ if len(toolCalls) > 0 {
+ // 1. Append assistant message with tool_calls so the model sees its own decision next turn.
+ idxs := make([]int, 0, len(toolCalls))
+ for k := range toolCalls {
+ idxs = append(idxs, k)
+ }
+ sort.Ints(idxs)
+ tcs := make([]ToolCall, 0, len(idxs))
+ for i, k := range idxs {
+ tc := *toolCalls[k]
+ if tc.ID == "" {
+ tc.ID = fmt.Sprintf("call_%d_%d", iter, i)
+ }
+ // Les arguments DOIVENT être du JSON valide : ils sont rangés dans
+ // l'historique vu par le modèle, et le template de chat de llama.cpp les
+ // re-parse à CHAQUE requête suivante. Un modèle très quantifié peut sortir
+ // des arguments vides OU tronqués/non-JSON (ex: `{"command":"python3 …`) ;
+ // stockés tels quels, ils font échouer le parsing du template → 500 en
+ // boucle jusqu'au reset. On neutralise tout ce qui n'est pas du JSON valide
+ // en objet vide (l'appel a de toute façon déjà été exécuté). json.Valid("")
+ // étant faux, ça couvre aussi le cas vide d'origine.
+ if !json.Valid([]byte(tc.Function.Arguments)) {
+ tc.Function.Arguments = "{}"
+ }
+ tcs = append(tcs, tc)
+ }
+ assistant := Message{Role: "assistant", ToolCalls: tcs}
+ if s := assistantContent.String(); s != "" {
+ assistant.Content = s
+ }
+ messages = append(messages, assistant)
+ extra = append(extra, assistant)
+ // 2. Execute each tool locally and append a "tool" reply.
+ for _, tc := range tcs {
+ // Arrêt demandé : on n'enchaîne pas les outils restants. Sans ce
+ // garde, un stop pendant une série d'appels laissait défiler toute
+ // la série avant de reprendre la main.
+ if ctx.Err() != nil {
+ return extra, nil
+ }
+ var args map[string]any
+ _ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
+ // Derive the human label (command / skill name) up front so we can
+ // announce the call BEFORE running it — otherwise the UI shows
+ // nothing while a slow shell command runs and looks frozen.
+ label := ""
+ switch tc.Function.Name {
+ case "mem_search", "web_search":
+ label, _ = args["query"].(string)
+ case "mem_read", "mem_add", "mem_edit", "edit", "write":
+ label, _ = args["file"].(string)
+ case "bash":
+ label, _ = args["command"].(string)
+ case "web_open", "web_read":
+ label, _ = args["url"].(string)
+ case "web_grep":
+ u, _ := args["url"].(string)
+ p, _ := args["pattern"].(string)
+ label = p + " @ " + u
+ default:
+ // Outils MCP : libellé = un aperçu compact des arguments.
+ if isMCPTool(tc.Function.Name) {
+ label = mcpArgLabel(args)
+ }
+ }
+ cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
+
+ result := ""
+ // diff : rempli par les outils d'écriture (edit / mémoire) pour que
+ // l'UI montre les lignes ajoutées et retirées.
+ var diff []DiffLine
+ // Appel rigoureusement identique déjà exécuté dans ce tour : on ne le
+ // rejoue pas. Les petits modèles réémettent volontiers deux fois la
+ // même écriture ; la rejouer produisait une fausse erreur (« old
+ // introuvable », puisque le remplacement est déjà fait).
+ callKey := tc.Function.Name + "\x00" + tc.Function.Arguments
+ if prev, seen := doneCalls[callKey]; seen {
+ repeatCount[callKey]++
+ result = repeatedCallResult(prev, repeatCount[callKey])
+ cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true}})
+ toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
+ messages = append(messages, toolMsg)
+ extra = append(extra, toolMsg)
+ continue
+ }
+ switch tc.Function.Name {
+ case "mem_search":
+ lim := 0
+ if v, ok := args["limit"].(float64); ok {
+ lim = int(v)
+ }
+ hits := MemSearch(label, lim)
+ if len(hits) == 0 {
+ result = "[aucun résultat]"
+ } else {
+ var b strings.Builder
+ for _, h := range hits {
+ fmt.Fprintf(&b, "- %s — %s\n %s\n", h.File, h.Title, h.Snippet)
+ }
+ result = strings.TrimRight(b.String(), "\n")
+ }
+ case "mem_read":
+ off, lim := 0, 0
+ if v, ok := args["offset"].(float64); ok {
+ off = int(v)
+ }
+ if v, ok := args["limit"].(float64); ok {
+ lim = int(v)
+ }
+ if c, rerr := MemRead(label, off, lim); rerr != nil {
+ result = "[erreur] " + rerr.Error()
+ } else {
+ result = c
+ }
+ case "mem_add":
+ content, _ := args["content"].(string)
+ if werr := MemAdd(label, content); werr != nil {
+ result = "[erreur] " + werr.Error()
+ } else {
+ result = fmt.Sprintf("[ok] page '%s' créée", label)
+ diff = addedDiff(content)
+ }
+ case "mem_edit":
+ oldText, _ := args["old"].(string)
+ newText, _ := args["new"].(string)
+ if werr := MemEdit(label, oldText, newText); errors.Is(werr, errAlreadyApplied) {
+ result = fmt.Sprintf("[ok] page '%s' %s", label, werr.Error())
+ } else if werr != nil {
+ result = "[erreur] " + werr.Error()
+ } else {
+ result = fmt.Sprintf("[ok] page '%s' modifiée", label)
+ diff = lineDiff(oldText, newText)
+ }
+ case "write":
+ content, _ := args["content"].(string)
+ if tgt := agentTargetSlug(); tgt != "" {
+ // Cible = un poste distant : on écrit LÀ-BAS. Pas de diff (on
+ // n'a pas l'ancien contenu du fichier distant).
+ result = nodeCall(tgt, nodeCapWrite, map[string]any{"path": label, "content": content})
+ } else {
+ result = fileWrite(label, content)
+ if !strings.HasPrefix(result, "[erreur]") {
+ diff = addedDiff(content)
+ }
+ }
+ case "edit":
+ oldText, _ := args["old"].(string)
+ newText, _ := args["new"].(string)
+ if tgt := agentTargetSlug(); tgt != "" {
+ result = nodeEditRemote(tgt, label, oldText, newText)
+ } else {
+ result = fileEdit(label, oldText, newText)
+ // Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
+ if !strings.HasPrefix(result, "[erreur]") {
+ diff = lineDiff(oldText, newText)
+ }
+ }
+ case "bash":
+ to := 0
+ switch v := args["timeout"].(type) {
+ case float64:
+ to = int(v)
+ case int:
+ to = v
+ }
+ if tgt := agentTargetSlug(); tgt != "" {
+ // Cible = un poste distant : la commande s'exécute LÀ-BAS via le
+ // canal du poste (fail-closed : nodeCall renvoie une erreur si le
+ // poste est déconnecté, on n'exécute JAMAIS sur le serveur à sa place).
+ result = nodeCall(tgt, nodeCapShell, map[string]any{"command": label, "timeout": to})
+ } else {
+ result = runShell(ctx, label, to)
+ }
+ case "web_search":
+ result = capWebOutput(toolWebSearch(args))
+ case "web_open":
+ result = capWebOutput(toolWebOpen(args))
+ case "web_read":
+ result = capWebOutput(toolWebRead(args))
+ case "web_grep":
+ result = capWebOutput(toolWebGrep(args))
+ default:
+ if isMCPTool(tc.Function.Name) {
+ result = mcpCall(tc.Function.Name, args)
+ } else {
+ result = "[erreur] outil inconnu: " + tc.Function.Name
+ }
+ }
+ if !strings.HasPrefix(result, "[erreur]") {
+ doneCalls[callKey] = result
+ }
+ cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff}})
+ toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
+ messages = append(messages, toolMsg)
+ extra = append(extra, toolMsg)
+ }
+ // Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour :
+ // une boucle d'outils peut à elle seule remplir la fenêtre (résultats
+ // enchaînés), on partait à 60% et on finissait en dépassement — rattrapé au
+ // mieux par le filet réactif sur 500, une seule fois. On re-teste donc ici,
+ // avec le contexte RÉEL du dernier appel (usage.prompt_tokens + généré).
+ if used := stats.PromptTokensTotal + stats.GenTokens; compactWouldTrigger(messages, used) {
+ yes, no := true, false
+ cb(StreamEvent{Compacting: &yes})
+ c, changed := compactMessages(ctx, messages, caps)
+ cb(StreamEvent{Compacting: &no})
+ logCompact("en-tour", used, messages, c, changed)
+ if changed {
+ messages = c
+ // La nouvelle base contient déjà tout ce tour : on la publie et on
+ // repart d'un `extra` vide, sinon l'appelant la ré-empilerait avec
+ // les messages du tour et dupliquerait tout.
+ extra = nil
+ cb(StreamEvent{NewHistory: append([]Message(nil), messages...)})
+ }
+ }
+ continue
+ }
+ // Normal end of turn. If the model produced no visible answer at all
+ // (empty content, e.g. it stopped right after a tool result), say so
+ // instead of leaving the user staring at a silent, finished chat.
+ if strings.TrimSpace(assistantContent.String()) == "" {
+ // Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) :
+ // si un modèle « pense sans agir » malgré tout, on le relance UNE fois avec
+ // une consigne impérative au lieu d'afficher « pas de réponse ».
+ if len(tools) > 0 && !disableTools && !nudged {
+ nudged = true
+ // Le raisonnement de ce tour avorté ne mène à rien : on demande à
+ // l'UI de l'effacer avant de relancer, pour ne pas afficher deux
+ // blocs de réflexion successifs.
+ cb(StreamEvent{DropReasoning: true})
+ messages = append(messages, Message{
+ Role: "user",
+ Content: "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.",
+ })
+ continue
+ }
+ cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"})
+ }
+ return extra, nil
+ }
+}
+
+// healthClient : /health doit répondre tout de suite ou pas du tout. Sans
+// timeout (http.Get et son client par défaut n'en ont aucun), un moteur qui
+// accepte la connexion sans jamais répondre — cas classique d'un très gros
+// modèle en cours de chargement, ou d'un process figé — bloquait healthCheck
+// indéfiniment. Et comme StartTurn commence par là, /api/chat/send restait
+// pendu : l'utilisateur voyait un bouton d'envoi qui ne rendait jamais la main.
+var healthClient = &http.Client{Timeout: 3 * time.Second}
+
+// healthCheck pings llama.cpp's /health endpoint.
+func healthCheck() bool {
+ resp, err := healthClient.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort()))
+ if err != nil {
+ return false
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, resp.Body)
+ return resp.StatusCode == 200
+}
diff --git a/internal/ajean/llm_oai.go b/internal/ajean/llm_oai.go
new file mode 100644
index 0000000..a018cce
--- /dev/null
+++ b/internal/ajean/llm_oai.go
@@ -0,0 +1,247 @@
+package ajean
+
+import (
+ "bufio"
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "fmt"
+ "math/big"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/caddyserver/certmagic"
+)
+
+// llm_oai.go — front TLS de l'accès OpenAI public "VPS aveugle" (SNI passthrough).
+//
+// Principe : le SaaS parle HTTPS OpenAI standard vers .oai.ajean.link.
+// Le VPS relais ne fait que recopier les octets TLS bruts (routage par SNI, sans
+// déchiffrer). C'est ICI, sur l'agent, que le TLS est terminé — avec un cert dont
+// la clé privée ne quitte JAMAIS cette machine — puis proxifié vers llama-server
+// local (/v1). Un attaquant qui possède le VPS ne voit donc que du chiffré.
+//
+// Certificat : Let's Encrypt via challenge TLS-ALPN-01 servi À TRAVERS LE TUNNEL
+// (l'agent est derrière CGNAT, mais le VPS forwarde la validation jusqu'à lui).
+// Aucun secret DNS nulle part : le seul DNS est un wildcard *.oai.ajean.link
+// statique posé une fois par l'opérateur.
+
+// oaiSuffix est le domaine sous lequel on autorise l'émission de certificats.
+const oaiSuffix = ".oai.ajean.link"
+
+// oaiHandler construit le reverse-proxy vers llama-server, restreint à la surface
+// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le SaaS envoie
+// la vraie clé (.api_key), que llama-server valide lui-même (--api-key).
+func oaiHandler() http.Handler {
+ llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}
+ lp := httputil.NewSingleHostReverseProxy(llama)
+ lp.FlushInterval = -1 // streaming SSE des complétions
+ lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
+ http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway)
+ }
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ p := r.URL.Path
+ if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") {
+ lp.ServeHTTP(w, r)
+ return
+ }
+ http.Error(w, "not found (endpoint OpenAI: /v1/*)", http.StatusNotFound)
+ })
+}
+
+// runOAIFront termine le TLS sur rawLn (avec tlsCfg) puis sert oaiHandler dessus.
+// rawLn peut être un vrai listener TCP (test local) ou un listener alimenté par
+// les streams "raw" du tunnel (prod). Bloquant.
+func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
+ srv := &http.Server{
+ Handler: oaiHandler(),
+ IdleTimeout: 120 * time.Second,
+ // completions longues : pas de Read/Write timeout.
+ }
+ return srv.Serve(tls.NewListener(rawLn, tlsCfg))
+}
+
+// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette
+// machine. Piloté par l'UI et lu en direct → activable/coupable sans redémarrer
+// le service de lien.
+func oaiPublicEnabled() bool { return getBool(bkState, "oai_public") }
+
+// setOAIPublic active (on) ou coupe (off) l'accès OpenAI public.
+func setOAIPublic(on bool) error { return putBool(bkState, "oai_public", on) }
+
+// oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via
+// Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et
+// répond elle-même aux challenges ACME. La clé privée est stockée dans
+// $AJEAN_HOME/certs et ne quitte jamais la machine. Toujours construite ; c'est le
+// démux (oaiPublicEnabled, lu en direct) qui décide de router ou non le trafic.
+func oaiTLSConfig() *tls.Config {
+ certmagic.Default.Storage = &certmagic.FileStorage{Path: filepath.Join(AjeanHome(), "certs")}
+ certmagic.DefaultACME.Agreed = true
+ certmagic.DefaultACME.Email = os.Getenv("AJEAN_ACME_EMAIL")
+ certmagic.DefaultACME.DisableHTTPChallenge = true // pas de :80 accessible (CGNAT) → TLS-ALPN uniquement
+ magic := certmagic.NewDefault()
+ magic.OnDemand = &certmagic.OnDemandConfig{
+ DecisionFunc: func(_ context.Context, name string) error {
+ if strings.HasSuffix(name, oaiSuffix) {
+ return nil
+ }
+ return fmt.Errorf("nom non autorisé pour l'accès OpenAI: %s", name)
+ },
+ }
+ cfg := magic.TLSConfig() // GetCertificate (on-demand) + gère l'ALPN acme-tls/1
+ // certmagic ne met QUE "acme-tls/1" dans NextProtos ; sans "http/1.1" les
+ // clients normaux sont rejetés (« unsupported application protocols »). On
+ // préfixe http/1.1 tout en gardant acme-tls/1 pour les challenges.
+ cfg.NextProtos = append([]string{"http/1.1"}, cfg.NextProtos...)
+ cfg.MinVersion = tls.VersionTLS12
+ return cfg
+}
+
+// --- démultiplexeur du tunnel ------------------------------------------------
+// Le relais ouvre soit un stream HTTP normal (UI / E2E), soit un stream "brut"
+// qui porte une session TLS de bout en bout (accès OpenAI). On les distingue au
+// 1er octet : un enregistrement TLS commence par 0x16 (handshake), une requête
+// HTTP par une lettre ASCII (GET/POST/…). Voir demuxTunnelStream dans relay_link.go.
+
+// peekedConn rend un net.Conn dont on a déjà consulté le début, sans perdre ces
+// octets (ils restent dans le bufio.Reader).
+type peekedConn struct {
+ net.Conn
+ r *bufio.Reader
+}
+
+func (p *peekedConn) Read(b []byte) (int, error) { return p.r.Read(b) }
+
+// chanListener est un net.Listener alimenté à la main (push), pour injecter dans
+// http.Server / tls.NewListener des conns déjà acceptées ailleurs (les streams
+// démultiplexés du tunnel).
+type chanListener struct {
+ ch chan net.Conn
+ done chan struct{}
+ addr net.Addr
+}
+
+func newChanListener(addr net.Addr) *chanListener {
+ return &chanListener{ch: make(chan net.Conn), done: make(chan struct{}), addr: addr}
+}
+
+func (l *chanListener) push(c net.Conn) {
+ select {
+ case l.ch <- c:
+ case <-l.done:
+ c.Close()
+ }
+}
+
+func (l *chanListener) Accept() (net.Conn, error) {
+ select {
+ case c := <-l.ch:
+ return c, nil
+ case <-l.done:
+ return nil, net.ErrClosed
+ }
+}
+
+func (l *chanListener) Close() error {
+ select {
+ case <-l.done:
+ default:
+ close(l.done)
+ }
+ return nil
+}
+
+func (l *chanListener) Addr() net.Addr {
+ if l.addr != nil {
+ return l.addr
+ }
+ return dummyAddr{}
+}
+
+type dummyAddr struct{}
+
+func (dummyAddr) Network() string { return "tunnel" }
+func (dummyAddr) String() string { return "tunnel" }
+
+// selfSignedTLSConfig fabrique un *tls.Config auto-signé pour host. Tests locaux
+// uniquement (curl -k) avant de brancher Let's Encrypt.
+func selfSignedTLSConfig(host string) (*tls.Config, error) {
+ priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return nil, err
+ }
+ serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
+ tmpl := x509.Certificate{
+ SerialNumber: serial,
+ Subject: pkix.Name{CommonName: host},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().AddDate(1, 0, 0),
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ tmpl.IPAddresses = []net.IP{ip}
+ } else {
+ tmpl.DNSNames = []string{host}
+ }
+ der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
+ if err != nil {
+ return nil, err
+ }
+ cert := tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}
+ return &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12}, nil
+}
+
+// cmdOAI pilote l'accès OpenAI public côté agent.
+//
+// ajean oai serve [port] [host] (test local) termine le TLS sur :port avec un
+// cert auto-signé et proxifie vers llama /v1.
+func cmdOAI(args []string) error {
+ sub := ""
+ if len(args) > 0 {
+ sub = args[0]
+ args = args[1:]
+ }
+ switch sub {
+ case "serve":
+ port := 8443
+ 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
+ }
+ host := "localhost"
+ if len(args) > 1 && args[1] != "" {
+ host = args[1]
+ }
+ tlsCfg, err := selfSignedTLSConfig(host)
+ if err != nil {
+ return err
+ }
+ ln, err := net.Listen("tcp", fmt.Sprintf("0.0.0.0:%d", port))
+ if err != nil {
+ return err
+ }
+ fmt.Printf("[ajean oai] front TLS (test, auto-signé) https://%s:%d/v1 → llama :%d\n", host, port, LLMPort())
+ return runOAIFront(ln, tlsCfg)
+ default:
+ fmt.Println("usage: ajean oai serve [port] [host] (front TLS de test → llama /v1)")
+ fmt.Println(" en prod, le front TLS est servi automatiquement dans le tunnel (ajean link)")
+ fmt.Println(" quand AJEAN_LINK_ALLOW_OAI=1 ; cert Let's Encrypt via TLS-ALPN-01.")
+ return nil
+ }
+}
diff --git a/internal/ajean/llm_repeat_test.go b/internal/ajean/llm_repeat_test.go
new file mode 100644
index 0000000..b9b731a
--- /dev/null
+++ b/internal/ajean/llm_repeat_test.go
@@ -0,0 +1,31 @@
+package ajean
+
+import (
+ "strings"
+ "testing"
+)
+
+// Un appel d'outil redemandé à l'identique ne doit rendre son contenu qu'UNE
+// fois, et l'avertissement doit être en TÊTE (collé après un résultat de
+// plusieurs milliers de caractères, il passait inaperçu et le modèle rebouclait).
+func TestRepeatedCallResultEscalates(t *testing.T) {
+ page := strings.Repeat("contenu de la page ", 300)
+
+ first := repeatedCallResult(page, 1)
+ if !strings.HasPrefix(first, "[déjà fait]") {
+ t.Fatalf("1re redemande : l'avertissement doit ouvrir le résultat, obtenu %.40q", first)
+ }
+ if !strings.Contains(first, page) {
+ t.Fatal("1re redemande : le contenu doit encore être rendu")
+ }
+
+ for _, n := range []int{2, 3, 7} {
+ again := repeatedCallResult(page, n)
+ if strings.Contains(again, page) {
+ t.Fatalf("redemande n°%d : le contenu ne doit plus être renvoyé", n)
+ }
+ if !strings.HasPrefix(again, "[déjà fait]") {
+ t.Fatalf("redemande n°%d : avertissement manquant", n)
+ }
+ }
+}
diff --git a/internal/ajean/llm_stream_cut_test.go b/internal/ajean/llm_stream_cut_test.go
new file mode 100644
index 0000000..71436b7
--- /dev/null
+++ b/internal/ajean/llm_stream_cut_test.go
@@ -0,0 +1,139 @@
+package ajean
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+// sseServer sert un flux de complétion puis le COUPE brutalement (fermeture de
+// la connexion sans terminer la réponse), comme le ferait un moteur qui perd sa
+// connexion en plein milieu. Renvoie le port à mettre dans PORT.
+func sseCuttingServer(t *testing.T, body string, cut bool) string {
+ t.Helper()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.WriteHeader(200)
+ _, _ = w.Write([]byte(body))
+ w.(http.Flusher).Flush()
+ if !cut {
+ return
+ }
+ // Coupe la connexion sans fin de réponse propre : côté client, la lecture
+ // du corps échoue (« unexpected EOF ») au lieu de se terminer.
+ hj, ok := w.(http.Hijacker)
+ if !ok {
+ t.Error("serveur de test non détournable")
+ return
+ }
+ conn, _, err := hj.Hijack()
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ _ = conn.Close()
+ }))
+ t.Cleanup(srv.Close)
+ u, err := url.Parse(srv.URL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return u.Port()
+}
+
+// chunk fabrique une ligne SSE de contenu.
+func sseChunk(text string) string {
+ return `data: {"choices":[{"delta":{"content":"` + text + `"}}]}` + "\n\n"
+}
+
+// Le cœur de l'issue #19 : llama-server termine normalement de son côté (« stop
+// processing » dans son journal), mais la lecture du flux casse. Avant, le tour
+// s'arrêtait EN SILENCE — l'agent « rendait la main sans avoir terminé, ni même
+// commenté ». Il doit maintenant remonter une erreur explicite.
+func TestFluxCoupeRemonteUneErreur(t *testing.T) {
+ testHome(t)
+ // Réponse annoncée sur 3 chunks mais coupée après le premier, sans [DONE].
+ port := sseCuttingServer(t, sseChunk("début de r"), true)
+ if err := SetConfigKey("PORT", port); err != nil {
+ t.Fatal(err)
+ }
+
+ var gotErr error
+ var content strings.Builder
+ _, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
+ switch {
+ case ev.Err != nil:
+ gotErr = ev.Err
+ case ev.Content != "":
+ content.WriteString(ev.Content)
+ }
+ return true
+ })
+ if err == nil {
+ t.Fatal("flux coupé : runChat a rendu la main SANS erreur (le tour s'arrêtait en silence)")
+ }
+ if gotErr == nil {
+ t.Fatal("flux coupé : aucune erreur poussée vers l'interface")
+ }
+ if !strings.Contains(err.Error(), "coupé") {
+ t.Errorf("message peu clair pour l'utilisateur : %v", err)
+ }
+ // Le texte déjà reçu n'est pas jeté : on a bien affiché ce qui était arrivé.
+ if !strings.Contains(content.String(), "début de r") {
+ t.Errorf("le texte reçu avant la coupure a été perdu : %q", content.String())
+ }
+}
+
+// Contre-épreuve : un flux qui se termine PROPREMENT ne doit évidemment pas
+// déclencher l'erreur, sans quoi chaque réponse normale se plaindrait.
+func TestFluxCompletNeRemonteRien(t *testing.T) {
+ testHome(t)
+ port := sseCuttingServer(t, sseChunk("réponse complète")+"data: [DONE]\n\n", false)
+ if err := SetConfigKey("PORT", port); err != nil {
+ t.Fatal(err)
+ }
+ var gotErr error
+ var content strings.Builder
+ _, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
+ if ev.Err != nil {
+ gotErr = ev.Err
+ }
+ if ev.Content != "" {
+ content.WriteString(ev.Content)
+ }
+ return true
+ })
+ if err != nil || gotErr != nil {
+ t.Fatalf("flux normal signalé en erreur : %v / %v", err, gotErr)
+ }
+ if content.String() != "réponse complète" {
+ t.Fatalf("contenu = %q", content.String())
+ }
+}
+
+// Un arrêt demandé par l'utilisateur (/stop) coupe aussi la lecture : il ne doit
+// PAS se déguiser en panne du moteur.
+func TestFluxAnnuleResteSilencieux(t *testing.T) {
+ testHome(t)
+ port := sseCuttingServer(t, sseChunk("a"), true)
+ if err := SetConfigKey("PORT", port); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ var gotErr error
+ _, _ = runChat(ctx, []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
+ if ev.Content != "" {
+ cancel() // l'utilisateur clique sur stop dès le premier morceau
+ }
+ if ev.Err != nil {
+ gotErr = ev.Err
+ }
+ return true
+ })
+ if gotErr != nil && strings.Contains(gotErr.Error(), "coupé") {
+ t.Fatalf("un stop volontaire a été présenté comme une panne : %v", gotErr)
+ }
+}
diff --git a/internal/ajean/llm_testcmd.go b/internal/ajean/llm_testcmd.go
new file mode 100644
index 0000000..081d2bf
--- /dev/null
+++ b/internal/ajean/llm_testcmd.go
@@ -0,0 +1,66 @@
+package ajean
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// cmdTest sanity-checks the LLM end-to-end: HTTP /health, then a minimal chat
+// completion to confirm the model actually generates tokens.
+func cmdTest(args []string) error {
+ port := LLMPort()
+ fmt.Printf("→ GET http://localhost:%d/health … ", port)
+ if !healthCheck() {
+ fmt.Println(red("ko"))
+ return fmt.Errorf("/health ne répond pas — ajean start d'abord")
+ }
+ fmt.Println(green("ok"))
+
+ fmt.Printf("→ chat completion (prompt « ping ») … ")
+ msgs := []Message{{Role: "user", Content: "Réponds juste « pong ». Rien d'autre."}}
+ var reply strings.Builder
+ t0 := time.Now()
+ var firstTok time.Time
+ tokens := 0
+ _, err := runChat(context.Background(), msgs, 0, Caps{}, func(ev StreamEvent) bool {
+ if ev.Content != "" {
+ if firstTok.IsZero() {
+ firstTok = time.Now()
+ }
+ tokens++
+ reply.WriteString(ev.Content)
+ }
+ return true
+ })
+ if err != nil {
+ fmt.Println(red("ko"))
+ return err
+ }
+ elapsed := time.Since(t0)
+ ttft := time.Duration(0)
+ if !firstTok.IsZero() {
+ ttft = firstTok.Sub(t0)
+ }
+ fmt.Println(green("ok"))
+
+ out := strings.TrimSpace(reply.String())
+ if len(out) > 120 {
+ out = out[:120] + "…"
+ }
+ fmt.Printf("\n %s %s\n", cyan("réponse :"), out)
+ fmt.Printf(" %s %s\n", cyan("ttft :"), ttft.Round(time.Millisecond))
+ fmt.Printf(" %s %s (%d tokens)\n", cyan("total :"), elapsed.Round(time.Millisecond), tokens)
+ if elapsed > 0 {
+ tps := float64(tokens) / (elapsed - ttft).Seconds()
+ if tokens > 0 && elapsed > ttft {
+ fmt.Printf(" %s %.1f tok/s\n", cyan("decode :"), tps)
+ }
+ }
+ if tokens == 0 {
+ return fmt.Errorf("aucun token généré")
+ }
+ fmt.Println("\n" + green("[ok]") + " l'IA répond")
+ return nil
+}
diff --git a/internal/ajean/mcp_client.go b/internal/ajean/mcp_client.go
new file mode 100644
index 0000000..7fcdf02
--- /dev/null
+++ b/internal/ajean/mcp_client.go
@@ -0,0 +1,511 @@
+package ajean
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "os/exec"
+ "regexp"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
+)
+
+// Pool de sessions MCP + exposition des outils au moteur de chat.
+//
+// Une session est gardée ouverte entre les tours de chat : un serveur stdio garde
+// son process vivant d'un message à l'autre — c'est voulu, desktop-commander & co
+// ont un coût de démarrage non négligeable.
+//
+// Les connexions sont établies EN PARALLÈLE (mcpEnsureAll) : en séquence, quatre
+// serveurs `npx` faisaient attendre la somme de leurs démarrages (~20 s), là où
+// les autres clients MCP paient le plus lent. Elles sont aussi pré-chauffées au
+// démarrage du service web/link (MCPPrewarm) pour que le premier message ne paie
+// pas le handshake.
+//
+// Le namespacing des outils suit la convention de l'écosystème :
+// mcp____. On maintient un registre nom-exposé → (serveur, outil)
+// pour router les appels sans avoir à re-parser des noms potentiellement
+// ambigus.
+
+const (
+ // mcpConnectTimeout borne l'établissement d'une session (handshake initialize
+ // + tools/list). Un serveur qui rame ne doit pas figer le tour de chat.
+ mcpConnectTimeout = 20 * time.Second
+ // mcpCallTimeout borne un appel d'outil MCP.
+ mcpCallTimeout = 120 * time.Second
+ // mcpMaxOutput cap la sortie renvoyée au modèle (cohérent avec toolMaxOutput).
+ mcpMaxOutput = 12000
+)
+
+// mcpSession est une connexion vivante à un serveur MCP.
+//
+// L'entrée est publiée dans le pool AVANT que la connexion aboutisse, pour qu'un
+// second appelant se mette en attente au lieu de lancer un deuxième process. Les
+// champs sess/tools/err ne doivent donc être lus qu'une fois `ready` fermé.
+type mcpSession struct {
+ name string
+ cfg MCPServerConfig
+ sess *mcpsdk.ClientSession
+ tools []*mcpsdk.Tool
+ err error // dernière erreur de connexion/liste, pour l'UI
+ ready chan struct{} // fermé quand la tentative de connexion est terminée
+}
+
+// mcpManager détient le pool de sessions.
+type mcpManager struct {
+ mu sync.Mutex
+ sessions map[string]*mcpSession
+ // registry mappe un nom d'outil exposé au modèle → (serveur, outil réel).
+ registry map[string]mcpToolRef
+}
+
+type mcpToolRef struct {
+ server string
+ tool string
+}
+
+var mcpMgr = &mcpManager{
+ sessions: map[string]*mcpSession{},
+ registry: map[string]mcpToolRef{},
+}
+
+// mcpInvalidate ferme et oublie la session d'un serveur (après un changement de
+// config), pour qu'elle soit reconstruite avec la nouvelle config au prochain
+// usage.
+func mcpInvalidate(name string) {
+ mcpMgr.mu.Lock()
+ s := mcpMgr.sessions[name]
+ delete(mcpMgr.sessions, name)
+ // Purge le registre des outils de ce serveur.
+ for exposed, ref := range mcpMgr.registry {
+ if ref.server == name {
+ delete(mcpMgr.registry, exposed)
+ }
+ }
+ mcpMgr.mu.Unlock()
+ // Une session encore en cours de connexion a sess == nil : c'est sa goroutine
+ // qui refermera ce qu'elle vient d'ouvrir, en constatant qu'elle n'est plus
+ // dans le pool.
+ if s != nil && s.sess != nil {
+ _ = s.sess.Close()
+ }
+}
+
+// mcpCloseAll ferme toutes les sessions (arrêt du service).
+func mcpCloseAll() {
+ mcpMgr.mu.Lock()
+ sessions := mcpMgr.sessions
+ mcpMgr.sessions = map[string]*mcpSession{}
+ mcpMgr.registry = map[string]mcpToolRef{}
+ mcpMgr.mu.Unlock()
+ for _, s := range sessions {
+ if s.sess != nil {
+ _ = s.sess.Close()
+ }
+ }
+}
+
+// headerRoundTripper injecte des en-têtes statiques (auth) sur chaque requête
+// HTTP vers un serveur MCP distant.
+type headerRoundTripper struct {
+ base http.RoundTripper
+ headers map[string]string
+}
+
+func (h headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ for k, v := range h.headers {
+ req.Header.Set(k, v)
+ }
+ return h.base.RoundTrip(req)
+}
+
+// connect établit une session pour la config donnée.
+func mcpConnect(ctx context.Context, name string, cfg MCPServerConfig) (*mcpsdk.ClientSession, error) {
+ client := mcpsdk.NewClient(&mcpsdk.Implementation{Name: "ajean", Version: Version}, nil)
+
+ var transport mcpsdk.Transport
+ switch cfg.Transport() {
+ case "stdio":
+ cmd := hideCmd(exec.Command(cfg.Command, cfg.Args...)) // pas de flash console (mode app Windows)
+ // Hérite de l'environnement du service + surcharges déclarées.
+ cmd.Env = os.Environ()
+ for k, v := range cfg.Env {
+ cmd.Env = append(cmd.Env, k+"="+v)
+ }
+ transport = &mcpsdk.CommandTransport{Command: cmd}
+ case "http":
+ httpClient := &http.Client{}
+ if len(cfg.Headers) > 0 {
+ httpClient.Transport = headerRoundTripper{base: http.DefaultTransport, headers: cfg.Headers}
+ }
+ transport = &mcpsdk.StreamableClientTransport{Endpoint: cfg.URL, HTTPClient: httpClient}
+ default:
+ return nil, fmt.Errorf("serveur MCP '%s' mal configuré (ni command ni url)", name)
+ }
+
+ sess, err := client.Connect(ctx, transport, nil)
+ if err != nil {
+ return nil, err
+ }
+ return sess, nil
+}
+
+// ensure renvoie une session vivante pour un serveur activé, en la (re)créant si
+// besoin. Doit être appelé sans détenir mgr.mu (il gère le lock lui-même).
+//
+// L'entrée est réservée dans le pool avant la connexion : deux appelants
+// simultanés (le pré-chauffage et un tour de chat, par exemple) partagent la même
+// tentative au lieu de lancer deux process stdio.
+func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession {
+ m.mu.Lock()
+ if s, ok := m.sessions[name]; ok {
+ m.mu.Unlock()
+ <-s.ready // connexion peut-être encore en cours : on attend son issue
+ return s
+ }
+ s := &mcpSession{name: name, cfg: cfg, ready: make(chan struct{})}
+ m.sessions[name] = s
+ m.mu.Unlock()
+
+ // Connexion hors-lock (peut être lente).
+ ctx, cancel := context.WithTimeout(context.Background(), mcpConnectTimeout)
+ defer cancel()
+ sess, err := mcpConnect(ctx, name, cfg)
+ if err != nil {
+ s.err = err
+ } else {
+ s.sess = sess
+ lt, lerr := sess.ListTools(ctx, nil)
+ if lerr != nil {
+ s.err = lerr
+ } else {
+ s.tools = lt.Tools
+ }
+ }
+
+ m.mu.Lock()
+ // La session a pu être invalidée pendant la connexion (changement de config,
+ // arrêt du service) : dans ce cas on ne la republie pas, on referme.
+ stale := m.sessions[name] != s
+ if !stale {
+ // (Re)peuple le registre pour ce serveur.
+ for exposed, ref := range m.registry {
+ if ref.server == name {
+ delete(m.registry, exposed)
+ }
+ }
+ for _, t := range s.tools {
+ m.registry[mcpExposedName(name, t.Name)] = mcpToolRef{server: name, tool: t.Name}
+ }
+ }
+ m.mu.Unlock()
+ close(s.ready)
+ if stale && s.sess != nil {
+ _ = s.sess.Close()
+ s.sess = nil
+ }
+ return s
+}
+
+// mcpEnsureAll connecte en parallèle tous les serveurs activés et attend qu'ils
+// soient tous fixés (connectés ou en erreur). C'est ce qui évite d'additionner
+// les temps de démarrage : le coût total est celui du serveur le plus lent.
+func mcpEnsureAll(servers map[string]MCPServerConfig) {
+ var wg sync.WaitGroup
+ for _, name := range sortedServerNames(servers) {
+ cfg := servers[name]
+ if !cfg.Enabled || cfg.Transport() == "" {
+ continue
+ }
+ wg.Add(1)
+ go func(name string, cfg MCPServerConfig) {
+ defer wg.Done()
+ mcpMgr.ensure(name, cfg)
+ }(name, cfg)
+ }
+ wg.Wait()
+}
+
+// MCPPrewarm établit les sessions MCP en tâche de fond, au démarrage du service
+// web/link. Sans ça le handshake était payé par le premier message de chat, ce
+// qui donnait l'impression d'une IA très lente à démarrer. Sans mode agent les
+// outils MCP ne sont de toute façon pas exposés : inutile de lancer les process.
+func MCPPrewarm() {
+ if !agentEnabled() {
+ return
+ }
+ servers, err := LoadMCPConfig()
+ if err != nil || len(servers) == 0 {
+ return
+ }
+ go mcpEnsureAll(servers)
+}
+
+var mcpSanitizeRe = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
+
+// mcpSanitize rend une chaîne compatible avec les noms d'outils (^[A-Za-z0-9_-]+$).
+func mcpSanitize(s string) string {
+ s = mcpSanitizeRe.ReplaceAllString(s, "_")
+ return strings.Trim(s, "_")
+}
+
+// mcpExposedName construit le nom d'outil vu par le modèle.
+func mcpExposedName(server, tool string) string {
+ return "mcp__" + mcpSanitize(server) + "__" + mcpSanitize(tool)
+}
+
+// mcpTools connecte les serveurs activés et renvoie leurs outils, prêts à être
+// annoncés au modèle. Réservé au mode agent (appelé par EnabledTools).
+func mcpTools() []Tool {
+ servers, err := LoadMCPConfig()
+ if err != nil || len(servers) == 0 {
+ return nil
+ }
+ mcpEnsureAll(servers) // connexions en parallèle, puis lecture du pool (instantanée)
+ var out []Tool
+ for _, name := range sortedServerNames(servers) {
+ cfg := servers[name]
+ if !cfg.Enabled || cfg.Transport() == "" {
+ continue
+ }
+ s := mcpMgr.ensure(name, cfg)
+ if s.sess == nil {
+ continue // connexion échouée : signalé dans l'UI, pas au modèle
+ }
+ for _, t := range s.tools {
+ if cfg.ToolDisabled(t.Name) {
+ continue // outil masqué par l'utilisateur : pas annoncé au modèle
+ }
+ desc := t.Description
+ if desc == "" {
+ desc = t.Title
+ }
+ // Préfixe le serveur d'origine dans la description : aide le modèle à
+ // choisir entre outils similaires de serveurs différents.
+ desc = "[MCP: " + name + "] " + desc
+ out = append(out, Tool{
+ Type: "function",
+ Function: ToolFunction{
+ Name: mcpExposedName(name, t.Name),
+ Description: desc,
+ Parameters: mcpNormalizeSchema(t.InputSchema),
+ },
+ })
+ }
+ }
+ return out
+}
+
+// mcpNormalizeSchema convertit le InputSchema du SDK (any → map) en map[string]any
+// pour notre type Tool. Un schéma vide devient un objet sans propriété.
+func mcpNormalizeSchema(schema any) map[string]any {
+ if m, ok := schema.(map[string]any); ok && m != nil {
+ return m
+ }
+ // Le SDK peut renvoyer une struct/RawMessage : passe par un round-trip JSON.
+ if schema != nil {
+ if b, err := json.Marshal(schema); err == nil {
+ var m map[string]any
+ if json.Unmarshal(b, &m) == nil && m != nil {
+ return m
+ }
+ }
+ }
+ return map[string]any{"type": "object", "properties": map[string]any{}}
+}
+
+// mcpArgLabel produit un libellé court à partir des arguments d'un appel MCP,
+// pour l'affichage « outil utilisé » dans l'UI (les outils MCP n'ont pas de
+// champ canonique connu à l'avance).
+func mcpArgLabel(args map[string]any) string {
+ if len(args) == 0 {
+ return ""
+ }
+ // Privilégie les clés parlantes courantes.
+ for _, k := range []string{"path", "file", "query", "command", "url", "name"} {
+ if v, ok := args[k].(string); ok && v != "" {
+ return v
+ }
+ }
+ b, _ := json.Marshal(args)
+ s := string(b)
+ if r := []rune(s); len(r) > 120 {
+ s = string(r[:120]) + "…"
+ }
+ return s
+}
+
+// isMCPTool indique si un nom d'outil est routé vers MCP.
+func isMCPTool(name string) bool {
+ return strings.HasPrefix(name, "mcp__")
+}
+
+// mcpCall exécute un outil MCP à partir de son nom exposé et renvoie une chaîne
+// résultat (texte aplati), cappée. Reconnecte une fois si la session est morte.
+func mcpCall(name string, args map[string]any) string {
+ mcpMgr.mu.Lock()
+ ref, ok := mcpMgr.registry[name]
+ mcpMgr.mu.Unlock()
+ if !ok {
+ return "[erreur] outil MCP inconnu ou serveur non connecté: " + name
+ }
+
+ result, err := mcpCallOnce(ref, args)
+ if err != nil {
+ // Session peut-être morte (process stdio tombé) : on l'invalide et on
+ // retente une fois avec une reconnexion fraîche.
+ servers, _ := LoadMCPConfig()
+ cfg, exists := servers[ref.server]
+ if exists && cfg.Enabled {
+ mcpInvalidate(ref.server)
+ mcpMgr.ensure(ref.server, cfg)
+ if result2, err2 := mcpCallOnce(ref, args); err2 == nil {
+ return result2
+ } else {
+ err = err2
+ }
+ }
+ return "[erreur MCP] " + err.Error()
+ }
+ return result
+}
+
+func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
+ mcpMgr.mu.Lock()
+ s := mcpMgr.sessions[ref.server]
+ mcpMgr.mu.Unlock()
+ if s == nil || s.sess == nil {
+ return "", fmt.Errorf("serveur %s non connecté", ref.server)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), mcpCallTimeout)
+ defer cancel()
+ res, err := s.sess.CallTool(ctx, &mcpsdk.CallToolParams{Name: ref.tool, Arguments: args})
+ if err != nil {
+ return "", err
+ }
+ out := flattenMCPContent(res)
+ if r := []rune(out); len(r) > mcpMaxOutput {
+ out = string(r[:mcpMaxOutput]) + "\n…[tronqué]"
+ }
+ if res.IsError {
+ return "[l'outil a renvoyé une erreur]\n" + out, nil
+ }
+ if strings.TrimSpace(out) == "" {
+ return "[ok] (aucune sortie)", nil
+ }
+ return out, nil
+}
+
+// flattenMCPContent aplatit le contenu d'un CallToolResult en texte. Les blocs
+// texte sont concaténés ; le contenu structuré est rendu en JSON s'il n'y a pas
+// de texte ; les autres types (image/audio) sont résumés.
+func flattenMCPContent(res *mcpsdk.CallToolResult) string {
+ var parts []string
+ for _, c := range res.Content {
+ switch v := c.(type) {
+ case *mcpsdk.TextContent:
+ parts = append(parts, v.Text)
+ case *mcpsdk.ImageContent:
+ parts = append(parts, "[image "+v.MIMEType+"]")
+ case *mcpsdk.AudioContent:
+ parts = append(parts, "[audio "+v.MIMEType+"]")
+ default:
+ if b, err := json.Marshal(c); err == nil {
+ parts = append(parts, string(b))
+ }
+ }
+ }
+ text := strings.TrimSpace(strings.Join(parts, "\n"))
+ if text == "" && res.StructuredContent != nil {
+ if b, err := json.MarshalIndent(res.StructuredContent, "", " "); err == nil {
+ return string(b)
+ }
+ }
+ return text
+}
+
+// mcpPromptLine renvoie une ligne système listant les serveurs MCP connectés et
+// leur nombre d'outils, pour situer le modèle. Ne force PAS de connexion : lit
+// seulement l'état déjà établi par mcpTools() (appelé par EnabledTools sur le
+// même tour), pour ne pas payer deux fois le handshake.
+func mcpPromptLine() string {
+ mcpMgr.mu.Lock()
+ defer mcpMgr.mu.Unlock()
+ var names []string
+ for name, s := range mcpMgr.sessions {
+ if s.sess != nil && len(s.tools) > 0 {
+ names = append(names, fmt.Sprintf("%s (%d)", name, len(s.tools)))
+ }
+ }
+ if len(names) == 0 {
+ return ""
+ }
+ sort.Strings(names)
+ return "\n\nMCP servers connected (their tools are prefixed mcp____): " + strings.Join(names, ", ") + "."
+}
+
+// MCPServerStatus est l'état d'un serveur pour l'UI web.
+type MCPServerStatus struct {
+ Name string `json:"name"`
+ Transport string `json:"transport"`
+ Enabled bool `json:"enabled"`
+ Connected bool `json:"connected"`
+ Error string `json:"error,omitempty"`
+ Tools []string `json:"tools"` // tous les outils découverts sur le serveur
+ Disabled []string `json:"disabled"` // sous-ensemble masqué (non exposé à l'IA)
+ // Détail de config (pour pré-remplir le formulaire d'édition).
+ Command string `json:"command,omitempty"`
+ Args []string `json:"args,omitempty"`
+ Env map[string]string `json:"env,omitempty"`
+ URL string `json:"url,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+}
+
+// MCPStatus renvoie l'état de tous les serveurs configurés, en tentant de
+// connecter ceux qui sont activés (pour refléter l'état réel dans l'UI).
+func MCPStatus() ([]MCPServerStatus, error) {
+ servers, err := LoadMCPConfig()
+ if err != nil {
+ return nil, err
+ }
+ mcpEnsureAll(servers) // en parallèle : le panneau MCP de l'UI n'attend plus la somme
+ var out []MCPServerStatus
+ for _, name := range sortedServerNames(servers) {
+ cfg := servers[name]
+ st := MCPServerStatus{
+ Name: name,
+ Transport: cfg.Transport(),
+ Enabled: cfg.Enabled,
+ Command: cfg.Command,
+ Args: cfg.Args,
+ Env: cfg.Env,
+ URL: cfg.URL,
+ Headers: cfg.Headers,
+ Tools: []string{},
+ Disabled: cfg.DisabledTools,
+ }
+ if st.Disabled == nil {
+ st.Disabled = []string{}
+ }
+ if cfg.Enabled && cfg.Transport() != "" {
+ s := mcpMgr.ensure(name, cfg)
+ if s.sess != nil {
+ st.Connected = true
+ for _, t := range s.tools {
+ st.Tools = append(st.Tools, t.Name)
+ }
+ }
+ if s.err != nil {
+ st.Error = s.err.Error()
+ }
+ }
+ out = append(out, st)
+ }
+ return out, nil
+}
diff --git a/internal/ajean/mcp_client_test.go b/internal/ajean/mcp_client_test.go
new file mode 100644
index 0000000..c65207e
--- /dev/null
+++ b/internal/ajean/mcp_client_test.go
@@ -0,0 +1,90 @@
+package ajean
+
+import (
+ "os/exec"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// TestMCPEnsureDeduplicatesConcurrentConnects vérifie que N appelants simultanés
+// (pré-chauffage + tour de chat + panneau MCP de l'UI) partagent une seule
+// tentative de connexion : sans ça, connecter en parallèle multiplierait les
+// process stdio lancés pour un même serveur.
+func TestMCPEnsureDeduplicatesConcurrentConnects(t *testing.T) {
+ testHome(t)
+ t.Cleanup(mcpCloseAll)
+
+ // Commande inexistante : la connexion échoue, mais l'entrée du pool est bien
+ // partagée — c'est ce qu'on teste.
+ cfg := MCPServerConfig{Command: "ajean-binaire-inexistant-pour-test", Enabled: true}
+
+ const callers = 8
+ var wg sync.WaitGroup
+ got := make([]*mcpSession, callers)
+ for i := range got {
+ wg.Add(1)
+ go func(i int) {
+ defer wg.Done()
+ got[i] = mcpMgr.ensure("bidon", cfg)
+ }(i)
+ }
+ wg.Wait()
+
+ for i, s := range got {
+ if s != got[0] {
+ t.Fatalf("appelant %d a obtenu une session différente : %p vs %p", i, s, got[0])
+ }
+ select {
+ case <-s.ready:
+ default:
+ t.Fatalf("appelant %d a reçu une session pas encore prête", i)
+ }
+ }
+ if got[0].err == nil {
+ t.Fatal("une commande inexistante devrait produire une erreur de connexion")
+ }
+}
+
+// TestMCPEndToEnd connecte le serveur MCP de référence (@modelcontextprotocol/
+// server-everything via npx), vérifie la découverte d'outils namespacés puis un
+// appel réel (echo). Skippé si npx est absent (CI sans Node).
+func TestMCPEndToEnd(t *testing.T) {
+ if _, err := exec.LookPath("npx"); err != nil {
+ t.Skip("npx indisponible — test MCP e2e ignoré")
+ }
+ testHome(t)
+
+ cfg := MCPServerConfig{
+ Command: "npx",
+ Args: []string{"-y", "@modelcontextprotocol/server-everything"},
+ Enabled: true,
+ }
+ if err := SetMCPServer("everything", cfg); err != nil {
+ t.Fatalf("SetMCPServer: %v", err)
+ }
+ t.Cleanup(mcpCloseAll)
+
+ tools := mcpTools()
+ if len(tools) == 0 {
+ t.Fatal("aucun outil MCP découvert (le serveur s'est-il connecté ?)")
+ }
+ echoName := mcpExposedName("everything", "echo")
+ found := false
+ for _, tl := range tools {
+ if tl.Function.Name == echoName {
+ found = true
+ }
+ if !strings.HasPrefix(tl.Function.Name, "mcp__everything__") {
+ t.Errorf("outil mal namespacé: %s", tl.Function.Name)
+ }
+ }
+ if !found {
+ t.Fatalf("outil %s introuvable dans %d outils", echoName, len(tools))
+ }
+
+ out := mcpCall(echoName, map[string]any{"message": "bonjour-ajean"})
+ if !strings.Contains(out, "bonjour-ajean") {
+ t.Fatalf("echo n'a pas renvoyé le message ; got: %q", out)
+ }
+}
diff --git a/internal/ajean/mcp_config.go b/internal/ajean/mcp_config.go
new file mode 100644
index 0000000..678de75
--- /dev/null
+++ b/internal/ajean/mcp_config.go
@@ -0,0 +1,258 @@
+package ajean
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+)
+
+// Configuration des serveurs MCP (Model Context Protocol).
+//
+// ajean peut se connecter à des serveurs MCP tiers pour enrichir la palette
+// d'outils de l'IA (accès fichiers rapide type desktop-commander, bases de
+// données, APIs métier…). Deux transports :
+//
+// - stdio : ajean lance un process local (command + args) et parle en
+// JSON-RPC sur stdin/stdout. C'est le cas de desktop-commander, du serveur
+// filesystem officiel, etc. — lancés via npx/uvx/un binaire.
+// - http : ajean parle à un serveur MCP distant en Streamable HTTP (url +
+// éventuels en-têtes d'auth).
+//
+// Le format du fichier mcp.json reprend celui de Claude Desktop (clé
+// "mcpServers" indexée par nom) pour que les utilisateurs puissent copier-coller
+// leurs configs existantes. On ajoute un champ "enabled" par serveur.
+//
+// IMPORTANT (sécurité) : un serveur MCP stdio exécute un process arbitraire sur
+// la machine où tourne ajean — même niveau de confiance que l'outil `bash` du
+// mode agent. La configuration MCP est donc réservée au propriétaire local de la
+// machine et ne doit JAMAIS être pilotable depuis le relais/accès distant.
+
+// MCPServerConfig décrit un serveur MCP configuré. Un seul des deux transports
+// est renseigné : Command => stdio, URL => http.
+type MCPServerConfig struct {
+ // Transport stdio.
+ Command string `json:"command,omitempty"`
+ Args []string `json:"args,omitempty"`
+ Env map[string]string `json:"env,omitempty"`
+
+ // Transport http (Streamable HTTP).
+ URL string `json:"url,omitempty"`
+ Headers map[string]string `json:"headers,omitempty"`
+
+ // Enabled : le serveur n'est connecté et ses outils exposés que s'il est
+ // activé. Un serveur nouvellement ajouté est actif par défaut (voir
+ // UnmarshalJSON) pour coller à l'intuition « je l'ajoute, il marche ».
+ Enabled bool `json:"enabled"`
+
+ // DisabledTools : outils du serveur à NE PAS exposer à l'IA (par nom réel,
+ // non namespacé). Permet de garder un serveur connecté tout en masquant
+ // certains de ses outils. Vide = tous les outils exposés.
+ DisabledTools []string `json:"disabledTools,omitempty"`
+}
+
+// ToolDisabled indique si un outil (nom réel) est masqué pour ce serveur.
+func (c MCPServerConfig) ToolDisabled(tool string) bool {
+ for _, t := range c.DisabledTools {
+ if t == tool {
+ return true
+ }
+ }
+ return false
+}
+
+// enabledDefaultTrue est un alias utilisé pour appliquer enabled=true par défaut
+// quand le champ est absent du JSON (compat configs Claude Desktop sans
+// "enabled").
+type mcpServerConfigAlias MCPServerConfig
+
+// UnmarshalJSON applique enabled=true par défaut lorsque la clé est absente,
+// pour rester compatible avec les fichiers mcp.json qui ne connaissent pas ce
+// champ (Claude Desktop, etc.).
+func (c *MCPServerConfig) UnmarshalJSON(b []byte) error {
+ // Sonde la présence de la clé "enabled".
+ var probe map[string]json.RawMessage
+ if err := json.Unmarshal(b, &probe); err != nil {
+ return err
+ }
+ alias := mcpServerConfigAlias{}
+ if err := json.Unmarshal(b, &alias); err != nil {
+ return err
+ }
+ if _, ok := probe["enabled"]; !ok {
+ alias.Enabled = true
+ }
+ *c = MCPServerConfig(alias)
+ return nil
+}
+
+// Transport renvoie "stdio", "http" ou "" (mal configuré : ni command ni url).
+func (c MCPServerConfig) Transport() string {
+ switch {
+ case strings.TrimSpace(c.Command) != "":
+ return "stdio"
+ case strings.TrimSpace(c.URL) != "":
+ return "http"
+ default:
+ return ""
+ }
+}
+
+// Validate vérifie qu'exactement un transport est renseigné.
+func (c MCPServerConfig) Validate() error {
+ hasCmd := strings.TrimSpace(c.Command) != ""
+ hasURL := strings.TrimSpace(c.URL) != ""
+ switch {
+ case hasCmd && hasURL:
+ return fmt.Errorf("un serveur MCP ne peut avoir à la fois 'command' (stdio) et 'url' (http)")
+ case !hasCmd && !hasURL:
+ return fmt.Errorf("un serveur MCP doit avoir soit 'command' (stdio) soit 'url' (http)")
+ }
+ return nil
+}
+
+// mcpConfigMu sérialise les accès concurrents à la déclaration des serveurs
+// MCP (l'UI web et les tours de chat peuvent lire/écrire en parallèle).
+var mcpConfigMu sync.Mutex
+
+// LoadMCPConfig lit les serveurs MCP déclarés. Aucun => map vide, pas d'erreur.
+func LoadMCPConfig() (map[string]MCPServerConfig, error) {
+ mcpConfigMu.Lock()
+ defer mcpConfigMu.Unlock()
+ return loadMCPConfigLocked()
+}
+
+func loadMCPConfigLocked() (map[string]MCPServerConfig, error) {
+ servers := map[string]MCPServerConfig{}
+ getJSON(bkState, "mcp", &servers)
+ if servers == nil {
+ servers = map[string]MCPServerConfig{}
+ }
+ return servers, nil
+}
+
+func saveMCPConfigLocked(servers map[string]MCPServerConfig) error {
+ return putJSON(bkState, "mcp", servers)
+}
+
+// SetMCPServer ajoute ou remplace un serveur nommé, puis invalide le pool de
+// sessions pour que le changement prenne effet au prochain tour.
+func SetMCPServer(name string, cfg MCPServerConfig) error {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return fmt.Errorf("nom de serveur vide")
+ }
+ if strings.Contains(name, "__") {
+ return fmt.Errorf("le nom ne peut pas contenir '__' (réservé au namespacing des outils)")
+ }
+ if err := cfg.Validate(); err != nil {
+ return err
+ }
+ mcpConfigMu.Lock()
+ servers, err := loadMCPConfigLocked()
+ if err != nil {
+ mcpConfigMu.Unlock()
+ return err
+ }
+ servers[name] = cfg
+ err = saveMCPConfigLocked(servers)
+ mcpConfigMu.Unlock()
+ if err != nil {
+ return err
+ }
+ mcpInvalidate(name)
+ return nil
+}
+
+// DeleteMCPServer retire un serveur et ferme sa session si ouverte.
+func DeleteMCPServer(name string) error {
+ mcpConfigMu.Lock()
+ servers, err := loadMCPConfigLocked()
+ if err != nil {
+ mcpConfigMu.Unlock()
+ return err
+ }
+ if _, ok := servers[name]; !ok {
+ mcpConfigMu.Unlock()
+ return fmt.Errorf("serveur MCP inconnu: %s", name)
+ }
+ delete(servers, name)
+ err = saveMCPConfigLocked(servers)
+ mcpConfigMu.Unlock()
+ if err != nil {
+ return err
+ }
+ mcpInvalidate(name)
+ return nil
+}
+
+// SetMCPServerEnabled active/désactive un serveur existant.
+func SetMCPServerEnabled(name string, on bool) error {
+ mcpConfigMu.Lock()
+ servers, err := loadMCPConfigLocked()
+ if err != nil {
+ mcpConfigMu.Unlock()
+ return err
+ }
+ cfg, ok := servers[name]
+ if !ok {
+ mcpConfigMu.Unlock()
+ return fmt.Errorf("serveur MCP inconnu: %s", name)
+ }
+ cfg.Enabled = on
+ servers[name] = cfg
+ err = saveMCPConfigLocked(servers)
+ mcpConfigMu.Unlock()
+ if err != nil {
+ return err
+ }
+ mcpInvalidate(name)
+ return nil
+}
+
+// SetMCPToolEnabled masque/démasque un outil précis d'un serveur (via sa liste
+// DisabledTools), puis invalide la session pour recalculer les outils exposés.
+func SetMCPToolEnabled(server, tool string, on bool) error {
+ mcpConfigMu.Lock()
+ servers, err := loadMCPConfigLocked()
+ if err != nil {
+ mcpConfigMu.Unlock()
+ return err
+ }
+ cfg, ok := servers[server]
+ if !ok {
+ mcpConfigMu.Unlock()
+ return fmt.Errorf("serveur MCP inconnu: %s", server)
+ }
+ // Reconstruit la liste sans l'outil concerné, puis l'ajoute si on désactive.
+ next := cfg.DisabledTools[:0:0]
+ for _, t := range cfg.DisabledTools {
+ if t != tool {
+ next = append(next, t)
+ }
+ }
+ if !on {
+ next = append(next, tool)
+ }
+ cfg.DisabledTools = next
+ servers[server] = cfg
+ err = saveMCPConfigLocked(servers)
+ mcpConfigMu.Unlock()
+ if err != nil {
+ return err
+ }
+ mcpInvalidate(server)
+ return nil
+}
+
+// sortedServerNames renvoie les noms triés, pour un ordre d'affichage/itération
+// stable.
+func sortedServerNames(servers map[string]MCPServerConfig) []string {
+ names := make([]string, 0, len(servers))
+ for n := range servers {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ return names
+}
diff --git a/internal/ajean/migrate_07.go b/internal/ajean/migrate_07.go
new file mode 100644
index 0000000..b4d28ab
--- /dev/null
+++ b/internal/ajean/migrate_07.go
@@ -0,0 +1,302 @@
+package ajean
+
+// migrate_07.go — CODE TEMPORAIRE, à supprimer d'ici une version ou deux.
+//
+// Reprend une installation 0.7.x au moment de `ajean install`. Tout est ici, et
+// rien d'autre n'en dépend : le jour où le parc aura basculé, supprimer ce
+// fichier et les deux appels à migrateFrom07() (sys_install_unix.go et
+// sys_install_windows.go) suffit. C'est la seule raison d'être de ce fichier —
+// la 0.8 ne contient AUCUN autre code de compatibilité.
+//
+// POURQUOI ICI, ET PAS AU DÉMARRAGE. La version précédente migrait à chaque
+// lancement. Sur un parc, ça veut dire une réécriture d'unités systemd à chaque
+// boot, et un échec laisse une machine distante sans service et sans terminal
+// pour réparer. `install` est le seul bon moment : root, délibéré, et il écrit
+// déjà les unités.
+//
+// CE QU'ON NE DÉTRUIT PAS. Les presets, la mémoire et les modèles sont
+// DÉPLACÉS, jamais copiés ni effacés. Les petits fichiers d'état sont lus puis
+// rangés dans avant-0.8/ — ils ne servent plus, mais ils sont là si quelque
+// chose a mal tourné.
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+)
+
+// legacyUnits : les noms de services qu'a portés le produit avant la 0.8.
+//
+// Les désactiver n'est pas cosmétique. Une unité `jean.service` restée active
+// relance un SECOND llama-server au prochain boot : deux moteurs sur le port
+// 8080, la VRAM prise deux fois, et un démarrage qui échoue à moitié sans
+// raison apparente.
+var legacyUnits = []string{"jean", "jean-link", "ajean", "ajean-link"}
+
+// hasEntry dit si home contient une entrée portant EXACTEMENT ce nom.
+//
+// os.Stat ne convient pas : sous Windows et macOS le système de fichiers est
+// insensible à la casse, donc Stat("MEMORY") réussit sur un dossier nommé
+// « memory ». Une installation 0.8 toute neuve passait ainsi pour une 0.7, et
+// la migration se redéclenchait à chaque `install`. On compare donc les noms
+// réels, tels que le système de fichiers les rapporte.
+func hasEntry(home, name string) bool {
+ entries, err := os.ReadDir(home)
+ if err != nil {
+ return false
+ }
+ for _, e := range entries {
+ if e.Name() == name {
+ return true
+ }
+ }
+ return false
+}
+
+// sameDir dit si deux chemins désignent le MÊME dossier — ce qui arrive quand
+// ils ne diffèrent que par la casse sur un système de fichiers qui l'ignore.
+func sameDir(a, b string) bool {
+ ai, err := os.Stat(a)
+ if err != nil {
+ return false
+ }
+ bi, err := os.Stat(b)
+ if err != nil {
+ return false
+ }
+ return os.SameFile(ai, bi)
+}
+
+// needsMigration07 dit si le dossier de données est encore dans l'ancienne
+// disposition. On se fie à ce qui ne peut PAS exister en 0.8.
+func needsMigration07(home string) bool {
+ return hasEntry(home, "config.env") || hasEntry(home, "configs") || hasEntry(home, "MEMORY")
+}
+
+// migrateFrom07 reprend une installation 0.7.x. Sans effet si le dossier est
+// déjà en 0.8. Doit être appelée AVANT provisionDataDir : elle déplace des
+// dossiers entiers, ce qui suppose que la destination n'existe pas encore.
+func migrateFrom07(home string) error {
+ if !needsMigration07(home) {
+ return nil
+ }
+ fmt.Printf("\n%s installation 0.7 détectée — reprise en cours\n", cyan("[migration]"))
+
+ // 1. Arrêter les anciens services : ils tiennent les fichiers qu'on déplace,
+ // et sous Windows un .gguf ouvert par llama-server refuse d'être renommé.
+ stopLegacyUnits()
+
+ // 2. Les dossiers. Un rename sur le même volume est instantané, même pour
+ // 130 Go de modèles, et atomique : jamais d'état à moitié déplacé.
+ for _, m := range []struct{ from, to string }{
+ {"configs", "presets"},
+ {"MEMORY", "memory"},
+ } {
+ if !hasEntry(home, m.from) { // nom exact : « MEMORY » n'est pas « memory »
+ continue
+ }
+ src, dst := filepath.Join(home, m.from), filepath.Join(home, m.to)
+ // « configs » et « presets » ne se confondent pas, mais « MEMORY » et
+ // « memory » si : sur un système insensible à la casse, ouvrir la
+ // destination ouvre en réalité la source. On ne considère donc la
+ // destination comme distincte que si c'est un AUTRE dossier.
+ if !sameDir(src, dst) {
+ // Destination déjà remplie : la machine a été migrée à la main et
+ // l'ancien dossier n'a pas été retiré. Écraser détruirait le travail
+ // fait, fusionner devinerait qui gagne. On laisse les deux et on le
+ // dit — c'est le seul cas où quelqu'un doit trancher.
+ if n, _ := os.ReadDir(dst); len(n) > 0 {
+ fmt.Printf(" %s %s/ ET %s/ existent tous les deux — %s/ laissé tel quel, à supprimer à la main\n",
+ yellow("[info]"), m.from, m.to, m.from)
+ continue
+ }
+ _ = os.Remove(dst) // destination vide créée par une install précédente
+ }
+ if err := os.Rename(src, dst); err != nil {
+ return fmt.Errorf("%s → %s : %w", m.from, m.to, err)
+ }
+ fmt.Printf(" %s %s/ → %s/\n", green("✓"), m.from, m.to)
+ }
+ if n, err := moveModels(home); err != nil {
+ return err
+ } else if n > 0 {
+ fmt.Printf(" %s %d modèle(s) → models/\n", green("✓"), n)
+ }
+
+ // 3. Les réglages, vers la base.
+ if err := importLegacyState(home); err != nil {
+ return err
+ }
+ // SKILLS/ : les skills ont été fondus dans la mémoire avant la 0.8, le
+ // dossier ne sert plus à rien mais peut contenir du travail de l'utilisateur.
+ // On l'archive au lieu de le laisser traîner ou de l'effacer.
+ if hasEntry(home, "SKILLS") {
+ _ = os.Rename(filepath.Join(home, "SKILLS"), filepath.Join(home, "avant-0.8", "SKILLS"))
+ }
+ fmt.Printf("%s reprise terminée — les anciens fichiers sont dans avant-0.8/\n\n", green("[ok]"))
+ return nil
+}
+
+// stopLegacyUnits est une variable pour que le test de migration n'aille pas
+// invoquer systemctl sur la machine qui exécute les tests.
+var stopLegacyUnits = stopLegacyUnitsReal
+
+// stopLegacyUnitsReal arrête et désactive les services d'avant la 0.8,
+// silencieux sur ceux qui n'existent pas (le cas courant).
+func stopLegacyUnitsReal() {
+ for _, u := range legacyUnits {
+ switch runtime.GOOS {
+ case "linux":
+ _ = exec.Command("systemctl", "stop", u).Run()
+ _ = exec.Command("systemctl", "disable", u).Run()
+ case "darwin":
+ for _, label := range []string{"com.jean." + u, "com.ajean." + u} {
+ _ = exec.Command("launchctl", "unload", "-w", "/Library/LaunchDaemons/"+label+".plist").Run()
+ }
+ }
+ }
+ if runtime.GOOS == "linux" {
+ _ = exec.Command("systemctl", "daemon-reload").Run()
+ }
+}
+
+// moveModels range les .gguf de la racine dans models/. Renvoie le nombre
+// déplacé. Un fichier qui résiste (encore ouvert) interrompt la migration : le
+// laisser derrière donnerait une installation à laquelle il manque un modèle,
+// sans le dire.
+func moveModels(home string) (int, error) {
+ entries, err := os.ReadDir(home)
+ if err != nil {
+ return 0, err
+ }
+ dst := filepath.Join(home, "models")
+ n := 0
+ for _, e := range entries {
+ name := e.Name()
+ if e.IsDir() || !strings.HasSuffix(strings.ToLower(name), ".gguf") {
+ continue
+ }
+ if n == 0 {
+ if err := os.MkdirAll(dst, 0o755); err != nil {
+ return 0, err
+ }
+ }
+ if err := os.Rename(filepath.Join(home, name), filepath.Join(dst, name)); err != nil {
+ return n, fmt.Errorf("déplacement de %s : %w", name, err)
+ }
+ n++
+ }
+ return n, nil
+}
+
+// importLegacyState recopie les anciens fichiers d'état dans la base, puis les
+// range dans avant-0.8/. Les clés et les buckets sont ceux de store.go.
+func importLegacyState(home string) error {
+ old := filepath.Join(home, "avant-0.8")
+ if err := os.MkdirAll(old, 0o755); err != nil {
+ return err
+ }
+ consumed := []string{}
+ read := func(name string) string {
+ b, err := os.ReadFile(filepath.Join(home, name))
+ if err != nil {
+ return ""
+ }
+ consumed = append(consumed, name)
+ return string(b)
+ }
+
+ // La configuration d'abord : sans elle le moteur ne démarre pas.
+ if cfg := parseEnv(read("config.env")); len(cfg) > 0 {
+ if err := WriteConfig(cfg); err != nil {
+ return err
+ }
+ fmt.Printf(" %s configuration (%d clés)\n", green("✓"), len(cfg))
+ }
+ if prefs := map[string]string{}; json.Unmarshal([]byte(read("webprefs.json")), &prefs) == nil && len(prefs) > 0 {
+ _ = replaceKV(bkPrefs, prefs)
+ }
+ if conv := read("conversation.json"); conv != "" {
+ _ = putBytes(bkChat, "conversation", []byte(conv))
+ fmt.Printf(" %s conversation\n", green("✓"))
+ }
+
+ // Clés et jetons : le contenu du fichier, débarrassé de sa fin de ligne.
+ for key, name := range map[string]string{
+ "api_key": ".api_key",
+ "web_key": ".web_key",
+ "crawl_key": ".crawl4ai_key",
+ "link_token": ".link_token",
+ "link_machine": ".link_machine",
+ } {
+ if v := strings.TrimSpace(read(name)); v != "" {
+ _ = putStr(bkState, key, v)
+ }
+ }
+ // Drapeaux : seule l'EXISTENCE du fichier comptait.
+ for key, name := range map[string]string{
+ "agent": ".agent_enabled",
+ "internet": ".internet_enabled",
+ "oai_public": ".oai_public",
+ } {
+ if _, err := os.Stat(filepath.Join(home, name)); err == nil {
+ _ = putBool(bkState, key, true)
+ consumed = append(consumed, name)
+ }
+ }
+ // Documents JSON repris tels quels.
+ for key, name := range map[string]string{
+ "model_dirs": "model_dirs.json",
+ "last_bench": ".last_bench.json",
+ "bench_presets": ".bench_presets.json",
+ "sysprompt": "sysprompt.txt",
+ } {
+ if v := read(name); v != "" {
+ if key == "sysprompt" {
+ _ = putStr(bkState, key, strings.TrimSpace(v))
+ } else {
+ _ = putBytes(bkState, key, []byte(v))
+ }
+ }
+ }
+ // .authorized_users : les identités appairées pour le chat chiffré de bout en
+ // bout, une clé publique par ligne. L'oublier coûte cher et de façon peu
+ // lisible : le tunnel s'établit, la machine s'affiche en ligne, mais le
+ // portail refuse toute conversation chiffrée — il faut ré-appairer sans
+ // comprendre pourquoi. Vécu sur le serveur de test.
+ if v := read(".authorized_users"); v != "" {
+ var list []string
+ for _, line := range strings.Split(v, "\n") {
+ if h := strings.ToLower(strings.TrimSpace(line)); h != "" {
+ list = append(list, h)
+ }
+ }
+ if len(list) > 0 {
+ sort.Strings(list)
+ _ = putJSON(bkState, "authorized_users", list)
+ fmt.Printf(" %s %d identité(s) appairée(s)\n", green("✓"), len(list))
+ }
+ }
+ // .pair_codes n'est PAS repris : ces codes expirent au bout de 10 minutes,
+ // les reprendre n'aurait aucun sens.
+
+ // mcp.json : on ne garde que la map interne, la clé « mcpServers » disparaît.
+ if v := read("mcp.json"); v != "" {
+ var f struct {
+ MCPServers json.RawMessage `json:"mcpServers"`
+ }
+ if json.Unmarshal([]byte(v), &f) == nil && len(f.MCPServers) > 0 {
+ _ = putBytes(bkState, "mcp", f.MCPServers)
+ }
+ }
+
+ for _, name := range consumed {
+ _ = os.Rename(filepath.Join(home, name), filepath.Join(old, name))
+ }
+ return nil
+}
diff --git a/internal/ajean/migrate_07_test.go b/internal/ajean/migrate_07_test.go
new file mode 100644
index 0000000..c05d13f
--- /dev/null
+++ b/internal/ajean/migrate_07_test.go
@@ -0,0 +1,166 @@
+package ajean
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// Test de la reprise 0.7 → 0.8. À supprimer avec migrate_07.go.
+//
+// Ce code déplace les presets, la mémoire et des modèles de plusieurs dizaines
+// de gigaoctets sur la machine de quelqu'un : il ne peut pas ne pas être testé.
+
+// fake07Home fabrique un dossier de données dans l'ancienne disposition.
+func fake07Home(t *testing.T) string {
+ t.Helper()
+ home := testHome(t)
+ stopLegacyUnits = func() {} // pas de systemctl pendant les tests
+ t.Cleanup(func() { stopLegacyUnits = stopLegacyUnitsReal })
+
+ write := func(rel, body string) {
+ p := filepath.Join(home, rel)
+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("config.env", "MODEL=\"m.gguf\"\nCTX=4096\nBIN=/usr/bin/llama-server\n")
+ write("configs/MON PRESET.env", "# NAME=MON PRESET\nMODEL=\"m.gguf\"\nCTX=8192\n")
+ write("MEMORY/user-preferences.md", "# Préférences\nfuseau: Europe/Paris\n")
+ write("m.gguf", "gguf")
+ write(".api_key", "sk-jean-secret\n")
+ write(".link_token", "jl_abcdef123456\n")
+ write(".agent_enabled", "")
+ write("webprefs.json", `{"theme":"dark"}`)
+ write("mcp.json", `{"mcpServers":{"fs":{"command":"npx","enabled":true}}}`)
+ write("sysprompt.txt", "Tu es AJEAN.\n")
+ write("conversation.json", `{"seq":42,"messages":[{"role":"user","content":"salut"}]}`)
+ write("SKILLS/vieux-skill/SKILL.md", "# un skill d'avant\n")
+ write(".authorized_users", "AABBCCDDEEFF00112233445566778899AABBCCDDEEFF001122334455667788AA\n")
+ return home
+}
+
+func TestMigration07(t *testing.T) {
+ home := fake07Home(t)
+ if !needsMigration07(home) {
+ t.Fatal("ancienne disposition non détectée")
+ }
+ if err := migrateFrom07(home); err != nil {
+ t.Fatal(err)
+ }
+
+ // Les dossiers ont bougé, pas disparu.
+ for _, c := range []struct{ path, want string }{
+ {filepath.Join(home, "presets", "MON PRESET.env"), "CTX=8192"},
+ {filepath.Join(home, "memory", "user-preferences.md"), "Europe/Paris"},
+ {filepath.Join(home, "models", "m.gguf"), "gguf"},
+ } {
+ b, err := os.ReadFile(c.path)
+ if err != nil {
+ t.Fatalf("%s absent : %v", c.path, err)
+ }
+ if !strings.Contains(string(b), c.want) {
+ t.Errorf("%s : contenu inattendu %q", c.path, string(b))
+ }
+ }
+ // hasEntry et non os.Stat : sur un système de fichiers insensible à la casse,
+ // Stat("MEMORY") réussit sur le dossier « memory » fraîchement créé.
+ for _, gone := range []string{"configs", "MEMORY", "m.gguf", "config.env"} {
+ if hasEntry(home, gone) {
+ t.Errorf("%s traîne encore à la racine", gone)
+ }
+ }
+
+ // Les réglages sont en base.
+ cfg := ReadConfig()
+ if cfg["MODEL"] != "m.gguf" || cfg["CTX"] != "4096" {
+ t.Errorf("configuration non reprise : %v", cfg)
+ }
+ if readAPIKey() != "sk-jean-secret" {
+ t.Errorf("clé API = %q", readAPIKey())
+ }
+ if readLinkToken() != "jl_abcdef123456" {
+ t.Errorf("jeton de liaison = %q", readLinkToken())
+ }
+ if !agentEnabled() {
+ t.Error("mode agent perdu")
+ }
+ if readSysPrompt() != "Tu es AJEAN." {
+ t.Errorf("prompt système = %q", readSysPrompt())
+ }
+ if loadWebPrefs()["theme"] != "dark" {
+ t.Error("préférences perdues")
+ }
+ if servers, _ := LoadMCPConfig(); servers["fs"].Command != "npx" {
+ t.Errorf("serveurs MCP perdus : %v", servers)
+ }
+ LoadConversation()
+ if conv.Seq != 42 {
+ t.Errorf("conversation perdue (seq %d)", conv.Seq)
+ }
+ // Les identités appairées : les perdre casse le chat chiffré du portail
+ // sans que rien ne le dise (le tunnel, lui, s'établit normalement).
+ // La liste est mise en cache une fois par process (authOnce) : en production
+ // c'est un process neuf qui sert après l'install, ici il faut le dire.
+ authOnce, authSet = sync.Once{}, map[string]bool{}
+ if !isAuthorizedUser("aabbccddeeff00112233445566778899aabbccddeeff001122334455667788aa") {
+ t.Error("identité appairée perdue : le chat E2E du portail serait refusé")
+ }
+
+ // Les fichiers consommés sont rangés, pas détruits.
+ for _, archive := range []string{"config.env", "SKILLS"} {
+ if _, err := os.Stat(filepath.Join(home, "avant-0.8", archive)); err != nil {
+ t.Errorf("%s n'a pas été conservé dans avant-0.8/", archive)
+ }
+ }
+ if hasEntry(home, "SKILLS") {
+ t.Error("SKILLS/ traîne encore à la racine")
+ }
+
+ // Idempotence : relancer ne doit plus rien voir à migrer.
+ if needsMigration07(home) {
+ t.Error("la migration se redéclencherait au prochain install")
+ }
+ if err := migrateFrom07(home); err != nil {
+ t.Fatalf("second appel : %v", err)
+ }
+}
+
+// Une installation 0.8 neuve ne doit rien déclencher.
+func TestMigration07IgnoreUneInstallationNeuve(t *testing.T) {
+ home := testHome(t)
+ if err := provisionDataDir(); err != nil {
+ t.Fatal(err)
+ }
+ if needsMigration07(home) {
+ t.Fatal("migration déclenchée sur une installation neuve")
+ }
+}
+
+// Machine migrée à la main : les nouveaux dossiers sont remplis ET les anciens
+// traînent encore. L'install ne doit rien écraser, ni échouer.
+func TestMigration07NEcrasePasUnDossierDejaRempli(t *testing.T) {
+ home := fake07Home(t)
+ precieux := filepath.Join(home, "presets", "DEJA LA.env")
+ if err := os.MkdirAll(filepath.Dir(precieux), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(precieux, []byte("CTX=1\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := migrateFrom07(home); err != nil {
+ t.Fatalf("la migration doit se poursuivre, pas échouer : %v", err)
+ }
+ b, err := os.ReadFile(precieux)
+ if err != nil || !strings.Contains(string(b), "CTX=1") {
+ t.Error("le preset déjà migré a été écrasé")
+ }
+ if !hasEntry(home, "configs") {
+ t.Error("l'ancien dossier a été supprimé alors qu'il fallait le laisser")
+ }
+}
diff --git a/internal/ajean/node_api.go b/internal/ajean/node_api.go
new file mode 100644
index 0000000..82c929b
--- /dev/null
+++ b/internal/ajean/node_api.go
@@ -0,0 +1,253 @@
+// node_api.go — endpoints HTTP du poste distant.
+//
+// Deux familles :
+// - GESTION (propriétaire, derrière la clé de pilotage) : générer un code
+// d'appairage, lister/configurer/révoquer les postes.
+// - ENRÔLEMENT (public) : le poste échange un code d'appairage à usage unique
+// contre sa clé d'appareil. Authentifié par le code, pas par la clé de
+// pilotage — que le poste ne possède pas.
+package ajean
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/coder/websocket"
+)
+
+// handleNodePair (authed) génère un code d'appairage à usage unique valable
+// nodePairCodeTTL. Le propriétaire choisit les capacités accordées et le dossier
+// racine ; le poste ne pourra jamais dépasser ça.
+func handleNodePair(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Caps []string `json:"caps"`
+ Root string `json:"root"`
+ }
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ caps := nodeSanitizeCaps(req.Caps)
+ if len(caps) == 0 {
+ // Défaut prudent : lecture seule. Le propriétaire élargit ensuite.
+ caps = []string{nodeCapRead, nodeCapList}
+ }
+ p := nodePairPending{
+ Code: strings.ToUpper(nodeRandHex(4)), // 8 caractères hex lisibles
+ Caps: caps,
+ Root: strings.TrimSpace(req.Root),
+ Expires: time.Now().Add(nodePairCodeTTL).Unix(),
+ }
+ if err := savePairPending(p); err != nil {
+ sendJSON(w, 500, map[string]any{"error": err.Error()})
+ return
+ }
+ sendJSON(w, 200, map[string]any{
+ "ok": true,
+ "code": p.Code,
+ "caps": p.Caps,
+ "root": p.Root,
+ "expires": p.Expires,
+ "ttl_min": int(nodePairCodeTTL.Minutes()),
+ "machine": machineID(), // pour l'accès via ajean.link (/node//)
+ "agent_pub": e2ePubHex(), // clé publique de l'agent (le poste scelle vers elle)
+ "fingerprint": e2eFingerprint(), // empreinte, ancre de confiance
+ })
+}
+
+// handleNodeEnroll (PUBLIC) enrôle un poste. Le corps est un SCEAU anonyme vers
+// la clé publique de l'agent contenant {pub, code, name, os} : le relais ne peut
+// ni l'ouvrir, ni voir le code, ni la clé publique du poste. Si le code matche,
+// la clé publique est enregistrée comme identité autorisée du poste.
+func handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Sealed string `json:"sealed"` // base64(ephPub||nonce||ct) vers la clé agent
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Sealed == "" {
+ sendJSON(w, 400, map[string]any{"error": "requête invalide"})
+ return
+ }
+ blob, err := base64.StdEncoding.DecodeString(req.Sealed)
+ if err != nil {
+ sendJSON(w, 400, map[string]any{"error": "format du sceau"})
+ return
+ }
+ plain, err := e2eOpenSeal(blob)
+ if err != nil {
+ sendJSON(w, 400, map[string]any{"error": "sceau invalide"})
+ return
+ }
+ var pm struct {
+ Pub string `json:"pub"`
+ Code string `json:"code"`
+ Name string `json:"name"`
+ OS string `json:"os"`
+ }
+ if err := json.Unmarshal(plain, &pm); err != nil {
+ sendJSON(w, 400, map[string]any{"error": "contenu du sceau"})
+ return
+ }
+ pending, ok := loadPairPending()
+ if !ok {
+ sendJSON(w, 403, map[string]any{"error": "aucun code d'appairage actif — générez-en un dans l'interface"})
+ return
+ }
+ if !strings.EqualFold(strings.TrimSpace(pm.Code), pending.Code) {
+ sendJSON(w, 403, map[string]any{"error": "code incorrect"})
+ return
+ }
+ clearPairPending() // usage unique
+
+ pub := strings.ToLower(strings.TrimSpace(pm.Pub))
+ if len(pub) != 64 {
+ sendJSON(w, 400, map[string]any{"error": "clé publique du poste invalide"})
+ return
+ }
+ name := strings.TrimSpace(pm.Name)
+ if name == "" {
+ name = "poste"
+ }
+ node := pairedNode{
+ ID: nodeRandHex(8),
+ Name: name,
+ OS: strings.TrimSpace(pm.OS),
+ PubHex: pub,
+ Caps: pending.Caps,
+ Root: pending.Root,
+ CreatedAt: time.Now().Unix(),
+ }
+ nodes := append(loadNodes(), node)
+ if err := saveNodes(nodes); err != nil {
+ sendJSON(w, 500, map[string]any{"error": err.Error()})
+ return
+ }
+ // Réponse en clair : elle ne contient AUCUN secret (le secret, la clé privée,
+ // n'a jamais quitté le poste). machine_id sert au poste pour l'URL relais.
+ sendJSON(w, 200, map[string]any{
+ "ok": true,
+ "id": node.ID,
+ "machine_id": machineID(),
+ "caps": node.Caps,
+ "root": node.Root,
+ "name": node.Name,
+ })
+}
+
+// handleNodes (authed) liste les postes appairés avec leur état de connexion.
+func handleNodes(w http.ResponseWriter, r *http.Request) {
+ connected := map[string]bool{}
+ for _, nc := range nodeConnected() {
+ connected[nc.id] = true
+ }
+ nodes := loadNodes()
+ out := make([]map[string]any, 0, len(nodes))
+ for _, n := range nodes {
+ out = append(out, map[string]any{
+ "id": n.ID,
+ "name": n.Name,
+ "slug": nodeSlug(n.Name),
+ "os": n.OS,
+ "caps": n.Caps,
+ "root": n.Root,
+ "created_at": n.CreatedAt,
+ "last_seen": n.LastSeen,
+ "connected": connected[n.ID],
+ })
+ }
+ _, pending := loadPairPending()
+ sendJSON(w, 200, map[string]any{"ok": true, "nodes": out, "all_caps": nodeAllCaps, "pairing": pending, "target": agentTargetSlug()})
+}
+
+// handleNodeTarget (authed) choisit la machine sur laquelle l'agent agit :
+// slug d'un poste, ou "" pour le serveur local.
+func handleNodeTarget(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ Slug string `json:"slug"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendJSON(w, 400, map[string]any{"error": "requête invalide"})
+ return
+ }
+ if err := setAgentTargetSlug(strings.TrimSpace(req.Slug)); err != nil {
+ sendJSON(w, 500, map[string]any{"error": err.Error()})
+ return
+ }
+ sendJSON(w, 200, map[string]any{"ok": true, "target": agentTargetSlug()})
+}
+
+// handleNodeCaps (authed) met à jour les capacités autorisées et/ou le dossier
+// racine d'un poste. Prend effet à la prochaine (re)connexion du poste.
+func handleNodeCaps(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ ID string `json:"id"`
+ Caps []string `json:"caps"`
+ Root *string `json:"root"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendJSON(w, 400, map[string]any{"error": "requête invalide"})
+ return
+ }
+ nodes := loadNodes()
+ found := false
+ for i := range nodes {
+ if nodes[i].ID == req.ID {
+ nodes[i].Caps = nodeSanitizeCaps(req.Caps)
+ if req.Root != nil {
+ nodes[i].Root = strings.TrimSpace(*req.Root)
+ }
+ found = true
+ break
+ }
+ }
+ if !found {
+ sendJSON(w, 404, map[string]any{"error": "poste inconnu"})
+ return
+ }
+ if err := saveNodes(nodes); err != nil {
+ sendJSON(w, 500, map[string]any{"error": err.Error()})
+ return
+ }
+ // Coupe la session en cours pour que les nouvelles capacités s'appliquent
+ // (le poste se reconnecte et re-négocie l'intersection).
+ for _, nc := range nodeConnected() {
+ if nc.id == req.ID {
+ _ = nc.conn.Close(websocket.StatusNormalClosure, "capacités mises à jour")
+ }
+ }
+ sendJSON(w, 200, map[string]any{"ok": true})
+}
+
+// handleNodeRevoke (authed) révoque un poste : oublie sa clé et le déconnecte.
+func handleNodeRevoke(w http.ResponseWriter, r *http.Request) {
+ var req struct {
+ ID string `json:"id"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendJSON(w, 400, map[string]any{"error": "requête invalide"})
+ return
+ }
+ nodes := loadNodes()
+ kept := nodes[:0]
+ removed := false
+ for _, n := range nodes {
+ if n.ID == req.ID {
+ removed = true
+ continue
+ }
+ kept = append(kept, n)
+ }
+ if !removed {
+ sendJSON(w, 404, map[string]any{"error": "poste inconnu"})
+ return
+ }
+ if err := saveNodes(kept); err != nil {
+ sendJSON(w, 500, map[string]any{"error": err.Error()})
+ return
+ }
+ for _, nc := range nodeConnected() {
+ if nc.id == req.ID {
+ _ = nc.conn.Close(websocket.StatusPolicyViolation, "poste révoqué")
+ }
+ }
+ sendJSON(w, 200, map[string]any{"ok": true})
+}
diff --git a/internal/ajean/node_proto.go b/internal/ajean/node_proto.go
new file mode 100644
index 0000000..dc4a4f9
--- /dev/null
+++ b/internal/ajean/node_proto.go
@@ -0,0 +1,68 @@
+// node_proto.go — côté serveur, alias vers le protocole partagé (internal/nodewire)
+// + les helpers de nommage d'outils propres à ajean.
+//
+// Le format du fil (Msg, capacités, confinement de chemin) vit dans nodewire,
+// paquet SANS dépendance, pour que le client léger ajean-node le partage sans
+// tirer tout ajean. Ici on ne fait qu'exposer ces symboles sous les noms
+// historiques du paquet, pour ne pas toucher le reste du code serveur.
+package ajean
+
+import (
+ "strings"
+
+ "github.com/nathaninline/ajean/internal/nodewire"
+)
+
+// Capacités (alias des constantes partagées).
+const (
+ nodeCapShell = nodewire.CapShell
+ nodeCapRead = nodewire.CapRead
+ nodeCapWrite = nodewire.CapWrite
+ nodeCapList = nodewire.CapList
+)
+
+// nodeAllCaps : ordre canonique partagé.
+var nodeAllCaps = nodewire.AllCaps
+
+// nodeMsg : enveloppe du fil (alias de type → interop garantie avec le client).
+type nodeMsg = nodewire.Msg
+
+func nodeCapAllowed(allowed []string, cap string) bool { return nodewire.CapAllowed(allowed, cap) }
+func nodeCapIntersect(allowed, declared []string) []string {
+ return nodewire.CapIntersect(allowed, declared)
+}
+func nodeSanitizeCaps(caps []string) []string { return nodewire.SanitizeCaps(caps) }
+func nodeResolvePath(root, p string) (string, error) { return nodewire.ResolvePath(root, p) }
+
+// ── Helpers de nommage d'outils, propres au serveur ────────────────────────
+// (conservés pour les tests ; l'IA ne reçoit plus d'outils node__* — voir la
+// refonte « cible d'exécution » dans llm_client.go / node_server.go.)
+
+// nodeSlug transforme un nom de poste en identifiant sûr ([A-Za-z0-9_-]).
+func nodeSlug(name string) string {
+ s := mcpSanitize(name)
+ if s == "" {
+ s = "poste"
+ }
+ return s
+}
+
+func nodeToolName(slug, cap string) string { return "node__" + slug + "__" + cap }
+
+func isNodeTool(name string) bool { return strings.HasPrefix(name, "node__") }
+
+func parseNodeToolName(name string) (slug, cap string, ok bool) {
+ rest, found := strings.CutPrefix(name, "node__")
+ if !found {
+ return "", "", false
+ }
+ i := strings.LastIndex(rest, "__")
+ if i < 0 {
+ return "", "", false
+ }
+ slug, cap = rest[:i], rest[i+2:]
+ if slug == "" || cap == "" {
+ return "", "", false
+ }
+ return slug, cap, true
+}
diff --git a/internal/ajean/node_proto_test.go b/internal/ajean/node_proto_test.go
new file mode 100644
index 0000000..a5b861d
--- /dev/null
+++ b/internal/ajean/node_proto_test.go
@@ -0,0 +1,89 @@
+package ajean
+
+import (
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestNodeCapIntersect(t *testing.T) {
+ got := nodeCapIntersect(
+ []string{nodeCapShell, nodeCapRead, nodeCapWrite}, // autorisées par le propriétaire
+ []string{nodeCapRead, nodeCapList}, // déclarées par le poste
+ )
+ // Seul read est dans les deux ; l'ordre doit être canonique.
+ if len(got) != 1 || got[0] != nodeCapRead {
+ t.Fatalf("intersection attendue [read], obtenu %v", got)
+ }
+}
+
+func TestNodeSanitizeCapsDropsUnknown(t *testing.T) {
+ got := nodeSanitizeCaps([]string{"write", "danger", "read", "read"})
+ // Ordre canonique (read avant write), doublon et inconnu retirés.
+ want := []string{nodeCapRead, nodeCapWrite}
+ if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
+ t.Fatalf("attendu %v, obtenu %v", want, got)
+ }
+}
+
+func TestNodeToolNameRoundTrip(t *testing.T) {
+ name := nodeToolName("mon_poste", nodeCapShell)
+ if !isNodeTool(name) {
+ t.Fatalf("%s devrait être reconnu comme outil de poste", name)
+ }
+ slug, cap, ok := parseNodeToolName(name)
+ if !ok || slug != "mon_poste" || cap != nodeCapShell {
+ t.Fatalf("parse: ok=%v slug=%q cap=%q", ok, slug, cap)
+ }
+}
+
+func TestNodeToolNameSlugWithDoubleUnderscore(t *testing.T) {
+ // Un slug contenant « __ » ne doit pas casser la reconstitution : on coupe
+ // sur le DERNIER séparateur, et la capacité n'en contient jamais.
+ name := nodeToolName("a__b", nodeCapWrite)
+ slug, cap, ok := parseNodeToolName(name)
+ if !ok || slug != "a__b" || cap != nodeCapWrite {
+ t.Fatalf("parse: ok=%v slug=%q cap=%q", ok, slug, cap)
+ }
+}
+
+func TestNodeResolvePathConfinement(t *testing.T) {
+ root := t.TempDir()
+
+ // Chemin relatif normal : accepté, sous la racine.
+ got, err := nodeResolvePath(root, "sous/dossier/f.txt")
+ if err != nil {
+ t.Fatalf("chemin relatif refusé à tort: %v", err)
+ }
+ if want := filepath.Join(root, "sous", "dossier", "f.txt"); got != want {
+ t.Fatalf("attendu %q, obtenu %q", want, got)
+ }
+
+ // Traversée « .. » : refusée.
+ if _, err := nodeResolvePath(root, "../evade.txt"); err == nil {
+ t.Fatal("la traversée .. aurait dû être refusée")
+ }
+ if _, err := nodeResolvePath(root, "a/../../evade.txt"); err == nil {
+ t.Fatal("la traversée imbriquée aurait dû être refusée")
+ }
+
+ // Chemin absolu hors racine : refusé.
+ outside := filepath.Join(filepath.Dir(root), "ailleurs.txt")
+ if runtime.GOOS == "windows" {
+ outside = `C:\Windows\System32\evil.txt`
+ }
+ if _, err := nodeResolvePath(root, outside); err == nil {
+ t.Fatalf("un chemin absolu hors racine (%s) aurait dû être refusé", outside)
+ }
+
+ // Chemin absolu SOUS la racine : accepté.
+ inside := filepath.Join(root, "ok.txt")
+ if _, err := nodeResolvePath(root, inside); err != nil {
+ t.Fatalf("un chemin absolu sous la racine refusé à tort: %v", err)
+ }
+
+ // Racine vide : refusée (aucun dossier autorisé = aucun accès).
+ if _, err := nodeResolvePath("", "f.txt"); err == nil {
+ t.Fatal("une racine vide aurait dû refuser tout chemin")
+ }
+}
diff --git a/internal/ajean/node_server.go b/internal/ajean/node_server.go
new file mode 100644
index 0000000..1d3d157
--- /dev/null
+++ b/internal/ajean/node_server.go
@@ -0,0 +1,432 @@
+// node_server.go — côté SERVEUR du poste distant : appairage, endpoint
+// WebSocket, registre des postes connectés, et exposition de leurs outils à
+// l'agent. Le serveur n'exécute JAMAIS rien lui-même ici : il route une demande
+// d'outil vers le poste concerné et attend sa réponse.
+package ajean
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+ "github.com/nathaninline/ajean/internal/nodewire"
+)
+
+const (
+ nodePairCodeTTL = 10 * time.Minute
+ nodeCallTimeout = 5 * time.Minute // un shell distant peut être long
+ nodeHelloWait = 15 * time.Second
+)
+
+// pairedNode est un poste appairé, tel que persisté dans bkState["nodes"].
+// L'identité du poste = sa CLÉ PUBLIQUE X25519 (PubHex). Il n'y a plus de « clé
+// d'appareil » partagée : le poste prouve son identité en sachant chiffrer le
+// canal (ECDH avec sa clé privée), ce que le relais ne peut pas reproduire.
+type pairedNode struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ OS string `json:"os"`
+ PubHex string `json:"pub"` // clé publique X25519 du poste (son identité)
+ Caps []string `json:"caps"` // capacités AUTORISÉES par le propriétaire
+ Root string `json:"root,omitempty"`
+ CreatedAt int64 `json:"created_at"`
+ LastSeen int64 `json:"last_seen,omitempty"`
+}
+
+func loadNodes() []pairedNode {
+ var l []pairedNode
+ getJSON(bkState, "nodes", &l)
+ return l
+}
+
+func saveNodes(l []pairedNode) error { return putJSON(bkState, "nodes", l) }
+
+// findNodeByPub retrouve un poste appairé à partir de sa clé publique.
+func findNodeByPub(pub string) *pairedNode {
+ pub = strings.ToLower(strings.TrimSpace(pub))
+ if pub == "" {
+ return nil
+ }
+ nodes := loadNodes()
+ for i := range nodes {
+ if strings.ToLower(nodes[i].PubHex) == pub {
+ return &nodes[i]
+ }
+ }
+ return nil
+}
+
+// agentPrivHex renvoie la clé privée X25519 de l'agent en hex (pour dériver la
+// clé de canal poste↔agent via nodewire). Même clé que le canal navigateur, mais
+// domaine séparé par la constante de dérivation.
+func agentPrivHex() (string, error) {
+ k, err := e2ePrivateKey()
+ if err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(k.Bytes()), nil
+}
+
+// nodeRandHex renvoie n octets aléatoires en hexadécimal (2n caractères).
+func nodeRandHex(n int) string {
+ b := make([]byte, n)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+// ─── Code d'appairage (usage unique, TTL court) ────────────────────────────
+
+type nodePairPending struct {
+ Code string `json:"code"`
+ Caps []string `json:"caps"` // capacités que ce code accordera
+ Root string `json:"root,omitempty"`
+ Expires int64 `json:"expires"`
+}
+
+func loadPairPending() (nodePairPending, bool) {
+ var p nodePairPending
+ if !getJSON(bkState, "node_pair", &p) || p.Code == "" {
+ return nodePairPending{}, false
+ }
+ if time.Now().Unix() > p.Expires {
+ return nodePairPending{}, false
+ }
+ return p, true
+}
+
+func savePairPending(p nodePairPending) error { return putJSON(bkState, "node_pair", p) }
+func clearPairPending() { _ = putJSON(bkState, "node_pair", nodePairPending{}) }
+
+// ─── Registre des postes CONNECTÉS (en mémoire) ────────────────────────────
+
+type nodeConn struct {
+ id string
+ slug string
+ name string
+ os string
+ caps []string // capacités EFFECTIVES (autorisées ∩ déclarées)
+ root string
+
+ conn *websocket.Conn
+ ctx context.Context
+ ch *nodewire.Chan // canal chiffré poste↔agent (relais aveugle)
+ writeMu sync.Mutex
+
+ mu sync.Mutex
+ pending map[string]chan string
+}
+
+// send chiffre le message et l'envoie en un frame opaque : le relais ne voit
+// que du ciphertext.
+func (nc *nodeConn) send(m nodeMsg) error {
+ nc.writeMu.Lock()
+ defer nc.writeMu.Unlock()
+ raw, err := json.Marshal(m)
+ if err != nil {
+ return err
+ }
+ ctx, cancel := context.WithTimeout(nc.ctx, 15*time.Second)
+ defer cancel()
+ return wsjson.Write(ctx, nc.conn, nc.ch.Seal(raw))
+}
+
+// readMsg lit un frame chiffré et le déchiffre en nodeMsg.
+func (nc *nodeConn) readMsg(ctx context.Context) (nodeMsg, error) {
+ var fr nodewire.Frame
+ if err := wsjson.Read(ctx, nc.conn, &fr); err != nil {
+ return nodeMsg{}, err
+ }
+ plain, err := nc.ch.Open(fr)
+ if err != nil {
+ return nodeMsg{}, err
+ }
+ var m nodeMsg
+ if err := json.Unmarshal(plain, &m); err != nil {
+ return nodeMsg{}, err
+ }
+ return m, nil
+}
+
+var (
+ nodeRegMu sync.Mutex
+ nodeReg = map[string]*nodeConn{} // slug → connexion vivante
+)
+
+func nodeRegister(nc *nodeConn) {
+ nodeRegMu.Lock()
+ defer nodeRegMu.Unlock()
+ // Un même slug déjà connecté (reconnexion, ou deux postes de même nom) : on
+ // ferme l'ancien pour éviter deux postes indiscernables sous le même outil.
+ if old, ok := nodeReg[nc.slug]; ok {
+ _ = old.conn.Close(websocket.StatusPolicyViolation, "remplacé par une nouvelle session")
+ }
+ nodeReg[nc.slug] = nc
+}
+
+func nodeUnregister(nc *nodeConn) {
+ nodeRegMu.Lock()
+ defer nodeRegMu.Unlock()
+ if cur, ok := nodeReg[nc.slug]; ok && cur == nc {
+ delete(nodeReg, nc.slug)
+ }
+}
+
+func nodeGet(slug string) *nodeConn {
+ nodeRegMu.Lock()
+ defer nodeRegMu.Unlock()
+ return nodeReg[slug]
+}
+
+func nodeConnected() []*nodeConn {
+ nodeRegMu.Lock()
+ defer nodeRegMu.Unlock()
+ out := make([]*nodeConn, 0, len(nodeReg))
+ for _, nc := range nodeReg {
+ out = append(out, nc)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].slug < out[j].slug })
+ return out
+}
+
+// ─── Endpoint WebSocket : le poste se connecte ici ─────────────────────────
+
+// handleNodeWS accueille la connexion sortante d'un poste. Route PUBLIQUE : elle
+// peut arriver en direct (LAN) OU tunnelée par le relais ajean.link. Dans les
+// deux cas le canal est chiffré de BOUT EN BOUT (poste↔agent) : le relais ne voit
+// que de l'opaque. L'authentification = la capacité du poste à chiffrer avec la
+// clé de canal, que seul le détenteur de la clé privée appairée peut calculer.
+func handleNodeWS(w http.ResponseWriter, r *http.Request) {
+ c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
+ if err != nil {
+ return
+ }
+ c.SetReadLimit(-1)
+ ctx := r.Context()
+
+ // Poignée de main : 1er frame EN CLAIR = la clé publique du poste (elle est
+ // publique, aucun secret). Elle nous dit QUI se connecte et permet de dériver
+ // la clé de canal. Le poste ne pourra chiffrer/déchiffrer que s'il détient la
+ // clé privée correspondante — c'est ça, l'authentification.
+ helloCtx, cancel := context.WithTimeout(ctx, nodeHelloWait)
+ var hp struct {
+ Type string `json:"type"`
+ Pub string `json:"pub"`
+ }
+ err = wsjson.Read(helloCtx, c, &hp)
+ cancel()
+ if err != nil || hp.Type != "hello_pub" || hp.Pub == "" {
+ _ = c.Close(websocket.StatusProtocolError, "hello_pub attendu")
+ return
+ }
+ pn := findNodeByPub(hp.Pub)
+ if pn == nil {
+ _ = c.Close(websocket.StatusPolicyViolation, "poste non appairé")
+ return
+ }
+ privHex, err := agentPrivHex()
+ if err != nil {
+ _ = c.Close(websocket.StatusInternalError, "clé agent indisponible")
+ return
+ }
+ key, err := nodewire.ChannelKey(privHex, hp.Pub)
+ if err != nil {
+ _ = c.Close(websocket.StatusInternalError, "canal")
+ return
+ }
+ ch, err := nodewire.NewChan(key, false) // agent = côté d'envoi 2
+ if err != nil {
+ _ = c.Close(websocket.StatusInternalError, "canal")
+ return
+ }
+
+ nc := &nodeConn{
+ id: pn.ID,
+ conn: c,
+ ctx: ctx,
+ ch: ch,
+ pending: map[string]chan string{},
+ root: pn.Root,
+ }
+
+ // 1er frame CHIFFRÉ : le hello (nom/os/capacités déclarées). S'il ne déchiffre
+ // pas, le poste n'a pas la bonne clé privée → connexion refusée.
+ helloCtx2, cancel2 := context.WithTimeout(ctx, nodeHelloWait)
+ hello, err := nc.readMsg(helloCtx2)
+ cancel2()
+ if err != nil || hello.Type != "hello" {
+ _ = c.Close(websocket.StatusPolicyViolation, "hello chiffré attendu")
+ return
+ }
+ name := strings.TrimSpace(hello.Name)
+ if name == "" {
+ name = pn.Name
+ }
+ nc.name = name
+ nc.slug = nodeSlug(name)
+ nc.os = strings.TrimSpace(hello.OS)
+ nc.caps = nodeCapIntersect(pn.Caps, hello.Caps)
+
+ nodeRegister(nc)
+ defer nodeUnregister(nc)
+ touchNodeSeen(pn.ID)
+
+ // Boucle de lecture : des « result » chiffrés qui débloquent l'appel en attente.
+ for {
+ m, err := nc.readMsg(ctx)
+ if err != nil {
+ _ = c.CloseNow()
+ return
+ }
+ if m.Type == "result" {
+ nc.mu.Lock()
+ ch := nc.pending[m.ID]
+ delete(nc.pending, m.ID)
+ nc.mu.Unlock()
+ if ch != nil {
+ ch <- m.Result
+ }
+ }
+ }
+}
+
+func touchNodeSeen(id string) {
+ nodes := loadNodes()
+ for i := range nodes {
+ if nodes[i].ID == id {
+ nodes[i].LastSeen = time.Now().Unix()
+ _ = saveNodes(nodes)
+ return
+ }
+ }
+}
+
+// ─── Appel d'un outil de poste (routage serveur→poste→serveur) ─────────────
+
+// nodeCall envoie une demande d'exécution au poste et attend son résultat. Toute
+// la sécurité côté serveur tient ici : on ne route que vers un poste connecté et
+// une capacité effectivement autorisée.
+func nodeCall(slug, cap string, args map[string]any) string {
+ nc := nodeGet(slug)
+ if nc == nil {
+ return "[erreur] poste « " + slug + " » déconnecté"
+ }
+ if !nodeCapAllowed(nc.caps, cap) {
+ return "[erreur] capacité « " + cap + " » non autorisée sur ce poste"
+ }
+ id := nodeRandHex(8)
+ ch := make(chan string, 1)
+ nc.mu.Lock()
+ nc.pending[id] = ch
+ nc.mu.Unlock()
+ defer func() {
+ nc.mu.Lock()
+ delete(nc.pending, id)
+ nc.mu.Unlock()
+ }()
+
+ if err := nc.send(nodeMsg{Type: "call", ID: id, Cap: cap, Args: args}); err != nil {
+ return "[erreur] envoi au poste impossible: " + err.Error()
+ }
+ select {
+ case res := <-ch:
+ return res
+ case <-time.After(nodeCallTimeout):
+ return "[timeout] le poste n'a pas répondu à temps"
+ case <-nc.ctx.Done():
+ return "[erreur] poste déconnecté pendant l'appel"
+ }
+}
+
+// nodeEditRemote applique une édition (old→new, old unique) à un fichier d'un
+// poste : on le lit, on remplace localement, on réécrit — via les capacités
+// read + write du poste. Reproduit fileEdit mais à distance.
+func nodeEditRemote(slug, path, oldText, newText string) string {
+ if oldText == "" {
+ return "[erreur] old vide"
+ }
+ content := nodeCall(slug, nodeCapRead, map[string]any{"path": path})
+ if isNodeErr(content) {
+ return content
+ }
+ n := strings.Count(content, oldText)
+ if n == 0 {
+ if newText != "" && strings.Contains(content, newText) {
+ return "[ok] déjà à jour — le fichier contient déjà cette modification"
+ }
+ return "[erreur] old introuvable dans le fichier"
+ }
+ if n > 1 {
+ return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
+ }
+ updated := strings.Replace(content, oldText, newText, 1)
+ return nodeCall(slug, nodeCapWrite, map[string]any{"path": path, "content": updated})
+}
+
+// isNodeErr indique si un résultat d'appel de poste est un échec (à ne pas
+// traiter comme du contenu utile).
+func isNodeErr(s string) bool {
+ return strings.HasPrefix(s, "[erreur]") || strings.HasPrefix(s, "[refusé]") || strings.HasPrefix(s, "[timeout]")
+}
+
+// ─── Cible d'exécution de l'agent (quel PC l'IA pilote) ────────────────────
+
+// agentTargetSlug renvoie le slug du poste sur lequel l'agent agit, ou "" pour
+// le serveur local (comportement historique). Persisté dans bkState.
+func agentTargetSlug() string { return getStr(bkState, "agent_target") }
+
+func setAgentTargetSlug(slug string) error { return putStr(bkState, "agent_target", slug) }
+
+// nodeTargetMeta décrit la cible d'exécution courante pour le prompt système.
+type nodeTargetMeta struct {
+ slug, name, os, root string
+ connected bool
+}
+
+// nodeTargetMetaGet renvoie la cible sélectionnée (ok=false si c'est le serveur
+// local). Complète nom/os/dossier depuis le registre si le poste est connecté,
+// sinon depuis l'enregistrement d'appairage.
+func nodeTargetMetaGet() (nodeTargetMeta, bool) {
+ slug := agentTargetSlug()
+ if slug == "" {
+ return nodeTargetMeta{}, false
+ }
+ m := nodeTargetMeta{slug: slug}
+ if nc := nodeGet(slug); nc != nil {
+ m.name, m.os, m.root, m.connected = nc.name, nc.os, nc.root, true
+ return m, true
+ }
+ for _, n := range loadNodes() {
+ if nodeSlug(n.Name) == slug {
+ m.name, m.os, m.root = n.Name, n.OS, n.Root
+ break
+ }
+ }
+ if m.name == "" {
+ m.name = slug
+ }
+ return m, true
+}
+
+// agentTargetShellName renvoie le shell de la MACHINE CIBLE : cmd.exe si le poste
+// ciblé tourne sous Windows, bash sinon. Sans cible → le shell du serveur local.
+// Indispensable : le serveur peut être sous Linux (bash) alors que le poste est
+// sous Windows (cmd.exe) — annoncer le mauvais shell fait écrire au modèle une
+// syntaxe que la machine cible ne comprend pas.
+func agentTargetShellName() string {
+ if tgt, ok := nodeTargetMetaGet(); ok {
+ if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
+ return "cmd.exe"
+ }
+ return "bash"
+ }
+ return shellName()
+}
diff --git a/internal/ajean/node_ws_test.go b/internal/ajean/node_ws_test.go
new file mode 100644
index 0000000..b9521f5
--- /dev/null
+++ b/internal/ajean/node_ws_test.go
@@ -0,0 +1,138 @@
+package ajean
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/coder/websocket/wsjson"
+ "github.com/nathaninline/ajean/internal/nodewire"
+)
+
+// TestNodeE2E exerce tout le chemin : enrôlement SCELLÉ (le sceau du client est
+// ouvert par e2eOpenSeal de l'agent → interop crypto validée), puis canal
+// CHIFFRÉ poste↔agent (hello + appel d'outil + résultat), et refus d'une
+// capacité non autorisée.
+func TestNodeE2E(t *testing.T) {
+ testHome(t)
+
+ agentPub := e2ePubHex()
+ if agentPub == "" {
+ t.Fatal("clé publique agent indisponible")
+ }
+
+ // Le propriétaire autorise read + shell (pas write).
+ if err := savePairPending(nodePairPending{
+ Code: "ABCD1234",
+ Caps: []string{nodeCapRead, nodeCapShell},
+ Expires: time.Now().Add(time.Minute).Unix(),
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/node/enroll", handleNodeEnroll)
+ mux.HandleFunc("/api/node/ws", handleNodeWS)
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ // 1) Le poste génère sa paire et SCELLE {pub, code, name, os} vers l'agent.
+ priv, pub, err := nodewire.GenKeyPair()
+ if err != nil {
+ t.Fatal(err)
+ }
+ inner, _ := json.Marshal(map[string]string{"pub": pub, "code": "abcd1234", "name": "testpc", "os": "linux/amd64"})
+ blob, err := nodewire.SealTo(agentPub, inner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := json.Marshal(map[string]string{"sealed": base64.StdEncoding.EncodeToString(blob)})
+ resp, err := http.Post(srv.URL+"/api/node/enroll", "application/json", strings.NewReader(string(body)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var enr struct {
+ OK bool `json:"ok"`
+ }
+ _ = json.NewDecoder(resp.Body).Decode(&enr)
+ resp.Body.Close()
+ if !enr.OK {
+ t.Fatalf("enrôlement échoué: %+v", enr)
+ }
+ if findNodeByPub(pub) == nil {
+ t.Fatal("la clé publique du poste n'a pas été enregistrée")
+ }
+
+ // 2) Connexion WS chiffrée.
+ wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/api/node/ws"
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ c, _, err := websocket.Dial(ctx, wsURL, nil)
+ if err != nil {
+ t.Fatalf("dial: %v", err)
+ }
+ defer c.CloseNow()
+
+ // hello_pub en clair, puis canal chiffré côté client.
+ if err := wsjson.Write(ctx, c, map[string]string{"type": "hello_pub", "pub": pub}); err != nil {
+ t.Fatal(err)
+ }
+ key, _ := nodewire.ChannelKey(priv, agentPub)
+ ch, _ := nodewire.NewChan(key, true)
+ sendEnc := func(m nodewire.Msg) { _ = wsjson.Write(ctx, c, ch.Seal(mustJSON(m))) }
+ sendEnc(nodewire.Msg{Type: "hello", Name: "testpc", OS: "linux/amd64", Caps: []string{nodeCapRead, nodeCapShell, nodeCapWrite}})
+
+ // Boucle client : répond à chaque call chiffré par "RESULT:".
+ go func() {
+ for {
+ var fr nodewire.Frame
+ if err := wsjson.Read(context.Background(), c, &fr); err != nil {
+ return
+ }
+ plain, err := ch.Open(fr)
+ if err != nil {
+ return
+ }
+ var m nodewire.Msg
+ if json.Unmarshal(plain, &m) == nil && m.Type == "call" {
+ sendEnc(nodewire.Msg{Type: "result", ID: m.ID, Result: "RESULT:" + m.Cap})
+ }
+ }
+ }()
+
+ // Attend l'enregistrement.
+ slug := nodeSlug("testpc")
+ var nc *nodeConn
+ for i := 0; i < 200; i++ {
+ if nc = nodeGet(slug); nc != nil {
+ break
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ if nc == nil {
+ t.Fatal("le poste ne s'est pas enregistré (canal chiffré ?)")
+ }
+ if len(nc.caps) != 2 || nc.caps[0] != nodeCapShell || nc.caps[1] != nodeCapRead {
+ t.Fatalf("capacités effectives inattendues: %v", nc.caps)
+ }
+
+ // 3) Appel autorisé routé jusqu'au poste et retour, à travers le chiffrement.
+ if got := nodeCall(slug, nodeCapRead, map[string]any{"path": "x"}); got != "RESULT:read" {
+ t.Fatalf("appel read: attendu RESULT:read, obtenu %q", got)
+ }
+ // 4) Appel non autorisé (write) refusé côté serveur.
+ if got := nodeCall(slug, nodeCapWrite, map[string]any{"path": "x"}); !strings.Contains(got, "non autorisée") {
+ t.Fatalf("appel write aurait dû être refusé, obtenu %q", got)
+ }
+}
+
+func mustJSON(v any) []byte {
+ b, _ := json.Marshal(v)
+ return b
+}
diff --git a/internal/ajean/relay_e2e.go b/internal/ajean/relay_e2e.go
new file mode 100644
index 0000000..33aecdb
--- /dev/null
+++ b/internal/ajean/relay_e2e.go
@@ -0,0 +1,264 @@
+package ajean
+
+// relay_e2e.go — chiffrement bout-en-bout du chat entre le NAVIGATEUR et CET agent, de
+// sorte que le relais (ajean.link) ne voie que de l'opaque : « boîte noire ».
+//
+// Modèle : cet agent a une paire X25519 long-terme (clé privée locale, jamais
+// transmise). Sa clé publique est publiée au relais et son EMPREINTE est affichée
+// au `ajean link` — l'utilisateur la confirme dans le portail, ce qui défait tout
+// MITM du relais. Le navigateur dérive une racine R de son mot de passe (exportKey
+// OPAQUE), la SCELLE vers la clé publique de l'agent (le relais ne peut pas
+// l'ouvrir), puis chiffre/déchiffre le chat avec une clé dérivée de R.
+//
+// Aucune dépendance externe : crypto/ecdh (X25519) + AES-GCM de la stdlib.
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/ecdh"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+)
+
+func e2eKeyPath() string { return filepath.Join(AjeanHome(), ".e2e_key") }
+
+var (
+ e2eOnce sync.Once
+ e2eKey *ecdh.PrivateKey
+ e2eErr error
+)
+
+// e2ePrivateKey charge (ou crée puis persiste) la clé privée X25519 de l'agent.
+func e2ePrivateKey() (*ecdh.PrivateKey, error) {
+ e2eOnce.Do(func() {
+ if b, err := os.ReadFile(e2eKeyPath()); err == nil {
+ raw, derr := hex.DecodeString(strings.TrimSpace(string(b)))
+ if derr == nil {
+ e2eKey, e2eErr = ecdh.X25519().NewPrivateKey(raw)
+ return
+ }
+ }
+ k, err := ecdh.X25519().GenerateKey(rand.Reader)
+ if err != nil {
+ e2eErr = err
+ return
+ }
+ _ = os.MkdirAll(AjeanHome(), 0o755)
+ if err := os.WriteFile(e2eKeyPath(), []byte(hex.EncodeToString(k.Bytes())+"\n"), 0o600); err != nil {
+ e2eErr = err
+ return
+ }
+ e2eKey = k
+ })
+ return e2eKey, e2eErr
+}
+
+// e2ePubHex retourne la clé publique X25519 de l'agent (hex), pour publication au relais.
+func e2ePubHex() string {
+ k, err := e2ePrivateKey()
+ if err != nil {
+ return ""
+ }
+ return hex.EncodeToString(k.PublicKey().Bytes())
+}
+
+// e2eFingerprint retourne une empreinte lisible de la clé publique (à comparer
+// dans le portail). Format : 8 groupes hex de 4 = SHA-256(pub) tronqué.
+func e2eFingerprint() string {
+ k, err := e2ePrivateKey()
+ if err != nil {
+ return ""
+ }
+ h := sha256.Sum256(k.PublicKey().Bytes())
+ s := hex.EncodeToString(h[:8]) // 16 hex
+ var parts []string
+ for i := 0; i < len(s); i += 4 {
+ parts = append(parts, strings.ToUpper(s[i:i+4]))
+ }
+ return strings.Join(parts, "-")
+}
+
+// e2eOpenSeal ouvre une boîte scellée (ephPub32 || nonce12 || ct) chiffrée vers
+// la clé publique de l'agent, et retourne le secret en clair (la racine R).
+func e2eOpenSeal(blob []byte) ([]byte, error) {
+ if len(blob) < 32+12+16 {
+ return nil, fmt.Errorf("sceau trop court")
+ }
+ priv, err := e2ePrivateKey()
+ if err != nil {
+ return nil, err
+ }
+ ephPubBytes := blob[:32]
+ nonce := blob[32:44]
+ ct := blob[44:]
+ ephPub, err := ecdh.X25519().NewPublicKey(ephPubBytes)
+ if err != nil {
+ return nil, err
+ }
+ shared, err := priv.ECDH(ephPub)
+ if err != nil {
+ return nil, err
+ }
+ key := sealKey(shared, ephPubBytes, priv.PublicKey().Bytes())
+ gcm, err := newGCM(key)
+ if err != nil {
+ return nil, err
+ }
+ return gcm.Open(nil, nonce, ct, nil)
+}
+
+// sealKey dérive la clé AES de la boîte scellée (liée aux deux clés publiques).
+func sealKey(shared, ephPub, agentPub []byte) []byte {
+ h := sha256.New()
+ h.Write(shared)
+ h.Write(ephPub)
+ h.Write(agentPub)
+ return h.Sum(nil)
+}
+
+func newGCM(key []byte) (cipher.AEAD, error) {
+ blk, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, err
+ }
+ return cipher.NewGCM(blk)
+}
+
+// handleE2EChat : chat chiffré de bout en bout, AUTHENTIFIÉ. L'enveloppe est
+// déchiffrée avec la clé du canal authentifié (liée à l'identité appairée de
+// l'utilisateur) — un relais ne peut ni la lire ni la forger. Chaque événement SSE
+// est chiffré. Le relais ne voit que de l'opaque.
+func handleE2EChat(w http.ResponseWriter, r *http.Request) {
+ plain, key, err := e2eAuthOpenReq(r)
+ if err != nil {
+ http.Error(w, "e2e: "+err.Error(), http.StatusForbidden)
+ return
+ }
+ gcm, err := newGCM(key)
+ if err != nil {
+ http.Error(w, "e2e: clé", 500)
+ return
+ }
+ var body chatReq
+ if err := json.Unmarshal(plain, &body); err != nil {
+ http.Error(w, "e2e: requête", 400)
+ return
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ // no-transform : interdit à Cloudflare (proxy orange sur ajean.link) de
+ // bufferiser/compresser le flux — sinon les événements arrivent en retard.
+ 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}}})
+ nonce := make([]byte, 12)
+ if _, err := rand.Read(nonce); err != nil {
+ return false
+ }
+ sealedEv := append(nonce, gcm.Seal(nil, nonce, b, nil)...)
+ mu.Lock()
+ defer mu.Unlock()
+ if _, err := w.Write([]byte("data: " + base64.StdEncoding.EncodeToString(sealedEv) + "\n\n")); err != nil {
+ return false
+ }
+ if flusher != nil {
+ flusher.Flush()
+ }
+ return true
+ }
+ runChatStream(r.Context(), body, emit)
+}
+
+// handleE2EReq : proxy de CONTRÔLE chiffré de bout en bout. Même enveloppe que le
+// chat, mais le clair décrit un appel d'API interne {method, path, body}. On le
+// redispatche dans le handler web LOCAL (inner, déjà authentifié), puis on chiffre
+// la réponse {status, body}. Résultat : toute la gestion du serveur (presets, VRAM,
+// skills, service…) transite par le relais SANS qu'il en voie le contenu. « Zéro
+// exception » : le tunnel refuse tout /api/* en clair, seuls /api/e2e/* passent.
+func handleE2EReq(w http.ResponseWriter, r *http.Request, inner http.Handler) {
+ plain, key, err := e2eAuthOpenReq(r)
+ if err != nil {
+ http.Error(w, "e2e: "+err.Error(), http.StatusForbidden)
+ return
+ }
+ gcm, err := newGCM(key)
+ if err != nil {
+ http.Error(w, "e2e: clé", 500)
+ return
+ }
+ var req struct {
+ Method string `json:"method"`
+ Path string `json:"path"`
+ Body json.RawMessage `json:"body"`
+ }
+ if err := json.Unmarshal(plain, &req); err != nil {
+ writeE2EResp(w, gcm, 400, []byte(`{"error":"requête invalide"}`))
+ return
+ }
+ // Sécurité : on ne dispatche QUE des chemins d'API internes, jamais /api/e2e/*
+ // (pas de récursion) ni autre chose (pas d'accès à l'UI/aux assets par ce biais).
+ if !strings.HasPrefix(req.Path, "/api/") || strings.HasPrefix(req.Path, "/api/e2e") {
+ writeE2EResp(w, gcm, 403, []byte(`{"error":"chemin interdit"}`))
+ return
+ }
+ method := req.Method
+ if method == "" {
+ method = "GET"
+ }
+ // Un chemin/méthode mal formé ferait paniquer httptest.NewRequest : on récupère.
+ defer func() {
+ if rec := recover(); rec != nil {
+ writeE2EResp(w, gcm, 400, []byte(`{"error":"requête malformée"}`))
+ }
+ }()
+ var bodyReader io.Reader
+ if len(req.Body) > 0 && string(req.Body) != "null" {
+ bodyReader = bytes.NewReader(req.Body)
+ }
+ ir := httptest.NewRequest(method, req.Path, bodyReader)
+ ir.Header.Set("Content-Type", "application/json")
+ // Marque la requête comme arrivée PAR LE TUNNEL. Tout ce qui ressort d'ici est
+ // réemballé en JSON (voir plus bas) : un handler qui renvoie du binaire — le
+ // téléchargement d'un fichier — doit le savoir pour proposer une forme qui
+ // traverse, sans quoi le client reçoit l'enveloppe JSON à la place du fichier.
+ ir.Header.Set(e2eInnerHeader, "1")
+ rec := httptest.NewRecorder()
+ inner.ServeHTTP(rec, ir)
+ respBody := rec.Body.Bytes()
+ if len(respBody) == 0 {
+ respBody = []byte("null")
+ } else if !json.Valid(respBody) {
+ // Réponse non-JSON (ex : http.Error en texte) : on l'enveloppe en chaîne.
+ respBody, _ = json.Marshal(string(respBody))
+ }
+ writeE2EResp(w, gcm, rec.Code, respBody)
+}
+
+// writeE2EResp chiffre {status, body} avec la clé du chat et l'écrit en base64
+// (nonce||ct). Le relais ne voit que de l'opaque.
+func writeE2EResp(w http.ResponseWriter, gcm cipher.AEAD, status int, body []byte) {
+ out, _ := json.Marshal(map[string]any{"status": status, "body": json.RawMessage(body)})
+ nonce := make([]byte, 12)
+ if _, err := rand.Read(nonce); err != nil {
+ http.Error(w, "e2e: nonce", 500)
+ return
+ }
+ sealed := append(nonce, gcm.Seal(nil, nonce, out, nil)...)
+ w.Header().Set("Content-Type", "text/plain")
+ _, _ = w.Write([]byte(base64.StdEncoding.EncodeToString(sealed)))
+}
diff --git a/internal/ajean/relay_e2eauth.go b/internal/ajean/relay_e2eauth.go
new file mode 100644
index 0000000..d56a460
--- /dev/null
+++ b/internal/ajean/relay_e2eauth.go
@@ -0,0 +1,351 @@
+package ajean
+
+// relay_e2eauth.go — authentification MUTUELLE + anti-rejeu du canal chiffré.
+//
+// Le scellé anonyme (boîte scellée) garantit la confidentialité mais PAS
+// l'authenticité : n'importe qui connaissant la clé publique de l'agent (dont un
+// relais compromis) peut fabriquer une enveloppe. Ici on ferme ce trou.
+//
+// Modèle : l'utilisateur a une identité X25519 dérivée de façon déterministe de son
+// mot de passe (exportKey OPAQUE → racine R → uPriv) — le relais ne connaît pas R,
+// donc ne peut PAS reproduire cette identité. La clé publique uPub est APPAIRÉE une
+// fois à l'agent via un code affiché par « ajean link » (canal hors-bande : le log du
+// serveur, que le relais ne voit pas ; le code voyage scellé vers l'agent). Ensuite,
+// chaque requête est chiffrée avec K = SHA256(ECDH(uPriv, agentPriv) || "authchan"),
+// liée à uPub+horodatage (AAD) et protégée contre le rejeu. Seul le vrai utilisateur
+// peut produire une requête valide ; le relais est totalement verrouillé.
+
+import (
+ "crypto/ecdh"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base32"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+)
+
+// Fenêtre d'acceptation de l'horodatage (anti-rejeu) : ±90 s pour tolérer une
+// dérive d'horloge raisonnable entre l'appareil et le serveur.
+const e2eAuthWindowMs = 90_000
+
+// ---- Identités utilisateur appairées ---------------------------------------
+
+var (
+ authOnce sync.Once
+ authMu sync.Mutex
+ authSet = map[string]bool{}
+)
+
+func loadAuthUsers() {
+ authOnce.Do(func() {
+ var list []string
+ getJSON(bkState, "authorized_users", &list)
+ for _, h := range list {
+ if h = strings.ToLower(strings.TrimSpace(h)); isHexPub(h) {
+ authSet[h] = true
+ }
+ }
+ })
+}
+
+func isHexPub(h string) bool {
+ if len(h) != 64 {
+ return false
+ }
+ _, err := hex.DecodeString(h)
+ return err == nil
+}
+
+func isAuthorizedUser(uPubHex string) bool {
+ loadAuthUsers()
+ authMu.Lock()
+ defer authMu.Unlock()
+ return authSet[strings.ToLower(uPubHex)]
+}
+
+func authorizeUser(uPubHex string) error {
+ uPubHex = strings.ToLower(strings.TrimSpace(uPubHex))
+ if !isHexPub(uPubHex) {
+ return fmt.Errorf("clé publique invalide")
+ }
+ loadAuthUsers()
+ authMu.Lock()
+ defer authMu.Unlock()
+ if authSet[uPubHex] {
+ return nil
+ }
+ authSet[uPubHex] = true
+ list := make([]string, 0, len(authSet))
+ for h := range authSet {
+ list = append(list, h)
+ }
+ sort.Strings(list)
+ return putJSON(bkState, "authorized_users", list)
+}
+
+// ---- Codes d'appairage : à la demande, usage unique, TTL 10 min --------------
+//
+// Générés par « ajean link code » (ou affichés par « ajean link »), ils sont
+// partagés avec le service d'interface (autre process) via la base — sinon les
+// deux ne s'accorderaient pas. Stockés HACHÉS (SHA-256) : la base ne révèle
+// aucun code. Chaque code expire au bout de 10 min et est consommé (retiré) au
+// premier appairage réussi.
+
+const pairCodeTTL = 10 * time.Minute
+
+type pairEntry struct {
+ Hash string `json:"h"` // hex SHA-256 du code
+ Exp int64 `json:"e"` // expiration (unix ms)
+}
+
+func hashPairCode(code string) string {
+ h := sha256.Sum256([]byte(strings.ToUpper(strings.TrimSpace(code))))
+ return hex.EncodeToString(h[:])
+}
+
+// loadPairEntries lit le store et purge au passage les entrées expirées.
+func loadPairEntries() []pairEntry {
+ var es []pairEntry
+ if !getJSON(bkState, "pair_codes", &es) {
+ return nil
+ }
+ now := time.Now().UnixMilli()
+ kept := es[:0]
+ for _, e := range es {
+ if e.Exp > now {
+ kept = append(kept, e)
+ }
+ }
+ return kept
+}
+
+func savePairEntries(es []pairEntry) error { return putJSON(bkState, "pair_codes", es) }
+
+// newPairCode génère un code frais (usage unique, 10 min), le persiste haché et
+// le retourne en clair.
+func newPairCode() (string, error) {
+ b := make([]byte, 5) // 40 bits → 8 caractères base32
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ code := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
+ es := append(loadPairEntries(), pairEntry{
+ Hash: hashPairCode(code),
+ Exp: time.Now().Add(pairCodeTTL).UnixMilli(),
+ })
+ if err := savePairEntries(es); err != nil {
+ return "", err
+ }
+ return code, nil
+}
+
+// consumePairCode renvoie true si le code est valide (non expiré, non déjà
+// utilisé) et le CONSOMME (usage unique).
+func consumePairCode(code string) bool {
+ if strings.TrimSpace(code) == "" {
+ return false
+ }
+ want := hashPairCode(code)
+ es := loadPairEntries()
+ out := make([]pairEntry, 0, len(es))
+ found := false
+ for _, e := range es {
+ if !found && e.Hash == want {
+ found = true // retiré du store = consommé
+ continue
+ }
+ out = append(out, e)
+ }
+ if found {
+ _ = savePairEntries(out)
+ }
+ return found
+}
+
+// Limitation des tentatives d'appairage (anti-brute-force du code via le relais).
+var (
+ pairFailMu sync.Mutex
+ pairFails int
+ pairLockAt time.Time
+)
+
+func pairLocked() bool {
+ pairFailMu.Lock()
+ defer pairFailMu.Unlock()
+ if pairFails >= 10 {
+ if time.Since(pairLockAt) < 5*time.Minute {
+ return true
+ }
+ pairFails = 0 // fenêtre expirée
+ }
+ return false
+}
+
+func pairRecordFail() {
+ pairFailMu.Lock()
+ defer pairFailMu.Unlock()
+ pairFails++
+ pairLockAt = time.Now()
+}
+
+func pairReset() {
+ pairFailMu.Lock()
+ defer pairFailMu.Unlock()
+ pairFails = 0
+}
+
+// ---- Anti-rejeu -------------------------------------------------------------
+
+var (
+ replayMu sync.Mutex
+ replaySeen = map[string]time.Time{}
+)
+
+// replayCheck renvoie true si la requête (upub|ts|iv) a déjà été vue (= rejeu).
+// Sinon elle l'enregistre. Purge opportuniste des entrées hors fenêtre.
+func replayCheck(uPub string, ts int64, iv string) bool {
+ key := uPub + "|" + fmt.Sprint(ts) + "|" + iv
+ now := time.Now()
+ replayMu.Lock()
+ defer replayMu.Unlock()
+ if len(replaySeen) > 4096 {
+ for k, t := range replaySeen {
+ if now.Sub(t) > 2*e2eAuthWindowMs*time.Millisecond {
+ delete(replaySeen, k)
+ }
+ }
+ }
+ if _, ok := replaySeen[key]; ok {
+ return true
+ }
+ replaySeen[key] = now
+ return false
+}
+
+// ---- Dérivation de clé + ouverture authentifiée ----------------------------
+
+// e2eAuthKey dérive la clé du canal authentifié pour l'utilisateur uPub :
+// SHA256( ECDH(agentPriv, uPub) || "ajean-authchan-v1" ). Symétrique de la version
+// navigateur (WASM) qui calcule ECDH(uPriv, agentPub) = même secret partagé.
+func e2eAuthKey(uPubHex string) ([]byte, error) {
+ priv, err := e2ePrivateKey()
+ if err != nil {
+ return nil, err
+ }
+ uPubBytes, err := hex.DecodeString(uPubHex)
+ if err != nil {
+ return nil, err
+ }
+ uPub, err := ecdh.X25519().NewPublicKey(uPubBytes)
+ if err != nil {
+ return nil, err
+ }
+ ss, err := priv.ECDH(uPub)
+ if err != nil {
+ return nil, err
+ }
+ h := sha256.New()
+ h.Write(ss)
+ h.Write([]byte("ajean-authchan-v1"))
+ return h.Sum(nil), nil
+}
+
+func e2eAuthAAD(uPub string, ts int64) []byte {
+ return []byte(uPub + "|" + fmt.Sprint(ts))
+}
+
+// e2eAuthOpenReq lit l'enveloppe authentifiée {upub, ts, iv, ct} d'une requête,
+// vérifie l'appairage / l'horodatage / le rejeu, puis déchiffre. Renvoie le clair
+// et la clé de canal (réutilisée pour chiffrer la réponse).
+func e2eAuthOpenReq(r *http.Request) (plain []byte, key []byte, err error) {
+ var env struct {
+ UPub string `json:"upub"`
+ Ts int64 `json:"ts"`
+ Iv string `json:"iv"`
+ Ct string `json:"ct"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&env); err != nil {
+ return nil, nil, fmt.Errorf("enveloppe invalide")
+ }
+ if !isAuthorizedUser(env.UPub) {
+ return nil, nil, fmt.Errorf("appareil non appairé (confirme le code d'appairage)")
+ }
+ now := time.Now().UnixMilli()
+ if d := now - env.Ts; d > e2eAuthWindowMs || d < -e2eAuthWindowMs {
+ return nil, nil, fmt.Errorf("horodatage hors fenêtre")
+ }
+ if replayCheck(env.UPub, env.Ts, env.Iv) {
+ return nil, nil, fmt.Errorf("rejeu détecté")
+ }
+ key, err = e2eAuthKey(env.UPub)
+ if err != nil {
+ return nil, nil, err
+ }
+ gcm, err := newGCM(key)
+ if err != nil {
+ return nil, nil, err
+ }
+ iv, _ := base64.StdEncoding.DecodeString(env.Iv)
+ ct, _ := base64.StdEncoding.DecodeString(env.Ct)
+ plain, err = gcm.Open(nil, iv, ct, e2eAuthAAD(env.UPub, env.Ts))
+ if err != nil {
+ return nil, nil, fmt.Errorf("authentification échouée")
+ }
+ return plain, key, nil
+}
+
+// handleE2EPair : appairage d'une identité utilisateur. Le navigateur scelle
+// {upub, code} vers la clé publique de l'agent (le relais ne peut ni l'ouvrir ni
+// connaître le code, affiché uniquement dans le log du serveur). Si le code matche,
+// uPub est enregistré comme autorisé.
+func handleE2EPair(w http.ResponseWriter, r *http.Request) {
+ if pairLocked() {
+ http.Error(w, "trop de tentatives d'appairage, réessaie plus tard", http.StatusTooManyRequests)
+ return
+ }
+ var body struct {
+ Sealed string `json:"sealed"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
+ http.Error(w, "appairage: requête invalide", 400)
+ return
+ }
+ blob, err := base64.StdEncoding.DecodeString(body.Sealed)
+ if err != nil {
+ http.Error(w, "appairage: format", 400)
+ return
+ }
+ plain, err := e2eOpenSeal(blob)
+ if err != nil {
+ pairRecordFail()
+ http.Error(w, "appairage: sceau invalide", 400)
+ return
+ }
+ var pm struct {
+ UPub string `json:"upub"`
+ Code string `json:"code"`
+ }
+ if err := json.Unmarshal(plain, &pm); err != nil {
+ http.Error(w, "appairage: contenu", 400)
+ return
+ }
+ if !consumePairCode(pm.Code) {
+ pairRecordFail()
+ http.Error(w, "appairage: code incorrect ou expiré", http.StatusForbidden)
+ return
+ }
+ if err := authorizeUser(pm.UPub); err != nil {
+ http.Error(w, "appairage: "+err.Error(), 500)
+ return
+ }
+ pairReset()
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"ok":true}`))
+}
diff --git a/internal/ajean/relay_e2eauth_test.go b/internal/ajean/relay_e2eauth_test.go
new file mode 100644
index 0000000..a75accf
--- /dev/null
+++ b/internal/ajean/relay_e2eauth_test.go
@@ -0,0 +1,111 @@
+package ajean
+
+import (
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestIsHexPub(t *testing.T) {
+ ok := strings.Repeat("ab", 32) // 64 hex chars
+ cases := map[string]bool{
+ ok: true,
+ strings.ToUpper(ok): true,
+ ok[:62]: false, // trop court
+ ok + "cd": false, // trop long
+ strings.Repeat("zz", 32): false, // pas hexadécimal
+ "": false,
+ strings.Repeat("ab", 31) + "g1": false,
+ }
+ for in, want := range cases {
+ if got := isHexPub(in); got != want {
+ t.Errorf("isHexPub(%q) = %v, want %v", in, got, want)
+ }
+ }
+}
+
+func TestHashPairCodeNormalise(t *testing.T) {
+ // Le code est normalisé (majuscules + trim) avant hachage : l'utilisateur
+ // peut le taper en minuscules ou avec des espaces autour.
+ ref := hashPairCode("ABCD-1234")
+ for _, v := range []string{"abcd-1234", " ABCD-1234 ", "Abcd-1234\n"} {
+ if hashPairCode(v) != ref {
+ t.Errorf("hashPairCode(%q) devrait égaler hashPairCode(\"ABCD-1234\")", v)
+ }
+ }
+ if hashPairCode("ABCD-1235") == ref {
+ t.Error("codes différents → hashs différents attendus")
+ }
+}
+
+func TestReplayCheck(t *testing.T) {
+ replayMu.Lock()
+ replaySeen = map[string]time.Time{}
+ replayMu.Unlock()
+
+ ts := time.Now().UnixMilli()
+ if replayCheck("pubA", ts, "iv1") {
+ t.Fatal("première vue : ne doit pas être un rejeu")
+ }
+ if !replayCheck("pubA", ts, "iv1") {
+ t.Fatal("même (upub|ts|iv) revu : doit être détecté comme rejeu")
+ }
+ // Une composante différente = requête distincte, pas un rejeu.
+ if replayCheck("pubA", ts, "iv2") || replayCheck("pubB", ts, "iv1") || replayCheck("pubA", ts+1, "iv1") {
+ t.Error("clé différente (iv/upub/ts) ne doit pas être vue comme rejeu")
+ }
+}
+
+func TestReplayCheckPurge(t *testing.T) {
+ replayMu.Lock()
+ replaySeen = map[string]time.Time{}
+ old := time.Now().Add(-3 * e2eAuthWindowMs * time.Millisecond)
+ for i := 0; i < 5000; i++ {
+ replaySeen[strings.Repeat("x", 8)+string(rune(i))] = old
+ }
+ replayMu.Unlock()
+
+ // Au-delà de 4096 entrées, les entrées hors fenêtre sont purgées au passage.
+ replayCheck("pub", time.Now().UnixMilli(), "iv")
+ replayMu.Lock()
+ n := len(replaySeen)
+ replayMu.Unlock()
+ if n > 10 {
+ t.Errorf("purge attendue des vieilles entrées, il en reste %d", n)
+ }
+}
+
+func TestPairLockout(t *testing.T) {
+ pairReset()
+ t.Cleanup(pairReset)
+
+ if pairLocked() {
+ t.Fatal("pas de tentative : pas de verrou")
+ }
+ for i := 0; i < 9; i++ {
+ pairRecordFail()
+ }
+ if pairLocked() {
+ t.Fatal("9 échecs : pas encore verrouillé (seuil = 10)")
+ }
+ pairRecordFail()
+ if !pairLocked() {
+ t.Fatal("10 échecs : appairage verrouillé attendu")
+ }
+ // Fenêtre de 5 min expirée → le verrou saute et le compteur repart.
+ pairFailMu.Lock()
+ pairLockAt = time.Now().Add(-6 * time.Minute)
+ pairFailMu.Unlock()
+ if pairLocked() {
+ t.Fatal("fenêtre expirée : le verrou doit être levé")
+ }
+ if pairLocked() {
+ t.Fatal("après reset du compteur, toujours déverrouillé")
+ }
+}
+
+func TestE2EAuthAAD(t *testing.T) {
+ if string(e2eAuthAAD("pub", 42)) != "pub|42" {
+ t.Errorf("AAD inattendu : %q", e2eAuthAAD("pub", 42))
+ }
+}
diff --git a/internal/ajean/relay_link.go b/internal/ajean/relay_link.go
new file mode 100644
index 0000000..0fb8783
--- /dev/null
+++ b/internal/ajean/relay_link.go
@@ -0,0 +1,659 @@
+package ajean
+
+// relay_link.go — `ajean link ` : connecte ce serveur AJEAN au relais public
+// (ajean.link) par une connexion SORTANTE persistante, pour qu'un utilisateur
+// y accède depuis n'importe où sans ouvrir de port (CGNAT, box, etc.).
+//
+// Principe : l'agent ouvre un WebSocket vers le relais, l'authentifie avec le
+// token d'abonnement, puis multiplexe (yamux) ce lien unique en un stream par
+// requête navigateur. Chaque stream est reverse-proxyfié vers le `ajean web`
+// local. Keepalive + reconnexion automatique avec backoff.
+//
+// Le token est fourni par la boutique à l'achat. Il est mémorisé dans
+// $AJEAN_HOME/.link_token pour que `ajean link` (sans argument) reprenne la
+// connexion.
+
+import (
+ "bufio"
+ "context"
+ "crypto/rand"
+ "crypto/tls"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net"
+ "net/http"
+ "net/http/httputil"
+ "net/url"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/coder/websocket"
+ "github.com/hashicorp/yamux"
+)
+
+// defaultRelayURL est l'endpoint WebSocket du relais. Surchageable via
+// $AJEAN_LINK_URL (utile pour tester contre un relais local).
+const defaultRelayURL = "wss://ajean.link/agent"
+
+// machineID renvoie un identifiant stable de la machine, créé au premier appel.
+// Permet au relais de regrouper les connexions d'une même machine sous le compte.
+func machineID() string {
+ if id := getStr(bkState, "link_machine"); id != "" {
+ return id
+ }
+ buf := make([]byte, 8)
+ _, _ = rand.Read(buf)
+ id := hex.EncodeToString(buf)
+ _ = putStr(bkState, "link_machine", id)
+ return id
+}
+
+// readLinkToken renvoie la clé d'abonnement enregistrée, ou "".
+func readLinkToken() string { return getStr(bkState, "link_token") }
+
+func saveLinkToken(tok string) error { return putStr(bkState, "link_token", tok) }
+
+// removeLinkToken oublie la clé de liaison enregistrée (idempotent).
+func removeLinkToken() error { return putStr(bkState, "link_token", "") }
+
+// relayURL resolves the relay WebSocket endpoint (env override → default).
+func relayURL() string {
+ if u := os.Getenv("AJEAN_LINK_URL"); u != "" {
+ return u
+ }
+ return defaultRelayURL
+}
+
+// uiUnitName est l'unité qui exécute « ajean web » : l'UI locale, le tunnel du
+// relais et l'endpoint OpenAI, dans un seul et même process.
+const uiUnitName = "ajean-ui"
+
+func uiServiceName() string { return uiUnitName }
+
+// cmdLink ne gère QUE le compte d'accès distant : le jeton, l'appairage, l'état.
+// Le tunnel n'est plus un service à part — il est ouvert par « ajean web » dès
+// qu'un jeton est enregistré. Piloter le tunnel, c'est donc piloter le service
+// d'interface (voir cmdUI).
+func cmdLink(args []string) error {
+ sub := ""
+ if len(args) > 0 {
+ sub = args[0]
+ }
+ switch sub {
+ case "":
+ // `ajean link` seul : afficher l'aide (NE démarre rien — éviter de prendre
+ // un mot pour un jeton et d'écraser le vrai).
+ printLinkHelp()
+ return nil
+ case "status":
+ tok := readLinkToken()
+ if tok == "" {
+ fmt.Println(yellow("[info]") + " aucun jeton enregistré — lance: ajean link ")
+ return nil
+ }
+ fmt.Printf("%s jeton enregistré (%s…), relais: %s\n", green("[ok]"), tok[:min(8, len(tok))], relayURL())
+ if uiServiceActive() {
+ fmt.Printf("%s service %s actif — le tunnel est ouvert\n", green("[ok]"), uiServiceName())
+ } else {
+ fmt.Printf("%s service %s arrêté — pas de tunnel (ajean ui start)\n", yellow("[info]"), uiServiceName())
+ }
+ return nil
+ case "logout":
+ if err := removeLinkToken(); err != nil {
+ return err
+ }
+ fmt.Println(green("[ok]") + " jeton supprimé — « ajean ui restart » pour fermer le tunnel")
+ return nil
+ case "code":
+ code, err := newPairCode()
+ if err != nil {
+ return fmt.Errorf("génération du code (droits sur %s ?): %w", AjeanHome(), err)
+ }
+ fmt.Printf("%s code d'appairage (valable 10 min, à usage unique) :\n %s\n", green("[link]"), bold(code))
+ return nil
+ }
+
+ // Un argument restant n'est traité comme JETON que s'il en a la forme (`jl_…`).
+ // Sinon c'est une faute de frappe / sous-commande inconnue : on REFUSE, sans
+ // jamais écraser le jeton enregistré (le bug qui rendait le serveur injoignable).
+ if !strings.HasPrefix(sub, "jl_") {
+ fmt.Fprintf(os.Stderr, "%s sous-commande inconnue : %q\n\n", yellow("[link]"), sub)
+ printLinkHelp()
+ return fmt.Errorf("sous-commande link inconnue: %s", sub)
+ }
+ if err := saveLinkToken(strings.TrimSpace(sub)); err != nil {
+ return err
+ }
+ // Nouveau jeton : le service d'interface doit le relire pour ouvrir le tunnel.
+ if err := uiServiceCtl("restart"); err != nil {
+ return err
+ }
+ return linkPrintIdentity()
+}
+
+// cmdUI pilote le service d'interface — « ajean web » en arrière-plan : l'UI
+// locale, le tunnel du relais et l'endpoint OpenAI, servis par le MÊME process.
+// C'est ce qui garantit une conversation unique, identique en local et à distance.
+func cmdUI(args []string) error {
+ action := "status"
+ if len(args) > 0 && args[0] != "" {
+ action = args[0]
+ }
+ switch action {
+ case "start", "stop", "restart":
+ return uiServiceCtl(action)
+ case "status":
+ if uiServiceActive() {
+ fmt.Printf("%s service %s : actif\n", green("[ok]"), uiServiceName())
+ } else {
+ fmt.Printf("%s service %s : arrêté\n", yellow("[info]"), uiServiceName())
+ }
+ if readLinkToken() != "" {
+ fmt.Printf(" accès distant : jeton enregistré — le tunnel s'ouvre avec le service\n")
+ }
+ return nil
+ default:
+ return fmt.Errorf("usage: ajean ui [start|stop|restart|status]")
+ }
+}
+
+// printLinkHelp liste les sous-commandes de `ajean link`.
+func printLinkHelp() {
+ fmt.Print(`ajean link — accès distant via le relais ajean.link
+
+Usage :
+ ajean link enregistre le jeton (1re fois / pour le changer) et ouvre le tunnel
+ ajean link status état du jeton et du tunnel
+ ajean link code génère un code d'appairage (valable 10 min, à usage unique)
+ ajean link logout oublie le jeton enregistré
+
+Le jeton est fourni sur ajean.link. Le tunnel est ouvert par le service
+d'interface (« ajean ui ») dès qu'un jeton est enregistré : il n'y a pas de
+service séparé pour l'accès distant.
+`)
+}
+
+// linkPrintIdentity affiche l'empreinte E2E (à confirmer une fois) et un code
+// d'appairage frais (à saisir une fois) pour le portail.
+func linkPrintIdentity() error {
+ if fp := e2eFingerprint(); fp != "" {
+ fmt.Printf("\n%s empreinte E2E de cette machine :\n %s\n", green("[e2e]"), bold(fp))
+ fmt.Printf(" Confirme-la dans le portail (Mon compte → serveur) pour activer la boîte noire.\n")
+ }
+ code, err := newPairCode()
+ if err != nil {
+ fmt.Printf("%s code d'appairage indisponible (droits sur %s ?): %v\n", yellow("[link]"), AjeanHome(), err)
+ fmt.Printf(" Réessaie : sudo ajean link code\n")
+ return nil
+ }
+ fmt.Printf(" Code d'appairage (valable 10 min, usage unique) : %s\n\n", bold(code))
+ return nil
+}
+
+// ---------------------------------------------------------------------------
+// Tunnel DANS le process de l'app (macOS/Windows).
+//
+// Le modèle Linux est : UN SEUL process possède la conversation et sert les deux
+// surfaces (UI locale :8090 + tunnel). C'est ce qui rend le fil identique en
+// local et sur app.ajean.link — la conversation est un objet EN MÉMOIRE (voir
+// chat_conversation.go), simplement persisté sur disque ; deux process qui la
+// servent = deux fils divergents qui s'écrasent dans conversation.json.
+//
+// Sur un poste de bureau, l'app EST ce process propriétaire : elle sert déjà
+// :8090, donc elle fait aussi tourner le tunnel elle-même au lieu de déléguer à
+// un worker détaché. L'accès distant vit donc aussi longtemps que AJEAN.app est
+// ouverte — sur un portable qui s'endort, c'est de toute façon la réalité.
+
+var appLink struct {
+ mu sync.Mutex
+ running bool
+ stop context.CancelFunc
+ done chan struct{} // fermé quand la boucle a vraiment rendu la main
+}
+
+// appOwnsLink : vrai dans le process de l'app de bureau, qui pilote le tunnel
+// en interne. uiServiceCtl s'y adapte pour ne PAS lancer de worker concurrent.
+// appWebMux est le mux servi par l'app — réutilisé pour le tunnel afin que les
+// deux surfaces partagent la même conversation.
+var (
+ appOwnsLink bool
+ appWebMux *http.ServeMux
+)
+
+// startAppLink démarre la boucle de lien dans ce process, en servant le mux de
+// l'app (donc la MÊME conversation que l'UI locale). Idempotent.
+func startAppLink(mux *http.ServeMux) {
+ appLink.mu.Lock()
+ defer appLink.mu.Unlock()
+ if appLink.running {
+ return
+ }
+ token := readLinkToken()
+ if token == "" {
+ return
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ appLink.running, appLink.stop, appLink.done = true, cancel, done
+ handler := newLinkHandler(mux)
+ oaiTLS := oaiTLSConfig()
+ go func() {
+ defer close(done)
+ backoff := time.Second
+ for ctx.Err() == nil {
+ started := time.Now()
+ _ = runLinkSession(ctx, token, handler, oaiTLS)
+ // Une session qui a TENU repart de zéro. Sans cette remise, le délai
+ // grimpait de coupure en coupure et restait collé à 30 s pour toujours,
+ // y compris pour reconnecter un lien qui venait de vivre des heures.
+ if time.Since(started) > time.Minute {
+ backoff = time.Second
+ }
+ select {
+ case <-ctx.Done():
+ return
+ case <-time.After(backoff):
+ }
+ if backoff < 30*time.Second {
+ backoff *= 2
+ if backoff > 30*time.Second {
+ backoff = 30 * time.Second
+ }
+ }
+ }
+ }()
+}
+
+// stopAppLink arrête la boucle interne ET attend qu'elle ait rendu la main.
+// L'attente est ce qui garantit qu'un redémarrage ne fait pas cohabiter deux
+// sessions : sans elle, le nouveau tunnel s'ouvrait pendant que l'ancien vivait
+// encore, et le relais voyait deux agents pour la même machine.
+func stopAppLink() {
+ appLink.mu.Lock()
+ if !appLink.running {
+ appLink.mu.Unlock()
+ return
+ }
+ cancel, done := appLink.stop, appLink.done
+ appLink.running, appLink.stop, appLink.done = false, nil, nil
+ appLink.mu.Unlock()
+
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(5 * time.Second): // filet : on ne bloque pas l'UI indéfiniment
+ }
+}
+
+func appLinkRunning() bool {
+ appLink.mu.Lock()
+ defer appLink.mu.Unlock()
+ return appLink.running
+}
+
+// killForeignUIWorker tue un worker de lien détaché encore en vie (laissé par
+// une version antérieure ou une autre copie de l'app). Indispensable AVANT que
+// l'app ne prenne la main : sinon deux process servent la même conversation et
+// les fils divergent entre l'UI locale et app.ajean.link.
+func killForeignUIWorker() {
+ if pid := uiUserPID(); pid > 0 {
+ killTree(pid)
+ _ = os.Remove(uiPIDPath())
+ }
+}
+
+// uiPIDPath / uiLogPath : suivi du worker de lien hors systemd (macOS,
+// Windows), où AJEAN est une app de bureau lancée sans droits root.
+func uiPIDPath() string { return filepath.Join(AjeanHome(), uiUnitName+".pid") }
+func uiLogPath() string { return filepath.Join(AjeanHome(), uiUnitName+".log") }
+
+// uiServiceCtl pilote le worker de lien (start/stop/restart). Sous Linux c'est
+// l'unité systemd ajean-ui (avec sudo non interactif si on n'est pas root) ;
+// ailleurs — macOS et Windows, où il n'y a ni systemd ni droits root — on lance
+// « ajean link serve » en processus détaché suivi par un fichier PID, comme le
+// fait déjà le service principal. Sans ça, l'accès distant restait définitivement
+// « arrêté » sur un Mac ou un PC : le token était enregistré mais rien ne
+// composait jamais le tunnel.
+func uiServiceCtl(action string) error {
+ if runtime.GOOS != "linux" {
+ // Dans l'app de bureau, le tunnel tourne DANS ce process (conversation
+ // unique) : on ne lance surtout pas un worker séparé.
+ if appOwnsLink {
+ switch action {
+ case "stop":
+ stopAppLink()
+ case "start", "restart":
+ stopAppLink()
+ killForeignUIWorker()
+ startAppLink(appWebMux)
+ if !appLinkRunning() {
+ return fmt.Errorf("tunnel non démarré (clé de liaison absente ?)")
+ }
+ default:
+ return fmt.Errorf("action inconnue: %s", action)
+ }
+ return nil
+ }
+ return uiUserSvcCtl(action)
+ }
+ bin, pre := "systemctl", []string{}
+ if os.Geteuid() != 0 {
+ bin, pre = "sudo", []string{"-n", "systemctl"}
+ }
+ cmd := exec.Command(bin, append(pre, action, uiServiceName())...)
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("systemctl %s %s: %w", action, uiServiceName(), err)
+ }
+ switch action {
+ case "start", "restart":
+ fmt.Printf("%s service %s %s\n", green("[ok]"), uiServiceName(), action+"é")
+ case "stop":
+ fmt.Printf("%s service %s arrêté\n", green("[ok]"), uiServiceName())
+ }
+ return nil
+}
+
+// uiServiceActive indique si le worker de lien tourne (unité systemd sous
+// Linux, processus suivi par fichier PID ailleurs).
+func uiServiceActive() bool {
+ if runtime.GOOS != "linux" {
+ if appOwnsLink {
+ return appLinkRunning()
+ }
+ return uiUserPID() > 0
+ }
+ out, _ := exec.Command("systemctl", "is-active", uiServiceName()).Output()
+ return strings.TrimSpace(string(out)) == "active"
+}
+
+// uiUserPID renvoie le PID du worker de lien s'il tourne vraiment, 0 sinon
+// (fichier absent, illisible, ou process mort → on nettoie le fichier obsolète).
+func uiUserPID() int {
+ b, err := os.ReadFile(uiPIDPath())
+ if err != nil {
+ return 0
+ }
+ if i := strings.IndexByte(string(b), '\n'); i >= 0 {
+ b = b[:i]
+ }
+ pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
+ if pid <= 0 || !pidAlive(pid) {
+ return 0
+ }
+ return pid
+}
+
+// uiUserSvcCtl : équivalent de systemctl pour les OS sans systemd.
+func uiUserSvcCtl(action string) error {
+ switch action {
+ case "stop", "restart":
+ if pid := uiUserPID(); pid > 0 {
+ killTree(pid)
+ for i := 0; i < 30 && pidAlive(pid); i++ {
+ time.Sleep(100 * time.Millisecond)
+ }
+ }
+ _ = os.Remove(uiPIDPath())
+ if action == "stop" {
+ fmt.Printf("%s service %s arrêté\n", green("[ok]"), uiServiceName())
+ return nil
+ }
+ case "start":
+ if uiUserPID() > 0 {
+ fmt.Printf("%s service %s déjà en cours\n", yellow("[info]"), uiServiceName())
+ return nil
+ }
+ default:
+ return fmt.Errorf("action inconnue: %s", action)
+ }
+
+ self, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ if p, err := filepath.EvalSymlinks(self); err == nil {
+ self = p
+ }
+ if err := os.MkdirAll(AjeanHome(), 0o755); err != nil {
+ return err
+ }
+ logf, err := os.OpenFile(uiLogPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err != nil {
+ return fmt.Errorf("ouverture du log %s: %w", uiLogPath(), err)
+ }
+ defer logf.Close()
+
+ cmd := spawnDetached(self, "web")
+ cmd.Stdout, cmd.Stderr = logf, logf
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("démarrage de « ajean web »: %w", err)
+ }
+ pid := cmd.Process.Pid
+ // PID + binaire d'origine : la 2e ligne dit QUELLE copie de l'app a lancé ce
+ // worker — précieux pour diagnostiquer un process rescapé d'une autre version.
+ if err := os.WriteFile(uiPIDPath(), []byte(strconv.Itoa(pid)+"\n"+self+"\n"), 0o644); err != nil {
+ return fmt.Errorf("écriture du PID: %w", err)
+ }
+ _ = cmd.Process.Release()
+
+ // Le worker peut mourir aussitôt (token refusé, relais injoignable) : on laisse
+ // passer un instant avant de déclarer la victoire, sinon l'UI afficherait
+ // « en ligne » sur un process déjà mort.
+ time.Sleep(1500 * time.Millisecond)
+ if !pidAlive(pid) {
+ _ = os.Remove(uiPIDPath())
+ return fmt.Errorf("le lien s'est arrêté aussitôt — voir %s", uiLogPath())
+ }
+ fmt.Printf("%s service %s démarré (PID %d)\n", green("[ok]"), uiServiceName(), pid)
+ return nil
+}
+
+// newLinkHandler construit le handler servi à travers le tunnel :
+// - /v1/*, /health, /props, /metrics, /slots → llama-server local (endpoint
+// compatible OpenAI, avec injection de la clé API locale) → permet de
+// brancher OpenCode, Hermes, etc. sur ajean.link/oai//v1
+// - tout le reste → l'UI web de AJEAN (avec injection de la clé de pilotage)
+func newLinkHandler(mux *http.ServeMux) http.Handler {
+ web := withLocalAuth(mux)
+ llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}
+ lp := httputil.NewSingleHostReverseProxy(llama)
+ lp.FlushInterval = -1 // streaming SSE des complétions
+ apiKey := readAPIKey()
+ base := lp.Director
+ lp.Director = func(req *http.Request) {
+ base(req)
+ // Le client distant n'a pas la clé API de llama-server ; on l'injecte ici
+ // (l'auth réelle est faite par le relais via la clé de liaison du compte).
+ if apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ }
+ }
+ lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
+ http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway)
+ }
+ // Boîte noire « zéro exception » : TOUTE l'API (chat ET contrôle) ne transite
+ // que chiffrée de bout en bout. Le relais ne voit jamais de clair.
+ // - /api/e2e/chat : chat chiffré (streaming SSE chiffré).
+ // - /api/e2e/req : proxy de contrôle chiffré (presets, VRAM, skills, service…).
+ // Tout autre /api/* en clair est REFUSÉ via le tunnel.
+ oaiAllowed := os.Getenv("AJEAN_LINK_ALLOW_OAI") == "1"
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ p := r.URL.Path
+ if strings.HasPrefix(p, "/api/e2e/") {
+ if p == "/api/e2e/req" {
+ handleE2EReq(w, r, web) // dispatche dans le handler local authentifié
+ return
+ }
+ if p == "/api/e2e/pair" {
+ handleE2EPair(w, r) // appairage d'une identité utilisateur (code hors-bande)
+ return
+ }
+ web.ServeHTTP(w, r) // /api/e2e/chat est routé par le mux
+ return
+ }
+ // Poste distant : l'enrôlement (sceau anonyme) et le WebSocket (canal
+ // chiffré poste↔agent) sont E2E — le relais ne voit que de l'opaque. On les
+ // laisse donc passer, comme /api/e2e/*, sans casser la boîte noire.
+ if p == "/api/node/enroll" || p == "/api/node/ws" {
+ web.ServeHTTP(w, r)
+ return
+ }
+ if strings.HasPrefix(p, "/api/") {
+ http.Error(w, "via le relais : seuls les endpoints chiffrés /api/e2e/* sont autorisés (boîte noire)", http.StatusForbidden)
+ return
+ }
+ if strings.HasPrefix(p, "/v1") || p == "/health" || p == "/props" || p == "/metrics" || strings.HasPrefix(p, "/slots") {
+ // L'endpoint OpenAI (OpenCode/Hermes) ne peut pas être chiffré navigateur :
+ // il transiterait en clair par le relais. Désactivé par défaut.
+ if !oaiAllowed {
+ http.Error(w, "endpoint OpenAI désactivé via le relais (transiterait en clair) ; AJEAN_LINK_ALLOW_OAI=1 pour l'autoriser", http.StatusForbidden)
+ return
+ }
+ lp.ServeHTTP(w, r)
+ return
+ }
+ web.ServeHTTP(w, r)
+ })
+}
+
+// withLocalAuth injecte la clé de pilotage locale dans chaque requête arrivant
+// par le tunnel. Le navigateur distant ne connaît que le token (vérifié par le
+// relais) ; c'est ici, en local, qu'on satisfait l'auth de l'API web sans
+// exposer la clé au client.
+//
+// ⚠️ La clé est relue À CHAQUE REQUÊTE, et surtout pas capturée une fois pour
+// toutes à l'ouverture du tunnel. Avec une capture, changer la clé (depuis
+// l'interface ou `ajean set-web-key`) faisait injecter l'ANCIENNE jusqu'au
+// prochain redémarrage du service : tout l'accès distant tombait en 401, et
+// comme le chat n'affiche pas le code HTTP d'un flux qui n'arrive jamais, le
+// symptôme était un « chargement de la conversation » infini, sans la moindre
+// erreur pour dire pourquoi. Le coût est nul : requireWebAuth relit déjà la clé
+// à chaque requête de l'autre côté.
+func withLocalAuth(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if webKey := readWebKey(); webKey != "" && r.Header.Get("Authorization") == "" {
+ r.Header.Set("Authorization", "Bearer "+webKey)
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// runLinkSession opens one WebSocket→yamux session and serves it until it dies.
+// It blocks for the lifetime of the connection and returns the error that ended
+// it (so the caller can reconnect).
+// runLinkSession tient UNE session de tunnel, jusqu'à ce qu'elle meure ou que
+// ctx soit annulé. Le contexte est indispensable : sans lui, un « arrêter le
+// lien » ne prenait effet qu'à la mort naturelle du WebSocket, qui n'arrive
+// jamais tant que le relais répond. Un redémarrage (stop puis start enchaînés)
+// lançait donc une seconde session pendant que la première vivait encore : deux
+// agents connectés au relais pour la même machine, une seule conversation
+// derrière.
+func runLinkSession(ctx context.Context, token string, handler http.Handler, oaiTLS *tls.Config) error {
+ dialCtx, cancel := context.WithTimeout(ctx, 20*time.Second)
+ defer cancel()
+
+ // Identifie la machine auprès du relais (id stable + hostname).
+ host, _ := os.Hostname()
+ dialURL := relayURL()
+ q := url.Values{"m": {machineID()}, "h": {host}}
+ if pk := e2ePubHex(); pk != "" {
+ q.Set("pk", pk) // clé publique E2E → publiée au relais pour le scellement navigateur
+ }
+ if strings.Contains(dialURL, "?") {
+ dialURL += "&" + q.Encode()
+ } else {
+ dialURL += "?" + q.Encode()
+ }
+
+ c, _, err := websocket.Dial(dialCtx, dialURL, &websocket.DialOptions{
+ HTTPHeader: http.Header{"Authorization": {"Bearer " + token}},
+ })
+ if err != nil {
+ return fmt.Errorf("dial: %w", err)
+ }
+ // Pas de limite de taille : on fait passer des streams arbitraires (SSE).
+ c.SetReadLimit(-1)
+ conn := websocket.NetConn(ctx, c, websocket.MessageBinary)
+
+ // L'agent est le côté "serveur" yamux : c'est le relais qui ouvre un stream
+ // par requête navigateur, et nous on les accepte.
+ ycfg := yamux.DefaultConfig()
+ ycfg.EnableKeepAlive = true
+ ycfg.KeepAliveInterval = 25 * time.Second
+ ycfg.ConnectionWriteTimeout = 30 * time.Second
+ ycfg.LogOutput = io.Discard
+ sess, err := yamux.Server(conn, ycfg)
+ if err != nil {
+ return fmt.Errorf("yamux: %w", err)
+ }
+ defer sess.Close()
+
+ fmt.Printf("%s lien établi ✓\n", green("[link]"))
+
+ // Chaque Accept() = un stream. Deux natures possibles :
+ // - requête HTTP normale (UI AJEAN / E2E) → servie par `handler` ;
+ // - session TLS brute (accès OpenAI public) → terminée par le front TLS.
+ // On les distingue au 1er octet (0x16 = handshake TLS). Sans OAI activé, tout
+ // va au HTTP (comportement historique inchangé).
+ httpLn := newChanListener(sess.Addr())
+ defer httpLn.Close()
+ // Même garde-fou que le serveur local : un stream du tunnel qui n'envoie
+ // jamais d'en-têtes ne doit pas retenir une goroutine indéfiniment. Pas de
+ // WriteTimeout : le chat E2E est un flux SSE de longue durée.
+ srv := &http.Server{Handler: handler, ReadHeaderTimeout: 30 * time.Second}
+ go srv.Serve(httpLn)
+
+ var oaiLn *chanListener
+ if oaiTLS != nil {
+ oaiLn = newChanListener(sess.Addr())
+ defer oaiLn.Close()
+ go runOAIFront(oaiLn, oaiTLS)
+ }
+
+ // Annulation : fermer la session yamux débloque l'Accept ci-dessous, qui est
+ // le seul point où cette fonction attend. La goroutine meurt avec la session.
+ go func() {
+ <-ctx.Done()
+ _ = sess.Close()
+ }()
+
+ for {
+ stream, err := sess.Accept()
+ if err != nil {
+ if ctx.Err() != nil {
+ return ctx.Err() // arrêt demandé, pas une panne
+ }
+ return fmt.Errorf("accept: %w", err)
+ }
+ go demuxTunnelStream(stream, httpLn, oaiLn)
+ }
+}
+
+// demuxTunnelStream aiguille un stream du tunnel selon son 1er octet : 0x16 (TLS)
+// → front OAI ; sinon → HTTP. On consulte l'octet sans le consommer (peekedConn).
+func demuxTunnelStream(stream net.Conn, httpLn, oaiLn *chanListener) {
+ br := bufio.NewReader(stream)
+ b, err := br.Peek(1)
+ if err != nil {
+ stream.Close()
+ return
+ }
+ pc := &peekedConn{Conn: stream, r: br}
+ if b[0] == 0x16 { // handshake TLS = accès OpenAI public
+ if oaiLn != nil && oaiPublicEnabled() {
+ oaiLn.push(pc)
+ } else {
+ stream.Close() // public désactivé → on refuse (fail-closed)
+ }
+ return
+ }
+ httpLn.push(pc)
+}
diff --git a/internal/ajean/relay_localauth_test.go b/internal/ajean/relay_localauth_test.go
new file mode 100644
index 0000000..7cdad30
--- /dev/null
+++ b/internal/ajean/relay_localauth_test.go
@@ -0,0 +1,64 @@
+package ajean
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+// Changer la clé de pilotage ne doit PAS couper l'accès distant jusqu'au
+// prochain redémarrage. La clé était capturée à l'ouverture du tunnel : après un
+// `ajean set-web-key`, le tunnel injectait l'ancienne, tout passait en 401, et
+// le portail restait bloqué sur « chargement de la conversation » sans jamais
+// dire pourquoi.
+func TestWithLocalAuthSuitLaCleCourante(t *testing.T) {
+ testHome(t)
+ if err := putStr(bkState, "web_key", "cle-numero-1"); err != nil {
+ t.Fatal(err)
+ }
+ // Handler protégé, comme l'API web réelle.
+ protege := withLocalAuth(requireWebAuth(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(200)
+ }))
+ appel := func() int {
+ rr := httptest.NewRecorder()
+ protege.ServeHTTP(rr, httptest.NewRequest("GET", "/api/status", nil))
+ return rr.Code
+ }
+ if code := appel(); code != 200 {
+ t.Fatalf("clé initiale : HTTP %d", code)
+ }
+ // La clé change pendant que le tunnel tourne (bouton de l'interface, CLI).
+ if err := putStr(bkState, "web_key", "cle-numero-2"); err != nil {
+ t.Fatal(err)
+ }
+ if code := appel(); code != 200 {
+ t.Fatalf("après changement de clé : HTTP %d — le tunnel injecte encore l'ancienne", code)
+ }
+ // Clé retirée : l'API est ouverte, le tunnel ne doit rien casser non plus.
+ if err := putStr(bkState, "web_key", ""); err != nil {
+ t.Fatal(err)
+ }
+ if code := appel(); code != 200 {
+ t.Fatalf("sans clé : HTTP %d", code)
+ }
+}
+
+// Une requête qui porte DÉJÀ une autorisation n'est pas réécrite : c'est le
+// client distant qui décide dans ce cas.
+func TestWithLocalAuthNecraseAucunEnTete(t *testing.T) {
+ testHome(t)
+ if err := putStr(bkState, "web_key", "cle-locale"); err != nil {
+ t.Fatal(err)
+ }
+ var vu string
+ protege := withLocalAuth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ vu = r.Header.Get("Authorization")
+ }))
+ req := httptest.NewRequest("GET", "/api/status", nil)
+ req.Header.Set("Authorization", "Bearer venue-du-client")
+ protege.ServeHTTP(httptest.NewRecorder(), req)
+ if vu != "Bearer venue-du-client" {
+ t.Fatalf("en-tête client écrasé : %q", vu)
+ }
+}
diff --git a/internal/ajean/remote.go b/internal/ajean/remote.go
new file mode 100644
index 0000000..9326162
--- /dev/null
+++ b/internal/ajean/remote.go
@@ -0,0 +1,210 @@
+// Sous-commande « ajean remote » — client « poste distant » intégré au binaire
+// principal. On l'installe sur un PC pour que l'IA d'un serveur AJEAN puisse agir
+// dessus (shell/fichiers), chiffré de bout en bout via ajean.link (relais aveugle)
+// ou en direct sur le réseau local.
+//
+// ajean remote install --machine ID --key CLÉ --code CODE [--allow shell,read,write,list] [--root DIR]
+// appaire ce PC et l'installe en service (démarrage auto)
+// ajean remote connect ... idem mais en avant-plan (test)
+// ajean remote run boucle client (utilisé par le service)
+// ajean remote status | uninstall | logout
+//
+// Historiquement un binaire séparé « ajean-remote » ; fusionné dans ce binaire
+// pour n'avoir qu'une seule application.
+package ajean
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "runtime"
+ "strings"
+
+ "github.com/nathaninline/ajean/internal/nodeclient"
+)
+
+func cmdRemote(args []string) error {
+ sub := ""
+ if len(args) > 0 {
+ sub = args[0]
+ args = args[1:]
+ }
+ switch sub {
+ case "install":
+ return remoteConnect(args, true)
+ case "connect":
+ return remoteConnect(args, false)
+ case "run":
+ return remoteRun()
+ case "uninstall":
+ if err := nodeclient.Uninstall(); err != nil {
+ return err
+ }
+ fmt.Println("[ok] service retiré")
+ return nil
+ case "logout":
+ _ = nodeclient.Uninstall()
+ err := os.Remove(nodeclient.ConfigPath())
+ if err != nil && !os.IsNotExist(err) {
+ return err
+ }
+ fmt.Println("[ok] configuration oubliée")
+ return nil
+ case "status":
+ return remoteStatus()
+ default:
+ remoteUsage()
+ return nil
+ }
+}
+
+func remoteUsage() {
+ fmt.Print(`ajean remote — piloter CE PC depuis l'IA d'un serveur AJEAN
+
+ ajean remote install --code CODE [options] appaire + installe en service (démarrage auto, invisible)
+ ajean remote connect --code CODE [options] idem en avant-plan (pour tester)
+ ajean remote status état de la configuration
+ ajean remote uninstall retire le service (garde la config)
+ ajean remote logout retire le service ET oublie la clé
+
+Options :
+ --machine ID machine à joindre via ajean.link (fournie par l'UI → Postes distants)
+ --key CLÉ clé publique de l'agent (le poste scelle vers elle)
+ --code CODE code d'appairage (10 min, généré dans l'UI AJEAN → Postes distants)
+ --allow LISTE capacités acceptées : shell,read,write,list (défaut : ce que le serveur autorise)
+ --root DIR dossier autorisé (confine lecture/écriture)
+ --yes auto-approuve les actions (obligatoire pour le service, sans terminal)
+`)
+}
+
+// remoteConnect appaire au besoin, sauvegarde, puis installe (service) ou lance
+// (avant-plan) selon install.
+func remoteConnect(args []string, install bool) error {
+ f := parseRemoteFlags(args)
+ cfg, lerr := nodeclient.LoadConfig()
+ // Un --code est un jeton d'appairage à usage unique : le fournir veut TOUJOURS
+ // dire « (ré)appaire-moi maintenant ». Sans ce « || f.code != "" », une config
+ // résiduelle (test précédent, ancien binaire ajean-remote au même emplacement
+ // %ProgramData%\ajean-remote\config.json) faisait sauter l'appairage en silence :
+ // le service démarrait avec de vieilles clés que le serveur ignore et le poste
+ // ne remontait jamais, alors que tout affichait « [ok] ».
+ needEnroll := lerr != nil || cfg.Priv == "" || f.code != ""
+ if f.url != "" {
+ cfg.ServerURL = f.url
+ }
+ if f.key != "" {
+ cfg.AgentPub = f.key
+ }
+ if f.machine != "" {
+ cfg.MachineID = f.machine
+ }
+ if needEnroll {
+ if cfg.ServerURL == "" {
+ return fmt.Errorf("url du serveur requise (ex: https://ajean.link)")
+ }
+ if f.code == "" {
+ return fmt.Errorf("code d'appairage requis : --code CODE")
+ }
+ if cfg.AgentPub == "" {
+ return fmt.Errorf("clé de l'agent requise : --key (fournie par la commande d'appairage)")
+ }
+ if err := nodeclient.Enroll(&cfg, f.code); err != nil {
+ return err
+ }
+ fmt.Printf("[ok] appairé (id %s)\n", cfg.ID)
+ }
+ if len(f.allow) > 0 {
+ cfg.Caps = nodeclient.SanitizeCaps(f.allow)
+ }
+ if f.root != "" {
+ cfg.Root = f.root
+ }
+ if cfg.Root == "" {
+ cfg.Root = nodeclient.DataDir() + string(os.PathSeparator) + "workspace"
+ }
+ if f.yes || install {
+ cfg.AutoYes = true // un service n'a pas de terminal pour confirmer
+ }
+ if cfg.Name == "" {
+ cfg.Name, _ = os.Hostname()
+ }
+ if err := nodeclient.SaveConfig(cfg); err != nil {
+ return err
+ }
+ fmt.Printf("[ok] poste « %s » → %s\n", cfg.Name, cfg.ServerURL)
+ fmt.Printf("[ok] capacités : %s | dossier : %s\n", strings.Join(cfg.Caps, ", "), cfg.Root)
+
+ if install {
+ if err := nodeclient.Install(); err != nil {
+ return err
+ }
+ fmt.Println("[ok] service installé et démarré — il tournera en tâche de fond et au redémarrage")
+ return nil
+ }
+ nodeclient.Run(context.Background(), cfg, false)
+ return nil
+}
+
+func remoteRun() error {
+ cfg, err := nodeclient.LoadConfig()
+ if err != nil {
+ return fmt.Errorf("aucune configuration — lance d'abord « ajean remote install … »")
+ }
+ return nodeclient.RunService(cfg)
+}
+
+func remoteStatus() error {
+ cfg, err := nodeclient.LoadConfig()
+ if err != nil {
+ fmt.Println("aucun poste configuré")
+ return nil
+ }
+ fmt.Printf("serveur : %s\n", cfg.ServerURL)
+ fmt.Printf("nom : %s\n", cfg.Name)
+ fmt.Printf("capacités : %s\n", strings.Join(cfg.Caps, ", "))
+ fmt.Printf("dossier : %s\n", cfg.Root)
+ fmt.Printf("auto-oui : %v\n", cfg.AutoYes)
+ fmt.Printf("os : %s/%s\n", runtime.GOOS, runtime.GOARCH)
+ return nil
+}
+
+type remoteFlags struct {
+ url, code, root, key, machine string
+ allow []string
+ yes bool
+}
+
+func parseRemoteFlags(args []string) remoteFlags {
+ var f remoteFlags
+ for i := 0; i < len(args); i++ {
+ a := args[i]
+ next := func() string {
+ if i+1 < len(args) {
+ i++
+ return args[i]
+ }
+ return ""
+ }
+ switch {
+ case a == "--code":
+ f.code = next()
+ case a == "--key":
+ f.key = next()
+ case a == "--machine":
+ f.machine = next()
+ case a == "--allow":
+ for _, p := range strings.Split(next(), ",") {
+ if p = strings.TrimSpace(p); p != "" {
+ f.allow = append(f.allow, p)
+ }
+ }
+ case a == "--root":
+ f.root = next()
+ case a == "--yes" || a == "-y":
+ f.yes = true
+ case !strings.HasPrefix(a, "-") && f.url == "":
+ f.url = a
+ }
+ }
+ return f
+}
diff --git a/internal/ajean/run.go b/internal/ajean/run.go
new file mode 100644
index 0000000..95058ff
--- /dev/null
+++ b/internal/ajean/run.go
@@ -0,0 +1,259 @@
+// ajean — single-binary LLM server manager + web UI for llama.cpp deployments.
+// Point d'entrée réel : Main(), appelé par cmd/ajean (les métadonnées Windows
+// .syso/go:generate vivent là-bas, dans le dossier du package main).
+package ajean
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+const Version = "0.9.4"
+
+// Main est le vrai main() du binaire (cmd/ajean ne fait que l'appeler).
+func Main() {
+ // Rattache la console du terminal parent si on est lancé depuis un shell
+ // (Windows : binaire GUI). Retourne false au double-clic (aucune console) →
+ // on bascule alors sur l'expérience « application ». Hors Windows : toujours
+ // true. À faire AVANT toute écriture.
+ haveConsole := setupConsole()
+
+ // Nettoie un éventuel binaire .old laissé par une mise à jour Windows.
+ cleanupOldBinary()
+
+ args := os.Args[1:]
+ noArgs := len(args) == 0
+ cmd := "help"
+ if len(args) > 0 {
+ cmd = args[0]
+ args = args[1:]
+ }
+ // Double-clic sur le binaire (aucun argument, aucune console rattachée) → on
+ // lance l'expérience « application » (UI web + navigateur + icône tray) plutôt
+ // que d'afficher l'aide. Le binaire étant compilé en sous-système GUI, il n'y a
+ // AUCUNE console à ce stade (donc plus de fenêtre noire). Lancé depuis un shell,
+ // `ajean` sans argument garde son comportement d'aide.
+ if noArgs && !haveConsole {
+ mustExit(cmdApp(args))
+ return
+ }
+ switch cmd {
+ case "app":
+ mustExit(cmdApp(args))
+ case "start", "restart":
+ // Un moteur sans BIN ni MODEL démarre, meurt, et systemd le relance en
+ // boucle : l'utilisateur ne voyait qu'un « activating » rassurant et un
+ // /health muet. On le dit AVANT de lancer le service.
+ mustExit(preflightEngine())
+ mustExit(serviceAction(cmd))
+ case "stop", "status", "enable", "disable":
+ mustExit(serviceAction(cmd))
+ case "logs":
+ mustExit(serviceLogs())
+ case "edit":
+ mustExit(editConfig())
+ case "set-api-key":
+ mustExit(cmdSetAPIKey(args))
+ case "set-web-key":
+ mustExit(cmdSetWebKey(args))
+ case "vram":
+ mustExit(showVram())
+ case "gpu":
+ mustExit(cmdGPU(args))
+ case "network":
+ mustExit(cmdNetwork(args))
+ case "switch":
+ mustExit(cmdSwitch(args))
+ case "chat":
+ mustExit(cmdChat(args))
+ case "export":
+ mustExit(cmdExport(args))
+ case "web":
+ mustExit(cmdWeb(args))
+ case "link":
+ mustExit(cmdLink(args))
+ case "remote":
+ mustExit(cmdRemote(args))
+ case "ui":
+ mustExit(cmdUI(args))
+ case "oai":
+ mustExit(cmdOAI(args))
+ case "agent":
+ mustExit(cmdAgent(args))
+ case "internet":
+ mustExit(cmdInternet(args))
+ case "memory":
+ mustExit(cmdMemory(args))
+ case "serve":
+ mustExit(cmdServe(args))
+ case "test":
+ mustExit(cmdTest(args))
+ case "bench":
+ mustExit(cmdBench(args))
+ case "llamacpp":
+ mustExit(cmdLlamacpp(args))
+ case "update":
+ mustExit(cmdUpdate(args))
+ case "where":
+ mustExit(cmdWhere(args))
+ case restartArg:
+ // Sous-commande interne, absente de l'aide : l'accompagnateur détaché qui
+ // attend la fermeture de l'app puis la relance.
+ mustExit(cmdRestartAfterUpdate(args))
+ case "install":
+ mustExit(cmdInstall(args))
+ case "uninstall":
+ mustExit(cmdUninstall(args))
+ case "version", "-v", "--version":
+ fmt.Println("ajean", Version)
+ case "help", "-h", "--help", "":
+ printHelp()
+ default:
+ fmt.Fprintf(os.Stderr, "commande inconnue: %s\n\n", cmd)
+ printHelp()
+ os.Exit(2)
+ }
+}
+
+func printHelp() {
+ fmt.Printf(`ajean %s — manager llama.cpp + UI web (single binary)
+
+Usage: ajean [args]
+
+AJEAN tourne en deux services : ajean-engine (le modèle) et ajean-ui
+(l'interface web, le tunnel d'accès distant et l'endpoint OpenAI).
+
+Moteur (ajean-engine) :
+ start | stop | restart gérer le service
+ status | logs état / logs en direct
+ enable | disable auto-démarrage au boot
+ edit éditer la configuration dans $EDITOR
+ switch [N] activer un preset de presets/ (interactif ou par numéro)
+ test | bench [N] vérifier que l'IA répond / mesurer prefill + decode tok/s
+ vram utilisation GPU/VRAM (nvidia-smi)
+ gpu [index…] liste les GPU / choisit le(s)quel(s) utiliser (gpu all = tous)
+ set-api-key [clé] protéger llama-server (clé Bearer); vide = générer, "" = retirer
+ network [on|off|status] rendre l'endpoint OpenAI joignable depuis le réseau local
+ (HOST + règle de pare-feu sous Windows)
+
+Interface (ajean-ui) :
+ ui [start|stop|restart|status] pilote le service d'interface
+ web [PORT] sert l'interface au premier plan (défaut :8090) —
+ ouvre aussi le tunnel si un jeton est enregistré
+ set-web-key [clé] protéger l'API de pilotage; vide = générer, "" = retirer
+
+Interaction:
+ chat [system-prompt] chat terminal streamé
+ export [options] [fichier] exporte la conversation de l'interface web
+ (Markdown par défaut, « - » = sortie standard)
+ --json --last N --no-reasoning
+ --no-tools --no-results
+ agent [on|off|status] donne à l'IA ses outils (shell, fichiers, mémoire)
+ memory [off|ondemand|always|status] mode mémoire de l'IA
+ internet [on|off|status|engine |url |key ]
+ accès web de l'IA (moteur intégré ou serveur Crawl4AI)
+
+Accès distant (ajean.link) :
+ link enregistre le jeton et ouvre le tunnel
+ link code code d'appairage (10 min, usage unique) pour le portail
+ link status | logout état du jeton / l'oublier
+
+Poste distant (piloter CE PC depuis l'IA d'un serveur AJEAN) :
+ remote install --machine ID --key CLÉ --code CODE [--allow shell,read,write,list]
+ appaire ce PC + installe le service (démarrage auto)
+ remote connect … idem en avant-plan (pour tester)
+ remote status | uninstall | logout
+ (commande d'appairage prête à copier : UI web → Postes distants)
+
+Backend llama.cpp :
+ llamacpp install clone + compile llama.cpp (CUDA/ROCm/Metal/CPU), pointe BIN dessus
+ llamacpp update git pull + recompile le backend existant
+ llamacpp status commit courant, backend détecté, retard sur origin
+
+Installation:
+ where affiche l'emplacement du binaire, de la base et des dossiers
+ install | uninstall installer / désinstaller
+ update [--check] mettre à jour depuis les releases GitHub
+ serve entrypoint du service : exec llama-server (usage interne)
+
+Env:
+ AJEAN_HOME racine des données (défaut : /etc/ajean, %%ProgramData%%\ajean sous Windows)
+ EDITOR éditeur pour 'ajean edit' (défaut : nano, notepad sous Windows)
+`, Version)
+}
+
+func mustExit(err error) {
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "[err]", err)
+ os.Exit(1)
+ }
+}
+
+// AjeanHome resolves the AJEAN data directory.
+// Précédence : $AJEAN_HOME → /etc/default/ajean (unix) → défaut plateforme.
+func AjeanHome() string {
+ if h := os.Getenv("AJEAN_HOME"); h != "" {
+ return h
+ }
+ if h := readEtcDefault(); h != "" {
+ return h
+ }
+ return defaultAjeanHome()
+}
+
+// readEtcDefault parses /etc/default/ajean for AJEAN_HOME=. Quiet on errors.
+func readEtcDefault() string {
+ b, err := os.ReadFile("/etc/default/ajean")
+ if err != nil {
+ return ""
+ }
+ for _, line := range strings.Split(string(b), "\n") {
+ s := strings.TrimSpace(line)
+ if s == "" || strings.HasPrefix(s, "#") {
+ continue
+ }
+ s = strings.TrimPrefix(s, "export ")
+ if k, v, ok := strings.Cut(s, "="); ok && strings.TrimSpace(k) == "AJEAN_HOME" {
+ return strings.Trim(strings.TrimSpace(v), "\"'")
+ }
+ }
+ return ""
+}
+
+// Arborescence de $AJEAN_HOME. Elle tient en six dossiers, et rien d'autre :
+// tout le reste (config, préférences, conversation, clés, drapeaux) vit dans la
+// base ajean.db — voir store.go.
+func backendsDir() string { return filepath.Join(AjeanHome(), "backends") }
+func binDir() string { return filepath.Join(AjeanHome(), "bin") }
+func presetsDir() string { return filepath.Join(AjeanHome(), "presets") }
+func memoryDir() string { return filepath.Join(AjeanHome(), "memory") }
+func modelsDir() string { return filepath.Join(AjeanHome(), "models") }
+func workspaceDir() string { return filepath.Join(AjeanHome(), "workspace") }
+
+// serviceName est le nom de l'unité qui exécute llama-server. Son pendant est
+// uiUnitName (« ajean-ui »), qui sert l'interface et le tunnel.
+func serviceName() string {
+ if n := os.Getenv("AJEAN_SERVICE"); n != "" {
+ return n
+ }
+ return "ajean-engine"
+}
+
+// Color helpers (ANSI). Disabled when stdout is not a TTY.
+var colorOn = isTerminal()
+
+func col(code, s string) string {
+ if !colorOn {
+ return s
+ }
+ return "\033[" + code + "m" + s + "\033[0m"
+}
+func bold(s string) string { return col("1", s) }
+func cyan(s string) string { return col("1;36", s) }
+func green(s string) string { return col("32", s) }
+func red(s string) string { return col("31", s) }
+func dim(s string) string { return col("2", s) }
+func yellow(s string) string { return col("33", s) }
+func magenta(s string) string { return col("35", s) }
diff --git a/internal/ajean/store.go b/internal/ajean/store.go
new file mode 100644
index 0000000..9676291
--- /dev/null
+++ b/internal/ajean/store.go
@@ -0,0 +1,282 @@
+package ajean
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ bolt "go.etcd.io/bbolt"
+ bolterrors "go.etcd.io/bbolt/errors"
+)
+
+// store.go — l'unique endroit où AJEAN écrit son état.
+//
+// Avant, chaque réglage avait son fichier : config.env, webprefs.json,
+// conversation.json, .api_key, .link_token, .agent_enabled, model_dirs.json…
+// Une douzaine de formats, une douzaine de façons de rater une écriture
+// concurrente, et un dossier de données illisible. Tout ça tient désormais dans
+// une seule base bbolt — pur Go, un seul fichier, transactionnelle.
+//
+// Ce qui N'EST PAS en base, et pourquoi : les presets (presets/*.env) et les
+// pages de mémoire (memory/*.md) restent des fichiers, parce qu'ils sont faits
+// pour être lus, édités et sauvegardés à la main. Les modèles (.gguf) et les
+// backends compilés restent des fichiers, évidemment.
+
+// Buckets. Un par nature de donnée : ça garde les itérations bornées et rend le
+// contenu de la base lisible au débogage.
+const (
+ bkConfig = "config" // configuration de llama-server (ex-config.env)
+ bkPrefs = "prefs" // préférences de l'UI web
+ bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
+ bkChat = "chat" // conversation partagée
+)
+
+// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
+//
+// bbolt pose un verrou EXCLUSIF sur son fichier tant qu'il est ouvert. Or une
+// machine installée fait tourner en permanence le service de lien, qui sert le
+// tunnel et l'UI : s'il gardait la base ouverte, plus une seule commande ne
+// fonctionnerait à côté — « ajean status », « ajean switch », « ajean edit »
+// échoueraient toutes sur un délai d'attente, sur la machine même où tout est
+// censé marcher. On ouvre donc pour la durée d'une opération, puis on referme.
+//
+// Le coût est celui d'un open+close sur un fichier de quelques dizaines de Ko,
+// négligeable devant le moindre appel au modèle. Le délai d'attente absorbe la
+// contention entre process ; dbMu la sérialise à l'intérieur du process.
+var dbMu sync.Mutex
+
+func dbPath() string { return filepath.Join(AjeanHome(), "ajean.db") }
+
+// withDB ouvre la base, exécute fn, puis referme — toujours, même en erreur.
+func withDB(fn func(*bolt.DB) error) error {
+ path := dbPath()
+
+ dbMu.Lock()
+ defer dbMu.Unlock()
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ d, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 5 * time.Second})
+ if err != nil {
+ return fmt.Errorf("base %s inaccessible : %w", path, err)
+ }
+ defer d.Close()
+ return fn(d)
+}
+
+// view exécute une lecture. Un bucket encore absent (base neuve) est traité
+// comme vide : les buckets ne sont créés QU'À l'écriture, pour qu'une simple
+// lecture n'ouvre jamais de transaction d'écriture — elle coûterait un fsync,
+// et les lectures sont de loin les plus fréquentes.
+func view(bucket string, fn func(b *bolt.Bucket) error) error {
+ return withDB(func(d *bolt.DB) error {
+ return d.View(func(tx *bolt.Tx) error {
+ b := tx.Bucket([]byte(bucket))
+ if b == nil {
+ return nil
+ }
+ return fn(b)
+ })
+ })
+}
+
+// update exécute une écriture, en créant le bucket au besoin.
+func update(bucket string, fn func(b *bolt.Bucket) error) error {
+ cacheBust(bucket)
+ return withDB(func(d *bolt.DB) error {
+ return d.Update(func(tx *bolt.Tx) error {
+ b, err := tx.CreateBucketIfNotExists([]byte(bucket))
+ if err != nil {
+ return err
+ }
+ return fn(b)
+ })
+ })
+}
+
+// getBytesErr lit une valeur en REMONTANT l'erreur d'accès. À réserver aux
+// lecteurs pour qui « je n'ai pas pu lire » et « il n'y a rien » ne veulent pas
+// dire la même chose — au premier chef les secrets : sans clé enregistrée, l'API
+// de pilotage est ouverte, donc une lecture ratée traitée comme « pas de clé »
+// ouvrirait l'API au lieu de la fermer. Voir readWebKeyErr.
+func getBytesErr(bucket, key string) ([]byte, error) {
+ var out []byte
+ err := view(bucket, func(b *bolt.Bucket) error {
+ if v := b.Get([]byte(key)); v != nil {
+ out = append([]byte(nil), v...) // la valeur ne survit pas à la transaction
+ }
+ return nil
+ })
+ return out, err
+}
+
+// getBytes lit une valeur. Une base inaccessible se comporte comme une base
+// vide : les appelants sont des lecteurs de réglages, aucun n'a de recours utile
+// face à une erreur d'E/S, et tous ont déjà un défaut. Les lecteurs pour qui
+// l'erreur CHANGE la décision prennent getBytesErr.
+func getBytes(bucket, key string) []byte {
+ out, _ := getBytesErr(bucket, key)
+ return out
+}
+
+// putBytes écrit une valeur. Une valeur nil supprime la clé.
+func putBytes(bucket, key string, val []byte) error {
+ return update(bucket, func(b *bolt.Bucket) error {
+ if val == nil {
+ return b.Delete([]byte(key))
+ }
+ return b.Put([]byte(key), val)
+ })
+}
+
+func getStr(bucket, key string) string { return string(getBytes(bucket, key)) }
+
+// putStr écrit une chaîne ; une chaîne vide supprime la clé, pour que « absent »
+// et « vide » ne soient jamais deux états distincts à distinguer.
+func putStr(bucket, key, val string) error {
+ if val == "" {
+ return putBytes(bucket, key, nil)
+ }
+ return putBytes(bucket, key, []byte(val))
+}
+
+func getBool(bucket, key string) bool { return getStr(bucket, key) == "1" }
+
+func putBool(bucket, key string, on bool) error {
+ if !on {
+ return putBytes(bucket, key, nil)
+ }
+ return putStr(bucket, key, "1")
+}
+
+// getJSON décode une valeur JSON dans dst. Renvoie false si la clé est absente
+// ou illisible — dans les deux cas l'appelant garde son zéro.
+func getJSON(bucket, key string, dst any) bool {
+ b := getBytes(bucket, key)
+ if len(b) == 0 {
+ return false
+ }
+ return json.Unmarshal(b, dst) == nil
+}
+
+func putJSON(bucket, key string, v any) error {
+ b, err := json.Marshal(v)
+ if err != nil {
+ return err
+ }
+ return putBytes(bucket, key, b)
+}
+
+// --- Cache de lecture ---------------------------------------------------------
+//
+// La base est rouverte à CHAQUE opération (choix délibéré, voir plus haut), ce
+// qui est parfait pour un réglage lu de temps en temps mais coûteux dans le
+// chemin chaud : la boucle d'inférence relit le port, la clé, le mode
+// raisonnement et le seuil de compactage à chaque itération, et le compactage
+// se re-teste après chaque appel d'outil. Un tour agentique un peu fourni
+// rouvrait la base une centaine de fois.
+//
+// cachedKV garde donc le contenu d'un bucket en mémoire, invalidé par :
+// - une écriture de CE process (cacheBust, appelé par update/replaceKV) ;
+// - un changement de taille ou de date du fichier, qui trahit l'écriture d'un
+// AUTRE process (la CLI pendant que le service tourne) ;
+// - l'âge, plafonné à une seconde, filet pour le cas limite où deux écritures
+// rapprochées laisseraient date et taille inchangées.
+//
+// Il ne sert PAS aux secrets : eux se lisent directement (voir getBytesErr).
+const cacheMaxAge = time.Second
+
+type kvCache struct {
+ kv map[string]string
+ when time.Time
+ mtime time.Time
+ size int64
+}
+
+var (
+ cacheMu sync.Mutex
+ caches = map[string]kvCache{}
+)
+
+// cacheBust vide le cache d'un bucket après une écriture locale.
+func cacheBust(bucket string) {
+ cacheMu.Lock()
+ delete(caches, bucket)
+ cacheMu.Unlock()
+}
+
+// dbStamp renvoie la date et la taille du fichier de base — de quoi repérer
+// l'écriture d'un autre process pour le prix d'un stat.
+func dbStamp() (time.Time, int64) {
+ fi, err := os.Stat(dbPath())
+ if err != nil {
+ return time.Time{}, -1
+ }
+ return fi.ModTime(), fi.Size()
+}
+
+// cachedKV renvoie tout le contenu d'un bucket, depuis le cache quand il est
+// encore valable. La carte renvoyée appartient à l'appelant (copie).
+func cachedKV(bucket string) map[string]string {
+ mtime, size := dbStamp()
+ cacheMu.Lock()
+ c, ok := caches[bucket]
+ fresh := ok && c.size == size && c.mtime.Equal(mtime) && time.Since(c.when) < cacheMaxAge
+ cacheMu.Unlock()
+ if !fresh {
+ kv := allKV(bucket)
+ c = kvCache{kv: kv, when: time.Now(), mtime: mtime, size: size}
+ cacheMu.Lock()
+ caches[bucket] = c
+ cacheMu.Unlock()
+ }
+ out := make(map[string]string, len(c.kv))
+ for k, v := range c.kv {
+ out[k] = v
+ }
+ return out
+}
+
+// allKV renvoie tout le contenu d'un bucket. Utilisé par la configuration, dont
+// les clés ne sont pas connues à l'avance (EXTRA_ARGS et consorts).
+func allKV(bucket string) map[string]string {
+ m := map[string]string{}
+ _ = view(bucket, func(b *bolt.Bucket) error {
+ return b.ForEach(func(k, v []byte) error {
+ m[string(k)] = string(v)
+ return nil
+ })
+ })
+ return m
+}
+
+// replaceKV remplace tout le contenu d'un bucket en une seule transaction.
+// C'est ce qu'exige l'application d'un preset : à aucun instant la config ne
+// doit être un mélange de l'ancienne et de la nouvelle.
+func replaceKV(bucket string, m map[string]string) error {
+ cacheBust(bucket)
+ return withDB(func(d *bolt.DB) error {
+ return d.Update(func(tx *bolt.Tx) error {
+ if err := tx.DeleteBucket([]byte(bucket)); err != nil && !errors.Is(err, bolterrors.ErrBucketNotFound) {
+ return err
+ }
+ b, err := tx.CreateBucket([]byte(bucket))
+ if err != nil {
+ return err
+ }
+ for k, v := range m {
+ if v == "" {
+ continue
+ }
+ if err := b.Put([]byte(k), []byte(v)); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ })
+}
diff --git a/internal/ajean/store_cache_test.go b/internal/ajean/store_cache_test.go
new file mode 100644
index 0000000..0d2ee52
--- /dev/null
+++ b/internal/ajean/store_cache_test.go
@@ -0,0 +1,61 @@
+package ajean
+
+import "testing"
+
+// Le cache de lecture ne doit jamais servir une valeur périmée après une
+// écriture de ce process : un « ajean edit » suivi d'un « ajean restart » doit
+// démarrer sur la NOUVELLE configuration.
+func TestCacheConfigInvalideParEcriture(t *testing.T) {
+ testHome(t)
+ if err := WriteConfig(map[string]string{"CTX": "4096"}); err != nil {
+ t.Fatal(err)
+ }
+ if got := ReadConfig()["CTX"]; got != "4096" { // remplit le cache
+ t.Fatalf("CTX = %q, attendu 4096", got)
+ }
+ if err := SetConfigKey("CTX", "8192"); err != nil {
+ t.Fatal(err)
+ }
+ if got := ReadConfig()["CTX"]; got != "8192" {
+ t.Fatalf("valeur périmée servie par le cache : CTX = %q, attendu 8192", got)
+ }
+ if err := WriteConfig(map[string]string{"MODEL": "m.gguf"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := ReadConfig()["CTX"]; ok {
+ t.Error("WriteConfig remplace tout : CTX ne doit plus exister")
+ }
+}
+
+// La carte renvoyée appartient à l'appelant : la modifier ne doit pas polluer
+// le cache, sans quoi un appelant distrait corromprait la configuration vue par
+// tous les autres.
+func TestCacheConfigRenvoieUneCopie(t *testing.T) {
+ testHome(t)
+ if err := WriteConfig(map[string]string{"CTX": "4096"}); err != nil {
+ t.Fatal(err)
+ }
+ cfg := ReadConfig()
+ cfg["CTX"] = "999"
+ delete(cfg, "CTX")
+ if got := ReadConfig()["CTX"]; got != "4096" {
+ t.Fatalf("le cache a été altéré par l'appelant : CTX = %q", got)
+ }
+}
+
+// Une lecture ratée ne doit pas se confondre avec « aucune clé » : sans clé,
+// l'API de pilotage est OUVERTE, donc l'erreur doit remonter pour que
+// requireWebAuth ferme au lieu d'ouvrir.
+func TestWebKeyDistingueVideEtErreur(t *testing.T) {
+ testHome(t)
+ k, err := readWebKeyErr()
+ if err != nil || k != "" {
+ t.Fatalf("base neuve : attendu (\"\", nil), reçu (%q, %v)", k, err)
+ }
+ if err := putStr(bkState, "web_key", "secret"); err != nil {
+ t.Fatal(err)
+ }
+ if k, err := readWebKeyErr(); err != nil || k != "secret" {
+ t.Fatalf("attendu (\"secret\", nil), reçu (%q, %v)", k, err)
+ }
+}
diff --git a/internal/ajean/store_conc_test.go b/internal/ajean/store_conc_test.go
new file mode 100644
index 0000000..50eda6e
--- /dev/null
+++ b/internal/ajean/store_conc_test.go
@@ -0,0 +1,35 @@
+package ajean
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+// Deux « process » logiques qui martèlent la base en parallèle ne doivent ni
+// s'exclure ni perdre d'écriture : c'est le scénario du serveur, où le service
+// de lien tourne pendant qu'on tape des commandes.
+func TestAccesConcurrent(t *testing.T) {
+ testHome(t)
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func(n int) {
+ defer wg.Done()
+ for j := 0; j < 20; j++ {
+ _ = SetConfigKey("K", "v")
+ _ = ReadConfig()
+ }
+ }(i)
+ }
+ done := make(chan struct{})
+ go func() { wg.Wait(); close(done) }()
+ select {
+ case <-done:
+ case <-time.After(30 * time.Second):
+ t.Fatal("blocage sous accès concurrent")
+ }
+ if ReadConfig()["K"] != "v" {
+ t.Fatal("écriture perdue")
+ }
+}
diff --git a/internal/ajean/store_test.go b/internal/ajean/store_test.go
new file mode 100644
index 0000000..1ecd5d2
--- /dev/null
+++ b/internal/ajean/store_test.go
@@ -0,0 +1,67 @@
+package ajean
+
+import "testing"
+
+// testHome donne au test un $AJEAN_HOME à lui, et neutralise le pare-feu : un
+// test ne doit jamais poser (ni retirer) une règle entrante sur la machine qui
+// le fait tourner.
+func testHome(t *testing.T) string {
+ t.Helper()
+ home := t.TempDir()
+ t.Setenv("AJEAN_HOME", home)
+ firewallInert = true
+ t.Cleanup(func() { firewallInert = false })
+ return home
+}
+
+// La configuration écrite doit se relire à l'identique, et une clé vidée
+// disparaître au lieu de rester à "".
+func TestConfigAllerRetour(t *testing.T) {
+ testHome(t)
+ if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "CTX": "4096"}); err != nil {
+ t.Fatal(err)
+ }
+ if cfg := ReadConfig(); cfg["MODEL"] != "m.gguf" || cfg["CTX"] != "4096" {
+ t.Fatalf("configuration relue incorrecte : %v", cfg)
+ }
+ if err := SetConfigKey("CTX", ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := ReadConfig()["CTX"]; ok {
+ t.Error("CTX vidée mais toujours présente")
+ }
+}
+
+// WriteConfig REMPLACE : aucune clé de l'ancienne configuration ne doit
+// survivre, sans quoi une bascule de preset laisserait des réglages fantômes.
+func TestWriteConfigRemplaceTout(t *testing.T) {
+ testHome(t)
+ if err := WriteConfig(map[string]string{"A": "1", "B": "2"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := WriteConfig(map[string]string{"B": "3"}); err != nil {
+ t.Fatal(err)
+ }
+ cfg := ReadConfig()
+ if _, ok := cfg["A"]; ok {
+ t.Error("A a survécu au remplacement")
+ }
+ if cfg["B"] != "3" {
+ t.Errorf("B = %q, attendu 3", cfg["B"])
+ }
+}
+
+// Une base absente se comporte comme une base vide : les lecteurs ont tous un
+// défaut, aucun ne doit paniquer.
+func TestLecturesSurBaseVide(t *testing.T) {
+ testHome(t)
+ if len(ReadConfig()) != 0 {
+ t.Error("configuration non vide sur une base neuve")
+ }
+ if agentEnabled() || internetEnabled() {
+ t.Error("interrupteurs actifs sur une base neuve")
+ }
+ if readAPIKey() != "" || readWebKey() != "" || readLinkToken() != "" {
+ t.Error("clés non vides sur une base neuve")
+ }
+}
diff --git a/internal/ajean/sys_brand_icon.go b/internal/ajean/sys_brand_icon.go
new file mode 100644
index 0000000..f38e81d
--- /dev/null
+++ b/internal/ajean/sys_brand_icon.go
@@ -0,0 +1,135 @@
+package ajean
+
+// sys_brand_icon.go — l'icône de la marque AJEAN, rendue à la volée.
+//
+// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis NOIR + « j »
+// blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). Une seule source pour
+// tous les usages — zone de notification Windows, barre de menus macOS, icône du
+// .exe — pour qu'ils ne puissent plus diverger comme quand le favicon est passé
+// au noir en laissant les icônes système en bleu.
+//
+// Aucun asset binaire à committer : tout est dessiné ici, et l'icône du .exe est
+// produite par `go generate ./cmd/ajean` (voir tools/gen-icon).
+
+import (
+ "bytes"
+ "encoding/binary"
+ "image"
+ "image/color"
+ "image/png"
+)
+
+const trayIconSize = 32
+
+var (
+ brandBlack = color.RGBA{0x00, 0x00, 0x00, 0xff}
+ brandWhite = color.RGBA{0xff, 0xff, 0xff, 0xff}
+ brandClear = color.RGBA{0, 0, 0, 0}
+)
+
+// glyphRects : les trois rectangles blancs du « j », en unités de la grille 12.
+var glyphRects = [][4]float64{{6, 3, 2, 2}, {6, 5, 2, 2}, {4, 7, 2, 2}}
+
+// brandIconImage dessine l'icône à la taille n. bg peint le carré arrondi, fg le
+// « j ». Les deux peuvent être transparents : c'est ce qui produit l'icône
+// « template » de macOS.
+func brandIconImage(n int, bg, fg color.RGBA) *image.RGBA {
+ const r = 2.0 // rayon des coins, en unités de la grille 12
+ outside := func(gx, gy float64) bool {
+ corner := func(cx, cy float64) bool {
+ dx, dy := gx-cx, gy-cy
+ return dx*dx+dy*dy > r*r
+ }
+ switch {
+ case gx < r && gy < r:
+ return corner(r, r)
+ case gx > 12-r && gy < r:
+ return corner(12-r, r)
+ case gx < r && gy > 12-r:
+ return corner(r, 12-r)
+ case gx > 12-r && gy > 12-r:
+ return corner(12-r, 12-r)
+ }
+ return false
+ }
+ img := image.NewRGBA(image.Rect(0, 0, n, n))
+ scale := float64(n) / 12
+ for y := 0; y < n; y++ {
+ for x := 0; x < n; x++ {
+ gx := (float64(x) + 0.5) / scale
+ gy := (float64(y) + 0.5) / scale
+ if outside(gx, gy) {
+ img.Set(x, y, brandClear)
+ continue
+ }
+ c := bg
+ for _, rc := range glyphRects {
+ if gx >= rc[0] && gx < rc[0]+rc[2] && gy >= rc[1] && gy < rc[1]+rc[3] {
+ c = fg
+ }
+ }
+ img.Set(x, y, c)
+ }
+ }
+ return img
+}
+
+func encodePNG(img *image.RGBA) []byte {
+ var buf bytes.Buffer
+ _ = png.Encode(&buf, img)
+ return buf.Bytes()
+}
+
+// BrandIconPNG rend l'icône de marque (noir + « j » blanc) en PNG de n pixels.
+// Exporté pour le générateur d'icône du .exe (tools/gen-icon).
+func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) }
+
+// brandTemplatePNG rend la variante « template » attendue par macOS : seule la
+// couche alpha compte, le système colore la forme selon le thème de la barre de
+// menus. Le « j » est donc DÉCOUPÉ (transparent) dans un carré opaque, sans quoi
+// une icône entièrement noire disparaît sur une barre de menus sombre.
+//
+// Utilisée par sys_tray_darwin.go — un fichier que seule une compilation avec
+// CGO voit. Les analyseurs lancés sans CGO la croient morte : elle avait été
+// supprimée à ce titre, ce qui a cassé la compilation macOS en CI.
+//
+//lint:ignore U1000 utilisée par sys_tray_darwin.go, invisible sans CGO/macOS
+func brandTemplatePNG(n int) []byte {
+ return encodePNG(brandIconImage(n, brandBlack, brandClear))
+}
+
+// BrandICO emballe une ou plusieurs tailles PNG dans un conteneur .ico. Windows
+// accepte le PNG comme image d'une entrée .ico (alpha conservé pour les coins
+// arrondis). Plusieurs tailles = un rendu net partout, de la barre des tâches
+// (16 px) à la grande tuile de l'explorateur (256 px).
+func BrandICO(sizes ...int) []byte {
+ if len(sizes) == 0 {
+ sizes = []int{trayIconSize}
+ }
+ imgs := make([][]byte, 0, len(sizes))
+ for _, n := range sizes {
+ imgs = append(imgs, BrandIconPNG(n))
+ }
+ var ico bytes.Buffer
+ binary.Write(&ico, binary.LittleEndian, uint16(0)) // réservé
+ binary.Write(&ico, binary.LittleEndian, uint16(1)) // type = icône
+ binary.Write(&ico, binary.LittleEndian, uint16(len(imgs))) // nombre d'images
+ offset := uint32(6 + 16*len(imgs)) // fin du répertoire
+ for i, p := range imgs {
+ n := sizes[i]
+ // 256 px se code 0 dans un .ico (le champ ne fait qu'un octet).
+ ico.WriteByte(byte(n % 256))
+ ico.WriteByte(byte(n % 256))
+ ico.WriteByte(0) // couleurs de la palette (0 = truecolor)
+ ico.WriteByte(0) // réservé
+ binary.Write(&ico, binary.LittleEndian, uint16(1)) // plans
+ binary.Write(&ico, binary.LittleEndian, uint16(32)) // bits/pixel
+ binary.Write(&ico, binary.LittleEndian, uint32(len(p))) // taille données
+ binary.Write(&ico, binary.LittleEndian, offset) // offset données
+ offset += uint32(len(p))
+ }
+ for _, p := range imgs {
+ ico.Write(p)
+ }
+ return ico.Bytes()
+}
diff --git a/internal/ajean/sys_console_darwin.go b/internal/ajean/sys_console_darwin.go
new file mode 100644
index 0000000..f551ff5
--- /dev/null
+++ b/internal/ajean/sys_console_darwin.go
@@ -0,0 +1,87 @@
+//go:build darwin
+
+package ajean
+
+// sys_console_darwin.go — détection du lancement « par clic » sous macOS.
+//
+// Sur macOS il n'y a pas d'équivalent d'AttachConsole : un binaire lancé depuis
+// le Finder hérite quand même de descripteurs stdout/stderr (vers les logs
+// système). Le signal fiable, c'est l'emplacement de l'exécutable : la release
+// publie un bundle AJEAN.app dont le binaire vit dans
+// AJEAN.app/Contents/MacOS/ajean. Si on tourne depuis là, c'est un double-clic
+// dans le Finder → expérience « application » (UI web + navigateur), exactement
+// comme le double-clic sur ajean.exe sous Windows.
+//
+// Le même binaire copié dans /usr/local/bin garde évidemment le comportement CLI.
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func setupConsole() bool {
+ exe, err := os.Executable()
+ if err != nil {
+ return true
+ }
+ if p, err := filepath.EvalSymlinks(exe); err == nil {
+ exe = p
+ }
+ // .../AJEAN.app/Contents/MacOS/ajean
+ if strings.HasSuffix(filepath.Dir(exe), "/Contents/MacOS") {
+ fixFinderPath()
+ return false
+ }
+ return true
+}
+
+// appWarning signale une situation de lancement qui casse AJEAN de façon peu
+// évidente. Aujourd'hui : l'App Translocation de Gatekeeper. Une app non signée
+// ouverte depuis Téléchargements est exécutée depuis une copie en LECTURE SEULE
+// sous /private/var/folders/…/AppTranslocation//, à un chemin différent à
+// CHAQUE lancement. Conséquences vécues : plusieurs instances qui se marchent
+// dessus (la seconde trouve le port 8090 occupé et se contente d'ouvrir l'UI de
+// la première, souvent une version périmée), des process fantômes issus de
+// copies précédentes, et une mise à jour en place impossible.
+// Le remède est côté utilisateur : déplacer AJEAN.app dans /Applications.
+func appWarning() string {
+ exe, err := os.Executable()
+ if err != nil {
+ return ""
+ }
+ if !strings.Contains(exe, "/AppTranslocation/") {
+ return ""
+ }
+ return "AJEAN tourne depuis une copie temporaire en lecture seule (App Translocation de macOS). " +
+ "Fermez l'app, déplacez AJEAN.app dans le dossier Applications, puis rouvrez-la — " +
+ "sinon chaque lancement crée une instance séparée et les mises à jour du moteur échouent."
+}
+
+// fixFinderPath complète le PATH famélique hérité du Finder. Une app lancée par
+// LaunchServices reçoit /usr/bin:/bin:/usr/sbin:/sbin — pas les dossiers de
+// Homebrew : sans ça, AJEAN ne voit ni `brew`, ni `cmake`, ni `git`, et la
+// compilation d'un backend échoue alors que les outils sont bien installés.
+func fixFinderPath() {
+ extra := []string{
+ "/opt/homebrew/bin", "/opt/homebrew/sbin", // Homebrew Apple Silicon
+ "/usr/local/bin", "/usr/local/sbin", // Homebrew Intel
+ "/opt/local/bin", // MacPorts
+ }
+ path := os.Getenv("PATH")
+ have := map[string]bool{}
+ for _, p := range strings.Split(path, ":") {
+ have[p] = true
+ }
+ var add []string
+ for _, p := range extra {
+ if !have[p] {
+ if fi, err := os.Stat(p); err == nil && fi.IsDir() {
+ add = append(add, p)
+ }
+ }
+ }
+ if len(add) > 0 {
+ _ = os.Setenv("PATH", strings.Join(add, ":")+":"+path)
+ }
+}
diff --git a/internal/ajean/sys_console_other.go b/internal/ajean/sys_console_other.go
new file mode 100644
index 0000000..1cb9988
--- /dev/null
+++ b/internal/ajean/sys_console_other.go
@@ -0,0 +1,14 @@
+//go:build !windows && !darwin
+
+package ajean
+
+// setupConsole : sous Linux, le process a toujours une vraie console/tty
+// standard (stdout hérité). Rien à faire → on signale simplement « on a une
+// console ». Les cas « lancé par un clic » sont traités par
+// sys_console_windows.go (double-clic sur ajean.exe) et sys_console_darwin.go
+// (ouverture de AJEAN.app depuis le Finder).
+func setupConsole() bool { return true }
+
+// appWarning : rien à signaler hors macOS (l'App Translocation est spécifique à
+// Gatekeeper). Voir sys_console_darwin.go.
+func appWarning() string { return "" }
diff --git a/internal/ajean/sys_console_windows.go b/internal/ajean/sys_console_windows.go
new file mode 100644
index 0000000..8921f7f
--- /dev/null
+++ b/internal/ajean/sys_console_windows.go
@@ -0,0 +1,100 @@
+//go:build windows
+
+package ajean
+
+// sys_console_windows.go — gestion de la console sous Windows.
+//
+// Le binaire est compilé en sous-système CONSOLE. C'est ce qui fait que `cmd`
+// ATTEND la fin du programme : avec un binaire graphique, il rendait la main
+// aussitôt et réaffichait son invite AVANT la sortie d'AJEAN, ce qui donnait
+//
+// C:\Users\Nathan>ajean
+//
+// C:\Users\Nathan>ajean 0.8.0 — manager llama.cpp + UI web
+//
+// La même cause cassait la redirection et les tubes, et rendait `ajean chat`
+// inutilisable depuis un terminal (le shell et AJEAN se disputaient l'entrée).
+//
+// EN CONTREPARTIE, Windows alloue une console à tout lancement sans terminal —
+// un double-clic. On la referme alors immédiatement : voir setupConsole. Les
+// raccourcis créés par l'installation demandent en plus un démarrage minimisé,
+// si bien que cette console n'est jamais peinte à l'écran.
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+// win appelle une procédure Win32 et renvoie (résultat, trouvée).
+//
+// LazyProc.Call PANIQUE quand la procédure est introuvable. Or tout ce fichier
+// s'exécute AVANT le reste du programme : une panique ici et il ne reste rien à
+// l'écran pour la comprendre, l'application ne démarre tout simplement plus.
+// C'est exactement ce qui est arrivé en cherchant ShowWindow dans kernel32
+// alors qu'elle vit dans user32. Aucun réglage de confort ne mérite ça.
+func win(dll *syscall.LazyDLL, name string, a ...uintptr) (uintptr, bool) {
+ p := dll.NewProc(name)
+ if err := p.Find(); err != nil {
+ return 0, false
+ }
+ r, _, _ := p.Call(a...)
+ return r, true
+}
+
+var kernel32 = syscall.NewLazyDLL("kernel32.dll")
+
+// setupConsole décide dans quel monde on tourne et renvoie true en usage CLI.
+//
+// Le signal est le nombre de processus rattachés à notre console : si nous
+// sommes le SEUL, c'est que Windows l'a créée pour nous, donc qu'aucun terminal
+// ne nous a lancés. On la fait disparaître et on bascule en mode application.
+// Un shell rattaché (cmd, PowerShell, Windows Terminal) en fait au moins deux.
+func setupConsole() bool {
+ // Le compte vaut 0 quand il n'y a aucune console (parent détaché). On répond
+ // alors « CLI », le cas contraire étant couvert autrement : les relances
+ // internes passent « app » explicitement (voir launch, sys_firstrun_windows.go).
+ // Mieux vaut afficher une aide inutile que démarrer un serveur en silence.
+ var pids [8]uint32
+ n, ok := win(kernel32, "GetConsoleProcessList",
+ uintptr(unsafe.Pointer(&pids[0])), uintptr(len(pids)))
+ if ok && n == 1 {
+ // Masquer AVANT de libérer : sinon la fenêtre reste peinte le temps que
+ // Windows la détruise. ShowWindow est dans user32, pas kernel32.
+ if hwnd, ok := win(kernel32, "GetConsoleWindow"); ok && hwnd != 0 {
+ const swHide = 0
+ win(u32s, "ShowWindow", hwnd, swHide)
+ }
+ win(kernel32, "FreeConsole")
+ return false
+ }
+
+ // Lancé depuis un terminal : les flux standard sont déjà ceux du shell,
+ // redirections et tubes compris. Il n'y a rien à rebrancher, seulement à
+ // régler l'encodage et les couleurs.
+ configureConsole()
+ return true
+}
+
+// configureConsole passe la console en UTF-8 + traitement des séquences ANSI
+// (couleurs/curseur).
+func configureConsole() {
+ const (
+ cpUTF8 = 65001
+ enableVirtualTerminalProcessing = 0x0004
+ stdOutputHandle = ^uintptr(10) // -11
+ )
+ win(kernel32, "SetConsoleOutputCP", uintptr(cpUTF8))
+ win(kernel32, "SetConsoleCP", uintptr(cpUTF8))
+ h, ok := win(kernel32, "GetStdHandle", stdOutputHandle)
+ if !ok {
+ return
+ }
+ var mode uint32
+ if r, ok := win(kernel32, "GetConsoleMode", h, uintptr(unsafe.Pointer(&mode))); ok && r != 0 {
+ win(kernel32, "SetConsoleMode", h, uintptr(mode|enableVirtualTerminalProcessing))
+ }
+}
+
+// appWarning : rien à signaler sous Windows (pas d'App Translocation). Voir
+// sys_console_darwin.go.
+func appWarning() string { return "" }
diff --git a/internal/ajean/sys_datadir.go b/internal/ajean/sys_datadir.go
new file mode 100644
index 0000000..0d3f0ff
--- /dev/null
+++ b/internal/ajean/sys_datadir.go
@@ -0,0 +1,51 @@
+package ajean
+
+import (
+ "os"
+ "runtime"
+)
+
+// sys_datadir.go — création du dossier de données, identique sur les trois
+// plateformes. Avant, chaque installateur (Linux, macOS, Windows, plus le
+// premier lancement de l'app) créait ses dossiers et écrivait son propre
+// modèle de config.env : quatre copies qui divergeaient à la première
+// modification. Il n'y en a plus qu'une.
+
+// dataDirs est l'arborescence complète de $AJEAN_HOME. Rien d'autre n'y est
+// créé : tout le reste vit dans ajean.db.
+func dataDirs() []string {
+ return []string{AjeanHome(), backendsDir(), binDir(), presetsDir(), memoryDir(), modelsDir(), workspaceDir()}
+}
+
+// defaultConfig est la configuration de départ d'une installation neuve. Les
+// valeurs sont volontairement incomplètes (BIN et MODEL sont vides) : c'est
+// l'écran d'accueil, ou « ajean llamacpp install », qui les renseigne.
+func defaultConfig() map[string]string {
+ host := "0.0.0.0"
+ if runtime.GOOS == "windows" {
+ host = "127.0.0.1"
+ }
+ return map[string]string{
+ "PORT": "8080",
+ "HOST": host,
+ "CTX": "32768",
+ "BATCH": "2048",
+ "UBATCH": "512",
+ "NGL": "999",
+ }
+}
+
+// provisionDataDir crée l'arborescence et, sur une installation neuve, pose la
+// configuration de départ. Idempotente : une configuration existante n'est
+// jamais écrasée.
+func provisionDataDir() error {
+ for _, d := range dataDirs() {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ return err
+ }
+ }
+ if len(ReadConfig()) > 0 {
+ return nil
+ }
+ return WriteConfig(defaultConfig())
+}
diff --git a/internal/ajean/sys_disk_other.go b/internal/ajean/sys_disk_other.go
new file mode 100644
index 0000000..604dd84
--- /dev/null
+++ b/internal/ajean/sys_disk_other.go
@@ -0,0 +1,26 @@
+//go:build !windows
+
+package ajean
+
+// sys_disk_other.go — espace libre d'un système de fichiers via statfs(2).
+// Bavail (et pas Bfree) : les blocs réservés à root ne sont pas utilisables pour
+// poser un .gguf de 40 Go.
+
+import "syscall"
+
+// diskFreeAt renvoie les octets libres du système de fichiers contenant dir, ou
+// -1 si la mesure échoue. dir doit exister (l'appelant remonte les parents au
+// besoin).
+func diskFreeAt(dir string) int64 {
+ var st syscall.Statfs_t
+ if err := syscall.Statfs(dir, &st); err != nil {
+ return -1
+ }
+ // Les types de Bavail/Bsize diffèrent selon l'OS (int64 sous Linux, uint64
+ // et uint32 sous macOS) : on passe par uint64 avant de multiplier.
+ free := uint64(st.Bavail) * uint64(st.Bsize)
+ if free > 1<<62 {
+ return -1
+ }
+ return int64(free)
+}
diff --git a/internal/ajean/sys_disk_windows.go b/internal/ajean/sys_disk_windows.go
new file mode 100644
index 0000000..166bc32
--- /dev/null
+++ b/internal/ajean/sys_disk_windows.go
@@ -0,0 +1,32 @@
+package ajean
+
+// sys_disk_windows.go — espace libre d'un volume via GetDiskFreeSpaceExW. On
+// veut l'espace réellement disponible pour l'utilisateur courant (quotas
+// compris), donc le premier paramètre de sortie, pas le total du volume.
+
+import (
+ "syscall"
+ "unsafe"
+)
+
+var procGetDiskFreeSpaceExW = syscall.NewLazyDLL("kernel32.dll").NewProc("GetDiskFreeSpaceExW")
+
+// diskFreeAt renvoie les octets libres du volume contenant dir, ou -1 si la
+// mesure échoue. dir doit exister (l'appelant remonte les parents au besoin).
+func diskFreeAt(dir string) int64 {
+ p, err := syscall.UTF16PtrFromString(dir)
+ if err != nil {
+ return -1
+ }
+ var free, total, totalFree uint64
+ r, _, _ := procGetDiskFreeSpaceExW.Call(
+ uintptr(unsafe.Pointer(p)),
+ uintptr(unsafe.Pointer(&free)),
+ uintptr(unsafe.Pointer(&total)),
+ uintptr(unsafe.Pointer(&totalFree)),
+ )
+ if r == 0 || free > 1<<62 {
+ return -1
+ }
+ return int64(free)
+}
diff --git a/internal/ajean/sys_firewall_other.go b/internal/ajean/sys_firewall_other.go
new file mode 100644
index 0000000..59def08
--- /dev/null
+++ b/internal/ajean/sys_firewall_other.go
@@ -0,0 +1,16 @@
+//go:build !windows
+
+package ajean
+
+// Linux et macOS n'ont pas de pare-feu entrant activé par défaut, et quand il y
+// en a un (ufw, firewalld, pf) c'est une affaire d'administration système qu'un
+// gestionnaire de modèles n'a pas à trancher dans le dos de l'utilisateur. On
+// n'en pilote donc aucun : « inconnu » dit exactement ça, et l'interface
+// n'affiche pas d'avertissement de pare-feu là où il n'y a rien à avertir.
+
+func firewallOpen(int) error { return nil }
+func firewallClose(int) error { return nil }
+
+func firewallState(int) string { return "inconnu" }
+
+func firewallManualHint(int) string { return "" }
diff --git a/internal/ajean/sys_firewall_windows.go b/internal/ajean/sys_firewall_windows.go
new file mode 100644
index 0000000..8857a30
--- /dev/null
+++ b/internal/ajean/sys_firewall_windows.go
@@ -0,0 +1,79 @@
+//go:build windows
+
+package ajean
+
+import (
+ "fmt"
+ "os/exec"
+ "strconv"
+ "strings"
+)
+
+// sys_firewall_windows.go — règles de pare-feu entrantes pour le moteur.
+//
+// Sur une installation Windows fraîche, même avec HOST=0.0.0.0, le pare-feu
+// bloque tout ce qui arrive du réseau : l'utilisateur voyait « ça écoute
+// partout » et se prenait quand même un délai d'attente depuis son autre
+// machine. AJEAN pose donc la règle lui-même, quand il en a le droit.
+//
+// `ajean install` ne réclame PAS les droits administrateur (voir
+// sys_install_windows.go) : netsh échouera donc souvent. On ne fait pas semblant
+// que ça a marché — firewallState relit la règle, et firewallManualHint donne la
+// commande exacte à coller dans un terminal administrateur.
+
+// firewallRuleName identifie nos règles. Une par port : changer PORT dans la
+// configuration ne doit pas laisser une règle ouverte sur l'ancien.
+func firewallRuleName(port int) string {
+ return "AJEAN moteur (port " + strconv.Itoa(port) + ")"
+}
+
+// netsh exécute netsh sans faire clignoter de console (hideCmd) et renvoie sa
+// sortie combinée.
+func netsh(args ...string) (string, error) {
+ if firewallInert {
+ return "", fmt.Errorf("pare-feu non piloté")
+ }
+ out, err := hideCmd(exec.Command("netsh", args...)).CombinedOutput()
+ return strings.TrimSpace(string(out)), err
+}
+
+// firewallOpen autorise le port en entrée (TCP), pour les profils privé et
+// domaine seulement : ouvrir un modèle non authentifié sur un réseau public
+// (café, hôtel) n'est pas un défaut qu'on pose au nom de l'utilisateur.
+func firewallOpen(port int) error {
+ _ = firewallClose(port) // idempotence : pas d'empilement de règles homonymes
+ out, err := netsh("advfirewall", "firewall", "add", "rule",
+ "name="+firewallRuleName(port), "dir=in", "action=allow",
+ "protocol=TCP", "localport="+strconv.Itoa(port), "profile=private,domain")
+ if err != nil {
+ return fmt.Errorf("règle de pare-feu refusée (droits administrateur requis) : %s", out)
+ }
+ return nil
+}
+
+// firewallClose retire la règle. Absente = rien à faire, et surtout pas une
+// erreur.
+func firewallClose(port int) error {
+ _, _ = netsh("advfirewall", "firewall", "delete", "rule", "name="+firewallRuleName(port))
+ return nil
+}
+
+// firewallState relit l'état RÉEL de la règle plutôt que de croire au succès
+// supposé d'une commande passée.
+func firewallState(port int) string {
+ out, err := netsh("advfirewall", "firewall", "show", "rule", "name="+firewallRuleName(port))
+ if err != nil || strings.TrimSpace(out) == "" {
+ return "ferme"
+ }
+ // netsh répond « Aucune règle ne correspond aux critères » (localisé) avec un
+ // code de sortie non nul : le err ci-dessus suffit. Ici la règle existe.
+ return "ouvert"
+}
+
+// firewallManualHint : la commande à coller dans un terminal ADMINISTRATEUR
+// quand AJEAN n'a pas pu poser la règle lui-même.
+func firewallManualHint(port int) string {
+ return fmt.Sprintf("le pare-feu Windows bloque encore le port %d. Ouvre un terminal ADMINISTRATEUR et lance :\n"+
+ ` netsh advfirewall firewall add rule name="%s" dir=in action=allow protocol=TCP localport=%d profile=private,domain`,
+ port, firewallRuleName(port), port)
+}
diff --git a/internal/ajean/sys_firstrun_other.go b/internal/ajean/sys_firstrun_other.go
new file mode 100644
index 0000000..51df785
--- /dev/null
+++ b/internal/ajean/sys_firstrun_other.go
@@ -0,0 +1,14 @@
+//go:build !windows
+
+package ajean
+
+// appFirstRun : hors Windows, `ajean app` est lancé depuis un terminal par
+// quelqu'un qui a déjà suivi l'installation — pas de double-clic à désambiguïser,
+// donc pas de boîte de dialogue. On se contente de provisionner les données au
+// premier lancement. Ne relance jamais (renvoie toujours false).
+func appFirstRun() bool {
+ if len(ReadConfig()) == 0 {
+ _ = cmdInstall(nil)
+ }
+ return false
+}
diff --git a/internal/ajean/sys_firstrun_windows.go b/internal/ajean/sys_firstrun_windows.go
new file mode 100644
index 0000000..dbf32f0
--- /dev/null
+++ b/internal/ajean/sys_firstrun_windows.go
@@ -0,0 +1,512 @@
+//go:build windows
+
+package ajean
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+ "unsafe"
+
+ "golang.org/x/mod/semver"
+)
+
+// Premier lancement sous Windows.
+//
+// Avant : double-cliquer ajean-windows-amd64.exe déclenchait une installation
+// SILENCIEUSE (copie du binaire dans %ProgramData%\ajean\bin, ajout au PATH) dont
+// l'utilisateur ne voyait rien — cmdApp appelait cmdInstall sans console pour en
+// afficher la sortie. D'où la confusion légitime : est-ce que ça installe ou est-ce
+// que ça lance ? Et surtout, deux copies du binaire coexistaient (celle du Bureau,
+// qui tourne, et celle installée) : « mettre à jour » ne touchait que celle lancée,
+// tandis que le raccourci et le PATH pointaient sur l'autre, restée en arrière.
+//
+// Maintenant, deux cas seulement :
+//
+// 1. AJEAN n'est pas installé. On installe, sans demander : rester à
+// l'emplacement du fichier téléchargé ne donne pas une installation
+// utilisable, la question n'avait donc qu'une réponse. Copie + PATH +
+// raccourcis, un message qui dit ce qui a été fait, et on démarre depuis la
+// copie installée.
+//
+// 2. AJEAN est déjà installé. Le fichier téléchargé se comporte en installeur de
+// mise à jour, avec trois situations bien distinctes (voir runAsInstaller) :
+// plus récent et application arrêtée, on remplace et on démarre, sans un mot ;
+// plus récent mais application EN COURS, on demande s'il faut la fermer pour
+// appliquer la mise à jour, sans quoi le nouveau binaire serait écrit sans
+// que rien ne change à l'écran ; plus ancien que la version installée, on
+// avertit et on laisse choisir entre démarrer et fermer, plutôt que d'imposer
+// une régression silencieuse.
+//
+// Dans les deux cas il n'existe au final qu'UN binaire qui compte, à un
+// emplacement connu, et c'est celui que le bouton de mise à jour modifie.
+
+const (
+ mbYesNo = 0x00000004
+ mbIconQuestion = 0x00000020
+ mbIconInfo = 0x00000040
+ mbTopmost = 0x00040000
+ idYes = 6
+)
+
+var (
+ pMessageBoxW = u32s.NewProc("MessageBoxW")
+
+ verDLL = syscall.NewLazyDLL("version.dll")
+ pGetFileVersionInfoSizeW = verDLL.NewProc("GetFileVersionInfoSizeW")
+ pGetFileVersionInfoW = verDLL.NewProc("GetFileVersionInfoW")
+ pVerQueryValueW = verDLL.NewProc("VerQueryValueW")
+)
+
+func messageBox(text, title string, flags uintptr) int {
+ t, _ := syscall.UTF16PtrFromString(text)
+ c, _ := syscall.UTF16PtrFromString(title)
+ r, _, _ := pMessageBoxW.Call(0, uintptr(unsafe.Pointer(t)), uintptr(unsafe.Pointer(c)), flags|mbTopmost)
+ return int(r)
+}
+
+// appFirstRun prépare le dossier de données et, si le binaire lancé n'est pas
+// celui installé, propose l'installation. Renvoie true si l'app a été relancée
+// depuis la copie installée (l'appelant doit alors rendre la main immédiatement).
+func appFirstRun() bool {
+ // Le dossier de données et config.env sont indispensables pour que l'UI
+ // s'ouvre : on les crée sans rien demander, ils ne surprennent personne.
+ _ = provisionDataDir()
+
+ exe, err := os.Executable()
+ if err != nil {
+ return false
+ }
+ target := installedExePath()
+ // canonPath des DEUX côtés : si l'égalité rate parce que les chemins sont
+ // écrits différemment (forme courte 8.3, casse, lien), l'application installée
+ // se prend pour une copie téléchargée et se relance… en boucle infinie.
+ if canonPath(exe) == canonPath(target) {
+ // On EST l'application installée. On en profite pour garantir que les
+ // raccourcis existent : sans ça, quelqu'un qui perd son raccourci ne
+ // retrouve plus AJEAN, et n'a aucune raison de relancer le fichier
+ // téléchargé (qu'il a souvent supprimé) pour le récupérer. En tâche de
+ // fond, l'appel PowerShell ne doit pas retarder le démarrage.
+ go ensureShortcuts(target)
+ return false
+ }
+ if _, err := os.Stat(target); err == nil {
+ return runAsInstaller(target)
+ }
+
+ // Première installation. On ne demande PAS l'autorisation : il n'y a rien à
+ // arbitrer. Rester à l'emplacement du fichier téléchargé ne donne pas une
+ // installation utilisable (pas de raccourci, rien dans le PATH, et une
+ // application qui disparaît le jour où l'on vide son dossier de
+ // téléchargements). Poser une question dont une seule réponse mène quelque
+ // part, c'est faire porter à l'utilisateur un choix qui n'existe pas.
+ //
+ // On l'informe en revanche de ce qui vient d'être fait : il a double-cliqué
+ // un fichier, autant dire où le programme s'est installé et qu'il peut
+ // supprimer ce qu'il a téléchargé.
+ if _, err := installSelf(filepath.Dir(target)); err != nil {
+ // Échec le plus courant : pas les droits sur %ProgramData%. AJEAN reste
+ // parfaitement utilisable depuis son emplacement actuel, le dossier de
+ // données étant ailleurs — on démarre donc au lieu d'abandonner.
+ messageBox("Installation impossible :\n\n"+err.Error()+"\n\nAJEAN va démarrer depuis l'emplacement actuel.", "AJEAN", mbIconInfo)
+ return false
+ }
+ _, _ = addToUserPath(filepath.Dir(target))
+ shortcuts := ensureShortcuts(target)
+
+ messageBox("AJEAN est installé.\n\n"+target+"\n\n"+shortcuts+
+ "\n\nL'application va démarrer. Vous pouvez supprimer le fichier téléchargé.",
+ "AJEAN", mbIconInfo)
+
+ return launch(target)
+}
+
+// exeSelf renvoie le chemin réel du binaire en cours (liens résolus).
+func exeSelf() string {
+ p, err := os.Executable()
+ if err != nil {
+ return ""
+ }
+ if r, err := filepath.EvalSymlinks(p); err == nil {
+ return r
+ }
+ return p
+}
+
+// sameFile dit si deux fichiers ont exactement le même contenu. Taille d'abord :
+// deux binaires qui diffèrent ont presque toujours des tailles différentes, et
+// c'est immédiat, là où le hachage lit 15 Mo.
+func sameFile(a, b string) bool {
+ if a == "" || b == "" {
+ return false
+ }
+ fa, err1 := os.Stat(a)
+ fb, err2 := os.Stat(b)
+ if err1 != nil || err2 != nil {
+ return false
+ }
+ if fa.Size() != fb.Size() {
+ return false
+ }
+ ha, err1 := hashFile(a)
+ hb, err2 := hashFile(b)
+ return err1 == nil && err2 == nil && ha == hb
+}
+
+func hashFile(p string) (string, error) {
+ f, err := os.Open(p)
+ if err != nil {
+ return "", err
+ }
+ defer f.Close()
+ h := sha256.New()
+ if _, err := io.Copy(h, f); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
+
+// launch démarre la copie installée et demande à l'appelant de rendre la main.
+// Renvoie false si le lancement échoue, auquel cas l'exécutable courant prend le
+// relais : mieux vaut démarrer depuis le mauvais dossier que pas du tout.
+func launch(target string) bool {
+ // « app » est passé EXPLICITEMENT plutôt que de compter sur la détection du
+ // mode application. Celle-ci répond à une seule question — « ai-je été
+ // double-cliqué ? » — en regardant qui partage notre console. Un processus
+ // lancé sans console du tout (relance après mise à jour) ne peut pas y
+ // répondre, et se serait retrouvé à afficher l'aide au lieu de démarrer.
+ // hideCmd : le binaire est en sous-système console, donc Windows allouerait
+ // une console à l'enfant et on verrait une fenêtre noire apparaître. Comme
+ // « app » est passé explicitement, l'absence de console ne gêne pas la
+ // détection du mode application.
+ cmd := hideCmd(exec.Command(target, "app"))
+ cmd.Dir = filepath.Dir(target)
+ return cmd.Start() == nil
+}
+
+// runAsInstaller : AJEAN est déjà installé et on exécute une AUTRE copie (le
+// fichier fraîchement téléchargé). Le fichier joue alors le rôle d'installeur de
+// mise à jour. Trois situations, trois comportements distincts, parce qu'elles
+// n'appellent pas la même décision de l'utilisateur.
+func runAsInstaller(target string) bool {
+ installed := binaryVersion(target)
+ // Version illisible (binaire antérieur aux ressources de version, fichier
+ // tronqué par une copie interrompue) : on la traite comme ANCIENNE, donc
+ // remplaçable. La traiter comme « égale » condamnait ces installations à ne
+ // plus jamais se mettre à jour en lançant le fichier téléchargé, sans que
+ // rien ne le signale.
+ cmp := 1
+ if installed != "" {
+ cmp = semver.Compare(ensureV(Version), ensureV(installed))
+ }
+ // Même numéro de version ne veut pas dire même binaire : une préversion
+ // republiée en garde le sien. Sans cette comparaison des fichiers, relancer
+ // le nouveau téléchargement démarrait simplement l'ancienne copie installée,
+ // sans rien remplacer et sans rien dire. On traite alors ce cas comme une
+ // mise à jour ordinaire.
+ if cmp == 0 && !sameFile(exeSelf(), target) {
+ cmp = 1
+ }
+ running := runningPIDs(target)
+
+ switch {
+ case cmp < 0:
+ // Le fichier lancé est PLUS ANCIEN que la version installée. Le remplacer
+ // serait une régression silencieuse, et démarrer sans rien dire laisserait
+ // croire qu'on utilise la version qu'on vient de télécharger.
+ if messageBox(
+ "Une version plus récente d'AJEAN est déjà installée sur cet ordinateur.\n\n"+
+ " installée : "+installed+"\n"+
+ " ce fichier : "+Version+"\n\n"+
+ "Rien ne sera remplacé.\n\n"+
+ "Voulez-vous démarrer AJEAN (version "+installed+") ?\n"+
+ "Répondre Non ferme simplement cette fenêtre.",
+ "AJEAN", mbYesNo|mbIconQuestion) != idYes {
+ return true // rien à faire, on quitte sans démarrer
+ }
+ ensureShortcuts(target)
+ return launch(target)
+
+ case cmp > 0 && len(running) > 0:
+ // Mise à jour disponible, mais AJEAN tourne. Écrire le nouveau binaire
+ // sans redémarrer ne changerait RIEN à ce que l'utilisateur a sous les
+ // yeux : l'onglet qui s'ouvrirait serait servi par l'ancienne version. On
+ // pose donc la seule question qui compte.
+ if messageBox(
+ "AJEAN est déjà en cours d'exécution.\n\n"+
+ " version en cours : "+verLabel(installed)+"\n"+
+ " ce fichier : "+Version+" (plus récente)\n\n"+
+ "Fermer AJEAN et le redémarrer pour appliquer la mise à jour ?\n\n"+
+ "Répondre Non ouvre AJEAN dans sa version actuelle, sans rien mettre à jour.",
+ "Mise à jour d'AJEAN", mbYesNo|mbIconQuestion) != idYes {
+ ensureShortcuts(target)
+ return launch(target) // l'instance en cours reprend la main (port occupé)
+ }
+ stopProcesses(running)
+ if err := replaceInstalled(target); err != nil {
+ messageBox("La mise à jour a échoué :\n\n"+err.Error()+"\n\nAJEAN va redémarrer dans sa version actuelle.",
+ "AJEAN", mbIconInfo)
+ }
+ ensureShortcuts(target)
+ return launch(target)
+
+ case cmp > 0:
+ // Mise à jour, application arrêtée : rien à décider, on remplace et on
+ // démarre. C'est le cas courant et il doit rester muet.
+ _ = replaceInstalled(target)
+ ensureShortcuts(target)
+ return launch(target)
+ }
+
+ // Même version (ou version installée illisible) : on démarre l'installée.
+ ensureShortcuts(target)
+ return launch(target)
+}
+
+// verLabel évite d'afficher un numéro de version vide dans une boîte de dialogue.
+func verLabel(v string) string {
+ if v == "" {
+ return "inconnue"
+ }
+ return v
+}
+
+// replaceInstalled écrase le binaire installé par celui qu'on exécute. Le
+// renommage préalable en .old permet de remplacer un exécutable encore ouvert
+// (même mécanique que replaceBinary pour `ajean update`).
+func replaceInstalled(target string) error {
+ removeOldBinaries(target)
+ old, err := renameAside(target) // nom unique, cf. renameAside
+ if err != nil {
+ return err
+ }
+ if _, err := installSelf(filepath.Dir(target)); err != nil {
+ _ = os.Rename(old, target) // rollback
+ return err
+ }
+ _ = os.Remove(old) // échoue tant que l'ancien tourne ; nettoyé au prochain lancement
+ return nil
+}
+
+// runningPIDs liste les processus qui exécutent exactement ce fichier. On compare
+// le CHEMIN, pas le nom : tuer par nom d'image (« ajean.exe ») emporterait aussi
+// le processus courant et toute autre copie sans rapport.
+//
+// La comparaison se fait ICI, sur des chemins canonisés, et non dans le script
+// PowerShell : Windows expose le même fichier sous plusieurs écritures (forme
+// courte 8.3 « ADMINI~1 » contre « Administrateur », casse variable, liens). Un
+// simple -ieq entre chaînes rate alors l'instance en cours, et on remplace le
+// binaire en croyant l'application arrêtée : elle continue de tourner en
+// ancienne version, sans que rien ne l'indique. Constaté sur banc d'essai.
+func runningPIDs(target string) []int {
+ want := canonPath(target)
+ ps := `Get-Process -ErrorAction SilentlyContinue | ForEach-Object {
+ try { if ($_.Path) { "$($_.Id)|$($_.Path)" } } catch { }
+}`
+ out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)).Output()
+ if err != nil {
+ return nil
+ }
+ var pids []int
+ self := os.Getpid()
+ for _, line := range strings.Split(string(out), "\n") {
+ i := strings.IndexByte(line, '|')
+ if i < 0 {
+ continue
+ }
+ n, err := strconv.Atoi(strings.TrimSpace(line[:i]))
+ if err != nil || n == self {
+ continue
+ }
+ if canonPath(strings.TrimSpace(line[i+1:])) == want {
+ pids = append(pids, n)
+ }
+ }
+ return pids
+}
+
+// canonPath ramène un chemin Windows à une écriture unique et comparable :
+// résolution des liens et de la forme courte 8.3 quand le fichier existe, puis
+// minuscules (le système de fichiers est insensible à la casse).
+func canonPath(p string) string {
+ p = strings.TrimSpace(p)
+ if p == "" {
+ return ""
+ }
+ if long, err := filepath.EvalSymlinks(p); err == nil {
+ p = long
+ }
+ return strings.ToLower(filepath.Clean(p))
+}
+
+// stopProcesses arrête les instances listées, puis laisse le port se libérer :
+// sans cette attente, l'instance qu'on relance trouve :8090 encore occupé et se
+// contente d'ouvrir le navigateur sur une application en train de mourir.
+func stopProcesses(pids []int) {
+ for _, pid := range pids {
+ if p, err := os.FindProcess(pid); err == nil {
+ _ = p.Kill()
+ }
+ }
+ for i := 0; i < 40; i++ { // jusqu'à ~4 s
+ if !portBusy(appPort) {
+ return
+ }
+ time.Sleep(100 * time.Millisecond)
+ }
+}
+
+func portBusy(port int) bool {
+ ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
+ if err != nil {
+ return true
+ }
+ _ = ln.Close()
+ return false
+}
+
+// binaryVersion lit la version dans les ressources du fichier (VS_VERSIONINFO,
+// posées par goversioninfo, cf cmd/ajean/versioninfo.json), comme le fait
+// l'onglet « Détails » des propriétés Windows.
+//
+// ⚠️ NE PAS remplacer par un `ajean version` exécuté : le binaire est compilé en
+// sous-système GUI, il s'attache à la console du parent et n'écrit RIEN dans un
+// tuyau. La sortie capturée est vide, donc la comparaison de versions échouerait
+// toujours en silence et la mise à jour ne se ferait jamais (vérifié).
+//
+// Renvoie "" si la ressource est absente ou illisible : on s'abstient alors de
+// remplacer quoi que ce soit.
+func binaryVersion(path string) string {
+ p, err := syscall.UTF16PtrFromString(path)
+ if err != nil {
+ return ""
+ }
+ size, _, _ := pGetFileVersionInfoSizeW.Call(uintptr(unsafe.Pointer(p)), 0)
+ if size == 0 {
+ return ""
+ }
+ buf := make([]byte, size)
+ if r, _, _ := pGetFileVersionInfoW.Call(
+ uintptr(unsafe.Pointer(p)), 0, size, uintptr(unsafe.Pointer(&buf[0]))); r == 0 {
+ return ""
+ }
+ sub, _ := syscall.UTF16PtrFromString(`\`)
+ var info *vsFixedFileInfo
+ var length uint32
+ if r, _, _ := pVerQueryValueW.Call(
+ uintptr(unsafe.Pointer(&buf[0])), uintptr(unsafe.Pointer(sub)),
+ uintptr(unsafe.Pointer(&info)), uintptr(unsafe.Pointer(&length))); r == 0 || info == nil {
+ return ""
+ }
+ return fmt.Sprintf("%d.%d.%d",
+ info.FileVersionMS>>16, info.FileVersionMS&0xffff, info.FileVersionLS>>16)
+}
+
+// vsFixedFileInfo : bloc racine de VS_VERSIONINFO. Seuls les champs de version
+// nous intéressent, mais la structure doit être complète pour l'alignement.
+type vsFixedFileInfo struct {
+ Signature uint32
+ StrucVersion uint32
+ FileVersionMS uint32
+ FileVersionLS uint32
+ ProductVersionMS uint32
+ ProductVersionLS uint32
+ FileFlagsMask uint32
+ FileFlags uint32
+ FileOS uint32
+ FileType uint32
+ FileSubtype uint32
+ FileDateMS uint32
+ FileDateLS uint32
+}
+
+// ensureShortcuts garantit qu'un raccourci « AJEAN » existe dans le menu
+// Démarrer et sur le Bureau, et qu'il pointe sur le bon exécutable. Appelée à
+// CHAQUE lancement du fichier téléchargé, pas seulement à l'installation :
+// un raccourci perdu (nettoyage, migration de profil, dossier Démarrer
+// réorganisé) rendait l'application introuvable alors qu'elle était installée.
+// Les raccourcis manquants sont recréés ; ceux qui existent sont laissés tels
+// quels, pour ne pas défaire un déplacement volontaire.
+//
+// Le raccourci du menu Démarrer va dans « Programmes », l'endroit où Windows
+// range les applications installées et où la recherche du menu Démarrer va
+// chercher.
+func ensureShortcuts(target string) string {
+ ps := fmt.Sprintf(`$t=%s
+$w=New-Object -ComObject WScript.Shell
+$done=@()
+$progs=Join-Path ([Environment]::GetFolderPath('StartMenu')) 'Programs'
+foreach ($d in @($progs, [Environment]::GetFolderPath('Desktop'))) {
+ if (-not $d) { continue }
+ if (-not (Test-Path $d)) { continue }
+ $lnk=Join-Path $d 'AJEAN.lnk'
+ # Un raccourci existant est REPOINTÉ s'il vise autre chose que le binaire
+ # canonique — typiquement l'ancien « ajean.exe », qui n'est plus qu'un alias.
+ # Le laisser en l'état condamnait l'utilisateur à relancer indéfiniment une
+ # version périmée par son propre raccourci.
+ if (Test-Path $lnk) {
+ try {
+ $s=$w.CreateShortcut($lnk)
+ $chg=$false
+ if ($s.TargetPath -and $s.TargetPath -ne $t -and (Test-Path $s.TargetPath)) {
+ $s.TargetPath=$t
+ $s.WorkingDirectory=(Split-Path $t)
+ $chg=$true
+ }
+ # Les raccourcis posés avant le passage en sous-système console n'ont pas
+ # le démarrage minimisé, et laissent donc apparaître une fenêtre noire.
+ if ($s.WindowStyle -ne 7) { $s.WindowStyle=7; $chg=$true }
+ # Icône épinglée sur l'exe : sans ça, Windows garde parfois en cache
+ # l'icône d'un binaire remplacé et affiche un carré blanc.
+ if ($s.IconLocation -ne "$t,0") { $s.IconLocation="$t,0"; $chg=$true }
+ if ($chg) { $s.Save() }
+ } catch {}
+ $done+=$d; continue
+ }
+ try {
+ $s=$w.CreateShortcut($lnk)
+ $s.TargetPath=$t
+ $s.WorkingDirectory=(Split-Path $t)
+ $s.Description='AJEAN, votre IA locale'
+ # Minimisé (7) : le binaire étant en sous-système console, Windows lui alloue
+ # une console au lancement. AJEAN la referme aussitôt, mais demander un
+ # démarrage minimisé garantit qu'elle n'est jamais peinte à l'écran.
+ $s.WindowStyle=7
+ $s.IconLocation="$t,0"
+ $s.Save()
+ $done+=$d
+ } catch {}
+}
+Write-Output ($done -join ';')`, psQuote(target))
+ cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
+ out, err := cmd.CombinedOutput()
+ if err != nil || strings.TrimSpace(string(out)) == "" {
+ return "(raccourcis non créés — lancez AJEAN depuis " + target + ")"
+ }
+ return "Raccourci « AJEAN » ajouté au menu Démarrer et au Bureau."
+}
+
+// removeShortcuts efface les raccourcis posés par ensureShortcuts. Renvoie true
+// si au moins un a été supprimé. Balaie aussi l'ancien emplacement (racine du
+// menu Démarrer, utilisé jusqu'en 0.6.11) pour ne pas laisser d'orphelin.
+func removeShortcuts() bool {
+ ps := `$n=0
+$sm=[Environment]::GetFolderPath('StartMenu')
+foreach ($d in @($sm, (Join-Path $sm 'Programs'), [Environment]::GetFolderPath('Desktop'))) {
+ if (-not $d) { continue }
+ $p=Join-Path $d 'AJEAN.lnk'
+ if (Test-Path $p) { try { Remove-Item $p -Force; $n++ } catch {} }
+}
+Write-Output $n`
+ out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)).CombinedOutput()
+ return err == nil && strings.TrimSpace(string(out)) != "0"
+}
diff --git a/internal/ajean/sys_firstrun_windows_test.go b/internal/ajean/sys_firstrun_windows_test.go
new file mode 100644
index 0000000..d4bb084
--- /dev/null
+++ b/internal/ajean/sys_firstrun_windows_test.go
@@ -0,0 +1,138 @@
+//go:build windows
+
+package ajean
+
+import (
+ "os"
+ "path/filepath"
+ "regexp"
+ "testing"
+)
+
+// binaryVersion pilote la mise à jour automatique du binaire installé quand on
+// lance le fichier téléchargé : si elle renvoie du vide, plus aucune mise à jour
+// n'a lieu, et en silence. C'est exactement ce qui arrivait avec la première
+// implémentation (exécuter `ajean version`, dont la sortie est vide pour un
+// binaire en sous-système GUI). D'où ces tests sur la plomberie syscall.
+func TestBinaryVersionReadsResource(t *testing.T) {
+ // kernel32.dll porte toujours une ressource de version.
+ dll := filepath.Join(os.Getenv("SystemRoot"), "System32", "kernel32.dll")
+ if _, err := os.Stat(dll); err != nil {
+ t.Skip("kernel32.dll introuvable")
+ }
+ v := binaryVersion(dll)
+ if !regexp.MustCompile(`^\d+\.\d+\.\d+$`).MatchString(v) {
+ t.Fatalf("binaryVersion(kernel32.dll) = %q, attendu une version x.y.z", v)
+ }
+}
+
+// Sur une machine vierge, le dossier bin n'existe pas encore : installSelf doit
+// le créer. Sans ça, le premier lancement échouait sur « open
+// C:\ProgramData\ajean\bin\ajean.exe: The system cannot find the path specified »
+// et AJEAN ne s'installait jamais.
+func TestInstallSelfCreatesBinDir(t *testing.T) {
+ binDir := filepath.Join(t.TempDir(), "ajean", "bin") // deux niveaux absents
+ dst, err := installSelf(binDir)
+ if err != nil {
+ t.Fatalf("installSelf sur un dossier absent : %v", err)
+ }
+ if _, err := os.Stat(dst); err != nil {
+ t.Fatalf("binaire non écrit : %v", err)
+ }
+}
+
+// replaceInstalled doit écraser le binaire en place, y compris quand le fichier
+// cible existe déjà, et laisser l'ancien de côté sans le confondre avec la cible.
+func TestReplaceInstalled(t *testing.T) {
+ dir := t.TempDir()
+ target := filepath.Join(dir, "ajean.exe")
+ if err := os.WriteFile(target, []byte("ancienne version"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := replaceInstalled(target); err != nil {
+ t.Fatalf("replaceInstalled : %v", err)
+ }
+ got, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // installSelf copie l'exécutable courant (ici le binaire de test).
+ self, err := os.ReadFile(os.Args[0])
+ if err != nil {
+ t.Skip("binaire de test illisible")
+ }
+ if len(got) != len(self) {
+ t.Fatalf("cible = %d octets, attendu %d (le binaire courant)", len(got), len(self))
+ }
+ if _, err := os.Stat(target + ".old"); err == nil {
+ t.Error(".old subsiste alors que la cible n'était pas verrouillée")
+ }
+}
+
+// Un fichier sans ressource de version doit renvoyer "" : runAsInstaller s'en
+// sert pour s'abstenir plutôt que de remplacer un binaire au hasard.
+func TestBinaryVersionWithoutResource(t *testing.T) {
+ p := filepath.Join(t.TempDir(), "vide.exe")
+ if err := os.WriteFile(p, []byte("pas un exe"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if v := binaryVersion(p); v != "" {
+ t.Fatalf("binaryVersion sur un fichier sans ressource = %q, attendu \"\"", v)
+ }
+}
+
+// Reproduit le cas signale en usage : l'alias « ajean.exe » est EN COURS
+// d'execution au moment de la mise a jour. copyExe seul echouait, l'alias
+// restait fige sur une version perimee, et tout raccourci le visant relancait
+// indefiniment l'ancienne version — qui affichait « une version plus recente
+// est deja installee » a chaque lancement.
+func TestReplaceExeSurchargeUnFichierVerrouille(t *testing.T) {
+ dir := t.TempDir()
+ src := filepath.Join(dir, "neuf.exe")
+ dst := filepath.Join(dir, "ajean.exe")
+ if err := os.WriteFile(src, []byte("VERSION-NEUVE"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(dst, []byte("version-perimee"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ // Verrou exclusif : imite un .exe en cours d'execution sous Windows.
+ held, err := os.OpenFile(dst, os.O_RDONLY, 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer held.Close()
+
+ if err := replaceExe(src, dst); err != nil {
+ t.Fatalf("remplacement impossible: %v", err)
+ }
+ b, err := os.ReadFile(dst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(b) != "VERSION-NEUVE" {
+ t.Fatalf("l'alias est reste perime: %q", b)
+ }
+}
+
+// Le remplacement ne doit JAMAIS faire disparaitre la cible : si la copie
+// echoue apres le renommage, l'ancien fichier revient a sa place.
+func TestReplaceExeRestaureSiLaCopieEchoue(t *testing.T) {
+ dir := t.TempDir()
+ dst := filepath.Join(dir, "ajean.exe")
+ if err := os.WriteFile(dst, []byte("a-preserver"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ // Source inexistante : la copie echouera forcement.
+ if err := replaceExe(filepath.Join(dir, "absent.exe"), dst); err == nil {
+ t.Fatal("attendu une erreur")
+ }
+ b, err := os.ReadFile(dst)
+ if err != nil {
+ t.Fatalf("le fichier a disparu: %v", err)
+ }
+ if string(b) != "a-preserver" {
+ t.Fatalf("contenu altere: %q", b)
+ }
+}
diff --git a/internal/ajean/sys_install_darwin.go b/internal/ajean/sys_install_darwin.go
new file mode 100644
index 0000000..a555121
--- /dev/null
+++ b/internal/ajean/sys_install_darwin.go
@@ -0,0 +1,76 @@
+//go:build darwin
+
+package ajean
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+)
+
+// sys_install_darwin.go — la part launchd de l'installation. Le parcours commun
+// (dossiers, lien du binaire, /etc/default, chown) vit dans sys_install_unix.go.
+//
+// ⚠️ NON TESTÉ sur un vrai Mac. Le support macOS a été ajouté parce que le code
+// systemd tournait par erreur et échouait sur « systemctl: command not found ».
+// Implémentation prudente, à valider sur une machine Apple.
+
+// launchdPlistTemplate — champs : Label, binaire, argument, UserName,
+// WorkingDirectory, AJEAN_HOME, StandardOutPath, StandardErrorPath.
+// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure ; RunAtLoad relance au
+// boot une fois chargé avec `-w`.
+const launchdPlistTemplate = `
+
+
+
+ Label%s
+ ProgramArguments
+
+ %s
+ %s
+
+ UserName%s
+ WorkingDirectory%s
+ EnvironmentVariables
+ AJEAN_HOME%s
+ RunAtLoad
+ KeepAlive
+ SuccessfulExit
+ StandardOutPath%s
+ StandardErrorPath%s
+
+
+`
+
+// unitArgs associe chaque service à la sous-commande qu'il exécute.
+func unitArgs() map[string]string {
+ return map[string]string{serviceName(): "serve", uiUnitName: "web"}
+}
+
+func installServices(targetUser, ajeanHome string) error {
+ exe := installedExePath()
+ for name, arg := range unitArgs() {
+ logPath := filepath.Join(ajeanHome, name+".log")
+ plist := fmt.Sprintf(launchdPlistTemplate,
+ launchdLabel(name), exe, arg, targetUser, ajeanHome, ajeanHome, logPath, logPath)
+ path := launchdPlistPath(name)
+ if err := os.WriteFile(path, []byte(plist), 0o644); err != nil {
+ return err
+ }
+ fmt.Printf(" %s %s\n", green("✓"), path)
+ }
+ return nil
+}
+
+// uninstallServices décharge et efface les deux daemons. L'interface passe
+// AVANT le moteur : elle sait le redémarrer, l'ordre inverse pourrait le
+// relancer sous nos pieds.
+func uninstallServices() {
+ for _, name := range []string{uiUnitName, serviceName()} {
+ _ = exec.Command("launchctl", "unload", "-w", launchdPlistPath(name)).Run()
+ if err := os.Remove(launchdPlistPath(name)); err == nil {
+ fmt.Printf(" %s %s\n", green("✓"), launchdPlistPath(name))
+ }
+ }
+}
diff --git a/internal/ajean/sys_install_linux.go b/internal/ajean/sys_install_linux.go
new file mode 100644
index 0000000..769f004
--- /dev/null
+++ b/internal/ajean/sys_install_linux.go
@@ -0,0 +1,110 @@
+//go:build linux
+
+package ajean
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+)
+
+// sys_install_linux.go — la part systemd de l'installation. Le parcours commun
+// (dossiers, lien du binaire, /etc/default, chown) vit dans sys_install_unix.go.
+
+// sudoersTemplate autorise l'utilisateur à piloter UNE unité sans mot de passe.
+// Posé pour les deux : l'interface, qui tourne sans privilèges, doit pouvoir
+// redémarrer le moteur (bascule de preset) comme elle-même (nouveau jeton).
+const sudoersTemplate = `# Permet à %[1]s de piloter l'unité %[2]s sans mot de passe (posé par ajean install).
+%[1]s ALL=(root) NOPASSWD: /bin/systemctl start %[2]s, /bin/systemctl stop %[2]s, /bin/systemctl restart %[2]s, /bin/systemctl enable %[2]s, /bin/systemctl disable %[2]s
+`
+
+// engineUnitTemplate — le moteur : exec llama-server, supervisé directement par
+// systemd. Champs : User, WorkingDirectory, ExecStart.
+const engineUnitTemplate = `[Unit]
+Description=AJEAN — moteur llama.cpp
+After=network.target
+
+[Service]
+Type=simple
+User=%s
+WorkingDirectory=%s
+ExecStart=%s
+Restart=on-failure
+RestartSec=3
+
+# Priorité CPU : on remonte le process pour qu'il ne soit pas dépriorisé face
+# aux tâches de fond (sampling/orchestration côté CPU pèsent sur le débit même
+# en inference GPU). Nice négatif + scheduling normal réactif.
+Nice=-10
+CPUSchedulingPolicy=other
+CPUAccounting=yes
+
+[Install]
+WantedBy=multi-user.target
+`
+
+// uiUnitTemplate — l'interface : UI web locale, tunnel du relais et endpoint
+// OpenAI, servis par un SEUL process (donc une seule conversation).
+// Champs : unité du moteur (dépendance), User, WorkingDirectory, ExecStart.
+const uiUnitTemplate = `[Unit]
+Description=AJEAN — interface web + accès distant
+After=network-online.target %s.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=%s
+WorkingDirectory=%s
+ExecStart=%s
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+`
+
+func installServices(targetUser, ajeanHome string) error {
+ svc, exe := serviceName(), installedExePath()
+ units := map[string]string{
+ svc: fmt.Sprintf(engineUnitTemplate, targetUser, ajeanHome, exe+" serve"),
+ uiUnitName: fmt.Sprintf(uiUnitTemplate, svc, targetUser, ajeanHome, exe+" web"),
+ }
+ for name, body := range units {
+ path := "/etc/systemd/system/" + name + ".service"
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ return err
+ }
+ fmt.Printf(" %s %s\n", green("✓"), path)
+
+ sudoers := fmt.Sprintf(sudoersTemplate, targetUser, name)
+ sudoersPath := "/etc/sudoers.d/" + name
+ if err := os.WriteFile(sudoersPath, []byte(sudoers), 0o440); err != nil {
+ return err
+ }
+ fmt.Printf(" %s %s\n", green("✓"), sudoersPath)
+ }
+ _ = exec.Command("systemctl", "daemon-reload").Run()
+ return nil
+}
+
+// uninstallServices arrête, désactive et efface les deux unités. L'interface
+// passe AVANT le moteur : elle sait le redémarrer, l'ordre inverse pourrait le
+// relancer sous nos pieds.
+func uninstallServices() {
+ svc := serviceName()
+ for _, name := range []string{uiUnitName, svc} {
+ _ = exec.Command("systemctl", "stop", name).Run()
+ _ = exec.Command("systemctl", "disable", name).Run()
+ }
+ for _, p := range []string{
+ "/etc/systemd/system/" + svc + ".service",
+ "/etc/systemd/system/" + uiUnitName + ".service",
+ "/etc/sudoers.d/" + svc,
+ "/etc/sudoers.d/" + uiUnitName,
+ } {
+ if err := os.Remove(p); err == nil {
+ fmt.Printf(" %s %s\n", green("✓"), p)
+ }
+ }
+ _ = exec.Command("systemctl", "daemon-reload").Run()
+}
diff --git a/internal/ajean/sys_install_unix.go b/internal/ajean/sys_install_unix.go
new file mode 100644
index 0000000..c65ed50
--- /dev/null
+++ b/internal/ajean/sys_install_unix.go
@@ -0,0 +1,162 @@
+//go:build !windows
+
+package ajean
+
+import (
+ "fmt"
+ "os"
+ "os/user"
+ "path/filepath"
+ "strings"
+)
+
+// sys_install_unix.go — installation et désinstallation, partie commune à Linux
+// et macOS. Les deux plateformes font exactement la même chose, à un détail
+// près : la façon de déclarer les services (systemd d'un côté, launchd de
+// l'autre). Ce détail vit dans sys_install_linux.go / sys_install_darwin.go,
+// derrière deux fonctions : installServices et uninstallServices.
+//
+// Avant, chaque plateforme avait sa copie intégrale du parcours : deux fichiers
+// identiques à 60 %, donc toute correction à faire deux fois. Comme le support
+// macOS n'est pas testé sur une vraie machine, c'est précisément là qu'une
+// divergence passait inaperçue.
+
+func cmdInstall(args []string) error {
+ if os.Geteuid() != 0 {
+ return fmt.Errorf("ajean install doit être exécuté en root (sudo ajean install)")
+ }
+ targetUser := os.Getenv("SUDO_USER")
+ if targetUser == "" {
+ targetUser = "root"
+ }
+ for _, a := range args {
+ if strings.HasPrefix(a, "--user=") {
+ targetUser = strings.TrimPrefix(a, "--user=")
+ }
+ }
+ u, err := user.Lookup(targetUser)
+ if err != nil {
+ return fmt.Errorf("utilisateur '%s' introuvable: %w", targetUser, err)
+ }
+ ajeanHome := defaultAjeanHome()
+ if v := os.Getenv("AJEAN_HOME"); v != "" {
+ ajeanHome = v
+ }
+
+ fmt.Printf("Installation pour utilisateur %s\n", cyan(targetUser))
+ fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
+ fmt.Printf(" services = %s + %s\n", serviceName(), uiUnitName)
+
+ // 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
+ // elle déplace des dossiers entiers, donc la destination ne doit pas
+ // encore exister. Code temporaire, voir migrate_07.go.
+ if err := migrateFrom07(ajeanHome); err != nil {
+ return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
+ }
+
+ // 1. Arborescence + configuration de départ.
+ if err := provisionDataDir(); err != nil {
+ return err
+ }
+ fmt.Printf(" %s arborescence prête\n", green("✓"))
+
+ // 2. Lien vers /usr/local/bin/ajean.
+ if err := linkInstalledExe(); err != nil {
+ return err
+ }
+
+ // 3. /etc/default/ajean, pour que les invocations root résolvent AJEAN_HOME.
+ if err := os.MkdirAll("/etc/default", 0o755); err != nil {
+ return err
+ }
+ defaults := fmt.Sprintf("# Généré par ajean install — racine des données\nAJEAN_HOME=%s\n", ajeanHome)
+ if err := os.WriteFile("/etc/default/ajean", []byte(defaults), 0o644); err != nil {
+ return err
+ }
+ fmt.Printf(" %s /etc/default/ajean\n", green("✓"))
+
+ // 4. Déclaration des services (systemd ou launchd selon la plateforme).
+ if err := installServices(targetUser, ajeanHome); err != nil {
+ return err
+ }
+
+ // 5. AJEAN_HOME appartient à l'utilisateur cible : les deux services et les
+ // commandes qu'il tape doivent pouvoir écrire dans la base.
+ chown(ajeanHome, u)
+
+ fmt.Println()
+ fmt.Printf("%s installation terminée.\n", green("[ok]"))
+ fmt.Printf("\nProchaines étapes :\n")
+ fmt.Printf(" 1. installe le moteur : %s (compile llama.cpp, renseigne BIN)\n", bold("sudo -u "+targetUser+" ajean llamacpp install"))
+ fmt.Printf(" 2. choisis le modèle : %s (renseigne MODEL, un fichier .gguf)\n", bold("sudo -u "+targetUser+" ajean edit"))
+ fmt.Printf(" 3. démarre le moteur : %s\n", bold("sudo ajean start"))
+ fmt.Printf(" 4. vérifie : %s\n", bold("ajean test"))
+ fmt.Printf(" 5. démarre l'interface : %s (http://localhost:8090)\n", bold("sudo ajean ui start"))
+ return nil
+}
+
+// linkInstalledExe pose /usr/local/bin/ajean sur le binaire courant.
+//
+// Garde-fou (issue #5) : si on tourne DÉJÀ depuis la cible — l'utilisateur a
+// posé le binaire là puis lancé `sudo ajean install` — il ne faut surtout pas
+// Remove+Symlink sur soi-même : ça effacerait le vrai binaire et créerait un
+// lien vers lui-même (« Too many levels of symbolic links »). On compare les
+// chemins réels, résolus.
+func linkInstalledExe() error {
+ self, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ if rp, e := filepath.EvalSymlinks(self); e == nil {
+ self = rp
+ }
+ target := installedExePath()
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ if tp, e := filepath.EvalSymlinks(target); e == nil && tp == self {
+ fmt.Printf(" %s %s est déjà le binaire installé (aucun lien à créer)\n", green("✓"), target)
+ return nil
+ }
+ _ = os.Remove(target)
+ if err := os.Symlink(self, target); err != nil {
+ // Repli sur une copie si le lien échoue (volumes différents).
+ data, rerr := os.ReadFile(self)
+ if rerr != nil {
+ return rerr
+ }
+ if werr := os.WriteFile(target, data, 0o755); werr != nil {
+ return werr
+ }
+ }
+ fmt.Printf(" %s %s -> %s\n", green("✓"), target, self)
+ return nil
+}
+
+func cmdUninstall(args []string) error {
+ if os.Geteuid() != 0 {
+ return fmt.Errorf("ajean uninstall doit être exécuté en root")
+ }
+ uninstallServices()
+ for _, p := range []string{"/etc/default/ajean", installedExePath()} {
+ if err := os.Remove(p); err == nil {
+ fmt.Printf(" %s %s\n", green("✓"), p)
+ }
+ }
+ fmt.Println(dim("(données utilisateur conservées — supprime $AJEAN_HOME manuellement si tu veux purger)"))
+ fmt.Println(green("[ok]") + " désinstallé")
+ return nil
+}
+
+// chown donne récursivement path à l'utilisateur/groupe indiqué.
+func chown(path string, u *user.User) {
+ var uid, gid int
+ fmt.Sscanf(u.Uid, "%d", &uid)
+ fmt.Sscanf(u.Gid, "%d", &gid)
+ _ = filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
+ if err == nil {
+ _ = os.Chown(p, uid, gid)
+ }
+ return nil
+ })
+}
diff --git a/internal/ajean/sys_install_windows.go b/internal/ajean/sys_install_windows.go
new file mode 100644
index 0000000..1ccd722
--- /dev/null
+++ b/internal/ajean/sys_install_windows.go
@@ -0,0 +1,234 @@
+//go:build windows
+
+package ajean
+
+import (
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+)
+
+// On Windows there's no systemd unit, sudoers, or /usr/local/bin to populate.
+// `ajean install` simply provisions the data directory and a starter config; the
+// service itself is managed by the PID-file supervisor in sys_service_windows.go
+// (ajean start / stop / status), which needs no admin rights.
+
+func cmdInstall(args []string) error {
+ ajeanHome := AjeanHome()
+
+ fmt.Printf("Installation (Windows)\n")
+ fmt.Printf(" AJEAN_HOME = %s\n", ajeanHome)
+ fmt.Printf(" service = %s\n", serviceName())
+
+ // 0. Reprise d'une installation 0.7, si c'en est une. AVANT provisionDataDir :
+ // elle déplace des dossiers entiers, donc la destination ne doit pas
+ // encore exister. Code temporaire, voir migrate_07.go.
+ if err := migrateFrom07(ajeanHome); err != nil {
+ return fmt.Errorf("reprise de l'installation 0.7 : %w", err)
+ }
+
+ // 1. Arborescence + configuration de départ (partagé avec le premier
+ // lancement de l'app, voir sys_datadir.go).
+ if err := provisionDataDir(); err != nil {
+ return err
+ }
+ fmt.Printf(" %s arborescence prête\n", green("✓"))
+
+ // 2. Le binaire va dans AJEAN_HOME\bin, ajouté au PATH utilisateur, pour que
+ // `ajean` soit appelable depuis n'importe quel shell (pendant Windows du
+ // lien /usr/local/bin créé par l'installateur Unix).
+ dir := binDir()
+ onPath := false
+ if dst, err := installSelf(dir); err != nil {
+ fmt.Printf(" %s copie du binaire impossible (%v) — ajoute-le au PATH à la main\n", dim("•"), err)
+ } else {
+ fmt.Printf(" %s binaire installé %s\n", green("✓"), dst)
+ added, err := addToUserPath(dir)
+ switch {
+ case err != nil:
+ fmt.Printf(" %s mise à jour du PATH impossible (%v)\n", dim("•"), err)
+ case added:
+ fmt.Printf(" %s %s ajouté au PATH utilisateur\n", green("✓"), dir)
+ onPath = true
+ default:
+ fmt.Printf(" %s %s déjà dans le PATH\n", dim("•"), dir)
+ onPath = true
+ }
+ }
+
+ fmt.Println()
+ fmt.Printf("%s installation terminée.\n", green("[ok]"))
+ fmt.Printf("\nProchaines étapes :\n")
+ fmt.Printf(" 1. édite la config : %s (renseigne BIN, MODEL)\n", bold("ajean edit"))
+ fmt.Printf(" 2. démarre le service: %s\n", bold("ajean start"))
+ fmt.Printf(" 3. UI web : %s\n", bold("ajean web"))
+ if onPath {
+ fmt.Printf("\n%s ouvre un NOUVEAU terminal pour que 'ajean' soit reconnu (le PATH n'est lu qu'au démarrage du shell).\n", dim("[info]"))
+ } else {
+ fmt.Printf("\n%s pour exécuter 'ajean' depuis n'importe où, ajoute son dossier au PATH.\n", dim("[info]"))
+ }
+ return nil
+}
+
+// installSelf copie l'exécutable en cours dans binDir sous le nom ajean.exe et
+// renvoie le chemin de destination. Sans effet si l'exe tourne déjà depuis là
+// (ré-installation).
+//
+// binDir est créé ici, et pas seulement par l'appelant : sur une machine vierge,
+// le premier lancement passait par appFirstRun, qui ne créait que le dossier de
+// données et pas son sous-dossier bin. L'utilisateur recevait alors « open
+// C:\ProgramData\ajean\bin\ajean.exe: The system cannot find the path specified »
+// et AJEAN démarrait depuis le fichier téléchargé, sans jamais s'installer.
+func installSelf(binDir string) (string, error) {
+ src, err := os.Executable()
+ if err != nil {
+ return "", err
+ }
+ src, _ = filepath.EvalSymlinks(src)
+ dst := filepath.Join(binDir, "ajean.exe")
+ if strings.EqualFold(src, dst) {
+ return dst, nil
+ }
+ if err := os.MkdirAll(binDir, 0o755); err != nil {
+ return "", err
+ }
+ // replaceExe et non copyExe : ajean.exe peut etre en cours d'execution
+ // (service en tache de fond), auquel cas Windows refuse de l'ecraser.
+ if err := replaceExe(src, dst); err != nil {
+ return "", err
+ }
+ return dst, nil
+}
+
+// replaceExe copie src vers dst, y compris quand dst est un exécutable EN COURS.
+//
+// Windows refuse d'écraser un .exe en cours d'exécution, mais accepte de le
+// RENOMMER : on décale l'ancien puis on écrit le nouveau à sa place. Sans ça,
+// l'alias « ajean.exe » restait figé sur une version périmée dès qu'il tournait
+// au moment de la mise à jour — et comme les raccourcis existants le visent
+// encore, chaque lancement relançait l'ancienne version, qui constatait qu'une
+// plus récente était installée et le disait. À chaque fois. Constaté en usage.
+func replaceExe(src, dst string) error {
+ if err := copyExe(src, dst); err == nil {
+ return nil
+ }
+ removeOldBinaries(dst) // reliquats des remplacements précédents
+ // Nom unique : un écartement encore verrouillé par un ancien processus ne
+ // doit pas bloquer celui-ci (voir renameAside).
+ old, err := renameAside(dst)
+ if err != nil {
+ return err // ni écrasable ni renommable : on laisse la place en l'état
+ }
+ if err := copyExe(src, dst); err != nil {
+ _ = os.Rename(old, dst) // rien ne doit disparaître
+ return err
+ }
+ _ = os.Remove(old) // échoue tant que l'ancien tourne ; nettoyé plus tard
+ return nil
+}
+
+func copyExe(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
+ if err != nil {
+ return err
+ }
+ if _, err := io.Copy(out, in); err != nil {
+ out.Close()
+ return err
+ }
+ return out.Close()
+}
+
+// addToUserPath appends dir to the per-user PATH (HKCU\Environment) persistently,
+// without admin rights and without setx's 1024-char truncation. Returns false if
+// dir was already present. The change applies to newly launched shells.
+func addToUserPath(dir string) (bool, error) {
+ // Read the *user* PATH (not the process PATH, which is User+Machine merged),
+ // edit it, and write it back, all via PowerShell's environment API which
+ // handles the registry REG_EXPAND_SZ type and the WM_SETTINGCHANGE broadcast.
+ ps := fmt.Sprintf(`$d=%s
+$p=[Environment]::GetEnvironmentVariable('Path','User')
+if (-not $p) { $p='' }
+$parts=$p.Split(';') | Where-Object { $_ -ne '' }
+if ($parts -contains $d) { Write-Output 'present'; exit 0 }
+$new=(@($parts) + $d) -join ';'
+[Environment]::SetEnvironmentVariable('Path',$new,'User')
+Write-Output 'added'`, psQuote(dir))
+ cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
+ outBytes, err := cmd.CombinedOutput()
+ out := strings.TrimSpace(string(outBytes))
+ if err != nil {
+ return false, fmt.Errorf("%v: %s", err, out)
+ }
+ return strings.Contains(out, "added"), nil
+}
+
+// removeFromUserPath drops dir from the per-user PATH if present. Returns false
+// if it wasn't there.
+func removeFromUserPath(dir string) (bool, error) {
+ ps := fmt.Sprintf(`$d=%s
+$p=[Environment]::GetEnvironmentVariable('Path','User')
+if (-not $p) { Write-Output 'absent'; exit 0 }
+$parts=$p.Split(';') | Where-Object { $_ -ne '' -and $_ -ne $d }
+if (($p.Split(';') | Where-Object { $_ -eq $d }).Count -eq 0) { Write-Output 'absent'; exit 0 }
+[Environment]::SetEnvironmentVariable('Path',($parts -join ';'),'User')
+Write-Output 'removed'`, psQuote(dir))
+ cmd := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps))
+ outBytes, err := cmd.CombinedOutput()
+ out := strings.TrimSpace(string(outBytes))
+ if err != nil {
+ return false, fmt.Errorf("%v: %s", err, out)
+ }
+ return strings.Contains(out, "removed"), nil
+}
+
+// psQuote wraps s in a PowerShell single-quoted string literal (doubling any
+// embedded single quotes), safe against spaces and metacharacters in the path.
+func psQuote(s string) string {
+ return "'" + strings.ReplaceAll(s, "'", "''") + "'"
+}
+
+func cmdUninstall(args []string) error {
+ keepData := true
+ for _, a := range args {
+ if a == "--purge" {
+ keepData = false
+ }
+ if a == "--keep-data" {
+ keepData = true
+ }
+ }
+ // Stop the background server if it's running.
+ _ = svcStop(false)
+
+ // Pull AJEAN_HOME\bin off the user PATH (best-effort).
+ dir := binDir()
+ if removed, err := removeFromUserPath(dir); err == nil && removed {
+ fmt.Printf(" %s %s retiré du PATH utilisateur\n", green("✓"), dir)
+ }
+
+ // Raccourcis posés à l'installation (menu Démarrer + Bureau).
+ if removeShortcuts() {
+ fmt.Printf(" %s raccourcis « AJEAN » supprimés\n", green("✓"))
+ }
+
+ if !keepData {
+ ajeanHome := AjeanHome()
+ if err := os.RemoveAll(ajeanHome); err != nil {
+ return fmt.Errorf("suppression de %s: %w", ajeanHome, err)
+ }
+ fmt.Printf(" %s %s supprimé\n", green("✓"), ajeanHome)
+ } else {
+ fmt.Println(dim("(données utilisateur conservées — relance avec --purge pour tout supprimer)"))
+ }
+ fmt.Println(green("[ok]") + " désinstallé")
+ return nil
+}
diff --git a/internal/ajean/sys_network.go b/internal/ajean/sys_network.go
new file mode 100644
index 0000000..ac245ea
--- /dev/null
+++ b/internal/ajean/sys_network.go
@@ -0,0 +1,177 @@
+package ajean
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+)
+
+// sys_network.go — « est-ce que ma machine est joignable depuis le réseau ? »
+//
+// Le moteur écoute à l'adresse HOST. Sous Windows, l'installation pose
+// HOST=127.0.0.1 : le chat du navigateur marche (il passe par le serveur web
+// d'AJEAN, sur la même machine), mais l'endpoint OpenAI :8080/v1 est INVISIBLE
+// depuis le reste du réseau. Or c'est très exactement ce que les gens viennent
+// chercher : brancher un logiciel tiers sur le modèle qui tourne dans le salon.
+//
+// Deux choses manquaient, et pas une seule :
+// 1. HOST n'était réglable NULLE PART dans l'interface — il fallait connaître
+// « ajean edit » et savoir quoi y écrire.
+// 2. Même à 0.0.0.0, le pare-feu Windows bloque les connexions entrantes tant
+// qu'aucune règle n'autorise le port. AJEAN n'en posait aucune.
+//
+// D'où un seul interrupteur qui fait les deux, et qui DIT ce qu'il n'a pas pu
+// faire (poser une règle de pare-feu exige les droits administrateur, que
+// l'installation d'AJEAN ne réclame pas).
+
+// firewallInert coupe tout pilotage du pare-feu. Positionné par les tests : une
+// suite qui laisse une règle entrante derrière elle est un effet de bord
+// inacceptable, et sur un runner élevé elle ouvrirait le port pour de bon.
+// Déclaré ici, et non dans sys_firewall_windows.go, pour être LU sur les trois
+// plateformes (sinon il n'est qu'écrit par les tests hors Windows, ce que
+// staticcheck signale à juste titre comme une variable morte).
+var firewallInert bool
+
+// hostLocalOnly est l'adresse d'écoute « cette machine seulement ».
+const hostLocalOnly = "127.0.0.1"
+
+// hostAllInterfaces est l'adresse d'écoute « tout le réseau ».
+const hostAllInterfaces = "0.0.0.0"
+
+// engineHost renvoie l'adresse d'écoute configurée pour le moteur. Vide = le
+// défaut de backend_serve.go, c'est-à-dire toutes les interfaces.
+func engineHost() string {
+ h := strings.TrimSpace(ReadConfig()["HOST"])
+ if h == "" {
+ return hostAllInterfaces
+ }
+ return h
+}
+
+// lanExposed : le moteur écoute-t-il ailleurs que sur la boucle locale ?
+func lanExposed() bool {
+ h := engineHost()
+ return h != hostLocalOnly && h != "::1" && !strings.EqualFold(h, "localhost")
+}
+
+// netStatus est ce que l'interface affiche, et ce que renvoie `ajean network`.
+type netStatus struct {
+ Exposed bool `json:"exposed"` // le moteur écoute-t-il sur le réseau
+ Host string `json:"host"` // adresse d'écoute effective
+ Port int `json:"port"` // port du moteur
+ URL string `json:"url"` // endpoint OpenAI à coller dans un logiciel tiers
+ Firewall string `json:"firewall"` // "ouvert", "ferme", "inconnu" (aucun pare-feu piloté)
+ Hint string `json:"hint"` // ce qu'il reste à faire à la main, ou ""
+}
+
+// networkStatus assemble l'état courant.
+func networkStatus() netStatus {
+ st := netStatus{Exposed: lanExposed(), Host: engineHost(), Port: LLMPort()}
+ st.URL = fmt.Sprintf("http://%s:%d/v1", localIP(), st.Port)
+ st.Firewall = firewallState(st.Port)
+ if st.Exposed && st.Firewall == "ferme" {
+ st.Hint = firewallManualHint(st.Port)
+ }
+ return st
+}
+
+// setLANExposure écrit HOST et, à l'ouverture, tente de poser la règle de
+// pare-feu. Elle renvoie l'état obtenu ; une règle non posée n'est PAS une
+// erreur bloquante (l'utilisateur peut la poser lui-même, Hint le lui dit).
+//
+// Le service n'est pas redémarré ici : llama-server ne lit --host qu'au
+// lancement, et c'est à l'appelant de choisir le moment (l'interface propose le
+// redémarrage, la CLI le rappelle).
+func setLANExposure(on bool) (netStatus, error) {
+ host := hostLocalOnly
+ if on {
+ host = hostAllInterfaces
+ }
+ if err := SetConfigKey("HOST", host); err != nil {
+ return netStatus{}, err
+ }
+ port := LLMPort()
+ switch {
+ case firewallInert:
+ // rien : voir la note sur firewallInert
+ case on:
+ _ = firewallOpen(port)
+ default:
+ _ = firewallClose(port)
+ }
+ return networkStatus(), nil
+}
+
+// cmdNetwork : `ajean network [on|off|status]`.
+func cmdNetwork(args []string) error {
+ sub := "status"
+ if len(args) > 0 {
+ sub = args[0]
+ }
+ switch sub {
+ case "on", "off":
+ st, err := setLANExposure(sub == "on")
+ if err != nil {
+ return err
+ }
+ if st.Exposed {
+ fmt.Printf("%s le moteur écoutera sur tout le réseau (%s)\n", green("[ok]"), st.URL)
+ } else {
+ fmt.Printf("%s le moteur n'écoutera plus que sur cette machine\n", green("[ok]"))
+ }
+ printNetHint(st)
+ fmt.Printf("%s redémarre le moteur pour appliquer : %s\n", dim("[info]"), bold("ajean restart"))
+ return nil
+ case "status", "":
+ st := networkStatus()
+ state := dim("cette machine seulement")
+ if st.Exposed {
+ state = green("tout le réseau")
+ }
+ fmt.Printf("%s écoute: %s (HOST=%s, PORT=%d)\n", cyan("Réseau"), state, st.Host, st.Port)
+ switch st.Firewall {
+ case "ouvert":
+ fmt.Printf(" pare-feu : %s\n", green("port autorisé"))
+ case "ferme":
+ fmt.Printf(" pare-feu : %s\n", yellow("aucune règle pour ce port"))
+ default:
+ fmt.Printf(" pare-feu : %s\n", dim("non géré par AJEAN sur cette plateforme"))
+ }
+ if st.Exposed {
+ fmt.Printf(" endpoint OpenAI : %s\n", bold(st.URL))
+ }
+ printNetHint(st)
+ return nil
+ }
+ return fmt.Errorf("usage: ajean network [on|off|status]")
+}
+
+func printNetHint(st netStatus) {
+ if st.Hint != "" {
+ fmt.Printf("%s %s\n", yellow("[attention]"), st.Hint)
+ }
+}
+
+// handleNetwork : GET l'état, POST {exposed:bool} pour basculer.
+func handleNetwork(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodPost {
+ var req struct {
+ Exposed *bool `json:"exposed"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ if req.Exposed != nil {
+ st, err := setLANExposure(*req.Exposed)
+ if err != nil {
+ sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
+ return
+ }
+ sendJSON(w, 200, map[string]any{"ok": true, "status": st})
+ return
+ }
+ }
+ sendJSON(w, 200, map[string]any{"ok": true, "status": networkStatus()})
+}
diff --git a/internal/ajean/sys_network_test.go b/internal/ajean/sys_network_test.go
new file mode 100644
index 0000000..8f1c1b6
--- /dev/null
+++ b/internal/ajean/sys_network_test.go
@@ -0,0 +1,106 @@
+package ajean
+
+import (
+ "encoding/json"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestEngineHostDefautEtBascule(t *testing.T) {
+ testHome(t)
+ // Clé absente : c'est le défaut de backend_serve.go qui s'applique, soit toutes
+ // les interfaces. Annoncer « fermé » ici serait un mensonge.
+ if h := engineHost(); h != hostAllInterfaces {
+ t.Fatalf("HOST absent → %q ; attendu %q", h, hostAllInterfaces)
+ }
+ if !lanExposed() {
+ t.Fatal("HOST absent devrait compter comme exposé")
+ }
+ if _, err := setLANExposure(false); err != nil {
+ t.Fatal(err)
+ }
+ if lanExposed() || ReadConfig()["HOST"] != hostLocalOnly {
+ t.Fatalf("fermeture sans effet : HOST=%q", ReadConfig()["HOST"])
+ }
+ if _, err := setLANExposure(true); err != nil {
+ t.Fatal(err)
+ }
+ if !lanExposed() || ReadConfig()["HOST"] != hostAllInterfaces {
+ t.Fatalf("ouverture sans effet : HOST=%q", ReadConfig()["HOST"])
+ }
+}
+
+// « localhost » écrit à la main vaut boucle locale, au même titre que 127.0.0.1.
+func TestLanExposedReconnaitLocalhost(t *testing.T) {
+ testHome(t)
+ for _, h := range []string{"127.0.0.1", "localhost", "::1", "LOCALHOST"} {
+ if err := SetConfigKey("HOST", h); err != nil {
+ t.Fatal(err)
+ }
+ if lanExposed() {
+ t.Errorf("HOST=%q compté comme exposé", h)
+ }
+ }
+ for _, h := range []string{"0.0.0.0", "192.168.1.25", "::"} {
+ if err := SetConfigKey("HOST", h); err != nil {
+ t.Fatal(err)
+ }
+ if !lanExposed() {
+ t.Errorf("HOST=%q compté comme fermé", h)
+ }
+ }
+}
+
+// Une machine volontairement fermée ne doit pas se rouvrir toute seule parce
+// qu'on bascule sur un preset écrit avant que HOST existe.
+func TestHostSurvitAuChangementDePreset(t *testing.T) {
+ testHome(t)
+ if _, err := setLANExposure(false); err != nil {
+ t.Fatal(err)
+ }
+ p := filepath.Join(t.TempDir(), "vieux.env")
+ if err := os.WriteFile(p, []byte("MODEL=\"x.gguf\"\nCTX=4096\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := applyPresetFile(p); err != nil {
+ t.Fatal(err)
+ }
+ if ReadConfig()["HOST"] != hostLocalOnly {
+ t.Fatalf("HOST perdu à la bascule : %q", ReadConfig()["HOST"])
+ }
+ // Mais un preset qui revendique HOST garde le dernier mot.
+ if err := os.WriteFile(p, []byte("MODEL=\"x.gguf\"\nHOST=\"0.0.0.0\"\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := applyPresetFile(p); err != nil {
+ t.Fatal(err)
+ }
+ if ReadConfig()["HOST"] != hostAllInterfaces {
+ t.Fatalf("le preset n'a pas pu imposer HOST : %q", ReadConfig()["HOST"])
+ }
+}
+
+func TestHandleNetwork(t *testing.T) {
+ testHome(t)
+ rr := httptest.NewRecorder()
+ handleNetwork(rr, httptest.NewRequest("POST", "/api/network", strings.NewReader(`{"exposed":false}`)))
+ if rr.Code != 200 {
+ t.Fatalf("HTTP %d : %s", rr.Code, rr.Body)
+ }
+ var out struct {
+ OK bool `json:"ok"`
+ Status netStatus `json:"status"`
+ }
+ if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
+ t.Fatal(err)
+ }
+ if !out.OK || out.Status.Exposed || out.Status.Host != hostLocalOnly {
+ t.Fatalf("état inattendu : %+v", out.Status)
+ }
+ if out.Status.Port == 0 || !strings.Contains(out.Status.URL, "/v1") {
+ t.Fatalf("endpoint mal formé : %+v", out.Status)
+ }
+}
diff --git a/internal/ajean/sys_paths.go b/internal/ajean/sys_paths.go
new file mode 100644
index 0000000..37c0194
--- /dev/null
+++ b/internal/ajean/sys_paths.go
@@ -0,0 +1,85 @@
+package ajean
+
+import (
+ "fmt"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+)
+
+// « Où sont mes fichiers ? » — la question que personne ne devrait avoir à poser.
+// Un binaire unique qui s'auto-installe au premier lancement rend les
+// emplacements invisibles : on ne sait plus si le .exe qu'on a double-cliqué EST
+// l'application ou seulement un installateur, ni où atterrissent la config, les
+// modèles et les fichiers que l'agent écrit. On centralise donc la réponse ici,
+// et on l'expose à la fois en ligne de commande (`ajean where`) et dans l'UI.
+
+type ajeanPaths struct {
+ Home string `json:"home"` // AJEAN_HOME : racine des données
+ Database string `json:"database"` // ajean.db : config, préférences, conversation, clés
+ Exe string `json:"exe"` // binaire en cours d'exécution
+ Installed string `json:"installed"` // binaire installé (peut différer de Exe)
+ Workspace string `json:"workspace"` // dossier de travail du mode agent
+ Memory string `json:"memory"`
+ Presets string `json:"presets"`
+ Models string `json:"models"`
+ Backends string `json:"backends"`
+}
+
+// installedExePath est l'emplacement canonique du binaire après installation.
+// Il diffère par plateforme : sous Unix les installateurs posent /usr/local/bin/ajean
+// (c'est ce que référencent les unités systemd et le plist launchd) ; sous Windows,
+// faute d'équivalent, on utilise AJEAN_HOME\bin ajouté au PATH utilisateur.
+func installedExePath() string {
+ if runtime.GOOS == "windows" {
+ return filepath.Join(binDir(), "ajean.exe")
+ }
+ return "/usr/local/bin/ajean"
+}
+
+func currentPaths() ajeanPaths {
+ exe, _ := os.Executable()
+ if resolved, err := filepath.EvalSymlinks(exe); err == nil {
+ exe = resolved
+ }
+ return ajeanPaths{
+ Home: AjeanHome(),
+ Database: dbPath(),
+ Exe: exe,
+ Installed: installedExePath(),
+ Workspace: agentWorkspace(),
+ Memory: memoryDir(),
+ Presets: presetsDir(),
+ Models: modelsDir(),
+ Backends: backendsDir(),
+ }
+}
+
+func cmdWhere(args []string) error {
+ p := currentPaths()
+ fmt.Printf("Emplacements AJEAN\n\n")
+ for _, row := range [][2]string{
+ {"données (AJEAN_HOME)", p.Home},
+ {"base", p.Database},
+ {"binaire en cours", p.Exe},
+ {"binaire installé", p.Installed},
+ {"travail de l'agent", p.Workspace},
+ {"mémoire", p.Memory},
+ {"presets", p.Presets},
+ {"modèles", p.Models},
+ {"backends", p.Backends},
+ } {
+ fmt.Printf(" %-20s %s\n", row[0], row[1])
+ }
+ if p.Exe != p.Installed {
+ fmt.Printf("\n%s tu exécutes une copie qui n'est PAS le binaire installé.\n", dim("[info]"))
+ fmt.Printf(" Les mises à jour depuis l'application ne modifient que la copie lancée.\n")
+ }
+ return nil
+}
+
+// handlePaths (GET /api/paths) alimente le bloc « Emplacements » de l'UI.
+func handlePaths(w http.ResponseWriter, r *http.Request) {
+ sendJSON(w, 200, currentPaths())
+}
diff --git a/internal/ajean/sys_platform_unix.go b/internal/ajean/sys_platform_unix.go
new file mode 100644
index 0000000..103f7d1
--- /dev/null
+++ b/internal/ajean/sys_platform_unix.go
@@ -0,0 +1,366 @@
+//go:build unix
+
+package ajean
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// defaultAjeanHome est la racine des données quand ni $AJEAN_HOME ni
+// /etc/default/ajean n'en imposent une.
+//
+// Linux : /etc/ajean (machine de prod, service systemd lancé par root).
+// macOS : /etc/ajean n'est PAS écrivable par une app lancée depuis le Finder
+// (« mkdir /etc/ajean: permission denied »), et un Mac est une machine de bureau,
+// pas un serveur — on retombe donc sur le dossier utilisateur standard, sauf si
+// un `sudo ajean install` a déjà créé /etc/ajean à notre nom.
+func defaultAjeanHome() string { return unixHome("ajean") }
+
+func unixHome(name string) string {
+ if runtime.GOOS != "darwin" || os.Geteuid() == 0 {
+ return "/etc/" + name
+ }
+ if isWritableDir("/etc/" + name) {
+ return "/etc/" + name // installé par `sudo ajean install` puis chown à l'utilisateur
+ }
+ if home, err := os.UserHomeDir(); err == nil {
+ return filepath.Join(home, "Library", "Application Support", name)
+ }
+ return "/etc/" + name
+}
+
+// isWritableDir teste qu'un dossier existe ET qu'on peut y écrire (le seul test
+// fiable : les permissions POSIX seules ignorent ACL, SIP, volumes read-only).
+func isWritableDir(dir string) bool {
+ fi, err := os.Stat(dir)
+ if err != nil || !fi.IsDir() {
+ return false
+ }
+ probe := filepath.Join(dir, ".ajean-write-test")
+ f, err := os.OpenFile(probe, os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return false
+ }
+ f.Close()
+ _ = os.Remove(probe)
+ return true
+}
+
+// defaultEditor is used by `ajean edit` when $EDITOR is unset.
+func defaultEditor() string { return "nano" }
+
+// hideCmd : no-op sur Unix (pas de fenêtre de console à masquer).
+func hideCmd(cmd *exec.Cmd) *exec.Cmd { return cmd }
+
+// openBrowser ouvre l'URL dans le navigateur par défaut (macOS: open, Linux:
+// xdg-open). Best-effort.
+func openBrowser(url string) error {
+ bin := "xdg-open"
+ if runtime.GOOS == "darwin" {
+ bin = "open"
+ }
+ return exec.Command(bin, url).Start()
+}
+
+// totalRAMGB renvoie la RAM physique totale en Go (Linux: /proc/meminfo,
+// macOS: sysctl hw.memsize).
+func totalRAMGB() float64 {
+ if runtime.GOOS == "darwin" {
+ out, err := exec.Command("sysctl", "-n", "hw.memsize").Output()
+ if err != nil {
+ return 0
+ }
+ if v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64); err == nil {
+ return v / (1024 * 1024 * 1024)
+ }
+ return 0
+ }
+ b, err := os.ReadFile("/proc/meminfo")
+ if err != nil {
+ return 0
+ }
+ for _, line := range strings.Split(string(b), "\n") {
+ if strings.HasPrefix(line, "MemTotal:") {
+ f := strings.Fields(line)
+ if len(f) >= 2 {
+ if kb, err := strconv.ParseFloat(f[1], 64); err == nil {
+ return kb / (1024 * 1024) // kB → Go
+ }
+ }
+ }
+ }
+ return 0
+}
+
+// setLibraryPath ensures llama-server can load shared libs bundled next to the
+// binary by prepending dir to LD_LIBRARY_PATH. It also appends the CUDA runtime
+// lib directories: a CUDA-enabled build links against libcudart/libcublas, which
+// live under /usr/local/cuda*/lib64 and are often absent from the global ld
+// cache — without them llama-server fails to load the GPU backend (or runs
+// degraded), costing a large chunk of throughput.
+// Sur macOS, dyld IGNORE LD_LIBRARY_PATH : la variable équivalente est
+// DYLD_LIBRARY_PATH. Les binaires macOS des releases llama.cpp sont livrés avec
+// leurs libllama/libggml*.dylib À CÔTÉ de l'exécutable — sans cette variable,
+// llama-server meurt instantanément sur « Library not loaded », donc sans le
+// moindre message dans l'UI.
+func setLibraryPath(dir string) {
+ envVar, joined := libraryPathValue(dir)
+ _ = os.Setenv(envVar, joined)
+ if runtime.GOOS == "darwin" {
+ // Filet de sécurité : DYLD_FALLBACK_LIBRARY_PATH est consulté en dernier
+ // recours et survit à certains contextes où DYLD_LIBRARY_PATH est purgé.
+ _ = os.Setenv("DYLD_FALLBACK_LIBRARY_PATH", joined+":/usr/local/lib:/usr/lib")
+ }
+}
+
+// libraryPathValue calcule (nom de variable, valeur) sans toucher à
+// l'environnement du process : utilisé pour lancer un llama-server ÉPHÉMÈRE
+// (ex. `--list-devices` depuis l'UI) sans polluer — ni faire grossir à chaque
+// appel — le LD_LIBRARY_PATH du serveur web lui-même.
+func libraryPathValue(dir string) (string, string) {
+ parts := []string{dir}
+ parts = append(parts, cudaLibDirs()...)
+ envVar := "LD_LIBRARY_PATH"
+ if runtime.GOOS == "darwin" {
+ envVar = "DYLD_LIBRARY_PATH"
+ }
+ if existing := os.Getenv(envVar); existing != "" {
+ parts = append(parts, existing)
+ }
+ return envVar, strings.Join(parts, ":")
+}
+
+// libraryPathEnv renvoie un environnement complet (os.Environ + la variable de
+// recherche de bibliothèques) pour une commande ponctuelle.
+func libraryPathEnv(dir string) []string {
+ k, v := libraryPathValue(dir)
+ return append(os.Environ(), k+"="+v)
+}
+
+// cudaLibDirs returns the CUDA runtime lib directories present on the machine,
+// preferring the highest-versioned install. Empty when no CUDA toolkit is found.
+func cudaLibDirs() []string {
+ var dirs []string
+ seen := map[string]bool{}
+ add := func(d string) {
+ if d != "" && !seen[d] && isDir(d) {
+ seen[d] = true
+ dirs = append(dirs, d)
+ }
+ }
+ // Default symlink first (usually points at the active toolkit).
+ add("/usr/local/cuda/lib64")
+ add("/usr/local/cuda/targets/x86_64-linux/lib")
+ // Versioned installs, newest last so it takes precedence in PATH order.
+ versioned, _ := filepath.Glob("/usr/local/cuda-*/lib64")
+ sort.Strings(versioned)
+ for i := len(versioned) - 1; i >= 0; i-- {
+ add(versioned[i])
+ }
+ return dirs
+}
+
+// autoInstallTool installs a missing build tool with the system package manager
+// (apt/dnf/pacman on Linux, brew on macOS). Best-effort: returns an error when no
+// known manager is available or the install fails. Uses sudo on Linux when not
+// already root (brew refuses to run as root).
+func autoInstallTool(name string) error {
+ managers := []struct {
+ bin string
+ install []string // args before the package name
+ }{
+ {"apt-get", []string{"install", "-y"}},
+ {"dnf", []string{"install", "-y"}},
+ {"pacman", []string{"-S", "--noconfirm"}},
+ {"brew", []string{"install"}},
+ }
+ for _, m := range managers {
+ if _, err := exec.LookPath(m.bin); err != nil {
+ continue
+ }
+ argv := append(append([]string{m.bin}, m.install...), name)
+ if m.bin != "brew" && os.Geteuid() != 0 {
+ if _, err := exec.LookPath("sudo"); err != nil {
+ return fmt.Errorf("%s requiert root (ni root ni sudo disponibles)", m.bin)
+ }
+ argv = append([]string{"sudo"}, argv...)
+ }
+ cmd := exec.Command(argv[0], argv[1:]...)
+ cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
+ return cmd.Run()
+ }
+ return fmt.Errorf("aucun gestionnaire de paquets connu — installe %s manuellement", name)
+}
+
+// msvcGenerator is Windows-only; on Unix the default CMake generator (Unix
+// Makefiles) is correct, so detectBuildPlan never calls this for real. Present
+// only so the shared code compiles.
+func msvcGenerator() string { return "" }
+
+// ensureCompiler makes sure a C/C++ toolchain (cc + c++ + make) is present,
+// installing it via the system package manager when missing. build-essential on
+// Debian/Ubuntu pulls the lot; elsewhere we fall back to individual packages.
+func ensureCompiler() error {
+ haveCC := hasTool("cc") || hasTool("gcc") || hasTool("clang")
+ haveCXX := hasTool("c++") || hasTool("g++") || hasTool("clang++")
+ if haveCC && haveCXX && hasTool("make") {
+ return nil
+ }
+ candidates := []string{"build-essential", "gcc", "g++", "make"}
+ if _, err := exec.LookPath("apt-get"); err != nil {
+ // Non-Debian: build-essential n'existe pas, on vise les paquets directs.
+ candidates = []string{"gcc", "gcc-c++", "make"}
+ }
+ fmt.Println(yellow("[info]") + " compilateur C/C++ absent — installation via le gestionnaire de paquets…")
+ for _, pkg := range candidates {
+ _ = autoInstallTool(pkg) // best-effort, paquets variables selon la distro
+ }
+ if (hasTool("cc") || hasTool("gcc")) && (hasTool("c++") || hasTool("g++")) && hasTool("make") {
+ fmt.Println(green("✓") + " compilateur C/C++ prêt.")
+ return nil
+ }
+ return fmt.Errorf("compilateur C/C++ introuvable — installe gcc/g++/make (ou build-essential) manuellement")
+}
+
+// cudaPathEnv is Windows-specific (the MSBuild CUDA integration needs CUDA_PATH);
+// on Unix the Makefiles/Ninja generators find nvcc via PATH/CUDACXX, so there's
+// nothing extra to inject.
+func cudaPathEnv(toolkitDir string) []string { return nil }
+
+// ensureCudaVSIntegration is Windows-specific (MSBuild CUDA integration check);
+// no-op on Unix.
+func ensureCudaVSIntegration(toolkitDir string) error { return nil }
+
+// ensureAccelerator is a no-op on Unix: CUDA/ROCm toolkits are installed through
+// the distro (their layout is already probed by findNvcc / detectBuildPlan), and
+// auto-installing multi-GB GPU toolkits across distros is too varied to do safely.
+func ensureAccelerator() {}
+
+// refreshToolPath is a no-op on Unix: package managers install into directories
+// already on PATH (/usr/bin, /usr/local/bin), unlike Windows.
+func refreshToolPath() {}
+
+// execServer replaces the current process with llama-server (so systemd
+// supervises llama-server directly, as the old start.sh did with `exec`).
+// args[0] must be the binary path.
+func execServer(bin string, args []string) error {
+ return syscall.Exec(bin, args, os.Environ())
+}
+
+// newShellCmd builds the command used by the run_shell tool.
+func newShellCmd(ctx context.Context, command string) *exec.Cmd {
+ return exec.CommandContext(ctx, "/bin/bash", "-c", command)
+}
+
+// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram. Linux : /proc/meminfo.
+// macOS : sysctl pour le total, vm_stat pour ce qui est réellement libre (pages
+// free + inactive + speculative ; le reste — wired, active, compressé — est
+// considéré occupé, comme le fait le Moniteur d'activité).
+func ramUsageMB() (used, total int) {
+ if runtime.GOOS == "darwin" {
+ total = int(totalRAMGB() * 1024)
+ if total == 0 {
+ return 0, 0
+ }
+ out, err := exec.Command("vm_stat").Output()
+ if err != nil {
+ return 0, total
+ }
+ pageSize := 4096
+ freePages := 0
+ for _, line := range strings.Split(string(out), "\n") {
+ if strings.HasPrefix(line, "Mach Virtual Memory Statistics") {
+ // « … (page size of 16384 bytes) » — l'Apple Silicon n'est pas en 4 Ko.
+ if i := strings.Index(line, "page size of "); i >= 0 {
+ if v, err := strconv.Atoi(strings.Fields(line[i+len("page size of "):])[0]); err == nil {
+ pageSize = v
+ }
+ }
+ continue
+ }
+ k, v, ok := strings.Cut(line, ":")
+ if !ok {
+ continue
+ }
+ switch strings.TrimSpace(k) {
+ case "Pages free", "Pages inactive", "Pages speculative":
+ if n, err := strconv.Atoi(strings.Trim(strings.TrimSpace(v), ".")); err == nil {
+ freePages += n
+ }
+ }
+ }
+ freeMB := freePages * pageSize / (1024 * 1024)
+ if freeMB > total {
+ freeMB = total
+ }
+ return total - freeMB, total
+ }
+ b, err := os.ReadFile("/proc/meminfo")
+ if err != nil {
+ return 0, 0
+ }
+ var totalKB, availKB int
+ for _, line := range strings.Split(string(b), "\n") {
+ f := strings.Fields(line)
+ if len(f) < 2 {
+ continue
+ }
+ v, _ := strconv.Atoi(f[1]) // kB
+ switch f[0] {
+ case "MemTotal:":
+ totalKB = v
+ case "MemAvailable:":
+ availKB = v
+ }
+ }
+ if totalKB == 0 {
+ return 0, 0
+ }
+ return (totalKB - availKB) / 1024, totalKB / 1024
+}
+
+// --- Supervision de processus détachés (worker de lien, service en mode
+// utilisateur). Pendant Unix de sys_platform_windows.go.
+
+// spawnDetached prépare une commande qui survivra à la mort de AJEAN : Setsid la
+// place dans une nouvelle session, donc elle n'est pas tuée avec notre groupe.
+func spawnDetached(name string, args ...string) *exec.Cmd {
+ cmd := exec.Command(name, args...)
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
+ return cmd
+}
+
+// pidAlive : le signal 0 ne tue rien, il teste juste l'existence du process.
+func pidAlive(pid int) bool {
+ if pid <= 0 {
+ return false
+ }
+ return syscall.Kill(pid, 0) == nil
+}
+
+// killTree arrête le process ET ses enfants. Setsid ayant fait de lui un chef de
+// groupe, un PID négatif vise le groupe entier ; SIGKILL en dernier recours.
+func killTree(pid int) {
+ if pid <= 0 {
+ return
+ }
+ if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
+ _ = syscall.Kill(pid, syscall.SIGTERM)
+ }
+ for i := 0; i < 30 && pidAlive(pid); i++ {
+ time.Sleep(100 * time.Millisecond)
+ }
+ if pidAlive(pid) {
+ _ = syscall.Kill(-pid, syscall.SIGKILL)
+ }
+}
diff --git a/internal/ajean/sys_platform_windows.go b/internal/ajean/sys_platform_windows.go
new file mode 100644
index 0000000..64a55b9
--- /dev/null
+++ b/internal/ajean/sys_platform_windows.go
@@ -0,0 +1,439 @@
+//go:build windows
+
+package ajean
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "unsafe"
+)
+
+// init makes the Windows console behave like a modern terminal: UTF-8 so the
+// Unicode glyphs ajean prints (✓ ▶ …) and child-process output don't turn into
+// mojibake (the default OEM codepage, e.g. cp850, renders "✓" as "├ö"), and VT
+// processing so the ANSI colour/cursor escapes (including the build progress
+// line) are interpreted instead of printed literally. Best-effort: a redirected
+// or legacy console just keeps its defaults.
+func init() {
+ const (
+ cpUTF8 = 65001
+ enableVirtualTerminalProcessing = 0x0004
+ stdOutputHandle = ^uintptr(10) // -11 as DWORD
+ )
+ kernel32 := syscall.NewLazyDLL("kernel32.dll")
+ _, _, _ = kernel32.NewProc("SetConsoleOutputCP").Call(uintptr(cpUTF8))
+ _, _, _ = kernel32.NewProc("SetConsoleCP").Call(uintptr(cpUTF8))
+
+ getStdHandle := kernel32.NewProc("GetStdHandle")
+ getConsoleMode := kernel32.NewProc("GetConsoleMode")
+ setConsoleMode := kernel32.NewProc("SetConsoleMode")
+ h, _, _ := getStdHandle.Call(stdOutputHandle)
+ var mode uint32
+ if r, _, _ := getConsoleMode.Call(h, uintptr(unsafe.Pointer(&mode))); r != 0 {
+ _, _, _ = setConsoleMode.Call(h, uintptr(mode|enableVirtualTerminalProcessing))
+ }
+}
+
+// homeRoot is the parent directory of the data root: %ProgramData% (machine-wide,
+// the closest analogue to /etc), falling back to %LOCALAPPDATA% for unprivileged
+// setups. Ancien et nouveau dossier partagent ce parent, ce qui garantit que la
+// migration ajean → ajean est un rename intra-volume (voir sys_migrate.go).
+func homeRoot() string {
+ if pd := os.Getenv("ProgramData"); pd != "" {
+ return pd
+ }
+ if la := os.Getenv("LOCALAPPDATA"); la != "" {
+ return la
+ }
+ return os.TempDir()
+}
+
+// defaultAjeanHome est la racine des données quand $AJEAN_HOME est absent.
+func defaultAjeanHome() string { return filepath.Join(homeRoot(), "ajean") }
+
+// defaultEditor is used by `ajean edit` when $EDITOR is unset.
+func defaultEditor() string { return "notepad" }
+
+// openBrowser ouvre l'URL dans le navigateur par défaut. rundll32 évite les
+// pièges de quoting de `cmd /c start`.
+func openBrowser(url string) error {
+ return hideCmd(exec.Command("rundll32", "url.dll,FileProtocolHandler", url)).Start()
+}
+
+// hideCmd empêche une commande externe d'ouvrir une fenêtre de console
+// (CREATE_NO_WINDOW). Indispensable quand AJEAN tourne sans console (mode app) :
+// sinon chaque `nvidia-smi`/`git`/… ferait clignoter une fenêtre noire. Fusionne
+// avec les flags déjà présents pour ne pas écraser un éventuel détachement.
+func hideCmd(cmd *exec.Cmd) *exec.Cmd {
+ const createNoWindow = 0x08000000
+ if cmd.SysProcAttr == nil {
+ cmd.SysProcAttr = &syscall.SysProcAttr{}
+ }
+ cmd.SysProcAttr.HideWindow = true
+ cmd.SysProcAttr.CreationFlags |= createNoWindow
+ return cmd
+}
+
+// totalRAMGB renvoie la RAM physique totale en Go (GlobalMemoryStatusEx).
+func totalRAMGB() float64 {
+ var m struct {
+ dwLength uint32
+ dwMemoryLoad uint32
+ ullTotalPhys uint64
+ ullAvailPhys uint64
+ ullTotalPageFile uint64
+ ullAvailPageFile uint64
+ ullTotalVirtual uint64
+ ullAvailVirtual uint64
+ ullAvailExtendedVirtual uint64
+ }
+ m.dwLength = uint32(unsafe.Sizeof(m))
+ kernel32 := syscall.NewLazyDLL("kernel32.dll")
+ r, _, _ := kernel32.NewProc("GlobalMemoryStatusEx").Call(uintptr(unsafe.Pointer(&m)))
+ if r == 0 {
+ return 0
+ }
+ return float64(m.ullTotalPhys) / (1024 * 1024 * 1024)
+}
+
+// setLibraryPath ensures llama-server can load its dependent DLLs. Windows
+// resolves them via PATH (and the binary's own directory), so we prepend dir.
+// For a CUDA build we must also add the CUDA Toolkit's bin: ggml-cuda.dll links
+// against cublas64_*/cublasLt64_*.dll which live there, not next to the binary —
+// without it the server dies with 0xC0000135 (DLL not found) unless the launching
+// shell happened to have CUDA on PATH.
+func setLibraryPath(dir string) {
+ parts := []string{dir}
+ if nvcc := findNvcc(); nvcc != "" {
+ binDir := filepath.Dir(nvcc) // …\CUDA\vX.Y\bin
+ parts = append(parts, binDir)
+ // CUDA 13+ a déplacé les DLL runtime (cublas64_*, cublasLt64_*, cudart64_*)
+ // dans bin\x64\ ; sur CUDA 12 elles sont directement dans bin\. On ajoute
+ // les deux pour couvrir les deux layouts.
+ if x64 := filepath.Join(binDir, "x64"); isDir(x64) {
+ parts = append(parts, x64)
+ }
+ }
+ if existing := os.Getenv("PATH"); existing != "" {
+ parts = append(parts, existing)
+ }
+ _ = os.Setenv("PATH", strings.Join(parts, string(os.PathListSeparator)))
+}
+
+// libraryPathEnv : même chose pour une commande ponctuelle (ex. --list-devices
+// depuis l'UI), sans modifier le PATH du process web.
+func libraryPathEnv(dir string) []string {
+ parts := []string{dir}
+ if nvcc := findNvcc(); nvcc != "" {
+ binDir := filepath.Dir(nvcc)
+ parts = append(parts, binDir)
+ if x64 := filepath.Join(binDir, "x64"); isDir(x64) {
+ parts = append(parts, x64)
+ }
+ }
+ if existing := os.Getenv("PATH"); existing != "" {
+ parts = append(parts, existing)
+ }
+ return append(os.Environ(), "PATH="+strings.Join(parts, string(os.PathListSeparator)))
+}
+
+// execServer runs llama-server as a child process and waits for it. Windows has
+// no exec() that replaces the current image, so `ajean serve` stays alive as the
+// parent (this is the detached process the service supervisor tracks).
+// args[0] is the binary path; the rest are its arguments.
+func execServer(bin string, args []string) error {
+ cmd := hideCmd(exec.Command(bin, args[1:]...)) // pas de console pour llama-server (mode app)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ cmd.Env = os.Environ()
+ return cmd.Run()
+}
+
+// wingetIDs maps a tool's command name to its winget package ID.
+var wingetIDs = map[string]string{
+ "git": "Git.Git",
+ "cmake": "Kitware.CMake",
+ "ninja": "Ninja-build.Ninja",
+}
+
+// autoInstallTool installs a missing build tool via winget (bundled with Windows
+// 10/11 and Server 2025). Returns an error if winget is absent or the install
+// fails; the caller re-checks availability afterwards.
+func autoInstallTool(name string) error {
+ if _, err := exec.LookPath("winget"); err != nil {
+ return fmt.Errorf("winget introuvable — installe %s manuellement", name)
+ }
+ id, ok := wingetIDs[name]
+ if !ok {
+ id = name
+ }
+ cmd := hideCmd(exec.Command("winget", "install", "--id", id, "-e",
+ "--accept-source-agreements", "--accept-package-agreements",
+ "--disable-interactivity", "--silent"))
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ return cmd.Run()
+}
+
+// refreshToolPath reloads the process PATH from the Windows registry (Machine +
+// User), so tools just installed by winget become resolvable without restarting
+// the shell.
+func refreshToolPath() {
+ ps := `$m=[Environment]::GetEnvironmentVariable('Path','Machine')
+$u=[Environment]::GetEnvironmentVariable('Path','User')
+Write-Output ((@($m,$u) | Where-Object { $_ }) -join ';')`
+ out, err := hideCmd(exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", ps)).Output()
+ if err != nil {
+ return
+ }
+ if merged := strings.TrimSpace(string(out)); merged != "" {
+ _ = os.Setenv("PATH", merged)
+ }
+}
+
+// vswherePath returns the location of vswhere.exe, the official tool for
+// locating Visual Studio / Build Tools installs. It ships in a fixed spot.
+func vswherePath() string {
+ base := os.Getenv("ProgramFiles(x86)")
+ if base == "" {
+ base = os.Getenv("ProgramFiles")
+ }
+ return filepath.Join(base, "Microsoft Visual Studio", "Installer", "vswhere.exe")
+}
+
+// msvcInstallVersion returns the major version of the newest MSVC install that
+// has the C++ toolchain (e.g. "17"), or "" if none is found.
+func msvcInstallVersion() string {
+ vs := vswherePath()
+ if _, err := os.Stat(vs); err != nil {
+ return ""
+ }
+ out, err := hideCmd(exec.Command(vs, "-latest", "-products", "*",
+ "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
+ "-property", "installationVersion")).Output()
+ if err != nil {
+ return ""
+ }
+ ver := strings.TrimSpace(string(out))
+ if ver == "" {
+ return ""
+ }
+ if i := strings.IndexByte(ver, '.'); i > 0 {
+ return ver[:i]
+ }
+ return ver
+}
+
+// msvcGenerator returns the CMake generator name for the installed MSVC, falling
+// back to VS 2022 (the version `ensureCompiler` installs).
+func msvcGenerator() string {
+ switch msvcInstallVersion() {
+ case "16":
+ return "Visual Studio 16 2019"
+ case "15":
+ return "Visual Studio 15 2017"
+ default:
+ return "Visual Studio 17 2022"
+ }
+}
+
+// ensureCompiler makes sure an MSVC C++ toolchain is available, installing the
+// Visual Studio 2022 Build Tools (VCTools workload) via winget if not. This is a
+// large download but keeps `ajean llamacpp install` fully unattended on a bare
+// Windows box.
+func ensureCompiler() error {
+ if msvcInstallVersion() != "" {
+ return nil
+ }
+ if _, err := exec.LookPath("winget"); err != nil {
+ return fmt.Errorf("compilateur C++ absent et winget introuvable — installe « Visual Studio Build Tools » (charge de travail C++) manuellement")
+ }
+ fmt.Printf("%s compilateur C++ absent — installation des Build Tools MSVC (gros téléchargement, une seule fois)…\n", yellow("[info]"))
+ cmd := hideCmd(exec.Command("winget", "install", "--id", "Microsoft.VisualStudio.2022.BuildTools", "-e",
+ "--accept-source-agreements", "--accept-package-agreements",
+ "--disable-interactivity",
+ "--override", "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"))
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("installation des Build Tools MSVC échouée: %w", err)
+ }
+ if msvcInstallVersion() == "" {
+ return fmt.Errorf("Build Tools installés mais toolchain C++ introuvable — relance la commande ou vérifie l'installation Visual Studio")
+ }
+ fmt.Printf("%s compilateur C++ prêt.\n", green("✓"))
+ return nil
+}
+
+// ensureAccelerator installs the CUDA Toolkit when an NVIDIA GPU is present but
+// nvcc isn't, so the build can target the GPU instead of falling back to CPU.
+// Best-effort: any failure just leaves the machine on the CPU path (the caller
+// ignores the return value). The CUDA download is large; we only trigger it when
+// a GPU is actually detected.
+func ensureAccelerator() {
+ if !hasNvidiaGPU() {
+ return // pas de GPU NVIDIA visible → rien à installer, build CPU
+ }
+ if findNvcc() != "" {
+ return // toolkit déjà présent
+ }
+ if _, err := exec.LookPath("winget"); err != nil {
+ fmt.Printf("%s GPU NVIDIA détecté mais CUDA Toolkit absent et winget introuvable — build CPU (installe le CUDA Toolkit pour l'accélération GPU)\n", yellow("[info]"))
+ return
+ }
+ fmt.Printf("%s GPU NVIDIA détecté — installation du CUDA Toolkit pour l'accélération GPU (gros téléchargement, une seule fois)…\n", yellow("[info]"))
+ cmd := hideCmd(exec.Command("winget", "install", "--id", "Nvidia.CUDA", "-e",
+ "--accept-source-agreements", "--accept-package-agreements",
+ "--disable-interactivity"))
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ fmt.Printf("%s installation du CUDA Toolkit échouée (%v) — on continue en CPU\n", yellow("[warn]"), err)
+ return
+ }
+ refreshToolPath()
+ if findNvcc() != "" {
+ fmt.Printf("%s CUDA Toolkit prêt — build GPU activé.\n", green("✓"))
+ } else {
+ fmt.Printf("%s CUDA Toolkit installé mais nvcc introuvable dans cette session — relance la commande pour activer le GPU\n", yellow("[info]"))
+ }
+}
+
+// ensureCudaVSIntegration vérifie que l'intégration MSBuild de CUDA (fichiers
+// « CUDA x.y.props/targets » dans BuildCustomizations de Visual Studio) est en
+// place — sans elle, le générateur Visual Studio échoue sur le cryptique
+// « No CUDA toolset found » (CMakeDetermineCompilerId). Cas typiques : CUDA
+// installé dans un chemin custom (ex. F:\Cuda) sans cocher « Visual Studio
+// Integration », ou VS (ré)installé APRÈS CUDA — l'installeur NVIDIA n'intègre
+// que les VS présents au moment où il tourne. On tente d'abord de copier les
+// fichiers depuis le toolkit (extras\visual_studio_integration\MSBuildExtensions) ;
+// si ça échoue (droits), on explique quoi faire au lieu de laisser l'erreur
+// CMake brute. Best-effort : sans vswhere/VS détectable on laisse cmake juger.
+func ensureCudaVSIntegration(toolkitDir string) error {
+ vs := vswherePath()
+ if _, err := os.Stat(vs); err != nil {
+ return nil
+ }
+ out, err := hideCmd(exec.Command(vs, "-latest", "-products", "*",
+ "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
+ "-property", "installationPath")).Output()
+ if err != nil {
+ return nil
+ }
+ installPath := strings.TrimSpace(string(out))
+ if installPath == "" {
+ return nil
+ }
+ dsts, _ := filepath.Glob(filepath.Join(installPath, "MSBuild", "Microsoft", "VC", "*", "BuildCustomizations"))
+ for _, d := range dsts {
+ if m, _ := filepath.Glob(filepath.Join(d, "CUDA *.props")); len(m) > 0 {
+ return nil // intégration déjà en place
+ }
+ }
+ // Absente : tentative de réparation depuis le toolkit lui-même.
+ src := filepath.Join(toolkitDir, "extras", "visual_studio_integration", "MSBuildExtensions")
+ files, _ := filepath.Glob(filepath.Join(src, "*"))
+ copied := 0
+ for _, dst := range dsts {
+ ok := len(files) > 0
+ for _, f := range files {
+ b, err := os.ReadFile(f)
+ if err != nil {
+ ok = false
+ break
+ }
+ if err := os.WriteFile(filepath.Join(dst, filepath.Base(f)), b, 0o644); err != nil {
+ ok = false
+ break
+ }
+ }
+ if ok {
+ copied++
+ }
+ }
+ if copied > 0 {
+ fmt.Printf("%s intégration Visual Studio de CUDA absente — réparée (fichiers copiés depuis %s)\n", yellow("[fix]"), src)
+ return nil
+ }
+ return fmt.Errorf(`l'intégration Visual Studio de CUDA est absente : aucun fichier « CUDA x.y.props » sous
+ %s\MSBuild\Microsoft\VC\\BuildCustomizations
+Sans elle, CMake échoue sur « No CUDA toolset found ». Pour corriger, au choix :
+ 1. relance l'installeur du CUDA Toolkit (installation personnalisée) et coche « CUDA → Visual Studio Integration » — Visual Studio doit déjà être installé à ce moment-là ;
+ 2. ou copie (en admin) les fichiers de
+ %s
+ vers le dossier BuildCustomizations ci-dessus, puis relance ajean llamacpp install`, installPath, src)
+}
+
+// cudaPathEnv returns the CUDA toolkit env vars the MSBuild CUDA integration
+// needs (CUDA_PATH and the version-specific CUDA_PATH_Vx_y), derived from the
+// toolkit root, e.g. "...\CUDA\v13.3" → CUDA_PATH_V13_3. Returns NUL-free
+// "KEY=VAL" strings.
+func cudaPathEnv(toolkitDir string) []string {
+ out := []string{"CUDA_PATH=" + toolkitDir}
+ // Le dossier se nomme "v13.3" → variable CUDA_PATH_V13_3.
+ ver := strings.TrimPrefix(filepath.Base(toolkitDir), "v")
+ if ver != "" {
+ out = append(out, "CUDA_PATH_V"+strings.ReplaceAll(ver, ".", "_")+"="+toolkitDir)
+ }
+ return out
+}
+
+// newShellCmd builds the command used by the run_shell tool. hideCmd évite un
+// flash de console à chaque commande d'agent quand AJEAN tourne en mode app.
+func newShellCmd(ctx context.Context, command string) *exec.Cmd {
+ return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command))
+}
+
+// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram (GlobalMemoryStatusEx).
+func ramUsageMB() (used, total int) {
+ var m struct {
+ dwLength uint32
+ dwMemoryLoad uint32
+ ullTotalPhys uint64
+ ullAvailPhys uint64
+ ullTotalPageFile uint64
+ ullAvailPageFile uint64
+ ullTotalVirtual uint64
+ ullAvailVirtual uint64
+ ullAvailExtendedVirtual uint64
+ }
+ m.dwLength = uint32(unsafe.Sizeof(m))
+ kernel32 := syscall.NewLazyDLL("kernel32.dll")
+ if r, _, _ := kernel32.NewProc("GlobalMemoryStatusEx").Call(uintptr(unsafe.Pointer(&m))); r == 0 {
+ return 0, 0
+ }
+ const mb = 1024 * 1024
+ return int((m.ullTotalPhys - m.ullAvailPhys) / mb), int(m.ullTotalPhys / mb)
+}
+
+// --- Supervision de processus détachés (worker de lien, service). Pendant
+// Windows de sys_platform_unix.go.
+
+// spawnDetached prépare une commande détachée et SANS console : en mode app
+// (double-clic) le moindre enfant console ferait clignoter une fenêtre noire.
+func spawnDetached(name string, args ...string) *exec.Cmd {
+ cmd := exec.Command(name, args...)
+ cmd.SysProcAttr = &syscall.SysProcAttr{
+ HideWindow: true,
+ CreationFlags: createNewProcessGroup | detachedProcess | createNoWindow,
+ }
+ return cmd
+}
+
+// pidAlive : alias de processAlive (API commune avec Unix).
+func pidAlive(pid int) bool { return processAlive(pid) }
+
+// killTree arrête le process et toute sa descendance (taskkill /T).
+func killTree(pid int) {
+ if pid <= 0 {
+ return
+ }
+ _ = hideCmd(exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/T", "/F")).Run()
+}
diff --git a/internal/ajean/sys_restart_other.go b/internal/ajean/sys_restart_other.go
new file mode 100644
index 0000000..7a134b6
--- /dev/null
+++ b/internal/ajean/sys_restart_other.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package ajean
+
+// Pendant non-Windows de sys_restart_windows.go : sous Linux le redemarrage
+// passe par systemd (restartAfterUpdate), qui sait deja relancer proprement un
+// service. Il n'y a pas d'accompagnateur a lancer.
+
+const restartArg = "restart-after-update"
+
+func scheduleAppRestart() (bool, string) { return false, "" }
+
+func cmdRestartAfterUpdate([]string) error { return nil }
diff --git a/internal/ajean/sys_restart_windows.go b/internal/ajean/sys_restart_windows.go
new file mode 100644
index 0000000..57f127d
--- /dev/null
+++ b/internal/ajean/sys_restart_windows.go
@@ -0,0 +1,86 @@
+//go:build windows
+
+package ajean
+
+import (
+ "fmt"
+ "os"
+ "strconv"
+ "time"
+)
+
+// Redémarrage propre de l'application après une mise à jour, sous Windows.
+//
+// Un process ne peut pas se relancer lui-même : tant qu'il tourne, le nouveau
+// binaire ne peut pas prendre sa place sur le port, et le dossier de données
+// reste tenu. On délègue donc à un ACCOMPAGNATEUR détaché — une copie de nous
+// lancée avec une sous-commande interne — qui attend notre disparition, profite
+// de cette fenêtre pour terminer la migration, puis relance l'application.
+//
+// C'est cette fenêtre qui manquait. Une mise à jour en place laisse AJEAN
+// tourner, et donc le dossier tenu : le renommage échouait avec « Accès refusé »,
+// qui sous Windows ne distingue pas un verrou d'un manque de droits. D'où des
+// postes qui restaient indéfiniment sur l'ancien nom.
+
+const restartArg = "restart-after-update"
+
+// scheduleAppRestart lance l'accompagnateur et annonce que le redémarrage est
+// pris en charge. L'appelant doit ensuite rendre la main puis quitter, pour que
+// la réponse HTTP parte AVANT que le process ne disparaisse.
+func scheduleAppRestart() (bool, string) {
+ exe, err := os.Executable()
+ if err != nil {
+ return false, ""
+ }
+ cmd := spawnDetached(exe, restartArg, strconv.Itoa(os.Getpid()))
+ if err := cmd.Start(); err != nil {
+ return false, ""
+ }
+ // L'accompagnateur ne doit pas mourir avec nous : spawnDetached l'a déjà
+ // sorti de notre groupe de processus, on se contente de l'oublier.
+ _ = cmd.Process.Release()
+
+ go func() {
+ time.Sleep(1500 * time.Millisecond) // laisser la réponse atteindre le navigateur
+ os.Exit(0)
+ }()
+ return true, "AJEAN redémarre — la page se reconnectera toute seule dans quelques secondes."
+}
+
+// cmdRestartAfterUpdate est exécuté par l'accompagnateur détaché.
+//
+// Il n'écrit rien à l'écran : personne ne le regarde, il n'a pas de console, et
+// son seul travail visible est de faire réapparaître l'application.
+func cmdRestartAfterUpdate(args []string) error {
+ if len(args) > 0 {
+ if pid, err := strconv.Atoi(args[0]); err == nil {
+ waitForExit(pid, 30*time.Second)
+ }
+ }
+ target := installedExePath()
+ if _, err := os.Stat(target); err != nil {
+ // La cible a disparu : mieux vaut relancer ce qu'on a que rien du tout.
+ if self, e := os.Executable(); e == nil {
+ target = self
+ }
+ }
+ if !launch(target) {
+ return fmt.Errorf("relance de %s impossible", target)
+ }
+ return nil
+}
+
+// waitForExit attend la fin du process pid, sans dépasser le délai imparti.
+// Un dépassement n'est pas bloquant : on poursuit quand même, quitte à ce que
+// la migration échoue et soit retentée plus tard. Rester coincé ici serait pire
+// — l'utilisateur se retrouverait sans application du tout.
+func waitForExit(pid int, timeout time.Duration) {
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ if !pidAlive(pid) {
+ time.Sleep(500 * time.Millisecond) // laisser les handles se fermer
+ return
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+}
diff --git a/internal/ajean/sys_service.go b/internal/ajean/sys_service.go
new file mode 100644
index 0000000..23e6511
--- /dev/null
+++ b/internal/ajean/sys_service.go
@@ -0,0 +1,185 @@
+package ajean
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+// sys_service.go holds the platform-neutral pieces of service management. The
+// actual start/stop/restart/status/logs implementation is platform-specific:
+// - sys_service_linux.go → systemd (systemctl/journalctl)
+// - sys_service_darwin.go → launchd (launchctl)
+// - sys_service_windows.go → PID-file background process supervisor
+//
+// editConfig and showVram live here because they work the same everywhere.
+
+// preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de
+// lancer le service. Sinon llama-server sortait en erreur, systemd le relançait
+// toutes les 3 s, et `ajean start` affichait un « activating » rassurant pendant
+// que `ajean test` répondait « /health ne répond pas ». Le diagnostic n'était
+// visible que dans le journal.
+func preflightEngine() error {
+ cfg := ReadConfig()
+ bin := strings.TrimSpace(cfg["BIN"])
+ if bin == "" {
+ return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
+ bold("ajean llamacpp install"), bold("ajean edit"))
+ }
+ if !filepath.IsAbs(bin) {
+ bin = filepath.Join(AjeanHome(), bin)
+ }
+ if _, err := os.Stat(prebuiltResolveBin(bin)); err != nil {
+ return fmt.Errorf("moteur introuvable : %s — relance %s", bin, bold("ajean llamacpp install"))
+ }
+ model := strings.TrimSpace(cfg["MODEL"])
+ if model == "" {
+ return fmt.Errorf("MODEL non défini — indique un .gguf avec %s (dossier des modèles : %s)",
+ bold("ajean edit"), modelsDir())
+ }
+ p, err := resolveServeModelPath(model)
+ if err != nil {
+ return fmt.Errorf("MODEL=%s : %w", model, err)
+ }
+ if _, err := os.Stat(p); err != nil {
+ return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("ajean edit"))
+ }
+ // Modèle en plusieurs fichiers : une tranche manquante ne se voit qu'au moment
+ // où llama-server réclame un tenseur absent, dans le journal du service.
+ if missing := shardFamilyMissing(filepath.Dir(p), filepath.Base(p)); len(missing) > 0 {
+ return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous",
+ strings.Join(missing, ", "), filepath.Dir(p), len(shardFamily(filepath.Base(p))))
+ }
+ return nil
+}
+
+// configTemplate est le squelette commenté proposé par `ajean edit` : la
+// configuration vit en base, donc sur une installation neuve le fichier
+// temporaire était QUASI VIDE — impossible de deviner quoi écrire. On y déroule
+// donc les clés utiles avec leur rôle, les valeurs déjà définies telles quelles,
+// les autres commentées.
+var configTemplate = []struct{ key, help string }{
+ {"BIN", "chemin de llama-server (posé par « ajean llamacpp install »)"},
+ {"MODEL", "nom de fichier .gguf ou chemin complet"},
+ {"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
+ {"PORT", "port du moteur (défaut 8080)"},
+ {"CTX", "taille du contexte (défaut 32768)"},
+ {"NGL", "couches déportées sur le GPU (défaut 999 = tout)"},
+ {"BATCH", "batch (défaut 2048)"},
+ {"UBATCH", "micro-batch (défaut 512)"},
+ {"THREADS", "threads CPU, 0 = auto"},
+ {"THREADS_BATCH", "threads CPU du prefill, 0 = auto"},
+ {"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
+ {"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
+ {"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
+ {"COMPACT", "compactage automatique du contexte (off pour couper)"},
+ {"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
+ {"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
+}
+
+// configEditorText rend la configuration au format présenté dans $EDITOR.
+func configEditorText(cfg map[string]string) string {
+ var b strings.Builder
+ b.WriteString("# Configuration du moteur AJEAN (ajean-engine).\n")
+ b.WriteString("# Une clé par ligne : CLE=valeur. Les lignes commentées (#) sont ignorées :\n")
+ b.WriteString("# décommente celles dont tu as besoin. « ajean restart » applique.\n\n")
+ seen := map[string]bool{}
+ for _, f := range configTemplate {
+ seen[f.key] = true
+ fmt.Fprintf(&b, "# %s\n", f.help)
+ if v, ok := cfg[f.key]; ok && v != "" {
+ fmt.Fprintf(&b, "%s=%s\n\n", f.key, quoteValue(v))
+ } else {
+ fmt.Fprintf(&b, "#%s=\n\n", f.key)
+ }
+ }
+ // Tout ce que le squelette ne connaît pas (clés d'une version plus récente,
+ // réglages posés par l'UI) : conservé tel quel, en fin de fichier.
+ rest := map[string]string{}
+ for k, v := range cfg {
+ if !seen[k] {
+ rest[k] = v
+ }
+ }
+ if len(rest) > 0 {
+ b.WriteString("# --- autres clés déjà définies ---\n")
+ b.WriteString(formatEnv(rest))
+ }
+ return b.String()
+}
+
+// editConfig ouvre la configuration dans $EDITOR. La configuration vit en base
+// (voir store.go) : on la déroule dans un fichier temporaire au format clé=valeur,
+// on laisse l'éditeur faire son travail, puis on relit. Le contenu n'est réécrit
+// que si l'éditeur sort proprement — un éditeur avorté ne doit rien effacer.
+func editConfig() error {
+ editor := os.Getenv("EDITOR")
+ if editor == "" {
+ editor = defaultEditor()
+ }
+ tmp, err := os.CreateTemp("", "ajean-config-*.env")
+ if err != nil {
+ return err
+ }
+ path := tmp.Name()
+ defer os.Remove(path)
+ if _, err := tmp.WriteString(configEditorText(ReadConfig())); err != nil {
+ tmp.Close()
+ return err
+ }
+ tmp.Close()
+
+ cmd := exec.Command(editor, path)
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
+ if err := cmd.Run(); err != nil {
+ return err
+ }
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if err := WriteConfig(parseEnv(string(b))); err != nil {
+ return err
+ }
+ fmt.Println(dim("[info] ajean restart pour appliquer"))
+ return nil
+}
+
+// showVram parses `nvidia-smi --query-gpu=...` and renders a colored bar.
+// nvidia-smi is available on both Linux and Windows when an NVIDIA driver is
+// installed, so this is platform-neutral.
+func showVram() error {
+ out, err := hideCmd(exec.Command("nvidia-smi",
+ "--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
+ "--format=csv,noheader,nounits")).Output()
+ if err != nil {
+ return fmt.Errorf("nvidia-smi indisponible: %w", err)
+ }
+ 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])
+ }
+ name := parts[0]
+ used, _ := strconv.Atoi(parts[1])
+ total, _ := strconv.Atoi(parts[2])
+ util, _ := strconv.Atoi(parts[3])
+ temp, _ := strconv.Atoi(parts[4])
+ pct := 0
+ if total > 0 {
+ pct = used * 100 / total
+ }
+ full := pct / 5
+ bar := strings.Repeat("█", full) + strings.Repeat("░", 20-full)
+ fmt.Printf("\n %s\n", cyan(name))
+ fmt.Printf(" VRAM %s %3d%% %.1f / %.1f GiB\n", green(bar), pct, float64(used)/1024, float64(total)/1024)
+ fmt.Printf(" GPU %3d%% Temp %d°C\n\n", util, temp)
+ }
+ return nil
+}
diff --git a/internal/ajean/sys_service_darwin.go b/internal/ajean/sys_service_darwin.go
new file mode 100644
index 0000000..3a5f105
--- /dev/null
+++ b/internal/ajean/sys_service_darwin.go
@@ -0,0 +1,293 @@
+//go:build darwin
+
+package ajean
+
+import (
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+)
+
+// sys_service_darwin.go — gestion du service via launchd (LaunchDaemon), équivalent
+// macOS de sys_service_linux.go (systemd). ⚠️ NON TESTÉ sur un vrai Mac : jusqu'ici
+// le support macOS était absent (le code systemd/Linux était utilisé par erreur,
+// cf. issue #4). Implémentation prudente basée sur launchctl load/unload/list.
+
+// launchdLabel dérive le label launchd du service (ex. "com.ajean.ajean"), en
+// conservant le préfixe hérité « com.ajean. » tant que c'est LUI qui est
+// réellement installé : le plist d'un daemon déjà chargé ne se renomme pas tout
+// seul, et viser le mauvais label reviendrait à ne plus voir le service.
+func launchdLabel(svc string) string {
+ modern := "com.ajean." + svc
+ if _, err := os.Stat("/Library/LaunchDaemons/" + modern + ".plist"); err == nil {
+ return modern
+ }
+ if legacy := "com.ajean." + svc; fileExists("/Library/LaunchDaemons/" + legacy + ".plist") {
+ return legacy
+ }
+ return modern
+}
+
+func fileExists(p string) bool {
+ _, err := os.Stat(p)
+ return err == nil
+}
+
+// launchdPlistPath : chemin du LaunchDaemon (domaine système, exécuté par root
+// puis abaissé à l'utilisateur cible via la clé UserName du plist).
+func launchdPlistPath(svc string) string {
+ return "/Library/LaunchDaemons/" + launchdLabel(svc) + ".plist"
+}
+
+// launchdLogPath : sortie standard/erreur du service, sous AJEAN_HOME (accessible
+// en écriture par l'utilisateur du service après le chown de l'installation).
+func launchdLogPath() string { return filepath.Join(AjeanHome(), serviceName()+".log") }
+
+// serviceAction mappe start/stop/restart/enable/disable sur launchctl. `load -w`
+// (re)active le service ET le rend persistant au boot ; `unload -w` le désactive.
+func serviceAction(action string) error {
+ svc := serviceName()
+ plist := launchdPlistPath(svc)
+ // Pas de LaunchDaemon installé — cas normal d'un Mac de bureau, où AJEAN tourne
+ // via AJEAN.app sans jamais passer par `sudo ajean install` : on gère alors le
+ // service comme sous Windows, avec un `ajean serve` détaché suivi par un fichier
+ // PID. Aucun droit root requis. Sans ça, `start` échouait sur un plist absent
+ // et l'UI affichait éternellement « service arrêté ».
+ if _, err := os.Stat(plist); err != nil {
+ return userSvcAction(action)
+ }
+ run := func(args ...string) error {
+ bin, a := "launchctl", args
+ if os.Geteuid() != 0 {
+ bin, a = "sudo", append([]string{"-n", "launchctl"}, args...)
+ }
+ cmd := exec.Command(bin, a...)
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ return cmd.Run()
+ }
+ switch action {
+ case "start", "enable":
+ if err := run("load", "-w", plist); err != nil {
+ return err
+ }
+ return checkStarted(svc)
+ case "stop", "disable":
+ if err := run("unload", "-w", plist); err != nil {
+ return err
+ }
+ fmt.Println(green("[ok]") + " arrêté")
+ return nil
+ case "restart":
+ _ = run("unload", plist) // best-effort : peut ne pas être chargé
+ if err := run("load", "-w", plist); err != nil {
+ return err
+ }
+ return checkStarted(svc)
+ case "status":
+ if serviceIsActive() {
+ fmt.Printf("%s %s: actif (launchd)\n", green("[ok]"), svc)
+ } else {
+ fmt.Printf("%s %s: arrêté\n", yellow("[info]"), svc)
+ }
+ fmt.Printf(" logs : %s\n", launchdLogPath())
+ return nil
+ }
+ return fmt.Errorf("action inconnue: %s", action)
+}
+
+func checkStarted(svc string) error {
+ time.Sleep(2 * time.Second)
+ if serviceIsActive() {
+ fmt.Printf("%s %s: actif\n", green("[ok]"), svc)
+ return nil
+ }
+ fmt.Printf("%s %s: non démarré — derniers logs :\n", red("[ERREUR]"), svc)
+ fmt.Println("------------------------------------------------")
+ if b, err := os.ReadFile(launchdLogPath()); err == nil {
+ lines := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
+ if len(lines) > 20 {
+ lines = lines[len(lines)-20:]
+ }
+ fmt.Println(strings.Join(lines, "\n"))
+ }
+ fmt.Println("------------------------------------------------")
+ fmt.Printf("→ ajean logs pour plus de détails\n→ ajean edit pour corriger config.env\n")
+ return fmt.Errorf("service %s non démarré", svc)
+}
+
+func serviceLogs() error {
+ // tail -f du fichier de log défini dans le plist (StandardOut/ErrorPath).
+ cmd := exec.Command("tail", "-n", "80", "-f", launchdLogPath())
+ cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
+ return cmd.Run()
+}
+
+// serviceIsActive : le service est chargé ET possède un PID courant. `launchctl
+// list