mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
@@ -16,6 +16,17 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Depuis que l'image compile whisper.cpp avec CUDA, le build empile
|
||||
# nvidia/cuda:*-devel (~6 Go), l'image runtime CUDA de llama.cpp (~4 Go)
|
||||
# et Chromium/Playwright (~1 Go) — pour ~14 Go libres sur un runner neuf.
|
||||
# Ces trois répertoires ne servent à rien ici et rendent une dizaine de
|
||||
# gigaoctets en moins d'une minute.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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/
|
||||
+80
@@ -23,6 +23,68 @@ 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 : whisper-server (dictée vocale) ────────────────────────
|
||||
# DEUX binaires sont construits, pas un. LLAMACPP_IMAGE accepte la variante
|
||||
# CPU de l'image amont (voir ligne 11) : sur cette base, les bibliothèques
|
||||
# CUDA sont absentes et un binaire lié à CUDA ne démarre pas du tout —
|
||||
# l'éditeur de liens échoue avant la première instruction, donc aucun repli
|
||||
# n'est possible depuis l'intérieur du programme. Loki choisit à l'exécution
|
||||
# (whisperServerBin, dictate_server.go).
|
||||
#
|
||||
# whisper-server et non whisper-cli : le modèle reste chargé entre deux
|
||||
# dictées. L'ancien chemin le relisait depuis le disque à chaque phrase, ce
|
||||
# qui dominait le temps de réponse.
|
||||
#
|
||||
# ⚠️ GGML_NATIVE=OFF est OBLIGATOIRE sur LES DEUX cibles. Par défaut ggml
|
||||
# compile en -march=native, c'est-à-dire pour le processeur DU RUNNER DE
|
||||
# BUILD — un Xeon récent chez GitHub, avec AVX-512 et AMX. Le binaire partait
|
||||
# alors sur une machine qui n'a pas ces instructions et mourait d'un SIGILL en
|
||||
# pleine transcription : « AMX is not ready to be used! », puis plus rien,
|
||||
# l'interface affichant un échec sans raison. OFF retombe sur la ligne de base
|
||||
# AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis 2013.
|
||||
ARG WHISPER_CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
|
||||
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \
|
||||
-DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \
|
||||
-DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF"
|
||||
|
||||
# Ubuntu 22.04 : glibc plus ancienne que l'image runtime, donc compatible quoi
|
||||
# qu'elle embarque.
|
||||
FROM ubuntu:22.04 AS whisperbuild-cpu
|
||||
ARG WHISPER_CMAKE_FLAGS
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
|
||||
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} \
|
||||
&& cmake --build /w/build -j"$(nproc)" --target whisper-server
|
||||
|
||||
# Version CUDA. La MAJEURE de CUDA doit correspondre à celle de l'image
|
||||
# runtime : le binaire est lié dynamiquement à libcudart, fournie par
|
||||
# LLAMACPP_IMAGE et non par cette étape.
|
||||
#
|
||||
# ⚠️ -j"$(nproc)" et JAMAIS -j nu. Avec Make, « -j » sans nombre autorise un
|
||||
# parallélisme ILLIMITÉ. ggml-cuda compte ~200 fichiers d'instanciation de
|
||||
# gabarits, et chaque nvcc réclame 1 à 2 Go : le runner GitHub (16 Go) était
|
||||
# tué par l'OOM après avoir lancé 92 % des compilations en treize secondes,
|
||||
# sans écrire la moindre ligne d'erreur. L'étape CPU y survivait — peu de
|
||||
# fichiers, compilation légère — ce qui rendait le piège invisible jusqu'ici.
|
||||
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 AS whisperbuild-cuda
|
||||
ARG WHISPER_CMAKE_FLAGS
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
|
||||
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} -DGGML_CUDA=ON \
|
||||
&& cmake --build /w/build -j"$(nproc)" --target whisper-server \
|
||||
&& strip /w/build/bin/whisper-server
|
||||
|
||||
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
||||
FROM ${LLAMACPP_IMAGE} AS runtime
|
||||
@@ -43,6 +105,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 +138,12 @@ 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). Les modèles (190 Mo à 1,1 Go) ne sont
|
||||
# PAS dans l'image : téléchargés à la demande dans /data/whisper/.
|
||||
# Deux binaires : voir l'étape whisperbuild-cpu pour la raison.
|
||||
COPY --from=whisperbuild-cpu /w/build/bin/whisper-server /usr/local/bin/whisper-server-cpu
|
||||
COPY --from=whisperbuild-cuda /w/build/bin/whisper-server /usr/local/bin/whisper-server-cuda
|
||||
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 +152,8 @@ ENV LOKI_CONTAINER=1 \
|
||||
LOKI_HOME=/data \
|
||||
LOKI_MODEL_DIRS=/models \
|
||||
LOKI_ENGINE_BIN=/app/llama-server \
|
||||
LOKI_WHISPER_SERVER_CPU=/usr/local/bin/whisper-server-cpu \
|
||||
LOKI_WHISPER_SERVER_CUDA=/usr/local/bin/whisper-server-cuda \
|
||||
LD_LIBRARY_PATH=/app \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
@@ -19,6 +19,14 @@ 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.
|
||||
|
||||
## 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,7 @@ 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) | — |
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
@@ -122,8 +138,8 @@ et modèles compris.
|
||||
| `/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/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 +154,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
|
||||
@@ -154,16 +180,88 @@ Héritées d'AJEAN :
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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 +272,72 @@ 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.
|
||||
- **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 +360,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 +394,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,
|
||||
|
||||
+43
-9
@@ -1,19 +1,53 @@
|
||||
La grande nouveauté de cette version : AJEAN sait enfin voir les images.
|
||||
# Loki 0.12.0
|
||||
|
||||
## La vision, configurable par preset
|
||||
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
|
||||
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
|
||||
|
||||
Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul.
|
||||
## Le sélecteur de l'en-tête charge vraiment un modèle
|
||||
|
||||
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.
|
||||
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
|
||||
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
|
||||
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
|
||||
créé, le sélecteur n'offrait aucun choix.
|
||||
|
||||
## Les images arrivent vraiment au modèle
|
||||
## Dictée vocale
|
||||
|
||||
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.
|
||||
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
|
||||
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
|
||||
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
|
||||
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
|
||||
|
||||
## Cartes raisonnement/outils : taille fixe, défilement qui suit
|
||||
|
||||
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
|
||||
génération — remonter lire le début est respecté. Les cartes d'outil suivent
|
||||
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
|
||||
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.
|
||||
|
||||
## Mesures
|
||||
|
||||
- La carte du temps total affiche la **vitesse moyenne de la conversation**
|
||||
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
|
||||
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
|
||||
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
|
||||
seulement les octets lus).
|
||||
|
||||
## Interface
|
||||
|
||||
- Discussions triées par date de **création** (récentes en tête) : une
|
||||
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
|
||||
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
|
||||
Performance.
|
||||
|
||||
## Rappel 0.11.0
|
||||
|
||||
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
|
||||
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
|
||||
l'interface ; tâches planifiées reprises de l'amont.
|
||||
|
||||
## 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": 12,
|
||||
"Patch": 3,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 9,
|
||||
"Patch": 4,
|
||||
"Minor": 12,
|
||||
"Patch": 3,
|
||||
"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.12.3",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -4,8 +4,17 @@
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,15 @@
|
||||
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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+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") != ""})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -337,6 +410,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))
|
||||
}
|
||||
@@ -393,7 +473,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 +489,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 +518,75 @@ 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":
|
||||
return fmt.Errorf("fichier absent du dépôt (HTTP 404) — la révision a pu être réécrite depuis que le lien a été copié")
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 +610,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 +836,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,90 @@ 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{"absent du dépôt", "404"},
|
||||
},
|
||||
{
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,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 {
|
||||
@@ -286,12 +292,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 +357,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 +376,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,6 +400,22 @@ 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,
|
||||
@@ -400,6 +473,13 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// 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:
|
||||
@@ -419,6 +499,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 +522,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 +530,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 +563,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 +670,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
|
||||
@@ -752,6 +867,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,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 := getBytes(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
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -30,28 +31,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 +106,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 +126,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
|
||||
}
|
||||
@@ -188,7 +195,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
|
||||
@@ -233,7 +243,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 +256,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 +405,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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -228,9 +232,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
|
||||
}
|
||||
|
||||
+47
-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.")
|
||||
}
|
||||
@@ -149,7 +161,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 +173,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 everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. 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 when the user explicitly named that location.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -185,16 +201,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 +274,10 @@ func fileWrite(path, content string) string {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// 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 +312,28 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return "[erreur] old vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// 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 +341,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 {
|
||||
|
||||
@@ -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,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,90 @@
|
||||
// Réglages de la dictée vocale — modèle, langue, matériel, 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é
|
||||
// et le GPU occupé sont des propriétés de la machine, identiques pour tous les
|
||||
// onglets ouverts.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dictateCfgKey = "dictate_cfg"
|
||||
|
||||
// DictateCfg : Device vaut "cpu" ou un indice de GPU en décimal ("0", "1"),
|
||||
// dans l'ordre de nvidia-smi — le même que celui affiché par /api/vram.
|
||||
type DictateCfg struct {
|
||||
Model string `json:"model"`
|
||||
Lang string `json:"lang"`
|
||||
Device string `json:"device"`
|
||||
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.
|
||||
func (c DictateCfg) withDefauts() DictateCfg {
|
||||
if c.Model == "" {
|
||||
// large-v3-turbo : meilleur rapport qualité/vitesse en français à ce
|
||||
// jour. Le défaut penche vers la qualité.
|
||||
c.Model = "large-v3-turbo-q5_0"
|
||||
}
|
||||
if c.Lang == "" {
|
||||
// Pas "auto" : sur des tranches de quelques secondes la détection se
|
||||
// trompe, et une langue mal détectée produit du charabia — des suites
|
||||
// de caractères géorgiens ont été observées en production.
|
||||
c.Lang = "fr"
|
||||
}
|
||||
if c.Device == "" {
|
||||
c.Device = "cpu"
|
||||
}
|
||||
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 {
|
||||
c = c.withDefauts()
|
||||
// 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é.
|
||||
if _, ok := whisperCatalogue[c.Model]; !ok {
|
||||
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return putJSON(bkState, dictateCfgKey, c)
|
||||
}
|
||||
|
||||
// cudaVisibleDevices traduit le réglage en valeur de CUDA_VISIBLE_DEVICES.
|
||||
// Vide = aucun GPU visible = whisper tourne sur le CPU. Toute valeur qui n'est
|
||||
// pas un indice positif retombe sur le CPU : mieux vaut une dictée lente qu'un
|
||||
// GPU choisi au hasard sur une machine dont on ne sait rien.
|
||||
func (c DictateCfg) cudaVisibleDevices() string {
|
||||
n, err := strconv.Atoi(c.Device)
|
||||
if err != nil || n < 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// 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,87 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDictateCfgDefauts(t *testing.T) {
|
||||
c := DictateCfg{}.withDefauts()
|
||||
if c.Model != "large-v3-turbo-q5_0" {
|
||||
t.Errorf("modèle par défaut = %q, attendu large-v3-turbo-q5_0", c.Model)
|
||||
}
|
||||
if c.Lang != "fr" {
|
||||
t.Errorf("langue par défaut = %q, attendu fr — la détection auto se trompe sur les tranches courtes", c.Lang)
|
||||
}
|
||||
if c.Device != "cpu" {
|
||||
t.Errorf("matériel par défaut = %q, attendu cpu (aucun GPU supposé)", c.Device)
|
||||
}
|
||||
if c.Reactivity != "moyen" {
|
||||
t.Errorf("réactivité par défaut = %q, attendu moyen", c.Reactivity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCudaVisibleDevices(t *testing.T) {
|
||||
cas := []struct{ device, want string }{
|
||||
{"cpu", ""},
|
||||
{"0", "0"},
|
||||
{"1", "1"},
|
||||
{"", ""},
|
||||
{"bidon", ""},
|
||||
{"-1", ""},
|
||||
}
|
||||
for _, c := range cas {
|
||||
got := DictateCfg{Device: c.device}.cudaVisibleDevices()
|
||||
if got != c.want {
|
||||
t.Errorf("cudaVisibleDevices(%q) = %q, attendu %q", c.device, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkBounds(t *testing.T) {
|
||||
cas := []struct {
|
||||
react string
|
||||
min, max time.Duration
|
||||
}{
|
||||
{"court", 1000 * time.Millisecond, 5 * time.Second},
|
||||
{"moyen", 1500 * time.Millisecond, 8 * time.Second},
|
||||
{"long", 3 * time.Second, 15 * time.Second},
|
||||
{"inconnu", 1500 * time.Millisecond, 8 * time.Second},
|
||||
}
|
||||
for _, c := range cas {
|
||||
min, max := DictateCfg{Reactivity: c.react}.chunkBounds()
|
||||
if min != c.min || max != c.max {
|
||||
t.Errorf("chunkBounds(%q) = %v/%v, attendu %v/%v", c.react, min, max, c.min, c.max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictateCfgAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
in := DictateCfg{Model: "medium-q5_0", Lang: "en", Device: "1", Reactivity: "long"}
|
||||
if err := dictateCfgSave(in); err != nil {
|
||||
t.Fatalf("enregistrement : %v", err)
|
||||
}
|
||||
if out := dictateCfgLoad(); out != in {
|
||||
t.Errorf("relu %+v, attendu %+v", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 TestDictateCfgSaveRefuseModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "modele-inexistant", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err == nil {
|
||||
t.Error("un modèle hors catalogue doit être refusé")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans réglage enregistré, la lecture doit rendre les défauts — et non le zéro
|
||||
// de la structure, qui donnerait un modèle vide à whisper-server.
|
||||
func TestDictateCfgLoadSansRien(t *testing.T) {
|
||||
testHome(t)
|
||||
c := dictateCfgLoad()
|
||||
if c.Model == "" || c.Lang == "" || c.Device == "" || c.Reactivity == "" {
|
||||
t.Errorf("lecture à vide = %+v, aucun champ ne doit rester vide", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Catalogue des modèles de dictée. Aucun n'est embarqué dans l'image : ils
|
||||
// pèsent de 190 Mo à 1,1 Go et vivent dans <LOKI_HOME>/whisper/, donc dans le
|
||||
// volume /data — ils survivent aux recréations du conteneur.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Les modèles ggml officiels de whisper.cpp.
|
||||
const whisperModelBase = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
|
||||
|
||||
type whisperModel struct {
|
||||
Nom string // libellé affiché
|
||||
Fichier string // nom du .bin, identique en local et chez Hugging Face
|
||||
Octets int64 // taille du téléchargement, pour prévenir avant de le lancer
|
||||
VRAMMo int // ordre de grandeur occupé sur le GPU, pour choisir en connaissance de cause
|
||||
}
|
||||
|
||||
// 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 whisperCatalogue = map[string]whisperModel{
|
||||
"small-q5_1": {Nom: "small — rapide, correct", Fichier: "ggml-small-q5_1.bin", Octets: 190_000_000, VRAMMo: 600},
|
||||
"medium-q5_0": {Nom: "medium — bon compromis", Fichier: "ggml-medium-q5_0.bin", Octets: 539_000_000, VRAMMo: 1400},
|
||||
"large-v3-turbo-q5_0": {Nom: "large-v3-turbo — recommandé", Fichier: "ggml-large-v3-turbo-q5_0.bin", Octets: 574_000_000, VRAMMo: 1600},
|
||||
"large-v3-q5_0": {Nom: "large-v3 — le plus précis, le plus lent", Fichier: "ggml-large-v3-q5_0.bin", Octets: 1_080_000_000, VRAMMo: 3600},
|
||||
}
|
||||
|
||||
// whisperModelPathFor : chemin local. 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 whisperModelPathFor(id string) string {
|
||||
m, ok := whisperCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(LokiHome(), "whisper", m.Fichier)
|
||||
}
|
||||
|
||||
func whisperModelURLFor(id string) string {
|
||||
m, ok := whisperCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return whisperModelBase + m.Fichier
|
||||
}
|
||||
|
||||
func whisperModelPresent(id string) bool {
|
||||
p := whisperModelPathFor(id)
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && st.Size() > 0
|
||||
}
|
||||
|
||||
// whisperCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
|
||||
// l'ordre dans lequel se pose la question du compromis.
|
||||
func whisperCatalogueTrie() []map[string]any {
|
||||
out := make([]map[string]any, 0, len(whisperCatalogue))
|
||||
for id, m := range whisperCatalogue {
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "nom": m.Nom, "octets": m.Octets,
|
||||
"vram_mo": m.VRAMMo, "present": whisperModelPresent(id),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogueContientLeDefautEtLAncien(t *testing.T) {
|
||||
// Le défaut doit exister, sinon dictateCfgSave refuse sa propre valeur par
|
||||
// défaut et plus aucun réglage n'est enregistrable.
|
||||
if _, ok := whisperCatalogue["large-v3-turbo-q5_0"]; !ok {
|
||||
t.Error("le modèle par défaut large-v3-turbo-q5_0 est absent du catalogue")
|
||||
}
|
||||
// small-q5_1 est le modèle déjà téléchargé sur les installations
|
||||
// existantes : le retirer le rendrait insélectionnable alors que son
|
||||
// fichier est là.
|
||||
if _, ok := whisperCatalogue["small-q5_1"]; !ok {
|
||||
t.Error("small-q5_1 est absent : les installations existantes l'ont déjà sur disque")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPathEtURL(t *testing.T) {
|
||||
testHome(t)
|
||||
p := whisperModelPathFor("small-q5_1")
|
||||
if filepath.Base(p) != "ggml-small-q5_1.bin" {
|
||||
t.Errorf("chemin = %q, le fichier doit s'appeler ggml-small-q5_1.bin", p)
|
||||
}
|
||||
// Le chemin DOIT rester celui d'avant, sinon les installations existantes
|
||||
// re-téléchargent 190 Mo pour rien.
|
||||
if filepath.Base(filepath.Dir(p)) != "whisper" {
|
||||
t.Errorf("chemin = %q, le dossier doit rester <LOKI_HOME>/whisper", p)
|
||||
}
|
||||
u := whisperModelURLFor("small-q5_1")
|
||||
if !strings.HasSuffix(u, "/ggml-small-q5_1.bin") {
|
||||
t.Errorf("URL = %q, doit finir par /ggml-small-q5_1.bin", u)
|
||||
}
|
||||
if !strings.HasPrefix(u, "https://") {
|
||||
t.Errorf("URL = %q, doit être en HTTPS", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPathInconnuVide(t *testing.T) {
|
||||
testHome(t)
|
||||
if p := whisperModelPathFor("nawak"); p != "" {
|
||||
t.Errorf("un modèle hors catalogue doit donner un chemin vide, pas %q", p)
|
||||
}
|
||||
if u := whisperModelURLFor("nawak"); u != "" {
|
||||
t.Errorf("URL d'un modèle inconnu = %q, attendu vide", u)
|
||||
}
|
||||
if whisperModelPresent("nawak") {
|
||||
t.Error("un modèle inconnu ne peut pas être présent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogueTrieParTaille(t *testing.T) {
|
||||
testHome(t)
|
||||
l := whisperCatalogueTrie()
|
||||
if len(l) < 4 {
|
||||
t.Fatalf("catalogue de %d entrées, au moins 4 attendues", len(l))
|
||||
}
|
||||
var prec int64
|
||||
for i, m := range l {
|
||||
o, _ := m["octets"].(int64)
|
||||
if i > 0 && o < prec {
|
||||
t.Errorf("entrée %d : catalogue non trié par taille croissante", i)
|
||||
}
|
||||
prec = o
|
||||
for _, clef := range []string{"id", "nom", "octets", "vram_mo", "present"} {
|
||||
if _, ok := m[clef]; !ok {
|
||||
t.Errorf("entrée %d : clé %q manquante — l'UI en a besoin", i, clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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":"small-q5_1","lang":"en","device":"1","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.Device != "1" || got.Lang != "en" || got.Model != "small-q5_1" || 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","lang":"fr","device":"cpu","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) < 4 {
|
||||
t.Errorf("%d modèles renvoyés, au moins 4 attendus", len(l))
|
||||
}
|
||||
}
|
||||
|
||||
// Un identifiant inconnu ne doit pas lancer de téléchargement : whisperDownload
|
||||
// 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 whisper-server :
|
||||
// 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,328 @@
|
||||
// Supervision de whisper-server : le modèle est chargé UNE fois et reste en
|
||||
// mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
|
||||
//
|
||||
// L'ancien chemin lançait whisper-cli par requête. Le chargement du modèle
|
||||
// dominait le temps de réponse (~3 s pour 3,4 s d'audio), et découper la
|
||||
// dictée en tranches par-dessus ce mécanisme aurait rechargé le modèle toutes
|
||||
// les quelques secondes.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// whisperServerBin : deux binaires sont livrés, pas un.
|
||||
//
|
||||
// L'image runtime peut être bâtie sur la variante CPU de llama.cpp
|
||||
// (LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server). Sur cette base les
|
||||
// bibliothèques CUDA sont absentes, et un binaire lié à CUDA ne démarre pas du
|
||||
// tout : l'éditeur de liens échoue avant la première instruction, il n'y a
|
||||
// donc aucun repli possible depuis l'intérieur du programme. D'où deux
|
||||
// binaires et un choix fait ici.
|
||||
func whisperServerBin(gpu bool) string {
|
||||
env, defaut := "LOKI_WHISPER_SERVER_CPU", "/usr/local/bin/whisper-server-cpu"
|
||||
if gpu {
|
||||
env, defaut = "LOKI_WHISPER_SERVER_CUDA", "/usr/local/bin/whisper-server-cuda"
|
||||
}
|
||||
for _, p := range []string{os.Getenv(env), defaut} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if gpu {
|
||||
// Pas de binaire CUDA : l'appelant retombe sur le CPU en le disant.
|
||||
return ""
|
||||
}
|
||||
if p, err := exec.LookPath("whisper-server"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func whisperServerArgs(c DictateCfg, port int) ([]string, error) {
|
||||
model := whisperModelPathFor(c.Model)
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return []string{
|
||||
"-m", model,
|
||||
"-l", c.Lang,
|
||||
// 127.0.0.1 et pas 0.0.0.0 : whisper-server n'a aucune
|
||||
// authentification. Sur un conteneur au réseau partagé, l'exposer
|
||||
// offrirait la transcription à tout le réseau.
|
||||
"--host", "127.0.0.1",
|
||||
"--port", strconv.Itoa(port),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// whisperServerEnv pose CUDA_VISIBLE_DEVICES en REMPLAÇANT toute valeur déjà
|
||||
// présente. Une variable dupliquée laisse le gagnant dépendre de la libc, et
|
||||
// le choix de GPU deviendrait un coup de dé.
|
||||
func whisperServerEnv(c DictateCfg, 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)
|
||||
}
|
||||
}
|
||||
// Valeur vide en mode CPU : masque TOUS les GPU. Retirer la variable
|
||||
// ferait l'inverse — ggml les verrait tous.
|
||||
return append(out, clef+c.cudaVisibleDevices())
|
||||
}
|
||||
|
||||
// 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 whisper-server 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
|
||||
}
|
||||
|
||||
// whisperMarqueur reconnaît les annotations que whisper produit à la place
|
||||
// d'un texte : [BLANK_AUDIO], (silence), [SOUND]… Un contenu entièrement
|
||||
// entouré de crochets ou de parenthèses n'est jamais de la parole dictée, et
|
||||
// le laisser passer le collerait tel quel dans le champ de saisie.
|
||||
func whisperMarqueur(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
|
||||
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
|
||||
}
|
||||
|
||||
// whisperInferSur prend l'URL de base en paramètre : c'est ce qui rend l'appel
|
||||
// testable sans lancer de processus.
|
||||
func whisperInferSur(ctx context.Context, base string, wav []byte) (string, error) {
|
||||
var corps bytes.Buffer
|
||||
mw := multipart.NewWriter(&corps)
|
||||
part, err := mw.CreateFormFile("file", "dictee.wav")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(wav); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mw.WriteField("response_format", "json"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/inference", &corps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
brut, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("whisper-server a répondu %d : %s", resp.StatusCode, lastLine(string(brut)))
|
||||
}
|
||||
var j struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(brut, &j); err != nil {
|
||||
return "", fmt.Errorf("réponse illisible de whisper-server : %w", err)
|
||||
}
|
||||
if whisperMarqueur(j.Text) {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(j.Text), nil
|
||||
}
|
||||
|
||||
// ─── Supervision ─────────────────────────────────────────────────────────────
|
||||
|
||||
// whisperIdle : au-delà, le serveur s'éteint et rend sa VRAM. Une dictée n'est
|
||||
// pas un service permanent ; garder un modèle chargé toute la journée pour
|
||||
// quelques phrases prive le moteur de chat de mémoire GPU.
|
||||
const whisperIdle = 10 * time.Minute
|
||||
|
||||
// whisperReady : plafond d'attente au démarrage. Un modèle de 1 Go lu depuis un
|
||||
// disque lent puis poussé sur le GPU prend du temps ; échouer trop tôt
|
||||
// afficherait une panne là où il n'y a qu'une lenteur.
|
||||
const whisperReady = 120 * time.Second
|
||||
|
||||
var wsrvMu sync.Mutex
|
||||
var wsrv struct {
|
||||
cmd *exec.Cmd
|
||||
port int
|
||||
cfg DictateCfg
|
||||
log *bytes.Buffer
|
||||
timer *time.Timer
|
||||
note string // pourquoi on ne tourne pas sur le matériel demandé
|
||||
depuis time.Time
|
||||
}
|
||||
|
||||
// whisperEnsure 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 ou de GPU effectif
|
||||
// sans redémarrer Loki.
|
||||
func whisperEnsure() (int, error) {
|
||||
cfg := dictateCfgLoad()
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
|
||||
whisperTouchLocked()
|
||||
return wsrv.port, nil
|
||||
}
|
||||
whisperStopLocked()
|
||||
return whisperStartLocked(cfg)
|
||||
}
|
||||
|
||||
func whisperStartLocked(cfg DictateCfg) (int, error) {
|
||||
if !whisperModelPresent(cfg.Model) {
|
||||
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
|
||||
}
|
||||
note := ""
|
||||
gpu := cfg.cudaVisibleDevices() != ""
|
||||
bin := whisperServerBin(gpu)
|
||||
if bin == "" && gpu {
|
||||
// Image bâtie sans CUDA : on le DIT, au lieu de laisser croire que le
|
||||
// GPU choisi est utilisé.
|
||||
note = "binaire CUDA absent de cette image — dictée sur le processeur"
|
||||
gpu = false
|
||||
cfg.Device = "cpu"
|
||||
bin = whisperServerBin(false)
|
||||
}
|
||||
if bin == "" {
|
||||
return 0, fmt.Errorf("whisper-server introuvable (image à reconstruire)")
|
||||
}
|
||||
port, err := portLibre()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aucun port libre pour whisper-server : %w", err)
|
||||
}
|
||||
args, err := whisperServerArgs(cfg, port)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
log := &bytes.Buffer{}
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = whisperServerEnv(cfg, os.Environ())
|
||||
cmd.Stdout, cmd.Stderr = log, log
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("démarrage de whisper-server : %w", err)
|
||||
}
|
||||
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad()
|
||||
wsrv.log, wsrv.note, wsrv.depuis = log, note, time.Now()
|
||||
if err := whisperAttendPret(cmd, port, log); err != nil {
|
||||
whisperStopLocked()
|
||||
return 0, err
|
||||
}
|
||||
whisperTouchLocked()
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// whisperAttendPret sonde le serveur jusqu'à ce qu'il réponde. 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 (leçon de la PR #18).
|
||||
func whisperAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
|
||||
fin := time.Now().Add(whisperReady)
|
||||
url := "http://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 := whisperExitReason(err)
|
||||
if l := lastLine(log.String()); l != "" {
|
||||
msg += " — dernière sortie : " + l
|
||||
}
|
||||
return fmt.Errorf("whisper-server s'est arrêté au démarrage : %s", msg)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
|
||||
cancel()
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("whisper-server n'a pas répondu en %s", whisperReady)
|
||||
}
|
||||
|
||||
func whisperTouchLocked() {
|
||||
if wsrv.timer != nil {
|
||||
wsrv.timer.Stop()
|
||||
}
|
||||
wsrv.timer = time.AfterFunc(whisperIdle, whisperShutdown)
|
||||
}
|
||||
|
||||
func whisperStopLocked() {
|
||||
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, wsrv.note = DictateCfg{}, ""
|
||||
}
|
||||
|
||||
// whisperShutdown 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 whisperShutdown() {
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
whisperStopLocked()
|
||||
}
|
||||
|
||||
// whisperInfer garantit le serveur puis lui soumet le WAV.
|
||||
func whisperInfer(ctx context.Context, wav []byte) (string, error) {
|
||||
port, err := whisperEnsure()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return whisperInferSur(ctx, "http://127.0.0.1:"+strconv.Itoa(port), wav)
|
||||
}
|
||||
|
||||
func whisperEtat() map[string]any {
|
||||
cfg := dictateCfgLoad()
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
etat := map[string]any{
|
||||
"actif": wsrv.cmd != nil,
|
||||
"modele": cfg.Model,
|
||||
"present": whisperModelPresent(cfg.Model),
|
||||
"device": cfg.Device,
|
||||
"note": wsrv.note,
|
||||
}
|
||||
if wsrv.cmd != nil {
|
||||
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
|
||||
}
|
||||
return etat
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func argVal(args []string, flag string) (string, bool) {
|
||||
for i, a := range args {
|
||||
if a == flag && i+1 < len(args) {
|
||||
return args[i+1], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestServerArgs(t *testing.T) {
|
||||
testHome(t)
|
||||
c := DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu"}.withDefauts()
|
||||
args, err := whisperServerArgs(c, 8091)
|
||||
if err != nil {
|
||||
t.Fatalf("construction des arguments : %v", err)
|
||||
}
|
||||
if v, ok := argVal(args, "-m"); !ok || !strings.HasSuffix(v, "ggml-small-q5_1.bin") {
|
||||
t.Errorf("-m = %q, doit pointer sur le .bin du modèle choisi", v)
|
||||
}
|
||||
if v, ok := argVal(args, "-l"); !ok || v != "fr" {
|
||||
t.Errorf("-l = %q, attendu fr", v)
|
||||
}
|
||||
if v, ok := argVal(args, "--port"); !ok || v != "8091" {
|
||||
t.Errorf("--port = %q, attendu 8091", v)
|
||||
}
|
||||
// whisper-server n'a aucune authentification : l'exposer sur 0.0.0.0
|
||||
// ouvrirait la transcription à tout le réseau.
|
||||
if v, ok := argVal(args, "--host"); !ok || v != "127.0.0.1" {
|
||||
t.Errorf("--host = %q, attendu 127.0.0.1", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerArgsModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if _, err := whisperServerArgs(DictateCfg{Model: "nawak", Lang: "fr", Device: "cpu"}, 8091); err == nil {
|
||||
t.Error("un modèle hors catalogue doit produire une erreur, pas une commande avec -m vide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerEnvGPU(t *testing.T) {
|
||||
base := []string{"PATH=/usr/bin", "CUDA_VISIBLE_DEVICES=7", "HOME=/root"}
|
||||
|
||||
env := whisperServerEnv(DictateCfg{Device: "1"}, base)
|
||||
if !contientExact(env, "CUDA_VISIBLE_DEVICES=1") {
|
||||
t.Errorf("env = %v, doit contenir CUDA_VISIBLE_DEVICES=1", env)
|
||||
}
|
||||
// L'ancienne valeur doit être REMPLACÉE, pas doublée : avec deux
|
||||
// occurrences, le gagnant dépend de l'implémentation — le choix de
|
||||
// l'utilisateur ne doit pas dépendre de ça.
|
||||
if n := compte(env, "CUDA_VISIBLE_DEVICES="); n != 1 {
|
||||
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
|
||||
}
|
||||
if !contientExact(env, "PATH=/usr/bin") {
|
||||
t.Error("le reste de l'environnement doit être conservé")
|
||||
}
|
||||
|
||||
env = whisperServerEnv(DictateCfg{Device: "cpu"}, base)
|
||||
if !contientExact(env, "CUDA_VISIBLE_DEVICES=") {
|
||||
t.Errorf("env = %v, en mode CPU la variable doit être posée VIDE (et non retirée) pour masquer tous les GPU", env)
|
||||
}
|
||||
}
|
||||
|
||||
func contientExact(l []string, s string) bool {
|
||||
for _, v := range l {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func compte(l []string, prefixe string) int {
|
||||
n := 0
|
||||
for _, v := range l {
|
||||
if strings.HasPrefix(v, prefixe) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestPortLibre(t *testing.T) {
|
||||
p, err := portLibre()
|
||||
if err != nil {
|
||||
t.Fatalf("portLibre : %v", 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()
|
||||
}
|
||||
|
||||
func TestInferParseLaReponse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/inference" {
|
||||
t.Errorf("chemin appelé = %q, attendu /inference", r.URL.Path)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
|
||||
t.Errorf("Content-Type = %q, whisper-server attend du multipart", ct)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"text":" bonjour ceci est un test "}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
||||
if err != nil {
|
||||
t.Fatalf("whisperInferSur : %v", err)
|
||||
}
|
||||
if txt != "bonjour ceci est un test" {
|
||||
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// Sur du silence, whisper renvoie ses marqueurs internes. Les laisser passer
|
||||
// les collerait tels quels dans le champ de saisie — c'est ce qui est arrivé
|
||||
// avec [BLANK_AUDIO].
|
||||
func TestInferFiltreLesMarqueurs(t *testing.T) {
|
||||
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"text":"` + marqueur + `"}`))
|
||||
}))
|
||||
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
||||
srv.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
|
||||
}
|
||||
if txt != "" {
|
||||
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferErreurHTTP(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux")); err == nil {
|
||||
t.Error("un 500 de whisper-server doit remonter une erreur")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle absent doit être annoncé comme tel, pas produire un lancement de
|
||||
// whisper-server voué à mourir sur un fichier introuvable.
|
||||
func TestEnsureModeleAbsent(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := whisperEnsure()
|
||||
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 := whisperEtat()
|
||||
if e["actif"] != false {
|
||||
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
|
||||
}
|
||||
for _, clef := range []string{"modele", "present", "device", "note"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
-18
@@ -194,6 +194,14 @@ type Caps struct {
|
||||
// Mem = mode d'accès à la mémoire persistante (off / ondemand / always),
|
||||
// indépendant du mode agent. Voir MemMode.
|
||||
Mem MemMode
|
||||
// Code = mode code actif pour cette discussion (sélecteur Chat/Code) :
|
||||
// ajoute les outils codeur (read/grep/glob, git, critères, jobs) et le
|
||||
// prompt de rôle. Requiert Agent.
|
||||
Code bool
|
||||
// Role = rôle du tour en mode code : "" ou "builder" (défaut), "planner",
|
||||
// "verifier" (passe de vérification, seule autorisée à marquer un critère
|
||||
// passed), "explorer", "code-reviewer". Voir code_roles.go.
|
||||
Role string
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
@@ -261,12 +269,71 @@ func steerSystem(msgs []Message, hint string) []Message {
|
||||
return append([]Message{{Role: "system", Content: hint}}, msgs...)
|
||||
}
|
||||
|
||||
// normalizeSystemMessages garantit un UNIQUE message système, en tête, dans la
|
||||
// séquence ENVOYÉE au moteur. Repris de l'amont AJEAN (v0.9.8, issue #26).
|
||||
//
|
||||
// steerSystem (ci-dessus) empêche déjà loki d'en poser un en cours de route, mais
|
||||
// il ne couvre que SES propres consignes : un historique venu d'ailleurs (import,
|
||||
// preset, conversation migrée, agent de code qui compose sa propre séquence) peut
|
||||
// encore porter deux systèmes, ou un système ailleurs qu'en position 0. Les
|
||||
// gabarits stricts — Qwen3.x en --jinja — répondent alors « System message must
|
||||
// be at the beginning », un 500 qui tue le tour sans rien expliquer.
|
||||
//
|
||||
// La séquence renvoyée est une COPIE : l'historique d'origine garde sa forme pour
|
||||
// l'affichage, la persistance et la compaction. Un modèle qui accepte le système
|
||||
// n'importe où n'est pas gêné de le recevoir en tête — la normalisation est donc
|
||||
// sans risque pour tous, et sans effet sur une conversation déjà normale (payload
|
||||
// identique, garanti par test).
|
||||
func normalizeSystemMessages(msgs []Message) []Message {
|
||||
var sys []string
|
||||
sawSystem := false
|
||||
rest := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
if s, ok := m.Content.(string); ok {
|
||||
sawSystem = true
|
||||
if strings.TrimSpace(s) != "" {
|
||||
sys = append(sys, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Contenu système non textuel (cas théorique) : on le préserve tel quel
|
||||
// plutôt que de le perdre.
|
||||
}
|
||||
rest = append(rest, m)
|
||||
}
|
||||
// Aucun message système : rien à réordonner, on rend l'entrée telle quelle —
|
||||
// pas de copie inutile sur le chemin le plus fréquent.
|
||||
if !sawSystem {
|
||||
return msgs
|
||||
}
|
||||
// Des systèmes existaient mais tous vides : on les a retirés (un système vide
|
||||
// hors position 0 casserait tout autant), sans en réinsérer.
|
||||
if len(sys) == 0 {
|
||||
return rest
|
||||
}
|
||||
out := make([]Message, 0, len(rest)+1)
|
||||
out = append(out, Message{Role: "system", Content: strings.Join(sys, "\n\n")})
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
// EnabledTools returns the tools to advertise on the next inference call.
|
||||
func EnabledTools(caps Caps) []Tool {
|
||||
tools := []Tool{}
|
||||
// Passe de VÉRIFICATION du mode code : jeu d'outils fermé, en lecture
|
||||
// seule + bash (lancer les tests) + critères. Ni write/edit (elle ne
|
||||
// corrige pas), ni mémoire ni web (hors sujet, et chaque schéma coûte du
|
||||
// contexte).
|
||||
if caps.Code && caps.Role == "verifier" {
|
||||
return []Tool{bashTool(), readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool(), criteriaTool()}
|
||||
}
|
||||
if caps.Agent {
|
||||
tools = append(tools, bashTool(), writeTool(), editTool())
|
||||
}
|
||||
if caps.Code {
|
||||
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
|
||||
bashBgTool(), bashTailTool(), gitStatusTool(), gitDiffTool(), gitCloneTool(), criteriaTool())
|
||||
}
|
||||
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
|
||||
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
|
||||
if caps.Mem != MemOff {
|
||||
@@ -277,12 +344,16 @@ func EnabledTools(caps Caps) []Tool {
|
||||
// donc prompt et outils restent cohérents — pas de web_search halluciné.
|
||||
if caps.Agent && caps.Internet {
|
||||
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
|
||||
// Capture d'écran : seulement si Playwright est réellement présent dans
|
||||
// l'image. Annoncer un outil absent enverrait le modèle en boucle de
|
||||
// réessai sur un échec systématique.
|
||||
if screenshotAvailable() {
|
||||
tools = append(tools, webScreenshotTool())
|
||||
}
|
||||
}
|
||||
// Capture d'écran : seulement si Playwright est réellement présent dans
|
||||
// l'image (annoncer un outil absent enverrait le modèle en boucle de
|
||||
// réessai). Offerte aussi SANS accès internet quand le mode code est actif :
|
||||
// photographier son propre serveur de dev est une vérification locale, pas
|
||||
// une sortie sur le web — et sans elle, le modèle croyait n'avoir aucun
|
||||
// navigateur et perdait des minutes à installer puppeteer. La borne aux
|
||||
// adresses locales vit dans toolWebScreenshot.
|
||||
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
|
||||
tools = append(tools, webScreenshotTool())
|
||||
}
|
||||
// Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
|
||||
// Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
|
||||
@@ -305,6 +376,7 @@ type StreamEvent struct {
|
||||
Reasoning string
|
||||
ToolUsed *ToolUsedEvent
|
||||
Stats *StatsEvent
|
||||
Ask *AskEvent
|
||||
Err error
|
||||
// DropReasoning demande à l'UI de retirer la dernière bulle de raisonnement :
|
||||
// le modèle a « pensé sans agir » et on relance le tour, ce raisonnement-là
|
||||
@@ -324,6 +396,14 @@ type StreamEvent struct {
|
||||
// ajouter.
|
||||
NewHistory []Message
|
||||
}
|
||||
|
||||
// AskEvent : l'outil ask — question structurée posée à l'utilisateur, rendue
|
||||
// par l'UI comme une carte à boutons. Le tour se termine juste après.
|
||||
type AskEvent struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ToolUsedEvent struct {
|
||||
Name string
|
||||
Label string // user-visible summary (skill name or the command)
|
||||
@@ -338,6 +418,12 @@ type ToolUsedEvent struct {
|
||||
// Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose
|
||||
// (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-).
|
||||
Diff []DiffLine
|
||||
// Image : chemin (relatif au dossier de travail) d'une image PRODUITE par
|
||||
// l'outil — aujourd'hui la capture de web_screenshot. L'UI l'affiche dans la
|
||||
// bulle de l'outil. Sans ça, la capture n'apparaissait QUE si le modèle
|
||||
// pensait à recopier la ligne markdown rendue par l'outil : un petit modèle
|
||||
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
|
||||
Image string
|
||||
}
|
||||
|
||||
// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI.
|
||||
@@ -574,7 +660,23 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// reasoning is enabled we split that out ourselves so the UI's reasoning
|
||||
// bubble works regardless of backend. The ik_llama.cpp fork already sends
|
||||
// reasoning_content, in which case we leave content untouched.
|
||||
reasoningOn := reasoningActive(ReadConfig()["REASONING"])
|
||||
chatCfg := ReadConfig()
|
||||
reasoningOn := reasoningActive(chatCfg["REASONING"])
|
||||
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
|
||||
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
|
||||
effortWanted := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
|
||||
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
|
||||
// on part alors directement sur la traduction apprise, sans repayer le 500.
|
||||
reasoningEffort := effortResolve(effortWanted)
|
||||
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
|
||||
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
|
||||
// `enable_thinking` qui porte alors la consigne, seul.
|
||||
thinkOff := reasoningExplicitlyOff(chatCfg["REASONING"]) || effortWanted == "none"
|
||||
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
|
||||
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
|
||||
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
|
||||
// modèle réfléchissait pendant que l'interface annonçait le contraire.
|
||||
reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort)
|
||||
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
|
||||
// retry the same turn once with tools removed so the model answers in plain
|
||||
// text from the tool results already gathered, instead of dying mid-chat.
|
||||
@@ -583,6 +685,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// dépassement de la fenêtre de contexte après de gros résultats d'outils), on
|
||||
// compacte l'historique en vol et on rejoue le tour — une seule fois.
|
||||
compactedRetry := false
|
||||
// Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par
|
||||
// tour, comme les autres filets.
|
||||
effortRetried := false
|
||||
// Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas
|
||||
// rejouer deux fois exactement la même écriture dans un même échange.
|
||||
doneCalls := map[string]string{}
|
||||
@@ -595,14 +700,28 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
|
||||
// explicite au lieu d'afficher « pas de réponse ».
|
||||
nudged := false
|
||||
// Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours
|
||||
// parfaitement légitimes (une recherche enchaîne facilement des dizaines
|
||||
// d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule
|
||||
// le contexte — c'est un choix assumé.
|
||||
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
|
||||
// relance par tour, comme le nudge.
|
||||
patternRetried := false
|
||||
// Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond
|
||||
// d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà
|
||||
// d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui
|
||||
// demande de conclure — de la pression, pas une barrière. Sans ça, un modèle
|
||||
// qui tourne en rond n'avait rien en face de lui sauf le bouton stop.
|
||||
toolRuns, budgetNudges, budget := 0, 0, agentBudget()
|
||||
for iter := 0; ; iter++ {
|
||||
// Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté
|
||||
// llama-server reste valide, seul le nouveau message est à traiter.
|
||||
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
|
||||
budgetNudges++
|
||||
logBudget(toolRuns, budget, budgetNudges)
|
||||
messages = append(messages, Message{Role: "user", Content: msg})
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": "loki",
|
||||
"messages": messages,
|
||||
"model": "loki",
|
||||
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
|
||||
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
|
||||
"messages": normalizeSystemMessages(messages),
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
|
||||
@@ -611,6 +730,17 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
|
||||
// l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les
|
||||
// deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que
|
||||
// c'est bien loki qui a le dernier mot sur `chat_template_kwargs`.
|
||||
applySampling(payload)
|
||||
if reasoningEffort != "" {
|
||||
payload["reasoning_effort"] = reasoningEffort
|
||||
}
|
||||
if reasoningKwargs != nil {
|
||||
payload["chat_template_kwargs"] = reasoningKwargs
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
payload["tools"] = tools
|
||||
// The model sometimes emits parallel tool calls, which this llama.cpp
|
||||
@@ -644,6 +774,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if msg == "" {
|
||||
msg = resp.Status
|
||||
}
|
||||
// Refus du niveau de raisonnement par le gabarit du modèle (Qwen3.8 ne
|
||||
// connaît pas « high », gpt-oss ne connaît pas « xhigh »…). C'est un
|
||||
// refus DÉFINITIF, pas une question de taille de prompt : à traiter
|
||||
// avant la compaction, qui sinon taillait l'historique pour rien puis
|
||||
// échouait quand même. On rejoue le tour avec le niveau accepté (ou
|
||||
// sans le champ), l'historique intact.
|
||||
if !effortRetried && effortWanted != "" {
|
||||
if fixed, ok := effortFromRejection(msg, effortWanted); ok {
|
||||
effortRetried = true
|
||||
effortRemember(effortWanted, fixed)
|
||||
logEffortFallback(effortWanted, fixed)
|
||||
reasoningEffort = fixed
|
||||
reasoningKwargs = reasoningTemplateKwargs(thinkOff, fixed)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Le prompt a peut-être dépassé la fenêtre de contexte : on tente une
|
||||
// compaction en vol et on rejoue le tour (une seule fois) avant tout le
|
||||
// reste. C'est le filet de secours à la Hermes.
|
||||
@@ -775,10 +921,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
switch cur.Function.Name {
|
||||
case "mem_search", "web_search":
|
||||
key = "query"
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write":
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
||||
key = "file"
|
||||
case "web_open", "web_read", "web_grep":
|
||||
key = "url"
|
||||
case "grep", "glob":
|
||||
key = "pattern"
|
||||
case "ask":
|
||||
key = "question"
|
||||
case "criteria":
|
||||
key = "action"
|
||||
case "bash_tail":
|
||||
key = "id"
|
||||
case "git_clone":
|
||||
key = "url"
|
||||
}
|
||||
p := previewArg(cur.Function.Arguments, key)
|
||||
// Corps en cours de frappe pour les outils d'écriture : on le diffuse
|
||||
@@ -939,6 +1095,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if ctx.Err() != nil {
|
||||
return extra, nil
|
||||
}
|
||||
toolRuns++ // alimente le budget souple (voir budgetNudge)
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
// Derive the human label (command / skill name) up front so we can
|
||||
@@ -948,10 +1105,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
switch tc.Function.Name {
|
||||
case "mem_search", "web_search":
|
||||
label, _ = args["query"].(string)
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write":
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
||||
label, _ = args["file"].(string)
|
||||
case "bash":
|
||||
case "bash", "bash_bg":
|
||||
label, _ = args["command"].(string)
|
||||
case "grep", "glob":
|
||||
label, _ = args["pattern"].(string)
|
||||
case "ask":
|
||||
label, _ = args["question"].(string)
|
||||
case "criteria":
|
||||
label, _ = args["action"].(string)
|
||||
case "bash_tail":
|
||||
label, _ = args["id"].(string)
|
||||
case "git_clone":
|
||||
label, _ = args["url"].(string)
|
||||
case "web_open", "web_read":
|
||||
label, _ = args["url"].(string)
|
||||
case "web_grep":
|
||||
@@ -1038,10 +1205,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// Cible = un poste distant : on écrit LÀ-BAS. Pas de diff (on
|
||||
// n'a pas l'ancien contenu du fichier distant).
|
||||
result = nodeCall(tgt, nodeCapWrite, map[string]any{"path": label, "content": content})
|
||||
} else if msg := codeWriteGuard(caps, label, true); msg != "" {
|
||||
result = msg
|
||||
} else {
|
||||
result = fileWrite(label, content)
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
diff = addedDiff(content)
|
||||
trackerNoteWrite(resolveAgentPath(label))
|
||||
result += lspDiagBlock(resolveAgentPath(label), caps)
|
||||
}
|
||||
}
|
||||
case "edit":
|
||||
@@ -1049,13 +1220,41 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
newText, _ := args["new"].(string)
|
||||
if tgt := agentTargetSlug(); tgt != "" {
|
||||
result = nodeEditRemote(tgt, label, oldText, newText)
|
||||
} else if msg := codeWriteGuard(caps, label, false); msg != "" {
|
||||
result = msg
|
||||
} else {
|
||||
result = fileEdit(label, oldText, newText)
|
||||
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
diff = lineDiff(oldText, newText)
|
||||
trackerNoteWrite(resolveAgentPath(label))
|
||||
result += lspDiagBlock(resolveAgentPath(label), caps)
|
||||
}
|
||||
}
|
||||
case "read":
|
||||
result = toolRead(args, caps.Code)
|
||||
case "grep":
|
||||
result = toolGrep(args, caps.Code)
|
||||
case "glob":
|
||||
result = toolGlob(args, caps.Code)
|
||||
case "ask":
|
||||
result = toolAsk(args)
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
q, _ := args["question"].(string)
|
||||
cb(StreamEvent{Ask: &AskEvent{Question: q, Options: askOptions(args)}})
|
||||
}
|
||||
case "bash_bg":
|
||||
result = toolBashBg(args)
|
||||
case "bash_tail":
|
||||
result = toolBashTail(args)
|
||||
case "git_status":
|
||||
result = toolGitStatus(ctx)
|
||||
case "git_diff":
|
||||
result = toolGitDiff(ctx, args)
|
||||
case "git_clone":
|
||||
result = toolGitClone(ctx, args)
|
||||
case "criteria":
|
||||
result = toolCriteria(args, caps.Role == "verifier")
|
||||
case "bash":
|
||||
to := 0
|
||||
switch v := args["timeout"].(type) {
|
||||
@@ -1064,6 +1263,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
case int:
|
||||
to = v
|
||||
}
|
||||
// Politique de sécurité : les commandes catastrophiques sont
|
||||
// refusées AVANT toute exécution, cible distante comprise.
|
||||
if reason := dangerousCommand(label); reason != "" {
|
||||
result = refusedCommandResult(reason)
|
||||
break
|
||||
}
|
||||
if tgt := agentTargetSlug(); tgt != "" {
|
||||
// Cible = un poste distant : la commande s'exécute LÀ-BAS via le
|
||||
// canal du poste (fail-closed : nodeCall renvoie une erreur si le
|
||||
@@ -1081,7 +1286,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
case "web_grep":
|
||||
result = capWebOutput(toolWebGrep(args))
|
||||
case "web_screenshot":
|
||||
result = toolWebScreenshot(args)
|
||||
result = toolWebScreenshot(args, caps)
|
||||
default:
|
||||
if isMCPTool(tc.Function.Name) {
|
||||
result = mcpCall(tc.Function.Name, args)
|
||||
@@ -1092,7 +1297,13 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
doneCalls[callKey] = result
|
||||
}
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff}})
|
||||
// Capture d'écran : l'image part avec l'événement pour être
|
||||
// affichée dans la bulle, quoi que le modèle en fasse ensuite.
|
||||
shot := ""
|
||||
if tc.Function.Name == "web_screenshot" {
|
||||
shot = capturedRelPath(result)
|
||||
}
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff, Image: shot}})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
@@ -1139,6 +1350,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Appel d'outil TEXTUEL (halluciné, jamais exécuté) dans la réponse
|
||||
// finale : on relance le tour UNE fois avec la consigne corrective
|
||||
// (code_retry.go). Le texte fautif reste affiché — le remplacer serait
|
||||
// mentir sur ce qui s'est passé — mais l'historique du modèle garde la
|
||||
// trace ET la correction, donc la vraie réponse suit immédiatement.
|
||||
if !patternRetried && len(tools) > 0 && !disableTools && textualToolCall(assistantContent.String()) {
|
||||
patternRetried = true
|
||||
bad := Message{Role: "assistant", Content: assistantContent.String()}
|
||||
fix := Message{Role: "user", Content: retryCorrective}
|
||||
messages = append(messages, bad, fix)
|
||||
extra = append(extra, bad, fix)
|
||||
continue
|
||||
}
|
||||
// Normal end of turn. If the model produced no visible answer at all
|
||||
// (empty content, e.g. it stopped right after a tool result), say so
|
||||
// instead of leaving the user staring at a silent, finished chat.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package loki
|
||||
|
||||
// llm_effort.go — repli quand le gabarit du modèle REFUSE la valeur de
|
||||
// `reasoning_effort`.
|
||||
//
|
||||
// L'hypothèse d'origine (backend_models.go) était : un gabarit jinja qui ne lit
|
||||
// pas `reasoning_effort` l'ignore sans erreur, donc rien à prévoir côté loki.
|
||||
// Elle tombe dès qu'un gabarit VALIDE la valeur au lieu de la subir. Vu en
|
||||
// production sur Qwen3.8-27B :
|
||||
//
|
||||
// {"error":{"code":500,"message":"... raise_exception('Unexpected reasoning
|
||||
// effort ' ~ reasoning_effort) ... Unexpected reasoning effort high. Supported
|
||||
// types are xhigh (default), medium, and low."}}
|
||||
//
|
||||
// Ce gabarit connaît xhigh/medium/low mais PAS « high » — le niveau que
|
||||
// l'interface propose et enregistre par défaut. Résultat : chaque message part
|
||||
// en 500, le tour meurt, et le message d'erreur affiché est une trace jinja.
|
||||
// Pire, le 500 arrivait dans la branche « prompt trop long » de runChat, donc
|
||||
// loki compactait l'historique pour rien avant d'abandonner.
|
||||
//
|
||||
// D'où ce fichier : reconnaître ce refus, traduire le niveau demandé vers celui
|
||||
// que le gabarit accepte (le plus proche sur l'échelle), rejouer le tour SANS
|
||||
// toucher à l'historique, et retenir la traduction pour ce modèle afin de ne
|
||||
// pas repayer l'aller-retour à chaque message.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// effortLadder : les niveaux connus, du plus faible au plus fort. Sert à choisir
|
||||
// un remplaçant PROCHE quand le gabarit refuse la valeur demandée — « high »
|
||||
// devient « xhigh » (voisin immédiat) plutôt que « low ».
|
||||
//
|
||||
// La liste vaut pour tous les gabarits croisés jusqu'ici : gpt-oss lit
|
||||
// low/medium/high, Qwen3.8 lit low/medium/xhigh, certains ajoutent « minimal ».
|
||||
// Une valeur hors échelle n'est pas classable : on retire alors le champ plutôt
|
||||
// que d'inventer un niveau.
|
||||
var effortLadder = []string{"none", "minimal", "low", "medium", "high", "xhigh"}
|
||||
|
||||
// effortRank renvoie la position d'un niveau sur l'échelle, ou -1 s'il est
|
||||
// inconnu.
|
||||
func effortRank(v string) int {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
for i, s := range effortLadder {
|
||||
if s == v {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// effortWords capte les niveaux cités dans un message d'erreur. Les bornes de
|
||||
// mot évitent de lire « high » à l'intérieur de « xhigh ».
|
||||
var effortWords = regexp.MustCompile(`\b(none|minimal|low|medium|high|xhigh)\b`)
|
||||
|
||||
// effortRejection dit si le corps d'erreur renvoyé par llama-server est un refus
|
||||
// de la valeur de `reasoning_effort` par le gabarit, et renvoie les niveaux que
|
||||
// ce gabarit déclare accepter (éventuellement vide : tous ne les listent pas).
|
||||
//
|
||||
// La détection reste volontairement large — le texte vient du gabarit du modèle,
|
||||
// pas de llama.cpp, donc sa formulation change d'un modèle à l'autre. Le seul
|
||||
// invariant : il parle de reasoning effort et dit que la valeur ne va pas.
|
||||
func effortRejection(body string) (supported []string, ok bool) {
|
||||
low := strings.ToLower(body)
|
||||
if !strings.Contains(low, "reasoning effort") && !strings.Contains(low, "reasoning_effort") {
|
||||
return nil, false
|
||||
}
|
||||
refus := false
|
||||
for _, w := range []string{"unexpected", "unsupported", "not supported", "invalid", "unknown"} {
|
||||
if strings.Contains(low, w) {
|
||||
refus = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !refus {
|
||||
return nil, false
|
||||
}
|
||||
// Les niveaux acceptés sont annoncés APRÈS « supported » (« Supported types
|
||||
// are xhigh (default), medium, and low. »). Ne lire que cette fin de message
|
||||
// évite de prendre pour une liste le niveau refusé, cité juste avant.
|
||||
if i := strings.LastIndex(low, "supported"); i >= 0 {
|
||||
for _, m := range effortWords.FindAllString(low[i:], -1) {
|
||||
if effortRank(m) >= 0 && !slicesHas(supported, m) {
|
||||
supported = append(supported, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
return supported, true
|
||||
}
|
||||
|
||||
func slicesHas(list []string, v string) bool {
|
||||
for _, s := range list {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// nearestEffort traduit le niveau demandé vers le plus proche parmi ceux que le
|
||||
// gabarit accepte. Renvoie "" quand il n'y a rien de sensé à envoyer : le champ
|
||||
// est alors simplement retiré de la requête, et le gabarit reprend son défaut.
|
||||
//
|
||||
// À égalité de distance on prend le niveau le PLUS FORT : demander « haute » et
|
||||
// se retrouver avec « moyenne » dégrade la réponse en silence, alors qu'un cran
|
||||
// au-dessus ne coûte que du temps de génération.
|
||||
//
|
||||
// « none » n'est jamais traduit : c'est une consigne de couper le raisonnement,
|
||||
// pas une intensité. Un gabarit qui la refuse reçoit déjà `enable_thinking:
|
||||
// false` par chat_template_kwargs (backend_models.go), qui dit la même chose
|
||||
// dans une langue que tous comprennent.
|
||||
func nearestEffort(want string, supported []string) string {
|
||||
want = strings.ToLower(strings.TrimSpace(want))
|
||||
if want == "" || want == "none" {
|
||||
return ""
|
||||
}
|
||||
rank := effortRank(want)
|
||||
if rank < 0 {
|
||||
return ""
|
||||
}
|
||||
best, bestRank := "", -1
|
||||
for _, s := range supported {
|
||||
r := effortRank(s)
|
||||
if r < 0 || s == want {
|
||||
continue // s == want : le gabarit ne peut pas à la fois refuser et accepter
|
||||
}
|
||||
if best == "" || abs(r-rank) < abs(bestRank-rank) || (abs(r-rank) == abs(bestRank-rank) && r > bestRank) {
|
||||
best, bestRank = s, r
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// effortFromRejection combine les deux : à partir du corps d'erreur et du niveau
|
||||
// demandé, renvoie le niveau à réessayer ("" = ne rien envoyer) et true si le
|
||||
// refus a bien été reconnu.
|
||||
func effortFromRejection(body, want string) (string, bool) {
|
||||
supported, ok := effortRejection(body)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return nearestEffort(want, supported), true
|
||||
}
|
||||
|
||||
// effortFallbacks retient les traductions apprises, par modèle : sans ça chaque
|
||||
// message repaierait un aller-retour 500 avant de tomber sur la bonne valeur.
|
||||
// Clé = fichier de modèle + niveau demandé ; la valeur "" est significative
|
||||
// (= ne rien envoyer), d'où sync.Map plutôt qu'une map avec test de zéro.
|
||||
var effortFallbacks sync.Map
|
||||
|
||||
func effortKey(want string) string {
|
||||
return filepath.Base(strings.TrimSpace(ReadConfig()["MODEL"])) + "\x00" + want
|
||||
}
|
||||
|
||||
// effortResolve renvoie le niveau réellement envoyable pour la valeur demandée,
|
||||
// c'est-à-dire la traduction déjà apprise pour ce modèle s'il y en a une.
|
||||
func effortResolve(want string) string {
|
||||
if want == "" {
|
||||
return ""
|
||||
}
|
||||
if v, ok := effortFallbacks.Load(effortKey(want)); ok {
|
||||
return v.(string)
|
||||
}
|
||||
return want
|
||||
}
|
||||
|
||||
// effortRemember enregistre la traduction pour les messages suivants.
|
||||
func effortRemember(want, got string) {
|
||||
if want == "" {
|
||||
return
|
||||
}
|
||||
effortFallbacks.Store(effortKey(want), got)
|
||||
}
|
||||
|
||||
// logEffortFallback trace le repli sur stderr : sinon l'intensité choisie dans
|
||||
// l'interface n'est pas celle qui part au moteur, sans que rien ne le dise.
|
||||
func logEffortFallback(want, got string) {
|
||||
if got == "" {
|
||||
fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — champ retiré pour ce modèle\n", want)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — repli sur %q pour ce modèle\n", want, got)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Le message qui a motivé tout ce fichier : Qwen3.8-27B refuse « high », le
|
||||
// niveau que l'interface enregistre par défaut, et llama-server répond 500.
|
||||
const qwenRefus = `{"error":{"code":500,"message":"\n------------\nWhile executing CallExpression at line 49, column 28 in source:\n...', 'low') %}\n {{- raise_exception('Unexpected reasoning effort ' ~ reason...\n ^\nError: Jinja Exception: Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low.","type":"server_error"}}`
|
||||
|
||||
func TestEffortRejection(t *testing.T) {
|
||||
sup, ok := effortRejection(qwenRefus)
|
||||
if !ok {
|
||||
t.Fatal("refus de reasoning_effort non reconnu")
|
||||
}
|
||||
// « high » est cité AVANT la liste (c'est la valeur refusée) : le lire comme
|
||||
// une valeur acceptée renverrait loki sur la même erreur en boucle.
|
||||
want := map[string]bool{"xhigh": true, "medium": true, "low": true}
|
||||
if len(sup) != len(want) {
|
||||
t.Fatalf("niveaux acceptés = %v, attendu %v", sup, want)
|
||||
}
|
||||
for _, s := range sup {
|
||||
if !want[s] {
|
||||
t.Errorf("niveau %q lu à tort comme accepté (%v)", s, sup)
|
||||
}
|
||||
}
|
||||
// Les autres 500 (prompt trop long, appel d'outil illisible) ne doivent pas
|
||||
// passer par ce filet : ils ont le leur.
|
||||
for _, body := range []string{
|
||||
"",
|
||||
`{"error":{"code":500,"message":"the request exceeds the available context size"}}`,
|
||||
`{"error":{"code":500,"message":"Failed to parse tool call"}}`,
|
||||
// Parle bien de reasoning_effort mais ne le refuse pas.
|
||||
`{"error":{"code":500,"message":"reasoning_effort applied"}}`,
|
||||
} {
|
||||
if _, ok := effortRejection(body); ok {
|
||||
t.Errorf("corps %q pris à tort pour un refus d'intensité", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sans liste de niveaux acceptés, il n'y a rien à deviner : on retire le champ.
|
||||
func TestEffortRejectionSansListe(t *testing.T) {
|
||||
sup, ok := effortRejection("Error: Unknown reasoning effort 'xhigh'")
|
||||
if !ok {
|
||||
t.Fatal("refus non reconnu")
|
||||
}
|
||||
if len(sup) != 0 {
|
||||
t.Fatalf("niveaux acceptés = %v, attendu aucun", sup)
|
||||
}
|
||||
if got := nearestEffort("xhigh", sup); got != "" {
|
||||
t.Errorf("nearestEffort sans liste = %q, attendu \"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearestEffort(t *testing.T) {
|
||||
qwen := []string{"xhigh", "medium", "low"}
|
||||
oss := []string{"low", "medium", "high"}
|
||||
cases := []struct {
|
||||
want string
|
||||
supported []string
|
||||
expect string
|
||||
}{
|
||||
{"high", qwen, "xhigh"}, // voisin immédiat vers le haut
|
||||
{"xhigh", oss, "high"}, // voisin immédiat vers le bas
|
||||
{"medium", qwen, "low"}, // « medium » refusé quand même : on ne le renvoie pas
|
||||
{"low", []string{"medium", "high"}, "medium"},
|
||||
{"high", nil, ""}, // rien d'annoncé : on retire le champ
|
||||
{"", qwen, ""}, // auto : il n'y avait rien à envoyer
|
||||
{"none", qwen, ""}, // consigne de coupure, pas une intensité
|
||||
{"maximum", qwen, ""}, // hors échelle : on n'invente pas
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := nearestEffort(c.want, c.supported); got != c.expect {
|
||||
t.Errorf("nearestEffort(%q, %v) = %q, attendu %q", c.want, c.supported, got, c.expect)
|
||||
}
|
||||
}
|
||||
// À égalité de distance, le niveau le plus FORT : dégrader en silence est
|
||||
// pire que générer un peu plus longtemps.
|
||||
if got := nearestEffort("medium", []string{"low", "high"}); got != "high" {
|
||||
t.Errorf("égalité de distance = %q, attendu high", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffortFromRejection(t *testing.T) {
|
||||
got, ok := effortFromRejection(qwenRefus, "high")
|
||||
if !ok || got != "xhigh" {
|
||||
t.Fatalf("effortFromRejection = %q, %v ; attendu xhigh, true", got, ok)
|
||||
}
|
||||
if _, ok := effortFromRejection("boom", "high"); ok {
|
||||
t.Error("un 500 quelconque ne doit pas déclencher le repli d'intensité")
|
||||
}
|
||||
}
|
||||
|
||||
// La traduction apprise doit valoir pour les messages SUIVANTS (sinon chaque
|
||||
// message repaie un aller-retour 500) et rester attachée au modèle : un autre
|
||||
// modèle a un autre gabarit, donc d'autres niveaux.
|
||||
func TestEffortMemoire(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "/data/models/qwen.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := effortResolve("high"); got != "high" {
|
||||
t.Fatalf("sans refus connu : %q, attendu high", got)
|
||||
}
|
||||
effortRemember("high", "xhigh")
|
||||
if got := effortResolve("high"); got != "xhigh" {
|
||||
t.Errorf("après apprentissage : %q, attendu xhigh", got)
|
||||
}
|
||||
if err := WriteConfig(map[string]string{"MODEL": "/data/models/gpt-oss.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := effortResolve("high"); got != "high" {
|
||||
t.Errorf("autre modèle : %q, attendu high (gabarit inconnu)", got)
|
||||
}
|
||||
// Champ retiré ("") : c'est une réponse apprise, pas une absence de réponse.
|
||||
effortRemember("high", "")
|
||||
if got := effortResolve("high"); got != "" {
|
||||
t.Errorf("repli « champ retiré » : %q, attendu \"\"", got)
|
||||
}
|
||||
}
|
||||
+67
-14
@@ -40,15 +40,67 @@ import (
|
||||
// oaiSuffix est le domaine sous lequel on autorise l'émission de certificats.
|
||||
const oaiSuffix = ".oai.ajean.link"
|
||||
|
||||
// viaTunnelHeader marque une requête arrivée par le tunnel du relais (posé par
|
||||
// withLocalAuth, qui l'efface d'abord — un client ne peut donc pas le forger).
|
||||
// Il sert à garder la promesse du tunnel : hors du front TLS dédié, la surface
|
||||
// des complétions y reste fermée tant que oaiPublicEnabled() est faux.
|
||||
const viaTunnelHeader = "X-Loki-Via"
|
||||
|
||||
// sendOAIError répond au format d'erreur d'OpenAI. Les SDK officiels lisent
|
||||
// body.error.message ; un {"error":"…"} plat leur fait afficher un message vide,
|
||||
// et certains clients (LiteLLM, LangChain) échouent carrément au décodage.
|
||||
func sendOAIError(w http.ResponseWriter, status int, msg, typ, code string) {
|
||||
sendJSON(w, status, map[string]any{"error": map[string]any{
|
||||
"message": msg, "type": typ, "param": nil, "code": code,
|
||||
}})
|
||||
}
|
||||
|
||||
// mountOAI branche la surface compatible OpenAI sur le mux de Loki, protégée par
|
||||
// la clé des complétions (web_auth.go).
|
||||
//
|
||||
// C'est ce qui rend l'API joignable par le nom de domaine ou l'IP de
|
||||
// L'INTERFACE, sans publier de second port : le moteur peut rester sur la boucle
|
||||
// locale, et un reverse proxy devant Loki suffit pour le TLS. L'adresse annoncée
|
||||
// auparavant (celle du moteur, port 8080) n'était joignable dans aucun
|
||||
// déploiement en conteneur, où ce port n'est pas publié.
|
||||
//
|
||||
// ⚠️ On ne monte QUE /v1/. Surtout pas /health, /props, /metrics ni /slots — que
|
||||
// oaiHandler autorise pour le tunnel : ils divulgueraient le modèle chargé, la
|
||||
// taille de contexte et l'état des slots, sans authentification quand aucune clé
|
||||
// n'est définie. Le filtre interne d'oaiHandler reste en seconde barrière.
|
||||
func mountOAI(mux *http.ServeMux) {
|
||||
mux.Handle("/v1/", requireCompletionKey(oaiHandler()))
|
||||
}
|
||||
|
||||
// oaiHandler construit le reverse-proxy vers llama-server, restreint à la surface
|
||||
// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le SaaS envoie
|
||||
// la vraie clé (.api_key), que llama-server valide lui-même (--api-key).
|
||||
// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le client
|
||||
// envoie la vraie clé, que llama-server valide lui-même (--api-key). Elle est
|
||||
// donc vérifiée deux fois — par Loki puis par le moteur — et c'est voulu : le
|
||||
// moteur reste protégé même si on l'expose un jour en direct.
|
||||
func oaiHandler() http.Handler {
|
||||
llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}
|
||||
lp := httputil.NewSingleHostReverseProxy(llama)
|
||||
lp.FlushInterval = -1 // streaming SSE des complétions
|
||||
lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
|
||||
http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway)
|
||||
lp := &httputil.ReverseProxy{
|
||||
// FlushInterval négatif = on vide le tampon à chaque écriture : c'est ce
|
||||
// qui fait arriver les tokens un par un chez le client au lieu d'un bloc
|
||||
// en fin de génération.
|
||||
// ⚠️ Ne JAMAIS envelopper le ResponseWriter sur ce chemin (compteur,
|
||||
// journalisation…) : un wrapper qui n'implémente pas http.Flusher
|
||||
// retransforme le flux en réponse bufferisée, sans autre symptôme qu'une
|
||||
// attente inexplicable.
|
||||
FlushInterval: -1,
|
||||
// Rewrite (et non Director) : le port du moteur est relu À CHAQUE
|
||||
// REQUÊTE. Capturé à la construction, il figeait l'ancienne valeur dès
|
||||
// qu'on changeait PORT depuis l'interface, et le proxy visait dans le
|
||||
// vide jusqu'au redémarrage de Loki. Rewrite n'hérite pas non plus des
|
||||
// X-Forwarded-* entrants : un client ne peut pas les forger vers le
|
||||
// moteur.
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())})
|
||||
pr.Out.Host = pr.In.Host
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) {
|
||||
sendOAIError(w, http.StatusBadGateway,
|
||||
"moteur injoignable : "+e.Error(), "api_error", "upstream_unavailable")
|
||||
},
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
p := r.URL.Path
|
||||
@@ -56,7 +108,8 @@ func oaiHandler() http.Handler {
|
||||
lp.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.Error(w, "not found (endpoint OpenAI: /v1/*)", http.StatusNotFound)
|
||||
sendOAIError(w, http.StatusNotFound,
|
||||
"chemin inconnu — l'endpoint compatible OpenAI est /v1/*", "invalid_request_error", "not_found")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,14 +125,14 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
|
||||
return srv.Serve(tls.NewListener(rawLn, tlsCfg))
|
||||
}
|
||||
|
||||
// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette
|
||||
// machine. Piloté par l'UI et lu en direct → activable/coupable sans redémarrer
|
||||
// le service de lien.
|
||||
// oaiPublicEnabled indique si le front TLS du tunnel doit servir la surface
|
||||
// OpenAI. Drapeau désormais en LECTURE SEULE : plus aucune interface ne
|
||||
// l'allume, depuis que l'endpoint est servi par Loki sur son propre port (voir
|
||||
// mountOAI). Il ne subsiste que pour les installations migrées depuis AJEAN dont
|
||||
// le tunnel sert encore ce front — et pour son défaut, qui est le refus :
|
||||
// demuxTunnelStream ferme le flux quand il est absent.
|
||||
func oaiPublicEnabled() bool { return getBool(bkState, "oai_public") }
|
||||
|
||||
// setOAIPublic active (on) ou coupe (off) l'accès OpenAI public.
|
||||
func setOAIPublic(on bool) error { return putBool(bkState, "oai_public", on) }
|
||||
|
||||
// oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via
|
||||
// Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et
|
||||
// répond elle-même aux challenges ACME. La clé privée est stockée dans
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
package loki
|
||||
|
||||
// Tests de la surface compatible OpenAI servie par Loki (mountOAI).
|
||||
//
|
||||
// Elle est joignable partout où l'interface l'est : c'est tout l'intérêt, et
|
||||
// c'est aussi ce qui rend sa garde d'accès critique. Ces tests verrouillent les
|
||||
// quatre promesses : la clé est exigée quand elle existe, l'amont n'est jamais
|
||||
// appelé quand elle manque, l'en-tête du client arrive INTACT au moteur (qui la
|
||||
// revalide), et rien d'autre que /v1 n'est monté.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fauxMoteur monte un llama-server de comédie et pointe la configuration
|
||||
// dessus : le proxy vise 127.0.0.1:LLMPort(), c'est donc tout ce qu'il faut.
|
||||
// Renvoie le serveur et un pointeur sur les en-têtes de la dernière requête
|
||||
// reçue (nil tant qu'il n'a rien reçu).
|
||||
func fauxMoteur(t *testing.T, h http.HandlerFunc) (*httptest.Server, func() http.Header) {
|
||||
t.Helper()
|
||||
var vues http.Header
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
vues = r.Header.Clone()
|
||||
if h != nil {
|
||||
h(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"loki"}]}`))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
port := srv.URL[strings.LastIndexByte(srv.URL, ':')+1:]
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
t.Fatalf("port du faux moteur illisible : %q", srv.URL)
|
||||
}
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return srv, func() http.Header { return vues }
|
||||
}
|
||||
|
||||
// appelOAI joue une requête à travers la garde + le proxy, sans monter tout le
|
||||
// serveur web (newWebMux a des effets de bord : MCP, migrations, conversation).
|
||||
func appelOAI(t *testing.T, méthode, chemin, clé string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(méthode, chemin, nil)
|
||||
if clé != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+clé)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
requireCompletionKey(oaiHandler()).ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// erreurOAI décode le corps au format d'erreur d'OpenAI — celui que les SDK
|
||||
// savent présenter à l'utilisateur.
|
||||
func erreurOAI(t *testing.T, rec *httptest.ResponseRecorder) (message, code string) {
|
||||
t.Helper()
|
||||
var body struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("corps illisible (%d) : %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
return body.Error.Message, body.Error.Code
|
||||
}
|
||||
|
||||
// Sans clé configurée, l'endpoint est ouvert — comme llama-server sans
|
||||
// --api-key. Le panneau et le démarrage le disent en rouge ; ici on vérifie
|
||||
// seulement que le comportement est bien celui-là, et pas un refus silencieux.
|
||||
func TestOAISansCleLaissePasser(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
rec := appelOAI(t, "GET", "/v1/models", "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"loki"`) {
|
||||
t.Errorf("réponse du moteur non relayée : %s", rec.Body.String())
|
||||
}
|
||||
if vues() == nil {
|
||||
t.Error("le moteur n'a pas été appelé")
|
||||
}
|
||||
}
|
||||
|
||||
// La bonne clé passe, ET l'en-tête arrive INTACT au moteur : c'est le contrat.
|
||||
// llama-server la valide lui aussi (--api-key), il reste donc protégé même si
|
||||
// on l'expose un jour en direct.
|
||||
func TestOAIBonneCleEtEnTeteRelayee(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
if err := writeAPIKey("sk-loki-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := appelOAI(t, "GET", "/v1/models", "sk-loki-test")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := vues().Get("Authorization"); got != "Bearer sk-loki-test" {
|
||||
t.Errorf("en-tête transmis au moteur = %q, attendu la clé du client", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Mauvaise clé, et clé absente : 401 au format OpenAI, et le moteur n'est JAMAIS
|
||||
// appelé — une garde qui laisse passer la requête avant de refuser ne protège
|
||||
// rien.
|
||||
func TestOAICleRefusee(t *testing.T) {
|
||||
for _, cas := range []struct{ nom, envoyée string }{
|
||||
{"mauvaise clé", "sk-loki-pasbonne"},
|
||||
{"aucune clé", ""},
|
||||
} {
|
||||
t.Run(cas.nom, func(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
if err := writeAPIKey("sk-loki-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := appelOAI(t, "POST", "/v1/chat/completions", cas.envoyée)
|
||||
if rec.Code != 401 {
|
||||
t.Fatalf("code %d, attendu 401 — corps %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if rec.Header().Get("WWW-Authenticate") == "" {
|
||||
t.Error("en-tête WWW-Authenticate absent")
|
||||
}
|
||||
msg, code := erreurOAI(t, rec)
|
||||
if code != "invalid_api_key" || msg == "" {
|
||||
t.Errorf("erreur = {code:%q, message:%q}", code, msg)
|
||||
}
|
||||
if vues() != nil {
|
||||
t.Error("le moteur a été appelé malgré le refus")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// La clé résiduelle de config.env fait foi quand la base n'en a pas : c'est
|
||||
// celle que le moteur exige (backend_serve.go). Sans ce miroir, Loki laissait
|
||||
// passer et llama refusait — un 401 que rien n'expliquait.
|
||||
func TestOAICleResiduelleDeConfig(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
if err := SetConfigKey("API_KEY", "sk-ancienne"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := appelOAI(t, "GET", "/v1/models", ""); rec.Code != 401 {
|
||||
t.Fatalf("sans clé : code %d, attendu 401", rec.Code)
|
||||
}
|
||||
if vues() != nil {
|
||||
t.Fatal("le moteur a été appelé malgré le refus")
|
||||
}
|
||||
if rec := appelOAI(t, "GET", "/v1/models", "sk-ancienne"); rec.Code != 200 {
|
||||
t.Fatalf("avec la clé de config.env : code %d, attendu 200", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Un préflight CORS ne porte jamais d'Authorization. Le refuser casserait tout
|
||||
// client tiers qui tourne dans un navigateur.
|
||||
func TestOAIPreflightPasseSansCle(t *testing.T) {
|
||||
testHome(t)
|
||||
_, vues := fauxMoteur(t, nil)
|
||||
if err := writeAPIKey("sk-loki-test"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rec := appelOAI(t, "OPTIONS", "/v1/chat/completions", ""); rec.Code == 401 {
|
||||
t.Fatalf("préflight refusé : %s", rec.Body.String())
|
||||
}
|
||||
if vues() == nil {
|
||||
t.Error("le préflight n'a pas atteint le moteur, qui répond ses en-têtes CORS")
|
||||
}
|
||||
}
|
||||
|
||||
// Seul /v1/ est monté. /metrics, /props et /slots divulgueraient le modèle
|
||||
// chargé et l'état du moteur — sans clé, à qui joint l'adresse.
|
||||
func TestOAIMonteSeulementV1(t *testing.T) {
|
||||
testHome(t)
|
||||
mux := http.NewServeMux()
|
||||
mountOAI(mux)
|
||||
témoin := false
|
||||
mux.HandleFunc("/", func(http.ResponseWriter, *http.Request) { témoin = true })
|
||||
|
||||
for _, chemin := range []string{"/metrics", "/props", "/slots", "/api/ping", "/"} {
|
||||
témoin = false
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", chemin, nil))
|
||||
if !témoin {
|
||||
t.Errorf("%s n'a pas été servi par le handler racine — il est passé à l'endpoint OpenAI", chemin)
|
||||
}
|
||||
}
|
||||
// …et /v1/, lui, ne va PAS au handler racine.
|
||||
témoin = false
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil))
|
||||
if témoin {
|
||||
t.Error("/v1/models a été servi par le handler racine")
|
||||
}
|
||||
}
|
||||
|
||||
// Le streaming doit arriver en morceaux. Si FlushInterval régresse, le premier
|
||||
// événement n'arrive qu'à la fin de la génération — invisible en test unitaire
|
||||
// classique, d'où ce test qui bloque l'amont entre deux écritures.
|
||||
func TestOAIStreamingArriveEnMorceaux(t *testing.T) {
|
||||
testHome(t)
|
||||
débloque := make(chan struct{})
|
||||
fauxMoteur(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
t.Error("le faux moteur ne sait pas vider son tampon")
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("data: un\n\n"))
|
||||
fl.Flush()
|
||||
<-débloque
|
||||
_, _ = w.Write([]byte("data: deux\n\n"))
|
||||
fl.Flush()
|
||||
})
|
||||
|
||||
front := httptest.NewServer(requireCompletionKey(oaiHandler()))
|
||||
defer front.Close()
|
||||
defer close(débloque)
|
||||
|
||||
resp, err := http.Get(front.URL + "/v1/chat/completions")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
lu := make(chan string, 1)
|
||||
go func() {
|
||||
ligne, _ := bufio.NewReader(resp.Body).ReadString('\n')
|
||||
lu <- ligne
|
||||
}()
|
||||
select {
|
||||
case ligne := <-lu:
|
||||
if !strings.Contains(ligne, "un") {
|
||||
t.Fatalf("première ligne = %q", ligne)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("rien reçu avant la fin de la génération : le flux est bufferisé")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package loki
|
||||
|
||||
// lsp.go — client LSP minimal pour le VÉRIFICATEUR DE CODE : après chaque
|
||||
// write/edit en mode code, les diagnostics du serveur de langage (erreurs de
|
||||
// compilation, types, imports) sont ajoutés AU RÉSULTAT DE L'OUTIL — le modèle
|
||||
// apprend immédiatement que son code ne compile pas, sans lancer de build.
|
||||
// Repris de l'intégration LSP d'OpenFox (manager + languages.json — voir
|
||||
// NOTICE.md), réécrit en Go réduit à l'essentiel : initialize, didOpen/
|
||||
// didChange, publishDiagnostics. Pas de complétion, pas de hover — Loki n'est
|
||||
// pas un éditeur.
|
||||
//
|
||||
// La table langage → serveur vit dans lsp_languages.json (embarquée). Un
|
||||
// serveur absent du PATH désactive silencieusement son langage : le mode code
|
||||
// marche sans LSP, il vérifie juste moins tôt.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed lsp_languages.json
|
||||
var lspLanguagesJSON []byte
|
||||
|
||||
type lspLanguage struct {
|
||||
ID string `json:"id"`
|
||||
Command []string `json:"command"`
|
||||
Extensions []string `json:"extensions"`
|
||||
}
|
||||
|
||||
var (
|
||||
lspLangsOnce sync.Once
|
||||
lspLangs []lspLanguage
|
||||
)
|
||||
|
||||
func lspLanguages() []lspLanguage {
|
||||
lspLangsOnce.Do(func() {
|
||||
_ = json.Unmarshal(lspLanguagesJSON, &lspLangs)
|
||||
})
|
||||
return lspLangs
|
||||
}
|
||||
|
||||
// lspLangFor renvoie la config du langage d'un fichier, nil si aucun.
|
||||
func lspLangFor(path string) *lspLanguage {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range lspLanguages() {
|
||||
for _, e := range lspLangs[i].Extensions {
|
||||
if e == ext {
|
||||
return &lspLangs[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Serveur -----------------------------------------------------------------
|
||||
|
||||
const (
|
||||
lspDiagWait = 2500 * time.Millisecond // attente max des diagnostics
|
||||
lspIdleTimeout = 5 * time.Minute // arrêt d'un serveur inactif
|
||||
lspMaxDiags = 10 // diagnostics montrés au modèle
|
||||
)
|
||||
|
||||
type lspServer struct {
|
||||
lang string
|
||||
root string
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
mu sync.Mutex // sérialise les requêtes (un didOpen+attente à la fois)
|
||||
nextID int
|
||||
version map[string]int // uri → version de document
|
||||
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
|
||||
diagMu sync.Mutex
|
||||
diags map[string][]lspDiagnostic
|
||||
diagCh chan string // uri fraîchement publié
|
||||
lastUsed time.Time
|
||||
dead bool
|
||||
}
|
||||
|
||||
type lspDiagnostic struct {
|
||||
Range struct {
|
||||
Start struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"start"`
|
||||
} `json:"range"`
|
||||
Severity int `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
var (
|
||||
lspMu sync.Mutex
|
||||
lspServers = map[string]*lspServer{} // clé : lang + racine
|
||||
)
|
||||
|
||||
// lspDiagBlock : le bloc à AJOUTER au résultat d'un write/edit réussi.
|
||||
// "" si pas de mode code, pas de serveur pour ce langage, ou aucun diagnostic.
|
||||
func lspDiagBlock(absPath string, caps Caps) string {
|
||||
if !caps.Code {
|
||||
return ""
|
||||
}
|
||||
diags := lspDiagnosticsFor(absPath)
|
||||
if diags == "" {
|
||||
return ""
|
||||
}
|
||||
return "\n\n[diagnostics LSP]\n" + diags
|
||||
}
|
||||
|
||||
// lspDiagnosticsFor ouvre (ou met à jour) le document auprès du serveur de son
|
||||
// langage et attend ses diagnostics. Best-effort borné : au pire lspDiagWait.
|
||||
func lspDiagnosticsFor(absPath string) string {
|
||||
lang := lspLangFor(absPath)
|
||||
if lang == nil {
|
||||
return ""
|
||||
}
|
||||
if _, err := exec.LookPath(lang.Command[0]); err != nil {
|
||||
return "" // serveur non installé : le mode code marche sans
|
||||
}
|
||||
root := agentCwd()
|
||||
// Le dépôt cloné est la vraie racine projet quand le fichier est dedans :
|
||||
// gopls a besoin du go.mod, tsserver du tsconfig.
|
||||
if r := projectRootFor(absPath, root); r != "" {
|
||||
root = r
|
||||
}
|
||||
srv, err := lspGet(lang, root)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
diags, ok := srv.check(absPath)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if len(diags) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(diags) > lspMaxDiags {
|
||||
diags = diags[:lspMaxDiags]
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, d := range diags {
|
||||
sev := "info"
|
||||
switch d.Severity {
|
||||
case 1:
|
||||
sev = "error"
|
||||
case 2:
|
||||
sev = "warning"
|
||||
}
|
||||
fmt.Fprintf(&b, "%d:%d [%s] %s\n", d.Range.Start.Line+1, d.Range.Start.Character+1, sev, strings.TrimSpace(d.Message))
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// projectRootFor remonte du fichier vers ws en cherchant un marqueur de racine
|
||||
// de projet. Renvoie "" si rien trouvé au-dessus du fichier (dans la borne ws).
|
||||
func projectRootFor(absPath, ws string) string {
|
||||
dir := filepath.Dir(absPath)
|
||||
for {
|
||||
for _, marker := range []string{"go.mod", "package.json", "pyproject.toml", ".git"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
if rel, ok := relWithin(ws, dir); !ok || rel == "" || rel == "." {
|
||||
return ""
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return ""
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// lspGet renvoie le serveur (lang, racine), démarré au besoin.
|
||||
func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
key := lang.ID + "\x00" + root
|
||||
lspMu.Lock()
|
||||
defer lspMu.Unlock()
|
||||
if s := lspServers[key]; s != nil && !s.dead {
|
||||
return s, nil
|
||||
}
|
||||
s, err := lspStart(lang, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lspServers[key] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
cmd := exec.Command(lang.Command[0], lang.Command[1:]...)
|
||||
cmd.Dir = root
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd.Stderr = io.Discard
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &lspServer{
|
||||
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{},
|
||||
diagCh: make(chan string, 16), lastUsed: time.Now(),
|
||||
}
|
||||
go s.readLoop(stdout)
|
||||
go s.idleWatch()
|
||||
// initialize / initialized. La réponse est consommée par readLoop ; on
|
||||
// n'attend pas les capacités — on n'utilise que les diagnostics.
|
||||
s.send("initialize", map[string]any{
|
||||
"processId": nil,
|
||||
"rootUri": fileURI(root),
|
||||
"capabilities": map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"publishDiagnostics": map[string]any{},
|
||||
"synchronization": map[string]any{"didSave": false},
|
||||
},
|
||||
},
|
||||
"workspaceFolders": []map[string]any{{"uri": fileURI(root), "name": filepath.Base(root)}},
|
||||
}, true)
|
||||
s.notify("initialized", map[string]any{})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// check ouvre (ou met à jour) le document et attend ses diagnostics.
|
||||
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.dead {
|
||||
return nil, false
|
||||
}
|
||||
s.lastUsed = time.Now()
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
uri := fileURI(absPath)
|
||||
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
|
||||
s.diagMu.Lock()
|
||||
delete(s.diags, uri)
|
||||
s.diagMu.Unlock()
|
||||
v, open := s.version[uri]
|
||||
if !open {
|
||||
s.version[uri] = 1
|
||||
s.notify("textDocument/didOpen", map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"uri": uri, "languageId": s.lang, "version": 1, "text": string(content),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
v++
|
||||
s.version[uri] = v
|
||||
s.notify("textDocument/didChange", map[string]any{
|
||||
"textDocument": map[string]any{"uri": uri, "version": v},
|
||||
"contentChanges": []map[string]any{{"text": string(content)}},
|
||||
})
|
||||
}
|
||||
// Attend la publication pour CET uri, bornée.
|
||||
deadline := time.After(lspDiagWait)
|
||||
for {
|
||||
select {
|
||||
case got := <-s.diagCh:
|
||||
if got != uri {
|
||||
continue
|
||||
}
|
||||
s.diagMu.Lock()
|
||||
d := s.diags[uri]
|
||||
s.diagMu.Unlock()
|
||||
return d, true
|
||||
case <-deadline:
|
||||
// Pas de publication à temps : certains serveurs ne publient rien
|
||||
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
|
||||
s.diagMu.Lock()
|
||||
d := s.diags[uri]
|
||||
s.diagMu.Unlock()
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop parse les messages LSP (en-têtes Content-Length) et garde les
|
||||
// publishDiagnostics ; tout le reste est ignoré.
|
||||
func (s *lspServer) readLoop(r io.Reader) {
|
||||
br := bufio.NewReaderSize(r, 1<<20)
|
||||
for {
|
||||
length := 0
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
s.markDead()
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
|
||||
fmt.Sscanf(strings.TrimSpace(v), "%d", &length)
|
||||
}
|
||||
}
|
||||
if length <= 0 || length > 32<<20 {
|
||||
s.markDead()
|
||||
return
|
||||
}
|
||||
buf := make([]byte, length)
|
||||
if _, err := io.ReadFull(br, buf); err != nil {
|
||||
s.markDead()
|
||||
return
|
||||
}
|
||||
var msg struct {
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
URI string `json:"uri"`
|
||||
Diagnostics []lspDiagnostic `json:"diagnostics"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if json.Unmarshal(buf, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
if msg.Method == "textDocument/publishDiagnostics" {
|
||||
s.diagMu.Lock()
|
||||
s.diags[msg.Params.URI] = msg.Params.Diagnostics
|
||||
s.diagMu.Unlock()
|
||||
select {
|
||||
case s.diagCh <- msg.Params.URI:
|
||||
default: // personne n'attend : la publication reste dans s.diags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *lspServer) idleWatch() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
s.mu.Lock()
|
||||
idle := time.Since(s.lastUsed) > lspIdleTimeout
|
||||
dead := s.dead
|
||||
s.mu.Unlock()
|
||||
if dead {
|
||||
return
|
||||
}
|
||||
if idle {
|
||||
s.shutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *lspServer) shutdown() {
|
||||
s.markDead()
|
||||
if s.cmd.Process != nil {
|
||||
// shutdown/exit protocolaires seraient plus polis, mais le process peut
|
||||
// être déjà figé — kill borné est fiable et le serveur n'a pas d'état.
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
lspMu.Lock()
|
||||
delete(lspServers, s.lang+"\x00"+s.root)
|
||||
lspMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *lspServer) markDead() {
|
||||
s.mu.Lock()
|
||||
s.dead = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *lspServer) send(method string, params any, isRequest bool) {
|
||||
s.nextID++
|
||||
msg := map[string]any{"jsonrpc": "2.0", "method": method, "params": params}
|
||||
if isRequest {
|
||||
msg["id"] = s.nextID
|
||||
}
|
||||
s.writeMsg(msg)
|
||||
}
|
||||
|
||||
func (s *lspServer) notify(method string, params any) { s.send(method, params, false) }
|
||||
|
||||
func (s *lspServer) writeMsg(msg map[string]any) {
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = fmt.Fprintf(s.stdin, "Content-Length: %d\r\n\r\n%s", len(b), b)
|
||||
if err != nil {
|
||||
s.markDead()
|
||||
}
|
||||
}
|
||||
|
||||
// fileURI convertit un chemin absolu en URI file://.
|
||||
func fileURI(path string) string {
|
||||
p := filepath.ToSlash(path)
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p // Windows : C:\x → /C:/x
|
||||
}
|
||||
return "file://" + p
|
||||
}
|
||||
|
||||
// Pas d'arrêt global : les serveurs LSP sont des enfants du process loki et
|
||||
// meurent avec lui (et l'inactivité de 5 min couvre le reste — idleWatch).
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"id": "go",
|
||||
"command": ["gopls", "serve"],
|
||||
"extensions": [".go"]
|
||||
},
|
||||
{
|
||||
"id": "typescript",
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"command": ["pyright-langserver", "--stdio"],
|
||||
"extensions": [".py"]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,157 @@
|
||||
package loki
|
||||
|
||||
// Catalogue MCP embarqué.
|
||||
//
|
||||
// Ajouter un serveur MCP demandait jusqu'ici d'écrire soi-même la commande et
|
||||
// ses arguments (`npx -y @scope/paquet …`) dans la modale. Le catalogue fournit
|
||||
// une liste de serveurs connus, déjà renseignés : un clic préremplit la modale,
|
||||
// l'utilisateur relit la commande et enregistre.
|
||||
//
|
||||
// Le catalogue est un JSON EMBARQUÉ dans le binaire (go:embed), pas un appel à
|
||||
// un annuaire distant : cohérent avec un Loki qui tourne hors ligne, et rien de
|
||||
// ce qui s'exécute sur la machine ne vient du réseau. Pour proposer un serveur
|
||||
// de plus : éditer mcp_catalog.json et recompiler.
|
||||
//
|
||||
// SÉCURITÉ : le catalogue ne fait qu'AFFICHER une commande. Il n'installe ni
|
||||
// n'exécute rien tout seul — c'est l'enregistrement du serveur par
|
||||
// l'utilisateur, via la modale existante, qui décide. Même niveau de confiance
|
||||
// que l'outil `bash` du mode agent (voir l'en-tête de mcp_config.go).
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed mcp_catalog.json
|
||||
var mcpCatalogRaw []byte
|
||||
|
||||
// MCPCatalogEntry décrit un serveur proposé par le catalogue. Les champs de
|
||||
// transport reprennent ceux de MCPServerConfig : l'UI peut les recopier tels
|
||||
// quels dans la modale d'ajout.
|
||||
type MCPCatalogEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
|
||||
// Runtime : binaire nécessaire pour lancer le serveur ("node" → npx,
|
||||
// "python" → uvx). Sert à signaler une entrée non installable ici.
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
|
||||
// Secrets : variables d'environnement que l'utilisateur DOIT renseigner
|
||||
// (jetons, clés d'API). L'UI les met en évidence ; elles sont volontairement
|
||||
// vides dans le catalogue.
|
||||
Secrets []string `json:"secrets,omitempty"`
|
||||
|
||||
// Renseignés à la lecture, pas dans le fichier.
|
||||
Installed bool `json:"installed"`
|
||||
RuntimeOK bool `json:"runtimeOk"`
|
||||
}
|
||||
|
||||
type mcpCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Servers []MCPCatalogEntry `json:"servers"`
|
||||
}
|
||||
|
||||
var (
|
||||
mcpCatalogOnce sync.Once
|
||||
mcpCatalogParsed []MCPCatalogEntry
|
||||
mcpCatalogParseEr error
|
||||
)
|
||||
|
||||
// mcpCatalogEntries décode le JSON embarqué une seule fois. Le contenu est figé
|
||||
// dans le binaire : inutile de le relire à chaque appel.
|
||||
func mcpCatalogEntries() ([]MCPCatalogEntry, error) {
|
||||
mcpCatalogOnce.Do(func() {
|
||||
var f mcpCatalogFile
|
||||
if err := json.Unmarshal(mcpCatalogRaw, &f); err != nil {
|
||||
mcpCatalogParseEr = fmt.Errorf("catalogue MCP illisible : %w", err)
|
||||
return
|
||||
}
|
||||
mcpCatalogParsed = f.Servers
|
||||
})
|
||||
return mcpCatalogParsed, mcpCatalogParseEr
|
||||
}
|
||||
|
||||
// mcpRuntimeAvailable indique si le binaire qui lance ce type de serveur est
|
||||
// présent. L'image conteneur embarque Node ; uv/uvx peut manquer.
|
||||
func mcpRuntimeAvailable(runtime string) bool {
|
||||
switch runtime {
|
||||
case "node":
|
||||
return lookPathOK("npx")
|
||||
case "python":
|
||||
return lookPathOK("uvx")
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func lookPathOK(bin string) bool {
|
||||
_, err := exec.LookPath(bin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// MCPCatalog renvoie les entrées du catalogue, chemins résolus et marquées
|
||||
// « déjà installée » (un serveur configuré porte le même nom que l'entrée).
|
||||
func MCPCatalog() ([]MCPCatalogEntry, error) {
|
||||
entries, err := mcpCatalogEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configured, err := LoadMCPConfig()
|
||||
if err != nil {
|
||||
// Une config MCP illisible ne doit pas vider le catalogue : on affiche
|
||||
// les entrées sans savoir lesquelles sont déjà là.
|
||||
configured = map[string]MCPServerConfig{}
|
||||
}
|
||||
|
||||
out := make([]MCPCatalogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
e.Args = expandCatalogSlice(e.Args)
|
||||
e.Env = expandCatalogMap(e.Env)
|
||||
_, e.Installed = configured[e.ID]
|
||||
e.RuntimeOK = mcpRuntimeAvailable(e.Runtime)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// expandCatalogToken remplace les jetons de chemin du catalogue. Seul
|
||||
// {workspace} existe : c'est le dossier de travail de l'agent, le seul chemin
|
||||
// qu'un serveur du catalogue a une raison légitime de recevoir par défaut.
|
||||
func expandCatalogToken(s string) string {
|
||||
if !strings.Contains(s, "{workspace}") {
|
||||
return s
|
||||
}
|
||||
return strings.ReplaceAll(s, "{workspace}", workspaceDir())
|
||||
}
|
||||
|
||||
func expandCatalogSlice(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = expandCatalogToken(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expandCatalogMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = expandCatalogToken(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "Catalogue MCP embarqué (hors ligne, aucun annuaire distant). Éditable à la main : ajoute une entrée pour la proposer dans l'interface, puis relance le build. Le jeton {workspace} est remplacé par le dossier de travail de l'agent. Les entrées « uvx --with mcp<2 … » visent des serveurs publiés avant le SDK MCP 2.0 : sans cet épinglage, ils plantent au démarrage (ImportError McpError).",
|
||||
"servers": [
|
||||
{
|
||||
"id": "filesystem",
|
||||
"name": "Filesystem",
|
||||
"category": "Fichiers",
|
||||
"description": "Lecture, écriture et recherche de fichiers, limitées aux dossiers passés en argument.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "{workspace}"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Git",
|
||||
"category": "Code",
|
||||
"description": "Historique, diff et statut d'un dépôt Git ; commits et branches.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-git", "--repository", "{workspace}"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "fetch",
|
||||
"name": "Fetch",
|
||||
"category": "Web",
|
||||
"description": "Télécharge une URL et la convertit en Markdown pour le modèle.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-fetch"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "memory",
|
||||
"name": "Memory",
|
||||
"category": "Agent",
|
||||
"description": "Mémoire sous forme de graphe de connaissances (entités, relations, observations).",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-memory"],
|
||||
"env": { "MEMORY_FILE_PATH": "{workspace}/mcp-memory.json" },
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "sequential-thinking",
|
||||
"name": "Sequential Thinking",
|
||||
"category": "Agent",
|
||||
"description": "Structure le raisonnement en étapes révisables. Utile aux petits modèles locaux.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "time",
|
||||
"name": "Time",
|
||||
"category": "Utilitaires",
|
||||
"description": "Heure courante et conversions de fuseaux horaires.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-time"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "sqlite",
|
||||
"name": "SQLite",
|
||||
"category": "Données",
|
||||
"description": "Interroge et modifie une base SQLite locale.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/sqlite",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-sqlite", "--db-path", "{workspace}/agent.db"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "everything",
|
||||
"name": "Everything (test)",
|
||||
"category": "Utilitaires",
|
||||
"description": "Serveur de référence exposant toutes les primitives MCP. Sert à vérifier l'intégration.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-everything"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "duckduckgo",
|
||||
"name": "DuckDuckGo",
|
||||
"category": "Web",
|
||||
"description": "Recherche web via DuckDuckGo, sans clé d'API.",
|
||||
"homepage": "https://pypi.org/project/duckduckgo-mcp-server/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["duckduckgo-mcp-server"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "playwright",
|
||||
"name": "Playwright",
|
||||
"category": "Web",
|
||||
"description": "Pilote un navigateur (navigation, clics, formulaires) via l'arbre d'accessibilité.",
|
||||
"homepage": "https://github.com/microsoft/playwright-mcp",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--headless"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "puppeteer",
|
||||
"name": "Puppeteer",
|
||||
"category": "Web",
|
||||
"description": "Automatisation d'un Chrome sans interface (alternative à Playwright).",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/puppeteer",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "context7",
|
||||
"name": "Context7",
|
||||
"category": "Code",
|
||||
"description": "Documentation à jour des bibliothèques, injectée directement dans le contexte.",
|
||||
"homepage": "https://github.com/upstash/context7",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "github",
|
||||
"name": "GitHub",
|
||||
"category": "Code",
|
||||
"description": "Dépôts, issues et pull requests via l'API GitHub. Nécessite un jeton personnel.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" },
|
||||
"secrets": ["GITHUB_PERSONAL_ACCESS_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "postgres",
|
||||
"name": "PostgreSQL",
|
||||
"category": "Données",
|
||||
"description": "Requêtes SQL en lecture seule et inspection du schéma d'une base PostgreSQL.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-postgres",
|
||||
"postgresql://utilisateur:motdepasse@hote:5432/base"
|
||||
],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"name": "Docker",
|
||||
"category": "Infra",
|
||||
"description": "Gère conteneurs et stacks compose. Nécessite l'accès au socket Docker.",
|
||||
"homepage": "https://pypi.org/project/docker-mcp/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "docker-mcp"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "brave-search",
|
||||
"name": "Brave Search",
|
||||
"category": "Web",
|
||||
"description": "Recherche web et locale via l'API Brave. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env": { "BRAVE_API_KEY": "" },
|
||||
"secrets": ["BRAVE_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "firecrawl",
|
||||
"name": "Firecrawl",
|
||||
"category": "Web",
|
||||
"description": "Scraping et crawl de sites avec extraction structurée. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/firecrawl/firecrawl-mcp-server",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "firecrawl-mcp"],
|
||||
"env": { "FIRECRAWL_API_KEY": "" },
|
||||
"secrets": ["FIRECRAWL_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "notion",
|
||||
"name": "Notion",
|
||||
"category": "Productivité",
|
||||
"description": "Lecture et écriture de pages et bases Notion. Nécessite un jeton d'intégration.",
|
||||
"homepage": "https://github.com/makenotion/notion-mcp-server",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@notionhq/notion-mcp-server"],
|
||||
"env": { "NOTION_TOKEN": "" },
|
||||
"secrets": ["NOTION_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "slack",
|
||||
"name": "Slack",
|
||||
"category": "Productivité",
|
||||
"description": "Lecture des canaux et envoi de messages Slack. Nécessite un jeton de bot.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/slack",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": { "SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": "" },
|
||||
"secrets": ["SLACK_BOT_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "google-maps",
|
||||
"name": "Google Maps",
|
||||
"category": "Utilitaires",
|
||||
"description": "Géocodage, itinéraires et recherche de lieux. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/google-maps",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-google-maps"],
|
||||
"env": { "GOOGLE_MAPS_API_KEY": "" },
|
||||
"secrets": ["GOOGLE_MAPS_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "sentry",
|
||||
"name": "Sentry",
|
||||
"category": "Infra",
|
||||
"description": "Récupère les issues et traces d'erreur d'un projet Sentry.",
|
||||
"homepage": "https://pypi.org/project/mcp-server-sentry/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-sentry", "--auth-token", "REMPLACE_MOI"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le catalogue est embarqué : s'il ne décode pas, le binaire livré est cassé et
|
||||
// le panneau MCP part vide. Ce test attrape une virgule en trop au build.
|
||||
func TestMCPCatalogDecodes(t *testing.T) {
|
||||
entries, err := mcpCatalogEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("catalogue illisible : %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("catalogue vide")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.ID == "" || e.Name == "" || e.Command == "" {
|
||||
t.Errorf("entrée incomplète : %+v", e)
|
||||
}
|
||||
if seen[e.ID] {
|
||||
t.Errorf("id dupliqué : %s", e.ID)
|
||||
}
|
||||
seen[e.ID] = true
|
||||
if e.Description == "" {
|
||||
t.Errorf("%s : description manquante", e.ID)
|
||||
}
|
||||
// Un secret annoncé doit exister comme variable d'environnement, sinon
|
||||
// l'UI met en avant un champ que l'utilisateur ne peut pas remplir.
|
||||
for _, s := range e.Secrets {
|
||||
if _, ok := e.Env[s]; !ok {
|
||||
t.Errorf("%s : secret %q absent de env", e.ID, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les jetons {workspace} doivent disparaître : un serveur lancé avec un chemin
|
||||
// littéral « {workspace} » démarre puis échoue à la première lecture.
|
||||
func TestMCPCatalogExpandsWorkspaceToken(t *testing.T) {
|
||||
entries, err := MCPCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("MCPCatalog : %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
for _, a := range e.Args {
|
||||
if strings.Contains(a, "{workspace}") {
|
||||
t.Errorf("%s : jeton non résolu dans args : %s", e.ID, a)
|
||||
}
|
||||
}
|
||||
for k, v := range e.Env {
|
||||
if strings.Contains(v, "{workspace}") {
|
||||
t.Errorf("%s : jeton non résolu dans env[%s] : %s", e.ID, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningEffortValue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
" ": "",
|
||||
"auto": "",
|
||||
"default": "",
|
||||
"off": "none",
|
||||
"none": "none",
|
||||
"false": "none",
|
||||
"0": "none",
|
||||
"low": "low",
|
||||
"MEDIUM": "medium",
|
||||
" High ": "high",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := reasoningEffortValue(in); got != want {
|
||||
t.Errorf("reasoningEffortValue(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningExplicitlyOff(t *testing.T) {
|
||||
// Clé absente ou vide = pas de consigne, surtout pas une interdiction.
|
||||
for _, v := range []string{"", " ", "on", "auto", "deepseek"} {
|
||||
if reasoningExplicitlyOff(v) {
|
||||
t.Errorf("reasoningExplicitlyOff(%q) = true, attendu false", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"off", "none", "0", "false", " Disabled "} {
|
||||
if !reasoningExplicitlyOff(v) {
|
||||
t.Errorf("reasoningExplicitlyOff(%q) = false, attendu true", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningTemplateKwargs(t *testing.T) {
|
||||
// « aucune » doit parler aux gabarits hybrides, pas seulement à ceux qui
|
||||
// lisent reasoning_effort : sinon le modèle réfléchit quand même.
|
||||
if got := reasoningTemplateKwargs(false, "none"); got == nil || got["enable_thinking"] != false {
|
||||
t.Errorf("effort none : %v, attendu enable_thinking=false", got)
|
||||
}
|
||||
if got := reasoningTemplateKwargs(true, ""); got == nil || got["enable_thinking"] != false {
|
||||
t.Errorf("REASONING=off : %v, attendu enable_thinking=false", got)
|
||||
}
|
||||
// Un niveau explicite voyage aussi dans les kwargs : les binaires anciens ne
|
||||
// relaient pas le champ de haut niveau au gabarit.
|
||||
if got := reasoningTemplateKwargs(false, "high"); got == nil || got["reasoning_effort"] != "high" {
|
||||
t.Errorf("effort high : %v, attendu reasoning_effort=high", got)
|
||||
}
|
||||
if _, ok := reasoningTemplateKwargs(false, "high")["enable_thinking"]; ok {
|
||||
t.Error("un niveau explicite ne doit pas couper le raisonnement")
|
||||
}
|
||||
// Auto = aucune consigne : on n'écrit rien dans la requête.
|
||||
if got := reasoningTemplateKwargs(false, ""); got != nil {
|
||||
t.Errorf("auto : %v, attendu nil", got)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package loki
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -37,6 +39,13 @@ const (
|
||||
// mcpConnectTimeout borne l'établissement d'une session (handshake initialize
|
||||
// + tools/list). Un serveur qui rame ne doit pas figer le tour de chat.
|
||||
mcpConnectTimeout = 20 * time.Second
|
||||
// mcpConnectTimeoutCold : npx et uvx TÉLÉCHARGENT le paquet du serveur au
|
||||
// premier lancement (le catalogue les utilise tous les deux). Sur un réseau
|
||||
// ou un disque lents, ça dépasse largement 20 s, et l'utilisateur voyait
|
||||
// « enregistré mais connexion échouée : context deadline exceeded » pour un
|
||||
// serveur parfaitement sain. Les lancements suivants profitent du cache et
|
||||
// retombent dans le régime normal.
|
||||
mcpConnectTimeoutCold = 3 * time.Minute
|
||||
// mcpCallTimeout borne un appel d'outil MCP.
|
||||
mcpCallTimeout = 120 * time.Second
|
||||
// mcpMaxOutput cap la sortie renvoyée au modèle (cohérent avec toolMaxOutput).
|
||||
@@ -174,11 +183,11 @@ func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession {
|
||||
m.mu.Unlock()
|
||||
|
||||
// Connexion hors-lock (peut être lente).
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpConnectTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg))
|
||||
defer cancel()
|
||||
sess, err := mcpConnect(ctx, name, cfg)
|
||||
if err != nil {
|
||||
s.err = err
|
||||
s.err = mcpFriendlyErr(err, cfg)
|
||||
} else {
|
||||
s.sess = sess
|
||||
lt, lerr := sess.ListTools(ctx, nil)
|
||||
@@ -509,3 +518,30 @@ func MCPStatus() ([]MCPServerStatus, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mcpTimeoutFor choisit le délai de connexion d'un serveur : long pour un
|
||||
// lanceur qui télécharge son paquet à la volée (npx, uvx), court sinon.
|
||||
func mcpTimeoutFor(cfg MCPServerConfig) time.Duration {
|
||||
base := strings.ToLower(filepath.Base(cfg.Command))
|
||||
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
|
||||
if base == "npx" || base == "uvx" {
|
||||
return mcpConnectTimeoutCold
|
||||
}
|
||||
return mcpConnectTimeout
|
||||
}
|
||||
|
||||
// mcpFriendlyErr habille l'erreur de connexion la plus vécue : le délai
|
||||
// dépassé d'un npx/uvx en plein téléchargement. L'erreur brute (« context
|
||||
// deadline exceeded ») envoyait l'utilisateur chercher un bug de config alors
|
||||
// qu'il suffit souvent de réessayer une fois le paquet en cache.
|
||||
func mcpFriendlyErr(err error, cfg MCPServerConfig) error {
|
||||
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
base := strings.ToLower(filepath.Base(cfg.Command))
|
||||
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
|
||||
if base == "npx" || base == "uvx" {
|
||||
return fmt.Errorf("délai dépassé — %s télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", base)
|
||||
}
|
||||
return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s", mcpConnectTimeout)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Repris de l'amont AJEAN (v0.9.8) avec le correctif : les tests valent tels
|
||||
// quels, la fonction étant portée à l'identique.
|
||||
//
|
||||
// LE test qui rassure : sur une conversation NORMALE (un seul system en tête,
|
||||
// suivi de user/assistant/tool, appels d'outils et contenu multimodal compris),
|
||||
// la normalisation ne doit RIEN changer — même contenu, même ordre. C'est la
|
||||
// garantie que le fix est invisible pour les 99 % de cas qui marchaient déjà.
|
||||
func TestNormalizeSystemMessagesNoOpOnNormalConversation(t *testing.T) {
|
||||
in := []Message{
|
||||
{Role: "system", Content: "Tu es Loki."},
|
||||
{Role: "user", Content: "cherche la météo"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_1", Type: "function"}}},
|
||||
{Role: "tool", Content: "résultat brut", ToolCallID: "call_1"},
|
||||
{Role: "assistant", Content: "Il fait beau."},
|
||||
// contenu multimodal (image) : Content n'est pas une string
|
||||
{Role: "user", Content: []any{map[string]any{"type": "text", "text": "et demain ?"}}},
|
||||
}
|
||||
// copie profonde de référence pour détecter toute mutation de l'entrée
|
||||
ref := append([]Message(nil), in...)
|
||||
out := normalizeSystemMessages(in)
|
||||
if !reflect.DeepEqual(out, in) {
|
||||
t.Errorf("séquence normale modifiée !\navant: %#v\naprès: %#v", in, out)
|
||||
}
|
||||
if !reflect.DeepEqual(in, ref) {
|
||||
t.Errorf("l'entrée a été mutée (interdit) : %#v", in)
|
||||
}
|
||||
}
|
||||
|
||||
// L'ordre relatif de tous les messages NON-system (et le lien assistant→tool via
|
||||
// ToolCallID) doit être préservé quand on ramène un system égaré en tête.
|
||||
func TestNormalizeSystemMessagesPreservesOrderAndToolLink(t *testing.T) {
|
||||
in := []Message{
|
||||
{Role: "system", Content: "S1"},
|
||||
{Role: "user", Content: "q"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_9"}}},
|
||||
{Role: "tool", Content: "r", ToolCallID: "call_9"},
|
||||
{Role: "system", Content: "n'appelle plus d'outil"}, // égaré, en fin
|
||||
}
|
||||
out := normalizeSystemMessages(in)
|
||||
want := []Message{
|
||||
{Role: "system", Content: "S1\n\nn'appelle plus d'outil"},
|
||||
{Role: "user", Content: "q"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_9"}}},
|
||||
{Role: "tool", Content: "r", ToolCallID: "call_9"},
|
||||
}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Errorf("ordre/lien outil cassé :\n%#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Un system inséré en fin de séquence (relance anti-boucle) ou en double doit
|
||||
// être ramené en un seul bloc, en tête : sinon les gabarits Qwen3.x renvoient
|
||||
// « System message must be at the beginning » (issue #26).
|
||||
func TestNormalizeSystemMessages(t *testing.T) {
|
||||
sys := func(c string) Message { return Message{Role: "system", Content: c} }
|
||||
usr := func(c string) Message { return Message{Role: "user", Content: c} }
|
||||
|
||||
firstRoleSystemOnce := func(t *testing.T, out []Message) {
|
||||
count := 0
|
||||
for i, m := range out {
|
||||
if m.Role == "system" {
|
||||
count++
|
||||
if i != 0 {
|
||||
t.Errorf("system trouvé en position %d, doit être en 0", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
t.Errorf("%d messages system, doit être fusionné en un seul", count)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("system en fin", func(t *testing.T) {
|
||||
out := normalizeSystemMessages([]Message{sys("A"), usr("q"), sys("stop")})
|
||||
firstRoleSystemOnce(t, out)
|
||||
if out[0].Content != "A\n\nstop" {
|
||||
t.Errorf("fusion attendue 'A\\n\\nstop', obtenu %q", out[0].Content)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("attendu 2 messages, obtenu %d", len(out))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sans system, inchangé", func(t *testing.T) {
|
||||
in := []Message{usr("a"), usr("b")}
|
||||
out := normalizeSystemMessages(in)
|
||||
if len(out) != 2 || out[0].Role != "user" {
|
||||
t.Fatalf("séquence sans system ne doit pas changer : %#v", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("system vide ignoré", func(t *testing.T) {
|
||||
out := normalizeSystemMessages([]Message{sys(" "), usr("q")})
|
||||
if len(out) != 1 || out[0].Role != "user" {
|
||||
t.Fatalf("un system vide ne doit rien ajouter : %#v", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -541,6 +541,18 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
|
||||
// à chaque requête de l'autre côté.
|
||||
func withLocalAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// ⚠️ Marquage AVANT tout : on efface d'abord, sinon un client forge
|
||||
// l'en-tête et se fait passer pour du trafic de tunnel (ou l'inverse).
|
||||
r.Header.Del(viaTunnelHeader)
|
||||
r.Header.Set(viaTunnelHeader, "tunnel")
|
||||
// La surface des complétions a SA clé (celle du moteur, fournie par le
|
||||
// client). Y injecter la clé de PILOTAGE la ferait refuser par la garde,
|
||||
// et surtout la relaierait à llama-server — un secret d'administration
|
||||
// envoyé à un process qui n'en a aucun usage.
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if webKey := readWebKey(); webKey != "" && r.Header.Get("Authorization") == "" {
|
||||
r.Header.Set("Authorization", "Bearer "+webKey)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.9.4"
|
||||
const Version = "0.12.3"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
|
||||
func Main() {
|
||||
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
bkPrefs = "prefs" // préférences de l'UI web
|
||||
bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
|
||||
bkChat = "chat" // conversation partagée
|
||||
bkTasks = "tasks" // tâches planifiées (une entrée JSON par tâche)
|
||||
)
|
||||
|
||||
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
|
||||
|
||||
@@ -2,12 +2,15 @@ package loki
|
||||
|
||||
// sys_brand_icon.go — l'icône de la marque Loki, rendue à la volée.
|
||||
//
|
||||
// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis NOIR + « j »
|
||||
// RÉPLIQUE EXACTE du favicon de l'UI web : carré à coins arrondis SAUGE + « j »
|
||||
// blanc (rects (6,3) (6,5) (4,7) sur une grille 12x12). Une seule source pour
|
||||
// tous les usages — zone de notification Windows, barre de menus macOS, icône du
|
||||
// .exe — pour qu'ils ne puissent plus diverger comme quand le favicon est passé
|
||||
// au noir en laissant les icônes système en bleu.
|
||||
//
|
||||
// La sauge est --accent de l'interface (#5E7F5A) : l'icône de l'onglet et celle
|
||||
// du bureau portent la couleur de l'app, pas un carré noir anonyme.
|
||||
//
|
||||
// Aucun asset binaire à committer : tout est dessiné ici, et l'icône du .exe est
|
||||
// produite par `go generate ./cmd/loki` (voir tools/gen-icon).
|
||||
|
||||
@@ -22,6 +25,8 @@ import (
|
||||
const trayIconSize = 32
|
||||
|
||||
var (
|
||||
// brandSage = --accent du thème clair. Le blanc du « j » s'y lit à 4,9:1.
|
||||
brandSage = color.RGBA{0x5e, 0x7f, 0x5a, 0xff}
|
||||
brandBlack = color.RGBA{0x00, 0x00, 0x00, 0xff}
|
||||
brandWhite = color.RGBA{0xff, 0xff, 0xff, 0xff}
|
||||
brandClear = color.RGBA{0, 0, 0, 0}
|
||||
@@ -80,9 +85,9 @@ func encodePNG(img *image.RGBA) []byte {
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// BrandIconPNG rend l'icône de marque (noir + « j » blanc) en PNG de n pixels.
|
||||
// BrandIconPNG rend l'icône de marque (sauge + « j » blanc) en PNG de n pixels.
|
||||
// Exporté pour le générateur d'icône du .exe (tools/gen-icon).
|
||||
func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandBlack, brandWhite)) }
|
||||
func BrandIconPNG(n int) []byte { return encodePNG(brandIconImage(n, brandSage, brandWhite)) }
|
||||
|
||||
// brandTemplatePNG rend la variante « template » attendue par macOS : seule la
|
||||
// couche alpha compte, le système colore la forme selon le thème de la barre de
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package loki
|
||||
|
||||
// sys_datavolume.go — garde-fou contre la PERTE DE DONNÉES en conteneur.
|
||||
//
|
||||
// Le symptôme vécu : modèles, discussions et fichiers disparus après un
|
||||
// redémarrage du serveur. La cause, dans tous les cas observés : /data n'est
|
||||
// PAS un volume monté (template Docker sans mapping, `docker run` sans -v,
|
||||
// chemin hôte qui change d'un lancement à l'autre). Tout vit alors dans la
|
||||
// couche d'écriture du conteneur, qui meurt avec lui à la première recréation
|
||||
// (mise à jour d'image, compose down/up, redémarrage de l'array Unraid).
|
||||
//
|
||||
// Loki ne peut pas monter le volume à la place de l'utilisateur, mais il peut
|
||||
// le VOIR et le DIRE : au démarrage en conteneur, si LOKI_HOME n'est pas un
|
||||
// point de montage, on le journalise en rouge et l'UI l'affiche en bandeau.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// dataDirIsMounted dit si LokiHome() est un point de montage (bind mount ou
|
||||
// volume Docker). Lecture de /proc/self/mountinfo — Linux seulement, or c'est
|
||||
// le seul endroit où Loki tourne en conteneur. Hors conteneur : sans objet.
|
||||
//
|
||||
// Renvoie (monté, connu) : connu=false quand on n'a pas pu lire mountinfo —
|
||||
// on ne crie pas au loup sur une simple erreur de lecture.
|
||||
func dataDirIsMounted() (mounted, known bool) {
|
||||
f, err := os.Open("/proc/self/mountinfo")
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
defer f.Close()
|
||||
target := filepath.Clean(LokiHome())
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
// Format mountinfo : champs séparés par des espaces, le 5ᵉ (index 4)
|
||||
// est le point de montage. Les espaces dans les chemins sont encodés
|
||||
// \040 — décodés avant comparaison.
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
mp := strings.ReplaceAll(fields[4], `\040`, " ")
|
||||
if filepath.Clean(mp) == target {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
return false, sc.Err() == nil
|
||||
}
|
||||
|
||||
// dataVolumeWarning renvoie le message d'alerte si les données sont en périple
|
||||
// ("" si tout va bien). Évalué au démarrage du serveur web et servi par
|
||||
// /api/status pour le bandeau de l'UI.
|
||||
func dataVolumeWarning() string {
|
||||
if os.Getenv("LOKI_CONTAINER") == "" {
|
||||
return "" // hors conteneur : l'utilisateur gère ses chemins
|
||||
}
|
||||
mounted, known := dataDirIsMounted()
|
||||
if !known || mounted {
|
||||
return ""
|
||||
}
|
||||
return "⚠️ " + LokiHome() + " n'est PAS un volume monté : modèles, discussions et fichiers seront PERDUS à la recréation du conteneur (mise à jour, redémarrage du serveur). " +
|
||||
"Ajoute un mapping de volume — compose : « - /mnt/user/appdata/loki/data:/data » (et « …/models:/models ») ; " +
|
||||
"docker run : « -v /chemin/hote/data:/data ». Voir docker-compose.unraid.yml du dépôt."
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
// sys_disk_fs_linux.go — l'espace libre annoncé par le système est-il DIGNE DE
|
||||
// FOI pour décider d'un téléchargement ?
|
||||
//
|
||||
// Sur Unraid, les partages vivent sous /mnt/user, servi par shfs — un système
|
||||
// de fichiers FUSE qui agrège plusieurs disques et le cache. statfs(2) n'y
|
||||
// renvoie pas l'espace du partage : selon la configuration, c'est celui d'UN
|
||||
// disque, ou du pool de cache. Vécu : « il ne reste que 11,9 Go » sur une
|
||||
// machine qui en a des centaines de libres, et le téléchargement du modèle
|
||||
// REFUSÉ sur cette base.
|
||||
//
|
||||
// Même problème sur les montages réseau (NFS, CIFS) et sur la couche
|
||||
// d'écriture d'un conteneur (overlayfs), qui rapporte le disque virtuel Docker
|
||||
// et non le volume monté dessous.
|
||||
//
|
||||
// On ne cherche pas à corriger le chiffre — c'est impossible depuis l'intérieur
|
||||
// du conteneur. On sait juste dire « ce chiffre est indicatif », et on cesse
|
||||
// alors de bloquer sur lui.
|
||||
|
||||
import "syscall"
|
||||
|
||||
// Magies de systèmes de fichiers dont statfs ment (ou approxime) sur l'espace
|
||||
// libre. Valeurs de <linux/magic.h>.
|
||||
const (
|
||||
fuseSuperMagic = 0x65735546 // FUSE — shfs d'Unraid, sshfs, rclone…
|
||||
overlayfsMagic = 0x794c7630 // couche d'écriture d'un conteneur
|
||||
nfsSuperMagic = 0x6969
|
||||
smbSuperMagic = 0x517b // CIFS/SMB1
|
||||
smb2MagicNumber = 0xfe534d42 // CIFS/SMB2+
|
||||
cephSuperMagic = 0x00c36400
|
||||
glusterfsSuperMag = 0x58465342
|
||||
)
|
||||
|
||||
// diskFreeReliable dit si l'espace libre rapporté pour dir peut servir à
|
||||
// REFUSER une écriture. false = on affiche le chiffre, mais on ne bloque plus.
|
||||
func diskFreeReliable(dir string) bool {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(dir, &st); err != nil {
|
||||
return false
|
||||
}
|
||||
switch uint32(st.Type) {
|
||||
case fuseSuperMagic, overlayfsMagic, nfsSuperMagic,
|
||||
smbSuperMagic, smb2MagicNumber, cephSuperMagic, glusterfsSuperMag:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package loki
|
||||
|
||||
// Hors Linux, on n'a pas de table de magies de systèmes de fichiers à
|
||||
// consulter : l'espace libre rapporté est considéré comme fiable (c'est le cas
|
||||
// des volumes locaux Windows et macOS, les seules cibles concernées ici).
|
||||
func diskFreeReliable(string) bool { return true }
|
||||
@@ -0,0 +1,104 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
// sys_loadpct_linux.go — pourcentage de CHARGEMENT du modèle, pour la pastille
|
||||
// « chargement… » de l'UI qui restait muette de longues minutes sur un gros
|
||||
// GGUF (un 27B Q4 met facilement 1 à 3 minutes à monter en VRAM).
|
||||
//
|
||||
// llama-server n'expose aucun progrès pendant le chargement (/health renvoie
|
||||
// juste 503). Mais le chargement, c'est LIRE le fichier .gguf : on mesure donc
|
||||
// les octets lus par le process moteur (/proc/<pid>/io, read_bytes — les pages
|
||||
// mmap rentrées comptent dedans) et on les rapporte à la taille du fichier.
|
||||
// Approximation honnête : monotone, ~0→100, et surtout elle BOUGE — c'est ce
|
||||
// qui distingue « ça charge » de « c'est planté ».
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// procVmRSS lit la mémoire résidente (octets) d'un process — /proc/<pid>/status,
|
||||
// ligne « VmRSS: N kB ». 0 si illisible.
|
||||
func procVmRSS(pid int) int64 {
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "VmRSS:"); ok {
|
||||
v = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB"))
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
|
||||
return n * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// engineLoadPct renvoie le pourcentage estimé de chargement du modèle, ou -1
|
||||
// quand il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue).
|
||||
func engineLoadPct() int {
|
||||
pid := readServicePID()
|
||||
if pid <= 0 {
|
||||
return -1
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
model := cfg["MODEL"]
|
||||
if model == "" {
|
||||
return -1
|
||||
}
|
||||
path, err := resolveServeModelPath(model)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
size := modelTotalSize(path)
|
||||
if size <= 0 {
|
||||
return -1
|
||||
}
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/io")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
var read int64 = -1
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "read_bytes:"); ok {
|
||||
read, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
break
|
||||
}
|
||||
}
|
||||
if read < 0 {
|
||||
return -1
|
||||
}
|
||||
// Modèle déjà dans le cache disque (redémarrage à chaud) : llama-server ne
|
||||
// LIT plus rien sur le disque — les pages viennent du cache, read_bytes
|
||||
// reste à zéro et le pourcentage restait figé à 0 jusqu'au « prêt ». La
|
||||
// mémoire résidente (VmRSS), elle, grandit quoi qu'il arrive : les pages du
|
||||
// .gguf touchées via mmap y comptent, cache ou pas. On prend le plus avancé
|
||||
// des deux — approximatif (l'offload GPU peut relâcher des pages), mais le
|
||||
// compteur BOUGE au lieu de mentir.
|
||||
if rss := procVmRSS(pid); rss > read {
|
||||
read = rss
|
||||
}
|
||||
pct := int(read * 100 / size)
|
||||
if pct > 99 {
|
||||
// 100 % n'existe pas ici : c'est /health qui dit « prêt ». Au-delà de 99
|
||||
// (relectures, autres IO du process), on plafonne.
|
||||
pct = 99
|
||||
}
|
||||
return pct
|
||||
}
|
||||
|
||||
// modelTotalSize : taille du modèle, shards compris (model-00001-of-00004.gguf
|
||||
// → somme des tranches présentes — c'est tout ça qu'il faut charger).
|
||||
func modelTotalSize(path string) int64 {
|
||||
if total := shardFamilySize(filepath.Dir(path), filepath.Base(path)); total > 0 {
|
||||
return total
|
||||
}
|
||||
if st, err := os.Stat(path); err == nil {
|
||||
return st.Size()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package loki
|
||||
|
||||
// Hors Linux, pas de /proc/<pid>/io : le pourcentage de chargement n'est pas
|
||||
// mesurable, l'UI garde « chargement… » sans chiffre.
|
||||
func engineLoadPct() int { return -1 }
|
||||
@@ -262,6 +262,12 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "/bin/bash", "-c", command)
|
||||
}
|
||||
|
||||
// newShellCmdDetached : commande shell SANS contexte, pour les jobs
|
||||
// d'arrière-plan (bash_bg) qui doivent survivre au tour qui les a lancés.
|
||||
func newShellCmdDetached(command string) *exec.Cmd {
|
||||
return exec.Command("/bin/bash", "-c", command)
|
||||
}
|
||||
|
||||
// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram. Linux : /proc/meminfo.
|
||||
// macOS : sysctl pour le total, vm_stat pour ce qui est réellement libre (pages
|
||||
// free + inactive + speculative ; le reste — wired, active, compressé — est
|
||||
|
||||
@@ -391,6 +391,12 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
|
||||
return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command))
|
||||
}
|
||||
|
||||
// newShellCmdDetached : commande shell SANS contexte, pour les jobs
|
||||
// d'arrière-plan (bash_bg) qui doivent survivre au tour qui les a lancés.
|
||||
func newShellCmdDetached(command string) *exec.Cmd {
|
||||
return hideCmd(exec.Command("cmd", "/C", command))
|
||||
}
|
||||
|
||||
// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram (GlobalMemoryStatusEx).
|
||||
func ramUsageMB() (used, total int) {
|
||||
var m struct {
|
||||
|
||||
@@ -67,7 +67,7 @@ var configTemplate = []struct{ key, help string }{
|
||||
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
|
||||
{"PORT", "port du moteur (défaut 8080)"},
|
||||
{"CTX", "taille du contexte (défaut 32768)"},
|
||||
{"NGL", "couches déportées sur le GPU (défaut 999 = tout ; auto = ce qui tient en VRAM)"},
|
||||
{"NGL", "couches déportées sur le GPU (999 ou auto = ce qui tient en VRAM ; all = tout, quitte à saturer)"},
|
||||
{"BATCH", "batch (défaut 2048)"},
|
||||
{"UBATCH", "micro-batch (défaut 512)"},
|
||||
{"THREADS", "threads CPU, 0 = auto"},
|
||||
@@ -75,6 +75,13 @@ var configTemplate = []struct{ key, help string }{
|
||||
{"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
|
||||
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
|
||||
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
|
||||
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
|
||||
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
|
||||
{"TOP_P", "noyau de probabilité (top_p) ; vide = défaut du moteur"},
|
||||
{"TOP_K", "top_k ; 0 = désactivé, vide = défaut du moteur"},
|
||||
{"MIN_P", "seuil de probabilité (min_p) ; vide = défaut du moteur"},
|
||||
{"PRESENCE_PENALTY", "pénalité de présence ; vide = défaut du moteur"},
|
||||
{"REPEAT_PENALTY", "pénalité de répétition (1 = neutre) ; vide = défaut du moteur"},
|
||||
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
|
||||
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
|
||||
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package loki
|
||||
|
||||
// tasks.go — les tâches planifiées : des consignes que l'IA exécute toute seule,
|
||||
// en arrière-plan, sur une fréquence réglable (intervalle simple ou cron). Chaque
|
||||
// tâche tourne ISOLÉE de la conversation partagée (voir RunAutonomous dans
|
||||
// tasks_run.go) : elle ne pollue pas le chat et livre son résultat via les propres
|
||||
// outils de l'IA (mail via MCP, shell, web…). L'utilisateur peut désactiver une
|
||||
// tâche, ou tout suspendre d'un coup via l'interrupteur maître (tasks_paused).
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// Task est une tâche planifiée persistée dans le bucket bkTasks (clé = ID).
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prompt string `json:"prompt"`
|
||||
Schedule string `json:"schedule"` // "@every 2h" | "@every 1d@23:00" | expr cron 5 champs
|
||||
TZ string `json:"tz"` // fuseau IANA du navigateur (ex. "Europe/Paris") pour l'heure
|
||||
Preset string `json:"preset"` // id du preset à activer avant l'exécution (vide = preset actif)
|
||||
// Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON
|
||||
// (tâches créées avant ces champs) garde le comportement historique : accès.
|
||||
NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche
|
||||
NoWeb bool `json:"no_web"` // true = pas d'accès au web pour cette tâche
|
||||
Enabled bool `json:"enabled"`
|
||||
LastRun int64 `json:"last_run"` // epoch ms, 0 = jamais
|
||||
NextRun int64 `json:"next_run"` // epoch ms, 0 = à (re)calculer
|
||||
LastDurMs int64 `json:"last_dur_ms"` // durée du dernier passage en ms
|
||||
LastOK bool `json:"last_ok"`
|
||||
LastError string `json:"last_error"`
|
||||
LastReport string `json:"last_report"` // dernier texte final de l'IA (compte-rendu court)
|
||||
|
||||
Running bool `json:"-"` // transitoire : une exécution est en cours
|
||||
}
|
||||
|
||||
// newTaskID génère un identifiant court et unique.
|
||||
func newTaskID() string {
|
||||
var b [6]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// listTasks renvoie toutes les tâches, triées par nom pour un affichage stable.
|
||||
func listTasks() []Task {
|
||||
var out []Task
|
||||
_ = view(bkTasks, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var t Task
|
||||
if json.Unmarshal(v, &t) == nil {
|
||||
out = append(out, t)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Name != out[j].Name {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// getTask lit une tâche par ID. Renvoie false si absente.
|
||||
func getTask(id string) (Task, bool) {
|
||||
var t Task
|
||||
ok := getJSON(bkTasks, id, &t)
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// saveTask enregistre (crée ou remplace) une tâche.
|
||||
func saveTask(t Task) error { return putJSON(bkTasks, t.ID, t) }
|
||||
|
||||
// deleteTask supprime une tâche par ID.
|
||||
func deleteTask(id string) error { return putBytes(bkTasks, id, nil) }
|
||||
|
||||
// tasksPaused indique si l'interrupteur maître suspend TOUTES les tâches. On
|
||||
// stocke « en pause » plutôt que « actif » pour que l'état par défaut (base
|
||||
// neuve) soit « actif » sans dépendre d'une écriture : c'est un frein, pas un
|
||||
// moteur.
|
||||
func tasksPaused() bool { return getBool(bkState, "tasks_paused") }
|
||||
|
||||
func setTasksPaused(on bool) error { return putBool(bkState, "tasks_paused", on) }
|
||||
|
||||
// computeNextRun calcule le prochain déclenchement d'une tâche à partir de son
|
||||
// Schedule (dans le fuseau tz), relatif à `from`. Renvoie 0 si le schedule est
|
||||
// invalide (la tâche ne se déclenchera alors jamais — l'UI refuse déjà un
|
||||
// schedule invalide à la sauvegarde, ceci n'est qu'un garde-fou).
|
||||
func computeNextRun(schedule, tz string, from time.Time) int64 {
|
||||
next, err := nextAfter(schedule, tz, from)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return next.UnixMilli()
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package loki
|
||||
|
||||
// tasks_cron.go — planification des tâches, résolution à la minute. Deux formes :
|
||||
//
|
||||
// - Intervalle : "@every <durée>" (ex. "@every 2h", "@every 30m"), parsé par
|
||||
// time.ParseDuration, borné à ≥ 1 minute.
|
||||
// - Cron : 5 champs "min hour dom month dow" avec *, listes a,b, plages a-b et
|
||||
// pas */n (ex. "0 9 * * 1-5" = 9h du lundi au vendredi).
|
||||
//
|
||||
// Aucune dépendance externe : Go n'a pas de lib cron et on n'en ajoute pas pour
|
||||
// si peu. nextAfter avance minute par minute jusqu'au prochain match — largement
|
||||
// assez rapide pour une poignée de tâches évaluées une fois par minute.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// minInterval borne la fréquence : sous la minute, le scheduler (ticker 60 s) ne
|
||||
// pourrait de toute façon pas suivre, et une tâche qui lance une inférence toutes
|
||||
// les quelques secondes n'a pas de sens.
|
||||
const minInterval = time.Minute
|
||||
|
||||
// loc résout un fuseau IANA (ex. « Europe/Paris »). Vide ou introuvable → heure
|
||||
// locale du serveur. Nécessaire parce que le serveur tourne souvent en UTC : sans
|
||||
// ça, « tous les jours à 23h » et les expressions cron se calculeraient en UTC et
|
||||
// se déclencheraient à la mauvaise heure pour l'utilisateur.
|
||||
func loc(tz string) *time.Location {
|
||||
if tz == "" {
|
||||
return time.Local
|
||||
}
|
||||
l, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return time.Local
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// nextAfter renvoie le prochain instant (strictement après `from`) où `schedule`
|
||||
// se déclenche, dans le fuseau tz. Erreur si le schedule est invalide.
|
||||
//
|
||||
// Formes acceptées :
|
||||
// - "@every 30m" / "@every 2h" : intervalle glissant depuis `from`.
|
||||
// - "@every 1d@23:00" : tous les N jours, ancré à une heure de la journée.
|
||||
// - expression cron 5 champs.
|
||||
func nextAfter(schedule, tz string, from time.Time) (time.Time, error) {
|
||||
s := strings.TrimSpace(schedule)
|
||||
if s == "" {
|
||||
return time.Time{}, fmt.Errorf("fréquence vide")
|
||||
}
|
||||
if rest, ok := cutPrefix(s, "@every"); ok {
|
||||
return nextEvery(strings.TrimSpace(rest), tz, from)
|
||||
}
|
||||
return nextCron(s, tz, from)
|
||||
}
|
||||
|
||||
// nextEvery gère les intervalles « @every … », avec ou sans ancre horaire.
|
||||
func nextEvery(spec, tz string, from time.Time) (time.Time, error) {
|
||||
durPart, timePart, hasTime := strings.Cut(spec, "@")
|
||||
durPart = strings.TrimSpace(durPart)
|
||||
|
||||
// Jours : time.ParseDuration ne connaît pas « d ». On les gère à part, ce qui
|
||||
// permet aussi l'ancre horaire (« tous les N jours à HH:MM »).
|
||||
if n, ok := cutSuffix(durPart, "d"); ok {
|
||||
days, err := strconv.Atoi(n)
|
||||
if err != nil || days < 1 {
|
||||
return time.Time{}, fmt.Errorf("nombre de jours invalide : %q", durPart)
|
||||
}
|
||||
if !hasTime {
|
||||
return from.Add(time.Duration(days) * 24 * time.Hour), nil
|
||||
}
|
||||
hh, mm, err := parseHHMM(timePart)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
l := loc(tz)
|
||||
now := from.In(l)
|
||||
cand := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, l)
|
||||
for !cand.After(now) {
|
||||
cand = cand.AddDate(0, 0, days)
|
||||
}
|
||||
return cand, nil
|
||||
}
|
||||
|
||||
d, err := time.ParseDuration(durPart)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("intervalle invalide (ex. « 2h », « 30m ») : %w", err)
|
||||
}
|
||||
if d < minInterval {
|
||||
return time.Time{}, fmt.Errorf("intervalle minimum : 1 minute")
|
||||
}
|
||||
return from.Add(d), nil
|
||||
}
|
||||
|
||||
// parseHHMM lit « 23:00 » / « 9:30 » en heures/minutes.
|
||||
func parseHHMM(s string) (int, int, error) {
|
||||
hs, ms, ok := strings.Cut(strings.TrimSpace(s), ":")
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("heure invalide (attendu HH:MM) : %q", s)
|
||||
}
|
||||
h, err1 := strconv.Atoi(strings.TrimSpace(hs))
|
||||
m, err2 := strconv.Atoi(strings.TrimSpace(ms))
|
||||
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, 0, fmt.Errorf("heure hors bornes : %q", s)
|
||||
}
|
||||
return h, m, nil
|
||||
}
|
||||
|
||||
// cutSuffix : strings.CutSuffix (Go 1.20+) réécrit pour rester lisible ici.
|
||||
func cutSuffix(s, suffix string) (string, bool) {
|
||||
if strings.HasSuffix(s, suffix) {
|
||||
return s[:len(s)-len(suffix)], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// validateSchedule vérifie qu'un schedule est acceptable, sans calculer de date.
|
||||
func validateSchedule(schedule, tz string) error {
|
||||
_, err := nextAfter(schedule, tz, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// cronSpec = les 5 champs compilés en ensembles de valeurs autorisées.
|
||||
type cronSpec struct {
|
||||
min, hour, dom, month, dow map[int]bool
|
||||
}
|
||||
|
||||
// nextCron parse une expression cron 5 champs et cherche le prochain match à la
|
||||
// minute, en partant de la minute qui suit `from`. Une borne (~366 j) évite une
|
||||
// boucle infinie sur une expression jamais satisfiable (ex. « 31 février »).
|
||||
func nextCron(expr, tz string, from time.Time) (time.Time, error) {
|
||||
spec, err := parseCron(expr)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
// On repart de la minute suivante, dans le fuseau visé (l'heure murale de
|
||||
// l'expression cron est celle de l'utilisateur, pas celle du serveur).
|
||||
t := from.In(loc(tz)).Truncate(time.Minute).Add(time.Minute)
|
||||
limit := t.Add(366 * 24 * time.Hour)
|
||||
for ; t.Before(limit); t = t.Add(time.Minute) {
|
||||
if spec.matches(t) {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("fréquence jamais satisfaite : %q", expr)
|
||||
}
|
||||
|
||||
func (s cronSpec) matches(t time.Time) bool {
|
||||
return s.min[t.Minute()] &&
|
||||
s.hour[t.Hour()] &&
|
||||
s.dom[t.Day()] &&
|
||||
s.month[int(t.Month())] &&
|
||||
s.dow[int(t.Weekday())]
|
||||
}
|
||||
|
||||
func parseCron(expr string) (cronSpec, error) {
|
||||
f := strings.Fields(expr)
|
||||
if len(f) != 5 {
|
||||
return cronSpec{}, fmt.Errorf("cron : 5 champs attendus « min heure jour mois jour-semaine », reçu %d", len(f))
|
||||
}
|
||||
var s cronSpec
|
||||
var err error
|
||||
if s.min, err = parseField(f[0], 0, 59); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ minute : %w", err)
|
||||
}
|
||||
if s.hour, err = parseField(f[1], 0, 23); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ heure : %w", err)
|
||||
}
|
||||
if s.dom, err = parseField(f[2], 1, 31); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ jour-du-mois : %w", err)
|
||||
}
|
||||
if s.month, err = parseField(f[3], 1, 12); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ mois : %w", err)
|
||||
}
|
||||
// Jour de la semaine : 0 ET 7 valent dimanche. On normalise 7 → 0.
|
||||
if s.dow, err = parseField(f[4], 0, 7); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ jour-semaine : %w", err)
|
||||
}
|
||||
if s.dow[7] {
|
||||
s.dow[0] = true
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// parseField compile un champ cron (*, a, a-b, a,b, */n, a-b/n) en l'ensemble des
|
||||
// valeurs autorisées dans [lo, hi].
|
||||
func parseField(field string, lo, hi int) (map[int]bool, error) {
|
||||
out := map[int]bool{}
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
rng := part
|
||||
step := 1
|
||||
if base, st, ok := strings.Cut(part, "/"); ok {
|
||||
n, err := strconv.Atoi(st)
|
||||
if err != nil || n < 1 {
|
||||
return nil, fmt.Errorf("pas invalide : %q", part)
|
||||
}
|
||||
step = n
|
||||
rng = base
|
||||
}
|
||||
start, end := lo, hi
|
||||
if rng != "*" {
|
||||
if a, b, ok := strings.Cut(rng, "-"); ok {
|
||||
var err error
|
||||
if start, err = atoiRange(a, lo, hi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end, err = atoiRange(b, lo, hi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
v, err := atoiRange(rng, lo, hi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, end = v, v
|
||||
}
|
||||
}
|
||||
if start > end {
|
||||
return nil, fmt.Errorf("plage inversée : %q", part)
|
||||
}
|
||||
for v := start; v <= end; v += step {
|
||||
out[v] = true
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("champ vide")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func atoiRange(s string, lo, hi int) (int, error) {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("valeur invalide : %q", s)
|
||||
}
|
||||
if v < lo || v > hi {
|
||||
return 0, fmt.Errorf("valeur %d hors bornes [%d-%d]", v, lo, hi)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// cutPrefix reconnaît le préfixe "@every" (insensible à la casse) et renvoie le
|
||||
// reste. strings.CutPrefix existe (Go 1.20+) mais est sensible à la casse.
|
||||
func cutPrefix(s, prefix string) (string, bool) {
|
||||
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
|
||||
return s[len(prefix):], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNextAfterInterval(t *testing.T) {
|
||||
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
|
||||
got, err := nextAfter("@every 2h", "", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur inattendue : %v", err)
|
||||
}
|
||||
want := from.Add(2 * time.Hour)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("@every 2h : got %v, want %v", got, want)
|
||||
}
|
||||
// Casse tolérée sur le préfixe.
|
||||
if _, err := nextAfter("@EVERY 30m", "", from); err != nil {
|
||||
t.Fatalf("@EVERY 30m devrait être valide : %v", err)
|
||||
}
|
||||
// Sous la minute → refusé.
|
||||
if _, err := nextAfter("@every 10s", "", from); err == nil {
|
||||
t.Fatalf("@every 10s aurait dû être refusé")
|
||||
}
|
||||
if _, err := nextAfter("@every pouet", "", from); err == nil {
|
||||
t.Fatalf("durée invalide aurait dû être refusée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextEveryDays(t *testing.T) {
|
||||
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
|
||||
// Sans ancre horaire : intervalle glissant de N*24h.
|
||||
got, err := nextAfter("@every 3d", "", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
if want := from.Add(72 * time.Hour); !got.Equal(want) {
|
||||
t.Fatalf("@every 3d : got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextEveryDayAtTime(t *testing.T) {
|
||||
// « tous les 1 jour à 23:00 », fuseau Europe/Paris (CEST = UTC+2 en août).
|
||||
from := time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) // 22h à Paris
|
||||
got, err := nextAfter("@every 1d@23:00", "Europe/Paris", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
// 23:00 Paris le 15 = 21:00 UTC.
|
||||
want := time.Date(2026, 8, 15, 21, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("@every 1d@23:00 : got %v, want %v", got.UTC(), want)
|
||||
}
|
||||
// Après 23:00 Paris → le lendemain.
|
||||
from2 := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC) // 23h30 Paris
|
||||
got2, _ := nextAfter("@every 1d@23:00", "Europe/Paris", from2)
|
||||
want2 := time.Date(2026, 8, 16, 21, 0, 0, 0, time.UTC)
|
||||
if !got2.Equal(want2) {
|
||||
t.Fatalf("lendemain : got %v, want %v", got2.UTC(), want2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextCronEveryQuarter(t *testing.T) {
|
||||
from := time.Date(2026, 8, 15, 10, 7, 30, 0, time.UTC)
|
||||
got, err := nextAfter("*/15 * * * *", "", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
want := time.Date(2026, 8, 15, 10, 15, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("*/15 : got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextCronWeekdayMorning(t *testing.T) {
|
||||
// 2026-08-15 est un samedi. « 0 9 * * 1-5 » → lundi 17 août 09:00.
|
||||
from := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
|
||||
got, err := nextAfter("0 9 * * 1-5", "UTC", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
want := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("0 9 * * 1-5 : got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextCronInParisTZ(t *testing.T) {
|
||||
// « 0 9 * * * » à Paris = 07:00 UTC en été.
|
||||
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC)
|
||||
got, err := nextAfter("0 9 * * *", "Europe/Paris", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
want := time.Date(2026, 8, 15, 7, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("cron TZ : got %v, want %v", got.UTC(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextCronSundayBothForms(t *testing.T) {
|
||||
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) // samedi
|
||||
got, err := nextAfter("0 0 * * 7", "UTC", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
want := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("dow=7 : got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCronInvalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"* * * *", // 4 champs
|
||||
"* * * * * *", // 6 champs
|
||||
"60 * * * *", // minute hors bornes
|
||||
"* 24 * * *", // heure hors bornes
|
||||
"5-1 * * * *", // plage inversée
|
||||
"*/0 * * * *", // pas nul
|
||||
"abc * * * *", // non numérique
|
||||
"@every", // durée absente
|
||||
"@every 1d@25:00", // heure hors bornes
|
||||
"@every 0d@10:00", // zéro jour
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := validateSchedule(c, ""); err == nil {
|
||||
t.Errorf("schedule %q aurait dû être refusé", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScheduleOK(t *testing.T) {
|
||||
cases := []string{"@every 1m", "@every 2h", "@every 3d", "@every 1d@23:00", "0 */2 * * *", "30 8 * * 1-5", "0 0 1 * *"}
|
||||
for _, c := range cases {
|
||||
if err := validateSchedule(c, "Europe/Paris"); err != nil {
|
||||
t.Errorf("schedule %q aurait dû être valide : %v", c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package loki
|
||||
|
||||
// tasks_run.go — exécution d'une tâche, ISOLÉE de la discussion ouverte.
|
||||
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté au fonctionnement de loki
|
||||
// (dossier de travail par discussion, modes de la machine).
|
||||
//
|
||||
// Une tâche ne doit ni apparaître dans le chat, ni casser le rejeu SSE, ni entrer
|
||||
// en collision avec un tour utilisateur. On ne partage donc qu'UN SEUL point avec
|
||||
// la conversation : son verrou de génération (conv.Generating), qui garantit
|
||||
// qu'une seule inférence tourne à la fois — llama-server est lancé en
|
||||
// --parallel 1. Tout le reste — messages, journal d'affichage — n'est jamais
|
||||
// touché : la tâche construit son propre fil éphémère et le jette à la fin, ne
|
||||
// gardant que le texte final comme compte-rendu.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunAutonomous exécute un prompt en arrière-plan, sans laisser de trace dans la
|
||||
// discussion ouverte, et renvoie le texte final produit par l'IA (le compte-rendu).
|
||||
// Prend le MÊME verrou que StartTurn : renvoie ErrBusy si un tour (utilisateur ou
|
||||
// une autre tâche) est déjà en cours, ou errModelLoading si le modèle n'est pas
|
||||
// prêt. caps gouverne l'accès aux outils (agent, mémoire, internet).
|
||||
// lastReport est le compte-rendu du passage précédent (vide au premier) : il est
|
||||
// réinjecté pour donner à l'IA une continuité d'une exécution à l'autre.
|
||||
func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prompt, lastReport string, caps Caps, temperature float64) (string, error) {
|
||||
if !healthCheck() {
|
||||
return "", errModelLoading
|
||||
}
|
||||
// Contexte annulable propre à la tâche, exposé via c.cancel : le bouton stop
|
||||
// du chat (/api/chat/stop → conv.Stop) interrompt donc VRAIMENT une tâche de
|
||||
// fond, au lieu de l'afficher « en cours » sans pouvoir l'arrêter.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
c.mu.Lock()
|
||||
if c.Generating {
|
||||
c.mu.Unlock()
|
||||
return "", ErrBusy
|
||||
}
|
||||
c.Generating = true
|
||||
c.cancel = cancel
|
||||
c.runningTaskID = taskID
|
||||
c.runningTaskName = taskName
|
||||
c.mu.Unlock()
|
||||
// Libère le verrou quoi qu'il arrive. On ne touche NI c.Messages NI c.Log NI
|
||||
// c.epoch : la discussion ouverte reste totalement à l'écart.
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
c.runningTaskID = ""
|
||||
c.runningTaskName = ""
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Dossier de travail de la tâche : le sien, pas celui de la discussion
|
||||
// ouverte (chat_workspace.go). Stable d'un passage à l'autre — une tâche qui
|
||||
// tient un fichier de suivi le retrouve la fois suivante.
|
||||
setTaskWorkspace(taskWorkspaceDir(taskID))
|
||||
defer setTaskWorkspace("")
|
||||
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
}
|
||||
msgs := []Message{{Role: "user", Content: prompt}}
|
||||
// Même préambule que la vraie génération : consigne personnelle + préambule
|
||||
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
|
||||
// contexte et les mêmes outils qu'en chat. On préfixe le tout d'une note de
|
||||
// contexte (conscience du mode autonome + mémoire du passage précédent),
|
||||
// fusionnée dans UN SEUL message système en tête — comme l'exigent les
|
||||
// gabarits stricts (normalizeSystemMessages le garantit de toute façon).
|
||||
final := msgs
|
||||
sys := readSysPrompt()
|
||||
note := taskContextNote(taskName, lastReport)
|
||||
switch {
|
||||
case sys != "" && note != "":
|
||||
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, msgs...)
|
||||
case sys != "":
|
||||
final = append([]Message{{Role: "system", Content: sys}}, msgs...)
|
||||
case note != "":
|
||||
final = append([]Message{{Role: "system", Content: note}}, msgs...)
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
_, err := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
content.WriteString(ev.Content)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Les messages d'outils ne sont pas conservés : la tâche est éphémère, seul
|
||||
// son compte-rendu survit.
|
||||
return strings.TrimSpace(content.String()), err
|
||||
}
|
||||
|
||||
// taskWorkspaceDir donne (et crée) le dossier de travail d'une tâche, sous la
|
||||
// racine du dossier de travail — jamais dans le dossier d'une discussion, dont
|
||||
// il partagerait le sort à la suppression.
|
||||
func taskWorkspaceDir(taskID string) string {
|
||||
root := agentWorkspace()
|
||||
if taskID == "" {
|
||||
return root
|
||||
}
|
||||
dir := filepath.Join(root, "tasks", taskID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return root
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// taskContextNote construit la note de contexte injectée en tête du fil d'une
|
||||
// tâche : d'abord la conscience du mode (l'IA tourne seule, sans utilisateur en
|
||||
// face), puis — s'il existe — le compte-rendu du passage précédent, pour la
|
||||
// continuité.
|
||||
func taskContextNote(taskName, lastReport string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[Tâche planifiée")
|
||||
if taskName != "" {
|
||||
b.WriteString(" « " + taskName + " »")
|
||||
}
|
||||
b.WriteString("] Tu es exécuté automatiquement en arrière-plan, sans utilisateur " +
|
||||
"présent pour te répondre. Mène la tâche à son terme de façon autonome, puis " +
|
||||
"termine par un compte-rendu clair et court de ce que tu as fait ou trouvé.")
|
||||
if r := strings.TrimSpace(lastReport); r != "" {
|
||||
if len(r) > reportMax {
|
||||
r = r[:reportMax] + "…"
|
||||
}
|
||||
b.WriteString("\n\nCompte-rendu de ta dernière exécution (pour la continuité — " +
|
||||
"appuie-toi dessus, ne le répète pas tel quel) :\n" + r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// reportMax borne la taille du compte-rendu conservé : le texte final peut être
|
||||
// long, on n'en garde qu'un aperçu pour l'interface et pour la relance suivante.
|
||||
const reportMax = 4000
|
||||
|
||||
// runTask exécute une tâche et met à jour son état persisté (dernier passage,
|
||||
// prochain passage, succès/échec, compte-rendu).
|
||||
func runTask(t Task) {
|
||||
start := time.Now()
|
||||
|
||||
// Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement
|
||||
// du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai
|
||||
// échec de la tâche : la faire tourner sur le mauvais modèle serait pire.
|
||||
if err := ensureTaskPreset(t.Preset); err != nil {
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("changement de preset : %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
report, err := conv.RunAutonomous(context.Background(), t.ID, t.Name, t.Prompt, t.LastReport, taskCaps(t), 0)
|
||||
|
||||
// Occupé, ou modèle pas encore prêt : ce n'est pas un échec de la tâche, juste
|
||||
// un mauvais moment. On ne touche pas à son état (NextRun reste dans le passé)
|
||||
// pour qu'elle soit réessayée au tick suivant.
|
||||
if err == ErrBusy || err == errModelLoading {
|
||||
return
|
||||
}
|
||||
recordTaskEnd(t.ID, start, report, err)
|
||||
}
|
||||
|
||||
// taskCaps dérive les capacités d'une tâche : on part du mode agent de la machine
|
||||
// (qui débloque les outils), puis on applique les réglages propres à la tâche pour
|
||||
// la mémoire et le web. Le web reste borné à ce que la machine offre réellement
|
||||
// (serveur Crawl4AI configuré et joignable) : une tâche ne peut pas l'inventer.
|
||||
//
|
||||
// Le mode code reste OFF : c'est un mode de discussion (rôles, critères,
|
||||
// vérification, puces dans le fil) qui n'a pas de sens sans personne en face.
|
||||
func taskCaps(t Task) Caps {
|
||||
// Mem posé EXPLICITEMENT à MemOff : le zéro de MemMode est la chaîne vide,
|
||||
// que EnabledTools ne reconnaît pas comme « coupée » (il compare à MemOff) —
|
||||
// une tâche sans agent se serait donc vu offrir les outils mem_*.
|
||||
c := Caps{Agent: agentEnabled(), Mem: MemOff}
|
||||
if !c.Agent {
|
||||
return c // agent coupé : aucun outil, mémoire et web n'ont plus de sens
|
||||
}
|
||||
if t.NoWeb {
|
||||
c.Internet = false
|
||||
} else {
|
||||
c.Internet = internetEnabled() && crawlReachable()
|
||||
}
|
||||
if t.NoMem {
|
||||
c.Mem = MemOff
|
||||
} else if m := memMode(); m == MemOff {
|
||||
// La tâche veut la mémoire mais la machine l'a coupée globalement : on donne
|
||||
// au moins les outils à la demande, sans l'injection proactive.
|
||||
c.Mem = MemOnDemand
|
||||
} else {
|
||||
c.Mem = m
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ensureTaskPreset bascule le moteur sur le preset d'id `id` s'il n'est pas déjà
|
||||
// actif, puis attend que le modèle ait rechargé. Vide = rien à faire.
|
||||
func ensureTaskPreset(id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
list, err := ListPresets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var target *Preset
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
target = &list[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return fmt.Errorf("preset introuvable : %s", id)
|
||||
}
|
||||
if target.Active {
|
||||
return nil
|
||||
}
|
||||
if err := SwitchToPreset(target.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
// Le moteur redémarre : on attend qu'il réponde à nouveau (charger un gros
|
||||
// modèle prend un moment).
|
||||
deadline := time.Now().Add(4 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
if healthCheck() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("le modèle n'a pas fini de charger après le changement de preset")
|
||||
}
|
||||
|
||||
// recordTaskEnd met à jour l'état persisté d'une tâche après une exécution :
|
||||
// durée, dernier passage, prochain passage, succès/échec, compte-rendu. Relit la
|
||||
// tâche juste avant d'écrire pour ne pas écraser une modification concurrente
|
||||
// (bascule ou édition depuis l'interface pendant l'exécution).
|
||||
func recordTaskEnd(id string, start time.Time, report string, err error) {
|
||||
now := time.Now()
|
||||
cur, ok := getTask(id)
|
||||
if !ok {
|
||||
return // supprimée entre-temps : rien à écrire
|
||||
}
|
||||
cur.LastDurMs = now.Sub(start).Milliseconds()
|
||||
cur.LastRun = now.UnixMilli()
|
||||
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
|
||||
// « interrompue » sans allumer l'indicateur rouge d'erreur.
|
||||
cur.LastOK = true
|
||||
cur.LastError = ""
|
||||
cur.LastReport = "(interrompue manuellement)"
|
||||
} else if err != nil {
|
||||
cur.LastOK = false
|
||||
cur.LastError = err.Error()
|
||||
} else {
|
||||
cur.LastOK = true
|
||||
cur.LastError = ""
|
||||
if len(report) > reportMax {
|
||||
report = report[:reportMax] + "…"
|
||||
}
|
||||
cur.LastReport = report
|
||||
}
|
||||
_ = saveTask(cur)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package loki
|
||||
|
||||
// tasks_sched.go — la boucle de planification. Une seule goroutine, un tick par
|
||||
// minute, dans le process qui possède la conversation et l'UI (ajean-ui /
|
||||
// `ajean link`). Voir StartTaskScheduler, câblée dans newWebMux (web_server.go).
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var taskSchedOnce sync.Once
|
||||
|
||||
// StartTaskScheduler démarre la boucle de planification (idempotent : sûr à
|
||||
// appeler à chaque construction du mux, ne lance qu'une goroutine).
|
||||
func StartTaskScheduler() {
|
||||
taskSchedOnce.Do(func() { go taskSchedLoop() })
|
||||
}
|
||||
|
||||
// taskSchedLoop réveille les tâches dues, une par tick (elles sont de toute façon
|
||||
// sérialisées par le gate de génération). Le tick d'une minute suffit : c'est la
|
||||
// résolution de la planification (voir tasks_cron.go).
|
||||
func taskSchedLoop() {
|
||||
// Premier passage immédiat pour amorcer les NextRun manquants (après un
|
||||
// redémarrage du process, ou pour une tâche fraîchement créée sans NextRun).
|
||||
primeNextRuns()
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
tickTasks(time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// primeNextRuns renseigne NextRun de toute tâche activée qui n'en a pas (0),
|
||||
// relativement à maintenant — sinon une tâche à NextRun=0 serait considérée « due »
|
||||
// en permanence, ou jamais planifiée selon le sens de la comparaison.
|
||||
func primeNextRuns() {
|
||||
now := time.Now()
|
||||
for _, t := range listTasks() {
|
||||
if t.Enabled && t.NextRun == 0 {
|
||||
t.NextRun = computeNextRun(t.Schedule, t.TZ, now)
|
||||
_ = saveTask(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tickTasks lance la première tâche due (si l'interrupteur maître n'est pas en
|
||||
// pause). Une seule par tick : lancer plusieurs inférences en parallèle est de
|
||||
// toute façon impossible (gate de génération), et étaler leur départ évite qu'une
|
||||
// rafale de tâches monopolise le modèle d'un coup.
|
||||
func tickTasks(now time.Time) {
|
||||
if tasksPaused() {
|
||||
return
|
||||
}
|
||||
nowMs := now.UnixMilli()
|
||||
for _, t := range listTasks() {
|
||||
if !t.Enabled {
|
||||
continue
|
||||
}
|
||||
if t.NextRun == 0 {
|
||||
// Priming raté (schedule invalide) ou tâche neuve : on tente de le poser,
|
||||
// sans la déclencher ce tour-ci.
|
||||
t.NextRun = computeNextRun(t.Schedule, t.TZ, now)
|
||||
_ = saveTask(t)
|
||||
continue
|
||||
}
|
||||
if t.NextRun <= nowMs {
|
||||
runTask(t) // met à jour LastRun/NextRun/… et persiste
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Aller-retour complet en base : ce qui est enregistré se relit à l'identique,
|
||||
// la liste est triée par nom (affichage stable) et la suppression efface bien.
|
||||
func TestTasksCRUD(t *testing.T) {
|
||||
testHome(t)
|
||||
if got := listTasks(); len(got) != 0 {
|
||||
t.Fatalf("base neuve : %d tâches, attendu 0", len(got))
|
||||
}
|
||||
a := Task{ID: newTaskID(), Name: "zèbre", Prompt: "p", Schedule: "@every 2h", Enabled: true}
|
||||
b := Task{ID: newTaskID(), Name: "abeille", Prompt: "p", Schedule: "0 9 * * 1-5"}
|
||||
if err := saveTask(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveTask(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := listTasks()
|
||||
if len(list) != 2 || list[0].Name != "abeille" || list[1].Name != "zèbre" {
|
||||
t.Fatalf("liste non triée par nom : %+v", list)
|
||||
}
|
||||
got, ok := getTask(a.ID)
|
||||
if !ok || got.Prompt != "p" || got.Schedule != "@every 2h" || !got.Enabled {
|
||||
t.Fatalf("relecture incorrecte : %+v (ok=%v)", got, ok)
|
||||
}
|
||||
if err := deleteTask(a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := getTask(a.ID); ok {
|
||||
t.Error("tâche supprimée toujours présente")
|
||||
}
|
||||
if len(listTasks()) != 1 {
|
||||
t.Error("la suppression n'a pas retiré la tâche de la liste")
|
||||
}
|
||||
}
|
||||
|
||||
// L'interrupteur maître est un FREIN : base neuve = tâches actives, sans
|
||||
// dépendre d'une écriture préalable.
|
||||
func TestTasksPauseDefautActif(t *testing.T) {
|
||||
testHome(t)
|
||||
if tasksPaused() {
|
||||
t.Error("base neuve : les tâches devraient être actives")
|
||||
}
|
||||
if err := setTasksPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tasksPaused() {
|
||||
t.Error("pause non enregistrée")
|
||||
}
|
||||
_ = setTasksPaused(false)
|
||||
if tasksPaused() {
|
||||
t.Error("reprise non enregistrée")
|
||||
}
|
||||
}
|
||||
|
||||
// Le dossier de travail d'une tâche lui appartient : jamais celui d'une
|
||||
// discussion (qui disparaît avec elle), et stable d'un passage à l'autre — une
|
||||
// tâche qui tient un fichier de suivi doit le retrouver.
|
||||
func TestTaskWorkspaceIsole(t *testing.T) {
|
||||
testHome(t)
|
||||
dir := taskWorkspaceDir("abc123")
|
||||
if dir == agentWorkspace() || dir == convWorkspace() {
|
||||
t.Fatalf("la tâche partage le dossier d'une discussion : %s", dir)
|
||||
}
|
||||
if filepath.Base(dir) != "abc123" || filepath.Base(filepath.Dir(dir)) != "tasks" {
|
||||
t.Errorf("emplacement inattendu : %s", dir)
|
||||
}
|
||||
if again := taskWorkspaceDir("abc123"); again != dir {
|
||||
t.Errorf("dossier instable entre deux passages : %s puis %s", dir, again)
|
||||
}
|
||||
// La bascule ne doit toucher QUE les outils : ce que voit l'utilisateur
|
||||
// (panneau Fichiers, dépôts) reste sur sa discussion.
|
||||
setTaskWorkspace(dir)
|
||||
defer setTaskWorkspace("")
|
||||
if agentCwd() != dir {
|
||||
t.Errorf("agentCwd = %s, attendu %s", agentCwd(), dir)
|
||||
}
|
||||
if convWorkspace() == dir {
|
||||
t.Error("convWorkspace suit la tâche : le panneau Fichiers changerait de dossier en cours de route")
|
||||
}
|
||||
}
|
||||
|
||||
// Les accès d'une tâche : l'agent commande tout, la tâche ne peut que RETIRER
|
||||
// (no_mem/no_web), et le mode code reste hors sujet sans personne en face.
|
||||
func TestTaskCaps(t *testing.T) {
|
||||
testHome(t)
|
||||
if c := taskCaps(Task{}); c.Agent || c.Internet || c.Mem != MemOff {
|
||||
t.Errorf("agent coupé : aucun outil attendu, obtenu %+v", c)
|
||||
}
|
||||
if err := setAgentEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := taskCaps(Task{})
|
||||
if !c.Agent {
|
||||
t.Fatal("mode agent actif : la tâche doit avoir les outils")
|
||||
}
|
||||
if c.Code {
|
||||
t.Error("le mode code n'a pas de sens pour une tâche autonome")
|
||||
}
|
||||
// Par défaut, la tâche suit le mode mémoire de la machine.
|
||||
if c.Mem != memMode() {
|
||||
t.Errorf("mémoire = %q, attendu celle de la machine (%q)", c.Mem, memMode())
|
||||
}
|
||||
// Machine mémoire COUPÉE mais tâche qui la demande : elle garde au moins les
|
||||
// outils à la demande, sans l'injection proactive.
|
||||
if err := setMemMode(MemOff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := taskCaps(Task{}).Mem; got != MemOnDemand {
|
||||
t.Errorf("mémoire = %q, attendu %q", got, MemOnDemand)
|
||||
}
|
||||
if c := taskCaps(Task{NoMem: true, NoWeb: true}); c.Mem != MemOff || c.Internet {
|
||||
t.Errorf("refus explicite non respecté : %+v", c)
|
||||
}
|
||||
// Web : borné à ce que la machine offre réellement — une tâche ne peut pas
|
||||
// l'inventer (aucun serveur Crawl4AI joignable ici).
|
||||
if c := taskCaps(Task{}); c.Internet {
|
||||
t.Error("accès web accordé alors que la machine ne l'offre pas")
|
||||
}
|
||||
}
|
||||
|
||||
// Fin d'exécution : succès, échec et interruption volontaire n'écrivent pas la
|
||||
// même chose — et une interruption n'est PAS un échec (pas d'indicateur rouge).
|
||||
func TestRecordTaskEnd(t *testing.T) {
|
||||
testHome(t)
|
||||
base := Task{ID: "t1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true}
|
||||
if err := saveTask(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now().Add(-2 * time.Second)
|
||||
|
||||
recordTaskEnd("t1", start, strings.Repeat("x", reportMax+50), nil)
|
||||
got, _ := getTask("t1")
|
||||
if !got.LastOK || got.LastError != "" {
|
||||
t.Errorf("succès mal enregistré : %+v", got)
|
||||
}
|
||||
if len(got.LastReport) != reportMax+len("…") {
|
||||
t.Errorf("compte-rendu non borné : %d caractères", len(got.LastReport))
|
||||
}
|
||||
if got.LastRun == 0 || got.NextRun <= got.LastRun {
|
||||
t.Errorf("horodatages incohérents : last=%d next=%d", got.LastRun, got.NextRun)
|
||||
}
|
||||
if got.LastDurMs < 1000 {
|
||||
t.Errorf("durée non mesurée : %d ms", got.LastDurMs)
|
||||
}
|
||||
|
||||
recordTaskEnd("t1", start, "", errors.New("boum"))
|
||||
got, _ = getTask("t1")
|
||||
if got.LastOK || got.LastError != "boum" {
|
||||
t.Errorf("échec mal enregistré : %+v", got)
|
||||
}
|
||||
|
||||
recordTaskEnd("t1", start, "", context.Canceled)
|
||||
got, _ = getTask("t1")
|
||||
if !got.LastOK || got.LastError != "" || !strings.Contains(got.LastReport, "interrompue") {
|
||||
t.Errorf("interruption prise pour un échec : %+v", got)
|
||||
}
|
||||
|
||||
// Tâche supprimée entre-temps : on n'en ressuscite pas une.
|
||||
recordTaskEnd("disparue", start, "r", nil)
|
||||
if _, ok := getTask("disparue"); ok {
|
||||
t.Error("recordTaskEnd a recréé une tâche supprimée")
|
||||
}
|
||||
}
|
||||
|
||||
// Le tic ne déclenche rien quand l'interrupteur maître est en pause, et pose le
|
||||
// NextRun manquant sans lancer la tâche pour autant.
|
||||
func TestTickTasks(t *testing.T) {
|
||||
testHome(t)
|
||||
now := time.Now()
|
||||
due := Task{ID: "due", Name: "due", Prompt: "p", Schedule: "@every 1h", Enabled: true,
|
||||
NextRun: now.Add(-time.Minute).UnixMilli()}
|
||||
if err := saveTask(due); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = setTasksPaused(true)
|
||||
tickTasks(now)
|
||||
if got, _ := getTask("due"); got.LastRun != 0 {
|
||||
t.Error("une tâche s'est exécutée alors que tout est suspendu")
|
||||
}
|
||||
_ = setTasksPaused(false)
|
||||
|
||||
// NextRun absent : le tic le pose (relatif à maintenant) sans exécuter.
|
||||
neuve := Task{ID: "neuve", Name: "neuve", Prompt: "p", Schedule: "@every 2h", Enabled: true}
|
||||
if err := saveTask(neuve); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primeNextRuns()
|
||||
got, _ := getTask("neuve")
|
||||
if got.NextRun == 0 || got.LastRun != 0 {
|
||||
t.Errorf("amorçage incorrect : next=%d last=%d", got.NextRun, got.LastRun)
|
||||
}
|
||||
if d := time.UnixMilli(got.NextRun).Sub(now); d < 110*time.Minute || d > 130*time.Minute {
|
||||
t.Errorf("prochain passage à %v, attendu ~2 h", d)
|
||||
}
|
||||
// Tâche désactivée : jamais amorcée, jamais lancée.
|
||||
off := Task{ID: "off", Name: "off", Prompt: "p", Schedule: "@every 1h"}
|
||||
if err := saveTask(off); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primeNextRuns()
|
||||
if got, _ := getTask("off"); got.NextRun != 0 {
|
||||
t.Error("une tâche désactivée a été planifiée")
|
||||
}
|
||||
}
|
||||
|
||||
// Une tâche épinglée sur un preset qui n'existe plus échoue PROPREMENT : elle
|
||||
// est marquée en erreur (et surtout, elle ne tourne pas sur le mauvais modèle).
|
||||
func TestRunTaskPresetIntrouvable(t *testing.T) {
|
||||
testHome(t)
|
||||
tk := Task{ID: "p1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true, Preset: "fantome"}
|
||||
if err := saveTask(tk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runTask(tk)
|
||||
got, _ := getTask("p1")
|
||||
if got.LastOK || !strings.Contains(got.LastError, "preset") {
|
||||
t.Errorf("échec de bascule mal signalé : %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// La note de contexte dit à l'IA qu'elle tourne seule, et lui redonne son
|
||||
// compte-rendu précédent — borné, pour ne pas manger le contexte.
|
||||
func TestTaskContextNote(t *testing.T) {
|
||||
n := taskContextNote("Veille mails", "")
|
||||
if !strings.Contains(n, "Veille mails") || !strings.Contains(n, "sans utilisateur") {
|
||||
t.Errorf("note incomplète : %q", n)
|
||||
}
|
||||
if strings.Contains(n, "dernière exécution") {
|
||||
t.Error("premier passage : il n'y a pas de compte-rendu à rappeler")
|
||||
}
|
||||
long := taskContextNote("t", strings.Repeat("y", reportMax+100))
|
||||
if !strings.Contains(long, "dernière exécution") {
|
||||
t.Error("le compte-rendu précédent doit être réinjecté")
|
||||
}
|
||||
if len(long) > reportMax+600 {
|
||||
t.Errorf("note non bornée : %d caractères", len(long))
|
||||
}
|
||||
}
|
||||
|
||||
// L'API refuse une tâche bancale AVANT de l'enregistrer : sans ça une fréquence
|
||||
// illisible donnerait une tâche qui ne se déclenche jamais, sans rien dire.
|
||||
func TestHandleTaskSaveValide(t *testing.T) {
|
||||
testHome(t)
|
||||
post := func(body string) (int, map[string]any) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleTaskSave(rr, httptest.NewRequest("POST", "/api/tasks/save", strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
||||
return rr.Code, out
|
||||
}
|
||||
if code, out := post(`{"name":"","prompt":"p","schedule":"@every 1h"}`); code != 400 {
|
||||
t.Errorf("nom vide accepté : %d %v", code, out)
|
||||
}
|
||||
if code, out := post(`{"name":"n","prompt":"","schedule":"@every 1h"}`); code != 400 {
|
||||
t.Errorf("consigne vide acceptée : %d %v", code, out)
|
||||
}
|
||||
if code, out := post(`{"name":"n","prompt":"p","schedule":"tous les jours"}`); code != 400 {
|
||||
t.Errorf("fréquence illisible acceptée : %d %v", code, out)
|
||||
}
|
||||
if len(listTasks()) != 0 {
|
||||
t.Fatal("une tâche refusée a quand même été enregistrée")
|
||||
}
|
||||
|
||||
code, out := post(`{"name":"n","prompt":"p","schedule":"0 9 * * 1-5","tz":"Europe/Paris","enabled":true}`)
|
||||
if code != 200 || out["ok"] != true {
|
||||
t.Fatalf("création refusée : %d %v", code, out)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
got, ok := getTask(id)
|
||||
if !ok || got.TZ != "Europe/Paris" || got.NextRun == 0 {
|
||||
t.Fatalf("tâche mal créée : %+v", got)
|
||||
}
|
||||
|
||||
// Édition : l'historique d'exécution SURVIT (on ne repart pas de zéro parce
|
||||
// qu'on a corrigé une faute dans la consigne).
|
||||
got.LastRun, got.LastReport, got.LastOK = 42, "vu hier", true
|
||||
if err := saveTask(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, out := post(`{"id":"` + id + `","name":"n2","prompt":"p2","schedule":"0 9 * * 1-5","enabled":true}`); code != 200 {
|
||||
t.Fatalf("édition refusée : %d %v", code, out)
|
||||
}
|
||||
after, _ := getTask(id)
|
||||
if after.Name != "n2" || after.Prompt != "p2" {
|
||||
t.Errorf("édition non appliquée : %+v", after)
|
||||
}
|
||||
if after.LastRun != 42 || after.LastReport != "vu hier" {
|
||||
t.Errorf("historique perdu à l'édition : %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// Un message envoyé pendant qu'une tâche tourne doit dire LAQUELLE occupe le
|
||||
// modèle : « génération en cours » sur un fil vide et immobile n'explique rien.
|
||||
func TestBusyReasonNommeLaTache(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := conv.busyReason(); err != ErrBusy {
|
||||
t.Errorf("hors tâche : %v, attendu %v", err, ErrBusy)
|
||||
}
|
||||
conv.mu.Lock()
|
||||
conv.runningTaskName = "Veille mails"
|
||||
conv.mu.Unlock()
|
||||
defer func() {
|
||||
conv.mu.Lock()
|
||||
conv.runningTaskName = ""
|
||||
conv.mu.Unlock()
|
||||
}()
|
||||
if err := conv.busyReason(); err == nil || !strings.Contains(err.Error(), "Veille mails") {
|
||||
t.Errorf("la tâche n'est pas nommée : %v", err)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user