mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 15:08:16 +02:00
Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
513aca1c7b | ||
|
|
f1075030dd | ||
|
|
c4e399e101 | ||
|
|
c32fd40f1a | ||
|
|
382c5dbdce | ||
|
|
fa69dac77f | ||
|
|
e9ec8ae3a8 | ||
|
|
74fb1fc781 | ||
|
|
91d1796615 | ||
|
|
00b2350fb9 | ||
|
|
b5904907e8 | ||
|
|
d1626781f9 | ||
|
|
9bdaf00ca4 | ||
|
|
54468b3336 | ||
|
|
17f8fa8f34 | ||
|
|
c45ba615fa | ||
|
|
2d4f5378c0 | ||
|
|
9d2ff33943 | ||
|
|
475d523e4a | ||
|
|
3edd356f34 | ||
|
|
dfdee26e4c | ||
|
|
cfe70f2b70 | ||
|
|
b4f4135101 | ||
|
|
6f6a3218ad | ||
|
|
78c258ab45 | ||
|
|
33de4a1144 | ||
|
|
009b585e75 | ||
|
|
895aa67366 | ||
|
|
08eaf10a34 | ||
|
|
e177d54e39 | ||
|
|
66b53c8255 | ||
|
|
3991e68fc9 | ||
|
|
cc218ccfd7 | ||
|
|
d8ed86bbba | ||
|
|
748328db25 | ||
|
|
8e88a73d91 | ||
|
|
f4969de9f7 | ||
|
|
5d391c4f73 | ||
|
|
3cd4facb0e | ||
|
|
3624194b39 | ||
|
|
2fa05fdaf7 | ||
|
|
e0ed0a32af | ||
|
|
a19b490b6a | ||
|
|
393986faa6 | ||
|
|
2585d2c2e1 | ||
|
|
fedbaf32a0 | ||
|
|
cbf8635c61 | ||
|
|
69bf8d985d | ||
|
|
50fccdc9ad | ||
|
|
66072e4047 | ||
|
|
62573ff972 | ||
|
|
481028b023 | ||
|
|
86d711e772 | ||
|
|
ad107ed284 | ||
|
|
43489769f4 | ||
|
|
a333603ca6 | ||
|
|
f4090059f1 | ||
|
|
b814862f40 | ||
|
|
88dbe5642f | ||
|
|
7f5765160d | ||
|
|
b339cced0a | ||
|
|
d7214d97eb | ||
|
|
0f58ad7b49 | ||
|
|
03ae361ade | ||
|
|
40f34170b9 | ||
|
|
f3b0f78b64 | ||
|
|
6a77999aac | ||
|
|
b405adea6c | ||
|
|
895ff92ed8 | ||
|
|
4e5bc44607 | ||
|
|
0430aa2281 | ||
|
|
608e7f7038 | ||
|
|
cf6e19794d | ||
|
|
7ef2c956c5 | ||
|
|
34218fae52 | ||
|
|
a7587235c6 | ||
|
|
53d23418e9 | ||
|
|
901d518f70 | ||
|
|
c6cc7d300b | ||
|
|
fb908b180b | ||
|
|
11a4aebf37 | ||
|
|
fb1294d42c | ||
|
|
533ff7d0d1 | ||
|
|
69e89e5c2f | ||
|
|
3d8a72754c | ||
|
|
88e1b38582 | ||
|
|
781520afd8 | ||
|
|
0a3af60715 | ||
|
|
ebe04f9d61 | ||
|
|
847aad217b | ||
|
|
e602adbbee | ||
|
|
a1047b4e08 | ||
|
|
1dd1d32020 | ||
|
|
ee60ec8480 | ||
|
|
b378311873 | ||
|
|
54504cd1d5 | ||
|
|
e1d0e8f04a | ||
|
|
af83257c76 | ||
|
|
b518e98b43 | ||
|
|
1c031015de | ||
|
|
9c7f5283cf | ||
|
|
c03224fcd6 | ||
|
|
fef8cca2c4 | ||
|
|
c3a1885987 | ||
|
|
82136d6e0d | ||
|
|
f9ff17a614 | ||
|
|
18edc9d3ed | ||
|
|
cd1792df41 | ||
|
|
040c32a6d6 | ||
|
|
41910b01ec | ||
|
|
45f1ec4941 | ||
|
|
6efd2a362e | ||
|
|
30be0a5909 | ||
|
|
da4dcbce57 | ||
|
|
a44f3c763a | ||
|
|
276a8287cb | ||
|
|
ce23dccf03 | ||
|
|
5522adfd9e | ||
|
|
f99e5b59b8 | ||
|
|
7310c84129 | ||
|
|
b9d2e88e64 | ||
|
|
001d633750 | ||
|
|
d42c334b9a | ||
|
|
59ebbf6f8d | ||
|
|
948aa5c54b |
No files matched your search
@@ -15,6 +15,17 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# L'image runtime CUDA de llama.cpp (~4 Go) et Chromium/Playwright
|
||||
# (~1 Go) tiennent large dans les ~14 Go libres d'un runner neuf, mais
|
||||
# ces trois répertoires ne servent à rien ici et rendent une dizaine de
|
||||
# gigaoctets en moins d'une minute. Gardé depuis l'époque où l'image
|
||||
# compilait whisper.cpp avec CUDA : la marge reste bonne à prendre.
|
||||
|
||||
- name: Libérer de l'espace disque
|
||||
run: |
|
||||
df -h /
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
df -h /
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -45,5 +56,8 @@ jobs:
|
||||
LOKI_VERSION=${{ github.sha }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
|
||||
# binaire de dictée) tant que ses lignes du Dockerfile ne bougent
|
||||
# pas : seul le code Go et l'assemblage de l'image se refont.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -13,3 +13,12 @@
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Ancienne application Python/React (fork pré-Go), plus utilisée : elle traîne
|
||||
# encore sur certains postes de développement et ne doit JAMAIS revenir dans le
|
||||
# dépôt — un « git add -A » distrait y a déjà versé 94 Mo de node_modules.
|
||||
/backend/
|
||||
/frontend/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
node_modules/
|
||||
+65
@@ -14,6 +14,17 @@
|
||||
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
|
||||
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
|
||||
|
||||
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
|
||||
# appartient à l'étape où il apparaît ; les « ARG SHERPA_* » nus de l'étape
|
||||
# asrfetch héritent du scope GLOBAL — c'est-à-dire d'ici. Placés plus bas, ils
|
||||
# hériteraient d'une valeur VIDE, et l'étape téléchargerait une URL tronquée.
|
||||
# Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
|
||||
#
|
||||
# La version est ÉPINGLÉE, et l'empreinte avec : le binaire de dictée est
|
||||
# exécuté sur la machine de l'utilisateur, il ne se prend pas « au dernier ».
|
||||
ARG SHERPA_ONNX_VERSION=v1.13.7
|
||||
ARG SHERPA_ONNX_SHA256=2aa3965b37aa235e56aa3ebbb94942c20a23960059a006b4442b4bf893967983
|
||||
|
||||
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
|
||||
FROM golang:1.25 AS gobuild
|
||||
WORKDIR /src
|
||||
@@ -23,6 +34,45 @@ COPY cmd/ cmd/
|
||||
COPY internal/ internal/
|
||||
COPY tools/ tools/
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
|
||||
# gopls : serveur LSP Go, consommé par le vérificateur de code du mode code
|
||||
# (lsp.go). Compilé ici parce que l'image finale n'a pas de toolchain Go.
|
||||
# GOTOOLCHAIN=auto : gopls@latest exige régulièrement un Go plus récent que
|
||||
# celui de l'image (v0.23 veut ≥ 1.26 quand l'image est en 1.25) ; en local,
|
||||
# l'installation échouait net. Auto télécharge le toolchain requis — coût
|
||||
# limité à cette étape de build.
|
||||
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
||||
|
||||
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
|
||||
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
|
||||
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
|
||||
# compilation (dont une CUDA de ~200 fichiers nvcc, tuée par l'OOM du runner
|
||||
# plus d'une fois) laissent place à une extraction de 35 Mo.
|
||||
#
|
||||
# Le build « static-no-tts » lie sherpa-onnx ET onnxruntime en statique : rien à
|
||||
# fournir côté image en dehors de la libc et de libstdc++, déjà présentes. Il
|
||||
# n'existe qu'en variante CPU — c'est assumé : un modèle de 0,6 B en int8 sur
|
||||
# des tranches de quelques secondes n'a pas besoin de la carte, qui reste au
|
||||
# moteur de chat. La variante CUDA existe mais tirerait onnxruntime-gpu et
|
||||
# cuDNN dans l'image, pour un gain sans intérêt à cette taille de tranche.
|
||||
#
|
||||
# L'empreinte est vérifiée : le binaire s'exécute sur la machine de
|
||||
# l'utilisateur, une release remplacée en amont ne doit pas passer en silence.
|
||||
FROM debian:12-slim AS asrfetch
|
||||
ARG SHERPA_ONNX_VERSION
|
||||
ARG SHERPA_ONNX_SHA256
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
curl ca-certificates bzip2 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN set -eu; \
|
||||
nom="sherpa-onnx-${SHERPA_ONNX_VERSION}-linux-x64-static-no-tts"; \
|
||||
curl -fsSL -o /tmp/sherpa.tar.bz2 \
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/${SHERPA_ONNX_VERSION}/${nom}.tar.bz2"; \
|
||||
echo "${SHERPA_ONNX_SHA256} /tmp/sherpa.tar.bz2" | sha256sum -c -; \
|
||||
mkdir -p /out; \
|
||||
tar -xjf /tmp/sherpa.tar.bz2 -C /out --strip-components=2 \
|
||||
"${nom}/bin/sherpa-onnx-offline-websocket-server"; \
|
||||
chmod +x /out/sherpa-onnx-offline-websocket-server; \
|
||||
/out/sherpa-onnx-offline-websocket-server --help >/dev/null 2>&1 || true
|
||||
|
||||
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
||||
FROM ${LLAMACPP_IMAGE} AS runtime
|
||||
@@ -43,6 +93,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Serveurs LSP du mode code (lsp.go) : TypeScript/JavaScript et Python via
|
||||
# npm, Go via gopls compilé à l'étape 1. Un serveur absent désactive juste son
|
||||
# langage — mais autant livrer l'image complète.
|
||||
RUN npm install -g --no-audit --no-fund typescript typescript-language-server pyright && npm cache clean --force
|
||||
|
||||
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
|
||||
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
|
||||
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
|
||||
# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
|
||||
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
|
||||
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
|
||||
@@ -66,6 +126,10 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
||||
fi
|
||||
|
||||
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
||||
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
|
||||
# l'image : téléchargé à la demande dans /data/asr/.
|
||||
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
||||
|
||||
@@ -74,6 +138,7 @@ ENV LOKI_CONTAINER=1 \
|
||||
LOKI_HOME=/data \
|
||||
LOKI_MODEL_DIRS=/models \
|
||||
LOKI_ENGINE_BIN=/app/llama-server \
|
||||
LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \
|
||||
LD_LIBRARY_PATH=/app \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
@@ -19,6 +19,33 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
|
||||
pour que l'UI puisse redémarrer le moteur (changement de modèle).
|
||||
- **`loki config get/set`** : lecture/écriture non interactive de la
|
||||
configuration, utilisée par l'entrypoint Docker.
|
||||
- **Mode code** (`internal/loki/code_*.go`, `lsp.go`, `agents/`) : agent de
|
||||
code avec critères d'acceptation, passe de vérification indépendante,
|
||||
outils read/grep/glob, politique d'exécution, diagnostics LSP, outils git,
|
||||
jobs d'arrière-plan et relance sur appel d'outil textuel. La conception
|
||||
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
|
||||
patterns, table languages.json) est reprise
|
||||
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
|
||||
ajoutent depuis deux idées reprises d'OpenFox et réimplémentées ici : les
|
||||
**sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans son
|
||||
propre contexte) et le **fenêtrage du fil** au chargement d'une longue
|
||||
discussion.
|
||||
|
||||
## Reprises depuis l'amont après le fork
|
||||
|
||||
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
|
||||
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
|
||||
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
|
||||
présent dans l'image) et la **préparation des images** envoyées au modèle
|
||||
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
|
||||
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
|
||||
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
|
||||
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
|
||||
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ seule image.
|
||||
│ ▲ modèles .gguf │
|
||||
│ chromium (Playwright) — captures de pages web │
|
||||
│ /data (config, bbolt, presets, mémoire, │
|
||||
│ workspace + captures, modèles installés) │
|
||||
│ workspace par discussion, modèles) │
|
||||
│ /models (GGUF déposés à la main) │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -77,6 +77,22 @@ Le champ **Télécharger un modèle** reste disponible pour coller un lien direc
|
||||
(dépôt privé, fichier hors des conventions). Un dépôt à accès restreint demande
|
||||
la variable d'environnement `HF_TOKEN`.
|
||||
|
||||
Certains dépôts sont **à accès restreint** (« gated ») : leur arborescence se lit
|
||||
sans rien, mais chaque `.gguf` répond `401` tant que les conditions du dépôt
|
||||
n'ont pas été acceptées sur huggingface.co **et** qu'un jeton n'est pas fourni.
|
||||
Loki les marque « accès restreint » dès la liste des résultats et rappelle le
|
||||
geste à faire, plutôt que de laisser choisir une quantification pour échouer au
|
||||
lancement du transfert.
|
||||
|
||||
Le jeton se règle **dans l'interface** : éditeur de preset → *Modèle* → **Jeton
|
||||
Hugging Face**. Il est vérifié auprès de Hugging Face avant d'être enregistré
|
||||
(le compte associé s'affiche), rangé avec les autres secrets sous `/data` — donc
|
||||
il survit aux redémarrages et aux changements de preset — et il sert aussi bien à
|
||||
la recherche qu'au téléchargement. À défaut, la variable d'environnement
|
||||
`HF_TOKEN` reste lue comme avant ; le jeton enregistré dans l'interface a la
|
||||
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
|
||||
n'est envoyé qu'aux adresses Hugging Face.
|
||||
|
||||
## Installation sur Unraid
|
||||
|
||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||
@@ -105,7 +121,9 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_NGL` | couches GPU initiales | `999` (tout) |
|
||||
| `LOKI_HOME` | données (volume) | `/data` |
|
||||
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
|
||||
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint | — |
|
||||
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
|
||||
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
|
||||
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
@@ -121,9 +139,11 @@ et modèles compris.
|
||||
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
|
||||
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
|
||||
| `/data/models/` | modèles téléchargés depuis l'interface |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) |
|
||||
| `/data/workspace/` | dossier de travail de l'agent |
|
||||
| `/data/workspace/captures/<id>/` | captures d'écran, un dossier par discussion |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell ; chiffrées si le chiffrement est actif |
|
||||
| `/data/backups/memory/` | snapshots de la mémoire pris avant chaque opération qui touche à tout |
|
||||
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
|
||||
| `/data/workspace/` | racine du dossier de travail de l'agent |
|
||||
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
|
||||
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
|
||||
| `/models` | GGUF déposés à la main depuis l'hôte (volume séparé, `LOKI_MODEL_DIRS`) |
|
||||
|
||||
@@ -138,6 +158,16 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
|
||||
emplacements différents dès que le partage n'est pas en cache-only ou que le
|
||||
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
|
||||
|
||||
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
|
||||
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
|
||||
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
|
||||
redémarrage de l'array). Vérifie avec la commande `docker inspect` ci-dessus :
|
||||
il doit y avoir une ligne `… → /data` **et** une `… → /models`. S'il n'y en a
|
||||
pas, ton conteneur a été lancé sans mapping (template Docker Unraid incomplet,
|
||||
`docker run` sans `-v`) — recrée-le avec les volumes du compose fourni. Depuis
|
||||
cette version, Loki le détecte au démarrage : bandeau rouge dans l'UI et
|
||||
avertissement en tête du journal du conteneur.
|
||||
|
||||
Autre piège au redémarrage du serveur : Docker relance les conteneurs
|
||||
`restart: unless-stopped` **avant** que le plugin Nvidia Driver ait chargé ses
|
||||
modules. Le journal montre alors des `ERROR: init … result=11` et le moteur
|
||||
@@ -149,21 +179,165 @@ Héritées d'AJEAN :
|
||||
|
||||
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
|
||||
conversations. La **vision** demande un modèle multimodal *et* son projecteur
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle). Toute image
|
||||
envoyée au modèle (pièce jointe, `see_image`, capture) est d'abord
|
||||
**redressée** — l'orientation EXIF d'une photo de téléphone est cuite dans
|
||||
les pixels, sinon le projecteur, qui ignore l'EXIF, la voyait couchée — et
|
||||
**ramenée sous 1568 px** de grand côté, taille au-delà de laquelle le base64
|
||||
grossit sans rien apprendre de plus au modèle.
|
||||
- **Mémoire persistante** (`memory off|ondemand|always`).
|
||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
|
||||
- **Contrôle du navigateur** (`computer on`, ou *Réglages → Contrôle du
|
||||
navigateur*) : l'IA **pilote** un Chromium — celui de Playwright, déjà dans
|
||||
l'image — par le protocole DevTools. Elle ouvre une page, en reçoit les
|
||||
éléments interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit
|
||||
par numéro : `browser_open`, `browser_snapshot`, `browser_find`,
|
||||
`browser_click`, `browser_type`, `browser_key`, `browser_scroll`. Aucune
|
||||
vision requise — ça marche avec de petits modèles texte. Avec un projecteur
|
||||
`mmproj` chargé s'ajoutent `browser_screenshot` (image quadrillée tous les
|
||||
100 px) et `browser_click_xy`, pour ce que l'arbre d'accessibilité ne montre
|
||||
pas (canvas, bandeau en iframe). Ce sont des **actions réelles** sur le web :
|
||||
l'interrupteur est distinct de l'accès internet et n'agit qu'en **mode
|
||||
agent**, au même niveau de confiance que `bash`. La session navigateur est
|
||||
unique et réutilisée entre les appels ; couper l'interrupteur la ferme.
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
|
||||
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
|
||||
ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les
|
||||
lancements suivants partent du cache en quelques secondes.
|
||||
- **Tâches planifiées** : une consigne que l'IA exécute **toute seule**, sur une
|
||||
fréquence réglable (« toutes les 2 h », « tous les jours à 9 h », ou une
|
||||
expression cron). Chaque tâche tourne **isolée des discussions** — elle n'écrit
|
||||
pas dans le fil, travaille dans son propre dossier (`workspace/tasks/<id>/`) et
|
||||
livre son résultat par les outils de l'IA (mail via MCP, shell, fichiers…) ; le
|
||||
compte-rendu du dernier passage est visible dans sa fiche, et **réinjecté** au
|
||||
passage suivant pour la continuité. Réglages par tâche : preset (donc modèle) à
|
||||
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
|
||||
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
|
||||
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de
|
||||
tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier
|
||||
`/data/scripts` lancé directement, **sans charger le modèle ni consommer un
|
||||
token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce
|
||||
dossier vit **hors du workspace** : vider une discussion, ou la supprimer,
|
||||
n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`,
|
||||
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
|
||||
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
|
||||
pilote que les tâches de ce projet.
|
||||
- **Notifications** (Web Push) : le serveur prévient le navigateur **à la fin
|
||||
d'une réponse et à la fin d'une tâche planifiée**, même l'app fermée ou le
|
||||
téléphone verrouillé — c'est le serveur qui pousse, pas la page (un onglet
|
||||
caché relâche son flux). L'interrupteur est dans *Réglages → Mode agent*, à
|
||||
armer **sur chaque appareil** (l'abonnement appartient au navigateur). Exige
|
||||
**HTTPS** ou localhost ; sur iPhone, il faut d'abord ajouter Loki à l'écran
|
||||
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
|
||||
le reste sous `/data` ; le corps de la notification reste générique (aucun
|
||||
extrait de réponse), puisqu'elle transite par Apple ou Google.
|
||||
- **Chiffrement de la mémoire** (*Réglages → Mémoire*) : pages mémoire,
|
||||
discussions, blocs archivés au compactage et trackers chiffrés en
|
||||
**AES-256-GCM** sur le disque. Chiffrement à enveloppe : une clé de données
|
||||
(DEK) tirée une fois, enfermée dans un coffre par une clé dérivée en
|
||||
**Argon2id**. Ce qui ouvre le coffre : la **clé de pilotage de l'appareil**
|
||||
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre
|
||||
seul) ou une **clé de récupération** affichée une seule fois à l'activation.
|
||||
La DEK ne vit qu'en RAM : après un redémarrage à froid, la mémoire est
|
||||
verrouillée jusqu'à ce qu'un navigateur se reconnecte — verrouillée, Loki
|
||||
n'écrit jamais de clair par-dessus du chiffré, il refuse d'écrire. Règle
|
||||
tenue partout : **rien n'est supprimé avant que son remplaçant ait été relu
|
||||
et vérifié**, un **snapshot** est pris avant chaque bascule, et une migration
|
||||
interrompue reprend au démarrage.
|
||||
- **Sauvegarde chiffrée en fichier** : *exporter* télécharge un paquet scellé
|
||||
(mémoire, presets, réglages) que *importer* rejoue sur un autre serveur avec
|
||||
la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de
|
||||
l'amont sans son relais : ici rien ne part sur un service tiers, le fichier
|
||||
reste chez toi.
|
||||
- **Interface en anglais** (*Réglages → Apparence → Langue*) : la source reste
|
||||
française et « English » applique un dictionnaire sur la coque — navigation,
|
||||
intitulés, boutons, interrupteurs. Ce qui n'y figure pas **reste en
|
||||
français** plutôt que d'afficher une clé technique, et le fil de discussion
|
||||
n'est jamais touché : c'est ton contenu. Le dictionnaire s'enrichit sans
|
||||
toucher au reste de l'interface (`ui/src/js/28-i18n.js`).
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **API OpenAI-compatible** exposable (`network on`, protégée par clé).
|
||||
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
|
||||
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
|
||||
valeurs partent dans **chaque requête** au moteur, pas sur sa ligne de commande :
|
||||
les changer ne demande donc aucun redémarrage. Un champ laissé vide n'envoie
|
||||
rien et llama-server garde son défaut — utile parce que le défaut de llama.cpp
|
||||
(top_k 40, min_p 0.05) est rarement celui que recommande le modèle (Qwen3.8 en
|
||||
réflexion veut top_k 20, min_p 0, temp 1.0, top_p 0.95).
|
||||
- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce
|
||||
fork la sert autrement que l'amont).
|
||||
|
||||
Ajoutées par ce fork :
|
||||
|
||||
- **Mode Code** : chaque discussion a un sélecteur **Chat | Code** dans le pied
|
||||
de la carte de saisie. En mode Code, Loki devient un agent de code : outils
|
||||
`read`/`grep`/`glob` (lecture bornée, numéros de ligne — et un fichier doit
|
||||
avoir été LU avant d'être modifié), outils git (`git_status`, `git_diff`,
|
||||
`git_clone` — le clone atterrit dans le dossier de la discussion), jobs
|
||||
d'arrière-plan (`bash_bg`/`bash_tail` pour un serveur de dev ou un build
|
||||
long), et **critères d'acceptation** : le modèle pose le contrat (2-6
|
||||
critères testables, éditables dans le panneau au-dessus de la saisie), puis
|
||||
une **passe de vérification indépendante** — même modèle, contexte isolé,
|
||||
seule habilitée à marquer un critère « passé » — contrôle le diff et relance
|
||||
la correction jusqu'à ce que tout passe (2 corrections max). Les fichiers
|
||||
modifiés passent au **LSP** (gopls, typescript-language-server, pyright —
|
||||
inclus dans l'image) : les erreurs de compilation reviennent dans le résultat
|
||||
de l'outil, sans lancer de build. Sécurité : commandes catastrophiques
|
||||
refusées (rm -rf /, mkfs, reboot…), chemins bornés au dossier de la
|
||||
discussion en mode Code. En mode Chat, un message qui ressemble à une tâche
|
||||
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
|
||||
jamais bascule automatique. Conception reprise
|
||||
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
|
||||
`NOTICE.md`. **Sous-agents** : l'outil `subagent` délègue une recherche
|
||||
(`explorer`), une relecture (`code-reviewer`) ou un découpage (`planner`) à
|
||||
un rôle qui travaille dans **son propre contexte** et ne rend que sa réponse.
|
||||
Sur un modèle local, c'est ce qui sauve la fenêtre : « trouve où est géré le
|
||||
cache » coûte dix lectures de fichiers, qui resteraient sinon dans
|
||||
l'historique jusqu'à la compaction alors que seule la réponse comptait. Tous
|
||||
les rôles délégués sont en **lecture seule** — ce qui modifie le dépôt reste
|
||||
dans le fil principal, sous tes yeux — et un sous-agent ne peut pas en
|
||||
appeler un autre.
|
||||
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
|
||||
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
|
||||
playwright, context7, github…) avec leur commande déjà renseignée, classés par
|
||||
catégorie. Choisir une entrée **n'installe rien** : ça remplit le formulaire
|
||||
d'ajout, tu relis la commande — qui s'exécutera sur cette machine — puis tu
|
||||
enregistres. Le catalogue est un JSON **embarqué dans le binaire**
|
||||
(`internal/loki/mcp_catalog.json`), donc aucun appel à un annuaire distant :
|
||||
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
|
||||
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
|
||||
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
|
||||
- **Intensité du raisonnement** : un niveau — auto / aucune / basse / moyenne /
|
||||
haute / maximale — envoyé à `llama-server` comme `reasoning_effort`. Réglable
|
||||
**dans la barre de saisie**, parce que ça se décide en écrivant le message : le
|
||||
changement s'applique au message suivant, sans redémarrer le moteur. L'éditeur
|
||||
de preset garde le même réglage comme **défaut du modèle** ; appliquer un
|
||||
preset reprend donc la main sur le choix fait à la volée. `none` coupe le
|
||||
raisonnement ; les autres valeurs sont passées au gabarit jinja du modèle, ce
|
||||
qui ne change le comportement que des modèles qui les lisent (gpt-oss et
|
||||
apparentés) — ailleurs c'est ignoré sans erreur, et l'interface le dit plutôt
|
||||
que de promettre un effet. Aucun gabarit ne les connaît toutes (gpt-oss :
|
||||
basse/moyenne/haute ; Qwen3.8 : basse/moyenne/maximale) et certains **refusent**
|
||||
celles qu'ils ne connaissent pas, avec une erreur 500 qui tuait le tour : loki
|
||||
lit alors les niveaux annoncés par le refus, **repli sur le plus proche** (haute
|
||||
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
|
||||
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
|
||||
raisonnement est coupé pour ce modèle.
|
||||
- **Ouverture rapide des longues discussions** : le serveur ne rejoue que la
|
||||
**fin** du journal (les 4000 derniers événements). Un fil de plusieurs
|
||||
centaines de tours s'affichait en quelques secondes et laissait autant de
|
||||
bulles dans le DOM à traîner ensuite. Rien n'est effacé : un bandeau en tête
|
||||
dit combien d'événements sont masqués et **charge le début** d'un clic.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. Supprimer une discussion
|
||||
efface aussi ses captures d'écran (`workspace/captures/<id>/`), pour que le
|
||||
disque ne se remplisse pas en silence.
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les
|
||||
chemins relatifs du modèle y sont résolus. Changer de discussion change donc
|
||||
les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que
|
||||
le disque ne se remplisse pas en silence.
|
||||
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
|
||||
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
|
||||
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
|
||||
@@ -174,16 +348,78 @@ Ajoutées par ce fork :
|
||||
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
|
||||
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
|
||||
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
|
||||
- **Panneau Fichiers** (bouton dossier du pied de carte) : ce que l'agent a
|
||||
écrit dans son dossier de travail, avec navigation dans les sous-dossiers,
|
||||
téléchargement et suppression. Un dossier affiche la taille de **tout** son
|
||||
contenu — c'est ce qu'on libère en le supprimant. Le pied donne l'occupation
|
||||
disque totale de l'agent. Les chemins sont bornés au dossier de travail, liens
|
||||
symboliques résolus des deux côtés : rien du reste du disque n'est atteignable.
|
||||
- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
|
||||
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
|
||||
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
|
||||
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
|
||||
supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
|
||||
sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le
|
||||
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
|
||||
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
|
||||
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
|
||||
- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
|
||||
et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
|
||||
aucune requête vers un service de polices. Deux variantes : claire par défaut,
|
||||
**Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
|
||||
L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
|
||||
changement de preset ne demande plus d'ouvrir les réglages) ; la barre
|
||||
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
|
||||
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
|
||||
visibles en pied de colonne.
|
||||
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
|
||||
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
|
||||
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
|
||||
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
|
||||
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
|
||||
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
|
||||
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
|
||||
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
|
||||
du moteur. Conséquence directe : l'API est joignable partout où l'interface
|
||||
l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de
|
||||
second port ni ouvrir le moteur. L'amont annonçait `http://<ip>:8080/v1`, une
|
||||
adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP
|
||||
détectée est celle du bridge Docker).
|
||||
- Authentification par la **clé API** du panneau (`Authorization: Bearer …`),
|
||||
vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et
|
||||
l'interface le dit en rouge.
|
||||
- **Adresse publique** : un champ où saisir son domaine, pour le cas du
|
||||
reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse
|
||||
affichée suit celle du navigateur.
|
||||
- **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki
|
||||
honore `X-Forwarded-Proto` pour annoncer une adresse en `https`.
|
||||
- L'ancienne exposition publique via le relais de l'amont
|
||||
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
|
||||
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
|
||||
pouvait donc qu'échouer.
|
||||
- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une
|
||||
recherche légitime est pire que la laisser durer — mais au-delà de 24 appels
|
||||
sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui
|
||||
demande de conclure. Le rappel revient tous les 24 appels, en durcissant le
|
||||
ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière.
|
||||
Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf
|
||||
le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois
|
||||
les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` —
|
||||
`AGENT_BUDGET=off` le désactive complètement.
|
||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||
dans le fil.
|
||||
- **Paramètres** : les réglages d'application (identité, apparence, accès
|
||||
OpenAI, actions) sont regroupés à part des réglages d'IA.
|
||||
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
|
||||
volets — la nav des sections à gauche (IA, moteur, application), le panneau
|
||||
choisi à droite. La barre latérale ne garde que les discussions (les plus
|
||||
récentes en tête) et le moniteur machine.
|
||||
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
|
||||
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
|
||||
survit au rechargement, et un vieux tour garde le modèle de l'époque.
|
||||
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
|
||||
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
|
||||
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
|
||||
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
|
||||
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
|
||||
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
|
||||
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
|
||||
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
|
||||
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
|
||||
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
|
||||
l'affichage ne se fige plus.
|
||||
|
||||
Retirées par ce fork :
|
||||
|
||||
@@ -206,12 +442,14 @@ Retirées par ce fork :
|
||||
|---|---|---|
|
||||
| Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` |
|
||||
| Moteur llama.cpp | compilé sur la machine (`ajean llamacpp install`) | image officielle llama.cpp (`server-cuda`), précompilée |
|
||||
| Panneau « Moteur » | propose d'installer/compiler | annonce le moteur fourni par l'image, sans proposer d'install |
|
||||
| Panneau « Moteur » | propose d'installer/compiler | affiche la version qui tourne et la met à jour en un clic (sans rebuild) |
|
||||
| Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) |
|
||||
| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` |
|
||||
| Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié |
|
||||
| Historique de tchat | conversation unique | discussions multiples, titrées et persistées |
|
||||
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
|
||||
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
|
||||
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
|
||||
|
||||
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
|
||||
@@ -238,6 +476,44 @@ docker build --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda-b
|
||||
Aucune compilation de llama.cpp n'a lieu : le moteur est maintenu et
|
||||
précompilé par l'équipe amont (toutes architectures GPU courantes).
|
||||
|
||||
## Mettre à jour llama.cpp sans reconstruire l'image
|
||||
|
||||
llama.cpp publie plusieurs versions par jour ; l'image de Loki, elle, ne se
|
||||
reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à la date
|
||||
du dernier build, et le rattraper imposait un rebuild complet (2,6 Go) pour un
|
||||
composant de 170 Mo.
|
||||
|
||||
**Réglages → Moteur** affiche désormais la version de llama.cpp qui tourne
|
||||
(`b10450`, avec son commit) et deux boutons :
|
||||
|
||||
- **vérifier la version** — interroge le registre et dit s'il existe plus récent ;
|
||||
- **mettre à jour le moteur** — télécharge `llama-server` et ses bibliothèques
|
||||
depuis l'image officielle, `ghcr.io/ggml-org/llama.cpp:server-cuda`.
|
||||
|
||||
Ce qui est téléchargé n'est **pas l'image** : un manifeste OCI liste ses couches,
|
||||
et seules celles qui portent `/app` sont récupérées (~170 Mo). Le runtime CUDA
|
||||
(2 Go) et la base système sont déjà dans l'image de Loki. Le moteur atterrit dans
|
||||
`/data/engine/<version>/`, donc sur le volume de données : il survit à un
|
||||
`docker compose pull`.
|
||||
|
||||
La variante est déduite du moteur en place (CUDA, Vulkan, SYCL, MUSA ou CPU) :
|
||||
une installation Vulkan ne se verra jamais proposer une image CUDA.
|
||||
|
||||
**Le garde-fou.** La mise à jour apporte llama.cpp, pas le runtime CUDA, qui
|
||||
reste celui de l'image. Un llama.cpp compilé pour un CUDA plus récent ne
|
||||
chargerait pas son backend GPU ici — et le symptôme serait silencieux : tout
|
||||
fonctionne, mais sur le processeur. Le nouveau moteur est donc lancé à blanc
|
||||
avant toute bascule ; s'il ne démarre pas, ou s'il ne voit plus aucune carte
|
||||
alors que le moteur courant en voyait, la mise à jour est refusée et le moteur
|
||||
courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intact :
|
||||
**revenir au moteur de l'image** y ramène en un clic, sans réseau.
|
||||
|
||||
Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
|
||||
solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
|
||||
|
||||
Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
|
||||
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT — © les contributeurs d'AJEAN (« Jean contributors ») pour le code amont,
|
||||
|
||||
+89
-9
@@ -1,19 +1,99 @@
|
||||
La grande nouveauté de cette version : AJEAN sait enfin voir les images.
|
||||
# Loki 0.13.0
|
||||
|
||||
## La vision, configurable par preset
|
||||
Loki rattrape deux versions majeures d'AJEAN (0.14 → 0.15.4) et reprend deux
|
||||
idées d'OpenFox. L'IA peut piloter un navigateur, la mémoire se chiffre sur le
|
||||
disque, et le serveur sait te prévenir quand c'est prêt.
|
||||
|
||||
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.
|
||||
## L'IA pilote un navigateur
|
||||
|
||||
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.
|
||||
Un interrupteur dans *Réglages → Contrôle du navigateur* et l'IA ouvre des pages
|
||||
dans le Chromium déjà embarqué dans l'image. Elle en reçoit les éléments
|
||||
interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit par numéro :
|
||||
`browser_open`, `browser_find`, `browser_click`, `browser_type`, `browser_key`,
|
||||
`browser_scroll`. **Aucune vision requise** — ça marche avec un petit modèle
|
||||
texte. Avec un projecteur `mmproj` chargé s'ajoutent `browser_screenshot`
|
||||
(capture quadrillée tous les 100 px) et `browser_click_xy`, pour ce que l'arbre
|
||||
d'accessibilité ne montre pas : canvas, bandeau cookies en iframe.
|
||||
|
||||
## Les images arrivent vraiment au modèle
|
||||
Ce sont des actions réelles sur le web : l'interrupteur est distinct de l'accès
|
||||
internet et n'agit qu'en **mode agent**, au même niveau de confiance que `bash`.
|
||||
En CLI : `loki computer [on|off|status]`.
|
||||
|
||||
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.
|
||||
## La mémoire se chiffre
|
||||
|
||||
*Réglages → Mémoire → chiffrer la mémoire sur le disque* : pages mémoire,
|
||||
discussions, blocs archivés au compactage et trackers passent en **AES-256-GCM**
|
||||
sous `/data`. La clé de données est enfermée dans un coffre par une clé dérivée
|
||||
en **Argon2id** ; ce qui l'ouvre, c'est la **clé de pilotage de cet appareil**
|
||||
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre seul)
|
||||
ou une **clé de récupération** affichée une seule fois à l'activation.
|
||||
|
||||
La clé ne vit qu'en RAM : après un redémarrage à froid, la mémoire reste
|
||||
verrouillée jusqu'à ce qu'un navigateur se reconnecte. Verrouillé, Loki n'écrase
|
||||
jamais du chiffré par du clair — il refuse d'écrire. Un **snapshot** est pris
|
||||
avant chaque bascule, une migration interrompue reprend au démarrage, et rien
|
||||
n'est supprimé avant que son remplaçant ait été relu et vérifié.
|
||||
|
||||
**Sauvegarde chiffrée** : *exporter* télécharge un paquet scellé (mémoire,
|
||||
presets, réglages) que *importer* rejoue sur un autre serveur avec la seule clé —
|
||||
de quoi remonter le conteneur ailleurs. Le fichier reste chez toi : aucun envoi
|
||||
vers un service tiers.
|
||||
|
||||
## Notifications, même app fermée
|
||||
|
||||
Le serveur pousse une notification à la fin d'une réponse **et à la fin d'une
|
||||
tâche planifiée**, succès comme échec — c'est le cas qui compte, personne ne
|
||||
regarde. Interrupteur dans *Réglages → Mode agent*, à armer sur chaque appareil.
|
||||
Demande HTTPS (ou localhost) ; sur iPhone, ajoute d'abord Loki à l'écran
|
||||
d'accueil.
|
||||
|
||||
## Tâches : des scripts qui tournent sans modèle
|
||||
|
||||
Un nouveau dossier `/data/scripts`, **hors du workspace jetable** : supprimer une
|
||||
discussion n'y touche pas. Une tâche peut désormais être un **script seul** — le
|
||||
planificateur le lance sans charger le modèle ni consommer un jeton. Une
|
||||
sauvegarde, une synchro, un nettoyage n'ont rien à demander à un LLM.
|
||||
|
||||
L'IA dispose aussi de `task_create`, `task_list`, `task_update` et `task_delete` :
|
||||
elle se pose ses propres rappels et veilles, cloisonnés par projet.
|
||||
|
||||
## Mode code : des sous-agents
|
||||
|
||||
L'outil `subagent` délègue une recherche (`explorer`), une relecture
|
||||
(`code-reviewer`) ou un découpage (`planner`) à un rôle qui travaille dans **son
|
||||
propre contexte** et ne rend que sa réponse. Sur un modèle local, c'est la
|
||||
fenêtre de contexte qu'on sauve : « trouve où est géré le cache » coûte dix
|
||||
lectures de fichiers, qui restaient sinon dans l'historique alors que seule la
|
||||
réponse comptait. Tous les rôles délégués sont en lecture seule.
|
||||
|
||||
Le builder ne publie plus de lui-même : sans demande explicite, ni commit, ni
|
||||
push, ni redémarrage de service.
|
||||
|
||||
## Interface
|
||||
|
||||
- **Anglais** (*Apparence → Langue*) : la coque — navigation, intitulés,
|
||||
boutons — passe en anglais. Ce qui n'est pas encore traduit reste en français
|
||||
plutôt que d'afficher une clé technique, et le fil de discussion n'est jamais
|
||||
touché.
|
||||
- **Résultats d'outils** : le flux ne transporte plus qu'un aperçu ; « voir
|
||||
plus » charge le reste à la demande et déplie vraiment le bloc. Le compteur
|
||||
« ~N tok » dit enfin la taille réelle, pas celle de l'aperçu.
|
||||
- **Longues discussions** : à l'ouverture, seule la fin du fil est rejouée. Un
|
||||
bandeau dit combien d'événements sont masqués et charge le début d'un clic.
|
||||
- **Images** : l'orientation EXIF est cuite dans les pixels (fini les photos de
|
||||
téléphone couchées pour le modèle) et le grand côté ramené sous 1568 px.
|
||||
|
||||
## Corrections
|
||||
|
||||
- Un outil qui porte une image dans un message séparé (`see_image`,
|
||||
`browser_screenshot`) renvoyait « [déjà fait] » **sans** l'image et le modèle
|
||||
bouclait. Relancer la même commande `bash` est de nouveau permis.
|
||||
- **MCP** ne tronque plus sa réponse à 12000 caractères avant le modèle.
|
||||
- Le dossier mémoire n'est plus joignable qu'aux outils `mem_*` : un `cat
|
||||
memory/…` contournait l'index `MEMORY.md`.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
```
|
||||
ajean update
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
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.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.8 KiB |
Binary file not shown.
Binary file not shown.
@@ -2,14 +2,14 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 9,
|
||||
"Patch": 4,
|
||||
"Minor": 13,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 9,
|
||||
"Patch": 4,
|
||||
"Minor": 13,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||
"OriginalFilename": "loki.exe",
|
||||
"ProductName": "Loki",
|
||||
"ProductVersion": "0.9.4",
|
||||
"ProductVersion": "0.13.0",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
@@ -34,4 +34,4 @@
|
||||
"CharsetID": "04B0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -4,12 +4,39 @@
|
||||
set -eu
|
||||
|
||||
: "${LOKI_HOME:=/data}"
|
||||
export LOKI_HOME # les sous-commandes loki doivent voir le MEME dossier
|
||||
mkdir -p "$LOKI_HOME"
|
||||
|
||||
# Garde-fou persistance : si /data n'est pas un point de montage, tout ce que
|
||||
# l'utilisateur fera (modèles, discussions, fichiers) mourra avec le conteneur.
|
||||
# On le dit au premier écran du journal — l'UI l'affiche aussi en bandeau.
|
||||
if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
|
||||
echo "[entrypoint] ⚠️ $LOKI_HOME n'est pas un volume monté : les données seront PERDUES à la recréation du conteneur."
|
||||
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
|
||||
fi
|
||||
|
||||
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
|
||||
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
|
||||
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
|
||||
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
|
||||
#
|
||||
# SAUF si l'utilisateur a mis le moteur à jour depuis l'interface : celui-là
|
||||
# vit sous $LOKI_HOME/engine/<version>/ (volume de données, il survit au
|
||||
# conteneur) et c'est lui qu'il a choisi. Le reposer sur /app à chaque
|
||||
# redémarrage ramenait silencieusement l'ancien moteur — un modèle qui
|
||||
# chargeait la veille ne chargeait plus le lendemain, « unknown model
|
||||
# architecture », sans que rien n'ait été touché. On ne le garde que s'il
|
||||
# existe encore : un dossier supprimé à la main ne doit pas laisser BIN dans
|
||||
# le vide.
|
||||
cur=$(loki config get BIN)
|
||||
case "$cur" in
|
||||
"$LOKI_HOME"/engine/*/llama-server) [ -x "$cur" ] || cur="" ;;
|
||||
*) cur="" ;;
|
||||
esac
|
||||
if [ -n "$cur" ]; then
|
||||
echo "[entrypoint] moteur mis à jour conservé : $cur"
|
||||
else
|
||||
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
|
||||
fi
|
||||
|
||||
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
|
||||
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
# Dictée en temps réel, configurable selon le matériel
|
||||
|
||||
*Conception validée le 21 août 2026.*
|
||||
|
||||
## Le problème
|
||||
|
||||
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
|
||||
défauts la rendent pénible :
|
||||
|
||||
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
|
||||
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
|
||||
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
|
||||
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
|
||||
|
||||
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
|
||||
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
|
||||
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
|
||||
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
|
||||
un rechargement complet du modèle toutes les quelques secondes.
|
||||
|
||||
## Ce qu'on construit
|
||||
|
||||
Trois choses, dans cet ordre de dépendance :
|
||||
|
||||
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
|
||||
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
|
||||
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
|
||||
|
||||
## 1. Le moteur
|
||||
|
||||
### whisper-server plutôt que whisper-cli
|
||||
|
||||
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
|
||||
Loki le supervise comme il supervise déjà `llama-server`.
|
||||
|
||||
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
|
||||
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
|
||||
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
|
||||
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
|
||||
modèle ; les suivants sont immédiats.
|
||||
|
||||
### Choix du GPU
|
||||
|
||||
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
|
||||
pas de code à maintenir dans ggml :
|
||||
|
||||
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
|
||||
- valeur vide → CPU seul
|
||||
|
||||
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
|
||||
|
||||
### Compilation : deux binaires, pas un
|
||||
|
||||
L'étape `whisperbuild` produit **deux** binaires :
|
||||
|
||||
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
|
||||
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
|
||||
|
||||
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
|
||||
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
|
||||
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
|
||||
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
|
||||
CUDA condamnerait donc la variante CPU de l'image.
|
||||
|
||||
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
|
||||
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
|
||||
le disant, plutôt que de laisser la dictée morte.
|
||||
|
||||
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
|
||||
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
|
||||
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
|
||||
|
||||
## 2. Le découpage
|
||||
|
||||
### On coupe dans les silences, jamais à heure fixe
|
||||
|
||||
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
|
||||
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
|
||||
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
|
||||
l'améliorer.
|
||||
|
||||
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
|
||||
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
|
||||
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
|
||||
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
|
||||
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
|
||||
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
|
||||
est borné par la coupure forcée.
|
||||
|
||||
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
|
||||
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
|
||||
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
|
||||
pas voir le texte se figer.
|
||||
|
||||
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
|
||||
dont personne ne connaît l'interaction :
|
||||
|
||||
| Réglage | Réserve minimale | Coupure forcée |
|
||||
|---|---|---|
|
||||
| Court | 1 s | 5 s |
|
||||
| Moyen (défaut) | 1,5 s | 8 s |
|
||||
| Long | 3 s | 15 s |
|
||||
|
||||
### La logique vit en Go
|
||||
|
||||
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
|
||||
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
|
||||
|
||||
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
|
||||
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
|
||||
Et le navigateur redevient bête : capter, envoyer, afficher.
|
||||
|
||||
### Contrat de la socket
|
||||
|
||||
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
|
||||
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
|
||||
|
||||
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
|
||||
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
|
||||
sous les yeux.
|
||||
|
||||
### Langue forcée par défaut
|
||||
|
||||
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
|
||||
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
|
||||
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
|
||||
|
||||
## 3. Le panneau Dictée
|
||||
|
||||
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
|
||||
« Identité ». Suit le gabarit des panneaux existants.
|
||||
|
||||
| Réglage | Contenu |
|
||||
|---|---|
|
||||
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
|
||||
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
|
||||
| **Langue** | Français par défaut, auto et autres langues disponibles |
|
||||
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
|
||||
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
|
||||
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
|
||||
|
||||
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
|
||||
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
|
||||
progression et suppression, en généralisant le mécanisme mono-modèle existant.
|
||||
|
||||
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
|
||||
le jeton Hugging Face.
|
||||
|
||||
## Les pannes, nommées
|
||||
|
||||
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
|
||||
|
||||
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
|
||||
d'échouer sec
|
||||
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
|
||||
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
|
||||
l'utilisateur est prévenu
|
||||
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
|
||||
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
|
||||
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
|
||||
dans la mauvaise direction.
|
||||
|
||||
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
|
||||
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
|
||||
|
||||
## Tests
|
||||
|
||||
En Go, sur ce qui peut réellement casser :
|
||||
|
||||
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
|
||||
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
|
||||
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
|
||||
modèle, langue
|
||||
- **l'aller-retour des réglages** dans `bkState`
|
||||
- **l'extinction sur inactivité**
|
||||
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
|
||||
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
|
||||
|
||||
## Ce que les tests ne prouveront pas
|
||||
|
||||
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
|
||||
échantillon de voix n'est disponible côté développement. Les tests établissent que le
|
||||
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
|
||||
`small` reste une constatation d'usage.
|
||||
|
||||
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
|
||||
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
|
||||
@@ -5,6 +5,7 @@ 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/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/caddyserver/certmagic v0.25.4
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/getlantern/systray v1.2.2
|
||||
@@ -13,6 +14,7 @@ require (
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,6 +30,7 @@ require (
|
||||
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/golang-jwt/jwt/v5 v5.3.1 // 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
|
||||
@@ -45,7 +48,6 @@ require (
|
||||
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
|
||||
)
|
||||
@@ -6,6 +6,8 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
|
||||
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/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
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=
|
||||
@@ -39,8 +41,10 @@ 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.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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=
|
||||
@@ -84,6 +88,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
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=
|
||||
@@ -102,24 +107,86 @@ 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.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
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.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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=
|
||||
@@ -0,0 +1,16 @@
|
||||
You are in CODE MODE: an autonomous coding agent working in this discussion's folder.
|
||||
|
||||
Method — in this order:
|
||||
1. If the task has no acceptance criteria yet, set them FIRST with the criteria tool (action=add): 2-6 short, testable statements of what DONE means. For a trivial task (one obvious change), skip criteria and just do it.
|
||||
2. Explore before you change: read the relevant files (read, grep, glob, git_status). Never edit a file you have not read.
|
||||
3. Implement with edit (small patches) or write (new files). Match the style of the surrounding code.
|
||||
4. Prove it: run the build/tests with bash. A change that was never run is not done. For a web page or UI, start the dev server with bash_bg then use web_screenshot on its localhost URL — a headless Chromium is ALREADY installed, so NEVER install puppeteer, playwright or a browser.
|
||||
5. Report briefly what changed and how you verified it.
|
||||
|
||||
Rules:
|
||||
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
|
||||
- Write each file COMPLETE in a single write call. Many small writes waste turns.
|
||||
- You may NOT mark a criterion passed — the verification pass does that.
|
||||
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
|
||||
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
|
||||
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
|
||||
@@ -0,0 +1,5 @@
|
||||
You are the CODE REVIEW pass of code mode. Review the current changes (git_diff) for real defects.
|
||||
|
||||
Look for, in this order: correctness bugs (wrong logic, unhandled errors, races), security issues (injection, path traversal, secrets), then significant simplifications. Ignore style and taste.
|
||||
|
||||
For each finding: file:line, one-line problem, one-line fix. If the diff is sound, say so in one sentence. Do not modify anything.
|
||||
@@ -0,0 +1,3 @@
|
||||
You are the EXPLORATION pass of code mode. Answer questions about the codebase without modifying anything.
|
||||
|
||||
Use read, grep, glob and git tools to locate and understand code. Cite files as path:line. Be concise: report what you found and where, not everything you read. If the answer spans several places, list them with one line each.
|
||||
@@ -0,0 +1,8 @@
|
||||
You are the PLANNING pass of code mode. Break the user's task down BEFORE any code is written.
|
||||
|
||||
Produce:
|
||||
1. A short restatement of the goal (one sentence).
|
||||
2. Acceptance criteria via the criteria tool (action=add): 2-6 short, testable statements of what DONE means.
|
||||
3. The implementation steps, ordered, with the files each step touches.
|
||||
|
||||
Explore the code (read, grep, glob, git_status) as needed to make the plan concrete — real file paths, real function names. Do not modify anything. End by asking the user to confirm the plan or amend it.
|
||||
@@ -0,0 +1,12 @@
|
||||
You are the VERIFICATION pass of code mode. You independently check the acceptance criteria against the actual code — you did not write it, trust nothing.
|
||||
|
||||
For each criterion still pending or failed:
|
||||
1. Understand what it actually requires.
|
||||
2. Check the real changes: git_diff, read the touched files, run the relevant build/test command with bash when applicable.
|
||||
3. Record the verdict with the criteria tool: action=set, status=passed (satisfied) or failed (with a note saying exactly what is missing — actionable, one line).
|
||||
|
||||
Rules:
|
||||
- Trivial or non-code criteria that are plainly satisfied: pass them without exploring.
|
||||
- Only fail a criterion that genuinely misses its requirement; a different-but-valid implementation passes.
|
||||
- Do NOT fix anything yourself. You only verify and report.
|
||||
- Be fast: check the diff first, read only what the criteria require.
|
||||
@@ -134,7 +134,7 @@ func detectBuildPlan() buildPlan {
|
||||
}
|
||||
|
||||
// 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")) {
|
||||
if hasTool("glslc") && (hasVulkanLib() || hasTool("vulkaninfo")) {
|
||||
p.backend = "vulkan"
|
||||
p.flags = append(p.flags, "-DGGML_VULKAN=ON")
|
||||
return p
|
||||
@@ -457,6 +457,33 @@ func hasTool(name string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// hasVulkanLib détecte le chargeur Vulkan (libvulkan) sans supposer un unique
|
||||
// chemin de distribution. Repris de l'amont AJEAN (issues #28, #29) : l'ancienne
|
||||
// détection testait en dur le chemin Debian multiarch
|
||||
// (/usr/lib/x86_64-linux-gnu/libvulkan.so.1), invisible sur Fedora/RHEL/Atomic où
|
||||
// la bibliothèque vit dans /usr/lib64 — le plan de build retombait alors sur le
|
||||
// CPU sur une machine parfaitement capable. On interroge d'abord ldconfig (la
|
||||
// source de vérité du linker dynamique), puis une liste de chemins connus.
|
||||
func hasVulkanLib() bool {
|
||||
// ldconfig -p liste les bibliothèques du cache, tous chemins confondus.
|
||||
if out, err := exec.Command("ldconfig", "-p").Output(); err == nil {
|
||||
if strings.Contains(string(out), "libvulkan.so") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, p := range []string{
|
||||
"/usr/lib/x86_64-linux-gnu/libvulkan.so.1", // Debian/Ubuntu multiarch
|
||||
"/usr/lib64/libvulkan.so.1", // Fedora/RHEL/Bazzite
|
||||
"/usr/lib/libvulkan.so.1", // Arch et divers
|
||||
"/lib64/libvulkan.so.1",
|
||||
} {
|
||||
if isFile(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requireTools(tools ...string) error {
|
||||
missing := missingTools(tools)
|
||||
if len(missing) == 0 {
|
||||
|
||||
@@ -12,10 +12,41 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// readAPIKeyErr renvoie la clé Bearer des complétions en distinguant « aucune
|
||||
// clé » d'une LECTURE RATÉE — même nuance que readWebKeyErr, et pour la même
|
||||
// raison : sans clé, l'endpoint est OUVERT. Confondre les deux, c'est ouvrir
|
||||
// l'accès aux complétions parce que la base était momentanément verrouillée par
|
||||
// une commande CLI. requireCompletionKey refuse donc plutôt que d'ouvrir.
|
||||
func readAPIKeyErr() (string, error) {
|
||||
b, err := getBytesErr(bkState, "api_key")
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// 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") }
|
||||
// définie (ou illisible). Elle est rangée hors de la configuration pour survivre
|
||||
// aux changements de preset, qui remplacent la configuration en bloc. Réservé à
|
||||
// l'affichage et aux appels internes ; toute décision d'accès passe par
|
||||
// effectiveAPIKeyErr.
|
||||
func readAPIKey() string { k, _ := readAPIKeyErr(); return k }
|
||||
|
||||
// effectiveAPIKeyErr renvoie la clé que llama-server exige RÉELLEMENT : celle
|
||||
// rangée en base, sinon la clé résiduelle de config.env (repli rétro-compatible,
|
||||
// cf. backend_serve.go).
|
||||
//
|
||||
// Une seule source pour les deux côtés du relais. Quand la garde de Loki ne
|
||||
// regardait que la clé en base, un API_KEY oublié dans config.env donnait un
|
||||
// endpoint « ouvert » côté Loki et un 401 côté moteur — le client voyait un
|
||||
// refus sans jamais comprendre quelle clé fournir.
|
||||
func effectiveAPIKeyErr() (string, error) {
|
||||
k, err := readAPIKeyErr()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if k != "" {
|
||||
return k, nil
|
||||
}
|
||||
return strings.TrimSpace(ReadConfig()["API_KEY"]), nil
|
||||
}
|
||||
|
||||
// authHeader sets the Authorization: Bearer header on req when an API key is
|
||||
// configured, so Loki's own internal calls (chat/web/bench/test) authenticate
|
||||
@@ -203,6 +234,61 @@ func splitArgs(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// samplingKeys : les paramètres d'échantillonnage réglables par preset. Repris
|
||||
// de l'amont AJEAN (v0.9.5/v0.9.6).
|
||||
//
|
||||
// Ils sont injectés dans CHAQUE requête de complétion (applySampling) et non
|
||||
// passés à llama-server au lancement : une valeur présente dans la requête
|
||||
// l'emporte de toute façon sur le défaut du serveur, donc c'est le seul endroit
|
||||
// où le réglage prend vraiment effet. Sans ça, seule la température voyageait, et
|
||||
// top_k/min_p/… retombaient sur les défauts de llama.cpp — rarement ceux que
|
||||
// recommande le modèle (Qwen3.8 veut top_k 20 / min_p 0, pas 40 / 0.05).
|
||||
//
|
||||
// Correspondance clé de config → champ du corps OpenAI. Une valeur vide vaut
|
||||
// « clé absente » : llama-server garde alors son propre défaut pour ce champ.
|
||||
var samplingKeys = []struct {
|
||||
cfg, field string
|
||||
integer bool
|
||||
}{
|
||||
{"TEMP", "temperature", false},
|
||||
{"TOP_P", "top_p", false},
|
||||
{"TOP_K", "top_k", true},
|
||||
{"MIN_P", "min_p", false},
|
||||
{"PRESENCE_PENALTY", "presence_penalty", false},
|
||||
{"REPEAT_PENALTY", "repeat_penalty", false},
|
||||
}
|
||||
|
||||
// applySampling superpose les paramètres d'échantillonnage du preset au corps
|
||||
// d'une requête de complétion. Les clés vides sont ignorées (le serveur garde son
|
||||
// défaut) ; TEMP, s'il est défini, l'emporte sur la température déjà posée dans le
|
||||
// payload — c'est le réglage recommandé du modèle, et l'interface n'a pas de
|
||||
// curseur de température qui pourrait le contredire.
|
||||
//
|
||||
// Contrairement à l'amont, REASONING_EFFORT n'est PAS traité ici : loki lui
|
||||
// réserve un chemin plus riche (llm_client.go + llm_effort.go — champ de haut
|
||||
// niveau, kwargs du gabarit, repli quand le gabarit refuse le niveau). L'écrire
|
||||
// depuis ici écraserait `chat_template_kwargs`, donc la consigne « aucune » avec.
|
||||
func applySampling(payload map[string]any) { applySamplingFrom(payload, ReadConfig()) }
|
||||
|
||||
// applySamplingFrom est la partie pure d'applySampling : testable sans base.
|
||||
func applySamplingFrom(payload map[string]any, cfg map[string]string) {
|
||||
for _, s := range samplingKeys {
|
||||
v := strings.TrimSpace(cfg[s.cfg])
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if s.integer {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
payload[s.field] = n
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
payload[s.field] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -0,0 +1,650 @@
|
||||
// backend_engine_oci.go — mise à jour du moteur llama.cpp SANS reconstruire
|
||||
// l'image et sans compiler : on va chercher le moteur là où l'équipe llama.cpp
|
||||
// le publie déjà tout compilé — dans son image de conteneur officielle — et on
|
||||
// en extrait le seul dossier qui nous intéresse, /app.
|
||||
//
|
||||
// Pourquoi ce détour plutôt que les releases GitHub (backend_prebuilt.go) :
|
||||
// llama.cpp ne publie AUCUN binaire CUDA pour Linux dans ses releases. La seule
|
||||
// distribution CUDA/Linux officielle et précompilée, ce sont ces images
|
||||
// (ghcr.io/ggml-org/llama.cpp:server-cuda-b#####). C'est aussi exactement ce
|
||||
// dont l'image de Loki hérite : mettre à jour par ce chemin, c'est obtenir le
|
||||
// même moteur qu'une reconstruction complète de l'image, en ~170 Mo au lieu de
|
||||
// 2,6 Go et sans passer par un rebuild.
|
||||
//
|
||||
// Ce qu'on télécharge : PAS l'image. Un manifeste OCI liste ses couches ; le
|
||||
// runtime CUDA (2 Go) et les paquets système sont déjà présents dans l'image de
|
||||
// Loki, donc inutiles. Seules les couches du haut portent /app (llama-server et
|
||||
// ses .so, ~170 Mo). On les prend en partant du sommet et on s'arrête dès qu'on
|
||||
// tient un moteur complet.
|
||||
//
|
||||
// Ce que ça NE remplace PAS : le runtime CUDA, lui, vient de l'image. Un
|
||||
// llama.cpp compilé pour un CUDA majeur plus récent que celui de l'image ne
|
||||
// démarrera pas — d'où le test à blanc (--list-devices) avant toute bascule, et
|
||||
// la possibilité de revenir au moteur d'origine en un clic.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ociBase est une variable et non une constante pour que les tests puissent la
|
||||
// pointer sur un faux registre : tout ce qui suit se joue dans le dialogue avec
|
||||
// le registre, et le vérifier en frappant ghcr.io rendrait la CI dépendante du
|
||||
// réseau.
|
||||
// LOKI_OCI_REGISTRY le remplace : c'est la porte de sortie pour un réseau qui
|
||||
// n'atteint pas ghcr.io directement — miroir d'entreprise, cache local — sans
|
||||
// quoi la mise à jour du moteur y serait tout simplement impossible.
|
||||
var ociBase = "https://ghcr.io"
|
||||
|
||||
func init() {
|
||||
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("LOKI_OCI_REGISTRY")), "/"); v != "" {
|
||||
if !strings.Contains(v, "://") {
|
||||
v = "https://" + v
|
||||
}
|
||||
ociBase = v
|
||||
}
|
||||
}
|
||||
|
||||
// ociHost : l'hôte seul, tel que l'attend le paramètre `service` du jeton.
|
||||
func ociHost() string {
|
||||
h := strings.TrimPrefix(strings.TrimPrefix(ociBase, "https://"), "http://")
|
||||
return strings.TrimSuffix(h, "/")
|
||||
}
|
||||
|
||||
const (
|
||||
ociEngineRepo = "ggml-org/llama.cpp"
|
||||
|
||||
// Au-delà de cette taille, une couche n'est plus une couche « applicative » :
|
||||
// c'est le runtime CUDA (2 Go) ou une base système, qu'on ne veut à aucun
|
||||
// prix télécharger. On descend les couches du sommet vers la base ; croiser
|
||||
// une couche de cette taille signifie qu'on a dépassé /app.
|
||||
ociLayerCap = 600 << 20
|
||||
)
|
||||
|
||||
// ociLayer : une couche du manifeste (on n'a besoin que de ça).
|
||||
type ociLayer struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ociDoc couvre à la fois un index multi-architecture et un manifeste d'image :
|
||||
// les deux arrivent sur la même URL, distingués par le champ qui est rempli.
|
||||
type ociDoc struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Manifests []struct {
|
||||
Digest string `json:"digest"`
|
||||
Platform struct {
|
||||
Architecture string `json:"architecture"`
|
||||
OS string `json:"os"`
|
||||
} `json:"platform"`
|
||||
} `json:"manifests"`
|
||||
Layers []ociLayer `json:"layers"`
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}
|
||||
|
||||
// ociAccept : tous les types de manifeste qu'on sait lire. Sans cet en-tête, le
|
||||
// registre répond en schéma 1 (obsolète) et la lecture échoue sans expliquer.
|
||||
const ociAccept = "application/vnd.oci.image.index.v1+json," +
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json," +
|
||||
"application/vnd.oci.image.manifest.v1+json," +
|
||||
"application/vnd.docker.distribution.manifest.v2+json"
|
||||
|
||||
// ociToken obtient un jeton de lecture ANONYME. Les images publiques de ghcr.io
|
||||
// sont lisibles sans compte, mais le registre exige quand même un jeton porteur.
|
||||
func ociToken(repo string) (string, error) {
|
||||
u := fmt.Sprintf("%s/token?scope=repository:%s:pull&service=%s", ociBase, repo, ociHost())
|
||||
resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(u)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("registre %s injoignable : %w", ociHost(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("%s/token : HTTP %d", ociHost(), resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Token == "" {
|
||||
return "", fmt.Errorf("jeton de lecture illisible")
|
||||
}
|
||||
return out.Token, nil
|
||||
}
|
||||
|
||||
// ociFetch fait un GET authentifié sur le registre.
|
||||
func ociFetch(url, token, accept string) (*http.Response, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if accept != "" {
|
||||
req.Header.Set("Accept", accept)
|
||||
}
|
||||
return (&http.Client{Timeout: 0}).Do(req)
|
||||
}
|
||||
|
||||
// ociArch traduit GOARCH vers le nom de plateforme OCI.
|
||||
func ociArch() string {
|
||||
if runtime.GOARCH == "arm64" {
|
||||
return "arm64"
|
||||
}
|
||||
return "amd64"
|
||||
}
|
||||
|
||||
// ociResolve lit le manifeste d'un tag et renvoie les couches de l'image pour
|
||||
// CETTE architecture, plus le numéro de build annoncé par l'image
|
||||
// (org.opencontainers.image.version, ex. « b10450 »).
|
||||
func ociResolve(repo, tag string) (layers []ociLayer, version string, err error) {
|
||||
token, err := ociToken(repo)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
get := func(ref string) (*ociDoc, error) {
|
||||
resp, err := ociFetch(fmt.Sprintf("%s/v2/%s/manifests/%s", ociBase, repo, ref), token, ociAccept)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 404 {
|
||||
return nil, fmt.Errorf("version inconnue dans le registre : %s", ref)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return nil, fmt.Errorf("manifeste %s : HTTP %d", ref, resp.StatusCode)
|
||||
}
|
||||
var d ociDoc
|
||||
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
|
||||
return nil, fmt.Errorf("manifeste %s illisible : %w", ref, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
doc, err := get(tag)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
version = doc.Annotations["org.opencontainers.image.version"]
|
||||
if len(doc.Manifests) > 0 {
|
||||
want := ociArch()
|
||||
digest := ""
|
||||
for _, m := range doc.Manifests {
|
||||
if m.Platform.OS == "linux" && m.Platform.Architecture == want {
|
||||
digest = m.Digest
|
||||
break
|
||||
}
|
||||
}
|
||||
if digest == "" {
|
||||
return nil, "", fmt.Errorf("aucune image linux/%s pour %s", want, tag)
|
||||
}
|
||||
if doc, err = get(digest); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if v := doc.Annotations["org.opencontainers.image.version"]; v != "" {
|
||||
version = v
|
||||
}
|
||||
}
|
||||
if len(doc.Layers) == 0 {
|
||||
return nil, "", fmt.Errorf("manifeste %s sans couche", tag)
|
||||
}
|
||||
return doc.Layers, version, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variante, version et emplacement du moteur
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineOCISupported : ce mécanisme n'a de sens que là où ces images tournent.
|
||||
// Les images llama.cpp sont linux/amd64 et linux/arm64, rien d'autre.
|
||||
func engineOCISupported() bool {
|
||||
return runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
|
||||
}
|
||||
|
||||
// engineVariant déduit la variante d'image correspondant au moteur COURANT, en
|
||||
// regardant les backends ggml posés à côté du binaire. C'est la seule source
|
||||
// fiable : le conteneur ne sait pas de quelle image il a été bâti, et demander
|
||||
// à l'utilisateur de retenir « server-cuda » serait un piège à erreur.
|
||||
//
|
||||
// Renvoie un préfixe de tag : "server-cuda", "server-vulkan", "server-intel",
|
||||
// "server-musa", ou "server" (CPU) par défaut.
|
||||
func engineVariant() string {
|
||||
dir := ""
|
||||
if bin := currentEngineBin(); bin != "" {
|
||||
dir = filepath.Dir(bin)
|
||||
}
|
||||
if dir == "" {
|
||||
return "server"
|
||||
}
|
||||
has := func(prefix string) bool {
|
||||
m, _ := filepath.Glob(filepath.Join(dir, prefix+"*"))
|
||||
return len(m) > 0
|
||||
}
|
||||
switch {
|
||||
case has("libggml-cuda.so"):
|
||||
return "server-cuda"
|
||||
case has("libggml-musa.so"):
|
||||
return "server-musa"
|
||||
case has("libggml-sycl.so"):
|
||||
return "server-intel"
|
||||
case has("libggml-vulkan.so"):
|
||||
return "server-vulkan"
|
||||
}
|
||||
return "server"
|
||||
}
|
||||
|
||||
// currentEngineBin : le binaire réellement utilisé (BIN de la configuration,
|
||||
// avec le repli sur le moteur de l'image quand BIN n'est pas encore posé).
|
||||
func currentEngineBin() string {
|
||||
bin := prebuiltResolveBin(ReadConfig()["BIN"])
|
||||
if bin != "" && isFile(bin) {
|
||||
return bin
|
||||
}
|
||||
return providedEngineBin()
|
||||
}
|
||||
|
||||
// La bannière de version de llama.cpp a changé de forme plusieurs fois, et un
|
||||
// binaire ancien reste parfaitement utilisable : on lit les trois formes.
|
||||
//
|
||||
// version: 0.1.0-dev (build 10450, commit ece963f41) ← actuelle
|
||||
// version: 10450 (ece963f) ← précédente
|
||||
// build: 4589 (a1b2c3d) ← plus ancienne
|
||||
//
|
||||
// C'est le NUMÉRO DE BUILD qui compte : c'est lui qui figure dans les tags
|
||||
// d'image (server-cuda-b10450) et donc la seule chose comparable entre ce qui
|
||||
// tourne et ce qui est publié.
|
||||
var reEngineBuildLong = regexp.MustCompile(`build\s+(\d+),\s*commit\s+([0-9a-fA-F]+)`)
|
||||
var reEngineBuildShort = regexp.MustCompile(`(?m)^\s*(?:version|build)\s*:\s*(\d+)\s*\(([0-9a-fA-F]+)\)`)
|
||||
|
||||
func parseEngineBuild(out string) (build int, commit string) {
|
||||
for _, re := range []*regexp.Regexp{reEngineBuildLong, reEngineBuildShort} {
|
||||
if m := re.FindStringSubmatch(out); m != nil {
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n, m[2]
|
||||
}
|
||||
}
|
||||
return 0, ""
|
||||
}
|
||||
|
||||
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
|
||||
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
|
||||
// d'en installer un autre.
|
||||
func engineBuildOf(bin string) (int, string) {
|
||||
if bin == "" || !isFile(bin) {
|
||||
return 0, ""
|
||||
}
|
||||
cmd := hideCmd(exec.Command(bin, "--version"))
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
|
||||
return parseEngineBuild(string(out))
|
||||
}
|
||||
|
||||
// engineDir : racine des moteurs installés par ce mécanisme, sous LOKI_HOME —
|
||||
// c'est-à-dire sur le volume /data en conteneur, donc conservés d'un
|
||||
// redémarrage (et d'une mise à jour d'image) à l'autre.
|
||||
func engineDir() string { return filepath.Join(LokiHome(), "engine") }
|
||||
|
||||
// engineOwns dit si un chemin désigne un moteur installé ici.
|
||||
func engineOwns(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(engineDir())+"/")
|
||||
}
|
||||
|
||||
// engineInstalled liste les moteurs présents sous engineDir(), du plus ancien
|
||||
// au plus récent (ordre des numéros de build).
|
||||
func engineInstalled() []string {
|
||||
entries, err := os.ReadDir(engineDir())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var tags []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && isFile(filepath.Join(engineDir(), e.Name(), "llama-server")) {
|
||||
tags = append(tags, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Slice(tags, func(i, j int) bool { return engineTagBuild(tags[i]) < engineTagBuild(tags[j]) })
|
||||
return tags
|
||||
}
|
||||
|
||||
var reTagBuild = regexp.MustCompile(`-b(\d+)$`)
|
||||
|
||||
// engineTagBuild extrait le numéro de build d'un tag (« server-cuda-b10450 » →
|
||||
// 10450), 0 si le tag n'en porte pas (tag mouvant « server-cuda »).
|
||||
func engineTagBuild(tag string) int {
|
||||
m := reTagBuild.FindStringSubmatch(tag)
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[1])
|
||||
return n
|
||||
}
|
||||
|
||||
// engineTagOf : le tag d'un moteur téléchargé ici (« server-cuda-b10450 »), ""
|
||||
// pour tout autre chemin.
|
||||
func engineTagOf(bin string) string {
|
||||
if !engineOwns(bin) {
|
||||
return ""
|
||||
}
|
||||
return filepath.Base(filepath.Dir(bin))
|
||||
}
|
||||
|
||||
// engineBinFor : chemin du llama-server d'une version installée.
|
||||
func engineBinFor(tag string) string {
|
||||
p := filepath.Join(engineDir(), filepath.Base(tag), "llama-server")
|
||||
if isFile(p) {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// enginePrune supprime les versions installées autres que celles à garder.
|
||||
// Chaque version pèse ~450 Mo une fois décompressée : en laisser traîner cinq
|
||||
// remplit le volume /data sans rien apporter.
|
||||
func enginePrune(keep map[string]bool, logf func(string)) {
|
||||
for _, tag := range engineInstalled() {
|
||||
if keep[tag] {
|
||||
continue
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err == nil && logf != nil {
|
||||
logf("ancienne version supprimée : " + tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Installation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineOCIInstall télécharge le moteur du tag demandé et l'installe dans
|
||||
// engineDir()/<tag>. Renvoie le chemin du llama-server installé.
|
||||
//
|
||||
// Réentrant : une version déjà installée est renvoyée telle quelle, et une
|
||||
// installation interrompue laisse un dossier « .part » qui est repris de zéro.
|
||||
func engineOCIInstall(tag string, logf, phasef func(string)) (string, error) {
|
||||
tag = filepath.Base(strings.TrimSpace(tag)) // jamais de séparateur dans un nom de dossier
|
||||
if tag == "" || tag == "." {
|
||||
return "", fmt.Errorf("version du moteur non précisée")
|
||||
}
|
||||
if bin := engineBinFor(tag); bin != "" {
|
||||
logf("déjà installé : " + tag)
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
phasef("lecture du manifeste " + tag + "…")
|
||||
layers, version, err := ociResolve(ociEngineRepo, tag)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if version != "" {
|
||||
logf(fmt.Sprintf("image %s:%s — llama.cpp %s, %d couches", ociEngineRepo, tag, version, len(layers)))
|
||||
}
|
||||
|
||||
dest := filepath.Join(engineDir(), tag)
|
||||
tmp := dest + ".part"
|
||||
if err := os.RemoveAll(tmp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(tmp, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer os.RemoveAll(tmp) // no-op après le rename final
|
||||
|
||||
token, err := ociToken(ociEngineRepo)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// On descend du SOMMET vers la base : /app est ajouté en dernier dans le
|
||||
// Dockerfile amont, et les couches du bas sont le runtime CUDA et le système,
|
||||
// déjà présents ici. Une couche du haut peut masquer un fichier d'une couche
|
||||
// plus basse : le premier extrait gagne, comme le ferait un runtime OCI.
|
||||
seen := map[string]bool{}
|
||||
var links []archiveLink
|
||||
for i := len(layers) - 1; i >= 0; i-- {
|
||||
l := layers[i]
|
||||
if l.Size > ociLayerCap {
|
||||
logf(fmt.Sprintf("couche %d ignorée (%d Mo) : c'est le runtime de l'image, pas le moteur", i, l.Size/1_000_000))
|
||||
break
|
||||
}
|
||||
part := filepath.Join(tmp, "layer.tgz")
|
||||
if l.Size > 4<<20 {
|
||||
phasef(fmt.Sprintf("téléchargement du moteur (%d Mo)…", l.Size/1_000_000))
|
||||
}
|
||||
if err := ociDownloadBlob(ociEngineRepo, l.Digest, token, part, l.Size, logf); err != nil {
|
||||
return "", fmt.Errorf("téléchargement de la couche %d : %w", i, err)
|
||||
}
|
||||
n, err := extractAppLayer(part, tmp, seen, &links)
|
||||
_ = os.Remove(part)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extraction de la couche %d : %w", i, err)
|
||||
}
|
||||
if n > 0 {
|
||||
logf(fmt.Sprintf("couche %d : %d fichiers du moteur", i, n))
|
||||
}
|
||||
if engineSetComplete(seen) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := applyLinks(links); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !seen["llama-server"] {
|
||||
return "", fmt.Errorf("llama-server absent de l'image %s — variante inattendue ?", tag)
|
||||
}
|
||||
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.Rename(tmp, dest); err != nil {
|
||||
return "", err
|
||||
}
|
||||
bin := filepath.Join(dest, "llama-server")
|
||||
if err := os.Chmod(bin, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logf("moteur installé : " + bin)
|
||||
return bin, nil
|
||||
}
|
||||
|
||||
// engineSetComplete : a-t-on de quoi lancer le moteur ? Le binaire seul ne
|
||||
// suffit pas — depuis que llama.cpp éclate son code en bibliothèques, l'exécutable
|
||||
// tient dans une couche et ses .so dans la précédente. S'arrêter au binaire
|
||||
// donnait un moteur qui meurt sur « libllama.so: cannot open shared object ».
|
||||
func engineSetComplete(seen map[string]bool) bool {
|
||||
if !seen["llama-server"] {
|
||||
return false
|
||||
}
|
||||
base, llama := false, false
|
||||
for name := range seen {
|
||||
switch {
|
||||
case strings.HasPrefix(name, "libggml-base.so"):
|
||||
base = true
|
||||
case strings.HasPrefix(name, "libllama.so"):
|
||||
llama = true
|
||||
}
|
||||
}
|
||||
return base && llama
|
||||
}
|
||||
|
||||
// ociDownloadBlob télécharge une couche vers dest, avec la progression du
|
||||
// téléchargement dans le journal du job.
|
||||
func ociDownloadBlob(repo, digest, token, dest string, size int64, logf func(string)) error {
|
||||
req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/%s/blobs/%s", ociBase, repo, digest), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return downloadRequest(req, dest, size, logf)
|
||||
}
|
||||
|
||||
// extractAppLayer extrait les entrées « app/… » d'une couche (tar.gz) à PLAT
|
||||
// dans dir, en ignorant celles déjà vues (une couche supérieure fait foi) et
|
||||
// les marqueurs de suppression OCI (.wh.*). Renvoie le nombre de fichiers
|
||||
// extraits. Les liens sont accumulés dans links : leur cible n'est pas
|
||||
// forcément déjà extraite au moment où on croise l'entrée.
|
||||
func extractAppLayer(archive, dir string, seen map[string]bool, links *[]archiveLink) (int, error) {
|
||||
f, err := os.Open(archive)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
count := 0
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return count, nil
|
||||
}
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
name, ok := appEntryName(h.Name)
|
||||
if !ok || seen[name] {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
switch h.Typeflag {
|
||||
case tar.TypeReg:
|
||||
w, err := os.Create(p)
|
||||
if err != nil {
|
||||
return count, err
|
||||
}
|
||||
if _, err := io.Copy(w, tr); err != nil {
|
||||
w.Close()
|
||||
return count, err
|
||||
}
|
||||
w.Close()
|
||||
_ = os.Chmod(p, os.FileMode(h.Mode)&0o777)
|
||||
seen[name] = true
|
||||
count++
|
||||
case tar.TypeSymlink, tar.TypeLink:
|
||||
// Indispensable : les .so sont livrés sous leur nom versionné
|
||||
// (libllama.so.0.1.0) plus deux liens (libllama.so.0, libllama.so) —
|
||||
// c'est le nom court que l'éditeur de liens cherche au lancement.
|
||||
target, ok := appEntryName(h.Linkname)
|
||||
if !ok {
|
||||
target = filepath.Base(h.Linkname) // lien relatif au sein de /app
|
||||
}
|
||||
*links = append(*links, archiveLink{path: p, target: target})
|
||||
seen[name] = true
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// appEntryName ramène « app/libllama.so » à « libllama.so ». Renvoie ok=false
|
||||
// pour tout ce qui n'est pas un fichier direct de /app : autres dossiers,
|
||||
// sous-dossiers, et marqueurs de suppression OCI.
|
||||
func appEntryName(raw string) (string, bool) {
|
||||
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
|
||||
rest, ok := strings.CutPrefix(p, "app/")
|
||||
if !ok || rest == "" || strings.Contains(rest, "/") {
|
||||
return "", false
|
||||
}
|
||||
if strings.HasPrefix(rest, ".wh.") {
|
||||
return "", false
|
||||
}
|
||||
return rest, true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vérification et bascule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineOCILatest renvoie le dernier build publié pour la variante (numéro et
|
||||
// tag figé correspondant). On lit le tag MOUVANT (« server-cuda ») et son
|
||||
// annotation de version plutôt que de lister les 1000 tags du dépôt : une seule
|
||||
// requête, et c'est l'amont qui dit ce qu'est « la dernière ».
|
||||
func engineOCILatest(variant string) (build int, tag string, err error) {
|
||||
_, version, err := ociResolve(ociEngineRepo, variant)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
if version == "" {
|
||||
// Pas d'annotation : on retombe sur le tag mouvant lui-même, qui pointe
|
||||
// bien sur la dernière image — on ne saura juste pas dire son numéro.
|
||||
return 0, variant, nil
|
||||
}
|
||||
// L'annotation vaut « b10450 » : le tag figé est « server-cuda-b10450 ».
|
||||
n, _ := strconv.Atoi(strings.TrimPrefix(version, "b"))
|
||||
return n, variant + "-" + version, nil
|
||||
}
|
||||
|
||||
// engineSmokeTest lance à blanc le moteur fraîchement installé — c'est là que
|
||||
// se joue le seul vrai risque de ce mécanisme. La mise à jour apporte llama.cpp,
|
||||
// PAS le runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
|
||||
// CUDA plus récent ne chargera pas son backend GPU ici.
|
||||
//
|
||||
// Deux échecs distincts, et le second est le sournois :
|
||||
//
|
||||
// 1. le binaire ne démarre pas du tout (bibliothèque manquante) — bruyant ;
|
||||
// 2. il démarre mais ne voit plus AUCUNE carte, alors que le moteur courant en
|
||||
// voyait. Rien ne casse : le modèle se charge, tourne sur le processeur, et
|
||||
// on ne s'en aperçoit qu'au débit. C'est le pire résultat possible, donc on
|
||||
// le traite comme un échec de mise à jour.
|
||||
//
|
||||
// Ne voir aucune carte n'est un échec que si le moteur courant en voyait :
|
||||
// sur une machine sans GPU, « aucune carte » est le résultat normal.
|
||||
func engineSmokeTest(bin, ancien string) error {
|
||||
devs, err := engineListDevices(bin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(devs) > 0 {
|
||||
return nil
|
||||
}
|
||||
if ancien == "" || ancien == bin {
|
||||
return nil
|
||||
}
|
||||
avant, err := engineListDevices(ancien)
|
||||
if err != nil || len(avant) == 0 {
|
||||
return nil // le moteur courant n'en voyait pas plus : rien à reprocher au nouveau
|
||||
}
|
||||
return fmt.Errorf("le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit %d — "+
|
||||
"il tournerait sur le processeur, beaucoup plus lentement", len(avant))
|
||||
}
|
||||
|
||||
// engineListDevices lance `llama-server --list-devices` sur un binaire donné.
|
||||
func engineListDevices(bin string) ([]map[string]any, error) {
|
||||
cmd := hideCmd(exec.Command(bin, "--list-devices"))
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("le moteur ne démarre pas ici : %v\n%s", err, tailLines(string(out), 12))
|
||||
}
|
||||
return parseListDevices(string(out)), nil
|
||||
}
|
||||
|
||||
// tailLines garde les n dernières lignes non vides d'une sortie.
|
||||
func tailLines(s string, n int) string {
|
||||
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package loki
|
||||
|
||||
// Vérification contre le VRAI registre. Ignoré par défaut (la CI ne doit pas
|
||||
// dépendre du réseau ni télécharger 170 Mo) :
|
||||
//
|
||||
// LOKI_NET_TEST=1 go test ./internal/loki -run TestMoteurReelDepuisGhcr -v
|
||||
//
|
||||
// Ce test existe parce que le faux registre ne peut pas garantir une chose : que
|
||||
// la DISPOSITION de l'image amont est toujours celle qu'on suppose. Si llama.cpp
|
||||
// range un jour son moteur autrement — sous-dossier, couche gigantesque, autre
|
||||
// jeu de bibliothèques — c'est ici qu'on le verra, et pas chez l'utilisateur.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMoteurReelDepuisGhcr(t *testing.T) {
|
||||
if os.Getenv("LOKI_NET_TEST") == "" {
|
||||
t.Skip("test réseau — LOKI_NET_TEST=1 pour l'exécuter")
|
||||
}
|
||||
if os.Getenv("LOKI_HOME") == "" {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
}
|
||||
|
||||
build, tag, err := engineOCILatest("server-cuda")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("dernier build publié : %d → %s", build, tag)
|
||||
if build == 0 {
|
||||
t.Error("numéro de build absent de l'annotation de l'image")
|
||||
}
|
||||
|
||||
bin, err := engineOCIInstall(tag, func(s string) { t.Log(s) }, func(s string) { t.Log("▶ " + s) })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := filepath.Dir(bin)
|
||||
// Ce que l'éditeur de liens réclamera au lancement. Un seul manquant et le
|
||||
// moteur meurt sur « cannot open shared object file ».
|
||||
for _, n := range []string{"libllama.so", "libggml.so", "libggml-base.so", "libggml-cuda.so", "libmtmd.so"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
|
||||
t.Errorf("%s : %v", n, err)
|
||||
}
|
||||
}
|
||||
// Et le binaire doit savoir dire sa version : c'est ce qui pilote la
|
||||
// comparaison « installé vs publié » de tout le panneau.
|
||||
if got, commit := engineBuildOf(bin); got != build {
|
||||
t.Errorf("le moteur installé annonce le build %d (%s), attendu %d", got, commit, build)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package loki
|
||||
|
||||
// Mise à jour du moteur depuis l'image officielle llama.cpp.
|
||||
//
|
||||
// Ce qui se joue ici n'est pas « est-ce qu'on télécharge » mais « est-ce qu'on
|
||||
// télécharge le MINIMUM et est-ce que ce qu'on pose est LANÇABLE ». Deux pièges
|
||||
// concrets, tous deux invisibles sans test :
|
||||
//
|
||||
// - l'image officielle range llama-server dans une couche et ses .so dans la
|
||||
// précédente ; s'arrêter au binaire donne un moteur qui meurt sur
|
||||
// « libllama.so: cannot open shared object file » ;
|
||||
// - la couche du dessous est le runtime CUDA (2 Go). La descendre par erreur,
|
||||
// c'est transformer une mise à jour de 170 Mo en un téléchargement de 2 Go.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- Faux registre OCI ------------------------------------------------------
|
||||
|
||||
// fauxCouche fabrique une couche tar.gz. Les entrées sont décrites par leur nom
|
||||
// complet dans l'archive ; une valeur préfixée par « -> » crée un lien.
|
||||
func fauxCouche(t *testing.T, entrees map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
for nom, contenu := range entrees {
|
||||
if cible, ok := strings.CutPrefix(contenu, "-> "); ok {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeSymlink, Linkname: cible, Mode: 0o777}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeReg, Size: int64(len(contenu)), Mode: 0o755}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(contenu)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// fauxRegistre sert un index multi-architecture, le manifeste amd64 et les
|
||||
// blobs. `tailles` permet d'annoncer une taille différente de la taille réelle
|
||||
// (c'est ce que lit le garde-fou des grosses couches). Renvoie le compteur des
|
||||
// blobs réellement téléchargés.
|
||||
func fauxRegistre(t *testing.T, version string, couches [][]byte, tailles []int64) (servis map[string]int) {
|
||||
t.Helper()
|
||||
servis = map[string]int{}
|
||||
digests := make([]string, len(couches))
|
||||
blobs := map[string][]byte{}
|
||||
for i, c := range couches {
|
||||
sum := sha256.Sum256(c)
|
||||
digests[i] = "sha256:" + hex.EncodeToString(sum[:])
|
||||
blobs[digests[i]] = c
|
||||
}
|
||||
manifeste := map[string]any{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.manifest.v1+json",
|
||||
"layers": func() []map[string]any {
|
||||
var out []map[string]any
|
||||
for i := range couches {
|
||||
taille := int64(len(couches[i]))
|
||||
if tailles != nil && tailles[i] > 0 {
|
||||
taille = tailles[i]
|
||||
}
|
||||
out = append(out, map[string]any{
|
||||
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
|
||||
"digest": digests[i], "size": taille,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}(),
|
||||
}
|
||||
manBytes, _ := json.Marshal(manifeste)
|
||||
manSum := sha256.Sum256(manBytes)
|
||||
manDigest := "sha256:" + hex.EncodeToString(manSum[:])
|
||||
|
||||
index := map[string]any{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.oci.image.index.v1+json",
|
||||
"manifests": []map[string]any{
|
||||
// L'architecture qui ne nous concerne pas vient EN PREMIER : choisir
|
||||
// « le premier manifeste » au lieu de filtrer sur la plateforme
|
||||
// installerait un moteur d'une autre architecture.
|
||||
{"digest": "sha256:mauvaise", "platform": map[string]string{"os": "linux", "architecture": "ppc64le"}},
|
||||
{"digest": manDigest, "platform": map[string]string{"os": "linux", "architecture": ociArch()}},
|
||||
},
|
||||
"annotations": map[string]string{"org.opencontainers.image.version": version},
|
||||
}
|
||||
indexBytes, _ := json.Marshal(index)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/token"):
|
||||
_, _ = w.Write([]byte(`{"token":"jeton-de-test"}`))
|
||||
case strings.Contains(r.URL.Path, "/manifests/"):
|
||||
if r.Header.Get("Authorization") != "Bearer jeton-de-test" {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
if ref == manDigest {
|
||||
_, _ = w.Write(manBytes)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write(indexBytes)
|
||||
case strings.Contains(r.URL.Path, "/blobs/"):
|
||||
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
b, ok := blobs[ref]
|
||||
if !ok {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
servis[ref]++
|
||||
_, _ = w.Write(b)
|
||||
default:
|
||||
w.WriteHeader(404)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ancien := ociBase
|
||||
ociBase = srv.URL
|
||||
t.Cleanup(func() { ociBase = ancien })
|
||||
return servis
|
||||
}
|
||||
|
||||
// --- Tests ------------------------------------------------------------------
|
||||
|
||||
// Le cas réel : trois couches utiles au-dessus d'un runtime de 2 Go. On doit
|
||||
// prendre le binaire ET les bibliothèques, résoudre les liens de version, et
|
||||
// ne JAMAIS descendre jusqu'au runtime.
|
||||
func TestInstallationMoteurDepuisImage(t *testing.T) {
|
||||
testHome(t)
|
||||
couches := [][]byte{
|
||||
fauxCouche(t, map[string]string{"usr/local/cuda/lib64/libcudart.so": "runtime CUDA"}), // 2 Go annoncés
|
||||
fauxCouche(t, map[string]string{
|
||||
"app/libggml-base.so": "-> libggml-base.so.0",
|
||||
"app/libggml-base.so.0": "-> libggml-base.so.0.20.0",
|
||||
"app/libggml-base.so.0.20.0": "binaire ELF",
|
||||
"app/libllama.so": "-> libllama.so.0",
|
||||
"app/libllama.so.0": "-> libllama.so.0.1.0",
|
||||
"app/libllama.so.0.1.0": "binaire ELF",
|
||||
"app/libggml-cuda.so": "binaire ELF",
|
||||
}),
|
||||
fauxCouche(t, map[string]string{"app/llama-server": "#!/bin/true\n", "app/llama": "#!/bin/true\n"}),
|
||||
fauxCouche(t, map[string]string{"etc/passwd": "sans rapport"}),
|
||||
}
|
||||
servis := fauxRegistre(t, "b10450", couches, []int64{2 << 30, 0, 0, 0})
|
||||
|
||||
bin, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if veut := filepath.Join(engineDir(), "server-cuda-b10450", "llama-server"); bin != veut {
|
||||
t.Errorf("binaire installé en %s, attendu %s", bin, veut)
|
||||
}
|
||||
|
||||
// La couche du runtime CUDA ne doit pas avoir été demandée : c'est tout
|
||||
// l'intérêt (170 Mo au lieu de 2,6 Go).
|
||||
sum := sha256.Sum256(couches[0])
|
||||
if n := servis["sha256:"+hex.EncodeToString(sum[:])]; n != 0 {
|
||||
t.Errorf("la couche du runtime CUDA a été téléchargée %d fois", n)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(bin)
|
||||
for _, nom := range []string{"llama-server", "llama", "libggml-cuda.so", "libggml-base.so.0.20.0", "libllama.so.0.1.0"} {
|
||||
if !isFile(filepath.Join(dir, nom)) {
|
||||
t.Errorf("%s manquant", nom)
|
||||
}
|
||||
}
|
||||
// Les noms courts sont ceux que l'éditeur de liens cherche au lancement :
|
||||
// sans eux le moteur ne démarre pas, alors que tout « a l'air » installé.
|
||||
for _, lien := range []string{"libggml-base.so", "libggml-base.so.0", "libllama.so", "libllama.so.0"} {
|
||||
p := filepath.Join(dir, lien)
|
||||
if _, err := os.Stat(p); err != nil { // Stat suit le lien : vérifie aussi qu'il ne pend pas
|
||||
t.Errorf("%s : %v", lien, err)
|
||||
}
|
||||
}
|
||||
// /app à plat, et rien d'autre que /app.
|
||||
if isDir(filepath.Join(dir, "etc")) || isDir(filepath.Join(dir, "usr")) {
|
||||
t.Error("des fichiers hors /app ont été extraits")
|
||||
}
|
||||
// Windows n'a pas de bit exécutable : Stat y renvoie 0666/0444 quoi qu'il
|
||||
// arrive, et l'assertion échouait toujours — alors que l'installation, elle,
|
||||
// est correcte (le bit est bien posé côté Linux, là où le moteur tourne).
|
||||
if st, err := os.Stat(bin); err != nil || (runtime.GOOS != "windows" && st.Mode()&0o100 == 0) {
|
||||
t.Errorf("llama-server non exécutable : %v", err)
|
||||
}
|
||||
|
||||
// Réinstaller la même version ne redemande RIEN au registre : le job de mise
|
||||
// à jour repasse par là à chaque vérification.
|
||||
total := func() int {
|
||||
n := 0
|
||||
for _, v := range servis {
|
||||
n += v
|
||||
}
|
||||
return n
|
||||
}
|
||||
avant := total()
|
||||
if _, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total() != avant {
|
||||
t.Errorf("une version déjà installée a été re-téléchargée (%d → %d couches)", avant, total())
|
||||
}
|
||||
|
||||
// Et la version se retrouve dans la liste des moteurs installés.
|
||||
if got := engineInstalled(); len(got) != 1 || got[0] != "server-cuda-b10450" {
|
||||
t.Errorf("versions installées = %v", got)
|
||||
}
|
||||
if engineTagBuild("server-cuda-b10450") != 10450 {
|
||||
t.Error("numéro de build mal extrait du tag")
|
||||
}
|
||||
if !engineOwns(bin) {
|
||||
t.Error("le binaire installé n'est pas reconnu comme géré par Loki")
|
||||
}
|
||||
}
|
||||
|
||||
// Une variante dont l'image ne contient pas de llama-server (mauvais tag, image
|
||||
// « full » sans serveur) doit échouer CLAIREMENT, et ne rien laisser derrière
|
||||
// elle qui ressemblerait à un moteur installé.
|
||||
func TestInstallationRefuseUneImageSansMoteur(t *testing.T) {
|
||||
testHome(t)
|
||||
fauxRegistre(t, "b10450", [][]byte{
|
||||
fauxCouche(t, map[string]string{"app/README": "pas de moteur ici"}),
|
||||
}, nil)
|
||||
|
||||
if _, err := engineOCIInstall("server-b1", func(string) {}, func(string) {}); err == nil {
|
||||
t.Fatal("installation acceptée sans llama-server")
|
||||
}
|
||||
if got := engineInstalled(); len(got) != 0 {
|
||||
t.Errorf("versions installées après échec = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// engineOCILatest lit le numéro de build dans l'annotation de l'image, et en
|
||||
// déduit le tag figé — c'est lui qu'on installe, pour que « mis à jour vers »
|
||||
// désigne une version précise et reproductible plutôt qu'un tag mouvant.
|
||||
func TestDerniereVersionPubliee(t *testing.T) {
|
||||
fauxRegistre(t, "b10450", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
|
||||
build, tag, err := engineOCILatest("server-cuda")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if build != 10450 || tag != "server-cuda-b10450" {
|
||||
t.Errorf("build=%d tag=%q", build, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// La bannière du moteur est la seule source de vérité sur la version qui tourne
|
||||
// vraiment — le tag de l'image, lui, ment dès qu'on a basculé de moteur.
|
||||
func TestLectureVersionMoteur(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
nom, sortie string
|
||||
build int
|
||||
commit string
|
||||
}{
|
||||
// Relevée sur le binaire réel de l'image server-cuda-b10450.
|
||||
{"forme actuelle", "version: 0.1.0-dev (build 10450, commit ece963f41)\nbuilt with GNU 14.2.0 for Linux x86_64\n", 10450, "ece963f41"},
|
||||
{"forme précédente", "version: 10450 (ece963f)\nbuilt with cc\n", 10450, "ece963f"},
|
||||
{"forme ancienne", "build: 4589 (a1b2c3d)\n", 4589, "a1b2c3d"},
|
||||
{"bannière de démarrage", "ggml_cuda_init: found 1 device\nversion: 9001 (deadbee)\n", 9001, "deadbee"},
|
||||
{"rien d'exploitable", "usage: llama-server [options]\n", 0, ""},
|
||||
} {
|
||||
t.Run(c.nom, func(t *testing.T) {
|
||||
build, commit := parseEngineBuild(c.sortie)
|
||||
if build != c.build || commit != c.commit {
|
||||
t.Errorf("= (%d, %q), attendu (%d, %q)", build, commit, c.build, c.commit)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Le filtre des entrées d'archive : on ne veut QUE les fichiers directs de /app.
|
||||
// Un sous-dossier ou un marqueur de suppression OCI recopié à plat produirait
|
||||
// des fichiers parasites à côté du binaire.
|
||||
func TestFiltreDesEntreesApp(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
entree, veut string
|
||||
ok bool
|
||||
}{
|
||||
{"app/llama-server", "llama-server", true},
|
||||
{"./app/libllama.so", "libllama.so", true},
|
||||
{"app/", "", false},
|
||||
{"app/tools/quantize", "", false},
|
||||
{"app/.wh.libggml-cuda.so", "", false},
|
||||
{"usr/local/cuda/lib64/libcudart.so", "", false},
|
||||
{"apparence/piege", "", false},
|
||||
} {
|
||||
got, ok := appEntryName(c.entree)
|
||||
if ok != c.ok || got != c.veut {
|
||||
t.Errorf("%q → (%q, %v), attendu (%q, %v)", c.entree, got, ok, c.veut, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le binaire seul ne fait pas un moteur : c'est la règle d'arrêt de la descente
|
||||
// des couches.
|
||||
func TestMoteurCompletExigeLesBibliotheques(t *testing.T) {
|
||||
vu := func(noms ...string) map[string]bool {
|
||||
m := map[string]bool{}
|
||||
for _, n := range noms {
|
||||
m[n] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
if engineSetComplete(vu("llama-server")) {
|
||||
t.Error("binaire seul accepté comme moteur complet")
|
||||
}
|
||||
if engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0")) {
|
||||
t.Error("moteur sans libllama accepté")
|
||||
}
|
||||
if !engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0", "libllama.so.0.1.0")) {
|
||||
t.Error("moteur complet refusé")
|
||||
}
|
||||
}
|
||||
|
||||
// La variante se déduit des backends posés à côté du binaire : c'est ce qui
|
||||
// évite de proposer une image CUDA à une installation Vulkan (et l'inverse).
|
||||
func TestVarianteDeduiteDesBackends(t *testing.T) {
|
||||
for _, c := range []struct{ backend, veut string }{
|
||||
{"libggml-cuda.so", "server-cuda"},
|
||||
{"libggml-vulkan.so", "server-vulkan"},
|
||||
{"libggml-sycl.so", "server-intel"},
|
||||
{"libggml-musa.so", "server-musa"},
|
||||
{"", "server"},
|
||||
} {
|
||||
t.Run(c.veut, func(t *testing.T) {
|
||||
home := testHome(t)
|
||||
dir := filepath.Join(home, "faux-moteur")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bin := filepath.Join(dir, "llama-server")
|
||||
if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.backend != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, c.backend), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := SetConfigKey("BIN", bin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := engineVariant(); got != c.veut {
|
||||
t.Errorf("variante = %q, attendu %q", got, c.veut)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Le ménage garde la version demandée et efface les autres : sans lui, chaque
|
||||
// mise à jour laisse ~450 Mo sur le volume de données.
|
||||
func TestMenageDesAnciennesVersions(t *testing.T) {
|
||||
testHome(t)
|
||||
for _, tag := range []string{"server-cuda-b1", "server-cuda-b2", "server-cuda-b3"} {
|
||||
d := filepath.Join(engineDir(), tag)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
enginePrune(map[string]bool{"server-cuda-b3": true}, nil)
|
||||
got := engineInstalled()
|
||||
if fmt.Sprint(got) != "[server-cuda-b3]" {
|
||||
t.Errorf("après ménage : %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package loki
|
||||
|
||||
// backend_external.go — presets « externes » : au lieu de lancer un llama-server
|
||||
// local, un tel preset route le chat vers une API OpenAI-compatible distante
|
||||
// (OpenAI, Groq, OpenRouter, un autre Loki, un vLLM sur une autre machine…).
|
||||
// Un preset externe se reconnaît à sa clé EXTERNAL=1 ; il porte l'URL, le nom du
|
||||
// modèle et la clé d'accès.
|
||||
//
|
||||
// Choix d'archi, repris d'AJEAN : l'externe est un PRESET COMME UN AUTRE, un
|
||||
// fichier .env dans presetsDir. Toute la mécanique existante s'applique sans
|
||||
// rien changer — liste, nom d'affichage, empreinte d'activité, bascule, prompt
|
||||
// système par preset, réglages d'échantillonnage. La différence ne vit qu'à
|
||||
// DEUX endroits : au moment de l'inférence (resolveChatEndpoint) et au moment
|
||||
// de la bascule (aucun moteur à redémarrer).
|
||||
//
|
||||
// Ce qui ne s'applique pas, et c'est voulu : un preset externe n'a ni MODEL, ni
|
||||
// NGL, ni moteur. La machine distante décide de tout ça. CTX, en revanche,
|
||||
// reste utile — c'est lui qui pilote la jauge de contexte et le seuil de
|
||||
// compaction côté Loki.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||
)
|
||||
|
||||
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
|
||||
type chatEndpoint struct {
|
||||
URL string // URL complète des complétions
|
||||
Model string // nom de modèle envoyé dans le payload
|
||||
Key string // Bearer ("" = aucun)
|
||||
External bool // true = API distante (pas le llama-server local)
|
||||
}
|
||||
|
||||
// isExternalConfig indique si une configuration décrit un endpoint externe.
|
||||
func isExternalConfig(cfg map[string]string) bool {
|
||||
return strings.TrimSpace(cfg[extKeyFlag]) == "1"
|
||||
}
|
||||
|
||||
// externalActive : le preset actif est-il un endpoint externe ?
|
||||
func externalActive() bool { return isExternalConfig(ReadConfig()) }
|
||||
|
||||
// completionsURL normalise l'URL saisie en une URL de complétions complète.
|
||||
// Accepte les trois formes qu'on colle en pratique :
|
||||
//
|
||||
// - une URL déjà complète (…/chat/completions) — laissée telle quelle ;
|
||||
// - une base OpenAI (…/v1, …/openai/v1, …/api/v1) — on ajoute /chat/completions ;
|
||||
// - autre chose — on suppose une racine et on ajoute /v1/chat/completions.
|
||||
//
|
||||
// Exiger la forme exacte serait une source d'échecs muets : la page d'un
|
||||
// fournisseur donne tantôt « https://api.groq.com/openai/v1 », tantôt l'URL
|
||||
// complète, et se tromper ne produit qu'un 404 illisible.
|
||||
func completionsURL(raw string) string {
|
||||
u := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasSuffix(u, "/chat/completions") {
|
||||
return u
|
||||
}
|
||||
if strings.HasSuffix(u, "/v1") {
|
||||
return u + "/chat/completions"
|
||||
}
|
||||
return u + "/v1/chat/completions"
|
||||
}
|
||||
|
||||
// resolveChatEndpoint décide où envoyer les complétions : l'API externe si le
|
||||
// preset actif en est un, sinon le llama-server local.
|
||||
func resolveChatEndpoint() chatEndpoint {
|
||||
cfg := ReadConfig()
|
||||
if isExternalConfig(cfg) {
|
||||
return chatEndpoint{
|
||||
URL: completionsURL(cfg[extKeyURL]),
|
||||
Model: strings.TrimSpace(cfg[extKeyModel]),
|
||||
Key: strings.TrimSpace(cfg[extKeyToken]),
|
||||
External: true,
|
||||
}
|
||||
}
|
||||
return chatEndpoint{
|
||||
URL: fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()),
|
||||
Model: "loki",
|
||||
Key: readAPIKey(),
|
||||
}
|
||||
}
|
||||
|
||||
// auth pose l'en-tête d'autorisation de CET endpoint. Indispensable de le tenir
|
||||
// ici plutôt que d'appeler authHeader : celui-ci envoie la clé du serveur LOCAL,
|
||||
// et l'expédier à api.openai.com serait fuiter un secret chez un tiers en même
|
||||
// temps qu'un 401 garanti.
|
||||
func (e chatEndpoint) auth(set func(k, v string)) {
|
||||
if e.Key != "" {
|
||||
set("Authorization", "Bearer "+e.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
|
||||
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
|
||||
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
|
||||
// compaction, exactement comme pour un preset local.
|
||||
func externalPresetContent(url, model, key, ctx string) string {
|
||||
m := map[string]string{
|
||||
extKeyFlag: "1",
|
||||
extKeyURL: strings.TrimSpace(url),
|
||||
extKeyModel: strings.TrimSpace(model),
|
||||
}
|
||||
if k := strings.TrimSpace(key); k != "" {
|
||||
m[extKeyToken] = k
|
||||
}
|
||||
if c := strings.TrimSpace(ctx); c != "" {
|
||||
m["CTX"] = c
|
||||
}
|
||||
return formatEnv(m)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les trois formes d'URL qu'on colle en pratique. Exiger la forme exacte serait
|
||||
// une source d'échecs muets : la page d'un fournisseur donne tantôt la base,
|
||||
// tantôt l'URL complète, et se tromper ne produit qu'un 404 illisible.
|
||||
func TestNormalisationDeLURLDeComplétions(t *testing.T) {
|
||||
cas := map[string]string{
|
||||
"https://api.openai.com/v1": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.groq.com/openai/v1": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"https://api.openai.com/v1/chat/completions": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.openai.com/v1/": "https://api.openai.com/v1/chat/completions",
|
||||
"http://192.168.1.20:8080": "http://192.168.1.20:8080/v1/chat/completions",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cas {
|
||||
if got := completionsURL(in); got != want {
|
||||
t.Errorf("completionsURL(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset externe actif détourne les complétions ; sans lui, on reste sur le
|
||||
// llama-server local avec la clé LOCALE.
|
||||
func TestEndpointSuitLePresetActif(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := SetConfigKey("PORT", "9999"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep := resolveChatEndpoint()
|
||||
if ep.External {
|
||||
t.Fatal("aucun preset externe : l'endpoint doit rester local")
|
||||
}
|
||||
if ep.URL != "http://localhost:9999/v1/chat/completions" {
|
||||
t.Fatalf("URL locale = %q", ep.URL)
|
||||
}
|
||||
if ep.Model != "loki" {
|
||||
t.Fatalf("modèle local = %q, attendu loki", ep.Model)
|
||||
}
|
||||
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep = resolveChatEndpoint()
|
||||
if !ep.External {
|
||||
t.Fatal("EXTERNAL=1 non reconnu")
|
||||
}
|
||||
if ep.URL != "https://api.groq.com/openai/v1/chat/completions" {
|
||||
t.Fatalf("URL externe = %q", ep.URL)
|
||||
}
|
||||
if ep.Model != "llama-3.3-70b" || ep.Key != "sk-secret" {
|
||||
t.Fatalf("modèle/clé externes = %q / %q", ep.Model, ep.Key)
|
||||
}
|
||||
// CTX passe par la clé standard : c'est elle qui pilote la jauge de contexte
|
||||
// et le seuil de compaction, exactement comme pour un preset local.
|
||||
if got := ReadConfig()["CTX"]; got != "65536" {
|
||||
t.Fatalf("CTX = %q, attendu 65536", got)
|
||||
}
|
||||
}
|
||||
|
||||
// La clé du serveur LOCAL ne doit jamais partir chez un tiers : ce serait fuiter
|
||||
// un secret en même temps qu'un 401 garanti.
|
||||
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
|
||||
if err := SetConfigKey("API_KEY", "sk-loki-locale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep := resolveChatEndpoint()
|
||||
if ep.Key != "" {
|
||||
t.Fatalf("clé envoyée à l'API externe = %q — le preset n'en déclare aucune", ep.Key)
|
||||
}
|
||||
got := map[string]string{}
|
||||
ep.auth(func(k, v string) { got[k] = v })
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("en-tête posé sans clé de preset : %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset externe se reconnaît, un preset local ne se prend pas pour un externe.
|
||||
func TestReconnaissanceDunPresetExterne(t *testing.T) {
|
||||
if !isExternalConfig(map[string]string{extKeyFlag: "1"}) {
|
||||
t.Error("EXTERNAL=1 non reconnu")
|
||||
}
|
||||
for _, v := range []string{"", "0", "off", "false"} {
|
||||
if isExternalConfig(map[string]string{extKeyFlag: v}) {
|
||||
t.Errorf("EXTERNAL=%q pris pour un preset externe", v)
|
||||
}
|
||||
}
|
||||
if isExternalConfig(parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n")) {
|
||||
t.Error("un preset local est pris pour un externe")
|
||||
}
|
||||
}
|
||||
|
||||
// La route de test rend un message LISIBLE plutôt que le JSON brut : c'est tout
|
||||
// l'intérêt de tester avant d'enregistrer.
|
||||
func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer sk-bonne" {
|
||||
w.WriteHeader(401)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
post := func(body string) map[string]any {
|
||||
w := httptest.NewRecorder()
|
||||
handlePresetExternalTest(w, httptest.NewRequest("POST", "/api/preset/external/test", strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return out
|
||||
}
|
||||
out := post(`{"url":"` + srv.URL + `","model":"m","key":"sk-mauvaise","keyTouched":true}`)
|
||||
if out["ok"] == true {
|
||||
t.Fatal("clé refusée annoncée comme valide")
|
||||
}
|
||||
if msg, _ := out["error"].(string); msg != "Incorrect API key provided" {
|
||||
t.Fatalf("message d'erreur = %q, attendu celui de l'API et non le JSON brut", msg)
|
||||
}
|
||||
if out = post(`{"url":"` + srv.URL + `","model":"m","key":"sk-bonne","keyTouched":true}`); out["ok"] != true {
|
||||
t.Fatalf("clé valide refusée : %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Rééditer un preset sans toucher au champ clé ne doit PAS l'effacer : la modale
|
||||
// ne la reçoit jamais en clair, donc un champ vide n'est pas une demande
|
||||
// d'effacement.
|
||||
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
|
||||
strings.NewReader(`{"id":"`+id+`","name":"Groq","url":"https://api.groq.com/openai/v1","model":"llama-3.3","key":""}`)))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("code %d : %s", w.Code, w.Body.String())
|
||||
}
|
||||
content, err := ReadPreset(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := parseEnv(content)
|
||||
if cfg[extKeyToken] != "sk-gardee" {
|
||||
t.Fatalf("clé = %q — effacée par une édition qui n'y touchait pas", cfg[extKeyToken])
|
||||
}
|
||||
if cfg[extKeyModel] != "llama-3.3" {
|
||||
t.Fatalf("modèle non mis à jour : %q", cfg[extKeyModel])
|
||||
}
|
||||
// Et la clé ne ressort jamais en clair par la route de lecture.
|
||||
w = httptest.NewRecorder()
|
||||
handlePresetExternal(w, httptest.NewRequest("GET", "/api/preset/external?id="+id, nil))
|
||||
if strings.Contains(w.Body.String(), "sk-gardee") {
|
||||
t.Fatalf("la clé est renvoyée en clair : %s", w.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
if out["hasKey"] != true {
|
||||
t.Fatal("hasKey doit signaler la présence d'une clé")
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset externe demande explicitement d'écraser la clé par une vide.
|
||||
func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
|
||||
strings.NewReader(`{"id":"`+id+`","name":"Local distant","url":"http://192.168.1.20:8080","model":"qwen","key":"","keyTouched":true}`)))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("code %d : %s", w.Code, w.Body.String())
|
||||
}
|
||||
content, _ := ReadPreset(id)
|
||||
if k := parseEnv(content)[extKeyToken]; k != "" {
|
||||
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
|
||||
}
|
||||
}
|
||||
+183
-3
@@ -67,6 +67,11 @@ type hfRepo struct {
|
||||
ID string `json:"id"`
|
||||
Downloads int `json:"downloads"`
|
||||
Likes int `json:"likes"`
|
||||
// Gated : le dépôt exige d'avoir accepté ses conditions ET un jeton. Il se
|
||||
// LIT pourtant sans rien (arborescence en 200), et ne refuse qu'au moment du
|
||||
// transfert : sans cette pastille, l'interface propose des quants avec leur
|
||||
// verdict mémoire et le téléchargement échoue en 401 sans prévenir.
|
||||
Gated bool `json:"gated"`
|
||||
}
|
||||
|
||||
// hfEntry — un .gguf installable. Shards > 1 signale une famille de tranches :
|
||||
@@ -85,6 +90,7 @@ type hfEntry struct {
|
||||
// hfListing — le contenu utile d'un dépôt, trié.
|
||||
type hfListing struct {
|
||||
Repo string `json:"repo"`
|
||||
Gated bool `json:"gated"`
|
||||
Models []hfEntry `json:"models"`
|
||||
Projectors []hfEntry `json:"projectors"`
|
||||
Drafts []hfEntry `json:"drafts"`
|
||||
@@ -95,13 +101,115 @@ type hfListing struct {
|
||||
// transfert : sans lui un dépôt gated répond 401 et Loki afficherait « aucun
|
||||
// résultat » là où il faudrait dire « dépôt à accès restreint ».
|
||||
func hfAuth(req *http.Request) {
|
||||
if k := os.Getenv("HF_TOKEN"); k != "" {
|
||||
if k := hfToken(); k != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+k)
|
||||
}
|
||||
req.Header.Set("User-Agent", "loki/"+Version)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
// ---- Jeton Hugging Face -----------------------------------------------------
|
||||
//
|
||||
// Le jeton vivait UNIQUEMENT dans la variable d'environnement HF_TOKEN. En
|
||||
// conteneur, c'était l'exiger au mauvais moment : on découvre qu'un dépôt est
|
||||
// verrouillé depuis l'interface, et il faudrait éditer un docker-compose puis
|
||||
// recréer le conteneur pour y répondre. Il se règle donc aussi dans l'interface,
|
||||
// et se range là où sont déjà les secrets (base d'état) — pas dans config.env,
|
||||
// que le changement de preset réécrit en bloc.
|
||||
//
|
||||
// Priorité : le jeton enregistré d'abord, la variable d'environnement ensuite.
|
||||
// Rien d'enregistré = comportement d'avant, à l'octet près.
|
||||
|
||||
// hfToken renvoie le jeton effectivement utilisé, ou "".
|
||||
func hfToken() string {
|
||||
if t := strings.TrimSpace(getStr(bkState, "hf_token")); t != "" {
|
||||
return t
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("HF_TOKEN"))
|
||||
}
|
||||
|
||||
// hfTokenSource dit D'OÙ vient le jeton : « config » (enregistré ici), « env »
|
||||
// (HF_TOKEN du conteneur) ou "" (aucun). L'interface en a besoin pour ne pas
|
||||
// proposer d'effacer un jeton qu'elle ne peut pas effacer.
|
||||
func hfTokenSource() string {
|
||||
switch {
|
||||
case strings.TrimSpace(getStr(bkState, "hf_token")) != "":
|
||||
return "config"
|
||||
case strings.TrimSpace(os.Getenv("HF_TOKEN")) != "":
|
||||
return "env"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hfTokenSet dit si un jeton est disponible, pour que l'interface distingue
|
||||
// « il faut en poser un » de « celui qui est posé ne suffit pas ».
|
||||
func hfTokenSet() bool { return hfToken() != "" }
|
||||
|
||||
// writeHFToken enregistre (ou efface, tok == "") le jeton. Le cache des réponses
|
||||
// Hugging Face est vidé au passage : il contient des listes obtenues SANS jeton,
|
||||
// où un dépôt verrouillé peut manquer ou paraître illisible.
|
||||
func writeHFToken(tok string) error {
|
||||
if err := putStr(bkState, "hf_token", strings.TrimSpace(tok)); err != nil {
|
||||
return err
|
||||
}
|
||||
hfCacheMu.Lock()
|
||||
hfCache = map[string]hfCacheItem{}
|
||||
hfCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskHFToken n'affiche qu'assez de caractères pour reconnaître le jeton posé.
|
||||
// Il n'est jamais renvoyé en clair : contrairement à la clé API du serveur,
|
||||
// personne n'a besoin de le recopier depuis Loki.
|
||||
func maskHFToken(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
if len(t) <= 8 {
|
||||
return "…" + t[len(t)-2:]
|
||||
}
|
||||
return t[:3] + "…" + t[len(t)-4:]
|
||||
}
|
||||
|
||||
// hfWhoAmI vérifie un jeton auprès de Hugging Face et renvoie le nom du compte.
|
||||
// Enregistrer un jeton sans le vérifier, c'est déplacer l'échec : on croirait le
|
||||
// problème réglé jusqu'au prochain 401, quinze minutes de téléchargement plus
|
||||
// tard.
|
||||
func hfWhoAmI(ctx context.Context, tok string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, hfTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", hfHost+"/api/whoami-v2", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(tok))
|
||||
req.Header.Set("User-Agent", "loki/"+Version)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Hugging Face injoignable : %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return "", fmt.Errorf("jeton refusé par Hugging Face (expiré, révoqué ou mal copié)")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("Hugging Face a répondu %d", resp.StatusCode)
|
||||
}
|
||||
var who struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &who); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return who.Name, nil
|
||||
}
|
||||
|
||||
// hfCache : mémo commun à la recherche et aux arborescences, clé = l'URL
|
||||
// appelée. Sans lui, chaque frappe au clavier dans le champ de recherche
|
||||
// produit une requête sortante.
|
||||
@@ -172,29 +280,99 @@ func hfSearch(ctx context.Context, q string) ([]hfRepo, error) {
|
||||
if len(q) > hfMaxQuery {
|
||||
q = q[:hfMaxQuery]
|
||||
}
|
||||
// `expand[]` remplace les champs par défaut de la réponse : `gated` s'y
|
||||
// ajoute, mais downloads et likes doivent alors être redemandés
|
||||
// explicitement, sinon ils disparaissent et la liste perd son classement
|
||||
// lisible.
|
||||
endpoint := hfHost + "/api/models?" + url.Values{
|
||||
"search": {q},
|
||||
"filter": {"gguf"},
|
||||
"limit": {fmt.Sprint(hfMaxRepos)},
|
||||
"sort": {"downloads"},
|
||||
"direction": {"-1"},
|
||||
"expand[]": {"gated", "downloads", "likes"},
|
||||
}.Encode()
|
||||
|
||||
var raw []struct {
|
||||
ID string `json:"id"`
|
||||
Downloads int `json:"downloads"`
|
||||
Likes int `json:"likes"`
|
||||
Gated any `json:"gated"`
|
||||
}
|
||||
if err := hfGetJSON(ctx, endpoint, hfSearchTTL, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]hfRepo, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes})
|
||||
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes, Gated: hfGatedFlag(r.Gated)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hfGatedFlag lit le champ `gated` de l'API, qui n'est PAS un booléen : il vaut
|
||||
// false, "auto" (accepter les conditions suffit) ou "manual" (l'auteur valide
|
||||
// chaque demande). Les deux dernières valeurs verrouillent le téléchargement de
|
||||
// la même façon ; seule la présence d'un verrou nous intéresse ici.
|
||||
func hfGatedFlag(v any) bool {
|
||||
switch g := v.(type) {
|
||||
case bool:
|
||||
return g
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(g)) {
|
||||
case "", "false", "none", "no":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hfRepoGated dit si un dépôt est verrouillé. L'arborescence (hfFiles) ne porte
|
||||
// pas l'information : elle se lit sur la fiche du dépôt, en un appel séparé et
|
||||
// mis en cache comme elle. Une erreur ici ne doit RIEN casser — au pire on
|
||||
// n'affiche pas l'avertissement, et le téléchargement dira lui-même pourquoi il
|
||||
// a été refusé.
|
||||
func hfRepoGated(ctx context.Context, repo string) bool {
|
||||
var info struct {
|
||||
Gated any `json:"gated"`
|
||||
}
|
||||
endpoint := hfHost + "/api/models/" + repo + "?" + url.Values{"expand[]": {"gated"}}.Encode()
|
||||
if err := hfGetJSON(ctx, endpoint, hfFilesTTL, &info); err != nil {
|
||||
return false
|
||||
}
|
||||
return hfGatedFlag(info.Gated)
|
||||
}
|
||||
|
||||
// isHFHost dit si un hôte appartient à Hugging Face. Sert à décider qui a le
|
||||
// droit de recevoir le jeton.
|
||||
func isHFHost(host string) bool {
|
||||
h := strings.ToLower(host)
|
||||
if i := strings.IndexByte(h, ':'); i >= 0 {
|
||||
h = h[:i]
|
||||
}
|
||||
return h == "huggingface.co" || strings.HasSuffix(h, ".huggingface.co") ||
|
||||
h == "hf.co" || strings.HasSuffix(h, ".hf.co")
|
||||
}
|
||||
|
||||
// hfRepoFromURL retrouve « auteur/dépôt » dans un lien de téléchargement
|
||||
// Hugging Face, pour pouvoir le nommer dans un message d'erreur. Renvoie "" si
|
||||
// le lien pointe ailleurs.
|
||||
func hfRepoFromURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !strings.Contains(u.Host, "huggingface.co") {
|
||||
return ""
|
||||
}
|
||||
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(segs) < 4 || (segs[2] != "resolve" && segs[2] != "blob") {
|
||||
return ""
|
||||
}
|
||||
repo := segs[0] + "/" + segs[1]
|
||||
if !hfRepoRe.MatchString(repo) {
|
||||
return ""
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
// hfFiles liste les .gguf d'un dépôt et les range par famille.
|
||||
//
|
||||
// `recursive=1` n'est pas un confort : les quants volumineux vivent dans des
|
||||
@@ -229,7 +407,9 @@ func hfFiles(ctx context.Context, repo string) (hfListing, error) {
|
||||
}
|
||||
files = append(files, hfFile{Path: f.Path, Size: n})
|
||||
}
|
||||
return hfClassify(repo, files), nil
|
||||
out := hfClassify(repo, files)
|
||||
out.Gated = hfRepoGated(ctx, repo)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hfFile — une entrée d'arborescence, réduite à ce dont le classement a besoin.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -205,3 +209,151 @@ func TestFitVerdict(t *testing.T) {
|
||||
t.Errorf("le contexte ne change rien au verdict (%q dans les deux cas)", court)
|
||||
}
|
||||
}
|
||||
|
||||
// Le champ `gated` de l'API n'est pas un booléen : false, "auto" ou "manual".
|
||||
// Le lire comme un bool laissait passer les deux valeurs qui verrouillent
|
||||
// vraiment le téléchargement.
|
||||
func TestHFGatedFlag(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in any
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{false, false},
|
||||
{true, true},
|
||||
{"auto", true},
|
||||
{"manual", true},
|
||||
{"false", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := hfGatedFlag(c.in); got != c.want {
|
||||
t.Errorf("hfGatedFlag(%#v) = %v, attendu %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hfRepoFromURL sert à NOMMER le dépôt dans le message d'erreur : un lien qui
|
||||
// ne vient pas de Hugging Face ne doit rien produire plutôt qu'un nom inventé.
|
||||
func TestHFRepoFromURL(t *testing.T) {
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf", "orcarouter/Qwen3.8-27B-Uncensored-GGUF"},
|
||||
{"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF/blob/main/sub/dir/m.gguf", "ggml-org/Qwen3.8-27B-GGUF"},
|
||||
{"https://example.com/ggml-org/Qwen3.8-27B-GGUF/resolve/main/m.gguf", ""},
|
||||
{"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF", ""},
|
||||
{"pas une url", ""},
|
||||
} {
|
||||
if got := hfRepoFromURL(c.in); got != c.want {
|
||||
t.Errorf("hfRepoFromURL(%q) = %q, attendu %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le jeton enregistré dans l'interface prime sur HF_TOKEN, et son absence
|
||||
// rend exactement le comportement d'avant : la variable d'environnement.
|
||||
func TestHFTokenPrecedence(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv("HF_TOKEN", "")
|
||||
if hfToken() != "" || hfTokenSource() != "" || hfTokenSet() {
|
||||
t.Fatalf("sans rien : jeton=%q source=%q", hfToken(), hfTokenSource())
|
||||
}
|
||||
t.Setenv("HF_TOKEN", "hf_env")
|
||||
if hfToken() != "hf_env" || hfTokenSource() != "env" {
|
||||
t.Fatalf("HF_TOKEN seul : jeton=%q source=%q", hfToken(), hfTokenSource())
|
||||
}
|
||||
if err := writeHFToken(" hf_enregistre "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hfToken() != "hf_enregistre" || hfTokenSource() != "config" {
|
||||
t.Fatalf("jeton enregistré : jeton=%q source=%q", hfToken(), hfTokenSource())
|
||||
}
|
||||
// Retiré ici, la variable du conteneur reprend la main plutôt que de laisser
|
||||
// Loki sans jeton alors que l'environnement en fournit un.
|
||||
if err := writeHFToken(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hfToken() != "hf_env" || hfTokenSource() != "env" {
|
||||
t.Fatalf("après retrait : jeton=%q source=%q", hfToken(), hfTokenSource())
|
||||
}
|
||||
}
|
||||
|
||||
// Le jeton ne doit jamais ressortir en clair de l'interface.
|
||||
func TestMaskHFToken(t *testing.T) {
|
||||
if got := maskHFToken("hf_abcdefghijklmnop"); got != "hf_…mnop" {
|
||||
t.Errorf("masque = %q", got)
|
||||
}
|
||||
if got := maskHFToken(""); got != "" {
|
||||
t.Errorf("masque d'un jeton absent = %q", got)
|
||||
}
|
||||
if strings.Contains(maskHFToken("hf_abcdefghijklmnop"), "efghij") {
|
||||
t.Error("le masque laisse voir le milieu du jeton")
|
||||
}
|
||||
}
|
||||
|
||||
// Un secret ne part QUE vers Hugging Face : un lien collé vers un autre
|
||||
// hébergeur ne doit pas recevoir le jeton du compte.
|
||||
func TestDLRequestTokenOnlyToHuggingFace(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv("HF_TOKEN", "hf_secret")
|
||||
for _, c := range []struct {
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"https://huggingface.co/a/b/resolve/main/m.gguf", true},
|
||||
{"https://cdn-lfs.huggingface.co/a/b/m.gguf", true},
|
||||
{"https://hf.co/a/b/resolve/main/m.gguf", true},
|
||||
{"https://example.com/m.gguf", false},
|
||||
{"https://huggingface.co.evil.example/m.gguf", false},
|
||||
} {
|
||||
req, err := dlRequest(context.Background(), c.url, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := req.Header.Get("Authorization") != ""; got != c.want {
|
||||
t.Errorf("%s : en-tête Authorization présent=%v, attendu %v", c.url, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La route décrit le jeton sans jamais le rendre, et l'efface sans appeler
|
||||
// Hugging Face (un jeton vide n'a rien à vérifier).
|
||||
func TestHandleHFTokenGetAndClear(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv("HF_TOKEN", "")
|
||||
if err := writeHFToken("hf_abcdefghijklmnop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
call := func(method, body string) map[string]any {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(method, "http://placeholder/api/hf/token", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
handleHFToken(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s → HTTP %d : %s", method, w.Code, w.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
got := call("GET", "")
|
||||
if got["set"] != true || got["source"] != "config" {
|
||||
t.Fatalf("état lu = %v", got)
|
||||
}
|
||||
if s, _ := got["masked"].(string); s == "" || strings.Contains(s, "efghij") {
|
||||
t.Fatalf("masque inattendu : %q", s)
|
||||
}
|
||||
if strings.Contains(fmt.Sprint(got), "hf_abcdefghijklmnop") {
|
||||
t.Fatalf("le jeton ressort en clair : %v", got)
|
||||
}
|
||||
|
||||
got = call("POST", `{"token":""}`)
|
||||
if got["set"] != false || got["source"] != "" {
|
||||
t.Fatalf("après effacement = %v", got)
|
||||
}
|
||||
if hfToken() != "" {
|
||||
t.Fatalf("jeton toujours présent : %q", hfToken())
|
||||
}
|
||||
}
|
||||
@@ -264,10 +264,12 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) {
|
||||
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())}}
|
||||
out := []map[string]any{{"path": modelsDir(), "home": true, "count": modelDirCount(modelsDir()), "exists": true,
|
||||
"free": diskFree(modelsDir()), "free_exact": diskFreeReliable(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)})
|
||||
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0,
|
||||
"free": diskFree(d), "free_exact": diskFreeReliable(d)})
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("LOKI_MODEL_DIRS") != ""})
|
||||
}
|
||||
+244
-15
@@ -70,6 +70,79 @@ func reasoningActive(v string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
|
||||
// "" (auto) n'y figure pas : c'est l'absence de réglage.
|
||||
//
|
||||
// Aucun gabarit ne les connaît toutes — gpt-oss lit low/medium/high, Qwen3.8 lit
|
||||
// low/medium/xhigh — et certains REFUSENT celles qu'ils ne connaissent pas (voir
|
||||
// llm_effort.go, qui traduit alors vers le niveau accepté le plus proche). La
|
||||
// liste reste donc l'union des niveaux courants plutôt que le plus petit
|
||||
// dénominateur commun, sinon le maximum d'un modèle est hors d'atteinte.
|
||||
var ReasoningEfforts = []string{"none", "low", "medium", "high", "xhigh"}
|
||||
|
||||
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
|
||||
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
|
||||
//
|
||||
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
|
||||
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
|
||||
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur ;
|
||||
// un gabarit qui la VALIDE, lui, peut lever une exception jinja et faire
|
||||
// répondre 500 à llama-server (Qwen3.8 sur « high »). Le repli n'est pas ici —
|
||||
// on ne sait pas d'avance ce que le gabarit accepte — mais dans llm_effort.go,
|
||||
// qui apprend la réponse du refus lui-même et rejoue le tour.
|
||||
func reasoningEffortValue(v string) string {
|
||||
switch s := strings.ToLower(strings.TrimSpace(v)); s {
|
||||
case "", "auto", "default":
|
||||
return "" // rien envoyé : le gabarit garde son comportement
|
||||
case "off", "false", "0", "no", "disable", "disabled", "none":
|
||||
return "none"
|
||||
case "low", "medium", "high":
|
||||
return s
|
||||
default:
|
||||
// Valeur inconnue (éditée à la main) : on ne l'invente pas, on la laisse
|
||||
// passer au gabarit, qui l'ignorera s'il ne la connaît pas.
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// reasoningTemplateKwargs construit les `chat_template_kwargs` à joindre à une
|
||||
// requête de chat pour que « aucune » coupe VRAIMENT le raisonnement.
|
||||
//
|
||||
// Pourquoi ce doublon avec `reasoning_effort` : ce champ n'agit que sur les
|
||||
// gabarits qui le LISENT (gpt-oss et apparentés). Un modèle hybride à la Qwen3
|
||||
// ne connaît que `enable_thinking` — on lui envoie `reasoning_effort: none`, son
|
||||
// gabarit l'ignore, et il continue de réfléchir alors que l'interface affiche
|
||||
// « aucune ». Les llama-server récents traduisent eux-mêmes `none` en
|
||||
// `enable_thinking=false`, mais pas les binaires plus anciens, qui laissent
|
||||
// simplement tomber le champ.
|
||||
//
|
||||
// D'où la règle : quand le raisonnement doit être coupé, on le dit dans la
|
||||
// langue que TOUS les gabarits comprennent. `chat_template_kwargs` est passé tel
|
||||
// quel au gabarit jinja ; une clé qu'il n'utilise pas est ignorée sans erreur.
|
||||
// Même procédé que la compaction (chat_compact.go), qui coupe déjà la réflexion
|
||||
// de cette façon.
|
||||
//
|
||||
// off = REASONING explicitement à off. Clé absente ≠ off : sans consigne on ne
|
||||
// touche à rien, le gabarit garde son défaut.
|
||||
// Renvoie nil quand il n'y a rien à imposer.
|
||||
func reasoningTemplateKwargs(off bool, effort string) map[string]any {
|
||||
if off || effort == "none" {
|
||||
return map[string]any{"enable_thinking": false}
|
||||
}
|
||||
if effort != "" {
|
||||
// Niveau explicite : les vieux binaires ne relaient pas le champ de haut
|
||||
// niveau au gabarit, celui-ci passe toujours.
|
||||
return map[string]any{"reasoning_effort": effort}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reasoningExplicitlyOff dit si REASONING interdit le raisonnement. Une clé
|
||||
// absente ou vide n'est PAS une interdiction : c'est l'absence de consigne.
|
||||
func reasoningExplicitlyOff(v string) bool {
|
||||
return strings.TrimSpace(v) != "" && !reasoningActive(v)
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -128,38 +201,70 @@ func modelFromPresetContent(content string) string {
|
||||
// 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 {
|
||||
//
|
||||
// Renvoie le nombre d'octets réellement libérés — l'interface le dit, et
|
||||
// « 41,2 Go libérés » est la seule confirmation qui vaille quelque chose après
|
||||
// un ménage de disque.
|
||||
//
|
||||
// Un modèle EN SERVICE n'est jamais effacé : le fichier reste mappé par
|
||||
// llama-server (la place n'est donc même pas rendue) et le prochain démarrage
|
||||
// meurt sur un .gguf absent, sans que rien n'ait prévenu.
|
||||
func deleteModelFile(name string) (int64, error) {
|
||||
p, err := resolveModelPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
if _, active := modelUsers(name); active {
|
||||
return 0, fmt.Errorf("%s", modelInServiceMsg)
|
||||
}
|
||||
freed := modelFamilySize(p)
|
||||
if err := os.Remove(p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
|
||||
return 0, fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
|
||||
}
|
||||
return err
|
||||
return 0, 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
|
||||
return freed, nil
|
||||
}
|
||||
|
||||
// handleModelDelete deletes a single .gguf from LOKI_HOME.
|
||||
// handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés.
|
||||
//
|
||||
// Deux garde-fous, et un seul est un refus définitif :
|
||||
//
|
||||
// - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ;
|
||||
// - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets
|
||||
// concernés. L'interface la montre, l'utilisateur tranche, et renvoie la
|
||||
// même requête avec force:true. Refuser tout court transformerait le
|
||||
// nettoyage en impasse — c'est exactement le travers qu'on corrige ici.
|
||||
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
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 {
|
||||
users, active := modelUsers(req.Name)
|
||||
if active {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": modelInServiceMsg, "active": true})
|
||||
return
|
||||
}
|
||||
if len(users) > 0 && !req.Force {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "used": users,
|
||||
"error": "modèle utilisé par : " + strings.Join(users, ", ")})
|
||||
return
|
||||
}
|
||||
freed, err := deleteModelFile(req.Name)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users})
|
||||
}
|
||||
|
||||
// ---- Hugging Face downloads -------------------------------------------------
|
||||
@@ -309,7 +414,16 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if len(todoURLs) == 0 {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
|
||||
// Rien à faire : toutes les tranches sont là. Ce n'était pas une erreur,
|
||||
// c'était le résultat voulu — le modèle est installé. On répond ok avec la
|
||||
// valeur à écrire dans MODEL=, l'interface le sélectionne et enchaîne la
|
||||
// file (le projecteur vision, par exemple) au lieu de s'arrêter en rouge.
|
||||
value, _, _ := modelPresence(name, req.Dir)
|
||||
if value == "" {
|
||||
value = modelPickerValue(filepath.Dir(dests[0]), name)
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "exists": true,
|
||||
"value": value, "parts": 0})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -337,6 +451,13 @@ func checkDiskSpace(dir string, size int64) error {
|
||||
if size <= 0 || free < 0 {
|
||||
return nil
|
||||
}
|
||||
// Système de fichiers dont statfs approxime (FUSE/shfs d'Unraid, overlay,
|
||||
// réseau) : le chiffre reste affiché, mais il ne peut plus REFUSER un
|
||||
// téléchargement. Vécu : « 11,9 Go libres » annoncés sur un partage Unraid
|
||||
// qui en avait des centaines, et l'installation du modèle bloquée.
|
||||
if !diskFreeReliable(dir) {
|
||||
return nil
|
||||
}
|
||||
if free < size+dlSpaceMargin {
|
||||
return fmt.Errorf("espace insuffisant sur %s : %s libres, %s nécessaires", dir, humanBytes(free), humanBytes(size+dlSpaceMargin))
|
||||
}
|
||||
@@ -382,6 +503,21 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
|
||||
// puis échouer au deux tiers du transfert.
|
||||
urls, names := shardURLSet(dlURL, name)
|
||||
name = names[0]
|
||||
// Déjà sur le disque : on ne sonde même pas Hugging Face. C'est le cas qui
|
||||
// mettait l'interface dans une impasse — un modèle gardé après la suppression
|
||||
// de son preset ne pouvait plus être re-choisi qu'en le retéléchargeant, et
|
||||
// le téléchargement refusait d'écraser. Il n'y a rien à télécharger : il y a
|
||||
// un modèle à SÉLECTIONNER, et l'UI a besoin de sa valeur MODEL= pour ça.
|
||||
// Cherché dans TOUS les dossiers déclarés, pas seulement la destination
|
||||
// choisie : re-télécharger 40 Go sur le SSD parce que le fichier est sur le
|
||||
// disque externe déjà déclaré n'aide personne — le sélecteur, lui, le trouve
|
||||
// où qu'il soit.
|
||||
if value, path, ok := modelPresence(name, ""); ok {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "dir": filepath.Dir(path),
|
||||
"exists": true, "value": value, "size": modelFamilySize(path),
|
||||
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
var total int64
|
||||
@@ -393,7 +529,8 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
total += n
|
||||
}
|
||||
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true, "parts": len(urls)}
|
||||
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total,
|
||||
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)}
|
||||
if err := checkDiskSpace(dir, total); err != nil {
|
||||
out["enough"] = false
|
||||
out["error"] = err.Error()
|
||||
@@ -408,8 +545,12 @@ func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
|
||||
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 != "" {
|
||||
// Dépôt gated/privé : le jeton (réglage de l'interface, sinon HF_TOKEN) est
|
||||
// ce qui débloque le transfert. Il n'accompagne QUE les liens Hugging Face :
|
||||
// un lien collé vers un autre hébergeur n'a aucune raison de recevoir un
|
||||
// secret, et le CDN vers lequel HF redirige est signé, donc n'en a pas besoin
|
||||
// non plus (Go retire l'en-tête au changement de domaine).
|
||||
if k := hfToken(); k != "" && isHFHost(req.URL.Host) {
|
||||
req.Header.Set("Authorization", "Bearer "+k)
|
||||
}
|
||||
req.Header.Set("User-Agent", "loki/"+Version)
|
||||
@@ -433,6 +574,93 @@ func contentRangeTotal(v string) int64 {
|
||||
return n
|
||||
}
|
||||
|
||||
// dlSourceError traduit un refus HTTP en phrase qui dit quoi faire.
|
||||
//
|
||||
// Vécu : un dépôt Hugging Face « gated » (conditions à accepter avant de
|
||||
// télécharger) répond 200 sur son arborescence — Loki liste donc tous ses
|
||||
// quants, avec leur verdict mémoire — puis 401 sur CHAQUE .gguf. « HTTP 401
|
||||
// depuis la source » ne dit alors ni que le dépôt est verrouillé, ni qu'il faut
|
||||
// un jeton, ni où l'accepter : l'utilisateur voit un modèle proposé comme
|
||||
// installable qui échoue sans raison.
|
||||
//
|
||||
// Hugging Face, lui, le dit — dans l'en-tête X-Error-Code (GatedRepo,
|
||||
// RepoNotFound, EntryNotFound…). On le traduit plutôt que de le recopier :
|
||||
// l'interface est en français, et le message d'origine (« Please log in »)
|
||||
// parle d'une session de navigateur qui n'existe pas ici.
|
||||
func dlSourceError(resp *http.Response, dlURL string) error {
|
||||
code := resp.Header.Get("X-Error-Code")
|
||||
switch resp.StatusCode {
|
||||
case 401, 403:
|
||||
return fmt.Errorf("%s%s", dlAccessReason(code, hfRepoFromURL(dlURL)), dlTokenHint())
|
||||
case 404:
|
||||
switch code {
|
||||
case "EntryNotFound":
|
||||
// Nommer le fichier : sur un modèle en fragments, c'est souvent UN
|
||||
// fragment qui manque — certains dépôts n'en publient que six sur
|
||||
// sept et livrent la table PLE à part, à lier soi-même — et sans
|
||||
// le nom, on cherche une révision réécrite qui n'existe pas.
|
||||
return fmt.Errorf("fichier %s absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", dlFileLabel(dlURL))
|
||||
case "RevisionNotFound":
|
||||
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
|
||||
}
|
||||
return fmt.Errorf("lien introuvable (HTTP 404)")
|
||||
case 416:
|
||||
return fmt.Errorf("la source refuse la plage d'octets demandée (HTTP 416) — fichier modifié pendant le transfert ?")
|
||||
case 429:
|
||||
return fmt.Errorf("trop de requêtes vers la source (HTTP 429) — réessaie dans quelques minutes")
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
return fmt.Errorf("la source est en panne (HTTP %d) — réessaie plus tard", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
|
||||
}
|
||||
|
||||
// dlFileLabel : « nom.gguf » entre guillemets pour un message, ou vide si le
|
||||
// lien n'a pas de nom de fichier lisible.
|
||||
func dlFileLabel(dlURL string) string {
|
||||
u, err := url.Parse(dlURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := path.Base(u.Path)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
return ""
|
||||
}
|
||||
return "« " + name + " »"
|
||||
}
|
||||
|
||||
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
|
||||
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
|
||||
// accepter, qui n'existent que là-bas.
|
||||
func dlAccessReason(code, repo string) string {
|
||||
switch code {
|
||||
case "GatedRepo":
|
||||
if repo == "" {
|
||||
return "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co"
|
||||
}
|
||||
return "dépôt à accès restreint : accepte ses conditions sur huggingface.co/" + repo
|
||||
case "RepoNotFound":
|
||||
if repo == "" {
|
||||
return "dépôt privé ou inexistant"
|
||||
}
|
||||
return "dépôt privé ou inexistant : " + repo
|
||||
}
|
||||
if repo != "" {
|
||||
return "accès refusé par Hugging Face sur " + repo
|
||||
}
|
||||
return "accès refusé par la source"
|
||||
}
|
||||
|
||||
// dlTokenHint complète la raison par l'état du jeton. Deux situations opposées
|
||||
// se cachent derrière le même 401 : pas de jeton du tout, ou un jeton qui n'a
|
||||
// pas accès à CE dépôt — et le geste à faire n'est pas le même.
|
||||
func dlTokenHint() string {
|
||||
if !hfTokenSet() {
|
||||
return " — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
|
||||
}
|
||||
return " — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -456,7 +684,7 @@ func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err e
|
||||
// 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)
|
||||
return 0, false, dlSourceError(resp, dlURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,8 +910,9 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||
err := dlSourceError(resp, dlURL)
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == 200 && pos > start {
|
||||
// Resume refused: the body restarts from 0, rewind our bookkeeping.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -135,3 +136,98 @@ func TestRunDownloadParallelAndFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un dépôt Hugging Face verrouillé répond 401 sur le .gguf alors que son
|
||||
// arborescence se lit sans jeton. « HTTP 401 depuis la source » n'apprenait
|
||||
// rien : le message doit nommer le verrou, le dépôt, et le geste à faire.
|
||||
func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
|
||||
const gated = "https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf"
|
||||
resp := func(status int, code string) *http.Response {
|
||||
r := &http.Response{StatusCode: status, Header: http.Header{}}
|
||||
if code != "" {
|
||||
r.Header.Set("X-Error-Code", code)
|
||||
}
|
||||
return r
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
token string
|
||||
resp *http.Response
|
||||
url string
|
||||
want []string
|
||||
absent []string
|
||||
}{
|
||||
{
|
||||
name: "gated sans jeton", resp: resp(401, "GatedRepo"), url: gated,
|
||||
want: []string{"accès restreint", "orcarouter/Qwen3.8-27B-Uncensored-GGUF", "HF_TOKEN"},
|
||||
},
|
||||
{
|
||||
name: "gated avec jeton refusé", token: "hf_xxx", resp: resp(401, "GatedRepo"), url: gated,
|
||||
want: []string{"accès restreint", "n'y donne pas accès"},
|
||||
absent: []string{"renseigne le jeton"},
|
||||
},
|
||||
{
|
||||
name: "dépôt privé ou absent", resp: resp(401, "RepoNotFound"), url: gated,
|
||||
want: []string{"privé ou inexistant", "HF_TOKEN"},
|
||||
},
|
||||
{
|
||||
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
|
||||
want: []string{"« m.gguf » absent du dépôt", "404"},
|
||||
},
|
||||
{
|
||||
// Dépôt qui publie six fragments sur sept (table PLE livrée à part) :
|
||||
// le message doit NOMMER le fragment manquant, sinon on cherche une
|
||||
// révision réécrite qui n'existe pas.
|
||||
name: "fragment absent", resp: resp(404, "EntryNotFound"),
|
||||
url: "https://huggingface.co/x/y-GGUF/resolve/main/Q2_K/y-Q2_K-00007-of-00007.gguf",
|
||||
want: []string{"« y-Q2_K-00007-of-00007.gguf » absent du dépôt", "fragment jamais publié"},
|
||||
},
|
||||
{
|
||||
name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
|
||||
want: []string{"accès refusé par la source"},
|
||||
absent: []string{"huggingface.co"},
|
||||
},
|
||||
{
|
||||
name: "panne de la source", resp: resp(503, ""), url: gated,
|
||||
want: []string{"en panne", "503"},
|
||||
},
|
||||
{
|
||||
name: "code inconnu", resp: resp(418, ""), url: gated,
|
||||
want: []string{"HTTP 418 depuis la source"},
|
||||
},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Setenv("HF_TOKEN", c.token)
|
||||
got := dlSourceError(c.resp, c.url).Error()
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("message %q ne contient pas %q", got, w)
|
||||
}
|
||||
}
|
||||
for _, a := range c.absent {
|
||||
if strings.Contains(got, a) {
|
||||
t.Errorf("message %q contient %q alors qu'il ne devrait pas", got, a)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// La sonde est le premier appel réseau d'une installation : c'est elle qui doit
|
||||
// remonter le refus expliqué, pas un code HTTP nu.
|
||||
func TestDLProbeSurfacesExplainedError(t *testing.T) {
|
||||
t.Setenv("HF_TOKEN", "")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Error-Code", "GatedRepo")
|
||||
w.WriteHeader(401)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, _, err := dlProbe(context.Background(), srv.URL+"/m.gguf")
|
||||
if err == nil {
|
||||
t.Fatal("un 401 doit faire échouer la sonde")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HF_TOKEN") {
|
||||
t.Errorf("message %q sans indication sur le jeton", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package loki
|
||||
|
||||
// backend_models_refs.go — QUI référence un .gguf, et peut-on l'effacer ?
|
||||
//
|
||||
// Vécu qui a produit ce fichier : un modèle téléchargé depuis l'éditeur d'un
|
||||
// preset, le preset supprimé (sans cocher « supprimer aussi le .gguf »), et
|
||||
// 40 Go de fichier plus rien pour les reprendre — la route /api/models/delete
|
||||
// existait mais AUCUN bouton ne l'appelait. Supprimer un modèle depuis
|
||||
// l'interface demande deux choses : savoir qui s'en sert (le moteur en service,
|
||||
// d'autres presets), et le dire AVANT d'effacer.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// modelRefIndex — l'index « ce fichier est référencé par… », construit en une
|
||||
// seule passe. Demander « qui utilise ce modèle ? » fichier par fichier
|
||||
// relisait tous les presets à chaque ligne de la liste des modèles.
|
||||
//
|
||||
// Les clés sont des chemins normalisés (normDir) : un preset peut nommer son
|
||||
// modèle en chemin absolu et un autre en simple nom de fichier, c'est le même
|
||||
// fichier sur le disque.
|
||||
type modelRefIndex struct {
|
||||
active map[string]bool // référencé par config.env — donc par le moteur qui tourne
|
||||
presets map[string][]string // chemin -> noms d'affichage des presets
|
||||
}
|
||||
|
||||
// modelRefKey normalise une valeur MODEL=/MMPROJ= en clé d'index, ou "" si elle
|
||||
// ne désigne pas un .gguf. resolveModelPath accepte le nom simple comme le
|
||||
// chemin absolu et les résout dans les dossiers déclarés — c'est exactement la
|
||||
// règle qu'appliquera le moteur.
|
||||
func modelRefKey(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
p, err := resolveServeModelPath(v)
|
||||
if err != nil {
|
||||
// Introuvable : on garde quand même une clé comparable, sinon deux
|
||||
// presets qui nomment le même fichier absent paraîtraient distincts.
|
||||
p = filepath.Join(modelsDir(), baseName(v))
|
||||
}
|
||||
return normDir(p)
|
||||
}
|
||||
|
||||
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
|
||||
// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets
|
||||
// d'avant la clé MMPROJ l'écrivaient là).
|
||||
func modelKeysOf(env map[string]string) []string {
|
||||
vals := []string{env["MODEL"], env["MMPROJ"]}
|
||||
args := splitArgs(env["EXTRA_ARGS"])
|
||||
for i, a := range args {
|
||||
if a == "--mmproj" && i+1 < len(args) {
|
||||
vals = append(vals, args[i+1])
|
||||
}
|
||||
}
|
||||
out := []string{}
|
||||
for _, v := range vals {
|
||||
if k := modelRefKey(v); k != "" {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildModelRefIndex lit la configuration active et tous les presets.
|
||||
func buildModelRefIndex() modelRefIndex {
|
||||
ix := modelRefIndex{active: map[string]bool{}, presets: map[string][]string{}}
|
||||
for _, k := range modelKeysOf(ReadConfig()) {
|
||||
ix.active[k] = true
|
||||
}
|
||||
list, err := ListPresets()
|
||||
if err != nil {
|
||||
return ix
|
||||
}
|
||||
for _, p := range list {
|
||||
content, err := ReadPreset(p.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, k := range modelKeysOf(parseEnv(content)) {
|
||||
if seen[k] {
|
||||
continue // MODEL et MMPROJ identiques : un seul « utilisé par »
|
||||
}
|
||||
seen[k] = true
|
||||
ix.presets[k] = append(ix.presets[k], p.Name)
|
||||
}
|
||||
}
|
||||
return ix
|
||||
}
|
||||
|
||||
// lookup renvoie les presets qui référencent path et s'il est en service.
|
||||
func (ix modelRefIndex) lookup(path string) (presets []string, active bool) {
|
||||
k := normDir(path)
|
||||
return ix.presets[k], ix.active[k]
|
||||
}
|
||||
|
||||
// modelUsers répond pour UN modèle (nom de fichier ou chemin).
|
||||
func modelUsers(name string) (presets []string, active bool) {
|
||||
k := modelRefKey(name)
|
||||
if k == "" {
|
||||
return nil, false
|
||||
}
|
||||
return buildModelRefIndex().lookup(k)
|
||||
}
|
||||
|
||||
// modelInServiceErr est le refus opposé à la suppression d'un modèle que le
|
||||
// moteur a ouvert. Effacer le .gguf sous un llama-server qui tourne ne libère
|
||||
// même pas la place (le fichier reste mappé) et rend le prochain démarrage
|
||||
// impossible sans rien dire.
|
||||
const modelInServiceMsg = "modèle en service — bascule sur un autre preset avant de le supprimer"
|
||||
|
||||
// modelFamilySize additionne ce que la suppression libérera réellement
|
||||
// (toutes les tranches présentes).
|
||||
func modelFamilySize(p string) int64 {
|
||||
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
|
||||
}
|
||||
|
||||
// modelPresence dit si un modèle est DÉJÀ sur le disque, et sous quelle valeur
|
||||
// l'écrire dans MODEL=. dir restreint la recherche à ce dossier (celui visé par
|
||||
// un téléchargement) ; vide = tous les dossiers déclarés.
|
||||
//
|
||||
// C'est ce qui transforme « le modèle existe déjà » — un cul-de-sac rouge au
|
||||
// bout d'un clic sur un quant — en « déjà là, je le sélectionne ».
|
||||
func modelPresence(name, dir string) (value, path string, ok bool) {
|
||||
base := baseName(strings.TrimSpace(name))
|
||||
if base == "" || !strings.HasSuffix(strings.ToLower(base), ".gguf") {
|
||||
return "", "", false
|
||||
}
|
||||
dirs := modelDirs()
|
||||
if strings.TrimSpace(dir) != "" {
|
||||
if d, err := resolveDownloadDir(dir); err == nil {
|
||||
dirs = []string{d}
|
||||
}
|
||||
}
|
||||
for _, d := range dirs {
|
||||
p := filepath.Join(d, base)
|
||||
if st, err := os.Stat(p); err != nil || st.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Famille incomplète = modèle inutilisable : on ne prétend pas qu'il est
|
||||
// là, l'appelant doit pouvoir relancer le téléchargement des tranches
|
||||
// manquantes (handleModelDownload les reprend une par une).
|
||||
if len(shardFamilyMissing(d, base)) > 0 {
|
||||
continue
|
||||
}
|
||||
return modelPickerValue(d, base), p, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// modelPickerValue est la valeur à écrire dans MODEL= pour un fichier donné :
|
||||
// le simple nom quand il vit dans le dossier de téléchargement de Loki (le
|
||||
// preset reste alors portable d'une machine à l'autre), le chemin complet
|
||||
// ailleurs. Même règle que la liste /api/models.
|
||||
func modelPickerValue(dir, base string) string {
|
||||
if normDir(dir) == normDir(modelsDir()) {
|
||||
return base
|
||||
}
|
||||
return filepath.Join(dir, base)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeModel pose un .gguf factice dans dir et renvoie son chemin.
|
||||
func writeModel(t *testing.T, dir, name string, size int) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Le scénario complet du rapport : un modèle téléchargé depuis un preset, le
|
||||
// preset supprimé sans son .gguf, et plus rien pour reprendre le fichier.
|
||||
// Après ce correctif il doit être A) retrouvé par le sélecteur, B) effaçable.
|
||||
func TestModeleOrphelinResteUtilisableEtEffaçable(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "modele.gguf", 1024)
|
||||
|
||||
// A) Présent sur le disque, sous la valeur qu'on écrira dans MODEL=.
|
||||
value, path, ok := modelPresence("modele.gguf", "")
|
||||
if !ok {
|
||||
t.Fatal("modèle présent non détecté — le téléchargement resterait un cul-de-sac")
|
||||
}
|
||||
if value != "modele.gguf" {
|
||||
t.Fatalf("valeur MODEL= = %q, attendu le simple nom de fichier", value)
|
||||
}
|
||||
if path != filepath.Join(modelsDir(), "modele.gguf") {
|
||||
t.Fatalf("chemin = %q", path)
|
||||
}
|
||||
|
||||
// B) Plus personne ne le référence : il s'efface sans force.
|
||||
users, active := modelUsers("modele.gguf")
|
||||
if active || len(users) != 0 {
|
||||
t.Fatalf("modèle orphelin annoncé utilisé : actif=%v presets=%v", active, users)
|
||||
}
|
||||
freed, err := deleteModelFile("modele.gguf")
|
||||
if err != nil {
|
||||
t.Fatalf("suppression refusée : %v", err)
|
||||
}
|
||||
if freed != 1024 {
|
||||
t.Fatalf("octets libérés = %d, attendu 1024", freed)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("le fichier est toujours là")
|
||||
}
|
||||
}
|
||||
|
||||
// Le modèle que le moteur a ouvert ne s'efface pas : la place ne serait même pas
|
||||
// rendue, et le prochain démarrage mourrait sur un .gguf absent.
|
||||
func TestSuppressionRefuseeSurLeModeleEnService(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "servi.gguf", 8)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "servi.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, active := modelUsers("servi.gguf"); !active {
|
||||
t.Fatal("modèle de la configuration active non reconnu")
|
||||
}
|
||||
if _, err := deleteModelFile("servi.gguf"); err == nil {
|
||||
t.Fatal("le modèle en service a été supprimé")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(modelsDir(), "servi.gguf")); err != nil {
|
||||
t.Fatal("fichier supprimé malgré le refus")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle nommé par des presets n'est pas bloqué : il est SIGNALÉ, et
|
||||
// force:true tranche. Refuser tout court recréerait l'impasse qu'on corrige.
|
||||
func TestSuppressionSignaleLesPresetsPuisObeitAForce(t *testing.T) {
|
||||
testHome(t)
|
||||
p := writeModel(t, modelsDir(), "partage.gguf", 16)
|
||||
if _, err := SavePreset("", "Rapide", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SavePreset("", "Long", "MODEL=\"partage.gguf\"\nCTX=131072\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, active := modelUsers("partage.gguf")
|
||||
if active {
|
||||
t.Fatal("aucune configuration active ne devrait référencer ce modèle")
|
||||
}
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("presets référents = %v, attendu les deux", users)
|
||||
}
|
||||
|
||||
post := func(body string) (int, map[string]any) {
|
||||
r := httptest.NewRequest("POST", "/api/models/delete", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
handleModelDelete(w, r)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out
|
||||
}
|
||||
code, out := post(`{"name":"partage.gguf"}`)
|
||||
if code != 409 {
|
||||
t.Fatalf("code = %d, attendu 409 (presets référents)", code)
|
||||
}
|
||||
if len(out["used"].([]any)) != 2 {
|
||||
t.Fatalf("la réponse doit NOMMER les presets concernés : %v", out)
|
||||
}
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatal("fichier supprimé alors que la confirmation n'a pas eu lieu")
|
||||
}
|
||||
if code, out = post(`{"name":"partage.gguf","force":true}`); code != 200 {
|
||||
t.Fatalf("code = %d avec force:true — %v", code, out)
|
||||
}
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatal("force:true n'a pas supprimé le fichier")
|
||||
}
|
||||
}
|
||||
|
||||
// MMPROJ et un --mmproj resté dans EXTRA_ARGS comptent aussi : effacer le
|
||||
// projecteur du modèle en service casse la vision sans rien dire.
|
||||
func TestProjecteurCompteCommeReference(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "mmproj-F16.gguf", 4)
|
||||
writeModel(t, modelsDir(), "vieux-mmproj.gguf", 4)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "MMPROJ": "mmproj-F16.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, active := modelUsers("mmproj-F16.gguf"); !active {
|
||||
t.Fatal("MMPROJ= non compté comme référence")
|
||||
}
|
||||
if _, err := SavePreset("", "Vision", `MODEL="m.gguf"`+"\n"+`EXTRA_ARGS="--mmproj vieux-mmproj.gguf --flash-attn"`+"\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, _ := modelUsers("vieux-mmproj.gguf")
|
||||
if len(users) != 1 || users[0] != "Vision" {
|
||||
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
|
||||
}
|
||||
}
|
||||
|
||||
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
|
||||
// télécharger, et prétendre le contraire donnerait un moteur qui meurt sur un
|
||||
// tenseur introuvable.
|
||||
func TestFamilleIncompleteNestPasPresente(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "gros-00001-of-00002.gguf", 4)
|
||||
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); ok {
|
||||
t.Fatal("famille incomplète annoncée présente")
|
||||
}
|
||||
writeModel(t, modelsDir(), "gros-00002-of-00002.gguf", 4)
|
||||
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); !ok {
|
||||
t.Fatal("famille complète non détectée")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle rangé dans un dossier déclaré ailleurs (disque externe) est trouvé
|
||||
// lui aussi, et sous son chemin complet — un simple nom ne le désignerait pas.
|
||||
func TestPresenceDansUnDossierDeclare(t *testing.T) {
|
||||
home := testHome(t)
|
||||
ext := filepath.Join(home, "externe")
|
||||
writeModel(t, ext, "ailleurs.gguf", 32)
|
||||
if _, _, ok := modelPresence("ailleurs.gguf", ""); ok {
|
||||
t.Fatal("dossier non déclaré : le modèle ne doit pas être visible")
|
||||
}
|
||||
if err := saveExtraModelDirs([]string{ext}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, _, ok := modelPresence("ailleurs.gguf", "")
|
||||
if !ok {
|
||||
t.Fatal("modèle d'un dossier déclaré non détecté")
|
||||
}
|
||||
if value != filepath.Join(ext, "ailleurs.gguf") {
|
||||
t.Fatalf("valeur MODEL= = %q, attendu le chemin complet", value)
|
||||
}
|
||||
}
|
||||
|
||||
// /api/models doit marquer « dossier loki » le dossier de téléchargement et en
|
||||
// donner le SIMPLE NOM : c'est ce que resolveModelPath retrouve, et c'est ce qui
|
||||
// permet au sélecteur de reconnaître un MODEL=modele.gguf écrit à la main.
|
||||
func TestListeModelesNommeLeDossierDeLoki(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "local.gguf", 64)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "local.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, httptest.NewRequest("GET", "/api/models", nil))
|
||||
var out []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("liste = %v", out)
|
||||
}
|
||||
if out[0]["home"] != true {
|
||||
t.Fatal("le dossier de téléchargement n'est pas marqué « dossier loki »")
|
||||
}
|
||||
if out[0]["value"] != "local.gguf" {
|
||||
t.Fatalf("value = %v, attendu le simple nom de fichier", out[0]["value"])
|
||||
}
|
||||
if out[0]["active"] != true {
|
||||
t.Fatal("le modèle en service doit être signalé comme tel")
|
||||
}
|
||||
}
|
||||
|
||||
// Télécharger un modèle DÉJÀ sur le disque n'est pas une erreur : c'est le
|
||||
// résultat voulu. Les deux routes doivent répondre « ok, exists » avec la valeur
|
||||
// à écrire dans MODEL= — sinon l'interface s'arrête en rouge sur « le modèle
|
||||
// existe déjà » et il devient impossible de refaire un preset dessus.
|
||||
func TestTelechargerUnModelePresentLeSelectionne(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "deja.gguf", 2048)
|
||||
const url = "https://huggingface.co/auteur/depot/resolve/main/deja.gguf"
|
||||
|
||||
call := func(h func(w http.ResponseWriter, r *http.Request), path string) (int, map[string]any) {
|
||||
body := `{"url":"` + url + `","dir":""}`
|
||||
w := httptest.NewRecorder()
|
||||
h(w, httptest.NewRequest("POST", path, strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out
|
||||
}
|
||||
|
||||
// La sonde ne doit même pas sortir sur le réseau : elle répond depuis le disque.
|
||||
code, out := call(handleModelDownloadProbe, "/api/models/download/probe")
|
||||
if code != 200 || out["ok"] != true || out["exists"] != true {
|
||||
t.Fatalf("sonde : code=%d out=%v", code, out)
|
||||
}
|
||||
if out["value"] != "deja.gguf" {
|
||||
t.Fatalf("sonde : value = %v, attendu le nom à mettre dans MODEL=", out["value"])
|
||||
}
|
||||
|
||||
code, out = call(handleModelDownload, "/api/models/download")
|
||||
if code != 200 || out["ok"] != true || out["exists"] != true {
|
||||
t.Fatalf("téléchargement : code=%d out=%v", code, out)
|
||||
}
|
||||
if out["value"] != "deja.gguf" {
|
||||
t.Fatalf("téléchargement : value = %v", out["value"])
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,13 @@ func TestDeleteModelFileEmporteLesTranches(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil {
|
||||
freed, err := deleteModelFile("m-00001-of-00002.gguf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if freed != 2 {
|
||||
t.Fatalf("octets libérés = %d, attendu 2 (les deux tranches d'un octet)", freed)
|
||||
}
|
||||
for _, n := range names[:2] {
|
||||
if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) {
|
||||
t.Fatalf("tranche %s non supprimée", n)
|
||||
|
||||
@@ -440,8 +440,18 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) {
|
||||
// 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 {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return downloadRequest(req, dest, total, logf)
|
||||
}
|
||||
|
||||
// downloadRequest est la même chose pour une requête déjà construite — un
|
||||
// registre OCI exige un en-tête d'autorisation, même en lecture anonyme.
|
||||
func downloadRequest(req *http.Request, dest string, total int64, logf func(string)) error {
|
||||
client := &http.Client{Timeout: 0}
|
||||
resp, err := client.Get(url)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -225,9 +225,38 @@ func applyPresetFile(target string) error {
|
||||
}
|
||||
next[k] = v
|
||||
}
|
||||
// Le moteur suit la machine, pas le preset (voir presetImposesEngine).
|
||||
if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) {
|
||||
next["BIN"] = cur["BIN"]
|
||||
}
|
||||
return WriteConfig(next)
|
||||
}
|
||||
|
||||
// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ?
|
||||
//
|
||||
// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du
|
||||
// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque
|
||||
// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de
|
||||
// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et
|
||||
// un modèle qui chargeait la veille mourait sur « unknown model architecture »
|
||||
// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur
|
||||
// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un
|
||||
// preset : le courant reste.
|
||||
//
|
||||
// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go :
|
||||
// « un backend custom se choisit par modèle »), lui, est un vrai choix par
|
||||
// modèle : le preset gagne.
|
||||
func presetImposesEngine(p string) bool {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || engineOwns(p) || prebuiltOwns(p) {
|
||||
return false
|
||||
}
|
||||
if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -235,6 +264,17 @@ func SwitchToPreset(target string) error {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
|
||||
// Preset externe (backend_external.go) : rien à redémarrer, il n'a pas de
|
||||
// MODEL et llama-server partirait en boucle de crash. On arrête au contraire
|
||||
// le moteur encore en vie — le chat part vers l'API distante, garder le
|
||||
// modèle en VRAM ne sert plus à rien.
|
||||
if isExternalConfig(ReadConfig()) {
|
||||
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
|
||||
if !serviceIsActive() {
|
||||
return nil
|
||||
}
|
||||
return serviceAction("stop")
|
||||
}
|
||||
fmt.Println(dim("[info] redémarrage du service..."))
|
||||
return serviceAction("restart")
|
||||
}
|
||||
@@ -287,6 +327,10 @@ func cmdSwitch(args []string) error {
|
||||
// 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…).
|
||||
//
|
||||
// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) :
|
||||
// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le
|
||||
// moteur mis à jour à la première bascule.
|
||||
var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
|
||||
|
||||
// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
|
||||
@@ -294,9 +338,11 @@ func newPresetSeed() map[string]string {
|
||||
cur := ReadConfig()
|
||||
seed := map[string]string{}
|
||||
for _, k := range newPresetSeedKeys {
|
||||
if v := cur[k]; v != "" {
|
||||
seed[k] = v
|
||||
v := cur[k]
|
||||
if v == "" || (k == "BIN" && !presetImposesEngine(v)) {
|
||||
continue
|
||||
}
|
||||
seed[k] = v
|
||||
}
|
||||
return seed
|
||||
}
|
||||
@@ -331,6 +377,41 @@ func SavePreset(id, name, content string) (string, error) {
|
||||
return id, os.WriteFile(p, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE,
|
||||
// installe aussitôt la nouvelle version comme configuration active (mêmes
|
||||
// règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas —
|
||||
// l'appelant redémarre alors le moteur.
|
||||
//
|
||||
// Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du
|
||||
// chat qui affiche toujours 32768. Seul le fichier du preset avait changé ;
|
||||
// config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun
|
||||
// preset n'était détecté actif (empreinte différente). Éditer le preset en
|
||||
// service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou
|
||||
// nouveau, lui, n'est qu'un fichier : rien ne bouge.
|
||||
//
|
||||
// « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui
|
||||
// porte l'empreinte de la configuration courante.
|
||||
func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) {
|
||||
wasActive := false
|
||||
if id != "" {
|
||||
if old, rerr := ReadPreset(id); rerr == nil {
|
||||
wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig())
|
||||
}
|
||||
}
|
||||
newID, err = SavePreset(id, name, content)
|
||||
if err != nil || !wasActive {
|
||||
return newID, false, err
|
||||
}
|
||||
p, err := safePresetPath(newID)
|
||||
if err != nil {
|
||||
return newID, false, err
|
||||
}
|
||||
if err := applyPresetFile(p); err != nil {
|
||||
return newID, false, err
|
||||
}
|
||||
return newID, true, nil
|
||||
}
|
||||
|
||||
// DeletePreset removes a preset by id; refuses if it is the active config.
|
||||
func DeletePreset(id string) error {
|
||||
p, err := safePresetPath(id)
|
||||
@@ -344,7 +425,14 @@ func DeletePreset(id string) error {
|
||||
if presetFingerprint(target) == configFingerprint(ReadConfig()) {
|
||||
return fmt.Errorf("preset actif, switche d'abord")
|
||||
}
|
||||
return os.Remove(p)
|
||||
if err := os.Remove(p); err != nil {
|
||||
return err
|
||||
}
|
||||
// Le prompt système du preset vit en base, pas dans son .env (web_sysprompt.go) :
|
||||
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
|
||||
// sous le même id.
|
||||
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadPreset returns the contents of a preset by id (filename).
|
||||
|
||||
@@ -68,6 +68,64 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Le moteur suit la machine. Vécu : moteur mis à jour depuis l'interface
|
||||
// (BIN → /data/engine/server-cuda-b10680), Qwen3.8-Flash-Next charge ; on
|
||||
// change de modèle — le preset, créé avant, porte BIN=/app/llama-server — et
|
||||
// le vieux moteur revient : « unknown model architecture: 'qwen4exp' ». Seul
|
||||
// un backend personnalisé, choix par modèle, a le droit d'imposer son BIN.
|
||||
func TestSwitchToPresetGardeLeMoteur(t *testing.T) {
|
||||
home := testHome(t)
|
||||
t.Setenv("LOKI_ENGINE_BIN", "/app/llama-server")
|
||||
write := func(p, body string) {
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engine := filepath.Join(home, "engine", "server-cuda-b10680", "llama-server")
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
|
||||
|
||||
// Preset d'avant la mise à jour : il porte le moteur de l'image.
|
||||
image := filepath.Join(home, "image.env")
|
||||
write(image, "MODEL=nouveau.gguf\nBIN=/app/llama-server\n")
|
||||
if err := applyPresetFile(image); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != engine {
|
||||
t.Errorf("BIN = %q après bascule, attendu le moteur mis à jour %q", got, engine)
|
||||
}
|
||||
// Preset muet sur le moteur : idem.
|
||||
muet := filepath.Join(home, "muet.env")
|
||||
write(muet, "MODEL=autre.gguf\n")
|
||||
if err := applyPresetFile(muet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != engine {
|
||||
t.Errorf("BIN = %q après preset muet, attendu %q conservé", got, engine)
|
||||
}
|
||||
// Backend personnalisé : le preset gagne.
|
||||
custom := filepath.Join(home, "custom.env")
|
||||
write(custom, "MODEL=exotique.gguf\nBIN=/opt/mon-fork/llama-server\n")
|
||||
if err := applyPresetFile(custom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != "/opt/mon-fork/llama-server" {
|
||||
t.Errorf("BIN = %q, attendu le backend personnalisé du preset", got)
|
||||
}
|
||||
// Un nouveau preset ne fige pas ce backend-là… si, justement : c'est un
|
||||
// choix par modèle. Mais il ne fige ni l'image ni un moteur téléchargé.
|
||||
if seed := newPresetSeed(); seed["BIN"] != "/opt/mon-fork/llama-server" {
|
||||
t.Errorf("seed BIN = %q, attendu le backend personnalisé", seed["BIN"])
|
||||
}
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
|
||||
if seed := newPresetSeed(); seed["BIN"] != "" {
|
||||
t.Errorf("seed BIN = %q, un moteur téléchargé ne doit pas être figé dans un preset", seed["BIN"])
|
||||
}
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN=/app/llama-server\n")
|
||||
if seed := newPresetSeed(); seed["BIN"] != "" {
|
||||
t.Errorf("seed BIN = %q, le moteur de l'image ne doit pas être figé dans un preset", seed["BIN"])
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -102,3 +160,54 @@ func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) {
|
||||
t.Errorf("WEB_ENGINE = %q, attendu go (réglage machine, jamais dicté par un preset)", cfg["WEB_ENGINE"])
|
||||
}
|
||||
}
|
||||
|
||||
// Modifier le preset ACTIF doit changer la configuration courante. Vécu : CTX
|
||||
// passé à 100000 dans l'éditeur, « enregistré » — et la carte du chat qui
|
||||
// affiche toujours 32768, parce que seul le fichier du preset avait bougé ;
|
||||
// config.env, lui, gardait l'ancienne valeur et plus aucun preset n'était
|
||||
// détecté actif. Un preset qui n'est pas celui en service, ou un preset
|
||||
// nouveau, ne touche pas à la configuration.
|
||||
func TestModifierLePresetActifLApplique(t *testing.T) {
|
||||
testHome(t)
|
||||
dir := presetsDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(name, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".env"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=32768\n")
|
||||
write("autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=4096\n")
|
||||
setConfig(t, "MODEL=foo.gguf\nCTX=32768\nMEM_MODE=always\n")
|
||||
|
||||
// Le preset en service : la config suit, les réglages machine restent.
|
||||
id, applied, err := SavePresetApplying("actif", "Actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=100000\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "actif" || !applied {
|
||||
t.Fatalf("id=%q applied=%v, attendu actif/true", id, applied)
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
if cfg["CTX"] != "100000" || cfg["MEM_MODE"] != "always" {
|
||||
t.Fatalf("configuration non suivie : %v", cfg)
|
||||
}
|
||||
|
||||
// Un autre preset : le fichier change, la config non.
|
||||
if _, applied, err = SavePresetApplying("autre", "Autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=8192\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied || ReadConfig()["CTX"] != "100000" {
|
||||
t.Fatalf("un preset inactif a touché la configuration (applied=%v, CTX=%q)", applied, ReadConfig()["CTX"])
|
||||
}
|
||||
|
||||
// Un preset nouveau : jamais appliqué à la volée.
|
||||
if _, applied, err = SavePresetApplying("", "Neuf", "MODEL=baz.gguf\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied || ReadConfig()["MODEL"] != "foo.gguf" {
|
||||
t.Fatalf("un preset nouveau a touché la configuration (applied=%v)", applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Le sampling du preset doit se retrouver dans le corps de la requête, avec les
|
||||
// bons types (top_k entier, le reste flottant), et TEMP écrase la température
|
||||
// déjà posée. Une clé vide n'ajoute rien : le serveur garde son défaut.
|
||||
func TestApplySamplingFrom(t *testing.T) {
|
||||
payload := map[string]any{"temperature": 0.7}
|
||||
applySamplingFrom(payload, map[string]string{
|
||||
"TEMP": "1.0",
|
||||
"TOP_P": "0.95",
|
||||
"TOP_K": "20",
|
||||
"MIN_P": "0",
|
||||
"REPEAT_PENALTY": "1.05",
|
||||
"PRESENCE_PENALTY": "", // vide → ignoré
|
||||
})
|
||||
if payload["temperature"] != 1.0 {
|
||||
t.Errorf("TEMP doit écraser la température : %v", payload["temperature"])
|
||||
}
|
||||
if payload["top_p"] != 0.95 || payload["min_p"] != 0.0 || payload["repeat_penalty"] != 1.05 {
|
||||
t.Errorf("flottants attendus : %v %v %v", payload["top_p"], payload["min_p"], payload["repeat_penalty"])
|
||||
}
|
||||
if payload["top_k"] != 20 {
|
||||
t.Errorf("top_k doit être un entier 20, pas %#v", payload["top_k"])
|
||||
}
|
||||
if _, ok := payload["presence_penalty"]; ok {
|
||||
t.Error("une clé vide ne doit rien envoyer")
|
||||
}
|
||||
}
|
||||
|
||||
// L'échantillonnage ne touche PAS au raisonnement : ce chemin appartient à
|
||||
// llm_client.go/llm_effort.go, qui y met aussi `enable_thinking`. L'amont écrit
|
||||
// `chat_template_kwargs` ici ; le faire chez nous effacerait la consigne
|
||||
// « aucune » — donc le modèle réfléchirait alors que l'interface dit l'inverse.
|
||||
func TestApplySamplingNeTouchePasAuRaisonnement(t *testing.T) {
|
||||
payload := map[string]any{"chat_template_kwargs": map[string]any{"enable_thinking": false}}
|
||||
applySamplingFrom(payload, map[string]string{"REASONING_EFFORT": "medium", "TOP_K": "20"})
|
||||
kw, ok := payload["chat_template_kwargs"].(map[string]any)
|
||||
if !ok || kw["enable_thinking"] != false || len(kw) != 1 {
|
||||
t.Errorf("chat_template_kwargs modifié : %v", payload["chat_template_kwargs"])
|
||||
}
|
||||
if _, ok := payload["reasoning_effort"]; ok {
|
||||
t.Error("reasoning_effort ne doit pas être posé par l'échantillonnage")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans aucune clé de sampling, le payload n'est pas modifié : on ne force pas de
|
||||
// valeurs qui contrediraient le défaut du modèle.
|
||||
func TestApplySamplingEmpty(t *testing.T) {
|
||||
payload := map[string]any{"temperature": 0.7}
|
||||
applySamplingFrom(payload, map[string]string{})
|
||||
if len(payload) != 1 || payload["temperature"] != 0.7 {
|
||||
t.Errorf("payload modifié à tort : %v", payload)
|
||||
}
|
||||
}
|
||||
+199
-18
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -23,15 +24,163 @@ import (
|
||||
// é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 {
|
||||
return strings.Contains(binHelp(bin), "--reasoning ")
|
||||
}
|
||||
|
||||
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
|
||||
// capacités se déduisent de cette aide (--reasoning, --load-mode, « -ngl auto »)
|
||||
// et relancer le moteur à chaque question coûterait quelques secondes de plus au
|
||||
// démarrage. Aide illisible = chaîne vide : tous les tests de capacité répondent
|
||||
// « non », c'est-à-dire « ne prends aucun risque, garde la forme historique ».
|
||||
var (
|
||||
helpMu sync.Mutex
|
||||
helpCache = map[string]string{}
|
||||
)
|
||||
|
||||
func binHelp(bin string) string {
|
||||
helpMu.Lock()
|
||||
defer helpMu.Unlock()
|
||||
if h, ok := helpCache[bin]; ok {
|
||||
return h
|
||||
}
|
||||
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
|
||||
h := ""
|
||||
if err := cmd.Run(); err == nil || out.Len() > 0 {
|
||||
h = out.String()
|
||||
}
|
||||
return strings.Contains(out.String(), "--reasoning ")
|
||||
helpCache[bin] = h
|
||||
return h
|
||||
}
|
||||
|
||||
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
|
||||
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
|
||||
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
|
||||
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
|
||||
func binSupportsLoadMode(bin string) bool {
|
||||
return strings.Contains(binHelp(bin), "--load-mode")
|
||||
}
|
||||
|
||||
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
|
||||
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
|
||||
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
|
||||
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
|
||||
func binSupportsNGLAuto(bin string) bool {
|
||||
h := binHelp(bin)
|
||||
i := strings.Index(h, "--n-gpu-layers")
|
||||
if i < 0 {
|
||||
i = strings.Index(h, "-ngl")
|
||||
}
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
end := i + 400
|
||||
if end > len(h) {
|
||||
end = len(h)
|
||||
}
|
||||
return strings.Contains(h[i:end], "'auto'")
|
||||
}
|
||||
|
||||
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
|
||||
//
|
||||
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
|
||||
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
|
||||
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
|
||||
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
|
||||
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
|
||||
// grossier mais insensible à la mise en page.
|
||||
func binFitsLayersItself(bin string) bool {
|
||||
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
|
||||
}
|
||||
|
||||
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
|
||||
// note à afficher quand Loki a substitué quelque chose. Fonction pure : la seule
|
||||
// question qui demande le moteur (« sait-il répartir les couches tout seul ? »)
|
||||
// arrive déjà tranchée, ce qui la rend testable sans lancer llama-server.
|
||||
func nglArgs(ngl string, fitsItself bool) ([]string, string) {
|
||||
ngl = strings.TrimSpace(ngl)
|
||||
switch {
|
||||
case strings.EqualFold(ngl, "auto"):
|
||||
return nil, "" // rien du tout : le moteur décide seul
|
||||
case ngl == "999" && fitsItself:
|
||||
return []string{"-ngl", "auto"},
|
||||
"NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la " +
|
||||
"VRAM libre. NGL=all pour forcer l'ancien comportement."
|
||||
case ngl != "":
|
||||
return []string{"-ngl", ngl}, ""
|
||||
case fitsItself:
|
||||
return []string{"-ngl", "auto"}, ""
|
||||
default:
|
||||
// Moteur ancien : omettre -ngl le ferait tourner 100 % CPU.
|
||||
return []string{"-ngl", "999"}, ""
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyFlag dit si l'utilisateur a déjà posé l'un de ces drapeaux dans
|
||||
// EXTRA_ARGS. Loki ajoute plusieurs valeurs par défaut (--parallel, -ngl) : les
|
||||
// ajouter EN PLUS de celles de l'utilisateur fait râler le moteur (« argument
|
||||
// specified multiple times ») et, pire, c'est la DERNIÈRE occurrence qui gagne
|
||||
// — le réglage explicite du preset serait donc silencieusement écrasé par le
|
||||
// défaut de Loki. Quand l'utilisateur a tranché, on se tait.
|
||||
func hasAnyFlag(args []string, flags ...string) bool {
|
||||
for _, a := range args {
|
||||
name := a
|
||||
if i := strings.IndexByte(name, '='); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
for _, f := range flags {
|
||||
if name == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
|
||||
// --load-mode, sur les moteurs qui le connaissent :
|
||||
//
|
||||
// --mlock → --load-mode mlock (garder le modèle en RAM)
|
||||
// --no-mmap → --load-mode none (tout charger, pas de mmap)
|
||||
// --mmap → --load-mode mmap
|
||||
//
|
||||
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
|
||||
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
|
||||
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
|
||||
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
|
||||
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||
if !supportsLoadMode {
|
||||
return args
|
||||
}
|
||||
explicit := hasAnyFlag(args, "--load-mode")
|
||||
mlock, nommap, mmap := false, false, false
|
||||
out := make([]string, 0, len(args)+2)
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--mlock":
|
||||
mlock = true
|
||||
case "--no-mmap":
|
||||
nommap = true
|
||||
case "--mmap":
|
||||
mmap = true
|
||||
default:
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
if explicit || !(mlock || nommap || mmap) {
|
||||
return out
|
||||
}
|
||||
mode := "mmap"
|
||||
switch {
|
||||
case mlock:
|
||||
mode = "mlock"
|
||||
case nommap:
|
||||
mode = "none"
|
||||
}
|
||||
return append(out, "--load-mode", mode)
|
||||
}
|
||||
|
||||
// cmdServe replaces the historic start.sh: read config.env, build the
|
||||
@@ -99,6 +248,12 @@ func cmdServe(args []string) error {
|
||||
ktv := get("KV_TYPE_K", kv)
|
||||
vtv := get("KV_TYPE_V", kv)
|
||||
|
||||
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
|
||||
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
|
||||
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
|
||||
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
|
||||
extra := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
|
||||
|
||||
llmArgs := []string{bin,
|
||||
"-m", model,
|
||||
"-c", get("CTX", "32768"),
|
||||
@@ -109,15 +264,43 @@ func cmdServe(args []string) error {
|
||||
"--host", get("HOST", "0.0.0.0"),
|
||||
"--port", get("PORT", "8080"),
|
||||
}
|
||||
// NGL=auto : on n'envoie PAS -ngl, et llama.cpp choisit lui-même combien de
|
||||
// couches tiennent dans la VRAM libre (common_fit_params). Il s'en abstient
|
||||
// dès que la valeur est imposée — « n_gpu_layers already set by user to 999,
|
||||
// abort » — d'où des cudaMalloc en échec, un repli partiel sur le CPU et un
|
||||
// débit effondré alors que le GPU tourne à 100 %.
|
||||
// Le défaut reste 999 (tout sur le GPU) : omettre -ngl sur un llama.cpp
|
||||
// antérieur à cet ajustement automatique le ferait tourner 100 % CPU.
|
||||
if ngl := get("NGL", "999"); !strings.EqualFold(ngl, "auto") {
|
||||
llmArgs = append(llmArgs, "-ngl", ngl)
|
||||
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
|
||||
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
|
||||
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
|
||||
// Vécu : une config 27B/60k ctx qui tournait très bien est morte en
|
||||
// « cudaMalloc failed: out of memory » après une mise à jour du moteur,
|
||||
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
|
||||
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
|
||||
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
|
||||
if !hasAnyFlag(extra, "--parallel", "-np") {
|
||||
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
|
||||
}
|
||||
// Couches GPU. Quatre cas, dans cet ordre :
|
||||
//
|
||||
// NGL=auto → rien du tout (le moteur décide seul)
|
||||
// NGL=999 → -ngl auto sur un moteur récent (voir plus bas)
|
||||
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche pour de vrai)
|
||||
// clé absente → -ngl auto si le moteur le propose, sinon -ngl 999
|
||||
//
|
||||
// Imposer un NOMBRE désarme common_fit_params, qui mesure la VRAM libre et
|
||||
// choisit combien de couches y tiennent : « n_gpu_layers already set by user
|
||||
// to 999, abort ». Le moteur pousse alors tout sur le GPU, échoue en
|
||||
// cudaMalloc ou se replie à moitié sur le CPU — débit effondré, GPU à 100 %.
|
||||
//
|
||||
// 999 est traité à part parce que ce n'a JAMAIS été un nombre de couches :
|
||||
// c'est la sentinelle historique « toutes », semée dans config.env par
|
||||
// defaultConfig() sur chaque installation neuve. La quasi-totalité des
|
||||
// configurations la portent donc sans que personne ne l'ait choisie — la
|
||||
// traiter comme un choix délibéré, c'est condamner tout le monde à l'abandon
|
||||
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
|
||||
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
|
||||
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
|
||||
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
|
||||
if note != "" {
|
||||
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
|
||||
}
|
||||
llmArgs = append(llmArgs, args...)
|
||||
}
|
||||
if ktv != "" {
|
||||
llmArgs = append(llmArgs, "-ctk", ktv)
|
||||
@@ -176,14 +359,12 @@ func cmdServe(args []string) error {
|
||||
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
|
||||
// $LOKI_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 != "" {
|
||||
if k, _ := effectiveAPIKeyErr(); 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"])...)
|
||||
// EXTRA_ARGS (déjà découpé plus haut comme le ferait le shell — les guillemets
|
||||
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
|
||||
llmArgs = append(llmArgs, extra...)
|
||||
|
||||
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
||||
// mmproj-F16.gguf) still resolve.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHasAnyFlag(t *testing.T) {
|
||||
args := []string{"--parallel", "4", "--load-mode=mlock", "-fa"}
|
||||
if !hasAnyFlag(args, "--parallel", "-np") {
|
||||
t.Fatal("--parallel devrait être détecté")
|
||||
}
|
||||
// Forme --clé=valeur : le nom seul compte.
|
||||
if !hasAnyFlag(args, "--load-mode") {
|
||||
t.Fatal("--load-mode=… devrait être détecté")
|
||||
}
|
||||
if hasAnyFlag(args, "-ngl", "--n-gpu-layers") {
|
||||
t.Fatal("-ngl absent, ne devrait pas être détecté")
|
||||
}
|
||||
// Une VALEUR qui ressemble à un drapeau ne doit pas compter comme tel :
|
||||
// ici "-ngl" n'est pas posé, seul --parallel l'est.
|
||||
if hasAnyFlag(nil, "--parallel") {
|
||||
t.Fatal("liste vide : rien à détecter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLoadFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
supports bool
|
||||
want []string
|
||||
}{
|
||||
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
|
||||
[]string{"--mlock", "--no-mmap"}},
|
||||
{"mlock traduit", []string{"--mlock", "-fa"}, true,
|
||||
[]string{"-fa", "--load-mode", "mlock"}},
|
||||
{"no-mmap traduit", []string{"--no-mmap"}, true,
|
||||
[]string{"--load-mode", "none"}},
|
||||
{"mmap traduit", []string{"--mmap"}, true,
|
||||
[]string{"--load-mode", "mmap"}},
|
||||
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
|
||||
[]string{"--load-mode", "mlock"}},
|
||||
{"--load-mode explicite gagne, vieux drapeaux retirés",
|
||||
[]string{"--mlock", "--load-mode", "dio"}, true,
|
||||
[]string{"--load-mode", "dio"}},
|
||||
{"rien à traduire", []string{"-fa", "--n-cpu-moe", "8"}, true,
|
||||
[]string{"-fa", "--n-cpu-moe", "8"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := normalizeLoadFlags(c.in, c.supports)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNGLArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ngl string
|
||||
fitsItself bool
|
||||
want []string
|
||||
wantNote bool
|
||||
}{
|
||||
// Le cas qui motive tout : defaultConfig() sème NGL=999 sur chaque
|
||||
// installation neuve. Personne ne l'a choisi, et le moteur récent
|
||||
// abandonne son calcul de VRAM dès qu'on lui impose un nombre.
|
||||
{"sentinelle 999, moteur récent", "999", true, []string{"-ngl", "auto"}, true},
|
||||
{"sentinelle 999, moteur ancien", "999", false, []string{"-ngl", "999"}, false},
|
||||
{"nombre choisi, jamais touché", "28", true, []string{"-ngl", "28"}, false},
|
||||
{"nombre choisi, moteur ancien", "28", false, []string{"-ngl", "28"}, false},
|
||||
{"all reste all", "all", true, []string{"-ngl", "all"}, false},
|
||||
{"auto : aucun drapeau", "auto", true, nil, false},
|
||||
{"auto insensible à la casse", "AUTO", true, nil, false},
|
||||
{"espaces parasites autour de la sentinelle", " 999 ", true, []string{"-ngl", "auto"}, true},
|
||||
{"clé absente, moteur récent", "", true, []string{"-ngl", "auto"}, false},
|
||||
{"clé absente, moteur ancien", "", false, []string{"-ngl", "999"}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, note := nglArgs(c.ngl, c.fitsItself)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("args = %q, want %q", got, c.want)
|
||||
}
|
||||
if (note != "") != c.wantNote {
|
||||
t.Fatalf("note = %q, en voulait-on une ? %v", note, c.wantNote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package loki
|
||||
|
||||
// backup_bundle.go — construit et restaure un paquet de sauvegarde
|
||||
// {mémoire, presets, réglages}, CHIFFRÉ côté agent avant de quitter la machine.
|
||||
//
|
||||
// Le relais ne voit qu'un blob opaque (voir jean-relay/backup.go). Comme le tar
|
||||
// entier est chiffré, même les noms de fichiers et de presets sont cachés.
|
||||
//
|
||||
// Format du blob :
|
||||
// magic "LOKIBK1" || uint32(len entête) || entête JSON || corps
|
||||
// entête : { v, salt, kdf, box_pw, box_rec } — la DEK de sauvegarde wrappée
|
||||
// par la KEK(mot de passe) ET par la KEK(clé de récupération).
|
||||
// corps : gcmSeal(backupDEK, tar.gz du bundle).
|
||||
//
|
||||
// Restauration : mot de passe → KEK → déballe la DEK → déchiffre le corps. Ne
|
||||
// dépend d'AUCUN état de l'ancienne machine : un serveur vierge suffit.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var backupMagic = []byte("LKBK1") // v2 : entête = keyvault, corps chiffré par la DEK
|
||||
|
||||
const backupBodyAAD = "loki-backup-body"
|
||||
|
||||
// buildBundleTar empaquète mémoire + presets + réglages en un tar.gz.
|
||||
// Les fichiers mémoire sont pris TELS QUELS (chiffrés si le chiffrement est
|
||||
// actif) : le bundle reste donc chiffré même avant l'enveloppe de sauvegarde.
|
||||
func buildBundleTar() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
addDir := func(prefix, dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil // dossier absent = rien à ajouter
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &tar.Header{Name: prefix + "/" + e.Name(), Mode: 0o600, Size: int64(len(b))}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mémoire de TOUS les projets : un sous-dossier par projet (memory/<slug>/...).
|
||||
if roots, err := os.ReadDir(projectsRoot()); err == nil {
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := addDir("memory/"+d.Name(), filepath.Join(projectsRoot(), d.Name())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := addDir("presets", presetsDir()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Réglages (bucket config) sérialisés en JSON.
|
||||
cfg, _ := json.Marshal(ReadConfig())
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "config.json", Mode: 0o600, Size: int64(len(cfg))}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tw.Write(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildBackupBlob assemble le paquet chiffré : magic || uint32(len keyvault) ||
|
||||
// keyvault JSON || gcmSeal(DEK, tar). AUCUN mot de passe de sauvegarde : le corps
|
||||
// est chiffré avec la DEK de la mémoire (déjà en RAM), et le keyvault (la DEK
|
||||
// wrappée par la clé d'API + la clé de récupération) voyage en entête. La
|
||||
// restauration n'a donc besoin que de la clé d'API. Le relais ne voit qu'un blob
|
||||
// opaque : le keyvault en entête ne contient que la DEK CHIFFRÉE.
|
||||
func buildBackupBlob(v *keyVault, dek, tarData []byte) ([]byte, error) {
|
||||
kv, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := gcmSeal(dek, tarData, []byte(backupBodyAAD))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
out.Write(backupMagic)
|
||||
var lenbuf [4]byte
|
||||
binary.BigEndian.PutUint32(lenbuf[:], uint32(len(kv)))
|
||||
out.Write(lenbuf[:])
|
||||
out.Write(kv)
|
||||
out.Write(body)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// openBackupBlob lit l'entête keyvault, l'ouvre avec secret (clé d'API OU clé de
|
||||
// récupération) pour retrouver la DEK, puis déchiffre le corps.
|
||||
func openBackupBlob(blob []byte, secret string) ([]byte, error) {
|
||||
if !bytes.HasPrefix(blob, backupMagic) {
|
||||
return nil, fmt.Errorf("format de sauvegarde non reconnu")
|
||||
}
|
||||
rest := blob[len(backupMagic):]
|
||||
if len(rest) < 4 {
|
||||
return nil, fmt.Errorf("blob tronqué")
|
||||
}
|
||||
n := binary.BigEndian.Uint32(rest[:4])
|
||||
rest = rest[4:]
|
||||
if int(n) > len(rest) {
|
||||
return nil, fmt.Errorf("entête tronqué")
|
||||
}
|
||||
var v keyVault
|
||||
if err := json.Unmarshal(rest[:n], &v); err != nil {
|
||||
return nil, fmt.Errorf("entête illisible : %w", err)
|
||||
}
|
||||
body := rest[n:]
|
||||
dek, _, err := v.unlockWith(secret)
|
||||
if err != nil {
|
||||
dek, _, err = v.unlockWith(normalizeRecovery(secret))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clé incorrecte")
|
||||
}
|
||||
return gcmOpen(dek, body, []byte(backupBodyAAD))
|
||||
}
|
||||
|
||||
// restoreBundleTar réécrit mémoire + presets + réglages depuis un tar.gz. Prend
|
||||
// D'ABORD un snapshot de sécurité de la mémoire, puis écrit de façon vérifiée.
|
||||
func restoreBundleTar(tarData []byte) error {
|
||||
if _, err := snapshotMemory("avant-restauration"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(tarData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var cfgJSON []byte
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Les chemins tar sont TOUJOURS à séparateur « / » (jamais l'OS) : on
|
||||
// raisonne en slash, sinon le préfixe "memory/" casse sous Windows.
|
||||
name := path.Clean("/" + strings.ReplaceAll(hdr.Name, "\\", "/"))[1:]
|
||||
if strings.Contains(name, "..") {
|
||||
continue // sécurité : jamais de traversée
|
||||
}
|
||||
base := path.Base(name)
|
||||
switch {
|
||||
case name == "config.json":
|
||||
cfgJSON = b
|
||||
case strings.HasPrefix(name, "memory/"):
|
||||
// Chemin relatif SOUS memory/ (memory/<slug>/page.md). Les sauvegardes
|
||||
// d'avant les projets stockaient les pages à plat (memory/page.md) : on
|
||||
// les rattache au projet par défaut.
|
||||
rel := strings.TrimPrefix(name, "memory/")
|
||||
if !strings.Contains(rel, "/") {
|
||||
rel = defaultProjectSlug + "/" + rel
|
||||
}
|
||||
dst := filepath.Join(projectsRoot(), filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(dst, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.HasPrefix(name, "presets/"):
|
||||
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(presetsDir(), base), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cfgJSON) > 0 {
|
||||
var cfg map[string]string
|
||||
if json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||
_ = WriteConfig(cfg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un paquet exporté doit se rouvrir avec la clé de pilotage comme avec la clé de
|
||||
// récupération, et rendre la mémoire à l'identique. C'est le scénario « je
|
||||
// remonte mon conteneur ailleurs » : rien d'autre que le fichier et la clé.
|
||||
func TestBackupBundleRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("notes.md", "# Notes\nsecret du client\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovery, err := EnableMemEncryption("clé-de-pilotage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
t.Fatalf("coffre introuvable après activation: %v", err)
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarData, err := buildBundleTar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, err := buildBackupBlob(v, dek, tarData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Le paquet ne doit rien laisser voir en clair.
|
||||
if strings.Contains(string(blob), "secret du client") {
|
||||
t.Fatal("le contenu d'une page apparaît en clair dans le paquet")
|
||||
}
|
||||
|
||||
// La clé de pilotage l'ouvre…
|
||||
if _, err := openBackupBlob(blob, "clé-de-pilotage"); err != nil {
|
||||
t.Fatalf("ouverture avec la clé de pilotage: %v", err)
|
||||
}
|
||||
// …la clé de récupération aussi…
|
||||
back, err := openBackupBlob(blob, recovery)
|
||||
if err != nil {
|
||||
t.Fatalf("ouverture avec la clé de récupération: %v", err)
|
||||
}
|
||||
// …et une mauvaise clé, non.
|
||||
if _, err := openBackupBlob(blob, "pas la bonne"); err == nil {
|
||||
t.Fatal("une clé fausse aurait dû être refusée")
|
||||
}
|
||||
|
||||
// Page effacée puis restaurée depuis le paquet : contenu intact.
|
||||
p, _ := safeMemPath("notes.md")
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restoreBundleTar(back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := memPageText("notes.md")
|
||||
if !ok || !strings.Contains(got, "secret du client") {
|
||||
t.Fatalf("page non restaurée: %q (lisible=%v)", got, ok)
|
||||
}
|
||||
// Le snapshot de sécurité pris avant restauration doit exister.
|
||||
if entries, err := os.ReadDir(filepath.Join(LokiHome(), "backups", "memory")); err != nil || len(entries) == 0 {
|
||||
t.Fatal("aucun snapshot pris avant la restauration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package loki
|
||||
|
||||
// browser_grid.go — surimpose une GRILLE DE COORDONNÉES sur le screenshot envoyé
|
||||
// au MODÈLE (pas sur celui sauvegardé pour l'utilisateur). Un petit modèle vise
|
||||
// mal au pixel près ; avec des lignes graduées tous les 100 px et des étiquettes
|
||||
// chiffrées, il lit x/y précisément et browser_click_xy tombe juste du premier
|
||||
// coup (bandeaux cookies en iframe, canvas, jeux…). Sans dépendance externe : la
|
||||
// police de chiffres est un bitmap 3×5 codé ici.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const gridStep = 100 // espacement des lignes, en pixels
|
||||
|
||||
// digits5x3 : bitmap 5 lignes × 3 colonnes pour chaque chiffre (# = pixel plein).
|
||||
var digits5x3 = map[rune][5]string{
|
||||
'0': {"###", "# #", "# #", "# #", "###"},
|
||||
'1': {" # ", "## ", " # ", " # ", "###"},
|
||||
'2': {"###", " #", "###", "# ", "###"},
|
||||
'3': {"###", " #", "###", " #", "###"},
|
||||
'4': {"# #", "# #", "###", " #", " #"},
|
||||
'5': {"###", "# ", "###", " #", "###"},
|
||||
'6': {"###", "# ", "###", "# #", "###"},
|
||||
'7': {"###", " #", " #", " #", " #"},
|
||||
'8': {"###", "# #", "###", "# #", "###"},
|
||||
'9': {"###", "# #", "###", " #", "###"},
|
||||
}
|
||||
|
||||
// overlayGrid décode un PNG, y dessine la grille + les graduations, et renvoie le
|
||||
// PNG résultant. En cas d'erreur (image illisible), renvoie l'entrée inchangée.
|
||||
func overlayGrid(raw []byte) []byte {
|
||||
src, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
b := src.Bounds()
|
||||
img := image.NewRGBA(b)
|
||||
draw.Draw(img, b, src, b.Min, draw.Src)
|
||||
|
||||
line := color.RGBA{255, 0, 200, 130} // magenta translucide pour les lignes
|
||||
labelBG := color.RGBA{0, 0, 0, 200} // fond sombre derrière les chiffres
|
||||
labelFG := color.RGBA{255, 255, 0, 255} // chiffres jaunes (contraste sur toute page)
|
||||
w, h := b.Dx(), b.Dy()
|
||||
|
||||
// Lignes verticales + étiquette x en haut.
|
||||
for x := gridStep; x < w; x += gridStep {
|
||||
for y := 0; y < h; y++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+x+2, b.Min.Y+2, strconv.Itoa(x), labelFG, labelBG)
|
||||
}
|
||||
// Lignes horizontales + étiquette y à gauche.
|
||||
for y := gridStep; y < h; y += gridStep {
|
||||
for x := 0; x < w; x++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+2, b.Min.Y+y+2, strconv.Itoa(y), labelFG, labelBG)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return raw
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// blend applique une couleur semi-transparente (alpha) sur un pixel.
|
||||
func blend(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if !(image.Pt(x, y).In(img.Bounds())) {
|
||||
return
|
||||
}
|
||||
a := float64(c.A) / 255
|
||||
o := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(float64(c.R)*a + float64(o.R)*(1-a)),
|
||||
G: uint8(float64(c.G)*a + float64(o.G)*(1-a)),
|
||||
B: uint8(float64(c.B)*a + float64(o.B)*(1-a)),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
|
||||
// drawLabel écrit un nombre (police 3×5, échelle ×2) avec un fond sombre pour
|
||||
// rester lisible sur n'importe quel fond.
|
||||
func drawLabel(img *image.RGBA, x, y int, s string, fg, bg color.RGBA) {
|
||||
const sc = 2
|
||||
glyphW, glyphH, gap := 3*sc, 5*sc, sc
|
||||
boxW := len(s)*(glyphW+gap) + gap
|
||||
boxH := glyphH + 2*gap
|
||||
// Fond.
|
||||
for dy := -gap; dy < boxH-gap; dy++ {
|
||||
for dx := -gap; dx < boxW-gap; dx++ {
|
||||
setOpaque(img, x+dx, y+dy, bg)
|
||||
}
|
||||
}
|
||||
// Chiffres.
|
||||
cx := x
|
||||
for _, r := range s {
|
||||
g, ok := digits5x3[r]
|
||||
if !ok {
|
||||
cx += glyphW + gap
|
||||
continue
|
||||
}
|
||||
for row := 0; row < 5; row++ {
|
||||
for col := 0; col < 3; col++ {
|
||||
if g[row][col] != '#' {
|
||||
continue
|
||||
}
|
||||
for iy := 0; iy < sc; iy++ {
|
||||
for ix := 0; ix < sc; ix++ {
|
||||
setOpaque(img, cx+col*sc+ix, y+row*sc+iy, fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += glyphW + gap
|
||||
}
|
||||
}
|
||||
|
||||
func setOpaque(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if image.Pt(x, y).In(img.Bounds()) {
|
||||
img.SetRGBA(x, y, color.RGBA{c.R, c.G, c.B, 255})
|
||||
}
|
||||
}
|
||||
+231
-25
@@ -34,7 +34,10 @@ const (
|
||||
// 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
|
||||
// Descendu de 0.25 à 0.20 avec l'archivage recall : compacter plus large ne
|
||||
// coûte plus de perte (les gros blocs restent rappelables), donc autant faire
|
||||
// des compactions franches et rares plutôt que fréquentes et molles.
|
||||
compactTailFrac = 0.20
|
||||
// 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
|
||||
@@ -77,11 +80,54 @@ func ctxWindow() int {
|
||||
return 32768
|
||||
}
|
||||
|
||||
// compactSummaryBudget renvoie le budget en tokens du résumé, adapté à la
|
||||
// fenêtre. Assez pour PORTER LE FIL (la demande, les faits déjà trouvés, les
|
||||
// décisions, l'état d'avancement) sans jamais le perdre — c'était la plainte de
|
||||
// fond : à 700 tokens fixes, tout le passé d'une longue conversation était
|
||||
// écrasé en un demi-paragraphe et le modèle « perdait le fil ». Le résumé reste
|
||||
// de toute façon minuscule devant le torse compacté (souvent des dizaines de
|
||||
// milliers de tokens), donc la réduction demeure massive. Bornes : 700 (petites
|
||||
// fenêtres) à 1600 (grandes), ~4 % de la fenêtre entre les deux.
|
||||
func compactSummaryBudget() int {
|
||||
n := ctxWindow() * 4 / 100
|
||||
if n < 700 {
|
||||
n = 700
|
||||
}
|
||||
if n > 1600 {
|
||||
n = 1600
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// imageLostMarker remplace la partie `image_url` d'un message multimodal dans
|
||||
// msgText. Toute la chaîne de compaction passe par msgText — estimation de
|
||||
// contexte, choix des messages à résumer, transcript donné au résumeur — et
|
||||
// msgText n'extrayait QUE les parties `text`. Un message qui porte une capture
|
||||
// (screenshotImageMessage) ne pesait donc que sa légende (« Capture … »), et le
|
||||
// résumeur ne savait même pas qu'une image avait été montrée : au compactage
|
||||
// suivant l'image disparaissait SANS AUCUNE trace. Observé en amont comme cause
|
||||
// probable de boucles de raisonnement — le modèle a bien regardé l'image, ne
|
||||
// peut plus s'y référer, et redérive à l'aveugle en texte.
|
||||
//
|
||||
// Ce marqueur ne rend pas l'image récupérable (le résumé est du texte), il rend
|
||||
// sa PERTE visible : le résumeur peut la mentionner, et le modèle sait qu'il
|
||||
// doit REVOIR l'image plutôt que deviner.
|
||||
//
|
||||
// Formulation volontairement générale depuis l'arrivée de see_image : une image
|
||||
// peut désormais venir d'une capture (web_screenshot), d'une pièce jointe ou
|
||||
// d'un fichier du disque. Dire « reprends la capture » enverrait le modèle
|
||||
// photographier une page web alors que l'image perdue était un PNG sur le
|
||||
// disque — le geste juste dépend de la provenance, que la légende conservée
|
||||
// juste avant ce marqueur indique.
|
||||
const imageLostMarker = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]"
|
||||
|
||||
// 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.
|
||||
// de contexte et le transcript de compaction ne partent pas d'un texte vide, et
|
||||
// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans
|
||||
// quoi elle reste invisible à toute la chaîne de compaction.
|
||||
func msgText(m Message) string {
|
||||
switch v := m.Content.(type) {
|
||||
case string:
|
||||
@@ -89,20 +135,30 @@ func msgText(m Message) string {
|
||||
case []map[string]any:
|
||||
var b strings.Builder
|
||||
for _, part := range v {
|
||||
if part["type"] == "text" {
|
||||
switch part["type"] {
|
||||
case "text":
|
||||
if s, ok := part["text"].(string); ok {
|
||||
b.WriteString(s)
|
||||
}
|
||||
case "image_url":
|
||||
b.WriteString(imageLostMarker)
|
||||
}
|
||||
}
|
||||
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" {
|
||||
part, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch part["type"] {
|
||||
case "text":
|
||||
if s, ok := part["text"].(string); ok {
|
||||
b.WriteString(s)
|
||||
}
|
||||
case "image_url":
|
||||
b.WriteString(imageLostMarker)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
@@ -181,8 +237,13 @@ func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens in
|
||||
// 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.
|
||||
// - head : nb de messages protégés en tête = messages système, et RIEN d'autre.
|
||||
// Le tout premier message utilisateur y était épinglé pour « ancrer
|
||||
// l'objectif » ; c'était contre-productif. Après compaction, le modèle voyait
|
||||
// deux demandes explicites — celle du DÉBUT de la conversation, épinglée, et
|
||||
// celle réinjectée par `pending` — et répondait volontiers à l'ancienne, en
|
||||
// abandonnant la tâche en cours. La demande courante est déjà garantie par
|
||||
// `pending` ; le premier message, lui, part au résumé comme le reste.
|
||||
// - 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
|
||||
@@ -204,9 +265,6 @@ 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-- {
|
||||
@@ -238,13 +296,46 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
|
||||
torso := msgs[head:tailStart]
|
||||
|
||||
// 3. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
|
||||
// 3. Archivage des gros blocs (mémoire longue). AVANT de compacter, chaque bloc
|
||||
// du torse assez long est enregistré VERBATIM sous un id (r7…) : le résumé et
|
||||
// le repli le remplacent par une référence à cet id, que le modèle pourra
|
||||
// rappeler avec recall(id). C'est ce qui rend le compactage sûr même
|
||||
// agressif — rien n'est perdu, seulement déplacé hors du contexte.
|
||||
// On n'archive QUE si le modèle a réellement ses outils (mode agent) : sans
|
||||
// eux il ne pourrait pas rappeler, autant garder un résumé propre sans ids.
|
||||
// Un échec d'écriture (base absente, tests) laisse archived[i] nil → repli
|
||||
// transparent sur le comportement classique (troncature/marqueur sans id).
|
||||
archived := make([]*recallEntry, len(torso))
|
||||
var index []recallEntry
|
||||
if caps.Agent && compactEnabled() {
|
||||
names := torsoToolNames(torso)
|
||||
for i, m := range torso {
|
||||
if !recallEligible(m) {
|
||||
continue
|
||||
}
|
||||
content := msgText(m)
|
||||
label := recallLabel(m.Role, names[m.ToolCallID], content)
|
||||
id, aerr := archiveRecallBlock(label, m.Role, content)
|
||||
if aerr != nil {
|
||||
continue
|
||||
}
|
||||
archived[i] = &recallEntry{id: id, label: label}
|
||||
index = append(index, recallEntry{id: id, label: label})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 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.
|
||||
// PAS d'entrée au résumeur, cf. juste en dessous. Un bloc archivé pointe vers
|
||||
// son id (récupérable par recall) au lieu d'être effacé aveuglément.
|
||||
pruned := make([]Message, len(torso))
|
||||
for i, m := range torso {
|
||||
pruned[i] = m
|
||||
if e := archived[i]; e != nil {
|
||||
pruned[i].Content = recallMarker(e.id, e.label)
|
||||
continue
|
||||
}
|
||||
if m.Role == "tool" {
|
||||
if t := msgText(m); len(t) > compactToolPruneLen {
|
||||
pruned[i].Content = compactPrunedMarker
|
||||
@@ -252,7 +343,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Résumé du torse par le modèle local (un seul appel).
|
||||
// 5. 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
|
||||
@@ -262,12 +353,26 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
// 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é.
|
||||
// Un bloc archivé est réduit à sa TÊTE (assez pour le résumer fidèlement).
|
||||
// On ne montre PAS l'id recall au résumeur : sa seule tâche est d'écrire de
|
||||
// la prose. Lui exposer « recall:rN » l'incite à répondre juste par l'id
|
||||
// (résumé dégénéré) ; l'index des ids est de toute façon ajouté séparément
|
||||
// et de façon déterministe par compactSummaryUserMsg.
|
||||
// Les résultats d'outils non archivés 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 archived[i] != nil {
|
||||
r := []rune(msgText(m))
|
||||
headTxt := string(r)
|
||||
if len(r) > recallSummaryHeadLen {
|
||||
headTxt = string(r[:recallSummaryHeadLen]) + "…"
|
||||
}
|
||||
forSummary[i].Content = headTxt + "\n[…reste de ce bloc omis ici]"
|
||||
continue
|
||||
}
|
||||
if m.Role == "tool" {
|
||||
if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
|
||||
forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
|
||||
@@ -276,15 +381,20 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
}
|
||||
summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
|
||||
var mid []Message
|
||||
if err != nil || strings.TrimSpace(summary) == "" {
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
||||
// torse dégraissé, qui porte au moins les têtes de blocs et les marqueurs
|
||||
// recall : bien plus informatif qu'un résumé dégénéré.
|
||||
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).
|
||||
// Le message porte aussi la CONSCIENCE de la compaction et l'index des ids
|
||||
// rappelables (voir compactSummaryUserMsg).
|
||||
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."},
|
||||
{Role: "user", Content: compactSummaryUserMsg(summary, index)},
|
||||
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +437,94 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
return out, true
|
||||
}
|
||||
|
||||
// summaryLooksEmpty détecte un résumé raté : trop court, ou constitué seulement
|
||||
// de références « recall:rN » (un petit modèle, mal aiguillé, répond parfois par
|
||||
// un id au lieu d'écrire de la vraie prose). Dans ce cas on préfère le repli
|
||||
// dégraissé. Seuil à 40 caractères de VRAI texte (hors jetons recall).
|
||||
func summaryLooksEmpty(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if len([]rune(s)) < 40 {
|
||||
return true
|
||||
}
|
||||
var real strings.Builder
|
||||
for _, f := range strings.Fields(s) {
|
||||
low := strings.ToLower(strings.Trim(f, ".,;:()[]-•*"))
|
||||
if strings.HasPrefix(low, "recall:r") || strings.HasPrefix(low, "recall(") {
|
||||
continue
|
||||
}
|
||||
real.WriteString(f)
|
||||
real.WriteString(" ")
|
||||
}
|
||||
return len([]rune(strings.TrimSpace(real.String()))) < 40
|
||||
}
|
||||
|
||||
// recallEntry associe un id d'archive à son libellé court, pour l'index injecté
|
||||
// dans le message de compaction.
|
||||
type recallEntry struct{ id, label string }
|
||||
|
||||
// recallEligible dit si un message du torse mérite d'être archivé sous un id :
|
||||
// un bloc assez gros (le résumé ne le porterait pas fidèlement) d'un rôle réel,
|
||||
// et qui n'est pas déjà le résumé d'une compaction précédente (on ne ré-archive
|
||||
// pas un résumé).
|
||||
func recallEligible(m Message) bool {
|
||||
switch m.Role {
|
||||
case "tool", "user", "assistant":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
t := msgText(m)
|
||||
if len([]rune(t)) <= recallArchiveMinLen {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(t, compactSummaryPrefix)
|
||||
}
|
||||
|
||||
// torsoToolNames associe chaque ToolCallID du torse au nom de l'outil qui l'a
|
||||
// produit (l'assistant porte les tool_calls, le résultat ne porte que l'id),
|
||||
// pour donner un libellé lisible aux blocs archivés (« web_read: … »).
|
||||
func torsoToolNames(torso []Message) map[string]string {
|
||||
names := map[string]string{}
|
||||
for _, m := range torso {
|
||||
for _, tc := range m.ToolCalls {
|
||||
if tc.ID != "" {
|
||||
names[tc.ID] = tc.Function.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// recallMarker remplace un bloc archivé dans le torse dégraissé (repli). Il cite
|
||||
// l'id récupérable et interdit explicitement de relancer l'outil pour le refaire.
|
||||
func recallMarker(id, label string) string {
|
||||
return fmt.Sprintf("[Block archived to save context — full content retrievable with recall(\"%s\") (%s). Do NOT re-run a tool to fetch it back.]", id, label)
|
||||
}
|
||||
|
||||
// compactSummaryUserMsg construit le message `user` synthétique qui porte le
|
||||
// résumé. Il rend le modèle CONSCIENT que la conversation a été compactée (il le
|
||||
// sait aussi bien que nous), et lui donne l'index des blocs rappelables. L'index
|
||||
// ne liste QUE les ids créés à cette passe : sa taille reste bornée, une
|
||||
// conversation infinie ne gonfle jamais cette section (les vieux blocs restent
|
||||
// joignables par recall_search).
|
||||
func compactSummaryUserMsg(summary string, index []recallEntry) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(compactSummaryPrefix)
|
||||
if len(index) > 0 {
|
||||
b.WriteString(" The earlier turns of this conversation were summarized to save context, but nothing is lost: any block referenced below (or in the summary) as recall:rN can be brought back verbatim with the recall(id) tool, and recall_search(\"keywords\") finds older archived blocks not listed here. Here is the summary:\n\n")
|
||||
} else {
|
||||
b.WriteString(" The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n")
|
||||
}
|
||||
b.WriteString(summary)
|
||||
if len(index) > 0 {
|
||||
b.WriteString("\n\nArchived blocks you can bring back with recall(id):\n")
|
||||
for _, e := range index {
|
||||
fmt.Fprintf(&b, "- %s — %s\n", e.id, e.label)
|
||||
}
|
||||
b.WriteString("Only recall a block when you actually need its full content.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderTranscript sérialise le torse en texte lisible pour le résumeur.
|
||||
func renderTranscript(msgs []Message) string {
|
||||
var b strings.Builder
|
||||
@@ -383,11 +581,16 @@ Summarize densely and faithfully, keeping ONLY the essentials:
|
||||
- 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.
|
||||
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
|
||||
Write the summary in the SAME language as the conversation.`
|
||||
|
||||
// Le résumé part au MÊME endroit que le chat : sur un preset externe, il
|
||||
// s'appuie sur l'API distante (backend_external.go). Le laisser taper le
|
||||
// llama-server local ferait échouer toute compaction dès qu'aucun moteur
|
||||
// local ne tourne — et la compaction, c'est ce qui empêche le fil de mourir.
|
||||
ep := resolveChatEndpoint()
|
||||
payload := map[string]any{
|
||||
"model": "loki",
|
||||
"model": ep.Model,
|
||||
"messages": []Message{
|
||||
{Role: "system", Content: sys},
|
||||
{Role: "user", Content: transcript},
|
||||
@@ -396,20 +599,19 @@ Write the summary in the SAME language as the conversation.`
|
||||
"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,
|
||||
"max_tokens": compactSummaryBudget(),
|
||||
// Pas de réflexion pour un résumé : plus rapide, plus dense, et évite qu'un
|
||||
// modèle hybride gaspille tout le budget en <think> (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))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
authHeader(req)
|
||||
ep.auth(req.Header.Set)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", friendlyLLMError(err)
|
||||
@@ -417,7 +619,11 @@ Write the summary in the SAME language as the conversation.`
|
||||
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)))
|
||||
who := "llama-server"
|
||||
if ep.External {
|
||||
who = "API externe"
|
||||
}
|
||||
return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out summarizeResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
|
||||
@@ -20,10 +20,17 @@ func TestCompactBoundsProtectsHead(t *testing.T) {
|
||||
um("q2"), am("r2"),
|
||||
um("q3"), am("r3"),
|
||||
}
|
||||
// budget minuscule → queue = juste le dernier tour, tête = system + 1er user.
|
||||
// budget minuscule → queue = juste le dernier tour, tête = les seuls systèmes.
|
||||
// Le 1er message user N'EST PLUS épinglé : il l'était pour « ancrer
|
||||
// l'objectif », mais après compaction le modèle voyait deux demandes (celle du
|
||||
// début, épinglée, et la courante réinjectée par `pending`) et répondait
|
||||
// volontiers à l'ancienne. Voir compactBounds et TestCompactKeepsPendingRequest.
|
||||
head, tail := compactBounds(msgs, 1)
|
||||
if head != 2 { // system + premier user
|
||||
t.Fatalf("head = %d, attendu 2", head)
|
||||
if head != 1 { // le message système, et lui seul
|
||||
t.Fatalf("head = %d, attendu 1 (systèmes uniquement)", head)
|
||||
}
|
||||
if msgs[head].Role != "user" {
|
||||
t.Fatalf("le 1er message user doit être compactable, il commence le torse ; rôle obtenu %q", msgs[head].Role)
|
||||
}
|
||||
// Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin).
|
||||
if r := msgs[tail].Role; r != "user" && r != "assistant" {
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -28,8 +30,12 @@ import (
|
||||
// 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
|
||||
// réponse) ne doit pas se faire tronquer le début avant d'être compacté. 20000
|
||||
// n'y suffisait pas — un seul tour à très long raisonnement tronquait déjà le
|
||||
// journal de rejeu, et l'utilisateur perdait le DÉBUT de sa conversation à
|
||||
// l'écran. Le journal ne pèse que de la mémoire d'affichage, jamais du contexte
|
||||
// modèle : la marge est bon marché.
|
||||
const maxLogEvents = 200000
|
||||
|
||||
// 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
|
||||
@@ -56,6 +62,12 @@ type Conversation struct {
|
||||
Generating bool `json:"-"`
|
||||
cancel context.CancelFunc // annule la génération en cours (/stop)
|
||||
epoch int // incrémenté à chaque reset → invalide les abonnés
|
||||
// Tâche planifiée en cours (tasks_run.go). Le verrou de génération est le
|
||||
// SEUL point partagé entre une tâche de fond et le chat : ces deux champs
|
||||
// disent à l'interface POURQUOI elle est occupée, au lieu de laisser croire
|
||||
// à une génération fantôme dans la discussion ouverte.
|
||||
runningTaskID string
|
||||
runningTaskName string
|
||||
}
|
||||
|
||||
var conv = func() *Conversation {
|
||||
@@ -68,12 +80,70 @@ var conv = func() *Conversation {
|
||||
// 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.
|
||||
|
||||
// loadConvAttempts / loadConvRetryWait : au redémarrage du conteneur, l'ancien
|
||||
// process peut encore tenir le verrou bbolt quelques centaines de ms pendant que
|
||||
// le nouveau démarre. Quatre essais espacés de 250 ms couvrent ce chevauchement
|
||||
// sans retarder perceptiblement un démarrage normal (où le premier essai passe).
|
||||
const (
|
||||
loadConvAttempts = 4
|
||||
loadConvRetryWait = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// loadConvRetries compte les ATTENTES de reprise du dernier LoadConversation
|
||||
// (0 = le premier essai a suffi). Il n'existe que pour être observable.
|
||||
//
|
||||
// Le test de non-régression veut vérifier qu'une absence légitime — première
|
||||
// installation, rien d'enregistré — ne déclenche AUCUNE reprise. Il le mesurait
|
||||
// au chronomètre (« moins de 250 ms »), ce qui confond « aucune reprise » avec
|
||||
// « une seule tentative, mais lente » : sur un runner CI chargé, créer et
|
||||
// ouvrir la base bbolt a pris 435 ms et le test a échoué alors qu'aucune reprise
|
||||
// n'avait eu lieu. On compte donc les reprises au lieu de les déduire d'un
|
||||
// temps de mur, qui ne dépend pas que de nous.
|
||||
var loadConvRetries atomic.Int32
|
||||
|
||||
// LoadConversation recharge l'état persisté au démarrage du process. Sans état
|
||||
// enregistré (première fois) on part d'une conversation vide.
|
||||
//
|
||||
// Une lecture RATÉE n'est PAS une absence, et les confondre coûte cher ici :
|
||||
// getStr rend "" aussi bien pour « clé absente » que pour « base inaccessible »,
|
||||
// donc sur un verrou transitoire convEnsureActive croyait la discussion active
|
||||
// inexistante, forgeait un NOUVEL identifiant et l'écrasait — le fil en cours
|
||||
// devenait orphelin, en silence. On sonde donc la base avec son erreur AVANT
|
||||
// toute écriture, on réessaie, et en cas d'échec durable on le dit et on ne
|
||||
// touche à rien.
|
||||
func LoadConversation() {
|
||||
// convEnsureActive reprend au passage le fil unique des versions
|
||||
// précédentes (clé « conversation ») comme première discussion.
|
||||
b := getBytes(bkChat, convKey(convEnsureActive()))
|
||||
var b []byte
|
||||
var err error
|
||||
loadConvRetries.Store(0)
|
||||
retry := func() {
|
||||
loadConvRetries.Add(1)
|
||||
time.Sleep(loadConvRetryWait)
|
||||
}
|
||||
for attempt := 0; attempt < loadConvAttempts; attempt++ {
|
||||
// Sonde : lire la clé de la discussion active en gardant l'erreur. Tant
|
||||
// qu'elle échoue, convEnsureActive ne doit surtout pas être appelée.
|
||||
if _, err = getBytesErr(bkChat, ckActive); err != nil {
|
||||
retry()
|
||||
continue
|
||||
}
|
||||
// convEnsureActive reprend au passage le fil unique des versions
|
||||
// précédentes (clé « conversation ») comme première discussion.
|
||||
// getStoreBytesErr : même lecture, en DÉCHIFFRANT au besoin (mémoire
|
||||
// chiffrée). Verrouillé, elle rend (nil, nil) — pas une erreur d'accès :
|
||||
// on démarre alors sur un fil vide sans rien écraser, et tout revient au
|
||||
// déverrouillage.
|
||||
if b, err = getStoreBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
|
||||
break
|
||||
}
|
||||
retry()
|
||||
}
|
||||
if err != nil {
|
||||
// Toujours en échec : on le DIT au lieu de repartir à vide en silence, et
|
||||
// on abandonne le chargement sans rien écrire — un serveur qui refuse de
|
||||
// démarrer serait pire, et l'historique sur disque reste intact.
|
||||
fmt.Fprintf(os.Stderr, "[conv] base illisible au démarrage (%v) — aucune discussion chargée ; rien n'a été écrasé, l'historique est intact\n", err)
|
||||
return
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -107,6 +177,19 @@ func (c *Conversation) loadFrom(b []byte) {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// reloadEncryptedStores recharge la discussion depuis le disque après un
|
||||
// déverrouillage : verrouillée, la lecture au démarrage a rendu un fil vide.
|
||||
// On ne recharge QUE dans ce cas — écraser un fil déjà rempli ferait perdre le
|
||||
// tour en cours, qui n'a pas pu être persisté.
|
||||
func reloadEncryptedStores() {
|
||||
conv.mu.Lock()
|
||||
empty := len(conv.Log) == 0
|
||||
conv.mu.Unlock()
|
||||
if empty {
|
||||
LoadConversation()
|
||||
}
|
||||
}
|
||||
|
||||
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
|
||||
// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit
|
||||
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
|
||||
@@ -125,7 +208,10 @@ func (c *Conversation) persist() {
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
_ = putBytes(bkChat, convKey(id), b)
|
||||
// Chiffré si la mémoire l'est et qu'elle est déverrouillée. Verrouillée,
|
||||
// l'écriture est REFUSÉE plutôt que de remplacer un blob chiffré par du
|
||||
// clair — le fil de ce tour reste en RAM, rien n'est perdu sur disque.
|
||||
_ = putStoreBytes(bkChat, convKey(id), b)
|
||||
convTouchMeta(id, title, turns)
|
||||
}
|
||||
|
||||
@@ -210,6 +296,21 @@ func (c *Conversation) compactLogLocked() {
|
||||
bufKey, toks, ts0, seq0 = "", 0, 0, 0
|
||||
}
|
||||
for _, ev := range c.Log {
|
||||
// Outils : un appel s'écrit en de nombreux événements (annonce done=false,
|
||||
// frappe du corps, streaming des arguments) jusqu'au done=true, qui porte
|
||||
// déjà l'état FINAL (résultat + diff). Les intermédiaires ne servent qu'à
|
||||
// l'affichage EN DIRECT ; les garder dans le journal le faisait enfler sur
|
||||
// une conversation agentique (gros write, appels en chaîne) jusqu'à
|
||||
// maxLogEvents → troncature des plus VIEUX événements, donc perte des
|
||||
// premiers messages à l'affichage. On ne conserve donc que le done=true
|
||||
// (même politique que coalesceReplay au replay/export).
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
flush()
|
||||
if done, _ := tu["done"].(bool); done {
|
||||
out = append(out, ev)
|
||||
}
|
||||
continue
|
||||
}
|
||||
key := textKey(ev.Delta)
|
||||
if key == "" {
|
||||
flush()
|
||||
@@ -286,12 +387,42 @@ func (c *Conversation) state() map[string]any {
|
||||
turns++
|
||||
}
|
||||
}
|
||||
return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
|
||||
st := map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
|
||||
// Occupé PAR UNE TÂCHE : l'interface l'annonce (« tâche en cours ») plutôt que
|
||||
// d'afficher un tour qui n'apparaîtra jamais dans le fil.
|
||||
if c.runningTaskID != "" {
|
||||
st["task_id"] = c.runningTaskID
|
||||
st["task_name"] = c.runningTaskName
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// isGenerating dit si un tour est en cours. Sert à la LISTE des discussions :
|
||||
// elle doit pouvoir montrer laquelle travaille dès le chargement de la page,
|
||||
// sans attendre que le rejeu du flux SSE ait rattrapé son retard.
|
||||
func (c *Conversation) isGenerating() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.Generating
|
||||
}
|
||||
|
||||
// ErrBusy : une génération est déjà en cours (un seul tour à la fois).
|
||||
var ErrBusy = fmt.Errorf("génération en cours")
|
||||
|
||||
// busyReason explique le refus à l'utilisateur. Sans ça, un message envoyé
|
||||
// pendant qu'une TÂCHE planifiée tourne renvoyait « génération en cours » alors
|
||||
// que le fil est vide et que rien ne bouge à l'écran — incompréhensible. On
|
||||
// nomme la tâche et on dit comment reprendre la main.
|
||||
func (c *Conversation) busyReason() error {
|
||||
c.mu.Lock()
|
||||
name := c.runningTaskName
|
||||
c.mu.Unlock()
|
||||
if name == "" {
|
||||
return ErrBusy
|
||||
}
|
||||
return fmt.Errorf("la tâche planifiée « %s » occupe le modèle — attends la fin ou arrête-la (bouton stop)", name)
|
||||
}
|
||||
|
||||
// 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.")
|
||||
@@ -321,6 +452,15 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "Prends-en connaissance."
|
||||
}
|
||||
// Mode code : fige le rôle du tour (plan demandé → planner, sinon builder).
|
||||
// La détection de bascule (puce « passer en mode Code ? ») est émise après
|
||||
// la bulle utilisateur, plus bas.
|
||||
if caps.Code && caps.Role == "" {
|
||||
caps.Role = "builder"
|
||||
if wantsPlan(text) {
|
||||
caps.Role = "planner"
|
||||
}
|
||||
}
|
||||
// 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)})
|
||||
@@ -331,10 +471,22 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
// 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}
|
||||
// Nom du modèle qui va produire la réponse : journalisé avec la borne de
|
||||
// tour, donc rejoué au chargement — la carte de réponse garde son modèle
|
||||
// après un rafraîchissement, et un vieux tour garde le modèle de l'époque,
|
||||
// pas celui chargé aujourd'hui.
|
||||
if m := chatModelName(); m != "" {
|
||||
delta["model"] = m
|
||||
}
|
||||
if len(files) > 0 {
|
||||
delta["files"] = files
|
||||
}
|
||||
c.appendDelta(epoch, delta)
|
||||
// En mode chat, un message qui ressemble à une tâche de code fait
|
||||
// apparaître la puce « passer en mode Code ? » (une fois par discussion).
|
||||
if !caps.Code {
|
||||
maybeCodeHint(c, epoch, text)
|
||||
}
|
||||
c.persist()
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
@@ -343,12 +495,29 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
return nil
|
||||
}
|
||||
|
||||
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
|
||||
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
|
||||
func chatModelName() string {
|
||||
m := strings.TrimSpace(ReadConfig()["MODEL"])
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.LastIndexAny(m, "/\\"); i >= 0 {
|
||||
m = m[i+1:]
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(m), ".gguf") {
|
||||
m = m[:len(m)-len(".gguf")]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 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) {
|
||||
turnStart := time.Now()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
stale := c.epoch != epoch
|
||||
@@ -370,6 +539,17 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
// Notification Web Push : ce chemin (generate) ne sert QUE les tours
|
||||
// utilisateur — les tâches de fond passent par RunAutonomous et ont leur
|
||||
// propre notification — donc pas de doublon. Détaché : l'envoi HTTP vers
|
||||
// le service de push ne doit pas retenir la fin du tour. Corps générique
|
||||
// (pas d'extrait de réponse) : la notif transite par Apple/Google.
|
||||
//
|
||||
// ctx.Err() != nil = tour interrompu par un « stop » : pas de notification,
|
||||
// l'utilisateur est là et a coupé volontairement.
|
||||
if hasPushSubs() && ctx.Err() == nil {
|
||||
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
|
||||
}
|
||||
}()
|
||||
|
||||
// Snapshot de la vue modèle.
|
||||
@@ -391,15 +571,25 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// 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
|
||||
// Contexte du projet actif (description, index mémoire, index des trackers),
|
||||
// même traitement : injecté dans la vue envoyée, jamais persisté — voir
|
||||
// projectSystemMessages.
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
|
||||
// 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
|
||||
// Le comptage EXACT du contexte vient de `usage.prompt_tokens` (option
|
||||
// include_usage). Tous les moteurs ne le renvoient pas — un llama-server
|
||||
// récent a cessé de le faire, et la jauge est restée bloquée à zéro sur des
|
||||
// discussions de plusieurs dizaines de milliers de tokens. On note donc si
|
||||
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
|
||||
// déjà la compaction), approximative mais jamais absente.
|
||||
sawUsage := false
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
@@ -409,6 +599,16 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||
}
|
||||
// Taille réelle du résultat (l'aperçu n'en montre qu'un début) : le
|
||||
// compteur ~N tok de la bulle affiche la vraie valeur, pas celle de
|
||||
// l'aperçu.
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
}
|
||||
// Référence pour charger le reste à la demande (bouton « voir plus »).
|
||||
if ev.ToolUsed.ResultID != "" {
|
||||
tu["result_id"] = ev.ToolUsed.ResultID
|
||||
}
|
||||
// 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 != "" {
|
||||
@@ -419,6 +619,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
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
|
||||
@@ -439,6 +642,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// 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 {
|
||||
sawUsage = true
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
@@ -446,6 +650,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||
case ev.Ask != nil:
|
||||
// Question structurée (outil ask) : carte à boutons dans l'UI.
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||
case ev.Reasoning != "":
|
||||
@@ -476,6 +683,32 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
stale := c.epoch != epoch
|
||||
c.mu.Unlock()
|
||||
|
||||
// Moteur muet sur l'usage : on publie l'estimation, sinon la jauge reste à
|
||||
// zéro et l'utilisateur ne voit jamais son contexte se remplir — ni la
|
||||
// compaction arriver.
|
||||
if !stale && !sawUsage {
|
||||
est := estimateTokens(msgs)
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = est
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(epoch, map[string]any{"ctx_used": est})
|
||||
ctxUsed = est
|
||||
}
|
||||
|
||||
// Boucle du contrat (mode code) : vérification indépendante des critères,
|
||||
// corrections, re-vérification — AVANT de rendre la main. Voir
|
||||
// code_verify.go. No-op hors mode code ou sans critères.
|
||||
if !stale && ctx.Err() == nil {
|
||||
c.codeVerifyLoop(ctx, caps, temperature, epoch)
|
||||
c.mu.Lock()
|
||||
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
|
||||
@@ -557,9 +790,11 @@ func (c *Conversation) Stop() {
|
||||
// le bump d'epoch réduit de toute façon au silence.
|
||||
func (c *Conversation) Reset() {
|
||||
c.Stop()
|
||||
// Vider la discussion efface aussi ses captures : les messages qui les
|
||||
// affichaient disparaissent, les fichiers n'auraient plus aucun lecteur.
|
||||
dropConvCaptures(convEnsureActive())
|
||||
// Vider la discussion efface aussi ses fichiers (dépôts, captures, ce que
|
||||
// l'agent y a écrit) : les messages qui les mentionnaient disparaissent, plus
|
||||
// rien ne les rattacherait à quoi que ce soit. L'UI le dit avant de demander
|
||||
// confirmation.
|
||||
dropConvFiles(convEnsureActive())
|
||||
c.mu.Lock()
|
||||
c.Messages = nil
|
||||
c.Log = nil
|
||||
@@ -662,11 +897,15 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
|
||||
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()
|
||||
}
|
||||
// ⚠️ On ne « flushe » PAS l'annonce d'outil en attente sur un événement
|
||||
// non-outil. Un appel d'outil s'écrit dans le journal en DEUX temps : une
|
||||
// annonce (done=false, juste « je vais lancer X ») puis le résultat
|
||||
// (done=true). Si un événement non-outil (stats, un bout de raisonnement)
|
||||
// se glisse ENTRE les deux, flusher ici émettait l'annonce PUIS le done →
|
||||
// le même outil apparaissait DEUX fois (à l'export Markdown et au replay).
|
||||
// On garde donc l'annonce en attente : le done la remplace (émis une seule
|
||||
// fois), et si aucun done n'arrive jamais (outil interrompu par un stop),
|
||||
// le flushTool final l'émet une seule fois, en fin de fil.
|
||||
// Delta texte ? (une seule clé content ou reasoning_content, valeur string)
|
||||
key := ""
|
||||
if s, ok := ev.Delta["content"].(string); ok {
|
||||
@@ -713,7 +952,18 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
|
||||
// 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) {
|
||||
// replayTailMax borne le nombre d'événements REJOUÉS au chargement d'une
|
||||
// discussion. Ce n'est pas une troncature du journal (rien n'est effacé) : le
|
||||
// début reste sur disque et le client le demande d'un clic (full=true).
|
||||
//
|
||||
// Une discussion de plusieurs centaines de tours rejouait des dizaines de
|
||||
// milliers d'événements à chaque ouverture d'onglet : quelques secondes de voile
|
||||
// de chargement, et autant de bulles dans le DOM que le navigateur devait ensuite
|
||||
// traîner à chaque rendu. On rejoue la fin — ce qu'on vient lire — et on annonce
|
||||
// combien d'événements sont restés en arrière.
|
||||
const replayTailMax = 4000
|
||||
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit func(map[string]any) bool) {
|
||||
// Réveille les attentes de cond quand la connexion se ferme.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
@@ -737,8 +987,31 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
snapshot := append([]LogEvent(nil), c.Log...)
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
// ⚠️ Garde-fou anti-« premiers messages manquants au changement de session ».
|
||||
// Le client se réabonne avec from=lastSeq (le dernier Seq qu'il a vu). Les Seq
|
||||
// ne sont PAS globaux : ouvrir une session PLUS ANCIENNE charge un journal dont
|
||||
// les Seq sont plus bas. Si le client garde un from élevé (hérité de la session
|
||||
// qu'il quittait) et se reconnecte, coalesceReplay saute tout (Seq <= from) →
|
||||
// la conversation ouverte apparaît tronquée, voire vide. En session normale, le
|
||||
// client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il
|
||||
// l'est, son curseur vient d'une autre session → on repart du début.
|
||||
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
|
||||
from = 0
|
||||
}
|
||||
last := from
|
||||
for _, ev := range coalesceReplay(snapshot, from) {
|
||||
events := coalesceReplay(snapshot, from)
|
||||
// Replay borné : on ne garde que la fin, et on DIT au client combien
|
||||
// d'événements manquent devant (il affiche « charger le début »). Jamais
|
||||
// borné sur une reprise de flux (from > 0) : là, le client a déjà tout le
|
||||
// début à l'écran et attend la SUITE.
|
||||
if !full && from == 0 && len(events) > replayTailMax {
|
||||
hidden := len(events) - replayTailMax
|
||||
events = events[hidden:]
|
||||
if !emit(map[string]any{"replay_truncated": hidden}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -752,6 +1025,16 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
if !emit(map[string]any{"caught_up": true}) {
|
||||
return
|
||||
}
|
||||
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les
|
||||
// événements du fil, et `ctx_used` n'y figure qu'aux tours où il a changé —
|
||||
// souvent hors de la fenêtre rejouée. Sans cet envoi, toute page rechargée
|
||||
// affichait « 0 / N (0 %) » sur une discussion pourtant bien remplie.
|
||||
c.mu.Lock()
|
||||
used := c.CtxUsed
|
||||
c.mu.Unlock()
|
||||
if used > 0 && !emit(map[string]any{"ctx_used": used}) {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// resetConvForTest vide l'état en RAM pour forcer un VRAI rechargement depuis la
|
||||
// base, comme au démarrage d'un process qui ne connaît encore rien. `conv` est un
|
||||
// global du paquet : sans ça, un test verrait l'état laissé par le précédent.
|
||||
func resetConvForTest() {
|
||||
conv.mu.Lock()
|
||||
conv.Messages, conv.Log, conv.Seq, conv.CtxUsed = nil, nil, 0, 0
|
||||
conv.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestLoadConversationReessayeSousContention : la panne d'origine est un verrou
|
||||
// bbolt transitoire au redémarrage du conteneur (l'ancien process tient encore
|
||||
// le fichier pendant que le nouveau démarre), confondu avec « rien à charger ».
|
||||
//
|
||||
// Réserve honnête : ce test ne reproduit PAS la panne à coup sûr. withDB ouvre
|
||||
// déjà la base avec Options.Timeout de 5 s, qui absorbe seul une contention de
|
||||
// 400 ms — ce test passe donc aussi sur le code d'AVANT le correctif. Il est
|
||||
// gardé comme filet de non-régression sur le nouveau chemin (getBytesErr + boucle
|
||||
// de tentatives) : ce chemin ne doit ni perdre la conversation, ni se bloquer.
|
||||
func TestLoadConversationReessayeSousContention(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
// 1. Écrit une conversation par l'API normale (elle crée aussi la clé active).
|
||||
resetConvForTest()
|
||||
conv.mu.Lock()
|
||||
conv.Messages = []Message{{Role: "user", Content: "bonjour"}}
|
||||
conv.mu.Unlock()
|
||||
conv.persist()
|
||||
activeID := getStr(bkChat, ckActive)
|
||||
if activeID == "" {
|
||||
t.Fatal("persist n'a pas créé de discussion active — le montage du test est faux")
|
||||
}
|
||||
|
||||
// 2. État en RAM vidé : le rechargement doit tout reprendre depuis la base.
|
||||
resetConvForTest()
|
||||
|
||||
// 3. Verrou exclusif tenu une fenêtre courte dans une goroutine séparée : la
|
||||
// base est momentanément indisponible, exactement comme au chevauchement.
|
||||
db, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 2 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("impossible de prendre le verrou pour le test : %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
db.Close()
|
||||
}()
|
||||
|
||||
LoadConversation()
|
||||
wg.Wait()
|
||||
|
||||
conv.mu.Lock()
|
||||
got := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("conversation perdue malgré une contention temporaire : %d message(s), attendu 1", got)
|
||||
}
|
||||
if id := getStr(bkChat, ckActive); id != activeID {
|
||||
t.Fatalf("la discussion active a changé (%q → %q) : le fil d'origine est orphelin", activeID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConversationBaseIllisibleNeForgePasDeDiscussion garde l'invariant qui
|
||||
// justifie le correctif : convEnsureActive ÉCRIT (elle forge un identifiant et
|
||||
// l'enregistre sous bkChat/active). Elle ne doit donc jamais être appelée sur la
|
||||
// foi d'une lecture ratée — sinon un verrou durable suffisait à mettre le fil en
|
||||
// cours de côté au profit d'une discussion neuve, en silence.
|
||||
//
|
||||
// Réserve honnête, là encore : avec une base illisible l'écriture échoue elle
|
||||
// aussi, donc le disque finit identique dans les deux versions. Ce que ce test
|
||||
// verrouille, c'est le CONTRAT — sortir sans rien écrire, et sans faire attendre
|
||||
// indéfiniment — pas la reproduction du dégât.
|
||||
func TestLoadConversationBaseIllisibleNeForgePasDeDiscussion(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
// Fichier de base invalide : bolt.Open échoue tout de suite (pas d'attente de
|
||||
// verrou), ce qui exerce le chemin d'erreur sans allonger le test.
|
||||
path := filepath.Join(home, "loki.db")
|
||||
if err := os.WriteFile(path, []byte("ceci n'est pas une base bbolt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetConvForTest()
|
||||
start := time.Now()
|
||||
LoadConversation()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Budget : loadConvAttempts tentatives espacées de loadConvRetryWait, plus une
|
||||
// marge. Au-delà, la boucle de reprise retarderait le démarrage du serveur.
|
||||
if max := time.Duration(loadConvAttempts)*loadConvRetryWait + 2*time.Second; elapsed > max {
|
||||
t.Fatalf("démarrage retardé de %v par une base illisible (budget %v)", elapsed, max)
|
||||
}
|
||||
// Contrepartie de l'assertion « aucune reprise » du test d'absence légitime :
|
||||
// un compteur bloqué à zéro la rendrait vraie sans rien prouver. Ici les
|
||||
// reprises ont bel et bien lieu, et on le vérifie.
|
||||
if n := loadConvRetries.Load(); int(n) != loadConvAttempts {
|
||||
t.Fatalf("%d reprise(s) comptée(s) sur une base illisible, attendu %d", n, loadConvAttempts)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("la base a été modifiée alors qu'elle n'a même pas pu être lue")
|
||||
}
|
||||
conv.mu.Lock()
|
||||
n := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("%d message(s) chargés depuis une base illisible", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConversationAbsenceLegitimeResteRapide : le correctif ne doit pas avoir
|
||||
// transformé le cas NORMAL (base saine, rien d'enregistré encore — premier
|
||||
// démarrage) en attente inutile. Une seule tentative doit suffire.
|
||||
func TestLoadConversationAbsenceLegitimeResteRapide(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
resetConvForTest()
|
||||
|
||||
LoadConversation()
|
||||
|
||||
// Mesuré sur le COMPTEUR de reprises, pas au chronomètre : une absence
|
||||
// légitime doit sortir au premier essai. Le temps de mur ne disait pas ça —
|
||||
// il mêlait le coût de l'essai lui-même (création de la base bbolt, fsync)
|
||||
// à l'attente de reprise, et un runner CI chargé l'a fait échouer à 435 ms
|
||||
// alors qu'aucune reprise n'avait été déclenchée.
|
||||
if n := loadConvRetries.Load(); n != 0 {
|
||||
t.Fatalf("absence légitime traitée comme une erreur : %d reprise(s) déclenchée(s), aucune ne devait l'être", n)
|
||||
}
|
||||
conv.mu.Lock()
|
||||
n := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("%d message(s) chargés alors que rien n'avait été enregistré", n)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func TestSubscribeReplayAndLive(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 32)
|
||||
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestSubscribeFromOffset(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, 1, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 1, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func TestResetNotifiesSubscribers(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
resetSeen := make(chan bool, 4)
|
||||
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, false, func(m map[string]any) bool {
|
||||
if _, ok := m["reset"]; ok {
|
||||
resetSeen <- true
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
} {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, tc.from, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, tc.from, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -216,7 +216,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, 502, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 502, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -225,3 +225,134 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "suite"})
|
||||
waitSeq(t, got, 503)
|
||||
}
|
||||
|
||||
// --- Régressions v0.12.7 : journal d'affichage (outils, replay, changement de session) ---
|
||||
|
||||
// compactLogLocked doit coalescer les événements tool_used d'un même appel en un
|
||||
// seul (le done=true, qui porte l'état final) : les intermédiaires (annonce, frappe
|
||||
// du corps) ne servent qu'à l'affichage en direct et faisaient enfler le journal
|
||||
// jusqu'à la troncature des premiers messages.
|
||||
func TestCompactLogCoalesceTools(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Log = []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"user": "édite"}},
|
||||
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f"}}},
|
||||
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "typing": true, "body": "a"}}},
|
||||
{Seq: 4, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "result": "ok", "done": true}}},
|
||||
{Seq: 5, Delta: map[string]any{"content": "fini"}},
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.compactLogLocked()
|
||||
c.mu.Unlock()
|
||||
nTool := 0
|
||||
for _, ev := range c.Log {
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
nTool++
|
||||
if done, _ := tu["done"].(bool); !done {
|
||||
t.Error("un tool_used non terminé a survécu à la compaction")
|
||||
}
|
||||
}
|
||||
}
|
||||
if nTool != 1 {
|
||||
t.Fatalf("compactLogLocked garde %d tool_used au lieu de 1", nTool)
|
||||
}
|
||||
}
|
||||
|
||||
// coalesceReplay ne doit émettre un outil qu'UNE fois même si un événement non-outil
|
||||
// (stats, un bout de raisonnement) tombe entre l'annonce (done=false) et le done=true.
|
||||
func TestCoalesceReplayNoDoubleTool(t *testing.T) {
|
||||
events := []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls"}}},
|
||||
{Seq: 2, Delta: map[string]any{"stats": map[string]any{"gen_tokens": 3}}},
|
||||
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls", "result": "x", "done": true}}},
|
||||
}
|
||||
n := 0
|
||||
for _, ev := range coalesceReplay(events, 0) {
|
||||
if _, ok := ev["tool_used"]; ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("outil émis %d fois au replay (attendu 1)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Un abonné qui arrive avec un `from` SUPÉRIEUR au dernier Seq du journal (curseur
|
||||
// hérité d'une autre session, plus récente) doit obtenir le fil COMPLET, pas rien :
|
||||
// sans ce garde-fou, ouvrir une session plus ancienne la montrait tronquée/vide.
|
||||
func TestSubscribeStaleFromCrossSession(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "premier message"})
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "réponse"})
|
||||
// Le journal va jusqu'à Seq 2 ; on s'abonne avec from=9999 (curseur d'une autre session).
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var got []map[string]any
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.Subscribe(ctx, 9999, false, func(ev map[string]any) bool {
|
||||
got = append(got, ev)
|
||||
if ev["caught_up"] != nil {
|
||||
cancel()
|
||||
}
|
||||
return true
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
<-done
|
||||
seen := ""
|
||||
for _, ev := range got {
|
||||
if u, ok := ev["user"].(string); ok {
|
||||
seen += u
|
||||
}
|
||||
}
|
||||
if !strings.Contains(seen, "premier message") {
|
||||
t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// Replay borné : une discussion très longue ne rejoue que sa fin, annonce
|
||||
// combien d'événements manquent, et rend TOUT si le client le demande.
|
||||
func TestReplayTailIsBoundedUnlessFull(t *testing.T) {
|
||||
c := &Conversation{}
|
||||
c.cond = sync.NewCond(&c.mu)
|
||||
for i := 0; i < replayTailMax+50; i++ {
|
||||
c.Log = append(c.Log, LogEvent{Seq: i + 1, Delta: map[string]any{"user": "m"}})
|
||||
}
|
||||
|
||||
collect := func(full bool) (events int, hidden int) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
c.Subscribe(ctx, 0, full, func(m map[string]any) bool {
|
||||
if h, ok := m["replay_truncated"].(int); ok {
|
||||
hidden = h
|
||||
return true
|
||||
}
|
||||
if m["caught_up"] != nil {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
if _, ok := m["pad"]; ok {
|
||||
return true
|
||||
}
|
||||
events++
|
||||
return true
|
||||
})
|
||||
}()
|
||||
<-done
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
events, hidden := collect(false)
|
||||
if hidden != 50 {
|
||||
t.Fatalf("50 événements auraient dû être annoncés masqués, obtenu %d", hidden)
|
||||
}
|
||||
if events > replayTailMax {
|
||||
t.Fatalf("replay borné dépassé : %d événements rejoués", events)
|
||||
}
|
||||
if full, h := collect(true); full <= replayTailMax || h != 0 {
|
||||
t.Fatalf("full=true doit tout rejouer sans bandeau, obtenu %d événements / %d masqués", full, h)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package loki
|
||||
|
||||
// chat_convfiles.go — les fichiers rangés PAR DISCUSSION.
|
||||
//
|
||||
// Avant, tout ce qui passait par le chat — pièces jointes déposées, rapports
|
||||
// écrits par l'agent, captures de pages — atterrissait dans UN seul dossier de
|
||||
// travail partagé par toutes les discussions. Changer de discussion ne changeait
|
||||
// rien au panneau Fichiers (on revoyait les mêmes), et supprimer une discussion
|
||||
// laissait ses fichiers derrière elle — seules ses captures, déjà rangées par
|
||||
// identifiant, partaient avec.
|
||||
//
|
||||
// Désormais chaque discussion a son dossier :
|
||||
//
|
||||
// <workspace>/discussions/<id>/ ← dossier courant de l'agent
|
||||
// <workspace>/discussions/<id>/uploads/ ← pièces jointes de CETTE discussion
|
||||
// <workspace>/discussions/<id>/captures/ ← captures de CETTE discussion
|
||||
//
|
||||
// Supprimer une discussion (ou la vider) emporte tout le dossier, et le panneau
|
||||
// Fichiers n'ouvre que celui de la discussion active.
|
||||
//
|
||||
// La racine du dossier de travail reste la BORNE de sécurité : les liens écrits
|
||||
// dans les anciens messages (`uploads/x.pdf`, `captures/<id>/y.jpg`) pointent
|
||||
// encore vers elle et doivent continuer de marcher — c'est le rôle de
|
||||
// workspaceFile, qui cherche d'abord dans la discussion puis retombe sur les
|
||||
// emplacements d'avant.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
// convFilesRoot : le sous-dossier qui contient un dossier par discussion.
|
||||
// Nommé, plutôt que d'éparpiller les identifiants à la racine, pour que le
|
||||
// dossier de travail reste lisible quand on l'ouvre depuis l'hôte.
|
||||
convFilesRoot = "discussions"
|
||||
|
||||
// uploadsSub : les dépôts de l'utilisateur, à l'intérieur de la discussion.
|
||||
uploadsSub = "uploads"
|
||||
)
|
||||
|
||||
// safeConvID refuse tout identifiant qui ne soit pas un simple nom : c'est lui
|
||||
// qui devient un segment de chemin, et il arrive parfois du client (paramètre de
|
||||
// requête, ancien lien de message). Renvoie "" si l'identifiant est douteux.
|
||||
func safeConvID(id string) string {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return ""
|
||||
}
|
||||
for _, r := range id {
|
||||
if r == '-' || r == '_' || unicode.IsDigit(r) || (r < unicode.MaxASCII && unicode.IsLetter(r)) {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// convDirFor renvoie le dossier (absolu) d'une discussion, sans le créer. Un
|
||||
// identifiant vide ou douteux retombe sur la racine du dossier de travail :
|
||||
// mieux vaut écrire au mauvais endroit que refuser d'écrire.
|
||||
func convDirFor(convID string) string {
|
||||
id := safeConvID(convID)
|
||||
if id == "" {
|
||||
return agentWorkspace()
|
||||
}
|
||||
return filepath.Join(agentWorkspace(), convFilesRoot, id)
|
||||
}
|
||||
|
||||
// convWorkspace est le dossier de travail COURANT : celui de la discussion
|
||||
// active, créé au besoin. C'est là que résolvent les chemins relatifs du modèle
|
||||
// (resolveAgentPath), que démarre son shell, et que se déposent les fichiers.
|
||||
//
|
||||
// Si la création échoue (disque plein, droits), on retombe sur la racine du
|
||||
// dossier de travail : l'agent doit continuer d'écrire quelque part.
|
||||
func convWorkspace() string {
|
||||
dir := convDirFor(convEnsureActive())
|
||||
if dir == agentWorkspace() {
|
||||
return dir
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return agentWorkspace()
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// dropConvFiles efface TOUT le dossier d'une discussion (dépôts, captures,
|
||||
// fichiers écrits par l'agent). Appelé quand la discussion est supprimée ou
|
||||
// vidée : plus un message ne les mentionne, personne ne les retrouverait.
|
||||
// Best-effort — un échec ne doit rien interrompre.
|
||||
func dropConvFiles(convID string) {
|
||||
dir := convDirFor(convID)
|
||||
if dir == agentWorkspace() {
|
||||
return // identifiant douteux : surtout pas la racine
|
||||
}
|
||||
_ = os.RemoveAll(dir)
|
||||
}
|
||||
|
||||
// relWithin dit si `abs` se trouve DANS `root` et, si oui, renvoie son chemin
|
||||
// relatif en séparateurs '/'.
|
||||
//
|
||||
// EvalSymlinks des deux côtés : sans ça, un lien posé dans le dossier passerait
|
||||
// le test de préfixe tout en pointant ailleurs sur le disque.
|
||||
func relWithin(root, abs string) (string, bool) {
|
||||
if root == "" {
|
||||
return "", false
|
||||
}
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = r
|
||||
}
|
||||
if a, err := filepath.EvalSymlinks(abs); err == nil {
|
||||
abs = a
|
||||
}
|
||||
rel, err := filepath.Rel(root, abs)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return "", false
|
||||
}
|
||||
return filepath.ToSlash(rel), true
|
||||
}
|
||||
|
||||
// workspaceFile résout un chemin RELATIF venu d'un client ou d'un message
|
||||
// (`uploads/rapport.pdf`, `captures/xxx.jpg`) en fichier du disque.
|
||||
//
|
||||
// Ordre de recherche :
|
||||
// 1. le dossier de la discussion active — le cas normal ;
|
||||
// 2. les emplacements d'AVANT le rangement par discussion, pour que les liens
|
||||
// des anciens messages continuent d'ouvrir leur fichier.
|
||||
//
|
||||
// ok=false signale une sortie du périmètre (« .. »), pas une absence : un
|
||||
// fichier introuvable revient avec le chemin attendu, pour que l'appelant
|
||||
// réponde « introuvable » et non « hors du dossier de travail ».
|
||||
func workspaceFile(rel string) (string, bool) {
|
||||
rel = strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"), "/")
|
||||
if rel == "" {
|
||||
return "", false
|
||||
}
|
||||
base := convWorkspace()
|
||||
p := filepath.Join(base, filepath.FromSlash(rel))
|
||||
if _, ok := relWithin(base, p); !ok {
|
||||
return "", false
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p, true
|
||||
}
|
||||
if legacy, ok := legacyWorkspaceFile(rel); ok {
|
||||
return legacy, true
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
// legacyWorkspaceFile retrouve un fichier désigné par un chemin d'avant le
|
||||
// rangement par discussion. Deux formes existaient :
|
||||
//
|
||||
// captures/<id>/x.jpg → discussions/<id>/captures/x.jpg (déplacé par la migration)
|
||||
// uploads/x.pdf → resté à la racine si aucune discussion ne le réclamait
|
||||
func legacyWorkspaceFile(rel string) (string, bool) {
|
||||
if parts := strings.SplitN(rel, "/", 3); len(parts) == 3 && parts[0] == captureDir && safeConvID(parts[1]) != "" {
|
||||
p := filepath.Join(convDirFor(parts[1]), captureDir, filepath.FromSlash(parts[2]))
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
p := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
|
||||
if _, ok := workspaceRel(p); !ok {
|
||||
return "", false
|
||||
}
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// filesRoot donne la racine du panneau Fichiers pour un `scope` de requête :
|
||||
// la discussion active par défaut, la racine du dossier de travail pour
|
||||
// `scope=legacy` — celle où dorment les fichiers d'avant le rangement par
|
||||
// discussion, que la migration n'a pas su rattacher.
|
||||
func filesRoot(scope string) (root string, legacy bool) {
|
||||
if strings.TrimSpace(scope) == "legacy" {
|
||||
return agentWorkspace(), true
|
||||
}
|
||||
return convWorkspace(), false
|
||||
}
|
||||
|
||||
// legacyCount compte ce qui traîne à la racine du dossier de travail, hors
|
||||
// dossier des discussions. L'UI n'affiche le raccourci « hors discussion » que
|
||||
// si ce compte est non nul — il tombe à zéro une fois le ménage fait, et le
|
||||
// bouton disparaît de lui-même.
|
||||
func legacyCount() int {
|
||||
ents, err := os.ReadDir(agentWorkspace())
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, e := range ents {
|
||||
if e.Name() == convFilesRoot || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var convFilesMigrateOnce sync.Once
|
||||
|
||||
// migrateConvFiles range les fichiers des versions précédentes dans le dossier
|
||||
// de leur discussion. Une seule fois par process, au démarrage du serveur web.
|
||||
func migrateConvFiles() { convFilesMigrateOnce.Do(migrateConvFilesNow) }
|
||||
|
||||
func migrateConvFilesNow() {
|
||||
root := agentWorkspace()
|
||||
if root == "" {
|
||||
return
|
||||
}
|
||||
// 1. Les captures étaient DÉJÀ par discussion : captures/<id> → discussions/<id>/captures.
|
||||
// Un simple renommage, donc instantané quel que soit le nombre d'images.
|
||||
oldCaps := filepath.Join(root, captureDir)
|
||||
if ents, err := os.ReadDir(oldCaps); err == nil {
|
||||
for _, e := range ents {
|
||||
id := safeConvID(e.Name())
|
||||
if !e.IsDir() || id == "" {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(convDirFor(id), captureDir)
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
continue // déjà rangé (migration interrompue puis reprise)
|
||||
}
|
||||
if os.MkdirAll(filepath.Dir(dst), 0o755) != nil {
|
||||
continue
|
||||
}
|
||||
_ = os.Rename(filepath.Join(oldCaps, e.Name()), dst)
|
||||
}
|
||||
_ = os.Remove(oldCaps) // ne part que s'il est vide
|
||||
}
|
||||
// 2. Les dépôts, eux, étaient dans un pot commun. On rend chaque fichier à la
|
||||
// discussion qui le mentionne — l'historique garde le nom des pièces
|
||||
// jointes de chaque tour. Ce qui n'est réclamé par personne reste à la
|
||||
// racine : toujours téléchargeable (legacyWorkspaceFile) et visible via
|
||||
// « hors discussion », plutôt que rattaché au hasard.
|
||||
migrateLegacyUploads(filepath.Join(root, uploadsSub))
|
||||
}
|
||||
|
||||
func migrateLegacyUploads(src string) {
|
||||
ents, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
left := map[string]bool{}
|
||||
for _, e := range ents {
|
||||
if !e.IsDir() {
|
||||
left[e.Name()] = true
|
||||
}
|
||||
}
|
||||
for _, m := range convIndex() {
|
||||
if len(left) == 0 {
|
||||
break
|
||||
}
|
||||
for _, name := range convAttachNames(m.ID) {
|
||||
if !left[name] {
|
||||
continue
|
||||
}
|
||||
dst := filepath.Join(convDirFor(m.ID), uploadsSub)
|
||||
if os.MkdirAll(dst, 0o755) != nil {
|
||||
continue
|
||||
}
|
||||
if os.Rename(filepath.Join(src, name), filepath.Join(dst, name)) == nil {
|
||||
delete(left, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = os.Remove(src) // ne part que s'il est vide
|
||||
}
|
||||
|
||||
// convAttachNames relit les pièces jointes annoncées dans le journal d'affichage
|
||||
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
|
||||
// relie un fichier déposé à sa discussion.
|
||||
func convAttachNames(id string) []string {
|
||||
b := storeBytes(bkChat, convKey(id))
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
var st struct {
|
||||
Log []struct {
|
||||
Delta struct {
|
||||
Files []attachInfo `json:"files"`
|
||||
} `json:"delta"`
|
||||
} `json:"log"`
|
||||
}
|
||||
if json.Unmarshal(b, &st) != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, ev := range st.Log {
|
||||
for _, f := range ev.Delta.Files {
|
||||
if n := safeUploadName(f.Name); n != "" {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le cœur de la demande : deux discussions ne partagent pas leurs fichiers.
|
||||
// Avant, uploads/ était commun — on retrouvait les mêmes pièces jointes partout.
|
||||
func TestFichiersIsolesParDiscussion(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
|
||||
a := convEnsureActive()
|
||||
dirA, err := uploadsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, filepath.Join(dirA, "note-a.txt"), "aaa")
|
||||
|
||||
b := convNew() // b devient active
|
||||
if a == b {
|
||||
t.Fatal("convNew n'a pas créé de nouvelle discussion")
|
||||
}
|
||||
dirB, err := uploadsDir()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dirA == dirB {
|
||||
t.Fatalf("les deux discussions déposent au même endroit : %s", dirA)
|
||||
}
|
||||
writeFile(t, filepath.Join(dirB, "note-b.txt"), "bbb")
|
||||
|
||||
// La discussion ouverte ne voit QUE ses propres dépôts.
|
||||
names := listedNames(t, "uploads")
|
||||
if names["note-a.txt"] || !names["note-b.txt"] {
|
||||
t.Fatalf("la liste de la discussion b montre %v", names)
|
||||
}
|
||||
// Et le message ne peut pas joindre le fichier de l'autre discussion.
|
||||
if got := attachFiles([]string{"uploads/note-a.txt"}); len(got) != 0 {
|
||||
t.Fatalf("pièce jointe d'une autre discussion retenue : %+v", got)
|
||||
}
|
||||
if got := attachFiles([]string{"uploads/note-b.txt"}); len(got) != 1 {
|
||||
t.Fatalf("pièce jointe de la discussion courante refusée : %+v", got)
|
||||
}
|
||||
|
||||
// Retour sur a : ses fichiers sont là, ceux de b ont disparu de la vue.
|
||||
if err := convSwitch(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names = listedNames(t, "uploads")
|
||||
if !names["note-a.txt"] || names["note-b.txt"] {
|
||||
t.Fatalf("la liste de la discussion a montre %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer une discussion emporte TOUS ses fichiers, pas seulement ses
|
||||
// captures — et surtout pas ceux des autres.
|
||||
func TestConvDeleteSupprimeLesFichiers(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
|
||||
a := convEnsureActive()
|
||||
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
|
||||
writeFile(t, filepath.Join(ws, "rapport.md"), "rapport")
|
||||
|
||||
b := convNew()
|
||||
autre := convWorkspace()
|
||||
writeFile(t, filepath.Join(autre, "garde-moi.txt"), "ok")
|
||||
|
||||
if err := convDelete(a); err != nil {
|
||||
t.Fatalf("suppression : %v", err)
|
||||
}
|
||||
if _, err := os.Stat(ws); !os.IsNotExist(err) {
|
||||
t.Fatalf("le dossier de la discussion supprimée est toujours là (%v)", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(autre, "garde-moi.txt")); err != nil {
|
||||
t.Fatalf("les fichiers d'une AUTRE discussion ont été supprimés : %v", err)
|
||||
}
|
||||
if convEnsureActive() != b {
|
||||
t.Fatalf("discussion active = %q, attendu %q", convEnsureActive(), b)
|
||||
}
|
||||
}
|
||||
|
||||
// Vider une discussion (« clear chat ») efface aussi ses fichiers : les messages
|
||||
// qui les mentionnaient partent avec.
|
||||
func TestResetSupprimeLesFichiersDeLaDiscussion(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
|
||||
|
||||
conv.Reset()
|
||||
|
||||
if _, err := os.Stat(filepath.Join(ws, "uploads", "joint.pdf")); !os.IsNotExist(err) {
|
||||
t.Fatalf("le fichier de la discussion vidée a survécu (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Un identifiant de discussion douteux ne doit JAMAIS devenir un segment de
|
||||
// chemin : dropConvFiles effacerait alors la racine du dossier de travail.
|
||||
func TestDropConvFilesRefuseUnIdDouteux(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "a.txt"), "a")
|
||||
racine := agentWorkspace()
|
||||
|
||||
for _, bad := range []string{"", ".", "..", "../..", "c1/../..", "/etc", `..\..`} {
|
||||
dropConvFiles(bad)
|
||||
if _, err := os.Stat(racine); err != nil {
|
||||
t.Fatalf("id %q a détruit le dossier de travail : %v", bad, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ws, "a.txt")); err != nil {
|
||||
t.Fatalf("fichier de la discussion détruit : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Les liens des anciens messages doivent continuer d'ouvrir leur fichier : ils
|
||||
// pointent vers uploads/ et captures/<id>/ à la RACINE du dossier de travail.
|
||||
func TestTelechargementDesCheminsDAvant(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
racine := agentWorkspace()
|
||||
id := convEnsureActive()
|
||||
|
||||
writeFile(t, filepath.Join(racine, "uploads", "ancien.txt"), "avant")
|
||||
// Capture d'avant, déplacée par la migration là où elle est désormais rangée.
|
||||
writeFile(t, filepath.Join(convDirFor(id), captureDir, "vue.jpg"), "jpeg")
|
||||
|
||||
for path, want := range map[string]string{
|
||||
"uploads/ancien.txt": "avant",
|
||||
captureDir + "/" + id + "/vue.jpg": "jpeg",
|
||||
captureDir + "/vue.jpg": "jpeg", // le chemin d'aujourd'hui
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(path), nil))
|
||||
if rec.Code != 200 || rec.Body.String() != want {
|
||||
t.Errorf("path=%q : code %d, corps %q", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La migration range les fichiers des versions précédentes : les captures par
|
||||
// leur identifiant, les dépôts par la discussion qui les mentionne. Ce que
|
||||
// personne ne réclame reste à la racine, atteignable par « hors discussion ».
|
||||
func TestMigrationRangeLesFichiersDAvant(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
racine := agentWorkspace()
|
||||
|
||||
a := convEnsureActive()
|
||||
// Un tour avec pièce jointe : c'est cette trace, dans le journal de la
|
||||
// discussion, qui rattache le fichier à elle.
|
||||
state, _ := json.Marshal(map[string]any{
|
||||
"log": []map[string]any{{"seq": 1, "delta": map[string]any{
|
||||
"user": "regarde",
|
||||
"files": []attachInfo{{Name: "joint.pdf", Path: "uploads/joint.pdf", Size: 3}},
|
||||
}}},
|
||||
})
|
||||
if err := putBytes(bkChat, convKey(a), state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(racine, "uploads", "joint.pdf"), "pdf")
|
||||
writeFile(t, filepath.Join(racine, "uploads", "orphelin.bin"), "??")
|
||||
writeFile(t, filepath.Join(racine, captureDir, a, "vue.jpg"), "jpeg")
|
||||
|
||||
// La migration ne joue qu'une fois par process : on rearme le verrou pour
|
||||
// pouvoir l'observer ici.
|
||||
convFilesMigrateOnce = sync.Once{}
|
||||
migrateConvFiles()
|
||||
|
||||
if _, err := os.Stat(filepath.Join(convDirFor(a), uploadsSub, "joint.pdf")); err != nil {
|
||||
t.Errorf("le dépôt n'a pas rejoint sa discussion : %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(convDirFor(a), captureDir, "vue.jpg")); err != nil {
|
||||
t.Errorf("la capture n'a pas rejoint sa discussion : %v", err)
|
||||
}
|
||||
// Le fichier que personne ne réclame reste lisible plutôt que rattaché au hasard.
|
||||
if _, err := os.Stat(filepath.Join(racine, uploadsSub, "orphelin.bin")); err != nil {
|
||||
t.Errorf("le dépôt orphelin a été perdu : %v", err)
|
||||
}
|
||||
if legacyCount() == 0 {
|
||||
t.Error("le reliquat hors discussion n'est pas signalé à l'UI")
|
||||
}
|
||||
}
|
||||
|
||||
// La vue « hors discussion » ne doit pas devenir une porte dérobée vers les
|
||||
// fichiers des autres discussions.
|
||||
func TestScopeLegacyNeDonnePasLesDiscussions(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
racine := agentWorkspace()
|
||||
writeFile(t, filepath.Join(ws, "secret.txt"), "x")
|
||||
writeFile(t, filepath.Join(racine, "ancien.txt"), "y")
|
||||
|
||||
out := listScope(t, "", "legacy")
|
||||
if out["ok"] != true {
|
||||
t.Fatalf("liste refusée : %v", out)
|
||||
}
|
||||
for _, e := range out["entries"].([]any) {
|
||||
if e.(map[string]any)["name"] == convFilesRoot {
|
||||
t.Fatalf("le dossier des discussions est listé : %v", out)
|
||||
}
|
||||
}
|
||||
// Et il ne se supprime pas d'un clic : ce serait toutes les discussions.
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
|
||||
strings.NewReader(`{"path":`+jsonQuote(convFilesRoot)+`,"scope":"legacy"}`)))
|
||||
if rec.Code == http.StatusOK {
|
||||
t.Errorf("suppression du dossier des discussions acceptée : %s", rec.Body.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ws, "secret.txt")); err != nil {
|
||||
t.Fatalf("fichier d'une discussion supprimé : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// listedNames renvoie les noms listés dans un dossier du panneau.
|
||||
func listedNames(t *testing.T, dir string) map[string]bool {
|
||||
t.Helper()
|
||||
out := listFiles(t, dir)
|
||||
names := map[string]bool{}
|
||||
if out["ok"] != true {
|
||||
return names
|
||||
}
|
||||
for _, e := range out["entries"].([]any) {
|
||||
names[e.(map[string]any)["name"].(string)] = true
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func listScope(t *testing.T, dir, scope string) map[string]any {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handleChatFiles(rec, httptest.NewRequest("GET",
|
||||
"/api/chat/files?dir="+url.QueryEscape(dir)+"&scope="+url.QueryEscape(scope), nil))
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
out["_code"] = rec.Code
|
||||
return out
|
||||
}
|
||||
@@ -103,12 +103,78 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
|
||||
Version: Version,
|
||||
ExportedAt: time.Now().Format(time.RFC3339),
|
||||
CtxUsed: ctxUsed,
|
||||
Messages: msgs,
|
||||
Messages: stripInlineImages(msgs),
|
||||
Log: filterLog(log, o),
|
||||
}
|
||||
return json.MarshalIndent(p, "", " ")
|
||||
}
|
||||
|
||||
// stripInlineImages remplace les images en base64 (data URI) portées par les
|
||||
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
|
||||
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
|
||||
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
|
||||
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
|
||||
// d'affichage non plus (il ne porte pas les images). On ne touche donc qu'ici, et
|
||||
// sur une COPIE : les messages appartiennent à la conversation vivante.
|
||||
func stripInlineImages(msgs []Message) []Message {
|
||||
out := make([]Message, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out[i] = m
|
||||
out[i].Content = stripInlineImagesValue(m.Content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripInlineImagesValue(v any) any {
|
||||
// Une partie image (format OpenAI) : {"type":"image_url","image_url":{"url":"data:…"}}.
|
||||
// On copie la partie et on élide l'URL data: en gardant l'en-tête (type MIME) lisible.
|
||||
elide := func(part map[string]any) map[string]any {
|
||||
cp := make(map[string]any, len(part))
|
||||
for k, val := range part {
|
||||
cp[k] = val
|
||||
}
|
||||
if iu, ok := cp["image_url"].(map[string]any); ok {
|
||||
c2 := make(map[string]any, len(iu))
|
||||
for k, val := range iu {
|
||||
c2[k] = val
|
||||
}
|
||||
if url, ok := c2["url"].(string); ok && strings.HasPrefix(url, "data:") {
|
||||
c2["url"] = elideDataURI(url)
|
||||
}
|
||||
cp["image_url"] = c2
|
||||
}
|
||||
return cp
|
||||
}
|
||||
switch parts := v.(type) {
|
||||
case []map[string]any: // message fraîchement produit
|
||||
out := make([]map[string]any, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = elide(p)
|
||||
}
|
||||
return out
|
||||
case []any: // message relu depuis la base (JSON) : []any de maps
|
||||
out := make([]any, len(parts))
|
||||
for i, p := range parts {
|
||||
if mp, ok := p.(map[string]any); ok {
|
||||
out[i] = elide(mp)
|
||||
} else {
|
||||
out[i] = p
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return v // simple chaîne (cas courant) : rien à faire
|
||||
}
|
||||
|
||||
// elideDataURI garde l'en-tête « data:<mime>;base64, » (lisible) et remplace le
|
||||
// corps base64 par sa taille, pour que l'export reste informatif sans le poids.
|
||||
func elideDataURI(url string) string {
|
||||
if i := strings.Index(url, ","); i >= 0 {
|
||||
return url[:i+1] + fmt.Sprintf("<élidé — %s>", humanBytes(int64(len(url)-i-1)))
|
||||
}
|
||||
return "<image élidée>"
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -258,3 +258,35 @@ func TestCmdExportEcritLeFichier(t *testing.T) {
|
||||
t.Fatal("option inconnue acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// L'export JSON d'un message multimodal (vision) doit ÉLIDER le base64 des images :
|
||||
// sinon le fichier pesait plusieurs Mo par image. Le Markdown, lui, n'a jamais porté
|
||||
// les images (juste les noms de fichiers).
|
||||
func TestExportJSONElideImages(t *testing.T) {
|
||||
t.Cleanup(func() { conv.Reset() })
|
||||
big := strings.Repeat("A", 300000)
|
||||
conv.mu.Lock()
|
||||
conv.Messages = []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64," + big}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
conv.Log = []LogEvent{{Seq: 1, TS: 1, Delta: map[string]any{"user": "regarde"}}}
|
||||
conv.Seq = 1
|
||||
conv.mu.Unlock()
|
||||
b, err := conv.ExportJSON(defaultExportOpts())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), big) {
|
||||
t.Error("le base64 brut d'une image est encore présent dans le JSON")
|
||||
}
|
||||
if len(b) > 10000 {
|
||||
t.Errorf("JSON toujours énorme malgré l'élision : %d octets", len(b))
|
||||
}
|
||||
if !strings.Contains(string(b), "élidé") {
|
||||
t.Error("marqueur d'élision absent du JSON")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,8 @@ func webGrepTool() Tool {
|
||||
// ─── 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
|
||||
// toolMaxOutput (8000) pour le shell. (La mémoire et MCP, eux, renvoient tout
|
||||
// au modèle et laissent l'UI charger l'aperçu.) 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.
|
||||
|
||||
+123
-46
@@ -3,6 +3,7 @@ package loki
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -88,8 +89,15 @@ func MemList() []MemPage {
|
||||
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))})
|
||||
// Page chiffrée + mémoire verrouillée : on la LISTE quand même (elle
|
||||
// existe, la cacher ferait croire à une perte) avec un titre qui dit
|
||||
// pourquoi son contenu n'apparaît pas.
|
||||
txt, ok := memPageText(e.Name())
|
||||
title := titleOf(txt)
|
||||
if !ok {
|
||||
title = "🔒 (chiffré — mémoire verrouillée)"
|
||||
}
|
||||
out = append(out, MemPage{Name: e.Name(), Title: title})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
@@ -98,36 +106,92 @@ func MemList() []MemPage {
|
||||
// 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.
|
||||
//
|
||||
// Classement en deux temps, pour ne PAS laisser une grosse page bourrée d'un mot
|
||||
// fréquent écraser une petite page qui matche VRAIMENT la requête :
|
||||
// 1. Couverture d'abord : les pages qui contiennent le PLUS de termes distincts
|
||||
// de la requête passent devant (chercher « copine Nathan » doit remonter la
|
||||
// page qui a les deux mots, pas celle qui répète « Nathan » 40 fois).
|
||||
// 2. À couverture égale, score pondéré par la RARETÉ du terme (IDF) : un mot
|
||||
// rare et discriminant (« copine ») pèse bien plus qu'un mot omniprésent
|
||||
// (« Nathan »). La fréquence est amortie (log) pour que le bourrage ne gagne
|
||||
// pas, et un match dans le NOM ou le TITRE est fortement bonifié.
|
||||
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
|
||||
terms := uniqueTerms(strings.ToLower(query))
|
||||
|
||||
type doc struct {
|
||||
p MemPage
|
||||
name string // minuscules
|
||||
title string // minuscules
|
||||
content string // brut (pour l'extrait)
|
||||
hay string // minuscules : nom + contenu
|
||||
}
|
||||
var ranked []scored
|
||||
docs := make([]doc, 0)
|
||||
df := make(map[string]int) // nb de pages contenant chaque terme
|
||||
for _, p := range MemList() {
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
|
||||
content := string(b)
|
||||
hay := strings.ToLower(p.Name + "\n" + content)
|
||||
score := 0
|
||||
content, _ := memPageText(p.Name) // "" si chiffré et verrouillé : matche alors sur le nom seul
|
||||
d := doc{
|
||||
p: p,
|
||||
name: strings.ToLower(p.Name),
|
||||
title: strings.ToLower(p.Title),
|
||||
content: content,
|
||||
hay: strings.ToLower(p.Name + "\n" + content),
|
||||
}
|
||||
docs = append(docs, d)
|
||||
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 strings.Contains(d.hay, t) {
|
||||
df[t]++
|
||||
}
|
||||
}
|
||||
if score == 0 && query != "" {
|
||||
}
|
||||
n := len(docs)
|
||||
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
matched int // termes distincts trouvés (clé de tri primaire)
|
||||
score float64 // pertinence pondérée (clé secondaire)
|
||||
}
|
||||
var ranked []scored
|
||||
for _, d := range docs {
|
||||
matched := 0
|
||||
score := 0.0
|
||||
for _, t := range terms {
|
||||
cnt := strings.Count(d.hay, t)
|
||||
if cnt == 0 {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
// IDF : rare = discriminant. +1 pour qu'un terme fréquent compte encore.
|
||||
idf := math.Log(float64(n+1)/float64(df[t]+1)) + 1
|
||||
// Fréquence amortie : 10 occurrences ne valent pas 10x une occurrence.
|
||||
tf := 1.0 + math.Log(float64(cnt))
|
||||
field := 1.0
|
||||
if strings.Contains(d.name, t) {
|
||||
field += 4 // le terme est dans le NOM de la page
|
||||
}
|
||||
if strings.Contains(d.title, t) {
|
||||
field += 2 // ... ou dans son TITRE
|
||||
}
|
||||
score += idf * tf * field
|
||||
}
|
||||
if matched == 0 && len(terms) > 0 {
|
||||
continue
|
||||
}
|
||||
ranked = append(ranked, scored{
|
||||
hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)},
|
||||
score: score,
|
||||
hit: MemHit{File: d.p.Name, Title: d.p.Title, Snippet: snippetAround(d.content, terms)},
|
||||
matched: matched,
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].matched != ranked[j].matched {
|
||||
return ranked[i].matched > ranked[j].matched // couverture d'abord
|
||||
}
|
||||
return ranked[i].score > ranked[j].score
|
||||
})
|
||||
out := []MemHit{}
|
||||
for i, r := range ranked {
|
||||
if i >= limit {
|
||||
@@ -138,6 +202,19 @@ func MemSearch(query string, limit int) []MemHit {
|
||||
return out
|
||||
}
|
||||
|
||||
// uniqueTerms découpe une requête en mots distincts (dédupliqués, ordre gardé).
|
||||
func uniqueTerms(q string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, t := range strings.Fields(q) {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
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), " ")
|
||||
@@ -175,12 +252,11 @@ func snippetAround(content string, terms []string) string {
|
||||
// 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)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return "", fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return "", fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
@@ -207,11 +283,14 @@ func MemAdd(name, content string) error {
|
||||
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 {
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
return os.WriteFile(p, []byte(body), 0o644)
|
||||
// L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y
|
||||
// apparaît toujours, même si le modèle oublie de l'y écrire.
|
||||
memIndexAdd(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemEdit remplace oldText par newText dans une page. oldText doit apparaître
|
||||
@@ -221,12 +300,11 @@ func MemAdd(name, content string) error {
|
||||
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)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
content := string(b)
|
||||
@@ -246,17 +324,13 @@ func MemEdit(name, oldText, newText string) error {
|
||||
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)
|
||||
return writeMemFile(name, []byte(updated))
|
||||
}
|
||||
|
||||
// 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)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -276,15 +350,14 @@ func MemSave(name, old, content string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée,
|
||||
// nouvelle ajoutée). Une simple sauvegarde n'ajoute que si la ligne manquait.
|
||||
memIndexRename(old, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemDelete supprime une page mémoire.
|
||||
@@ -296,7 +369,11 @@ func MemDelete(name string) error {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return fmt.Errorf("introuvable")
|
||||
}
|
||||
return os.Remove(p)
|
||||
if err := os.Remove(p); err != nil {
|
||||
return err
|
||||
}
|
||||
memIndexRemove(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Reproduit le bug rapporté : une petite page dont le NOM et le TITRE contiennent
|
||||
// « copine » ne remontait pas pour la requête « copine Nathan », noyée par de
|
||||
// grosses pages qui répètent « Nathan » des dizaines de fois. Le classement doit
|
||||
// mettre en tête la page qui matche le PLUS de termes distincts (couverture),
|
||||
// puis pondérer par la rareté.
|
||||
func TestMemSearchCoverageBeatsFrequency(t *testing.T) {
|
||||
testHome(t)
|
||||
|
||||
// Deux pages « bruyantes » bourrées de « Nathan », sans « copine ».
|
||||
if err := MemAdd("business-nathan.md", "# Business Nathan\n"+strings.Repeat("Nathan veut un business. ", 40)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("guide-ajean.md", "# Guide AJEAN\n"+strings.Repeat("projet de Nathan. ", 30)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// La petite page cible : matche les DEUX termes de la requête.
|
||||
if err := MemAdd("profil-copine-nathan.md", "# Profil : Copine de Nathan\n- Prénom : Bao\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hits := MemSearch("copine Nathan", 8)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("aucun résultat pour « copine Nathan »")
|
||||
}
|
||||
if hits[0].File != "profil-copine-nathan.md" {
|
||||
var names []string
|
||||
for _, h := range hits {
|
||||
names = append(names, h.File)
|
||||
}
|
||||
t.Fatalf("la page copine devrait être 1re (matche les 2 termes), obtenu ordre : %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une requête mono-terme rare doit privilégier un match dans le nom/titre.
|
||||
func TestMemSearchNameTitleBoost(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = MemAdd("notes-diverses.md", "# Notes\nUn jour Bao est passée dire bonjour, puis repartie.\n")
|
||||
_ = MemAdd("profil-copine.md", "# Profil : Copine\n- Prénom : Bao\n")
|
||||
hits := MemSearch("copine", 8)
|
||||
if len(hits) == 0 || hits[0].File != "profil-copine.md" {
|
||||
t.Fatalf("la page dont le nom/titre porte « copine » devrait être 1re, obtenu : %+v", hits)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,36 @@ import (
|
||||
//
|
||||
// Approximation 1 tok ≈ 4 caractères : suffisante pour une alerte, et sans
|
||||
// dépendance à un tokenizer.
|
||||
const promptCharBudget = 7500 // ~1875 tokens, tout allumé
|
||||
//
|
||||
// Le budget n'a été relevé que pour de VRAIS outils nouveaux, jamais pour des
|
||||
// lignes de prompt — et chaque palier dit lequel :
|
||||
//
|
||||
// 7500 → 7900 : recall + recall_search (chat_recall.go), ~720 car. Le compactage
|
||||
// cesse d'être destructif : le modèle ne relance plus les lectures
|
||||
// et recherches déjà faites. Il récupère bien plus qu'il ne coûte.
|
||||
// 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre
|
||||
// actions — quatre outils auraient coûté quatre fois ça.
|
||||
// 8600 → 11000 : les quatre outils task_* (tasks_tools.go), ~1290 car. L'IA se
|
||||
// donne elle-même ses rappels et ses veilles ; sans eux il fallait
|
||||
// passer par l'interface. Descriptions déjà au plus court, et un
|
||||
// seul schéma par action (create/update/delete ne partagent pas
|
||||
// assez d'arguments pour fusionner sans rendre chacun ambigu).
|
||||
// Les ~500 car restants couvrent web_screenshot, déclaré dès que
|
||||
// Playwright est présent — c'est le cas dans l'image, pas sur le
|
||||
// runner de CI : le budget doit tenir pour l'image, la vraie.
|
||||
// (Le pilotage de navigateur, lui, est HORS de ce décompte : ses
|
||||
// schémas n'existent que si on arme son interrupteur.)
|
||||
//
|
||||
// Les ~200 car de prompt qui les accompagnent sont le seul écart
|
||||
// assumé à la règle « pas de relèvement pour du prompt » : un outil
|
||||
// qu'aucune ligne ne mentionne n'est jamais appelé de lui-même, et
|
||||
// la ligne dit ce que les schémas ne disent pas (quand se planifier,
|
||||
// et qu'un script planifié ne charge aucun modèle). Elle a été
|
||||
// ramenée de trois lignes à une.
|
||||
//
|
||||
// Descriptions déjà réduites au strict nécessaire. La consigne d'origine tient
|
||||
// pour la suite : ce budget ne se relève pas pour du prompt.
|
||||
const promptCharBudget = 11000 // ~2750 tokens, tout allumé
|
||||
|
||||
func TestSystemPromptStaysLean(t *testing.T) {
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// chat_recall.go — mémoire longue de la conversation.
|
||||
//
|
||||
// Le compactage résume les vieux tours pour libérer la fenêtre, mais un résumé
|
||||
// perd forcément du détail (un gros code, une longue page web, un diff). Plutôt
|
||||
// que de le perdre pour de bon, on ARCHIVE chaque gros bloc AVANT de le
|
||||
// compacter, sous un identifiant court et stable (r7, r42…). Le résumé cite l'id
|
||||
// à la place du bloc (« code du jeu fourni → recall:r7 »), et le modèle peut le
|
||||
// rappeler VERBATIM quand il en a besoin :
|
||||
//
|
||||
// recall(id) → ramène le bloc exact, tel qu'il était.
|
||||
// recall_search(query) → retrouve un bloc (et son id) par mots-clés, même très
|
||||
// ancien et disparu du résumé courant.
|
||||
//
|
||||
// L'archive est GLOBALE (un compteur monotone via bbolt NextSequence), pas liée
|
||||
// à une discussion : un id ne pointe jamais que vers un seul bloc, pour toujours.
|
||||
// Elle vit sur disque (bucket bkRecall) donc survit aux redémarrages et au
|
||||
// multi-appareils. Le contexte, lui, ne porte que les ids ENCORE cités par le
|
||||
// résumé courant : sa taille reste plate même sur une conversation infinie,
|
||||
// pendant que l'archive grossit tranquillement sur disque (bon marché).
|
||||
//
|
||||
// En mode code, c'est ce qui rend une longue session tenable : un fichier lu ou
|
||||
// un diff produit tôt dans la session n'est plus perdu au compactage suivant.
|
||||
|
||||
const (
|
||||
// Un bloc du torse plus long que ça (en caractères) est archivé et remplacé
|
||||
// par son id. En dessous, le résumé le porte très bien tout seul : pas la peine
|
||||
// de créer un id pour trois lignes.
|
||||
recallArchiveMinLen = 800
|
||||
// Longueur de l'extrait de tête laissé au résumeur pour un bloc archivé : de
|
||||
// quoi le résumer fidèlement, le reste étant récupérable par recall.
|
||||
recallSummaryHeadLen = 400
|
||||
// Nombre de résultats par défaut / max pour recall_search.
|
||||
recallSearchDefault = 5
|
||||
recallSearchMax = 20
|
||||
)
|
||||
|
||||
// recallBlock est un bloc archivé, adressable par son id.
|
||||
type recallBlock struct {
|
||||
ID string `json:"id"` // "r<seq>"
|
||||
Seq uint64 `json:"seq"` // séquence brute (tri, clé)
|
||||
Label string `json:"label"` // libellé court lisible (rôle + première ligne)
|
||||
Role string `json:"role"` // rôle d'origine (tool/user/assistant)
|
||||
Content string `json:"content"` // contenu VERBATIM
|
||||
Created int64 `json:"created"` // unix, pour un éventuel ménage futur
|
||||
}
|
||||
|
||||
// recallKey encode une séquence en clé bbolt triable (big-endian).
|
||||
func recallKey(seq uint64) []byte {
|
||||
k := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(k, seq)
|
||||
return k
|
||||
}
|
||||
|
||||
// recallIDToSeq lit "r<n>" → n. Tolère un id sans préfixe et les espaces : le
|
||||
// modèle écrit aussi bien recall("r7") que recall("7") ou recall("R7 ").
|
||||
func recallIDToSeq(id string) (uint64, bool) {
|
||||
s := strings.TrimSpace(id)
|
||||
s = strings.TrimPrefix(s, "r")
|
||||
s = strings.TrimPrefix(s, "R")
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
|
||||
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
|
||||
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
|
||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||
encode, err := memEncoderNow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var id string
|
||||
err = withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blk := recallBlock{
|
||||
ID: "r" + strconv.FormatUint(seq, 10),
|
||||
Seq: seq,
|
||||
Label: label,
|
||||
Role: role,
|
||||
Content: content,
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
id = blk.ID
|
||||
raw, err := json.Marshal(blk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
|
||||
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
|
||||
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
|
||||
// refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), enc)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cacheBust(bkRecall)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
|
||||
func recallGet(id string) (recallBlock, bool) {
|
||||
seq, ok := recallIDToSeq(id)
|
||||
if !ok {
|
||||
return recallBlock{}, false
|
||||
}
|
||||
var blk recallBlock
|
||||
found := false
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
v := b.Get(recallKey(seq))
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : le bloc reste hors de portée
|
||||
}
|
||||
found = json.Unmarshal(dec, &blk) == nil
|
||||
return nil
|
||||
})
|
||||
return blk, found
|
||||
}
|
||||
|
||||
// recallSearch cherche dans toute l'archive par recouvrement de mots-clés.
|
||||
// Score = nombre de termes distincts de la requête présents dans (libellé +
|
||||
// contenu), le libellé comptant double (un mot du titre est plus signifiant).
|
||||
// Simple, lexical, zéro dépendance — l'issue de secours pour un bloc sorti du
|
||||
// résumé courant. À égalité, le plus RÉCENT d'abord (seq décroissante).
|
||||
func recallSearch(query string, limit int) []recallBlock {
|
||||
terms := recallTokens(query)
|
||||
if len(terms) == 0 {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = recallSearchDefault
|
||||
}
|
||||
if limit > recallSearchMax {
|
||||
limit = recallSearchMax
|
||||
}
|
||||
type scored struct {
|
||||
blk recallBlock
|
||||
score int
|
||||
}
|
||||
var hits []scored
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var blk recallBlock
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : ce bloc ne participe pas
|
||||
}
|
||||
if json.Unmarshal(dec, &blk) != nil {
|
||||
return nil
|
||||
}
|
||||
label := strings.ToLower(blk.Label)
|
||||
body := strings.ToLower(blk.Content)
|
||||
score := 0
|
||||
for t := range terms {
|
||||
if strings.Contains(label, t) {
|
||||
score += 2
|
||||
}
|
||||
if strings.Contains(body, t) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
hits = append(hits, scored{blk, score})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
if hits[i].score != hits[j].score {
|
||||
return hits[i].score > hits[j].score
|
||||
}
|
||||
return hits[i].blk.Seq > hits[j].blk.Seq
|
||||
})
|
||||
if len(hits) > limit {
|
||||
hits = hits[:limit]
|
||||
}
|
||||
out := make([]recallBlock, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.blk
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recallTokens éclate une chaîne en un ensemble de mots-clés minuscules (>= 2
|
||||
// caractères), en coupant sur tout ce qui n'est ni lettre ni chiffre.
|
||||
func recallTokens(s string) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ')
|
||||
}) {
|
||||
if len([]rune(w)) >= 2 {
|
||||
out[w] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recallLabel fabrique un libellé court et lisible pour un bloc, à partir de son
|
||||
// rôle (préfixé du nom d'outil quand on le connaît) et de sa première ligne.
|
||||
func recallLabel(role, toolName, content string) string {
|
||||
head := ""
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if s := strings.TrimSpace(line); s != "" {
|
||||
head = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if r := []rune(head); len(r) > 70 {
|
||||
head = strings.TrimSpace(string(r[:70])) + "…"
|
||||
}
|
||||
tag := role
|
||||
if role == "tool" && toolName != "" {
|
||||
tag = toolName
|
||||
}
|
||||
if head == "" {
|
||||
return tag
|
||||
}
|
||||
return tag + ": " + head
|
||||
}
|
||||
|
||||
// toolRecall ramène un bloc archivé pour le modèle (outil recall).
|
||||
func toolRecall(args map[string]any) string {
|
||||
id, _ := args["id"].(string)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return "[erreur] id manquant"
|
||||
}
|
||||
blk, ok := recallGet(id)
|
||||
if !ok {
|
||||
return fmt.Sprintf("[erreur] bloc %q introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés", id)
|
||||
}
|
||||
return fmt.Sprintf("[%s — %s, rappelé verbatim]\n%s", blk.ID, blk.Label, blk.Content)
|
||||
}
|
||||
|
||||
// toolRecallSearch retrouve des blocs par mots-clés (outil recall_search).
|
||||
func toolRecallSearch(args map[string]any) string {
|
||||
q, _ := args["query"].(string)
|
||||
if strings.TrimSpace(q) == "" {
|
||||
return "[erreur] query manquante"
|
||||
}
|
||||
lim := 0
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
lim = int(v)
|
||||
}
|
||||
hits := recallSearch(q, lim)
|
||||
if len(hits) == 0 {
|
||||
return "[aucun bloc archivé ne correspond]"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Blocs trouvés (rappelle le bon avec recall(id)) :\n")
|
||||
for _, h := range hits {
|
||||
fmt.Fprintf(&b, "- %s — %s\n %s\n", h.ID, h.Label, recallSnippet(h.Content, 160))
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// recallSnippet renvoie un extrait d'une ligne, borné, pour l'aperçu de recherche.
|
||||
func recallSnippet(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if r := []rune(s); len(r) > n {
|
||||
return strings.TrimSpace(string(r[:n])) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// recallTool / recallSearchTool : schémas annoncés au modèle en mode agent.
|
||||
func recallTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "recall",
|
||||
Description: "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "Block id, e.g. \"r7\"."},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recallSearchTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "recall_search",
|
||||
Description: "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Keywords describing the block."},
|
||||
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Default %d, max %d", recallSearchDefault, recallSearchMax)},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// bigBlock fabrique un contenu franchement au-dessus de recallArchiveMinLen,
|
||||
// pour qu'il soit archivé plutôt que simplement porté par le résumé.
|
||||
func bigBlock(marker string) string {
|
||||
return marker + "\n" + strings.Repeat("ligne de contenu volumineux à conserver verbatim\n", 40)
|
||||
}
|
||||
|
||||
// Un bloc archivé doit revenir MOT POUR MOT : c'est toute la promesse de recall.
|
||||
// Un résumé approximatif suffisait déjà avant ; ce qu'on ajoute ici, c'est
|
||||
// l'exactitude — un gros code ou un diff ne survit pas à une reformulation.
|
||||
func TestRecallRendVerbatim(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
content := bigBlock("MARQUEUR-UNIQUE-42")
|
||||
id, err := archiveRecallBlock("web_read: page", "tool", content)
|
||||
if err != nil {
|
||||
t.Fatalf("archivage impossible : %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(id, "r") {
|
||||
t.Fatalf("id mal formé : %q", id)
|
||||
}
|
||||
blk, ok := recallGet(id)
|
||||
if !ok {
|
||||
t.Fatalf("bloc %q introuvable juste après son archivage", id)
|
||||
}
|
||||
if blk.Content != content {
|
||||
t.Fatal("le contenu rappelé diffère de l'original : l'archive n'est pas verbatim")
|
||||
}
|
||||
// Le passage par l'outil (ce que voit le modèle) doit aussi porter le contenu.
|
||||
if got := toolRecall(map[string]any{"id": id}); !strings.Contains(got, "MARQUEUR-UNIQUE-42") {
|
||||
t.Fatalf("l'outil recall ne rend pas le bloc : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Les ids doivent être uniques et monotones : deux compactages qui se croisent ne
|
||||
// doivent jamais écraser le bloc de l'autre. NextSequence le garantit côté bbolt ;
|
||||
// ce test verrouille qu'on s'en sert bien.
|
||||
func TestRecallIDsUniquesEtMonotones(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
vus := map[string]bool{}
|
||||
var last uint64
|
||||
for i := 0; i < 5; i++ {
|
||||
id, err := archiveRecallBlock("bloc", "tool", bigBlock("contenu"))
|
||||
if err != nil {
|
||||
t.Fatalf("archivage %d : %v", i, err)
|
||||
}
|
||||
if vus[id] {
|
||||
t.Fatalf("id %q réutilisé : un bloc en écrase un autre", id)
|
||||
}
|
||||
vus[id] = true
|
||||
seq, ok := recallIDToSeq(id)
|
||||
if !ok {
|
||||
t.Fatalf("id illisible : %q", id)
|
||||
}
|
||||
if i > 0 && seq <= last {
|
||||
t.Fatalf("séquence non monotone : %d après %d", seq, last)
|
||||
}
|
||||
last = seq
|
||||
}
|
||||
}
|
||||
|
||||
// recallIDToSeq doit tolérer ce que le modèle écrit réellement : avec ou sans
|
||||
// préfixe, majuscule, espaces. Un id refusé pour un espace en trop enverrait le
|
||||
// modèle croire que son bloc a disparu.
|
||||
func TestRecallIDToSeqTolerant(t *testing.T) {
|
||||
for _, in := range []string{"r7", "R7", " r7 ", "7"} {
|
||||
if seq, ok := recallIDToSeq(in); !ok || seq != 7 {
|
||||
t.Fatalf("%q → (%d, %v), attendu (7, true)", in, seq, ok)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "rr", "abc", "r-1"} {
|
||||
if _, ok := recallIDToSeq(in); ok {
|
||||
t.Fatalf("%q accepté alors qu'il est invalide", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recall_search est l'issue de secours quand l'id n'est plus dans le résumé
|
||||
// courant : le bloc doit rester trouvable par mots-clés, et l'outil doit rendre
|
||||
// son id pour que le modèle puisse enchaîner sur recall(id).
|
||||
func TestRecallSearchRetrouveParMotsCles(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
if _, err := archiveRecallBlock("web_read: recette", "tool", bigBlock("gratin dauphinois")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wanted, err := archiveRecallBlock("web_read: horaires", "tool", bigBlock("le train de 14h12 part quai 3"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits := recallSearch("train horaires", 0)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("aucun résultat pour des mots-clés pourtant présents")
|
||||
}
|
||||
if hits[0].ID != wanted {
|
||||
t.Fatalf("meilleur résultat %q, attendu %q (le libellé doit compter double)", hits[0].ID, wanted)
|
||||
}
|
||||
out := toolRecallSearch(map[string]any{"query": "train horaires"})
|
||||
if !strings.Contains(out, wanted) {
|
||||
t.Fatalf("l'outil recall_search ne rend pas l'id à rappeler : %q", out)
|
||||
}
|
||||
if n := len(recallSearch("motabsolumentabsent", 0)); n != 0 {
|
||||
t.Fatalf("%d bloc(s) remontent pour un terme absent", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Le cœur du lot : un gros bloc du torse doit être ARCHIVÉ avant compaction, et
|
||||
// l'historique compacté doit porter son id. Sans llama-server en test, le résumé
|
||||
// échoue et on tombe sur le repli dégraissé — c'est justement le chemin où
|
||||
// l'ancien code effaçait le contenu sans laisser d'adresse.
|
||||
func TestCompactArchiveLesGrosBlocs(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
page := func(n int) Message {
|
||||
return tm(bigBlock("page numéro " + string(rune('a'+n))))
|
||||
}
|
||||
msgs := []Message{um("cherche les horaires du train pour Lyon")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("web_read"), page(i))
|
||||
}
|
||||
// caps.Agent : sans le mode agent, le modèle n'aurait pas l'outil recall et on
|
||||
// n'archive délibérément rien.
|
||||
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true})
|
||||
if !changed {
|
||||
t.Fatal("aucune compaction alors que le torse est plein de grosses pages")
|
||||
}
|
||||
joined := ""
|
||||
for _, m := range out {
|
||||
joined += msgText(m) + "\n"
|
||||
}
|
||||
if !strings.Contains(joined, "recall(") {
|
||||
t.Fatalf("l'historique compacté ne cite aucun bloc rappelable :\n%s", joined)
|
||||
}
|
||||
if strings.Contains(joined, compactPrunedMarker) {
|
||||
t.Fatal("un gros bloc a été effacé sans adresse de rappel alors qu'il était archivable")
|
||||
}
|
||||
// Et le contenu doit être réellement récupérable, pas seulement cité.
|
||||
hits := recallSearch("page numéro", 0)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("les blocs cités dans l'historique ne sont pas dans l'archive")
|
||||
}
|
||||
if !strings.Contains(hits[0].Content, "ligne de contenu volumineux") {
|
||||
t.Fatal("le bloc archivé ne contient pas le texte d'origine")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans mode agent, le modèle n'a pas l'outil recall : archiver produirait des ids
|
||||
// qu'il ne peut pas utiliser, et un résumé pollué de références mortes.
|
||||
func TestCompactNArchivePasSansAgent(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
msgs := []Message{um("question")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("web_read"), tm(bigBlock("page")))
|
||||
}
|
||||
out, _ := compactMessages(t.Context(), msgs, Caps{})
|
||||
for _, m := range out {
|
||||
if strings.Contains(msgText(m), "recall(") {
|
||||
t.Fatal("des références recall sont proposées alors que l'outil n'est pas fourni")
|
||||
}
|
||||
}
|
||||
if len(recallSearch("page", 0)) != 0 {
|
||||
t.Fatal("des blocs ont été archivés hors mode agent")
|
||||
}
|
||||
}
|
||||
|
||||
// recallEligible décide ce qui mérite un id. Deux pièges : les petits blocs (le
|
||||
// résumé les porte très bien) et le résumé d'une compaction PRÉCÉDENTE, qu'on
|
||||
// ré-archiverait à chaque passe en faisant enfler l'archive pour rien.
|
||||
func TestRecallEligible(t *testing.T) {
|
||||
gros := strings.Repeat("x", recallArchiveMinLen+1)
|
||||
if !recallEligible(tm(gros)) {
|
||||
t.Fatal("un gros résultat d'outil doit être archivé")
|
||||
}
|
||||
if recallEligible(tm("court")) {
|
||||
t.Fatal("un petit bloc ne mérite pas un id : le résumé le porte")
|
||||
}
|
||||
if recallEligible(Message{Role: "system", Content: gros}) {
|
||||
t.Fatal("un message système n'a rien à faire dans l'archive")
|
||||
}
|
||||
if recallEligible(um(compactSummaryPrefix + " " + gros)) {
|
||||
t.Fatal("le résumé d'une compaction précédente serait ré-archivé à chaque passe")
|
||||
}
|
||||
}
|
||||
|
||||
// Garde-fou anti-résumé-dégénéré : un petit modèle mal aiguillé répond parfois
|
||||
// par un simple « recall:r7 » au lieu d'écrire de la prose. Accepter ça, c'est
|
||||
// remplacer tout le passé de la conversation par une référence vide.
|
||||
func TestSummaryLooksEmpty(t *testing.T) {
|
||||
vrai := "L'utilisateur cherche les horaires du train pour Lyon ; le train de 14h12 part quai 3, reste à confirmer le tarif."
|
||||
if summaryLooksEmpty(vrai) {
|
||||
t.Fatalf("un vrai résumé est rejeté : %q", vrai)
|
||||
}
|
||||
for _, degenere := range []string{"", " ", "recall:r7", "recall:r7 recall:r8 recall:r9", "voir recall(r7)."} {
|
||||
if !summaryLooksEmpty(degenere) {
|
||||
t.Fatalf("résumé dégénéré accepté : %q", degenere)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compactSummaryUserMsg porte l'index des ids rappelables. Il doit rester borné
|
||||
// à la passe courante : sinon une conversation infinie ferait enfler cette
|
||||
// section jusqu'à annuler le bénéfice de la compaction.
|
||||
func TestCompactSummaryUserMsg(t *testing.T) {
|
||||
sans := compactSummaryUserMsg("résumé quelconque", nil)
|
||||
if !strings.HasPrefix(sans, compactSummaryPrefix) {
|
||||
t.Fatal("le message de compaction doit être reconnaissable à son préfixe")
|
||||
}
|
||||
if strings.Contains(sans, "recall(") {
|
||||
t.Fatal("recall est proposé alors qu'aucun bloc n'a été archivé")
|
||||
}
|
||||
avec := compactSummaryUserMsg("résumé quelconque", []recallEntry{{id: "r7", label: "web_read: horaires"}})
|
||||
if !strings.Contains(avec, "r7") || !strings.Contains(avec, "web_read: horaires") {
|
||||
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
|
||||
}
|
||||
}
|
||||
@@ -16,10 +16,10 @@ package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -30,28 +30,24 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// captureDir : sous-dossier du workspace où atterrissent les captures. Elles
|
||||
// sont rangées PAR DISCUSSION (captures/<id>/…) pour que supprimer une
|
||||
// discussion supprime aussi ses images — sinon elles s'accumulaient sur le
|
||||
// disque sans qu'aucun écran ne les mentionne plus.
|
||||
// captureDir : sous-dossier où atterrissent les captures, À L'INTÉRIEUR du
|
||||
// dossier de la discussion (discussions/<id>/captures/…) — comme les dépôts.
|
||||
// Supprimer une discussion emporte donc ses images, qui sinon s'accumulaient
|
||||
// sur le disque sans qu'aucun écran ne les mentionne plus.
|
||||
const captureDir = "captures"
|
||||
|
||||
// captureDirFor renvoie le dossier de captures d'une discussion (chemin absolu)
|
||||
// et son préfixe relatif, celui qui sert dans les URLs d'affichage.
|
||||
// et son préfixe relatif, celui qui sert dans les URLs d'affichage. Le relatif
|
||||
// part du dossier de la DISCUSSION : /api/chat/image résout d'abord là
|
||||
// (workspaceFile), et les liens des anciens messages — en captures/<id>/… —
|
||||
// continuent d'ouvrir leur image par le chemin de repli.
|
||||
func captureDirFor(convID string) (abs, rel string) {
|
||||
rel = captureDir + "/" + convID
|
||||
return filepath.Join(agentWorkspace(), captureDir, convID), rel
|
||||
return filepath.Join(convDirFor(convID), captureDir), captureDir
|
||||
}
|
||||
|
||||
// dropConvCaptures supprime les captures d'une discussion. Appelé quand on la
|
||||
// supprime ou qu'on la vide. Best-effort : un échec ne doit rien interrompre.
|
||||
func dropConvCaptures(convID string) {
|
||||
if convID == "" {
|
||||
return
|
||||
}
|
||||
abs, _ := captureDirFor(convID)
|
||||
_ = os.RemoveAll(abs)
|
||||
}
|
||||
// Supprimer les captures d'une discussion n'a plus de fonction à soi : elles
|
||||
// vivent dans son dossier, et dropConvFiles (chat_convfiles.go) l'emporte en
|
||||
// entier — dépôts et fichiers écrits par l'agent compris.
|
||||
|
||||
// screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a
|
||||
// son propre délai interne, celui-ci est le garde-fou externe.
|
||||
@@ -109,6 +105,11 @@ func screenshotVisionNote() string {
|
||||
// modèle, ou avec un modèle sans vision (gpt-oss), le gabarit sérialise le
|
||||
// base64 de l'image en TEXTE — la requête vue en production pesait 55 000
|
||||
// tokens pour 32 768 de contexte, et tous les tours suivants échouaient.
|
||||
//
|
||||
// La sonde est un appel interne comme les autres : elle DOIT s'authentifier.
|
||||
// Sans l'en-tête Bearer, un moteur protégé par API_KEY répond 401 et la sonde
|
||||
// conclut « pas de vision » — le projecteur était bien chargé, les images ne
|
||||
// partaient jamais et le modèle brodait à partir du seul chemin de fichier.
|
||||
var (
|
||||
visionProbeMu sync.Mutex
|
||||
visionProbeAt time.Time
|
||||
@@ -124,7 +125,12 @@ func engineSeesImages() bool {
|
||||
visionProbeAt = time.Now()
|
||||
visionProbeSeen = false
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/props", LLMPort()))
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/props", LLMPort()), nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
authHeader(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -147,6 +153,12 @@ func engineSeesImages() bool {
|
||||
// où un base64 de plusieurs dizaines de milliers de tokens était rejoué à
|
||||
// chaque tour jusqu'à dépasser définitivement le contexte ;
|
||||
// - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture.
|
||||
//
|
||||
// Le texte restant reçoit imageLostMarker : sans lui, une conversation rouverte
|
||||
// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le
|
||||
// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire
|
||||
// au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne.
|
||||
// Vaut pour toutes les provenances : capture, pièce jointe, see_image.
|
||||
func stripImageParts(msgs []Message) []Message {
|
||||
for i, m := range msgs {
|
||||
parts, ok := m.Content.([]any)
|
||||
@@ -169,7 +181,7 @@ func stripImageParts(msgs []Message) []Message {
|
||||
}
|
||||
}
|
||||
if dropped {
|
||||
msgs[i].Content = strings.Join(texts, "\n")
|
||||
msgs[i].Content = strings.Join(texts, "\n") + imageLostMarker
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
@@ -188,7 +200,10 @@ func screenshotImageMessage(relPath string) (Message, bool) {
|
||||
if !visionEnabled() || !engineSeesImages() {
|
||||
return Message{}, false
|
||||
}
|
||||
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath))
|
||||
abs, ok := workspaceFile(relPath)
|
||||
if !ok {
|
||||
return Message{}, false
|
||||
}
|
||||
mime := imageMime(abs)
|
||||
if mime == "" {
|
||||
return Message{}, false
|
||||
@@ -197,11 +212,13 @@ func screenshotImageMessage(relPath string) (Message, bool) {
|
||||
if err != nil || len(b) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
// Redimensionnement sous maxImageDim avant l'envoi : une capture de page
|
||||
// fait facilement 1500×4000 px, dont le base64 pèse plus que ce que le
|
||||
// projecteur regarde (voir prepareImageForModel, web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
}},
|
||||
imageURLPart(b, mime),
|
||||
}}, true
|
||||
}
|
||||
|
||||
@@ -233,7 +250,7 @@ func safeSlug(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func toolWebScreenshot(args map[string]any) string {
|
||||
func toolWebScreenshot(args map[string]any, caps Caps) string {
|
||||
bin := playwrightBin()
|
||||
if bin == "" {
|
||||
return "[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0)."
|
||||
@@ -246,6 +263,13 @@ func toolWebScreenshot(args map[string]any) string {
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return "[erreur] url invalide : elle doit commencer par http:// ou https://"
|
||||
}
|
||||
// Accès internet coupé : la capture reste offerte au MODE CODE, mais bornée
|
||||
// aux adresses locales — voir sa propre page de dev, pas le web. Sans cette
|
||||
// borne, l'outil rouvrirait par la fenêtre ce que l'interrupteur ferme.
|
||||
if !caps.Internet && !isLocalURL(url) {
|
||||
return "[refusé] L'accès internet est coupé : la capture ne marche que sur une adresse locale " +
|
||||
"(localhost / 127.0.0.1), par exemple ton serveur de dev. Active l'accès internet pour le web."
|
||||
}
|
||||
// width n'est plus déclaré dans le schéma (budget de préambule), mais reste
|
||||
// honoré s'il arrive quand même : un modèle qui l'invente obtient le
|
||||
// comportement attendu plutôt qu'un paramètre ignoré en silence.
|
||||
@@ -388,3 +412,19 @@ func lastLines(s string, n int) string {
|
||||
}
|
||||
return strings.Join(keep, " · ")
|
||||
}
|
||||
|
||||
// isLocalURL : l'adresse désigne-t-elle cette machine ? Sert de borne quand
|
||||
// l'accès internet est coupé mais que le mode code doit pouvoir photographier
|
||||
// son propre serveur de dev.
|
||||
func isLocalURL(raw string) bool {
|
||||
u, err := neturl.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := u.Hostname()
|
||||
switch host {
|
||||
case "localhost", "127.0.0.1", "::1", "0.0.0.0":
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(host, ".localhost")
|
||||
}
|
||||
@@ -139,7 +139,9 @@ func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
|
||||
// Un base64 d'image persisté dans l'historique est rejoué à chaque tour : vu en
|
||||
// production, 55 000 tokens de requête pour 32 768 de contexte — plus aucun
|
||||
// tour ne passait. stripImageParts guérit les conversations existantes en
|
||||
// retirant les parties image et en aplatissant le texte restant.
|
||||
// retirant les parties image et en aplatissant le texte restant — en laissant
|
||||
// imageLostMarker, pour que le modèle sache qu'une image A ÉTÉ montrée et qu'il
|
||||
// doit en reprendre une plutôt que la redécrire de mémoire.
|
||||
func TestStripImagePartsGueritLHistorique(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: "bonjour"}, // simple chaîne : intouchée
|
||||
@@ -153,8 +155,40 @@ func TestStripImagePartsGueritLHistorique(t *testing.T) {
|
||||
t.Fatalf("message texte modifié : %#v", out[0].Content)
|
||||
}
|
||||
got, ok := out[1].Content.(string)
|
||||
if !ok || got != "Voici la capture demandée." {
|
||||
t.Fatalf("le message multimodal doit être aplati en texte sans l'image, obtenu %#v", out[1].Content)
|
||||
if !ok || got != "Voici la capture demandée."+imageLostMarker {
|
||||
t.Fatalf("le message multimodal doit être aplati en texte, sans l'image mais avec le marqueur de perte, obtenu %#v", out[1].Content)
|
||||
}
|
||||
if strings.Contains(got, "base64") {
|
||||
t.Fatalf("le base64 de l'image ne doit plus apparaître : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// msgText est le point de passage de TOUTE la chaîne de compaction (estimation
|
||||
// du contexte, transcript donné au résumeur). S'il n'extrait que les parties
|
||||
// `text`, un message qui porte une capture ne pèse que sa légende et le résumeur
|
||||
// ignore jusqu'à l'existence de l'image : elle disparaît sans laisser de trace.
|
||||
func TestMsgTextSignaleLaPresenceDUneImage(t *testing.T) {
|
||||
// Message VIVANT tel que construit par screenshotImageMessage ([]map[string]any).
|
||||
live := Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Capture demandée :"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
|
||||
}}
|
||||
// Même message RELU depuis le JSON persisté ([]any de map génériques).
|
||||
reloaded := Message{Role: "user", Content: []any{
|
||||
map[string]any{"type": "text", "text": "Capture demandée :"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
|
||||
}}
|
||||
for name, m := range map[string]Message{"vivant": live, "relu": reloaded} {
|
||||
got := msgText(m)
|
||||
if !strings.Contains(got, "Capture demandée :") {
|
||||
t.Fatalf("%s : le texte de la légende doit être conservé, obtenu %q", name, got)
|
||||
}
|
||||
if !strings.Contains(got, imageLostMarker) {
|
||||
t.Fatalf("%s : la présence de l'image doit être signalée, obtenu %q", name, got)
|
||||
}
|
||||
if strings.Contains(got, "base64") {
|
||||
t.Fatalf("%s : le base64 ne doit jamais entrer dans le transcript : %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package loki
|
||||
|
||||
// chat_scripts.go — le dossier de scripts DÉDIÉ et PROTÉGÉ de l'agent.
|
||||
//
|
||||
// La durabilité des scripts vient d'une séparation STRUCTURELLE, pas d'un verrou
|
||||
// sur les commandes : les scripts qu'on veut CONSERVER vivent dans scriptsDir() =
|
||||
// LOKI_HOME/scripts, à côté de memory, HORS du workspace jetable. Un nettoyage —
|
||||
// ou une catastrophe — dans le workspace ne peut plus les toucher. Le workspace
|
||||
// reste le bac à sable où l'IA peut tout casser sans risque (clones, tests,
|
||||
// fichiers temporaires).
|
||||
//
|
||||
// L'IA a par ailleurs la main libre sur le shell (suppression/déplacement/
|
||||
// renommage inclus) : aucun garde-fou « anti-rm » ne filtre les commandes. Le
|
||||
// SEUL accès réservé est le dossier memory, joignable uniquement par les outils
|
||||
// mem_* (voir guardToolOnly plus bas) — parce qu'il est chiffré, pas pour le
|
||||
// protéger d'une suppression.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scriptsPath résout un nom de script fourni par le modèle vers un chemin DANS
|
||||
// scriptsDir, en refusant toute évasion hors du dossier (« ../ », chemin absolu).
|
||||
// Renvoie une erreur plutôt qu'un chemin hors périmètre.
|
||||
func scriptsPath(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("nom de script vide")
|
||||
}
|
||||
// On interdit les chemins absolus et la remontée : un script vit dans
|
||||
// scriptsDir, point. Les sous-dossiers relatifs restent permis. Un nom qui
|
||||
// commence par un séparateur (« /etc/… ») est rejeté explicitement : sous
|
||||
// Windows il n'est pas « absolu » au sens de filepath mais n'a rien à faire là.
|
||||
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
|
||||
return "", fmt.Errorf("nom de script invalide (commence par un séparateur) : %s", name)
|
||||
}
|
||||
// Caractères hostiles au shell : le nom finit dans une ligne de commande
|
||||
// (scriptRunCommand l'entoure de guillemets) ; on refuse tout ce qui pourrait
|
||||
// en sortir. Le backslash est inclus : sous Windows c'est un séparateur (utiliser
|
||||
// « / » pour les sous-dossiers), sous Unix c'est un échappement shell. Refuser
|
||||
// partout rend le comportement identique sur les deux plateformes. Un nom de
|
||||
// fichier n'a de toute façon besoin d'aucun de ces caractères.
|
||||
if i := strings.IndexAny(name, "\"'`$;&|<>*?\\\n\r"); i >= 0 {
|
||||
return "", fmt.Errorf("nom de script invalide (caractère interdit %q) : %s", name[i:i+1], name)
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(name))
|
||||
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("nom de script invalide (pas de chemin absolu ni de ../) : %s", name)
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), clean)
|
||||
// Ceinture et bretelles : vérifie que le résultat est bien SOUS scriptsDir.
|
||||
if !underDir(full, scriptsDir()) {
|
||||
return "", fmt.Errorf("nom de script hors du dossier scripts : %s", name)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// scriptExists valide qu'un nom désigne bien un fichier script existant dans le
|
||||
// dossier (sans lire son contenu). Utilisé pour valider une tâche « script seul ».
|
||||
func scriptExists(name string) error {
|
||||
full, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return fmt.Errorf("script introuvable : %s", name)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return fmt.Errorf("%s est un dossier, pas un script", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listScripts renvoie les scripts présents (chemins relatifs à scriptsDir, avec
|
||||
// leur taille), triés. Ignore les dossiers.
|
||||
func listScripts() ([]scriptInfo, error) {
|
||||
root := scriptsDir()
|
||||
var out []scriptInfo
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // dossier absent ou illisible : on renvoie ce qu'on a
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
size := int64(0)
|
||||
if fi, ferr := d.Info(); ferr == nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
out = append(out, scriptInfo{Name: filepath.ToSlash(rel), Size: size})
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, err
|
||||
}
|
||||
|
||||
type scriptInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// toolOnlyDirs : dossiers auxquels l'IA n'a AUCUN accès direct (ni lecture, ni
|
||||
// écriture, ni listing) par bash/write/edit. Le seul chemin autorisé passe par
|
||||
// les outils dédiés — mem_* pour la mémoire. Ces outils écrivent en Go
|
||||
// directement (MemAdd…), ils ne passent PAS par fileWrite/runShell, donc ils ne
|
||||
// sont pas concernés par ces gardes.
|
||||
//
|
||||
// Le dossier scripts N'EST PAS ici : l'IA y écrit et y exécute ses scripts
|
||||
// librement (avec write/bash). Sa durabilité vient seulement de sa séparation
|
||||
// d'avec le workspace jetable, pas d'un verrou.
|
||||
func toolOnlyDirs() []string { return []string{memoryDir()} }
|
||||
|
||||
// toolOnlyLabel donne le nom des outils à utiliser à la place d'un accès direct
|
||||
// (seule la mémoire est concernée aujourd'hui, voir toolOnlyDirs).
|
||||
func toolOnlyLabel() string {
|
||||
return "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)"
|
||||
}
|
||||
|
||||
// guardToolOnlyPath refuse un accès write/edit à un chemin (déjà résolu) situé
|
||||
// dans un dossier « tool-only » (memory). Renvoie "" si le chemin est autorisé.
|
||||
func guardToolOnlyPath(path string) string {
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if underDir(path, d) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// guardToolOnlyCommand refuse une commande shell qui référence un dossier
|
||||
// « tool-only » (memory), lecture comprise (`cat`, `ls`, `>`…) : on bloque TOUTE
|
||||
// mention du dossier, pas seulement une suppression. Renvoie "" si autorisé.
|
||||
func guardToolOnlyCommand(command string) string {
|
||||
lc := strings.ToLower(command)
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if strings.Contains(lc, strings.ToLower(normPath(d))) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s via le shell. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// underDir indique si path est égal à dir ou situé dessous, après nettoyage et
|
||||
// (sous Windows) insensibilité à la casse.
|
||||
func underDir(path, dir string) bool {
|
||||
a := normPath(path)
|
||||
b := normPath(dir)
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(a, b+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// normPath normalise un chemin pour la comparaison : absolu si possible, nettoyé,
|
||||
// et minusculé sous Windows (système de fichiers insensible à la casse).
|
||||
func normPath(p string) string {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
if runtime.GOOS == "windows" {
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGuardToolOnly vérifie que memory est inaccessible en direct (shell/write/
|
||||
// edit) et n'autorise que les outils dédiés, tandis que scripts reste libre.
|
||||
func TestGuardToolOnly(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
// Shell : toute mention du dossier memory est refusée (lecture comprise). Le
|
||||
// dossier scripts, lui, est librement utilisable (l'IA y écrit et exécute).
|
||||
blockedCmds := []string{
|
||||
"cat " + memoryDir() + "/note.md",
|
||||
"echo x > " + memoryDir() + "/x",
|
||||
}
|
||||
for _, c := range blockedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg == "" {
|
||||
t.Errorf("commande aurait dû être refusée : %q", c)
|
||||
}
|
||||
}
|
||||
allowedCmds := []string{
|
||||
"ls " + workspaceDir(),
|
||||
"bash " + scriptsDir() + "/backup.sh", // exécuter un script : autorisé
|
||||
"ls " + scriptsDir(),
|
||||
}
|
||||
for _, c := range allowedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg != "" {
|
||||
t.Errorf("commande ne devait pas être bloquée : %q -> %s", c, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// write/edit : un chemin dans memory est refusé ; dans scripts ou workspace, non.
|
||||
if msg := guardToolOnlyPath(filepath.Join(memoryDir(), "a.md")); msg == "" {
|
||||
t.Errorf("écriture directe dans memory aurait dû être refusée")
|
||||
}
|
||||
for _, p := range []string{filepath.Join(scriptsDir(), "b.sh"), filepath.Join(workspaceDir(), "ok.txt")} {
|
||||
if msg := guardToolOnlyPath(p); msg != "" {
|
||||
t.Errorf("écriture ne devait pas être bloquée : %q -> %s", p, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptsPathEscape vérifie qu'on ne peut pas sortir du dossier scripts ni
|
||||
// glisser un caractère hostile au shell dans le nom.
|
||||
func TestScriptsPathEscape(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
bad := []string{"../evil.sh", "..", "/etc/passwd", `..\..\x`, `a";rm -rf ~;".sh`, "a$b.sh", "a`b`.sh"}
|
||||
for _, name := range bad {
|
||||
if _, err := scriptsPath(name); err == nil {
|
||||
t.Errorf("aurait dû refuser le nom : %q", name)
|
||||
}
|
||||
}
|
||||
good := []string{"backup.ps1", "sub/backup.sh", "a.sh", "my backup.sh"}
|
||||
for _, name := range good {
|
||||
p, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
t.Errorf("aurait dû accepter %q : %v", name, err)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(normPath(p), normPath(scriptsDir())) {
|
||||
t.Errorf("%q résolu hors de scriptsDir : %s", name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptExists couvre la validation d'existence utilisée par les tâches script.
|
||||
func TestScriptExists(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
if err := scriptExists("hello.sh"); err == nil {
|
||||
t.Fatalf("un script absent aurait dû échouer")
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), "hello.sh")
|
||||
if err := os.MkdirAll(scriptsDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("hello.sh"); err != nil {
|
||||
t.Fatalf("scriptExists : %v", err)
|
||||
}
|
||||
list, _ := listScripts()
|
||||
if len(list) != 1 || list[0].Name != "hello.sh" {
|
||||
t.Fatalf("list inattendu : %+v", list)
|
||||
}
|
||||
// Un dossier n'est pas un script.
|
||||
if err := os.MkdirAll(filepath.Join(scriptsDir(), "d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("d"); err == nil {
|
||||
t.Fatalf("un dossier ne doit pas passer pour un script")
|
||||
}
|
||||
}
|
||||
+100
-13
@@ -11,6 +11,10 @@ package loki
|
||||
// bkChat/active → identifiant de la discussion ouverte
|
||||
// bkChat/conv:<id> → l'état complet d'une discussion (JSON de Conversation)
|
||||
//
|
||||
// Les FICHIERS suivent le même découpage, sur le disque cette fois :
|
||||
// <workspace>/discussions/<id>/ (chat_convfiles.go). Supprimer une discussion
|
||||
// supprime donc aussi ses fichiers.
|
||||
//
|
||||
// Basculer = enregistrer la discussion courante, charger l'autre en mémoire et
|
||||
// incrémenter l'epoch : les abonnés SSE reçoivent alors {reset:true} et
|
||||
// rejouent le nouveau fil depuis zéro. C'est le mécanisme déjà utilisé par
|
||||
@@ -49,13 +53,58 @@ type convMeta struct {
|
||||
Created int64 `json:"created"`
|
||||
Updated int64 `json:"updated"`
|
||||
Turns int `json:"turns"`
|
||||
// Project = slug du projet auquel la discussion appartient (projects.go). Vide
|
||||
// sur les discussions d'avant les projets : ensureDefaultProject les rattache à
|
||||
// « Générale » au premier démarrage, et convIndexForProject traite un champ vide
|
||||
// comme le projet par défaut pour que rien ne disparaisse entre-temps.
|
||||
Project string `json:"project,omitempty"`
|
||||
}
|
||||
|
||||
func convKey(id string) string { return "conv:" + id }
|
||||
|
||||
// convProjectOf renvoie le projet d'une discussion, en traitant l'absence comme
|
||||
// le projet par défaut : une discussion sans projet ne doit jamais devenir
|
||||
// invisible.
|
||||
func convProjectOf(m convMeta) string {
|
||||
if s := strings.TrimSpace(m.Project); s != "" {
|
||||
return s
|
||||
}
|
||||
return defaultProjectSlug
|
||||
}
|
||||
|
||||
// convIndexForProject filtre l'index sur un projet.
|
||||
func convIndexForProject(slug string) []convMeta {
|
||||
var out []convMeta
|
||||
for _, m := range convIndex() {
|
||||
if convProjectOf(m) == slug {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tagOrphanConversations rattache au projet donné les discussions qui n'en ont
|
||||
// pas encore (migration, une seule fois : ensuite il n'y a plus d'orphelines).
|
||||
func tagOrphanConversations(slug string) {
|
||||
idx := convIndex()
|
||||
changed := false
|
||||
for i := range idx {
|
||||
if strings.TrimSpace(idx[i].Project) == "" {
|
||||
idx[i].Project = slug
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
convIndexSave(idx)
|
||||
}
|
||||
}
|
||||
|
||||
func convIndex() []convMeta {
|
||||
var idx []convMeta
|
||||
if b := getBytes(bkChat, ckIndex); len(b) > 0 {
|
||||
// L'index porte les TITRES des discussions : chiffré comme leur contenu
|
||||
// quand la mémoire l'est. Verrouillé, il se lit vide — la liste est masquée,
|
||||
// rien n'est perdu.
|
||||
if b, ok := getStoreBytes(bkChat, ckIndex); ok && len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &idx)
|
||||
}
|
||||
// Plus récemment modifiée en tête : c'est l'ordre attendu d'une liste de
|
||||
@@ -69,7 +118,7 @@ func convIndexSave(idx []convMeta) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, ckIndex, b)
|
||||
_ = putStoreBytes(bkChat, ckIndex, b)
|
||||
}
|
||||
|
||||
// convEnsureActive garantit qu'une discussion active existe, et reprend le fil
|
||||
@@ -82,11 +131,11 @@ func convEnsureActive() string {
|
||||
if legacy := getBytes(bkChat, ckLegacy); len(legacy) > 0 {
|
||||
// Migration : le fil unique devient la première discussion. On garde la
|
||||
// clé d'origine intacte — en cas de retour arrière, rien n'est perdu.
|
||||
_ = putBytes(bkChat, convKey(id), legacy)
|
||||
_ = putStoreBytes(bkChat, convKey(id), legacy)
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
now := time.Now().Unix()
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -112,7 +161,7 @@ func convTouchMeta(id string, title string, turns int) {
|
||||
convIndexSave(idx)
|
||||
return
|
||||
}
|
||||
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns}))
|
||||
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns, Project: activeProjectSlug()}))
|
||||
}
|
||||
|
||||
// convSummary lit le premier message utilisateur pour en faire un titre. Sans
|
||||
@@ -138,8 +187,40 @@ func convSummary(msgs []Message) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ConvList renvoie les discussions et l'identifiant de l'active (pour l'UI).
|
||||
func ConvList() ([]convMeta, string) { return convIndex(), convEnsureActive() }
|
||||
// ConvList renvoie les discussions DU PROJET ACTIF et l'identifiant de l'active
|
||||
// (pour l'UI). Le cloisonnement est ici : une discussion d'un autre projet n'a
|
||||
// aucune raison d'apparaître dans la barre latérale.
|
||||
func ConvList() ([]convMeta, string) {
|
||||
return convIndexForProject(activeProjectSlug()), convEnsureActive()
|
||||
}
|
||||
|
||||
// projectSwitch bascule sur un autre projet ET ouvre une de ses discussions : la
|
||||
// plus récemment modifiée, ou une neuve s'il n'en a aucune. Sans ça, changer de
|
||||
// projet laisserait à l'écran une discussion qui n'appartient plus à la liste
|
||||
// affichée — et la mémoire (memoryDir) aurait changé sous ses pieds.
|
||||
func projectSwitch(slug string) error {
|
||||
if !projectExists(slug) {
|
||||
return fmt.Errorf("projet introuvable")
|
||||
}
|
||||
convOpMu.Lock()
|
||||
defer convOpMu.Unlock()
|
||||
if slug == activeProjectSlug() {
|
||||
return nil
|
||||
}
|
||||
conv.persist() // fige la discussion du projet qu'on quitte
|
||||
if err := setActiveProject(slug); err != nil {
|
||||
return err
|
||||
}
|
||||
list := convIndexForProject(slug)
|
||||
if len(list) == 0 {
|
||||
convCreate() // convIndex trie par date de modification : voir convIndex
|
||||
return nil
|
||||
}
|
||||
// convIndex rend la plus récemment modifiée en tête.
|
||||
_ = putStr(bkChat, ckActive, list[0].ID)
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(list[0].ID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// convSwitch bascule sur une autre discussion : la courante est enregistrée,
|
||||
// la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients
|
||||
@@ -165,7 +246,7 @@ func convSwitch(id string) error {
|
||||
}
|
||||
conv.persist() // fige la discussion qu'on quitte
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(id)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(id)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -175,7 +256,7 @@ func convSwitch(id string) error {
|
||||
func convCreate() string {
|
||||
id := newConvID()
|
||||
now := time.Now().Unix()
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(nil)
|
||||
return id
|
||||
@@ -228,9 +309,15 @@ func convDelete(id string) error {
|
||||
}
|
||||
convIndexSave(next)
|
||||
_ = putBytes(bkChat, convKey(id), nil)
|
||||
// Les captures de cette discussion n'ont plus rien qui les référence : les
|
||||
// garder occuperait le disque pour toujours.
|
||||
dropConvCaptures(id)
|
||||
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
|
||||
// elle, et ses critères/mode/puce partent avec ses messages.
|
||||
stopConvJobs(id)
|
||||
critDrop(id)
|
||||
dropConvMode(id)
|
||||
// Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a
|
||||
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
|
||||
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
|
||||
dropConvFiles(id)
|
||||
if id != getStr(bkChat, ckActive) {
|
||||
return nil
|
||||
}
|
||||
@@ -239,6 +326,6 @@ func convDelete(id string) error {
|
||||
return nil
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, next[0].ID)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(next[0].ID)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(next[0].ID)))
|
||||
return nil
|
||||
}
|
||||
+73
-10
@@ -44,6 +44,18 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// « Loki » 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 loki »).
|
||||
b.WriteString("You are Loki, an expert assistant operating directly on this machine with real tools.")
|
||||
// Mode code : le prompt du rôle (builder/planner/verifier…) prend la suite
|
||||
// de l'identité. Court par construction (voir code_roles.go) — la règle
|
||||
// « ne pas regonfler » vaut aussi pour lui.
|
||||
if caps.Code {
|
||||
role := caps.Role
|
||||
if role == "" {
|
||||
role = "builder"
|
||||
}
|
||||
if rp := rolePrompt(role); rp != "" {
|
||||
b.WriteString("\n\n" + rp)
|
||||
}
|
||||
}
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
|
||||
}
|
||||
@@ -77,6 +89,13 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// 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")
|
||||
// Auto-planification : les schémas task_* partent déjà dans la requête, donc
|
||||
// on n'y répète NI leur usage NI leurs arguments — seulement QUAND s'en
|
||||
// servir (sinon le modèle ne pense jamais à se planifier quoi que ce soit)
|
||||
// et le fait qu'un script planifié tourne sans modèle, que les schémas ne
|
||||
// disent pas. Une ligne, pas trois : le budget du préambule est compté
|
||||
// (TestSystemPromptStaysLean).
|
||||
b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\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")
|
||||
}
|
||||
@@ -89,6 +108,9 @@ func baseSystemPrompt(caps Caps) string {
|
||||
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 && caps.ComputerUse {
|
||||
b.WriteString(cuPromptLine())
|
||||
}
|
||||
if caps.Agent {
|
||||
if line := mcpPromptLine(); line != "" {
|
||||
b.WriteString(line)
|
||||
@@ -149,7 +171,7 @@ func machineSystemPrompt(caps Caps) string {
|
||||
if u, err := user.Current(); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
cwd := agentWorkspace()
|
||||
cwd := agentCwd()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
|
||||
@@ -161,7 +183,11 @@ func machineSystemPrompt(caps Caps) string {
|
||||
}
|
||||
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 loki was launched from.")
|
||||
// Formulation reprise de l'amont AJEAN (v0.10.2) : dire ce que le dossier
|
||||
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
|
||||
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
|
||||
// aux « installations » en /usr/local/bin qui échouent faute de root.
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -176,6 +202,14 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// 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 {
|
||||
// Accès réservé aux outils : le dossier mémoire n'est JAMAIS touché au shell
|
||||
// (ni lu, ni écrit, ni listé) — uniquement via les outils mem_*, qui savent
|
||||
// tenir l'index MEMORY.md à jour. Un `cat memory/…` contournait l'index et
|
||||
// laissait le modèle croire qu'il avait lu une page que la mémoire, elle,
|
||||
// n'avait pas servie.
|
||||
if msg := guardToolOnlyCommand(command); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = toolDefaultTimeout
|
||||
}
|
||||
@@ -185,16 +219,17 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
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ù loki a été
|
||||
// lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau.
|
||||
// Le shell démarre dans le dossier de la discussion ouverte, pas dans le
|
||||
// dossier d'où loki a été lancé : un `> notes.txt` du modèle ne doit pas
|
||||
// atterrir sur le Bureau, ni dans une AUTRE discussion.
|
||||
//
|
||||
// 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
|
||||
// La racine est résolue UNE fois par process (agentWorkspace), donc si elle
|
||||
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même la
|
||||
// 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 ws := agentCwd(); ws != "" {
|
||||
if err := os.MkdirAll(ws, 0o755); err == nil {
|
||||
cmd.Dir = ws
|
||||
}
|
||||
@@ -257,6 +292,14 @@ func fileWrite(path, content string) string {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
@@ -291,16 +334,32 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return "[erreur] old vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
content := string(b)
|
||||
n := strings.Count(content, oldText)
|
||||
// Fichier en CRLF, modèle en LF : la recherche exacte échouait toujours.
|
||||
// On compare alors en LF normalisé, et on réécrit dans le style du fichier
|
||||
// (préservation des fins de ligne — repris des tests line-ending d'OpenFox).
|
||||
crlf := strings.Contains(content, "\r\n")
|
||||
search, oldN, newN := content, oldText, newText
|
||||
if crlf && !strings.Contains(oldText, "\r\n") {
|
||||
search = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
}
|
||||
n := strings.Count(search, oldN)
|
||||
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) {
|
||||
if newN != "" && strings.Contains(search, newN) {
|
||||
return "[ok] déjà à jour — le fichier contient déjà cette modification"
|
||||
}
|
||||
return "[erreur] old introuvable dans le fichier"
|
||||
@@ -308,7 +367,11 @@ func fileEdit(path, oldText, newText string) string {
|
||||
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)
|
||||
updated := strings.Replace(search, oldN, newN, 1)
|
||||
if search != content {
|
||||
// La comparaison s'est faite en LF : on remet le fichier en CRLF.
|
||||
updated = strings.ReplaceAll(updated, "\n", "\r\n")
|
||||
}
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package loki
|
||||
|
||||
// chat_vision_tool.go — l'outil see_image : le modèle charge lui-même une image
|
||||
// du disque dans sa VISION, sans que l'utilisateur ait à la joindre au message.
|
||||
//
|
||||
// Loki savait déjà voir deux choses : une pièce jointe (web_upload.go) et une
|
||||
// capture d'écran qu'il venait de prendre (chat_screenshot.go). Pas un fichier
|
||||
// qui dort sur le disque. « Regarde la capture dans ~/photos/bug.png » n'avait
|
||||
// donc aucune réponse : `read` rend des octets binaires, et le modèle finissait
|
||||
// par décrire ce qu'il croyait deviner du nom de fichier.
|
||||
//
|
||||
// Mécanique : le résultat de l'outil reste un simple texte (un accusé). L'image,
|
||||
// elle, est réinjectée juste après dans un message utilisateur multimodal
|
||||
// (image_url) — le SEUL format que llama-server comprenne une fois --mmproj
|
||||
// chargé, et exactement le chemin déjà emprunté par les pièces jointes et les
|
||||
// captures.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// maxVisionBytes borne la taille d'une image chargée dans la vision. Au-delà,
|
||||
// le base64 gonfle le contexte pour rien : le moteur redimensionne de toute
|
||||
// façon avant l'encodeur d'images.
|
||||
const maxVisionBytes = 12 << 20 // 12 Mio
|
||||
|
||||
// toolSeeImage lit un fichier image et renvoie (accusé texte, partie image_url).
|
||||
// La partie image vaut nil en cas d'erreur : l'appelant n'injecte alors rien.
|
||||
//
|
||||
// Chaque refus dit CE QUI manque plutôt que « impossible » : sans projecteur, la
|
||||
// réponse n'est pas la même que sur un poste distant ou un fichier trop lourd,
|
||||
// et un modèle qui reçoit un motif clair peut corriger son geste lui-même.
|
||||
func toolSeeImage(path string) (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image", nil
|
||||
}
|
||||
// Cible = un poste distant : le fichier est LÀ-BAS, pas lisible d'ici.
|
||||
if agentTargetSlug() != "" {
|
||||
return "[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
|
||||
}
|
||||
if path == "" {
|
||||
return "[erreur] chemin de fichier manquant", nil
|
||||
}
|
||||
abs := resolveAgentPath(path)
|
||||
mime := imageMime(abs)
|
||||
if mime == "" {
|
||||
return "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : " + path, nil
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return "[erreur] fichier introuvable : " + path, nil
|
||||
}
|
||||
if st.IsDir() {
|
||||
return "[erreur] c'est un dossier, pas une image : " + path, nil
|
||||
}
|
||||
if st.Size() > maxVisionBytes {
|
||||
return fmt.Sprintf("[erreur] image trop lourde (%s, max %s) : %s",
|
||||
humanBytes(st.Size()), humanBytes(maxVisionBytes), path), nil
|
||||
}
|
||||
// Sonde du moteur EN DERNIER, juste avant le travail coûteux. Elle fait un
|
||||
// appel réseau (/props, mis en cache 10 s) : la poser d'entrée, c'était payer
|
||||
// un aller-retour pour répondre « chemin manquant », et surtout masquer les
|
||||
// motifs précis ci-dessus derrière un « le moteur ne voit pas » qui n'apprend
|
||||
// rien quand le vrai problème est une faute de frappe dans le chemin.
|
||||
//
|
||||
// Elle reste indispensable : un projecteur déclaré ne garantit pas que le
|
||||
// moteur en service sache traiter une image (mauvais couple modèle/mmproj,
|
||||
// moteur trop ancien). Autant le dire que d'envoyer 12 Mio de base64 que
|
||||
// personne ne regardera.
|
||||
if !engineSeesImages() {
|
||||
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "[erreur] lecture impossible : " + err.Error(), nil
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return "[erreur] fichier vide : " + path, nil
|
||||
}
|
||||
// Même préparation que les pièces jointes : orientation EXIF redressée et
|
||||
// grand côté ramené sous maxImageDim (voir web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return "[ok] image chargée : " + filepath.Base(abs), imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// imageURLPart construit la partie multimodale {type:image_url, image_url:{url}}
|
||||
// à partir d'octets image déjà préparés — le SEUL format qu'un llama-server
|
||||
// --mmproj comprend. Partagé par see_image, web_screenshot et browser_screenshot.
|
||||
func imageURLPart(b []byte, mime string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// seeImageMessage emballe la partie image dans le message utilisateur qui la
|
||||
// porte jusqu'au modèle. La légende nomme le fichier : après compaction il ne
|
||||
// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne
|
||||
// dirait pas LAQUELLE rouvrir.
|
||||
func seeImageMessage(label string, img map[string]any) Message {
|
||||
// Sans parenthèses vides : browser_screenshot n'a pas d'argument (label=""),
|
||||
// là où see_image porte le chemin du fichier.
|
||||
text := "Image :"
|
||||
if label != "" {
|
||||
text = "Image demandée (" + label + ") :"
|
||||
}
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": text},
|
||||
img,
|
||||
}}
|
||||
}
|
||||
|
||||
// seeImageTool — schéma envoyé au modèle. Description tenue au plus court : les
|
||||
// schémas partent dans CHAQUE requête et le préambule a un budget
|
||||
// (TestSystemPromptStaysLean).
|
||||
func seeImageTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "see_image",
|
||||
Description: "Ouvre un fichier image du disque pour le VOIR (png, jpg, gif, webp, bmp).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Chemin de l'image (relatif au dossier de travail, ou absolu)"},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngPixel : un PNG valide d'un pixel, assez pour qu'imageMime le reconnaisse.
|
||||
var pngPixel, _ = base64.StdEncoding.DecodeString(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
|
||||
|
||||
// visionOn arme la vision (clé MMPROJ) et renvoie le dossier de travail.
|
||||
func visionOn(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := testHome(t)
|
||||
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
// Sans projecteur, l'outil DIT ce qui manque au lieu d'échouer vaguement — et
|
||||
// il ne renvoie surtout aucune image.
|
||||
func TestSeeImageSansVisionRefuseEtExplique(t *testing.T) {
|
||||
testHome(t)
|
||||
txt, img := toolSeeImage("photo.png")
|
||||
if img != nil {
|
||||
t.Fatal("une image a été chargée alors que la vision est inactive")
|
||||
}
|
||||
if !strings.Contains(txt, "MMPROJ") {
|
||||
t.Fatalf("le refus ne dit pas ce qui manque : %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// Les refus qui ne dépendent pas du moteur : format, absence, dossier, poids.
|
||||
// Chacun doit nommer son motif — un modèle qui reçoit « impossible » ne peut
|
||||
// pas corriger son geste, alors qu'un « ce n'est pas une image » se rattrape.
|
||||
func TestSeeImageMotifsDeRefus(t *testing.T) {
|
||||
home := visionOn(t)
|
||||
write := func(name string, b []byte) string {
|
||||
p := filepath.Join(home, name)
|
||||
if err := os.WriteFile(p, b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
write("notes.txt", []byte("bonjour"))
|
||||
if err := os.MkdirAll(filepath.Join(home, "dossier.png"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gros := write("gros.png", append(append([]byte(nil), pngPixel...), make([]byte, maxVisionBytes)...))
|
||||
|
||||
cas := []struct{ path, attendu string }{
|
||||
{"", "chemin de fichier manquant"},
|
||||
{filepath.Join(home, "notes.txt"), "format non reconnu"},
|
||||
{filepath.Join(home, "absente.png"), "introuvable"},
|
||||
{filepath.Join(home, "dossier.png"), "dossier"},
|
||||
{gros, "trop lourde"},
|
||||
}
|
||||
for _, c := range cas {
|
||||
txt, img := toolSeeImage(c.path)
|
||||
if img != nil {
|
||||
t.Errorf("%q : une image a été chargée malgré le refus", c.path)
|
||||
}
|
||||
if !strings.Contains(txt, c.attendu) {
|
||||
t.Errorf("%q : motif = %q, attendu contenir %q", c.path, txt, c.attendu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le message porteur nomme le fichier : après compactage il ne restera que sa
|
||||
// légende et imageLostMarker, et « Image demandée : » tout court ne dirait pas
|
||||
// LAQUELLE rouvrir.
|
||||
func TestMessagePorteurNommeLeFichier(t *testing.T) {
|
||||
img := map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}
|
||||
m := seeImageMessage("captures/bug.png", img)
|
||||
if m.Role != "user" {
|
||||
t.Fatalf("rôle = %q, attendu user (un message tool ne porte que du texte)", m.Role)
|
||||
}
|
||||
parts, ok := m.Content.([]map[string]any)
|
||||
if !ok || len(parts) != 2 {
|
||||
t.Fatalf("contenu multimodal attendu, obtenu %#v", m.Content)
|
||||
}
|
||||
txt, _ := parts[0]["text"].(string)
|
||||
if !strings.Contains(txt, "captures/bug.png") {
|
||||
t.Fatalf("la légende ne nomme pas le fichier : %q", txt)
|
||||
}
|
||||
if parts[1]["type"] != "image_url" {
|
||||
t.Fatalf("seconde partie = %v, attendu image_url", parts[1]["type"])
|
||||
}
|
||||
// Et cette légende survit au compactage, suivie du marqueur de perte.
|
||||
got := msgText(m)
|
||||
if !strings.Contains(got, "captures/bug.png") || !strings.Contains(got, imageLostMarker) {
|
||||
t.Fatalf("après compaction : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le marqueur ne doit plus parler QUE de captures d'écran : une image perdue
|
||||
// peut venir du disque, et « reprends la capture » enverrait photographier une
|
||||
// page web à la place.
|
||||
func TestMarqueurDePerteNestPlusSpecifiqueAuxCaptures(t *testing.T) {
|
||||
if strings.Contains(imageLostMarker, "take the screenshot again") {
|
||||
t.Fatal("le marqueur impose encore la capture d'écran comme seul recours")
|
||||
}
|
||||
if !strings.Contains(imageLostMarker, "see_image") {
|
||||
t.Fatal("le marqueur ne mentionne pas see_image : le modèle ne saura pas rouvrir un fichier")
|
||||
}
|
||||
}
|
||||
|
||||
// L'outil n'est proposé QUE si la vision est active : l'annoncer sans projecteur
|
||||
// ferait promettre au modèle de regarder, puis se contredire.
|
||||
func TestOutilProposeSeulementAvecLaVision(t *testing.T) {
|
||||
testHome(t)
|
||||
has := func() bool {
|
||||
for _, tl := range EnabledTools(Caps{Agent: true}) {
|
||||
if tl.Function.Name == "see_image" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if has() {
|
||||
t.Fatal("see_image proposé sans projecteur configuré")
|
||||
}
|
||||
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !has() {
|
||||
t.Fatal("see_image absent alors que la vision est active")
|
||||
}
|
||||
// Hors mode agent, jamais : l'outil lit un fichier du disque.
|
||||
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tl := range EnabledTools(Caps{}) {
|
||||
if tl.Function.Name == "see_image" {
|
||||
t.Fatal("see_image proposé hors mode agent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le chemin doit remonter du JSON d'appel jusqu'à l'outil. Le libellé d'un appel
|
||||
// est dérivé par une table nom d'outil → argument ; see_image n'y figurait pas,
|
||||
// donc il recevait une chaîne vide et répondait invariablement « chemin de
|
||||
// fichier manquant » — l'outil aurait été inutilisable sans rien casser ailleurs.
|
||||
func TestLeCheminDeLImageRemonteJusquALOutil(t *testing.T) {
|
||||
if got := toolCallLabel("see_image", map[string]any{"file": "captures/bug.png"}); got != "captures/bug.png" {
|
||||
t.Fatalf("libellé de see_image = %q, attendu le chemin du fichier", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Contre-épreuve de l'extraction : les libellés des autres outils n'ont pas
|
||||
// bougé. Déplacer une table de 40 lignes hors d'une fonction de 700, c'est
|
||||
// exactement le genre de geste qui casse un cas au passage sans bruit.
|
||||
func TestLibellesDesAutresOutilsInchanges(t *testing.T) {
|
||||
cas := []struct{ tool, key, val, want string }{
|
||||
{"bash", "command", "ls -la", "ls -la"},
|
||||
{"read", "file", "main.go", "main.go"},
|
||||
{"web_search", "query", "météo", "météo"},
|
||||
{"grep", "pattern", "TODO", "TODO"},
|
||||
{"recall", "id", "b12", "b12"},
|
||||
{"git_clone", "url", "https://x/y", "https://x/y"},
|
||||
{"outil_inconnu", "file", "x", ""},
|
||||
}
|
||||
for _, c := range cas {
|
||||
if got := toolCallLabel(c.tool, map[string]any{c.key: c.val}); got != c.want {
|
||||
t.Errorf("toolCallLabel(%q) = %q, attendu %q", c.tool, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := toolCallLabel("web_grep", map[string]any{"url": "https://x", "pattern": "p"}); got != "p @ https://x" {
|
||||
t.Errorf("web_grep : %q", got)
|
||||
}
|
||||
if got := toolCallLabel("tracker", map[string]any{"action": "add", "name": "poids"}); got != "add poids" {
|
||||
t.Errorf("tracker : %q", got)
|
||||
}
|
||||
// Argument absent ou du mauvais type : chaîne vide, jamais de panique.
|
||||
if got := toolCallLabel("read", map[string]any{"file": 42}); got != "" {
|
||||
t.Errorf("argument non-chaîne : %q", got)
|
||||
}
|
||||
if got := toolCallLabel("bash", nil); got != "" {
|
||||
t.Errorf("arguments nil : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Dossier de travail du mode agent.
|
||||
@@ -17,6 +18,12 @@ import (
|
||||
// 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.
|
||||
//
|
||||
// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif
|
||||
// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour
|
||||
// qu'un fichier appartienne à la discussion où il est né et disparaisse avec
|
||||
// elle — ou celui de la tâche planifiée en cours (agentCwd, plus bas). La racine
|
||||
// reste la borne de sécurité des téléchargements.
|
||||
|
||||
const workspaceEnv = "LOKI_WORKSPACE"
|
||||
|
||||
@@ -68,8 +75,33 @@ func workspaceCandidates() []string {
|
||||
return c
|
||||
}
|
||||
|
||||
// Dossier de travail imposé pendant l'exécution d'une TÂCHE planifiée
|
||||
// (tasks_run.go). Une tâche tourne hors de toute discussion : sans ça, ses
|
||||
// fichiers atterriraient dans le dossier de la discussion qui se trouve ouverte
|
||||
// — et changeraient de place en cours de route si l'utilisateur en changeait.
|
||||
//
|
||||
// La bascule est un état de processus et non de goroutine, ce que Go ne sait pas
|
||||
// faire autrement ; c'est sûr ici parce qu'une SEULE génération tourne à la fois
|
||||
// (le verrou de conv), et parce que seuls les points d'entrée des OUTILS lisent
|
||||
// agentCwd(). Ce que voit l'utilisateur — panneau Fichiers, dépôts, liens des
|
||||
// messages — continue de passer par convWorkspace() et reste donc sur sa
|
||||
// discussion, même pendant qu'une tâche écrit ailleurs.
|
||||
var taskWS atomic.Value // string
|
||||
|
||||
func setTaskWorkspace(dir string) { taskWS.Store(dir) }
|
||||
|
||||
// agentCwd = le dossier où l'agent travaille MAINTENANT : celui de la tâche en
|
||||
// cours s'il y en a une, sinon celui de la discussion ouverte.
|
||||
func agentCwd() string {
|
||||
if d, _ := taskWS.Load().(string); d != "" {
|
||||
return d
|
||||
}
|
||||
return convWorkspace()
|
||||
}
|
||||
|
||||
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
|
||||
// "~/x" → dans le home de l'utilisateur ; relatif → dans le workspace.
|
||||
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
|
||||
// discussion ouverte.
|
||||
func resolveAgentPath(p string) string {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
@@ -83,5 +115,5 @@ func resolveAgentPath(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(agentWorkspace(), filepath.FromSlash(p))
|
||||
return filepath.Join(agentCwd(), filepath.FromSlash(p))
|
||||
}
|
||||
@@ -6,14 +6,19 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le
|
||||
// répertoire courant du processus (Bureau, C:\ProgramData\loki\bin…).
|
||||
// Un chemin relatif du modèle doit atterrir dans le dossier de la DISCUSSION
|
||||
// ouverte, jamais dans le répertoire courant du processus (Bureau,
|
||||
// C:\ProgramData\loki\bin…) ni dans un dossier commun à toutes les discussions.
|
||||
func TestResolveAgentPathRelative(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
got := resolveAgentPath("meteo.json")
|
||||
want := filepath.Join(agentWorkspace(), "meteo.json")
|
||||
want := filepath.Join(ws, "meteo.json")
|
||||
if got != want {
|
||||
t.Fatalf("resolveAgentPath = %q, attendu %q", got, want)
|
||||
}
|
||||
if !strings.Contains(got, convFilesRoot) {
|
||||
t.Fatalf("le fichier n'est pas rangé par discussion : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un chemin absolu demandé explicitement reste intouché.
|
||||
@@ -25,9 +30,10 @@ func TestResolveAgentPathAbsolute(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResolveAgentPathSubdir(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
got := resolveAgentPath("notes/2026/a.txt")
|
||||
if !strings.HasPrefix(got, agentWorkspace()) {
|
||||
t.Fatalf("%q hors du workspace %q", got, agentWorkspace())
|
||||
if !strings.HasPrefix(got, ws) {
|
||||
t.Fatalf("%q hors du dossier de la discussion %q", got, ws)
|
||||
}
|
||||
if strings.Contains(got, "/") && filepath.Separator != '/' {
|
||||
t.Fatalf("séparateurs non normalisés : %q", got)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package loki
|
||||
|
||||
// code_criteria.go — critères d'acceptation du mode code (contrat d'exécution,
|
||||
// repris du session-metadata/criteria d'OpenFox, réécrit en Go — voir
|
||||
// NOTICE.md). Les critères sont posés au début d'une tâche (par le modèle via
|
||||
// l'outil `criteria`, ou à la main dans l'UI), puis la passe de vérification
|
||||
// (code_verify.go) les fait passer à `passed` ou `failed`. Le tour de build ne
|
||||
// se termine « vraiment » que quand tout est passed — c'est le contrat.
|
||||
//
|
||||
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
|
||||
// la discussion, sa suppression les emporte.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Criterion struct {
|
||||
ID int `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"` // pending | passed | failed
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
const critMaxCount = 20
|
||||
|
||||
func critKey(convID string) string { return "crit:" + convID }
|
||||
|
||||
func critList(convID string) []Criterion {
|
||||
var list []Criterion
|
||||
if b := getBytes(bkChat, critKey(convID)); len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &list)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func critSave(convID string, list []Criterion) {
|
||||
if len(list) == 0 {
|
||||
_ = putBytes(bkChat, critKey(convID), nil) // nil supprime la clé
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, critKey(convID), b)
|
||||
}
|
||||
|
||||
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
|
||||
|
||||
// critAllPassed : le contrat est rempli. Une liste vide ne compte pas comme
|
||||
// remplie — il n'y a simplement pas de contrat.
|
||||
func critAllPassed(list []Criterion) bool {
|
||||
if len(list) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range list {
|
||||
if c.Status != "passed" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func critPending(list []Criterion) int {
|
||||
n := 0
|
||||
for _, c := range list {
|
||||
if c.Status != "passed" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// critRender : la liste formatée pour un prompt (builder ou verifier).
|
||||
func critRender(list []Criterion) string {
|
||||
var b strings.Builder
|
||||
for _, c := range list {
|
||||
mark := "[ ]"
|
||||
switch c.Status {
|
||||
case "passed":
|
||||
mark = "[✓]"
|
||||
case "failed":
|
||||
mark = "[✗]"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
|
||||
if c.Note != "" {
|
||||
fmt.Fprintf(&b, " — %s", c.Note)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func criteriaTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "criteria",
|
||||
Description: "Manage the acceptance criteria of the current task (the contract that defines DONE). " +
|
||||
"action=add posits new criteria (texts[]), action=set updates one (id + status passed|failed|pending, optional note), " +
|
||||
"action=list shows them, action=clear removes them all. Set criteria BEFORE building; only the verification pass may mark passed.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
|
||||
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
|
||||
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
|
||||
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
|
||||
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolCriteria exécute l'outil. allowPass : seul le passage de VÉRIFICATION a
|
||||
// le droit de marquer `passed` (sinon le builder s'auto-valide et le contrat ne
|
||||
// contraint plus rien).
|
||||
func toolCriteria(args map[string]any, allowPass bool) string {
|
||||
convID := convEnsureActive()
|
||||
action, _ := args["action"].(string)
|
||||
list := critList(convID)
|
||||
switch action {
|
||||
case "list", "":
|
||||
if len(list) == 0 {
|
||||
return "[aucun critère]"
|
||||
}
|
||||
return critRender(list)
|
||||
case "add":
|
||||
raw, _ := args["texts"].([]any)
|
||||
next := 1
|
||||
for _, c := range list {
|
||||
if c.ID >= next {
|
||||
next = c.ID + 1
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
for _, r := range raw {
|
||||
s, ok := r.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
if len(list) >= critMaxCount {
|
||||
break
|
||||
}
|
||||
list = append(list, Criterion{ID: next, Text: strings.TrimSpace(s), Status: "pending"})
|
||||
next++
|
||||
added++
|
||||
}
|
||||
if added == 0 {
|
||||
return "[erreur] aucun critère fourni (texts)"
|
||||
}
|
||||
critSave(convID, list)
|
||||
critNotify(list)
|
||||
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
|
||||
case "set":
|
||||
idf, _ := args["id"].(float64)
|
||||
id := int(idf)
|
||||
status, _ := args["status"].(string)
|
||||
note, _ := args["note"].(string)
|
||||
if status == "passed" && !allowPass {
|
||||
return "[refusé] Seule la passe de vérification peut marquer un critère passed. Termine ton implémentation ; la vérification suivra."
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].ID != id {
|
||||
continue
|
||||
}
|
||||
if status != "" {
|
||||
list[i].Status = status
|
||||
}
|
||||
if note != "" {
|
||||
list[i].Note = strings.TrimSpace(note)
|
||||
}
|
||||
critSave(convID, list)
|
||||
critNotify(list)
|
||||
return "[ok] critère #" + fmt.Sprint(id) + " → " + list[i].Status
|
||||
}
|
||||
return fmt.Sprintf("[erreur] critère #%d inconnu", id)
|
||||
case "clear":
|
||||
critDrop(convID)
|
||||
critNotify(nil)
|
||||
return "[ok] critères effacés"
|
||||
}
|
||||
return "[erreur] action inconnue : " + action
|
||||
}
|
||||
|
||||
// critNotify pousse la liste à l'UI (panneau critères) via le journal de la
|
||||
// conversation. Best-effort : hors génération il n'y a pas d'epoch en cours,
|
||||
// on publie sur l'epoch courant.
|
||||
func critNotify(list []Criterion) {
|
||||
if conv == nil {
|
||||
return
|
||||
}
|
||||
conv.mu.Lock()
|
||||
epoch := conv.epoch
|
||||
conv.mu.Unlock()
|
||||
if list == nil {
|
||||
list = []Criterion{}
|
||||
}
|
||||
conv.appendDelta(epoch, map[string]any{"criteria": list, "ts": time.Now().UnixMilli()})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cycle de vie des critères : add → set → clear, avec la garde allowPass.
|
||||
func TestCriteresCycleDeVie(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
|
||||
out := toolCriteria(map[string]any{"action": "add", "texts": []any{"le test passe", "le build compile"}}, false)
|
||||
if !strings.Contains(out, "2 critère(s)") {
|
||||
t.Fatalf("ajout raté : %s", out)
|
||||
}
|
||||
list := critList(id)
|
||||
if len(list) != 2 || list[0].ID != 1 || list[1].ID != 2 {
|
||||
t.Fatalf("liste inattendue : %+v", list)
|
||||
}
|
||||
// Le builder (allowPass=false) ne peut PAS s'auto-valider.
|
||||
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, false)
|
||||
if !strings.Contains(out, "[refusé]") {
|
||||
t.Fatalf("auto-validation acceptée : %s", out)
|
||||
}
|
||||
// Le vérificateur (allowPass=true), si.
|
||||
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, true)
|
||||
if !strings.Contains(out, "passed") || strings.Contains(out, "[refusé]") {
|
||||
t.Fatalf("validation vérificateur refusée : %s", out)
|
||||
}
|
||||
// failed reste ouvert à tous (le builder peut constater un échec).
|
||||
out = toolCriteria(map[string]any{"action": "set", "id": float64(2), "status": "failed", "note": "le build casse"}, false)
|
||||
if !strings.Contains(out, "failed") {
|
||||
t.Fatalf("échec non enregistré : %s", out)
|
||||
}
|
||||
list = critList(id)
|
||||
if critAllPassed(list) {
|
||||
t.Fatal("allPassed avec un failed")
|
||||
}
|
||||
if critPending(list) != 1 {
|
||||
t.Fatalf("pending = %d, attendu 1", critPending(list))
|
||||
}
|
||||
// Rendu : les statuts sont visibles.
|
||||
r := critRender(list)
|
||||
if !strings.Contains(r, "[✓] #1") || !strings.Contains(r, "[✗] #2") || !strings.Contains(r, "le build casse") {
|
||||
t.Fatalf("rendu incomplet :\n%s", r)
|
||||
}
|
||||
toolCriteria(map[string]any{"action": "clear"}, false)
|
||||
if len(critList(id)) != 0 {
|
||||
t.Fatal("clear sans effet")
|
||||
}
|
||||
}
|
||||
|
||||
// Une liste vide n'est pas un contrat rempli.
|
||||
func TestContratVide(t *testing.T) {
|
||||
if critAllPassed(nil) {
|
||||
t.Fatal("liste vide considérée comme remplie")
|
||||
}
|
||||
}
|
||||
|
||||
// Le plafond de critères tient.
|
||||
func TestCriteresPlafond(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
var texts []any
|
||||
for i := 0; i < critMaxCount+10; i++ {
|
||||
texts = append(texts, "critère")
|
||||
}
|
||||
toolCriteria(map[string]any{"action": "add", "texts": texts}, false)
|
||||
if n := len(critList(id)); n != critMaxCount {
|
||||
t.Fatalf("%d critères, plafond %d", n, critMaxCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package loki
|
||||
|
||||
// code_git.go — outils git natifs du mode code : git_status, git_diff,
|
||||
// git_clone. Le modèle les avait via bash, mais des outils dédiés donnent une
|
||||
// sortie bornée, un libellé propre dans le fil, et un clone qui atterrit
|
||||
// TOUJOURS dans le dossier de la discussion (jamais ailleurs sur le disque).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const gitMaxOutput = 8000
|
||||
|
||||
func gitStatusTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "git_status",
|
||||
Description: "git status of the working folder (branch + changed files, porcelain format).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func gitDiffTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "git_diff",
|
||||
Description: "git diff of the working folder (unstaged + staged). Optional file to diff a single path.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Limit the diff to this path"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func gitCloneTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "git_clone",
|
||||
Description: "Clone a git repository (https:// or git@) INTO the working folder of this discussion.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "Repository URL"},
|
||||
"dir": map[string]any{"type": "string", "description": "Target folder name (default: repo name)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runGit exécute git dans le dossier de la discussion, sortie bornée.
|
||||
func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return "[erreur] git n'est pas installé sur cette machine"
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "git", args...)
|
||||
cmd.Dir = agentCwd()
|
||||
// Jamais d'invite interactive (mot de passe, hôte inconnu) : un process
|
||||
// serveur n'a personne pour y répondre, il faut échouer vite et le dire.
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes")
|
||||
out, err := cmd.CombinedOutput()
|
||||
s := tailRunes(strings.TrimSpace(string(out)), gitMaxOutput)
|
||||
if cctx.Err() == context.DeadlineExceeded {
|
||||
return "[timeout] git " + strings.Join(args, " ")
|
||||
}
|
||||
if err != nil {
|
||||
if s == "" {
|
||||
s = err.Error()
|
||||
}
|
||||
return "[erreur] " + s
|
||||
}
|
||||
if s == "" {
|
||||
s = "(aucune sortie)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func toolGitStatus(ctx context.Context) string {
|
||||
return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch")
|
||||
}
|
||||
|
||||
func toolGitDiff(ctx context.Context, args map[string]any) string {
|
||||
// Un seul appel qui montre TOUT ce qui a changé : index + arbre de travail.
|
||||
// HEAD peut ne pas exister (dépôt tout neuf) : on retombe sur le diff simple.
|
||||
gitArgs := []string{"diff", "HEAD"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
gitArgs = append(gitArgs, "--", file)
|
||||
}
|
||||
out := runGit(ctx, 30*time.Second, gitArgs...)
|
||||
if strings.HasPrefix(out, "[erreur]") && strings.Contains(out, "HEAD") {
|
||||
fallback := []string{"diff"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
fallback = append(fallback, "--", file)
|
||||
}
|
||||
out = runGit(ctx, 30*time.Second, fallback...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gitURLRe : https:// ou git@hôte: — pas de chemins locaux ni de protocoles
|
||||
// exotiques (file://, ext:: qui exécute une commande arbitraire).
|
||||
var gitURLRe = regexp.MustCompile(`^(https://[\w.\-]+(:\d+)?/|git@[\w.\-]+:)[\w.\-~/]+$`)
|
||||
|
||||
func toolGitClone(ctx context.Context, args map[string]any) string {
|
||||
url, _ := args["url"].(string)
|
||||
url = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(url), ".git")) + ".git"
|
||||
if !gitURLRe.MatchString(strings.TrimSuffix(url, ".git")) && !gitURLRe.MatchString(url) {
|
||||
return "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés"
|
||||
}
|
||||
dir, _ := args["dir"].(string)
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
base := filepath.Base(strings.TrimSuffix(url, ".git"))
|
||||
dir = base
|
||||
}
|
||||
// Le clone atterrit DANS la discussion, quoi qu'il arrive.
|
||||
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
|
||||
return "[erreur] dossier cible invalide"
|
||||
}
|
||||
target := filepath.Join(agentCwd(), dir)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "[erreur] le dossier " + dir + " existe déjà"
|
||||
}
|
||||
out := runGit(ctx, 5*time.Minute, "clone", "--", url, target)
|
||||
if strings.HasPrefix(out, "[erreur]") || strings.HasPrefix(out, "[timeout]") {
|
||||
return out
|
||||
}
|
||||
return fmt.Sprintf("[ok] cloné dans %s/\n%s", dir, out)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package loki
|
||||
|
||||
// code_jobs.go — commandes shell d'ARRIÈRE-PLAN pour le mode code : bash_bg
|
||||
// lance un process détaché (serveur de dev, watcher, build long) et rend la
|
||||
// main tout de suite ; bash_tail relit sa sortie et son état. Sans ça, le
|
||||
// modèle lance `./serveur &` dans bash et le tour reste suspendu aux tubes
|
||||
// (voir le commentaire WaitDelay de runShell).
|
||||
//
|
||||
// La sortie va dans un fichier du dossier de la discussion
|
||||
// (.loki/jobs/<id>.log) : elle survit au tour, et le panneau Fichiers la montre.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bgJob struct {
|
||||
ID string
|
||||
Command string
|
||||
LogPath string
|
||||
Cmd *exec.Cmd
|
||||
Started time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
done bool
|
||||
exit int
|
||||
}
|
||||
|
||||
var (
|
||||
jobsMu sync.Mutex
|
||||
jobs = map[string]*bgJob{}
|
||||
jobSeq int
|
||||
)
|
||||
|
||||
func bashBgTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "bash_bg",
|
||||
Description: "Start a shell command in the BACKGROUND (dev server, watcher, long build) and return immediately with a job id. Output goes to a log file; check it with bash_tail. Use this instead of appending '&' in bash.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{"type": "string", "description": "The command"},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func bashTailTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "bash_tail",
|
||||
Description: "Read the last lines of a background job's output and its status (running/exited). kill:true stops the job.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "Job id from bash_bg"},
|
||||
"lines": map[string]any{"type": "integer", "description": "Lines to show (default 40, max 200)"},
|
||||
"kill": map[string]any{"type": "boolean", "description": "Stop the job"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolBashBg lance la commande détachée. Même politique que bash (commandes
|
||||
// dangereuses refusées), même dossier de départ (discussion courante).
|
||||
func toolBashBg(args map[string]any) string {
|
||||
command, _ := args["command"].(string)
|
||||
if strings.TrimSpace(command) == "" {
|
||||
return "[erreur] commande vide"
|
||||
}
|
||||
if reason := dangerousCommand(command); reason != "" {
|
||||
return refusedCommandResult(reason)
|
||||
}
|
||||
ws := agentCwd()
|
||||
logDir := filepath.Join(ws, ".loki", "jobs")
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
jobsMu.Lock()
|
||||
jobSeq++
|
||||
id := fmt.Sprintf("job%d", jobSeq)
|
||||
jobsMu.Unlock()
|
||||
logPath := filepath.Join(logDir, id+".log")
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
// Contexte de FOND, pas celui du tour : le job doit survivre à la fin du
|
||||
// tour — c'est sa raison d'être. Il s'arrête par bash_tail kill:true, ou
|
||||
// avec le conteneur.
|
||||
cmd := newShellCmdDetached(command)
|
||||
cmd.Dir = ws
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
j := &bgJob{ID: id, Command: command, LogPath: logPath, Cmd: cmd, Started: time.Now()}
|
||||
jobsMu.Lock()
|
||||
jobs[id] = j
|
||||
jobsMu.Unlock()
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
logFile.Close()
|
||||
j.mu.Lock()
|
||||
j.done = true
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
j.exit = ee.ExitCode()
|
||||
}
|
||||
j.mu.Unlock()
|
||||
}()
|
||||
return fmt.Sprintf("[ok] job %s lancé (pid %d) — sortie dans .loki/jobs/%s.log, suis-la avec bash_tail", id, cmd.Process.Pid, id)
|
||||
}
|
||||
|
||||
// toolBashTail relit la fin du journal d'un job et son état.
|
||||
func toolBashTail(args map[string]any) string {
|
||||
id, _ := args["id"].(string)
|
||||
jobsMu.Lock()
|
||||
j := jobs[id]
|
||||
jobsMu.Unlock()
|
||||
if j == nil {
|
||||
return "[erreur] job inconnu : " + id
|
||||
}
|
||||
if kill, _ := args["kill"].(bool); kill {
|
||||
j.mu.Lock()
|
||||
running := !j.done
|
||||
j.mu.Unlock()
|
||||
if running && j.Cmd.Process != nil {
|
||||
_ = j.Cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
lines := 40
|
||||
if v, ok := args["lines"].(float64); ok && int(v) > 0 {
|
||||
lines = int(v)
|
||||
}
|
||||
if lines > 200 {
|
||||
lines = 200
|
||||
}
|
||||
b, err := os.ReadFile(j.LogPath)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
all := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
||||
if len(all) > lines {
|
||||
all = all[len(all)-lines:]
|
||||
}
|
||||
j.mu.Lock()
|
||||
status := "en cours"
|
||||
if j.done {
|
||||
status = fmt.Sprintf("terminé (exit %d)", j.exit)
|
||||
}
|
||||
j.mu.Unlock()
|
||||
out := fmt.Sprintf("job %s — %s — depuis %s\ncommande : %s\n", j.ID, status, time.Since(j.Started).Round(time.Second), j.Command)
|
||||
tail := strings.Join(all, "\n")
|
||||
if strings.TrimSpace(tail) == "" {
|
||||
tail = "(aucune sortie pour l'instant)"
|
||||
}
|
||||
return out + "\n" + tailRunes(tail, toolMaxOutput)
|
||||
}
|
||||
|
||||
// stopConvJobs tue les jobs d'une discussion (suppression / vidage de la
|
||||
// discussion : ses fichiers partent, ses process aussi).
|
||||
func stopConvJobs(convID string) {
|
||||
prefix := filepath.Join(agentWorkspace(), convFilesRoot, safeConvID(convID))
|
||||
jobsMu.Lock()
|
||||
defer jobsMu.Unlock()
|
||||
for id, j := range jobs {
|
||||
if strings.HasPrefix(j.LogPath, prefix) {
|
||||
j.mu.Lock()
|
||||
running := !j.done
|
||||
j.mu.Unlock()
|
||||
if running && j.Cmd.Process != nil {
|
||||
_ = j.Cmd.Process.Kill()
|
||||
}
|
||||
delete(jobs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package loki
|
||||
|
||||
// code_mode.go — le mode Chat/Code d'une discussion. Bascule EXPLICITE
|
||||
// (sélecteur dans le pied du composeur), persistée par discussion ; en mode
|
||||
// chat, une détection côté serveur peut SUGGÉRER la bascule (puce ignorable
|
||||
// dans l'UI) mais ne l'impose jamais — un faux positif coûterait des tours de
|
||||
// planification/vérification sur un simple bonjour.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func modeKey(convID string) string { return "mode:" + convID }
|
||||
func hintKey(convID string) string { return "hinted:" + convID }
|
||||
|
||||
// convCodeMode : la discussion active est-elle en mode code ?
|
||||
func convCodeMode() bool { return getStr(bkChat, modeKey(convEnsureActive())) == "code" }
|
||||
|
||||
func setConvMode(convID, mode string) error {
|
||||
if mode != "code" {
|
||||
mode = "" // chat = défaut = clé absente
|
||||
}
|
||||
return putStr(bkChat, modeKey(convID), mode)
|
||||
}
|
||||
|
||||
// dropConvMode : appelé à la suppression d'une discussion.
|
||||
func dropConvMode(convID string) {
|
||||
_ = putStr(bkChat, modeKey(convID), "")
|
||||
_ = putStr(bkChat, hintKey(convID), "")
|
||||
}
|
||||
|
||||
// --- Détection « ça ressemble à du code » ------------------------------------
|
||||
// Côté serveur, AUCUN token : ni consigne dans le prompt, ni appel modèle.
|
||||
// Un indice suffit ; la puce de l'UI est gratuite à ignorer.
|
||||
|
||||
var codeHintRes = []*regexp.Regexp{
|
||||
// Un bloc de code clôturé.
|
||||
regexp.MustCompile("```"),
|
||||
// Un chemin de fichier source.
|
||||
regexp.MustCompile(`(?i)\b[\w./\\-]+\.(go|py|js|jsx|ts|tsx|rs|java|kt|c|h|cpp|hpp|cs|rb|php|swift|sh|ps1|sql|html|css|scss|vue|svelte|toml|yaml|yml|json|dockerfile|makefile)\b`),
|
||||
// Verbes / vocabulaire de tâche de code.
|
||||
regexp.MustCompile(`(?i)\b(corrige|implémente|implemente|refactor|refactorise|débogue|debug|compile|stack ?trace|segfault|exception|traceback|unit ?test|pull ?request|merge|commit|clone)\b`),
|
||||
regexp.MustCompile(`(?i)\b(bug|erreur de compilation|tests? (échoue|cassé)|fonction|classe|module|api rest|endpoint)\b`),
|
||||
}
|
||||
|
||||
// looksLikeCode dit si un message utilisateur ressemble à une tâche de code.
|
||||
// Le dépôt git déjà cloné dans la discussion est aussi un indice fort.
|
||||
func looksLikeCode(text string) bool {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return false
|
||||
}
|
||||
for _, re := range codeHintRes {
|
||||
if re.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// convHasRepo : un dépôt git vit déjà dans le dossier de la discussion.
|
||||
func convHasRepo() bool {
|
||||
ws := agentCwd()
|
||||
if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
entries, err := os.ReadDir(ws)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ws, e.Name(), ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y
|
||||
// prête. Une seule fois par discussion : une suggestion répétée est du bruit.
|
||||
func maybeCodeHint(c *Conversation, epoch int, text string) {
|
||||
if convCodeMode() || !agentEnabled() {
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
if getBool(bkChat, hintKey(id)) {
|
||||
return
|
||||
}
|
||||
if !looksLikeCode(text) && !convHasRepo() {
|
||||
return
|
||||
}
|
||||
_ = putBool(bkChat, hintKey(id), true)
|
||||
c.appendDelta(epoch, map[string]any{"code_hint": true})
|
||||
}
|
||||
|
||||
// --- API ---------------------------------------------------------------------
|
||||
|
||||
// handleChatMode — GET : {mode, criteria} de la discussion active ;
|
||||
// POST {"mode":"code"|"chat"} : bascule (et pousse le nouvel état aux abonnés).
|
||||
func handleChatMode(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var body struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Mode != "code" && body.Mode != "chat" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu (chat|code)"})
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
if err := setConvMode(id, body.Mode); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Pousse la bascule à tous les appareils abonnés (le sélecteur doit
|
||||
// suivre, comme le fil lui-même).
|
||||
conv.mu.Lock()
|
||||
epoch := conv.epoch
|
||||
conv.mu.Unlock()
|
||||
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode})
|
||||
return
|
||||
}
|
||||
mode := "chat"
|
||||
if convCodeMode() {
|
||||
mode = "code"
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"mode": mode,
|
||||
"criteria": critList(convEnsureActive()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleChatCriteria — POST : édition manuelle des critères depuis l'UI.
|
||||
// {"criteria":[{"id":1,"text":"…","status":"pending"},…]} remplace la liste.
|
||||
func handleChatCriteria(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Criteria []Criterion `json:"criteria"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
var clean []Criterion
|
||||
next := 1
|
||||
for _, c := range body.Criteria {
|
||||
if strings.TrimSpace(c.Text) == "" || len(clean) >= critMaxCount {
|
||||
continue
|
||||
}
|
||||
if c.ID <= 0 {
|
||||
c.ID = next
|
||||
}
|
||||
if c.ID >= next {
|
||||
next = c.ID + 1
|
||||
}
|
||||
switch c.Status {
|
||||
case "pending", "passed", "failed":
|
||||
default:
|
||||
c.Status = "pending"
|
||||
}
|
||||
clean = append(clean, c)
|
||||
}
|
||||
critSave(convEnsureActive(), clean)
|
||||
critNotify(clean)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "criteria": clean})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// La bascule est persistée par discussion et retombe sur chat par défaut.
|
||||
func TestModeParDiscussion(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
if convCodeMode() {
|
||||
t.Fatal("mode code par défaut")
|
||||
}
|
||||
if err := setConvMode(id, "code"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !convCodeMode() {
|
||||
t.Fatal("bascule sans effet")
|
||||
}
|
||||
dropConvMode(id)
|
||||
if convCodeMode() {
|
||||
t.Fatal("mode code après nettoyage")
|
||||
}
|
||||
}
|
||||
|
||||
// La détection reconnaît une tâche de code, pas la conversation ordinaire.
|
||||
func TestDetectionCode(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"corrige le bug dans main.go",
|
||||
"j'ai une stack trace : Exception in thread",
|
||||
"```python\nprint('x')\n```",
|
||||
"implémente la fonction de tri",
|
||||
"le build casse avec une erreur de compilation",
|
||||
} {
|
||||
if !looksLikeCode(s) {
|
||||
t.Errorf("non détecté : %q", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"quelle est la capitale de l'Australie ?",
|
||||
"raconte-moi une histoire",
|
||||
"quel temps fait-il demain ?",
|
||||
"",
|
||||
} {
|
||||
if looksLikeCode(s) {
|
||||
t.Errorf("détecté à tort : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les rôles embarqués existent tous, et le prompt du builder est court —
|
||||
// la règle « ne pas regonfler » de baseSystemPrompt s'applique aussi ici.
|
||||
func TestRolesEmbarques(t *testing.T) {
|
||||
for _, r := range roleNames {
|
||||
p := rolePrompt(r)
|
||||
if p == "" {
|
||||
t.Errorf("rôle %s : prompt vide", r)
|
||||
}
|
||||
if len(p) > 2500 {
|
||||
t.Errorf("rôle %s : prompt trop long (%d octets)", r, len(p))
|
||||
}
|
||||
}
|
||||
if rolePrompt("inexistant") != "" {
|
||||
t.Error("rôle inconnu devrait rendre vide")
|
||||
}
|
||||
}
|
||||
|
||||
// wantsPlan : demande de plan reconnue, implémentation directe non.
|
||||
func TestWantsPlan(t *testing.T) {
|
||||
if !wantsPlan("Fais un plan avant de coder") {
|
||||
t.Error("plan non reconnu")
|
||||
}
|
||||
if wantsPlan("corrige le bug maintenant") {
|
||||
t.Error("plan détecté à tort")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package loki
|
||||
|
||||
// code_policy.go — politique d'exécution des outils de l'agent (repris dans
|
||||
// l'esprit de tool-policy/path-security d'OpenFox, réécrit en Go — voir
|
||||
// NOTICE.md).
|
||||
//
|
||||
// Deux gardes indépendantes :
|
||||
// - dangerousCommand : liste courte de commandes CATASTROPHIQUES (effacement
|
||||
// de disque, arrêt machine, fork bomb). Toujours active, mode chat compris :
|
||||
// aucune de ces commandes n'a d'usage légitime lancée par un modèle. Le
|
||||
// refus explique quoi faire (l'utilisateur la lance lui-même).
|
||||
// - codePathAllowed : en MODE CODE, tous les chemins des outils fichiers sont
|
||||
// bornés au dossier de la discussion — liens symboliques résolus des deux
|
||||
// côtés, comme le panneau Fichiers (relWithin). Le mode chat garde le
|
||||
// comportement historique (chemins absolus honorés) : c'est le mode code
|
||||
// qui promet un bac à sable, pas l'agent généraliste.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// dangerousPatterns : motifs (insensibles à la casse) de commandes refusées.
|
||||
// Volontairement ÉTROIT : viser la perte de données irréversible et l'arrêt de
|
||||
// la machine, pas la moindre écriture. Un motif trop large rend l'agent
|
||||
// inutilisable et pousse à tout désactiver.
|
||||
var dangerousPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
reason string
|
||||
}{
|
||||
// rm -rf / (ou ~, ou une racine de lecteur) — pas un rm -rf d'un sous-dossier.
|
||||
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s*$`), "efface la racine du disque ou le home"},
|
||||
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s`), "efface la racine du disque ou le home"},
|
||||
// Écriture directe sur un périphérique bloc / formatage.
|
||||
{regexp.MustCompile(`(?i)\bmkfs(\.[a-z0-9]+)?\b`), "formate un système de fichiers"},
|
||||
{regexp.MustCompile(`(?i)\bdd\b[^\n]*\bof=/dev/`), "écrit directement sur un périphérique disque"},
|
||||
{regexp.MustCompile(`(?i)>\s*/dev/sd[a-z]\b`), "écrit directement sur un périphérique disque"},
|
||||
// Arrêt / redémarrage de la machine (ici : le conteneur, donc tout Loki).
|
||||
// Ancré en position de COMMANDE (début, après ; && | ou sudo) : le mot
|
||||
// « reboot » dans un grep ou un texte n'est pas une commande.
|
||||
{regexp.MustCompile(`(?i)(^|[;&|]\s*|\bsudo\s+)(shutdown|poweroff|reboot|halt)\b`), "arrête ou redémarre la machine"},
|
||||
// Fork bomb classique.
|
||||
{regexp.MustCompile(`:\(\)\s*\{\s*:\|:`), "fork bomb"},
|
||||
// chmod/chown récursif sur la racine.
|
||||
{regexp.MustCompile(`(?i)\bch(mod|own)\b[^\n]*-[a-z]*R[a-z]*\s+[^\s]+\s+/\s*$`), "change récursivement les droits de la racine"},
|
||||
// Windows : suppression récursive de la racine d'un lecteur, format.
|
||||
{regexp.MustCompile(`(?i)\b(rd|rmdir)\s+/s\b[^\n]*\s[a-z]:\\?\s*$`), "efface la racine d'un lecteur"},
|
||||
{regexp.MustCompile(`(?i)\bdel\s+(/[fsq]\s+)*[a-z]:\\\*`), "efface la racine d'un lecteur"},
|
||||
{regexp.MustCompile(`(?i)\bformat\s+[a-z]:`), "formate un lecteur"},
|
||||
}
|
||||
|
||||
// dangerousCommand renvoie la raison du refus si la commande correspond à un
|
||||
// motif interdit, "" sinon.
|
||||
func dangerousCommand(cmd string) string {
|
||||
for _, p := range dangerousPatterns {
|
||||
if p.re.MatchString(cmd) {
|
||||
return p.reason
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// refusedCommandResult formate le refus renvoyé au modèle : dire POURQUOI et
|
||||
// QUOI FAIRE, sinon il réessaie en boucle avec des variantes.
|
||||
func refusedCommandResult(reason string) string {
|
||||
return "[refusé] Commande bloquée par la politique de sécurité : " + reason + ". " +
|
||||
"Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal."
|
||||
}
|
||||
|
||||
// codePathAllowed dit si un chemin (déjà résolu par resolveAgentPath) est DANS
|
||||
// le dossier de la discussion courante. Utilisé par les outils fichiers en mode
|
||||
// code uniquement.
|
||||
func codePathAllowed(abs string) bool {
|
||||
ws := agentCwd()
|
||||
if ws == "" {
|
||||
return false
|
||||
}
|
||||
abs = filepath.Clean(abs)
|
||||
// Le fichier peut ne pas exister encore (write) : EvalSymlinks échouerait.
|
||||
// On teste alors son ancêtre le plus proche existant — c'est LUI qui doit
|
||||
// être dans le workspace, liens symboliques résolus.
|
||||
probe := abs
|
||||
for {
|
||||
if _, err := os.Stat(probe); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(probe)
|
||||
if parent == probe {
|
||||
break
|
||||
}
|
||||
probe = parent
|
||||
}
|
||||
if !sameOrWithin(ws, probe) {
|
||||
return false
|
||||
}
|
||||
// L'ancêtre est dans le workspace ; le chemin FINAL ne doit pas en
|
||||
// ressortir par des ".." lexicaux non encore résolus.
|
||||
rel, err := filepath.Rel(ws, abs)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sameOrWithin : p est root lui-même, ou dedans — liens symboliques résolus
|
||||
// des deux côtés (relWithin refuse « . », d'où ce cousin qui l'accepte : la
|
||||
// racine du workspace est un chemin légitime pour grep/glob).
|
||||
func sameOrWithin(root, p string) bool {
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = r
|
||||
}
|
||||
if q, err := filepath.EvalSymlinks(p); err == nil {
|
||||
p = q
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
// refusedPathResult : refus de chemin hors du bac à sable du mode code.
|
||||
func refusedPathResult(path string) string {
|
||||
return "[refusé] " + path + " est hors du dossier de cette discussion. " +
|
||||
"En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
}
|
||||
|
||||
// codeWriteGuard regroupe les gardes du mode code AVANT une écriture locale
|
||||
// (write/edit) : borne de chemin, puis tracker « lu avant d'écrire ».
|
||||
// Renvoie "" si tout est bon, sinon le message de refus pour le modèle.
|
||||
// En mode chat, aucune garde : comportement historique de l'agent.
|
||||
func codeWriteGuard(caps Caps, file string, forWrite bool) string {
|
||||
if !caps.Code {
|
||||
return ""
|
||||
}
|
||||
path := resolveAgentPath(file)
|
||||
if !codePathAllowed(path) {
|
||||
return refusedPathResult(file)
|
||||
}
|
||||
return trackerCheck(path, forWrite)
|
||||
}
|
||||
|
||||
// --- Mutex par fichier -------------------------------------------------------
|
||||
// Sérialise les écritures concurrentes sur un même fichier (write/edit). Sans
|
||||
// parallélisme d'outils aujourd'hui le coût est nul, mais le jour où deux tours
|
||||
// ou un job d'arrière-plan écrivent le même fichier, la corruption est évitée.
|
||||
|
||||
var fileMuMap sync.Map // clé : chemin nettoyé → *sync.Mutex
|
||||
|
||||
func fileMu(path string) *sync.Mutex {
|
||||
key := filepath.Clean(path)
|
||||
if m, ok := fileMuMap.Load(key); ok {
|
||||
return m.(*sync.Mutex)
|
||||
}
|
||||
m, _ := fileMuMap.LoadOrStore(key, &sync.Mutex{})
|
||||
return m.(*sync.Mutex)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La politique doit bloquer les commandes catastrophiques…
|
||||
func TestCommandesDangereusesBloquees(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"rm -rf /",
|
||||
"rm -fr / --no-preserve-root",
|
||||
"sudo rm -rf /*",
|
||||
"mkfs.ext4 /dev/sda1",
|
||||
"dd if=/dev/zero of=/dev/sda",
|
||||
"echo pwned > /dev/sda",
|
||||
"shutdown -h now",
|
||||
"reboot",
|
||||
":(){ :|:& };:",
|
||||
"format c:",
|
||||
"rd /s /q C:\\",
|
||||
} {
|
||||
if dangerousCommand(cmd) == "" {
|
||||
t.Errorf("aurait dû être bloquée : %q", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// … et laisser passer le travail normal, y compris les rm ciblés.
|
||||
func TestCommandesNormalesAutorisees(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"ls -la",
|
||||
"go test ./...",
|
||||
"rm -rf node_modules",
|
||||
"rm -rf ./dist",
|
||||
"git rm --cached fichier.txt",
|
||||
"grep -r 'reboot the router' docs/",
|
||||
"npm run build",
|
||||
"dd if=disque.img of=copie.img",
|
||||
"echo bonjour > notes.txt",
|
||||
} {
|
||||
if r := dangerousCommand(cmd); r != "" {
|
||||
t.Errorf("bloquée à tort (%s) : %q", r, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// En mode code, les chemins sont bornés au dossier de la discussion :
|
||||
// dedans OK, dehors refusé, y compris par remontée de ../.
|
||||
func TestCodePathBorne(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
inside := filepath.Join(ws, "src", "main.go")
|
||||
if !codePathAllowed(inside) {
|
||||
t.Error("chemin intérieur refusé")
|
||||
}
|
||||
if !codePathAllowed(filepath.Join(ws, "nouveau.txt")) {
|
||||
t.Error("fichier à créer (inexistant) refusé")
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(ws), "ailleurs.txt")
|
||||
if codePathAllowed(outside) {
|
||||
t.Error("chemin extérieur accepté")
|
||||
}
|
||||
if codePathAllowed(filepath.Join(ws, "..", "..", "etc", "passwd")) {
|
||||
t.Error("remontée ../ acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// codeWriteGuard : inactif en mode chat, actif en mode code (borne + tracker).
|
||||
func TestCodeWriteGuard(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
chat := Caps{Agent: true}
|
||||
code := Caps{Agent: true, Code: true}
|
||||
|
||||
// Mode chat : aucune garde, même hors workspace.
|
||||
if msg := codeWriteGuard(chat, "/tmp/hors-discussion.txt", true); msg != "" {
|
||||
t.Errorf("mode chat gardé à tort : %s", msg)
|
||||
}
|
||||
// Mode code : hors workspace refusé.
|
||||
if msg := codeWriteGuard(code, filepath.Join(filepath.Dir(ws), "x.txt"), true); msg == "" {
|
||||
t.Error("écriture hors workspace acceptée en mode code")
|
||||
}
|
||||
// Fichier existant jamais lu : write refusé (tracker).
|
||||
target := filepath.Join(ws, "present.txt")
|
||||
if err := os.WriteFile(target, []byte("v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := codeWriteGuard(code, "present.txt", true); !strings.Contains(msg, "pas lu") {
|
||||
t.Errorf("écrasement sans lecture accepté : %q", msg)
|
||||
}
|
||||
// Après lecture : autorisé.
|
||||
trackerNoteRead(target)
|
||||
if msg := codeWriteGuard(code, "present.txt", true); msg != "" {
|
||||
t.Errorf("refusé après lecture : %s", msg)
|
||||
}
|
||||
// Création d'un fichier neuf : pas de lecture préalable exigée.
|
||||
if msg := codeWriteGuard(code, "neuf.txt", true); msg != "" {
|
||||
t.Errorf("création refusée : %s", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package loki
|
||||
|
||||
// code_retry.go — filets « auto-retry » (repris des auto-retry patterns
|
||||
// d'OpenFox, réécrits en Go — voir NOTICE.md) : quand le modèle termine son
|
||||
// tour sur un texte qui contient un APPEL D'OUTIL TEXTUEL (halluciné, donc
|
||||
// jamais exécuté), on relance le tour UNE fois avec une consigne corrective au
|
||||
// lieu de laisser l'utilisateur lire un pseudo-JSON d'appel d'outil.
|
||||
//
|
||||
// Typique des petits modèles très quantifiés : `default_api:bash`,
|
||||
// `<tool_call>{...}</tool_call>`, un bloc ```tool_code```… Le modèle VOULAIT
|
||||
// agir ; il a juste raté la syntaxe du protocole. Le nudge « pensé sans agir »
|
||||
// existant ne les attrapait pas : le tour a bien produit du contenu.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
|
||||
// serrés — un faux positif relancerait un tour parfaitement bon.
|
||||
var defaultRetryPatterns = []string{
|
||||
`<tool_call>`,
|
||||
`</tool_call>`,
|
||||
`<\|tool_call\|>`,
|
||||
"```tool_code",
|
||||
"```tool_call",
|
||||
`\bdefault_api[.:]\w+`,
|
||||
`\bfunctions\.\w+\s*\(`,
|
||||
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
|
||||
`\[TOOL_REQUEST\]`,
|
||||
}
|
||||
|
||||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
||||
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
|
||||
// choisi son rôle (CLI, serveur…).
|
||||
var (
|
||||
retryOnce sync.Once
|
||||
retryRes []*regexp.Regexp
|
||||
)
|
||||
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
retryOnce.Do(func() { retryRes = compileRetryPatterns() })
|
||||
return retryRes
|
||||
}
|
||||
|
||||
func compileRetryPatterns() []*regexp.Regexp {
|
||||
pats := defaultRetryPatterns
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
|
||||
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
|
||||
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
|
||||
if raw := getStr(bkState, "retry_patterns"); raw != "" {
|
||||
var custom []string
|
||||
if json.Unmarshal([]byte(raw), &custom) == nil && len(custom) > 0 {
|
||||
pats = custom
|
||||
}
|
||||
}
|
||||
var out []*regexp.Regexp
|
||||
for _, p := range pats {
|
||||
if re, err := regexp.Compile("(?mi)" + p); err == nil {
|
||||
out = append(out, re)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
||||
// écrit en toutes lettres au lieu d'être émis par le protocole.
|
||||
func textualToolCall(content string) bool {
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
for _, re := range retryPatterns() {
|
||||
if re.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// retryCorrective : le message réinjecté pour relancer le tour.
|
||||
const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " +
|
||||
"Tool calls must go through the tool-call protocol, never in the answer text. " +
|
||||
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
||||
@@ -0,0 +1,34 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Les appels d'outils écrits en texte doivent être détectés…
|
||||
func TestAppelOutilTextuelDetecte(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"<tool_call>{\"name\":\"bash\"}</tool_call>",
|
||||
"Je vais lancer default_api:bash pour vérifier.",
|
||||
"default_api.run_shell(command='ls')",
|
||||
"functions.bash({\"command\": \"ls\"})",
|
||||
"```tool_code\nprint('x')\n```",
|
||||
"{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}",
|
||||
} {
|
||||
if !textualToolCall(s) {
|
||||
t.Errorf("non détecté : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// … sans jamais relancer un tour normal (code cité, JSON quelconque).
|
||||
func TestReponseNormaleNonRelancee(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"Voici la réponse : le fichier contient trois fonctions.",
|
||||
"```go\nfunc main() {}\n```",
|
||||
"Le JSON de config : {\"name\": \"loki\", \"port\": 8090}",
|
||||
"La fonction bash() de ce script accepte un paramètre.",
|
||||
"",
|
||||
} {
|
||||
if textualToolCall(s) {
|
||||
t.Errorf("relance à tort : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package loki
|
||||
|
||||
// code_roles.go — registre des rôles du mode code. Chaque rôle est un prompt
|
||||
// Markdown EMBARQUÉ dans le binaire (agents/*.md), comme le catalogue MCP :
|
||||
// pas d'appel réseau, pour en changer on édite le fichier et on recompile.
|
||||
// Démarche reprise du registre d'agents d'OpenFox (builder/planner/explorer/
|
||||
// verifier/code-reviewer — voir NOTICE.md), prompts réécrits COURTS : un
|
||||
// préambule verbeux fait sur-raisonner les modèles locaux (voir le commentaire
|
||||
// de baseSystemPrompt).
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed agents/*.md
|
||||
var rolesFS embed.FS
|
||||
|
||||
// roleNames : les rôles connus. builder et verifier sont pilotés par la boucle
|
||||
// du mode code (code_verify.go) ; planner s'active quand l'utilisateur demande
|
||||
// un plan ; explorer et code-reviewer sont disponibles pour des passes
|
||||
// manuelles futures.
|
||||
var roleNames = []string{"builder", "planner", "explorer", "verifier", "code-reviewer"}
|
||||
|
||||
// rolePrompt renvoie le prompt d'un rôle, "" si inconnu.
|
||||
func rolePrompt(name string) string {
|
||||
b, err := rolesFS.ReadFile("agents/" + name + ".md")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// planIntentRe-like : l'utilisateur demande-t-il un PLAN plutôt qu'une
|
||||
// implémentation ? Heuristique simple sur le début du message.
|
||||
func wantsPlan(text string) bool {
|
||||
low := strings.ToLower(text)
|
||||
for _, kw := range []string{"fais un plan", "fait un plan", "propose un plan", "planifie", "plan d'implémentation", "plan d'action", "make a plan", "plan first"} {
|
||||
if strings.Contains(low, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package loki
|
||||
|
||||
// code_subagent.go — l'outil `subagent` du mode code : déléguer une question
|
||||
// bornée à un RÔLE qui travaille dans SON PROPRE contexte, et n'en ramener que
|
||||
// la réponse.
|
||||
//
|
||||
// Repris de l'idée des sous-agents d'OpenFox (voir NOTICE.md), réécrit pour le
|
||||
// fil unique de Loki, sur la mécanique déjà en place pour la passe de
|
||||
// vérification (code_verify.go : un runChat isolé, non persisté).
|
||||
//
|
||||
// Pourquoi ça compte sur un modèle LOCAL : la fenêtre de contexte est petite.
|
||||
// « Trouve où est géré le cache » coûte dix lectures de fichiers qui restent
|
||||
// ensuite dans l'historique jusqu'à la compaction, alors que seule la RÉPONSE
|
||||
// comptait. Le sous-agent paie ces lectures dans un contexte jetable et ne rend
|
||||
// que le résultat : le fil du builder ne grossit que d'un résultat d'outil.
|
||||
//
|
||||
// Trois rôles, tous en LECTURE SEULE (voir EnabledTools) : explorer (cartographie
|
||||
// le code), code-reviewer (relit un changement), planner (découpe un travail).
|
||||
// Aucun n'écrit : ce qui modifie le dépôt reste dans le fil principal, sous les
|
||||
// yeux de l'utilisateur.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// subagentRoles : les rôles délégables. `verifier` n'en fait PAS partie — cette
|
||||
// passe est pilotée par la boucle du contrat (code_verify.go), c'est la seule
|
||||
// habilitée à marquer un critère passé, et la laisser s'appeler à la demande
|
||||
// permettrait au builder de se décerner son propre satisfecit.
|
||||
var subagentRoles = []string{"explorer", "code-reviewer", "planner"}
|
||||
|
||||
func isSubagentRole(r string) bool {
|
||||
for _, x := range subagentRoles {
|
||||
if x == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// subagentMaxOutput borne ce que le sous-agent ramène dans le contexte de
|
||||
// l'appelant. Au-delà, l'économie de contexte serait perdue — c'est un rapport,
|
||||
// pas un vidage de fichiers.
|
||||
const subagentMaxOutput = 6000
|
||||
|
||||
func subagentTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "subagent",
|
||||
// Description au plus court : elle part dans CHAQUE requête (budget du
|
||||
// préambule, cf. TestSystemPromptStaysLean).
|
||||
Description: "Délègue une recherche ou une relecture à un rôle qui travaille dans son propre contexte et ne rend que sa réponse. Lecture seule.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"role": map[string]any{
|
||||
"type": "string",
|
||||
"enum": subagentRoles,
|
||||
"description": "explorer (trouver/cartographier), code-reviewer (relire un changement), planner (découper)",
|
||||
},
|
||||
"task": map[string]any{"type": "string", "description": "La question, précise et autonome : le rôle ne voit pas la discussion."},
|
||||
},
|
||||
"required": []string{"role", "task"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// toolSubagent exécute la délégation. Le contexte du sous-agent est ISOLÉ : le
|
||||
// prompt de son rôle, le briefing machine, et la tâche — rien de l'historique
|
||||
// du fil, que précisément on ne veut pas payer deux fois. Sa trace n'est ni
|
||||
// persistée ni diffusée : seul son texte final revient.
|
||||
func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string {
|
||||
role, _ := args["role"].(string)
|
||||
task, _ := args["task"].(string)
|
||||
role = strings.TrimSpace(role)
|
||||
task = strings.TrimSpace(task)
|
||||
if !isSubagentRole(role) {
|
||||
return "[erreur] rôle inconnu : " + role + " (attendu : " + strings.Join(subagentRoles, ", ") + ")"
|
||||
}
|
||||
if task == "" {
|
||||
return "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire"
|
||||
}
|
||||
// Garde-fou anti-récursion : un sous-agent ne délègue pas à son tour. Sans
|
||||
// ça, un modèle qui boucle ouvre autant de contextes que de tours, et la
|
||||
// facture en jetons devient exponentielle pour une question unique.
|
||||
if parent.Role != "" && parent.Role != "builder" {
|
||||
return "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
|
||||
}
|
||||
|
||||
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
|
||||
// Cadrage : le rôle ne voit ni la discussion ni l'utilisateur. Son prompt de
|
||||
// rôle parle à un humain (« termine en demandant confirmation ») ; ici son
|
||||
// interlocuteur est le builder, et sa réponse est un résultat d'outil.
|
||||
framing := "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
if role == "planner" {
|
||||
framing = "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
}
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
|
||||
{Role: "user", Content: task + framing},
|
||||
}
|
||||
var out strings.Builder
|
||||
// Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit
|
||||
// la bulle de l'outil `subagent` et son rapport, pas les dix lectures.
|
||||
if _, err := runChat(ctx, msgs, 0, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
out.WriteString(ev.Content)
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
return "[erreur] sous-agent " + role + " : " + err.Error()
|
||||
}
|
||||
res := strings.TrimSpace(out.String())
|
||||
if res == "" {
|
||||
return "[erreur] le sous-agent " + role + " n'a rien répondu"
|
||||
}
|
||||
if r := []rune(res); len(r) > subagentMaxOutput {
|
||||
res = string(r[:subagentMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return "[" + role + "]\n" + res
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les garde-fous de l'outil subagent doivent répondre SANS appeler le modèle :
|
||||
// un rôle inconnu, une tâche vide ou un appel depuis un sous-agent sont refusés
|
||||
// à l'entrée (sinon chaque faute de frappe coûte une génération complète).
|
||||
func TestSubagentRefusesBeforeAnyInference(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
parent Caps
|
||||
want string
|
||||
}{
|
||||
{"rôle inconnu", map[string]any{"role": "architecte", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"rôle verifier refusé", map[string]any{"role": "verifier", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"tâche vide", map[string]any{"role": "explorer", "task": " "}, Caps{Code: true}, "tâche vide"},
|
||||
{"pas de récursion", map[string]any{"role": "explorer", "task": "x"}, Caps{Code: true, Role: "explorer"}, "ne peut pas en appeler un autre"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := toolSubagent(context.Background(), tc.args, tc.parent)
|
||||
if !strings.HasPrefix(got, "[erreur]") || !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("attendu un refus contenant %q, obtenu %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Un rôle délégué travaille en LECTURE SEULE : ni write/edit (les modifications
|
||||
// restent dans le fil principal), ni subagent (pas de récursion), et seul le
|
||||
// planner pose des critères.
|
||||
func TestSubagentRolesAreReadOnly(t *testing.T) {
|
||||
forbidden := map[string]bool{"write": true, "edit": true, "subagent": true, "mem_add": true, "mem_edit": true, "web_search": true}
|
||||
for _, role := range subagentRoles {
|
||||
names := map[string]bool{}
|
||||
for _, tool := range EnabledTools(Caps{Agent: true, Code: true, Role: role}) {
|
||||
names[tool.Function.Name] = true
|
||||
}
|
||||
for f := range forbidden {
|
||||
if names[f] {
|
||||
t.Fatalf("le rôle %s ne devrait pas avoir l'outil %s", role, f)
|
||||
}
|
||||
}
|
||||
if !names["read"] || !names["grep"] {
|
||||
t.Fatalf("le rôle %s devrait pouvoir lire le code", role)
|
||||
}
|
||||
if got := names["criteria"]; got != (role == "planner") {
|
||||
t.Fatalf("critères pour %s : %v (attendu %v)", role, got, role == "planner")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package loki
|
||||
|
||||
// code_tools.go — outils fichiers du MODE CODE : read, grep, glob, ask.
|
||||
// Inspirés des outils d'OpenFox (read/grep/ask — voir NOTICE.md), réécrits en
|
||||
// Go. Sans eux, le modèle lit les fichiers au `cat` via bash : sortie non
|
||||
// bornée, pas de numéros de ligne, et aucun moyen de savoir ce qu'il a lu
|
||||
// (voir code_tracker.go).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
readMaxLines = 1000 // lignes par appel (offset/limit pour la suite)
|
||||
readDefLines = 400
|
||||
readMaxLineLen = 500 // une ligne minifiée ne doit pas manger le contexte
|
||||
grepMaxHits = 60
|
||||
globMaxHits = 200
|
||||
)
|
||||
|
||||
// skipDirs : dossiers ignorés par grep/glob — dépendances et artefacts, jamais
|
||||
// du code à lire. `.git` compris : son contenu binaire pollue toute recherche.
|
||||
var skipDirs = map[string]bool{
|
||||
".git": true, "node_modules": true, "vendor": true, "dist": true,
|
||||
"build": true, "target": true, "__pycache__": true, ".venv": true,
|
||||
"venv": true, ".next": true, ".cache": true,
|
||||
}
|
||||
|
||||
func readTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "read",
|
||||
Description: "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Path (relative to the working folder)"},
|
||||
"offset": map[string]any{"type": "integer", "description": "Start line (default 1)"},
|
||||
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Lines (default %d, max %d)", readDefLines, readMaxLines)},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func grepTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "grep",
|
||||
Description: "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{"type": "string", "description": "Go regexp"},
|
||||
"path": map[string]any{"type": "string", "description": "File or directory to search (default .)"},
|
||||
"glob": map[string]any{"type": "string", "description": "Only files matching this glob (e.g. *.go)"},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func globTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "glob",
|
||||
Description: "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{"type": "string", "description": "Glob, e.g. src/**/*.ts"},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func askTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "ask",
|
||||
Description: "Ask the user a clarifying question with 2-4 short options, then END your turn. Their answer arrives as the next user message. Use when a decision is genuinely theirs (scope, destructive change, ambiguous requirement) — not for things you can check yourself.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"question": map[string]any{"type": "string", "description": "The question"},
|
||||
"options": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "2-4 short answer options"},
|
||||
},
|
||||
"required": []string{"question"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolRead lit un fichier avec numéros de ligne. Enregistre la lecture dans le
|
||||
// tracker (code_tracker.go) — c'est ce qui autorise edit/write ensuite.
|
||||
func toolRead(args map[string]any, codeMode bool) string {
|
||||
file, _ := args["file"].(string)
|
||||
if strings.TrimSpace(file) == "" {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path := resolveAgentPath(file)
|
||||
if codeMode && !codePathAllowed(path) {
|
||||
return refusedPathResult(file)
|
||||
}
|
||||
offset, limit := 1, readDefLines
|
||||
if v, ok := args["offset"].(float64); ok && int(v) > 0 {
|
||||
offset = int(v)
|
||||
}
|
||||
if v, ok := args["limit"].(float64); ok && int(v) > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
if limit > readMaxLines {
|
||||
limit = readMaxLines
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if isBinary(b) {
|
||||
fi, _ := os.Stat(path)
|
||||
size := int64(len(b))
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
return fmt.Sprintf("[binaire] %s (%d octets) — pas un fichier texte", file, size)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
total := len(lines)
|
||||
// Fichier fini par \n : Split laisse une dernière entrée vide, pas une ligne.
|
||||
if total > 0 && lines[total-1] == "" {
|
||||
total--
|
||||
}
|
||||
if offset > total {
|
||||
return fmt.Sprintf("[erreur] offset %d > %d lignes", offset, total)
|
||||
}
|
||||
end := offset - 1 + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
var out strings.Builder
|
||||
for i := offset - 1; i < end; i++ {
|
||||
l := lines[i]
|
||||
if utf8.RuneCountInString(l) > readMaxLineLen {
|
||||
r := []rune(l)
|
||||
l = string(r[:readMaxLineLen]) + "…"
|
||||
}
|
||||
fmt.Fprintf(&out, "%d: %s\n", i+1, l)
|
||||
}
|
||||
if end < total {
|
||||
fmt.Fprintf(&out, "… (%d lignes restantes — relis avec offset=%d)\n", total-end, end+1)
|
||||
}
|
||||
trackerNoteRead(path)
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// toolGrep cherche un motif regexp dans les fichiers.
|
||||
func toolGrep(args map[string]any, codeMode bool) string {
|
||||
pattern, _ := args["pattern"].(string)
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return "[erreur] motif vide"
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return "[erreur] motif invalide : " + err.Error()
|
||||
}
|
||||
root := agentCwd()
|
||||
if p, _ := args["path"].(string); strings.TrimSpace(p) != "" && p != "." {
|
||||
root = resolveAgentPath(p)
|
||||
}
|
||||
if codeMode && !codePathAllowed(root) {
|
||||
return refusedPathResult(root)
|
||||
}
|
||||
globPat, _ := args["glob"].(string)
|
||||
var hits []string
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // dossier illisible : on continue ailleurs
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if globPat != "" {
|
||||
if ok, _ := filepath.Match(globPat, d.Name()); !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if len(hits) >= grepMaxHits {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
b, rerr := os.ReadFile(path)
|
||||
if rerr != nil || isBinary(b) {
|
||||
return nil
|
||||
}
|
||||
rel, rrr := filepath.Rel(root, path)
|
||||
if rrr != nil {
|
||||
rel = path
|
||||
}
|
||||
for i, line := range strings.Split(string(b), "\n") {
|
||||
if re.MatchString(line) {
|
||||
if utf8.RuneCountInString(line) > readMaxLineLen {
|
||||
r := []rune(line)
|
||||
line = string(r[:readMaxLineLen]) + "…"
|
||||
}
|
||||
hits = append(hits, fmt.Sprintf("%s:%d: %s", filepath.ToSlash(rel), i+1, strings.TrimSpace(line)))
|
||||
if len(hits) >= grepMaxHits {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil && len(hits) == 0 {
|
||||
return "[erreur] " + walkErr.Error()
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return "[aucun résultat]"
|
||||
}
|
||||
out := strings.Join(hits, "\n")
|
||||
if len(hits) >= grepMaxHits {
|
||||
out += fmt.Sprintf("\n… (plafond de %d résultats atteint — affine le motif)", grepMaxHits)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolGlob liste les fichiers correspondant à un motif, plus récents d'abord.
|
||||
func toolGlob(args map[string]any, codeMode bool) string {
|
||||
pattern, _ := args["pattern"].(string)
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return "[erreur] motif vide"
|
||||
}
|
||||
root := agentCwd()
|
||||
re, err := globToRegexp(pattern)
|
||||
if err != nil {
|
||||
return "[erreur] motif invalide : " + err.Error()
|
||||
}
|
||||
type hit struct {
|
||||
rel string
|
||||
mod int64
|
||||
}
|
||||
var hits []hit
|
||||
filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, path)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !re.MatchString(rel) {
|
||||
return nil
|
||||
}
|
||||
info, ierr := d.Info()
|
||||
mod := int64(0)
|
||||
if ierr == nil {
|
||||
mod = info.ModTime().UnixNano()
|
||||
}
|
||||
hits = append(hits, hit{rel, mod})
|
||||
return nil
|
||||
})
|
||||
if len(hits) == 0 {
|
||||
return "[aucun fichier]"
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].mod > hits[j].mod })
|
||||
if len(hits) > globMaxHits {
|
||||
hits = hits[:globMaxHits]
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, h := range hits {
|
||||
out.WriteString(h.rel + "\n")
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// globToRegexp convertit un glob (avec **) en expression régulière sur des
|
||||
// chemins en séparateurs '/'. `**` = n'importe quelle profondeur, `*` = tout
|
||||
// sauf '/', `?` = un caractère sauf '/'.
|
||||
func globToRegexp(pattern string) (*regexp.Regexp, error) {
|
||||
pattern = strings.TrimPrefix(filepath.ToSlash(pattern), "./")
|
||||
var b strings.Builder
|
||||
b.WriteString("^")
|
||||
i := 0
|
||||
for i < len(pattern) {
|
||||
c := pattern[i]
|
||||
switch c {
|
||||
case '*':
|
||||
if strings.HasPrefix(pattern[i:], "**/") {
|
||||
b.WriteString(`(?:[^/]+/)*`)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(pattern[i:], "**") {
|
||||
b.WriteString(`.*`)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
b.WriteString(`[^/]*`)
|
||||
case '?':
|
||||
b.WriteString(`[^/]`)
|
||||
case '.', '+', '(', ')', '|', '[', ']', '{', '}', '^', '$', '\\':
|
||||
b.WriteString(`\` + string(c))
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
i++
|
||||
}
|
||||
b.WriteString("$")
|
||||
return regexp.Compile(b.String())
|
||||
}
|
||||
|
||||
// isBinary : heuristique simple — un NUL dans les premiers 8 Kio.
|
||||
func isBinary(b []byte) bool {
|
||||
n := len(b)
|
||||
if n > 8192 {
|
||||
n = 8192
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if b[i] == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolAsk formate la « réponse » de l'outil ask : la question part vers l'UI
|
||||
// via un événement dédié (voir runChat) ; ici on dit au modèle de clore son
|
||||
// tour et d'attendre.
|
||||
func toolAsk(args map[string]any) string {
|
||||
q, _ := args["question"].(string)
|
||||
if strings.TrimSpace(q) == "" {
|
||||
return "[erreur] question vide"
|
||||
}
|
||||
return "[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message."
|
||||
}
|
||||
|
||||
// askOptions extrait les options (chaînes) des arguments de l'outil ask.
|
||||
func askOptions(args map[string]any) []string {
|
||||
raw, _ := args["options"].([]any)
|
||||
var opts []string
|
||||
for _, o := range raw {
|
||||
if s, ok := o.(string); ok && strings.TrimSpace(s) != "" {
|
||||
opts = append(opts, s)
|
||||
}
|
||||
}
|
||||
if len(opts) > 4 {
|
||||
opts = opts[:4]
|
||||
}
|
||||
return opts
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// read numérote depuis 1, respecte offset/limit et annonce le reste.
|
||||
func TestReadOffsetLimit(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
var lines []string
|
||||
for i := 1; i <= 10; i++ {
|
||||
lines = append(lines, "ligne")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ws, "f.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := toolRead(map[string]any{"file": "f.txt", "offset": float64(3), "limit": float64(2)}, true)
|
||||
if !strings.Contains(out, "3: ligne") || !strings.Contains(out, "4: ligne") {
|
||||
t.Fatalf("lignes 3-4 attendues :\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "5: ligne") {
|
||||
t.Fatalf("limite ignorée :\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "restantes") {
|
||||
t.Fatalf("suite non annoncée :\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// read refuse un binaire et le dit, au lieu de déverser du bruit.
|
||||
func TestReadBinaire(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
if err := os.WriteFile(filepath.Join(ws, "bin.dat"), []byte{0x00, 0x01, 0x02}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := toolRead(map[string]any{"file": "bin.dat"}, true); !strings.Contains(out, "[binaire]") {
|
||||
t.Fatalf("binaire non détecté : %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// read enregistre la lecture : le tracker doit ensuite autoriser l'écriture.
|
||||
func TestReadNourritLeTracker(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
path := filepath.Join(ws, "suivi.txt")
|
||||
if err := os.WriteFile(path, []byte("contenu"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := trackerCheck(path, true); msg == "" {
|
||||
t.Fatal("écriture acceptée avant lecture")
|
||||
}
|
||||
toolRead(map[string]any{"file": "suivi.txt"}, true)
|
||||
if msg := trackerCheck(path, true); msg != "" {
|
||||
t.Fatalf("écriture refusée après lecture : %s", msg)
|
||||
}
|
||||
// Fichier modifié APRÈS la lecture : refus (lecture périmée).
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(path, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := trackerCheck(path, true); !strings.Contains(msg, "changé") {
|
||||
t.Fatalf("lecture périmée non détectée : %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// grep trouve, borne et ignore les dossiers de dépendances.
|
||||
func TestGrep(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "a.go"), "package main\nfunc Cible() {}\n")
|
||||
writeFile(t, filepath.Join(ws, "node_modules", "x.js"), "Cible aussi\n")
|
||||
out := toolGrep(map[string]any{"pattern": "Cible"}, true)
|
||||
if !strings.Contains(out, "a.go:2") {
|
||||
t.Fatalf("correspondance manquée :\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "node_modules") {
|
||||
t.Fatalf("node_modules aurait dû être ignoré :\n%s", out)
|
||||
}
|
||||
if out := toolGrep(map[string]any{"pattern": "IntrouvableXYZ"}, true); !strings.Contains(out, "aucun résultat") {
|
||||
t.Fatalf("absence non signalée : %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// glob : ** traverse les dossiers, tri par modification décroissante.
|
||||
func TestGlob(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "src", "a.ts"), "a")
|
||||
writeFile(t, filepath.Join(ws, "src", "sub", "b.ts"), "b")
|
||||
writeFile(t, filepath.Join(ws, "autre.txt"), "c")
|
||||
out := toolGlob(map[string]any{"pattern": "**/*.ts"}, true)
|
||||
if !strings.Contains(out, "src/a.ts") || !strings.Contains(out, "src/sub/b.ts") {
|
||||
t.Fatalf("fichiers .ts manquants :\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "autre.txt") {
|
||||
t.Fatalf("autre.txt ne matche pas **/*.ts :\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// globToRegexp : cas aux limites du **.
|
||||
func TestGlobToRegexp(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern, path string
|
||||
want bool
|
||||
}{
|
||||
{"*.go", "main.go", true},
|
||||
{"*.go", "src/main.go", false},
|
||||
{"**/*.go", "src/deep/main.go", true},
|
||||
{"**/*.go", "main.go", true}, // **/ = zéro dossier ou plus
|
||||
{"src/**", "src/a/b/c.txt", true},
|
||||
{"src/*.ts", "src/a.ts", true},
|
||||
{"src/*.ts", "src/sub/a.ts", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
re, err := globToRegexp(c.pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("%s : %v", c.pattern, err)
|
||||
}
|
||||
if got := re.MatchString(c.path); got != c.want {
|
||||
t.Errorf("%s sur %s : %v, attendu %v", c.pattern, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fileEdit préserve les fins de ligne CRLF d'un fichier quand le modèle
|
||||
// envoie du LF (et n'introduit jamais de mélange).
|
||||
func TestEditPreserveCRLF(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
path := filepath.Join(ws, "w.txt")
|
||||
if err := os.WriteFile(path, []byte("un\r\ndeux\r\ntrois\r\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := fileEdit(path, "deux", "DEUX")
|
||||
if !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("édition refusée : %s", out)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if string(b) != "un\r\nDEUX\r\ntrois\r\n" {
|
||||
t.Fatalf("fins de ligne perdues : %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// fileEdit multi-lignes en LF sur un fichier CRLF : la normalisation couvre
|
||||
// aussi le old qui contient des sauts de ligne.
|
||||
func TestEditCRLFMultiligne(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
path := filepath.Join(ws, "m.txt")
|
||||
if err := os.WriteFile(path, []byte("a\r\nb\r\nc\r\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := fileEdit(path, "a\nb", "a\nB")
|
||||
if !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("édition refusée : %s", out)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if string(b) != "a\r\nB\r\nc\r\n" {
|
||||
t.Fatalf("résultat inattendu : %q", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package loki
|
||||
|
||||
// code_tracker.go — suivi « lu avant d'écrire » (file-tracker d'OpenFox, réécrit
|
||||
// en Go — voir NOTICE.md). Le modèle doit avoir LU un fichier (outil read) avant
|
||||
// de le modifier (edit) ou de l'écraser (write sur un fichier existant), et sa
|
||||
// lecture doit être plus récente que la dernière modification du fichier sur le
|
||||
// disque. Ça bloque les deux corruptions silencieuses classiques :
|
||||
// - réécrire un fichier jamais ouvert (le modèle « devine » son contenu) ;
|
||||
// - éditer d'après une lecture périmée (le fichier a changé entre-temps —
|
||||
// autre outil, job d'arrière-plan, utilisateur).
|
||||
//
|
||||
// L'état est en mémoire, par discussion : un redémarrage oublie tout, et le
|
||||
// modèle relit — c'est le comportement voulu, pas un bug.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
trackerMu sync.Mutex
|
||||
trackerReads = map[string]time.Time{} // conv + chemin → instant de la lecture
|
||||
)
|
||||
|
||||
func trackerKey(path string) string {
|
||||
return convEnsureActive() + "\x00" + filepath.Clean(path)
|
||||
}
|
||||
|
||||
// trackerNoteRead enregistre qu'un fichier vient d'être lu (outil read).
|
||||
func trackerNoteRead(path string) {
|
||||
trackerMu.Lock()
|
||||
trackerReads[trackerKey(path)] = time.Now()
|
||||
trackerMu.Unlock()
|
||||
}
|
||||
|
||||
// trackerNoteWrite : après une écriture réussie, la version sur disque est celle
|
||||
// que le modèle vient de produire — il la « connaît ». Sans cette note, éditer
|
||||
// un fichier qu'on vient soi-même d'écrire serait refusé.
|
||||
func trackerNoteWrite(path string) {
|
||||
trackerNoteRead(path)
|
||||
}
|
||||
|
||||
// trackerCheck vérifie qu'une modification est sûre. forWrite distingue write
|
||||
// (autorisé sur un fichier INEXISTANT sans lecture préalable — création) de
|
||||
// edit (le fichier doit exister ET avoir été lu).
|
||||
// Renvoie "" si c'est bon, sinon le message de refus pour le modèle.
|
||||
func trackerCheck(path string, forWrite bool) string {
|
||||
fi, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
if forWrite {
|
||||
return "" // création d'un fichier neuf : rien à relire
|
||||
}
|
||||
return "" // edit sur fichier absent : fileEdit renverra sa propre erreur claire
|
||||
}
|
||||
trackerMu.Lock()
|
||||
readAt, seen := trackerReads[trackerKey(path)]
|
||||
trackerMu.Unlock()
|
||||
if !seen {
|
||||
return "[refusé] " + path + " existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
|
||||
}
|
||||
if fi.ModTime().After(readAt) {
|
||||
return "[refusé] " + path + " a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package loki
|
||||
|
||||
// code_verify.go — la boucle du contrat en mode code : build → VÉRIFICATION →
|
||||
// correction → re-vérification, jusqu'à ce que tous les critères passent ou
|
||||
// que le plafond de cycles soit atteint. Reprise de la boucle
|
||||
// builder/verifier d'OpenFox (voir NOTICE.md), adaptée au fil unique de Loki :
|
||||
//
|
||||
// - la passe de vérification tourne sur un CONTEXTE ISOLÉ (prompt du rôle +
|
||||
// critères + tâche), pas sur l'historique complet — même modèle, mais il
|
||||
// n'a pas « écrit » le code sous les yeux, et surtout ça ne gonfle pas le
|
||||
// contexte de la discussion ;
|
||||
// - seule cette passe a le droit de marquer un critère `passed`
|
||||
// (toolCriteria, allowPass) ;
|
||||
// - les corrections, elles, repartent dans l'historique NORMAL du fil : le
|
||||
// builder doit se souvenir de ce qu'il a fait.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// codeMaxFixTurns : nombre de tours de CORRECTION après échec de vérification.
|
||||
// Au-delà, on s'arrête et on dit ce qui manque — un modèle qui n'y arrive pas
|
||||
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
|
||||
const codeMaxFixTurns = 2
|
||||
|
||||
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
|
||||
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
|
||||
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
convID := convEnsureActive()
|
||||
list := critList(convID)
|
||||
if len(list) == 0 || critAllPassed(list) {
|
||||
return
|
||||
}
|
||||
// En phase de PLAN, on ne vérifie pas : rien n'a été construit.
|
||||
if caps.Role == "planner" {
|
||||
return
|
||||
}
|
||||
for fix := 0; ; fix++ {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.runVerifyPass(ctx, caps, temperature, epoch)
|
||||
list = critList(convID)
|
||||
if critAllPassed(list) {
|
||||
c.appendDelta(epoch, map[string]any{"verify_done": "passed"})
|
||||
return
|
||||
}
|
||||
if fix >= codeMaxFixTurns {
|
||||
c.appendDelta(epoch, map[string]any{"verify_done": "gave_up"})
|
||||
// Le fil doit dire où on en est — sinon l'utilisateur voit une
|
||||
// vérification échouée et aucun mot dessus.
|
||||
c.appendDelta(epoch, map[string]any{"content": "\n\n_(vérification : " +
|
||||
fmt.Sprint(critPending(list)) + " critère(s) non satisfaits après " +
|
||||
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
|
||||
return
|
||||
}
|
||||
c.runFixTurn(ctx, caps, temperature, epoch, list)
|
||||
}
|
||||
}
|
||||
|
||||
// runVerifyPass lance la passe de vérification sur un contexte ISOLÉ.
|
||||
func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
vcaps := caps
|
||||
vcaps.Role = "verifier"
|
||||
// Marqueur de rôle pour l'UI : les bulles qui suivent portent le badge.
|
||||
c.appendDelta(epoch, map[string]any{"role": "verifier"})
|
||||
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
||||
|
||||
convID := convEnsureActive()
|
||||
list := critList(convID)
|
||||
task := c.lastUserText()
|
||||
var user strings.Builder
|
||||
user.WriteString("Task:\n" + task + "\n\nAcceptance criteria:\n" + critRender(list))
|
||||
user.WriteString("\n\nVerify each non-passed criterion now (git_diff first), and record every verdict with the criteria tool.")
|
||||
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt("verifier") + "\n\n" + machineSystemPrompt(vcaps)},
|
||||
{Role: "user", Content: user.String()},
|
||||
}
|
||||
// Trace ISOLÉE : ni persistée dans c.Messages, ni compactée — elle vit et
|
||||
// meurt dans cette passe. Seuls les deltas d'affichage sortent.
|
||||
_, _ = runChat(ctx, msgs, temperature, vcaps, func(ev StreamEvent) bool {
|
||||
c.forwardStream(ev, epoch)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
|
||||
// critères en échec. Sa trace est persistée comme un tour ordinaire.
|
||||
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
|
||||
var failed []Criterion
|
||||
for _, cr := range list {
|
||||
if cr.Status != "passed" {
|
||||
failed = append(failed, cr)
|
||||
}
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"role": "builder"})
|
||||
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
||||
|
||||
fixMsg := Message{Role: "user", Content: "Verification failed on these criteria:\n" + critRender(failed) +
|
||||
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed."}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.epoch != epoch {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.Messages = append(c.Messages, fixMsg)
|
||||
msgs := append([]Message(nil), c.Messages...)
|
||||
c.mu.Unlock()
|
||||
|
||||
final := msgs
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
|
||||
}
|
||||
var content strings.Builder
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
content.WriteString(ev.Content)
|
||||
}
|
||||
c.forwardStream(ev, epoch)
|
||||
return true
|
||||
})
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.Messages = append(c.Messages, extra...)
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
}
|
||||
|
||||
// forwardStream relaie les événements d'un runChat secondaire (vérification,
|
||||
// correction) vers le journal d'affichage — même mapping que generate(), sans
|
||||
// la gestion de compaction (ces passes n'en déclenchent pas : contexte court).
|
||||
func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
|
||||
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,
|
||||
}
|
||||
if ev.ToolUsed.Body != "" {
|
||||
tu["body"] = ev.ToolUsed.Body
|
||||
}
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.Ask != nil:
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
case ev.Stats != nil:
|
||||
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 != "":
|
||||
c.appendDelta(epoch, map[string]any{"content": ev.Content})
|
||||
}
|
||||
}
|
||||
|
||||
// lastUserText retrouve le dernier message utilisateur du fil (la tâche).
|
||||
func (c *Conversation) lastUserText() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for i := len(c.Messages) - 1; i >= 0; i-- {
|
||||
if c.Messages[i].Role != "user" {
|
||||
continue
|
||||
}
|
||||
if s, ok := c.Messages[i].Content.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return "(tâche non retrouvée — vérifie d'après le diff)"
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
package loki
|
||||
|
||||
// computer_cdp.go — pilote un Chromium/Chrome LOCAL via le protocole DevTools
|
||||
// (CDP), en Go pur (websocket coder/websocket déjà présent), sans dépendance
|
||||
// lourde type chromedp/playwright. C'est le moteur « web » du computer use :
|
||||
// ouvrir une URL, lire l'arbre d'accessibilité (éléments interactifs numérotés,
|
||||
// façon set-of-marks), cliquer/taper/défiler, et — à la demande — capturer un
|
||||
// screenshot pour la vision.
|
||||
//
|
||||
// Choix d'archi : le conteneur Loki embarque déjà un Chromium (celui de
|
||||
// Playwright, cf. Dockerfile) ; on le pilote en CDP. Une seule session navigateur
|
||||
// est maintenue (lazy) et réutilisée entre les appels d'outils, comme le cache
|
||||
// web de chat_internet.go.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
var (
|
||||
cdpMu sync.Mutex
|
||||
cdpCur *cdpSession
|
||||
)
|
||||
|
||||
// Taille FIXE du viewport piloté (et donc du screenshot). Communiquée au modèle
|
||||
// pour que ses coordonnées browser_click_xy lues sur l'image tombent juste.
|
||||
const (
|
||||
cuViewW = 1280
|
||||
cuViewH = 800
|
||||
)
|
||||
|
||||
// cdpSession détient le process Chrome, la connexion websocket CDP vers la cible
|
||||
// « page », et le multiplexage requête/réponse (un lecteur en tâche de fond
|
||||
// distribue les réponses aux appels en attente par id).
|
||||
type cdpSession struct {
|
||||
cmd *exec.Cmd
|
||||
userDir string
|
||||
ws *websocket.Conn
|
||||
wctx context.Context
|
||||
wcancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[int]chan cdpMessage
|
||||
|
||||
netMu sync.Mutex
|
||||
inflight int // requêtes réseau en cours (pour l'attente d'inactivité)
|
||||
lastNet time.Time // dernier événement réseau
|
||||
|
||||
lastSnap string // dernier snapshot renvoyé, pour dé-dupliquer les états identiques
|
||||
}
|
||||
|
||||
type cdpMessage struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *cdpError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type cdpError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ─── découverte du binaire navigateur ────────────────────────────────────────
|
||||
|
||||
// chromePath localise un binaire Chromium/Chrome/Edge exploitable. LOKI_CHROME
|
||||
// force un chemin ; sinon on cherche D'ABORD le Chromium de Playwright (celui
|
||||
// qu'embarque l'image Docker, sous PLAYWRIGHT_BROWSERS_PATH), puis les
|
||||
// emplacements standards par OS, puis le PATH.
|
||||
//
|
||||
// L'ordre compte : dans le conteneur, AUCUN chromium n'est installé par le
|
||||
// gestionnaire de paquets — le seul navigateur présent est celui que
|
||||
// `playwright install chromium` a déposé dans /opt/pw-browsers. Sans cette
|
||||
// recherche, le pilotage de navigateur serait déclaré indisponible dans l'image
|
||||
// même qui embarque un navigateur (voir Dockerfile, ARG PLAYWRIGHT).
|
||||
func chromePath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("LOKI_CHROME")); p != "" {
|
||||
return p
|
||||
}
|
||||
if p := playwrightChromium(); p != "" {
|
||||
return p
|
||||
}
|
||||
var cands []string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)"), os.Getenv("LocalAppData")} {
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
cands = append(cands,
|
||||
filepath.Join(base, `Google\Chrome\Application\chrome.exe`),
|
||||
filepath.Join(base, `Microsoft\Edge\Application\msedge.exe`),
|
||||
filepath.Join(base, `Chromium\Application\chrome.exe`),
|
||||
)
|
||||
}
|
||||
case "darwin":
|
||||
cands = []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
}
|
||||
default:
|
||||
for _, n := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range cands {
|
||||
if c != "" {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"chrome", "google-chrome", "chromium"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// playwrightChromium renvoie le binaire Chromium déposé par Playwright, ou ""
|
||||
// s'il n'y en a pas. Playwright range ses navigateurs sous
|
||||
// PLAYWRIGHT_BROWSERS_PATH (l'image Docker fixe /opt/pw-browsers) ou, à défaut,
|
||||
// dans le cache utilisateur, en un dossier chromium-<build> par version.
|
||||
//
|
||||
// On prend la version la PLUS RÉCENTE (les builds sont numérotés, tri croissant
|
||||
// sur le nom) : une image reconstruite laisse parfois l'ancien dossier derrière
|
||||
// elle, et lancer un vieux Chromium sur le CDP d'aujourd'hui, c'est chercher
|
||||
// des pannes sans raison. `chrome` d'abord, `headless_shell` en repli : ce
|
||||
// dernier ne sait pas ouvrir de fenêtre (LOKI_CU_HEADFUL serait sans effet)
|
||||
// mais pilote très bien une page en headless.
|
||||
func playwrightChromium() string {
|
||||
var roots []string
|
||||
if p := strings.TrimSpace(os.Getenv("PLAYWRIGHT_BROWSERS_PATH")); p != "" && p != "0" {
|
||||
roots = append(roots, p)
|
||||
}
|
||||
roots = append(roots, "/opt/pw-browsers")
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
roots = append(roots, filepath.Join(home, "AppData", "Local", "ms-playwright"))
|
||||
case "darwin":
|
||||
roots = append(roots, filepath.Join(home, "Library", "Caches", "ms-playwright"))
|
||||
default:
|
||||
roots = append(roots, filepath.Join(home, ".cache", "ms-playwright"))
|
||||
}
|
||||
}
|
||||
// Chemin du binaire À L'INTÉRIEUR d'un dossier chromium-<build>, par OS.
|
||||
var rels [][]string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
rels = [][]string{{"chrome-win", "chrome.exe"}, {"chrome-win", "headless_shell.exe"}}
|
||||
case "darwin":
|
||||
rels = [][]string{{"chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"}, {"chrome-mac", "headless_shell"}}
|
||||
default:
|
||||
rels = [][]string{{"chrome-linux", "chrome"}, {"chrome-linux", "headless_shell"}}
|
||||
}
|
||||
for _, root := range roots {
|
||||
dirs, err := filepath.Glob(filepath.Join(root, "chromium*-*"))
|
||||
if err != nil || len(dirs) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
for _, rel := range rels {
|
||||
p := filepath.Join(append([]string{dirs[i]}, rel...)...)
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── lancement + connexion ───────────────────────────────────────────────────
|
||||
|
||||
func cdpLaunch() (*cdpSession, error) {
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
return nil, fmt.Errorf("navigateur Chromium/Chrome/Edge introuvable — installe-le ou définis LOKI_CHROME=<chemin du binaire>")
|
||||
}
|
||||
// Balaye les profils temporaires abandonnés (Chrome tué sans close() lors d'un
|
||||
// redémarrage du service) : sinon /tmp se remplit de dossiers loki-cu-* au fil
|
||||
// des restarts.
|
||||
sweepStaleCUDirs()
|
||||
userDir, err := os.MkdirTemp("", "loki-cu-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Headless par défaut (marche sur le serveur sans écran) ; LOKI_CU_HEADFUL=1
|
||||
// ouvre une vraie fenêtre visible sur une machine de bureau.
|
||||
args := []string{
|
||||
"--remote-debugging-port=0",
|
||||
"--user-data-dir=" + userDir,
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-gpu", "--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-features=Translate,BackForwardCache",
|
||||
// Indispensables sur un serveur headless sous un compte non-root : sans
|
||||
// --no-sandbox Chrome plante au lancement (namespaces refusés), et /dev/shm
|
||||
// y est souvent minuscule → --disable-dev-shm-usage évite les crash onglet.
|
||||
"--no-sandbox", "--disable-dev-shm-usage",
|
||||
"--window-size=1280,900",
|
||||
"about:blank",
|
||||
}
|
||||
if os.Getenv("LOKI_CU_HEADFUL") == "" {
|
||||
args = append([]string{"--headless=new"}, args...)
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
// Chrome écrit le port de debug réel dans DevToolsActivePort (ligne 1).
|
||||
port := ""
|
||||
portFile := filepath.Join(userDir, "DevToolsActivePort")
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(portFile); err == nil {
|
||||
if line := strings.SplitN(strings.TrimSpace(string(b)), "\n", 2)[0]; line != "" {
|
||||
port = line
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if port == "" {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, fmt.Errorf("Chrome n'a pas exposé son port de debug (DevToolsActivePort)")
|
||||
}
|
||||
wsURL, err := cdpPageWS(port)
|
||||
if err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
c.SetReadLimit(-1) // les screenshots dépassent la limite par défaut (32 Kio)
|
||||
s := &cdpSession{cmd: cmd, userDir: userDir, ws: c, wctx: ctx, wcancel: cancel, pending: map[int]chan cdpMessage{}}
|
||||
go s.readLoop()
|
||||
_, _ = s.call("Page.enable", nil)
|
||||
_, _ = s.call("Runtime.enable", nil)
|
||||
_, _ = s.call("DOM.enable", nil)
|
||||
_, _ = s.call("Network.enable", nil) // pour l'attente d'inactivité réseau (settle)
|
||||
// Viewport DÉTERMINISTE : sans ça, headless donne une taille bizarre (mesuré :
|
||||
// 1264x749) que le modèle interprétait mal pour browser_click_xy (il visait trop
|
||||
// bas). On force exactement cuViewW x cuViewH, DPR 1 → le screenshot fait cette
|
||||
// taille, et les coordonnées lues dessus = coordonnées de clic, 1:1.
|
||||
_, _ = s.call("Emulation.setDeviceMetricsOverride", map[string]any{
|
||||
"width": cuViewW, "height": cuViewH, "deviceScaleFactor": 1, "mobile": false,
|
||||
})
|
||||
// Garder toute navigation dans le MÊME onglet : le pilote n'est attaché qu'à une
|
||||
// cible « page ». Un lien target=_blank ou un window.open ouvrait un onglet que
|
||||
// le pilote ne voyait pas → l'IA croyait que « rien ne se passe » et bouclait
|
||||
// (vécu sur le test IONOS « Devenez client »). Ce script, réévalué au début de
|
||||
// CHAQUE document, neutralise les nouveaux onglets.
|
||||
_, _ = s.call("Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": cdpSameTabJS})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpSameTabJS force les navigations « nouvel onglet » à rester dans l'onglet
|
||||
// courant (window.open redirigé, target=_blank retiré au clic).
|
||||
const cdpSameTabJS = `(function(){try{
|
||||
window.open=function(u){if(u)location.href=u;return window;};
|
||||
document.addEventListener('click',function(e){
|
||||
var a=e.target&&e.target.closest&&e.target.closest('a[target]');
|
||||
if(a)a.removeAttribute('target');
|
||||
},true);
|
||||
}catch(e){}})();`
|
||||
|
||||
// cdpPageWS interroge l'API HTTP de debug pour trouver l'URL websocket d'une
|
||||
// cible de type « page ».
|
||||
func cdpPageWS(port string) (string, error) {
|
||||
cl := &http.Client{Timeout: 5 * time.Second}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := cl.Get("http://127.0.0.1:" + port + "/json")
|
||||
if err == nil {
|
||||
var targets []struct {
|
||||
Type string `json:"type"`
|
||||
WS string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&targets)
|
||||
resp.Body.Close()
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && t.WS != "" {
|
||||
return t.WS, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
return "", fmt.Errorf("aucune cible 'page' exposée par Chrome")
|
||||
}
|
||||
|
||||
func (s *cdpSession) readLoop() {
|
||||
for {
|
||||
_, data, err := s.ws.Read(s.wctx)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
for _, ch := range s.pending {
|
||||
close(ch)
|
||||
}
|
||||
s.pending = map[int]chan cdpMessage{}
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
var m cdpMessage
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
continue
|
||||
}
|
||||
if m.ID != 0 {
|
||||
s.mu.Lock()
|
||||
ch := s.pending[m.ID]
|
||||
delete(s.pending, m.ID)
|
||||
s.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Événements réseau : on suit le nombre de requêtes en vol pour que settle()
|
||||
// puisse attendre l'inactivité réseau (fin du chargement AJAX).
|
||||
switch m.Method {
|
||||
case "Network.requestWillBeSent":
|
||||
s.netMu.Lock()
|
||||
s.inflight++
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
case "Network.loadingFinished", "Network.loadingFailed":
|
||||
s.netMu.Lock()
|
||||
if s.inflight > 0 {
|
||||
s.inflight--
|
||||
}
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call envoie une commande CDP et attend sa réponse (borné à 30 s).
|
||||
func (s *cdpSession) call(method string, params map[string]any) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
ch := make(chan cdpMessage, 1)
|
||||
s.pending[id] = ch
|
||||
s.mu.Unlock()
|
||||
|
||||
payload := map[string]any{"id": id, "method": method}
|
||||
if params != nil {
|
||||
payload["params"] = params
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
wctx, cancel := context.WithTimeout(s.wctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if err := s.ws.Write(wctx, websocket.MessageText, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case m, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connexion CDP fermée")
|
||||
}
|
||||
if m.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", method, m.Error.Message)
|
||||
}
|
||||
return m.Result, nil
|
||||
case <-wctx.Done():
|
||||
return nil, fmt.Errorf("%s: délai dépassé", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.ws != nil {
|
||||
_ = s.ws.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
if s.wcancel != nil {
|
||||
s.wcancel()
|
||||
}
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
if s.userDir != "" {
|
||||
os.RemoveAll(s.userDir)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpGet renvoie la session courante, en (re)lançant Chrome au besoin. Un ping
|
||||
// léger valide qu'une session existante répond encore.
|
||||
func cdpGet() (*cdpSession, error) {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
if _, err := cdpCur.call("Runtime.evaluate", map[string]any{"expression": "1", "returnByValue": true}); err == nil {
|
||||
return cdpCur, nil
|
||||
}
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
s, err := cdpLaunch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdpCur = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpShutdown ferme la session navigateur si elle existe (appelé quand on coupe
|
||||
// le computer use, pour ne pas laisser un Chrome headless traîner).
|
||||
// sweepStaleCUDirs supprime les profils Chrome temporaires (loki-cu-*) de plus
|
||||
// d'une heure laissés dans le dossier temp — best-effort, silencieux.
|
||||
func sweepStaleCUDirs() {
|
||||
entries, err := os.ReadDir(os.TempDir())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.HasPrefix(e.Name(), "loki-cu-") {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
os.RemoveAll(filepath.Join(os.TempDir(), e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cdpShutdown() {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── évaluation JS ───────────────────────────────────────────────────────────
|
||||
|
||||
// cdpEval exécute une expression JS dans la page et renvoie sa valeur (JSON brut).
|
||||
func (s *cdpSession) eval(expr string) (json.RawMessage, error) {
|
||||
res, err := s.call("Runtime.evaluate", map[string]any{
|
||||
"expression": expr,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Result struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
} `json:"result"`
|
||||
ExceptionDetails *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"exceptionDetails"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &r)
|
||||
if r.ExceptionDetails != nil {
|
||||
return nil, fmt.Errorf("JS: %s", r.ExceptionDetails.Text)
|
||||
}
|
||||
return r.Result.Value, nil
|
||||
}
|
||||
|
||||
// ─── snapshot d'accessibilité (set-of-marks) ─────────────────────────────────
|
||||
|
||||
// cdpHelpersJS : fonctions partagées par le snapshot et browser_find, préfixées au
|
||||
// script évalué. Points clés :
|
||||
// - ajActionable ne garde que les éléments RÉELLEMENT cliquables/saisissables :
|
||||
// on écarte les conteneurs (list, listitem, banner, navigation, menu, region,
|
||||
// div/span muets…) qui polluaient la liste et embrouillaient le modèle.
|
||||
// - ajName enrichit un champ de formulaire avec son <label> (« Prénom » plutôt
|
||||
// que « firstName »), et NE RÉVÈLE PAS la valeur d'un champ mot de passe
|
||||
// (le secret ne doit pas repartir dans le contexte/l'export).
|
||||
const cdpHelpersJS = `
|
||||
function ajRole(el){var r=el.getAttribute('role');if(r)return r;var t=el.tagName.toLowerCase();if(t==='a')return 'link';if(t==='button')return 'button';if(t==='select')return 'combobox';if(t==='textarea')return 'textbox';if(t==='input'){var ty=(el.type||'text').toLowerCase();var m={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',reset:'button',file:'button',range:'slider',password:'password',search:'searchbox'};return m[ty]||'textbox';}return t;}
|
||||
function ajName(el){var t=el.tagName.toLowerCase();var lbl=el.getAttribute('aria-label')||'';if(!lbl){var lb=el.getAttribute('aria-labelledby');if(lb){var e2=document.getElementById(lb.split(' ')[0]);if(e2)lbl=e2.innerText||'';}}if(!lbl&&el.labels&&el.labels.length)lbl=el.labels[0].innerText||'';if(!lbl)lbl=el.getAttribute('placeholder')||el.getAttribute('title')||el.getAttribute('alt')||'';var val='';if((t==='input'||t==='textarea')&&el.type!=='password'&&el.type!=='hidden')val=el.value||'';var n=val?(lbl?(lbl+' = '+val):val):lbl;if(!n&&t!=='input'&&t!=='select'&&t!=='textarea')n=el.innerText||'';if(!n){var c=el.querySelector('img[alt],[aria-label],[title],svg title');if(c)n=c.getAttribute&&(c.getAttribute('alt')||c.getAttribute('aria-label')||c.getAttribute('title'))||c.textContent||'';}if(!n)n=el.getAttribute('name')||el.getAttribute('value')||'';return (n||'').replace(/\s+/g,' ').trim().slice(0,100);}
|
||||
function ajActionable(el){var t=el.tagName.toLowerCase();if(t==='a')return !!el.getAttribute('href');if(t==='button'||t==='input'||t==='select'||t==='textarea'||t==='summary')return el.type!=='hidden';if(el.isContentEditable)return true;if(el.getAttribute('onclick'))return true;var r=(el.getAttribute('role')||'').toLowerCase();return {button:1,link:1,checkbox:1,radio:1,tab:1,menuitem:1,menuitemcheckbox:1,menuitemradio:1,option:1,'switch':1,combobox:1,textbox:1,searchbox:1,slider:1,spinbutton:1}[r]===1;}
|
||||
function ajVis(el){var r=el.getBoundingClientRect();if(r.width<3||r.height<3)return false;var s=getComputedStyle(el);if(s.visibility==='hidden'||s.display==='none'||s.opacity==='0')return false;if(r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)return false;return true;}
|
||||
var ajSel='a[href],button,input,select,textarea,[role],[onclick],[tabindex],summary,[contenteditable="true"]';
|
||||
// Traversée du SHADOW DOM : les bandeaux cookies et composants web (usercentrics,
|
||||
// cookiebot…) vivent dans des shadow roots que querySelectorAll ne voit pas → le
|
||||
// modèle ne pouvait ni les lister ni les cliquer (test Lady Sushi). ajQueryAll /
|
||||
// ajByRef / ajClear descendent récursivement dans chaque shadowRoot ouvert.
|
||||
function ajQueryAll(){var out=[];function w(r){var e=r.querySelectorAll(ajSel);for(var i=0;i<e.length;i++)out.push(e[i]);var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);return out;}
|
||||
function ajByRef(ref){function f(r){var e=r.querySelector('[data-loki-ref="'+ref+'"]');if(e)return e;var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++)if(a[i].shadowRoot){var x=f(a[i].shadowRoot);if(x)return x;}return null;}return f(document);}
|
||||
function ajClear(){function w(r){var o=r.querySelectorAll('[data-loki-ref]');for(var i=0;i<o.length;i++)o[i].removeAttribute('data-loki-ref');var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);}
|
||||
`
|
||||
|
||||
// cdpSnapshotJS marque chaque élément ACTIONNABLE et VISIBLE d'un attribut
|
||||
// data-loki-ref numéroté, mémorise le centre de chacun dans window.__lokiRefs,
|
||||
// et renvoie {title,url,refs,above,below}. Compact volontairement.
|
||||
const cdpSnapshotJS = cdpHelpersJS + `(function(){
|
||||
ajClear();
|
||||
var els=ajQueryAll().filter(function(el){return ajActionable(el)&&ajVis(el);});
|
||||
var map={},refs=[],i=0;
|
||||
for(var k=0;k<els.length&&i<200;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
i++;var r=el.getBoundingClientRect();map[i]={x:r.left+r.width/2,y:r.top+r.height/2};
|
||||
el.setAttribute('data-loki-ref',String(i));
|
||||
refs.push('['+i+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
window.__lokiRefs=map;
|
||||
var de=document.documentElement;
|
||||
var sh=Math.max(de.scrollHeight,document.body?document.body.scrollHeight:0);
|
||||
return {title:document.title||'',url:location.href,refs:refs,
|
||||
above:Math.round(scrollY),below:Math.round(Math.max(0,sh-scrollY-innerHeight))};
|
||||
})()`
|
||||
|
||||
type cuSnap struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Refs []string `json:"refs"`
|
||||
Above int `json:"above"` // pixels défilables au-dessus de la vue
|
||||
Below int `json:"below"` // pixels défilables en dessous de la vue
|
||||
}
|
||||
|
||||
// snapshot lit l'arbre interactif courant et le formate pour le modèle.
|
||||
func (s *cdpSession) snapshot() (string, error) {
|
||||
val, err := s.eval(cdpSnapshotJS)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var snap cuSnap
|
||||
if err := json.Unmarshal(val, &snap); err != nil {
|
||||
return "", fmt.Errorf("snapshot illisible: %v", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n%s\n\n", snap.Title, snap.URL)
|
||||
if len(snap.Refs) == 0 {
|
||||
b.WriteString("(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%d élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n", len(snap.Refs))
|
||||
b.WriteString(strings.Join(snap.Refs, "\n"))
|
||||
}
|
||||
// Indice de défilement : la liste ne couvre QUE la partie visible. Sans ce
|
||||
// repère, le modèle croit la page épuisée après un scroll (3 éléments restants)
|
||||
// et se met à deviner des URLs au lieu de défiler (vécu sur le test IONOS).
|
||||
if snap.Below > 20 || snap.Above > 20 {
|
||||
b.WriteString("\n\n")
|
||||
if snap.Below > 20 {
|
||||
fmt.Fprintf(&b, "↓ encore ~%d px sous la vue — browser_scroll(down) pour révéler d'autres éléments. ", snap.Below)
|
||||
}
|
||||
if snap.Above > 20 {
|
||||
fmt.Fprintf(&b, "↑ ~%d px au-dessus — browser_scroll(up) pour y revenir.", snap.Above)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), " "), nil
|
||||
}
|
||||
|
||||
// snapshotDedup renvoie le snapshot courant, ou une ligne courte s'il est
|
||||
// RIGOUREUSEMENT identique au dernier renvoyé (clic sans effet, ré-ouverture de
|
||||
// la même page…). Évite de re-lister 20+ éléments pour rien — les numéros restent
|
||||
// valides puisque le DOM n'a pas changé. Économie de contexte pour petits modèles.
|
||||
func (s *cdpSession) snapshotDedup() (string, error) {
|
||||
full, err := s.snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if full == s.lastSnap {
|
||||
return "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", nil
|
||||
}
|
||||
s.lastSnap = full
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// cdpFindJS cherche, sur TOUTE la page (pas seulement le viewport), les éléments
|
||||
// interactifs dont le rôle ou le nom contient la requête, leur pose un
|
||||
// data-loki-ref numéroté, fait défiler le premier au centre, et renvoie la liste.
|
||||
// Sert à localiser un lien/bouton hors écran sans défiler à l'aveugle.
|
||||
// browser_find est NON DESTRUCTIF (contrairement au snapshot) : il n'efface PAS les
|
||||
// data-loki-ref existants — sinon les numéros du dernier snapshot deviendraient
|
||||
// invalides et un browser_click ensuite échouerait (« introuvable », vécu au test #11).
|
||||
// Il RÉUTILISE le ref d'un élément déjà numéroté, et n'attribue un NOUVEAU numéro
|
||||
// (au-delà du max courant) qu'aux éléments hors écran encore sans ref.
|
||||
const cdpFindJS = `function(q){
|
||||
q=(q||'').toLowerCase();
|
||||
var pool=ajQueryAll();
|
||||
var maxRef=0;for(var m=0;m<pool.length;m++){var v=parseInt(pool[m].getAttribute('data-loki-ref'),10)||0;if(v>maxRef)maxRef=v;}
|
||||
var els=pool.filter(ajActionable);
|
||||
var refs=[],count=0,first=null;
|
||||
for(var k=0;k<els.length&&count<60;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
if((rl+' '+nm).toLowerCase().indexOf(q)<0)continue;
|
||||
var st=getComputedStyle(el);if(st.display==='none'||st.visibility==='hidden')continue;
|
||||
var r=el.getBoundingClientRect();if(r.width<1&&r.height<1)continue;
|
||||
var ref=el.getAttribute('data-loki-ref');
|
||||
if(!ref){maxRef++;ref=String(maxRef);el.setAttribute('data-loki-ref',ref);}
|
||||
if(!first)first=el;count++;
|
||||
refs.push('['+ref+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
if(first)first.scrollIntoView({block:'center'});
|
||||
return {refs:refs};
|
||||
}`
|
||||
|
||||
func (s *cdpSession) find(query string) (string, error) {
|
||||
qb, _ := json.Marshal(query)
|
||||
val, err := s.eval(cdpHelpersJS + "(" + cdpFindJS + ")(" + string(qb) + ")")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var r struct {
|
||||
Refs []string `json:"refs"`
|
||||
}
|
||||
if err := json.Unmarshal(val, &r); err != nil {
|
||||
return "", fmt.Errorf("recherche illisible: %v", err)
|
||||
}
|
||||
if len(r.Refs) == 0 {
|
||||
return fmt.Sprintf("Aucun élément contenant « %s » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien).", query), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d élément(s) contenant « %s » (le 1er est amené dans la vue) — clique par numéro :\n", len(r.Refs), query)
|
||||
b.WriteString(strings.Join(r.Refs, "\n"))
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ─── actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// waitLoad attend (au mieux) que la page ait fini de charger après une navigation,
|
||||
// puis que le DOM se STABILISE (contenu rendu en AJAX après readyState=complete).
|
||||
func (s *cdpSession) waitLoad() {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
val, err := s.eval(`document.readyState`)
|
||||
if err == nil && strings.Trim(string(val), `"`) == "complete" {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
s.settle()
|
||||
}
|
||||
|
||||
// settle attend l'INACTIVITÉ RÉSEAU : sur une SPA, readyState passe à « complete »
|
||||
// avant que le contenu chargé en AJAX (résultats de recherche, étape de tunnel…)
|
||||
// ne soit arrivé. On rend la main dès qu'il n'y a plus de requête en vol depuis
|
||||
// ~600 ms, borné à 6 s. Ça supprime le « bash sleep » que le modèle bricolait
|
||||
// avant un browser_snapshot (vécu au test IONOS #10).
|
||||
func (s *cdpSession) settle() {
|
||||
const quiet = 600 * time.Millisecond
|
||||
deadline := time.Now().Add(6 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.netMu.Lock()
|
||||
inflight := s.inflight
|
||||
since := time.Since(s.lastNet)
|
||||
s.netMu.Unlock()
|
||||
if inflight <= 0 && since >= quiet {
|
||||
return
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) navigate(url string) error {
|
||||
// Toute navigation = nouvelle page : on oublie le dernier snapshot pour que le
|
||||
// browser_open qui suit renvoie la page COMPLÈTE, jamais « (page inchangée) »
|
||||
// (piège quand la session persiste d'un test à l'autre, vécu sur Lady Sushi).
|
||||
s.lastSnap = ""
|
||||
// « back » / « forward » : navigation dans l'historique (récupération d'erreur),
|
||||
// pas une URL — sinon on en ferait « https://back ».
|
||||
switch strings.ToLower(strings.TrimSpace(url)) {
|
||||
case "back":
|
||||
_, err := s.eval("history.back();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
case "forward":
|
||||
_, err := s.eval("history.forward();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(url, "://") {
|
||||
url = "https://" + url
|
||||
}
|
||||
res, err := s.call("Page.navigate", map[string]any{"url": url})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Page.navigate signale l'échec réseau dans errorText (DNS, connexion refusée…) ;
|
||||
// et une page d'erreur Chrome a une location « chrome-error:// ». Dans les deux
|
||||
// cas on renvoie une erreur EXPLICITE plutôt qu'un faux snapshot « Actualiser »
|
||||
// que le modèle re-tentait en boucle (vécu : account.ionos.com ré-ouvert 2×).
|
||||
var nr struct {
|
||||
ErrorText string `json:"errorText"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &nr)
|
||||
s.waitLoad()
|
||||
loc, _ := s.eval("location.href")
|
||||
// ERR_ABORTED est bénin (redirection/téléchargement) : on ne le traite pas comme
|
||||
// un échec. Le signal fiable est une location « chrome-error:// ».
|
||||
failed := strings.Contains(string(loc), "chrome-error")
|
||||
if nr.ErrorText != "" && nr.ErrorText != "net::ERR_ABORTED" {
|
||||
failed = true
|
||||
}
|
||||
if failed {
|
||||
reason := nr.ErrorText
|
||||
if reason == "" {
|
||||
reason = "page injoignable"
|
||||
}
|
||||
return fmt.Errorf("chargement de %s échoué (%s) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", url, reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refCenter renvoie les coordonnées viewport (fraîches) d'un élément marqué, en
|
||||
// le faisant d'abord défiler au centre.
|
||||
func (s *cdpSession) refCenter(ref int) (float64, float64, error) {
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(){var el=ajByRef(%d);if(!el){return null;}el.scrollIntoView({block:'center',inline:'center'});var r=el.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`, ref)
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if string(val) == "null" || len(val) == 0 {
|
||||
return 0, 0, fmt.Errorf("élément [%d] introuvable — refais un browser_snapshot (la page a peut-être changé)", ref)
|
||||
}
|
||||
var p struct{ X, Y float64 }
|
||||
if err := json.Unmarshal(val, &p); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return p.X, p.Y, nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) clickRef(ref int) error {
|
||||
x, y, err := s.refCenter(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clickXY clique à des coordonnées pixel du VIEWPORT (lues sur le screenshot).
|
||||
// Seul moyen d'atteindre ce que le DOM ne voit pas : un bandeau de consentement
|
||||
// dans un iframe cross-origin, un canvas, une carte (vécu sur Lady Sushi).
|
||||
func (s *cdpSession) clickXY(x, y float64) error {
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) typeText(text string) error {
|
||||
_, err := s.call("Input.insertText", map[string]any{"text": text})
|
||||
return err
|
||||
}
|
||||
|
||||
// typeInto écrit dans le champ [ref]. Cas spécial des listes déroulantes NATIVES
|
||||
// (<select>, rôle « combobox ») : leur menu s'ouvre en natif hors du DOM, donc
|
||||
// ses options ne sont pas cliquables dans le snapshot (galère du test IONOS #14).
|
||||
// Ici on SÉLECTIONNE directement l'option dont le texte contient la valeur, et on
|
||||
// émet input/change. Sinon on focus+sélectionne le contenu et on laisse insertText
|
||||
// remplacer (comportement texte normal).
|
||||
func (s *cdpSession) typeInto(ref int, text string) error {
|
||||
vb, _ := json.Marshal(text)
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(ref,val){
|
||||
var el=ajByRef(ref);
|
||||
if(!el)return 'NOTFOUND';
|
||||
el.scrollIntoView({block:'center'});
|
||||
if(el.tagName.toLowerCase()==='select'){
|
||||
var o=el.options,lv=val.toLowerCase(),k=-1;
|
||||
for(var i=0;i<o.length;i++){var tx=(o[i].text||'').toLowerCase();if(tx===lv||tx.indexOf(lv)>=0||(o[i].value||'').toLowerCase()===lv){k=i;break;}}
|
||||
if(k<0)return 'NOOPT';
|
||||
el.selectedIndex=k;el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
return 'SELECT';
|
||||
}
|
||||
el.focus();
|
||||
try{if(typeof el.select==='function')el.select();else if(window.getSelection){var r=document.createRange();r.selectNodeContents(el);var s=getSelection();s.removeAllRanges();s.addRange(r);}}catch(e){}
|
||||
return 'TEXT';
|
||||
})(%d,%s)`, ref, string(vb))
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.Trim(string(val), `"`) {
|
||||
case "NOTFOUND":
|
||||
return fmt.Errorf("champ [%d] introuvable — refais un browser_snapshot", ref)
|
||||
case "NOOPT":
|
||||
return fmt.Errorf("aucune option de la liste [%d] ne correspond à « %s » — fais un browser_screenshot pour voir les choix", ref, text)
|
||||
case "SELECT":
|
||||
return nil // liste déroulante réglée, rien à taper
|
||||
default: // TEXT
|
||||
return s.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpKeys mappe les touches nommées vers leur description CDP.
|
||||
var cdpKeys = map[string]struct {
|
||||
key, code string
|
||||
vk int
|
||||
}{
|
||||
"enter": {"Enter", "Enter", 13},
|
||||
"tab": {"Tab", "Tab", 9},
|
||||
"backspace": {"Backspace", "Backspace", 8},
|
||||
"escape": {"Escape", "Escape", 27},
|
||||
"esc": {"Escape", "Escape", 27},
|
||||
"delete": {"Delete", "Delete", 46},
|
||||
"up": {"ArrowUp", "ArrowUp", 38},
|
||||
"down": {"ArrowDown", "ArrowDown", 40},
|
||||
"left": {"ArrowLeft", "ArrowLeft", 37},
|
||||
"right": {"ArrowRight", "ArrowRight", 39},
|
||||
"home": {"Home", "Home", 36},
|
||||
"end": {"End", "End", 35},
|
||||
"pageup": {"PageUp", "PageUp", 33},
|
||||
"pagedown": {"PageDown", "PageDown", 34},
|
||||
}
|
||||
|
||||
func (s *cdpSession) pressKey(name string) error {
|
||||
k, ok := cdpKeys[strings.ToLower(strings.TrimSpace(name))]
|
||||
if !ok {
|
||||
return fmt.Errorf("touche inconnue « %s » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown", name)
|
||||
}
|
||||
for _, typ := range []string{"rawKeyDown", "keyUp"} {
|
||||
if _, err := s.call("Input.dispatchKeyEvent", map[string]any{
|
||||
"type": typ, "key": k.key, "code": k.code,
|
||||
"windowsVirtualKeyCode": k.vk, "nativeVirtualKeyCode": k.vk,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) scroll(dir string) error {
|
||||
dy, dx := 0, 0
|
||||
switch strings.ToLower(strings.TrimSpace(dir)) {
|
||||
case "down", "":
|
||||
dy = 600
|
||||
case "up":
|
||||
dy = -600
|
||||
case "right":
|
||||
dx = 600
|
||||
case "left":
|
||||
dx = -600
|
||||
default:
|
||||
return fmt.Errorf("direction inconnue « %s » (up, down, left, right)", dir)
|
||||
}
|
||||
_, err := s.eval(fmt.Sprintf("window.scrollBy(%d,%d);'ok'", dx, dy))
|
||||
if err == nil {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// screenshot capture le viewport en PNG (octets décodés).
|
||||
func (s *cdpSession) screenshot() ([]byte, error) {
|
||||
res, err := s.call("Page.captureScreenshot", map[string]any{"format": "png"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(r.Data)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCDPSmoke pilote un vrai Chrome de bout en bout : lancement, navigation,
|
||||
// snapshot d'accessibilité, click, screenshot. Coûteux et dépendant d'un
|
||||
// navigateur installé → ne tourne QUE si LOKI_CU_SMOKE=1.
|
||||
func TestCDPSmoke(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if !strings.Contains(strings.ToLower(snap), "example") {
|
||||
t.Errorf("titre 'example' attendu dans le snapshot, absent")
|
||||
}
|
||||
if !strings.Contains(snap, "link") {
|
||||
t.Errorf("le lien 'More information' devrait apparaître comme [n] link")
|
||||
}
|
||||
// browser_find doit retrouver le lien par son texte.
|
||||
found, err := s.find("more")
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(found), "link") {
|
||||
t.Errorf("browser_find('more') devrait trouver le lien, got: %s", found)
|
||||
}
|
||||
// Le screenshot doit produire des octets PNG non vides.
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
t.Fatalf("screenshot: %v", err)
|
||||
}
|
||||
if len(png) < 100 {
|
||||
t.Errorf("screenshot suspicieusement petit: %d octets", len(png))
|
||||
}
|
||||
t.Logf("screenshot: %d octets", len(png))
|
||||
}
|
||||
|
||||
// TestCDPTypeReplacesField vérifie le fix du test IONOS #4 : browser_type(ref) cible le
|
||||
// BON champ et REMPLACE son contenu, même avec deux champs et une valeur existante.
|
||||
func TestCDPTypeReplacesField(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Deux champs texte injectés dans la page courante ; le 2e contient « ancien ».
|
||||
if _, err := s.eval(`document.body.innerHTML='<input id=a placeholder=prenom><input id=b value=ancien>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Retrouve le numéro du 2e champ (celui à remplacer).
|
||||
val, err := s.eval(`document.querySelector('#b').getAttribute('data-loki-ref')`)
|
||||
if err != nil {
|
||||
t.Fatalf("eval ref: %v", err)
|
||||
}
|
||||
ref, _ := strconv.Atoi(strings.Trim(string(val), `"`))
|
||||
if ref == 0 {
|
||||
t.Fatalf("pas de ref sur #b (snapshot: %s)", val)
|
||||
}
|
||||
if err := s.typeInto(ref, "nouveau"); err != nil {
|
||||
t.Fatalf("typeInto: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#b').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "nouveau" {
|
||||
t.Errorf("champ #b = %q, attendu \"nouveau\" (remplacement raté)", v)
|
||||
}
|
||||
// Le premier champ ne doit PAS avoir été touché.
|
||||
got2, _ := s.eval(`document.querySelector('#a').value`)
|
||||
if v := strings.Trim(string(got2), `"`); v != "" {
|
||||
t.Errorf("champ #a = %q, attendu vide (mauvais ciblage)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotHygiene vérifie que le snapshot (1) masque la valeur d'un champ
|
||||
// mot de passe et (2) ne liste pas les conteneurs non-actionnables (un <ul>/<li>).
|
||||
func TestCDPSnapshotHygiene(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
inject := `document.body.innerHTML='` +
|
||||
`<label for=pw>Mot de passe</label><input id=pw type=password value=SECRET123>` +
|
||||
`<label for=em>Email</label><input id=em type=text value="a@b.co">` +
|
||||
`<ul><li>bloc conteneur</li></ul>` +
|
||||
`<a href="#x">un lien</a>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if strings.Contains(snap, "SECRET123") {
|
||||
t.Error("la valeur du champ mot de passe ne doit PAS apparaître dans le snapshot")
|
||||
}
|
||||
if strings.Contains(snap, "bloc conteneur") {
|
||||
t.Error("un <li> conteneur ne doit pas figurer dans la liste des éléments actionnables")
|
||||
}
|
||||
if !strings.Contains(snap, "un lien") {
|
||||
t.Error("le lien actionnable devrait figurer dans le snapshot")
|
||||
}
|
||||
// Le champ mot de passe lui-même reste listé (par son label), juste sans valeur.
|
||||
if !strings.Contains(strings.ToLower(snap), "mot de passe") {
|
||||
t.Error("le champ mot de passe devrait apparaître par son label")
|
||||
}
|
||||
// Un champ texte non-secret DOIT montrer sa valeur (observabilité du formulaire).
|
||||
if !strings.Contains(snap, "a@b.co") {
|
||||
t.Error("la valeur d'un champ texte non-secret devrait apparaître dans le snapshot")
|
||||
}
|
||||
// Un <input type=text> doit être annoncé « textbox », pas « text » (qui ressemble
|
||||
// à du texte statique et faisait croire au modèle que c'était une étiquette).
|
||||
if !strings.Contains(snap, "textbox") {
|
||||
t.Error("un champ de saisie devrait avoir le rôle 'textbox'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPRefUniqueness reproduit le bug du test IONOS #9 : un snapshot doit
|
||||
// EFFACER les anciens data-loki-ref, sinon deux éléments partagent un numéro et
|
||||
// browser_type/browser_click visent le mauvais champ (querySelector prend le 1er du DOM).
|
||||
func TestCDPRefUniqueness(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>A</a><a href=#>B</a><a href=#>C</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Simule un attribut périmé laissé par un snapshot antérieur sur un AUTRE élément.
|
||||
if _, err := s.eval(`document.querySelectorAll('a')[2].setAttribute('data-loki-ref','1');'ok'`); err != nil {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
// Un nouveau snapshot doit repartir propre : chaque numéro sur UN SEUL élément.
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot2: %v", err)
|
||||
}
|
||||
dup, _ := s.eval(`document.querySelectorAll('[data-loki-ref="1"]').length`)
|
||||
if strings.TrimSpace(string(dup)) != "1" {
|
||||
t.Errorf("data-loki-ref=\"1\" devrait être unique, trouvé %s occurrence(s)", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSettleWaitsAsync vérifie que settle() attend le contenu injecté en
|
||||
// asynchrone (cas du test #10 où le modèle devait « bash sleep » avant snapshot).
|
||||
func TestCDPSettleWaitsAsync(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Page réelle (réseau réel), puis on déclenche un fetch AJAX qui n'ajoute son
|
||||
// lien qu'une fois la requête terminée. settle() doit attendre l'inactivité
|
||||
// réseau et donc voir le lien.
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
if _, err := s.eval(`fetch(location.href,{cache:'no-store'}).then(function(){var a=document.createElement('a');a.href='#';a.textContent='AjaxDone';document.body.appendChild(a);});'ok'`); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond) // laisse requestWillBeSent s'enregistrer
|
||||
s.settle() // doit attendre la fin du fetch
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "AjaxDone") {
|
||||
t.Errorf("settle() aurait dû attendre la fin du fetch AJAX; snapshot:\n%s", snap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPTypeSelectsOption vérifie le fix du test #14 : browser_type sur un <select>
|
||||
// choisit l'option par son texte au lieu de taper des caractères.
|
||||
func TestCDPTypeSelectsOption(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<select id=civ><option value="">--</option><option>Madame</option><option>Monsieur</option><option>Autre</option></select>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
ref, _ := s.eval(`+document.querySelector('#civ').getAttribute('data-loki-ref')`)
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(string(ref)))
|
||||
if n == 0 {
|
||||
t.Fatalf("pas de ref sur le select (%s)", ref)
|
||||
}
|
||||
if err := s.typeInto(n, "Monsieur"); err != nil {
|
||||
t.Fatalf("typeInto select: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#civ').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "Monsieur" {
|
||||
t.Errorf("le select devrait valoir « Monsieur », got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotDedup vérifie qu'un état identique renvoie une ligne courte au
|
||||
// lieu de re-lister tous les éléments (économie de contexte, test #12).
|
||||
func TestCDPSnapshotDedup(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Un lien</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
first, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup1: %v", err)
|
||||
}
|
||||
if !strings.Contains(first, "Un lien") {
|
||||
t.Fatalf("1er snapshot devrait lister le lien, got: %s", first)
|
||||
}
|
||||
second, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup2: %v", err)
|
||||
}
|
||||
if strings.Contains(second, "Un lien") || !strings.Contains(second, "inchang") {
|
||||
t.Errorf("2e snapshot identique devrait être compact (inchangé), got: %s", second)
|
||||
}
|
||||
// Après un vrai changement, on redonne la liste complète.
|
||||
if _, err := s.eval(`document.body.innerHTML+='<a href=#>Autre</a>';'ok'`); err != nil {
|
||||
t.Fatalf("change: %v", err)
|
||||
}
|
||||
third, _ := s.snapshotDedup()
|
||||
if !strings.Contains(third, "Autre") {
|
||||
t.Errorf("après changement, le snapshot complet devrait revenir, got: %s", third)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPFindKeepsRefs vérifie le fix du test #11 : browser_find ne doit PAS effacer
|
||||
// les data-loki-ref du dernier snapshot (sinon un browser_click ensuite échoue).
|
||||
func TestCDPFindKeepsRefs(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Alpha</a><a href=#>Beta</a><a href=#>Connexion</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
before, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
// Recherche d'un AUTRE élément : ne doit pas toucher au ref de « Connexion ».
|
||||
if _, err := s.find("Alpha"); err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
after, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
if string(before) != string(after) || strings.Trim(string(after), `"`) == "" {
|
||||
t.Errorf("browser_find a modifié/effacé le ref de « Connexion » : avant=%s après=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClickXY vérifie le clic par coordonnées (pour iframe cross-origin/canvas).
|
||||
func TestCDPClickXY(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un bouton à une position connue qui note son propre clic.
|
||||
inject := `document.body.innerHTML='<button id=b style="position:fixed;left:40px;top:30px;width:120px;height:40px">X</button>';` +
|
||||
`window.__hit=false;document.getElementById('b').addEventListener('click',function(){window.__hit=true;});'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if err := s.clickXY(100, 50); err != nil { // dans le rectangle du bouton
|
||||
t.Fatalf("clickXY: %v", err)
|
||||
}
|
||||
hit, _ := s.eval(`window.__hit`)
|
||||
if strings.TrimSpace(string(hit)) != "true" {
|
||||
t.Errorf("le clic par coordonnées aurait dû atteindre le bouton, got %s", hit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPShadowDOM vérifie le fix du test Lady Sushi : les éléments d'un shadow
|
||||
// DOM (bandeaux cookies, web components) sont vus par le snapshot ET cliquables.
|
||||
func TestCDPShadowDOM(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un host avec un bouton « TOUT REFUSER » DANS un shadow root ouvert.
|
||||
inject := `document.body.innerHTML='<div id=host></div>';` +
|
||||
`var sr=document.getElementById('host').attachShadow({mode:'open'});` +
|
||||
`sr.innerHTML='<button id=b>TOUT REFUSER</button>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "TOUT REFUSER") {
|
||||
t.Fatalf("le bouton du shadow DOM devrait apparaître dans le snapshot; got:\n%s", snap)
|
||||
}
|
||||
// browser_find doit aussi le trouver, et browser_click le viser (via ajByRef).
|
||||
found, err := s.find("refuser")
|
||||
if err != nil || !strings.Contains(strings.ToLower(found), "refuser") {
|
||||
t.Errorf("find n'a pas trouvé le bouton shadow: %v / %s", err, found)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPNavigateError vérifie qu'une URL injoignable renvoie une ERREUR claire
|
||||
// (et pas un faux snapshot avec un bouton « Actualiser » que le modèle re-tente).
|
||||
func TestCDPNavigateError(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
err = s.navigate("https://nonexistent-host-loki-test-99999.invalid")
|
||||
if err == nil {
|
||||
t.Error("une URL injoignable devrait renvoyer une erreur, pas nil")
|
||||
} else {
|
||||
t.Logf("erreur attendue: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestDedupableTool verrouille le fix du test IONOS : les outils à état vivant /
|
||||
// à image (cu_*, see_image, bash) ne doivent JAMAIS être dédupliqués, sinon un
|
||||
// second appel identique renvoie « [déjà fait] » sans rejouer l'action ni
|
||||
// l'image, et l'IA boucle.
|
||||
func TestDedupableTool(t *testing.T) {
|
||||
never := []string{"bash", "see_image", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_key", "browser_scroll", "browser_screenshot"}
|
||||
for _, n := range never {
|
||||
if dedupableTool(n) {
|
||||
t.Errorf("%s ne doit PAS être déduplicable", n)
|
||||
}
|
||||
}
|
||||
always := []string{"write", "edit", "mem_add", "web_open"}
|
||||
for _, n := range always {
|
||||
if !dedupableTool(n) {
|
||||
t.Errorf("%s devrait rester déduplicable", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package loki
|
||||
|
||||
// computer_use.go — le « computer use » : donner à Jean la capacité d'agir sur
|
||||
// une interface graphique de la machine hôte. Étape 1 : le WEB, via un Chromium
|
||||
// piloté en CDP (computer_cdp.go). Le modèle lit l'arbre d'accessibilité (des
|
||||
// éléments interactifs NUMÉROTÉS, façon set-of-marks) et agit par numéro —
|
||||
// aucune vision requise, ce qui marche même avec de petits modèles texte à
|
||||
// faible contexte. browser_screenshot n'est proposé que si la vision est active, pour
|
||||
// les cas sans a11y (canvas, jeux).
|
||||
//
|
||||
// C'est un axe de capacité indépendant, sous le mode agent (comme l'accès
|
||||
// internet) : un outil qui pilote un navigateur exécute des actions réelles, donc
|
||||
// même niveau de confiance que bash. Interrupteur : clé bkState "computer".
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func computerUseEnabled() bool { return getBool(bkState, "computer") }
|
||||
|
||||
func setComputerUseEnabled(on bool) error { return putBool(bkState, "computer", on) }
|
||||
|
||||
// cuMaxOutput borne ce qu'un appel d'outil computer use injecte dans le contexte
|
||||
// (aligné sur le web et le shell : 8000).
|
||||
const cuMaxOutput = 8000
|
||||
|
||||
func capCUOutput(s string) string {
|
||||
if r := []rune(s); len(r) > cuMaxOutput {
|
||||
return string(r[:cuMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── schémas d'outils (annoncés au modèle) ───────────────────────────────────
|
||||
|
||||
func cuOpenTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_open",
|
||||
Description: "Open a URL in the controlled browser and return the page's interactive elements, each numbered [n]. Start here. Pass 'back' or 'forward' instead of a URL to move through history.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"url": map[string]any{"type": "string", "description": "URL to open, or 'back' / 'forward'"}},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuSnapshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_snapshot",
|
||||
Description: "Re-read the current page's interactive elements (numbered). Use it after the page changes to get fresh [n] refs.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click",
|
||||
Description: "Click the element with the given number from the latest snapshot. Returns the page state afterwards.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"ref": map[string]any{"type": "integer", "description": "Element number [n]"}},
|
||||
"required": []string{"ref"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuTypeTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_type",
|
||||
Description: "Type text into a field. ALWAYS give 'ref' (the field's number) so it targets the right field and REPLACES its current content — no need to browser_click the field first. For a dropdown/select (role 'combobox'), pass the option's text and it picks that option directly (don't click it open). Without a ref it types into whatever is focused, which is unreliable.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to type"},
|
||||
"ref": map[string]any{"type": "integer", "description": "Optional: field number [n] to focus first"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuKeyTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_key",
|
||||
Description: "Press a special key: enter, tab, backspace, escape, delete, up, down, left, right, home, end, pageup, pagedown.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"key": map[string]any{"type": "string", "description": "Key name"}},
|
||||
"required": []string{"key"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuFindTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_find",
|
||||
Description: "Find interactive elements ANYWHERE on the page (not just the visible part) whose label contains the text, and scroll the first into view. Use it to locate an off-screen link or button (e.g. 'créer un compte', 'panier') instead of scrolling blindly or guessing URLs.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"text": map[string]any{"type": "string", "description": "Text to look for in element labels"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScrollTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_scroll",
|
||||
Description: "Scroll the page (up, down, left, right), then return the now-visible interactive elements.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"direction": map[string]any{"type": "string", "description": "up, down, left or right"}},
|
||||
"required": []string{"direction"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickXYTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click_xy",
|
||||
Description: fmt.Sprintf("Click at pixel coordinates (x from the left, y from the top) read off the latest browser_screenshot — %dx%d pixels, 1:1 with the page, with a coordinate grid drawn every 100px to read x/y precisely. Use it ONLY for something the numbered elements can't reach: a cookie/consent banner inside a cross-origin iframe, a canvas, a map. Otherwise always prefer browser_click(ref).", cuViewW, cuViewH),
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"x": map[string]any{"type": "number", "description": "pixels from the left edge"},
|
||||
"y": map[string]any{"type": "number", "description": "pixels from the top edge"},
|
||||
},
|
||||
"required": []string{"x", "y"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScreenshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_screenshot",
|
||||
Description: "Capture the current page as an image. It is shown to the user immediately AND saved to your working folder; the result gives the filename to link with Markdown if they want the file. Use it to see a visual layout the numbered elements don't convey, or when the user asks for a screenshot — never hunt the disk for it.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
// computerUseTools est la liste annoncée au modèle quand le computer use est
|
||||
// actif. browser_screenshot n'y figure que si la vision est réellement active.
|
||||
func computerUseTools() []Tool {
|
||||
tools := []Tool{cuOpenTool(), cuSnapshotTool(), cuFindTool(), cuClickTool(), cuTypeTool(), cuKeyTool(), cuScrollTool()}
|
||||
// Vision active : la capture d'écran ET le clic par coordonnées (qui n'a de sens
|
||||
// que si le modèle peut lire l'image pour viser).
|
||||
if visionEnabled() {
|
||||
tools = append(tools, cuScreenshotTool(), cuClickXYTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// cuPromptLine est la consigne d'usage ajoutée au prompt système quand le
|
||||
// computer use est actif (l'ordre d'appel que les schémas isolés ne disent pas).
|
||||
func cuPromptLine() string {
|
||||
line := "\nComputer use (a browser you control on this machine): browser_open a URL, then act on the NUMBERED elements — browser_click(ref), browser_type(text, ref), browser_key, browser_scroll. Each action returns the fresh numbered elements; after the page changes, trust the new numbers, not old ones. The list shows only what's in view: to reach an off-screen link or button (e.g. 'créer un compte'), use browser_find(text) to jump straight to it rather than scrolling blindly or, worse, guessing URLs — inventing paths mostly 404s. If a page shows an error (404, 'introuvable'), browser_open goes 'back' or try another link. Prefer the numbered elements over screenshots."
|
||||
if visionEnabled() {
|
||||
line += " Use browser_screenshot when the numbers aren't enough (canvas, visual layout). If a button is visible but NOT in the numbered list (e.g. a cookie banner inside an iframe), browser_screenshot then browser_click_xy(x,y) at its pixel position."
|
||||
}
|
||||
line += "\n"
|
||||
return line
|
||||
}
|
||||
|
||||
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
|
||||
|
||||
func toolCUOpen(args map[string]any) string {
|
||||
url, _ := args["url"].(string)
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return "[erreur] url manquante"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.navigate(url); err != nil {
|
||||
return "[erreur] navigation : " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUSnapshot() string {
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUFind(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "[erreur] texte de recherche manquant"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
res, err := s.find(text)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func toolCUClick(args map[string]any) string {
|
||||
ref, ok := intArg(args, "ref")
|
||||
if !ok {
|
||||
return "[erreur] ref manquant (numéro de l'élément)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickRef(ref); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] cliqué [%d]\n\n%s", ref, snap)
|
||||
}
|
||||
|
||||
func toolCUType(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if ref, ok := intArg(args, "ref"); ok {
|
||||
if err := s.typeInto(ref, text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
} else if err := s.typeText(text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] saisi « %s »\n\n%s", text, snap)
|
||||
}
|
||||
|
||||
func toolCUKey(args map[string]any) string {
|
||||
key, _ := args["key"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.pressKey(key); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] touche %s\n\n%s", key, snap)
|
||||
}
|
||||
|
||||
func toolCUClickXY(args map[string]any) string {
|
||||
x, okx := floatArg(args, "x")
|
||||
y, oky := floatArg(args, "y")
|
||||
if !okx || !oky {
|
||||
return "[erreur] coordonnées x et y requises (en pixels du screenshot)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickXY(x, y); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] clic en (%.0f, %.0f)\n\n%s", x, y, snap)
|
||||
}
|
||||
|
||||
func toolCUScroll(args map[string]any) string {
|
||||
dir, _ := args["direction"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.scroll(dir); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return snap
|
||||
}
|
||||
|
||||
// toolCUScreenshot renvoie (accusé texte, partie image_url) comme see_image :
|
||||
// l'image est réinjectée dans un message utilisateur multimodal par l'appelant.
|
||||
//
|
||||
// La capture PLEINE RÉSOLUTION est aussi ENREGISTRÉE dans le dossier de travail :
|
||||
// sans ça, le modèle qui reçoit « envoie-moi une capture » partait chercher un
|
||||
// PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour
|
||||
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
|
||||
func toolCUScreenshot() (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
// Enregistre le PNG plein format dans le workspace (chemin RELATIF renvoyé au
|
||||
// modèle : un lien Markdown [capture](nom.png) est servi par /api/chat/file).
|
||||
saved := ""
|
||||
name := "capture-" + time.Now().Format("20060102-150405") + ".png"
|
||||
if ws := agentWorkspace(); ws != "" {
|
||||
if err := os.WriteFile(filepath.Join(ws, name), png, 0o644); err == nil {
|
||||
saved = name
|
||||
}
|
||||
}
|
||||
// L'image du MODÈLE porte une GRILLE de coordonnées (lignes graduées tous les
|
||||
// 100 px) pour qu'il lise x/y avec précision ; celle sauvegardée pour l'utilisateur
|
||||
// (plus haut, `png`) reste PROPRE, sans grille.
|
||||
b, mime := prepareImageForModel(overlayGrid(png), "image/png")
|
||||
// ⚠️ TOI (le modèle) vois l'image ci-dessous, mais l'utilisateur NE la voit PAS
|
||||
// automatiquement dans le chat. Pour la lui afficher, il faut l'insérer en IMAGE
|
||||
// Markdown  — un simple lien [texte](fichier) ne fait qu'un bouton
|
||||
// de téléchargement, pas d'aperçu.
|
||||
msg := fmt.Sprintf("[ok] capture prise (tu la vois ci-dessous, %dx%d px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille.", cuViewW, cuViewH)
|
||||
if saved != "" {
|
||||
msg += " Enregistrée sous « " + saved + " ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : . Un simple lien [capture](" + saved + ") ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci."
|
||||
}
|
||||
return msg, imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// intArg extrait un entier d'un argument d'outil (le JSON décode les nombres en
|
||||
// float64).
|
||||
func intArg(args map[string]any, key string) (int, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// floatArg extrait un nombre d'un argument d'outil (coordonnées de clic).
|
||||
func floatArg(args map[string]any, key string) (float64, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ─── CLI : loki computer [on|off|status] ────────────────────────────────────
|
||||
|
||||
func cmdComputer(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
if err := setComputerUseEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := " contrôle du navigateur activé — l'IA pilote un navigateur (browser_open/browser_click/browser_type/…) si le mode agent est actif"
|
||||
if chromePath() == "" {
|
||||
msg += "\n" + red("[!]") + " aucun Chrome/Chromium/Edge détecté — installe-le ou définis LOKI_CHROME=<chemin>"
|
||||
}
|
||||
fmt.Println(green("[ok]") + msg)
|
||||
case "off":
|
||||
if err := setComputerUseEnabled(false); err != nil {
|
||||
return err
|
||||
}
|
||||
cdpShutdown()
|
||||
fmt.Println(green("[ok]") + " contrôle du navigateur désactivé")
|
||||
case "", "status":
|
||||
state := dim("off")
|
||||
if computerUseEnabled() {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Contrôle du navigateur"), state)
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
fmt.Printf(" navigateur : %s — installe Chrome/Chromium/Edge ou LOKI_CHROME=<chemin>\n", red("introuvable"))
|
||||
} else {
|
||||
fmt.Printf(" navigateur : %s\n", bold(bin))
|
||||
}
|
||||
fmt.Printf(" outils : browser_open, browser_snapshot, browser_find, browser_click, browser_type, browser_key, browser_scroll")
|
||||
if visionEnabled() {
|
||||
fmt.Printf(", browser_screenshot, browser_click_xy")
|
||||
}
|
||||
fmt.Println()
|
||||
default:
|
||||
return fmt.Errorf("usage: loki computer [on|off|status]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Réglages de la dictée vocale — modèle et réactivité.
|
||||
//
|
||||
// Ils vivent dans le magasin clé/valeur (bkState), comme le jeton Hugging
|
||||
// Face : ce sont des réglages de SERVEUR, pas de navigateur. Le modèle chargé
|
||||
// est une propriété de la machine, identique pour tous les onglets ouverts.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dictateCfgKey = "dictate_cfg"
|
||||
|
||||
// DictateCfg : ce qui reste réglable depuis le passage à Parakeet.
|
||||
//
|
||||
// Deux champs ont disparu, et c'est le moteur qui l'impose, pas un choix de
|
||||
// simplification :
|
||||
// - la LANGUE : Parakeet v3 détecte la langue lui-même et n'a aucun drapeau
|
||||
// pour la forcer. Le réglage n'aurait servi qu'à mentir.
|
||||
// - le GPU : le binaire sherpa-onnx livré dans l'image est le build statique
|
||||
// CPU. Annoncer un sélecteur de carte sans pouvoir l'honorer serait pire que
|
||||
// de ne rien annoncer. Un modèle 0,6 B en int8 sur des tranches de quelques
|
||||
// secondes n'a pas besoin de la carte, qui reste au moteur de chat.
|
||||
type DictateCfg struct {
|
||||
Model string `json:"model"`
|
||||
Reactivity string `json:"reactivity"`
|
||||
}
|
||||
|
||||
// withDefauts comble les champs vides. Appliqué à la LECTURE plutôt qu'à
|
||||
// l'écriture : une version future qui ajoute un champ trouvera les réglages
|
||||
// déjà enregistrés sans champ correspondant, et doit pouvoir le combler. C'est
|
||||
// aussi ce qui rattrape les réglages enregistrés du temps de whisper, dont le
|
||||
// Model ne veut plus rien dire.
|
||||
func (c DictateCfg) withDefauts() DictateCfg {
|
||||
if _, connu := asrCatalogue[c.Model]; !connu {
|
||||
// v3 : le seul des deux qui parle français.
|
||||
c.Model = "parakeet-tdt-0.6b-v3"
|
||||
}
|
||||
if c.Reactivity == "" {
|
||||
c.Reactivity = "moyen"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func dictateCfgLoad() DictateCfg {
|
||||
var c DictateCfg
|
||||
getJSON(bkState, dictateCfgKey, &c)
|
||||
return c.withDefauts()
|
||||
}
|
||||
|
||||
func dictateCfgSave(c DictateCfg) error {
|
||||
// Valider ICI plutôt qu'au démarrage du serveur : un modèle hors catalogue
|
||||
// accepté en silence ne se manifesterait qu'au premier clic sur le micro,
|
||||
// loin du geste qui l'a causé. On valide AVANT withDefauts, qui corrigerait
|
||||
// justement la faute qu'on veut signaler.
|
||||
if _, ok := asrCatalogue[c.Model]; !ok {
|
||||
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return putJSON(bkState, dictateCfgKey, c.withDefauts())
|
||||
}
|
||||
|
||||
// chunkBounds : réserve minimale avant de couper, et coupure forcée quand
|
||||
// aucun silence n'arrive. Consommé par le découpeur du plan « temps réel ».
|
||||
func (c DictateCfg) chunkBounds() (time.Duration, time.Duration) {
|
||||
switch c.Reactivity {
|
||||
case "court":
|
||||
return 1000 * time.Millisecond, 5 * time.Second
|
||||
case "long":
|
||||
return 3 * time.Second, 15 * time.Second
|
||||
default:
|
||||
return 1500 * time.Millisecond, 8 * time.Second
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les défauts sont appliqués à la LECTURE : un réglage enregistré par une
|
||||
// version antérieure, sans le champ, doit être comblé plutôt que de produire un
|
||||
// modèle vide passé en argument au serveur.
|
||||
func TestDefautsCombles(t *testing.T) {
|
||||
testHome(t)
|
||||
c := DictateCfg{}.withDefauts()
|
||||
if _, ok := asrCatalogue[c.Model]; !ok {
|
||||
t.Errorf("modèle par défaut %q hors catalogue", c.Model)
|
||||
}
|
||||
if c.Reactivity == "" {
|
||||
t.Error("réactivité par défaut vide")
|
||||
}
|
||||
}
|
||||
|
||||
// Le passage de whisper à Parakeet laisse des réglages enregistrés dont le
|
||||
// Model ne veut plus rien dire (« large-v3-turbo-q5_0 »). Ils doivent retomber
|
||||
// sur le défaut, sans quoi la dictée serait morte après mise à jour, avec pour
|
||||
// seul indice « modèle de dictée inconnu » au premier clic sur le micro.
|
||||
func TestModeleWhisperHeriteRetombeSurLeDefaut(t *testing.T) {
|
||||
testHome(t)
|
||||
c := DictateCfg{Model: "large-v3-turbo-q5_0", Reactivity: "court"}.withDefauts()
|
||||
if _, ok := asrCatalogue[c.Model]; !ok {
|
||||
t.Fatalf("modèle hérité %q non corrigé", c.Model)
|
||||
}
|
||||
if c.Reactivity != "court" {
|
||||
t.Errorf("réactivité = %q, elle devait être conservée", c.Reactivity)
|
||||
}
|
||||
}
|
||||
|
||||
// Valider à l'ÉCRITURE : un modèle hors catalogue accepté en silence ne se
|
||||
// manifesterait qu'au premier clic sur le micro, loin du geste qui l'a causé.
|
||||
func TestSaveRefuseModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "nawak", Reactivity: "moyen"}); err == nil {
|
||||
t.Error("un modèle hors catalogue a été accepté")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
want := DictateCfg{Model: "parakeet-tdt-0.6b-v2", Reactivity: "long"}
|
||||
if err := dictateCfgSave(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dictateCfgLoad(); got != want {
|
||||
t.Errorf("relu %+v, attendu %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Les bornes de découpage pilotent la latence perçue : plus la réserve est
|
||||
// courte, plus le texte arrive vite, au prix d'un contexte plus maigre pour le
|
||||
// modèle. L'ordre entre les trois réglages est ce qui doit tenir.
|
||||
func TestChunkBounds(t *testing.T) {
|
||||
court, _ := DictateCfg{Reactivity: "court"}.chunkBounds()
|
||||
moyen, _ := DictateCfg{Reactivity: "moyen"}.chunkBounds()
|
||||
long, longMax := DictateCfg{Reactivity: "long"}.chunkBounds()
|
||||
if !(court < moyen && moyen < long) {
|
||||
t.Errorf("réserves non croissantes : court=%v moyen=%v long=%v", court, moyen, long)
|
||||
}
|
||||
if longMax <= long {
|
||||
t.Error("la coupure forcée doit être plus grande que la réserve minimale")
|
||||
}
|
||||
// Une valeur inconnue doit retomber sur le réglage moyen, pas sur zéro : une
|
||||
// réserve nulle couperait à chaque échantillon.
|
||||
inconnu, _ := DictateCfg{Reactivity: "nawak"}.chunkBounds()
|
||||
if inconnu != moyen {
|
||||
t.Errorf("réactivité inconnue = %v, attendu le réglage moyen %v", inconnu, moyen)
|
||||
}
|
||||
if inconnu <= 0 {
|
||||
t.Error("réserve nulle : la dictée couperait à chaque échantillon")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
// Catalogue des modèles de dictée — Parakeet (NVIDIA), servi par sherpa-onnx.
|
||||
//
|
||||
// Aucun n'est embarqué dans l'image : ils pèsent près de 500 Mo compressés et
|
||||
// vivent dans <LOKI_HOME>/asr/, donc dans le volume /data — ils survivent aux
|
||||
// recréations du conteneur.
|
||||
//
|
||||
// Différence de forme avec whisper.cpp, qu'ils remplacent : un modèle n'est plus
|
||||
// UN fichier .bin mais un DOSSIER (encodeur, décodeur, joiner, table de jetons),
|
||||
// livré en .tar.bz2. Le téléchargement décompresse donc au passage — d'où
|
||||
// asrExtract plutôt qu'une simple copie de flux.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/bzip2"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Les archives sherpa-onnx officielles (conversions ONNX des modèles NeMo).
|
||||
const asrModelBase = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
|
||||
|
||||
type asrModel struct {
|
||||
Nom string // libellé affiché
|
||||
Dossier string // nom du dossier, identique en local et dans l'archive
|
||||
Octets int64 // taille du TÉLÉCHARGEMENT (archive compressée), pour prévenir
|
||||
Langues string // ce que le modèle sait transcrire — c'est LE critère de choix
|
||||
}
|
||||
|
||||
// asrFichiers : les quatre fichiers qu'un modèle doit avoir pour être utilisable.
|
||||
// Sert au contrôle de présence : un dossier à moitié extrait (téléchargement
|
||||
// interrompu) ne doit pas passer pour un modèle installé.
|
||||
var asrFichiers = []string{"encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"}
|
||||
|
||||
// Les tailles sont celles publiées par le dépôt amont. Elles servent à informer
|
||||
// avant un téléchargement, pas à vérifier le fichier : une taille qui dérive
|
||||
// d'une release à l'autre ne doit pas casser la dictée.
|
||||
var asrCatalogue = map[string]asrModel{
|
||||
"parakeet-tdt-0.6b-v3": {
|
||||
Nom: "Parakeet v3 — recommandé",
|
||||
Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8",
|
||||
Octets: 487_170_055,
|
||||
Langues: "25 langues européennes, français inclus — détection automatique",
|
||||
},
|
||||
"parakeet-tdt-0.6b-v2": {
|
||||
Nom: "Parakeet v2 — anglais seul",
|
||||
Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8",
|
||||
Octets: 482_468_385,
|
||||
Langues: "anglais uniquement",
|
||||
},
|
||||
}
|
||||
|
||||
// asrRoot : la racine des modèles de dictée. Volontairement distincte de
|
||||
// l'ancien dossier `whisper/` : les .bin ggml qui y dorment ne servent plus à
|
||||
// rien, mais les effacer tout seuls serait détruire des données que
|
||||
// l'utilisateur n'a pas demandé de perdre. Ils sont à supprimer à la main.
|
||||
func asrRoot() string { return filepath.Join(LokiHome(), "asr") }
|
||||
|
||||
// asrModelDirFor : dossier local d'un modèle. Vide si l'identifiant est inconnu —
|
||||
// l'appelant doit traiter ce cas plutôt que de bâtir un chemin sur une chaîne
|
||||
// arbitraire venue d'une requête HTTP.
|
||||
func asrModelDirFor(id string) string {
|
||||
m, ok := asrCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(asrRoot(), m.Dossier)
|
||||
}
|
||||
|
||||
func asrModelURLFor(id string) string {
|
||||
m, ok := asrCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return asrModelBase + m.Dossier + ".tar.bz2"
|
||||
}
|
||||
|
||||
// asrModelPresent : le modèle est installé ET complet. On vérifie les quatre
|
||||
// fichiers plutôt que l'existence du dossier : une extraction coupée en deux
|
||||
// laisse un dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur
|
||||
// tomberait au premier clic sur le micro plutôt qu'ici.
|
||||
func asrModelPresent(id string) bool {
|
||||
dir := asrModelDirFor(id)
|
||||
if dir == "" {
|
||||
return false
|
||||
}
|
||||
for _, f := range asrFichiers {
|
||||
st, err := os.Stat(filepath.Join(dir, f))
|
||||
if err != nil || st.Size() == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// asrModelFile : chemin d'un des fichiers du modèle, pour les arguments passés
|
||||
// au serveur. Vide si le modèle est inconnu.
|
||||
func asrModelFile(id, fichier string) string {
|
||||
dir := asrModelDirFor(id)
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, fichier)
|
||||
}
|
||||
|
||||
// asrCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
|
||||
// l'ordre dans lequel se pose la question du compromis.
|
||||
func asrCatalogueTrie() []map[string]any {
|
||||
out := make([]map[string]any, 0, len(asrCatalogue))
|
||||
for id, m := range asrCatalogue {
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "nom": m.Nom, "octets": m.Octets,
|
||||
"langues": m.Langues, "present": asrModelPresent(id),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// asrExtract décompresse une archive .tar.bz2 sous `dest`, en ne gardant que le
|
||||
// contenu du dossier de premier niveau (l'archive amont l'embarque déjà).
|
||||
//
|
||||
// Deux précautions qui ne sont pas du zèle :
|
||||
// - un chemin d'entrée qui remonte (« ../ ») ou absolu est REFUSÉ : une archive
|
||||
// téléchargée est une entrée non fiable, et une entrée piégée écrirait
|
||||
// n'importe où sur le disque du serveur ;
|
||||
// - on extrait dans un dossier temporaire renommé à la fin, pour qu'une
|
||||
// extraction interrompue ne laisse jamais un modèle à moitié installé sous
|
||||
// son nom définitif.
|
||||
func asrExtract(r io.Reader, dest string) error {
|
||||
tmp := dest + ".part"
|
||||
if err := os.RemoveAll(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(tmp, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(bzip2.NewReader(r))
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
rel, err := asrSafeRel(h.Name)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
if rel == "" {
|
||||
continue // le dossier racine de l'archive lui-même
|
||||
}
|
||||
cible := filepath.Join(tmp, rel)
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(cible, 0o755); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(cible), 0o755); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(cible)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
// #nosec G110 — l'archive vient d'une release GitHub épinglée par son
|
||||
// nom, pas d'une source arbitraire ; et la borne utile ici est le
|
||||
// disque, pas un plafond deviné.
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Liens, périphériques, FIFO : une archive de modèle n'en a aucun
|
||||
// besoin, et les suivre serait un autre moyen d'écrire hors du dossier.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dest)
|
||||
}
|
||||
|
||||
// asrSafeRel retire le dossier de premier niveau d'un chemin d'archive et refuse
|
||||
// tout ce qui sortirait du dossier de destination. Renvoie "" pour l'entrée du
|
||||
// dossier racine elle-même.
|
||||
func asrSafeRel(name string) (string, error) {
|
||||
n := strings.ReplaceAll(strings.TrimSpace(name), `\`, "/")
|
||||
if n == "" || strings.HasPrefix(n, "/") || filepath.IsAbs(n) {
|
||||
return "", fmt.Errorf("entrée d'archive au chemin absolu : %q", name)
|
||||
}
|
||||
parts := strings.Split(strings.Trim(n, "/"), "/")
|
||||
for _, p := range parts {
|
||||
if p == ".." {
|
||||
return "", fmt.Errorf("entrée d'archive qui remonte hors du dossier : %q", name)
|
||||
}
|
||||
}
|
||||
if len(parts) <= 1 {
|
||||
return "", nil // le dossier racine de l'archive
|
||||
}
|
||||
return filepath.Join(parts[1:]...), nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le catalogue nourrit une liste déroulante ET des chemins de fichiers : une
|
||||
// entrée bancale ne se verrait qu'au premier clic sur le micro.
|
||||
func TestCatalogueCoherent(t *testing.T) {
|
||||
for id, m := range asrCatalogue {
|
||||
if m.Nom == "" || m.Dossier == "" || m.Langues == "" {
|
||||
t.Errorf("%s : entrée incomplète %+v", id, m)
|
||||
}
|
||||
if m.Octets <= 0 {
|
||||
t.Errorf("%s : taille annoncée %d — la confirmation de téléchargement en a besoin", id, m.Octets)
|
||||
}
|
||||
if !strings.HasPrefix(asrModelURLFor(id), "https://") {
|
||||
t.Errorf("%s : URL non https (%s)", id, asrModelURLFor(id))
|
||||
}
|
||||
}
|
||||
// Le défaut doit exister, et être celui qui parle français.
|
||||
def := DictateCfg{}.withDefauts().Model
|
||||
if _, ok := asrCatalogue[def]; !ok {
|
||||
t.Fatalf("le modèle par défaut %q n'est pas au catalogue", def)
|
||||
}
|
||||
if !strings.Contains(asrCatalogue[def].Langues, "français") {
|
||||
t.Errorf("le modèle par défaut (%s) ne parle pas français : %q", def, asrCatalogue[def].Langues)
|
||||
}
|
||||
}
|
||||
|
||||
// Un identifiant hors catalogue ne doit JAMAIS produire de chemin : il vient
|
||||
// d'une requête HTTP, et bâtir un chemin dessus ouvrirait le disque.
|
||||
func TestCheminVideSiInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if p := asrModelDirFor("../../etc"); p != "" {
|
||||
t.Errorf("chemin bâti sur un identifiant inconnu : %q", p)
|
||||
}
|
||||
if u := asrModelURLFor("nawak"); u != "" {
|
||||
t.Errorf("URL bâtie sur un identifiant inconnu : %q", u)
|
||||
}
|
||||
if f := asrModelFile("nawak", "encoder.int8.onnx"); f != "" {
|
||||
t.Errorf("chemin de fichier bâti sur un identifiant inconnu : %q", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle n'est « présent » que COMPLET : une extraction coupée laisse un
|
||||
// dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur tomberait
|
||||
// au premier clic sur le micro plutôt qu'ici.
|
||||
func TestPresentExigeTousLesFichiers(t *testing.T) {
|
||||
testHome(t)
|
||||
const id = "parakeet-tdt-0.6b-v3"
|
||||
dir := asrModelDirFor(id)
|
||||
if asrModelPresent(id) {
|
||||
t.Fatal("modèle annoncé présent alors que rien n'est installé")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, f := range asrFichiers {
|
||||
if asrModelPresent(id) {
|
||||
t.Fatalf("modèle annoncé présent avec seulement %d fichier(s) sur %d", i, len(asrFichiers))
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !asrModelPresent(id) {
|
||||
t.Error("modèle complet non reconnu")
|
||||
}
|
||||
// Un fichier vide (disque plein en fin d'extraction) ne compte pas.
|
||||
if err := os.WriteFile(filepath.Join(dir, asrFichiers[0]), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asrModelPresent(id) {
|
||||
t.Error("un fichier vide passe pour un modèle installé")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogueTriParTaille(t *testing.T) {
|
||||
testHome(t)
|
||||
l := asrCatalogueTrie()
|
||||
if len(l) != len(asrCatalogue) {
|
||||
t.Fatalf("%d entrées, attendu %d", len(l), len(asrCatalogue))
|
||||
}
|
||||
for i := 1; i < len(l); i++ {
|
||||
if l[i-1]["octets"].(int64) > l[i]["octets"].(int64) {
|
||||
t.Error("catalogue non trié du plus léger au plus lourd")
|
||||
}
|
||||
}
|
||||
for _, e := range l {
|
||||
for _, clef := range []string{"id", "nom", "octets", "langues", "present"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extraction d'archive ────────────────────────────────────────────────────
|
||||
|
||||
// asrSafeRel est la seule chose entre une archive téléchargée et le disque du
|
||||
// serveur. Une entrée qui remonte ou qui est absolue doit être REFUSÉE, pas
|
||||
// nettoyée en silence.
|
||||
func TestSafeRelRefuseLesEvasions(t *testing.T) {
|
||||
for _, mauvais := range []string{
|
||||
"/etc/passwd",
|
||||
"modele/../../etc/passwd",
|
||||
"../dehors.txt",
|
||||
`modele\..\..\dehors.txt`,
|
||||
} {
|
||||
if _, err := asrSafeRel(mauvais); err == nil {
|
||||
t.Errorf("entrée d'archive acceptée alors qu'elle sort du dossier : %q", mauvais)
|
||||
}
|
||||
}
|
||||
// Le dossier de premier niveau est retiré, le reste conservé.
|
||||
got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/encoder.int8.onnx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "encoder.int8.onnx" {
|
||||
t.Errorf("chemin = %q, le dossier racine de l'archive doit être retiré", got)
|
||||
}
|
||||
// L'entrée du dossier racine lui-même ne produit rien.
|
||||
if got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/"); err != nil || got != "" {
|
||||
t.Errorf("racine de l'archive : (%q, %v), attendu (\"\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Une extraction ratée ne doit jamais laisser un modèle à moitié installé sous
|
||||
// son nom définitif : asrModelPresent le croirait bon et le serveur mourrait au
|
||||
// démarrage. Elle ne doit pas non plus détruire un modèle déjà en place — le
|
||||
// remplacement n'a lieu qu'une fois l'extraction terminée.
|
||||
func TestExtractNeLaissePasDeDossierPartiel(t *testing.T) {
|
||||
testHome(t)
|
||||
dest := filepath.Join(t.TempDir(), "modele")
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
temoin := filepath.Join(dest, "deja-la.txt")
|
||||
if err := os.WriteFile(temoin, []byte("modèle précédent"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Un flux qui n'est pas du bzip2 : la lecture échoue dès la première entrée.
|
||||
if err := asrExtract(bytes.NewReader([]byte("ceci n'est pas une archive bzip2")), dest); err == nil {
|
||||
t.Fatal("un flux illisible doit produire une erreur")
|
||||
}
|
||||
if _, err := os.Stat(dest + ".part"); err == nil {
|
||||
t.Error("le dossier temporaire n'a pas été nettoyé après l'échec")
|
||||
}
|
||||
if _, err := os.Stat(temoin); err != nil {
|
||||
t.Error("le modèle précédent a été détruit par une extraction qui a échoué")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigRouteAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
corps := strings.NewReader(`{"model":"parakeet-tdt-0.6b-v2","reactivity":"court"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("POST = %d, corps %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("GET", "/api/dictate/config", nil))
|
||||
var got DictateCfg
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
if got.Model != "parakeet-tdt-0.6b-v2" || got.Reactivity != "court" {
|
||||
t.Errorf("relu %+v, attendu le réglage enregistré", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le refus doit venir de la ROUTE, pas seulement du magasin : sinon l'UI reçoit
|
||||
// un 200 et croit son réglage pris en compte.
|
||||
func TestConfigRouteRefuseModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
corps := strings.NewReader(`{"model":"nawak","reactivity":"moyen"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||
if rec.Code == 200 {
|
||||
t.Errorf("modèle hors catalogue accepté (%d) : %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRouteCorpsIllisible(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", strings.NewReader("pas du json")))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("code = %d, attendu 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsRoute(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateModels(rec, httptest.NewRequest("GET", "/api/dictate/models", nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET = %d", rec.Code)
|
||||
}
|
||||
var l []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &l); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
if len(l) < 2 {
|
||||
t.Errorf("%d modèles renvoyés, au moins 2 attendus", len(l))
|
||||
}
|
||||
}
|
||||
|
||||
// Un identifiant inconnu ne doit pas lancer de téléchargement : asrDownload
|
||||
// bâtirait une URL vide et le message d'erreur n'arriverait qu'en arrière-plan.
|
||||
func TestDownloadRouteRefuseInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
corps := strings.NewReader(`{"id":"nawak"}`)
|
||||
handleDictateDownload(rec, httptest.NewRequest("POST", "/api/dictate/models/download", corps))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("code = %d, attendu 400 pour un modèle inconnu", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRoute(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateState(rec, httptest.NewRequest("GET", "/api/dictate/state", nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET = %d", rec.Code)
|
||||
}
|
||||
var e map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
for _, clef := range []string{"actif", "modele", "present", "device", "dl"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante dans l'état", clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un audio trop court est rejeté AVANT toute tentative de lancer le serveur :
|
||||
// inutile de charger un modèle pour un clic parasite.
|
||||
func TestTranscribeAudioTropCourt(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleTranscribe(rec, httptest.NewRequest("POST", "/api/transcribe", strings.NewReader("court")))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("code = %d, attendu 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
// Supervision du serveur de transcription : le modèle est chargé UNE fois et
|
||||
// reste en mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
|
||||
//
|
||||
// Le moteur est sherpa-onnx (Parakeet, NVIDIA), qui a remplacé whisper.cpp. Ce
|
||||
// qui n'a PAS changé : un processus local supervisé, arrêté après un temps
|
||||
// d'inactivité, redémarré quand les réglages bougent. Ce qui a changé : le
|
||||
// dialogue. whisper-server exposait du HTTP multipart ; sherpa-onnx n'expose
|
||||
// qu'un WebSocket, dont le protocole tient en deux entiers et des flottants
|
||||
// (voir asrInferSur).
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// asrServerBin : le binaire du serveur de transcription. Un seul, contrairement
|
||||
// aux deux binaires whisper d'avant (CPU et CUDA) : le build sherpa-onnx livré
|
||||
// est le statique CPU, il n'a donc aucune dépendance CUDA à satisfaire et
|
||||
// démarre sur n'importe quelle base d'image.
|
||||
func asrServerBin() string {
|
||||
for _, p := range []string{os.Getenv("LOKI_ASR_SERVER"), "/usr/local/bin/sherpa-onnx-offline-websocket-server"} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("sherpa-onnx-offline-websocket-server"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// asrServerArgs. Les arguments de sherpa-onnx s'écrivent --clef=valeur : la
|
||||
// forme séparée par une espace n'est PAS reconnue et le serveur démarre alors
|
||||
// sans modèle.
|
||||
func asrServerArgs(c DictateCfg, port int) ([]string, error) {
|
||||
if asrModelDirFor(c.Model) == "" {
|
||||
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return []string{
|
||||
"--encoder=" + asrModelFile(c.Model, "encoder.int8.onnx"),
|
||||
"--decoder=" + asrModelFile(c.Model, "decoder.int8.onnx"),
|
||||
"--joiner=" + asrModelFile(c.Model, "joiner.int8.onnx"),
|
||||
"--tokens=" + asrModelFile(c.Model, "tokens.txt"),
|
||||
// nemo_transducer : sans ce type, les sorties du modèle sont mal
|
||||
// interprétées et la transcription est du bruit, pas une erreur.
|
||||
"--model-type=nemo_transducer",
|
||||
"--port=" + strconv.Itoa(port),
|
||||
// Le serveur n'a AUCUNE authentification. Il n'écoute donc que la boucle
|
||||
// locale : sur un conteneur au réseau partagé, l'exposer offrirait la
|
||||
// transcription à tout le réseau.
|
||||
"--host=127.0.0.1",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// asrServerEnv masque tous les GPU, en REMPLAÇANT toute valeur déjà présente.
|
||||
// Une variable dupliquée laisse le gagnant dépendre de la libc. Le binaire livré
|
||||
// est CPU de toute façon ; la variable est là pour que ça reste vrai si l'image
|
||||
// venait à embarquer un build GPU sans qu'on repense à la dictée.
|
||||
func asrServerEnv(base []string) []string {
|
||||
const clef = "CUDA_VISIBLE_DEVICES="
|
||||
out := make([]string, 0, len(base)+1)
|
||||
for _, v := range base {
|
||||
if !strings.HasPrefix(v, clef) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return append(out, clef)
|
||||
}
|
||||
|
||||
// portLibre demande au système un port disponible puis le relâche. Coder un
|
||||
// port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre
|
||||
// la fermeture et la reprise par le serveur est négligeable devant le risque
|
||||
// d'un conflit permanent.
|
||||
func portLibre() (int, error) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = l.Close() }()
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
|
||||
// asrMarqueur reconnaît ce qui n'est pas de la parole dictée : le vide, et les
|
||||
// annotations entre crochets ou parenthèses. Parakeet en produit beaucoup moins
|
||||
// que whisper, mais le filtre ne coûte rien et un « (silence) » collé dans le
|
||||
// champ de saisie se remarque tout de suite.
|
||||
func asrMarqueur(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
|
||||
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
|
||||
}
|
||||
|
||||
// ─── Protocole ───────────────────────────────────────────────────────────────
|
||||
|
||||
// asrEnteteOctets : l'en-tête du serveur sherpa-onnx, deux entiers 32 bits en
|
||||
// petit-boutien — la fréquence d'échantillonnage, puis le NOMBRE D'OCTETS
|
||||
// d'audio annoncé. Le serveur décode dès qu'il a reçu exactement ce nombre : un
|
||||
// compte faux, et il attend indéfiniment de quoi finir.
|
||||
const asrEnteteOctets = 8
|
||||
|
||||
// asrTrameMax borne la taille d'une trame WebSocket envoyée. Le serveur
|
||||
// reconstitue l'utterance à partir du compte annoncé, donc le découpage est
|
||||
// libre — mais pas la TAILLE : une pile WebSocket refuse par défaut les messages
|
||||
// au-delà de quelques dizaines de kilooctets et ferme la connexion au lieu de
|
||||
// répondre. 10 Ko est la valeur qu'emploie le client de référence de
|
||||
// sherpa-onnx ; on s'aligne dessus plutôt que de parier sur une limite.
|
||||
const asrTrameMax = 10240
|
||||
|
||||
// asrInferSur prend l'adresse du serveur en paramètre : c'est ce qui rend
|
||||
// l'appel testable sans lancer de processus.
|
||||
//
|
||||
// Le protocole, en entier : on ouvre une connexion, on envoie UNE trame binaire
|
||||
// contenant l'en-tête suivi des échantillons en float32 petit-boutien, et on lit
|
||||
// UNE trame texte qui porte le résultat en JSON.
|
||||
func asrInferSur(ctx context.Context, adresse string, wav []byte) (string, error) {
|
||||
taux, ech, err := wavVersFloat32(wav)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ech) == 0 {
|
||||
return "", nil // rien à transcrire : ce n'est pas une erreur
|
||||
}
|
||||
c, _, err := websocket.Dial(ctx, adresse, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connexion au serveur de dictée : %w", err)
|
||||
}
|
||||
defer func() { _ = c.CloseNow() }()
|
||||
// Une transcription peut légitimement dépasser la taille par défaut acceptée
|
||||
// en lecture ; on ne borne pas ce que le serveur nous renvoie.
|
||||
c.SetReadLimit(-1)
|
||||
|
||||
corps := make([]byte, asrEnteteOctets+4*len(ech))
|
||||
binary.LittleEndian.PutUint32(corps[0:4], uint32(taux))
|
||||
binary.LittleEndian.PutUint32(corps[4:8], uint32(4*len(ech)))
|
||||
for i, v := range ech {
|
||||
binary.LittleEndian.PutUint32(corps[asrEnteteOctets+4*i:], math.Float32bits(v))
|
||||
}
|
||||
for off := 0; off < len(corps); off += asrTrameMax {
|
||||
fin := off + asrTrameMax
|
||||
if fin > len(corps) {
|
||||
fin = len(corps)
|
||||
}
|
||||
if err := c.Write(ctx, websocket.MessageBinary, corps[off:fin]); err != nil {
|
||||
return "", fmt.Errorf("envoi de l'audio : %w", err)
|
||||
}
|
||||
}
|
||||
_, brut, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lecture du résultat : %w", err)
|
||||
}
|
||||
var j struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(brut, &j); err != nil {
|
||||
return "", fmt.Errorf("réponse illisible du serveur de dictée : %w", err)
|
||||
}
|
||||
if asrMarqueur(j.Text) {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(j.Text), nil
|
||||
}
|
||||
|
||||
// wavVersFloat32 lit le WAV produit par le navigateur (16 kHz mono) et rend ses
|
||||
// échantillons normalisés dans [-1, 1], ce qu'attend sherpa-onnx.
|
||||
//
|
||||
// On parcourt les blocs plutôt que de sauter à un offset fixe : un WAV n'a pas
|
||||
// d'en-tête de taille garantie (« LIST », « fact » et consorts s'intercalent
|
||||
// avant « data »), et l'offset 44 codé en dur transformerait ces octets en
|
||||
// craquement au début de chaque phrase.
|
||||
func wavVersFloat32(wav []byte) (taux int, ech []float32, err error) {
|
||||
if len(wav) < 12 || string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
|
||||
return 0, nil, errors.New("audio illisible : ce n'est pas un WAV")
|
||||
}
|
||||
var canaux, bits, format int
|
||||
pos := 12
|
||||
for pos+8 <= len(wav) {
|
||||
id := string(wav[pos : pos+4])
|
||||
taille := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8]))
|
||||
corps := pos + 8
|
||||
if taille < 0 || corps+taille > len(wav) {
|
||||
// Bloc annoncé plus grand que le fichier : on prend ce qui reste
|
||||
// plutôt que d'échouer — un enregistrement coupé net reste utile.
|
||||
taille = len(wav) - corps
|
||||
}
|
||||
switch id {
|
||||
case "fmt ":
|
||||
if taille < 16 {
|
||||
return 0, nil, errors.New("audio illisible : bloc fmt tronqué")
|
||||
}
|
||||
format = int(binary.LittleEndian.Uint16(wav[corps : corps+2]))
|
||||
canaux = int(binary.LittleEndian.Uint16(wav[corps+2 : corps+4]))
|
||||
taux = int(binary.LittleEndian.Uint32(wav[corps+4 : corps+8]))
|
||||
bits = int(binary.LittleEndian.Uint16(wav[corps+14 : corps+16]))
|
||||
case "data":
|
||||
if canaux <= 0 || taux <= 0 {
|
||||
return 0, nil, errors.New("audio illisible : bloc data avant fmt")
|
||||
}
|
||||
ech, err = pcmVersFloat32(wav[corps:corps+taille], format, bits, canaux)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return taux, ech, nil
|
||||
}
|
||||
// Les blocs sont alignés sur un nombre pair d'octets.
|
||||
pos = corps + taille + taille%2
|
||||
}
|
||||
return 0, nil, errors.New("audio illisible : aucun bloc data")
|
||||
}
|
||||
|
||||
// pcmVersFloat32 convertit les échantillons bruts. Les canaux sont MOYENNÉS
|
||||
// plutôt que de garder le premier : un navigateur qui livrerait du stéréo par
|
||||
// erreur donnerait sinon un canal muet une fois sur deux.
|
||||
func pcmVersFloat32(data []byte, format, bits, canaux int) ([]float32, error) {
|
||||
const pcmEntier, pcmFlottant = 1, 3
|
||||
switch {
|
||||
case format == pcmEntier && bits == 16:
|
||||
n := len(data) / 2 / canaux
|
||||
out := make([]float32, n)
|
||||
for i := 0; i < n; i++ {
|
||||
somme := 0.0
|
||||
for c := 0; c < canaux; c++ {
|
||||
v := int16(binary.LittleEndian.Uint16(data[2*(i*canaux+c):]))
|
||||
somme += float64(v) / 32768
|
||||
}
|
||||
out[i] = float32(somme / float64(canaux))
|
||||
}
|
||||
return out, nil
|
||||
case format == pcmFlottant && bits == 32:
|
||||
n := len(data) / 4 / canaux
|
||||
out := make([]float32, n)
|
||||
for i := 0; i < n; i++ {
|
||||
somme := 0.0
|
||||
for c := 0; c < canaux; c++ {
|
||||
somme += float64(math.Float32frombits(binary.LittleEndian.Uint32(data[4*(i*canaux+c):])))
|
||||
}
|
||||
out[i] = float32(somme / float64(canaux))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("format audio non géré (format %d, %d bits) — attendu PCM 16 bits", format, bits)
|
||||
}
|
||||
|
||||
// ─── Supervision ─────────────────────────────────────────────────────────────
|
||||
|
||||
// asrIdle : au-delà, le serveur s'éteint et rend sa mémoire. Une dictée n'est
|
||||
// pas un service permanent ; garder un modèle chargé toute la journée pour
|
||||
// quelques phrases prive la machine de mémoire vive.
|
||||
const asrIdle = 10 * time.Minute
|
||||
|
||||
// asrReady : plafond d'attente au démarrage. Un modèle de 600 Mo lu depuis un
|
||||
// disque lent prend du temps ; échouer trop tôt afficherait une panne là où il
|
||||
// n'y a qu'une lenteur.
|
||||
const asrReady = 120 * time.Second
|
||||
|
||||
var wsrvMu sync.Mutex
|
||||
|
||||
// wsrvDemarrage : le processus en cours de démarrage, visible SANS le verrou.
|
||||
// asrStartLocked garde wsrvMu pendant tout le chargement du modèle ; c'est la
|
||||
// seule poignée qu'un autre appelant (asrShutdownVite) a sur lui pour
|
||||
// l'interrompre. Nil hors démarrage.
|
||||
var wsrvDemarrage atomic.Pointer[exec.Cmd]
|
||||
|
||||
var wsrv struct {
|
||||
cmd *exec.Cmd
|
||||
port int
|
||||
cfg DictateCfg
|
||||
log *bytes.Buffer
|
||||
timer *time.Timer
|
||||
depuis time.Time
|
||||
}
|
||||
|
||||
// asrEnsure garantit un serveur vivant pour la configuration COURANTE, et
|
||||
// renvoie son port. Si les réglages ont changé depuis le lancement, l'ancien
|
||||
// est arrêté : c'est ce qui rend un changement de modèle effectif sans
|
||||
// redémarrer Loki.
|
||||
func asrEnsure() (int, error) {
|
||||
cfg := dictateCfgLoad()
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
|
||||
asrTouchLocked()
|
||||
return wsrv.port, nil
|
||||
}
|
||||
asrStopLocked()
|
||||
return asrStartLocked(cfg)
|
||||
}
|
||||
|
||||
func asrStartLocked(cfg DictateCfg) (int, error) {
|
||||
if !asrModelPresent(cfg.Model) {
|
||||
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
|
||||
}
|
||||
bin := asrServerBin()
|
||||
if bin == "" {
|
||||
return 0, fmt.Errorf("serveur de dictée introuvable (image à reconstruire)")
|
||||
}
|
||||
port, err := portLibre()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aucun port libre pour le serveur de dictée : %w", err)
|
||||
}
|
||||
args, err := asrServerArgs(cfg, port)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
log := &bytes.Buffer{}
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = asrServerEnv(os.Environ())
|
||||
cmd.Stdout, cmd.Stderr = log, log
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("démarrage du serveur de dictée : %w", err)
|
||||
}
|
||||
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, cfg
|
||||
wsrv.log, wsrv.depuis = log, time.Now()
|
||||
wsrvDemarrage.Store(cmd)
|
||||
err = asrAttendPret(cmd, port, log)
|
||||
wsrvDemarrage.Store(nil)
|
||||
if err != nil {
|
||||
asrStopLocked()
|
||||
return 0, err
|
||||
}
|
||||
asrTouchLocked()
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// asrAttendPret sonde le serveur jusqu'à ce qu'il accepte une connexion. Le
|
||||
// serveur sherpa-onnx construit son moteur AVANT d'ouvrir sa socket : le simple
|
||||
// fait qu'elle réponde signifie donc que le modèle est chargé — inutile de lui
|
||||
// envoyer un audio de test.
|
||||
//
|
||||
// Si le processus meurt entre-temps, on rapporte COMMENT il est mort : sur une
|
||||
// mort par signal, la dernière ligne du journal est celle d'avant le coup fatal —
|
||||
// elle ressemble à une explication sans en être une.
|
||||
func asrAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
|
||||
fin := time.Now().Add(asrReady)
|
||||
adr := "127.0.0.1:" + strconv.Itoa(port)
|
||||
mort := make(chan error, 1)
|
||||
go func() { mort <- cmd.Wait() }()
|
||||
for time.Now().Before(fin) {
|
||||
select {
|
||||
case err := <-mort:
|
||||
msg := asrExitReason(err)
|
||||
if l := lastLine(log.String()); l != "" {
|
||||
msg += " — dernière sortie : " + l
|
||||
}
|
||||
return fmt.Errorf("le serveur de dictée s'est arrêté au démarrage : %s", msg)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", adr, 2*time.Second)
|
||||
if err == nil {
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("le serveur de dictée n'a pas répondu en %s", asrReady)
|
||||
}
|
||||
|
||||
func asrTouchLocked() {
|
||||
if wsrv.timer != nil {
|
||||
wsrv.timer.Stop()
|
||||
}
|
||||
wsrv.timer = time.AfterFunc(asrIdle, asrShutdown)
|
||||
}
|
||||
|
||||
func asrStopLocked() {
|
||||
if wsrv.timer != nil {
|
||||
wsrv.timer.Stop()
|
||||
wsrv.timer = nil
|
||||
}
|
||||
if wsrv.cmd != nil && wsrv.cmd.Process != nil {
|
||||
_ = wsrv.cmd.Process.Kill()
|
||||
}
|
||||
wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil
|
||||
wsrv.cfg = DictateCfg{}
|
||||
}
|
||||
|
||||
// asrShutdown arrête le serveur. Appelé par la minuterie d'inactivité et à
|
||||
// chaque changement de réglages — sans ça, le serveur continuerait de tourner
|
||||
// avec l'ancien modèle et le nouveau réglage semblerait sans effet.
|
||||
func asrShutdown() {
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
asrStopLocked()
|
||||
}
|
||||
|
||||
// asrShutdownVite arrête le serveur sans jamais attendre derrière un démarrage.
|
||||
// asrShutdown prend wsrvMu, et asrEnsure le garde pendant tout le chargement du
|
||||
// modèle — jusqu'à asrReady (deux minutes) depuis un disque lent. Appelé par un
|
||||
// geste de l'interface, ce blocage ferait abandonner le navigateur alors que le
|
||||
// moteur, lui, est déjà arrêté.
|
||||
//
|
||||
// Si le verrou est pris, on tue le processus en train de démarrer : sa mort fait
|
||||
// sortir asrAttendPret, le verrou se libère, et on finit le ménage par la voie
|
||||
// normale. La dictée qui attendait reçoit une erreur de démarrage — c'est
|
||||
// l'utilisateur qui vient de demander la place, pas une panne.
|
||||
func asrShutdownVite() {
|
||||
for i := 0; i < 20; i++ {
|
||||
if wsrvMu.TryLock() {
|
||||
asrStopLocked()
|
||||
wsrvMu.Unlock()
|
||||
return
|
||||
}
|
||||
if cmd := wsrvDemarrage.Load(); cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// asrInfer garantit le serveur puis lui soumet le WAV.
|
||||
func asrInfer(ctx context.Context, wav []byte) (string, error) {
|
||||
port, err := asrEnsure()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(port), wav)
|
||||
}
|
||||
|
||||
func asrEtat() map[string]any {
|
||||
cfg := dictateCfgLoad()
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
etat := map[string]any{
|
||||
"actif": wsrv.cmd != nil,
|
||||
"modele": cfg.Model,
|
||||
"present": asrModelPresent(cfg.Model),
|
||||
// Le moteur livré est le build statique CPU (voir asrServerBin) : le dire
|
||||
// vaut mieux que laisser chercher un réglage de carte qui n'existe pas.
|
||||
"device": "cpu",
|
||||
}
|
||||
if wsrv.cmd != nil {
|
||||
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
|
||||
}
|
||||
return etat
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// wavTest fabrique un WAV 16 kHz mono PCM 16 bits, comme celui que le
|
||||
// navigateur envoie.
|
||||
func wavTest(ech []int16, taux int) []byte {
|
||||
data := make([]byte, 2*len(ech))
|
||||
for i, v := range ech {
|
||||
binary.LittleEndian.PutUint16(data[2*i:], uint16(v))
|
||||
}
|
||||
b := make([]byte, 0, 44+len(data))
|
||||
ajout32 := func(v uint32) {
|
||||
var x [4]byte
|
||||
binary.LittleEndian.PutUint32(x[:], v)
|
||||
b = append(b, x[:]...)
|
||||
}
|
||||
ajout16 := func(v uint16) {
|
||||
var x [2]byte
|
||||
binary.LittleEndian.PutUint16(x[:], v)
|
||||
b = append(b, x[:]...)
|
||||
}
|
||||
b = append(b, "RIFF"...)
|
||||
ajout32(uint32(36 + len(data)))
|
||||
b = append(b, "WAVE"...)
|
||||
b = append(b, "fmt "...)
|
||||
ajout32(16)
|
||||
ajout16(1) // PCM entier
|
||||
ajout16(1) // mono
|
||||
ajout32(uint32(taux))
|
||||
ajout32(uint32(taux * 2))
|
||||
ajout16(2) // alignement de bloc
|
||||
ajout16(16) // bits par échantillon
|
||||
b = append(b, "data"...)
|
||||
ajout32(uint32(len(data)))
|
||||
return append(b, data...)
|
||||
}
|
||||
|
||||
func TestServerArgsFormeCleValeur(t *testing.T) {
|
||||
testHome(t)
|
||||
args, err := asrServerArgs(DictateCfg{Model: "parakeet-tdt-0.6b-v3"}, 4242)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joint := strings.Join(args, " ")
|
||||
// sherpa-onnx ne reconnaît QUE --clef=valeur. La forme séparée par une
|
||||
// espace le laisse démarrer sans modèle, sans se plaindre.
|
||||
for _, a := range args {
|
||||
if !strings.HasPrefix(a, "--") || !strings.Contains(a, "=") {
|
||||
t.Errorf("argument %q : sherpa-onnx exige la forme --clef=valeur", a)
|
||||
}
|
||||
}
|
||||
for _, attendu := range []string{"--model-type=nemo_transducer", "--host=127.0.0.1", "--port=4242"} {
|
||||
if !strings.Contains(joint, attendu) {
|
||||
t.Errorf("argument manquant : %s\nobtenu : %s", attendu, joint)
|
||||
}
|
||||
}
|
||||
// Les quatre fichiers du modèle, sinon le serveur démarre puis meurt.
|
||||
for _, f := range asrFichiers {
|
||||
if !strings.Contains(joint, f) {
|
||||
t.Errorf("le fichier %s du modèle n'est pas passé au serveur", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerArgsRefuseModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if _, err := asrServerArgs(DictateCfg{Model: "nawak"}, 1234); err == nil {
|
||||
t.Error("un modèle hors catalogue doit être refusé avant le lancement")
|
||||
}
|
||||
}
|
||||
|
||||
// Le serveur ne doit jamais prendre une carte : le binaire livré est le build
|
||||
// CPU, et la VRAM appartient au moteur de chat.
|
||||
func TestServerEnvMasqueLesGPU(t *testing.T) {
|
||||
got := asrServerEnv([]string{"PATH=/bin", "CUDA_VISIBLE_DEVICES=0,1", "HOME=/root"})
|
||||
n := 0
|
||||
for _, v := range got {
|
||||
if strings.HasPrefix(v, "CUDA_VISIBLE_DEVICES=") {
|
||||
n++
|
||||
if v != "CUDA_VISIBLE_DEVICES=" {
|
||||
t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu vide", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Une variable en double laisse le gagnant dépendre de la libc.
|
||||
if n != 1 {
|
||||
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
|
||||
}
|
||||
if !contientTout(got, "PATH=/bin", "HOME=/root") {
|
||||
t.Error("le reste de l'environnement doit être conservé")
|
||||
}
|
||||
}
|
||||
|
||||
func contientTout(l []string, vals ...string) bool {
|
||||
for _, v := range vals {
|
||||
trouve := false
|
||||
for _, x := range l {
|
||||
if x == v {
|
||||
trouve = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !trouve {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestPortLibre(t *testing.T) {
|
||||
p, err := portLibre()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p < 1024 || p > 65535 {
|
||||
t.Errorf("port = %d, hors de la plage utilisable", p)
|
||||
}
|
||||
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
|
||||
if err != nil {
|
||||
t.Fatalf("port %d annoncé libre mais inutilisable : %v", p, err)
|
||||
}
|
||||
_ = l.Close()
|
||||
}
|
||||
|
||||
// ─── Conversion WAV → float32 ────────────────────────────────────────────────
|
||||
|
||||
// L'offset 44 codé en dur est le piège classique : un WAV n'a pas d'en-tête de
|
||||
// taille garantie, et des blocs (LIST, fact…) s'intercalent avant `data`. Les
|
||||
// prendre pour de l'audio produit un craquement au début de chaque phrase.
|
||||
func TestWavIgnoreLesBlocsIntercales(t *testing.T) {
|
||||
base := wavTest([]int16{0, 16384, -16384, 32767}, 16000)
|
||||
// Insère un bloc « LIST » de 6 octets juste après l'en-tête RIFF/WAVE.
|
||||
extra := append([]byte("LIST"), 6, 0, 0, 0, 'I', 'N', 'F', 'O', 'x', 'y')
|
||||
avec := append(append(append([]byte{}, base[:12]...), extra...), base[12:]...)
|
||||
binary.LittleEndian.PutUint32(avec[4:8], uint32(len(avec)-8))
|
||||
|
||||
taux, ech, err := wavVersFloat32(avec)
|
||||
if err != nil {
|
||||
t.Fatalf("wavVersFloat32 : %v", err)
|
||||
}
|
||||
if taux != 16000 {
|
||||
t.Errorf("taux = %d, attendu 16000", taux)
|
||||
}
|
||||
if len(ech) != 4 {
|
||||
t.Fatalf("%d échantillons, attendu 4 — un bloc intercalé a été pris pour de l'audio", len(ech))
|
||||
}
|
||||
if ech[0] != 0 || math.Abs(float64(ech[1])-0.5) > 0.001 {
|
||||
t.Errorf("échantillons mal normalisés : %v", ech)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWavRefuseCeQuiNEnEstPas(t *testing.T) {
|
||||
for nom, in := range map[string][]byte{
|
||||
"vide": {},
|
||||
"pas un WAV": []byte("ceci n'est pas un fichier audio du tout"),
|
||||
"sans data": append([]byte("RIFF\x00\x00\x00\x00WAVE"), "fmt "...),
|
||||
} {
|
||||
if _, _, err := wavVersFloat32(in); err == nil {
|
||||
t.Errorf("%s : accepté alors qu'il est illisible", nom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le stéréo doit être MOYENNÉ, pas réduit au premier canal : un navigateur qui
|
||||
// livrerait deux canaux donnerait sinon un silence une fois sur deux.
|
||||
func TestPcmStereoMoyenne(t *testing.T) {
|
||||
data := make([]byte, 8) // deux trames stéréo
|
||||
binary.LittleEndian.PutUint16(data[0:], uint16(int16(0)))
|
||||
binary.LittleEndian.PutUint16(data[2:], uint16(int16(32767)))
|
||||
binary.LittleEndian.PutUint16(data[4:], uint16(0x8000)) // -32768
|
||||
binary.LittleEndian.PutUint16(data[6:], uint16(int16(32767)))
|
||||
ech, err := pcmVersFloat32(data, 1, 16, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ech) != 2 {
|
||||
t.Fatalf("%d échantillons, attendu 2", len(ech))
|
||||
}
|
||||
if math.Abs(float64(ech[0])-0.5) > 0.01 {
|
||||
t.Errorf("premier échantillon = %v, attendu la moyenne des deux canaux (~0,5)", ech[0])
|
||||
}
|
||||
if math.Abs(float64(ech[1])) > 0.01 {
|
||||
t.Errorf("second échantillon = %v, attendu ~0 (canaux opposés)", ech[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcmFormatNonGere(t *testing.T) {
|
||||
if _, err := pcmVersFloat32(make([]byte, 8), 1, 24, 1); err == nil {
|
||||
t.Error("un PCM 24 bits doit être refusé explicitement, pas décodé de travers")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Protocole WebSocket ─────────────────────────────────────────────────────
|
||||
|
||||
// serveurASRFactice rejoue le protocole de sherpa-onnx : il lit l'en-tête et
|
||||
// les échantillons, puis répond en JSON. Il vérifie au passage que le client
|
||||
// annonce le BON nombre d'octets — un compte faux et le vrai serveur attend
|
||||
// indéfiniment de quoi finir, sans jamais répondre.
|
||||
func serveurASRFactice(t *testing.T, texte string) (adresse string, arret func(), recu *struct {
|
||||
Taux int
|
||||
N int
|
||||
}) {
|
||||
t.Helper()
|
||||
recu = &struct {
|
||||
Taux int
|
||||
N int
|
||||
}{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.CloseNow() }()
|
||||
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
|
||||
// il ne borne pas la taille des trames recues, ce test non plus.
|
||||
c.SetReadLimit(-1)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
var buf []byte
|
||||
for {
|
||||
_, b, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf = append(buf, b...)
|
||||
if len(buf) < asrEnteteOctets {
|
||||
continue
|
||||
}
|
||||
recu.Taux = int(binary.LittleEndian.Uint32(buf[0:4]))
|
||||
attendu := int(binary.LittleEndian.Uint32(buf[4:8]))
|
||||
if len(buf)-asrEnteteOctets < attendu {
|
||||
continue
|
||||
}
|
||||
recu.N = attendu / 4
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"text":"`+texte+`"}`))
|
||||
return
|
||||
}
|
||||
}))
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http"), srv.Close, recu
|
||||
}
|
||||
|
||||
func TestInferEnvoieLeBonEnTete(t *testing.T) {
|
||||
adresse, arret, recu := serveurASRFactice(t, " bonjour ceci est un test ")
|
||||
defer arret()
|
||||
|
||||
wav := wavTest(make([]int16, 800), 16000)
|
||||
txt, err := asrInferSur(context.Background(), adresse, wav)
|
||||
if err != nil {
|
||||
t.Fatalf("asrInferSur : %v", err)
|
||||
}
|
||||
if txt != "bonjour ceci est un test" {
|
||||
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
|
||||
}
|
||||
if recu.Taux != 16000 {
|
||||
t.Errorf("taux annoncé = %d, attendu 16000", recu.Taux)
|
||||
}
|
||||
if recu.N != 800 {
|
||||
t.Errorf("%d échantillons annoncés, attendu 800 — un compte faux fige le serveur", recu.N)
|
||||
}
|
||||
}
|
||||
|
||||
// Un audio plus gros qu'une trame doit arriver ENTIER : le serveur recolle les
|
||||
// morceaux à partir du compte annoncé, encore faut-il tout envoyer.
|
||||
func TestInferDecoupeLesGrosEnvois(t *testing.T) {
|
||||
adresse, arret, recu := serveurASRFactice(t, "ok")
|
||||
defer arret()
|
||||
|
||||
n := asrTrameMax // > une trame une fois converti en float32
|
||||
if _, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, n), 16000)); err != nil {
|
||||
t.Fatalf("asrInferSur : %v", err)
|
||||
}
|
||||
if recu.N != n {
|
||||
t.Errorf("%d échantillons reçus, attendu %d", recu.N, n)
|
||||
}
|
||||
}
|
||||
|
||||
// Sur du silence, le moteur peut rendre une annotation plutôt qu'un texte. La
|
||||
// laisser passer la collerait telle quelle dans le champ de saisie.
|
||||
func TestInferFiltreLesMarqueurs(t *testing.T) {
|
||||
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
|
||||
adresse, arret, _ := serveurASRFactice(t, marqueur)
|
||||
txt, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, 100), 16000))
|
||||
arret()
|
||||
if err != nil {
|
||||
t.Fatalf("asrInferSur(%q) : %v", marqueur, err)
|
||||
}
|
||||
if txt != "" {
|
||||
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferServeurInjoignable(t *testing.T) {
|
||||
p, err := portLibre()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(p), wavTest(make([]int16, 100), 16000))
|
||||
if err == nil {
|
||||
t.Error("un serveur injoignable doit remonter une erreur")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferReponseIllisible(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.CloseNow() }()
|
||||
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
|
||||
// il ne borne pas la taille des trames recues, ce test non plus.
|
||||
c.SetReadLimit(-1)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte("ceci n'est pas du JSON"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := asrInferSur(context.Background(), "ws"+strings.TrimPrefix(srv.URL, "http"), wavTest(make([]int16, 100), 16000))
|
||||
if err == nil {
|
||||
t.Error("une réponse illisible doit remonter une erreur")
|
||||
}
|
||||
}
|
||||
|
||||
// Un audio vide n'est pas une panne : on rend une transcription vide sans même
|
||||
// ouvrir de connexion.
|
||||
func TestInferAudioVide(t *testing.T) {
|
||||
txt, err := asrInferSur(context.Background(), "ws://127.0.0.1:1", wavTest(nil, 16000))
|
||||
if err != nil {
|
||||
t.Fatalf("un WAV sans échantillon ne doit pas être une erreur : %v", err)
|
||||
}
|
||||
if txt != "" {
|
||||
t.Errorf("texte = %q, attendu vide", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Supervision ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Un modèle absent doit être annoncé comme tel, pas produire un lancement du
|
||||
// serveur voué à mourir sur un fichier introuvable.
|
||||
func TestEnsureModeleAbsent(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "parakeet-tdt-0.6b-v3", Reactivity: "moyen"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := asrEnsure()
|
||||
if err == nil {
|
||||
t.Fatal("un modèle absent doit produire une erreur")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absent") {
|
||||
t.Errorf("erreur = %q, elle doit dire que le modèle est absent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEtatSansServeur(t *testing.T) {
|
||||
testHome(t)
|
||||
e := asrEtat()
|
||||
if e["actif"] != false {
|
||||
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
|
||||
}
|
||||
for _, clef := range []string{"modele", "present", "device"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
|
||||
}
|
||||
}
|
||||
// Le binaire livré est CPU : l'état ne doit pas laisser croire à une carte.
|
||||
if e["device"] != "cpu" {
|
||||
t.Errorf("device = %v, attendu \"cpu\"", e["device"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEtatSerialisable(t *testing.T) {
|
||||
testHome(t)
|
||||
if _, err := json.Marshal(asrEtat()); err != nil {
|
||||
t.Fatalf("l'état doit être sérialisable pour /api/dictate/state : %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package loki
|
||||
|
||||
// llm_budget.go — budget SOUPLE d'appels d'outils par tour.
|
||||
//
|
||||
// Le plafond dur (TOOL_LIMIT) et l'anti-boucle ont été retirés en v0.6.3 : ils
|
||||
// coupaient des tours parfaitement légitimes, une recherche enchaînant sans
|
||||
// problème des dizaines d'appels. Mais il ne restait plus RIEN entre « le
|
||||
// modèle converge » et « le modèle tourne en rond pendant une heure » — vu en
|
||||
// production : ~50 appels d'outil, 55 minutes, le modèle relisant cinq fois les
|
||||
// mêmes fonctions pour re-dériver une conclusion qu'il avait déjà écrite.
|
||||
//
|
||||
// La déduplication d'appels identiques (repeatedCallResult) ne rattrape pas ce
|
||||
// cas : sa clé est « nom + arguments bruts », et relire le même fichier par
|
||||
// tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés
|
||||
// différentes. Élargir cette clé à la CIBLE serait pire que le mal — deux
|
||||
// tranches d'un même fichier renvoient un contenu différent, les confondre
|
||||
// casserait toute lecture paginée légitime.
|
||||
//
|
||||
// D'où un budget qui ne coupe rien : au-delà d'un palier, on RAPPELLE au modèle
|
||||
// combien d'appels il a déjà faits et on lui demande de conclure. Le tour reste
|
||||
// entièrement sous son contrôle — c'est de la pression, pas une barrière.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// agentBudgetDefault : nombre d'appels d'outils au-delà duquel un tour cesse
|
||||
// d'être une recherche et commence à ressembler à du sur-place. Assez haut pour
|
||||
// qu'une vraie enquête (lire dix fichiers, croiser trois pages web) passe sans
|
||||
// jamais voir le rappel.
|
||||
const agentBudgetDefault = 24
|
||||
|
||||
// agentBudget lit le palier dans config.env (AGENT_BUDGET). 0 — ou une valeur
|
||||
// off/false/no/non — désactive complètement le mécanisme.
|
||||
func agentBudget() int {
|
||||
v := strings.ToLower(strings.TrimSpace(ReadConfig()["AGENT_BUDGET"]))
|
||||
switch v {
|
||||
case "":
|
||||
return agentBudgetDefault
|
||||
case "off", "false", "no", "non", "disable", "disabled":
|
||||
return 0
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
return agentBudgetDefault
|
||||
}
|
||||
|
||||
// budgetNudge renvoie le rappel à injecter quand le tour vient de franchir un
|
||||
// palier, ou "" s'il n'y a rien à dire.
|
||||
//
|
||||
// calls : appels d'outils exécutés depuis le début du tour
|
||||
// budget : taille d'un palier (0 = mécanisme désactivé)
|
||||
// sent : rappels déjà envoyés dans ce tour
|
||||
//
|
||||
// Les paliers sont à budget, 2×budget, 3×budget… : la pression revient donc
|
||||
// régulièrement au lieu de s'éteindre après un avertissement ignoré. Le coût en
|
||||
// contexte est négligeable (une phrase toutes les 24 requêtes) et le message est
|
||||
// AJOUTÉ EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté
|
||||
// llama-server.
|
||||
//
|
||||
// Le texte est en anglais comme le reste du protocole d'outils : c'est la langue
|
||||
// dans laquelle les modèles suivent le mieux une consigne impérative, quelle que
|
||||
// soit celle de la conversation.
|
||||
func budgetNudge(calls, budget, sent int) string {
|
||||
if budget <= 0 || calls < budget*(sent+1) {
|
||||
return ""
|
||||
}
|
||||
n := fmt.Sprintf("%d tool calls", calls)
|
||||
switch sent {
|
||||
case 0:
|
||||
return "[system] You have made " + n + " in this single turn. That is a lot: you almost certainly have enough to answer already. " +
|
||||
"Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing."
|
||||
case 1:
|
||||
return "[system] " + n + " now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. " +
|
||||
"Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further."
|
||||
default:
|
||||
return "[system] " + n + ". Stop. Write your final answer in this message. Do not call another tool."
|
||||
}
|
||||
}
|
||||
|
||||
// logBudget trace UNE ligne par rappel sur la sortie d'erreur (donc dans
|
||||
// `journalctl -u loki-ui`), comme logCompact. Sans ça, un tour qui part en
|
||||
// vrille est invisible côté serveur : on ne voit qu'une génération qui dure.
|
||||
func logBudget(calls, budget, sent int) {
|
||||
fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n",
|
||||
calls, budget, sent)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Le budget ne se déclenche pas avant son palier, et il revient à chaque
|
||||
// palier suivant : un modèle qui ignore le premier rappel doit en recevoir un
|
||||
// second, plus ferme.
|
||||
func TestBudgetNudgePaliers(t *testing.T) {
|
||||
const budget = 24
|
||||
cases := []struct {
|
||||
calls, sent int
|
||||
want bool
|
||||
}{
|
||||
{0, 0, false},
|
||||
{1, 0, false},
|
||||
{23, 0, false},
|
||||
{24, 0, true}, // 1er palier
|
||||
{40, 1, false}, // rappel déjà envoyé, palier suivant pas atteint
|
||||
{48, 1, true}, // 2e palier
|
||||
{72, 2, true}, // 3e palier
|
||||
{95, 3, false},
|
||||
{96, 3, true}, // 4e palier : la pression ne s'éteint pas
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := budgetNudge(c.calls, budget, c.sent) != ""
|
||||
if got != c.want {
|
||||
t.Errorf("budgetNudge(calls=%d, sent=%d) = %v, attendu %v", c.calls, c.sent, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le message durcit à chaque rappel : trois textes distincts, tous porteurs du
|
||||
// nombre d'appels (c'est LUI qui informe le modèle, pas le ton).
|
||||
func TestBudgetNudgeEscalade(t *testing.T) {
|
||||
const budget = 10
|
||||
seen := map[string]bool{}
|
||||
for sent := 0; sent < 3; sent++ {
|
||||
m := budgetNudge(budget*(sent+1), budget, sent)
|
||||
if m == "" {
|
||||
t.Fatalf("rappel n°%d vide", sent+1)
|
||||
}
|
||||
if seen[m] {
|
||||
t.Errorf("rappel n°%d identique à un précédent : %q", sent+1, m)
|
||||
}
|
||||
seen[m] = true
|
||||
}
|
||||
// Au-delà, on réutilise le texte le plus ferme plutôt que d'en inventer un
|
||||
// nouveau à chaque palier (à nombre d'appels égal, le message est le même).
|
||||
if a, b := budgetNudge(50, budget, 3), budgetNudge(50, budget, 4); a != b {
|
||||
t.Errorf("les rappels tardifs devraient partager le même texte :\n%q\n%q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// budget=0 (AGENT_BUDGET=off) : plus aucun rappel, quel que soit le nombre
|
||||
// d'appels — le mécanisme doit pouvoir être totalement coupé.
|
||||
func TestBudgetNudgeDesactive(t *testing.T) {
|
||||
for _, calls := range []int{0, 24, 500, 10000} {
|
||||
if m := budgetNudge(calls, 0, 0); m != "" {
|
||||
t.Errorf("budget désactivé mais rappel émis à %d appels : %q", calls, m)
|
||||
}
|
||||
}
|
||||
if m := budgetNudge(100, -5, 0); m != "" {
|
||||
t.Errorf("budget négatif devrait être inerte, obtenu %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentBudgetConfig(t *testing.T) {
|
||||
testHome(t)
|
||||
set := func(v string) {
|
||||
cfg := ReadConfig()
|
||||
cfg["AGENT_BUDGET"] = v
|
||||
if err := WriteConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
val string
|
||||
want int
|
||||
}{
|
||||
{"", agentBudgetDefault},
|
||||
{"40", 40},
|
||||
{"0", 0},
|
||||
{"off", 0},
|
||||
{"NON", 0},
|
||||
{"n'importe quoi", agentBudgetDefault}, // valeur illisible : on ne l'invente pas
|
||||
{"-3", agentBudgetDefault},
|
||||
} {
|
||||
set(c.val)
|
||||
if got := agentBudget(); got != c.want {
|
||||
t.Errorf("AGENT_BUDGET=%q → %d, attendu %d", c.val, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 222 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user