mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1075030dd | ||
|
|
c4e399e101 | ||
|
|
c32fd40f1a | ||
|
|
382c5dbdce | ||
|
|
fa69dac77f | ||
|
|
e9ec8ae3a8 | ||
|
|
74fb1fc781 | ||
|
|
91d1796615 | ||
|
|
00b2350fb9 | ||
|
|
b5904907e8 | ||
|
|
d1626781f9 | ||
|
|
9bdaf00ca4 | ||
|
|
54468b3336 | ||
|
|
17f8fa8f34 | ||
|
|
c45ba615fa | ||
|
|
2d4f5378c0 | ||
|
|
9d2ff33943 | ||
|
|
475d523e4a | ||
|
|
3edd356f34 | ||
|
|
dfdee26e4c | ||
|
|
cfe70f2b70 | ||
|
|
b4f4135101 | ||
|
|
6f6a3218ad | ||
|
|
78c258ab45 | ||
|
|
33de4a1144 | ||
|
|
009b585e75 | ||
|
|
895aa67366 | ||
|
|
08eaf10a34 | ||
|
|
e177d54e39 | ||
|
|
66b53c8255 | ||
|
|
3991e68fc9 | ||
|
|
cc218ccfd7 | ||
|
|
d8ed86bbba | ||
|
|
748328db25 |
No files matched your search
@@ -15,12 +15,12 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
# L'image runtime CUDA de llama.cpp (~4 Go) et Chromium/Playwright
|
||||
# (~1 Go) tiennent large dans les ~14 Go libres d'un runner neuf, mais
|
||||
# ces trois répertoires ne servent à rien ici et rendent une dizaine de
|
||||
# gigaoctets en moins d'une minute. Gardé depuis l'époque où l'image
|
||||
# compilait whisper.cpp avec CUDA : la marge reste bonne à prendre.
|
||||
|
||||
# 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 /
|
||||
@@ -56,5 +56,8 @@ jobs:
|
||||
LOKI_VERSION=${{ github.sha }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
|
||||
# binaire de dictée) tant que ses lignes du Dockerfile ne bougent
|
||||
# pas : seul le code Go et l'assemblage de l'image se refont.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+42
-57
@@ -14,6 +14,17 @@
|
||||
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
|
||||
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
|
||||
|
||||
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
|
||||
# appartient à l'étape où il apparaît ; les « ARG SHERPA_* » nus de l'étape
|
||||
# asrfetch héritent du scope GLOBAL — c'est-à-dire d'ici. Placés plus bas, ils
|
||||
# hériteraient d'une valeur VIDE, et l'étape téléchargerait une URL tronquée.
|
||||
# Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
|
||||
#
|
||||
# La version est ÉPINGLÉE, et l'empreinte avec : le binaire de dictée est
|
||||
# exécuté sur la machine de l'utilisateur, il ne se prend pas « au dernier ».
|
||||
ARG SHERPA_ONNX_VERSION=v1.13.7
|
||||
ARG SHERPA_ONNX_SHA256=2aa3965b37aa235e56aa3ebbb94942c20a23960059a006b4442b4bf893967983
|
||||
|
||||
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
|
||||
FROM golang:1.25 AS gobuild
|
||||
WORKDIR /src
|
||||
@@ -31,60 +42,37 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
|
||||
# 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).
|
||||
# ── Étape 1 bis : serveur de dictée (Parakeet via sherpa-onnx) ──────────
|
||||
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
|
||||
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
|
||||
# compilation (dont une CUDA de ~200 fichiers nvcc, tuée par l'OOM du runner
|
||||
# plus d'une fois) laissent place à une extraction de 35 Mo.
|
||||
#
|
||||
# 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.
|
||||
# Le build « static-no-tts » lie sherpa-onnx ET onnxruntime en statique : rien à
|
||||
# fournir côté image en dehors de la libc et de libstdc++, déjà présentes. Il
|
||||
# n'existe qu'en variante CPU — c'est assumé : un modèle de 0,6 B en int8 sur
|
||||
# des tranches de quelques secondes n'a pas besoin de la carte, qui reste au
|
||||
# moteur de chat. La variante CUDA existe mais tirerait onnxruntime-gpu et
|
||||
# cuDNN dans l'image, pour un gain sans intérêt à cette taille de tranche.
|
||||
#
|
||||
# ⚠️ 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
|
||||
# L'empreinte est vérifiée : le binaire s'exécute sur la machine de
|
||||
# l'utilisateur, une release remplacée en amont ne doit pas passer en silence.
|
||||
FROM debian:12-slim AS asrfetch
|
||||
ARG SHERPA_ONNX_VERSION
|
||||
ARG SHERPA_ONNX_SHA256
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git ca-certificates \
|
||||
curl ca-certificates bzip2 \
|
||||
&& 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
|
||||
RUN set -eu; \
|
||||
nom="sherpa-onnx-${SHERPA_ONNX_VERSION}-linux-x64-static-no-tts"; \
|
||||
curl -fsSL -o /tmp/sherpa.tar.bz2 \
|
||||
"https://github.com/k2-fsa/sherpa-onnx/releases/download/${SHERPA_ONNX_VERSION}/${nom}.tar.bz2"; \
|
||||
echo "${SHERPA_ONNX_SHA256} /tmp/sherpa.tar.bz2" | sha256sum -c -; \
|
||||
mkdir -p /out; \
|
||||
tar -xjf /tmp/sherpa.tar.bz2 -C /out --strip-components=2 \
|
||||
"${nom}/bin/sherpa-onnx-offline-websocket-server"; \
|
||||
chmod +x /out/sherpa-onnx-offline-websocket-server; \
|
||||
/out/sherpa-onnx-offline-websocket-server --help >/dev/null 2>&1 || true
|
||||
|
||||
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
||||
FROM ${LLAMACPP_IMAGE} AS runtime
|
||||
@@ -139,11 +127,9 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
||||
|
||||
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
|
||||
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
|
||||
# l'image : téléchargé à la demande dans /data/asr/.
|
||||
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
||||
|
||||
@@ -152,8 +138,7 @@ 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 \
|
||||
LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \
|
||||
LD_LIBRARY_PATH=/app \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
@@ -26,7 +26,26 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
|
||||
(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.
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales. S'y
|
||||
ajoutent depuis deux idées reprises d'OpenFox et réimplémentées ici : les
|
||||
**sous-agents** (`code_subagent.go` — un rôle délégué qui travaille dans son
|
||||
propre contexte) et le **fenêtrage du fil** au chargement d'une longue
|
||||
discussion.
|
||||
|
||||
## Reprises depuis l'amont après le fork
|
||||
|
||||
Le fork continue de suivre AJEAN et y reprend des fonctionnalités, adaptées au
|
||||
conteneur : le **contrôle du navigateur** (`computer_use.go`, `computer_cdp.go`,
|
||||
`browser_grid.go` — pilotage d'un Chromium en CDP, ici celui de Playwright déjà
|
||||
présent dans l'image) et la **préparation des images** envoyées au modèle
|
||||
(`web_upload_orient.go` — orientation EXIF et redimensionnement), les
|
||||
**notifications Web Push** (`push.go`, `web_push.go`), les **tâches script** et
|
||||
le dossier de scripts (`chat_scripts.go`, `tasks_script.go`, `tasks_tools.go`),
|
||||
et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
`mem_store.go`, `mem_io.go`, `mem_migrate.go`, `mem_snapshots.go`,
|
||||
`backup_bundle.go`). La sauvegarde vers le relais (`relay_backup.go`) n'est
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -122,6 +122,8 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `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 (repli : le jeton réglé dans l'UI prime) | — |
|
||||
| `LOKI_CHROME` | binaire du navigateur piloté (contrôle du navigateur) ; par défaut le Chromium de Playwright de l'image | — |
|
||||
| `LOKI_CU_HEADFUL` | `1` : ouvre une vraie fenêtre au lieu du mode headless (machine avec écran) | — |
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
@@ -137,7 +139,9 @@ et modèles compris.
|
||||
| `/data/loki.db` | base bbolt : préférences, discussions, mémoire des réglages |
|
||||
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
|
||||
| `/data/models/` | modèles téléchargés depuis l'interface |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) |
|
||||
| `/data/memory/` | pages de mémoire persistante (`.md`) — joignables **uniquement** par les outils `mem_*`, pas au shell ; chiffrées si le chiffrement est actif |
|
||||
| `/data/backups/memory/` | snapshots de la mémoire pris avant chaque opération qui touche à tout |
|
||||
| `/data/scripts/` | scripts durables de l'agent, hors du workspace jetable (planifiables sans modèle) |
|
||||
| `/data/workspace/` | racine du dossier de travail de l'agent |
|
||||
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
|
||||
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
|
||||
@@ -175,11 +179,29 @@ Héritées d'AJEAN :
|
||||
|
||||
- **Tchat** avec streaming, raisonnement visible, pièces jointes, export de
|
||||
conversations. La **vision** demande un modèle multimodal *et* son projecteur
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle).
|
||||
`mmproj` — voir [Installer un modèle](#installer-un-modèle). Toute image
|
||||
envoyée au modèle (pièce jointe, `see_image`, capture) est d'abord
|
||||
**redressée** — l'orientation EXIF d'une photo de téléphone est cuite dans
|
||||
les pixels, sinon le projecteur, qui ignore l'EXIF, la voyait couchée — et
|
||||
**ramenée sous 1568 px** de grand côté, taille au-delà de laquelle le base64
|
||||
grossit sans rien apprendre de plus au modèle.
|
||||
- **Mémoire persistante** (`memory off|ondemand|always`).
|
||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Contrôle du navigateur** (`computer on`, ou *Réglages → Contrôle du
|
||||
navigateur*) : l'IA **pilote** un Chromium — celui de Playwright, déjà dans
|
||||
l'image — par le protocole DevTools. Elle ouvre une page, en reçoit les
|
||||
éléments interactifs **numérotés** (`[12] bouton « Se connecter »`) et agit
|
||||
par numéro : `browser_open`, `browser_snapshot`, `browser_find`,
|
||||
`browser_click`, `browser_type`, `browser_key`, `browser_scroll`. Aucune
|
||||
vision requise — ça marche avec de petits modèles texte. Avec un projecteur
|
||||
`mmproj` chargé s'ajoutent `browser_screenshot` (image quadrillée tous les
|
||||
100 px) et `browser_click_xy`, pour ce que l'arbre d'accessibilité ne montre
|
||||
pas (canvas, bandeau en iframe). Ce sont des **actions réelles** sur le web :
|
||||
l'interrupteur est distinct de l'accès internet et n'agit qu'en **mode
|
||||
agent**, au même niveau de confiance que `bash`. La session navigateur est
|
||||
unique et réutilisée entre les appels ; couper l'interrupteur la ferme.
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
|
||||
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
|
||||
@@ -195,7 +217,48 @@ Héritées d'AJEAN :
|
||||
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.
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt. Deux types de
|
||||
tâche : une **consigne IA**, ou un **script seul** — un fichier du dossier
|
||||
`/data/scripts` lancé directement, **sans charger le modèle ni consommer un
|
||||
token** (sauvegarde, synchro, nettoyage n'ont rien à demander à un LLM). Ce
|
||||
dossier vit **hors du workspace** : vider une discussion, ou la supprimer,
|
||||
n'y touche pas. L'IA elle-même dispose des outils `task_create`, `task_list`,
|
||||
`task_update` et `task_delete` — elle peut donc se poser ses propres rappels
|
||||
et veilles — cloisonnés par projet : dans un projet, elle ne voit et ne
|
||||
pilote que les tâches de ce projet.
|
||||
- **Notifications** (Web Push) : le serveur prévient le navigateur **à la fin
|
||||
d'une réponse et à la fin d'une tâche planifiée**, même l'app fermée ou le
|
||||
téléphone verrouillé — c'est le serveur qui pousse, pas la page (un onglet
|
||||
caché relâche son flux). L'interrupteur est dans *Réglages → Mode agent*, à
|
||||
armer **sur chaque appareil** (l'abonnement appartient au navigateur). Exige
|
||||
**HTTPS** ou localhost ; sur iPhone, il faut d'abord ajouter Loki à l'écran
|
||||
d'accueil. Les clés VAPID sont générées à la première demande et rangées avec
|
||||
le reste sous `/data` ; le corps de la notification reste générique (aucun
|
||||
extrait de réponse), puisqu'elle transite par Apple ou Google.
|
||||
- **Chiffrement de la mémoire** (*Réglages → Mémoire*) : pages mémoire,
|
||||
discussions, blocs archivés au compactage et trackers chiffrés en
|
||||
**AES-256-GCM** sur le disque. Chiffrement à enveloppe : une clé de données
|
||||
(DEK) tirée une fois, enfermée dans un coffre par une clé dérivée en
|
||||
**Argon2id**. Ce qui ouvre le coffre : la **clé de pilotage de l'appareil**
|
||||
(le serveur n'en garde qu'une empreinte — il ne peut pas ouvrir le coffre
|
||||
seul) ou une **clé de récupération** affichée une seule fois à l'activation.
|
||||
La DEK ne vit qu'en RAM : après un redémarrage à froid, la mémoire est
|
||||
verrouillée jusqu'à ce qu'un navigateur se reconnecte — verrouillée, Loki
|
||||
n'écrit jamais de clair par-dessus du chiffré, il refuse d'écrire. Règle
|
||||
tenue partout : **rien n'est supprimé avant que son remplaçant ait été relu
|
||||
et vérifié**, un **snapshot** est pris avant chaque bascule, et une migration
|
||||
interrompue reprend au démarrage.
|
||||
- **Sauvegarde chiffrée en fichier** : *exporter* télécharge un paquet scellé
|
||||
(mémoire, presets, réglages) que *importer* rejoue sur un autre serveur avec
|
||||
la seule clé — de quoi remonter le conteneur ailleurs. C'est la sauvegarde de
|
||||
l'amont sans son relais : ici rien ne part sur un service tiers, le fichier
|
||||
reste chez toi.
|
||||
- **Interface en anglais** (*Réglages → Apparence → Langue*) : la source reste
|
||||
française et « English » applique un dictionnaire sur la coque — navigation,
|
||||
intitulés, boutons, interrupteurs. Ce qui n'y figure pas **reste en
|
||||
français** plutôt que d'afficher une clé technique, et le fil de discussion
|
||||
n'est jamais touché : c'est ton contenu. Le dictionnaire s'enrichit sans
|
||||
toucher au reste de l'interface (`ui/src/js/28-i18n.js`).
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **É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
|
||||
@@ -228,7 +291,15 @@ Ajoutées par ce fork :
|
||||
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`.
|
||||
`NOTICE.md`. **Sous-agents** : l'outil `subagent` délègue une recherche
|
||||
(`explorer`), une relecture (`code-reviewer`) ou un découpage (`planner`) à
|
||||
un rôle qui travaille dans **son propre contexte** et ne rend que sa réponse.
|
||||
Sur un modèle local, c'est ce qui sauve la fenêtre : « trouve où est géré le
|
||||
cache » coûte dix lectures de fichiers, qui resteraient sinon dans
|
||||
l'historique jusqu'à la compaction alors que seule la réponse comptait. Tous
|
||||
les rôles délégués sont en **lecture seule** — ce qui modifie le dépôt reste
|
||||
dans le fil principal, sous tes yeux — et un sous-agent ne peut pas en
|
||||
appeler un autre.
|
||||
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
|
||||
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
|
||||
playwright, context7, github…) avec leur commande déjà renseignée, classés par
|
||||
@@ -255,6 +326,11 @@ Ajoutées par ce fork :
|
||||
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
|
||||
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
|
||||
raisonnement est coupé pour ce modèle.
|
||||
- **Ouverture rapide des longues discussions** : le serveur ne rejoue que la
|
||||
**fin** du journal (les 4000 derniers événements). Un fil de plusieurs
|
||||
centaines de tours s'affichait en quelques secondes et laissait autant de
|
||||
bulles dans le DOM à traîner ensuite. Rien n'est effacé : un bandeau en tête
|
||||
dit combien d'événements sont masqués et **charge le début** d'un clic.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
@@ -290,6 +366,12 @@ Ajoutées par ce fork :
|
||||
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
|
||||
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
|
||||
visibles en pied de colonne.
|
||||
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
|
||||
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
|
||||
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
|
||||
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
|
||||
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
|
||||
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
|
||||
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
|
||||
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
|
||||
du moteur. Conséquence directe : l'API est joignable partout où l'interface
|
||||
|
||||
+19
-1
@@ -18,7 +18,25 @@ fi
|
||||
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
|
||||
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
|
||||
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
|
||||
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
|
||||
#
|
||||
# SAUF si l'utilisateur a mis le moteur à jour depuis l'interface : celui-là
|
||||
# vit sous $LOKI_HOME/engine/<version>/ (volume de données, il survit au
|
||||
# conteneur) et c'est lui qu'il a choisi. Le reposer sur /app à chaque
|
||||
# redémarrage ramenait silencieusement l'ancien moteur — un modèle qui
|
||||
# chargeait la veille ne chargeait plus le lendemain, « unknown model
|
||||
# architecture », sans que rien n'ait été touché. On ne le garde que s'il
|
||||
# existe encore : un dossier supprimé à la main ne doit pas laisser BIN dans
|
||||
# le vide.
|
||||
cur=$(loki config get BIN)
|
||||
case "$cur" in
|
||||
"$LOKI_HOME"/engine/*/llama-server) [ -x "$cur" ] || cur="" ;;
|
||||
*) cur="" ;;
|
||||
esac
|
||||
if [ -n "$cur" ]; then
|
||||
echo "[entrypoint] moteur mis à jour conservé : $cur"
|
||||
else
|
||||
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
|
||||
fi
|
||||
|
||||
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
|
||||
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
|
||||
|
||||
@@ -5,6 +5,7 @@ go 1.25.0
|
||||
require (
|
||||
codeberg.org/readeck/go-readability/v2 v2.1.2
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/caddyserver/certmagic v0.25.4
|
||||
github.com/coder/websocket v1.8.15
|
||||
github.com/getlantern/systray v1.2.2
|
||||
@@ -13,6 +14,7 @@ require (
|
||||
go.etcd.io/bbolt v1.5.0
|
||||
golang.org/x/mod v0.35.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -28,6 +30,7 @@ require (
|
||||
github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect
|
||||
github.com/go-stack/stack v1.8.0 // indirect
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/jsonschema-go v0.4.3 // indirect
|
||||
github.com/itlightning/dateparse v0.2.1 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
@@ -45,7 +48,6 @@ require (
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
golang.org/x/tools v0.44.0 // indirect
|
||||
)
|
||||
@@ -6,6 +6,8 @@ github.com/JohannesKaufmann/dom v0.3.1 h1:J16l9JAHWgkFPR3VIPbQ1gvS0cWab6laK1q7PF
|
||||
github.com/JohannesKaufmann/dom v0.3.1/go.mod h1:BZPkf8ZeYrBgABjwJn9iiKt8aiCtkxpHkevms+Yp2DE=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2 h1:XFJZFWESIWlUEHHjzBuv8RvrtCWnSGlimEX17ysSDb8=
|
||||
github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.2/go.mod h1:BHWO8lJzttJLqwuV8Rb1B3OG2OSzLbssZDI1FRg2eAA=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
|
||||
@@ -39,8 +41,10 @@ github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs=
|
||||
github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0=
|
||||
@@ -84,6 +88,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||
@@ -102,24 +107,86 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -13,3 +13,4 @@ Rules:
|
||||
- You may NOT mark a criterion passed — the verification pass does that.
|
||||
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
|
||||
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
|
||||
- Making the change is your job; shipping it is not. Without an explicit request, never commit, push, reset or rebase, never deploy, restart a service or replace a running binary. Read-only inspection (git_status, git_diff) is fine and encouraged.
|
||||
@@ -0,0 +1,119 @@
|
||||
package loki
|
||||
|
||||
// backend_external.go — presets « externes » : au lieu de lancer un llama-server
|
||||
// local, un tel preset route le chat vers une API OpenAI-compatible distante
|
||||
// (OpenAI, Groq, OpenRouter, un autre Loki, un vLLM sur une autre machine…).
|
||||
// Un preset externe se reconnaît à sa clé EXTERNAL=1 ; il porte l'URL, le nom du
|
||||
// modèle et la clé d'accès.
|
||||
//
|
||||
// Choix d'archi, repris d'AJEAN : l'externe est un PRESET COMME UN AUTRE, un
|
||||
// fichier .env dans presetsDir. Toute la mécanique existante s'applique sans
|
||||
// rien changer — liste, nom d'affichage, empreinte d'activité, bascule, prompt
|
||||
// système par preset, réglages d'échantillonnage. La différence ne vit qu'à
|
||||
// DEUX endroits : au moment de l'inférence (resolveChatEndpoint) et au moment
|
||||
// de la bascule (aucun moteur à redémarrer).
|
||||
//
|
||||
// Ce qui ne s'applique pas, et c'est voulu : un preset externe n'a ni MODEL, ni
|
||||
// NGL, ni moteur. La machine distante décide de tout ça. CTX, en revanche,
|
||||
// reste utile — c'est lui qui pilote la jauge de contexte et le seuil de
|
||||
// compaction côté Loki.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
|
||||
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
|
||||
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
|
||||
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
|
||||
)
|
||||
|
||||
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
|
||||
type chatEndpoint struct {
|
||||
URL string // URL complète des complétions
|
||||
Model string // nom de modèle envoyé dans le payload
|
||||
Key string // Bearer ("" = aucun)
|
||||
External bool // true = API distante (pas le llama-server local)
|
||||
}
|
||||
|
||||
// isExternalConfig indique si une configuration décrit un endpoint externe.
|
||||
func isExternalConfig(cfg map[string]string) bool {
|
||||
return strings.TrimSpace(cfg[extKeyFlag]) == "1"
|
||||
}
|
||||
|
||||
// externalActive : le preset actif est-il un endpoint externe ?
|
||||
func externalActive() bool { return isExternalConfig(ReadConfig()) }
|
||||
|
||||
// completionsURL normalise l'URL saisie en une URL de complétions complète.
|
||||
// Accepte les trois formes qu'on colle en pratique :
|
||||
//
|
||||
// - une URL déjà complète (…/chat/completions) — laissée telle quelle ;
|
||||
// - une base OpenAI (…/v1, …/openai/v1, …/api/v1) — on ajoute /chat/completions ;
|
||||
// - autre chose — on suppose une racine et on ajoute /v1/chat/completions.
|
||||
//
|
||||
// Exiger la forme exacte serait une source d'échecs muets : la page d'un
|
||||
// fournisseur donne tantôt « https://api.groq.com/openai/v1 », tantôt l'URL
|
||||
// complète, et se tromper ne produit qu'un 404 illisible.
|
||||
func completionsURL(raw string) string {
|
||||
u := strings.TrimRight(strings.TrimSpace(raw), "/")
|
||||
if u == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasSuffix(u, "/chat/completions") {
|
||||
return u
|
||||
}
|
||||
if strings.HasSuffix(u, "/v1") {
|
||||
return u + "/chat/completions"
|
||||
}
|
||||
return u + "/v1/chat/completions"
|
||||
}
|
||||
|
||||
// resolveChatEndpoint décide où envoyer les complétions : l'API externe si le
|
||||
// preset actif en est un, sinon le llama-server local.
|
||||
func resolveChatEndpoint() chatEndpoint {
|
||||
cfg := ReadConfig()
|
||||
if isExternalConfig(cfg) {
|
||||
return chatEndpoint{
|
||||
URL: completionsURL(cfg[extKeyURL]),
|
||||
Model: strings.TrimSpace(cfg[extKeyModel]),
|
||||
Key: strings.TrimSpace(cfg[extKeyToken]),
|
||||
External: true,
|
||||
}
|
||||
}
|
||||
return chatEndpoint{
|
||||
URL: fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()),
|
||||
Model: "loki",
|
||||
Key: readAPIKey(),
|
||||
}
|
||||
}
|
||||
|
||||
// auth pose l'en-tête d'autorisation de CET endpoint. Indispensable de le tenir
|
||||
// ici plutôt que d'appeler authHeader : celui-ci envoie la clé du serveur LOCAL,
|
||||
// et l'expédier à api.openai.com serait fuiter un secret chez un tiers en même
|
||||
// temps qu'un 401 garanti.
|
||||
func (e chatEndpoint) auth(set func(k, v string)) {
|
||||
if e.Key != "" {
|
||||
set("Authorization", "Bearer "+e.Key)
|
||||
}
|
||||
}
|
||||
|
||||
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
|
||||
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
|
||||
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
|
||||
// compaction, exactement comme pour un preset local.
|
||||
func externalPresetContent(url, model, key, ctx string) string {
|
||||
m := map[string]string{
|
||||
extKeyFlag: "1",
|
||||
extKeyURL: strings.TrimSpace(url),
|
||||
extKeyModel: strings.TrimSpace(model),
|
||||
}
|
||||
if k := strings.TrimSpace(key); k != "" {
|
||||
m[extKeyToken] = k
|
||||
}
|
||||
if c := strings.TrimSpace(ctx); c != "" {
|
||||
m["CTX"] = c
|
||||
}
|
||||
return formatEnv(m)
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les trois formes d'URL qu'on colle en pratique. Exiger la forme exacte serait
|
||||
// une source d'échecs muets : la page d'un fournisseur donne tantôt la base,
|
||||
// tantôt l'URL complète, et se tromper ne produit qu'un 404 illisible.
|
||||
func TestNormalisationDeLURLDeComplétions(t *testing.T) {
|
||||
cas := map[string]string{
|
||||
"https://api.openai.com/v1": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.groq.com/openai/v1": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"https://api.openai.com/v1/chat/completions": "https://api.openai.com/v1/chat/completions",
|
||||
"https://api.openai.com/v1/": "https://api.openai.com/v1/chat/completions",
|
||||
"http://192.168.1.20:8080": "http://192.168.1.20:8080/v1/chat/completions",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cas {
|
||||
if got := completionsURL(in); got != want {
|
||||
t.Errorf("completionsURL(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset externe actif détourne les complétions ; sans lui, on reste sur le
|
||||
// llama-server local avec la clé LOCALE.
|
||||
func TestEndpointSuitLePresetActif(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := SetConfigKey("PORT", "9999"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep := resolveChatEndpoint()
|
||||
if ep.External {
|
||||
t.Fatal("aucun preset externe : l'endpoint doit rester local")
|
||||
}
|
||||
if ep.URL != "http://localhost:9999/v1/chat/completions" {
|
||||
t.Fatalf("URL locale = %q", ep.URL)
|
||||
}
|
||||
if ep.Model != "loki" {
|
||||
t.Fatalf("modèle local = %q, attendu loki", ep.Model)
|
||||
}
|
||||
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep = resolveChatEndpoint()
|
||||
if !ep.External {
|
||||
t.Fatal("EXTERNAL=1 non reconnu")
|
||||
}
|
||||
if ep.URL != "https://api.groq.com/openai/v1/chat/completions" {
|
||||
t.Fatalf("URL externe = %q", ep.URL)
|
||||
}
|
||||
if ep.Model != "llama-3.3-70b" || ep.Key != "sk-secret" {
|
||||
t.Fatalf("modèle/clé externes = %q / %q", ep.Model, ep.Key)
|
||||
}
|
||||
// CTX passe par la clé standard : c'est elle qui pilote la jauge de contexte
|
||||
// et le seuil de compaction, exactement comme pour un preset local.
|
||||
if got := ReadConfig()["CTX"]; got != "65536" {
|
||||
t.Fatalf("CTX = %q, attendu 65536", got)
|
||||
}
|
||||
}
|
||||
|
||||
// La clé du serveur LOCAL ne doit jamais partir chez un tiers : ce serait fuiter
|
||||
// un secret en même temps qu'un 401 garanti.
|
||||
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(parseEnv(externalPresetContent(
|
||||
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
|
||||
if err := SetConfigKey("API_KEY", "sk-loki-locale"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ep := resolveChatEndpoint()
|
||||
if ep.Key != "" {
|
||||
t.Fatalf("clé envoyée à l'API externe = %q — le preset n'en déclare aucune", ep.Key)
|
||||
}
|
||||
got := map[string]string{}
|
||||
ep.auth(func(k, v string) { got[k] = v })
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("en-tête posé sans clé de preset : %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset externe se reconnaît, un preset local ne se prend pas pour un externe.
|
||||
func TestReconnaissanceDunPresetExterne(t *testing.T) {
|
||||
if !isExternalConfig(map[string]string{extKeyFlag: "1"}) {
|
||||
t.Error("EXTERNAL=1 non reconnu")
|
||||
}
|
||||
for _, v := range []string{"", "0", "off", "false"} {
|
||||
if isExternalConfig(map[string]string{extKeyFlag: v}) {
|
||||
t.Errorf("EXTERNAL=%q pris pour un preset externe", v)
|
||||
}
|
||||
}
|
||||
if isExternalConfig(parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n")) {
|
||||
t.Error("un preset local est pris pour un externe")
|
||||
}
|
||||
}
|
||||
|
||||
// La route de test rend un message LISIBLE plutôt que le JSON brut : c'est tout
|
||||
// l'intérêt de tester avant d'enregistrer.
|
||||
func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer sk-bonne" {
|
||||
w.WriteHeader(401)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"choices": []any{}})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
post := func(body string) map[string]any {
|
||||
w := httptest.NewRecorder()
|
||||
handlePresetExternalTest(w, httptest.NewRequest("POST", "/api/preset/external/test", strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return out
|
||||
}
|
||||
out := post(`{"url":"` + srv.URL + `","model":"m","key":"sk-mauvaise","keyTouched":true}`)
|
||||
if out["ok"] == true {
|
||||
t.Fatal("clé refusée annoncée comme valide")
|
||||
}
|
||||
if msg, _ := out["error"].(string); msg != "Incorrect API key provided" {
|
||||
t.Fatalf("message d'erreur = %q, attendu celui de l'API et non le JSON brut", msg)
|
||||
}
|
||||
if out = post(`{"url":"` + srv.URL + `","model":"m","key":"sk-bonne","keyTouched":true}`); out["ok"] != true {
|
||||
t.Fatalf("clé valide refusée : %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Rééditer un preset sans toucher au champ clé ne doit PAS l'effacer : la modale
|
||||
// ne la reçoit jamais en clair, donc un champ vide n'est pas une demande
|
||||
// d'effacement.
|
||||
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
|
||||
strings.NewReader(`{"id":"`+id+`","name":"Groq","url":"https://api.groq.com/openai/v1","model":"llama-3.3","key":""}`)))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("code %d : %s", w.Code, w.Body.String())
|
||||
}
|
||||
content, err := ReadPreset(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := parseEnv(content)
|
||||
if cfg[extKeyToken] != "sk-gardee" {
|
||||
t.Fatalf("clé = %q — effacée par une édition qui n'y touchait pas", cfg[extKeyToken])
|
||||
}
|
||||
if cfg[extKeyModel] != "llama-3.3" {
|
||||
t.Fatalf("modèle non mis à jour : %q", cfg[extKeyModel])
|
||||
}
|
||||
// Et la clé ne ressort jamais en clair par la route de lecture.
|
||||
w = httptest.NewRecorder()
|
||||
handlePresetExternal(w, httptest.NewRequest("GET", "/api/preset/external?id="+id, nil))
|
||||
if strings.Contains(w.Body.String(), "sk-gardee") {
|
||||
t.Fatalf("la clé est renvoyée en clair : %s", w.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
if out["hasKey"] != true {
|
||||
t.Fatal("hasKey doit signaler la présence d'une clé")
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset externe demande explicitement d'écraser la clé par une vide.
|
||||
func TestEffacementExpliciteDeLaCle(t *testing.T) {
|
||||
testHome(t)
|
||||
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
|
||||
strings.NewReader(`{"id":"`+id+`","name":"Local distant","url":"http://192.168.1.20:8080","model":"qwen","key":"","keyTouched":true}`)))
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("code %d : %s", w.Code, w.Body.String())
|
||||
}
|
||||
content, _ := ReadPreset(id)
|
||||
if k := parseEnv(content)[extKeyToken]; k != "" {
|
||||
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
|
||||
}
|
||||
}
|
||||
@@ -201,38 +201,70 @@ func modelFromPresetContent(content string) string {
|
||||
// n'effacer que la première laissait des dizaines de Go de fichiers que plus
|
||||
// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les
|
||||
// tranches suivantes n'y apparaissent pas).
|
||||
func deleteModelFile(name string) error {
|
||||
//
|
||||
// Renvoie le nombre d'octets réellement libérés — l'interface le dit, et
|
||||
// « 41,2 Go libérés » est la seule confirmation qui vaille quelque chose après
|
||||
// un ménage de disque.
|
||||
//
|
||||
// Un modèle EN SERVICE n'est jamais effacé : le fichier reste mappé par
|
||||
// llama-server (la place n'est donc même pas rendue) et le prochain démarrage
|
||||
// meurt sur un .gguf absent, sans que rien n'ait prévenu.
|
||||
func deleteModelFile(name string) (int64, error) {
|
||||
p, err := resolveModelPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
if _, active := modelUsers(name); active {
|
||||
return 0, fmt.Errorf("%s", modelInServiceMsg)
|
||||
}
|
||||
freed := modelFamilySize(p)
|
||||
if err := os.Remove(p); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
|
||||
return 0, fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
|
||||
}
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
dir := filepath.Dir(p)
|
||||
for _, n := range shardFamily(filepath.Base(p)) {
|
||||
_ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire
|
||||
}
|
||||
return nil
|
||||
return freed, nil
|
||||
}
|
||||
|
||||
// handleModelDelete deletes a single .gguf from LOKI_HOME.
|
||||
// handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés.
|
||||
//
|
||||
// Deux garde-fous, et un seul est un refus définitif :
|
||||
//
|
||||
// - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ;
|
||||
// - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets
|
||||
// concernés. L'interface la montre, l'utilisateur tranche, et renvoie la
|
||||
// même requête avec force:true. Refuser tout court transformerait le
|
||||
// nettoyage en impasse — c'est exactement le travers qu'on corrige ici.
|
||||
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := deleteModelFile(req.Name); err != nil {
|
||||
users, active := modelUsers(req.Name)
|
||||
if active {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": modelInServiceMsg, "active": true})
|
||||
return
|
||||
}
|
||||
if len(users) > 0 && !req.Force {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "used": users,
|
||||
"error": "modèle utilisé par : " + strings.Join(users, ", ")})
|
||||
return
|
||||
}
|
||||
freed, err := deleteModelFile(req.Name)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users})
|
||||
}
|
||||
|
||||
// ---- Hugging Face downloads -------------------------------------------------
|
||||
@@ -382,7 +414,16 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if len(todoURLs) == 0 {
|
||||
dlMu.Unlock()
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
|
||||
// Rien à faire : toutes les tranches sont là. Ce n'était pas une erreur,
|
||||
// c'était le résultat voulu — le modèle est installé. On répond ok avec la
|
||||
// valeur à écrire dans MODEL=, l'interface le sélectionne et enchaîne la
|
||||
// file (le projecteur vision, par exemple) au lieu de s'arrêter en rouge.
|
||||
value, _, _ := modelPresence(name, req.Dir)
|
||||
if value == "" {
|
||||
value = modelPickerValue(filepath.Dir(dests[0]), name)
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "exists": true,
|
||||
"value": value, "parts": 0})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
@@ -462,6 +503,21 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
|
||||
// puis échouer au deux tiers du transfert.
|
||||
urls, names := shardURLSet(dlURL, name)
|
||||
name = names[0]
|
||||
// Déjà sur le disque : on ne sonde même pas Hugging Face. C'est le cas qui
|
||||
// mettait l'interface dans une impasse — un modèle gardé après la suppression
|
||||
// de son preset ne pouvait plus être re-choisi qu'en le retéléchargeant, et
|
||||
// le téléchargement refusait d'écraser. Il n'y a rien à télécharger : il y a
|
||||
// un modèle à SÉLECTIONNER, et l'UI a besoin de sa valeur MODEL= pour ça.
|
||||
// Cherché dans TOUS les dossiers déclarés, pas seulement la destination
|
||||
// choisie : re-télécharger 40 Go sur le SSD parce que le fichier est sur le
|
||||
// disque externe déjà déclaré n'aide personne — le sélecteur, lui, le trouve
|
||||
// où qu'il soit.
|
||||
if value, path, ok := modelPresence(name, ""); ok {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "dir": filepath.Dir(path),
|
||||
"exists": true, "value": value, "size": modelFamilySize(path),
|
||||
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||||
defer cancel()
|
||||
var total int64
|
||||
@@ -539,7 +595,11 @@ func dlSourceError(resp *http.Response, dlURL string) error {
|
||||
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é")
|
||||
// Nommer le fichier : sur un modèle en fragments, c'est souvent UN
|
||||
// fragment qui manque — certains dépôts n'en publient que six sur
|
||||
// sept et livrent la table PLE à part, à lier soi-même — et sans
|
||||
// le nom, on cherche une révision réécrite qui n'existe pas.
|
||||
return fmt.Errorf("fichier %s absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", dlFileLabel(dlURL))
|
||||
case "RevisionNotFound":
|
||||
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
|
||||
}
|
||||
@@ -555,6 +615,20 @@ func dlSourceError(resp *http.Response, dlURL string) error {
|
||||
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
|
||||
}
|
||||
|
||||
// dlFileLabel : « nom.gguf » entre guillemets pour un message, ou vide si le
|
||||
// lien n'a pas de nom de fichier lisible.
|
||||
func dlFileLabel(dlURL string) string {
|
||||
u, err := url.Parse(dlURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := path.Base(u.Path)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
return ""
|
||||
}
|
||||
return "« " + name + " »"
|
||||
}
|
||||
|
||||
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
|
||||
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
|
||||
// accepter, qui n'existent que là-bas.
|
||||
|
||||
@@ -172,7 +172,15 @@ func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
|
||||
want: []string{"absent du dépôt", "404"},
|
||||
want: []string{"« m.gguf » absent du dépôt", "404"},
|
||||
},
|
||||
{
|
||||
// Dépôt qui publie six fragments sur sept (table PLE livrée à part) :
|
||||
// le message doit NOMMER le fragment manquant, sinon on cherche une
|
||||
// révision réécrite qui n'existe pas.
|
||||
name: "fragment absent", resp: resp(404, "EntryNotFound"),
|
||||
url: "https://huggingface.co/x/y-GGUF/resolve/main/Q2_K/y-Q2_K-00007-of-00007.gguf",
|
||||
want: []string{"« y-Q2_K-00007-of-00007.gguf » absent du dépôt", "fragment jamais publié"},
|
||||
},
|
||||
{
|
||||
name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package loki
|
||||
|
||||
// backend_models_refs.go — QUI référence un .gguf, et peut-on l'effacer ?
|
||||
//
|
||||
// Vécu qui a produit ce fichier : un modèle téléchargé depuis l'éditeur d'un
|
||||
// preset, le preset supprimé (sans cocher « supprimer aussi le .gguf »), et
|
||||
// 40 Go de fichier plus rien pour les reprendre — la route /api/models/delete
|
||||
// existait mais AUCUN bouton ne l'appelait. Supprimer un modèle depuis
|
||||
// l'interface demande deux choses : savoir qui s'en sert (le moteur en service,
|
||||
// d'autres presets), et le dire AVANT d'effacer.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// modelRefIndex — l'index « ce fichier est référencé par… », construit en une
|
||||
// seule passe. Demander « qui utilise ce modèle ? » fichier par fichier
|
||||
// relisait tous les presets à chaque ligne de la liste des modèles.
|
||||
//
|
||||
// Les clés sont des chemins normalisés (normDir) : un preset peut nommer son
|
||||
// modèle en chemin absolu et un autre en simple nom de fichier, c'est le même
|
||||
// fichier sur le disque.
|
||||
type modelRefIndex struct {
|
||||
active map[string]bool // référencé par config.env — donc par le moteur qui tourne
|
||||
presets map[string][]string // chemin -> noms d'affichage des presets
|
||||
}
|
||||
|
||||
// modelRefKey normalise une valeur MODEL=/MMPROJ= en clé d'index, ou "" si elle
|
||||
// ne désigne pas un .gguf. resolveModelPath accepte le nom simple comme le
|
||||
// chemin absolu et les résout dans les dossiers déclarés — c'est exactement la
|
||||
// règle qu'appliquera le moteur.
|
||||
func modelRefKey(v string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return ""
|
||||
}
|
||||
p, err := resolveServeModelPath(v)
|
||||
if err != nil {
|
||||
// Introuvable : on garde quand même une clé comparable, sinon deux
|
||||
// presets qui nomment le même fichier absent paraîtraient distincts.
|
||||
p = filepath.Join(modelsDir(), baseName(v))
|
||||
}
|
||||
return normDir(p)
|
||||
}
|
||||
|
||||
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
|
||||
// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets
|
||||
// d'avant la clé MMPROJ l'écrivaient là).
|
||||
func modelKeysOf(env map[string]string) []string {
|
||||
vals := []string{env["MODEL"], env["MMPROJ"]}
|
||||
args := splitArgs(env["EXTRA_ARGS"])
|
||||
for i, a := range args {
|
||||
if a == "--mmproj" && i+1 < len(args) {
|
||||
vals = append(vals, args[i+1])
|
||||
}
|
||||
}
|
||||
out := []string{}
|
||||
for _, v := range vals {
|
||||
if k := modelRefKey(v); k != "" {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildModelRefIndex lit la configuration active et tous les presets.
|
||||
func buildModelRefIndex() modelRefIndex {
|
||||
ix := modelRefIndex{active: map[string]bool{}, presets: map[string][]string{}}
|
||||
for _, k := range modelKeysOf(ReadConfig()) {
|
||||
ix.active[k] = true
|
||||
}
|
||||
list, err := ListPresets()
|
||||
if err != nil {
|
||||
return ix
|
||||
}
|
||||
for _, p := range list {
|
||||
content, err := ReadPreset(p.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, k := range modelKeysOf(parseEnv(content)) {
|
||||
if seen[k] {
|
||||
continue // MODEL et MMPROJ identiques : un seul « utilisé par »
|
||||
}
|
||||
seen[k] = true
|
||||
ix.presets[k] = append(ix.presets[k], p.Name)
|
||||
}
|
||||
}
|
||||
return ix
|
||||
}
|
||||
|
||||
// lookup renvoie les presets qui référencent path et s'il est en service.
|
||||
func (ix modelRefIndex) lookup(path string) (presets []string, active bool) {
|
||||
k := normDir(path)
|
||||
return ix.presets[k], ix.active[k]
|
||||
}
|
||||
|
||||
// modelUsers répond pour UN modèle (nom de fichier ou chemin).
|
||||
func modelUsers(name string) (presets []string, active bool) {
|
||||
k := modelRefKey(name)
|
||||
if k == "" {
|
||||
return nil, false
|
||||
}
|
||||
return buildModelRefIndex().lookup(k)
|
||||
}
|
||||
|
||||
// modelInServiceErr est le refus opposé à la suppression d'un modèle que le
|
||||
// moteur a ouvert. Effacer le .gguf sous un llama-server qui tourne ne libère
|
||||
// même pas la place (le fichier reste mappé) et rend le prochain démarrage
|
||||
// impossible sans rien dire.
|
||||
const modelInServiceMsg = "modèle en service — bascule sur un autre preset avant de le supprimer"
|
||||
|
||||
// modelFamilySize additionne ce que la suppression libérera réellement
|
||||
// (toutes les tranches présentes).
|
||||
func modelFamilySize(p string) int64 {
|
||||
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
|
||||
}
|
||||
|
||||
// modelPresence dit si un modèle est DÉJÀ sur le disque, et sous quelle valeur
|
||||
// l'écrire dans MODEL=. dir restreint la recherche à ce dossier (celui visé par
|
||||
// un téléchargement) ; vide = tous les dossiers déclarés.
|
||||
//
|
||||
// C'est ce qui transforme « le modèle existe déjà » — un cul-de-sac rouge au
|
||||
// bout d'un clic sur un quant — en « déjà là, je le sélectionne ».
|
||||
func modelPresence(name, dir string) (value, path string, ok bool) {
|
||||
base := baseName(strings.TrimSpace(name))
|
||||
if base == "" || !strings.HasSuffix(strings.ToLower(base), ".gguf") {
|
||||
return "", "", false
|
||||
}
|
||||
dirs := modelDirs()
|
||||
if strings.TrimSpace(dir) != "" {
|
||||
if d, err := resolveDownloadDir(dir); err == nil {
|
||||
dirs = []string{d}
|
||||
}
|
||||
}
|
||||
for _, d := range dirs {
|
||||
p := filepath.Join(d, base)
|
||||
if st, err := os.Stat(p); err != nil || st.IsDir() {
|
||||
continue
|
||||
}
|
||||
// Famille incomplète = modèle inutilisable : on ne prétend pas qu'il est
|
||||
// là, l'appelant doit pouvoir relancer le téléchargement des tranches
|
||||
// manquantes (handleModelDownload les reprend une par une).
|
||||
if len(shardFamilyMissing(d, base)) > 0 {
|
||||
continue
|
||||
}
|
||||
return modelPickerValue(d, base), p, true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// modelPickerValue est la valeur à écrire dans MODEL= pour un fichier donné :
|
||||
// le simple nom quand il vit dans le dossier de téléchargement de Loki (le
|
||||
// preset reste alors portable d'une machine à l'autre), le chemin complet
|
||||
// ailleurs. Même règle que la liste /api/models.
|
||||
func modelPickerValue(dir, base string) string {
|
||||
if normDir(dir) == normDir(modelsDir()) {
|
||||
return base
|
||||
}
|
||||
return filepath.Join(dir, base)
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeModel pose un .gguf factice dans dir et renvoie son chemin.
|
||||
func writeModel(t *testing.T, dir, name string, size int) string {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// Le scénario complet du rapport : un modèle téléchargé depuis un preset, le
|
||||
// preset supprimé sans son .gguf, et plus rien pour reprendre le fichier.
|
||||
// Après ce correctif il doit être A) retrouvé par le sélecteur, B) effaçable.
|
||||
func TestModeleOrphelinResteUtilisableEtEffaçable(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "modele.gguf", 1024)
|
||||
|
||||
// A) Présent sur le disque, sous la valeur qu'on écrira dans MODEL=.
|
||||
value, path, ok := modelPresence("modele.gguf", "")
|
||||
if !ok {
|
||||
t.Fatal("modèle présent non détecté — le téléchargement resterait un cul-de-sac")
|
||||
}
|
||||
if value != "modele.gguf" {
|
||||
t.Fatalf("valeur MODEL= = %q, attendu le simple nom de fichier", value)
|
||||
}
|
||||
if path != filepath.Join(modelsDir(), "modele.gguf") {
|
||||
t.Fatalf("chemin = %q", path)
|
||||
}
|
||||
|
||||
// B) Plus personne ne le référence : il s'efface sans force.
|
||||
users, active := modelUsers("modele.gguf")
|
||||
if active || len(users) != 0 {
|
||||
t.Fatalf("modèle orphelin annoncé utilisé : actif=%v presets=%v", active, users)
|
||||
}
|
||||
freed, err := deleteModelFile("modele.gguf")
|
||||
if err != nil {
|
||||
t.Fatalf("suppression refusée : %v", err)
|
||||
}
|
||||
if freed != 1024 {
|
||||
t.Fatalf("octets libérés = %d, attendu 1024", freed)
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("le fichier est toujours là")
|
||||
}
|
||||
}
|
||||
|
||||
// Le modèle que le moteur a ouvert ne s'efface pas : la place ne serait même pas
|
||||
// rendue, et le prochain démarrage mourrait sur un .gguf absent.
|
||||
func TestSuppressionRefuseeSurLeModeleEnService(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "servi.gguf", 8)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "servi.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, active := modelUsers("servi.gguf"); !active {
|
||||
t.Fatal("modèle de la configuration active non reconnu")
|
||||
}
|
||||
if _, err := deleteModelFile("servi.gguf"); err == nil {
|
||||
t.Fatal("le modèle en service a été supprimé")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(modelsDir(), "servi.gguf")); err != nil {
|
||||
t.Fatal("fichier supprimé malgré le refus")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle nommé par des presets n'est pas bloqué : il est SIGNALÉ, et
|
||||
// force:true tranche. Refuser tout court recréerait l'impasse qu'on corrige.
|
||||
func TestSuppressionSignaleLesPresetsPuisObeitAForce(t *testing.T) {
|
||||
testHome(t)
|
||||
p := writeModel(t, modelsDir(), "partage.gguf", 16)
|
||||
if _, err := SavePreset("", "Rapide", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := SavePreset("", "Long", "MODEL=\"partage.gguf\"\nCTX=131072\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, active := modelUsers("partage.gguf")
|
||||
if active {
|
||||
t.Fatal("aucune configuration active ne devrait référencer ce modèle")
|
||||
}
|
||||
if len(users) != 2 {
|
||||
t.Fatalf("presets référents = %v, attendu les deux", users)
|
||||
}
|
||||
|
||||
post := func(body string) (int, map[string]any) {
|
||||
r := httptest.NewRequest("POST", "/api/models/delete", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
handleModelDelete(w, r)
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out
|
||||
}
|
||||
code, out := post(`{"name":"partage.gguf"}`)
|
||||
if code != 409 {
|
||||
t.Fatalf("code = %d, attendu 409 (presets référents)", code)
|
||||
}
|
||||
if len(out["used"].([]any)) != 2 {
|
||||
t.Fatalf("la réponse doit NOMMER les presets concernés : %v", out)
|
||||
}
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Fatal("fichier supprimé alors que la confirmation n'a pas eu lieu")
|
||||
}
|
||||
if code, out = post(`{"name":"partage.gguf","force":true}`); code != 200 {
|
||||
t.Fatalf("code = %d avec force:true — %v", code, out)
|
||||
}
|
||||
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||
t.Fatal("force:true n'a pas supprimé le fichier")
|
||||
}
|
||||
}
|
||||
|
||||
// MMPROJ et un --mmproj resté dans EXTRA_ARGS comptent aussi : effacer le
|
||||
// projecteur du modèle en service casse la vision sans rien dire.
|
||||
func TestProjecteurCompteCommeReference(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "mmproj-F16.gguf", 4)
|
||||
writeModel(t, modelsDir(), "vieux-mmproj.gguf", 4)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "MMPROJ": "mmproj-F16.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, active := modelUsers("mmproj-F16.gguf"); !active {
|
||||
t.Fatal("MMPROJ= non compté comme référence")
|
||||
}
|
||||
if _, err := SavePreset("", "Vision", `MODEL="m.gguf"`+"\n"+`EXTRA_ARGS="--mmproj vieux-mmproj.gguf --flash-attn"`+"\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, _ := modelUsers("vieux-mmproj.gguf")
|
||||
if len(users) != 1 || users[0] != "Vision" {
|
||||
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
|
||||
}
|
||||
}
|
||||
|
||||
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
|
||||
// télécharger, et prétendre le contraire donnerait un moteur qui meurt sur un
|
||||
// tenseur introuvable.
|
||||
func TestFamilleIncompleteNestPasPresente(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "gros-00001-of-00002.gguf", 4)
|
||||
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); ok {
|
||||
t.Fatal("famille incomplète annoncée présente")
|
||||
}
|
||||
writeModel(t, modelsDir(), "gros-00002-of-00002.gguf", 4)
|
||||
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); !ok {
|
||||
t.Fatal("famille complète non détectée")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle rangé dans un dossier déclaré ailleurs (disque externe) est trouvé
|
||||
// lui aussi, et sous son chemin complet — un simple nom ne le désignerait pas.
|
||||
func TestPresenceDansUnDossierDeclare(t *testing.T) {
|
||||
home := testHome(t)
|
||||
ext := filepath.Join(home, "externe")
|
||||
writeModel(t, ext, "ailleurs.gguf", 32)
|
||||
if _, _, ok := modelPresence("ailleurs.gguf", ""); ok {
|
||||
t.Fatal("dossier non déclaré : le modèle ne doit pas être visible")
|
||||
}
|
||||
if err := saveExtraModelDirs([]string{ext}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
value, _, ok := modelPresence("ailleurs.gguf", "")
|
||||
if !ok {
|
||||
t.Fatal("modèle d'un dossier déclaré non détecté")
|
||||
}
|
||||
if value != filepath.Join(ext, "ailleurs.gguf") {
|
||||
t.Fatalf("valeur MODEL= = %q, attendu le chemin complet", value)
|
||||
}
|
||||
}
|
||||
|
||||
// /api/models doit marquer « dossier loki » le dossier de téléchargement et en
|
||||
// donner le SIMPLE NOM : c'est ce que resolveModelPath retrouve, et c'est ce qui
|
||||
// permet au sélecteur de reconnaître un MODEL=modele.gguf écrit à la main.
|
||||
func TestListeModelesNommeLeDossierDeLoki(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "local.gguf", 64)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "local.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
handleModels(w, httptest.NewRequest("GET", "/api/models", nil))
|
||||
var out []map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("liste = %v", out)
|
||||
}
|
||||
if out[0]["home"] != true {
|
||||
t.Fatal("le dossier de téléchargement n'est pas marqué « dossier loki »")
|
||||
}
|
||||
if out[0]["value"] != "local.gguf" {
|
||||
t.Fatalf("value = %v, attendu le simple nom de fichier", out[0]["value"])
|
||||
}
|
||||
if out[0]["active"] != true {
|
||||
t.Fatal("le modèle en service doit être signalé comme tel")
|
||||
}
|
||||
}
|
||||
|
||||
// Télécharger un modèle DÉJÀ sur le disque n'est pas une erreur : c'est le
|
||||
// résultat voulu. Les deux routes doivent répondre « ok, exists » avec la valeur
|
||||
// à écrire dans MODEL= — sinon l'interface s'arrête en rouge sur « le modèle
|
||||
// existe déjà » et il devient impossible de refaire un preset dessus.
|
||||
func TestTelechargerUnModelePresentLeSelectionne(t *testing.T) {
|
||||
testHome(t)
|
||||
writeModel(t, modelsDir(), "deja.gguf", 2048)
|
||||
const url = "https://huggingface.co/auteur/depot/resolve/main/deja.gguf"
|
||||
|
||||
call := func(h func(w http.ResponseWriter, r *http.Request), path string) (int, map[string]any) {
|
||||
body := `{"url":"` + url + `","dir":""}`
|
||||
w := httptest.NewRecorder()
|
||||
h(w, httptest.NewRequest("POST", path, strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &out)
|
||||
return w.Code, out
|
||||
}
|
||||
|
||||
// La sonde ne doit même pas sortir sur le réseau : elle répond depuis le disque.
|
||||
code, out := call(handleModelDownloadProbe, "/api/models/download/probe")
|
||||
if code != 200 || out["ok"] != true || out["exists"] != true {
|
||||
t.Fatalf("sonde : code=%d out=%v", code, out)
|
||||
}
|
||||
if out["value"] != "deja.gguf" {
|
||||
t.Fatalf("sonde : value = %v, attendu le nom à mettre dans MODEL=", out["value"])
|
||||
}
|
||||
|
||||
code, out = call(handleModelDownload, "/api/models/download")
|
||||
if code != 200 || out["ok"] != true || out["exists"] != true {
|
||||
t.Fatalf("téléchargement : code=%d out=%v", code, out)
|
||||
}
|
||||
if out["value"] != "deja.gguf" {
|
||||
t.Fatalf("téléchargement : value = %v", out["value"])
|
||||
}
|
||||
}
|
||||
@@ -106,9 +106,13 @@ func TestDeleteModelFileEmporteLesTranches(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil {
|
||||
freed, err := deleteModelFile("m-00001-of-00002.gguf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if freed != 2 {
|
||||
t.Fatalf("octets libérés = %d, attendu 2 (les deux tranches d'un octet)", freed)
|
||||
}
|
||||
for _, n := range names[:2] {
|
||||
if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) {
|
||||
t.Fatalf("tranche %s non supprimée", n)
|
||||
|
||||
@@ -225,9 +225,38 @@ func applyPresetFile(target string) error {
|
||||
}
|
||||
next[k] = v
|
||||
}
|
||||
// Le moteur suit la machine, pas le preset (voir presetImposesEngine).
|
||||
if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) {
|
||||
next["BIN"] = cur["BIN"]
|
||||
}
|
||||
return WriteConfig(next)
|
||||
}
|
||||
|
||||
// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ?
|
||||
//
|
||||
// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du
|
||||
// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque
|
||||
// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de
|
||||
// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et
|
||||
// un modèle qui chargeait la veille mourait sur « unknown model architecture »
|
||||
// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur
|
||||
// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un
|
||||
// preset : le courant reste.
|
||||
//
|
||||
// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go :
|
||||
// « un backend custom se choisit par modèle »), lui, est un vrai choix par
|
||||
// modèle : le preset gagne.
|
||||
func presetImposesEngine(p string) bool {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || engineOwns(p) || prebuiltOwns(p) {
|
||||
return false
|
||||
}
|
||||
if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SwitchToPreset installe le preset et redémarre le service. Les réglages
|
||||
// « appareil » (preservedKeys) sont conservés à travers la bascule.
|
||||
func SwitchToPreset(target string) error {
|
||||
@@ -235,6 +264,17 @@ func SwitchToPreset(target string) error {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
|
||||
// Preset externe (backend_external.go) : rien à redémarrer, il n'a pas de
|
||||
// MODEL et llama-server partirait en boucle de crash. On arrête au contraire
|
||||
// le moteur encore en vie — le chat part vers l'API distante, garder le
|
||||
// modèle en VRAM ne sert plus à rien.
|
||||
if isExternalConfig(ReadConfig()) {
|
||||
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
|
||||
if !serviceIsActive() {
|
||||
return nil
|
||||
}
|
||||
return serviceAction("stop")
|
||||
}
|
||||
fmt.Println(dim("[info] redémarrage du service..."))
|
||||
return serviceAction("restart")
|
||||
}
|
||||
@@ -287,6 +327,10 @@ func cmdSwitch(args []string) error {
|
||||
// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à
|
||||
// tout nettoyer à la main. On repart d'une base vide : les valeurs non
|
||||
// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…).
|
||||
//
|
||||
// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) :
|
||||
// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le
|
||||
// moteur mis à jour à la première bascule.
|
||||
var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
|
||||
|
||||
// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
|
||||
@@ -294,9 +338,11 @@ func newPresetSeed() map[string]string {
|
||||
cur := ReadConfig()
|
||||
seed := map[string]string{}
|
||||
for _, k := range newPresetSeedKeys {
|
||||
if v := cur[k]; v != "" {
|
||||
seed[k] = v
|
||||
v := cur[k]
|
||||
if v == "" || (k == "BIN" && !presetImposesEngine(v)) {
|
||||
continue
|
||||
}
|
||||
seed[k] = v
|
||||
}
|
||||
return seed
|
||||
}
|
||||
@@ -331,6 +377,41 @@ func SavePreset(id, name, content string) (string, error) {
|
||||
return id, os.WriteFile(p, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE,
|
||||
// installe aussitôt la nouvelle version comme configuration active (mêmes
|
||||
// règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas —
|
||||
// l'appelant redémarre alors le moteur.
|
||||
//
|
||||
// Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du
|
||||
// chat qui affiche toujours 32768. Seul le fichier du preset avait changé ;
|
||||
// config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun
|
||||
// preset n'était détecté actif (empreinte différente). Éditer le preset en
|
||||
// service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou
|
||||
// nouveau, lui, n'est qu'un fichier : rien ne bouge.
|
||||
//
|
||||
// « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui
|
||||
// porte l'empreinte de la configuration courante.
|
||||
func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) {
|
||||
wasActive := false
|
||||
if id != "" {
|
||||
if old, rerr := ReadPreset(id); rerr == nil {
|
||||
wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig())
|
||||
}
|
||||
}
|
||||
newID, err = SavePreset(id, name, content)
|
||||
if err != nil || !wasActive {
|
||||
return newID, false, err
|
||||
}
|
||||
p, err := safePresetPath(newID)
|
||||
if err != nil {
|
||||
return newID, false, err
|
||||
}
|
||||
if err := applyPresetFile(p); err != nil {
|
||||
return newID, false, err
|
||||
}
|
||||
return newID, true, nil
|
||||
}
|
||||
|
||||
// DeletePreset removes a preset by id; refuses if it is the active config.
|
||||
func DeletePreset(id string) error {
|
||||
p, err := safePresetPath(id)
|
||||
@@ -344,7 +425,14 @@ func DeletePreset(id string) error {
|
||||
if presetFingerprint(target) == configFingerprint(ReadConfig()) {
|
||||
return fmt.Errorf("preset actif, switche d'abord")
|
||||
}
|
||||
return os.Remove(p)
|
||||
if err := os.Remove(p); err != nil {
|
||||
return err
|
||||
}
|
||||
// Le prompt système du preset vit en base, pas dans son .env (web_sysprompt.go) :
|
||||
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
|
||||
// sous le même id.
|
||||
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadPreset returns the contents of a preset by id (filename).
|
||||
|
||||
@@ -68,6 +68,64 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Le moteur suit la machine. Vécu : moteur mis à jour depuis l'interface
|
||||
// (BIN → /data/engine/server-cuda-b10680), Qwen3.8-Flash-Next charge ; on
|
||||
// change de modèle — le preset, créé avant, porte BIN=/app/llama-server — et
|
||||
// le vieux moteur revient : « unknown model architecture: 'qwen4exp' ». Seul
|
||||
// un backend personnalisé, choix par modèle, a le droit d'imposer son BIN.
|
||||
func TestSwitchToPresetGardeLeMoteur(t *testing.T) {
|
||||
home := testHome(t)
|
||||
t.Setenv("LOKI_ENGINE_BIN", "/app/llama-server")
|
||||
write := func(p, body string) {
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engine := filepath.Join(home, "engine", "server-cuda-b10680", "llama-server")
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
|
||||
|
||||
// Preset d'avant la mise à jour : il porte le moteur de l'image.
|
||||
image := filepath.Join(home, "image.env")
|
||||
write(image, "MODEL=nouveau.gguf\nBIN=/app/llama-server\n")
|
||||
if err := applyPresetFile(image); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != engine {
|
||||
t.Errorf("BIN = %q après bascule, attendu le moteur mis à jour %q", got, engine)
|
||||
}
|
||||
// Preset muet sur le moteur : idem.
|
||||
muet := filepath.Join(home, "muet.env")
|
||||
write(muet, "MODEL=autre.gguf\n")
|
||||
if err := applyPresetFile(muet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != engine {
|
||||
t.Errorf("BIN = %q après preset muet, attendu %q conservé", got, engine)
|
||||
}
|
||||
// Backend personnalisé : le preset gagne.
|
||||
custom := filepath.Join(home, "custom.env")
|
||||
write(custom, "MODEL=exotique.gguf\nBIN=/opt/mon-fork/llama-server\n")
|
||||
if err := applyPresetFile(custom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != "/opt/mon-fork/llama-server" {
|
||||
t.Errorf("BIN = %q, attendu le backend personnalisé du preset", got)
|
||||
}
|
||||
// Un nouveau preset ne fige pas ce backend-là… si, justement : c'est un
|
||||
// choix par modèle. Mais il ne fige ni l'image ni un moteur téléchargé.
|
||||
if seed := newPresetSeed(); seed["BIN"] != "/opt/mon-fork/llama-server" {
|
||||
t.Errorf("seed BIN = %q, attendu le backend personnalisé", seed["BIN"])
|
||||
}
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
|
||||
if seed := newPresetSeed(); seed["BIN"] != "" {
|
||||
t.Errorf("seed BIN = %q, un moteur téléchargé ne doit pas être figé dans un preset", seed["BIN"])
|
||||
}
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN=/app/llama-server\n")
|
||||
if seed := newPresetSeed(); seed["BIN"] != "" {
|
||||
t.Errorf("seed BIN = %q, le moteur de l'image ne doit pas être figé dans un preset", seed["BIN"])
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset qui définit LUI-MÊME la sélection de cartes doit gagner sur celle
|
||||
// de la machine : « FABLE 2 GPU » impose CUDA_VISIBLE_DEVICES=1,0 pour que son
|
||||
// --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait
|
||||
@@ -102,3 +160,54 @@ func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) {
|
||||
t.Errorf("WEB_ENGINE = %q, attendu go (réglage machine, jamais dicté par un preset)", cfg["WEB_ENGINE"])
|
||||
}
|
||||
}
|
||||
|
||||
// Modifier le preset ACTIF doit changer la configuration courante. Vécu : CTX
|
||||
// passé à 100000 dans l'éditeur, « enregistré » — et la carte du chat qui
|
||||
// affiche toujours 32768, parce que seul le fichier du preset avait bougé ;
|
||||
// config.env, lui, gardait l'ancienne valeur et plus aucun preset n'était
|
||||
// détecté actif. Un preset qui n'est pas celui en service, ou un preset
|
||||
// nouveau, ne touche pas à la configuration.
|
||||
func TestModifierLePresetActifLApplique(t *testing.T) {
|
||||
testHome(t)
|
||||
dir := presetsDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(name, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".env"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=32768\n")
|
||||
write("autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=4096\n")
|
||||
setConfig(t, "MODEL=foo.gguf\nCTX=32768\nMEM_MODE=always\n")
|
||||
|
||||
// Le preset en service : la config suit, les réglages machine restent.
|
||||
id, applied, err := SavePresetApplying("actif", "Actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=100000\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "actif" || !applied {
|
||||
t.Fatalf("id=%q applied=%v, attendu actif/true", id, applied)
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
if cfg["CTX"] != "100000" || cfg["MEM_MODE"] != "always" {
|
||||
t.Fatalf("configuration non suivie : %v", cfg)
|
||||
}
|
||||
|
||||
// Un autre preset : le fichier change, la config non.
|
||||
if _, applied, err = SavePresetApplying("autre", "Autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=8192\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied || ReadConfig()["CTX"] != "100000" {
|
||||
t.Fatalf("un preset inactif a touché la configuration (applied=%v, CTX=%q)", applied, ReadConfig()["CTX"])
|
||||
}
|
||||
|
||||
// Un preset nouveau : jamais appliqué à la volée.
|
||||
if _, applied, err = SavePresetApplying("", "Neuf", "MODEL=baz.gguf\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied || ReadConfig()["MODEL"] != "foo.gguf" {
|
||||
t.Fatalf("un preset nouveau a touché la configuration (applied=%v)", applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package loki
|
||||
|
||||
// backup_bundle.go — construit et restaure un paquet de sauvegarde
|
||||
// {mémoire, presets, réglages}, CHIFFRÉ côté agent avant de quitter la machine.
|
||||
//
|
||||
// Le relais ne voit qu'un blob opaque (voir jean-relay/backup.go). Comme le tar
|
||||
// entier est chiffré, même les noms de fichiers et de presets sont cachés.
|
||||
//
|
||||
// Format du blob :
|
||||
// magic "LOKIBK1" || uint32(len entête) || entête JSON || corps
|
||||
// entête : { v, salt, kdf, box_pw, box_rec } — la DEK de sauvegarde wrappée
|
||||
// par la KEK(mot de passe) ET par la KEK(clé de récupération).
|
||||
// corps : gcmSeal(backupDEK, tar.gz du bundle).
|
||||
//
|
||||
// Restauration : mot de passe → KEK → déballe la DEK → déchiffre le corps. Ne
|
||||
// dépend d'AUCUN état de l'ancienne machine : un serveur vierge suffit.
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var backupMagic = []byte("LKBK1") // v2 : entête = keyvault, corps chiffré par la DEK
|
||||
|
||||
const backupBodyAAD = "loki-backup-body"
|
||||
|
||||
// buildBundleTar empaquète mémoire + presets + réglages en un tar.gz.
|
||||
// Les fichiers mémoire sont pris TELS QUELS (chiffrés si le chiffrement est
|
||||
// actif) : le bundle reste donc chiffré même avant l'enveloppe de sauvegarde.
|
||||
func buildBundleTar() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
addDir := func(prefix, dir string) error {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil // dossier absent = rien à ajouter
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hdr := &tar.Header{Name: prefix + "/" + e.Name(), Mode: 0o600, Size: int64(len(b))}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tw.Write(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mémoire de TOUS les projets : un sous-dossier par projet (memory/<slug>/...).
|
||||
if roots, err := os.ReadDir(projectsRoot()); err == nil {
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
if err := addDir("memory/"+d.Name(), filepath.Join(projectsRoot(), d.Name())); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := addDir("presets", presetsDir()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Réglages (bucket config) sérialisés en JSON.
|
||||
cfg, _ := json.Marshal(ReadConfig())
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "config.json", Mode: 0o600, Size: int64(len(cfg))}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := tw.Write(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildBackupBlob assemble le paquet chiffré : magic || uint32(len keyvault) ||
|
||||
// keyvault JSON || gcmSeal(DEK, tar). AUCUN mot de passe de sauvegarde : le corps
|
||||
// est chiffré avec la DEK de la mémoire (déjà en RAM), et le keyvault (la DEK
|
||||
// wrappée par la clé d'API + la clé de récupération) voyage en entête. La
|
||||
// restauration n'a donc besoin que de la clé d'API. Le relais ne voit qu'un blob
|
||||
// opaque : le keyvault en entête ne contient que la DEK CHIFFRÉE.
|
||||
func buildBackupBlob(v *keyVault, dek, tarData []byte) ([]byte, error) {
|
||||
kv, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := gcmSeal(dek, tarData, []byte(backupBodyAAD))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out bytes.Buffer
|
||||
out.Write(backupMagic)
|
||||
var lenbuf [4]byte
|
||||
binary.BigEndian.PutUint32(lenbuf[:], uint32(len(kv)))
|
||||
out.Write(lenbuf[:])
|
||||
out.Write(kv)
|
||||
out.Write(body)
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// openBackupBlob lit l'entête keyvault, l'ouvre avec secret (clé d'API OU clé de
|
||||
// récupération) pour retrouver la DEK, puis déchiffre le corps.
|
||||
func openBackupBlob(blob []byte, secret string) ([]byte, error) {
|
||||
if !bytes.HasPrefix(blob, backupMagic) {
|
||||
return nil, fmt.Errorf("format de sauvegarde non reconnu")
|
||||
}
|
||||
rest := blob[len(backupMagic):]
|
||||
if len(rest) < 4 {
|
||||
return nil, fmt.Errorf("blob tronqué")
|
||||
}
|
||||
n := binary.BigEndian.Uint32(rest[:4])
|
||||
rest = rest[4:]
|
||||
if int(n) > len(rest) {
|
||||
return nil, fmt.Errorf("entête tronqué")
|
||||
}
|
||||
var v keyVault
|
||||
if err := json.Unmarshal(rest[:n], &v); err != nil {
|
||||
return nil, fmt.Errorf("entête illisible : %w", err)
|
||||
}
|
||||
body := rest[n:]
|
||||
dek, _, err := v.unlockWith(secret)
|
||||
if err != nil {
|
||||
dek, _, err = v.unlockWith(normalizeRecovery(secret))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clé incorrecte")
|
||||
}
|
||||
return gcmOpen(dek, body, []byte(backupBodyAAD))
|
||||
}
|
||||
|
||||
// restoreBundleTar réécrit mémoire + presets + réglages depuis un tar.gz. Prend
|
||||
// D'ABORD un snapshot de sécurité de la mémoire, puis écrit de façon vérifiée.
|
||||
func restoreBundleTar(tarData []byte) error {
|
||||
if _, err := snapshotMemory("avant-restauration"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
|
||||
}
|
||||
gz, err := gzip.NewReader(bytes.NewReader(tarData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(gz)
|
||||
var cfgJSON []byte
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Les chemins tar sont TOUJOURS à séparateur « / » (jamais l'OS) : on
|
||||
// raisonne en slash, sinon le préfixe "memory/" casse sous Windows.
|
||||
name := path.Clean("/" + strings.ReplaceAll(hdr.Name, "\\", "/"))[1:]
|
||||
if strings.Contains(name, "..") {
|
||||
continue // sécurité : jamais de traversée
|
||||
}
|
||||
base := path.Base(name)
|
||||
switch {
|
||||
case name == "config.json":
|
||||
cfgJSON = b
|
||||
case strings.HasPrefix(name, "memory/"):
|
||||
// Chemin relatif SOUS memory/ (memory/<slug>/page.md). Les sauvegardes
|
||||
// d'avant les projets stockaient les pages à plat (memory/page.md) : on
|
||||
// les rattache au projet par défaut.
|
||||
rel := strings.TrimPrefix(name, "memory/")
|
||||
if !strings.Contains(rel, "/") {
|
||||
rel = defaultProjectSlug + "/" + rel
|
||||
}
|
||||
dst := filepath.Join(projectsRoot(), filepath.FromSlash(rel))
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(dst, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
case strings.HasPrefix(name, "presets/"):
|
||||
if err := os.MkdirAll(presetsDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(presetsDir(), base), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(cfgJSON) > 0 {
|
||||
var cfg map[string]string
|
||||
if json.Unmarshal(cfgJSON, &cfg) == nil {
|
||||
_ = WriteConfig(cfg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Un paquet exporté doit se rouvrir avec la clé de pilotage comme avec la clé de
|
||||
// récupération, et rendre la mémoire à l'identique. C'est le scénario « je
|
||||
// remonte mon conteneur ailleurs » : rien d'autre que le fichier et la clé.
|
||||
func TestBackupBundleRoundTrip(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("notes.md", "# Notes\nsecret du client\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recovery, err := EnableMemEncryption("clé-de-pilotage")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
v, err := loadVault()
|
||||
if err != nil || v == nil {
|
||||
t.Fatalf("coffre introuvable après activation: %v", err)
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tarData, err := buildBundleTar()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blob, err := buildBackupBlob(v, dek, tarData)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Le paquet ne doit rien laisser voir en clair.
|
||||
if strings.Contains(string(blob), "secret du client") {
|
||||
t.Fatal("le contenu d'une page apparaît en clair dans le paquet")
|
||||
}
|
||||
|
||||
// La clé de pilotage l'ouvre…
|
||||
if _, err := openBackupBlob(blob, "clé-de-pilotage"); err != nil {
|
||||
t.Fatalf("ouverture avec la clé de pilotage: %v", err)
|
||||
}
|
||||
// …la clé de récupération aussi…
|
||||
back, err := openBackupBlob(blob, recovery)
|
||||
if err != nil {
|
||||
t.Fatalf("ouverture avec la clé de récupération: %v", err)
|
||||
}
|
||||
// …et une mauvaise clé, non.
|
||||
if _, err := openBackupBlob(blob, "pas la bonne"); err == nil {
|
||||
t.Fatal("une clé fausse aurait dû être refusée")
|
||||
}
|
||||
|
||||
// Page effacée puis restaurée depuis le paquet : contenu intact.
|
||||
p, _ := safeMemPath("notes.md")
|
||||
if err := os.Remove(p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := restoreBundleTar(back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok := memPageText("notes.md")
|
||||
if !ok || !strings.Contains(got, "secret du client") {
|
||||
t.Fatalf("page non restaurée: %q (lisible=%v)", got, ok)
|
||||
}
|
||||
// Le snapshot de sécurité pris avant restauration doit exister.
|
||||
if entries, err := os.ReadDir(filepath.Join(LokiHome(), "backups", "memory")); err != nil || len(entries) == 0 {
|
||||
t.Fatal("aucun snapshot pris avant la restauration")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package loki
|
||||
|
||||
// browser_grid.go — surimpose une GRILLE DE COORDONNÉES sur le screenshot envoyé
|
||||
// au MODÈLE (pas sur celui sauvegardé pour l'utilisateur). Un petit modèle vise
|
||||
// mal au pixel près ; avec des lignes graduées tous les 100 px et des étiquettes
|
||||
// chiffrées, il lit x/y précisément et browser_click_xy tombe juste du premier
|
||||
// coup (bandeaux cookies en iframe, canvas, jeux…). Sans dépendance externe : la
|
||||
// police de chiffres est un bitmap 3×5 codé ici.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
const gridStep = 100 // espacement des lignes, en pixels
|
||||
|
||||
// digits5x3 : bitmap 5 lignes × 3 colonnes pour chaque chiffre (# = pixel plein).
|
||||
var digits5x3 = map[rune][5]string{
|
||||
'0': {"###", "# #", "# #", "# #", "###"},
|
||||
'1': {" # ", "## ", " # ", " # ", "###"},
|
||||
'2': {"###", " #", "###", "# ", "###"},
|
||||
'3': {"###", " #", "###", " #", "###"},
|
||||
'4': {"# #", "# #", "###", " #", " #"},
|
||||
'5': {"###", "# ", "###", " #", "###"},
|
||||
'6': {"###", "# ", "###", "# #", "###"},
|
||||
'7': {"###", " #", " #", " #", " #"},
|
||||
'8': {"###", "# #", "###", "# #", "###"},
|
||||
'9': {"###", "# #", "###", " #", "###"},
|
||||
}
|
||||
|
||||
// overlayGrid décode un PNG, y dessine la grille + les graduations, et renvoie le
|
||||
// PNG résultant. En cas d'erreur (image illisible), renvoie l'entrée inchangée.
|
||||
func overlayGrid(raw []byte) []byte {
|
||||
src, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
b := src.Bounds()
|
||||
img := image.NewRGBA(b)
|
||||
draw.Draw(img, b, src, b.Min, draw.Src)
|
||||
|
||||
line := color.RGBA{255, 0, 200, 130} // magenta translucide pour les lignes
|
||||
labelBG := color.RGBA{0, 0, 0, 200} // fond sombre derrière les chiffres
|
||||
labelFG := color.RGBA{255, 255, 0, 255} // chiffres jaunes (contraste sur toute page)
|
||||
w, h := b.Dx(), b.Dy()
|
||||
|
||||
// Lignes verticales + étiquette x en haut.
|
||||
for x := gridStep; x < w; x += gridStep {
|
||||
for y := 0; y < h; y++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+x+2, b.Min.Y+2, strconv.Itoa(x), labelFG, labelBG)
|
||||
}
|
||||
// Lignes horizontales + étiquette y à gauche.
|
||||
for y := gridStep; y < h; y += gridStep {
|
||||
for x := 0; x < w; x++ {
|
||||
blend(img, b.Min.X+x, b.Min.Y+y, line)
|
||||
}
|
||||
drawLabel(img, b.Min.X+2, b.Min.Y+y+2, strconv.Itoa(y), labelFG, labelBG)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return raw
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
// blend applique une couleur semi-transparente (alpha) sur un pixel.
|
||||
func blend(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if !(image.Pt(x, y).In(img.Bounds())) {
|
||||
return
|
||||
}
|
||||
a := float64(c.A) / 255
|
||||
o := img.RGBAAt(x, y)
|
||||
img.SetRGBA(x, y, color.RGBA{
|
||||
R: uint8(float64(c.R)*a + float64(o.R)*(1-a)),
|
||||
G: uint8(float64(c.G)*a + float64(o.G)*(1-a)),
|
||||
B: uint8(float64(c.B)*a + float64(o.B)*(1-a)),
|
||||
A: 255,
|
||||
})
|
||||
}
|
||||
|
||||
// drawLabel écrit un nombre (police 3×5, échelle ×2) avec un fond sombre pour
|
||||
// rester lisible sur n'importe quel fond.
|
||||
func drawLabel(img *image.RGBA, x, y int, s string, fg, bg color.RGBA) {
|
||||
const sc = 2
|
||||
glyphW, glyphH, gap := 3*sc, 5*sc, sc
|
||||
boxW := len(s)*(glyphW+gap) + gap
|
||||
boxH := glyphH + 2*gap
|
||||
// Fond.
|
||||
for dy := -gap; dy < boxH-gap; dy++ {
|
||||
for dx := -gap; dx < boxW-gap; dx++ {
|
||||
setOpaque(img, x+dx, y+dy, bg)
|
||||
}
|
||||
}
|
||||
// Chiffres.
|
||||
cx := x
|
||||
for _, r := range s {
|
||||
g, ok := digits5x3[r]
|
||||
if !ok {
|
||||
cx += glyphW + gap
|
||||
continue
|
||||
}
|
||||
for row := 0; row < 5; row++ {
|
||||
for col := 0; col < 3; col++ {
|
||||
if g[row][col] != '#' {
|
||||
continue
|
||||
}
|
||||
for iy := 0; iy < sc; iy++ {
|
||||
for ix := 0; ix < sc; ix++ {
|
||||
setOpaque(img, cx+col*sc+ix, y+row*sc+iy, fg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cx += glyphW + gap
|
||||
}
|
||||
}
|
||||
|
||||
func setOpaque(img *image.RGBA, x, y int, c color.RGBA) {
|
||||
if image.Pt(x, y).In(img.Bounds()) {
|
||||
img.SetRGBA(x, y, color.RGBA{c.R, c.G, c.B, 255})
|
||||
}
|
||||
}
|
||||
+231
-25
@@ -34,7 +34,10 @@ const (
|
||||
// Budget de la queue : fraction de la fenêtre gardée intacte (tours récents).
|
||||
// Plus la queue est petite, plus on compacte de torse d'un coup → le contexte
|
||||
// retombe bas et met longtemps à re-déclencher (au lieu de compacter souvent).
|
||||
compactTailFrac = 0.25
|
||||
// Descendu de 0.25 à 0.20 avec l'archivage recall : compacter plus large ne
|
||||
// coûte plus de perte (les gros blocs restent rappelables), donc autant faire
|
||||
// des compactions franches et rares plutôt que fréquentes et molles.
|
||||
compactTailFrac = 0.20
|
||||
// Un résultat d'outil du torse plus long que ça est remplacé par un marqueur
|
||||
// dans le torse DÉGRAISSÉ (repli si le résumé échoue).
|
||||
compactToolPruneLen = 200
|
||||
@@ -77,11 +80,54 @@ func ctxWindow() int {
|
||||
return 32768
|
||||
}
|
||||
|
||||
// compactSummaryBudget renvoie le budget en tokens du résumé, adapté à la
|
||||
// fenêtre. Assez pour PORTER LE FIL (la demande, les faits déjà trouvés, les
|
||||
// décisions, l'état d'avancement) sans jamais le perdre — c'était la plainte de
|
||||
// fond : à 700 tokens fixes, tout le passé d'une longue conversation était
|
||||
// écrasé en un demi-paragraphe et le modèle « perdait le fil ». Le résumé reste
|
||||
// de toute façon minuscule devant le torse compacté (souvent des dizaines de
|
||||
// milliers de tokens), donc la réduction demeure massive. Bornes : 700 (petites
|
||||
// fenêtres) à 1600 (grandes), ~4 % de la fenêtre entre les deux.
|
||||
func compactSummaryBudget() int {
|
||||
n := ctxWindow() * 4 / 100
|
||||
if n < 700 {
|
||||
n = 700
|
||||
}
|
||||
if n > 1600 {
|
||||
n = 1600
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// imageLostMarker remplace la partie `image_url` d'un message multimodal dans
|
||||
// msgText. Toute la chaîne de compaction passe par msgText — estimation de
|
||||
// contexte, choix des messages à résumer, transcript donné au résumeur — et
|
||||
// msgText n'extrayait QUE les parties `text`. Un message qui porte une capture
|
||||
// (screenshotImageMessage) ne pesait donc que sa légende (« Capture … »), et le
|
||||
// résumeur ne savait même pas qu'une image avait été montrée : au compactage
|
||||
// suivant l'image disparaissait SANS AUCUNE trace. Observé en amont comme cause
|
||||
// probable de boucles de raisonnement — le modèle a bien regardé l'image, ne
|
||||
// peut plus s'y référer, et redérive à l'aveugle en texte.
|
||||
//
|
||||
// Ce marqueur ne rend pas l'image récupérable (le résumé est du texte), il rend
|
||||
// sa PERTE visible : le résumeur peut la mentionner, et le modèle sait qu'il
|
||||
// doit REVOIR l'image plutôt que deviner.
|
||||
//
|
||||
// Formulation volontairement générale depuis l'arrivée de see_image : une image
|
||||
// peut désormais venir d'une capture (web_screenshot), d'une pièce jointe ou
|
||||
// d'un fichier du disque. Dire « reprends la capture » enverrait le modèle
|
||||
// photographier une page web alors que l'image perdue était un PNG sur le
|
||||
// disque — le geste juste dépend de la provenance, que la légende conservée
|
||||
// juste avant ce marqueur indique.
|
||||
const imageLostMarker = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]"
|
||||
|
||||
// msgText extrait le texte d'un message (Content est `any`, en pratique string
|
||||
// ou nil quand l'assistant n'a que des tool_calls). Un message multimodal
|
||||
// (userMessageContent : parties texte + image quand la vision est active) porte
|
||||
// un tableau de parties ; on en recolle les segments `text` pour que l'estimation
|
||||
// de contexte et le transcript de compaction ne partent pas d'un texte vide.
|
||||
// de contexte et le transcript de compaction ne partent pas d'un texte vide, et
|
||||
// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans
|
||||
// quoi elle reste invisible à toute la chaîne de compaction.
|
||||
func msgText(m Message) string {
|
||||
switch v := m.Content.(type) {
|
||||
case string:
|
||||
@@ -89,20 +135,30 @@ func msgText(m Message) string {
|
||||
case []map[string]any:
|
||||
var b strings.Builder
|
||||
for _, part := range v {
|
||||
if part["type"] == "text" {
|
||||
switch part["type"] {
|
||||
case "text":
|
||||
if s, ok := part["text"].(string); ok {
|
||||
b.WriteString(s)
|
||||
}
|
||||
case "image_url":
|
||||
b.WriteString(imageLostMarker)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
case []any: // même contenu relu depuis le JSON persisté (map générique)
|
||||
var b strings.Builder
|
||||
for _, p := range v {
|
||||
if part, ok := p.(map[string]any); ok && part["type"] == "text" {
|
||||
part, ok := p.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch part["type"] {
|
||||
case "text":
|
||||
if s, ok := part["text"].(string); ok {
|
||||
b.WriteString(s)
|
||||
}
|
||||
case "image_url":
|
||||
b.WriteString(imageLostMarker)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
@@ -181,8 +237,13 @@ func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens in
|
||||
// l'historique compacté et true s'il a effectivement changé.
|
||||
// compactBounds calcule les frontières head/tail pour un historique donné et un
|
||||
// budget de queue (en tokens). Fonction pure (pas d'IO) → testable :
|
||||
// - head : nb de messages protégés en tête = messages système + 1er message
|
||||
// utilisateur (il ancre l'objectif). Un message user est une frontière sûre.
|
||||
// - head : nb de messages protégés en tête = messages système, et RIEN d'autre.
|
||||
// Le tout premier message utilisateur y était épinglé pour « ancrer
|
||||
// l'objectif » ; c'était contre-productif. Après compaction, le modèle voyait
|
||||
// deux demandes explicites — celle du DÉBUT de la conversation, épinglée, et
|
||||
// celle réinjectée par `pending` — et répondait volontiers à l'ancienne, en
|
||||
// abandonnant la tâche en cours. La demande courante est déjà garantie par
|
||||
// `pending` ; le premier message, lui, part au résumé comme le reste.
|
||||
// - tailStart : index de début de la queue protégée. On remonte depuis la fin
|
||||
// jusqu'à remplir le budget, puis on recule jusqu'à une frontière SÛRE :
|
||||
// un message `user`, ou un `assistant` (qui, s'il porte des tool_calls, part
|
||||
@@ -204,9 +265,6 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
|
||||
for head < len(msgs) && msgs[head].Role == "system" {
|
||||
head++
|
||||
}
|
||||
if head < len(msgs) && msgs[head].Role == "user" {
|
||||
head++
|
||||
}
|
||||
tailStart = len(msgs)
|
||||
acc := 0
|
||||
for i := len(msgs) - 1; i >= head; i-- {
|
||||
@@ -238,13 +296,46 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
|
||||
torso := msgs[head:tailStart]
|
||||
|
||||
// 3. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
|
||||
// 3. Archivage des gros blocs (mémoire longue). AVANT de compacter, chaque bloc
|
||||
// du torse assez long est enregistré VERBATIM sous un id (r7…) : le résumé et
|
||||
// le repli le remplacent par une référence à cet id, que le modèle pourra
|
||||
// rappeler avec recall(id). C'est ce qui rend le compactage sûr même
|
||||
// agressif — rien n'est perdu, seulement déplacé hors du contexte.
|
||||
// On n'archive QUE si le modèle a réellement ses outils (mode agent) : sans
|
||||
// eux il ne pourrait pas rappeler, autant garder un résumé propre sans ids.
|
||||
// Un échec d'écriture (base absente, tests) laisse archived[i] nil → repli
|
||||
// transparent sur le comportement classique (troncature/marqueur sans id).
|
||||
archived := make([]*recallEntry, len(torso))
|
||||
var index []recallEntry
|
||||
if caps.Agent && compactEnabled() {
|
||||
names := torsoToolNames(torso)
|
||||
for i, m := range torso {
|
||||
if !recallEligible(m) {
|
||||
continue
|
||||
}
|
||||
content := msgText(m)
|
||||
label := recallLabel(m.Role, names[m.ToolCallID], content)
|
||||
id, aerr := archiveRecallBlock(label, m.Role, content)
|
||||
if aerr != nil {
|
||||
continue
|
||||
}
|
||||
archived[i] = &recallEntry{id: id, label: label}
|
||||
index = append(index, recallEntry{id: id, label: label})
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
|
||||
// marqueur. On travaille sur une copie pour ne pas muter l'historique amont.
|
||||
// ⚠️ Ce torse dégraissé ne sert QUE de repli si le résumé échoue — surtout
|
||||
// PAS d'entrée au résumeur, cf. juste en dessous.
|
||||
// PAS d'entrée au résumeur, cf. juste en dessous. Un bloc archivé pointe vers
|
||||
// son id (récupérable par recall) au lieu d'être effacé aveuglément.
|
||||
pruned := make([]Message, len(torso))
|
||||
for i, m := range torso {
|
||||
pruned[i] = m
|
||||
if e := archived[i]; e != nil {
|
||||
pruned[i].Content = recallMarker(e.id, e.label)
|
||||
continue
|
||||
}
|
||||
if m.Role == "tool" {
|
||||
if t := msgText(m); len(t) > compactToolPruneLen {
|
||||
pruned[i].Content = compactPrunedMarker
|
||||
@@ -252,7 +343,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Résumé du torse par le modèle local (un seul appel).
|
||||
// 5. Résumé du torse par le modèle local (un seul appel).
|
||||
//
|
||||
// Le résumé se fait sur le torse ORIGINAL, pas sur le dégraissé. C'était LE
|
||||
// bug de fond : on effaçait tous les résultats d'outils PUIS on demandait un
|
||||
@@ -262,12 +353,26 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
// que des outils avaient tourné. À chaque compactage, l'IA repartait donc
|
||||
// d'une recherche vide et recommençait à zéro : elle ne s'arrêtait jamais.
|
||||
//
|
||||
// Les résultats d'outils sont seulement RACCOURCIS (leur début, qui porte
|
||||
// l'essentiel : titre, en-tête, premières lignes) pour que la transcription
|
||||
// reste bornée. Les faits survivent, le volume reste maîtrisé.
|
||||
// Un bloc archivé est réduit à sa TÊTE (assez pour le résumer fidèlement).
|
||||
// On ne montre PAS l'id recall au résumeur : sa seule tâche est d'écrire de
|
||||
// la prose. Lui exposer « recall:rN » l'incite à répondre juste par l'id
|
||||
// (résumé dégénéré) ; l'index des ids est de toute façon ajouté séparément
|
||||
// et de façon déterministe par compactSummaryUserMsg.
|
||||
// Les résultats d'outils non archivés sont seulement RACCOURCIS (leur début,
|
||||
// qui porte l'essentiel : titre, en-tête, premières lignes) pour que la
|
||||
// transcription reste bornée. Les faits survivent, le volume reste maîtrisé.
|
||||
forSummary := make([]Message, len(torso))
|
||||
for i, m := range torso {
|
||||
forSummary[i] = m
|
||||
if archived[i] != nil {
|
||||
r := []rune(msgText(m))
|
||||
headTxt := string(r)
|
||||
if len(r) > recallSummaryHeadLen {
|
||||
headTxt = string(r[:recallSummaryHeadLen]) + "…"
|
||||
}
|
||||
forSummary[i].Content = headTxt + "\n[…reste de ce bloc omis ici]"
|
||||
continue
|
||||
}
|
||||
if m.Role == "tool" {
|
||||
if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
|
||||
forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
|
||||
@@ -276,15 +381,20 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
}
|
||||
summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
|
||||
var mid []Message
|
||||
if err != nil || strings.TrimSpace(summary) == "" {
|
||||
if err != nil || summaryLooksEmpty(summary) {
|
||||
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
|
||||
// torse dégraissé, qui porte au moins les têtes de blocs et les marqueurs
|
||||
// recall : bien plus informatif qu'un résumé dégénéré.
|
||||
mid = pruned
|
||||
} else {
|
||||
// Le résumé est injecté comme un tour utilisateur→assistant (jamais un
|
||||
// message `system` au milieu : certains gabarits, ex. Qwen, exigent que le
|
||||
// system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix).
|
||||
// Le message porte aussi la CONSCIENCE de la compaction et l'index des ids
|
||||
// rappelables (voir compactSummaryUserMsg).
|
||||
mid = []Message{
|
||||
{Role: "user", Content: compactSummaryPrefix + " The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n" + summary},
|
||||
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, without redoing work that is already done."},
|
||||
{Role: "user", Content: compactSummaryUserMsg(summary, index)},
|
||||
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,6 +437,94 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
|
||||
return out, true
|
||||
}
|
||||
|
||||
// summaryLooksEmpty détecte un résumé raté : trop court, ou constitué seulement
|
||||
// de références « recall:rN » (un petit modèle, mal aiguillé, répond parfois par
|
||||
// un id au lieu d'écrire de la vraie prose). Dans ce cas on préfère le repli
|
||||
// dégraissé. Seuil à 40 caractères de VRAI texte (hors jetons recall).
|
||||
func summaryLooksEmpty(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if len([]rune(s)) < 40 {
|
||||
return true
|
||||
}
|
||||
var real strings.Builder
|
||||
for _, f := range strings.Fields(s) {
|
||||
low := strings.ToLower(strings.Trim(f, ".,;:()[]-•*"))
|
||||
if strings.HasPrefix(low, "recall:r") || strings.HasPrefix(low, "recall(") {
|
||||
continue
|
||||
}
|
||||
real.WriteString(f)
|
||||
real.WriteString(" ")
|
||||
}
|
||||
return len([]rune(strings.TrimSpace(real.String()))) < 40
|
||||
}
|
||||
|
||||
// recallEntry associe un id d'archive à son libellé court, pour l'index injecté
|
||||
// dans le message de compaction.
|
||||
type recallEntry struct{ id, label string }
|
||||
|
||||
// recallEligible dit si un message du torse mérite d'être archivé sous un id :
|
||||
// un bloc assez gros (le résumé ne le porterait pas fidèlement) d'un rôle réel,
|
||||
// et qui n'est pas déjà le résumé d'une compaction précédente (on ne ré-archive
|
||||
// pas un résumé).
|
||||
func recallEligible(m Message) bool {
|
||||
switch m.Role {
|
||||
case "tool", "user", "assistant":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
t := msgText(m)
|
||||
if len([]rune(t)) <= recallArchiveMinLen {
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(t, compactSummaryPrefix)
|
||||
}
|
||||
|
||||
// torsoToolNames associe chaque ToolCallID du torse au nom de l'outil qui l'a
|
||||
// produit (l'assistant porte les tool_calls, le résultat ne porte que l'id),
|
||||
// pour donner un libellé lisible aux blocs archivés (« web_read: … »).
|
||||
func torsoToolNames(torso []Message) map[string]string {
|
||||
names := map[string]string{}
|
||||
for _, m := range torso {
|
||||
for _, tc := range m.ToolCalls {
|
||||
if tc.ID != "" {
|
||||
names[tc.ID] = tc.Function.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// recallMarker remplace un bloc archivé dans le torse dégraissé (repli). Il cite
|
||||
// l'id récupérable et interdit explicitement de relancer l'outil pour le refaire.
|
||||
func recallMarker(id, label string) string {
|
||||
return fmt.Sprintf("[Block archived to save context — full content retrievable with recall(\"%s\") (%s). Do NOT re-run a tool to fetch it back.]", id, label)
|
||||
}
|
||||
|
||||
// compactSummaryUserMsg construit le message `user` synthétique qui porte le
|
||||
// résumé. Il rend le modèle CONSCIENT que la conversation a été compactée (il le
|
||||
// sait aussi bien que nous), et lui donne l'index des blocs rappelables. L'index
|
||||
// ne liste QUE les ids créés à cette passe : sa taille reste bornée, une
|
||||
// conversation infinie ne gonfle jamais cette section (les vieux blocs restent
|
||||
// joignables par recall_search).
|
||||
func compactSummaryUserMsg(summary string, index []recallEntry) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(compactSummaryPrefix)
|
||||
if len(index) > 0 {
|
||||
b.WriteString(" The earlier turns of this conversation were summarized to save context, but nothing is lost: any block referenced below (or in the summary) as recall:rN can be brought back verbatim with the recall(id) tool, and recall_search(\"keywords\") finds older archived blocks not listed here. Here is the summary:\n\n")
|
||||
} else {
|
||||
b.WriteString(" The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n")
|
||||
}
|
||||
b.WriteString(summary)
|
||||
if len(index) > 0 {
|
||||
b.WriteString("\n\nArchived blocks you can bring back with recall(id):\n")
|
||||
for _, e := range index {
|
||||
fmt.Fprintf(&b, "- %s — %s\n", e.id, e.label)
|
||||
}
|
||||
b.WriteString("Only recall a block when you actually need its full content.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// renderTranscript sérialise le torse en texte lisible pour le résumeur.
|
||||
func renderTranscript(msgs []Message) string {
|
||||
var b strings.Builder
|
||||
@@ -383,11 +581,16 @@ Summarize densely and faithfully, keeping ONLY the essentials:
|
||||
- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time
|
||||
- Decisions made and established facts
|
||||
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
|
||||
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Aim for 300 words MAX — this is a compression summary, not a report.
|
||||
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
|
||||
Write the summary in the SAME language as the conversation.`
|
||||
|
||||
// Le résumé part au MÊME endroit que le chat : sur un preset externe, il
|
||||
// s'appuie sur l'API distante (backend_external.go). Le laisser taper le
|
||||
// llama-server local ferait échouer toute compaction dès qu'aucun moteur
|
||||
// local ne tourne — et la compaction, c'est ce qui empêche le fil de mourir.
|
||||
ep := resolveChatEndpoint()
|
||||
payload := map[string]any{
|
||||
"model": "loki",
|
||||
"model": ep.Model,
|
||||
"messages": []Message{
|
||||
{Role: "system", Content: sys},
|
||||
{Role: "user", Content: transcript},
|
||||
@@ -396,20 +599,19 @@ Write the summary in the SAME language as the conversation.`
|
||||
"temperature": 0.2,
|
||||
// Borne dure : sans ça, un modèle bavard (surtout à reasoning) produit un
|
||||
// résumé énorme et lent, donc peu de réduction → re-compaction à chaque tour.
|
||||
"max_tokens": 700,
|
||||
"max_tokens": compactSummaryBudget(),
|
||||
// Pas de réflexion pour un résumé : plus rapide, plus dense, et évite qu'un
|
||||
// modèle hybride gaspille tout le budget en <think> (résumé vide). llama.cpp
|
||||
// passe ces kwargs au gabarit Jinja (--jinja).
|
||||
"chat_template_kwargs": map[string]any{"enable_thinking": false},
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
authHeader(req)
|
||||
ep.auth(req.Header.Set)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", friendlyLLMError(err)
|
||||
@@ -417,7 +619,11 @@ Write the summary in the SAME language as the conversation.`
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
|
||||
return "", fmt.Errorf("résumé: llama-server %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
who := "llama-server"
|
||||
if ep.External {
|
||||
who = "API externe"
|
||||
}
|
||||
return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out summarizeResp
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
|
||||
@@ -20,10 +20,17 @@ func TestCompactBoundsProtectsHead(t *testing.T) {
|
||||
um("q2"), am("r2"),
|
||||
um("q3"), am("r3"),
|
||||
}
|
||||
// budget minuscule → queue = juste le dernier tour, tête = system + 1er user.
|
||||
// budget minuscule → queue = juste le dernier tour, tête = les seuls systèmes.
|
||||
// Le 1er message user N'EST PLUS épinglé : il l'était pour « ancrer
|
||||
// l'objectif », mais après compaction le modèle voyait deux demandes (celle du
|
||||
// début, épinglée, et la courante réinjectée par `pending`) et répondait
|
||||
// volontiers à l'ancienne. Voir compactBounds et TestCompactKeepsPendingRequest.
|
||||
head, tail := compactBounds(msgs, 1)
|
||||
if head != 2 { // system + premier user
|
||||
t.Fatalf("head = %d, attendu 2", head)
|
||||
if head != 1 { // le message système, et lui seul
|
||||
t.Fatalf("head = %d, attendu 1 (systèmes uniquement)", head)
|
||||
}
|
||||
if msgs[head].Role != "user" {
|
||||
t.Fatalf("le 1er message user doit être compactable, il commence le torse ; rôle obtenu %q", msgs[head].Role)
|
||||
}
|
||||
// Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin).
|
||||
if r := msgs[tail].Role; r != "user" && r != "assistant" {
|
||||
|
||||
@@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -28,8 +30,12 @@ import (
|
||||
// qu'une poignée d'événements au lieu d'un par token → 20000 couvre des
|
||||
// centaines de tours. La marge sert surtout à absorber UN tour en cours (streamé
|
||||
// token par token) avant sa coalescence : un très gros tour (long raisonnement +
|
||||
// réponse) ne doit pas se faire tronquer le début avant d'être compacté.
|
||||
const maxLogEvents = 20000
|
||||
// réponse) ne doit pas se faire tronquer le début avant d'être compacté. 20000
|
||||
// n'y suffisait pas — un seul tour à très long raisonnement tronquait déjà le
|
||||
// journal de rejeu, et l'utilisateur perdait le DÉBUT de sa conversation à
|
||||
// l'écran. Le journal ne pèse que de la mémoire d'affichage, jamais du contexte
|
||||
// modèle : la marge est bon marché.
|
||||
const maxLogEvents = 200000
|
||||
|
||||
// LogEvent = un événement d'affichage rejouable (un delta SSE + son numéro de
|
||||
// séquence monotone + un horodatage serveur en ms). Le TS permet au client de
|
||||
@@ -74,12 +80,70 @@ var conv = func() *Conversation {
|
||||
// modèle. En clair : cette machine déchiffre déjà pour lancer le modèle, le
|
||||
// relais reste aveugle — la persister ici ne change rien à la posture E2E.
|
||||
|
||||
// loadConvAttempts / loadConvRetryWait : au redémarrage du conteneur, l'ancien
|
||||
// process peut encore tenir le verrou bbolt quelques centaines de ms pendant que
|
||||
// le nouveau démarre. Quatre essais espacés de 250 ms couvrent ce chevauchement
|
||||
// sans retarder perceptiblement un démarrage normal (où le premier essai passe).
|
||||
const (
|
||||
loadConvAttempts = 4
|
||||
loadConvRetryWait = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// loadConvRetries compte les ATTENTES de reprise du dernier LoadConversation
|
||||
// (0 = le premier essai a suffi). Il n'existe que pour être observable.
|
||||
//
|
||||
// Le test de non-régression veut vérifier qu'une absence légitime — première
|
||||
// installation, rien d'enregistré — ne déclenche AUCUNE reprise. Il le mesurait
|
||||
// au chronomètre (« moins de 250 ms »), ce qui confond « aucune reprise » avec
|
||||
// « une seule tentative, mais lente » : sur un runner CI chargé, créer et
|
||||
// ouvrir la base bbolt a pris 435 ms et le test a échoué alors qu'aucune reprise
|
||||
// n'avait eu lieu. On compte donc les reprises au lieu de les déduire d'un
|
||||
// temps de mur, qui ne dépend pas que de nous.
|
||||
var loadConvRetries atomic.Int32
|
||||
|
||||
// LoadConversation recharge l'état persisté au démarrage du process. Sans état
|
||||
// enregistré (première fois) on part d'une conversation vide.
|
||||
//
|
||||
// Une lecture RATÉE n'est PAS une absence, et les confondre coûte cher ici :
|
||||
// getStr rend "" aussi bien pour « clé absente » que pour « base inaccessible »,
|
||||
// donc sur un verrou transitoire convEnsureActive croyait la discussion active
|
||||
// inexistante, forgeait un NOUVEL identifiant et l'écrasait — le fil en cours
|
||||
// devenait orphelin, en silence. On sonde donc la base avec son erreur AVANT
|
||||
// toute écriture, on réessaie, et en cas d'échec durable on le dit et on ne
|
||||
// touche à rien.
|
||||
func LoadConversation() {
|
||||
// convEnsureActive reprend au passage le fil unique des versions
|
||||
// précédentes (clé « conversation ») comme première discussion.
|
||||
b := getBytes(bkChat, convKey(convEnsureActive()))
|
||||
var b []byte
|
||||
var err error
|
||||
loadConvRetries.Store(0)
|
||||
retry := func() {
|
||||
loadConvRetries.Add(1)
|
||||
time.Sleep(loadConvRetryWait)
|
||||
}
|
||||
for attempt := 0; attempt < loadConvAttempts; attempt++ {
|
||||
// Sonde : lire la clé de la discussion active en gardant l'erreur. Tant
|
||||
// qu'elle échoue, convEnsureActive ne doit surtout pas être appelée.
|
||||
if _, err = getBytesErr(bkChat, ckActive); err != nil {
|
||||
retry()
|
||||
continue
|
||||
}
|
||||
// convEnsureActive reprend au passage le fil unique des versions
|
||||
// précédentes (clé « conversation ») comme première discussion.
|
||||
// getStoreBytesErr : même lecture, en DÉCHIFFRANT au besoin (mémoire
|
||||
// chiffrée). Verrouillé, elle rend (nil, nil) — pas une erreur d'accès :
|
||||
// on démarre alors sur un fil vide sans rien écraser, et tout revient au
|
||||
// déverrouillage.
|
||||
if b, err = getStoreBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
|
||||
break
|
||||
}
|
||||
retry()
|
||||
}
|
||||
if err != nil {
|
||||
// Toujours en échec : on le DIT au lieu de repartir à vide en silence, et
|
||||
// on abandonne le chargement sans rien écrire — un serveur qui refuse de
|
||||
// démarrer serait pire, et l'historique sur disque reste intact.
|
||||
fmt.Fprintf(os.Stderr, "[conv] base illisible au démarrage (%v) — aucune discussion chargée ; rien n'a été écrasé, l'historique est intact\n", err)
|
||||
return
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return
|
||||
}
|
||||
@@ -113,6 +177,19 @@ func (c *Conversation) loadFrom(b []byte) {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// reloadEncryptedStores recharge la discussion depuis le disque après un
|
||||
// déverrouillage : verrouillée, la lecture au démarrage a rendu un fil vide.
|
||||
// On ne recharge QUE dans ce cas — écraser un fil déjà rempli ferait perdre le
|
||||
// tour en cours, qui n'a pas pu être persisté.
|
||||
func reloadEncryptedStores() {
|
||||
conv.mu.Lock()
|
||||
empty := len(conv.Log) == 0
|
||||
conv.mu.Unlock()
|
||||
if empty {
|
||||
LoadConversation()
|
||||
}
|
||||
}
|
||||
|
||||
// persist enregistre l'état (appelé en fin de tour et sur reset, pas à chaque
|
||||
// delta) sous la discussion active, et rafraîchit ses métadonnées (titre déduit
|
||||
// du premier message, date, nombre d'échanges). L'appelant NE doit PAS détenir mu.
|
||||
@@ -131,7 +208,10 @@ func (c *Conversation) persist() {
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
_ = putBytes(bkChat, convKey(id), b)
|
||||
// Chiffré si la mémoire l'est et qu'elle est déverrouillée. Verrouillée,
|
||||
// l'écriture est REFUSÉE plutôt que de remplacer un blob chiffré par du
|
||||
// clair — le fil de ce tour reste en RAM, rien n'est perdu sur disque.
|
||||
_ = putStoreBytes(bkChat, convKey(id), b)
|
||||
convTouchMeta(id, title, turns)
|
||||
}
|
||||
|
||||
@@ -216,6 +296,21 @@ func (c *Conversation) compactLogLocked() {
|
||||
bufKey, toks, ts0, seq0 = "", 0, 0, 0
|
||||
}
|
||||
for _, ev := range c.Log {
|
||||
// Outils : un appel s'écrit en de nombreux événements (annonce done=false,
|
||||
// frappe du corps, streaming des arguments) jusqu'au done=true, qui porte
|
||||
// déjà l'état FINAL (résultat + diff). Les intermédiaires ne servent qu'à
|
||||
// l'affichage EN DIRECT ; les garder dans le journal le faisait enfler sur
|
||||
// une conversation agentique (gros write, appels en chaîne) jusqu'à
|
||||
// maxLogEvents → troncature des plus VIEUX événements, donc perte des
|
||||
// premiers messages à l'affichage. On ne conserve donc que le done=true
|
||||
// (même politique que coalesceReplay au replay/export).
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
flush()
|
||||
if done, _ := tu["done"].(bool); done {
|
||||
out = append(out, ev)
|
||||
}
|
||||
continue
|
||||
}
|
||||
key := textKey(ev.Delta)
|
||||
if key == "" {
|
||||
flush()
|
||||
@@ -422,6 +517,7 @@ func chatModelName() string {
|
||||
// tout ce que ce tour produirait ensuite (deltas, messages, persistance) est
|
||||
// abandonné au lieu de ressusciter des morceaux de l'ancienne conversation.
|
||||
func (c *Conversation) generate(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
turnStart := time.Now()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
stale := c.epoch != epoch
|
||||
@@ -443,6 +539,17 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.compactLogLocked() // le tour est fini : coalesce ses tokens pour garder le journal petit
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
// Notification Web Push : ce chemin (generate) ne sert QUE les tours
|
||||
// utilisateur — les tâches de fond passent par RunAutonomous et ont leur
|
||||
// propre notification — donc pas de doublon. Détaché : l'envoi HTTP vers
|
||||
// le service de push ne doit pas retenir la fin du tour. Corps générique
|
||||
// (pas d'extrait de réponse) : la notif transite par Apple/Google.
|
||||
//
|
||||
// ctx.Err() != nil = tour interrompu par un « stop » : pas de notification,
|
||||
// l'utilisateur est là et a coupé volontairement.
|
||||
if hasPushSubs() && ctx.Err() == nil {
|
||||
go sendPushToAll("Loki", "Réponse prête · "+fmtDurFR(time.Since(turnStart)))
|
||||
}
|
||||
}()
|
||||
|
||||
// Snapshot de la vue modèle.
|
||||
@@ -464,9 +571,12 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// Prompt système personnalisé (UI → /api/sysprompt, fichier côté serveur).
|
||||
// Injecté seulement dans la vue envoyée au modèle, jamais persisté dans
|
||||
// c.Messages : modifiable à chaud, effet dès le tour suivant.
|
||||
final := msgs
|
||||
// Contexte du projet actif (description, index mémoire, index des trackers),
|
||||
// même traitement : injecté dans la vue envoyée, jamais persisté — voir
|
||||
// projectSystemMessages.
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
|
||||
final = append([]Message{{Role: "system", Content: sp}}, final...)
|
||||
}
|
||||
|
||||
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
|
||||
@@ -489,6 +599,16 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||
}
|
||||
// Taille réelle du résultat (l'aperçu n'en montre qu'un début) : le
|
||||
// compteur ~N tok de la bulle affiche la vraie valeur, pas celle de
|
||||
// l'aperçu.
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
}
|
||||
// Référence pour charger le reste à la demande (bouton « voir plus »).
|
||||
if ev.ToolUsed.ResultID != "" {
|
||||
tu["result_id"] = ev.ToolUsed.ResultID
|
||||
}
|
||||
// Corps en cours de frappe : transitoire (l'état final est le diff), on
|
||||
// ne l'ajoute que quand il est là pour ne pas gonfler chaque événement.
|
||||
if ev.ToolUsed.Body != "" {
|
||||
@@ -777,11 +897,15 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
|
||||
continue
|
||||
}
|
||||
_, isTool := ev.Delta["tool_used"].(map[string]any)
|
||||
// Tout événement NON-outil clôt une éventuelle bulle d'outil en attente, pour
|
||||
// préserver l'ordre (l'outil non terminé s'affiche avant ce qui le suit).
|
||||
if !isTool {
|
||||
flushTool()
|
||||
}
|
||||
// ⚠️ On ne « flushe » PAS l'annonce d'outil en attente sur un événement
|
||||
// non-outil. Un appel d'outil s'écrit dans le journal en DEUX temps : une
|
||||
// annonce (done=false, juste « je vais lancer X ») puis le résultat
|
||||
// (done=true). Si un événement non-outil (stats, un bout de raisonnement)
|
||||
// se glisse ENTRE les deux, flusher ici émettait l'annonce PUIS le done →
|
||||
// le même outil apparaissait DEUX fois (à l'export Markdown et au replay).
|
||||
// On garde donc l'annonce en attente : le done la remplace (émis une seule
|
||||
// fois), et si aucun done n'arrive jamais (outil interrompu par un stop),
|
||||
// le flushTool final l'émet une seule fois, en fin de fil.
|
||||
// Delta texte ? (une seule clé content ou reasoning_content, valeur string)
|
||||
key := ""
|
||||
if s, ok := ev.Delta["content"].(string); ok {
|
||||
@@ -828,7 +952,18 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
|
||||
// de Log[from:] (léger), puis un caught_up, puis le DIRECT événement par événement.
|
||||
// Bloque jusqu'à ce que ctx (la connexion HTTP) soit annulé — la génération, elle,
|
||||
// continue indépendamment. emit renvoie false si l'écriture échoue (client parti).
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[string]any) bool) {
|
||||
// replayTailMax borne le nombre d'événements REJOUÉS au chargement d'une
|
||||
// discussion. Ce n'est pas une troncature du journal (rien n'est effacé) : le
|
||||
// début reste sur disque et le client le demande d'un clic (full=true).
|
||||
//
|
||||
// Une discussion de plusieurs centaines de tours rejouait des dizaines de
|
||||
// milliers d'événements à chaque ouverture d'onglet : quelques secondes de voile
|
||||
// de chargement, et autant de bulles dans le DOM que le navigateur devait ensuite
|
||||
// traîner à chaque rendu. On rejoue la fin — ce qu'on vient lire — et on annonce
|
||||
// combien d'événements sont restés en arrière.
|
||||
const replayTailMax = 4000
|
||||
|
||||
func (c *Conversation) Subscribe(ctx context.Context, from int, full bool, emit func(map[string]any) bool) {
|
||||
// Réveille les attentes de cond quand la connexion se ferme.
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
@@ -852,8 +987,31 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
snapshot := append([]LogEvent(nil), c.Log...)
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
// ⚠️ Garde-fou anti-« premiers messages manquants au changement de session ».
|
||||
// Le client se réabonne avec from=lastSeq (le dernier Seq qu'il a vu). Les Seq
|
||||
// ne sont PAS globaux : ouvrir une session PLUS ANCIENNE charge un journal dont
|
||||
// les Seq sont plus bas. Si le client garde un from élevé (hérité de la session
|
||||
// qu'il quittait) et se reconnecte, coalesceReplay saute tout (Seq <= from) →
|
||||
// la conversation ouverte apparaît tronquée, voire vide. En session normale, le
|
||||
// client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il
|
||||
// l'est, son curseur vient d'une autre session → on repart du début.
|
||||
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
|
||||
from = 0
|
||||
}
|
||||
last := from
|
||||
for _, ev := range coalesceReplay(snapshot, from) {
|
||||
events := coalesceReplay(snapshot, from)
|
||||
// Replay borné : on ne garde que la fin, et on DIT au client combien
|
||||
// d'événements manquent devant (il affiche « charger le début »). Jamais
|
||||
// borné sur une reprise de flux (from > 0) : là, le client a déjà tout le
|
||||
// début à l'écran et attend la SUITE.
|
||||
if !full && from == 0 && len(events) > replayTailMax {
|
||||
hidden := len(events) - replayTailMax
|
||||
events = events[hidden:]
|
||||
if !emit(map[string]any{"replay_truncated": hidden}) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, ev := range events {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// resetConvForTest vide l'état en RAM pour forcer un VRAI rechargement depuis la
|
||||
// base, comme au démarrage d'un process qui ne connaît encore rien. `conv` est un
|
||||
// global du paquet : sans ça, un test verrait l'état laissé par le précédent.
|
||||
func resetConvForTest() {
|
||||
conv.mu.Lock()
|
||||
conv.Messages, conv.Log, conv.Seq, conv.CtxUsed = nil, nil, 0, 0
|
||||
conv.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestLoadConversationReessayeSousContention : la panne d'origine est un verrou
|
||||
// bbolt transitoire au redémarrage du conteneur (l'ancien process tient encore
|
||||
// le fichier pendant que le nouveau démarre), confondu avec « rien à charger ».
|
||||
//
|
||||
// Réserve honnête : ce test ne reproduit PAS la panne à coup sûr. withDB ouvre
|
||||
// déjà la base avec Options.Timeout de 5 s, qui absorbe seul une contention de
|
||||
// 400 ms — ce test passe donc aussi sur le code d'AVANT le correctif. Il est
|
||||
// gardé comme filet de non-régression sur le nouveau chemin (getBytesErr + boucle
|
||||
// de tentatives) : ce chemin ne doit ni perdre la conversation, ni se bloquer.
|
||||
func TestLoadConversationReessayeSousContention(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
// 1. Écrit une conversation par l'API normale (elle crée aussi la clé active).
|
||||
resetConvForTest()
|
||||
conv.mu.Lock()
|
||||
conv.Messages = []Message{{Role: "user", Content: "bonjour"}}
|
||||
conv.mu.Unlock()
|
||||
conv.persist()
|
||||
activeID := getStr(bkChat, ckActive)
|
||||
if activeID == "" {
|
||||
t.Fatal("persist n'a pas créé de discussion active — le montage du test est faux")
|
||||
}
|
||||
|
||||
// 2. État en RAM vidé : le rechargement doit tout reprendre depuis la base.
|
||||
resetConvForTest()
|
||||
|
||||
// 3. Verrou exclusif tenu une fenêtre courte dans une goroutine séparée : la
|
||||
// base est momentanément indisponible, exactement comme au chevauchement.
|
||||
db, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 2 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("impossible de prendre le verrou pour le test : %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
db.Close()
|
||||
}()
|
||||
|
||||
LoadConversation()
|
||||
wg.Wait()
|
||||
|
||||
conv.mu.Lock()
|
||||
got := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if got != 1 {
|
||||
t.Fatalf("conversation perdue malgré une contention temporaire : %d message(s), attendu 1", got)
|
||||
}
|
||||
if id := getStr(bkChat, ckActive); id != activeID {
|
||||
t.Fatalf("la discussion active a changé (%q → %q) : le fil d'origine est orphelin", activeID, id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConversationBaseIllisibleNeForgePasDeDiscussion garde l'invariant qui
|
||||
// justifie le correctif : convEnsureActive ÉCRIT (elle forge un identifiant et
|
||||
// l'enregistre sous bkChat/active). Elle ne doit donc jamais être appelée sur la
|
||||
// foi d'une lecture ratée — sinon un verrou durable suffisait à mettre le fil en
|
||||
// cours de côté au profit d'une discussion neuve, en silence.
|
||||
//
|
||||
// Réserve honnête, là encore : avec une base illisible l'écriture échoue elle
|
||||
// aussi, donc le disque finit identique dans les deux versions. Ce que ce test
|
||||
// verrouille, c'est le CONTRAT — sortir sans rien écrire, et sans faire attendre
|
||||
// indéfiniment — pas la reproduction du dégât.
|
||||
func TestLoadConversationBaseIllisibleNeForgePasDeDiscussion(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
// Fichier de base invalide : bolt.Open échoue tout de suite (pas d'attente de
|
||||
// verrou), ce qui exerce le chemin d'erreur sans allonger le test.
|
||||
path := filepath.Join(home, "loki.db")
|
||||
if err := os.WriteFile(path, []byte("ceci n'est pas une base bbolt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
resetConvForTest()
|
||||
start := time.Now()
|
||||
LoadConversation()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Budget : loadConvAttempts tentatives espacées de loadConvRetryWait, plus une
|
||||
// marge. Au-delà, la boucle de reprise retarderait le démarrage du serveur.
|
||||
if max := time.Duration(loadConvAttempts)*loadConvRetryWait + 2*time.Second; elapsed > max {
|
||||
t.Fatalf("démarrage retardé de %v par une base illisible (budget %v)", elapsed, max)
|
||||
}
|
||||
// Contrepartie de l'assertion « aucune reprise » du test d'absence légitime :
|
||||
// un compteur bloqué à zéro la rendrait vraie sans rien prouver. Ici les
|
||||
// reprises ont bel et bien lieu, et on le vérifie.
|
||||
if n := loadConvRetries.Load(); int(n) != loadConvAttempts {
|
||||
t.Fatalf("%d reprise(s) comptée(s) sur une base illisible, attendu %d", n, loadConvAttempts)
|
||||
}
|
||||
after, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("la base a été modifiée alors qu'elle n'a même pas pu être lue")
|
||||
}
|
||||
conv.mu.Lock()
|
||||
n := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("%d message(s) chargés depuis une base illisible", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConversationAbsenceLegitimeResteRapide : le correctif ne doit pas avoir
|
||||
// transformé le cas NORMAL (base saine, rien d'enregistré encore — premier
|
||||
// démarrage) en attente inutile. Une seule tentative doit suffire.
|
||||
func TestLoadConversationAbsenceLegitimeResteRapide(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
resetConvForTest()
|
||||
|
||||
LoadConversation()
|
||||
|
||||
// Mesuré sur le COMPTEUR de reprises, pas au chronomètre : une absence
|
||||
// légitime doit sortir au premier essai. Le temps de mur ne disait pas ça —
|
||||
// il mêlait le coût de l'essai lui-même (création de la base bbolt, fsync)
|
||||
// à l'attente de reprise, et un runner CI chargé l'a fait échouer à 435 ms
|
||||
// alors qu'aucune reprise n'avait été déclenchée.
|
||||
if n := loadConvRetries.Load(); n != 0 {
|
||||
t.Fatalf("absence légitime traitée comme une erreur : %d reprise(s) déclenchée(s), aucune ne devait l'être", n)
|
||||
}
|
||||
conv.mu.Lock()
|
||||
n := len(conv.Messages)
|
||||
conv.mu.Unlock()
|
||||
if n != 0 {
|
||||
t.Fatalf("%d message(s) chargés alors que rien n'avait été enregistré", n)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ func TestSubscribeReplayAndLive(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 32)
|
||||
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -51,7 +51,7 @@ func TestSubscribeFromOffset(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, 1, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 1, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func TestResetNotifiesSubscribers(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
resetSeen := make(chan bool, 4)
|
||||
go c.Subscribe(ctx, 0, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 0, false, func(m map[string]any) bool {
|
||||
if _, ok := m["reset"]; ok {
|
||||
resetSeen <- true
|
||||
}
|
||||
@@ -202,7 +202,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
} {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, tc.from, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, tc.from, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -216,7 +216,7 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
got := make(chan int, 8)
|
||||
go c.Subscribe(ctx, 502, func(m map[string]any) bool {
|
||||
go c.Subscribe(ctx, 502, false, func(m map[string]any) bool {
|
||||
if s, ok := m["seq"].(int); ok {
|
||||
got <- s
|
||||
}
|
||||
@@ -225,3 +225,134 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "suite"})
|
||||
waitSeq(t, got, 503)
|
||||
}
|
||||
|
||||
// --- Régressions v0.12.7 : journal d'affichage (outils, replay, changement de session) ---
|
||||
|
||||
// compactLogLocked doit coalescer les événements tool_used d'un même appel en un
|
||||
// seul (le done=true, qui porte l'état final) : les intermédiaires (annonce, frappe
|
||||
// du corps) ne servent qu'à l'affichage en direct et faisaient enfler le journal
|
||||
// jusqu'à la troncature des premiers messages.
|
||||
func TestCompactLogCoalesceTools(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Log = []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"user": "édite"}},
|
||||
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f"}}},
|
||||
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "typing": true, "body": "a"}}},
|
||||
{Seq: 4, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "result": "ok", "done": true}}},
|
||||
{Seq: 5, Delta: map[string]any{"content": "fini"}},
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.compactLogLocked()
|
||||
c.mu.Unlock()
|
||||
nTool := 0
|
||||
for _, ev := range c.Log {
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
nTool++
|
||||
if done, _ := tu["done"].(bool); !done {
|
||||
t.Error("un tool_used non terminé a survécu à la compaction")
|
||||
}
|
||||
}
|
||||
}
|
||||
if nTool != 1 {
|
||||
t.Fatalf("compactLogLocked garde %d tool_used au lieu de 1", nTool)
|
||||
}
|
||||
}
|
||||
|
||||
// coalesceReplay ne doit émettre un outil qu'UNE fois même si un événement non-outil
|
||||
// (stats, un bout de raisonnement) tombe entre l'annonce (done=false) et le done=true.
|
||||
func TestCoalesceReplayNoDoubleTool(t *testing.T) {
|
||||
events := []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls"}}},
|
||||
{Seq: 2, Delta: map[string]any{"stats": map[string]any{"gen_tokens": 3}}},
|
||||
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls", "result": "x", "done": true}}},
|
||||
}
|
||||
n := 0
|
||||
for _, ev := range coalesceReplay(events, 0) {
|
||||
if _, ok := ev["tool_used"]; ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("outil émis %d fois au replay (attendu 1)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Un abonné qui arrive avec un `from` SUPÉRIEUR au dernier Seq du journal (curseur
|
||||
// hérité d'une autre session, plus récente) doit obtenir le fil COMPLET, pas rien :
|
||||
// sans ce garde-fou, ouvrir une session plus ancienne la montrait tronquée/vide.
|
||||
func TestSubscribeStaleFromCrossSession(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "premier message"})
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "réponse"})
|
||||
// Le journal va jusqu'à Seq 2 ; on s'abonne avec from=9999 (curseur d'une autre session).
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var got []map[string]any
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.Subscribe(ctx, 9999, false, func(ev map[string]any) bool {
|
||||
got = append(got, ev)
|
||||
if ev["caught_up"] != nil {
|
||||
cancel()
|
||||
}
|
||||
return true
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
<-done
|
||||
seen := ""
|
||||
for _, ev := range got {
|
||||
if u, ok := ev["user"].(string); ok {
|
||||
seen += u
|
||||
}
|
||||
}
|
||||
if !strings.Contains(seen, "premier message") {
|
||||
t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen)
|
||||
}
|
||||
}
|
||||
|
||||
// Replay borné : une discussion très longue ne rejoue que sa fin, annonce
|
||||
// combien d'événements manquent, et rend TOUT si le client le demande.
|
||||
func TestReplayTailIsBoundedUnlessFull(t *testing.T) {
|
||||
c := &Conversation{}
|
||||
c.cond = sync.NewCond(&c.mu)
|
||||
for i := 0; i < replayTailMax+50; i++ {
|
||||
c.Log = append(c.Log, LogEvent{Seq: i + 1, Delta: map[string]any{"user": "m"}})
|
||||
}
|
||||
|
||||
collect := func(full bool) (events int, hidden int) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
c.Subscribe(ctx, 0, full, func(m map[string]any) bool {
|
||||
if h, ok := m["replay_truncated"].(int); ok {
|
||||
hidden = h
|
||||
return true
|
||||
}
|
||||
if m["caught_up"] != nil {
|
||||
cancel()
|
||||
return false
|
||||
}
|
||||
if _, ok := m["pad"]; ok {
|
||||
return true
|
||||
}
|
||||
events++
|
||||
return true
|
||||
})
|
||||
}()
|
||||
<-done
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
|
||||
events, hidden := collect(false)
|
||||
if hidden != 50 {
|
||||
t.Fatalf("50 événements auraient dû être annoncés masqués, obtenu %d", hidden)
|
||||
}
|
||||
if events > replayTailMax {
|
||||
t.Fatalf("replay borné dépassé : %d événements rejoués", events)
|
||||
}
|
||||
if full, h := collect(true); full <= replayTailMax || h != 0 {
|
||||
t.Fatalf("full=true doit tout rejouer sans bandeau, obtenu %d événements / %d masqués", full, h)
|
||||
}
|
||||
}
|
||||
@@ -278,7 +278,7 @@ func migrateLegacyUploads(src string) {
|
||||
// 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))
|
||||
b := storeBytes(bkChat, convKey(id))
|
||||
if len(b) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -103,12 +103,78 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
|
||||
Version: Version,
|
||||
ExportedAt: time.Now().Format(time.RFC3339),
|
||||
CtxUsed: ctxUsed,
|
||||
Messages: msgs,
|
||||
Messages: stripInlineImages(msgs),
|
||||
Log: filterLog(log, o),
|
||||
}
|
||||
return json.MarshalIndent(p, "", " ")
|
||||
}
|
||||
|
||||
// stripInlineImages remplace les images en base64 (data URI) portées par les
|
||||
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
|
||||
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
|
||||
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
|
||||
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
|
||||
// d'affichage non plus (il ne porte pas les images). On ne touche donc qu'ici, et
|
||||
// sur une COPIE : les messages appartiennent à la conversation vivante.
|
||||
func stripInlineImages(msgs []Message) []Message {
|
||||
out := make([]Message, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out[i] = m
|
||||
out[i].Content = stripInlineImagesValue(m.Content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripInlineImagesValue(v any) any {
|
||||
// Une partie image (format OpenAI) : {"type":"image_url","image_url":{"url":"data:…"}}.
|
||||
// On copie la partie et on élide l'URL data: en gardant l'en-tête (type MIME) lisible.
|
||||
elide := func(part map[string]any) map[string]any {
|
||||
cp := make(map[string]any, len(part))
|
||||
for k, val := range part {
|
||||
cp[k] = val
|
||||
}
|
||||
if iu, ok := cp["image_url"].(map[string]any); ok {
|
||||
c2 := make(map[string]any, len(iu))
|
||||
for k, val := range iu {
|
||||
c2[k] = val
|
||||
}
|
||||
if url, ok := c2["url"].(string); ok && strings.HasPrefix(url, "data:") {
|
||||
c2["url"] = elideDataURI(url)
|
||||
}
|
||||
cp["image_url"] = c2
|
||||
}
|
||||
return cp
|
||||
}
|
||||
switch parts := v.(type) {
|
||||
case []map[string]any: // message fraîchement produit
|
||||
out := make([]map[string]any, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = elide(p)
|
||||
}
|
||||
return out
|
||||
case []any: // message relu depuis la base (JSON) : []any de maps
|
||||
out := make([]any, len(parts))
|
||||
for i, p := range parts {
|
||||
if mp, ok := p.(map[string]any); ok {
|
||||
out[i] = elide(mp)
|
||||
} else {
|
||||
out[i] = p
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return v // simple chaîne (cas courant) : rien à faire
|
||||
}
|
||||
|
||||
// elideDataURI garde l'en-tête « data:<mime>;base64, » (lisible) et remplace le
|
||||
// corps base64 par sa taille, pour que l'export reste informatif sans le poids.
|
||||
func elideDataURI(url string) string {
|
||||
if i := strings.Index(url, ","); i >= 0 {
|
||||
return url[:i+1] + fmt.Sprintf("<élidé — %s>", humanBytes(int64(len(url)-i-1)))
|
||||
}
|
||||
return "<image élidée>"
|
||||
}
|
||||
|
||||
// filterLog applique au journal les MÊMES cases que le Markdown. Les deux
|
||||
// formats avaient auparavant des options distinctes (le JSON proposait un
|
||||
// obscur « journal d'affichage » à la place des trois autres) : personne ne
|
||||
|
||||
@@ -258,3 +258,35 @@ func TestCmdExportEcritLeFichier(t *testing.T) {
|
||||
t.Fatal("option inconnue acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// L'export JSON d'un message multimodal (vision) doit ÉLIDER le base64 des images :
|
||||
// sinon le fichier pesait plusieurs Mo par image. Le Markdown, lui, n'a jamais porté
|
||||
// les images (juste les noms de fichiers).
|
||||
func TestExportJSONElideImages(t *testing.T) {
|
||||
t.Cleanup(func() { conv.Reset() })
|
||||
big := strings.Repeat("A", 300000)
|
||||
conv.mu.Lock()
|
||||
conv.Messages = []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64," + big}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
conv.Log = []LogEvent{{Seq: 1, TS: 1, Delta: map[string]any{"user": "regarde"}}}
|
||||
conv.Seq = 1
|
||||
conv.mu.Unlock()
|
||||
b, err := conv.ExportJSON(defaultExportOpts())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), big) {
|
||||
t.Error("le base64 brut d'une image est encore présent dans le JSON")
|
||||
}
|
||||
if len(b) > 10000 {
|
||||
t.Errorf("JSON toujours énorme malgré l'élision : %d octets", len(b))
|
||||
}
|
||||
if !strings.Contains(string(b), "élidé") {
|
||||
t.Error("marqueur d'élision absent du JSON")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,8 @@ func webGrepTool() Tool {
|
||||
// ─── exécution des outils (appelée par le dispatch de llm_client.go) ───────────────
|
||||
|
||||
// webMaxOutput borne ce qu'UN appel d'outil web injecte dans le contexte, comme
|
||||
// toolMaxOutput (8000) pour le shell et mcpMaxOutput (12000) pour MCP. Sans ce
|
||||
// toolMaxOutput (8000) pour le shell. (La mémoire et MCP, eux, renvoient tout
|
||||
// au modèle et laissent l'UI charger l'aperçu.) Sans ce
|
||||
// plafond, un `web_read(limit=500)` sur une page dense pouvait pousser 25 000
|
||||
// caractères d'un coup : la fenêtre partait en fumée en pleine recherche, ce qui
|
||||
// déclenchait des compactages en cascade au milieu du raisonnement.
|
||||
|
||||
+123
-46
@@ -3,6 +3,7 @@ package loki
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -88,8 +89,15 @@ func MemList() []MemPage {
|
||||
if !strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
continue
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), e.Name()))
|
||||
out = append(out, MemPage{Name: e.Name(), Title: titleOf(string(b))})
|
||||
// Page chiffrée + mémoire verrouillée : on la LISTE quand même (elle
|
||||
// existe, la cacher ferait croire à une perte) avec un titre qui dit
|
||||
// pourquoi son contenu n'apparaît pas.
|
||||
txt, ok := memPageText(e.Name())
|
||||
title := titleOf(txt)
|
||||
if !ok {
|
||||
title = "🔒 (chiffré — mémoire verrouillée)"
|
||||
}
|
||||
out = append(out, MemPage{Name: e.Name(), Title: title})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
@@ -98,36 +106,92 @@ func MemList() []MemPage {
|
||||
// MemSearch cherche les termes de la requête dans le nom + le contenu de chaque
|
||||
// page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de
|
||||
// recherche : à compléter par mem_read sur la page la plus pertinente.
|
||||
//
|
||||
// Classement en deux temps, pour ne PAS laisser une grosse page bourrée d'un mot
|
||||
// fréquent écraser une petite page qui matche VRAIMENT la requête :
|
||||
// 1. Couverture d'abord : les pages qui contiennent le PLUS de termes distincts
|
||||
// de la requête passent devant (chercher « copine Nathan » doit remonter la
|
||||
// page qui a les deux mots, pas celle qui répète « Nathan » 40 fois).
|
||||
// 2. À couverture égale, score pondéré par la RARETÉ du terme (IDF) : un mot
|
||||
// rare et discriminant (« copine ») pèse bien plus qu'un mot omniprésent
|
||||
// (« Nathan »). La fréquence est amortie (log) pour que le bourrage ne gagne
|
||||
// pas, et un match dans le NOM ou le TITRE est fortement bonifié.
|
||||
func MemSearch(query string, limit int) []MemHit {
|
||||
if limit <= 0 || limit > 30 {
|
||||
limit = 8
|
||||
}
|
||||
terms := strings.Fields(strings.ToLower(query))
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
score int
|
||||
terms := uniqueTerms(strings.ToLower(query))
|
||||
|
||||
type doc struct {
|
||||
p MemPage
|
||||
name string // minuscules
|
||||
title string // minuscules
|
||||
content string // brut (pour l'extrait)
|
||||
hay string // minuscules : nom + contenu
|
||||
}
|
||||
var ranked []scored
|
||||
docs := make([]doc, 0)
|
||||
df := make(map[string]int) // nb de pages contenant chaque terme
|
||||
for _, p := range MemList() {
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
|
||||
content := string(b)
|
||||
hay := strings.ToLower(p.Name + "\n" + content)
|
||||
score := 0
|
||||
content, _ := memPageText(p.Name) // "" si chiffré et verrouillé : matche alors sur le nom seul
|
||||
d := doc{
|
||||
p: p,
|
||||
name: strings.ToLower(p.Name),
|
||||
title: strings.ToLower(p.Title),
|
||||
content: content,
|
||||
hay: strings.ToLower(p.Name + "\n" + content),
|
||||
}
|
||||
docs = append(docs, d)
|
||||
for _, t := range terms {
|
||||
score += strings.Count(hay, t)
|
||||
if strings.Contains(strings.ToLower(p.Name), t) {
|
||||
score += 3 // bonus si le terme est dans le nom de la page
|
||||
if strings.Contains(d.hay, t) {
|
||||
df[t]++
|
||||
}
|
||||
}
|
||||
if score == 0 && query != "" {
|
||||
}
|
||||
n := len(docs)
|
||||
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
matched int // termes distincts trouvés (clé de tri primaire)
|
||||
score float64 // pertinence pondérée (clé secondaire)
|
||||
}
|
||||
var ranked []scored
|
||||
for _, d := range docs {
|
||||
matched := 0
|
||||
score := 0.0
|
||||
for _, t := range terms {
|
||||
cnt := strings.Count(d.hay, t)
|
||||
if cnt == 0 {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
// IDF : rare = discriminant. +1 pour qu'un terme fréquent compte encore.
|
||||
idf := math.Log(float64(n+1)/float64(df[t]+1)) + 1
|
||||
// Fréquence amortie : 10 occurrences ne valent pas 10x une occurrence.
|
||||
tf := 1.0 + math.Log(float64(cnt))
|
||||
field := 1.0
|
||||
if strings.Contains(d.name, t) {
|
||||
field += 4 // le terme est dans le NOM de la page
|
||||
}
|
||||
if strings.Contains(d.title, t) {
|
||||
field += 2 // ... ou dans son TITRE
|
||||
}
|
||||
score += idf * tf * field
|
||||
}
|
||||
if matched == 0 && len(terms) > 0 {
|
||||
continue
|
||||
}
|
||||
ranked = append(ranked, scored{
|
||||
hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)},
|
||||
score: score,
|
||||
hit: MemHit{File: d.p.Name, Title: d.p.Title, Snippet: snippetAround(d.content, terms)},
|
||||
matched: matched,
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].matched != ranked[j].matched {
|
||||
return ranked[i].matched > ranked[j].matched // couverture d'abord
|
||||
}
|
||||
return ranked[i].score > ranked[j].score
|
||||
})
|
||||
out := []MemHit{}
|
||||
for i, r := range ranked {
|
||||
if i >= limit {
|
||||
@@ -138,6 +202,19 @@ func MemSearch(query string, limit int) []MemHit {
|
||||
return out
|
||||
}
|
||||
|
||||
// uniqueTerms découpe une requête en mots distincts (dédupliqués, ordre gardé).
|
||||
func uniqueTerms(q string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, t := range strings.Fields(q) {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme.
|
||||
func snippetAround(content string, terms []string) string {
|
||||
flat := strings.Join(strings.Fields(content), " ")
|
||||
@@ -175,12 +252,11 @@ func snippetAround(content string, terms []string) string {
|
||||
// MemRead lit une plage de lignes d'une page (1-indexé, lignes préfixées du
|
||||
// numéro). offset/limit par défaut : tout depuis la ligne 1 (cap 500).
|
||||
func MemRead(name string, offset, limit int) (string, error) {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return "", fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return "", fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
@@ -207,11 +283,14 @@ func MemAdd(name, content string) error {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return fmt.Errorf("la page existe déjà — utilise mem_edit pour la modifier")
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
return os.WriteFile(p, []byte(body), 0o644)
|
||||
// L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y
|
||||
// apparaît toujours, même si le modèle oublie de l'y écrire.
|
||||
memIndexAdd(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemEdit remplace oldText par newText dans une page. oldText doit apparaître
|
||||
@@ -221,12 +300,11 @@ func MemAdd(name, content string) error {
|
||||
var errAlreadyApplied = errors.New("déjà à jour — la page contient déjà cette modification")
|
||||
|
||||
func MemEdit(name, oldText, newText string) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
if errors.Is(err, errMemLocked) {
|
||||
return fmt.Errorf("page '%s' chiffrée — mémoire verrouillée", name)
|
||||
}
|
||||
return fmt.Errorf("page '%s' introuvable", name)
|
||||
}
|
||||
content := string(b)
|
||||
@@ -246,17 +324,13 @@ func MemEdit(name, oldText, newText string) error {
|
||||
return fmt.Errorf("old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
return os.WriteFile(p, []byte(updated), 0o644)
|
||||
return writeMemFile(name, []byte(updated))
|
||||
}
|
||||
|
||||
// MemContent renvoie le contenu brut d'une page (vide si absente). Utilisé par
|
||||
// l'éditeur web.
|
||||
func MemContent(name string) string {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -276,15 +350,14 @@ func MemSave(name, old, content string) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
body := strings.TrimRight(content, "\n") + "\n"
|
||||
return os.WriteFile(p, []byte(body), 0o644)
|
||||
if err := writeMemFile(name, []byte(body)); err != nil {
|
||||
return err
|
||||
}
|
||||
// Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée,
|
||||
// nouvelle ajoutée). Une simple sauvegarde n'ajoute que si la ligne manquait.
|
||||
memIndexRename(old, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemDelete supprime une page mémoire.
|
||||
@@ -296,7 +369,11 @@ func MemDelete(name string) error {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return fmt.Errorf("introuvable")
|
||||
}
|
||||
return os.Remove(p)
|
||||
if err := os.Remove(p); err != nil {
|
||||
return err
|
||||
}
|
||||
memIndexRemove(name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Reproduit le bug rapporté : une petite page dont le NOM et le TITRE contiennent
|
||||
// « copine » ne remontait pas pour la requête « copine Nathan », noyée par de
|
||||
// grosses pages qui répètent « Nathan » des dizaines de fois. Le classement doit
|
||||
// mettre en tête la page qui matche le PLUS de termes distincts (couverture),
|
||||
// puis pondérer par la rareté.
|
||||
func TestMemSearchCoverageBeatsFrequency(t *testing.T) {
|
||||
testHome(t)
|
||||
|
||||
// Deux pages « bruyantes » bourrées de « Nathan », sans « copine ».
|
||||
if err := MemAdd("business-nathan.md", "# Business Nathan\n"+strings.Repeat("Nathan veut un business. ", 40)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("guide-ajean.md", "# Guide AJEAN\n"+strings.Repeat("projet de Nathan. ", 30)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// La petite page cible : matche les DEUX termes de la requête.
|
||||
if err := MemAdd("profil-copine-nathan.md", "# Profil : Copine de Nathan\n- Prénom : Bao\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hits := MemSearch("copine Nathan", 8)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("aucun résultat pour « copine Nathan »")
|
||||
}
|
||||
if hits[0].File != "profil-copine-nathan.md" {
|
||||
var names []string
|
||||
for _, h := range hits {
|
||||
names = append(names, h.File)
|
||||
}
|
||||
t.Fatalf("la page copine devrait être 1re (matche les 2 termes), obtenu ordre : %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une requête mono-terme rare doit privilégier un match dans le nom/titre.
|
||||
func TestMemSearchNameTitleBoost(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = MemAdd("notes-diverses.md", "# Notes\nUn jour Bao est passée dire bonjour, puis repartie.\n")
|
||||
_ = MemAdd("profil-copine.md", "# Profil : Copine\n- Prénom : Bao\n")
|
||||
hits := MemSearch("copine", 8)
|
||||
if len(hits) == 0 || hits[0].File != "profil-copine.md" {
|
||||
t.Fatalf("la page dont le nom/titre porte « copine » devrait être 1re, obtenu : %+v", hits)
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,36 @@ import (
|
||||
//
|
||||
// Approximation 1 tok ≈ 4 caractères : suffisante pour une alerte, et sans
|
||||
// dépendance à un tokenizer.
|
||||
const promptCharBudget = 7500 // ~1875 tokens, tout allumé
|
||||
//
|
||||
// Le budget n'a été relevé que pour de VRAIS outils nouveaux, jamais pour des
|
||||
// lignes de prompt — et chaque palier dit lequel :
|
||||
//
|
||||
// 7500 → 7900 : recall + recall_search (chat_recall.go), ~720 car. Le compactage
|
||||
// cesse d'être destructif : le modèle ne relance plus les lectures
|
||||
// et recherches déjà faites. Il récupère bien plus qu'il ne coûte.
|
||||
// 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre
|
||||
// actions — quatre outils auraient coûté quatre fois ça.
|
||||
// 8600 → 11000 : les quatre outils task_* (tasks_tools.go), ~1290 car. L'IA se
|
||||
// donne elle-même ses rappels et ses veilles ; sans eux il fallait
|
||||
// passer par l'interface. Descriptions déjà au plus court, et un
|
||||
// seul schéma par action (create/update/delete ne partagent pas
|
||||
// assez d'arguments pour fusionner sans rendre chacun ambigu).
|
||||
// Les ~500 car restants couvrent web_screenshot, déclaré dès que
|
||||
// Playwright est présent — c'est le cas dans l'image, pas sur le
|
||||
// runner de CI : le budget doit tenir pour l'image, la vraie.
|
||||
// (Le pilotage de navigateur, lui, est HORS de ce décompte : ses
|
||||
// schémas n'existent que si on arme son interrupteur.)
|
||||
//
|
||||
// Les ~200 car de prompt qui les accompagnent sont le seul écart
|
||||
// assumé à la règle « pas de relèvement pour du prompt » : un outil
|
||||
// qu'aucune ligne ne mentionne n'est jamais appelé de lui-même, et
|
||||
// la ligne dit ce que les schémas ne disent pas (quand se planifier,
|
||||
// et qu'un script planifié ne charge aucun modèle). Elle a été
|
||||
// ramenée de trois lignes à une.
|
||||
//
|
||||
// Descriptions déjà réduites au strict nécessaire. La consigne d'origine tient
|
||||
// pour la suite : ce budget ne se relève pas pour du prompt.
|
||||
const promptCharBudget = 11000 // ~2750 tokens, tout allumé
|
||||
|
||||
func TestSystemPromptStaysLean(t *testing.T) {
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// chat_recall.go — mémoire longue de la conversation.
|
||||
//
|
||||
// Le compactage résume les vieux tours pour libérer la fenêtre, mais un résumé
|
||||
// perd forcément du détail (un gros code, une longue page web, un diff). Plutôt
|
||||
// que de le perdre pour de bon, on ARCHIVE chaque gros bloc AVANT de le
|
||||
// compacter, sous un identifiant court et stable (r7, r42…). Le résumé cite l'id
|
||||
// à la place du bloc (« code du jeu fourni → recall:r7 »), et le modèle peut le
|
||||
// rappeler VERBATIM quand il en a besoin :
|
||||
//
|
||||
// recall(id) → ramène le bloc exact, tel qu'il était.
|
||||
// recall_search(query) → retrouve un bloc (et son id) par mots-clés, même très
|
||||
// ancien et disparu du résumé courant.
|
||||
//
|
||||
// L'archive est GLOBALE (un compteur monotone via bbolt NextSequence), pas liée
|
||||
// à une discussion : un id ne pointe jamais que vers un seul bloc, pour toujours.
|
||||
// Elle vit sur disque (bucket bkRecall) donc survit aux redémarrages et au
|
||||
// multi-appareils. Le contexte, lui, ne porte que les ids ENCORE cités par le
|
||||
// résumé courant : sa taille reste plate même sur une conversation infinie,
|
||||
// pendant que l'archive grossit tranquillement sur disque (bon marché).
|
||||
//
|
||||
// En mode code, c'est ce qui rend une longue session tenable : un fichier lu ou
|
||||
// un diff produit tôt dans la session n'est plus perdu au compactage suivant.
|
||||
|
||||
const (
|
||||
// Un bloc du torse plus long que ça (en caractères) est archivé et remplacé
|
||||
// par son id. En dessous, le résumé le porte très bien tout seul : pas la peine
|
||||
// de créer un id pour trois lignes.
|
||||
recallArchiveMinLen = 800
|
||||
// Longueur de l'extrait de tête laissé au résumeur pour un bloc archivé : de
|
||||
// quoi le résumer fidèlement, le reste étant récupérable par recall.
|
||||
recallSummaryHeadLen = 400
|
||||
// Nombre de résultats par défaut / max pour recall_search.
|
||||
recallSearchDefault = 5
|
||||
recallSearchMax = 20
|
||||
)
|
||||
|
||||
// recallBlock est un bloc archivé, adressable par son id.
|
||||
type recallBlock struct {
|
||||
ID string `json:"id"` // "r<seq>"
|
||||
Seq uint64 `json:"seq"` // séquence brute (tri, clé)
|
||||
Label string `json:"label"` // libellé court lisible (rôle + première ligne)
|
||||
Role string `json:"role"` // rôle d'origine (tool/user/assistant)
|
||||
Content string `json:"content"` // contenu VERBATIM
|
||||
Created int64 `json:"created"` // unix, pour un éventuel ménage futur
|
||||
}
|
||||
|
||||
// recallKey encode une séquence en clé bbolt triable (big-endian).
|
||||
func recallKey(seq uint64) []byte {
|
||||
k := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(k, seq)
|
||||
return k
|
||||
}
|
||||
|
||||
// recallIDToSeq lit "r<n>" → n. Tolère un id sans préfixe et les espaces : le
|
||||
// modèle écrit aussi bien recall("r7") que recall("7") ou recall("R7 ").
|
||||
func recallIDToSeq(id string) (uint64, bool) {
|
||||
s := strings.TrimSpace(id)
|
||||
s = strings.TrimPrefix(s, "r")
|
||||
s = strings.TrimPrefix(s, "R")
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
|
||||
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
|
||||
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
|
||||
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
|
||||
// l'appelant se rabat sur le compactage classique (troncature sans id).
|
||||
func archiveRecallBlock(label, role, content string) (string, error) {
|
||||
// Résolu AVANT d'ouvrir la base : chiffrer dans la transaction relirait la
|
||||
// configuration, donc la base, qui n'est pas rentrante (voir memEncoderNow).
|
||||
encode, err := memEncoderNow()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var id string
|
||||
err = withDB(func(d *bolt.DB) error {
|
||||
return d.Update(func(tx *bolt.Tx) error {
|
||||
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seq, err := b.NextSequence()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
blk := recallBlock{
|
||||
ID: "r" + strconv.FormatUint(seq, 10),
|
||||
Seq: seq,
|
||||
Label: label,
|
||||
Role: role,
|
||||
Content: content,
|
||||
Created: time.Now().Unix(),
|
||||
}
|
||||
id = blk.ID
|
||||
raw, err := json.Marshal(blk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Un bloc archivé, c'est du verbatim de conversation : chiffré comme
|
||||
// le reste quand la mémoire l'est (l'encodeur rend le clair inchangé
|
||||
// si le chiffrement est inactif ; verrouillé, memEncoderNow a déjà
|
||||
// refusé plus haut).
|
||||
enc, err := encode(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.Put(recallKey(seq), enc)
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cacheBust(bkRecall)
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
|
||||
func recallGet(id string) (recallBlock, bool) {
|
||||
seq, ok := recallIDToSeq(id)
|
||||
if !ok {
|
||||
return recallBlock{}, false
|
||||
}
|
||||
var blk recallBlock
|
||||
found := false
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
v := b.Get(recallKey(seq))
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : le bloc reste hors de portée
|
||||
}
|
||||
found = json.Unmarshal(dec, &blk) == nil
|
||||
return nil
|
||||
})
|
||||
return blk, found
|
||||
}
|
||||
|
||||
// recallSearch cherche dans toute l'archive par recouvrement de mots-clés.
|
||||
// Score = nombre de termes distincts de la requête présents dans (libellé +
|
||||
// contenu), le libellé comptant double (un mot du titre est plus signifiant).
|
||||
// Simple, lexical, zéro dépendance — l'issue de secours pour un bloc sorti du
|
||||
// résumé courant. À égalité, le plus RÉCENT d'abord (seq décroissante).
|
||||
func recallSearch(query string, limit int) []recallBlock {
|
||||
terms := recallTokens(query)
|
||||
if len(terms) == 0 {
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = recallSearchDefault
|
||||
}
|
||||
if limit > recallSearchMax {
|
||||
limit = recallSearchMax
|
||||
}
|
||||
type scored struct {
|
||||
blk recallBlock
|
||||
score int
|
||||
}
|
||||
var hits []scored
|
||||
_ = view(bkRecall, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var blk recallBlock
|
||||
dec, err := decodeMemContent(v)
|
||||
if err != nil {
|
||||
return nil // chiffré et verrouillé : ce bloc ne participe pas
|
||||
}
|
||||
if json.Unmarshal(dec, &blk) != nil {
|
||||
return nil
|
||||
}
|
||||
label := strings.ToLower(blk.Label)
|
||||
body := strings.ToLower(blk.Content)
|
||||
score := 0
|
||||
for t := range terms {
|
||||
if strings.Contains(label, t) {
|
||||
score += 2
|
||||
}
|
||||
if strings.Contains(body, t) {
|
||||
score++
|
||||
}
|
||||
}
|
||||
if score > 0 {
|
||||
hits = append(hits, scored{blk, score})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
sort.Slice(hits, func(i, j int) bool {
|
||||
if hits[i].score != hits[j].score {
|
||||
return hits[i].score > hits[j].score
|
||||
}
|
||||
return hits[i].blk.Seq > hits[j].blk.Seq
|
||||
})
|
||||
if len(hits) > limit {
|
||||
hits = hits[:limit]
|
||||
}
|
||||
out := make([]recallBlock, len(hits))
|
||||
for i, h := range hits {
|
||||
out[i] = h.blk
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recallTokens éclate une chaîne en un ensemble de mots-clés minuscules (>= 2
|
||||
// caractères), en coupant sur tout ce qui n'est ni lettre ni chiffre.
|
||||
func recallTokens(s string) map[string]struct{} {
|
||||
out := map[string]struct{}{}
|
||||
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ')
|
||||
}) {
|
||||
if len([]rune(w)) >= 2 {
|
||||
out[w] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// recallLabel fabrique un libellé court et lisible pour un bloc, à partir de son
|
||||
// rôle (préfixé du nom d'outil quand on le connaît) et de sa première ligne.
|
||||
func recallLabel(role, toolName, content string) string {
|
||||
head := ""
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if s := strings.TrimSpace(line); s != "" {
|
||||
head = s
|
||||
break
|
||||
}
|
||||
}
|
||||
if r := []rune(head); len(r) > 70 {
|
||||
head = strings.TrimSpace(string(r[:70])) + "…"
|
||||
}
|
||||
tag := role
|
||||
if role == "tool" && toolName != "" {
|
||||
tag = toolName
|
||||
}
|
||||
if head == "" {
|
||||
return tag
|
||||
}
|
||||
return tag + ": " + head
|
||||
}
|
||||
|
||||
// toolRecall ramène un bloc archivé pour le modèle (outil recall).
|
||||
func toolRecall(args map[string]any) string {
|
||||
id, _ := args["id"].(string)
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return "[erreur] id manquant"
|
||||
}
|
||||
blk, ok := recallGet(id)
|
||||
if !ok {
|
||||
return fmt.Sprintf("[erreur] bloc %q introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés", id)
|
||||
}
|
||||
return fmt.Sprintf("[%s — %s, rappelé verbatim]\n%s", blk.ID, blk.Label, blk.Content)
|
||||
}
|
||||
|
||||
// toolRecallSearch retrouve des blocs par mots-clés (outil recall_search).
|
||||
func toolRecallSearch(args map[string]any) string {
|
||||
q, _ := args["query"].(string)
|
||||
if strings.TrimSpace(q) == "" {
|
||||
return "[erreur] query manquante"
|
||||
}
|
||||
lim := 0
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
lim = int(v)
|
||||
}
|
||||
hits := recallSearch(q, lim)
|
||||
if len(hits) == 0 {
|
||||
return "[aucun bloc archivé ne correspond]"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("Blocs trouvés (rappelle le bon avec recall(id)) :\n")
|
||||
for _, h := range hits {
|
||||
fmt.Fprintf(&b, "- %s — %s\n %s\n", h.ID, h.Label, recallSnippet(h.Content, 160))
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// recallSnippet renvoie un extrait d'une ligne, borné, pour l'aperçu de recherche.
|
||||
func recallSnippet(s string, n int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
if r := []rune(s); len(r) > n {
|
||||
return strings.TrimSpace(string(r[:n])) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// recallTool / recallSearchTool : schémas annoncés au modèle en mode agent.
|
||||
func recallTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "recall",
|
||||
Description: "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "Block id, e.g. \"r7\"."},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func recallSearchTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "recall_search",
|
||||
Description: "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string", "description": "Keywords describing the block."},
|
||||
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Default %d, max %d", recallSearchDefault, recallSearchMax)},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// bigBlock fabrique un contenu franchement au-dessus de recallArchiveMinLen,
|
||||
// pour qu'il soit archivé plutôt que simplement porté par le résumé.
|
||||
func bigBlock(marker string) string {
|
||||
return marker + "\n" + strings.Repeat("ligne de contenu volumineux à conserver verbatim\n", 40)
|
||||
}
|
||||
|
||||
// Un bloc archivé doit revenir MOT POUR MOT : c'est toute la promesse de recall.
|
||||
// Un résumé approximatif suffisait déjà avant ; ce qu'on ajoute ici, c'est
|
||||
// l'exactitude — un gros code ou un diff ne survit pas à une reformulation.
|
||||
func TestRecallRendVerbatim(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
content := bigBlock("MARQUEUR-UNIQUE-42")
|
||||
id, err := archiveRecallBlock("web_read: page", "tool", content)
|
||||
if err != nil {
|
||||
t.Fatalf("archivage impossible : %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(id, "r") {
|
||||
t.Fatalf("id mal formé : %q", id)
|
||||
}
|
||||
blk, ok := recallGet(id)
|
||||
if !ok {
|
||||
t.Fatalf("bloc %q introuvable juste après son archivage", id)
|
||||
}
|
||||
if blk.Content != content {
|
||||
t.Fatal("le contenu rappelé diffère de l'original : l'archive n'est pas verbatim")
|
||||
}
|
||||
// Le passage par l'outil (ce que voit le modèle) doit aussi porter le contenu.
|
||||
if got := toolRecall(map[string]any{"id": id}); !strings.Contains(got, "MARQUEUR-UNIQUE-42") {
|
||||
t.Fatalf("l'outil recall ne rend pas le bloc : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Les ids doivent être uniques et monotones : deux compactages qui se croisent ne
|
||||
// doivent jamais écraser le bloc de l'autre. NextSequence le garantit côté bbolt ;
|
||||
// ce test verrouille qu'on s'en sert bien.
|
||||
func TestRecallIDsUniquesEtMonotones(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
vus := map[string]bool{}
|
||||
var last uint64
|
||||
for i := 0; i < 5; i++ {
|
||||
id, err := archiveRecallBlock("bloc", "tool", bigBlock("contenu"))
|
||||
if err != nil {
|
||||
t.Fatalf("archivage %d : %v", i, err)
|
||||
}
|
||||
if vus[id] {
|
||||
t.Fatalf("id %q réutilisé : un bloc en écrase un autre", id)
|
||||
}
|
||||
vus[id] = true
|
||||
seq, ok := recallIDToSeq(id)
|
||||
if !ok {
|
||||
t.Fatalf("id illisible : %q", id)
|
||||
}
|
||||
if i > 0 && seq <= last {
|
||||
t.Fatalf("séquence non monotone : %d après %d", seq, last)
|
||||
}
|
||||
last = seq
|
||||
}
|
||||
}
|
||||
|
||||
// recallIDToSeq doit tolérer ce que le modèle écrit réellement : avec ou sans
|
||||
// préfixe, majuscule, espaces. Un id refusé pour un espace en trop enverrait le
|
||||
// modèle croire que son bloc a disparu.
|
||||
func TestRecallIDToSeqTolerant(t *testing.T) {
|
||||
for _, in := range []string{"r7", "R7", " r7 ", "7"} {
|
||||
if seq, ok := recallIDToSeq(in); !ok || seq != 7 {
|
||||
t.Fatalf("%q → (%d, %v), attendu (7, true)", in, seq, ok)
|
||||
}
|
||||
}
|
||||
for _, in := range []string{"", "rr", "abc", "r-1"} {
|
||||
if _, ok := recallIDToSeq(in); ok {
|
||||
t.Fatalf("%q accepté alors qu'il est invalide", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recall_search est l'issue de secours quand l'id n'est plus dans le résumé
|
||||
// courant : le bloc doit rester trouvable par mots-clés, et l'outil doit rendre
|
||||
// son id pour que le modèle puisse enchaîner sur recall(id).
|
||||
func TestRecallSearchRetrouveParMotsCles(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
if _, err := archiveRecallBlock("web_read: recette", "tool", bigBlock("gratin dauphinois")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wanted, err := archiveRecallBlock("web_read: horaires", "tool", bigBlock("le train de 14h12 part quai 3"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hits := recallSearch("train horaires", 0)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("aucun résultat pour des mots-clés pourtant présents")
|
||||
}
|
||||
if hits[0].ID != wanted {
|
||||
t.Fatalf("meilleur résultat %q, attendu %q (le libellé doit compter double)", hits[0].ID, wanted)
|
||||
}
|
||||
out := toolRecallSearch(map[string]any{"query": "train horaires"})
|
||||
if !strings.Contains(out, wanted) {
|
||||
t.Fatalf("l'outil recall_search ne rend pas l'id à rappeler : %q", out)
|
||||
}
|
||||
if n := len(recallSearch("motabsolumentabsent", 0)); n != 0 {
|
||||
t.Fatalf("%d bloc(s) remontent pour un terme absent", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Le cœur du lot : un gros bloc du torse doit être ARCHIVÉ avant compaction, et
|
||||
// l'historique compacté doit porter son id. Sans llama-server en test, le résumé
|
||||
// échoue et on tombe sur le repli dégraissé — c'est justement le chemin où
|
||||
// l'ancien code effaçait le contenu sans laisser d'adresse.
|
||||
func TestCompactArchiveLesGrosBlocs(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
page := func(n int) Message {
|
||||
return tm(bigBlock("page numéro " + string(rune('a'+n))))
|
||||
}
|
||||
msgs := []Message{um("cherche les horaires du train pour Lyon")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("web_read"), page(i))
|
||||
}
|
||||
// caps.Agent : sans le mode agent, le modèle n'aurait pas l'outil recall et on
|
||||
// n'archive délibérément rien.
|
||||
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true})
|
||||
if !changed {
|
||||
t.Fatal("aucune compaction alors que le torse est plein de grosses pages")
|
||||
}
|
||||
joined := ""
|
||||
for _, m := range out {
|
||||
joined += msgText(m) + "\n"
|
||||
}
|
||||
if !strings.Contains(joined, "recall(") {
|
||||
t.Fatalf("l'historique compacté ne cite aucun bloc rappelable :\n%s", joined)
|
||||
}
|
||||
if strings.Contains(joined, compactPrunedMarker) {
|
||||
t.Fatal("un gros bloc a été effacé sans adresse de rappel alors qu'il était archivable")
|
||||
}
|
||||
// Et le contenu doit être réellement récupérable, pas seulement cité.
|
||||
hits := recallSearch("page numéro", 0)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("les blocs cités dans l'historique ne sont pas dans l'archive")
|
||||
}
|
||||
if !strings.Contains(hits[0].Content, "ligne de contenu volumineux") {
|
||||
t.Fatal("le bloc archivé ne contient pas le texte d'origine")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans mode agent, le modèle n'a pas l'outil recall : archiver produirait des ids
|
||||
// qu'il ne peut pas utiliser, et un résumé pollué de références mortes.
|
||||
func TestCompactNArchivePasSansAgent(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
|
||||
msgs := []Message{um("question")}
|
||||
for i := 0; i < 10; i++ {
|
||||
msgs = append(msgs, atc("web_read"), tm(bigBlock("page")))
|
||||
}
|
||||
out, _ := compactMessages(t.Context(), msgs, Caps{})
|
||||
for _, m := range out {
|
||||
if strings.Contains(msgText(m), "recall(") {
|
||||
t.Fatal("des références recall sont proposées alors que l'outil n'est pas fourni")
|
||||
}
|
||||
}
|
||||
if len(recallSearch("page", 0)) != 0 {
|
||||
t.Fatal("des blocs ont été archivés hors mode agent")
|
||||
}
|
||||
}
|
||||
|
||||
// recallEligible décide ce qui mérite un id. Deux pièges : les petits blocs (le
|
||||
// résumé les porte très bien) et le résumé d'une compaction PRÉCÉDENTE, qu'on
|
||||
// ré-archiverait à chaque passe en faisant enfler l'archive pour rien.
|
||||
func TestRecallEligible(t *testing.T) {
|
||||
gros := strings.Repeat("x", recallArchiveMinLen+1)
|
||||
if !recallEligible(tm(gros)) {
|
||||
t.Fatal("un gros résultat d'outil doit être archivé")
|
||||
}
|
||||
if recallEligible(tm("court")) {
|
||||
t.Fatal("un petit bloc ne mérite pas un id : le résumé le porte")
|
||||
}
|
||||
if recallEligible(Message{Role: "system", Content: gros}) {
|
||||
t.Fatal("un message système n'a rien à faire dans l'archive")
|
||||
}
|
||||
if recallEligible(um(compactSummaryPrefix + " " + gros)) {
|
||||
t.Fatal("le résumé d'une compaction précédente serait ré-archivé à chaque passe")
|
||||
}
|
||||
}
|
||||
|
||||
// Garde-fou anti-résumé-dégénéré : un petit modèle mal aiguillé répond parfois
|
||||
// par un simple « recall:r7 » au lieu d'écrire de la prose. Accepter ça, c'est
|
||||
// remplacer tout le passé de la conversation par une référence vide.
|
||||
func TestSummaryLooksEmpty(t *testing.T) {
|
||||
vrai := "L'utilisateur cherche les horaires du train pour Lyon ; le train de 14h12 part quai 3, reste à confirmer le tarif."
|
||||
if summaryLooksEmpty(vrai) {
|
||||
t.Fatalf("un vrai résumé est rejeté : %q", vrai)
|
||||
}
|
||||
for _, degenere := range []string{"", " ", "recall:r7", "recall:r7 recall:r8 recall:r9", "voir recall(r7)."} {
|
||||
if !summaryLooksEmpty(degenere) {
|
||||
t.Fatalf("résumé dégénéré accepté : %q", degenere)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// compactSummaryUserMsg porte l'index des ids rappelables. Il doit rester borné
|
||||
// à la passe courante : sinon une conversation infinie ferait enfler cette
|
||||
// section jusqu'à annuler le bénéfice de la compaction.
|
||||
func TestCompactSummaryUserMsg(t *testing.T) {
|
||||
sans := compactSummaryUserMsg("résumé quelconque", nil)
|
||||
if !strings.HasPrefix(sans, compactSummaryPrefix) {
|
||||
t.Fatal("le message de compaction doit être reconnaissable à son préfixe")
|
||||
}
|
||||
if strings.Contains(sans, "recall(") {
|
||||
t.Fatal("recall est proposé alors qu'aucun bloc n'a été archivé")
|
||||
}
|
||||
avec := compactSummaryUserMsg("résumé quelconque", []recallEntry{{id: "r7", label: "web_read: horaires"}})
|
||||
if !strings.Contains(avec, "r7") || !strings.Contains(avec, "web_read: horaires") {
|
||||
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -154,6 +153,12 @@ func engineSeesImages() bool {
|
||||
// où un base64 de plusieurs dizaines de milliers de tokens était rejoué à
|
||||
// chaque tour jusqu'à dépasser définitivement le contexte ;
|
||||
// - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture.
|
||||
//
|
||||
// Le texte restant reçoit imageLostMarker : sans lui, une conversation rouverte
|
||||
// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le
|
||||
// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire
|
||||
// au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne.
|
||||
// Vaut pour toutes les provenances : capture, pièce jointe, see_image.
|
||||
func stripImageParts(msgs []Message) []Message {
|
||||
for i, m := range msgs {
|
||||
parts, ok := m.Content.([]any)
|
||||
@@ -176,7 +181,7 @@ func stripImageParts(msgs []Message) []Message {
|
||||
}
|
||||
}
|
||||
if dropped {
|
||||
msgs[i].Content = strings.Join(texts, "\n")
|
||||
msgs[i].Content = strings.Join(texts, "\n") + imageLostMarker
|
||||
}
|
||||
}
|
||||
return msgs
|
||||
@@ -207,11 +212,13 @@ func screenshotImageMessage(relPath string) (Message, bool) {
|
||||
if err != nil || len(b) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
// Redimensionnement sous maxImageDim avant l'envoi : une capture de page
|
||||
// fait facilement 1500×4000 px, dont le base64 pèse plus que ce que le
|
||||
// projecteur regarde (voir prepareImageForModel, web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Voici la capture demandée."},
|
||||
{"type": "image_url", "image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
}},
|
||||
imageURLPart(b, mime),
|
||||
}}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,9 @@ func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
|
||||
// Un base64 d'image persisté dans l'historique est rejoué à chaque tour : vu en
|
||||
// production, 55 000 tokens de requête pour 32 768 de contexte — plus aucun
|
||||
// tour ne passait. stripImageParts guérit les conversations existantes en
|
||||
// retirant les parties image et en aplatissant le texte restant.
|
||||
// retirant les parties image et en aplatissant le texte restant — en laissant
|
||||
// imageLostMarker, pour que le modèle sache qu'une image A ÉTÉ montrée et qu'il
|
||||
// doit en reprendre une plutôt que la redécrire de mémoire.
|
||||
func TestStripImagePartsGueritLHistorique(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "user", Content: "bonjour"}, // simple chaîne : intouchée
|
||||
@@ -153,8 +155,40 @@ func TestStripImagePartsGueritLHistorique(t *testing.T) {
|
||||
t.Fatalf("message texte modifié : %#v", out[0].Content)
|
||||
}
|
||||
got, ok := out[1].Content.(string)
|
||||
if !ok || got != "Voici la capture demandée." {
|
||||
t.Fatalf("le message multimodal doit être aplati en texte sans l'image, obtenu %#v", out[1].Content)
|
||||
if !ok || got != "Voici la capture demandée."+imageLostMarker {
|
||||
t.Fatalf("le message multimodal doit être aplati en texte, sans l'image mais avec le marqueur de perte, obtenu %#v", out[1].Content)
|
||||
}
|
||||
if strings.Contains(got, "base64") {
|
||||
t.Fatalf("le base64 de l'image ne doit plus apparaître : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// msgText est le point de passage de TOUTE la chaîne de compaction (estimation
|
||||
// du contexte, transcript donné au résumeur). S'il n'extrait que les parties
|
||||
// `text`, un message qui porte une capture ne pèse que sa légende et le résumeur
|
||||
// ignore jusqu'à l'existence de l'image : elle disparaît sans laisser de trace.
|
||||
func TestMsgTextSignaleLaPresenceDUneImage(t *testing.T) {
|
||||
// Message VIVANT tel que construit par screenshotImageMessage ([]map[string]any).
|
||||
live := Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "Capture demandée :"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
|
||||
}}
|
||||
// Même message RELU depuis le JSON persisté ([]any de map génériques).
|
||||
reloaded := Message{Role: "user", Content: []any{
|
||||
map[string]any{"type": "text", "text": "Capture demandée :"},
|
||||
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
|
||||
}}
|
||||
for name, m := range map[string]Message{"vivant": live, "relu": reloaded} {
|
||||
got := msgText(m)
|
||||
if !strings.Contains(got, "Capture demandée :") {
|
||||
t.Fatalf("%s : le texte de la légende doit être conservé, obtenu %q", name, got)
|
||||
}
|
||||
if !strings.Contains(got, imageLostMarker) {
|
||||
t.Fatalf("%s : la présence de l'image doit être signalée, obtenu %q", name, got)
|
||||
}
|
||||
if strings.Contains(got, "base64") {
|
||||
t.Fatalf("%s : le base64 ne doit jamais entrer dans le transcript : %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
package loki
|
||||
|
||||
// chat_scripts.go — le dossier de scripts DÉDIÉ et PROTÉGÉ de l'agent.
|
||||
//
|
||||
// La durabilité des scripts vient d'une séparation STRUCTURELLE, pas d'un verrou
|
||||
// sur les commandes : les scripts qu'on veut CONSERVER vivent dans scriptsDir() =
|
||||
// LOKI_HOME/scripts, à côté de memory, HORS du workspace jetable. Un nettoyage —
|
||||
// ou une catastrophe — dans le workspace ne peut plus les toucher. Le workspace
|
||||
// reste le bac à sable où l'IA peut tout casser sans risque (clones, tests,
|
||||
// fichiers temporaires).
|
||||
//
|
||||
// L'IA a par ailleurs la main libre sur le shell (suppression/déplacement/
|
||||
// renommage inclus) : aucun garde-fou « anti-rm » ne filtre les commandes. Le
|
||||
// SEUL accès réservé est le dossier memory, joignable uniquement par les outils
|
||||
// mem_* (voir guardToolOnly plus bas) — parce qu'il est chiffré, pas pour le
|
||||
// protéger d'une suppression.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// scriptsPath résout un nom de script fourni par le modèle vers un chemin DANS
|
||||
// scriptsDir, en refusant toute évasion hors du dossier (« ../ », chemin absolu).
|
||||
// Renvoie une erreur plutôt qu'un chemin hors périmètre.
|
||||
func scriptsPath(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("nom de script vide")
|
||||
}
|
||||
// On interdit les chemins absolus et la remontée : un script vit dans
|
||||
// scriptsDir, point. Les sous-dossiers relatifs restent permis. Un nom qui
|
||||
// commence par un séparateur (« /etc/… ») est rejeté explicitement : sous
|
||||
// Windows il n'est pas « absolu » au sens de filepath mais n'a rien à faire là.
|
||||
if strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
|
||||
return "", fmt.Errorf("nom de script invalide (commence par un séparateur) : %s", name)
|
||||
}
|
||||
// Caractères hostiles au shell : le nom finit dans une ligne de commande
|
||||
// (scriptRunCommand l'entoure de guillemets) ; on refuse tout ce qui pourrait
|
||||
// en sortir. Le backslash est inclus : sous Windows c'est un séparateur (utiliser
|
||||
// « / » pour les sous-dossiers), sous Unix c'est un échappement shell. Refuser
|
||||
// partout rend le comportement identique sur les deux plateformes. Un nom de
|
||||
// fichier n'a de toute façon besoin d'aucun de ces caractères.
|
||||
if i := strings.IndexAny(name, "\"'`$;&|<>*?\\\n\r"); i >= 0 {
|
||||
return "", fmt.Errorf("nom de script invalide (caractère interdit %q) : %s", name[i:i+1], name)
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(name))
|
||||
if filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", fmt.Errorf("nom de script invalide (pas de chemin absolu ni de ../) : %s", name)
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), clean)
|
||||
// Ceinture et bretelles : vérifie que le résultat est bien SOUS scriptsDir.
|
||||
if !underDir(full, scriptsDir()) {
|
||||
return "", fmt.Errorf("nom de script hors du dossier scripts : %s", name)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// scriptExists valide qu'un nom désigne bien un fichier script existant dans le
|
||||
// dossier (sans lire son contenu). Utilisé pour valider une tâche « script seul ».
|
||||
func scriptExists(name string) error {
|
||||
full, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fi, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return fmt.Errorf("script introuvable : %s", name)
|
||||
}
|
||||
if fi.IsDir() {
|
||||
return fmt.Errorf("%s est un dossier, pas un script", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listScripts renvoie les scripts présents (chemins relatifs à scriptsDir, avec
|
||||
// leur taille), triés. Ignore les dossiers.
|
||||
func listScripts() ([]scriptInfo, error) {
|
||||
root := scriptsDir()
|
||||
var out []scriptInfo
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // dossier absent ou illisible : on renvoie ce qu'on a
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, p)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
size := int64(0)
|
||||
if fi, ferr := d.Info(); ferr == nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
out = append(out, scriptInfo{Name: filepath.ToSlash(rel), Size: size})
|
||||
return nil
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, err
|
||||
}
|
||||
|
||||
type scriptInfo struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// toolOnlyDirs : dossiers auxquels l'IA n'a AUCUN accès direct (ni lecture, ni
|
||||
// écriture, ni listing) par bash/write/edit. Le seul chemin autorisé passe par
|
||||
// les outils dédiés — mem_* pour la mémoire. Ces outils écrivent en Go
|
||||
// directement (MemAdd…), ils ne passent PAS par fileWrite/runShell, donc ils ne
|
||||
// sont pas concernés par ces gardes.
|
||||
//
|
||||
// Le dossier scripts N'EST PAS ici : l'IA y écrit et y exécute ses scripts
|
||||
// librement (avec write/bash). Sa durabilité vient seulement de sa séparation
|
||||
// d'avec le workspace jetable, pas d'un verrou.
|
||||
func toolOnlyDirs() []string { return []string{memoryDir()} }
|
||||
|
||||
// toolOnlyLabel donne le nom des outils à utiliser à la place d'un accès direct
|
||||
// (seule la mémoire est concernée aujourd'hui, voir toolOnlyDirs).
|
||||
func toolOnlyLabel() string {
|
||||
return "les outils mem_* (mem_search/mem_read/mem_add/mem_edit/mem_delete)"
|
||||
}
|
||||
|
||||
// guardToolOnlyPath refuse un accès write/edit à un chemin (déjà résolu) situé
|
||||
// dans un dossier « tool-only » (memory). Renvoie "" si le chemin est autorisé.
|
||||
func guardToolOnlyPath(path string) string {
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if underDir(path, d) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// guardToolOnlyCommand refuse une commande shell qui référence un dossier
|
||||
// « tool-only » (memory), lecture comprise (`cat`, `ls`, `>`…) : on bloque TOUTE
|
||||
// mention du dossier, pas seulement une suppression. Renvoie "" si autorisé.
|
||||
func guardToolOnlyCommand(command string) string {
|
||||
lc := strings.ToLower(command)
|
||||
for _, d := range toolOnlyDirs() {
|
||||
if strings.Contains(lc, strings.ToLower(normPath(d))) {
|
||||
return fmt.Sprintf("[refusé] pas d'accès direct au dossier %s via le shell. Utilise %s.", d, toolOnlyLabel())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// underDir indique si path est égal à dir ou situé dessous, après nettoyage et
|
||||
// (sous Windows) insensibilité à la casse.
|
||||
func underDir(path, dir string) bool {
|
||||
a := normPath(path)
|
||||
b := normPath(dir)
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(a, b+string(filepath.Separator))
|
||||
}
|
||||
|
||||
// normPath normalise un chemin pour la comparaison : absolu si possible, nettoyé,
|
||||
// et minusculé sous Windows (système de fichiers insensible à la casse).
|
||||
func normPath(p string) string {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
p = filepath.Clean(p)
|
||||
if runtime.GOOS == "windows" {
|
||||
p = strings.ToLower(p)
|
||||
}
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestGuardToolOnly vérifie que memory est inaccessible en direct (shell/write/
|
||||
// edit) et n'autorise que les outils dédiés, tandis que scripts reste libre.
|
||||
func TestGuardToolOnly(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
// Shell : toute mention du dossier memory est refusée (lecture comprise). Le
|
||||
// dossier scripts, lui, est librement utilisable (l'IA y écrit et exécute).
|
||||
blockedCmds := []string{
|
||||
"cat " + memoryDir() + "/note.md",
|
||||
"echo x > " + memoryDir() + "/x",
|
||||
}
|
||||
for _, c := range blockedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg == "" {
|
||||
t.Errorf("commande aurait dû être refusée : %q", c)
|
||||
}
|
||||
}
|
||||
allowedCmds := []string{
|
||||
"ls " + workspaceDir(),
|
||||
"bash " + scriptsDir() + "/backup.sh", // exécuter un script : autorisé
|
||||
"ls " + scriptsDir(),
|
||||
}
|
||||
for _, c := range allowedCmds {
|
||||
if msg := guardToolOnlyCommand(c); msg != "" {
|
||||
t.Errorf("commande ne devait pas être bloquée : %q -> %s", c, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// write/edit : un chemin dans memory est refusé ; dans scripts ou workspace, non.
|
||||
if msg := guardToolOnlyPath(filepath.Join(memoryDir(), "a.md")); msg == "" {
|
||||
t.Errorf("écriture directe dans memory aurait dû être refusée")
|
||||
}
|
||||
for _, p := range []string{filepath.Join(scriptsDir(), "b.sh"), filepath.Join(workspaceDir(), "ok.txt")} {
|
||||
if msg := guardToolOnlyPath(p); msg != "" {
|
||||
t.Errorf("écriture ne devait pas être bloquée : %q -> %s", p, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptsPathEscape vérifie qu'on ne peut pas sortir du dossier scripts ni
|
||||
// glisser un caractère hostile au shell dans le nom.
|
||||
func TestScriptsPathEscape(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
bad := []string{"../evil.sh", "..", "/etc/passwd", `..\..\x`, `a";rm -rf ~;".sh`, "a$b.sh", "a`b`.sh"}
|
||||
for _, name := range bad {
|
||||
if _, err := scriptsPath(name); err == nil {
|
||||
t.Errorf("aurait dû refuser le nom : %q", name)
|
||||
}
|
||||
}
|
||||
good := []string{"backup.ps1", "sub/backup.sh", "a.sh", "my backup.sh"}
|
||||
for _, name := range good {
|
||||
p, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
t.Errorf("aurait dû accepter %q : %v", name, err)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(normPath(p), normPath(scriptsDir())) {
|
||||
t.Errorf("%q résolu hors de scriptsDir : %s", name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestScriptExists couvre la validation d'existence utilisée par les tâches script.
|
||||
func TestScriptExists(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
|
||||
if err := scriptExists("hello.sh"); err == nil {
|
||||
t.Fatalf("un script absent aurait dû échouer")
|
||||
}
|
||||
full := filepath.Join(scriptsDir(), "hello.sh")
|
||||
if err := os.MkdirAll(scriptsDir(), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("#!/bin/sh\necho hi\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("hello.sh"); err != nil {
|
||||
t.Fatalf("scriptExists : %v", err)
|
||||
}
|
||||
list, _ := listScripts()
|
||||
if len(list) != 1 || list[0].Name != "hello.sh" {
|
||||
t.Fatalf("list inattendu : %+v", list)
|
||||
}
|
||||
// Un dossier n'est pas un script.
|
||||
if err := os.MkdirAll(filepath.Join(scriptsDir(), "d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := scriptExists("d"); err == nil {
|
||||
t.Fatalf("un dossier ne doit pas passer pour un script")
|
||||
}
|
||||
}
|
||||
@@ -53,13 +53,58 @@ type convMeta struct {
|
||||
Created int64 `json:"created"`
|
||||
Updated int64 `json:"updated"`
|
||||
Turns int `json:"turns"`
|
||||
// Project = slug du projet auquel la discussion appartient (projects.go). Vide
|
||||
// sur les discussions d'avant les projets : ensureDefaultProject les rattache à
|
||||
// « Générale » au premier démarrage, et convIndexForProject traite un champ vide
|
||||
// comme le projet par défaut pour que rien ne disparaisse entre-temps.
|
||||
Project string `json:"project,omitempty"`
|
||||
}
|
||||
|
||||
func convKey(id string) string { return "conv:" + id }
|
||||
|
||||
// convProjectOf renvoie le projet d'une discussion, en traitant l'absence comme
|
||||
// le projet par défaut : une discussion sans projet ne doit jamais devenir
|
||||
// invisible.
|
||||
func convProjectOf(m convMeta) string {
|
||||
if s := strings.TrimSpace(m.Project); s != "" {
|
||||
return s
|
||||
}
|
||||
return defaultProjectSlug
|
||||
}
|
||||
|
||||
// convIndexForProject filtre l'index sur un projet.
|
||||
func convIndexForProject(slug string) []convMeta {
|
||||
var out []convMeta
|
||||
for _, m := range convIndex() {
|
||||
if convProjectOf(m) == slug {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tagOrphanConversations rattache au projet donné les discussions qui n'en ont
|
||||
// pas encore (migration, une seule fois : ensuite il n'y a plus d'orphelines).
|
||||
func tagOrphanConversations(slug string) {
|
||||
idx := convIndex()
|
||||
changed := false
|
||||
for i := range idx {
|
||||
if strings.TrimSpace(idx[i].Project) == "" {
|
||||
idx[i].Project = slug
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
convIndexSave(idx)
|
||||
}
|
||||
}
|
||||
|
||||
func convIndex() []convMeta {
|
||||
var idx []convMeta
|
||||
if b := getBytes(bkChat, ckIndex); len(b) > 0 {
|
||||
// L'index porte les TITRES des discussions : chiffré comme leur contenu
|
||||
// quand la mémoire l'est. Verrouillé, il se lit vide — la liste est masquée,
|
||||
// rien n'est perdu.
|
||||
if b, ok := getStoreBytes(bkChat, ckIndex); ok && len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &idx)
|
||||
}
|
||||
// Plus récemment modifiée en tête : c'est l'ordre attendu d'une liste de
|
||||
@@ -73,7 +118,7 @@ func convIndexSave(idx []convMeta) {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, ckIndex, b)
|
||||
_ = putStoreBytes(bkChat, ckIndex, b)
|
||||
}
|
||||
|
||||
// convEnsureActive garantit qu'une discussion active existe, et reprend le fil
|
||||
@@ -86,11 +131,11 @@ func convEnsureActive() string {
|
||||
if legacy := getBytes(bkChat, ckLegacy); len(legacy) > 0 {
|
||||
// Migration : le fil unique devient la première discussion. On garde la
|
||||
// clé d'origine intacte — en cas de retour arrière, rien n'est perdu.
|
||||
_ = putBytes(bkChat, convKey(id), legacy)
|
||||
_ = putStoreBytes(bkChat, convKey(id), legacy)
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
now := time.Now().Unix()
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
|
||||
return id
|
||||
}
|
||||
|
||||
@@ -116,7 +161,7 @@ func convTouchMeta(id string, title string, turns int) {
|
||||
convIndexSave(idx)
|
||||
return
|
||||
}
|
||||
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns}))
|
||||
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns, Project: activeProjectSlug()}))
|
||||
}
|
||||
|
||||
// convSummary lit le premier message utilisateur pour en faire un titre. Sans
|
||||
@@ -142,8 +187,40 @@ func convSummary(msgs []Message) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// ConvList renvoie les discussions et l'identifiant de l'active (pour l'UI).
|
||||
func ConvList() ([]convMeta, string) { return convIndex(), convEnsureActive() }
|
||||
// ConvList renvoie les discussions DU PROJET ACTIF et l'identifiant de l'active
|
||||
// (pour l'UI). Le cloisonnement est ici : une discussion d'un autre projet n'a
|
||||
// aucune raison d'apparaître dans la barre latérale.
|
||||
func ConvList() ([]convMeta, string) {
|
||||
return convIndexForProject(activeProjectSlug()), convEnsureActive()
|
||||
}
|
||||
|
||||
// projectSwitch bascule sur un autre projet ET ouvre une de ses discussions : la
|
||||
// plus récemment modifiée, ou une neuve s'il n'en a aucune. Sans ça, changer de
|
||||
// projet laisserait à l'écran une discussion qui n'appartient plus à la liste
|
||||
// affichée — et la mémoire (memoryDir) aurait changé sous ses pieds.
|
||||
func projectSwitch(slug string) error {
|
||||
if !projectExists(slug) {
|
||||
return fmt.Errorf("projet introuvable")
|
||||
}
|
||||
convOpMu.Lock()
|
||||
defer convOpMu.Unlock()
|
||||
if slug == activeProjectSlug() {
|
||||
return nil
|
||||
}
|
||||
conv.persist() // fige la discussion du projet qu'on quitte
|
||||
if err := setActiveProject(slug); err != nil {
|
||||
return err
|
||||
}
|
||||
list := convIndexForProject(slug)
|
||||
if len(list) == 0 {
|
||||
convCreate() // convIndex trie par date de modification : voir convIndex
|
||||
return nil
|
||||
}
|
||||
// convIndex rend la plus récemment modifiée en tête.
|
||||
_ = putStr(bkChat, ckActive, list[0].ID)
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(list[0].ID)))
|
||||
return nil
|
||||
}
|
||||
|
||||
// convSwitch bascule sur une autre discussion : la courante est enregistrée,
|
||||
// la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients
|
||||
@@ -169,7 +246,7 @@ func convSwitch(id string) error {
|
||||
}
|
||||
conv.persist() // fige la discussion qu'on quitte
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(id)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(id)))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -179,7 +256,7 @@ func convSwitch(id string) error {
|
||||
func convCreate() string {
|
||||
id := newConvID()
|
||||
now := time.Now().Unix()
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
|
||||
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
|
||||
_ = putStr(bkChat, ckActive, id)
|
||||
conv.loadFrom(nil)
|
||||
return id
|
||||
@@ -249,6 +326,6 @@ func convDelete(id string) error {
|
||||
return nil
|
||||
}
|
||||
_ = putStr(bkChat, ckActive, next[0].ID)
|
||||
conv.loadFrom(getBytes(bkChat, convKey(next[0].ID)))
|
||||
conv.loadFrom(storeBytes(bkChat, convKey(next[0].ID)))
|
||||
return nil
|
||||
}
|
||||
@@ -89,6 +89,13 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// un téléchargement (voir /api/chat/file). Aucun outil ni syntaxe spéciale
|
||||
// à connaître pour le modèle — juste [texte](chemin).
|
||||
b.WriteString("To give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\n")
|
||||
// Auto-planification : les schémas task_* partent déjà dans la requête, donc
|
||||
// on n'y répète NI leur usage NI leurs arguments — seulement QUAND s'en
|
||||
// servir (sinon le modèle ne pense jamais à se planifier quoi que ce soit)
|
||||
// et le fait qu'un script planifié tourne sans modèle, que les schémas ne
|
||||
// disent pas. Une ligne, pas trois : le budget du préambule est compté
|
||||
// (TestSystemPromptStaysLean).
|
||||
b.WriteString("You can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n")
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString("Before answering anything about yourself or this machine, call mem_search first — even trivial-seeming questions. A tool check never replaces it: memory may hold context the tool won't reveal.\n")
|
||||
}
|
||||
@@ -101,6 +108,9 @@ func baseSystemPrompt(caps Caps) string {
|
||||
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\n")
|
||||
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
|
||||
}
|
||||
if caps.Agent && caps.ComputerUse {
|
||||
b.WriteString(cuPromptLine())
|
||||
}
|
||||
if caps.Agent {
|
||||
if line := mcpPromptLine(); line != "" {
|
||||
b.WriteString(line)
|
||||
@@ -177,7 +187,7 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// 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.")
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder (except your scripts folder) when the user explicitly named that location.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -192,6 +202,14 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// génération n'arrêtait rien du tout — le tour restait bloqué jusqu'au bout du
|
||||
// délai (5 minutes au maximum), bouton stop sans effet.
|
||||
func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// Accès réservé aux outils : le dossier mémoire n'est JAMAIS touché au shell
|
||||
// (ni lu, ni écrit, ni listé) — uniquement via les outils mem_*, qui savent
|
||||
// tenir l'index MEMORY.md à jour. Un `cat memory/…` contournait l'index et
|
||||
// laissait le modèle croire qu'il avait lu une page que la mémoire, elle,
|
||||
// n'avait pas servie.
|
||||
if msg := guardToolOnlyCommand(command); msg != "" {
|
||||
return msg
|
||||
}
|
||||
if timeoutSec <= 0 {
|
||||
timeoutSec = toolDefaultTimeout
|
||||
}
|
||||
@@ -274,6 +292,10 @@ func fileWrite(path, content string) string {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
@@ -312,6 +334,10 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return "[erreur] old vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Le dossier mémoire est réservé à ses outils dédiés : pas d'écriture directe.
|
||||
if msg := guardToolOnlyPath(path); msg != "" {
|
||||
return msg
|
||||
}
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package loki
|
||||
|
||||
// chat_vision_tool.go — l'outil see_image : le modèle charge lui-même une image
|
||||
// du disque dans sa VISION, sans que l'utilisateur ait à la joindre au message.
|
||||
//
|
||||
// Loki savait déjà voir deux choses : une pièce jointe (web_upload.go) et une
|
||||
// capture d'écran qu'il venait de prendre (chat_screenshot.go). Pas un fichier
|
||||
// qui dort sur le disque. « Regarde la capture dans ~/photos/bug.png » n'avait
|
||||
// donc aucune réponse : `read` rend des octets binaires, et le modèle finissait
|
||||
// par décrire ce qu'il croyait deviner du nom de fichier.
|
||||
//
|
||||
// Mécanique : le résultat de l'outil reste un simple texte (un accusé). L'image,
|
||||
// elle, est réinjectée juste après dans un message utilisateur multimodal
|
||||
// (image_url) — le SEUL format que llama-server comprenne une fois --mmproj
|
||||
// chargé, et exactement le chemin déjà emprunté par les pièces jointes et les
|
||||
// captures.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// maxVisionBytes borne la taille d'une image chargée dans la vision. Au-delà,
|
||||
// le base64 gonfle le contexte pour rien : le moteur redimensionne de toute
|
||||
// façon avant l'encodeur d'images.
|
||||
const maxVisionBytes = 12 << 20 // 12 Mio
|
||||
|
||||
// toolSeeImage lit un fichier image et renvoie (accusé texte, partie image_url).
|
||||
// La partie image vaut nil en cas d'erreur : l'appelant n'injecte alors rien.
|
||||
//
|
||||
// Chaque refus dit CE QUI manque plutôt que « impossible » : sans projecteur, la
|
||||
// réponse n'est pas la même que sur un poste distant ou un fichier trop lourd,
|
||||
// et un modèle qui reçoit un motif clair peut corriger son geste lui-même.
|
||||
func toolSeeImage(path string) (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image", nil
|
||||
}
|
||||
// Cible = un poste distant : le fichier est LÀ-BAS, pas lisible d'ici.
|
||||
if agentTargetSlug() != "" {
|
||||
return "[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
|
||||
}
|
||||
if path == "" {
|
||||
return "[erreur] chemin de fichier manquant", nil
|
||||
}
|
||||
abs := resolveAgentPath(path)
|
||||
mime := imageMime(abs)
|
||||
if mime == "" {
|
||||
return "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : " + path, nil
|
||||
}
|
||||
st, err := os.Stat(abs)
|
||||
if err != nil {
|
||||
return "[erreur] fichier introuvable : " + path, nil
|
||||
}
|
||||
if st.IsDir() {
|
||||
return "[erreur] c'est un dossier, pas une image : " + path, nil
|
||||
}
|
||||
if st.Size() > maxVisionBytes {
|
||||
return fmt.Sprintf("[erreur] image trop lourde (%s, max %s) : %s",
|
||||
humanBytes(st.Size()), humanBytes(maxVisionBytes), path), nil
|
||||
}
|
||||
// Sonde du moteur EN DERNIER, juste avant le travail coûteux. Elle fait un
|
||||
// appel réseau (/props, mis en cache 10 s) : la poser d'entrée, c'était payer
|
||||
// un aller-retour pour répondre « chemin manquant », et surtout masquer les
|
||||
// motifs précis ci-dessus derrière un « le moteur ne voit pas » qui n'apprend
|
||||
// rien quand le vrai problème est une faute de frappe dans le chemin.
|
||||
//
|
||||
// Elle reste indispensable : un projecteur déclaré ne garantit pas que le
|
||||
// moteur en service sache traiter une image (mauvais couple modèle/mmproj,
|
||||
// moteur trop ancien). Autant le dire que d'envoyer 12 Mio de base64 que
|
||||
// personne ne regardera.
|
||||
if !engineSeesImages() {
|
||||
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
|
||||
}
|
||||
b, err := os.ReadFile(abs)
|
||||
if err != nil {
|
||||
return "[erreur] lecture impossible : " + err.Error(), nil
|
||||
}
|
||||
if len(b) == 0 {
|
||||
return "[erreur] fichier vide : " + path, nil
|
||||
}
|
||||
// Même préparation que les pièces jointes : orientation EXIF redressée et
|
||||
// grand côté ramené sous maxImageDim (voir web_upload_orient.go).
|
||||
b, mime = prepareImageForModel(b, mime)
|
||||
return "[ok] image chargée : " + filepath.Base(abs), imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// imageURLPart construit la partie multimodale {type:image_url, image_url:{url}}
|
||||
// à partir d'octets image déjà préparés — le SEUL format qu'un llama-server
|
||||
// --mmproj comprend. Partagé par see_image, web_screenshot et browser_screenshot.
|
||||
func imageURLPart(b []byte, mime string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]any{
|
||||
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// seeImageMessage emballe la partie image dans le message utilisateur qui la
|
||||
// porte jusqu'au modèle. La légende nomme le fichier : après compaction il ne
|
||||
// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne
|
||||
// dirait pas LAQUELLE rouvrir.
|
||||
func seeImageMessage(label string, img map[string]any) Message {
|
||||
// Sans parenthèses vides : browser_screenshot n'a pas d'argument (label=""),
|
||||
// là où see_image porte le chemin du fichier.
|
||||
text := "Image :"
|
||||
if label != "" {
|
||||
text = "Image demandée (" + label + ") :"
|
||||
}
|
||||
return Message{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": text},
|
||||
img,
|
||||
}}
|
||||
}
|
||||
|
||||
// seeImageTool — schéma envoyé au modèle. Description tenue au plus court : les
|
||||
// schémas partent dans CHAQUE requête et le préambule a un budget
|
||||
// (TestSystemPromptStaysLean).
|
||||
func seeImageTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "see_image",
|
||||
Description: "Ouvre un fichier image du disque pour le VOIR (png, jpg, gif, webp, bmp).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Chemin de l'image (relatif au dossier de travail, ou absolu)"},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngPixel : un PNG valide d'un pixel, assez pour qu'imageMime le reconnaisse.
|
||||
var pngPixel, _ = base64.StdEncoding.DecodeString(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
|
||||
|
||||
// visionOn arme la vision (clé MMPROJ) et renvoie le dossier de travail.
|
||||
func visionOn(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := testHome(t)
|
||||
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
// Sans projecteur, l'outil DIT ce qui manque au lieu d'échouer vaguement — et
|
||||
// il ne renvoie surtout aucune image.
|
||||
func TestSeeImageSansVisionRefuseEtExplique(t *testing.T) {
|
||||
testHome(t)
|
||||
txt, img := toolSeeImage("photo.png")
|
||||
if img != nil {
|
||||
t.Fatal("une image a été chargée alors que la vision est inactive")
|
||||
}
|
||||
if !strings.Contains(txt, "MMPROJ") {
|
||||
t.Fatalf("le refus ne dit pas ce qui manque : %q", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// Les refus qui ne dépendent pas du moteur : format, absence, dossier, poids.
|
||||
// Chacun doit nommer son motif — un modèle qui reçoit « impossible » ne peut
|
||||
// pas corriger son geste, alors qu'un « ce n'est pas une image » se rattrape.
|
||||
func TestSeeImageMotifsDeRefus(t *testing.T) {
|
||||
home := visionOn(t)
|
||||
write := func(name string, b []byte) string {
|
||||
p := filepath.Join(home, name)
|
||||
if err := os.WriteFile(p, b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
write("notes.txt", []byte("bonjour"))
|
||||
if err := os.MkdirAll(filepath.Join(home, "dossier.png"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gros := write("gros.png", append(append([]byte(nil), pngPixel...), make([]byte, maxVisionBytes)...))
|
||||
|
||||
cas := []struct{ path, attendu string }{
|
||||
{"", "chemin de fichier manquant"},
|
||||
{filepath.Join(home, "notes.txt"), "format non reconnu"},
|
||||
{filepath.Join(home, "absente.png"), "introuvable"},
|
||||
{filepath.Join(home, "dossier.png"), "dossier"},
|
||||
{gros, "trop lourde"},
|
||||
}
|
||||
for _, c := range cas {
|
||||
txt, img := toolSeeImage(c.path)
|
||||
if img != nil {
|
||||
t.Errorf("%q : une image a été chargée malgré le refus", c.path)
|
||||
}
|
||||
if !strings.Contains(txt, c.attendu) {
|
||||
t.Errorf("%q : motif = %q, attendu contenir %q", c.path, txt, c.attendu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le message porteur nomme le fichier : après compactage il ne restera que sa
|
||||
// légende et imageLostMarker, et « Image demandée : » tout court ne dirait pas
|
||||
// LAQUELLE rouvrir.
|
||||
func TestMessagePorteurNommeLeFichier(t *testing.T) {
|
||||
img := map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}
|
||||
m := seeImageMessage("captures/bug.png", img)
|
||||
if m.Role != "user" {
|
||||
t.Fatalf("rôle = %q, attendu user (un message tool ne porte que du texte)", m.Role)
|
||||
}
|
||||
parts, ok := m.Content.([]map[string]any)
|
||||
if !ok || len(parts) != 2 {
|
||||
t.Fatalf("contenu multimodal attendu, obtenu %#v", m.Content)
|
||||
}
|
||||
txt, _ := parts[0]["text"].(string)
|
||||
if !strings.Contains(txt, "captures/bug.png") {
|
||||
t.Fatalf("la légende ne nomme pas le fichier : %q", txt)
|
||||
}
|
||||
if parts[1]["type"] != "image_url" {
|
||||
t.Fatalf("seconde partie = %v, attendu image_url", parts[1]["type"])
|
||||
}
|
||||
// Et cette légende survit au compactage, suivie du marqueur de perte.
|
||||
got := msgText(m)
|
||||
if !strings.Contains(got, "captures/bug.png") || !strings.Contains(got, imageLostMarker) {
|
||||
t.Fatalf("après compaction : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le marqueur ne doit plus parler QUE de captures d'écran : une image perdue
|
||||
// peut venir du disque, et « reprends la capture » enverrait photographier une
|
||||
// page web à la place.
|
||||
func TestMarqueurDePerteNestPlusSpecifiqueAuxCaptures(t *testing.T) {
|
||||
if strings.Contains(imageLostMarker, "take the screenshot again") {
|
||||
t.Fatal("le marqueur impose encore la capture d'écran comme seul recours")
|
||||
}
|
||||
if !strings.Contains(imageLostMarker, "see_image") {
|
||||
t.Fatal("le marqueur ne mentionne pas see_image : le modèle ne saura pas rouvrir un fichier")
|
||||
}
|
||||
}
|
||||
|
||||
// L'outil n'est proposé QUE si la vision est active : l'annoncer sans projecteur
|
||||
// ferait promettre au modèle de regarder, puis se contredire.
|
||||
func TestOutilProposeSeulementAvecLaVision(t *testing.T) {
|
||||
testHome(t)
|
||||
has := func() bool {
|
||||
for _, tl := range EnabledTools(Caps{Agent: true}) {
|
||||
if tl.Function.Name == "see_image" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if has() {
|
||||
t.Fatal("see_image proposé sans projecteur configuré")
|
||||
}
|
||||
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !has() {
|
||||
t.Fatal("see_image absent alors que la vision est active")
|
||||
}
|
||||
// Hors mode agent, jamais : l'outil lit un fichier du disque.
|
||||
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tl := range EnabledTools(Caps{}) {
|
||||
if tl.Function.Name == "see_image" {
|
||||
t.Fatal("see_image proposé hors mode agent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le chemin doit remonter du JSON d'appel jusqu'à l'outil. Le libellé d'un appel
|
||||
// est dérivé par une table nom d'outil → argument ; see_image n'y figurait pas,
|
||||
// donc il recevait une chaîne vide et répondait invariablement « chemin de
|
||||
// fichier manquant » — l'outil aurait été inutilisable sans rien casser ailleurs.
|
||||
func TestLeCheminDeLImageRemonteJusquALOutil(t *testing.T) {
|
||||
if got := toolCallLabel("see_image", map[string]any{"file": "captures/bug.png"}); got != "captures/bug.png" {
|
||||
t.Fatalf("libellé de see_image = %q, attendu le chemin du fichier", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Contre-épreuve de l'extraction : les libellés des autres outils n'ont pas
|
||||
// bougé. Déplacer une table de 40 lignes hors d'une fonction de 700, c'est
|
||||
// exactement le genre de geste qui casse un cas au passage sans bruit.
|
||||
func TestLibellesDesAutresOutilsInchanges(t *testing.T) {
|
||||
cas := []struct{ tool, key, val, want string }{
|
||||
{"bash", "command", "ls -la", "ls -la"},
|
||||
{"read", "file", "main.go", "main.go"},
|
||||
{"web_search", "query", "météo", "météo"},
|
||||
{"grep", "pattern", "TODO", "TODO"},
|
||||
{"recall", "id", "b12", "b12"},
|
||||
{"git_clone", "url", "https://x/y", "https://x/y"},
|
||||
{"outil_inconnu", "file", "x", ""},
|
||||
}
|
||||
for _, c := range cas {
|
||||
if got := toolCallLabel(c.tool, map[string]any{c.key: c.val}); got != c.want {
|
||||
t.Errorf("toolCallLabel(%q) = %q, attendu %q", c.tool, got, c.want)
|
||||
}
|
||||
}
|
||||
if got := toolCallLabel("web_grep", map[string]any{"url": "https://x", "pattern": "p"}); got != "p @ https://x" {
|
||||
t.Errorf("web_grep : %q", got)
|
||||
}
|
||||
if got := toolCallLabel("tracker", map[string]any{"action": "add", "name": "poids"}); got != "add poids" {
|
||||
t.Errorf("tracker : %q", got)
|
||||
}
|
||||
// Argument absent ou du mauvais type : chaîne vide, jamais de panique.
|
||||
if got := toolCallLabel("read", map[string]any{"file": 42}); got != "" {
|
||||
t.Errorf("argument non-chaîne : %q", got)
|
||||
}
|
||||
if got := toolCallLabel("bash", nil); got != "" {
|
||||
t.Errorf("arguments nil : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package loki
|
||||
|
||||
// code_subagent.go — l'outil `subagent` du mode code : déléguer une question
|
||||
// bornée à un RÔLE qui travaille dans SON PROPRE contexte, et n'en ramener que
|
||||
// la réponse.
|
||||
//
|
||||
// Repris de l'idée des sous-agents d'OpenFox (voir NOTICE.md), réécrit pour le
|
||||
// fil unique de Loki, sur la mécanique déjà en place pour la passe de
|
||||
// vérification (code_verify.go : un runChat isolé, non persisté).
|
||||
//
|
||||
// Pourquoi ça compte sur un modèle LOCAL : la fenêtre de contexte est petite.
|
||||
// « Trouve où est géré le cache » coûte dix lectures de fichiers qui restent
|
||||
// ensuite dans l'historique jusqu'à la compaction, alors que seule la RÉPONSE
|
||||
// comptait. Le sous-agent paie ces lectures dans un contexte jetable et ne rend
|
||||
// que le résultat : le fil du builder ne grossit que d'un résultat d'outil.
|
||||
//
|
||||
// Trois rôles, tous en LECTURE SEULE (voir EnabledTools) : explorer (cartographie
|
||||
// le code), code-reviewer (relit un changement), planner (découpe un travail).
|
||||
// Aucun n'écrit : ce qui modifie le dépôt reste dans le fil principal, sous les
|
||||
// yeux de l'utilisateur.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// subagentRoles : les rôles délégables. `verifier` n'en fait PAS partie — cette
|
||||
// passe est pilotée par la boucle du contrat (code_verify.go), c'est la seule
|
||||
// habilitée à marquer un critère passé, et la laisser s'appeler à la demande
|
||||
// permettrait au builder de se décerner son propre satisfecit.
|
||||
var subagentRoles = []string{"explorer", "code-reviewer", "planner"}
|
||||
|
||||
func isSubagentRole(r string) bool {
|
||||
for _, x := range subagentRoles {
|
||||
if x == r {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// subagentMaxOutput borne ce que le sous-agent ramène dans le contexte de
|
||||
// l'appelant. Au-delà, l'économie de contexte serait perdue — c'est un rapport,
|
||||
// pas un vidage de fichiers.
|
||||
const subagentMaxOutput = 6000
|
||||
|
||||
func subagentTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "subagent",
|
||||
// Description au plus court : elle part dans CHAQUE requête (budget du
|
||||
// préambule, cf. TestSystemPromptStaysLean).
|
||||
Description: "Délègue une recherche ou une relecture à un rôle qui travaille dans son propre contexte et ne rend que sa réponse. Lecture seule.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"role": map[string]any{
|
||||
"type": "string",
|
||||
"enum": subagentRoles,
|
||||
"description": "explorer (trouver/cartographier), code-reviewer (relire un changement), planner (découper)",
|
||||
},
|
||||
"task": map[string]any{"type": "string", "description": "La question, précise et autonome : le rôle ne voit pas la discussion."},
|
||||
},
|
||||
"required": []string{"role", "task"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// toolSubagent exécute la délégation. Le contexte du sous-agent est ISOLÉ : le
|
||||
// prompt de son rôle, le briefing machine, et la tâche — rien de l'historique
|
||||
// du fil, que précisément on ne veut pas payer deux fois. Sa trace n'est ni
|
||||
// persistée ni diffusée : seul son texte final revient.
|
||||
func toolSubagent(ctx context.Context, args map[string]any, parent Caps) string {
|
||||
role, _ := args["role"].(string)
|
||||
task, _ := args["task"].(string)
|
||||
role = strings.TrimSpace(role)
|
||||
task = strings.TrimSpace(task)
|
||||
if !isSubagentRole(role) {
|
||||
return "[erreur] rôle inconnu : " + role + " (attendu : " + strings.Join(subagentRoles, ", ") + ")"
|
||||
}
|
||||
if task == "" {
|
||||
return "[erreur] tâche vide — décris ce que le rôle doit chercher ou relire"
|
||||
}
|
||||
// Garde-fou anti-récursion : un sous-agent ne délègue pas à son tour. Sans
|
||||
// ça, un modèle qui boucle ouvre autant de contextes que de tours, et la
|
||||
// facture en jetons devient exponentielle pour une question unique.
|
||||
if parent.Role != "" && parent.Role != "builder" {
|
||||
return "[erreur] un sous-agent ne peut pas en appeler un autre — réponds avec ce que tu as"
|
||||
}
|
||||
|
||||
caps := Caps{Agent: true, Code: true, Role: role, Mem: MemOff}
|
||||
// Cadrage : le rôle ne voit ni la discussion ni l'utilisateur. Son prompt de
|
||||
// rôle parle à un humain (« termine en demandant confirmation ») ; ici son
|
||||
// interlocuteur est le builder, et sa réponse est un résultat d'outil.
|
||||
framing := "\n\nAnswer with your findings only: what you found and where (file:line). No preamble."
|
||||
if role == "planner" {
|
||||
framing = "\n\nYou are a delegated pass: nobody will answer you. Record the acceptance criteria with the criteria tool, then give the ordered steps. Do not ask for confirmation."
|
||||
}
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt(role) + "\n\n" + machineSystemPrompt(caps)},
|
||||
{Role: "user", Content: task + framing},
|
||||
}
|
||||
var out strings.Builder
|
||||
// Trace jetable : aucun forwardStream, rien de persisté. L'utilisateur voit
|
||||
// la bulle de l'outil `subagent` et son rapport, pas les dix lectures.
|
||||
if _, err := runChat(ctx, msgs, 0, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
out.WriteString(ev.Content)
|
||||
}
|
||||
return true
|
||||
}); err != nil {
|
||||
return "[erreur] sous-agent " + role + " : " + err.Error()
|
||||
}
|
||||
res := strings.TrimSpace(out.String())
|
||||
if res == "" {
|
||||
return "[erreur] le sous-agent " + role + " n'a rien répondu"
|
||||
}
|
||||
if r := []rune(res); len(r) > subagentMaxOutput {
|
||||
res = string(r[:subagentMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return "[" + role + "]\n" + res
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les garde-fous de l'outil subagent doivent répondre SANS appeler le modèle :
|
||||
// un rôle inconnu, une tâche vide ou un appel depuis un sous-agent sont refusés
|
||||
// à l'entrée (sinon chaque faute de frappe coûte une génération complète).
|
||||
func TestSubagentRefusesBeforeAnyInference(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args map[string]any
|
||||
parent Caps
|
||||
want string
|
||||
}{
|
||||
{"rôle inconnu", map[string]any{"role": "architecte", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"rôle verifier refusé", map[string]any{"role": "verifier", "task": "x"}, Caps{Code: true}, "rôle inconnu"},
|
||||
{"tâche vide", map[string]any{"role": "explorer", "task": " "}, Caps{Code: true}, "tâche vide"},
|
||||
{"pas de récursion", map[string]any{"role": "explorer", "task": "x"}, Caps{Code: true, Role: "explorer"}, "ne peut pas en appeler un autre"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := toolSubagent(context.Background(), tc.args, tc.parent)
|
||||
if !strings.HasPrefix(got, "[erreur]") || !strings.Contains(got, tc.want) {
|
||||
t.Fatalf("attendu un refus contenant %q, obtenu %q", tc.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Un rôle délégué travaille en LECTURE SEULE : ni write/edit (les modifications
|
||||
// restent dans le fil principal), ni subagent (pas de récursion), et seul le
|
||||
// planner pose des critères.
|
||||
func TestSubagentRolesAreReadOnly(t *testing.T) {
|
||||
forbidden := map[string]bool{"write": true, "edit": true, "subagent": true, "mem_add": true, "mem_edit": true, "web_search": true}
|
||||
for _, role := range subagentRoles {
|
||||
names := map[string]bool{}
|
||||
for _, tool := range EnabledTools(Caps{Agent: true, Code: true, Role: role}) {
|
||||
names[tool.Function.Name] = true
|
||||
}
|
||||
for f := range forbidden {
|
||||
if names[f] {
|
||||
t.Fatalf("le rôle %s ne devrait pas avoir l'outil %s", role, f)
|
||||
}
|
||||
}
|
||||
if !names["read"] || !names["grep"] {
|
||||
t.Fatalf("le rôle %s devrait pouvoir lire le code", role)
|
||||
}
|
||||
if got := names["criteria"]; got != (role == "planner") {
|
||||
t.Fatalf("critères pour %s : %v (attendu %v)", role, got, role == "planner")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,895 @@
|
||||
package loki
|
||||
|
||||
// computer_cdp.go — pilote un Chromium/Chrome LOCAL via le protocole DevTools
|
||||
// (CDP), en Go pur (websocket coder/websocket déjà présent), sans dépendance
|
||||
// lourde type chromedp/playwright. C'est le moteur « web » du computer use :
|
||||
// ouvrir une URL, lire l'arbre d'accessibilité (éléments interactifs numérotés,
|
||||
// façon set-of-marks), cliquer/taper/défiler, et — à la demande — capturer un
|
||||
// screenshot pour la vision.
|
||||
//
|
||||
// Choix d'archi : le conteneur Loki embarque déjà un Chromium (celui de
|
||||
// Playwright, cf. Dockerfile) ; on le pilote en CDP. Une seule session navigateur
|
||||
// est maintenue (lazy) et réutilisée entre les appels d'outils, comme le cache
|
||||
// web de chat_internet.go.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
var (
|
||||
cdpMu sync.Mutex
|
||||
cdpCur *cdpSession
|
||||
)
|
||||
|
||||
// Taille FIXE du viewport piloté (et donc du screenshot). Communiquée au modèle
|
||||
// pour que ses coordonnées browser_click_xy lues sur l'image tombent juste.
|
||||
const (
|
||||
cuViewW = 1280
|
||||
cuViewH = 800
|
||||
)
|
||||
|
||||
// cdpSession détient le process Chrome, la connexion websocket CDP vers la cible
|
||||
// « page », et le multiplexage requête/réponse (un lecteur en tâche de fond
|
||||
// distribue les réponses aux appels en attente par id).
|
||||
type cdpSession struct {
|
||||
cmd *exec.Cmd
|
||||
userDir string
|
||||
ws *websocket.Conn
|
||||
wctx context.Context
|
||||
wcancel context.CancelFunc
|
||||
|
||||
mu sync.Mutex
|
||||
nextID int
|
||||
pending map[int]chan cdpMessage
|
||||
|
||||
netMu sync.Mutex
|
||||
inflight int // requêtes réseau en cours (pour l'attente d'inactivité)
|
||||
lastNet time.Time // dernier événement réseau
|
||||
|
||||
lastSnap string // dernier snapshot renvoyé, pour dé-dupliquer les états identiques
|
||||
}
|
||||
|
||||
type cdpMessage struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *cdpError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type cdpError struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// ─── découverte du binaire navigateur ────────────────────────────────────────
|
||||
|
||||
// chromePath localise un binaire Chromium/Chrome/Edge exploitable. LOKI_CHROME
|
||||
// force un chemin ; sinon on cherche D'ABORD le Chromium de Playwright (celui
|
||||
// qu'embarque l'image Docker, sous PLAYWRIGHT_BROWSERS_PATH), puis les
|
||||
// emplacements standards par OS, puis le PATH.
|
||||
//
|
||||
// L'ordre compte : dans le conteneur, AUCUN chromium n'est installé par le
|
||||
// gestionnaire de paquets — le seul navigateur présent est celui que
|
||||
// `playwright install chromium` a déposé dans /opt/pw-browsers. Sans cette
|
||||
// recherche, le pilotage de navigateur serait déclaré indisponible dans l'image
|
||||
// même qui embarque un navigateur (voir Dockerfile, ARG PLAYWRIGHT).
|
||||
func chromePath() string {
|
||||
if p := strings.TrimSpace(os.Getenv("LOKI_CHROME")); p != "" {
|
||||
return p
|
||||
}
|
||||
if p := playwrightChromium(); p != "" {
|
||||
return p
|
||||
}
|
||||
var cands []string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
for _, base := range []string{os.Getenv("ProgramFiles"), os.Getenv("ProgramFiles(x86)"), os.Getenv("LocalAppData")} {
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
cands = append(cands,
|
||||
filepath.Join(base, `Google\Chrome\Application\chrome.exe`),
|
||||
filepath.Join(base, `Microsoft\Edge\Application\msedge.exe`),
|
||||
filepath.Join(base, `Chromium\Application\chrome.exe`),
|
||||
)
|
||||
}
|
||||
case "darwin":
|
||||
cands = []string{
|
||||
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
||||
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
||||
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
|
||||
}
|
||||
default:
|
||||
for _, n := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range cands {
|
||||
if c != "" {
|
||||
if _, err := os.Stat(c); err == nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"chrome", "google-chrome", "chromium"} {
|
||||
if p, err := exec.LookPath(n); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// playwrightChromium renvoie le binaire Chromium déposé par Playwright, ou ""
|
||||
// s'il n'y en a pas. Playwright range ses navigateurs sous
|
||||
// PLAYWRIGHT_BROWSERS_PATH (l'image Docker fixe /opt/pw-browsers) ou, à défaut,
|
||||
// dans le cache utilisateur, en un dossier chromium-<build> par version.
|
||||
//
|
||||
// On prend la version la PLUS RÉCENTE (les builds sont numérotés, tri croissant
|
||||
// sur le nom) : une image reconstruite laisse parfois l'ancien dossier derrière
|
||||
// elle, et lancer un vieux Chromium sur le CDP d'aujourd'hui, c'est chercher
|
||||
// des pannes sans raison. `chrome` d'abord, `headless_shell` en repli : ce
|
||||
// dernier ne sait pas ouvrir de fenêtre (LOKI_CU_HEADFUL serait sans effet)
|
||||
// mais pilote très bien une page en headless.
|
||||
func playwrightChromium() string {
|
||||
var roots []string
|
||||
if p := strings.TrimSpace(os.Getenv("PLAYWRIGHT_BROWSERS_PATH")); p != "" && p != "0" {
|
||||
roots = append(roots, p)
|
||||
}
|
||||
roots = append(roots, "/opt/pw-browsers")
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
roots = append(roots, filepath.Join(home, "AppData", "Local", "ms-playwright"))
|
||||
case "darwin":
|
||||
roots = append(roots, filepath.Join(home, "Library", "Caches", "ms-playwright"))
|
||||
default:
|
||||
roots = append(roots, filepath.Join(home, ".cache", "ms-playwright"))
|
||||
}
|
||||
}
|
||||
// Chemin du binaire À L'INTÉRIEUR d'un dossier chromium-<build>, par OS.
|
||||
var rels [][]string
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
rels = [][]string{{"chrome-win", "chrome.exe"}, {"chrome-win", "headless_shell.exe"}}
|
||||
case "darwin":
|
||||
rels = [][]string{{"chrome-mac", "Chromium.app", "Contents", "MacOS", "Chromium"}, {"chrome-mac", "headless_shell"}}
|
||||
default:
|
||||
rels = [][]string{{"chrome-linux", "chrome"}, {"chrome-linux", "headless_shell"}}
|
||||
}
|
||||
for _, root := range roots {
|
||||
dirs, err := filepath.Glob(filepath.Join(root, "chromium*-*"))
|
||||
if err != nil || len(dirs) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Strings(dirs)
|
||||
for i := len(dirs) - 1; i >= 0; i-- {
|
||||
for _, rel := range rels {
|
||||
p := filepath.Join(append([]string{dirs[i]}, rel...)...)
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ─── lancement + connexion ───────────────────────────────────────────────────
|
||||
|
||||
func cdpLaunch() (*cdpSession, error) {
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
return nil, fmt.Errorf("navigateur Chromium/Chrome/Edge introuvable — installe-le ou définis LOKI_CHROME=<chemin du binaire>")
|
||||
}
|
||||
// Balaye les profils temporaires abandonnés (Chrome tué sans close() lors d'un
|
||||
// redémarrage du service) : sinon /tmp se remplit de dossiers loki-cu-* au fil
|
||||
// des restarts.
|
||||
sweepStaleCUDirs()
|
||||
userDir, err := os.MkdirTemp("", "loki-cu-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Headless par défaut (marche sur le serveur sans écran) ; LOKI_CU_HEADFUL=1
|
||||
// ouvre une vraie fenêtre visible sur une machine de bureau.
|
||||
args := []string{
|
||||
"--remote-debugging-port=0",
|
||||
"--user-data-dir=" + userDir,
|
||||
"--no-first-run", "--no-default-browser-check",
|
||||
"--disable-gpu", "--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--disable-features=Translate,BackForwardCache",
|
||||
// Indispensables sur un serveur headless sous un compte non-root : sans
|
||||
// --no-sandbox Chrome plante au lancement (namespaces refusés), et /dev/shm
|
||||
// y est souvent minuscule → --disable-dev-shm-usage évite les crash onglet.
|
||||
"--no-sandbox", "--disable-dev-shm-usage",
|
||||
"--window-size=1280,900",
|
||||
"about:blank",
|
||||
}
|
||||
if os.Getenv("LOKI_CU_HEADFUL") == "" {
|
||||
args = append([]string{"--headless=new"}, args...)
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
// Chrome écrit le port de debug réel dans DevToolsActivePort (ligne 1).
|
||||
port := ""
|
||||
portFile := filepath.Join(userDir, "DevToolsActivePort")
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(portFile); err == nil {
|
||||
if line := strings.SplitN(strings.TrimSpace(string(b)), "\n", 2)[0]; line != "" {
|
||||
port = line
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if port == "" {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, fmt.Errorf("Chrome n'a pas exposé son port de debug (DevToolsActivePort)")
|
||||
}
|
||||
wsURL, err := cdpPageWS(port)
|
||||
if err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c, _, err := websocket.Dial(ctx, wsURL, nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
_ = cmd.Process.Kill()
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
}
|
||||
c.SetReadLimit(-1) // les screenshots dépassent la limite par défaut (32 Kio)
|
||||
s := &cdpSession{cmd: cmd, userDir: userDir, ws: c, wctx: ctx, wcancel: cancel, pending: map[int]chan cdpMessage{}}
|
||||
go s.readLoop()
|
||||
_, _ = s.call("Page.enable", nil)
|
||||
_, _ = s.call("Runtime.enable", nil)
|
||||
_, _ = s.call("DOM.enable", nil)
|
||||
_, _ = s.call("Network.enable", nil) // pour l'attente d'inactivité réseau (settle)
|
||||
// Viewport DÉTERMINISTE : sans ça, headless donne une taille bizarre (mesuré :
|
||||
// 1264x749) que le modèle interprétait mal pour browser_click_xy (il visait trop
|
||||
// bas). On force exactement cuViewW x cuViewH, DPR 1 → le screenshot fait cette
|
||||
// taille, et les coordonnées lues dessus = coordonnées de clic, 1:1.
|
||||
_, _ = s.call("Emulation.setDeviceMetricsOverride", map[string]any{
|
||||
"width": cuViewW, "height": cuViewH, "deviceScaleFactor": 1, "mobile": false,
|
||||
})
|
||||
// Garder toute navigation dans le MÊME onglet : le pilote n'est attaché qu'à une
|
||||
// cible « page ». Un lien target=_blank ou un window.open ouvrait un onglet que
|
||||
// le pilote ne voyait pas → l'IA croyait que « rien ne se passe » et bouclait
|
||||
// (vécu sur le test IONOS « Devenez client »). Ce script, réévalué au début de
|
||||
// CHAQUE document, neutralise les nouveaux onglets.
|
||||
_, _ = s.call("Page.addScriptToEvaluateOnNewDocument", map[string]any{"source": cdpSameTabJS})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpSameTabJS force les navigations « nouvel onglet » à rester dans l'onglet
|
||||
// courant (window.open redirigé, target=_blank retiré au clic).
|
||||
const cdpSameTabJS = `(function(){try{
|
||||
window.open=function(u){if(u)location.href=u;return window;};
|
||||
document.addEventListener('click',function(e){
|
||||
var a=e.target&&e.target.closest&&e.target.closest('a[target]');
|
||||
if(a)a.removeAttribute('target');
|
||||
},true);
|
||||
}catch(e){}})();`
|
||||
|
||||
// cdpPageWS interroge l'API HTTP de debug pour trouver l'URL websocket d'une
|
||||
// cible de type « page ».
|
||||
func cdpPageWS(port string) (string, error) {
|
||||
cl := &http.Client{Timeout: 5 * time.Second}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := cl.Get("http://127.0.0.1:" + port + "/json")
|
||||
if err == nil {
|
||||
var targets []struct {
|
||||
Type string `json:"type"`
|
||||
WS string `json:"webSocketDebuggerUrl"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&targets)
|
||||
resp.Body.Close()
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && t.WS != "" {
|
||||
return t.WS, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
return "", fmt.Errorf("aucune cible 'page' exposée par Chrome")
|
||||
}
|
||||
|
||||
func (s *cdpSession) readLoop() {
|
||||
for {
|
||||
_, data, err := s.ws.Read(s.wctx)
|
||||
if err != nil {
|
||||
s.mu.Lock()
|
||||
for _, ch := range s.pending {
|
||||
close(ch)
|
||||
}
|
||||
s.pending = map[int]chan cdpMessage{}
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
var m cdpMessage
|
||||
if json.Unmarshal(data, &m) != nil {
|
||||
continue
|
||||
}
|
||||
if m.ID != 0 {
|
||||
s.mu.Lock()
|
||||
ch := s.pending[m.ID]
|
||||
delete(s.pending, m.ID)
|
||||
s.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- m
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Événements réseau : on suit le nombre de requêtes en vol pour que settle()
|
||||
// puisse attendre l'inactivité réseau (fin du chargement AJAX).
|
||||
switch m.Method {
|
||||
case "Network.requestWillBeSent":
|
||||
s.netMu.Lock()
|
||||
s.inflight++
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
case "Network.loadingFinished", "Network.loadingFailed":
|
||||
s.netMu.Lock()
|
||||
if s.inflight > 0 {
|
||||
s.inflight--
|
||||
}
|
||||
s.lastNet = time.Now()
|
||||
s.netMu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// call envoie une commande CDP et attend sa réponse (borné à 30 s).
|
||||
func (s *cdpSession) call(method string, params map[string]any) (json.RawMessage, error) {
|
||||
s.mu.Lock()
|
||||
s.nextID++
|
||||
id := s.nextID
|
||||
ch := make(chan cdpMessage, 1)
|
||||
s.pending[id] = ch
|
||||
s.mu.Unlock()
|
||||
|
||||
payload := map[string]any{"id": id, "method": method}
|
||||
if params != nil {
|
||||
payload["params"] = params
|
||||
}
|
||||
data, _ := json.Marshal(payload)
|
||||
wctx, cancel := context.WithTimeout(s.wctx, 30*time.Second)
|
||||
defer cancel()
|
||||
if err := s.ws.Write(wctx, websocket.MessageText, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
select {
|
||||
case m, ok := <-ch:
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connexion CDP fermée")
|
||||
}
|
||||
if m.Error != nil {
|
||||
return nil, fmt.Errorf("%s: %s", method, m.Error.Message)
|
||||
}
|
||||
return m.Result, nil
|
||||
case <-wctx.Done():
|
||||
return nil, fmt.Errorf("%s: délai dépassé", method)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.ws != nil {
|
||||
_ = s.ws.Close(websocket.StatusNormalClosure, "")
|
||||
}
|
||||
if s.wcancel != nil {
|
||||
s.wcancel()
|
||||
}
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
if s.userDir != "" {
|
||||
os.RemoveAll(s.userDir)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpGet renvoie la session courante, en (re)lançant Chrome au besoin. Un ping
|
||||
// léger valide qu'une session existante répond encore.
|
||||
func cdpGet() (*cdpSession, error) {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
if _, err := cdpCur.call("Runtime.evaluate", map[string]any{"expression": "1", "returnByValue": true}); err == nil {
|
||||
return cdpCur, nil
|
||||
}
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
s, err := cdpLaunch()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cdpCur = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// cdpShutdown ferme la session navigateur si elle existe (appelé quand on coupe
|
||||
// le computer use, pour ne pas laisser un Chrome headless traîner).
|
||||
// sweepStaleCUDirs supprime les profils Chrome temporaires (loki-cu-*) de plus
|
||||
// d'une heure laissés dans le dossier temp — best-effort, silencieux.
|
||||
func sweepStaleCUDirs() {
|
||||
entries, err := os.ReadDir(os.TempDir())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Add(-time.Hour)
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() || !strings.HasPrefix(e.Name(), "loki-cu-") {
|
||||
continue
|
||||
}
|
||||
if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
|
||||
os.RemoveAll(filepath.Join(os.TempDir(), e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cdpShutdown() {
|
||||
cdpMu.Lock()
|
||||
defer cdpMu.Unlock()
|
||||
if cdpCur != nil {
|
||||
cdpCur.close()
|
||||
cdpCur = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── évaluation JS ───────────────────────────────────────────────────────────
|
||||
|
||||
// cdpEval exécute une expression JS dans la page et renvoie sa valeur (JSON brut).
|
||||
func (s *cdpSession) eval(expr string) (json.RawMessage, error) {
|
||||
res, err := s.call("Runtime.evaluate", map[string]any{
|
||||
"expression": expr,
|
||||
"returnByValue": true,
|
||||
"awaitPromise": true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Result struct {
|
||||
Value json.RawMessage `json:"value"`
|
||||
} `json:"result"`
|
||||
ExceptionDetails *struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"exceptionDetails"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &r)
|
||||
if r.ExceptionDetails != nil {
|
||||
return nil, fmt.Errorf("JS: %s", r.ExceptionDetails.Text)
|
||||
}
|
||||
return r.Result.Value, nil
|
||||
}
|
||||
|
||||
// ─── snapshot d'accessibilité (set-of-marks) ─────────────────────────────────
|
||||
|
||||
// cdpHelpersJS : fonctions partagées par le snapshot et browser_find, préfixées au
|
||||
// script évalué. Points clés :
|
||||
// - ajActionable ne garde que les éléments RÉELLEMENT cliquables/saisissables :
|
||||
// on écarte les conteneurs (list, listitem, banner, navigation, menu, region,
|
||||
// div/span muets…) qui polluaient la liste et embrouillaient le modèle.
|
||||
// - ajName enrichit un champ de formulaire avec son <label> (« Prénom » plutôt
|
||||
// que « firstName »), et NE RÉVÈLE PAS la valeur d'un champ mot de passe
|
||||
// (le secret ne doit pas repartir dans le contexte/l'export).
|
||||
const cdpHelpersJS = `
|
||||
function ajRole(el){var r=el.getAttribute('role');if(r)return r;var t=el.tagName.toLowerCase();if(t==='a')return 'link';if(t==='button')return 'button';if(t==='select')return 'combobox';if(t==='textarea')return 'textbox';if(t==='input'){var ty=(el.type||'text').toLowerCase();var m={checkbox:'checkbox',radio:'radio',button:'button',submit:'button',reset:'button',file:'button',range:'slider',password:'password',search:'searchbox'};return m[ty]||'textbox';}return t;}
|
||||
function ajName(el){var t=el.tagName.toLowerCase();var lbl=el.getAttribute('aria-label')||'';if(!lbl){var lb=el.getAttribute('aria-labelledby');if(lb){var e2=document.getElementById(lb.split(' ')[0]);if(e2)lbl=e2.innerText||'';}}if(!lbl&&el.labels&&el.labels.length)lbl=el.labels[0].innerText||'';if(!lbl)lbl=el.getAttribute('placeholder')||el.getAttribute('title')||el.getAttribute('alt')||'';var val='';if((t==='input'||t==='textarea')&&el.type!=='password'&&el.type!=='hidden')val=el.value||'';var n=val?(lbl?(lbl+' = '+val):val):lbl;if(!n&&t!=='input'&&t!=='select'&&t!=='textarea')n=el.innerText||'';if(!n){var c=el.querySelector('img[alt],[aria-label],[title],svg title');if(c)n=c.getAttribute&&(c.getAttribute('alt')||c.getAttribute('aria-label')||c.getAttribute('title'))||c.textContent||'';}if(!n)n=el.getAttribute('name')||el.getAttribute('value')||'';return (n||'').replace(/\s+/g,' ').trim().slice(0,100);}
|
||||
function ajActionable(el){var t=el.tagName.toLowerCase();if(t==='a')return !!el.getAttribute('href');if(t==='button'||t==='input'||t==='select'||t==='textarea'||t==='summary')return el.type!=='hidden';if(el.isContentEditable)return true;if(el.getAttribute('onclick'))return true;var r=(el.getAttribute('role')||'').toLowerCase();return {button:1,link:1,checkbox:1,radio:1,tab:1,menuitem:1,menuitemcheckbox:1,menuitemradio:1,option:1,'switch':1,combobox:1,textbox:1,searchbox:1,slider:1,spinbutton:1}[r]===1;}
|
||||
function ajVis(el){var r=el.getBoundingClientRect();if(r.width<3||r.height<3)return false;var s=getComputedStyle(el);if(s.visibility==='hidden'||s.display==='none'||s.opacity==='0')return false;if(r.bottom<0||r.top>innerHeight||r.right<0||r.left>innerWidth)return false;return true;}
|
||||
var ajSel='a[href],button,input,select,textarea,[role],[onclick],[tabindex],summary,[contenteditable="true"]';
|
||||
// Traversée du SHADOW DOM : les bandeaux cookies et composants web (usercentrics,
|
||||
// cookiebot…) vivent dans des shadow roots que querySelectorAll ne voit pas → le
|
||||
// modèle ne pouvait ni les lister ni les cliquer (test Lady Sushi). ajQueryAll /
|
||||
// ajByRef / ajClear descendent récursivement dans chaque shadowRoot ouvert.
|
||||
function ajQueryAll(){var out=[];function w(r){var e=r.querySelectorAll(ajSel);for(var i=0;i<e.length;i++)out.push(e[i]);var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);return out;}
|
||||
function ajByRef(ref){function f(r){var e=r.querySelector('[data-loki-ref="'+ref+'"]');if(e)return e;var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++)if(a[i].shadowRoot){var x=f(a[i].shadowRoot);if(x)return x;}return null;}return f(document);}
|
||||
function ajClear(){function w(r){var o=r.querySelectorAll('[data-loki-ref]');for(var i=0;i<o.length;i++)o[i].removeAttribute('data-loki-ref');var a=r.querySelectorAll('*');for(var j=0;j<a.length;j++)if(a[j].shadowRoot)w(a[j].shadowRoot);}w(document);}
|
||||
`
|
||||
|
||||
// cdpSnapshotJS marque chaque élément ACTIONNABLE et VISIBLE d'un attribut
|
||||
// data-loki-ref numéroté, mémorise le centre de chacun dans window.__lokiRefs,
|
||||
// et renvoie {title,url,refs,above,below}. Compact volontairement.
|
||||
const cdpSnapshotJS = cdpHelpersJS + `(function(){
|
||||
ajClear();
|
||||
var els=ajQueryAll().filter(function(el){return ajActionable(el)&&ajVis(el);});
|
||||
var map={},refs=[],i=0;
|
||||
for(var k=0;k<els.length&&i<200;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
i++;var r=el.getBoundingClientRect();map[i]={x:r.left+r.width/2,y:r.top+r.height/2};
|
||||
el.setAttribute('data-loki-ref',String(i));
|
||||
refs.push('['+i+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
window.__lokiRefs=map;
|
||||
var de=document.documentElement;
|
||||
var sh=Math.max(de.scrollHeight,document.body?document.body.scrollHeight:0);
|
||||
return {title:document.title||'',url:location.href,refs:refs,
|
||||
above:Math.round(scrollY),below:Math.round(Math.max(0,sh-scrollY-innerHeight))};
|
||||
})()`
|
||||
|
||||
type cuSnap struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Refs []string `json:"refs"`
|
||||
Above int `json:"above"` // pixels défilables au-dessus de la vue
|
||||
Below int `json:"below"` // pixels défilables en dessous de la vue
|
||||
}
|
||||
|
||||
// snapshot lit l'arbre interactif courant et le formate pour le modèle.
|
||||
func (s *cdpSession) snapshot() (string, error) {
|
||||
val, err := s.eval(cdpSnapshotJS)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var snap cuSnap
|
||||
if err := json.Unmarshal(val, &snap); err != nil {
|
||||
return "", fmt.Errorf("snapshot illisible: %v", err)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# %s\n%s\n\n", snap.Title, snap.URL)
|
||||
if len(snap.Refs) == 0 {
|
||||
b.WriteString("(aucun élément interactif détecté — page vide, en cours de chargement, ou rendue sur canvas ; réessaie browser_snapshot, ou utilise browser_screenshot pour voir)")
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%d élément(s) interactif(s) VISIBLE(s) — clique par numéro avec browser_click(ref) :\n", len(snap.Refs))
|
||||
b.WriteString(strings.Join(snap.Refs, "\n"))
|
||||
}
|
||||
// Indice de défilement : la liste ne couvre QUE la partie visible. Sans ce
|
||||
// repère, le modèle croit la page épuisée après un scroll (3 éléments restants)
|
||||
// et se met à deviner des URLs au lieu de défiler (vécu sur le test IONOS).
|
||||
if snap.Below > 20 || snap.Above > 20 {
|
||||
b.WriteString("\n\n")
|
||||
if snap.Below > 20 {
|
||||
fmt.Fprintf(&b, "↓ encore ~%d px sous la vue — browser_scroll(down) pour révéler d'autres éléments. ", snap.Below)
|
||||
}
|
||||
if snap.Above > 20 {
|
||||
fmt.Fprintf(&b, "↑ ~%d px au-dessus — browser_scroll(up) pour y revenir.", snap.Above)
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), " "), nil
|
||||
}
|
||||
|
||||
// snapshotDedup renvoie le snapshot courant, ou une ligne courte s'il est
|
||||
// RIGOUREUSEMENT identique au dernier renvoyé (clic sans effet, ré-ouverture de
|
||||
// la même page…). Évite de re-lister 20+ éléments pour rien — les numéros restent
|
||||
// valides puisque le DOM n'a pas changé. Économie de contexte pour petits modèles.
|
||||
func (s *cdpSession) snapshotDedup() (string, error) {
|
||||
full, err := s.snapshot()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if full == s.lastSnap {
|
||||
return "(page inchangée depuis le dernier affichage — les mêmes numéros restent valides)", nil
|
||||
}
|
||||
s.lastSnap = full
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// cdpFindJS cherche, sur TOUTE la page (pas seulement le viewport), les éléments
|
||||
// interactifs dont le rôle ou le nom contient la requête, leur pose un
|
||||
// data-loki-ref numéroté, fait défiler le premier au centre, et renvoie la liste.
|
||||
// Sert à localiser un lien/bouton hors écran sans défiler à l'aveugle.
|
||||
// browser_find est NON DESTRUCTIF (contrairement au snapshot) : il n'efface PAS les
|
||||
// data-loki-ref existants — sinon les numéros du dernier snapshot deviendraient
|
||||
// invalides et un browser_click ensuite échouerait (« introuvable », vécu au test #11).
|
||||
// Il RÉUTILISE le ref d'un élément déjà numéroté, et n'attribue un NOUVEAU numéro
|
||||
// (au-delà du max courant) qu'aux éléments hors écran encore sans ref.
|
||||
const cdpFindJS = `function(q){
|
||||
q=(q||'').toLowerCase();
|
||||
var pool=ajQueryAll();
|
||||
var maxRef=0;for(var m=0;m<pool.length;m++){var v=parseInt(pool[m].getAttribute('data-loki-ref'),10)||0;if(v>maxRef)maxRef=v;}
|
||||
var els=pool.filter(ajActionable);
|
||||
var refs=[],count=0,first=null;
|
||||
for(var k=0;k<els.length&&count<60;k++){var el=els[k];var nm=ajName(el),rl=ajRole(el);
|
||||
if((rl+' '+nm).toLowerCase().indexOf(q)<0)continue;
|
||||
var st=getComputedStyle(el);if(st.display==='none'||st.visibility==='hidden')continue;
|
||||
var r=el.getBoundingClientRect();if(r.width<1&&r.height<1)continue;
|
||||
var ref=el.getAttribute('data-loki-ref');
|
||||
if(!ref){maxRef++;ref=String(maxRef);el.setAttribute('data-loki-ref',ref);}
|
||||
if(!first)first=el;count++;
|
||||
refs.push('['+ref+'] '+rl+(nm?' "'+nm+'"':''));
|
||||
}
|
||||
if(first)first.scrollIntoView({block:'center'});
|
||||
return {refs:refs};
|
||||
}`
|
||||
|
||||
func (s *cdpSession) find(query string) (string, error) {
|
||||
qb, _ := json.Marshal(query)
|
||||
val, err := s.eval(cdpHelpersJS + "(" + cdpFindJS + ")(" + string(qb) + ")")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var r struct {
|
||||
Refs []string `json:"refs"`
|
||||
}
|
||||
if err := json.Unmarshal(val, &r); err != nil {
|
||||
return "", fmt.Errorf("recherche illisible: %v", err)
|
||||
}
|
||||
if len(r.Refs) == 0 {
|
||||
return fmt.Sprintf("Aucun élément contenant « %s » sur cette page. Essaie un autre mot, ou c'est peut-être ailleurs (autre page/lien).", query), nil
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d élément(s) contenant « %s » (le 1er est amené dans la vue) — clique par numéro :\n", len(r.Refs), query)
|
||||
b.WriteString(strings.Join(r.Refs, "\n"))
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// ─── actions ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// waitLoad attend (au mieux) que la page ait fini de charger après une navigation,
|
||||
// puis que le DOM se STABILISE (contenu rendu en AJAX après readyState=complete).
|
||||
func (s *cdpSession) waitLoad() {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
val, err := s.eval(`document.readyState`)
|
||||
if err == nil && strings.Trim(string(val), `"`) == "complete" {
|
||||
break
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
}
|
||||
s.settle()
|
||||
}
|
||||
|
||||
// settle attend l'INACTIVITÉ RÉSEAU : sur une SPA, readyState passe à « complete »
|
||||
// avant que le contenu chargé en AJAX (résultats de recherche, étape de tunnel…)
|
||||
// ne soit arrivé. On rend la main dès qu'il n'y a plus de requête en vol depuis
|
||||
// ~600 ms, borné à 6 s. Ça supprime le « bash sleep » que le modèle bricolait
|
||||
// avant un browser_snapshot (vécu au test IONOS #10).
|
||||
func (s *cdpSession) settle() {
|
||||
const quiet = 600 * time.Millisecond
|
||||
deadline := time.Now().Add(6 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
s.netMu.Lock()
|
||||
inflight := s.inflight
|
||||
since := time.Since(s.lastNet)
|
||||
s.netMu.Unlock()
|
||||
if inflight <= 0 && since >= quiet {
|
||||
return
|
||||
}
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *cdpSession) navigate(url string) error {
|
||||
// Toute navigation = nouvelle page : on oublie le dernier snapshot pour que le
|
||||
// browser_open qui suit renvoie la page COMPLÈTE, jamais « (page inchangée) »
|
||||
// (piège quand la session persiste d'un test à l'autre, vécu sur Lady Sushi).
|
||||
s.lastSnap = ""
|
||||
// « back » / « forward » : navigation dans l'historique (récupération d'erreur),
|
||||
// pas une URL — sinon on en ferait « https://back ».
|
||||
switch strings.ToLower(strings.TrimSpace(url)) {
|
||||
case "back":
|
||||
_, err := s.eval("history.back();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
case "forward":
|
||||
_, err := s.eval("history.forward();'ok'")
|
||||
s.waitLoad()
|
||||
return err
|
||||
}
|
||||
if !strings.Contains(url, "://") {
|
||||
url = "https://" + url
|
||||
}
|
||||
res, err := s.call("Page.navigate", map[string]any{"url": url})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Page.navigate signale l'échec réseau dans errorText (DNS, connexion refusée…) ;
|
||||
// et une page d'erreur Chrome a une location « chrome-error:// ». Dans les deux
|
||||
// cas on renvoie une erreur EXPLICITE plutôt qu'un faux snapshot « Actualiser »
|
||||
// que le modèle re-tentait en boucle (vécu : account.ionos.com ré-ouvert 2×).
|
||||
var nr struct {
|
||||
ErrorText string `json:"errorText"`
|
||||
}
|
||||
_ = json.Unmarshal(res, &nr)
|
||||
s.waitLoad()
|
||||
loc, _ := s.eval("location.href")
|
||||
// ERR_ABORTED est bénin (redirection/téléchargement) : on ne le traite pas comme
|
||||
// un échec. Le signal fiable est une location « chrome-error:// ».
|
||||
failed := strings.Contains(string(loc), "chrome-error")
|
||||
if nr.ErrorText != "" && nr.ErrorText != "net::ERR_ABORTED" {
|
||||
failed = true
|
||||
}
|
||||
if failed {
|
||||
reason := nr.ErrorText
|
||||
if reason == "" {
|
||||
reason = "page injoignable"
|
||||
}
|
||||
return fmt.Errorf("chargement de %s échoué (%s) — URL/hôte invalide, DNS, ou site bloqué ; corrige l'URL, essaie 'back', ou passe par un lien de la page", url, reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// refCenter renvoie les coordonnées viewport (fraîches) d'un élément marqué, en
|
||||
// le faisant d'abord défiler au centre.
|
||||
func (s *cdpSession) refCenter(ref int) (float64, float64, error) {
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(){var el=ajByRef(%d);if(!el){return null;}el.scrollIntoView({block:'center',inline:'center'});var r=el.getBoundingClientRect();return {x:r.left+r.width/2,y:r.top+r.height/2};})()`, ref)
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if string(val) == "null" || len(val) == 0 {
|
||||
return 0, 0, fmt.Errorf("élément [%d] introuvable — refais un browser_snapshot (la page a peut-être changé)", ref)
|
||||
}
|
||||
var p struct{ X, Y float64 }
|
||||
if err := json.Unmarshal(val, &p); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return p.X, p.Y, nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) clickRef(ref int) error {
|
||||
x, y, err := s.refCenter(ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
// clickXY clique à des coordonnées pixel du VIEWPORT (lues sur le screenshot).
|
||||
// Seul moyen d'atteindre ce que le DOM ne voit pas : un bandeau de consentement
|
||||
// dans un iframe cross-origin, un canvas, une carte (vécu sur Lady Sushi).
|
||||
func (s *cdpSession) clickXY(x, y float64) error {
|
||||
for _, typ := range []string{"mousePressed", "mouseReleased"} {
|
||||
if _, err := s.call("Input.dispatchMouseEvent", map[string]any{
|
||||
"type": typ, "x": x, "y": y, "button": "left", "clickCount": 1,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) typeText(text string) error {
|
||||
_, err := s.call("Input.insertText", map[string]any{"text": text})
|
||||
return err
|
||||
}
|
||||
|
||||
// typeInto écrit dans le champ [ref]. Cas spécial des listes déroulantes NATIVES
|
||||
// (<select>, rôle « combobox ») : leur menu s'ouvre en natif hors du DOM, donc
|
||||
// ses options ne sont pas cliquables dans le snapshot (galère du test IONOS #14).
|
||||
// Ici on SÉLECTIONNE directement l'option dont le texte contient la valeur, et on
|
||||
// émet input/change. Sinon on focus+sélectionne le contenu et on laisse insertText
|
||||
// remplacer (comportement texte normal).
|
||||
func (s *cdpSession) typeInto(ref int, text string) error {
|
||||
vb, _ := json.Marshal(text)
|
||||
expr := cdpHelpersJS + fmt.Sprintf(`(function(ref,val){
|
||||
var el=ajByRef(ref);
|
||||
if(!el)return 'NOTFOUND';
|
||||
el.scrollIntoView({block:'center'});
|
||||
if(el.tagName.toLowerCase()==='select'){
|
||||
var o=el.options,lv=val.toLowerCase(),k=-1;
|
||||
for(var i=0;i<o.length;i++){var tx=(o[i].text||'').toLowerCase();if(tx===lv||tx.indexOf(lv)>=0||(o[i].value||'').toLowerCase()===lv){k=i;break;}}
|
||||
if(k<0)return 'NOOPT';
|
||||
el.selectedIndex=k;el.dispatchEvent(new Event('input',{bubbles:true}));el.dispatchEvent(new Event('change',{bubbles:true}));
|
||||
return 'SELECT';
|
||||
}
|
||||
el.focus();
|
||||
try{if(typeof el.select==='function')el.select();else if(window.getSelection){var r=document.createRange();r.selectNodeContents(el);var s=getSelection();s.removeAllRanges();s.addRange(r);}}catch(e){}
|
||||
return 'TEXT';
|
||||
})(%d,%s)`, ref, string(vb))
|
||||
val, err := s.eval(expr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch strings.Trim(string(val), `"`) {
|
||||
case "NOTFOUND":
|
||||
return fmt.Errorf("champ [%d] introuvable — refais un browser_snapshot", ref)
|
||||
case "NOOPT":
|
||||
return fmt.Errorf("aucune option de la liste [%d] ne correspond à « %s » — fais un browser_screenshot pour voir les choix", ref, text)
|
||||
case "SELECT":
|
||||
return nil // liste déroulante réglée, rien à taper
|
||||
default: // TEXT
|
||||
return s.typeText(text)
|
||||
}
|
||||
}
|
||||
|
||||
// cdpKeys mappe les touches nommées vers leur description CDP.
|
||||
var cdpKeys = map[string]struct {
|
||||
key, code string
|
||||
vk int
|
||||
}{
|
||||
"enter": {"Enter", "Enter", 13},
|
||||
"tab": {"Tab", "Tab", 9},
|
||||
"backspace": {"Backspace", "Backspace", 8},
|
||||
"escape": {"Escape", "Escape", 27},
|
||||
"esc": {"Escape", "Escape", 27},
|
||||
"delete": {"Delete", "Delete", 46},
|
||||
"up": {"ArrowUp", "ArrowUp", 38},
|
||||
"down": {"ArrowDown", "ArrowDown", 40},
|
||||
"left": {"ArrowLeft", "ArrowLeft", 37},
|
||||
"right": {"ArrowRight", "ArrowRight", 39},
|
||||
"home": {"Home", "Home", 36},
|
||||
"end": {"End", "End", 35},
|
||||
"pageup": {"PageUp", "PageUp", 33},
|
||||
"pagedown": {"PageDown", "PageDown", 34},
|
||||
}
|
||||
|
||||
func (s *cdpSession) pressKey(name string) error {
|
||||
k, ok := cdpKeys[strings.ToLower(strings.TrimSpace(name))]
|
||||
if !ok {
|
||||
return fmt.Errorf("touche inconnue « %s » — connues : enter, tab, backspace, escape, delete, up/down/left/right, home, end, pageup, pagedown", name)
|
||||
}
|
||||
for _, typ := range []string{"rawKeyDown", "keyUp"} {
|
||||
if _, err := s.call("Input.dispatchKeyEvent", map[string]any{
|
||||
"type": typ, "key": k.key, "code": k.code,
|
||||
"windowsVirtualKeyCode": k.vk, "nativeVirtualKeyCode": k.vk,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
s.waitLoad()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *cdpSession) scroll(dir string) error {
|
||||
dy, dx := 0, 0
|
||||
switch strings.ToLower(strings.TrimSpace(dir)) {
|
||||
case "down", "":
|
||||
dy = 600
|
||||
case "up":
|
||||
dy = -600
|
||||
case "right":
|
||||
dx = 600
|
||||
case "left":
|
||||
dx = -600
|
||||
default:
|
||||
return fmt.Errorf("direction inconnue « %s » (up, down, left, right)", dir)
|
||||
}
|
||||
_, err := s.eval(fmt.Sprintf("window.scrollBy(%d,%d);'ok'", dx, dy))
|
||||
if err == nil {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// screenshot capture le viewport en PNG (octets décodés).
|
||||
func (s *cdpSession) screenshot() ([]byte, error) {
|
||||
res, err := s.call("Page.captureScreenshot", map[string]any{"format": "png"})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(res, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(r.Data)
|
||||
}
|
||||
@@ -0,0 +1,422 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestCDPSmoke pilote un vrai Chrome de bout en bout : lancement, navigation,
|
||||
// snapshot d'accessibilité, click, screenshot. Coûteux et dépendant d'un
|
||||
// navigateur installé → ne tourne QUE si LOKI_CU_SMOKE=1.
|
||||
func TestCDPSmoke(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if !strings.Contains(strings.ToLower(snap), "example") {
|
||||
t.Errorf("titre 'example' attendu dans le snapshot, absent")
|
||||
}
|
||||
if !strings.Contains(snap, "link") {
|
||||
t.Errorf("le lien 'More information' devrait apparaître comme [n] link")
|
||||
}
|
||||
// browser_find doit retrouver le lien par son texte.
|
||||
found, err := s.find("more")
|
||||
if err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(found), "link") {
|
||||
t.Errorf("browser_find('more') devrait trouver le lien, got: %s", found)
|
||||
}
|
||||
// Le screenshot doit produire des octets PNG non vides.
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
t.Fatalf("screenshot: %v", err)
|
||||
}
|
||||
if len(png) < 100 {
|
||||
t.Errorf("screenshot suspicieusement petit: %d octets", len(png))
|
||||
}
|
||||
t.Logf("screenshot: %d octets", len(png))
|
||||
}
|
||||
|
||||
// TestCDPTypeReplacesField vérifie le fix du test IONOS #4 : browser_type(ref) cible le
|
||||
// BON champ et REMPLACE son contenu, même avec deux champs et une valeur existante.
|
||||
func TestCDPTypeReplacesField(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Deux champs texte injectés dans la page courante ; le 2e contient « ancien ».
|
||||
if _, err := s.eval(`document.body.innerHTML='<input id=a placeholder=prenom><input id=b value=ancien>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Retrouve le numéro du 2e champ (celui à remplacer).
|
||||
val, err := s.eval(`document.querySelector('#b').getAttribute('data-loki-ref')`)
|
||||
if err != nil {
|
||||
t.Fatalf("eval ref: %v", err)
|
||||
}
|
||||
ref, _ := strconv.Atoi(strings.Trim(string(val), `"`))
|
||||
if ref == 0 {
|
||||
t.Fatalf("pas de ref sur #b (snapshot: %s)", val)
|
||||
}
|
||||
if err := s.typeInto(ref, "nouveau"); err != nil {
|
||||
t.Fatalf("typeInto: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#b').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "nouveau" {
|
||||
t.Errorf("champ #b = %q, attendu \"nouveau\" (remplacement raté)", v)
|
||||
}
|
||||
// Le premier champ ne doit PAS avoir été touché.
|
||||
got2, _ := s.eval(`document.querySelector('#a').value`)
|
||||
if v := strings.Trim(string(got2), `"`); v != "" {
|
||||
t.Errorf("champ #a = %q, attendu vide (mauvais ciblage)", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotHygiene vérifie que le snapshot (1) masque la valeur d'un champ
|
||||
// mot de passe et (2) ne liste pas les conteneurs non-actionnables (un <ul>/<li>).
|
||||
func TestCDPSnapshotHygiene(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
inject := `document.body.innerHTML='` +
|
||||
`<label for=pw>Mot de passe</label><input id=pw type=password value=SECRET123>` +
|
||||
`<label for=em>Email</label><input id=em type=text value="a@b.co">` +
|
||||
`<ul><li>bloc conteneur</li></ul>` +
|
||||
`<a href="#x">un lien</a>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
t.Logf("snapshot:\n%s", snap)
|
||||
if strings.Contains(snap, "SECRET123") {
|
||||
t.Error("la valeur du champ mot de passe ne doit PAS apparaître dans le snapshot")
|
||||
}
|
||||
if strings.Contains(snap, "bloc conteneur") {
|
||||
t.Error("un <li> conteneur ne doit pas figurer dans la liste des éléments actionnables")
|
||||
}
|
||||
if !strings.Contains(snap, "un lien") {
|
||||
t.Error("le lien actionnable devrait figurer dans le snapshot")
|
||||
}
|
||||
// Le champ mot de passe lui-même reste listé (par son label), juste sans valeur.
|
||||
if !strings.Contains(strings.ToLower(snap), "mot de passe") {
|
||||
t.Error("le champ mot de passe devrait apparaître par son label")
|
||||
}
|
||||
// Un champ texte non-secret DOIT montrer sa valeur (observabilité du formulaire).
|
||||
if !strings.Contains(snap, "a@b.co") {
|
||||
t.Error("la valeur d'un champ texte non-secret devrait apparaître dans le snapshot")
|
||||
}
|
||||
// Un <input type=text> doit être annoncé « textbox », pas « text » (qui ressemble
|
||||
// à du texte statique et faisait croire au modèle que c'était une étiquette).
|
||||
if !strings.Contains(snap, "textbox") {
|
||||
t.Error("un champ de saisie devrait avoir le rôle 'textbox'")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPRefUniqueness reproduit le bug du test IONOS #9 : un snapshot doit
|
||||
// EFFACER les anciens data-loki-ref, sinon deux éléments partagent un numéro et
|
||||
// browser_type/browser_click visent le mauvais champ (querySelector prend le 1er du DOM).
|
||||
func TestCDPRefUniqueness(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>A</a><a href=#>B</a><a href=#>C</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
// Simule un attribut périmé laissé par un snapshot antérieur sur un AUTRE élément.
|
||||
if _, err := s.eval(`document.querySelectorAll('a')[2].setAttribute('data-loki-ref','1');'ok'`); err != nil {
|
||||
t.Fatalf("stale: %v", err)
|
||||
}
|
||||
// Un nouveau snapshot doit repartir propre : chaque numéro sur UN SEUL élément.
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot2: %v", err)
|
||||
}
|
||||
dup, _ := s.eval(`document.querySelectorAll('[data-loki-ref="1"]').length`)
|
||||
if strings.TrimSpace(string(dup)) != "1" {
|
||||
t.Errorf("data-loki-ref=\"1\" devrait être unique, trouvé %s occurrence(s)", dup)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSettleWaitsAsync vérifie que settle() attend le contenu injecté en
|
||||
// asynchrone (cas du test #10 où le modèle devait « bash sleep » avant snapshot).
|
||||
func TestCDPSettleWaitsAsync(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Page réelle (réseau réel), puis on déclenche un fetch AJAX qui n'ajoute son
|
||||
// lien qu'une fois la requête terminée. settle() doit attendre l'inactivité
|
||||
// réseau et donc voir le lien.
|
||||
if err := s.navigate("https://example.com"); err != nil {
|
||||
t.Fatalf("navigate: %v", err)
|
||||
}
|
||||
if _, err := s.eval(`fetch(location.href,{cache:'no-store'}).then(function(){var a=document.createElement('a');a.href='#';a.textContent='AjaxDone';document.body.appendChild(a);});'ok'`); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond) // laisse requestWillBeSent s'enregistrer
|
||||
s.settle() // doit attendre la fin du fetch
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "AjaxDone") {
|
||||
t.Errorf("settle() aurait dû attendre la fin du fetch AJAX; snapshot:\n%s", snap)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPTypeSelectsOption vérifie le fix du test #14 : browser_type sur un <select>
|
||||
// choisit l'option par son texte au lieu de taper des caractères.
|
||||
func TestCDPTypeSelectsOption(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<select id=civ><option value="">--</option><option>Madame</option><option>Monsieur</option><option>Autre</option></select>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil { // pose les data-loki-ref
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
ref, _ := s.eval(`+document.querySelector('#civ').getAttribute('data-loki-ref')`)
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(string(ref)))
|
||||
if n == 0 {
|
||||
t.Fatalf("pas de ref sur le select (%s)", ref)
|
||||
}
|
||||
if err := s.typeInto(n, "Monsieur"); err != nil {
|
||||
t.Fatalf("typeInto select: %v", err)
|
||||
}
|
||||
got, _ := s.eval(`document.querySelector('#civ').value`)
|
||||
if v := strings.Trim(string(got), `"`); v != "Monsieur" {
|
||||
t.Errorf("le select devrait valoir « Monsieur », got %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPSnapshotDedup vérifie qu'un état identique renvoie une ligne courte au
|
||||
// lieu de re-lister tous les éléments (économie de contexte, test #12).
|
||||
func TestCDPSnapshotDedup(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Un lien</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
first, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup1: %v", err)
|
||||
}
|
||||
if !strings.Contains(first, "Un lien") {
|
||||
t.Fatalf("1er snapshot devrait lister le lien, got: %s", first)
|
||||
}
|
||||
second, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshotDedup2: %v", err)
|
||||
}
|
||||
if strings.Contains(second, "Un lien") || !strings.Contains(second, "inchang") {
|
||||
t.Errorf("2e snapshot identique devrait être compact (inchangé), got: %s", second)
|
||||
}
|
||||
// Après un vrai changement, on redonne la liste complète.
|
||||
if _, err := s.eval(`document.body.innerHTML+='<a href=#>Autre</a>';'ok'`); err != nil {
|
||||
t.Fatalf("change: %v", err)
|
||||
}
|
||||
third, _ := s.snapshotDedup()
|
||||
if !strings.Contains(third, "Autre") {
|
||||
t.Errorf("après changement, le snapshot complet devrait revenir, got: %s", third)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPFindKeepsRefs vérifie le fix du test #11 : browser_find ne doit PAS effacer
|
||||
// les data-loki-ref du dernier snapshot (sinon un browser_click ensuite échoue).
|
||||
func TestCDPFindKeepsRefs(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
if _, err := s.eval(`document.body.innerHTML='<a href=#>Alpha</a><a href=#>Beta</a><a href=#>Connexion</a>';'ok'`); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if _, err := s.snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
before, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
// Recherche d'un AUTRE élément : ne doit pas toucher au ref de « Connexion ».
|
||||
if _, err := s.find("Alpha"); err != nil {
|
||||
t.Fatalf("find: %v", err)
|
||||
}
|
||||
after, _ := s.eval(`document.querySelectorAll('a')[2].getAttribute('data-loki-ref')`)
|
||||
if string(before) != string(after) || strings.Trim(string(after), `"`) == "" {
|
||||
t.Errorf("browser_find a modifié/effacé le ref de « Connexion » : avant=%s après=%s", before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPClickXY vérifie le clic par coordonnées (pour iframe cross-origin/canvas).
|
||||
func TestCDPClickXY(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un bouton à une position connue qui note son propre clic.
|
||||
inject := `document.body.innerHTML='<button id=b style="position:fixed;left:40px;top:30px;width:120px;height:40px">X</button>';` +
|
||||
`window.__hit=false;document.getElementById('b').addEventListener('click',function(){window.__hit=true;});'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
if err := s.clickXY(100, 50); err != nil { // dans le rectangle du bouton
|
||||
t.Fatalf("clickXY: %v", err)
|
||||
}
|
||||
hit, _ := s.eval(`window.__hit`)
|
||||
if strings.TrimSpace(string(hit)) != "true" {
|
||||
t.Errorf("le clic par coordonnées aurait dû atteindre le bouton, got %s", hit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPShadowDOM vérifie le fix du test Lady Sushi : les éléments d'un shadow
|
||||
// DOM (bandeaux cookies, web components) sont vus par le snapshot ET cliquables.
|
||||
func TestCDPShadowDOM(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
|
||||
// Un host avec un bouton « TOUT REFUSER » DANS un shadow root ouvert.
|
||||
inject := `document.body.innerHTML='<div id=host></div>';` +
|
||||
`var sr=document.getElementById('host').attachShadow({mode:'open'});` +
|
||||
`sr.innerHTML='<button id=b>TOUT REFUSER</button>';'ok'`
|
||||
if _, err := s.eval(inject); err != nil {
|
||||
t.Fatalf("inject: %v", err)
|
||||
}
|
||||
snap, err := s.snapshot()
|
||||
if err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if !strings.Contains(snap, "TOUT REFUSER") {
|
||||
t.Fatalf("le bouton du shadow DOM devrait apparaître dans le snapshot; got:\n%s", snap)
|
||||
}
|
||||
// browser_find doit aussi le trouver, et browser_click le viser (via ajByRef).
|
||||
found, err := s.find("refuser")
|
||||
if err != nil || !strings.Contains(strings.ToLower(found), "refuser") {
|
||||
t.Errorf("find n'a pas trouvé le bouton shadow: %v / %s", err, found)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCDPNavigateError vérifie qu'une URL injoignable renvoie une ERREUR claire
|
||||
// (et pas un faux snapshot avec un bouton « Actualiser » que le modèle re-tente).
|
||||
func TestCDPNavigateError(t *testing.T) {
|
||||
if os.Getenv("LOKI_CU_SMOKE") != "1" {
|
||||
t.Skip("smoke test désactivé (LOKI_CU_SMOKE=1 pour l'activer)")
|
||||
}
|
||||
if chromePath() == "" {
|
||||
t.Skip("aucun navigateur détecté")
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
t.Fatalf("cdpGet: %v", err)
|
||||
}
|
||||
defer cdpShutdown()
|
||||
err = s.navigate("https://nonexistent-host-loki-test-99999.invalid")
|
||||
if err == nil {
|
||||
t.Error("une URL injoignable devrait renvoyer une erreur, pas nil")
|
||||
} else {
|
||||
t.Logf("erreur attendue: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestDedupableTool verrouille le fix du test IONOS : les outils à état vivant /
|
||||
// à image (cu_*, see_image, bash) ne doivent JAMAIS être dédupliqués, sinon un
|
||||
// second appel identique renvoie « [déjà fait] » sans rejouer l'action ni
|
||||
// l'image, et l'IA boucle.
|
||||
func TestDedupableTool(t *testing.T) {
|
||||
never := []string{"bash", "see_image", "browser_open", "browser_snapshot", "browser_click", "browser_type", "browser_key", "browser_scroll", "browser_screenshot"}
|
||||
for _, n := range never {
|
||||
if dedupableTool(n) {
|
||||
t.Errorf("%s ne doit PAS être déduplicable", n)
|
||||
}
|
||||
}
|
||||
always := []string{"write", "edit", "mem_add", "web_open"}
|
||||
for _, n := range always {
|
||||
if !dedupableTool(n) {
|
||||
t.Errorf("%s devrait rester déduplicable", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package loki
|
||||
|
||||
// computer_use.go — le « computer use » : donner à Jean la capacité d'agir sur
|
||||
// une interface graphique de la machine hôte. Étape 1 : le WEB, via un Chromium
|
||||
// piloté en CDP (computer_cdp.go). Le modèle lit l'arbre d'accessibilité (des
|
||||
// éléments interactifs NUMÉROTÉS, façon set-of-marks) et agit par numéro —
|
||||
// aucune vision requise, ce qui marche même avec de petits modèles texte à
|
||||
// faible contexte. browser_screenshot n'est proposé que si la vision est active, pour
|
||||
// les cas sans a11y (canvas, jeux).
|
||||
//
|
||||
// C'est un axe de capacité indépendant, sous le mode agent (comme l'accès
|
||||
// internet) : un outil qui pilote un navigateur exécute des actions réelles, donc
|
||||
// même niveau de confiance que bash. Interrupteur : clé bkState "computer".
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func computerUseEnabled() bool { return getBool(bkState, "computer") }
|
||||
|
||||
func setComputerUseEnabled(on bool) error { return putBool(bkState, "computer", on) }
|
||||
|
||||
// cuMaxOutput borne ce qu'un appel d'outil computer use injecte dans le contexte
|
||||
// (aligné sur le web et le shell : 8000).
|
||||
const cuMaxOutput = 8000
|
||||
|
||||
func capCUOutput(s string) string {
|
||||
if r := []rune(s); len(r) > cuMaxOutput {
|
||||
return string(r[:cuMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ─── schémas d'outils (annoncés au modèle) ───────────────────────────────────
|
||||
|
||||
func cuOpenTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_open",
|
||||
Description: "Open a URL in the controlled browser and return the page's interactive elements, each numbered [n]. Start here. Pass 'back' or 'forward' instead of a URL to move through history.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"url": map[string]any{"type": "string", "description": "URL to open, or 'back' / 'forward'"}},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuSnapshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_snapshot",
|
||||
Description: "Re-read the current page's interactive elements (numbered). Use it after the page changes to get fresh [n] refs.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click",
|
||||
Description: "Click the element with the given number from the latest snapshot. Returns the page state afterwards.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"ref": map[string]any{"type": "integer", "description": "Element number [n]"}},
|
||||
"required": []string{"ref"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuTypeTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_type",
|
||||
Description: "Type text into a field. ALWAYS give 'ref' (the field's number) so it targets the right field and REPLACES its current content — no need to browser_click the field first. For a dropdown/select (role 'combobox'), pass the option's text and it picks that option directly (don't click it open). Without a ref it types into whatever is focused, which is unreliable.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"text": map[string]any{"type": "string", "description": "Text to type"},
|
||||
"ref": map[string]any{"type": "integer", "description": "Optional: field number [n] to focus first"},
|
||||
},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuKeyTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_key",
|
||||
Description: "Press a special key: enter, tab, backspace, escape, delete, up, down, left, right, home, end, pageup, pagedown.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"key": map[string]any{"type": "string", "description": "Key name"}},
|
||||
"required": []string{"key"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuFindTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_find",
|
||||
Description: "Find interactive elements ANYWHERE on the page (not just the visible part) whose label contains the text, and scroll the first into view. Use it to locate an off-screen link or button (e.g. 'créer un compte', 'panier') instead of scrolling blindly or guessing URLs.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"text": map[string]any{"type": "string", "description": "Text to look for in element labels"}},
|
||||
"required": []string{"text"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScrollTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_scroll",
|
||||
Description: "Scroll the page (up, down, left, right), then return the now-visible interactive elements.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"direction": map[string]any{"type": "string", "description": "up, down, left or right"}},
|
||||
"required": []string{"direction"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuClickXYTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_click_xy",
|
||||
Description: fmt.Sprintf("Click at pixel coordinates (x from the left, y from the top) read off the latest browser_screenshot — %dx%d pixels, 1:1 with the page, with a coordinate grid drawn every 100px to read x/y precisely. Use it ONLY for something the numbered elements can't reach: a cookie/consent banner inside a cross-origin iframe, a canvas, a map. Otherwise always prefer browser_click(ref).", cuViewW, cuViewH),
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"x": map[string]any{"type": "number", "description": "pixels from the left edge"},
|
||||
"y": map[string]any{"type": "number", "description": "pixels from the top edge"},
|
||||
},
|
||||
"required": []string{"x", "y"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func cuScreenshotTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
Name: "browser_screenshot",
|
||||
Description: "Capture the current page as an image. It is shown to the user immediately AND saved to your working folder; the result gives the filename to link with Markdown if they want the file. Use it to see a visual layout the numbered elements don't convey, or when the user asks for a screenshot — never hunt the disk for it.",
|
||||
Parameters: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
}}
|
||||
}
|
||||
|
||||
// computerUseTools est la liste annoncée au modèle quand le computer use est
|
||||
// actif. browser_screenshot n'y figure que si la vision est réellement active.
|
||||
func computerUseTools() []Tool {
|
||||
tools := []Tool{cuOpenTool(), cuSnapshotTool(), cuFindTool(), cuClickTool(), cuTypeTool(), cuKeyTool(), cuScrollTool()}
|
||||
// Vision active : la capture d'écran ET le clic par coordonnées (qui n'a de sens
|
||||
// que si le modèle peut lire l'image pour viser).
|
||||
if visionEnabled() {
|
||||
tools = append(tools, cuScreenshotTool(), cuClickXYTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// cuPromptLine est la consigne d'usage ajoutée au prompt système quand le
|
||||
// computer use est actif (l'ordre d'appel que les schémas isolés ne disent pas).
|
||||
func cuPromptLine() string {
|
||||
line := "\nComputer use (a browser you control on this machine): browser_open a URL, then act on the NUMBERED elements — browser_click(ref), browser_type(text, ref), browser_key, browser_scroll. Each action returns the fresh numbered elements; after the page changes, trust the new numbers, not old ones. The list shows only what's in view: to reach an off-screen link or button (e.g. 'créer un compte'), use browser_find(text) to jump straight to it rather than scrolling blindly or, worse, guessing URLs — inventing paths mostly 404s. If a page shows an error (404, 'introuvable'), browser_open goes 'back' or try another link. Prefer the numbered elements over screenshots."
|
||||
if visionEnabled() {
|
||||
line += " Use browser_screenshot when the numbers aren't enough (canvas, visual layout). If a button is visible but NOT in the numbered list (e.g. a cookie banner inside an iframe), browser_screenshot then browser_click_xy(x,y) at its pixel position."
|
||||
}
|
||||
line += "\n"
|
||||
return line
|
||||
}
|
||||
|
||||
// ─── exécution (appelée par le dispatch de llm_client.go) ────────────────────
|
||||
|
||||
func toolCUOpen(args map[string]any) string {
|
||||
url, _ := args["url"].(string)
|
||||
if strings.TrimSpace(url) == "" {
|
||||
return "[erreur] url manquante"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.navigate(url); err != nil {
|
||||
return "[erreur] navigation : " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUSnapshot() string {
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, err := s.snapshotDedup()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
func toolCUFind(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "[erreur] texte de recherche manquant"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
res, err := s.find(text)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func toolCUClick(args map[string]any) string {
|
||||
ref, ok := intArg(args, "ref")
|
||||
if !ok {
|
||||
return "[erreur] ref manquant (numéro de l'élément)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickRef(ref); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] cliqué [%d]\n\n%s", ref, snap)
|
||||
}
|
||||
|
||||
func toolCUType(args map[string]any) string {
|
||||
text, _ := args["text"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if ref, ok := intArg(args, "ref"); ok {
|
||||
if err := s.typeInto(ref, text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
} else if err := s.typeText(text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] saisi « %s »\n\n%s", text, snap)
|
||||
}
|
||||
|
||||
func toolCUKey(args map[string]any) string {
|
||||
key, _ := args["key"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.pressKey(key); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] touche %s\n\n%s", key, snap)
|
||||
}
|
||||
|
||||
func toolCUClickXY(args map[string]any) string {
|
||||
x, okx := floatArg(args, "x")
|
||||
y, oky := floatArg(args, "y")
|
||||
if !okx || !oky {
|
||||
return "[erreur] coordonnées x et y requises (en pixels du screenshot)"
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.clickXY(x, y); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return fmt.Sprintf("[ok] clic en (%.0f, %.0f)\n\n%s", x, y, snap)
|
||||
}
|
||||
|
||||
func toolCUScroll(args map[string]any) string {
|
||||
dir, _ := args["direction"].(string)
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if err := s.scroll(dir); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
snap, _ := s.snapshotDedup()
|
||||
return snap
|
||||
}
|
||||
|
||||
// toolCUScreenshot renvoie (accusé texte, partie image_url) comme see_image :
|
||||
// l'image est réinjectée dans un message utilisateur multimodal par l'appelant.
|
||||
//
|
||||
// La capture PLEINE RÉSOLUTION est aussi ENREGISTRÉE dans le dossier de travail :
|
||||
// sans ça, le modèle qui reçoit « envoie-moi une capture » partait chercher un
|
||||
// PNG inexistant sur tout le disque (vécu sur le test IONOS, ~12 bash find pour
|
||||
// rien). Avec le chemin en retour, il n'a plus qu'à le donner en lien Markdown.
|
||||
func toolCUScreenshot() (string, map[string]any) {
|
||||
if !visionEnabled() {
|
||||
return "[erreur] la vision n'est pas active sur ce modèle — impossible de capturer l'écran", nil
|
||||
}
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
png, err := s.screenshot()
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error(), nil
|
||||
}
|
||||
// Enregistre le PNG plein format dans le workspace (chemin RELATIF renvoyé au
|
||||
// modèle : un lien Markdown [capture](nom.png) est servi par /api/chat/file).
|
||||
saved := ""
|
||||
name := "capture-" + time.Now().Format("20060102-150405") + ".png"
|
||||
if ws := agentWorkspace(); ws != "" {
|
||||
if err := os.WriteFile(filepath.Join(ws, name), png, 0o644); err == nil {
|
||||
saved = name
|
||||
}
|
||||
}
|
||||
// L'image du MODÈLE porte une GRILLE de coordonnées (lignes graduées tous les
|
||||
// 100 px) pour qu'il lise x/y avec précision ; celle sauvegardée pour l'utilisateur
|
||||
// (plus haut, `png`) reste PROPRE, sans grille.
|
||||
b, mime := prepareImageForModel(overlayGrid(png), "image/png")
|
||||
// ⚠️ TOI (le modèle) vois l'image ci-dessous, mais l'utilisateur NE la voit PAS
|
||||
// automatiquement dans le chat. Pour la lui afficher, il faut l'insérer en IMAGE
|
||||
// Markdown  — un simple lien [texte](fichier) ne fait qu'un bouton
|
||||
// de téléchargement, pas d'aperçu.
|
||||
msg := fmt.Sprintf("[ok] capture prise (tu la vois ci-dessous, %dx%d px, 1:1 avec la page). Une GRILLE de repères y est surimprimée (lignes + chiffres tous les 100 px) POUR TOI seulement, afin de viser précisément — le fichier enregistré, lui, est PROPRE (sans grille), donc l'aperçu ![capture] montré à l'utilisateur est net. Si un élément visible n'est PAS dans la liste numérotée (bandeau cookies en iframe, canvas), clique-le du premier coup avec browser_click_xy(x,y), coordonnées lues sur la grille.", cuViewW, cuViewH)
|
||||
if saved != "" {
|
||||
msg += " Enregistrée sous « " + saved + " ». Pour l'AFFICHER à l'utilisateur dans le chat, insère-la en IMAGE Markdown : . Un simple lien [capture](" + saved + ") ne donne qu'un bouton de téléchargement, sans aperçu. Ne cherche pas de fichier sur le disque, c'est celui-ci."
|
||||
}
|
||||
return msg, imageURLPart(b, mime)
|
||||
}
|
||||
|
||||
// intArg extrait un entier d'un argument d'outil (le JSON décode les nombres en
|
||||
// float64).
|
||||
func intArg(args map[string]any, key string) (int, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return int(v), true
|
||||
case int:
|
||||
return v, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// floatArg extrait un nombre d'un argument d'outil (coordonnées de clic).
|
||||
func floatArg(args map[string]any, key string) (float64, bool) {
|
||||
switch v := args[key].(type) {
|
||||
case float64:
|
||||
return v, true
|
||||
case int:
|
||||
return float64(v), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ─── CLI : loki computer [on|off|status] ────────────────────────────────────
|
||||
|
||||
func cmdComputer(args []string) error {
|
||||
sub := ""
|
||||
if len(args) > 0 {
|
||||
sub = args[0]
|
||||
}
|
||||
switch sub {
|
||||
case "on":
|
||||
if err := setComputerUseEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
msg := " contrôle du navigateur activé — l'IA pilote un navigateur (browser_open/browser_click/browser_type/…) si le mode agent est actif"
|
||||
if chromePath() == "" {
|
||||
msg += "\n" + red("[!]") + " aucun Chrome/Chromium/Edge détecté — installe-le ou définis LOKI_CHROME=<chemin>"
|
||||
}
|
||||
fmt.Println(green("[ok]") + msg)
|
||||
case "off":
|
||||
if err := setComputerUseEnabled(false); err != nil {
|
||||
return err
|
||||
}
|
||||
cdpShutdown()
|
||||
fmt.Println(green("[ok]") + " contrôle du navigateur désactivé")
|
||||
case "", "status":
|
||||
state := dim("off")
|
||||
if computerUseEnabled() {
|
||||
state = green("on")
|
||||
}
|
||||
fmt.Printf("%s état: %s\n", cyan("Contrôle du navigateur"), state)
|
||||
bin := chromePath()
|
||||
if bin == "" {
|
||||
fmt.Printf(" navigateur : %s — installe Chrome/Chromium/Edge ou LOKI_CHROME=<chemin>\n", red("introuvable"))
|
||||
} else {
|
||||
fmt.Printf(" navigateur : %s\n", bold(bin))
|
||||
}
|
||||
fmt.Printf(" outils : browser_open, browser_snapshot, browser_find, browser_click, browser_type, browser_key, browser_scroll")
|
||||
if visionEnabled() {
|
||||
fmt.Printf(", browser_screenshot, browser_click_xy")
|
||||
}
|
||||
fmt.Println()
|
||||
default:
|
||||
return fmt.Errorf("usage: loki computer [on|off|status]")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,45 +1,41 @@
|
||||
// Réglages de la dictée vocale — modèle, langue, matériel, réactivité.
|
||||
// Réglages de la dictée vocale — modèle et réactivité.
|
||||
//
|
||||
// Ils vivent dans le magasin clé/valeur (bkState), comme le jeton Hugging
|
||||
// Face : ce sont des réglages de SERVEUR, pas de navigateur. Le modèle chargé
|
||||
// et le GPU occupé sont des propriétés de la machine, identiques pour tous les
|
||||
// onglets ouverts.
|
||||
// est une propriété de la machine, identique 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.
|
||||
// DictateCfg : ce qui reste réglable depuis le passage à Parakeet.
|
||||
//
|
||||
// Deux champs ont disparu, et c'est le moteur qui l'impose, pas un choix de
|
||||
// simplification :
|
||||
// - la LANGUE : Parakeet v3 détecte la langue lui-même et n'a aucun drapeau
|
||||
// pour la forcer. Le réglage n'aurait servi qu'à mentir.
|
||||
// - le GPU : le binaire sherpa-onnx livré dans l'image est le build statique
|
||||
// CPU. Annoncer un sélecteur de carte sans pouvoir l'honorer serait pire que
|
||||
// de ne rien annoncer. Un modèle 0,6 B en int8 sur des tranches de quelques
|
||||
// secondes n'a pas besoin de la carte, qui reste au moteur de chat.
|
||||
type DictateCfg struct {
|
||||
Model string `json:"model"`
|
||||
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.
|
||||
// déjà enregistrés sans champ correspondant, et doit pouvoir le combler. C'est
|
||||
// aussi ce qui rattrape les réglages enregistrés du temps de whisper, dont le
|
||||
// Model ne veut plus rien dire.
|
||||
func (c DictateCfg) withDefauts() DictateCfg {
|
||||
if 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 _, connu := asrCatalogue[c.Model]; !connu {
|
||||
// v3 : le seul des deux qui parle français.
|
||||
c.Model = "parakeet-tdt-0.6b-v3"
|
||||
}
|
||||
if c.Reactivity == "" {
|
||||
c.Reactivity = "moyen"
|
||||
@@ -54,26 +50,14 @@ func dictateCfgLoad() DictateCfg {
|
||||
}
|
||||
|
||||
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 {
|
||||
// loin du geste qui l'a causé. On valide AVANT withDefauts, qui corrigerait
|
||||
// justement la faute qu'on veut signaler.
|
||||
if _, ok := asrCatalogue[c.Model]; !ok {
|
||||
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return putJSON(bkState, dictateCfgKey, c)
|
||||
}
|
||||
|
||||
// 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)
|
||||
return putJSON(bkState, dictateCfgKey, c.withDefauts())
|
||||
}
|
||||
|
||||
// chunkBounds : réserve minimale avant de couper, et coupure forcée quand
|
||||
|
||||
@@ -2,86 +2,77 @@ package loki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDictateCfgDefauts(t *testing.T) {
|
||||
// Les défauts sont appliqués à la LECTURE : un réglage enregistré par une
|
||||
// version antérieure, sans le champ, doit être comblé plutôt que de produire un
|
||||
// modèle vide passé en argument au serveur.
|
||||
func TestDefautsCombles(t *testing.T) {
|
||||
testHome(t)
|
||||
c := DictateCfg{}.withDefauts()
|
||||
if c.Model != "large-v3-turbo-q5_0" {
|
||||
t.Errorf("modèle par défaut = %q, attendu large-v3-turbo-q5_0", c.Model)
|
||||
if _, ok := asrCatalogue[c.Model]; !ok {
|
||||
t.Errorf("modèle par défaut %q hors catalogue", 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)
|
||||
if c.Reactivity == "" {
|
||||
t.Error("réactivité par défaut vide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCudaVisibleDevices(t *testing.T) {
|
||||
cas := []struct{ device, want string }{
|
||||
{"cpu", ""},
|
||||
{"0", "0"},
|
||||
{"1", "1"},
|
||||
{"", ""},
|
||||
{"bidon", ""},
|
||||
{"-1", ""},
|
||||
// Le passage de whisper à Parakeet laisse des réglages enregistrés dont le
|
||||
// Model ne veut plus rien dire (« large-v3-turbo-q5_0 »). Ils doivent retomber
|
||||
// sur le défaut, sans quoi la dictée serait morte après mise à jour, avec pour
|
||||
// seul indice « modèle de dictée inconnu » au premier clic sur le micro.
|
||||
func TestModeleWhisperHeriteRetombeSurLeDefaut(t *testing.T) {
|
||||
testHome(t)
|
||||
c := DictateCfg{Model: "large-v3-turbo-q5_0", Reactivity: "court"}.withDefauts()
|
||||
if _, ok := asrCatalogue[c.Model]; !ok {
|
||||
t.Fatalf("modèle hérité %q non corrigé", c.Model)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if c.Reactivity != "court" {
|
||||
t.Errorf("réactivité = %q, elle devait être conservée", c.Reactivity)
|
||||
}
|
||||
}
|
||||
|
||||
// Valider à l'ÉCRITURE : un modèle hors catalogue accepté en silence ne se
|
||||
// manifesterait qu'au premier clic sur le micro, loin du geste qui l'a causé.
|
||||
func TestSaveRefuseModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "nawak", Reactivity: "moyen"}); err == nil {
|
||||
t.Error("un modèle hors catalogue a été accepté")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
want := DictateCfg{Model: "parakeet-tdt-0.6b-v2", Reactivity: "long"}
|
||||
if err := dictateCfgSave(want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := dictateCfgLoad(); got != want {
|
||||
t.Errorf("relu %+v, attendu %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Les bornes de découpage pilotent la latence perçue : plus la réserve est
|
||||
// courte, plus le texte arrive vite, au prix d'un contexte plus maigre pour le
|
||||
// modèle. L'ordre entre les trois réglages est ce qui doit tenir.
|
||||
func TestChunkBounds(t *testing.T) {
|
||||
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},
|
||||
court, _ := DictateCfg{Reactivity: "court"}.chunkBounds()
|
||||
moyen, _ := DictateCfg{Reactivity: "moyen"}.chunkBounds()
|
||||
long, longMax := DictateCfg{Reactivity: "long"}.chunkBounds()
|
||||
if !(court < moyen && moyen < long) {
|
||||
t.Errorf("réserves non croissantes : court=%v moyen=%v long=%v", court, moyen, long)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if longMax <= long {
|
||||
t.Error("la coupure forcée doit être plus grande que la réserve minimale")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
// Une valeur inconnue doit retomber sur le réglage moyen, pas sur zéro : une
|
||||
// réserve nulle couperait à chaque échantillon.
|
||||
inconnu, _ := DictateCfg{Reactivity: "nawak"}.chunkBounds()
|
||||
if inconnu != moyen {
|
||||
t.Errorf("réactivité inconnue = %v, attendu le réglage moyen %v", inconnu, moyen)
|
||||
}
|
||||
if 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)
|
||||
if inconnu <= 0 {
|
||||
t.Error("réserve nulle : la dictée couperait à chaque échantillon")
|
||||
}
|
||||
}
|
||||
+182
-34
@@ -1,70 +1,120 @@
|
||||
// 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.
|
||||
// Catalogue des modèles de dictée — Parakeet (NVIDIA), servi par sherpa-onnx.
|
||||
//
|
||||
// Aucun n'est embarqué dans l'image : ils pèsent près de 500 Mo compressés et
|
||||
// vivent dans <LOKI_HOME>/asr/, donc dans le volume /data — ils survivent aux
|
||||
// recréations du conteneur.
|
||||
//
|
||||
// Différence de forme avec whisper.cpp, qu'ils remplacent : un modèle n'est plus
|
||||
// UN fichier .bin mais un DOSSIER (encodeur, décodeur, joiner, table de jetons),
|
||||
// livré en .tar.bz2. Le téléchargement décompresse donc au passage — d'où
|
||||
// asrExtract plutôt qu'une simple copie de flux.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/bzip2"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Les modèles ggml officiels de whisper.cpp.
|
||||
const whisperModelBase = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
|
||||
// Les archives sherpa-onnx officielles (conversions ONNX des modèles NeMo).
|
||||
const asrModelBase = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
|
||||
|
||||
type whisperModel struct {
|
||||
type asrModel 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
|
||||
Dossier string // nom du dossier, identique en local et dans l'archive
|
||||
Octets int64 // taille du TÉLÉCHARGEMENT (archive compressée), pour prévenir
|
||||
Langues string // ce que le modèle sait transcrire — c'est LE critère de choix
|
||||
}
|
||||
|
||||
// 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},
|
||||
// asrFichiers : les quatre fichiers qu'un modèle doit avoir pour être utilisable.
|
||||
// Sert au contrôle de présence : un dossier à moitié extrait (téléchargement
|
||||
// interrompu) ne doit pas passer pour un modèle installé.
|
||||
var asrFichiers = []string{"encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"}
|
||||
|
||||
// Les tailles sont celles publiées par le dépôt amont. Elles servent à informer
|
||||
// avant un téléchargement, pas à vérifier le fichier : une taille qui dérive
|
||||
// d'une release à l'autre ne doit pas casser la dictée.
|
||||
var asrCatalogue = map[string]asrModel{
|
||||
"parakeet-tdt-0.6b-v3": {
|
||||
Nom: "Parakeet v3 — recommandé",
|
||||
Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8",
|
||||
Octets: 487_170_055,
|
||||
Langues: "25 langues européennes, français inclus — détection automatique",
|
||||
},
|
||||
"parakeet-tdt-0.6b-v2": {
|
||||
Nom: "Parakeet v2 — anglais seul",
|
||||
Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8",
|
||||
Octets: 482_468_385,
|
||||
Langues: "anglais uniquement",
|
||||
},
|
||||
}
|
||||
|
||||
// whisperModelPathFor : chemin local. Vide si l'identifiant est inconnu —
|
||||
// asrRoot : la racine des modèles de dictée. Volontairement distincte de
|
||||
// l'ancien dossier `whisper/` : les .bin ggml qui y dorment ne servent plus à
|
||||
// rien, mais les effacer tout seuls serait détruire des données que
|
||||
// l'utilisateur n'a pas demandé de perdre. Ils sont à supprimer à la main.
|
||||
func asrRoot() string { return filepath.Join(LokiHome(), "asr") }
|
||||
|
||||
// asrModelDirFor : dossier local d'un modèle. Vide si l'identifiant est inconnu —
|
||||
// l'appelant doit traiter ce cas plutôt que de bâtir un chemin sur une chaîne
|
||||
// arbitraire venue d'une requête HTTP.
|
||||
func whisperModelPathFor(id string) string {
|
||||
m, ok := whisperCatalogue[id]
|
||||
func asrModelDirFor(id string) string {
|
||||
m, ok := asrCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(LokiHome(), "whisper", m.Fichier)
|
||||
return filepath.Join(asrRoot(), m.Dossier)
|
||||
}
|
||||
|
||||
func whisperModelURLFor(id string) string {
|
||||
m, ok := whisperCatalogue[id]
|
||||
func asrModelURLFor(id string) string {
|
||||
m, ok := asrCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return whisperModelBase + m.Fichier
|
||||
return asrModelBase + m.Dossier + ".tar.bz2"
|
||||
}
|
||||
|
||||
func whisperModelPresent(id string) bool {
|
||||
p := whisperModelPathFor(id)
|
||||
if p == "" {
|
||||
// asrModelPresent : le modèle est installé ET complet. On vérifie les quatre
|
||||
// fichiers plutôt que l'existence du dossier : une extraction coupée en deux
|
||||
// laisse un dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur
|
||||
// tomberait au premier clic sur le micro plutôt qu'ici.
|
||||
func asrModelPresent(id string) bool {
|
||||
dir := asrModelDirFor(id)
|
||||
if dir == "" {
|
||||
return false
|
||||
}
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && st.Size() > 0
|
||||
for _, f := range asrFichiers {
|
||||
st, err := os.Stat(filepath.Join(dir, f))
|
||||
if err != nil || st.Size() == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// whisperCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
|
||||
// asrModelFile : chemin d'un des fichiers du modèle, pour les arguments passés
|
||||
// au serveur. Vide si le modèle est inconnu.
|
||||
func asrModelFile(id, fichier string) string {
|
||||
dir := asrModelDirFor(id)
|
||||
if dir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(dir, fichier)
|
||||
}
|
||||
|
||||
// asrCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
|
||||
// l'ordre dans lequel se pose la question du compromis.
|
||||
func whisperCatalogueTrie() []map[string]any {
|
||||
out := make([]map[string]any, 0, len(whisperCatalogue))
|
||||
for id, m := range whisperCatalogue {
|
||||
func asrCatalogueTrie() []map[string]any {
|
||||
out := make([]map[string]any, 0, len(asrCatalogue))
|
||||
for id, m := range asrCatalogue {
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "nom": m.Nom, "octets": m.Octets,
|
||||
"vram_mo": m.VRAMMo, "present": whisperModelPresent(id),
|
||||
"langues": m.Langues, "present": asrModelPresent(id),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
@@ -72,3 +122,101 @@ func whisperCatalogueTrie() []map[string]any {
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// asrExtract décompresse une archive .tar.bz2 sous `dest`, en ne gardant que le
|
||||
// contenu du dossier de premier niveau (l'archive amont l'embarque déjà).
|
||||
//
|
||||
// Deux précautions qui ne sont pas du zèle :
|
||||
// - un chemin d'entrée qui remonte (« ../ ») ou absolu est REFUSÉ : une archive
|
||||
// téléchargée est une entrée non fiable, et une entrée piégée écrirait
|
||||
// n'importe où sur le disque du serveur ;
|
||||
// - on extrait dans un dossier temporaire renommé à la fin, pour qu'une
|
||||
// extraction interrompue ne laisse jamais un modèle à moitié installé sous
|
||||
// son nom définitif.
|
||||
func asrExtract(r io.Reader, dest string) error {
|
||||
tmp := dest + ".part"
|
||||
if err := os.RemoveAll(tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(tmp, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tr := tar.NewReader(bzip2.NewReader(r))
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
rel, err := asrSafeRel(h.Name)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
if rel == "" {
|
||||
continue // le dossier racine de l'archive lui-même
|
||||
}
|
||||
cible := filepath.Join(tmp, rel)
|
||||
switch h.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(cible, 0o755); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(cible), 0o755); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
f, err := os.Create(cible)
|
||||
if err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
// #nosec G110 — l'archive vient d'une release GitHub épinglée par son
|
||||
// nom, pas d'une source arbitraire ; et la borne utile ici est le
|
||||
// disque, pas un plafond deviné.
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
default:
|
||||
// Liens, périphériques, FIFO : une archive de modèle n'en a aucun
|
||||
// besoin, et les suivre serait un autre moyen d'écrire hors du dossier.
|
||||
continue
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(dest); err != nil {
|
||||
_ = os.RemoveAll(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dest)
|
||||
}
|
||||
|
||||
// asrSafeRel retire le dossier de premier niveau d'un chemin d'archive et refuse
|
||||
// tout ce qui sortirait du dossier de destination. Renvoie "" pour l'entrée du
|
||||
// dossier racine elle-même.
|
||||
func asrSafeRel(name string) (string, error) {
|
||||
n := strings.ReplaceAll(strings.TrimSpace(name), `\`, "/")
|
||||
if n == "" || strings.HasPrefix(n, "/") || filepath.IsAbs(n) {
|
||||
return "", fmt.Errorf("entrée d'archive au chemin absolu : %q", name)
|
||||
}
|
||||
parts := strings.Split(strings.Trim(n, "/"), "/")
|
||||
for _, p := range parts {
|
||||
if p == ".." {
|
||||
return "", fmt.Errorf("entrée d'archive qui remonte hors du dossier : %q", name)
|
||||
}
|
||||
}
|
||||
if len(parts) <= 1 {
|
||||
return "", nil // le dossier racine de l'archive
|
||||
}
|
||||
return filepath.Join(parts[1:]...), nil
|
||||
}
|
||||
@@ -1,75 +1,158 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"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)
|
||||
// Le catalogue nourrit une liste déroulante ET des chemins de fichiers : une
|
||||
// entrée bancale ne se verrait qu'au premier clic sur le micro.
|
||||
func TestCatalogueCoherent(t *testing.T) {
|
||||
for id, m := range asrCatalogue {
|
||||
if m.Nom == "" || m.Dossier == "" || m.Langues == "" {
|
||||
t.Errorf("%s : entrée incomplète %+v", id, m)
|
||||
}
|
||||
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)
|
||||
if m.Octets <= 0 {
|
||||
t.Errorf("%s : taille annoncée %d — la confirmation de téléchargement en a besoin", id, m.Octets)
|
||||
}
|
||||
if !strings.HasPrefix(asrModelURLFor(id), "https://") {
|
||||
t.Errorf("%s : URL non https (%s)", id, asrModelURLFor(id))
|
||||
}
|
||||
}
|
||||
// Le défaut doit exister, et être celui qui parle français.
|
||||
def := DictateCfg{}.withDefauts().Model
|
||||
if _, ok := asrCatalogue[def]; !ok {
|
||||
t.Fatalf("le modèle par défaut %q n'est pas au catalogue", def)
|
||||
}
|
||||
if !strings.Contains(asrCatalogue[def].Langues, "français") {
|
||||
t.Errorf("le modèle par défaut (%s) ne parle pas français : %q", def, asrCatalogue[def].Langues)
|
||||
}
|
||||
}
|
||||
|
||||
// Un identifiant hors catalogue ne doit JAMAIS produire de chemin : il vient
|
||||
// d'une requête HTTP, et bâtir un chemin dessus ouvrirait le disque.
|
||||
func TestCheminVideSiInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if p := asrModelDirFor("../../etc"); p != "" {
|
||||
t.Errorf("chemin bâti sur un identifiant inconnu : %q", p)
|
||||
}
|
||||
if u := asrModelURLFor("nawak"); u != "" {
|
||||
t.Errorf("URL bâtie sur un identifiant inconnu : %q", u)
|
||||
}
|
||||
if f := asrModelFile("nawak", "encoder.int8.onnx"); f != "" {
|
||||
t.Errorf("chemin de fichier bâti sur un identifiant inconnu : %q", f)
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle n'est « présent » que COMPLET : une extraction coupée laisse un
|
||||
// dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur tomberait
|
||||
// au premier clic sur le micro plutôt qu'ici.
|
||||
func TestPresentExigeTousLesFichiers(t *testing.T) {
|
||||
testHome(t)
|
||||
const id = "parakeet-tdt-0.6b-v3"
|
||||
dir := asrModelDirFor(id)
|
||||
if asrModelPresent(id) {
|
||||
t.Fatal("modèle annoncé présent alors que rien n'est installé")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, f := range asrFichiers {
|
||||
if asrModelPresent(id) {
|
||||
t.Fatalf("modèle annoncé présent avec seulement %d fichier(s) sur %d", i, len(asrFichiers))
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if !asrModelPresent(id) {
|
||||
t.Error("modèle complet non reconnu")
|
||||
}
|
||||
// Un fichier vide (disque plein en fin d'extraction) ne compte pas.
|
||||
if err := os.WriteFile(filepath.Join(dir, asrFichiers[0]), nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if asrModelPresent(id) {
|
||||
t.Error("un fichier vide passe pour un modèle installé")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogueTriParTaille(t *testing.T) {
|
||||
testHome(t)
|
||||
l := asrCatalogueTrie()
|
||||
if len(l) != len(asrCatalogue) {
|
||||
t.Fatalf("%d entrées, attendu %d", len(l), len(asrCatalogue))
|
||||
}
|
||||
for i := 1; i < len(l); i++ {
|
||||
if l[i-1]["octets"].(int64) > l[i]["octets"].(int64) {
|
||||
t.Error("catalogue non trié du plus léger au plus lourd")
|
||||
}
|
||||
}
|
||||
for _, e := range l {
|
||||
for _, clef := range []string{"id", "nom", "octets", "langues", "present"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Extraction d'archive ────────────────────────────────────────────────────
|
||||
|
||||
// asrSafeRel est la seule chose entre une archive téléchargée et le disque du
|
||||
// serveur. Une entrée qui remonte ou qui est absolue doit être REFUSÉE, pas
|
||||
// nettoyée en silence.
|
||||
func TestSafeRelRefuseLesEvasions(t *testing.T) {
|
||||
for _, mauvais := range []string{
|
||||
"/etc/passwd",
|
||||
"modele/../../etc/passwd",
|
||||
"../dehors.txt",
|
||||
`modele\..\..\dehors.txt`,
|
||||
} {
|
||||
if _, err := asrSafeRel(mauvais); err == nil {
|
||||
t.Errorf("entrée d'archive acceptée alors qu'elle sort du dossier : %q", mauvais)
|
||||
}
|
||||
}
|
||||
// Le dossier de premier niveau est retiré, le reste conservé.
|
||||
got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/encoder.int8.onnx")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "encoder.int8.onnx" {
|
||||
t.Errorf("chemin = %q, le dossier racine de l'archive doit être retiré", got)
|
||||
}
|
||||
// L'entrée du dossier racine lui-même ne produit rien.
|
||||
if got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/"); err != nil || got != "" {
|
||||
t.Errorf("racine de l'archive : (%q, %v), attendu (\"\", nil)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Une extraction ratée ne doit jamais laisser un modèle à moitié installé sous
|
||||
// son nom définitif : asrModelPresent le croirait bon et le serveur mourrait au
|
||||
// démarrage. Elle ne doit pas non plus détruire un modèle déjà en place — le
|
||||
// remplacement n'a lieu qu'une fois l'extraction terminée.
|
||||
func TestExtractNeLaissePasDeDossierPartiel(t *testing.T) {
|
||||
testHome(t)
|
||||
dest := filepath.Join(t.TempDir(), "modele")
|
||||
if err := os.MkdirAll(dest, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
temoin := filepath.Join(dest, "deja-la.txt")
|
||||
if err := os.WriteFile(temoin, []byte("modèle précédent"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Un flux qui n'est pas du bzip2 : la lecture échoue dès la première entrée.
|
||||
if err := asrExtract(bytes.NewReader([]byte("ceci n'est pas une archive bzip2")), dest); err == nil {
|
||||
t.Fatal("un flux illisible doit produire une erreur")
|
||||
}
|
||||
if _, err := os.Stat(dest + ".part"); err == nil {
|
||||
t.Error("le dossier temporaire n'a pas été nettoyé après l'échec")
|
||||
}
|
||||
if _, err := os.Stat(temoin); err != nil {
|
||||
t.Error("le modèle précédent a été détruit par une extraction qui a échoué")
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
func TestConfigRouteAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
corps := strings.NewReader(`{"model":"small-q5_1","lang":"en","device":"1","reactivity":"court"}`)
|
||||
corps := strings.NewReader(`{"model":"parakeet-tdt-0.6b-v2","reactivity":"court"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||
if rec.Code != 200 {
|
||||
@@ -21,7 +21,7 @@ func TestConfigRouteAllerRetour(t *testing.T) {
|
||||
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" {
|
||||
if got.Model != "parakeet-tdt-0.6b-v2" || got.Reactivity != "court" {
|
||||
t.Errorf("relu %+v, attendu le réglage enregistré", got)
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ func TestConfigRouteAllerRetour(t *testing.T) {
|
||||
// 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"}`)
|
||||
corps := strings.NewReader(`{"model":"nawak","reactivity":"moyen"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||
if rec.Code == 200 {
|
||||
@@ -58,12 +58,12 @@ func TestModelsRoute(t *testing.T) {
|
||||
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))
|
||||
if len(l) < 2 {
|
||||
t.Errorf("%d modèles renvoyés, au moins 2 attendus", len(l))
|
||||
}
|
||||
}
|
||||
|
||||
// Un identifiant inconnu ne doit pas lancer de téléchargement : whisperDownload
|
||||
// Un identifiant inconnu ne doit pas lancer de téléchargement : asrDownload
|
||||
// bâtirait une URL vide et le message d'erreur n'arriverait qu'en arrière-plan.
|
||||
func TestDownloadRouteRefuseInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
@@ -93,7 +93,7 @@ func TestStateRoute(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Un audio trop court est rejeté AVANT toute tentative de lancer whisper-server :
|
||||
// Un audio trop court est rejeté AVANT toute tentative de lancer le serveur :
|
||||
// inutile de charger un modèle pour un clic parasite.
|
||||
func TestTranscribeAudioTropCourt(t *testing.T) {
|
||||
testHome(t)
|
||||
|
||||
+278
-151
@@ -1,43 +1,40 @@
|
||||
// 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.
|
||||
// Supervision du serveur de transcription : le modèle est chargé UNE fois et
|
||||
// reste en mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
|
||||
//
|
||||
// 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.
|
||||
// Le moteur est sherpa-onnx (Parakeet, NVIDIA), qui a remplacé whisper.cpp. Ce
|
||||
// qui n'a PAS changé : un processus local supervisé, arrêté après un temps
|
||||
// d'inactivité, redémarré quand les réglages bougent. Ce qui a changé : le
|
||||
// dialogue. whisper-server exposait du HTTP multipart ; sherpa-onnx n'expose
|
||||
// qu'un WebSocket, dont le protocole tient en deux entiers et des flottants
|
||||
// (voir asrInferSur).
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// 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} {
|
||||
// asrServerBin : le binaire du serveur de transcription. Un seul, contrairement
|
||||
// aux deux binaires whisper d'avant (CPU et CUDA) : le build sherpa-onnx livré
|
||||
// est le statique CPU, il n'a donc aucune dépendance CUDA à satisfaire et
|
||||
// démarre sur n'importe quelle base d'image.
|
||||
func asrServerBin() string {
|
||||
for _, p := range []string{os.Getenv("LOKI_ASR_SERVER"), "/usr/local/bin/sherpa-onnx-offline-websocket-server"} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
@@ -45,36 +42,40 @@ func whisperServerBin(gpu bool) string {
|
||||
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 {
|
||||
if p, err := exec.LookPath("sherpa-onnx-offline-websocket-server"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func whisperServerArgs(c DictateCfg, port int) ([]string, error) {
|
||||
model := whisperModelPathFor(c.Model)
|
||||
if model == "" {
|
||||
// asrServerArgs. Les arguments de sherpa-onnx s'écrivent --clef=valeur : la
|
||||
// forme séparée par une espace n'est PAS reconnue et le serveur démarre alors
|
||||
// sans modèle.
|
||||
func asrServerArgs(c DictateCfg, port int) ([]string, error) {
|
||||
if asrModelDirFor(c.Model) == "" {
|
||||
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return []string{
|
||||
"-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),
|
||||
"--encoder=" + asrModelFile(c.Model, "encoder.int8.onnx"),
|
||||
"--decoder=" + asrModelFile(c.Model, "decoder.int8.onnx"),
|
||||
"--joiner=" + asrModelFile(c.Model, "joiner.int8.onnx"),
|
||||
"--tokens=" + asrModelFile(c.Model, "tokens.txt"),
|
||||
// nemo_transducer : sans ce type, les sorties du modèle sont mal
|
||||
// interprétées et la transcription est du bruit, pas une erreur.
|
||||
"--model-type=nemo_transducer",
|
||||
"--port=" + strconv.Itoa(port),
|
||||
// Le serveur n'a AUCUNE authentification. Il n'écoute donc que la boucle
|
||||
// locale : sur un conteneur au réseau partagé, l'exposer offrirait la
|
||||
// transcription à tout le réseau.
|
||||
"--host=127.0.0.1",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// asrServerEnv masque tous les GPU, en REMPLAÇANT toute valeur déjà présente.
|
||||
// Une variable dupliquée laisse le gagnant dépendre de la libc. Le binaire livré
|
||||
// est CPU de toute façon ; la variable est là pour que ça reste vrai si l'image
|
||||
// venait à embarquer un build GPU sans qu'on repense à la dictée.
|
||||
func asrServerEnv(base []string) []string {
|
||||
const clef = "CUDA_VISIBLE_DEVICES="
|
||||
out := make([]string, 0, len(base)+1)
|
||||
for _, v := range base {
|
||||
@@ -82,15 +83,13 @@ func whisperServerEnv(c DictateCfg, base []string) []string {
|
||||
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())
|
||||
return append(out, clef)
|
||||
}
|
||||
|
||||
// portLibre demande au système un port disponible puis le relâche. Coder un
|
||||
// port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre
|
||||
// la fermeture et la reprise par whisper-server est négligeable devant le
|
||||
// risque d'un conflit permanent.
|
||||
// la fermeture et la reprise par le serveur est négligeable devant le risque
|
||||
// d'un conflit permanent.
|
||||
func portLibre() (int, error) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -100,11 +99,11 @@ func portLibre() (int, error) {
|
||||
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 {
|
||||
// asrMarqueur reconnaît ce qui n'est pas de la parole dictée : le vide, et les
|
||||
// annotations entre crochets ou parenthèses. Parakeet en produit beaucoup moins
|
||||
// que whisper, mais le filtre ne coûte rien et un « (silence) » collé dans le
|
||||
// champ de saisie se remarque tout de suite.
|
||||
func asrMarqueur(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return true
|
||||
@@ -113,174 +112,277 @@ func whisperMarqueur(s string) bool {
|
||||
(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")
|
||||
// ─── Protocole ───────────────────────────────────────────────────────────────
|
||||
|
||||
// asrEnteteOctets : l'en-tête du serveur sherpa-onnx, deux entiers 32 bits en
|
||||
// petit-boutien — la fréquence d'échantillonnage, puis le NOMBRE D'OCTETS
|
||||
// d'audio annoncé. Le serveur décode dès qu'il a reçu exactement ce nombre : un
|
||||
// compte faux, et il attend indéfiniment de quoi finir.
|
||||
const asrEnteteOctets = 8
|
||||
|
||||
// asrTrameMax borne la taille d'une trame WebSocket envoyée. Le serveur
|
||||
// reconstitue l'utterance à partir du compte annoncé, donc le découpage est
|
||||
// libre — mais pas la TAILLE : une pile WebSocket refuse par défaut les messages
|
||||
// au-delà de quelques dizaines de kilooctets et ferme la connexion au lieu de
|
||||
// répondre. 10 Ko est la valeur qu'emploie le client de référence de
|
||||
// sherpa-onnx ; on s'aligne dessus plutôt que de parier sur une limite.
|
||||
const asrTrameMax = 10240
|
||||
|
||||
// asrInferSur prend l'adresse du serveur en paramètre : c'est ce qui rend
|
||||
// l'appel testable sans lancer de processus.
|
||||
//
|
||||
// Le protocole, en entier : on ouvre une connexion, on envoie UNE trame binaire
|
||||
// contenant l'en-tête suivi des échantillons en float32 petit-boutien, et on lit
|
||||
// UNE trame texte qui porte le résultat en JSON.
|
||||
func asrInferSur(ctx context.Context, adresse string, wav []byte) (string, error) {
|
||||
taux, ech, err := wavVersFloat32(wav)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(wav); err != nil {
|
||||
return "", err
|
||||
if len(ech) == 0 {
|
||||
return "", nil // rien à transcrire : ce n'est pas une erreur
|
||||
}
|
||||
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)
|
||||
c, _, err := websocket.Dial(ctx, adresse, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", fmt.Errorf("connexion au serveur de dictée : %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
defer func() { _ = c.CloseNow() }()
|
||||
// Une transcription peut légitimement dépasser la taille par défaut acceptée
|
||||
// en lecture ; on ne borne pas ce que le serveur nous renvoie.
|
||||
c.SetReadLimit(-1)
|
||||
|
||||
corps := make([]byte, asrEnteteOctets+4*len(ech))
|
||||
binary.LittleEndian.PutUint32(corps[0:4], uint32(taux))
|
||||
binary.LittleEndian.PutUint32(corps[4:8], uint32(4*len(ech)))
|
||||
for i, v := range ech {
|
||||
binary.LittleEndian.PutUint32(corps[asrEnteteOctets+4*i:], math.Float32bits(v))
|
||||
}
|
||||
for off := 0; off < len(corps); off += asrTrameMax {
|
||||
fin := off + asrTrameMax
|
||||
if fin > len(corps) {
|
||||
fin = len(corps)
|
||||
}
|
||||
if err := c.Write(ctx, websocket.MessageBinary, corps[off:fin]); err != nil {
|
||||
return "", fmt.Errorf("envoi de l'audio : %w", err)
|
||||
}
|
||||
}
|
||||
_, brut, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return "", 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)))
|
||||
return "", fmt.Errorf("lecture du résultat : %w", err)
|
||||
}
|
||||
var j struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(brut, &j); err != nil {
|
||||
return "", fmt.Errorf("réponse illisible de whisper-server : %w", err)
|
||||
return "", fmt.Errorf("réponse illisible du serveur de dictée : %w", err)
|
||||
}
|
||||
if whisperMarqueur(j.Text) {
|
||||
if asrMarqueur(j.Text) {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(j.Text), nil
|
||||
}
|
||||
|
||||
// wavVersFloat32 lit le WAV produit par le navigateur (16 kHz mono) et rend ses
|
||||
// échantillons normalisés dans [-1, 1], ce qu'attend sherpa-onnx.
|
||||
//
|
||||
// On parcourt les blocs plutôt que de sauter à un offset fixe : un WAV n'a pas
|
||||
// d'en-tête de taille garantie (« LIST », « fact » et consorts s'intercalent
|
||||
// avant « data »), et l'offset 44 codé en dur transformerait ces octets en
|
||||
// craquement au début de chaque phrase.
|
||||
func wavVersFloat32(wav []byte) (taux int, ech []float32, err error) {
|
||||
if len(wav) < 12 || string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
|
||||
return 0, nil, errors.New("audio illisible : ce n'est pas un WAV")
|
||||
}
|
||||
var canaux, bits, format int
|
||||
pos := 12
|
||||
for pos+8 <= len(wav) {
|
||||
id := string(wav[pos : pos+4])
|
||||
taille := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8]))
|
||||
corps := pos + 8
|
||||
if taille < 0 || corps+taille > len(wav) {
|
||||
// Bloc annoncé plus grand que le fichier : on prend ce qui reste
|
||||
// plutôt que d'échouer — un enregistrement coupé net reste utile.
|
||||
taille = len(wav) - corps
|
||||
}
|
||||
switch id {
|
||||
case "fmt ":
|
||||
if taille < 16 {
|
||||
return 0, nil, errors.New("audio illisible : bloc fmt tronqué")
|
||||
}
|
||||
format = int(binary.LittleEndian.Uint16(wav[corps : corps+2]))
|
||||
canaux = int(binary.LittleEndian.Uint16(wav[corps+2 : corps+4]))
|
||||
taux = int(binary.LittleEndian.Uint32(wav[corps+4 : corps+8]))
|
||||
bits = int(binary.LittleEndian.Uint16(wav[corps+14 : corps+16]))
|
||||
case "data":
|
||||
if canaux <= 0 || taux <= 0 {
|
||||
return 0, nil, errors.New("audio illisible : bloc data avant fmt")
|
||||
}
|
||||
ech, err = pcmVersFloat32(wav[corps:corps+taille], format, bits, canaux)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
return taux, ech, nil
|
||||
}
|
||||
// Les blocs sont alignés sur un nombre pair d'octets.
|
||||
pos = corps + taille + taille%2
|
||||
}
|
||||
return 0, nil, errors.New("audio illisible : aucun bloc data")
|
||||
}
|
||||
|
||||
// pcmVersFloat32 convertit les échantillons bruts. Les canaux sont MOYENNÉS
|
||||
// plutôt que de garder le premier : un navigateur qui livrerait du stéréo par
|
||||
// erreur donnerait sinon un canal muet une fois sur deux.
|
||||
func pcmVersFloat32(data []byte, format, bits, canaux int) ([]float32, error) {
|
||||
const pcmEntier, pcmFlottant = 1, 3
|
||||
switch {
|
||||
case format == pcmEntier && bits == 16:
|
||||
n := len(data) / 2 / canaux
|
||||
out := make([]float32, n)
|
||||
for i := 0; i < n; i++ {
|
||||
somme := 0.0
|
||||
for c := 0; c < canaux; c++ {
|
||||
v := int16(binary.LittleEndian.Uint16(data[2*(i*canaux+c):]))
|
||||
somme += float64(v) / 32768
|
||||
}
|
||||
out[i] = float32(somme / float64(canaux))
|
||||
}
|
||||
return out, nil
|
||||
case format == pcmFlottant && bits == 32:
|
||||
n := len(data) / 4 / canaux
|
||||
out := make([]float32, n)
|
||||
for i := 0; i < n; i++ {
|
||||
somme := 0.0
|
||||
for c := 0; c < canaux; c++ {
|
||||
somme += float64(math.Float32frombits(binary.LittleEndian.Uint32(data[4*(i*canaux+c):])))
|
||||
}
|
||||
out[i] = float32(somme / float64(canaux))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("format audio non géré (format %d, %d bits) — attendu PCM 16 bits", format, bits)
|
||||
}
|
||||
|
||||
// ─── Supervision ─────────────────────────────────────────────────────────────
|
||||
|
||||
// whisperIdle : au-delà, le serveur s'éteint et rend sa VRAM. Une dictée n'est
|
||||
// asrIdle : au-delà, le serveur s'éteint et rend sa mémoire. Une dictée n'est
|
||||
// pas un service permanent ; garder un modèle chargé toute la journée pour
|
||||
// quelques phrases prive le moteur de chat de mémoire GPU.
|
||||
const whisperIdle = 10 * time.Minute
|
||||
// quelques phrases prive la machine de mémoire vive.
|
||||
const asrIdle = 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
|
||||
// asrReady : plafond d'attente au démarrage. Un modèle de 600 Mo lu depuis un
|
||||
// disque lent prend du temps ; échouer trop tôt afficherait une panne là où il
|
||||
// n'y a qu'une lenteur.
|
||||
const asrReady = 120 * time.Second
|
||||
|
||||
var wsrvMu sync.Mutex
|
||||
|
||||
// wsrvDemarrage : le processus en cours de démarrage, visible SANS le verrou.
|
||||
// asrStartLocked garde wsrvMu pendant tout le chargement du modèle ; c'est la
|
||||
// seule poignée qu'un autre appelant (asrShutdownVite) a sur lui pour
|
||||
// l'interrompre. Nil hors démarrage.
|
||||
var wsrvDemarrage atomic.Pointer[exec.Cmd]
|
||||
|
||||
var wsrv struct {
|
||||
cmd *exec.Cmd
|
||||
port int
|
||||
cfg DictateCfg
|
||||
log *bytes.Buffer
|
||||
timer *time.Timer
|
||||
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
|
||||
// asrEnsure garantit un serveur vivant pour la configuration COURANTE, et
|
||||
// renvoie son port. Si les réglages ont changé depuis le lancement, l'ancien
|
||||
// est arrêté : c'est ce qui rend un changement de modèle ou de GPU effectif
|
||||
// sans redémarrer Loki.
|
||||
func whisperEnsure() (int, error) {
|
||||
// est arrêté : c'est ce qui rend un changement de modèle effectif sans
|
||||
// redémarrer Loki.
|
||||
func asrEnsure() (int, error) {
|
||||
cfg := dictateCfgLoad()
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
|
||||
whisperTouchLocked()
|
||||
asrTouchLocked()
|
||||
return wsrv.port, nil
|
||||
}
|
||||
whisperStopLocked()
|
||||
return whisperStartLocked(cfg)
|
||||
asrStopLocked()
|
||||
return asrStartLocked(cfg)
|
||||
}
|
||||
|
||||
func whisperStartLocked(cfg DictateCfg) (int, error) {
|
||||
if !whisperModelPresent(cfg.Model) {
|
||||
func asrStartLocked(cfg DictateCfg) (int, error) {
|
||||
if !asrModelPresent(cfg.Model) {
|
||||
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
|
||||
}
|
||||
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)
|
||||
}
|
||||
bin := asrServerBin()
|
||||
if bin == "" {
|
||||
return 0, fmt.Errorf("whisper-server introuvable (image à reconstruire)")
|
||||
return 0, fmt.Errorf("serveur de dictée introuvable (image à reconstruire)")
|
||||
}
|
||||
port, err := portLibre()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aucun port libre pour whisper-server : %w", err)
|
||||
return 0, fmt.Errorf("aucun port libre pour le serveur de dictée : %w", err)
|
||||
}
|
||||
args, err := whisperServerArgs(cfg, port)
|
||||
args, err := asrServerArgs(cfg, port)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
log := &bytes.Buffer{}
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = whisperServerEnv(cfg, os.Environ())
|
||||
cmd.Env = asrServerEnv(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)
|
||||
return 0, fmt.Errorf("démarrage du serveur de dictée : %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()
|
||||
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, cfg
|
||||
wsrv.log, wsrv.depuis = log, time.Now()
|
||||
wsrvDemarrage.Store(cmd)
|
||||
err = asrAttendPret(cmd, port, log)
|
||||
wsrvDemarrage.Store(nil)
|
||||
if err != nil {
|
||||
asrStopLocked()
|
||||
return 0, err
|
||||
}
|
||||
whisperTouchLocked()
|
||||
asrTouchLocked()
|
||||
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) + "/"
|
||||
// asrAttendPret sonde le serveur jusqu'à ce qu'il accepte une connexion. Le
|
||||
// serveur sherpa-onnx construit son moteur AVANT d'ouvrir sa socket : le simple
|
||||
// fait qu'elle réponde signifie donc que le modèle est chargé — inutile de lui
|
||||
// envoyer un audio de test.
|
||||
//
|
||||
// Si le processus meurt entre-temps, on rapporte COMMENT il est mort : sur une
|
||||
// mort par signal, la dernière ligne du journal est celle d'avant le coup fatal —
|
||||
// elle ressemble à une explication sans en être une.
|
||||
func asrAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
|
||||
fin := time.Now().Add(asrReady)
|
||||
adr := "127.0.0.1:" + strconv.Itoa(port)
|
||||
mort := make(chan error, 1)
|
||||
go func() { mort <- cmd.Wait() }()
|
||||
for time.Now().Before(fin) {
|
||||
select {
|
||||
case err := <-mort:
|
||||
msg := whisperExitReason(err)
|
||||
msg := asrExitReason(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)
|
||||
return fmt.Errorf("le serveur de dictée 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()
|
||||
conn, err := net.DialTimeout("tcp", adr, 2*time.Second)
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
_ = conn.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("whisper-server n'a pas répondu en %s", whisperReady)
|
||||
return fmt.Errorf("le serveur de dictée n'a pas répondu en %s", asrReady)
|
||||
}
|
||||
|
||||
func whisperTouchLocked() {
|
||||
func asrTouchLocked() {
|
||||
if wsrv.timer != nil {
|
||||
wsrv.timer.Stop()
|
||||
}
|
||||
wsrv.timer = time.AfterFunc(whisperIdle, whisperShutdown)
|
||||
wsrv.timer = time.AfterFunc(asrIdle, asrShutdown)
|
||||
}
|
||||
|
||||
func whisperStopLocked() {
|
||||
func asrStopLocked() {
|
||||
if wsrv.timer != nil {
|
||||
wsrv.timer.Stop()
|
||||
wsrv.timer = nil
|
||||
@@ -289,37 +391,62 @@ func whisperStopLocked() {
|
||||
_ = wsrv.cmd.Process.Kill()
|
||||
}
|
||||
wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil
|
||||
wsrv.cfg, wsrv.note = DictateCfg{}, ""
|
||||
wsrv.cfg = 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() {
|
||||
// asrShutdown arrête le serveur. Appelé par la minuterie d'inactivité et à
|
||||
// chaque changement de réglages — sans ça, le serveur continuerait de tourner
|
||||
// avec l'ancien modèle et le nouveau réglage semblerait sans effet.
|
||||
func asrShutdown() {
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
whisperStopLocked()
|
||||
asrStopLocked()
|
||||
}
|
||||
|
||||
// whisperInfer garantit le serveur puis lui soumet le WAV.
|
||||
func whisperInfer(ctx context.Context, wav []byte) (string, error) {
|
||||
port, err := whisperEnsure()
|
||||
// asrShutdownVite arrête le serveur sans jamais attendre derrière un démarrage.
|
||||
// asrShutdown prend wsrvMu, et asrEnsure le garde pendant tout le chargement du
|
||||
// modèle — jusqu'à asrReady (deux minutes) depuis un disque lent. Appelé par un
|
||||
// geste de l'interface, ce blocage ferait abandonner le navigateur alors que le
|
||||
// moteur, lui, est déjà arrêté.
|
||||
//
|
||||
// Si le verrou est pris, on tue le processus en train de démarrer : sa mort fait
|
||||
// sortir asrAttendPret, le verrou se libère, et on finit le ménage par la voie
|
||||
// normale. La dictée qui attendait reçoit une erreur de démarrage — c'est
|
||||
// l'utilisateur qui vient de demander la place, pas une panne.
|
||||
func asrShutdownVite() {
|
||||
for i := 0; i < 20; i++ {
|
||||
if wsrvMu.TryLock() {
|
||||
asrStopLocked()
|
||||
wsrvMu.Unlock()
|
||||
return
|
||||
}
|
||||
if cmd := wsrvDemarrage.Load(); cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// asrInfer garantit le serveur puis lui soumet le WAV.
|
||||
func asrInfer(ctx context.Context, wav []byte) (string, error) {
|
||||
port, err := asrEnsure()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return whisperInferSur(ctx, "http://127.0.0.1:"+strconv.Itoa(port), wav)
|
||||
return asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(port), wav)
|
||||
}
|
||||
|
||||
func whisperEtat() map[string]any {
|
||||
func asrEtat() 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,
|
||||
"present": asrModelPresent(cfg.Model),
|
||||
// Le moteur livré est le build statique CPU (voir asrServerBin) : le dire
|
||||
// vaut mieux que laisser chercher un réglage de carte qui n'existe pas.
|
||||
"device": "cpu",
|
||||
}
|
||||
if wsrv.cmd != nil {
|
||||
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
|
||||
|
||||
@@ -2,99 +2,130 @@ package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
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
|
||||
// wavTest fabrique un WAV 16 kHz mono PCM 16 bits, comme celui que le
|
||||
// navigateur envoie.
|
||||
func wavTest(ech []int16, taux int) []byte {
|
||||
data := make([]byte, 2*len(ech))
|
||||
for i, v := range ech {
|
||||
binary.LittleEndian.PutUint16(data[2*i:], uint16(v))
|
||||
}
|
||||
b := make([]byte, 0, 44+len(data))
|
||||
ajout32 := func(v uint32) {
|
||||
var x [4]byte
|
||||
binary.LittleEndian.PutUint32(x[:], v)
|
||||
b = append(b, x[:]...)
|
||||
}
|
||||
ajout16 := func(v uint16) {
|
||||
var x [2]byte
|
||||
binary.LittleEndian.PutUint16(x[:], v)
|
||||
b = append(b, x[:]...)
|
||||
}
|
||||
b = append(b, "RIFF"...)
|
||||
ajout32(uint32(36 + len(data)))
|
||||
b = append(b, "WAVE"...)
|
||||
b = append(b, "fmt "...)
|
||||
ajout32(16)
|
||||
ajout16(1) // PCM entier
|
||||
ajout16(1) // mono
|
||||
ajout32(uint32(taux))
|
||||
ajout32(uint32(taux * 2))
|
||||
ajout16(2) // alignement de bloc
|
||||
ajout16(16) // bits par échantillon
|
||||
b = append(b, "data"...)
|
||||
ajout32(uint32(len(data)))
|
||||
return append(b, data...)
|
||||
}
|
||||
|
||||
func TestServerArgsFormeCleValeur(t *testing.T) {
|
||||
testHome(t)
|
||||
args, err := asrServerArgs(DictateCfg{Model: "parakeet-tdt-0.6b-v3"}, 4242)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
joint := strings.Join(args, " ")
|
||||
// sherpa-onnx ne reconnaît QUE --clef=valeur. La forme séparée par une
|
||||
// espace le laisse démarrer sans modèle, sans se plaindre.
|
||||
for _, a := range args {
|
||||
if !strings.HasPrefix(a, "--") || !strings.Contains(a, "=") {
|
||||
t.Errorf("argument %q : sherpa-onnx exige la forme --clef=valeur", a)
|
||||
}
|
||||
}
|
||||
for _, attendu := range []string{"--model-type=nemo_transducer", "--host=127.0.0.1", "--port=4242"} {
|
||||
if !strings.Contains(joint, attendu) {
|
||||
t.Errorf("argument manquant : %s\nobtenu : %s", attendu, joint)
|
||||
}
|
||||
}
|
||||
// Les quatre fichiers du modèle, sinon le serveur démarre puis meurt.
|
||||
for _, f := range asrFichiers {
|
||||
if !strings.Contains(joint, f) {
|
||||
t.Errorf("le fichier %s du modèle n'est pas passé au serveur", f)
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestServerArgs(t *testing.T) {
|
||||
func TestServerArgsRefuseModeleInconnu(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)
|
||||
if _, err := asrServerArgs(DictateCfg{Model: "nawak"}, 1234); err == nil {
|
||||
t.Error("un modèle hors catalogue doit être refusé avant le lancement")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
// Le serveur ne doit jamais prendre une carte : le binaire livré est le build
|
||||
// CPU, et la VRAM appartient au moteur de chat.
|
||||
func TestServerEnvMasqueLesGPU(t *testing.T) {
|
||||
got := asrServerEnv([]string{"PATH=/bin", "CUDA_VISIBLE_DEVICES=0,1", "HOME=/root"})
|
||||
n := 0
|
||||
for _, v := range got {
|
||||
if strings.HasPrefix(v, "CUDA_VISIBLE_DEVICES=") {
|
||||
n++
|
||||
if v != "CUDA_VISIBLE_DEVICES=" {
|
||||
t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu vide", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Une variable en double laisse le gagnant dépendre de la libc.
|
||||
if n != 1 {
|
||||
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
|
||||
}
|
||||
if !contientExact(env, "PATH=/usr/bin") {
|
||||
if !contientTout(got, "PATH=/bin", "HOME=/root") {
|
||||
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
|
||||
func contientTout(l []string, vals ...string) bool {
|
||||
for _, v := range vals {
|
||||
trouve := false
|
||||
for _, x := range l {
|
||||
if x == v {
|
||||
trouve = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !trouve {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func compte(l []string, prefixe string) int {
|
||||
n := 0
|
||||
for _, v := range l {
|
||||
if strings.HasPrefix(v, prefixe) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
return true
|
||||
}
|
||||
|
||||
func TestPortLibre(t *testing.T) {
|
||||
p, err := portLibre()
|
||||
if err != nil {
|
||||
t.Fatalf("portLibre : %v", err)
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p < 1024 || p > 65535 {
|
||||
t.Errorf("port = %d, hors de la plage utilisable", p)
|
||||
@@ -106,40 +137,167 @@ func TestPortLibre(t *testing.T) {
|
||||
_ = 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()
|
||||
// ─── Conversion WAV → float32 ────────────────────────────────────────────────
|
||||
|
||||
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
||||
// L'offset 44 codé en dur est le piège classique : un WAV n'a pas d'en-tête de
|
||||
// taille garantie, et des blocs (LIST, fact…) s'intercalent avant `data`. Les
|
||||
// prendre pour de l'audio produit un craquement au début de chaque phrase.
|
||||
func TestWavIgnoreLesBlocsIntercales(t *testing.T) {
|
||||
base := wavTest([]int16{0, 16384, -16384, 32767}, 16000)
|
||||
// Insère un bloc « LIST » de 6 octets juste après l'en-tête RIFF/WAVE.
|
||||
extra := append([]byte("LIST"), 6, 0, 0, 0, 'I', 'N', 'F', 'O', 'x', 'y')
|
||||
avec := append(append(append([]byte{}, base[:12]...), extra...), base[12:]...)
|
||||
binary.LittleEndian.PutUint32(avec[4:8], uint32(len(avec)-8))
|
||||
|
||||
taux, ech, err := wavVersFloat32(avec)
|
||||
if err != nil {
|
||||
t.Fatalf("whisperInferSur : %v", err)
|
||||
t.Fatalf("wavVersFloat32 : %v", err)
|
||||
}
|
||||
if taux != 16000 {
|
||||
t.Errorf("taux = %d, attendu 16000", taux)
|
||||
}
|
||||
if len(ech) != 4 {
|
||||
t.Fatalf("%d échantillons, attendu 4 — un bloc intercalé a été pris pour de l'audio", len(ech))
|
||||
}
|
||||
if ech[0] != 0 || math.Abs(float64(ech[1])-0.5) > 0.001 {
|
||||
t.Errorf("échantillons mal normalisés : %v", ech)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWavRefuseCeQuiNEnEstPas(t *testing.T) {
|
||||
for nom, in := range map[string][]byte{
|
||||
"vide": {},
|
||||
"pas un WAV": []byte("ceci n'est pas un fichier audio du tout"),
|
||||
"sans data": append([]byte("RIFF\x00\x00\x00\x00WAVE"), "fmt "...),
|
||||
} {
|
||||
if _, _, err := wavVersFloat32(in); err == nil {
|
||||
t.Errorf("%s : accepté alors qu'il est illisible", nom)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le stéréo doit être MOYENNÉ, pas réduit au premier canal : un navigateur qui
|
||||
// livrerait deux canaux donnerait sinon un silence une fois sur deux.
|
||||
func TestPcmStereoMoyenne(t *testing.T) {
|
||||
data := make([]byte, 8) // deux trames stéréo
|
||||
binary.LittleEndian.PutUint16(data[0:], uint16(int16(0)))
|
||||
binary.LittleEndian.PutUint16(data[2:], uint16(int16(32767)))
|
||||
binary.LittleEndian.PutUint16(data[4:], uint16(0x8000)) // -32768
|
||||
binary.LittleEndian.PutUint16(data[6:], uint16(int16(32767)))
|
||||
ech, err := pcmVersFloat32(data, 1, 16, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ech) != 2 {
|
||||
t.Fatalf("%d échantillons, attendu 2", len(ech))
|
||||
}
|
||||
if math.Abs(float64(ech[0])-0.5) > 0.01 {
|
||||
t.Errorf("premier échantillon = %v, attendu la moyenne des deux canaux (~0,5)", ech[0])
|
||||
}
|
||||
if math.Abs(float64(ech[1])) > 0.01 {
|
||||
t.Errorf("second échantillon = %v, attendu ~0 (canaux opposés)", ech[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPcmFormatNonGere(t *testing.T) {
|
||||
if _, err := pcmVersFloat32(make([]byte, 8), 1, 24, 1); err == nil {
|
||||
t.Error("un PCM 24 bits doit être refusé explicitement, pas décodé de travers")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Protocole WebSocket ─────────────────────────────────────────────────────
|
||||
|
||||
// serveurASRFactice rejoue le protocole de sherpa-onnx : il lit l'en-tête et
|
||||
// les échantillons, puis répond en JSON. Il vérifie au passage que le client
|
||||
// annonce le BON nombre d'octets — un compte faux et le vrai serveur attend
|
||||
// indéfiniment de quoi finir, sans jamais répondre.
|
||||
func serveurASRFactice(t *testing.T, texte string) (adresse string, arret func(), recu *struct {
|
||||
Taux int
|
||||
N int
|
||||
}) {
|
||||
t.Helper()
|
||||
recu = &struct {
|
||||
Taux int
|
||||
N int
|
||||
}{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.CloseNow() }()
|
||||
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
|
||||
// il ne borne pas la taille des trames recues, ce test non plus.
|
||||
c.SetReadLimit(-1)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
var buf []byte
|
||||
for {
|
||||
_, b, err := c.Read(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf = append(buf, b...)
|
||||
if len(buf) < asrEnteteOctets {
|
||||
continue
|
||||
}
|
||||
recu.Taux = int(binary.LittleEndian.Uint32(buf[0:4]))
|
||||
attendu := int(binary.LittleEndian.Uint32(buf[4:8]))
|
||||
if len(buf)-asrEnteteOctets < attendu {
|
||||
continue
|
||||
}
|
||||
recu.N = attendu / 4
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte(`{"text":"`+texte+`"}`))
|
||||
return
|
||||
}
|
||||
}))
|
||||
return "ws" + strings.TrimPrefix(srv.URL, "http"), srv.Close, recu
|
||||
}
|
||||
|
||||
func TestInferEnvoieLeBonEnTete(t *testing.T) {
|
||||
adresse, arret, recu := serveurASRFactice(t, " bonjour ceci est un test ")
|
||||
defer arret()
|
||||
|
||||
wav := wavTest(make([]int16, 800), 16000)
|
||||
txt, err := asrInferSur(context.Background(), adresse, wav)
|
||||
if err != nil {
|
||||
t.Fatalf("asrInferSur : %v", err)
|
||||
}
|
||||
if txt != "bonjour ceci est un test" {
|
||||
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
|
||||
}
|
||||
if recu.Taux != 16000 {
|
||||
t.Errorf("taux annoncé = %d, attendu 16000", recu.Taux)
|
||||
}
|
||||
if recu.N != 800 {
|
||||
t.Errorf("%d échantillons annoncés, attendu 800 — un compte faux fige le serveur", recu.N)
|
||||
}
|
||||
}
|
||||
|
||||
// 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].
|
||||
// Un audio plus gros qu'une trame doit arriver ENTIER : le serveur recolle les
|
||||
// morceaux à partir du compte annoncé, encore faut-il tout envoyer.
|
||||
func TestInferDecoupeLesGrosEnvois(t *testing.T) {
|
||||
adresse, arret, recu := serveurASRFactice(t, "ok")
|
||||
defer arret()
|
||||
|
||||
n := asrTrameMax // > une trame une fois converti en float32
|
||||
if _, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, n), 16000)); err != nil {
|
||||
t.Fatalf("asrInferSur : %v", err)
|
||||
}
|
||||
if recu.N != n {
|
||||
t.Errorf("%d échantillons reçus, attendu %d", recu.N, n)
|
||||
}
|
||||
}
|
||||
|
||||
// Sur du silence, le moteur peut rendre une annotation plutôt qu'un texte. La
|
||||
// laisser passer la collerait telle quelle dans le champ de saisie.
|
||||
func TestInferFiltreLesMarqueurs(t *testing.T) {
|
||||
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
|
||||
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()
|
||||
adresse, arret, _ := serveurASRFactice(t, marqueur)
|
||||
txt, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, 100), 16000))
|
||||
arret()
|
||||
if err != nil {
|
||||
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
|
||||
t.Fatalf("asrInferSur(%q) : %v", marqueur, err)
|
||||
}
|
||||
if txt != "" {
|
||||
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
|
||||
@@ -147,25 +305,65 @@ func TestInferFiltreLesMarqueurs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
func TestInferServeurInjoignable(t *testing.T) {
|
||||
p, err := portLibre()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_, err = asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(p), wavTest(make([]int16, 100), 16000))
|
||||
if err == nil {
|
||||
t.Error("un serveur injoignable doit remonter une erreur")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle absent doit être annoncé comme tel, pas produire un lancement de
|
||||
// whisper-server voué à mourir sur un fichier introuvable.
|
||||
func TestInferReponseIllisible(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = c.CloseNow() }()
|
||||
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
|
||||
// il ne borne pas la taille des trames recues, ce test non plus.
|
||||
c.SetReadLimit(-1)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if _, _, err := c.Read(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
_ = c.Write(ctx, websocket.MessageText, []byte("ceci n'est pas du JSON"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := asrInferSur(context.Background(), "ws"+strings.TrimPrefix(srv.URL, "http"), wavTest(make([]int16, 100), 16000))
|
||||
if err == nil {
|
||||
t.Error("une réponse illisible doit remonter une erreur")
|
||||
}
|
||||
}
|
||||
|
||||
// Un audio vide n'est pas une panne : on rend une transcription vide sans même
|
||||
// ouvrir de connexion.
|
||||
func TestInferAudioVide(t *testing.T) {
|
||||
txt, err := asrInferSur(context.Background(), "ws://127.0.0.1:1", wavTest(nil, 16000))
|
||||
if err != nil {
|
||||
t.Fatalf("un WAV sans échantillon ne doit pas être une erreur : %v", err)
|
||||
}
|
||||
if txt != "" {
|
||||
t.Errorf("texte = %q, attendu vide", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Supervision ─────────────────────────────────────────────────────────────
|
||||
|
||||
// Un modèle absent doit être annoncé comme tel, pas produire un lancement du
|
||||
// serveur voué à mourir sur un fichier introuvable.
|
||||
func TestEnsureModeleAbsent(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err != nil {
|
||||
if err := dictateCfgSave(DictateCfg{Model: "parakeet-tdt-0.6b-v3", Reactivity: "moyen"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := whisperEnsure()
|
||||
_, err := asrEnsure()
|
||||
if err == nil {
|
||||
t.Fatal("un modèle absent doit produire une erreur")
|
||||
}
|
||||
@@ -176,13 +374,24 @@ func TestEnsureModeleAbsent(t *testing.T) {
|
||||
|
||||
func TestEtatSansServeur(t *testing.T) {
|
||||
testHome(t)
|
||||
e := whisperEtat()
|
||||
e := asrEtat()
|
||||
if e["actif"] != false {
|
||||
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
|
||||
}
|
||||
for _, clef := range []string{"modele", "present", "device", "note"} {
|
||||
for _, clef := range []string{"modele", "present", "device"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
|
||||
}
|
||||
}
|
||||
// Le binaire livré est CPU : l'état ne doit pas laisser croire à une carte.
|
||||
if e["device"] != "cpu" {
|
||||
t.Errorf("device = %v, attendu \"cpu\"", e["device"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEtatSerialisable(t *testing.T) {
|
||||
testHome(t)
|
||||
if _, err := json.Marshal(asrEtat()); err != nil {
|
||||
t.Fatalf("l'état doit être sérialisable pour /api/dictate/state : %v", err)
|
||||
}
|
||||
}
|
||||
+306
-67
@@ -202,6 +202,9 @@ type Caps struct {
|
||||
// "verifier" (passe de vérification, seule autorisée à marquer un critère
|
||||
// passed), "explorer", "code-reviewer". Voir code_roles.go.
|
||||
Role string
|
||||
// ComputerUse = pilotage du navigateur du conteneur (outils browser_*).
|
||||
// Requiert aussi Agent (mêmes actions réelles que bash). Voir computer_use.go.
|
||||
ComputerUse bool
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
@@ -219,7 +222,7 @@ func globalCaps() Caps {
|
||||
if !agent {
|
||||
return Caps{Agent: false, Internet: false, Mem: MemOff}
|
||||
}
|
||||
return Caps{Agent: true, Internet: internetEnabled() && crawlReachable(), Mem: memMode()}
|
||||
return Caps{Agent: true, Internet: internetEnabled() && crawlReachable(), Mem: memMode(), ComputerUse: computerUseEnabled()}
|
||||
}
|
||||
|
||||
// InjectSkills prepends context system messages to msgs: the decisive-agent
|
||||
@@ -327,17 +330,55 @@ func EnabledTools(caps Caps) []Tool {
|
||||
if caps.Code && caps.Role == "verifier" {
|
||||
return []Tool{bashTool(), readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool(), criteriaTool()}
|
||||
}
|
||||
// Rôles DÉLÉGUÉS (outil subagent) : lecture seule, et rien d'autre. Ni
|
||||
// write/edit (ce qui modifie le dépôt reste dans le fil principal, sous les
|
||||
// yeux de l'utilisateur), ni critères (seule la passe de vérification les
|
||||
// marque), ni subagent (pas de récursion), ni mémoire ni web — chaque schéma
|
||||
// coûte du contexte, et un explorateur n'a que du code à lire.
|
||||
if caps.Code && isSubagentRole(caps.Role) {
|
||||
tools := []Tool{readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool()}
|
||||
if caps.Role == "planner" {
|
||||
// Le planificateur POSE le contrat (criteria action=add) : c'est la
|
||||
// moitié de son travail, et son prompt le lui demande. Marquer un
|
||||
// critère « passé » lui reste interdit — allowPass ne vaut que pour
|
||||
// la passe de vérification (voir toolCriteria).
|
||||
tools = append(tools, criteriaTool())
|
||||
}
|
||||
if caps.Role == "explorer" || caps.Role == "code-reviewer" {
|
||||
// bash : lancer un test, compter des occurrences. Les commandes
|
||||
// catastrophiques restent refusées (code_policy.go) et les chemins
|
||||
// bornés au dossier de la discussion.
|
||||
tools = append(tools, bashTool())
|
||||
}
|
||||
return tools
|
||||
}
|
||||
if caps.Agent {
|
||||
tools = append(tools, bashTool(), writeTool(), editTool())
|
||||
// Mémoire longue de la conversation (chat_recall.go) : le compactage
|
||||
// n'archive des blocs QUE quand le mode agent est actif — annoncer recall
|
||||
// sans lui donnerait un outil qui ne peut rien trouver.
|
||||
tools = append(tools, recallTool(), recallSearchTool())
|
||||
// Tâches planifiées : l'IA se donne elle-même des rappels et des veilles
|
||||
// récurrentes, et gère les siennes (tasks_tools.go). Cloisonnées par
|
||||
// projet : dans un projet, elle ne voit et ne pilote QUE ses tâches.
|
||||
// Réservé au mode agent — une tâche sans outils pour agir n'aurait rien
|
||||
// à livrer.
|
||||
tools = append(tools, taskListTool(), taskCreateTool(), taskUpdateTool(), taskDeleteTool())
|
||||
}
|
||||
if caps.Code {
|
||||
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
|
||||
bashBgTool(), bashTailTool(), gitStatusTool(), gitDiffTool(), gitCloneTool(), criteriaTool())
|
||||
// Délégation à un rôle en contexte isolé (code_subagent.go). Réservée au
|
||||
// fil principal : un sous-agent ne délègue pas à son tour.
|
||||
tools = append(tools, subagentTool())
|
||||
}
|
||||
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
|
||||
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
|
||||
if caps.Mem != MemOff {
|
||||
tools = append(tools, memSearchTool(), memReadTool(), memAddTool(), memEditTool())
|
||||
// Trackers : 3e type de mémoire (données datées). Même axe que les pages —
|
||||
// un seul outil pour les quatre actions, cf. trackerTool.
|
||||
tools = append(tools, trackerTool())
|
||||
}
|
||||
// Outils web : seulement si le mode agent ET l'accès internet sont actifs.
|
||||
// caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
|
||||
@@ -352,9 +393,24 @@ func EnabledTools(caps Caps) []Tool {
|
||||
// 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.
|
||||
// Voir une image du DISQUE : seulement quand la vision est réellement active
|
||||
// (projecteur MMPROJ déclaré). Sinon l'outil ne saurait que renvoyer une
|
||||
// erreur, et l'annoncer ferait croire au modèle qu'il a des yeux qu'il n'a
|
||||
// pas — il promettrait alors de regarder, puis se contredirait.
|
||||
if caps.Agent && visionEnabled() {
|
||||
tools = append(tools, seeImageTool())
|
||||
}
|
||||
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
|
||||
tools = append(tools, webScreenshotTool())
|
||||
}
|
||||
// Pilotage de navigateur (browser_*) : l'IA ouvre une page, lit ses éléments
|
||||
// interactifs NUMÉROTÉS et agit par numéro. Ce sont des actions réelles sur
|
||||
// le web (cliquer, taper, valider un formulaire) : même niveau de confiance
|
||||
// que bash, donc mode agent requis, plus son propre interrupteur (Réglages).
|
||||
// Voir computer_use.go.
|
||||
if caps.Agent && caps.ComputerUse {
|
||||
tools = append(tools, computerUseTools()...)
|
||||
}
|
||||
// Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
|
||||
// Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
|
||||
// agent. La découverte est paresseuse et cachée (voir mcp_client.go).
|
||||
@@ -424,24 +480,77 @@ type ToolUsedEvent struct {
|
||||
// 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
|
||||
// ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en
|
||||
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle, qui affichait
|
||||
// sinon la taille de l'aperçu — toujours le même chiffre, quelle que soit la
|
||||
// page lue.
|
||||
ResultChars int
|
||||
// ResultID : identifiant (tool_call_id) avec lequel l'UI CHARGE le résultat
|
||||
// complet à la demande via /api/chat/tool-result. Le flux ne transporte que
|
||||
// l'aperçu ; le reste ne descend qu'au clic sur « voir plus ». Vide = Result
|
||||
// est déjà complet (rien à charger).
|
||||
ResultID string
|
||||
}
|
||||
|
||||
// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI.
|
||||
// Aligné sur le plus haut plafond côté modèle (mcpMaxOutput = 12000 ; shell et
|
||||
// web = 8000) : le modèle et l'UI voient donc la même chose, et l'étiquette
|
||||
// « ~N tok » de la bulle dit la VRAIE taille du résultat.
|
||||
// toolPreviewChars borne l'aperçu de résultat d'outil envoyé à l'UI. Le résultat
|
||||
// COMPLET reste côté serveur (conv.Messages) et se charge à la demande.
|
||||
//
|
||||
// Avant, cette borne était à 4000 : toute page web un peu longue s'affichait
|
||||
// « ~1004 tok » — la valeur du plafond, pas celle de la page. Le compteur
|
||||
// mentait, et il mentait toujours avec le même chiffre.
|
||||
const shownDisplayMax = 12000
|
||||
// Avant, l'UI recevait tout le résultat tronqué à 12000 caractères : chaque tour
|
||||
// poussait des dizaines de kilo-octets dans le flux SSE et dans le journal
|
||||
// persisté, pour un bloc que personne ne déplie une fois sur dix.
|
||||
const toolPreviewChars = 1600
|
||||
|
||||
// shownResult prépare un résultat d'outil pour l'affichage.
|
||||
func shownResult(s string) string {
|
||||
if r := []rune(s); len(r) > shownDisplayMax {
|
||||
return string(r[:shownDisplayMax]) + "\n…[tronqué]"
|
||||
// toolResultPreview renvoie (aperçu, taille réelle en runes, coupé ?).
|
||||
func toolResultPreview(s string) (string, int, bool) {
|
||||
r := []rune(s)
|
||||
if len(r) <= toolPreviewChars {
|
||||
return s, len(r), false
|
||||
}
|
||||
return s
|
||||
return string(r[:toolPreviewChars]), len(r), true
|
||||
}
|
||||
|
||||
// fillToolResult pose sur l'événement l'aperçu envoyé à l'UI, la taille réelle
|
||||
// (pour le compteur ~N tok) et, si le résultat est coupé et qu'on a un id, la
|
||||
// référence pour charger le reste à la demande. Sans id utilisable, on envoie le
|
||||
// résultat entier : mieux vaut un flux plus lourd qu'un bloc qu'on ne peut plus
|
||||
// déplier.
|
||||
func fillToolResult(ev *ToolUsedEvent, result, tcID string) *ToolUsedEvent {
|
||||
prev, n, cut := toolResultPreview(result)
|
||||
ev.ResultChars = n
|
||||
if cut && tcID != "" {
|
||||
ev.Result = prev
|
||||
ev.ResultID = tcID
|
||||
} else {
|
||||
ev.Result = result
|
||||
}
|
||||
return ev
|
||||
}
|
||||
|
||||
// dedupableTool indique si un appel RIGOUREUSEMENT identique (même outil, mêmes
|
||||
// arguments) doit être court-circuité au lieu d'être rejoué. Vrai pour les outils
|
||||
// où un ré-appel identique n'a pas de sens ou produit une fausse erreur (rejouer
|
||||
// une écriture déjà appliquée → « old introuvable »).
|
||||
//
|
||||
// FAUX pour bash : relancer la MÊME commande est un usage courant et légitime —
|
||||
// l'IA crée un fichier, le lance, le modifie, puis le RELANCE avec la même ligne ;
|
||||
// le résultat change parce que le fichier a changé. La dédup la bloquait par un
|
||||
// « [déjà fait] » exaspérant. bash a des effets de bord : on le laisse toujours
|
||||
// s'exécuter. Même raison pour bash_bg / bash_tail (suivre un job, c'est relire
|
||||
// la même sortie qui, elle, avance).
|
||||
//
|
||||
// FAUX aussi pour see_image et tout le pilotage de navigateur (browser_*) :
|
||||
// - leur résultat dépend de l'ÉTAT VIVANT de la page (un browser_snapshot sans
|
||||
// argument a forcément la même clé de dédup à chaque appel, alors que la page
|
||||
// a changé) — les dédupliquer fige l'IA sur un vieux cliché ;
|
||||
// - surtout, browser_screenshot et see_image portent leur IMAGE dans un message
|
||||
// à part (visionImg) que le chemin de dédup ne rejoue PAS : l'IA recevrait
|
||||
// « [déjà fait] » SANS l'image et tournerait en boucle.
|
||||
func dedupableTool(name string) bool {
|
||||
switch name {
|
||||
case "bash", "bash_bg", "bash_tail", "see_image":
|
||||
return false
|
||||
}
|
||||
return !strings.HasPrefix(name, "browser_")
|
||||
}
|
||||
|
||||
// repeatedCallResult construit ce qu'on renvoie quand le modèle redemande un
|
||||
@@ -651,6 +760,55 @@ func isNetTimeout(err error) bool {
|
||||
return errors.As(err, &ne) && ne.Timeout()
|
||||
}
|
||||
|
||||
// toolCallLabel dérive le libellé HUMAIN d'un appel d'outil : la commande, le
|
||||
// chemin, la requête… Il est annoncé à l'interface AVANT l'exécution — sans ça,
|
||||
// une commande shell lente laisse l'écran figé sans rien dire — et il sert
|
||||
// ensuite d'argument principal à plusieurs outils.
|
||||
//
|
||||
// Cette double fonction est un piège : un outil absent de cette table reçoit un
|
||||
// libellé VIDE, et si son exécution lit ce libellé (see_image, read, bash…), il
|
||||
// s'exécute sur une chaîne vide. Vécu à l'ajout de see_image : l'outil répondait
|
||||
// « chemin de fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne
|
||||
// bronche. D'où l'extraction : une table pareille se teste.
|
||||
func toolCallLabel(name string, args map[string]any) string {
|
||||
label := ""
|
||||
switch name {
|
||||
case "mem_search", "web_search", "recall_search":
|
||||
label, _ = args["query"].(string)
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
|
||||
label, _ = args["file"].(string)
|
||||
case "recall":
|
||||
label, _ = args["id"].(string)
|
||||
case "tracker":
|
||||
// Libellé lisible : « nom » ou « action nom », pas un dump d'arguments.
|
||||
label = strings.TrimSpace(str(args["action"]) + " " + str(args["name"]))
|
||||
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":
|
||||
u, _ := args["url"].(string)
|
||||
p, _ := args["pattern"].(string)
|
||||
label = p + " @ " + u
|
||||
default:
|
||||
// Outils MCP : libellé = un aperçu compact des arguments.
|
||||
if isMCPTool(name) {
|
||||
label = mcpArgLabel(args)
|
||||
}
|
||||
}
|
||||
return label
|
||||
}
|
||||
|
||||
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) {
|
||||
var extra []Message
|
||||
tools := EnabledTools(caps)
|
||||
@@ -697,12 +855,26 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
repeatCount := map[string]int{}
|
||||
// Garde-fou « pensé sans agir » : certains modèles à reasoning planifient un
|
||||
// appel d'outil dans leur <think> puis émettent le token de fin SANS l'émettre
|
||||
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
|
||||
// explicite au lieu d'afficher « pas de réponse ».
|
||||
nudged := false
|
||||
// (ni réponse, ni tool_call). On relance alors le tour avec un nudge explicite
|
||||
// au lieu d'afficher « pas de réponse ». Le premier suffit la plupart du temps ;
|
||||
// un second, plus direct, rattrape le cas où le modèle re-décrit le même plan
|
||||
// en boucle sans jamais l'exécuter — une simple reformulation de « agis » ne
|
||||
// suffit alors plus. Borné à maxNudges : un modèle vraiment bloqué doit finir
|
||||
// par rendre la main plutôt que faire attendre.
|
||||
const maxNudges = 2
|
||||
nudgeCount := 0
|
||||
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
|
||||
// relance par tour, comme le nudge.
|
||||
patternRetried := false
|
||||
// Reprises RÉSEAU consécutives (llm_retry_net.go). Remis à zéro dès qu'une
|
||||
// réponse arrive : une boucle d'outils longue retrouve son budget à chaque
|
||||
// itération réussie, un moteur durablement mort finit par rendre la main.
|
||||
netRetries := 0
|
||||
// Destination des complétions : llama-server local, ou une API OpenAI-compatible
|
||||
// externe si le preset actif en est un (backend_external.go). Résolu UNE FOIS
|
||||
// par tour — une bascule de preset en plein tour est rare, et se rejoue de
|
||||
// toute façon au message suivant.
|
||||
ep := resolveChatEndpoint()
|
||||
// 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
|
||||
@@ -718,7 +890,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
messages = append(messages, Message{Role: "user", Content: msg})
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": "loki",
|
||||
"model": ep.Model,
|
||||
// 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),
|
||||
@@ -750,15 +922,28 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
payload["parallel_tool_calls"] = false
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return extra, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
authHeader(req)
|
||||
// ⚠️ Surtout pas authHeader : celui-ci pose la clé du serveur LOCAL, et
|
||||
// l'envoyer à api.openai.com serait fuiter un secret chez un tiers en même
|
||||
// temps qu'un 401 garanti. Chaque endpoint porte la sienne.
|
||||
ep.auth(req.Header.Set)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
// Rien n'est encore parti à l'écran : le tour est rejouable tel quel.
|
||||
// C'est le cas du moteur qui redémarre (bascule de preset, rechargement
|
||||
// de modèle) — quelques secondes de connexion refusée qui faisaient
|
||||
// échouer pour de bon une tâche planifiée tombée pile là.
|
||||
if netRetries < llmNetRetries && llmRetryableErr(ctx, err) {
|
||||
logLLMRetry(netRetries+1, friendlyLLMError(err).Error())
|
||||
if werr := llmNetBackoff(ctx, netRetries); werr == nil {
|
||||
netRetries++
|
||||
continue
|
||||
}
|
||||
}
|
||||
err = friendlyLLMError(err)
|
||||
cb(StreamEvent{Err: err})
|
||||
return extra, err
|
||||
@@ -820,10 +1005,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
messages = steerSystem(messages, "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues.")
|
||||
continue
|
||||
}
|
||||
err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg)
|
||||
// Dernier recours, APRÈS les filets sémantiques ci-dessus : un statut
|
||||
// qui dit « pas maintenant » (passerelle, service indisponible, trop de
|
||||
// requêtes) et non « ta requête est fautive ». Le corps n'a pas été
|
||||
// diffusé, le tour est rejouable.
|
||||
if netRetries < llmNetRetries && llmRetryableStatus(resp.StatusCode) && ctx.Err() == nil {
|
||||
logLLMRetry(netRetries+1, fmt.Sprintf("le moteur a renvoyé %d", resp.StatusCode))
|
||||
if werr := llmNetBackoff(ctx, netRetries); werr == nil {
|
||||
netRetries++
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Nommer la BONNE machine : « llama-server a renvoyé 401 » sur un preset
|
||||
// externe envoie chercher la panne du mauvais côté.
|
||||
who := "llama-server"
|
||||
if ep.External {
|
||||
who = "l'API externe"
|
||||
}
|
||||
err := fmt.Errorf("%s a renvoyé %d : %s", who, resp.StatusCode, msg)
|
||||
cb(StreamEvent{Err: err})
|
||||
return extra, err
|
||||
}
|
||||
// Une réponse est arrivée : le budget de reprise réseau repart à neuf pour
|
||||
// la suite de la boucle d'outils.
|
||||
netRetries = 0
|
||||
toolCalls := map[int]*ToolCall{}
|
||||
assistantContent := strings.Builder{}
|
||||
finishReason := ""
|
||||
@@ -919,10 +1124,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if cur := toolCalls[0]; cur != nil {
|
||||
key := "command"
|
||||
switch cur.Function.Name {
|
||||
case "mem_search", "web_search":
|
||||
case "mem_search", "web_search", "recall_search":
|
||||
key = "query"
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
||||
key = "file"
|
||||
case "recall":
|
||||
key = "id"
|
||||
case "web_open", "web_read", "web_grep":
|
||||
key = "url"
|
||||
case "grep", "glob":
|
||||
@@ -1098,60 +1305,42 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
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
|
||||
// announce the call BEFORE running it — otherwise the UI shows
|
||||
// nothing while a slow shell command runs and looks frozen.
|
||||
label := ""
|
||||
switch tc.Function.Name {
|
||||
case "mem_search", "web_search":
|
||||
label, _ = args["query"].(string)
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
||||
label, _ = args["file"].(string)
|
||||
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":
|
||||
u, _ := args["url"].(string)
|
||||
p, _ := args["pattern"].(string)
|
||||
label = p + " @ " + u
|
||||
default:
|
||||
// Outils MCP : libellé = un aperçu compact des arguments.
|
||||
if isMCPTool(tc.Function.Name) {
|
||||
label = mcpArgLabel(args)
|
||||
}
|
||||
}
|
||||
// Libellé humain, annoncé AVANT l'exécution (voir toolCallLabel) : sans
|
||||
// ça l'interface reste muette pendant une commande lente. Il sert
|
||||
// aussi d'argument principal à plusieurs outils.
|
||||
label := toolCallLabel(tc.Function.Name, args)
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
|
||||
|
||||
result := ""
|
||||
// diff : rempli par les outils d'écriture (edit / mémoire) pour que
|
||||
// l'UI montre les lignes ajoutées et retirées.
|
||||
var diff []DiffLine
|
||||
// visionImg : partie image_url rendue par see_image, réinjectée après
|
||||
// le résultat de l'outil (un message `tool` ne porte que du texte).
|
||||
var visionImg map[string]any
|
||||
// Appel rigoureusement identique déjà exécuté dans ce tour : on ne le
|
||||
// rejoue pas. Les petits modèles réémettent volontiers deux fois la
|
||||
// même écriture ; la rejouer produisait une fausse erreur (« old
|
||||
// introuvable », puisque le remplacement est déjà fait).
|
||||
callKey := tc.Function.Name + "\x00" + tc.Function.Arguments
|
||||
if prev, seen := doneCalls[callKey]; seen {
|
||||
if prev, seen := doneCalls[callKey]; seen && dedupableTool(tc.Function.Name) {
|
||||
repeatCount[callKey]++
|
||||
result = repeatedCallResult(prev, repeatCount[callKey])
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true}})
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true}, result, tc.ID)})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
continue
|
||||
}
|
||||
switch tc.Function.Name {
|
||||
case "see_image":
|
||||
result, visionImg = toolSeeImage(label)
|
||||
case "recall":
|
||||
result = toolRecall(args)
|
||||
case "recall_search":
|
||||
result = toolRecallSearch(args)
|
||||
case "tracker":
|
||||
result = toolTracker(args)
|
||||
case "mem_search":
|
||||
lim := 0
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
@@ -1255,6 +1444,8 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = toolGitClone(ctx, args)
|
||||
case "criteria":
|
||||
result = toolCriteria(args, caps.Role == "verifier")
|
||||
case "subagent":
|
||||
result = toolSubagent(ctx, args, caps)
|
||||
case "bash":
|
||||
to := 0
|
||||
switch v := args["timeout"].(type) {
|
||||
@@ -1287,6 +1478,32 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
result = capWebOutput(toolWebGrep(args))
|
||||
case "web_screenshot":
|
||||
result = toolWebScreenshot(args, caps)
|
||||
case "task_list":
|
||||
result = toolTaskList(args)
|
||||
case "task_create":
|
||||
result = toolTaskCreate(args)
|
||||
case "task_update":
|
||||
result = toolTaskUpdate(args)
|
||||
case "task_delete":
|
||||
result = toolTaskDelete(args)
|
||||
case "browser_open":
|
||||
result = capCUOutput(toolCUOpen(args))
|
||||
case "browser_snapshot":
|
||||
result = capCUOutput(toolCUSnapshot())
|
||||
case "browser_find":
|
||||
result = capCUOutput(toolCUFind(args))
|
||||
case "browser_click":
|
||||
result = capCUOutput(toolCUClick(args))
|
||||
case "browser_click_xy":
|
||||
result = capCUOutput(toolCUClickXY(args))
|
||||
case "browser_type":
|
||||
result = capCUOutput(toolCUType(args))
|
||||
case "browser_key":
|
||||
result = capCUOutput(toolCUKey(args))
|
||||
case "browser_scroll":
|
||||
result = capCUOutput(toolCUScroll(args))
|
||||
case "browser_screenshot":
|
||||
result, visionImg = toolCUScreenshot()
|
||||
default:
|
||||
if isMCPTool(tc.Function.Name) {
|
||||
result = mcpCall(tc.Function.Name, args)
|
||||
@@ -1303,7 +1520,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
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}})
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Image: shot}, result, tc.ID)})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
@@ -1327,6 +1544,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
}
|
||||
}
|
||||
}
|
||||
// see_image a réussi : même relais, et surtout même règle ÉPHÉMÈRE
|
||||
// que la capture — l'image va dans `messages` (le tour en cours)
|
||||
// mais PAS dans `extra` (l'historique persistant). Un base64
|
||||
// persisté repartirait à CHAQUE tour suivant et finirait par
|
||||
// dépasser la fenêtre pour de bon. Le modèle regarde l'image
|
||||
// maintenant ; ce qu'il en dit, lui, reste.
|
||||
if visionImg != nil {
|
||||
messages = append(messages, seeImageMessage(label, visionImg))
|
||||
}
|
||||
}
|
||||
// Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour :
|
||||
// une boucle d'outils peut à elle seule remplir la fenêtre (résultats
|
||||
@@ -1368,18 +1594,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// instead of leaving the user staring at a silent, finished chat.
|
||||
if strings.TrimSpace(assistantContent.String()) == "" {
|
||||
// Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) :
|
||||
// si un modèle « pense sans agir » malgré tout, on le relance UNE fois avec
|
||||
// une consigne impérative au lieu d'afficher « pas de réponse ».
|
||||
if len(tools) > 0 && !disableTools && !nudged {
|
||||
nudged = true
|
||||
// si un modèle « pense sans agir » malgré tout, on le relance avec une
|
||||
// consigne impérative au lieu d'afficher « pas de réponse ».
|
||||
if len(tools) > 0 && !disableTools && nudgeCount < maxNudges {
|
||||
nudgeCount++
|
||||
// Le raisonnement de ce tour avorté ne mène à rien : on demande à
|
||||
// l'UI de l'effacer avant de relancer, pour ne pas afficher deux
|
||||
// blocs de réflexion successifs.
|
||||
cb(StreamEvent{DropReasoning: true})
|
||||
messages = append(messages, Message{
|
||||
Role: "user",
|
||||
Content: "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.",
|
||||
})
|
||||
nudge := "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."
|
||||
if nudgeCount > 1 {
|
||||
// Le premier nudge n'a pas suffi : le modèle re-décrit le même
|
||||
// plan sans l'exécuter. Second nudge plus impératif, où on lui
|
||||
// interdit explicitement de re-raisonner.
|
||||
nudge = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."
|
||||
}
|
||||
messages = append(messages, Message{Role: "user", Content: nudge})
|
||||
continue
|
||||
}
|
||||
cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"})
|
||||
@@ -1397,7 +1627,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
var healthClient = &http.Client{Timeout: 3 * time.Second}
|
||||
|
||||
// healthCheck pings llama.cpp's /health endpoint.
|
||||
//
|
||||
// Preset externe : il n'y a pas de llama-server local à sonder. On répond
|
||||
// « prêt » sans latence — la vraie joignabilité de l'API distante se révèle à
|
||||
// l'appel de complétion, avec un message d'erreur explicite si elle échoue.
|
||||
// Sans ce court-circuit, la saisie resterait bloquée sur un moteur éteint que
|
||||
// personne n'allumera jamais.
|
||||
func healthCheck() bool {
|
||||
if externalActive() {
|
||||
return true
|
||||
}
|
||||
resp, err := healthClient.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort()))
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package loki
|
||||
|
||||
// llm_retry_net.go — réessayer un tour que RIEN n'a encore diffusé.
|
||||
//
|
||||
// Jusqu'ici, la requête de complétion partait une fois : un `Do()` qui échoue
|
||||
// ou un statut d'erreur tuait le tour. Les messages d'erreur eux-mêmes le
|
||||
// disaient — « réessaie dans quelques secondes », « réessaie dans un
|
||||
// instant » — c'est-à-dire qu'on demandait à l'utilisateur de refaire à la main
|
||||
// ce que le code pouvait faire seul.
|
||||
//
|
||||
// Le cas courant chez Loki n'a rien d'exotique : le moteur redémarre (bascule de
|
||||
// preset, rechargement de modèle, redémarrage du conteneur) et la connexion est
|
||||
// refusée pendant quelques secondes. Une tâche planifiée qui tombe pile là
|
||||
// échouait pour de bon, sans personne pour cliquer.
|
||||
//
|
||||
// LA règle de sûreté, et elle ne souffre pas d'exception : on ne rejoue que
|
||||
// TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ. Une fois le flux commencé, la moitié de
|
||||
// la réponse est déjà chez l'utilisateur ; la rejouer la dupliquerait. Les deux
|
||||
// points de reprise (échec de `Do`, statut d'erreur avant lecture du corps)
|
||||
// sont tous deux situés avant la première ligne SSE.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// llmNetRetries : tentatives CONSÉCUTIVES par tour. Le compteur repart à zéro
|
||||
// dès qu'une réponse arrive, donc une boucle d'outils longue retrouve son
|
||||
// budget à chaque itération réussie — mais un moteur durablement mort rend la
|
||||
// main au lieu de tourner sans fin.
|
||||
llmNetRetries = 3
|
||||
// Attente de la 1re reprise, doublée ensuite (0,8 s → 1,6 s → 3,2 s). Un
|
||||
// llama-server qui redémarre met quelques secondes à rouvrir son port : plus
|
||||
// court ne sert à rien, plus long fait passer une panne franche pour un gel.
|
||||
llmNetWait = 800 * time.Millisecond
|
||||
llmNetMaxWait = 5 * time.Second
|
||||
)
|
||||
|
||||
// llmRetryableErr : cette erreur de transport vaut-elle une nouvelle tentative ?
|
||||
//
|
||||
// Un ctx déjà fini n'en vaut JAMAIS une : c'est le /stop de l'utilisateur ou la
|
||||
// limite de l'appelant, et réessayer serait passer outre. On le vérifie donc
|
||||
// avant de regarder l'erreur elle-même — une échéance dépassée remonte sinon
|
||||
// comme un simple timeout réseau, indiscernable d'une surcharge du moteur.
|
||||
func llmRetryableErr(ctx context.Context, err error) bool {
|
||||
if err == nil || ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, syscall.ECONNREFUSED): // moteur en cours de démarrage
|
||||
return true
|
||||
case errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return true
|
||||
case isNetTimeout(err): // moteur surchargé ou en plein chargement du modèle
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// llmRetryableStatus : ce code HTTP vaut-il une nouvelle tentative ?
|
||||
//
|
||||
// Volontairement étroit. 500 en est ABSENT : c'est le code que llama.cpp rend
|
||||
// pour un appel d'outil malformé ou un prompt qui dépasse le contexte, deux
|
||||
// échecs déterministes que les filets au-dessus traitent déjà (retrait des
|
||||
// outils, compaction en vol). Le rejouer trois fois ne ferait que retarder
|
||||
// l'erreur de plusieurs secondes. Restent les codes qui disent « pas
|
||||
// maintenant » : passerelle, service indisponible, trop de requêtes.
|
||||
func llmRetryableStatus(code int) bool {
|
||||
switch code {
|
||||
case http.StatusTooManyRequests, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// llmNetDelay : combien attendre avant la tentative n (0 = la première reprise).
|
||||
// Doublement puis plafond. Séparé de l'attente pour être vérifiable sans dormir
|
||||
// cinq secondes dans la suite de tests — et parce qu'un décalage de bits sur un
|
||||
// n non borné déborderait en silence (0,8 s << 64 revient à zéro).
|
||||
func llmNetDelay(n int) time.Duration {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
if n > 16 { // au-delà, le plafond s'applique de toute façon
|
||||
n = 16
|
||||
}
|
||||
if d := llmNetWait << n; d < llmNetMaxWait {
|
||||
return d
|
||||
}
|
||||
return llmNetMaxWait
|
||||
}
|
||||
|
||||
// llmNetBackoff attend avant la tentative n (0 = la première reprise).
|
||||
// Interruptible : un /stop pendant l'attente rend la main tout de suite au lieu
|
||||
// de faire patienter l'utilisateur pour une requête qu'il vient d'annuler.
|
||||
func llmNetBackoff(ctx context.Context, n int) error {
|
||||
t := time.NewTimer(llmNetDelay(n))
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// logLLMRetry trace la reprise. Elle n'est PAS envoyée à l'interface : le tour
|
||||
// n'a encore rien produit, et faire clignoter une erreur qu'on est en train de
|
||||
// rattraper inquiéterait pour rien. Le journal du conteneur, lui, doit la voir —
|
||||
// c'est là qu'on cherche quand « ça a mis huit secondes ».
|
||||
func logLLMRetry(attempt int, cause string) {
|
||||
fmt.Fprintf(os.Stderr, "[llm] %s — nouvelle tentative %d/%d\n", cause, attempt, llmNetRetries)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// flakyServer répond `code` aux `fails` premières requêtes, puis sert un flux
|
||||
// SSE normal. Renvoie le port (à mettre dans PORT) et le compteur d'appels.
|
||||
func flakyServer(t *testing.T, fails int, code int, body string) (string, *atomic.Int32) {
|
||||
t.Helper()
|
||||
var calls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if int(calls.Add(1)) <= fails {
|
||||
http.Error(w, "moteur occupé", code)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(200)
|
||||
_, _ = w.Write([]byte(body))
|
||||
w.(http.Flusher).Flush()
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
u, err := url.Parse(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u.Port(), &calls
|
||||
}
|
||||
|
||||
// Le cas qui motive tout le fichier : le moteur redémarre, la connexion est
|
||||
// refusée quelques secondes, et le tour mourait pour de bon — une tâche
|
||||
// planifiée tombée pile là échouait sans personne pour recliquer.
|
||||
func TestConnexionRefuseeEstRejouee(t *testing.T) {
|
||||
testHome(t)
|
||||
// Un port fermé : on ouvre puis on referme aussitôt pour en obtenir un dont on
|
||||
// est sûr que personne n'écoute.
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
_ = ln.Close()
|
||||
if err := SetConfigKey("PORT", itoa(port)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
_, err = runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true })
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("moteur injoignable : aucune erreur remontée")
|
||||
}
|
||||
// 3 reprises espacées de 0,8 / 1,6 / 3,2 s : le tour ne peut pas avoir rendu
|
||||
// la main tout de suite. Sans ce délai, aucune reprise n'a eu lieu.
|
||||
if min := llmNetWait + 2*llmNetWait; elapsed < min {
|
||||
t.Fatalf("rendu la main en %v : aucune reprise n'a eu lieu (attendu au moins %v)", elapsed, min)
|
||||
}
|
||||
}
|
||||
|
||||
// Un statut « pas maintenant » (503) est rejoué, et le tour aboutit.
|
||||
func TestStatutTransitoireEstRejoueEtAboutit(t *testing.T) {
|
||||
testHome(t)
|
||||
port, calls := flakyServer(t, 2, http.StatusServiceUnavailable,
|
||||
sseChunk("ça a fini par passer")+"data: [DONE]\n\n")
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var content strings.Builder
|
||||
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
content.WriteString(ev.Content)
|
||||
}
|
||||
return true
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("503 transitoire non rattrapé : %v", err)
|
||||
}
|
||||
if n := calls.Load(); n != 3 {
|
||||
t.Fatalf("%d appel(s) au moteur, attendu 3 (deux échecs puis la bonne)", n)
|
||||
}
|
||||
if !strings.Contains(content.String(), "ça a fini par passer") {
|
||||
t.Fatalf("réponse perdue : %q", content.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Contre-épreuve : un 500 n'est PAS un statut de reprise RÉSEAU. C'est ce que
|
||||
// llama.cpp rend pour un appel d'outil malformé ou un prompt trop long — deux
|
||||
// échecs déterministes ; attendre puis rejouer à l'identique ne ferait que
|
||||
// retarder l'erreur de plusieurs secondes.
|
||||
//
|
||||
// Le tour peut malgré tout repartir UNE fois : le filet de compaction en vol,
|
||||
// antérieur à ce fichier, retente le tour avec un historique résumé (le 500 est
|
||||
// souvent un dépassement de contexte). C'est un rejeu SÉMANTIQUE — il change la
|
||||
// requête et n'attend pas. La preuve qu'aucune reprise réseau n'a eu lieu est
|
||||
// donc le CHRONO : trois reprises coûteraient au bas mot 0,8 + 1,6 s d'attente.
|
||||
func TestErreur500NestPasRejoueeParLeReseau(t *testing.T) {
|
||||
testHome(t)
|
||||
port, calls := flakyServer(t, 99, http.StatusInternalServerError, "")
|
||||
if err := SetConfigKey("PORT", port); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now()
|
||||
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true })
|
||||
elapsed := time.Since(start)
|
||||
if err == nil {
|
||||
t.Fatal("500 : aucune erreur remontée")
|
||||
}
|
||||
if elapsed >= llmNetWait {
|
||||
t.Fatalf("rendu la main en %v : une attente de reprise réseau a eu lieu sur un 500", elapsed)
|
||||
}
|
||||
// Borne haute large : ce qui compte est qu'on ne soit pas allé au bout du
|
||||
// budget réseau (1 + 3 = 4 appels).
|
||||
if n := calls.Load(); n > 2 {
|
||||
t.Fatalf("%d appels au moteur sur un 500 : le budget de reprise réseau a été consommé", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Un /stop pendant le tour ne doit surtout pas être rattrapé : réessayer, c'est
|
||||
// passer outre l'annulation de l'utilisateur.
|
||||
func TestAnnulationNestJamaisRejouee(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if llmRetryableErr(ctx, syscall.ECONNREFUSED) {
|
||||
t.Fatal("ctx annulé : la reprise doit être refusée")
|
||||
}
|
||||
if llmRetryableErr(context.Background(), context.Canceled) {
|
||||
t.Fatal("context.Canceled : la reprise doit être refusée")
|
||||
}
|
||||
if llmRetryableErr(context.Background(), context.DeadlineExceeded) {
|
||||
t.Fatal("échéance dépassée : c'est la limite de l'appelant, pas une panne réseau")
|
||||
}
|
||||
// L'attente elle-même rend la main tout de suite sur un ctx fini.
|
||||
if err := llmNetBackoff(ctx, 2); err == nil {
|
||||
t.Fatal("l'attente doit être interrompue par le ctx")
|
||||
}
|
||||
}
|
||||
|
||||
// Les erreurs de transport qui méritent une reprise, et celles qui n'en méritent
|
||||
// pas. Une faute de frappe dans l'URL ne se répare pas en attendant.
|
||||
func TestErreursDeTransportRejouables(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, e := range []error{syscall.ECONNREFUSED, syscall.ECONNRESET, io.EOF, io.ErrUnexpectedEOF} {
|
||||
if !llmRetryableErr(ctx, e) {
|
||||
t.Errorf("%v devrait être rejouable (moteur qui redémarre ou connexion perdue)", e)
|
||||
}
|
||||
}
|
||||
if llmRetryableErr(ctx, errors.New("unsupported protocol scheme")) {
|
||||
t.Error("une erreur de configuration ne se répare pas en réessayant")
|
||||
}
|
||||
if llmRetryableErr(ctx, nil) {
|
||||
t.Error("pas d'erreur, pas de reprise")
|
||||
}
|
||||
}
|
||||
|
||||
// Les codes de reprise, et ceux qui n'en sont pas.
|
||||
func TestStatutsRejouables(t *testing.T) {
|
||||
for _, c := range []int{http.StatusTooManyRequests, http.StatusBadGateway,
|
||||
http.StatusServiceUnavailable, http.StatusGatewayTimeout} {
|
||||
if !llmRetryableStatus(c) {
|
||||
t.Errorf("%d dit « pas maintenant » : rejouable", c)
|
||||
}
|
||||
}
|
||||
for _, c := range []int{200, 400, 401, 404, 500} {
|
||||
if llmRetryableStatus(c) {
|
||||
t.Errorf("%d ne doit pas déclencher de reprise", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// L'attente double à chaque tentative, se plafonne, et ne déborde pas : un
|
||||
// décalage de bits sur un n non borné repasserait à zéro en silence — la reprise
|
||||
// deviendrait une rafale.
|
||||
func TestAttenteCroissantePuisPlafonnee(t *testing.T) {
|
||||
if got := llmNetDelay(0); got != llmNetWait {
|
||||
t.Errorf("1re attente = %v, attendu %v", got, llmNetWait)
|
||||
}
|
||||
if got := llmNetDelay(1); got != 2*llmNetWait {
|
||||
t.Errorf("2e attente = %v, attendu le double", got)
|
||||
}
|
||||
for _, n := range []int{10, 20, 64, 1000} {
|
||||
if got := llmNetDelay(n); got != llmNetMaxWait {
|
||||
t.Errorf("llmNetDelay(%d) = %v, attendu le plafond %v", n, got, llmNetMaxWait)
|
||||
}
|
||||
}
|
||||
if got := llmNetDelay(-1); got != llmNetWait {
|
||||
t.Errorf("llmNetDelay(-1) = %v, attendu la 1re attente", got)
|
||||
}
|
||||
}
|
||||
@@ -48,8 +48,6 @@ const (
|
||||
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).
|
||||
mcpMaxOutput = 12000
|
||||
)
|
||||
|
||||
// mcpSession est une connexion vivante à un serveur MCP.
|
||||
@@ -398,10 +396,12 @@ func mcpCallOnce(ref mcpToolRef, args map[string]any) (string, error) {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Le résultat complet part au modèle (même règle que la lecture mémoire) :
|
||||
// plus de troncature ici. Un serveur MCP est configuré EXPRÈS par le
|
||||
// propriétaire de la machine pour ce qu'il rend ; le couper au milieu, c'est
|
||||
// rendre la réponse inutilisable. L'UI, elle, n'affiche qu'un aperçu et
|
||||
// charge le reste à la demande (voir fillToolResult et /api/chat/tool-result).
|
||||
out := flattenMCPContent(res)
|
||||
if r := []rune(out); len(r) > mcpMaxOutput {
|
||||
out = string(r[:mcpMaxOutput]) + "\n…[tronqué]"
|
||||
}
|
||||
if res.IsError {
|
||||
return "[l'outil a renvoyé une erreur]\n" + out, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
package loki
|
||||
|
||||
// mem_crypto.go — primitives de chiffrement de la mémoire.
|
||||
//
|
||||
// Modèle à enveloppe (voir docs/chiffrement-memoire-et-sauvegarde.md) :
|
||||
// - DEK (Data Encryption Key) : 32 octets aléatoires, chiffre chaque page
|
||||
// (AES-256-GCM, nonce aléatoire par écriture). Ne change jamais.
|
||||
// - KEK (Key Encryption Key) : dérivée d'un mot de passe par Argon2id, sert
|
||||
// UNIQUEMENT à (dé)wrapper la DEK (voir mem_vault.go).
|
||||
//
|
||||
// Aucune dépendance exotique : AES-GCM de la stdlib + Argon2id de x/crypto.
|
||||
// La DEK en clair ne touche jamais le disque : elle vit en RAM le temps d'une
|
||||
// session déverrouillée, et sur disque uniquement sous forme wrappée.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
const (
|
||||
memDEKLen = 32 // AES-256
|
||||
memNonceLen = 12 // taille standard du nonce GCM
|
||||
memSaltLen = 16 // sel Argon2id
|
||||
)
|
||||
|
||||
// Paramètres Argon2id par défaut. Stockés dans chaque wrap du keyvault pour que
|
||||
// de futurs réglages n'invalident pas les coffres existants (on relit toujours
|
||||
// les paramètres écrits, jamais ces constantes, lors du déballage).
|
||||
const (
|
||||
argonTime = 3
|
||||
argonMemory = 64 * 1024 // 64 Mio
|
||||
argonThreads = 1
|
||||
)
|
||||
|
||||
// En-tête d'une page chiffrée. Sert de marqueur de format ET d'AAD (le tag GCM
|
||||
// couvre ainsi la version : impossible de rejouer un blob d'une autre version).
|
||||
var memPageMagic = []byte("LOKIMEMv1")
|
||||
|
||||
// errNotEncrypted signale un contenu qui n'est pas une page chiffrée valide
|
||||
// (mauvais magic) — utile pour distinguer « clair » de « chiffré » à la volée.
|
||||
var errNotEncrypted = errors.New("contenu non chiffré (magic absent)")
|
||||
|
||||
// randBytes renvoie n octets aléatoires cryptographiques.
|
||||
func randBytes(n int) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(rand.Reader, b); err != nil {
|
||||
return nil, fmt.Errorf("source aléatoire indisponible : %w", err)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// deriveKEK dérive une clé de 32 octets d'un mot de passe via Argon2id.
|
||||
func deriveKEK(password string, salt []byte, t, m uint32, p uint8) []byte {
|
||||
if t == 0 {
|
||||
t = argonTime
|
||||
}
|
||||
if m == 0 {
|
||||
m = argonMemory
|
||||
}
|
||||
if p == 0 {
|
||||
p = argonThreads
|
||||
}
|
||||
return argon2.IDKey([]byte(password), salt, t, m, p, memDEKLen)
|
||||
}
|
||||
|
||||
// gcmSeal chiffre plaintext avec key (AES-256-GCM) et renvoie nonce||ciphertext.
|
||||
// aad (données authentifiées mais non chiffrées) lie le blob à un contexte.
|
||||
func gcmSeal(key, plaintext, aad []byte) ([]byte, error) {
|
||||
gcm, err := memNewGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce, err := randBytes(memNonceLen)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ct := gcm.Seal(nil, nonce, plaintext, aad)
|
||||
out := make([]byte, 0, len(nonce)+len(ct))
|
||||
out = append(out, nonce...)
|
||||
out = append(out, ct...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// gcmOpen déchiffre un blob nonce||ciphertext produit par gcmSeal. Une clé
|
||||
// erronée ou un blob altéré renvoie une erreur (jamais un contenu partiel).
|
||||
func gcmOpen(key, blob, aad []byte) ([]byte, error) {
|
||||
gcm, err := memNewGCM(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(blob) < memNonceLen {
|
||||
return nil, errors.New("blob trop court")
|
||||
}
|
||||
nonce, ct := blob[:memNonceLen], blob[memNonceLen:]
|
||||
pt, err := gcm.Open(nil, nonce, ct, aad)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("déchiffrement refusé (clé erronée ou donnée altérée) : %w", err)
|
||||
}
|
||||
return pt, nil
|
||||
}
|
||||
|
||||
func memNewGCM(key []byte) (cipher.AEAD, error) {
|
||||
if len(key) != memDEKLen {
|
||||
return nil, fmt.Errorf("clé de %d octets, %d attendus", len(key), memDEKLen)
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cipher.NewGCM(block)
|
||||
}
|
||||
|
||||
// encPage chiffre le contenu d'une page mémoire : magic || nonce || ciphertext.
|
||||
// Le magic sert d'AAD (authentifié) et de marqueur de format.
|
||||
func encPage(dek, plaintext []byte) ([]byte, error) {
|
||||
sealed, err := gcmSeal(dek, plaintext, memPageMagic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]byte, 0, len(memPageMagic)+len(sealed))
|
||||
out = append(out, memPageMagic...)
|
||||
out = append(out, sealed...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decPage déchiffre une page produite par encPage. Renvoie errNotEncrypted si
|
||||
// le magic manque (donc si on tombe sur une page restée en clair).
|
||||
func decPage(dek, blob []byte) ([]byte, error) {
|
||||
if !bytes.HasPrefix(blob, memPageMagic) {
|
||||
return nil, errNotEncrypted
|
||||
}
|
||||
return gcmOpen(dek, blob[len(memPageMagic):], memPageMagic)
|
||||
}
|
||||
|
||||
// looksEncrypted indique si un contenu disque est une page chiffrée (magic).
|
||||
func looksEncrypted(blob []byte) bool { return bytes.HasPrefix(blob, memPageMagic) }
|
||||
|
||||
// memCryptoSelfTest vérifie que la chaîne crypto marche RÉELLEMENT sur cette
|
||||
// machine/ce build avant qu'on chiffre la moindre donnée : round-trip DEK sur
|
||||
// une page, plus un cycle KEK wrap/unwrap. Toute anomalie renvoie une erreur, et
|
||||
// l'appelant DOIT alors refuser d'activer le chiffrement (on ne touche pas à la
|
||||
// mémoire tant que ceci n'a pas réussi).
|
||||
func memCryptoSelfTest() error {
|
||||
dek, err := randBytes(memDEKLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sample := []byte("loki auto-test — éàçùô — 0123456789 — ✔")
|
||||
|
||||
enc, err := encPage(dek, sample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test chiffrement page : %w", err)
|
||||
}
|
||||
dec, err := decPage(dek, enc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test déchiffrement page : %w", err)
|
||||
}
|
||||
if !bytes.Equal(dec, sample) {
|
||||
return errors.New("auto-test : round-trip page incohérent")
|
||||
}
|
||||
// Une DEK erronée DOIT échouer (sinon le GCM ne protège rien).
|
||||
badKey, _ := randBytes(memDEKLen)
|
||||
if _, err := decPage(badKey, enc); err == nil {
|
||||
return errors.New("auto-test : une clé erronée a déchiffré (GCM cassé ?!)")
|
||||
}
|
||||
|
||||
// Cycle KEK : wrap la DEK sous un mot de passe, puis déballe.
|
||||
salt, err := randBytes(memSaltLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kek := deriveKEK("mot-de-passe-auto-test", salt, argonTime, argonMemory, argonThreads)
|
||||
box, err := gcmSeal(kek, dek, []byte("loki-vault-selftest"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test wrap DEK : %w", err)
|
||||
}
|
||||
got, err := gcmOpen(kek, box, []byte("loki-vault-selftest"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("auto-test unwrap DEK : %w", err)
|
||||
}
|
||||
if !bytes.Equal(got, dek) {
|
||||
return errors.New("auto-test : DEK déballée ≠ DEK d'origine")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemCryptoSelfTest(t *testing.T) {
|
||||
if err := memCryptoSelfTest(); err != nil {
|
||||
t.Fatalf("auto-test crypto échoué : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncDecPageRoundTrip(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
cases := []string{"", "court", "# titre\n\nlignes\néàçù ✔\n", strings.Repeat("x", 100000)}
|
||||
for _, c := range cases {
|
||||
enc, err := encPage(dek, []byte(c))
|
||||
if err != nil {
|
||||
t.Fatalf("encPage: %v", err)
|
||||
}
|
||||
if !looksEncrypted(enc) {
|
||||
t.Fatal("looksEncrypted devrait être vrai sur une page chiffrée")
|
||||
}
|
||||
dec, err := decPage(dek, enc)
|
||||
if err != nil {
|
||||
t.Fatalf("decPage: %v", err)
|
||||
}
|
||||
if !bytes.Equal(dec, []byte(c)) {
|
||||
t.Fatalf("round-trip incohérent pour %q", c[:min(len(c), 20)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecPagePlaintextDetected(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
if _, err := decPage(dek, []byte("# page en clair")); err != errNotEncrypted {
|
||||
t.Fatalf("attendu errNotEncrypted, obtenu %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecPageWrongKeyFails(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
bad, _ := randBytes(memDEKLen)
|
||||
enc, _ := encPage(dek, []byte("secret"))
|
||||
if _, err := decPage(bad, enc); err == nil {
|
||||
t.Fatal("une clé erronée n'aurait pas dû déchiffrer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecPageTamperedFails(t *testing.T) {
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
enc, _ := encPage(dek, []byte("secret"))
|
||||
enc[len(enc)-1] ^= 0xFF // altère le tag
|
||||
if _, err := decPage(dek, enc); err == nil {
|
||||
t.Fatal("un blob altéré n'aurait pas dû déchiffrer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileVerified(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "sub", "f.bin")
|
||||
data := []byte("contenu vérifié éàç")
|
||||
if err := memWriteFileVerified(p, data, 0o600); err != nil {
|
||||
t.Fatalf("écriture vérifiée: %v", err)
|
||||
}
|
||||
back, _ := os.ReadFile(p)
|
||||
if !bytes.Equal(back, data) {
|
||||
t.Fatal("relecture ≠ écrit")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package loki
|
||||
|
||||
// mem_fsutil.go — écritures fichier sûres pour la mémoire : temporaire + fsync +
|
||||
// rename atomique. Aucune donnée n'est jamais écrite « en place » par-dessus une
|
||||
// ancienne : on écrit à côté, on force sur disque, puis on bascule d'un rename
|
||||
// (atomique au niveau du système de fichiers).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// memWriteFileAtomic écrit data dans path de façon atomique et durable. En cas
|
||||
// d'échec, path conserve son ancien contenu (le temporaire est nettoyé).
|
||||
func memWriteFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-"+filepath.Base(path)+"-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
cleanup := func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil { // durabilité : sur disque avant rename
|
||||
cleanup()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, perm); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// memWriteFileVerified écrit data atomiquement PUIS relit le fichier et compare
|
||||
// octet pour octet. C'est la garantie « ce qui est sur le disque est bien ce
|
||||
// qu'on voulait » avant toute suppression de l'original. En cas d'écart, le
|
||||
// fichier écrit est retiré et une erreur est renvoyée.
|
||||
func memWriteFileVerified(path string, data []byte, perm os.FileMode) error {
|
||||
if err := memWriteFileAtomic(path, data, perm); err != nil {
|
||||
return err
|
||||
}
|
||||
back, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relecture de vérification impossible : %w", err)
|
||||
}
|
||||
if len(back) != len(data) {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("vérification échouée : %d octets relus, %d écrits", len(back), len(data))
|
||||
}
|
||||
for i := range back {
|
||||
if back[i] != data[i] {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("vérification échouée : octet %d diffère", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package loki
|
||||
|
||||
// mem_health.go — état de santé de la mémoire pour l'UI : chiffrement actif ?
|
||||
// verrouillé ? toutes les pages déchiffrables ? combien de copies du keyvault ?
|
||||
// dernier snapshot ? C'est le tableau de bord qui rassure d'un coup d'œil.
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
// MemHealth résume l'état de la mémoire (sérialisé tel quel vers l'UI).
|
||||
type MemHealth struct {
|
||||
Encrypted bool `json:"encrypted"` // chiffrement activé (drapeau)
|
||||
Fully bool `json:"fully"` // TOUT est réellement chiffré au repos (pages + conversations)
|
||||
Locked bool `json:"locked"` // chiffré mais DEK absente
|
||||
Pages int `json:"pages"` // nb de pages
|
||||
Readable int `json:"readable"` // pages déchiffrables (ou claires)
|
||||
Unreadable int `json:"unreadable"` // pages illisibles (chiffrées + verrouillé, ou corrompues)
|
||||
Mixed bool `json:"mixed"` // mélange clair/chiffré (migration en cours ?)
|
||||
VaultCopies int `json:"vault_copies"` // nb de copies du keyvault présentes (sur 3)
|
||||
HasRecovery bool `json:"has_recovery"` // une clé de récupération est enregistrée
|
||||
Snapshots int `json:"snapshots"` // nb de snapshots locaux
|
||||
LastSnapshot string `json:"last_snapshot"` // ID du snapshot le plus récent
|
||||
MigrationBusy bool `json:"migration_busy"` // une migration est en cours (journal présent)
|
||||
}
|
||||
|
||||
// memHealth calcule l'état courant.
|
||||
func memHealth() MemHealth {
|
||||
h := MemHealth{Encrypted: memEncActive(), Locked: memEncActive() && !memUnlocked()}
|
||||
|
||||
clair, chiffre := 0, 0
|
||||
for _, name := range mdPages() {
|
||||
h.Pages++
|
||||
p, _ := safeMemPath(name)
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
h.Unreadable++
|
||||
continue
|
||||
}
|
||||
if looksEncrypted(raw) {
|
||||
chiffre++
|
||||
if _, err := decodeMemContent(raw); err == nil {
|
||||
h.Readable++
|
||||
} else {
|
||||
h.Unreadable++
|
||||
}
|
||||
} else {
|
||||
clair++
|
||||
h.Readable++
|
||||
}
|
||||
}
|
||||
h.Mixed = clair > 0 && chiffre > 0
|
||||
|
||||
// Fully : la case « activer le chiffrement » ne doit être cochée QUE si TOUT est
|
||||
// réellement chiffré au repos — aucune page en clair ET aucune valeur de
|
||||
// conversation en clair. Vérifiable même verrouillé (looksEncrypted lit le magic
|
||||
// sans la DEK). Un chiffrement partiel/raté laisse donc la case DÉCOCHÉE.
|
||||
if h.Encrypted {
|
||||
full := clair == 0 && h.Pages == chiffre // toutes les pages chiffrées (0 en clair)
|
||||
if full {
|
||||
for _, b := range encryptedBuckets {
|
||||
if !bucketFullyEncrypted(b) {
|
||||
full = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
h.Fully = full
|
||||
}
|
||||
|
||||
// Copies du keyvault présentes.
|
||||
if _, err := os.Stat(vaultPathPrimary()); err == nil {
|
||||
h.VaultCopies++
|
||||
}
|
||||
if _, err := os.Stat(vaultPathBackup()); err == nil {
|
||||
h.VaultCopies++
|
||||
}
|
||||
if len(getBytes(bkState, vaultDBKey)) > 0 {
|
||||
h.VaultCopies++
|
||||
}
|
||||
if v, _ := loadVault(); v != nil {
|
||||
h.HasRecovery = v.hasKind(wrapRecovery)
|
||||
}
|
||||
|
||||
snaps := listSnapshots()
|
||||
h.Snapshots = len(snaps)
|
||||
if len(snaps) > 0 {
|
||||
h.LastSnapshot = snaps[0].ID
|
||||
}
|
||||
_, h.MigrationBusy = readMigrationJournal()
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package loki
|
||||
|
||||
// mem_index.go — maintien AUTOMATIQUE de l'index MEMORY.md du projet, par le CODE
|
||||
// (pas par le modèle). À chaque création / suppression / renommage d'une page, on
|
||||
// ajoute ou retire sa ligne `- [titre](fichier.md)` dans MEMORY.md. L'index
|
||||
// reflète donc TOUJOURS l'état réel des pages, quel que soit le modèle — un petit
|
||||
// modèle qui « oublie » de tenir son index ne peut plus le désynchroniser. Le
|
||||
// modèle garde une seule liberté : enrichir l'accroche après le titre, qu'on ne
|
||||
// touche jamais.
|
||||
//
|
||||
// L'index est ensuite INJECTÉ comme un message système au début de la
|
||||
// conversation (et après un compactage) plutôt que recopié dans le prompt à
|
||||
// chaque tour : le modèle sait d'emblée quelles pages existent, sans payer la
|
||||
// liste à chaque échange ni avoir à lancer une recherche pour la découvrir.
|
||||
//
|
||||
// Best-effort partout : si l'index est illisible, on n'échoue pas — il sera
|
||||
// re-synchronisé au prochain passage (reconcileMemIndex).
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const memIndexFile = "MEMORY.md"
|
||||
|
||||
// memIndexPrefix marque le message d'index injecté dans l'historique, pour le
|
||||
// détecter et éviter les doublons.
|
||||
const memIndexPrefix = "Project memory index"
|
||||
|
||||
// memIndexMessage construit le message système d'index à injecter UNE FOIS au
|
||||
// début de la conversation (et après un compactage). ok=false hors mode mémoire
|
||||
// proactif ou si l'index est vide. On n'injecte que l'INDEX (titres + accroches),
|
||||
// jamais le contenu des pages — l'IA fait mem_read pour lire une page.
|
||||
func memIndexMessage() (Message, bool) {
|
||||
if memMode() != MemAlways {
|
||||
return Message{}, false
|
||||
}
|
||||
idx := strings.TrimSpace(MemContent(memIndexFile))
|
||||
// Un index qui n'a que son en-tête ne dit rien au modèle et coûte du contexte
|
||||
// à chaque tour : on attend au moins une VRAIE ligne de page. Le test est fait
|
||||
// sur le début de ligne, parce que l'en-tête cite le format `- [titre](…)` en
|
||||
// exemple — une simple recherche du motif le prendrait pour une page.
|
||||
if idx == "" || !hasIndexedPage(idx) {
|
||||
return Message{}, false
|
||||
}
|
||||
content := memIndexPrefix + " for project \"" + projectName(activeProjectSlug()) +
|
||||
"\" — the pages you can open with mem_read (only titles/hooks here, not their content). Auto-maintained.\n\n" + idx
|
||||
return Message{Role: "system", Content: content}, true
|
||||
}
|
||||
|
||||
// projectContextPrefix marque le message de contexte projet injecté (description),
|
||||
// pour le détecter et éviter les doublons.
|
||||
const projectContextPrefix = "Project context"
|
||||
|
||||
// projectContextMessage construit le message système décrivant le projet actif à
|
||||
// partir de sa description, à injecter UNE FOIS au début de la conversation (et
|
||||
// après un compactage). ok=false si aucune description : l'IA sait alors juste
|
||||
// dans quel projet elle est via l'index mémoire, sans laïus. Indépendant du mode
|
||||
// mémoire — une description reste utile même mémoire coupée.
|
||||
func projectContextMessage() (Message, bool) {
|
||||
slug := activeProjectSlug()
|
||||
desc := strings.TrimSpace(projectDesc(slug))
|
||||
if desc == "" {
|
||||
return Message{}, false
|
||||
}
|
||||
content := projectContextPrefix + " — you are working within the project \"" +
|
||||
projectName(slug) + "\". What this project is about (set by the user):\n\n" + desc
|
||||
return Message{Role: "system", Content: content}, true
|
||||
}
|
||||
|
||||
// projectSystemMessages renvoie, dans l'ordre, tout ce qui situe l'IA dans son
|
||||
// projet : description du projet, index mémoire, index des trackers.
|
||||
//
|
||||
// Ces messages sont injectés dans la vue ENVOYÉE au modèle, jamais persistés dans
|
||||
// c.Messages — même traitement que le prompt système personnalisé (voir
|
||||
// Conversation.generate). Deux conséquences voulues :
|
||||
// - l'index est TOUJOURS à jour : une page créée au milieu d'une conversation y
|
||||
// apparaît au tour suivant, sans attendre un compactage ;
|
||||
// - le compactage ne peut pas les avaler, puisqu'ils ne font pas partie de
|
||||
// l'historique qu'il résume — donc rien à réinjecter après coup.
|
||||
//
|
||||
// Le prix est une invalidation du cache de prompt quand l'index change, c'est-à-
|
||||
// dire quand une page est créée ou supprimée : exactement les moments où le
|
||||
// modèle DOIT voir la nouvelle liste.
|
||||
func projectSystemMessages() []Message {
|
||||
var out []Message
|
||||
if m, ok := projectContextMessage(); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
if m, ok := memIndexMessage(); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
if m, ok := trackerIndexMessage(); ok {
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hasIndexedPage dit si l'index contient au moins une ligne de page réelle
|
||||
// (« - [titre](fichier.md) » en début de ligne).
|
||||
func hasIndexedPage(idx string) bool {
|
||||
for _, line := range strings.Split(idx, "\n") {
|
||||
l := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(l, "- [") && strings.Contains(l, "](") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isIndexFile indique si `name` est le fichier d'index (à ne jamais indexer
|
||||
// lui-même : il se listerait en boucle).
|
||||
func isIndexFile(name string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(name), memIndexFile)
|
||||
}
|
||||
|
||||
// indexLineFor construit la ligne d'index d'une page : `- [titre](fichier.md)`.
|
||||
// Le titre vient de la 1re ligne de la page, à défaut le nom du fichier.
|
||||
func indexLineFor(name string) string {
|
||||
title := name
|
||||
if t := titleOf(MemContent(name)); t != "" {
|
||||
title = t
|
||||
}
|
||||
return "- [" + title + "](" + name + ")"
|
||||
}
|
||||
|
||||
// indexRefFor est le motif qui identifie la ligne d'une page dans l'index :
|
||||
// `](fichier.md)`. Le nom complet ET la parenthèse fermante évitent qu'un nom en
|
||||
// matche un autre dont il serait le suffixe (`notes.md` vs `mes-notes.md`).
|
||||
func indexRefFor(name string) string { return "](" + name + ")" }
|
||||
|
||||
// writeIndex écrit le contenu de l'index. Passe par le disque directement plutôt
|
||||
// que par MemSave : MemSave normalise et pourrait un jour se mettre à indexer,
|
||||
// ce qui bouclerait.
|
||||
func writeIndex(body string) {
|
||||
dir := memoryDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(filepath.Join(dir, memIndexFile), []byte(body), 0o644)
|
||||
}
|
||||
|
||||
// memIndexAdd ajoute la ligne d'une page à MEMORY.md si elle n'y est pas déjà. Si
|
||||
// une ligne pour ce fichier existe (l'IA a pu y écrire une accroche), on la LAISSE
|
||||
// telle quelle : l'index appartient au code pour sa structure, au modèle pour sa
|
||||
// prose.
|
||||
func memIndexAdd(name string) {
|
||||
fn, err := memFileName(name)
|
||||
if err != nil || isIndexFile(fn) {
|
||||
return
|
||||
}
|
||||
ensureIndexSeed()
|
||||
content := MemContent(memIndexFile)
|
||||
ref := indexRefFor(fn)
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.Contains(line, ref) {
|
||||
return // déjà indexée
|
||||
}
|
||||
}
|
||||
writeIndex(strings.TrimRight(content, "\n") + "\n" + indexLineFor(fn) + "\n")
|
||||
}
|
||||
|
||||
// memIndexRemove retire la (les) ligne(s) d'une page de MEMORY.md.
|
||||
func memIndexRemove(name string) {
|
||||
fn, err := memFileName(name)
|
||||
if err != nil || isIndexFile(fn) {
|
||||
return
|
||||
}
|
||||
content := MemContent(memIndexFile)
|
||||
if content == "" {
|
||||
return
|
||||
}
|
||||
ref := indexRefFor(fn)
|
||||
lines := strings.Split(content, "\n")
|
||||
out := make([]string, 0, len(lines))
|
||||
changed := false
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, ref) {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
if !changed {
|
||||
return
|
||||
}
|
||||
writeIndex(strings.TrimRight(strings.Join(out, "\n"), "\n") + "\n")
|
||||
}
|
||||
|
||||
// memIndexRename retire l'ancienne ligne et ajoute la nouvelle.
|
||||
func memIndexRename(oldName, newName string) {
|
||||
if oldName != "" {
|
||||
memIndexRemove(oldName)
|
||||
}
|
||||
memIndexAdd(newName)
|
||||
}
|
||||
|
||||
// reconcileMemIndex complète l'index du projet ACTIF : pour chaque page existante
|
||||
// non référencée dans MEMORY.md, il ajoute sa ligne. ADDITIF — il ne touche ni aux
|
||||
// lignes ni aux accroches déjà présentes. Indispensable après la migration vers
|
||||
// les projets : les pages déplacées à la main sur le disque n'ont jamais été
|
||||
// indexées, puisque l'index n'est alimenté que par la création d'une page.
|
||||
func reconcileMemIndex() {
|
||||
pages := MemList()
|
||||
if len(pages) == 0 {
|
||||
return
|
||||
}
|
||||
ensureIndexSeed()
|
||||
content := MemContent(memIndexFile)
|
||||
var add []string
|
||||
for _, p := range pages {
|
||||
if isIndexFile(p.Name) {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(content, indexRefFor(p.Name)) {
|
||||
continue // déjà indexée
|
||||
}
|
||||
add = append(add, indexLineFor(p.Name))
|
||||
}
|
||||
if len(add) == 0 {
|
||||
return
|
||||
}
|
||||
writeIndex(strings.TrimRight(content, "\n") + "\n" + strings.Join(add, "\n") + "\n")
|
||||
}
|
||||
|
||||
// ensureIndexSeed crée MEMORY.md (avec son en-tête) s'il n'existe pas encore, pour
|
||||
// que l'ajout d'une première ligne ait un fichier où s'écrire.
|
||||
func ensureIndexSeed() {
|
||||
if strings.TrimSpace(MemContent(memIndexFile)) != "" {
|
||||
return
|
||||
}
|
||||
writeIndex(memorySeed(projectName(activeProjectSlug())))
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// L'index est tenu par le CODE : créer une page l'y inscrit, la supprimer l'en
|
||||
// retire. C'est tout l'intérêt — un modèle qui oublie de tenir son index ne peut
|
||||
// plus le désynchroniser.
|
||||
func TestMemIndexSuitLesPages(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
|
||||
if err := MemAdd("docker.md", "# Notes Docker\n\ncontenu"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := MemContent(memIndexFile)
|
||||
if !strings.Contains(idx, "](docker.md)") {
|
||||
t.Fatalf("page créée absente de l'index :\n%s", idx)
|
||||
}
|
||||
if !strings.Contains(idx, "Notes Docker") {
|
||||
t.Fatalf("l'index doit porter le titre de la page :\n%s", idx)
|
||||
}
|
||||
|
||||
if err := MemDelete("docker.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if idx := MemContent(memIndexFile); strings.Contains(idx, "](docker.md)") {
|
||||
t.Fatalf("page supprimée toujours indexée — l'IA la chercherait en vain :\n%s", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// L'accroche écrite après le titre appartient au MODÈLE : réenregistrer la page
|
||||
// ne doit pas l'effacer, sinon tout ce qu'il ajoute disparaît à la prochaine
|
||||
// modification.
|
||||
func TestMemIndexPreserveLAccroche(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Le modèle enrichit la ligne d'index.
|
||||
enriched := strings.Replace(MemContent(memIndexFile), "- [NAS](nas.md)", "- [NAS](nas.md) — adresses IP et partages", 1)
|
||||
if err := MemSave(memIndexFile, "", enriched); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Une réconciliation ne doit pas repasser dessus.
|
||||
reconcileMemIndex()
|
||||
memIndexAdd("nas.md")
|
||||
|
||||
idx := MemContent(memIndexFile)
|
||||
if !strings.Contains(idx, "adresses IP et partages") {
|
||||
t.Fatalf("l'accroche du modèle a été écrasée :\n%s", idx)
|
||||
}
|
||||
if strings.Count(idx, "](nas.md)") != 1 {
|
||||
t.Fatalf("ligne dupliquée pour la même page :\n%s", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// Un nom qui est le SUFFIXE d'un autre ne doit pas emporter sa ligne : c'est ce
|
||||
// que garantit le motif `](fichier.md)` plutôt qu'une simple recherche du nom.
|
||||
func TestMemIndexNeConfondPasLesNomsProches(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
if err := MemAdd("notes.md", "# Notes\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("mes-notes.md", "# Mes notes\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemDelete("notes.md"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
idx := MemContent(memIndexFile)
|
||||
if !strings.Contains(idx, "](mes-notes.md)") {
|
||||
t.Fatalf("la suppression de notes.md a emporté mes-notes.md :\n%s", idx)
|
||||
}
|
||||
if strings.Contains(idx, "](notes.md)") {
|
||||
t.Fatalf("notes.md est toujours indexée :\n%s", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// L'index n'est injecté qu'en mode mémoire proactif : en « sur demande » ou
|
||||
// « désactivée », le lister à chaque tour contredirait le réglage (et coûterait
|
||||
// du contexte pour rien).
|
||||
func TestMemIndexMessageSuitLeModeMemoire(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
if err := MemAdd("x.md", "# X\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := setMemMode(MemAlways); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ok := memIndexMessage()
|
||||
if !ok {
|
||||
t.Fatal("aucun index injecté en mode auto")
|
||||
}
|
||||
if s, _ := m.Content.(string); !strings.HasPrefix(s, memIndexPrefix) || !strings.Contains(s, "](x.md)") {
|
||||
t.Fatalf("message d'index mal formé : %q", s)
|
||||
}
|
||||
|
||||
for _, mode := range []MemMode{MemOnDemand, MemOff} {
|
||||
if err := setMemMode(mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := memIndexMessage(); ok {
|
||||
t.Fatalf("index injecté en mode %q", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// projectSystemMessages est le point d'entrée unique : il doit poser le contexte
|
||||
// projet AVANT l'index mémoire (on situe le chantier, puis on liste), et ne rien
|
||||
// produire quand il n'y a rien à dire.
|
||||
func TestProjectSystemMessagesOrdreEtVide(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
if err := setMemMode(MemAlways); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if msgs := projectSystemMessages(); len(msgs) != 0 {
|
||||
t.Fatalf("%d message(s) alors qu'il n'y a ni description, ni page, ni tracker", len(msgs))
|
||||
}
|
||||
|
||||
if err := setProjectDesc(defaultProjectSlug, "le NAS"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("x.md", "# X\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msgs := projectSystemMessages()
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("%d message(s), attendu 2 (contexte + index)", len(msgs))
|
||||
}
|
||||
first, _ := msgs[0].Content.(string)
|
||||
second, _ := msgs[1].Content.(string)
|
||||
if !strings.HasPrefix(first, projectContextPrefix) {
|
||||
t.Fatalf("le contexte projet doit venir en premier : %q", first)
|
||||
}
|
||||
if !strings.HasPrefix(second, memIndexPrefix) {
|
||||
t.Fatalf("l'index mémoire doit suivre le contexte : %q", second)
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if m.Role != "system" {
|
||||
t.Fatalf("rôle %q : ces messages doivent être fusionnables en tête par normalizeSystemMessages", m.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package loki
|
||||
|
||||
// mem_io.go — couche d'accès transparente aux pages mémoire. C'est le SEUL
|
||||
// endroit qui sait si une page est chiffrée ou non : chat_memory.go passe par
|
||||
// ici et n'a jamais à s'en soucier.
|
||||
//
|
||||
// Choix d'implémentation : le NOM du fichier ne change pas (toujours foo.md).
|
||||
// C'est le CONTENU qui est chiffré en place, précédé d'un magic (voir
|
||||
// mem_crypto.go) qui rend chaque fichier auto-descriptif. Avantage : rien à
|
||||
// changer dans le listing, la recherche ou la validation de chemins — la
|
||||
// détection se fait sur le contenu, pas sur l'extension.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// errMemLocked : page chiffrée alors que la mémoire est verrouillée (DEK absente).
|
||||
var errMemLocked = errors.New("page chiffrée, mémoire verrouillée")
|
||||
|
||||
// memEncActive indique si le chiffrement de la mémoire est activé (réglage
|
||||
// MEM_ENCRYPTED). Indépendant de l'état verrouillé/déverrouillé.
|
||||
func memEncActive() bool {
|
||||
switch strings.ToLower(strings.TrimSpace(ReadConfig()["MEM_ENCRYPTED"])) {
|
||||
case "1", "on", "true", "yes", "oui":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// decodeMemContent transforme le contenu BRUT d'un fichier en texte lisible :
|
||||
// - pas chiffré (pas de magic) : renvoyé tel quel.
|
||||
// - chiffré + DEK en RAM : déchiffré.
|
||||
// - chiffré + verrouillé : errMemLocked.
|
||||
func decodeMemContent(raw []byte) ([]byte, error) {
|
||||
if !looksEncrypted(raw) {
|
||||
return raw, nil
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return nil, errMemLocked
|
||||
}
|
||||
return decPage(dek, raw)
|
||||
}
|
||||
|
||||
// memReadPage lit une page par son nom et renvoie son texte clair. Erreur si le
|
||||
// fichier est absent, ou chiffré alors que la mémoire est verrouillée.
|
||||
func memReadPage(name string) ([]byte, error) {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeMemContent(raw)
|
||||
}
|
||||
|
||||
// memPageText lit une page et renvoie (texte, lisible). Ne renvoie jamais
|
||||
// d'erreur : une page chiffrée non déchiffrable donne readable=false (utile pour
|
||||
// lister/chercher sans planter quand la mémoire est verrouillée).
|
||||
func memPageText(name string) (string, bool) {
|
||||
b, err := memReadPage(name)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
}
|
||||
|
||||
// encodeMemContent prépare le contenu à écrire sur disque selon l'état du
|
||||
// chiffrement : chiffré si activé ET déverrouillé, clair sinon. Renvoie une
|
||||
// erreur si le chiffrement est activé mais la mémoire verrouillée (on refuse
|
||||
// alors d'écrire, plutôt que d'écrire en clair une donnée censée être chiffrée).
|
||||
func encodeMemContent(plain []byte) ([]byte, error) {
|
||||
if !memEncActive() {
|
||||
return plain, nil
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return nil, errMemLocked
|
||||
}
|
||||
return encPage(dek, plain)
|
||||
}
|
||||
|
||||
// memEncoderNow résout l'état du chiffrement (drapeau + DEK) AVANT toute
|
||||
// transaction, et renvoie un encodeur PUR. C'est la seule façon de chiffrer une
|
||||
// valeur écrite À L'INTÉRIEUR d'une transaction bbolt : la base n'est pas
|
||||
// rentrante — memEncActive lit la configuration, donc rouvrirait la base et se
|
||||
// bloquerait sur son propre verrou (vécu sur l'archivage des blocs recall).
|
||||
func memEncoderNow() (func([]byte) ([]byte, error), error) {
|
||||
if !memEncActive() {
|
||||
return func(p []byte) ([]byte, error) { return p, nil }, nil
|
||||
}
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return nil, errMemLocked
|
||||
}
|
||||
return func(p []byte) ([]byte, error) { return encPage(dek, p) }, nil
|
||||
}
|
||||
|
||||
// writeMemFile écrit (crée ou remplace) une page de façon sûre : encode selon
|
||||
// l'état du chiffrement, garde un .bak de la version précédente, écrit de façon
|
||||
// atomique et vérifiée. Un échec laisse l'ancienne version intacte.
|
||||
func writeMemFile(name string, plain []byte) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := encodeMemContent(plain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// .bak de la version précédente (best-effort) : filet en cas de pépin juste
|
||||
// après le rename.
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
return memWriteFileVerified(p, out, 0o600)
|
||||
}
|
||||
|
||||
// writeMemPlain écrit une page EN CLAIR quel que soit l'état du chiffrement.
|
||||
// Réservé au déchiffrement (DisableMemEncryption) : il faut pouvoir poser du
|
||||
// clair pendant que le drapeau MEM_ENCRYPTED est encore levé, sinon writeMemFile
|
||||
// re-chiffrerait ce qu'on cherche justement à déchiffrer.
|
||||
func writeMemPlain(name string, plain []byte) error {
|
||||
p, err := safeMemPath(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
return memWriteFileVerified(p, plain, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
package loki
|
||||
|
||||
// mem_migrate.go — bascule chiffrer / déchiffrer la mémoire, sans jamais risquer
|
||||
// de la perdre. Règles :
|
||||
// - auto-test crypto AVANT de toucher quoi que ce soit ;
|
||||
// - snapshot complet AVANT toute bascule ;
|
||||
// - écriture atomique + relecture vérifiée de CHAQUE page ;
|
||||
// - journal de migration pour reprendre proprement après un crash ;
|
||||
// - une page à moitié migrée reste lisible : le contenu est auto-descriptif
|
||||
// (magic), donc pages chiffrées et pages claires coexistent sans danger.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func migrationJournalPath() string { return filepath.Join(memoryDir(), ".migration") }
|
||||
|
||||
type migrationJournal struct {
|
||||
Op string `json:"op"` // "encrypt" | "decrypt"
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
func writeMigrationJournal(op string) error {
|
||||
b, _ := json.Marshal(migrationJournal{Op: op, StartedAt: time.Now().UTC()})
|
||||
return memWriteFileAtomic(migrationJournalPath(), b, 0o600)
|
||||
}
|
||||
|
||||
func clearMigrationJournal() { os.Remove(migrationJournalPath()) }
|
||||
|
||||
func readMigrationJournal() (*migrationJournal, bool) {
|
||||
b, err := os.ReadFile(migrationJournalPath())
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
var j migrationJournal
|
||||
if json.Unmarshal(b, &j) != nil {
|
||||
return nil, false
|
||||
}
|
||||
return &j, true
|
||||
}
|
||||
|
||||
// mdPages liste les noms de fichiers .md du dossier mémoire (les pages) du projet
|
||||
// ACTIF. Utilisé par les affichages ; le chiffrement, lui, passe par
|
||||
// allMemPageFiles pour couvrir TOUS les projets.
|
||||
func mdPages() []string {
|
||||
entries, err := os.ReadDir(memoryDir())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// allMemPageFiles renvoie les chemins ABSOLUS de toutes les pages .md de TOUS les
|
||||
// projets (memory/<slug>/*.md). Sert au chiffrement/déchiffrement global : la DEK
|
||||
// est unique, mais les pages sont réparties par projet.
|
||||
func allMemPageFiles() []string {
|
||||
var out []string
|
||||
roots, err := os.ReadDir(projectsRoot())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(projectsRoot(), d.Name())
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(e.Name()), ".md") {
|
||||
out = append(out, filepath.Join(dir, e.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EnableMemEncryption chiffre la mémoire. Génère la DEK, la wrappe sous le mot de
|
||||
// passe ET sous une clé de récupération (renvoyée : à montrer UNE fois à
|
||||
// l'utilisateur), puis chiffre chaque page en vérifiant. Idempotent-safe : si
|
||||
// déjà activé, renvoie une erreur claire.
|
||||
func EnableMemEncryption(password string) (recoveryKey string, err error) {
|
||||
if strings.TrimSpace(password) == "" {
|
||||
return "", fmt.Errorf("mot de passe vide")
|
||||
}
|
||||
if memEncActive() {
|
||||
return "", fmt.Errorf("le chiffrement est déjà activé")
|
||||
}
|
||||
// GARDE-FOU ANTI-PERTE : refuser de créer un nouveau coffre si un coffre existe
|
||||
// DÉJÀ sur le disque, même quand le drapeau MEM_ENCRYPTED a disparu. Sinon un
|
||||
// « ré-activer » (drapeau perdu, ex. effacé par un changement de preset) forgeait
|
||||
// une NOUVELLE DEK et re-chiffrait par-dessus : les pages déjà chiffrées sous
|
||||
// l'ancienne DEK devenaient définitivement illisibles. Ici on impose de
|
||||
// DÉVERROUILLER l'existant (unlock) au lieu de re-chiffrer. C'est ce bug qui a
|
||||
// orphelin des pages le 2026-08-25.
|
||||
if vaultExists() {
|
||||
return "", fmt.Errorf("un coffre de chiffrement existe déjà : déverrouille la mémoire (unlock) au lieu de la re-chiffrer")
|
||||
}
|
||||
if err := memCryptoSelfTest(); err != nil {
|
||||
return "", fmt.Errorf("auto-test crypto échoué, chiffrement refusé : %w", err)
|
||||
}
|
||||
if _, err := snapshotMemory("avant-chiffrement"); err != nil {
|
||||
return "", fmt.Errorf("snapshot de sécurité impossible, chiffrement annulé : %w", err)
|
||||
}
|
||||
|
||||
v, dek, err := newVault()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := v.addSecretWrap(dek, wrapPassword, "principal", password); err != nil {
|
||||
return "", err
|
||||
}
|
||||
recoveryKey, err = newRecoveryKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := v.addSecretWrap(dek, wrapRecovery, "récupération", normalizeRecovery(recoveryKey)); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := saveVault(v); err != nil {
|
||||
return "", fmt.Errorf("écriture du keyvault : %w", err)
|
||||
}
|
||||
|
||||
// À partir d'ici la DEK est en RAM et le chiffrement est actif : writeMemFile
|
||||
// chiffrera. On journalise, on migre chaque page, on lève le journal.
|
||||
setMemDEK(dek)
|
||||
if err := SetConfigKey("MEM_ENCRYPTED", "1"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := writeMigrationJournal("encrypt"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := reencryptPlaintextPages(); err != nil {
|
||||
// Journal laissé en place : la reprise au démarrage réessaiera. Les pages
|
||||
// déjà chiffrées sont valides, les pages claires restent lisibles.
|
||||
return recoveryKey, fmt.Errorf("chiffrement partiel (sera repris) : %w", err)
|
||||
}
|
||||
// Étend le chiffrement aux conversations (fil courant + archives + index).
|
||||
if err := reencryptChatStores(); err != nil {
|
||||
return recoveryKey, fmt.Errorf("chiffrement conversations partiel (sera repris) : %w", err)
|
||||
}
|
||||
clearMigrationJournal()
|
||||
scrubPlaintextResidue() // sinon le texte en clair traînerait à côté du chiffré
|
||||
return recoveryKey, nil
|
||||
}
|
||||
|
||||
// scrubPlaintextResidue efface le texte en clair laissé sur le disque APRÈS un
|
||||
// chiffrement réussi et vérifié : les .bak clairs (copies de sauvegarde d'une
|
||||
// écriture) et le snapshot « avant-chiffrement » (copie intégrale en clair).
|
||||
// Sans ça, chiffrer ne servirait à rien — le clair resterait lisible à côté.
|
||||
// Sûr : les pages chiffrées ont été relues et validées une par une, le keyvault
|
||||
// (3 copies + clé de récupération) garantit la récupération.
|
||||
func scrubPlaintextResidue() {
|
||||
// .bak clairs de TOUS les projets (memory/<slug>/*.bak).
|
||||
if roots, err := os.ReadDir(projectsRoot()); err == nil {
|
||||
for _, d := range roots {
|
||||
if !d.IsDir() {
|
||||
continue
|
||||
}
|
||||
dir := filepath.Join(projectsRoot(), d.Name())
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".bak") {
|
||||
continue
|
||||
}
|
||||
p := filepath.Join(dir, e.Name())
|
||||
if b, err := os.ReadFile(p); err == nil && !looksEncrypted(b) {
|
||||
os.Remove(p) // .bak en clair : à retirer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range listSnapshots() {
|
||||
if strings.Contains(s.Reason, "avant-chiffrement") {
|
||||
os.RemoveAll(filepath.Join(snapshotsRoot(), s.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reencryptPlaintextPages (re)chiffre toutes les pages encore en clair, DANS TOUS
|
||||
// LES PROJETS, et vérifie chacune par relecture. Sûr à rejouer.
|
||||
func reencryptPlaintextPages() error {
|
||||
dek, err := currentDEK()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DEK indisponible : %w", err)
|
||||
}
|
||||
for _, p := range allMemPageFiles() {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture %s : %w", p, err)
|
||||
}
|
||||
if looksEncrypted(raw) {
|
||||
continue // déjà fait
|
||||
}
|
||||
enc, err := encPage(dek, raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("chiffrement %s : %w", p, err)
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
if err := memWriteFileVerified(p, enc, 0o600); err != nil {
|
||||
return fmt.Errorf("écriture chiffrée %s : %w", p, err)
|
||||
}
|
||||
// Vérifie que la page se relit et se déchiffre à l'identique.
|
||||
back, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("relecture %s : %w", p, err)
|
||||
}
|
||||
plain, err := decPage(dek, back)
|
||||
if err != nil || string(plain) != string(raw) {
|
||||
return fmt.Errorf("vérification post-chiffrement de %s échouée", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DisableMemEncryption remet la mémoire en clair. Exige que la mémoire soit
|
||||
// déverrouillée (DEK en RAM). Snapshot d'abord, puis chaque page est déchiffrée,
|
||||
// écrite en clair et vérifiée ; enfin le keyvault est retiré.
|
||||
func DisableMemEncryption() error {
|
||||
if !memEncActive() {
|
||||
return fmt.Errorf("le chiffrement n'est pas activé")
|
||||
}
|
||||
if !memUnlocked() {
|
||||
return fmt.Errorf("mémoire verrouillée : déverrouille-la avant de déchiffrer")
|
||||
}
|
||||
if _, err := snapshotMemory("avant-dechiffrement"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, déchiffrement annulé : %w", err)
|
||||
}
|
||||
if err := writeMigrationJournal("decrypt"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// On garde le drapeau LEVÉ et la DEK en RAM pendant toute l'opération : on
|
||||
// écrit le clair via writeMemPlain (qui ignore le drapeau), on vérifie CHAQUE
|
||||
// page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption
|
||||
// laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore
|
||||
// levé, donc DisableMemEncryption peut être relancé).
|
||||
if err := decryptAllPages(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decryptChatStores(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau,
|
||||
// retirer le keyvault et purger la DEK.
|
||||
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
removeVault()
|
||||
clearMemDEK()
|
||||
clearMigrationJournal()
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES
|
||||
// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent).
|
||||
func decryptAllPages() error {
|
||||
for _, p := range allMemPageFiles() {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture %s : %w", p, err)
|
||||
}
|
||||
if !looksEncrypted(raw) {
|
||||
continue // déjà en clair
|
||||
}
|
||||
plain, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("déchiffrement %s impossible, on n'écrase rien : %w", p, err)
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
|
||||
return fmt.Errorf("écriture claire %s : %w", p, err)
|
||||
}
|
||||
back, err := os.ReadFile(p)
|
||||
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
|
||||
return fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resumeMemMigration reprend une migration interrompue au démarrage. Pour une
|
||||
// migration "encrypt", si la DEK est déjà en RAM (déverrouillée), on rejoue le
|
||||
// (re)chiffrement des pages claires ; sinon on laisse le journal en place pour un
|
||||
// prochain déverrouillage. Ne perd jamais de données.
|
||||
func resumeMemMigration() {
|
||||
j, ok := readMigrationJournal()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch j.Op {
|
||||
case "encrypt":
|
||||
if memEncActive() && memUnlocked() {
|
||||
if err := reencryptPlaintextPages(); err == nil {
|
||||
_ = reencryptChatStores()
|
||||
clearMigrationJournal()
|
||||
scrubPlaintextResidue()
|
||||
}
|
||||
}
|
||||
case "decrypt":
|
||||
// Déchiffrement interrompu : si la mémoire est déverrouillée et le drapeau
|
||||
// encore levé, on termine proprement (écrit le clair restant), puis on
|
||||
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
|
||||
// et l'écran de santé signalera le mélange.
|
||||
if memEncActive() && memUnlocked() {
|
||||
if err := decryptAllPages(); err == nil {
|
||||
_ = SetConfigKey("MEM_ENCRYPTED", "")
|
||||
removeVault()
|
||||
clearMemDEK()
|
||||
clearMigrationJournal()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedPages écrit quelques pages en clair dans un $LOKI_HOME de test.
|
||||
func seedPages(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
pages := map[string]string{
|
||||
"projet.md": "# Projet\n\nUn secret très important à ne jamais perdre.\n",
|
||||
"copine.md": "# Perso\n\nAnniversaire le 3 mars. éàçù ✔\n",
|
||||
"vide.md": "# Vide\n",
|
||||
}
|
||||
for name, content := range pages {
|
||||
if err := MemAdd(name, content); err != nil {
|
||||
t.Fatalf("MemAdd %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// MemAdd maintient désormais l'index MEMORY.md par le code : il existe donc sur
|
||||
// disque comme les autres pages. On l'ajoute à l'ensemble attendu (avec son
|
||||
// contenu réel) pour que les comptes et les lectures le prennent en compte.
|
||||
if idx := MemContent(memIndexFile); idx != "" {
|
||||
pages[memIndexFile] = idx
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
func TestEnableEncryptReadDisable(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
pages := seedPages(t)
|
||||
|
||||
rec, err := EnableMemEncryption("motdepasse-fort")
|
||||
if err != nil {
|
||||
t.Fatalf("EnableMemEncryption: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(rec) == "" {
|
||||
t.Fatal("clé de récupération vide")
|
||||
}
|
||||
if !memEncActive() || !memUnlocked() {
|
||||
t.Fatal("devrait être actif et déverrouillé après activation")
|
||||
}
|
||||
|
||||
// Sur disque, les pages doivent être chiffrées (magic présent).
|
||||
for name := range pages {
|
||||
p, _ := safeMemPath(name)
|
||||
raw, _ := os.ReadFile(p)
|
||||
if !looksEncrypted(raw) {
|
||||
t.Fatalf("page %s pas chiffrée sur disque", name)
|
||||
}
|
||||
}
|
||||
// Mais lisibles via l'API (déverrouillé).
|
||||
for name, want := range pages {
|
||||
if got := MemContent(name); got != want {
|
||||
t.Fatalf("contenu %s après chiffrement: %q != %q", name, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Verrouillage : plus rien de lisible, mais on ne perd rien.
|
||||
clearMemDEK()
|
||||
if got := MemContent("projet.md"); got != "" {
|
||||
t.Fatal("une page chiffrée verrouillée ne devrait pas être lisible")
|
||||
}
|
||||
h := memHealth()
|
||||
if !h.Locked || h.Readable != 0 || h.Unreadable != len(pages) {
|
||||
t.Fatalf("santé verrouillée inattendue: %+v", h)
|
||||
}
|
||||
|
||||
// Déverrouillage via mot de passe.
|
||||
v, _ := loadVault()
|
||||
dek, _, err := v.unlockWith("motdepasse-fort")
|
||||
if err != nil {
|
||||
t.Fatalf("unlock: %v", err)
|
||||
}
|
||||
setMemDEK(dek)
|
||||
if got := MemContent("copine.md"); got != pages["copine.md"] {
|
||||
t.Fatal("contenu illisible après déverrouillage")
|
||||
}
|
||||
|
||||
// Déchiffrement complet : retour en clair, keyvault retiré.
|
||||
if err := DisableMemEncryption(); err != nil {
|
||||
t.Fatalf("DisableMemEncryption: %v", err)
|
||||
}
|
||||
if memEncActive() {
|
||||
t.Fatal("chiffrement devrait être désactivé")
|
||||
}
|
||||
for name, want := range pages {
|
||||
p, _ := safeMemPath(name)
|
||||
raw, _ := os.ReadFile(p)
|
||||
if looksEncrypted(raw) {
|
||||
t.Fatalf("page %s encore chiffrée après déchiffrement", name)
|
||||
}
|
||||
if got := MemContent(name); got != want {
|
||||
t.Fatalf("contenu %s après déchiffrement altéré", name)
|
||||
}
|
||||
}
|
||||
if vaultExists() {
|
||||
t.Fatal("le keyvault aurait dû être retiré")
|
||||
}
|
||||
}
|
||||
|
||||
// Récupération : après chiffrement, on doit pouvoir rouvrir avec la clé de
|
||||
// récupération même sans le mot de passe.
|
||||
func TestUnlockWithRecoveryAfterEnable(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
rec, err := EnableMemEncryption("pw")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clearMemDEK()
|
||||
v, _ := loadVault()
|
||||
if _, _, err := v.unlockWith(normalizeRecovery(rec)); err != nil {
|
||||
t.Fatalf("récupération après activation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sécurité : quand la mémoire est chiffrée ET verrouillée, toute écriture doit
|
||||
// être REFUSÉE (jamais écrire en clair une donnée censée être chiffrée).
|
||||
func TestWriteRefusedWhenLocked(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clearMemDEK() // verrouille
|
||||
if err := MemAdd("nouvelle.md", "contenu"); err == nil {
|
||||
t.Fatal("MemAdd aurait dû être refusé (mémoire verrouillée)")
|
||||
}
|
||||
if err := MemSave("projet.md", "", "écrasé"); err == nil {
|
||||
t.Fatal("MemSave aurait dû être refusé (mémoire verrouillée)")
|
||||
}
|
||||
// Le fichier d'origine ne doit pas avoir été touché en clair.
|
||||
p, _ := safeMemPath("projet.md")
|
||||
raw, _ := os.ReadFile(p)
|
||||
if !looksEncrypted(raw) {
|
||||
t.Fatal("la page d'origine a été altérée alors que la mémoire était verrouillée")
|
||||
}
|
||||
}
|
||||
|
||||
// Après un chiffrement réussi, AUCUN texte en clair ne doit subsister : ni .bak
|
||||
// clair, ni snapshot « avant-chiffrement ».
|
||||
func TestNoPlaintextResidueAfterEncrypt(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(memoryDir())
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".bak") {
|
||||
b, _ := os.ReadFile(memoryDir() + "/" + e.Name())
|
||||
if !looksEncrypted(b) {
|
||||
t.Fatalf(".bak en clair résiduel : %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, s := range listSnapshots() {
|
||||
if strings.Contains(s.Reason, "avant-chiffrement") {
|
||||
t.Fatalf("snapshot en clair avant-chiffrement resté : %s", s.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reprise d'un chiffrement interrompu : journal présent + une page restée en
|
||||
// clair, mémoire déverrouillée → resume doit finir le chiffrement.
|
||||
func TestResumeInterruptedEncrypt(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Simule une interruption : on repose une page en clair et on rearme le journal.
|
||||
if err := writeMemPlain("projet.md", []byte("# Projet\n\nclair résiduel\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeMigrationJournal("encrypt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resumeMemMigration()
|
||||
p, _ := safeMemPath("projet.md")
|
||||
raw, _ := os.ReadFile(p)
|
||||
if !looksEncrypted(raw) {
|
||||
t.Fatal("la reprise n'a pas re-chiffré la page restée en clair")
|
||||
}
|
||||
if _, busy := readMigrationJournal(); busy {
|
||||
t.Fatal("le journal aurait dû être levé après reprise réussie")
|
||||
}
|
||||
}
|
||||
|
||||
// Un round-trip via snapshot : on prend une copie, on casse la mémoire, on
|
||||
// restaure.
|
||||
func TestSnapshotRestore(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
pages := seedPages(t)
|
||||
id, err := snapshotMemory("test")
|
||||
if err != nil || id == "" {
|
||||
t.Fatalf("snapshot: %v (id=%q)", err, id)
|
||||
}
|
||||
// Corrompt une page et en supprime une autre.
|
||||
pApath, _ := safeMemPath("projet.md")
|
||||
os.WriteFile(pApath, []byte("CORROMPU"), 0o600)
|
||||
MemDelete("copine.md")
|
||||
|
||||
if err := restoreSnapshot(id); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
for name, want := range pages {
|
||||
if got := MemContent(name); got != want {
|
||||
t.Fatalf("après restauration %s: %q != %q", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package loki
|
||||
|
||||
// mem_snapshots.go — filet de sécurité local : des copies horodatées du dossier
|
||||
// memory/, indépendantes du chiffrement et du relais. Un bug qui corromprait la
|
||||
// mémoire courante se rejoue depuis un snapshot antérieur, même pour un
|
||||
// utilisateur non abonné.
|
||||
//
|
||||
// Les snapshots copient TELS QUELS les fichiers de memory/ (clairs ou chiffrés,
|
||||
// keyvault compris) : ce sont des copies fidèles, jamais une transformation.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Nombre de snapshots conservés (rotation). Au-delà, les plus anciens sont
|
||||
// purgés — sauf ceux marqués « pré-bascule », gardés plus longtemps.
|
||||
const memSnapshotsKept = 20
|
||||
|
||||
func snapshotsRoot() string { return filepath.Join(LokiHome(), "backups", "memory") }
|
||||
|
||||
// MemSnapshot décrit une copie horodatée.
|
||||
type MemSnapshot struct {
|
||||
ID string `json:"id"` // nom de dossier (horodaté)
|
||||
Reason string `json:"reason"` // pourquoi elle a été prise
|
||||
When time.Time `json:"when"`
|
||||
Pages int `json:"pages"` // nb de fichiers .md/.md.enc
|
||||
}
|
||||
|
||||
// snapshotMemory prend une copie du dossier memory/. reason est un libellé court
|
||||
// (ex. "auto", "avant-chiffrement"). Renvoie l'ID de la copie. Ne fait rien (ID
|
||||
// vide) si le dossier mémoire est absent ou vide.
|
||||
func snapshotMemory(reason string) (string, error) {
|
||||
entries, err := os.ReadDir(memoryDir())
|
||||
if err != nil || len(entries) == 0 {
|
||||
return "", nil // rien à sauvegarder
|
||||
}
|
||||
id := time.Now().UTC().Format("20060102-150405") + "-" + sanitizeReason(reason)
|
||||
dst := filepath.Join(snapshotsRoot(), id)
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue // memory/ est plat ; on ignore un éventuel sous-dossier
|
||||
}
|
||||
src := filepath.Join(memoryDir(), e.Name())
|
||||
b, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lecture %s : %w", e.Name(), err)
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(dst, e.Name()), b, 0o600); err != nil {
|
||||
return "", fmt.Errorf("copie %s : %w", e.Name(), err)
|
||||
}
|
||||
}
|
||||
pruneSnapshots()
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// listSnapshots renvoie les snapshots existants, du plus récent au plus ancien.
|
||||
func listSnapshots() []MemSnapshot {
|
||||
entries, err := os.ReadDir(snapshotsRoot())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []MemSnapshot
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
id := e.Name()
|
||||
pages := 0
|
||||
if inner, err := os.ReadDir(filepath.Join(snapshotsRoot(), id)); err == nil {
|
||||
for _, f := range inner {
|
||||
n := strings.ToLower(f.Name())
|
||||
if strings.HasSuffix(n, ".md") || strings.HasSuffix(n, ".md.enc") {
|
||||
pages++
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, MemSnapshot{ID: id, Reason: reasonOf(id), When: whenOf(id), Pages: pages})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID > out[j].ID })
|
||||
return out
|
||||
}
|
||||
|
||||
// restoreSnapshot remplace le contenu de memory/ par celui d'un snapshot. Pour
|
||||
// ne rien perdre en cas d'erreur, on prend D'ABORD un snapshot de sécurité de
|
||||
// l'état courant, puis on écrit chaque fichier de façon vérifiée avant de retirer
|
||||
// les fichiers surnuméraires.
|
||||
func restoreSnapshot(id string) error {
|
||||
src := filepath.Join(snapshotsRoot(), filepath.Base(id))
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("snapshot introuvable : %w", err)
|
||||
}
|
||||
if _, err := snapshotMemory("avant-restauration"); err != nil {
|
||||
return fmt.Errorf("snapshot de sécurité impossible, restauration annulée : %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
restored := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(src, e.Name()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileVerified(filepath.Join(memoryDir(), e.Name()), b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
restored[e.Name()] = true
|
||||
}
|
||||
// Retire de memory/ ce qui n'est pas dans le snapshot (fichiers apparus après).
|
||||
if cur, err := os.ReadDir(memoryDir()); err == nil {
|
||||
for _, f := range cur {
|
||||
if !f.IsDir() && !restored[f.Name()] {
|
||||
os.Remove(filepath.Join(memoryDir(), f.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pruneSnapshots garde les memSnapshotsKept plus récents, en conservant en plus
|
||||
// tous les snapshots « avant-* » (pris avant une opération sensible).
|
||||
func pruneSnapshots() {
|
||||
all := listSnapshots()
|
||||
if len(all) <= memSnapshotsKept {
|
||||
return
|
||||
}
|
||||
kept := 0
|
||||
for _, s := range all {
|
||||
if strings.Contains(s.Reason, "avant") {
|
||||
continue // on garde les snapshots de sécurité
|
||||
}
|
||||
kept++
|
||||
if kept > memSnapshotsKept {
|
||||
os.RemoveAll(filepath.Join(snapshotsRoot(), s.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Petits utilitaires d'ID --------------------------------------------------
|
||||
|
||||
func sanitizeReason(reason string) string {
|
||||
reason = strings.ToLower(strings.TrimSpace(reason))
|
||||
var b strings.Builder
|
||||
for _, r := range reason {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
b.WriteRune(r)
|
||||
} else if r == ' ' || r == '_' {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
if b.Len() == 0 {
|
||||
return "snap"
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func reasonOf(id string) string {
|
||||
// format : 20060102-150405-<reason>
|
||||
parts := strings.SplitN(id, "-", 3)
|
||||
if len(parts) == 3 {
|
||||
return parts[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func whenOf(id string) time.Time {
|
||||
parts := strings.SplitN(id, "-", 3)
|
||||
if len(parts) >= 2 {
|
||||
if t, err := time.Parse("20060102-150405", parts[0]+"-"+parts[1]); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package loki
|
||||
|
||||
// mem_store.go — chiffrement transparent de VALEURS de la base (bbolt), pour
|
||||
// étendre le chiffrement au-delà des pages mémoire : conversations et archives
|
||||
// contiennent aussi des infos sensibles.
|
||||
//
|
||||
// Même enveloppe que les pages (encPage/decPage + magic) : une valeur est
|
||||
// chiffrée quand le chiffrement est actif ET la mémoire déverrouillée, en clair
|
||||
// sinon. La détection se fait sur le magic, donc clair et chiffré cohabitent
|
||||
// (utile pendant une migration ou un état verrouillé).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// errStoreLocked : écriture chiffrée demandée alors que la DEK n'est pas en RAM.
|
||||
var errStoreLocked = errors.New("valeur chiffrée, mémoire verrouillée")
|
||||
|
||||
// putStoreBytes écrit une valeur, chiffrée si le chiffrement est actif ET
|
||||
// déverrouillé. Si actif mais verrouillé, renvoie errStoreLocked SANS écrire
|
||||
// (on ne remplace jamais une valeur chiffrée par du clair).
|
||||
func putStoreBytes(bucket, key string, plain []byte) error {
|
||||
out, err := encodeMemContent(plain)
|
||||
if err != nil {
|
||||
return errStoreLocked
|
||||
}
|
||||
return putBytes(bucket, key, out)
|
||||
}
|
||||
|
||||
// getStoreBytes lit une valeur et la déchiffre au besoin. Renvoie (nil, false)
|
||||
// si absente, ou si chiffrée alors que la mémoire est verrouillée.
|
||||
func getStoreBytes(bucket, key string) ([]byte, bool) {
|
||||
raw := getBytes(bucket, key)
|
||||
if len(raw) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
dec, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return dec, true
|
||||
}
|
||||
|
||||
// storeBytes : getStoreBytes réduit à sa valeur, pour les appelants qui
|
||||
// traitent déjà « absent » et « illisible » de la même façon (un fil vide).
|
||||
func storeBytes(bucket, key string) []byte {
|
||||
b, _ := getStoreBytes(bucket, key)
|
||||
return b
|
||||
}
|
||||
|
||||
// getStoreBytesErr fait la même lecture que getStoreBytes mais SANS avaler
|
||||
// l'erreur d'accès (getBytes, lui, la jette — c'est le piège documenté sur
|
||||
// getBytesErr : « je n'ai pas pu lire » et « il n'y a rien » ne veulent pas dire
|
||||
// la même chose). Une valeur absente reste (nil, nil) — err n'est non-nil QUE
|
||||
// si la base elle-même n'a pas pu être ouverte/lue (verrou bbolt disputé, disque
|
||||
// indisponible…), pas pour une clé qui n'existe simplement pas encore.
|
||||
func getStoreBytesErr(bucket, key string) ([]byte, error) {
|
||||
raw, err := getBytesErr(bucket, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
dec, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
// Chiffré mais verrouillé (ou payload corrompu) : légitime, pas un échec
|
||||
// d'ACCÈS — même repli que getStoreBytes.
|
||||
return nil, nil
|
||||
}
|
||||
return dec, nil
|
||||
}
|
||||
|
||||
// putStoreJSON sérialise puis écrit (chiffré si actif+déverrouillé).
|
||||
func putStoreJSON(bucket, key string, v any) error {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return putStoreBytes(bucket, key, b)
|
||||
}
|
||||
|
||||
// getStoreJSON lit puis décode (déchiffre au besoin). false si absent/verrouillé.
|
||||
func getStoreJSON(bucket, key string, dst any) bool {
|
||||
b, ok := getStoreBytes(bucket, key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return json.Unmarshal(b, dst) == nil
|
||||
}
|
||||
|
||||
// reencryptBucket (re)chiffre toutes les valeurs d'un bucket encore en clair,
|
||||
// avec vérification par relecture. Sûr à rejouer. Exige la DEK en RAM.
|
||||
func reencryptBucket(bucket string) error {
|
||||
for k, v := range allKV(bucket) {
|
||||
raw := []byte(v)
|
||||
if looksEncrypted(raw) {
|
||||
continue
|
||||
}
|
||||
if err := putStoreBytes(bucket, k, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
back, ok := getStoreBytes(bucket, k)
|
||||
if !ok || string(back) != v {
|
||||
return errors.New("vérification post-chiffrement échouée pour " + bucket + "/" + k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bucketFullyEncrypted indique qu'AUCUNE valeur du bucket n'est en clair (toutes
|
||||
// portent le magic). Lisible sans la DEK. Utilisé pour savoir si le chiffrement
|
||||
// est réellement complet.
|
||||
func bucketFullyEncrypted(bucket string) bool {
|
||||
for _, v := range allKV(bucket) {
|
||||
if v != "" && !looksEncrypted([]byte(v)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// encryptedBuckets : les buckets dont les valeurs sont chiffrées quand le
|
||||
// chiffrement est actif. Ici : les discussions (bkChat porte l'index, la
|
||||
// discussion active et le journal de chacune), les blocs archivés au compactage
|
||||
// (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et
|
||||
// les trackers (bkTracker, 3ᵉ type de mémoire).
|
||||
//
|
||||
// Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils
|
||||
// portent des réglages, pas des données personnelles — et surtout le coffre
|
||||
// lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une
|
||||
// boucle : plus moyen de déverrouiller quoi que ce soit.
|
||||
var encryptedBuckets = []string{bkChat, bkRecall, bkTracker}
|
||||
|
||||
// reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK.
|
||||
func reencryptChatStores() error {
|
||||
for _, b := range encryptedBuckets {
|
||||
if err := reencryptBucket(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
|
||||
func decryptChatStores() error {
|
||||
for _, b := range encryptedBuckets {
|
||||
if err := decryptBucket(b); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
|
||||
// la DEK en RAM. Sûr à rejouer.
|
||||
func decryptBucket(bucket string) error {
|
||||
for k, v := range allKV(bucket) {
|
||||
raw := []byte(v)
|
||||
if !looksEncrypted(raw) {
|
||||
continue
|
||||
}
|
||||
plain, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := putBytes(bucket, k, plain); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La case « chiffré » (health.Fully) ne doit être vraie QUE si TOUT est chiffré.
|
||||
func TestFullyReflectsRealState(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("a.md", "# A\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = putBytes(bkChat, convKey("c1"), []byte(`{"id":"c1","log":[]}`))
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !memHealth().Fully {
|
||||
t.Fatal("après chiffrement complet, Fully devrait être vrai")
|
||||
}
|
||||
// Une page en clair réapparue → Fully faux.
|
||||
p, _ := safeMemPath("a.md")
|
||||
if err := os.WriteFile(p, []byte("# clair"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if memHealth().Fully {
|
||||
t.Fatal("une page en clair aurait dû rendre Fully faux")
|
||||
}
|
||||
// Remets la page chiffrée, salis une discussion → Fully faux aussi.
|
||||
if err := writeMemFile("a.md", []byte("# A\n")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = putBytes(bkChat, convKey("c2"), []byte("journal en clair"))
|
||||
if memHealth().Fully {
|
||||
t.Fatal("une discussion en clair aurait dû rendre Fully faux")
|
||||
}
|
||||
}
|
||||
|
||||
// Les discussions (journal + index) et les blocs archivés doivent être chiffrés
|
||||
// à l'activation, illisibles verrouillé, et intacts après déchiffrement.
|
||||
func TestConversationEncryptionLifecycle(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
|
||||
// Une discussion avec son journal et son entrée d'index.
|
||||
_ = putStoreBytes(bkChat, convKey("c1"), []byte(`{"id":"c1","log":[{"seq":1}],"messages":[]}`))
|
||||
convIndexSave([]convMeta{{ID: "c1", Title: "Client confidentiel", Created: 1, Updated: 2}})
|
||||
// Une page mémoire, pour activer le chiffrement (il chiffre tout).
|
||||
if err := MemAdd("note.md", "# Note\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Sur disque, journal ET index doivent porter le magic.
|
||||
if raw := getBytes(bkChat, convKey("c1")); !looksEncrypted(raw) {
|
||||
t.Fatal("journal de discussion non chiffré sur disque")
|
||||
}
|
||||
if raw := getBytes(bkChat, ckIndex); !looksEncrypted(raw) {
|
||||
t.Fatal("index des discussions non chiffré sur disque")
|
||||
}
|
||||
|
||||
// Déverrouillé : lisible.
|
||||
if idx := convIndex(); len(idx) != 1 || idx[0].Title != "Client confidentiel" {
|
||||
t.Fatalf("index illisible alors que déverrouillé: %+v", idx)
|
||||
}
|
||||
|
||||
// Verrouillé : plus rien de lisible, mais rien de perdu.
|
||||
clearMemDEK()
|
||||
if idx := convIndex(); len(idx) != 0 {
|
||||
t.Fatal("les discussions devraient être masquées quand verrouillé")
|
||||
}
|
||||
if b := storeBytes(bkChat, convKey("c1")); len(b) != 0 {
|
||||
t.Fatal("le journal devrait être illisible quand verrouillé")
|
||||
}
|
||||
// putStoreBytes doit REFUSER d'écrire du clair quand verrouillé.
|
||||
if err := putStoreBytes(bkChat, convKey("c1"), []byte("clair")); err != errStoreLocked {
|
||||
t.Fatalf("écriture clair aurait dû être refusée, obtenu %v", err)
|
||||
}
|
||||
if raw := getBytes(bkChat, convKey("c1")); !looksEncrypted(raw) {
|
||||
t.Fatal("le blob chiffré a été écrasé alors que verrouillé")
|
||||
}
|
||||
|
||||
// Re-déverrouille et déchiffre : tout revient en clair et intact.
|
||||
v, _ := loadVault()
|
||||
dek, _, err := v.unlockWith("pw")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setMemDEK(dek)
|
||||
if err := DisableMemEncryption(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if raw := getBytes(bkChat, convKey("c1")); looksEncrypted(raw) {
|
||||
t.Fatal("journal encore chiffré après déchiffrement")
|
||||
}
|
||||
if idx := convIndex(); len(idx) != 1 || idx[0].Title != "Client confidentiel" {
|
||||
t.Fatal("index perdu après déchiffrement")
|
||||
}
|
||||
}
|
||||
|
||||
// Un bloc archivé au compactage est du verbatim de conversation : chiffré comme
|
||||
// le reste, et hors de portée tant que la mémoire est verrouillée.
|
||||
func TestRecallBlocksFollowEncryption(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
if err := MemAdd("note.md", "# Note\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
id, err := archiveRecallBlock("tool: lecture", "tool", "contenu très bavard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if blk, ok := recallGet(id); !ok || blk.Content != "contenu très bavard" {
|
||||
t.Fatal("bloc illisible alors que déverrouillé")
|
||||
}
|
||||
if hits := recallSearch("bavard", 5); len(hits) != 1 {
|
||||
t.Fatalf("la recherche devrait trouver le bloc déverrouillé, obtenu %d", len(hits))
|
||||
}
|
||||
clearMemDEK()
|
||||
if _, ok := recallGet(id); ok {
|
||||
t.Fatal("bloc lisible alors que verrouillé")
|
||||
}
|
||||
if hits := recallSearch("bavard", 5); len(hits) != 0 {
|
||||
t.Fatal("la recherche ne devrait rien trouver quand verrouillé")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
package loki
|
||||
|
||||
// mem_vault.go — le keyvault : la DEK de la mémoire, wrappée par une ou
|
||||
// plusieurs KEK (mot de passe, clé de récupération, mot de passe app), et gardée
|
||||
// en RAM le temps d'une session déverrouillée.
|
||||
//
|
||||
// Le keyvault est le POINT DE DÉFAILLANCE UNIQUE : le perdre = perdre la
|
||||
// mémoire. Il est donc écrit en TROIS copies indépendantes, chacune suffisante
|
||||
// seule, et contient plusieurs wraps indépendants de la même DEK. Perdre le mot
|
||||
// de passe n'est pas fatal tant qu'il reste la clé de récupération.
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Types de wrap. Chaque wrap re-chiffre la MÊME DEK sous une KEK différente.
|
||||
const (
|
||||
wrapPassword = "password" // mot de passe mémoire choisi par l'utilisateur
|
||||
wrapRecovery = "recovery" // clé de récupération (secours anti-perte)
|
||||
wrapE2ERoot = "e2e-root" // racine E2E de l'abonné (mot de passe app)
|
||||
)
|
||||
|
||||
// checkPlain est le témoin scellé par la DEK dans le keyvault : le déballer
|
||||
// prouve que la DEK reconstituée est la bonne (détecte un wrap corrompu).
|
||||
var checkPlain = []byte("loki-memoire-ok-v1")
|
||||
|
||||
type kekParams struct {
|
||||
T uint32 `json:"t"`
|
||||
M uint32 `json:"m"`
|
||||
P uint8 `json:"p"`
|
||||
}
|
||||
|
||||
type vaultWrap struct {
|
||||
Kind string `json:"kind"`
|
||||
Label string `json:"label"`
|
||||
Salt string `json:"salt"` // base64 du sel Argon2id propre à ce wrap
|
||||
KDF kekParams `json:"kdf"`
|
||||
Box string `json:"box"` // base64 de gcmSeal(KEK, DEK, aad)
|
||||
}
|
||||
|
||||
type keyVault struct {
|
||||
V int `json:"v"`
|
||||
Check string `json:"check"` // base64 de gcmSeal(DEK, checkPlain)
|
||||
Wraps []vaultWrap `json:"wraps"`
|
||||
}
|
||||
|
||||
func vaultWrapAAD(kind string) []byte { return []byte("loki-vault/" + kind) }
|
||||
|
||||
// --- Emplacements du keyvault (3 copies indépendantes) -----------------------
|
||||
|
||||
// Le keyvault est GLOBAL (une seule DEK pour la mémoire de TOUS les projets) : il
|
||||
// vit à la racine de LOKI_HOME, pas dans le dossier d'un projet. L'ancien
|
||||
// emplacement (memory/.keyvault) est migré par ensureDefaultProject.
|
||||
func vaultPathPrimary() string { return filepath.Join(LokiHome(), ".keyvault") }
|
||||
func vaultPathBackup() string { return filepath.Join(LokiHome(), ".keyvault.bak") }
|
||||
|
||||
const vaultDBKey = "mem_keyvault"
|
||||
|
||||
// --- État en RAM de la DEK ----------------------------------------------------
|
||||
|
||||
var (
|
||||
memKeyMu sync.RWMutex
|
||||
memDEK []byte // nil = mémoire verrouillée
|
||||
)
|
||||
|
||||
// memUnlocked indique si la DEK est chargée en RAM (mémoire déverrouillée).
|
||||
func memUnlocked() bool {
|
||||
memKeyMu.RLock()
|
||||
defer memKeyMu.RUnlock()
|
||||
return memDEK != nil
|
||||
}
|
||||
|
||||
// setMemDEK charge la DEK en RAM (copie défensive).
|
||||
func setMemDEK(dek []byte) {
|
||||
memKeyMu.Lock()
|
||||
memDEK = append([]byte(nil), dek...)
|
||||
memKeyMu.Unlock()
|
||||
}
|
||||
|
||||
// clearMemDEK purge la DEK de la RAM (verrouille la mémoire).
|
||||
func clearMemDEK() {
|
||||
memKeyMu.Lock()
|
||||
for i := range memDEK {
|
||||
memDEK[i] = 0
|
||||
}
|
||||
memDEK = nil
|
||||
memKeyMu.Unlock()
|
||||
}
|
||||
|
||||
// currentDEK renvoie la DEK en RAM, ou une erreur si la mémoire est verrouillée.
|
||||
func currentDEK() ([]byte, error) {
|
||||
memKeyMu.RLock()
|
||||
defer memKeyMu.RUnlock()
|
||||
if memDEK == nil {
|
||||
return nil, errors.New("mémoire verrouillée — aucune clé chargée")
|
||||
}
|
||||
return append([]byte(nil), memDEK...), nil
|
||||
}
|
||||
|
||||
// --- Construction et manipulation du keyvault --------------------------------
|
||||
|
||||
// newVault crée un keyvault autour d'une DEK fraîche : aucun wrap encore, mais
|
||||
// le témoin est posé. Renvoie aussi la DEK en clair (à garder en RAM, jamais sur
|
||||
// disque).
|
||||
func newVault() (*keyVault, []byte, error) {
|
||||
dek, err := randBytes(memDEKLen)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
check, err := gcmSeal(dek, checkPlain, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &keyVault{V: 1, Check: base64.StdEncoding.EncodeToString(check)}, dek, nil
|
||||
}
|
||||
|
||||
// addSecretWrap (ou remplace) un wrap de la DEK sous un secret (mot de passe ou
|
||||
// clé de récupération). kind identifie le wrap ; un même kind+label écrase le
|
||||
// précédent (rotation de mot de passe = re-wrap, sans toucher aux pages).
|
||||
func (v *keyVault) addSecretWrap(dek []byte, kind, label, secret string) error {
|
||||
salt, err := randBytes(memSaltLen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kp := kekParams{T: argonTime, M: argonMemory, P: argonThreads}
|
||||
kek := deriveKEK(secret, salt, kp.T, kp.M, kp.P)
|
||||
box, err := gcmSeal(kek, dek, vaultWrapAAD(kind))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
w := vaultWrap{
|
||||
Kind: kind,
|
||||
Label: label,
|
||||
Salt: base64.StdEncoding.EncodeToString(salt),
|
||||
KDF: kp,
|
||||
Box: base64.StdEncoding.EncodeToString(box),
|
||||
}
|
||||
for i := range v.Wraps {
|
||||
if v.Wraps[i].Kind == kind && v.Wraps[i].Label == label {
|
||||
v.Wraps[i] = w
|
||||
return nil
|
||||
}
|
||||
}
|
||||
v.Wraps = append(v.Wraps, w)
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeWrap retire les wraps d'un kind (révocation). Renvoie le nombre retiré.
|
||||
// Refuse de vider le dernier wrap (sinon la DEK deviendrait irrécupérable).
|
||||
func (v *keyVault) removeWrap(kind, label string) (int, error) {
|
||||
kept := v.Wraps[:0:0]
|
||||
removed := 0
|
||||
for _, w := range v.Wraps {
|
||||
if w.Kind == kind && (label == "" || w.Label == label) {
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, w)
|
||||
}
|
||||
if removed > 0 && len(kept) == 0 {
|
||||
return 0, errors.New("refus : ce serait le dernier accès à la mémoire")
|
||||
}
|
||||
v.Wraps = kept
|
||||
return removed, nil
|
||||
}
|
||||
|
||||
// unlockWith tente de reconstituer la DEK à partir d'un secret : essaie chaque
|
||||
// wrap, et ne renvoie la DEK que si le témoin la valide. Renvoie le kind du wrap
|
||||
// qui a marché (utile pour l'UI).
|
||||
func (v *keyVault) unlockWith(secret string) ([]byte, string, error) {
|
||||
if len(v.Wraps) == 0 {
|
||||
return nil, "", errors.New("keyvault sans aucun wrap")
|
||||
}
|
||||
for _, w := range v.Wraps {
|
||||
salt, err := base64.StdEncoding.DecodeString(w.Salt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
box, err := base64.StdEncoding.DecodeString(w.Box)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
kek := deriveKEK(secret, salt, w.KDF.T, w.KDF.M, w.KDF.P)
|
||||
dek, err := gcmOpen(kek, box, vaultWrapAAD(w.Kind))
|
||||
if err != nil {
|
||||
continue // mauvais secret pour ce wrap, on essaie le suivant
|
||||
}
|
||||
if err := v.validate(dek); err != nil {
|
||||
continue
|
||||
}
|
||||
return dek, w.Kind, nil
|
||||
}
|
||||
return nil, "", errors.New("secret incorrect (aucun wrap ne correspond)")
|
||||
}
|
||||
|
||||
// validate confirme qu'une DEK candidate ouvre bien le témoin du keyvault.
|
||||
func (v *keyVault) validate(dek []byte) error {
|
||||
check, err := base64.StdEncoding.DecodeString(v.Check)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
got, err := gcmOpen(dek, check, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if subtle.ConstantTimeCompare(got, checkPlain) != 1 {
|
||||
return errors.New("témoin du keyvault invalide")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *keyVault) hasKind(kind string) bool {
|
||||
for _, w := range v.Wraps {
|
||||
if w.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// --- Persistance redondée -----------------------------------------------------
|
||||
|
||||
// saveVault écrit le keyvault dans ses TROIS copies. Réussit si AU MOINS une
|
||||
// copie a pu être écrite (on ne veut jamais bloquer sur une copie inaccessible),
|
||||
// mais renvoie l'erreur agrégée sinon.
|
||||
func saveVault(v *keyVault) error {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var errs []string
|
||||
okAny := false
|
||||
if err := os.MkdirAll(memoryDir(), 0o755); err == nil {
|
||||
if err := memWriteFileAtomic(vaultPathPrimary(), data, 0o600); err == nil {
|
||||
okAny = true
|
||||
} else {
|
||||
errs = append(errs, "primaire: "+err.Error())
|
||||
}
|
||||
} else {
|
||||
errs = append(errs, "memoryDir: "+err.Error())
|
||||
}
|
||||
if err := memWriteFileAtomic(vaultPathBackup(), data, 0o600); err == nil {
|
||||
okAny = true
|
||||
} else {
|
||||
errs = append(errs, "backup: "+err.Error())
|
||||
}
|
||||
if err := putBytes(bkState, vaultDBKey, data); err == nil {
|
||||
okAny = true
|
||||
} else {
|
||||
errs = append(errs, "db: "+err.Error())
|
||||
}
|
||||
if !okAny {
|
||||
return fmt.Errorf("aucune copie du keyvault écrite : %s", strings.Join(errs, " ; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadVault lit le keyvault depuis la première copie valide (primaire, backup,
|
||||
// db), puis RÉPARE silencieusement les copies manquantes/corrompues à partir de
|
||||
// celle qui a marché. Renvoie (nil, nil) si aucun keyvault n'existe encore.
|
||||
func loadVault() (*keyVault, error) {
|
||||
sources := []func() ([]byte, error){
|
||||
func() ([]byte, error) { return os.ReadFile(vaultPathPrimary()) },
|
||||
func() ([]byte, error) { return os.ReadFile(vaultPathBackup()) },
|
||||
func() ([]byte, error) {
|
||||
b := getBytes(bkState, vaultDBKey)
|
||||
if len(b) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return b, nil
|
||||
},
|
||||
}
|
||||
var found *keyVault
|
||||
anyPresent := false
|
||||
healthy := 0 // nb de copies présentes ET valides
|
||||
for _, src := range sources {
|
||||
b, err := src()
|
||||
if err != nil || len(b) == 0 {
|
||||
continue
|
||||
}
|
||||
anyPresent = true
|
||||
var v keyVault
|
||||
if json.Unmarshal(b, &v) == nil && v.V > 0 && len(v.Wraps) > 0 {
|
||||
healthy++
|
||||
if found == nil {
|
||||
vv := v
|
||||
found = &vv
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
if anyPresent {
|
||||
return nil, errors.New("keyvault présent mais illisible dans toutes les copies")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
// Ré-harmonise UNIQUEMENT si une des trois copies manque ou est corrompue :
|
||||
// sinon loadVault (appelé à chaque sondage de santé) réécrirait le disque en
|
||||
// boucle pour rien.
|
||||
if healthy < 3 {
|
||||
_ = saveVault(found)
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
// vaultExists indique si un keyvault est présent (mémoire chiffrée configurée).
|
||||
func vaultExists() bool {
|
||||
v, _ := loadVault()
|
||||
return v != nil
|
||||
}
|
||||
|
||||
// stripServerOpenableWraps retire du coffre tout wrap ouvrable par un secret que
|
||||
// le SERVEUR détient (la clé de pilotage web, jadis ajoutée comme wrap
|
||||
// « clé d'accès »). Objectif : le serveur ne doit JAMAIS pouvoir déchiffrer seul.
|
||||
// La clé de chiffrement ne vit que côté client (localStorage). Idempotent ;
|
||||
// refuse de vider le coffre (removeWrap garde toujours au moins un wrap).
|
||||
func stripServerOpenableWraps() {
|
||||
v, _ := loadVault()
|
||||
if v == nil || !v.hasKind(wrapE2ERoot) {
|
||||
return
|
||||
}
|
||||
if n, err := v.removeWrap(wrapE2ERoot, ""); err == nil && n > 0 {
|
||||
_ = saveVault(v)
|
||||
}
|
||||
}
|
||||
|
||||
// removeVault efface les trois copies du keyvault (après un déchiffrement complet
|
||||
// où il n'a plus lieu d'être).
|
||||
func removeVault() {
|
||||
os.Remove(vaultPathPrimary())
|
||||
os.Remove(vaultPathBackup())
|
||||
putBytes(bkState, vaultDBKey, nil)
|
||||
}
|
||||
|
||||
// --- Clé de récupération ------------------------------------------------------
|
||||
|
||||
// recoveryEnc : base32 Crockford (32 caractères, sans les ambigus I/L/O/U),
|
||||
// pour une saisie humaine sans confusion.
|
||||
var recoveryEnc = base32.NewEncoding("0123456789ABCDEFGHJKMNPQRSTVWXYZ").WithPadding(base32.NoPadding)
|
||||
|
||||
// newRecoveryKey génère une clé de récupération lisible : 20 octets aléatoires,
|
||||
// encodés en groupes de 4 (ex. ABCD-EFGH-JKMN-...). C'est un SECRET fort, à
|
||||
// noter hors ligne ; il rouvre la mémoire même si le mot de passe est perdu.
|
||||
func newRecoveryKey() (string, error) {
|
||||
raw, err := randBytes(20)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
s := recoveryEnc.EncodeToString(raw)
|
||||
var b strings.Builder
|
||||
for i, r := range s {
|
||||
if i > 0 && i%4 == 0 {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// normalizeRecovery met une clé de récupération saisie sous forme canonique
|
||||
// (majuscules, sans séparateurs) pour la dérivation de KEK.
|
||||
func normalizeRecovery(s string) string {
|
||||
s = strings.ToUpper(strings.TrimSpace(s))
|
||||
s = strings.NewReplacer("-", "", " ", "", "\t", "").Replace(s)
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVaultCreateUnlock(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, err := newVault()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := v.addSecretWrap(dek, wrapPassword, "principal", "hunter2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, kind, err := v.unlockWith("hunter2")
|
||||
if err != nil {
|
||||
t.Fatalf("unlock: %v", err)
|
||||
}
|
||||
if kind != wrapPassword {
|
||||
t.Fatalf("kind attendu password, obtenu %s", kind)
|
||||
}
|
||||
if !bytes.Equal(got, dek) {
|
||||
t.Fatal("DEK déballée ≠ DEK d'origine")
|
||||
}
|
||||
if _, _, err := v.unlockWith("mauvais"); err == nil {
|
||||
t.Fatal("un mauvais mot de passe n'aurait pas dû ouvrir")
|
||||
}
|
||||
}
|
||||
|
||||
// Rotation : re-wrapper sous un nouveau mot de passe ne doit PAS changer la DEK.
|
||||
func TestVaultRotation(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "ancien")
|
||||
if err := v.addSecretWrap(dek, wrapPassword, "principal", "nouveau"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := v.unlockWith("ancien"); err == nil {
|
||||
t.Fatal("l'ancien mot de passe aurait dû être remplacé")
|
||||
}
|
||||
got, _, err := v.unlockWith("nouveau")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, dek) {
|
||||
t.Fatal("la rotation a changé la DEK")
|
||||
}
|
||||
}
|
||||
|
||||
// La clé de récupération rouvre la mémoire même si le wrap mot de passe est retiré.
|
||||
func TestVaultRecovery(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "motdepasse")
|
||||
rec, err := newRecoveryKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v.addSecretWrap(dek, wrapRecovery, "récupération", normalizeRecovery(rec))
|
||||
|
||||
// On détruit l'accès mot de passe.
|
||||
if _, err := v.removeWrap(wrapPassword, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, kind, err := v.unlockWith(normalizeRecovery(rec))
|
||||
if err != nil {
|
||||
t.Fatalf("récupération: %v", err)
|
||||
}
|
||||
if kind != wrapRecovery || !bytes.Equal(got, dek) {
|
||||
t.Fatal("la clé de récupération n'a pas rouvert la mémoire")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVaultRefusesLastWrapRemoval(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "x")
|
||||
if _, err := v.removeWrap(wrapPassword, ""); err == nil {
|
||||
t.Fatal("retirer le dernier wrap aurait dû être refusé")
|
||||
}
|
||||
}
|
||||
|
||||
// Persistance : sauver puis relire depuis les 3 copies, y compris après
|
||||
// destruction d'une ou deux copies.
|
||||
func TestVaultPersistRedundancy(t *testing.T) {
|
||||
testHome(t)
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "pw")
|
||||
if err := saveVault(v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Détruit la copie primaire et la db, il reste le backup.
|
||||
os.Remove(vaultPathPrimary())
|
||||
putBytes(bkState, vaultDBKey, nil)
|
||||
got, err := loadVault()
|
||||
if err != nil {
|
||||
t.Fatalf("load après perte de 2 copies: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("keyvault introuvable alors que le backup existe")
|
||||
}
|
||||
// loadVault doit avoir réparé les copies manquantes.
|
||||
if _, err := os.Stat(vaultPathPrimary()); err != nil {
|
||||
t.Fatal("la copie primaire n'a pas été réparée")
|
||||
}
|
||||
d, _, err := got.unlockWith("pw")
|
||||
if err != nil || !bytes.Equal(d, dek) {
|
||||
t.Fatal("keyvault relu inutilisable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemDEKState(t *testing.T) {
|
||||
if memUnlocked() {
|
||||
clearMemDEK()
|
||||
}
|
||||
if _, err := currentDEK(); err == nil {
|
||||
t.Fatal("currentDEK devrait échouer quand verrouillé")
|
||||
}
|
||||
dek, _ := randBytes(memDEKLen)
|
||||
setMemDEK(dek)
|
||||
if !memUnlocked() {
|
||||
t.Fatal("devrait être déverrouillé")
|
||||
}
|
||||
got, err := currentDEK()
|
||||
if err != nil || !bytes.Equal(got, dek) {
|
||||
t.Fatal("currentDEK incohérent")
|
||||
}
|
||||
clearMemDEK()
|
||||
if memUnlocked() {
|
||||
t.Fatal("devrait être verrouillé après clear")
|
||||
}
|
||||
}
|
||||
|
||||
// Zéro-connaissance : le secret détenu par le SERVEUR (clé de pilotage web_key)
|
||||
// ne doit PAS pouvoir ouvrir le coffre, et stripServerOpenableWraps retire tout
|
||||
// wrap qui le permettrait.
|
||||
func TestServerCannotOpenVault(t *testing.T) {
|
||||
testHome(t)
|
||||
putStr(bkState, "web_key", "cle-de-pilotage-serveur")
|
||||
v, dek, _ := newVault()
|
||||
v.addSecretWrap(dek, wrapPassword, "principal", "mon-mdp-client")
|
||||
// La clé de pilotage du serveur n'ouvre pas le coffre.
|
||||
if _, _, err := v.unlockWith("cle-de-pilotage-serveur"); err == nil {
|
||||
t.Fatal("la clé serveur n'aurait jamais dû ouvrir le coffre")
|
||||
}
|
||||
// Si un wrap « clé d'accès » (ouvrable par le serveur) traînait, on le retire.
|
||||
v.addSecretWrap(dek, wrapE2ERoot, "clé d'accès", "cle-de-pilotage-serveur")
|
||||
saveVault(v)
|
||||
if got, _, err := v.unlockWith("cle-de-pilotage-serveur"); err != nil || !bytes.Equal(got, dek) {
|
||||
t.Fatal("pré-condition: le wrap clé d'accès devrait ouvrir avant strip")
|
||||
}
|
||||
stripServerOpenableWraps()
|
||||
v2, _ := loadVault()
|
||||
if _, _, err := v2.unlockWith("cle-de-pilotage-serveur"); err == nil {
|
||||
t.Fatal("après strip, la clé serveur ne doit plus ouvrir le coffre")
|
||||
}
|
||||
if _, _, err := v2.unlockWith("mon-mdp-client"); err != nil {
|
||||
t.Fatal("le mot de passe client doit toujours ouvrir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoveryKeyFormat(t *testing.T) {
|
||||
rec, err := newRecoveryKey()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if normalizeRecovery(rec) == "" {
|
||||
t.Fatal("clé de récupération vide")
|
||||
}
|
||||
if normalizeRecovery("abcd-efgh") != "ABCDEFGH" {
|
||||
t.Fatalf("normalisation inattendue: %q", normalizeRecovery("abcd-efgh"))
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,12 @@ const (
|
||||
nodePairCodeTTL = 10 * time.Minute
|
||||
nodeCallTimeout = 5 * time.Minute // un shell distant peut être long
|
||||
nodeHelloWait = 15 * time.Second
|
||||
// nodePingInterval : cadence du keepalive WebSocket poste↔serveur. 25 s tient
|
||||
// sous les délais d'inactivité usuels des proxys et relais (~60-100 s).
|
||||
nodePingInterval = 25 * time.Second
|
||||
// nodePingTimeout : au-delà, le poste ne répond plus — on ferme au lieu de
|
||||
// laisser une connexion morte occuper le registre.
|
||||
nodePingTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// pairedNode est un poste appairé, tel que persisté dans bkState["nodes"].
|
||||
@@ -279,6 +285,34 @@ func handleNodeWS(w http.ResponseWriter, r *http.Request) {
|
||||
defer nodeUnregister(nc)
|
||||
touchNodeSeen(pn.ID)
|
||||
|
||||
// Keepalive : un ping WebSocket périodique garde la connexion vivante à travers
|
||||
// les intermédiaires qui coupent les canaux INACTIFS (relais, Caddy, Cloudflare :
|
||||
// ~60-100 s sans trafic). Sans lui, un poste au repos — l'IA ne le sollicite pas
|
||||
// en permanence — était déconnecté en boucle puis reconnecté après backoff.
|
||||
// coder/websocket sérialise Ping avec les écritures de données en interne : pas
|
||||
// de course avec nc.send. Un ping sans réponse ferme la connexion, ce qui
|
||||
// débloque le Read de la boucle ci-dessous.
|
||||
pingCtx, stopPing := context.WithCancel(ctx)
|
||||
defer stopPing()
|
||||
go func() {
|
||||
t := time.NewTicker(nodePingInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-pingCtx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
pc, cancel := context.WithTimeout(pingCtx, nodePingTimeout)
|
||||
err := c.Ping(pc)
|
||||
cancel()
|
||||
if err != nil {
|
||||
_ = c.Close(websocket.StatusGoingAway, "ping sans réponse")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Boucle de lecture : des « result » chiffrés qui débloquent l'appel en attente.
|
||||
for {
|
||||
m, err := nc.readMsg(ctx)
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
package loki
|
||||
|
||||
// projects.go — les PROJETS. Un projet cloisonne une mémoire (un dossier de pages
|
||||
// .md + un index MEMORY.md, façon Claude Code) ET son propre jeu de discussions.
|
||||
// Fini la mémoire globale fourre-tout : un projet par chantier, chacun avec sa
|
||||
// mémoire et ses conversations indépendantes.
|
||||
//
|
||||
// Sur disque : memory/<slug>/*.md (+ MEMORY.md). Le `slug` est l'identité STABLE
|
||||
// (il ne bouge jamais, même à un renommage) ; le `Name` est le libellé affiché.
|
||||
// memoryDir() (run.go) pointe sur le dossier du projet ACTIF, donc TOUT le code
|
||||
// mémoire existant (chat_memory.go, l'index, les outils mem_*) hérite du
|
||||
// cloisonnement sans le savoir — c'est la seule couture à toucher.
|
||||
//
|
||||
// Les discussions portent un champ Project (convMeta) et sont filtrées à
|
||||
// l'affichage ; les tâches planifiées portent le projet qu'elles visent.
|
||||
//
|
||||
// Le registre (liste + projet actif) vit dans le bucket state : ce sont des noms
|
||||
// de projets et un slug, pas des données de conversation.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Project décrit un projet. CreatedAt sert au tri (plus ancien d'abord : le projet
|
||||
// « Générale » historique reste en tête).
|
||||
type Project struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
// Desc = description libre de ce à quoi sert le projet. Fournie À L'IA en tête
|
||||
// de conversation (projectContextMessage) pour qu'elle sache d'emblée sur quoi
|
||||
// elle travaille, sans avoir à le lui réexpliquer à chaque nouvelle discussion.
|
||||
Desc string `json:"desc,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultProjectSlug = "generale"
|
||||
defaultProjectName = "Générale"
|
||||
stProjectsKey = "projects" // bkState : []Project (JSON)
|
||||
stActiveProjectKey = "active_project" // bkState : slug du projet actif
|
||||
)
|
||||
|
||||
// projectsRoot est le dossier racine de la mémoire, qui contient un sous-dossier
|
||||
// par projet (memory/<slug>/...). C'est l'ancien dossier `memory` plat, promu en
|
||||
// racine de projets.
|
||||
func projectsRoot() string { return filepath.Join(LokiHome(), "memory") }
|
||||
|
||||
// projectMemoryDir renvoie le dossier mémoire d'un projet donné (par slug).
|
||||
func projectMemoryDir(slug string) string {
|
||||
if slug == "" {
|
||||
slug = defaultProjectSlug
|
||||
}
|
||||
return filepath.Join(projectsRoot(), slug)
|
||||
}
|
||||
|
||||
// --- projet actif (cache RAM + override pour les tâches) ----------------------
|
||||
|
||||
var (
|
||||
activeProjMu sync.Mutex
|
||||
activeProjCache string // "" = pas encore lu ; sinon slug mémorisé
|
||||
|
||||
projOverrideMu sync.Mutex
|
||||
projOverride string // non vide = force le projet actif (exécution d'une tâche)
|
||||
)
|
||||
|
||||
// activeProjectSlug renvoie le slug du projet actif. Priorité : override
|
||||
// d'exécution de tâche (RAM), puis cache, puis la valeur persistée, puis le
|
||||
// défaut. Lecture PURE : ne crée jamais de dossier (l'amorçage se fait dans
|
||||
// ensureDefaultProject au démarrage).
|
||||
func activeProjectSlug() string {
|
||||
projOverrideMu.Lock()
|
||||
o := projOverride
|
||||
projOverrideMu.Unlock()
|
||||
if o != "" {
|
||||
return o
|
||||
}
|
||||
activeProjMu.Lock()
|
||||
defer activeProjMu.Unlock()
|
||||
if activeProjCache != "" {
|
||||
return activeProjCache
|
||||
}
|
||||
s := strings.TrimSpace(getStr(bkState, stActiveProjectKey))
|
||||
if s == "" {
|
||||
s = defaultProjectSlug
|
||||
}
|
||||
activeProjCache = s
|
||||
return s
|
||||
}
|
||||
|
||||
// setActiveProject persiste le projet actif et rafraîchit le cache.
|
||||
func setActiveProject(slug string) error {
|
||||
slug = strings.TrimSpace(slug)
|
||||
if slug == "" {
|
||||
return fmt.Errorf("slug vide")
|
||||
}
|
||||
if err := putStr(bkState, stActiveProjectKey, slug); err != nil {
|
||||
return err
|
||||
}
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = slug
|
||||
activeProjMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// setProjectOverride force (ou libère avec "") le projet actif VU PAR memoryDir(),
|
||||
// le temps de l'exécution d'une tâche planifiée sur un projet précis. Sans effet
|
||||
// sur le projet actif persisté ni sur l'UI. Sûr car une seule inférence tourne à
|
||||
// la fois (verrou de génération), donc pas de course avec un tour utilisateur.
|
||||
func setProjectOverride(slug string) {
|
||||
projOverrideMu.Lock()
|
||||
projOverride = slug
|
||||
projOverrideMu.Unlock()
|
||||
}
|
||||
|
||||
// withProject exécute fn en forçant temporairement le projet vu par memoryDir().
|
||||
// Séquentiel et bref : sûr tant qu'aucune génération ne tourne (les appelants sont
|
||||
// des actions utilisateur, gardées en amont).
|
||||
func withProject(slug string, fn func()) {
|
||||
setProjectOverride(slug)
|
||||
defer setProjectOverride("")
|
||||
fn()
|
||||
}
|
||||
|
||||
// --- registre -----------------------------------------------------------------
|
||||
|
||||
// listProjects renvoie les projets connus, le plus ancien d'abord. Garantit au
|
||||
// moins le projet par défaut (base neuve).
|
||||
func listProjects() []Project {
|
||||
var out []Project
|
||||
if b := getBytes(bkState, stProjectsKey); len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &out)
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = []Project{{Slug: defaultProjectSlug, Name: defaultProjectName, CreatedAt: time.Now().UnixMilli()}}
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
||||
return out
|
||||
}
|
||||
|
||||
func saveProjects(list []Project) error {
|
||||
b, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return putBytes(bkState, stProjectsKey, b)
|
||||
}
|
||||
|
||||
// projectExists indique si un slug correspond à un projet connu.
|
||||
func projectExists(slug string) bool {
|
||||
for _, p := range listProjects() {
|
||||
if p.Slug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// projectName renvoie le libellé affiché d'un slug (le slug lui-même à défaut).
|
||||
func projectName(slug string) string {
|
||||
for _, p := range listProjects() {
|
||||
if p.Slug == slug {
|
||||
return p.Name
|
||||
}
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
// projectDesc renvoie la description d'un projet (vide si aucune / introuvable).
|
||||
func projectDesc(slug string) string {
|
||||
for _, p := range listProjects() {
|
||||
if p.Slug == slug {
|
||||
return p.Desc
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// setProjectDesc enregistre la description d'un projet, tronquée à une taille
|
||||
// raisonnable : c'est un contexte injecté à CHAQUE conversation, pas un cahier des
|
||||
// charges. Une chaîne vide efface la description.
|
||||
func setProjectDesc(slug, desc string) error {
|
||||
desc = strings.TrimSpace(desc)
|
||||
const max = 2000
|
||||
if len([]rune(desc)) > max {
|
||||
desc = strings.TrimSpace(string([]rune(desc)[:max]))
|
||||
}
|
||||
list := listProjects()
|
||||
found := false
|
||||
for i := range list {
|
||||
if list[i].Slug == slug {
|
||||
list[i].Desc = desc
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("projet introuvable")
|
||||
}
|
||||
return saveProjects(list)
|
||||
}
|
||||
|
||||
var identSlugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
||||
// slugifyIdent réduit un nom à un slug de fichier sûr (minuscules, alphanum + tirets).
|
||||
// Les accents courants sont translittérés pour ne pas tout perdre : « Réseau NAS »
|
||||
// donne « reseau-nas » et non « r-seau-nas ».
|
||||
func slugifyIdent(name string) string {
|
||||
s := strings.ToLower(strings.TrimSpace(name))
|
||||
repl := map[rune]string{'à': "a", 'â': "a", 'ä': "a", 'á': "a", 'ã': "a",
|
||||
'é': "e", 'è': "e", 'ê': "e", 'ë': "e", 'î': "i", 'ï': "i", 'í': "i",
|
||||
'ô': "o", 'ö': "o", 'ó': "o", 'õ': "o", 'ù': "u", 'û': "u", 'ü': "u", 'ú': "u",
|
||||
'ç': "c", 'ñ': "n"}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if v, ok := repl[r]; ok {
|
||||
b.WriteString(v)
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
slug := identSlugRe.ReplaceAllString(b.String(), "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
if len(slug) > 40 {
|
||||
slug = strings.Trim(slug[:40], "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
// memorySeed est le contenu initial de l'index MEMORY.md d'un nouveau projet.
|
||||
func memorySeed(name string) string {
|
||||
return "# Index mémoire — " + name + "\n\n" +
|
||||
"Une ligne par page de ce projet (`- [titre](fichier.md) — accroche`). " +
|
||||
"Tenu à jour automatiquement à la création et à la suppression d'une page ; " +
|
||||
"l'accroche après le titre, elle, est libre.\n"
|
||||
}
|
||||
|
||||
// createProject crée un projet à partir d'un nom d'affichage : slug unique, dossier
|
||||
// mémoire, index MEMORY.md amorcé. Renvoie le projet créé.
|
||||
func createProject(name string) (Project, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return Project{}, fmt.Errorf("nom vide")
|
||||
}
|
||||
if len([]rune(name)) > 60 {
|
||||
name = string([]rune(name)[:60])
|
||||
}
|
||||
base := slugifyIdent(name)
|
||||
if base == "" {
|
||||
base = "projet"
|
||||
}
|
||||
// Slug unique : suffixe -2, -3… en cas de collision.
|
||||
list := listProjects()
|
||||
taken := map[string]bool{}
|
||||
for _, p := range list {
|
||||
taken[p.Slug] = true
|
||||
}
|
||||
slug := base
|
||||
for i := 2; taken[slug]; i++ {
|
||||
slug = fmt.Sprintf("%s-%d", base, i)
|
||||
}
|
||||
dir := projectMemoryDir(slug)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
idx := filepath.Join(dir, memIndexFile)
|
||||
if _, err := os.Stat(idx); os.IsNotExist(err) {
|
||||
_ = os.WriteFile(idx, []byte(memorySeed(name)), 0o600)
|
||||
}
|
||||
p := Project{Slug: slug, Name: name, CreatedAt: time.Now().UnixMilli()}
|
||||
list = append(list, p)
|
||||
if err := saveProjects(list); err != nil {
|
||||
return Project{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// renameProject change le LIBELLÉ d'un projet. Le slug, les chemins disque et les
|
||||
// discussions ne bougent pas : renommer ne doit rien casser.
|
||||
func renameProject(slug, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("nom vide")
|
||||
}
|
||||
if len([]rune(name)) > 60 {
|
||||
name = string([]rune(name)[:60])
|
||||
}
|
||||
list := listProjects()
|
||||
found := false
|
||||
for i := range list {
|
||||
if list[i].Slug == slug {
|
||||
list[i].Name = name
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("projet introuvable")
|
||||
}
|
||||
return saveProjects(list)
|
||||
}
|
||||
|
||||
// deleteProject supprime un projet : son dossier mémoire, ses trackers ET ses
|
||||
// discussions. Refuse de supprimer le dernier projet restant. Si c'était le projet
|
||||
// actif, on rebascule sur un autre.
|
||||
func deleteProject(slug string) error {
|
||||
list := listProjects()
|
||||
if len(list) <= 1 {
|
||||
return fmt.Errorf("impossible de supprimer le dernier projet")
|
||||
}
|
||||
kept := make([]Project, 0, len(list))
|
||||
found := false
|
||||
for _, p := range list {
|
||||
if p.Slug == slug {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, p)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("projet introuvable")
|
||||
}
|
||||
// Discussions du projet.
|
||||
for _, m := range convIndexForProject(slug) {
|
||||
_ = convDelete(m.ID)
|
||||
}
|
||||
// Trackers du projet.
|
||||
for _, k := range trackerKeysForProject(slug) {
|
||||
_ = putBytes(bkTracker, k, nil)
|
||||
}
|
||||
// Dossier disque du projet.
|
||||
_ = os.RemoveAll(projectMemoryDir(slug))
|
||||
if err := saveProjects(kept); err != nil {
|
||||
return err
|
||||
}
|
||||
if activeProjectSlug() == slug {
|
||||
_ = setActiveProject(kept[0].Slug)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// moveMemPage déplace une page mémoire (.md) d'un projet vers un autre, puis met à
|
||||
// jour les DEUX index MEMORY.md. Best-effort sur les index : reconcileMemIndex
|
||||
// répare au pire au démarrage suivant.
|
||||
func moveMemPage(name, fromSlug, toSlug string) error {
|
||||
if strings.TrimSpace(fromSlug) == strings.TrimSpace(toSlug) {
|
||||
return fmt.Errorf("déjà dans ce projet")
|
||||
}
|
||||
if !projectExists(toSlug) {
|
||||
return fmt.Errorf("projet cible introuvable")
|
||||
}
|
||||
fn, err := memFileName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isIndexFile(fn) {
|
||||
return fmt.Errorf("l'index du projet n'est pas déplaçable")
|
||||
}
|
||||
src := filepath.Join(projectMemoryDir(fromSlug), fn)
|
||||
dst := filepath.Join(projectMemoryDir(toSlug), fn)
|
||||
raw, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return fmt.Errorf("page introuvable")
|
||||
}
|
||||
if _, err := os.Stat(dst); err == nil {
|
||||
return fmt.Errorf("une page du même nom existe déjà dans le projet cible")
|
||||
}
|
||||
if err := os.MkdirAll(projectMemoryDir(toSlug), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dst, raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(src)
|
||||
withProject(fromSlug, func() { memIndexRemove(fn) })
|
||||
withProject(toSlug, func() { memIndexAdd(fn) })
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureDefaultProject amorce le projet « Générale » et MIGRE l'existant (mémoire
|
||||
// plate + discussions sans projet) au premier démarrage de cette version.
|
||||
// Idempotent : à chaque démarrage suivant, il ne fait plus rien.
|
||||
func ensureDefaultProject() {
|
||||
// 1. Le registre PERSISTÉ contient-il le projet par défaut ? On lit le brut (pas
|
||||
// listProjects, qui synthétise toujours un défaut) pour savoir s'il faut écrire.
|
||||
var stored []Project
|
||||
if b := getBytes(bkState, stProjectsKey); len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &stored)
|
||||
}
|
||||
hasDefault := false
|
||||
for _, p := range stored {
|
||||
if p.Slug == defaultProjectSlug {
|
||||
hasDefault = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDefault {
|
||||
// Base neuve OU migration : on inscrit « Générale » en tête et on persiste.
|
||||
stored = append([]Project{{Slug: defaultProjectSlug, Name: defaultProjectName, CreatedAt: time.Now().UnixMilli()}}, stored...)
|
||||
_ = saveProjects(stored)
|
||||
}
|
||||
|
||||
genMem := projectMemoryDir(defaultProjectSlug)
|
||||
_ = os.MkdirAll(genMem, 0o755)
|
||||
|
||||
// 2. Migration de la mémoire plate historique (memory/*.md) vers le dossier du
|
||||
// projet Générale. Les pages plates vivaient à la RACINE de memory/, qui
|
||||
// accueille désormais les sous-dossiers de projet : on ne déplace donc que les
|
||||
// FICHIERS restés à la racine — les sous-dossiers sont déjà des projets.
|
||||
if entries, err := os.ReadDir(projectsRoot()); err == nil {
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
src := filepath.Join(projectsRoot(), e.Name())
|
||||
dst := filepath.Join(genMem, e.Name())
|
||||
if _, err := os.Stat(dst); os.IsNotExist(err) {
|
||||
_ = os.Rename(src, dst)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Index MEMORY.md du projet Générale s'il manque.
|
||||
idx := filepath.Join(genMem, memIndexFile)
|
||||
if _, err := os.Stat(idx); os.IsNotExist(err) {
|
||||
_ = os.WriteFile(idx, []byte(memorySeed(defaultProjectName)), 0o600)
|
||||
}
|
||||
|
||||
// 4. Discussions et tâches historiques sans projet → Générale.
|
||||
tagOrphanConversations(defaultProjectSlug)
|
||||
tagOrphanTasks(defaultProjectSlug)
|
||||
|
||||
// 5. Projet actif par défaut.
|
||||
if strings.TrimSpace(getStr(bkState, stActiveProjectKey)) == "" {
|
||||
_ = setActiveProject(defaultProjectSlug)
|
||||
}
|
||||
|
||||
// 6. Index de Générale : les pages migrées (déplacées sur le disque) n'ont jamais
|
||||
// été indexées — l'index n'est alimenté que par la création d'une page. On le
|
||||
// complète depuis les pages réelles. On force temporairement le projet vu par
|
||||
// memoryDir sur Générale (sûr au démarrage, aucune génération en cours).
|
||||
withProject(defaultProjectSlug, reconcileMemIndex)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newProjectHome monte un LOKI_HOME neuf et repose les caches de projet : le slug
|
||||
// actif est mémorisé en RAM (activeProjCache), donc sans ça un test hériterait du
|
||||
// projet du précédent alors qu'il croit partir d'une base vierge.
|
||||
func newProjectHome(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
t.Cleanup(func() {
|
||||
activeProjMu.Lock()
|
||||
activeProjCache = ""
|
||||
activeProjMu.Unlock()
|
||||
setProjectOverride("")
|
||||
})
|
||||
return home
|
||||
}
|
||||
|
||||
// La migration doit être SILENCIEUSE et sans perte : une installation d'avant les
|
||||
// projets a ses pages à plat dans memory/, elles doivent se retrouver dans
|
||||
// « Générale » — et y être INDEXÉES, ce que rien ne fait autrement (l'index n'est
|
||||
// alimenté que par la création d'une page).
|
||||
func TestEnsureDefaultProjectMigreLaMemoirePlate(t *testing.T) {
|
||||
home := newProjectHome(t)
|
||||
flat := filepath.Join(home, "memory")
|
||||
if err := os.MkdirAll(flat, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(flat, "docker.md"), []byte("# Notes Docker\n\ncontenu"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ensureDefaultProject()
|
||||
|
||||
if activeProjectSlug() != defaultProjectSlug {
|
||||
t.Fatalf("projet actif %q après migration, attendu %q", activeProjectSlug(), defaultProjectSlug)
|
||||
}
|
||||
moved := filepath.Join(flat, defaultProjectSlug, "docker.md")
|
||||
if _, err := os.Stat(moved); err != nil {
|
||||
t.Fatalf("la page plate n'a pas été déplacée dans le projet : %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(flat, "docker.md")); err == nil {
|
||||
t.Fatal("la page est restée à la racine : elle serait invisible ET dupliquée")
|
||||
}
|
||||
idx := MemContent(memIndexFile)
|
||||
if !strings.Contains(idx, "](docker.md)") {
|
||||
t.Fatalf("page migrée absente de l'index — elle ne serait jamais proposée à l'IA :\n%s", idx)
|
||||
}
|
||||
if !strings.Contains(idx, "Notes Docker") {
|
||||
t.Fatalf("l'index doit porter le TITRE de la page, pas seulement son nom :\n%s", idx)
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotence : le démarrage passe par ensureDefaultProject à chaque fois. Un
|
||||
// second passage ne doit ni redupliquer une ligne d'index, ni créer un doublon
|
||||
// de projet.
|
||||
func TestEnsureDefaultProjectEstIdempotent(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
if err := MemAdd("notes.md", "# Notes\n\nx"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := MemContent(memIndexFile)
|
||||
|
||||
ensureDefaultProject()
|
||||
|
||||
if after := MemContent(memIndexFile); after != before {
|
||||
t.Fatalf("l'index a changé au 2e passage :\navant %q\naprès %q", before, after)
|
||||
}
|
||||
if n := len(listProjects()); n != 1 {
|
||||
t.Fatalf("%d projets après deux amorçages, attendu 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Le cloisonnement est la promesse centrale : une page écrite dans un projet ne
|
||||
// doit pas exister dans un autre.
|
||||
func TestMemoireCloisonneeParProjet(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
|
||||
if err := MemAdd("generale.md", "# Page de Générale\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := createProject("Serveur NAS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setActiveProject(p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("nas.md", "# Page du NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
names := func() []string {
|
||||
var out []string
|
||||
for _, m := range MemList() {
|
||||
out = append(out, m.Name)
|
||||
}
|
||||
return out
|
||||
}
|
||||
got := strings.Join(names(), " ")
|
||||
if strings.Contains(got, "generale.md") {
|
||||
t.Fatalf("la page de Générale est visible depuis le projet NAS : %s", got)
|
||||
}
|
||||
if !strings.Contains(got, "nas.md") {
|
||||
t.Fatalf("la page du projet NAS est absente de son propre projet : %s", got)
|
||||
}
|
||||
if err := setActiveProject(defaultProjectSlug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(names(), " "); strings.Contains(got, "nas.md") {
|
||||
t.Fatalf("la page du NAS fuit dans Générale : %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Renommer ne doit RIEN casser : le slug est l'identité, il ne bouge pas, donc
|
||||
// les pages restent là où elles sont.
|
||||
func TestRenameProjectGardeLeSlugEtLesPages(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
p, err := createProject("Chantier")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setActiveProject(p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("plan.md", "# Plan\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := renameProject(p.Slug, "Chantier rénové"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if projectName(p.Slug) != "Chantier rénové" {
|
||||
t.Fatalf("libellé non mis à jour : %q", projectName(p.Slug))
|
||||
}
|
||||
if activeProjectSlug() != p.Slug {
|
||||
t.Fatalf("le slug a bougé au renommage : %q", activeProjectSlug())
|
||||
}
|
||||
if len(MemList()) != 2 { // la page + l'index
|
||||
t.Fatalf("pages perdues au renommage : %d", len(MemList()))
|
||||
}
|
||||
}
|
||||
|
||||
// Le dernier projet n'est pas supprimable : sans lui, il n'y a plus de mémoire du
|
||||
// tout et aucun endroit où retomber.
|
||||
func TestDeleteProjectRefuseLeDernier(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
if err := deleteProject(defaultProjectSlug); err == nil {
|
||||
t.Fatal("la suppression du dernier projet a été acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// Supprimer le projet ACTIF doit rebasculer sur un autre, sinon l'application
|
||||
// pointe sur un dossier qui n'existe plus.
|
||||
func TestDeleteProjectActifRebascule(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
p, err := createProject("Temporaire")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setActiveProject(p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := deleteProject(p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if activeProjectSlug() == p.Slug {
|
||||
t.Fatal("le projet actif est resté sur le projet supprimé")
|
||||
}
|
||||
if projectExists(p.Slug) {
|
||||
t.Fatal("le projet supprimé figure encore au registre")
|
||||
}
|
||||
if _, err := os.Stat(projectMemoryDir(p.Slug)); err == nil {
|
||||
t.Fatal("le dossier mémoire du projet supprimé existe encore")
|
||||
}
|
||||
}
|
||||
|
||||
// L'override sert aux tâches planifiées : il doit dévier memoryDir SANS toucher
|
||||
// au projet persisté, sinon lancer une tâche changerait le projet à l'écran.
|
||||
func TestProjectOverrideNeChangePasLActif(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
p, err := createProject("Veille")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := ""
|
||||
withProject(p.Slug, func() { seen = memoryDir() })
|
||||
if seen != projectMemoryDir(p.Slug) {
|
||||
t.Fatalf("memoryDir n'a pas suivi l'override : %q", seen)
|
||||
}
|
||||
if activeProjectSlug() != defaultProjectSlug {
|
||||
t.Fatalf("l'override a changé le projet actif : %q", activeProjectSlug())
|
||||
}
|
||||
if memoryDir() != projectMemoryDir(defaultProjectSlug) {
|
||||
t.Fatal("memoryDir est resté sur le projet de l'override après sa libération")
|
||||
}
|
||||
}
|
||||
|
||||
// slugifyIdent porte l'identité d'un projet ET d'un tracker : les accents doivent
|
||||
// être translittérés, pas jetés — « Réseau » qui devient « r-seau » est illisible
|
||||
// dans un chemin de fichier.
|
||||
func TestSlugifyIdent(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Serveur NAS": "serveur-nas",
|
||||
"Réseau & Wifi": "reseau-wifi",
|
||||
" Déjà vu ": "deja-vu",
|
||||
"Poids (kg)": "poids-kg",
|
||||
"": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := slugifyIdent(in); got != want {
|
||||
t.Errorf("slugifyIdent(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Une description non vide doit atteindre l'IA ; une description vide ne doit pas
|
||||
// produire un message de contexte creux.
|
||||
func TestProjectContextMessage(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
|
||||
if _, ok := projectContextMessage(); ok {
|
||||
t.Fatal("un message de contexte est produit alors qu'aucune description n'existe")
|
||||
}
|
||||
if err := setProjectDesc(defaultProjectSlug, "administration du NAS Unraid"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ok := projectContextMessage()
|
||||
if !ok {
|
||||
t.Fatal("aucun message de contexte malgré une description")
|
||||
}
|
||||
s, _ := m.Content.(string)
|
||||
if !strings.HasPrefix(s, projectContextPrefix) {
|
||||
t.Fatalf("préfixe manquant, le message ne serait pas reconnaissable : %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "NAS Unraid") {
|
||||
t.Fatalf("la description n'est pas dans le message : %q", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
// push.go — notifications Web Push : quand un tour utilisateur se termine
|
||||
// (chat_conversation.go, turn_done), le serveur Loki pousse une notification
|
||||
// vers les navigateurs abonnés (iPhone verrouillé, Android, desktop) via leur
|
||||
// service de push (Apple/Google). C'est le SERVEUR qui pousse, directement vers
|
||||
// le endpoint du navigateur — ça marche donc app fermée, contrairement à une
|
||||
// notification purement client (l'onglet caché relâche son flux SSE).
|
||||
//
|
||||
// Ce que ça ne fait PAS transiter par le tunnel E2E : la charge utile est
|
||||
// chiffrée de bout en bout avec les clés PROPRES de l'abonnement (RFC 8291),
|
||||
// puis remise au service de push public. L'inscription, elle, passe par /api
|
||||
// (donc jfetch/E2E) comme le reste.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
webpush "github.com/SherClockHolmes/webpush-go"
|
||||
)
|
||||
|
||||
// fmtDurFR : durée → « 42s », « 3 mn 05s », « 1 h 12 mn » pour le corps de la
|
||||
// notification. Miroir serveur de fmtElapsed() côté UI (09-stream.js).
|
||||
func fmtDurFR(d time.Duration) string {
|
||||
secs := int(d.Round(time.Second).Seconds())
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
h, m, s := secs/3600, (secs%3600)/60, secs%60
|
||||
switch {
|
||||
case h > 0:
|
||||
return fmt.Sprintf("%d h %02d mn", h, m)
|
||||
case m > 0:
|
||||
return fmt.Sprintf("%d mn %02ds", m, s)
|
||||
default:
|
||||
return fmt.Sprintf("%ds", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Stockées dans bkState (une seule base bbolt, voir store.go).
|
||||
const (
|
||||
stPushVAPIDPriv = "push_vapid_priv" // clé privée VAPID (base64url)
|
||||
stPushVAPIDPub = "push_vapid_pub" // clé publique VAPID (base64url), servie à l'UI
|
||||
stPushSubs = "push_subs" // liste JSON des abonnements
|
||||
)
|
||||
|
||||
// pushSub : un abonnement PushSubscription du navigateur. Même forme que l'objet
|
||||
// JS, on le reçoit tel quel depuis l'UI et on le rejoue tel quel à l'envoi.
|
||||
type pushSub struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys webpush.Keys `json:"keys"`
|
||||
}
|
||||
|
||||
// vapidMu sérialise la génération paresseuse des clés : deux requêtes /api/push/key
|
||||
// concurrentes sur une base neuve ne doivent pas produire deux paires différentes
|
||||
// (la seconde écraserait la première, invalidant les abonnements de la première).
|
||||
var vapidMu sync.Mutex
|
||||
|
||||
// vapidKeys renvoie la paire VAPID, la générant et la persistant au premier appel.
|
||||
// La paire est STABLE pour la vie de l'installation : la changer invaliderait tous
|
||||
// les abonnements déjà pris (le navigateur signe l'abonnement avec la clé publique).
|
||||
func vapidKeys() (priv, pub string, err error) {
|
||||
vapidMu.Lock()
|
||||
defer vapidMu.Unlock()
|
||||
priv = getStr(bkState, stPushVAPIDPriv)
|
||||
pub = getStr(bkState, stPushVAPIDPub)
|
||||
if priv != "" && pub != "" {
|
||||
return priv, pub, nil
|
||||
}
|
||||
priv, pub, err = webpush.GenerateVAPIDKeys()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = putStr(bkState, stPushVAPIDPriv, priv); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if err = putStr(bkState, stPushVAPIDPub, pub); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return priv, pub, nil
|
||||
}
|
||||
|
||||
// subsMu sérialise les mutations de la liste d'abonnements (lecture-modif-écriture) :
|
||||
// une inscription et une purge concurrentes ne doivent pas s'écraser l'une l'autre.
|
||||
var subsMu sync.Mutex
|
||||
|
||||
func loadSubs() []pushSub {
|
||||
var subs []pushSub
|
||||
getJSON(bkState, stPushSubs, &subs)
|
||||
return subs
|
||||
}
|
||||
|
||||
func saveSubs(subs []pushSub) error { return putJSON(bkState, stPushSubs, subs) }
|
||||
|
||||
// addSub enregistre un abonnement (idempotent : ré-inscrire le même endpoint met
|
||||
// simplement à jour ses clés au lieu de le dupliquer — un navigateur ré-abonné
|
||||
// garde le même endpoint mais peut renouveler ses clés).
|
||||
func addSub(s pushSub) error {
|
||||
if s.Endpoint == "" {
|
||||
return nil
|
||||
}
|
||||
subsMu.Lock()
|
||||
defer subsMu.Unlock()
|
||||
subs := loadSubs()
|
||||
for i, x := range subs {
|
||||
if x.Endpoint == s.Endpoint {
|
||||
subs[i] = s
|
||||
return saveSubs(subs)
|
||||
}
|
||||
}
|
||||
subs = append(subs, s)
|
||||
return saveSubs(subs)
|
||||
}
|
||||
|
||||
// removeSub retire un abonnement par son endpoint (désinscription explicite, ou
|
||||
// purge après un 404/410 « gone » renvoyé par le service de push).
|
||||
func removeSub(endpoint string) {
|
||||
if endpoint == "" {
|
||||
return
|
||||
}
|
||||
subsMu.Lock()
|
||||
defer subsMu.Unlock()
|
||||
subs := loadSubs()
|
||||
out := subs[:0]
|
||||
for _, x := range subs {
|
||||
if x.Endpoint != endpoint {
|
||||
out = append(out, x)
|
||||
}
|
||||
}
|
||||
_ = saveSubs(out)
|
||||
}
|
||||
|
||||
// pushPayload : ce que le service worker reçoit dans l'événement `push`.
|
||||
type pushPayload struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
// hasPushSubs : y a-t-il au moins un abonnement ? Évite de sérialiser une charge
|
||||
// utile pour rien à chaque fin de tour quand personne n'a activé les notifs.
|
||||
func hasPushSubs() bool { return len(loadSubs()) > 0 }
|
||||
|
||||
// sendPushToAll pousse une notification à tous les abonnés. Best-effort : les
|
||||
// échecs réseau sont ignorés (le service de push réessaiera selon le TTL), mais un
|
||||
// abonnement rejeté définitivement (404/410) est purgé pour ne pas s'accumuler.
|
||||
// Appelé depuis la goroutine de génération, à turn_done — jamais bloquant pour l'UI.
|
||||
func sendPushToAll(title, body string) {
|
||||
subs := loadSubs()
|
||||
if len(subs) == 0 {
|
||||
fmt.Printf("[push] fin de tour : aucun abonné enregistré (rien à envoyer)\n")
|
||||
return
|
||||
}
|
||||
priv, pub, err := vapidKeys()
|
||||
if err != nil {
|
||||
fmt.Printf("[push] clés VAPID indisponibles : %v\n", err)
|
||||
return
|
||||
}
|
||||
msg, _ := json.Marshal(pushPayload{Title: title, Body: body, Tag: "loki-turn"})
|
||||
opts := &webpush.Options{
|
||||
// ⚠️ SANS préfixe « mailto: » : webpush-go l'ajoute lui-même (sauf si la
|
||||
// chaîne commence par « https: »). Passer « mailto:… » ici donnait
|
||||
// « mailto:mailto:… », un sujet VAPID malformé → Apple répond 403
|
||||
// BadJwtToken et rien n'arrive. On donne donc l'email nu.
|
||||
Subscriber: "mail@nathaninline.com",
|
||||
VAPIDPublicKey: pub,
|
||||
VAPIDPrivateKey: priv,
|
||||
TTL: 120, // périmé après 2 min : une notif « réponse prête » n'a pas de sens tardive
|
||||
Urgency: webpush.UrgencyHigh,
|
||||
}
|
||||
fmt.Printf("[push] envoi à %d abonné(s)…\n", len(subs))
|
||||
for _, s := range subs {
|
||||
sub := &webpush.Subscription{Endpoint: s.Endpoint, Keys: s.Keys}
|
||||
resp, err := webpush.SendNotification(msg, sub, opts)
|
||||
if err != nil {
|
||||
fmt.Printf("[push] échec envoi (%s) : %v\n", endpointHost(s.Endpoint), err)
|
||||
continue
|
||||
}
|
||||
// 201 Created = accepté par le service de push. 4xx = problème (VAPID,
|
||||
// chiffrement, abonnement mort) — on TRACE le corps de la réponse, qui porte
|
||||
// le motif exact d'Apple/Google (ex. « BadJwtToken »).
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
fmt.Printf("[push] refus %d (%s) : %s\n", resp.StatusCode, endpointHost(s.Endpoint), strings.TrimSpace(string(body)))
|
||||
} else {
|
||||
fmt.Printf("[push] accepté %d (%s)\n", resp.StatusCode, endpointHost(s.Endpoint))
|
||||
}
|
||||
// 404/410 = abonnement expiré côté service de push : on le retire.
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
||||
removeSub(s.Endpoint)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// endpointHost : hôte de l'endpoint pour les logs (miroir de pushEndpointHost,
|
||||
// gardé dans ce fichier pour éviter un couplage de compilation entre les deux).
|
||||
func endpointHost(endpoint string) string { return pushEndpointHost(endpoint) }
|
||||
+19
-4
@@ -84,6 +84,8 @@ func Main() {
|
||||
mustExit(cmdOAI(args))
|
||||
case "agent":
|
||||
mustExit(cmdAgent(args))
|
||||
case "computer":
|
||||
mustExit(cmdComputer(args))
|
||||
case "internet":
|
||||
mustExit(cmdInternet(args))
|
||||
case "memory":
|
||||
@@ -156,6 +158,7 @@ Interaction:
|
||||
memory [off|ondemand|always|status] mode mémoire de l'IA
|
||||
internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]
|
||||
accès web de l'IA (moteur intégré ou serveur Crawl4AI)
|
||||
computer [on|off|status] laisse l'IA piloter un navigateur (browser_open/click/type…)
|
||||
|
||||
Accès distant (ajean.link) :
|
||||
link <token> enregistre le jeton et ouvre le tunnel
|
||||
@@ -227,13 +230,25 @@ func readEtcDefault() string {
|
||||
// Arborescence de $LOKI_HOME. Elle tient en six dossiers, et rien d'autre :
|
||||
// tout le reste (config, préférences, conversation, clés, drapeaux) vit dans la
|
||||
// base loki.db — voir store.go.
|
||||
func backendsDir() string { return filepath.Join(LokiHome(), "backends") }
|
||||
func binDir() string { return filepath.Join(LokiHome(), "bin") }
|
||||
func presetsDir() string { return filepath.Join(LokiHome(), "presets") }
|
||||
func memoryDir() string { return filepath.Join(LokiHome(), "memory") }
|
||||
func backendsDir() string { return filepath.Join(LokiHome(), "backends") }
|
||||
func binDir() string { return filepath.Join(LokiHome(), "bin") }
|
||||
func presetsDir() string { return filepath.Join(LokiHome(), "presets") }
|
||||
|
||||
// memoryDir est le dossier mémoire du projet ACTIF. C'est LA couture des projets
|
||||
// (projects.go) : tout le code mémoire passe par ici, donc il hérite du
|
||||
// cloisonnement sans avoir à connaître la notion de projet. Avant les projets,
|
||||
// memory/ était plat ; il est désormais la racine des dossiers de projet, et
|
||||
// ensureDefaultProject a migré les pages historiques vers memory/generale/.
|
||||
func memoryDir() string { return projectMemoryDir(activeProjectSlug()) }
|
||||
func modelsDir() string { return filepath.Join(LokiHome(), "models") }
|
||||
func workspaceDir() string { return filepath.Join(LokiHome(), "workspace") }
|
||||
|
||||
// scriptsDir est le dossier des scripts de l'agent : à côté de memory/presets,
|
||||
// HORS du workspace. Le workspace est un bac à sable jetable (clones, tests,
|
||||
// fichiers d'une discussion) qu'un nettoyage — ou la suppression de la
|
||||
// discussion — peut raser ; les scripts qu'on veut CONSERVER vivent ici.
|
||||
func scriptsDir() string { return filepath.Join(LokiHome(), "scripts") }
|
||||
|
||||
// serviceName est le nom de l'unité qui exécute llama-server. Son pendant est
|
||||
// uiUnitName (« loki-ui »), qui sert l'interface et le tunnel.
|
||||
func serviceName() string {
|
||||
|
||||
@@ -34,6 +34,14 @@ const (
|
||||
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)
|
||||
// bkRecall : blocs archivés au compactage, adressables par un id court
|
||||
// (chat_recall.go). Séparé de bkChat parce que sa durée de vie est autre — un
|
||||
// bloc survit à la suppression de la discussion qui l'a produit, et sa clé est
|
||||
// une séquence bbolt, pas un identifiant de discussion.
|
||||
bkRecall = "recall"
|
||||
// bkTracker : les trackers, 3e type de mémoire (tracker.go). Clé
|
||||
// « <slug-projet>/<slug-tracker> » — le cloisonnement par projet est dans la clé.
|
||||
bkTracker = "tracker"
|
||||
)
|
||||
|
||||
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
|
||||
|
||||
@@ -22,6 +22,7 @@ type lokiPaths struct {
|
||||
Installed string `json:"installed"` // binaire installé (peut différer de Exe)
|
||||
Workspace string `json:"workspace"` // dossier de travail du mode agent
|
||||
Memory string `json:"memory"`
|
||||
Scripts string `json:"scripts"` // scripts durables de l'agent (hors workspace jetable)
|
||||
Presets string `json:"presets"`
|
||||
Models string `json:"models"`
|
||||
Backends string `json:"backends"`
|
||||
@@ -50,6 +51,7 @@ func currentPaths() lokiPaths {
|
||||
Installed: installedExePath(),
|
||||
Workspace: agentWorkspace(),
|
||||
Memory: memoryDir(),
|
||||
Scripts: scriptsDir(),
|
||||
Presets: presetsDir(),
|
||||
Models: modelsDir(),
|
||||
Backends: backendsDir(),
|
||||
@@ -66,6 +68,7 @@ func cmdWhere(args []string) error {
|
||||
{"binaire installé", p.Installed},
|
||||
{"travail de l'agent", p.Workspace},
|
||||
{"mémoire", p.Memory},
|
||||
{"scripts", p.Scripts},
|
||||
{"presets", p.Presets},
|
||||
{"modèles", p.Models},
|
||||
{"backends", p.Backends},
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
@@ -25,6 +26,17 @@ type Task struct {
|
||||
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)
|
||||
// Project = slug du projet visé (projects.go). L'exécution force ce projet
|
||||
// (setProjectOverride), donc la tâche voit LA MÉMOIRE et les trackers du bon
|
||||
// chantier — une veille « NAS » n'écrit pas dans la mémoire « recettes ». Vide
|
||||
// sur les tâches d'avant les projets : traitées comme « Générale ».
|
||||
Project string `json:"project,omitempty"`
|
||||
// Kind distingue une tâche IA d'une tâche script. Vide ou "agent" = Prompt est
|
||||
// une consigne exécutée par le modèle (RunAutonomous). "script" = on lance
|
||||
// directement le script Script du dossier protégé, SANS charger le modèle ni
|
||||
// consommer de tokens. Le zéro JSON ("") garde le comportement historique (IA).
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Script string `json:"script,omitempty"` // pour Kind=="script" : nom du script dans scriptsDir()
|
||||
// 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
|
||||
@@ -68,6 +80,48 @@ func listTasks() []Task {
|
||||
return out
|
||||
}
|
||||
|
||||
// taskProjectOf renvoie le projet d'une tâche, l'absence valant projet par défaut :
|
||||
// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire. Un
|
||||
// projet SUPPRIMÉ depuis retombe de même sur « Générale » : sans ça, la tâche
|
||||
// restait rattachée à un slug fantôme et n'apparaissait plus dans aucun projet.
|
||||
func taskProjectOf(t Task) string {
|
||||
if s := strings.TrimSpace(t.Project); s != "" && projectExists(s) {
|
||||
return s
|
||||
}
|
||||
return defaultProjectSlug
|
||||
}
|
||||
|
||||
// Le PANNEAU de l'UI et le planificateur restent GLOBAUX : une tâche doit tourner
|
||||
// même si son projet n'est pas celui affiché, et masquer les tâches des autres
|
||||
// projets ferait croire qu'elles ne sont plus planifiées. Le projet gouverne ce
|
||||
// que la tâche VOIT quand elle s'exécute (runTask force setProjectOverride), pas
|
||||
// sa visibilité.
|
||||
|
||||
// tagOrphanTasks rattache au projet donné les tâches qui n'en ont pas encore
|
||||
// (migration, une seule fois).
|
||||
func tagOrphanTasks(slug string) {
|
||||
for _, t := range listTasks() {
|
||||
if strings.TrimSpace(t.Project) != "" {
|
||||
continue
|
||||
}
|
||||
t.Project = slug
|
||||
_ = saveTask(t)
|
||||
}
|
||||
}
|
||||
|
||||
// listTasksForProject ne renvoie que les tâches rattachées au projet `slug`
|
||||
// (mêmes tri et résolution que listTasks). Sert à cloisonner les outils task_*
|
||||
// exposés à l'IA : dans un projet, elle ne voit et ne pilote QUE ses tâches.
|
||||
func listTasksForProject(slug string) []Task {
|
||||
var out []Task
|
||||
for _, t := range listTasks() {
|
||||
if taskProjectOf(t) == slug {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// getTask lit une tâche par ID. Renvoie false si absente.
|
||||
func getTask(id string) (Task, bool) {
|
||||
var t Task
|
||||
|
||||
@@ -75,16 +75,19 @@ func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prom
|
||||
// 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
|
||||
// Contexte du projet de la tâche : le projet est déjà forcé par l'appelant
|
||||
// (setProjectOverride dans runTask), donc ces messages décrivent le bon
|
||||
// chantier — la tâche voit la mémoire et les trackers qu'elle vise.
|
||||
final := append(projectSystemMessages(), msgs...)
|
||||
sys := readSysPrompt()
|
||||
note := taskContextNote(taskName, lastReport)
|
||||
switch {
|
||||
case sys != "" && note != "":
|
||||
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, msgs...)
|
||||
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, final...)
|
||||
case sys != "":
|
||||
final = append([]Message{{Role: "system", Content: sys}}, msgs...)
|
||||
final = append([]Message{{Role: "system", Content: sys}}, final...)
|
||||
case note != "":
|
||||
final = append([]Message{{Role: "system", Content: note}}, msgs...)
|
||||
final = append([]Message{{Role: "system", Content: note}}, final...)
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
@@ -146,6 +149,14 @@ const reportMax = 4000
|
||||
func runTask(t Task) {
|
||||
start := time.Now()
|
||||
|
||||
// Tâche « script seul » : aucun modèle à charger, aucun token consommé — on
|
||||
// lance le script du dossier protégé et sa sortie devient le compte-rendu.
|
||||
// Testé AVANT le preset : basculer le moteur pour un script serait absurde.
|
||||
if t.Kind == "script" {
|
||||
runScriptTask(t)
|
||||
return
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -154,6 +165,14 @@ func runTask(t Task) {
|
||||
return
|
||||
}
|
||||
|
||||
// Projet visé : on force le projet vu par memoryDir() et par les trackers, le
|
||||
// temps du passage. Sans ça, une veille rattachée au projet « NAS » écrirait
|
||||
// dans la mémoire du projet ouvert à l'écran au moment du tic. Sûr : une seule
|
||||
// inférence tourne à la fois (verrou de génération), et RunAutonomous a déjà
|
||||
// rendu la main quand on libère.
|
||||
setProjectOverride(taskProjectOf(t))
|
||||
defer setProjectOverride("")
|
||||
|
||||
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
|
||||
@@ -266,4 +285,15 @@ func recordTaskEnd(id string, start time.Time, report string, err error) {
|
||||
cur.LastReport = report
|
||||
}
|
||||
_ = saveTask(cur)
|
||||
// Notification Web Push : une tâche planifiée tourne SANS personne devant
|
||||
// l'écran — c'est le cas où être prévenu compte le plus. Détaché (l'envoi
|
||||
// HTTP ne doit pas retenir le planificateur) et silencieux sur une
|
||||
// interruption manuelle : celui qui a cliqué « stop » est déjà là.
|
||||
if hasPushSubs() && !errors.Is(err, context.Canceled) {
|
||||
title, body := "Loki · tâche terminée", cur.Name
|
||||
if err != nil {
|
||||
title = "Loki · tâche en échec"
|
||||
}
|
||||
go sendPushToAll(title, body)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package loki
|
||||
|
||||
// tasks_script.go — exécution d'une tâche « script seul » : on lance un script du
|
||||
// dossier scriptsDir(), SANS charger le modèle ni consommer de tokens. La sortie
|
||||
// du script devient le compte-rendu, exactement comme pour une tâche IA, si bien
|
||||
// que l'UI et les outils task_* l'affichent sans traitement particulier.
|
||||
//
|
||||
// Une tâche script ne passe PAS par le verrou de génération de la conversation
|
||||
// (elle n'utilise pas le modèle), donc conv.runningTaskID ne la voit pas. On tient
|
||||
// donc un petit registre à part (scriptRunning) pour que l'UI l'affiche « en
|
||||
// cours » et puisse l'arrêter.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// scriptTaskTimeout borne l'exécution d'un script planifié. Généreux (un backup ou
|
||||
// une synchro peut durer), mais fini : une tâche coincée ne doit pas bloquer le
|
||||
// planificateur à vie.
|
||||
const scriptTaskTimeout = 10 * time.Minute
|
||||
|
||||
// scriptRunning suit les tâches script en cours (id → cancel + nom), pour que
|
||||
// handleTasks les signale « en cours » et que l'arrêt (/api/tasks/stop) puisse
|
||||
// annuler leur contexte.
|
||||
var scriptRunning = struct {
|
||||
mu sync.Mutex
|
||||
cancel map[string]context.CancelFunc
|
||||
name map[string]string
|
||||
}{cancel: map[string]context.CancelFunc{}, name: map[string]string{}}
|
||||
|
||||
func scriptRunBegin(id, name string, cancel context.CancelFunc) {
|
||||
scriptRunning.mu.Lock()
|
||||
scriptRunning.cancel[id] = cancel
|
||||
scriptRunning.name[id] = name
|
||||
scriptRunning.mu.Unlock()
|
||||
}
|
||||
|
||||
func scriptRunEnd(id string) {
|
||||
scriptRunning.mu.Lock()
|
||||
delete(scriptRunning.cancel, id)
|
||||
delete(scriptRunning.name, id)
|
||||
scriptRunning.mu.Unlock()
|
||||
}
|
||||
|
||||
// scriptRunningAny renvoie l'id et le nom d'une tâche script en cours (la
|
||||
// première trouvée), ou "","" si aucune. Le scheduler ne lance qu'une tâche à la
|
||||
// fois, donc en pratique il y en a au plus une.
|
||||
func scriptRunningAny() (string, string) {
|
||||
scriptRunning.mu.Lock()
|
||||
defer scriptRunning.mu.Unlock()
|
||||
for id, n := range scriptRunning.name {
|
||||
return id, n
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// scriptRunStop annule la tâche script en cours d'id `id`. Renvoie true si une
|
||||
// exécution correspondante a été trouvée et annulée.
|
||||
func scriptRunStop(id string) bool {
|
||||
scriptRunning.mu.Lock()
|
||||
c := scriptRunning.cancel[id]
|
||||
scriptRunning.mu.Unlock()
|
||||
if c != nil {
|
||||
c()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// runScriptTask exécute le script d'une tâche et enregistre son état (comme runTask
|
||||
// pour une tâche IA), mais sans aucune inférence.
|
||||
func runScriptTask(t Task) {
|
||||
start := time.Now()
|
||||
|
||||
name := strings.TrimSpace(t.Script)
|
||||
if name == "" {
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("aucun script associé à la tâche"))
|
||||
return
|
||||
}
|
||||
full, err := scriptsPath(name)
|
||||
if err != nil {
|
||||
recordTaskEnd(t.ID, start, "", err)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(full); err != nil {
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("script introuvable : %s", name))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scriptTaskTimeout)
|
||||
defer cancel()
|
||||
// Registre : rend la tâche visible « en cours » dans l'UI et arrêtable via
|
||||
// /api/tasks/stop (qui appelle scriptRunStop → cancel).
|
||||
scriptRunBegin(t.ID, t.Name, cancel)
|
||||
defer scriptRunEnd(t.ID)
|
||||
|
||||
// On réutilise runShell : garde-fous, dossier de travail, troncature de sortie
|
||||
// et gestion du timeout y sont déjà. La sortie formatée ("exit: N ...") sert à
|
||||
// la fois de compte-rendu et de signal de succès.
|
||||
out := runShell(ctx, scriptRunCommand(full), int(scriptTaskTimeout/time.Second))
|
||||
switch {
|
||||
case ctx.Err() == context.Canceled:
|
||||
// Arrêt volontaire (bouton stop) : recordTaskEnd l'affiche « interrompue »
|
||||
// sans allumer l'indicateur d'échec.
|
||||
recordTaskEnd(t.ID, start, "", context.Canceled)
|
||||
case strings.HasPrefix(out, "exit: 0"):
|
||||
recordTaskEnd(t.ID, start, out, nil)
|
||||
default:
|
||||
// Sortie non nulle, timeout ou erreur de lancement : échec, avec la sortie
|
||||
// complète comme message d'erreur.
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("%s", out))
|
||||
}
|
||||
}
|
||||
|
||||
// scriptRunCommand construit la ligne de commande qui exécute le script `path`
|
||||
// selon la plateforme et l'extension. Sous Unix, on lance le fichier directement
|
||||
// (il est écrit en 0755, le shebang choisit l'interpréteur). Sous Windows, un .ps1
|
||||
// passe par PowerShell ; le reste (.bat/.cmd/.exe) se lance tel quel via cmd. Le
|
||||
// nom de script est déjà bridé par scriptsPath (pas de guillemet ni de métacaractère
|
||||
// shell), donc l'entourer de guillemets suffit.
|
||||
func scriptRunCommand(path string) string {
|
||||
q := `"` + path + `"`
|
||||
if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".ps1") {
|
||||
return `powershell -NoProfile -ExecutionPolicy Bypass -File ` + q
|
||||
}
|
||||
return q
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package loki
|
||||
|
||||
// tasks_tools.go — outils task_* exposés au modèle pour qu'il gère LUI-MÊME ses
|
||||
// tâches planifiées (voir tasks.go) : se donner un rappel récurrent, une veille,
|
||||
// un ménage périodique… puis les lister, les ajuster ou les supprimer. Ils
|
||||
// réutilisent exactement l'infra de l'UI (validateSchedule, computeNextRun,
|
||||
// saveTask), donc une tâche créée par l'IA apparaît et se pilote comme les
|
||||
// autres dans le panneau Tâches.
|
||||
//
|
||||
// Gate : réservés au mode agent (EnabledTools). Une tâche n'a de sens que si
|
||||
// l'IA peut agir en arrière-plan ; sans agent elle n'aurait aucun outil pour
|
||||
// livrer son résultat.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// str extrait une chaîne d'un argument d'outil décodé en any ("" si absent ou
|
||||
// d'un autre type).
|
||||
func taskStrArg(v any) string {
|
||||
s, _ := v.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func taskListTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "task_list",
|
||||
Description: "List your scheduled tasks with their state, next run, and last result. Pass an id to get that task's full last report.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "Show only this task, with its full last report"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func taskCreateTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "task_create",
|
||||
Description: "Schedule work to run later, once or repeating. Give 'prompt' for an AI task, OR 'script' (a saved script name) for a script task that runs with no model and no tokens.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"name": map[string]any{"type": "string"},
|
||||
"prompt": map[string]any{"type": "string", "description": "AI task: self-contained instruction to run each time (omit if using script)"},
|
||||
"script": map[string]any{"type": "string", "description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved"},
|
||||
// Le format du schedule est le seul point que le modèle ne peut pas
|
||||
// deviner : il vit ici, dans le paramètre, pas dans la phrase de l'outil.
|
||||
"schedule": map[string]any{"type": "string", "description": "\"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\""},
|
||||
"enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"},
|
||||
},
|
||||
"required": []string{"name", "schedule"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func taskUpdateTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "task_update",
|
||||
Description: "Change one or more fields of a scheduled task by id.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string"},
|
||||
"name": map[string]any{"type": "string"},
|
||||
"prompt": map[string]any{"type": "string"},
|
||||
"schedule": map[string]any{"type": "string"},
|
||||
"enabled": map[string]any{"type": "boolean", "description": "false pauses, true resumes"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func taskDeleteTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "task_delete",
|
||||
Description: "Delete a scheduled task by id.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// defaultTaskTZ : le modèle n'a pas le fuseau du navigateur. On reprend celui
|
||||
// d'une tâche existante (posé par l'UI depuis le navigateur) pour que « tous les
|
||||
// jours à 9h » tombe à la bonne heure ; à défaut, fuseau vide = heure locale du
|
||||
// serveur (souvent UTC), ce que la description du planificateur assume déjà.
|
||||
func defaultTaskTZ() string {
|
||||
for _, t := range listTasks() {
|
||||
if strings.TrimSpace(t.TZ) != "" {
|
||||
return t.TZ
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toolTaskList formate les tâches pour le modèle. Avec un id, on ne montre QUE
|
||||
// cette tâche, compte-rendu COMPLET (le cas « donne-moi le compte-rendu de la
|
||||
// tâche X ») ; sans id, la liste complète avec un aperçu court par tâche.
|
||||
func toolTaskList(args map[string]any) string {
|
||||
proj := activeProjectSlug()
|
||||
tasks := listTasksForProject(proj)
|
||||
if id := strings.TrimSpace(taskStrArg(args["id"])); id != "" {
|
||||
// On ne révèle que les tâches du projet actif : une tâche d'un autre projet
|
||||
// est traitée comme inexistante (même message que si l'id était faux).
|
||||
t, ok := getTask(id)
|
||||
if !ok || taskProjectOf(t) != proj {
|
||||
return "[erreur] tâche introuvable : " + id
|
||||
}
|
||||
return formatTask(t, true)
|
||||
}
|
||||
if len(tasks) == 0 {
|
||||
return "[aucune tâche planifiée dans ce projet]"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, t := range tasks {
|
||||
b.WriteString(formatTask(t, false) + "\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// formatTask rend une tâche. full=true : compte-rendu entier (sur ses propres
|
||||
// lignes) ; full=false : aperçu court d'une ligne, pour une liste scannable.
|
||||
func formatTask(t Task, full bool) string {
|
||||
var b strings.Builder
|
||||
state := "off"
|
||||
if t.Enabled {
|
||||
state = "on"
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s [%s] « %s » — %s", t.ID, state, t.Name, t.Schedule)
|
||||
if t.NextRun > 0 && t.Enabled {
|
||||
fmt.Fprintf(&b, " (prochaine : %s)", time.UnixMilli(t.NextRun).In(loc(t.TZ)).Format("2006-01-02 15:04"))
|
||||
}
|
||||
if t.LastRun > 0 {
|
||||
when := time.UnixMilli(t.LastRun).In(loc(t.TZ)).Format("2006-01-02 15:04")
|
||||
if t.LastOK {
|
||||
fmt.Fprintf(&b, " ✓ dernier run %s", when)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " ✗ dernier run %s : %s", when, t.LastError)
|
||||
}
|
||||
if r := strings.TrimSpace(t.LastReport); r != "" {
|
||||
if full {
|
||||
// Compte-rendu ENTIER (déjà borné à 4000 car au stockage), tel quel,
|
||||
// sur ses propres lignes — c'est ce que l'utilisateur demande.
|
||||
b.WriteString("\n compte-rendu complet :\n" + r)
|
||||
} else {
|
||||
b.WriteString("\n compte-rendu : " + previewReport(r))
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// previewReport aplati + tronque le compte-rendu pour l'aperçu de la liste (un
|
||||
// rapport entier par tâche gonflerait la sortie). Le texte complet s'obtient via
|
||||
// task_list avec l'id de la tâche.
|
||||
func previewReport(s string) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
const max = 300
|
||||
if len(s) > max {
|
||||
return s[:max] + " […] (id pour le compte-rendu complet)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// toolTaskCreate crée une tâche depuis un appel d'outil du modèle.
|
||||
func toolTaskCreate(args map[string]any) string {
|
||||
name := strings.TrimSpace(taskStrArg(args["name"]))
|
||||
prompt := strings.TrimSpace(taskStrArg(args["prompt"]))
|
||||
script := strings.TrimSpace(taskStrArg(args["script"]))
|
||||
schedule := strings.TrimSpace(taskStrArg(args["schedule"]))
|
||||
if name == "" || schedule == "" {
|
||||
return "[erreur] name et schedule sont obligatoires"
|
||||
}
|
||||
kind := ""
|
||||
if script != "" {
|
||||
// Tâche script : le script doit exister dans le dossier protégé.
|
||||
kind = "script"
|
||||
if err := scriptExists(script); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
} else if prompt == "" {
|
||||
return "[erreur] fournis soit 'prompt' (tâche IA) soit 'script' (tâche script)"
|
||||
}
|
||||
tz := defaultTaskTZ()
|
||||
if err := validateSchedule(schedule, tz); err != nil {
|
||||
return "[erreur] fréquence invalide : " + err.Error()
|
||||
}
|
||||
enabled := true
|
||||
if v, ok := args["enabled"].(bool); ok {
|
||||
enabled = v
|
||||
}
|
||||
t := Task{
|
||||
ID: newTaskID(), Name: name, Prompt: prompt, Schedule: schedule,
|
||||
TZ: tz, Enabled: enabled, Kind: kind, Script: script,
|
||||
// Rattache la tâche au projet actif : l'IA ne crée que DANS son projet,
|
||||
// comme elle n'y voit et n'y pilote que ses propres tâches.
|
||||
Project: activeProjectSlug(),
|
||||
}
|
||||
t.NextRun = computeNextRun(schedule, tz, time.Now())
|
||||
if err := saveTask(t); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
when := ""
|
||||
if t.NextRun > 0 && enabled {
|
||||
when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04")
|
||||
}
|
||||
return fmt.Sprintf("[ok] tâche '%s' créée (id %s)%s", name, t.ID, when)
|
||||
}
|
||||
|
||||
// toolTaskUpdate modifie une tâche existante (partiel).
|
||||
func toolTaskUpdate(args map[string]any) string {
|
||||
id := strings.TrimSpace(taskStrArg(args["id"]))
|
||||
if id == "" {
|
||||
return "[erreur] id manquant"
|
||||
}
|
||||
t, ok := getTask(id)
|
||||
if !ok || taskProjectOf(t) != activeProjectSlug() {
|
||||
return "[erreur] tâche introuvable : " + id
|
||||
}
|
||||
if v, ok := args["name"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
t.Name = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := args["prompt"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
t.Prompt = strings.TrimSpace(v)
|
||||
}
|
||||
if v, ok := args["schedule"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
sched := strings.TrimSpace(v)
|
||||
if err := validateSchedule(sched, t.TZ); err != nil {
|
||||
return "[erreur] fréquence invalide : " + err.Error()
|
||||
}
|
||||
t.Schedule = sched
|
||||
t.NextRun = computeNextRun(sched, t.TZ, time.Now())
|
||||
}
|
||||
if v, ok := args["enabled"].(bool); ok {
|
||||
t.Enabled = v
|
||||
if v && t.NextRun == 0 {
|
||||
t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now())
|
||||
}
|
||||
}
|
||||
if err := saveTask(t); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("[ok] tâche '%s' (id %s) mise à jour", t.Name, t.ID)
|
||||
}
|
||||
|
||||
// toolTaskDelete supprime une tâche.
|
||||
func toolTaskDelete(args map[string]any) string {
|
||||
id := strings.TrimSpace(taskStrArg(args["id"]))
|
||||
if id == "" {
|
||||
return "[erreur] id manquant"
|
||||
}
|
||||
t, ok := getTask(id)
|
||||
if !ok || taskProjectOf(t) != activeProjectSlug() {
|
||||
return "[erreur] tâche introuvable : " + id
|
||||
}
|
||||
if err := deleteTask(id); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("[ok] tâche '%s' (id %s) supprimée", t.Name, id)
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
package loki
|
||||
|
||||
// tracker.go — les TRACKERS : le 3ᵉ type de mémoire, pour les données DATÉES qui
|
||||
// s'accumulent (compteurs, relevés, journaux d'événements). Séparé des pages .md
|
||||
// (faits + procédures) exprès : un tracker grandit sans fin, donc on ne le lit
|
||||
// JAMAIS en entier. On le consulte par NIVEAUX — vue d'ensemble → année → mois →
|
||||
// événements — chaque réponse indiquant comment descendre. Le contexte n'explose
|
||||
// donc jamais, quelle que soit l'ancienneté des données.
|
||||
//
|
||||
// Un seul outil `tracker` côté modèle (action view/add/edit/delete). Stockage
|
||||
// structuré dans le bucket bkTracker, cloisonné par projet : clé
|
||||
// « <slug-projet>/<slug-tracker> ».
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TrackerEvent = un point daté d'un tracker. TS = l'instant de l'événement (ms),
|
||||
// Text = la valeur ou la note (« abonnés : 4210 », « pesée 78,2 kg »…).
|
||||
type TrackerEvent struct {
|
||||
ID string `json:"id"`
|
||||
TS int64 `json:"ts"`
|
||||
Text string `json:"text"`
|
||||
// DateOnly = le point n'a PAS d'heure (juste une date). Sans ça, un point saisi
|
||||
// sans heure s'enregistre à 00:00 et s'affiche « minuit », ce qui est faux : il
|
||||
// n'a simplement pas d'heure. On ne montre alors que la date.
|
||||
DateOnly bool `json:"date_only,omitempty"`
|
||||
}
|
||||
|
||||
// Tracker = un flux d'événements sous un nom (« abonnés », « poids »…). Events
|
||||
// triés par TS croissant.
|
||||
type Tracker struct {
|
||||
Name string `json:"name"`
|
||||
Events []TrackerEvent `json:"events"`
|
||||
}
|
||||
|
||||
// --- stockage (bucket bkTracker, clé <projet>/<slug>) -------------------------
|
||||
|
||||
// trackerStoreKey : clé de stockage d’un tracker. « Store » dans le nom parce que
|
||||
// code_tracker.go porte un tout autre « tracker » (suivi lu-avant-écrit du mode
|
||||
// code) et que les deux vivent dans le même paquet.
|
||||
func trackerStoreKey(proj, slug string) string { return proj + "/" + slug }
|
||||
|
||||
// trackerLoad lit un tracker du projet actif par son slug. false si absent.
|
||||
func trackerLoad(slug string) (*Tracker, bool) {
|
||||
var s Tracker
|
||||
if !getStoreJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), &s) {
|
||||
return nil, false
|
||||
}
|
||||
return &s, true
|
||||
}
|
||||
|
||||
func trackerSave(slug string, s *Tracker) error {
|
||||
sort.SliceStable(s.Events, func(i, j int) bool { return s.Events[i].TS < s.Events[j].TS })
|
||||
return putStoreJSON(bkTracker, trackerStoreKey(activeProjectSlug(), slug), s)
|
||||
}
|
||||
|
||||
// trackerKeysForProject renvoie les clés brutes du bucket appartenant à un projet.
|
||||
// Sert à la suppression d'un projet (projects.go), qui emporte ses trackers.
|
||||
func trackerKeysForProject(proj string) []string {
|
||||
prefix := proj + "/"
|
||||
var out []string
|
||||
for k := range allKV(bkTracker) {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// trackerMeta = ligne légère de la vue d'ensemble (aucun événement chargé).
|
||||
type trackerMeta struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Count int `json:"count"`
|
||||
FirstTS int64 `json:"first_ts"`
|
||||
LastTS int64 `json:"last_ts"`
|
||||
LastText string `json:"last_text"` // texte du dernier point (dernière valeur)
|
||||
last *TrackerEvent // dernier événement, pour formater sa date selon DateOnly
|
||||
}
|
||||
|
||||
// trackerList renvoie les trackers du projet actif (métadonnées seulement), le
|
||||
// plus récemment alimenté d'abord.
|
||||
func trackerList() []trackerMeta {
|
||||
prefix := activeProjectSlug() + "/"
|
||||
var out []trackerMeta
|
||||
for k := range allKV(bkTracker) {
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimPrefix(k, prefix)
|
||||
s, ok := trackerLoad(slug)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m := trackerMeta{Slug: slug, Name: s.Name, Count: len(s.Events)}
|
||||
if n := len(s.Events); n > 0 {
|
||||
m.FirstTS = s.Events[0].TS
|
||||
last := s.Events[n-1]
|
||||
m.LastTS = last.TS
|
||||
m.LastText = last.Text
|
||||
m.last = &last
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].LastTS > out[j].LastTS })
|
||||
return out
|
||||
}
|
||||
|
||||
// --- mutations ----------------------------------------------------------------
|
||||
|
||||
// trackerAdd ajoute un point à un tracker (créé s'il n'existe pas). `when` vide =
|
||||
// maintenant.
|
||||
func trackerAdd(name, when, text string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", fmt.Errorf("nom de tracker vide")
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return "", fmt.Errorf("contenu vide")
|
||||
}
|
||||
ts, dateOnly, err := parseWhen(when)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
slug := slugifyIdent(name)
|
||||
if slug == "" {
|
||||
slug = "tracker"
|
||||
}
|
||||
s, ok := trackerLoad(slug)
|
||||
if !ok {
|
||||
s = &Tracker{Name: name}
|
||||
}
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
s.Name = name
|
||||
}
|
||||
id := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
s.Events = append(s.Events, TrackerEvent{ID: id, TS: ts, Text: strings.TrimSpace(text), DateOnly: dateOnly})
|
||||
if err := trackerSave(slug, s); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// trackerEditEvent modifie un événement (texte et/ou date). when/text vides =
|
||||
// inchangés.
|
||||
func trackerEditEvent(slug, id, when, text string) error {
|
||||
s, ok := trackerLoad(slug)
|
||||
if !ok {
|
||||
return fmt.Errorf("tracker introuvable")
|
||||
}
|
||||
for i := range s.Events {
|
||||
if s.Events[i].ID != id {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(text) != "" {
|
||||
s.Events[i].Text = strings.TrimSpace(text)
|
||||
}
|
||||
if strings.TrimSpace(when) != "" {
|
||||
ts, dateOnly, err := parseWhen(when)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Events[i].TS = ts
|
||||
s.Events[i].DateOnly = dateOnly
|
||||
}
|
||||
return trackerSave(slug, s)
|
||||
}
|
||||
return fmt.Errorf("événement introuvable")
|
||||
}
|
||||
|
||||
// trackerDeleteEvent retire un événement ; trackerDelete supprime le tracker entier.
|
||||
func trackerDeleteEvent(slug, id string) error {
|
||||
s, ok := trackerLoad(slug)
|
||||
if !ok {
|
||||
return fmt.Errorf("tracker introuvable")
|
||||
}
|
||||
kept := s.Events[:0]
|
||||
found := false
|
||||
for _, e := range s.Events {
|
||||
if e.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
kept = append(kept, e)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("événement introuvable")
|
||||
}
|
||||
s.Events = kept
|
||||
return trackerSave(slug, s)
|
||||
}
|
||||
|
||||
func trackerDelete(slug string) error {
|
||||
return putBytes(bkTracker, trackerStoreKey(activeProjectSlug(), slug), nil)
|
||||
}
|
||||
|
||||
// trackerRename change le NOM d'un tracker du projet actif. Le slug étant dérivé
|
||||
// du nom ET servant de clé de stockage comme d'identité pour l'ajout de points
|
||||
// (trackerAdd re-slugifie le nom), on re-clé le blob quand le slug change : sans
|
||||
// ça, un point ajouté après renommage créerait un tracker parallèle.
|
||||
func trackerRename(slug, newName string) error {
|
||||
newName = strings.TrimSpace(newName)
|
||||
if newName == "" {
|
||||
return fmt.Errorf("nom vide")
|
||||
}
|
||||
s, ok := trackerLoad(slug)
|
||||
if !ok {
|
||||
return fmt.Errorf("tracker introuvable")
|
||||
}
|
||||
newSlug := slugifyIdent(newName)
|
||||
if newSlug == "" {
|
||||
return fmt.Errorf("nom invalide")
|
||||
}
|
||||
if newSlug == slug {
|
||||
s.Name = newName
|
||||
return trackerSave(slug, s)
|
||||
}
|
||||
if _, exists := trackerLoad(newSlug); exists {
|
||||
return fmt.Errorf("un tracker du même nom existe déjà")
|
||||
}
|
||||
s.Name = newName
|
||||
if err := trackerSave(newSlug, s); err != nil {
|
||||
return err
|
||||
}
|
||||
return trackerDelete(slug)
|
||||
}
|
||||
|
||||
// trackerMoveToProject déplace un tracker du projet actif vers un autre projet.
|
||||
// Les octets sont déplacés tels quels : seule la clé change.
|
||||
func trackerMoveToProject(slug, toSlug string) error {
|
||||
from := activeProjectSlug()
|
||||
if !projectExists(toSlug) {
|
||||
return fmt.Errorf("projet cible introuvable")
|
||||
}
|
||||
if from == toSlug {
|
||||
return nil
|
||||
}
|
||||
fromKey, toKey := trackerStoreKey(from, slug), trackerStoreKey(toSlug, slug)
|
||||
raw := getBytes(bkTracker, fromKey)
|
||||
if len(raw) == 0 {
|
||||
return fmt.Errorf("tracker introuvable")
|
||||
}
|
||||
if len(getBytes(bkTracker, toKey)) > 0 {
|
||||
return fmt.Errorf("un tracker du même nom existe déjà dans le projet cible")
|
||||
}
|
||||
if err := putBytes(bkTracker, toKey, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
return putBytes(bkTracker, fromKey, nil)
|
||||
}
|
||||
|
||||
// --- temps --------------------------------------------------------------------
|
||||
|
||||
// parseWhen lit une date à précision variable (« 2026 », « 2026-07 »,
|
||||
// « 2026-07-15 », « 2026-07-15 14:30 ») et renvoie l'instant en ms (heure locale).
|
||||
// Vide ou « now » = maintenant. Le T de l'ISO est accepté à la place de l'espace.
|
||||
// dateOnly=true quand aucune heure n'a été fournie → le point n'aura pas d'heure
|
||||
// d'affichage, plutôt qu'un faux « minuit ».
|
||||
func parseWhen(when string) (ts int64, dateOnly bool, err error) {
|
||||
w := strings.TrimSpace(when)
|
||||
if w == "" || strings.EqualFold(w, "now") || strings.EqualFold(w, "maintenant") {
|
||||
return time.Now().UnixMilli(), false, nil
|
||||
}
|
||||
w = strings.Replace(w, "T", " ", 1)
|
||||
for _, layout := range []string{"2006-01-02 15:04:05", "2006-01-02 15:04", "2006-01-02 15"} {
|
||||
if t, e := time.ParseInLocation(layout, w, time.Local); e == nil {
|
||||
return t.UnixMilli(), false, nil
|
||||
}
|
||||
}
|
||||
for _, layout := range []string{"2006-01-02", "2006-01", "2006"} {
|
||||
if t, e := time.ParseInLocation(layout, w, time.Local); e == nil {
|
||||
return t.UnixMilli(), true, nil
|
||||
}
|
||||
}
|
||||
return 0, false, fmt.Errorf("date non comprise : %q (attendu AAAA, AAAA-MM, AAAA-MM-JJ ou AAAA-MM-JJ HH:MM)", when)
|
||||
}
|
||||
|
||||
// fmtTS formate un instant en « AAAA-MM-JJ HH:MM » (heure locale), base du
|
||||
// filtrage par préfixe et du regroupement — toujours complet.
|
||||
func fmtTS(ts int64) string { return time.UnixMilli(ts).In(time.Local).Format("2006-01-02 15:04") }
|
||||
|
||||
// fmtDay formate un instant en « AAAA-MM-JJ » (pour les étendues).
|
||||
func fmtDay(ts int64) string { return time.UnixMilli(ts).In(time.Local).Format("2006-01-02") }
|
||||
|
||||
// fmtEvent formate un point pour AFFICHAGE : sans heure si le point n'en a pas.
|
||||
func fmtEvent(e TrackerEvent) string {
|
||||
if e.DateOnly {
|
||||
return time.UnixMilli(e.TS).In(time.Local).Format("2006-01-02")
|
||||
}
|
||||
return fmtTS(e.TS)
|
||||
}
|
||||
|
||||
// whenParts compte les composantes datées d'un `when` (0 = rien, 1 = année,
|
||||
// 2 = mois, 3 = jour, 4+ = heure). Sert à décider le NIVEAU de la vue.
|
||||
func whenParts(when string) int {
|
||||
w := strings.TrimSpace(when)
|
||||
if w == "" {
|
||||
return 0
|
||||
}
|
||||
w = strings.Replace(w, "T", " ", 1)
|
||||
date := w
|
||||
if sp := strings.IndexByte(w, ' '); sp >= 0 {
|
||||
date = w[:sp]
|
||||
if strings.TrimSpace(w[sp:]) != "" {
|
||||
return 4
|
||||
}
|
||||
}
|
||||
return len(strings.Split(date, "-"))
|
||||
}
|
||||
|
||||
// --- vue par niveaux ----------------------------------------------------------
|
||||
|
||||
// trackerView construit la réponse texte de l'outil `tracker` en lecture. name
|
||||
// vide = vue d'ensemble ; sinon on descend selon `when` (année → mois →
|
||||
// événements). Chaque réponse dit comment zoomer : le modèle sait qu'il y a un
|
||||
// avant et un dessous sans jamais tout charger.
|
||||
func trackerView(name, when string) string {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return trackerOverview()
|
||||
}
|
||||
slug := slugifyIdent(name)
|
||||
s, ok := trackerLoad(slug)
|
||||
if !ok {
|
||||
return fmt.Sprintf("[aucun tracker « %s »] Appelle tracker() sans argument pour la liste, ou ajoute un point avec action:\"add\".", name)
|
||||
}
|
||||
if len(s.Events) == 0 {
|
||||
return fmt.Sprintf("Tracker « %s » : vide pour l'instant.", s.Name)
|
||||
}
|
||||
prefix := strings.Replace(strings.TrimSpace(when), "T", " ", 1)
|
||||
var match []TrackerEvent
|
||||
for _, e := range s.Events {
|
||||
if prefix == "" || strings.HasPrefix(fmtTS(e.TS), prefix) {
|
||||
match = append(match, e)
|
||||
}
|
||||
}
|
||||
switch level := whenParts(when); {
|
||||
case level == 0:
|
||||
return trackerSkeleton(s)
|
||||
case level == 1: // une année → les mois
|
||||
if len(match) == 0 {
|
||||
return fmt.Sprintf("Tracker « %s » : rien en %s.", s.Name, when)
|
||||
}
|
||||
return trackerGroup(s.Name, when, match, "2006-01", "un mois", `when:"`+when+`-07"`)
|
||||
default: // mois, jour, heure → on liste les événements
|
||||
return trackerEvents(s.Name, when, match)
|
||||
}
|
||||
}
|
||||
|
||||
// trackerOverview : la liste des trackers du projet, aucun contenu.
|
||||
func trackerOverview() string {
|
||||
list := trackerList()
|
||||
if len(list) == 0 {
|
||||
return "Aucun tracker dans ce projet. Crée-en un en ajoutant un point : action:\"add\", name:\"…\", text:\"…\" (when facultatif = maintenant)."
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Trackers du projet (%d) :\n", len(list))
|
||||
for _, m := range list {
|
||||
span := ""
|
||||
if m.Count > 0 {
|
||||
span = fmt.Sprintf(" — %d points, du %s au %s", m.Count, fmtDay(m.FirstTS), fmtDay(m.LastTS))
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s%s\n", m.Name, span)
|
||||
}
|
||||
b.WriteString("Zoom : tracker(name:\"<nom>\") pour le détail d'un tracker.")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// trackerSkeleton : les années présentes (avec compte) + l'année en cours
|
||||
// détaillée par mois. C'est la vue d'entrée d'un tracker.
|
||||
func trackerSkeleton(s *Tracker) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Tracker « %s » — %d points, du %s au %s\n", s.Name, len(s.Events), fmtDay(s.Events[0].TS), fmtDay(s.Events[len(s.Events)-1].TS))
|
||||
years := map[string]int{}
|
||||
var order []string
|
||||
for _, e := range s.Events {
|
||||
y := time.UnixMilli(e.TS).In(time.Local).Format("2006")
|
||||
if years[y] == 0 {
|
||||
order = append(order, y)
|
||||
}
|
||||
years[y]++
|
||||
}
|
||||
sort.Strings(order)
|
||||
parts := make([]string, 0, len(order))
|
||||
for _, y := range order {
|
||||
parts = append(parts, fmt.Sprintf("%s (%d)", y, years[y]))
|
||||
}
|
||||
fmt.Fprintf(&b, "Années : %s\n", strings.Join(parts, " · "))
|
||||
cur := time.Now().In(time.Local).Format("2006")
|
||||
var curEv []TrackerEvent
|
||||
for _, e := range s.Events {
|
||||
if strings.HasPrefix(fmtTS(e.TS), cur) {
|
||||
curEv = append(curEv, e)
|
||||
}
|
||||
}
|
||||
if len(curEv) > 0 {
|
||||
fmt.Fprintf(&b, "%s (année en cours) : %s\n", cur, monthsLine(curEv))
|
||||
}
|
||||
fmt.Fprintf(&b, "Zoom : tracker(name:\"%s\", when:\"2025\") pour une année · when:\"%s-07\" pour un mois.", s.Name, cur)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// trackerGroup regroupe des événements par un format de date avec leurs comptes.
|
||||
func trackerGroup(name, when string, ev []TrackerEvent, layout, unit, example string) string {
|
||||
groups := map[string]int{}
|
||||
var order []string
|
||||
for _, e := range ev {
|
||||
g := time.UnixMilli(e.TS).In(time.Local).Format(layout)
|
||||
if groups[g] == 0 {
|
||||
order = append(order, g)
|
||||
}
|
||||
groups[g]++
|
||||
}
|
||||
sort.Strings(order)
|
||||
parts := make([]string, 0, len(order))
|
||||
for _, g := range order {
|
||||
parts = append(parts, fmt.Sprintf("%s (%d)", g, groups[g]))
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Tracker « %s » en %s — %d points :\n%s\n", name, when, len(ev), strings.Join(parts, " · "))
|
||||
fmt.Fprintf(&b, "Zoom : %s pour %s.", example, unit)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// trackerEventsCap plafonne le nombre d'événements listés d'un coup : au-delà,
|
||||
// c'est le contexte qu'on gonflerait pour rien — un `when` plus précis affine.
|
||||
const trackerEventsCap = 60
|
||||
|
||||
// trackerEvents liste les événements (id, date, texte), plafonné.
|
||||
func trackerEvents(name, when string, ev []TrackerEvent) string {
|
||||
var b strings.Builder
|
||||
shown := ev
|
||||
trimmed := false
|
||||
if len(ev) > trackerEventsCap {
|
||||
shown = ev[len(ev)-trackerEventsCap:] // les plus récents
|
||||
trimmed = true
|
||||
}
|
||||
fmt.Fprintf(&b, "Tracker « %s »", name)
|
||||
if strings.TrimSpace(when) != "" {
|
||||
fmt.Fprintf(&b, " en %s", when)
|
||||
}
|
||||
if trimmed {
|
||||
fmt.Fprintf(&b, " — %d points (les %d plus récents ; affine le when pour les plus anciens) :\n", len(ev), trackerEventsCap)
|
||||
} else {
|
||||
fmt.Fprintf(&b, " — %d points :\n", len(ev))
|
||||
}
|
||||
for _, e := range shown {
|
||||
fmt.Fprintf(&b, "- [%s] %s — %s\n", e.ID, fmtEvent(e), e.Text)
|
||||
}
|
||||
b.WriteString("Modifier/supprimer : action:\"edit\"|\"delete\", name, id (l'identifiant entre crochets).")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// monthsLine résume des événements en « AAAA-MM (n) · … ».
|
||||
func monthsLine(ev []TrackerEvent) string {
|
||||
groups := map[string]int{}
|
||||
var order []string
|
||||
for _, e := range ev {
|
||||
g := time.UnixMilli(e.TS).In(time.Local).Format("2006-01")
|
||||
if groups[g] == 0 {
|
||||
order = append(order, g)
|
||||
}
|
||||
groups[g]++
|
||||
}
|
||||
sort.Strings(order)
|
||||
parts := make([]string, 0, len(order))
|
||||
for _, g := range order {
|
||||
parts = append(parts, fmt.Sprintf("%s (%d)", g, groups[g]))
|
||||
}
|
||||
return strings.Join(parts, " · ")
|
||||
}
|
||||
|
||||
// --- index injecté dans le contexte -------------------------------------------
|
||||
|
||||
// trackerIndexPrefix marque le message d'index des trackers injecté dans
|
||||
// l'historique (détection / anti-doublon), comme l'index mémoire.
|
||||
const trackerIndexPrefix = "Trackers (tracker tool)"
|
||||
|
||||
// trackerIndexMessage construit le message système listant les trackers du projet
|
||||
// avec LEUR DERNIER POINT, injecté UNE FOIS en début de conversation (et après un
|
||||
// compactage). L'IA sait alors qu'ils existent ET a souvent la réponse directe —
|
||||
// la dernière valeur — SANS appeler l'outil : elle ne l'appelle que pour fouiller
|
||||
// l'historique ou pour modifier. Quelques lignes, jamais le contenu.
|
||||
func trackerIndexMessage() (Message, bool) {
|
||||
if memMode() != MemAlways {
|
||||
return Message{}, false
|
||||
}
|
||||
list := trackerList()
|
||||
if len(list) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(trackerIndexPrefix + " — this is the COMPLETE list of your trackers, each with its LATEST point. To say which trackers exist or give a latest value, answer straight from this — do NOT call tracker with no arguments to list them, it would only repeat this. Call tracker(name[, when]) only to look further back in history, or with action \"add\"/\"edit\"/\"delete\" to change data.\n")
|
||||
for _, m := range list {
|
||||
last := ""
|
||||
if m.last != nil {
|
||||
last = " — " + m.last.Text + " (" + fmtEvent(*m.last) + ")"
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s%s\n", m.Name, last)
|
||||
}
|
||||
return Message{Role: "system", Content: strings.TrimRight(b.String(), "\n")}, true
|
||||
}
|
||||
|
||||
// --- outil modèle -------------------------------------------------------------
|
||||
|
||||
// trackerTool : schéma annoncé au modèle. Un seul outil pour les quatre actions —
|
||||
// quatre schémas coûteraient quatre fois le contexte pour la même chose.
|
||||
func trackerTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "tracker",
|
||||
Description: "Dated data piling up over time (counters, readings, logs). View drills down level by level: no name lists trackers, name gives years/months, name+when goes deeper.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"type": "string", "enum": []string{"view", "add", "edit", "delete"}, "description": "Default \"view\"."},
|
||||
"name": map[string]any{"type": "string", "description": "Tracker name."},
|
||||
"when": map[string]any{"type": "string", "description": "Date, as precise as needed: \"2026\", \"2026-07\", \"2026-07-15\", \"2026-07-15 14:30\"."},
|
||||
"text": map[string]any{"type": "string", "description": "Value or note (add/edit)."},
|
||||
"id": map[string]any{"type": "string", "description": "Point id, shown in brackets (edit/delete)."},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolTracker exécute l'outil `tracker` et rend le texte vu par le modèle.
|
||||
func toolTracker(args map[string]any) string {
|
||||
action := strings.ToLower(strings.TrimSpace(str(args["action"])))
|
||||
name := strings.TrimSpace(str(args["name"]))
|
||||
when := strings.TrimSpace(str(args["when"]))
|
||||
text := strings.TrimSpace(str(args["text"]))
|
||||
id := strings.TrimSpace(str(args["id"]))
|
||||
|
||||
switch action {
|
||||
case "", "view", "read", "list":
|
||||
return trackerView(name, when)
|
||||
case "add":
|
||||
newID, err := trackerAdd(name, when, text)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("[ok] point ajouté à « %s » (id %s).", name, newID)
|
||||
case "edit":
|
||||
if name == "" || id == "" {
|
||||
return "[erreur] name et id sont requis pour modifier un point"
|
||||
}
|
||||
if err := trackerEditEvent(slugifyIdent(name), id, when, text); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("[ok] point %s de « %s » modifié.", id, name)
|
||||
case "delete":
|
||||
if name == "" {
|
||||
return "[erreur] name requis"
|
||||
}
|
||||
// Sans id, c'est le tracker ENTIER qu'on supprime : on l'exige explicite
|
||||
// pour qu'une suppression de point ratée n'efface pas tout l'historique.
|
||||
if id == "" {
|
||||
return "[erreur] id requis — supprimer un tracker entier se fait depuis l'interface, pas par l'outil"
|
||||
}
|
||||
if err := trackerDeleteEvent(slugifyIdent(name), id); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
return fmt.Sprintf("[ok] point %s de « %s » supprimé.", id, name)
|
||||
}
|
||||
return fmt.Sprintf("[erreur] action inconnue %q (view, add, edit, delete)", action)
|
||||
}
|
||||
|
||||
// str extrait une chaîne d'un argument d'outil, tolérant au type (les modèles
|
||||
// envoient volontiers un nombre là où le schéma dit « string »).
|
||||
func str(v any) string {
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return s
|
||||
case float64:
|
||||
return strings.TrimSuffix(fmt.Sprintf("%.0f", s), ".0")
|
||||
case nil:
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parseWhen accepte une date à précision variable. Le point important est
|
||||
// dateOnly : un relevé saisi « le 12 mars » n'a pas d'heure, l'afficher
|
||||
// « 00:00 » serait une information inventée.
|
||||
func TestParseWhenPrecisionVariable(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
dateOnly bool
|
||||
}{
|
||||
{"2026", true},
|
||||
{"2026-07", true},
|
||||
{"2026-07-15", true},
|
||||
{"2026-07-15 14:30", false},
|
||||
{"2026-07-15T14:30", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ts, dateOnly, err := parseWhen(c.in)
|
||||
if err != nil {
|
||||
t.Fatalf("parseWhen(%q) : %v", c.in, err)
|
||||
}
|
||||
if ts <= 0 {
|
||||
t.Fatalf("parseWhen(%q) rend un instant nul", c.in)
|
||||
}
|
||||
if dateOnly != c.dateOnly {
|
||||
t.Fatalf("parseWhen(%q) dateOnly = %v, attendu %v", c.in, dateOnly, c.dateOnly)
|
||||
}
|
||||
}
|
||||
if _, _, err := parseWhen("la semaine dernière"); err == nil {
|
||||
t.Fatal("une date incompréhensible doit être refusée, pas rangée à un instant arbitraire")
|
||||
}
|
||||
// Vide = maintenant, et AVEC heure : c'est une saisie à l'instant t.
|
||||
ts, dateOnly, err := parseWhen("")
|
||||
if err != nil || dateOnly || ts <= 0 {
|
||||
t.Fatalf("parseWhen(\"\") = (%d, %v, %v), attendu maintenant avec heure", ts, dateOnly, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Un point sans heure ne doit jamais s'afficher avec un faux minuit.
|
||||
func TestFmtEventSansHeure(t *testing.T) {
|
||||
ts, dateOnly, err := parseWhen("2026-07-15")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := fmtEvent(TrackerEvent{TS: ts, DateOnly: dateOnly})
|
||||
if strings.Contains(got, ":") {
|
||||
t.Fatalf("un point sans heure affiche une heure : %q", got)
|
||||
}
|
||||
if got != "2026-07-15" {
|
||||
t.Fatalf("fmtEvent = %q, attendu 2026-07-15", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le stockage est cloisonné par projet, comme les pages : un tracker d'un projet
|
||||
// ne doit pas apparaître dans un autre.
|
||||
func TestTrackerCloisonneParProjet(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
|
||||
if _, err := trackerAdd("poids", "2026-01-02", "78,2 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := createProject("NAS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := setActiveProject(p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(trackerList()); n != 0 {
|
||||
t.Fatalf("%d tracker(s) visibles depuis un projet neuf", n)
|
||||
}
|
||||
if _, err := trackerAdd("température", "2026-01-02", "41 °C"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(trackerList()); n != 1 {
|
||||
t.Fatalf("%d tracker(s) dans le projet NAS, attendu 1", n)
|
||||
}
|
||||
if err := setActiveProject(defaultProjectSlug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := trackerList()
|
||||
if len(list) != 1 || list[0].Name != "poids" {
|
||||
t.Fatalf("le projet Générale ne retrouve pas son tracker : %+v", list)
|
||||
}
|
||||
}
|
||||
|
||||
// Les points sont triés par date à l'enregistrement : on peut donc saisir un
|
||||
// relevé oublié après coup sans que l'historique parte en désordre.
|
||||
func TestTrackerTriParDate(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
|
||||
for _, when := range []string{"2026-03-01", "2026-01-01", "2026-02-01"} {
|
||||
if _, err := trackerAdd("poids", when, "valeur "+when); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
s, ok := trackerLoad("poids")
|
||||
if !ok {
|
||||
t.Fatal("tracker introuvable")
|
||||
}
|
||||
if len(s.Events) != 3 {
|
||||
t.Fatalf("%d points, attendu 3", len(s.Events))
|
||||
}
|
||||
for i := 1; i < len(s.Events); i++ {
|
||||
if s.Events[i-1].TS > s.Events[i].TS {
|
||||
t.Fatalf("points non triés : %d après %d", s.Events[i].TS, s.Events[i-1].TS)
|
||||
}
|
||||
}
|
||||
// La vue d'ensemble doit annoncer la DERNIÈRE valeur, pas la dernière saisie.
|
||||
m := trackerList()[0]
|
||||
if !strings.Contains(m.LastText, "2026-03-01") {
|
||||
t.Fatalf("dernière valeur = %q, attendu celle de mars", m.LastText)
|
||||
}
|
||||
}
|
||||
|
||||
// Renommer re-clé le tracker quand le slug change : sans ça, un point ajouté
|
||||
// après renommage (trackerAdd re-slugifie le nom) créerait un tracker parallèle.
|
||||
func TestTrackerRenameRecleLeTracker(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
if _, err := trackerAdd("poids", "2026-01-01", "78 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := trackerRename("poids", "Masse corporelle"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := trackerLoad("poids"); ok {
|
||||
t.Fatal("l'ancienne clé existe encore : deux trackers pour la même donnée")
|
||||
}
|
||||
s, ok := trackerLoad("masse-corporelle")
|
||||
if !ok {
|
||||
t.Fatal("tracker introuvable sous son nouveau slug")
|
||||
}
|
||||
if len(s.Events) != 1 {
|
||||
t.Fatalf("%d point(s) après renommage, attendu 1", len(s.Events))
|
||||
}
|
||||
// Un point ajouté sous le NOUVEAU nom doit rejoindre le même tracker.
|
||||
if _, err := trackerAdd("Masse corporelle", "2026-02-01", "77 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(trackerList()); n != 1 {
|
||||
t.Fatalf("%d trackers après ajout post-renommage, attendu 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Déplacer vers un autre projet emporte les points, et ne laisse rien derrière.
|
||||
func TestTrackerMoveToProject(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
if _, err := trackerAdd("conso", "2026-01-01", "412 kWh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p, err := createProject("Maison")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := trackerMoveToProject("conso", p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := len(trackerList()); n != 0 {
|
||||
t.Fatalf("%d tracker(s) restants dans le projet d'origine", n)
|
||||
}
|
||||
if err := setActiveProject(p.Slug); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, ok := trackerLoad("conso")
|
||||
if !ok || len(s.Events) != 1 {
|
||||
t.Fatalf("tracker mal arrivé dans le projet cible : ok=%v", ok)
|
||||
}
|
||||
}
|
||||
|
||||
// La vue par niveaux ne doit JAMAIS tout charger : c'est la raison d'être des
|
||||
// trackers. Sur un historique long, la vue d'entrée résume par année.
|
||||
func TestTrackerViewNeChargeJamaisTout(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
|
||||
// 400 points étalés sur trois ans : bien au-delà du plafond d'affichage.
|
||||
base := time.Date(2024, 1, 1, 0, 0, 0, 0, time.Local)
|
||||
for i := 0; i < 400; i++ {
|
||||
d := base.AddDate(0, 0, i*3).Format("2006-01-02")
|
||||
if _, err := trackerAdd("conso", d, "valeur "+d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
skeleton := trackerView("conso", "")
|
||||
if strings.Count(skeleton, "\n") > 10 {
|
||||
t.Fatalf("la vue d'entrée déverse l'historique :\n%s", skeleton)
|
||||
}
|
||||
if !strings.Contains(skeleton, "Années") {
|
||||
t.Fatalf("la vue d'entrée doit résumer par année :\n%s", skeleton)
|
||||
}
|
||||
// Une descente au mois liste les points, mais reste plafonnée.
|
||||
events := trackerView("conso", "2024-01")
|
||||
if n := strings.Count(events, "\n- "); n > trackerEventsCap {
|
||||
t.Fatalf("%d points listés, plafond %d", n, trackerEventsCap)
|
||||
}
|
||||
}
|
||||
|
||||
// L'index injecté porte la DERNIÈRE valeur de chaque tracker : c'est ce qui
|
||||
// permet à l'IA de répondre « tu pesais 78 kg » sans appeler l'outil.
|
||||
func TestTrackerIndexMessagePorteLaDerniereValeur(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
if err := setMemMode(MemAlways); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := trackerIndexMessage(); ok {
|
||||
t.Fatal("un index est produit alors qu'aucun tracker n'existe")
|
||||
}
|
||||
if _, err := trackerAdd("poids", "2026-01-01", "78 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := trackerAdd("poids", "2026-02-01", "77 kg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, ok := trackerIndexMessage()
|
||||
if !ok {
|
||||
t.Fatal("aucun index malgré un tracker")
|
||||
}
|
||||
s, _ := m.Content.(string)
|
||||
if !strings.HasPrefix(s, trackerIndexPrefix) {
|
||||
t.Fatalf("préfixe manquant : %q", s)
|
||||
}
|
||||
if !strings.Contains(s, "77 kg") {
|
||||
t.Fatalf("la dernière valeur n'est pas dans l'index : %q", s)
|
||||
}
|
||||
if strings.Contains(s, "78 kg") {
|
||||
t.Fatalf("l'index déverse l'historique au lieu du dernier point : %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
// L'outil du modèle : les quatre actions passent par un seul schéma, et
|
||||
// supprimer un tracker ENTIER lui est refusé — une erreur d'id ne doit pas
|
||||
// effacer des années de relevés.
|
||||
func TestToolTrackerActions(t *testing.T) {
|
||||
newProjectHome(t)
|
||||
ensureDefaultProject()
|
||||
|
||||
if out := toolTracker(map[string]any{"action": "add", "name": "poids", "when": "2026-01-01", "text": "78 kg"}); !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("ajout refusé : %q", out)
|
||||
}
|
||||
s, ok := trackerLoad("poids")
|
||||
if !ok || len(s.Events) != 1 {
|
||||
t.Fatal("le point n'a pas été enregistré")
|
||||
}
|
||||
id := s.Events[0].ID
|
||||
|
||||
if out := toolTracker(map[string]any{"action": "edit", "name": "poids", "id": id, "text": "77 kg"}); !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("modification refusée : %q", out)
|
||||
}
|
||||
if s, _ := trackerLoad("poids"); s.Events[0].Text != "77 kg" {
|
||||
t.Fatalf("texte non modifié : %q", s.Events[0].Text)
|
||||
}
|
||||
|
||||
if out := toolTracker(map[string]any{"action": "delete", "name": "poids"}); !strings.HasPrefix(out, "[erreur]") {
|
||||
t.Fatalf("suppression du tracker entier acceptée par l'outil : %q", out)
|
||||
}
|
||||
if _, ok := trackerLoad("poids"); !ok {
|
||||
t.Fatal("le tracker a été supprimé malgré le refus annoncé")
|
||||
}
|
||||
|
||||
if out := toolTracker(map[string]any{"action": "delete", "name": "poids", "id": id}); !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("suppression du point refusée : %q", out)
|
||||
}
|
||||
if out := toolTracker(map[string]any{"action": "danser"}); !strings.HasPrefix(out, "[erreur]") {
|
||||
t.Fatalf("action inconnue acceptée : %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// str tolère qu'un modèle envoie un nombre là où le schéma annonce une chaîne :
|
||||
// c'est fréquent, et refuser l'appel pour ça n'apprend rien au modèle.
|
||||
func TestStrTolerantAuType(t *testing.T) {
|
||||
if got := str(float64(42)); got != "42" {
|
||||
t.Fatalf("str(42.0) = %q, attendu \"42\"", got)
|
||||
}
|
||||
if got := str(nil); got != "" {
|
||||
t.Fatalf("str(nil) = %q", got)
|
||||
}
|
||||
if got := str("texte"); got != "texte" {
|
||||
t.Fatalf("str(\"texte\") = %q", got)
|
||||
}
|
||||
}
|
||||
+1953
-238
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "Loki",
|
||||
"short_name": "Loki",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#5E7F5A",
|
||||
"icons": [
|
||||
{
|
||||
"src": "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -14,6 +14,10 @@
|
||||
(iOS n'accepte pas le SVG pour l'icône d'accueil). Aucun fichier externe requis. -->
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><rect width='12' height='12' rx='2' ry='2' fill='%235E7F5A'/><rect x='6' y='3' width='2' height='2' fill='%23fff'/><rect x='6' y='5' width='2' height='2' fill='%23fff'/><rect x='4' y='7' width='2' height='2' fill='%23fff'/></svg>">
|
||||
<link rel="apple-touch-icon" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALQAAAC0CAYAAAA9zQYyAAADD0lEQVR4nOzdW27aWgBGYag6ylZqh1I6lFZKppmjEylRLlyMsbG9+n3vsP2w8mfDC193d/Dj17ene5zDuv39/bif+4xZDhAwQ8wR+GRvKGJuMVXcN7+JkJnSrWGPfrGQmdPYsL+MeZGYmdvYxq76KxAyS7hmrQcvtJhZyjXtDQpazCxtaIMXgxYzazGkxbNBi5m1udTkyaDFzFqda3PU13awVkeDts6s3alGPwUtZrbiWKuuHKS8C9o6szUfm7XQpLwGbZ3ZqrftWmhSBE3Kc9CuG2zdS8MWmhRBkyJoUvbuz5RYaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSRE0KYImRdCkCJoUQZMiaFIETYqgSbnLj9ez2/05PCxy7s/D90XOXYqFJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAiaFEGTImhSBE2KoEkRNCmCJkXQpAialK9LP8C9/Tk8LHLuz8P3Rc7911hoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZl/+PXt6elHwKmYqFJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgiZF0KQImhRBkyJoUgRNiqBJETQpgibly9/fj/ulHwKm8H/LFpoUQZMiaFKeg3aPZuteGrbQpAialNegXTvYqrftWmhS3gVtpdmaj81aaFI+BW2l2YpjrR5daFGzdqcadeUg5WTQVpq1Otfm2YUWNWtzqcmLVw5RsxZDWhx0hxY1Sxva4OAPhaJmKde0NypSPwXHPYwZ0VFf21lr5ja2sZvDtNZM6daxnGxphc0tpvqvP8vVQdwMMcfV9S53YYGzu9Nnr/8CAAD//xZRk2OJYiOqAAAAAElFTkSuQmCC">
|
||||
<!-- Manifeste PWA : installabilité Android/desktop et, surtout, Web Push. iOS
|
||||
s'appuie sur les métas apple-* ci-dessus ; le manifeste ne le gêne pas.
|
||||
Servi à la racine par web_server.go, comme /sw.js. -->
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<!-- Marque le mode PWA AVANT le rendu : la hauteur de coque et le défilement du
|
||||
document en dépendent (voir styles.css). ⚠️ On NE PEUT PAS se fier au média
|
||||
`display-mode:standalone` : WebKit l'adosse au `display` du MANIFESTE, or cette
|
||||
@@ -51,6 +55,14 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span id="status-svc" class="statuspill" onclick="toggleSvcLog()" title="voir le journal du moteur">…</span>
|
||||
</div>
|
||||
<div id="side-scroll">
|
||||
<!-- Projet actif : il gouverne la mémoire, les trackers ET la liste des
|
||||
discussions en dessous. Posé AU-DESSUS d'elles parce qu'il les contient —
|
||||
changer de projet change la liste. Le bouton ouvre le panneau de gestion
|
||||
(créer, renommer, décrire, supprimer), geste bien plus rare que la bascule. -->
|
||||
<div class="projrow">
|
||||
<select id="proj-switch" onchange="onProjectSwitch(this)" title="projet actif — mémoire, trackers et discussions cloisonnés" aria-label="projet actif"></select>
|
||||
<button class="preset-add" onclick="openSettings('projets')" title="gérer les projets" aria-label="gérer les projets"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg></button>
|
||||
</div>
|
||||
<!-- Discussions : plusieurs fils, la liste la plus récente en tête. Le fil
|
||||
actif est partagé par tous les appareils (comme la conversation unique
|
||||
d'origine) : basculer ici bascule partout.
|
||||
@@ -90,7 +102,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
|
||||
bloc entier se replie d'un clic. -->
|
||||
<div id="side-perf">
|
||||
<div class="perf-head"><span>Performance</span></div>
|
||||
<!-- « Libérer la VRAM » est posé LÀ, sur les jauges : c'est en les regardant
|
||||
qu'on décide de rendre la carte à une autre application. Le libellé
|
||||
bascule en « Recharger le modèle » dès que le moteur est arrêté — voir
|
||||
paintVramBtn (js/05-status.js). -->
|
||||
<div class="perf-head"><span>Performance</span><button id="vram-btn" class="perf-act" onclick="vramBtnClick()" title="décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine">Libérer la VRAM</button></div>
|
||||
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -161,7 +177,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<option value="high">haute</option>
|
||||
<option value="xhigh">maximale</option>
|
||||
</select>
|
||||
<!-- Dictée vocale : enregistre au micro, transcrit en local (whisper.cpp
|
||||
<!-- Dictée vocale : enregistre au micro, transcrit en local (Parakeet
|
||||
côté serveur) et pose le texte dans le champ. Voir js/23-dictate.js.
|
||||
Le navigateur n'autorise le micro qu'en HTTPS (ou localhost). -->
|
||||
<button id="mic" onclick="dictateToggle()" title="dicter au micro" aria-label="dicter au micro">
|
||||
@@ -194,7 +210,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
||||
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
||||
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles), puis se redessine seul
|
||||
à chaque résultat d'outil, fin de tour ou dépôt (filesOnActivity) — un ReadDir à chaque
|
||||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||||
utile à personne. -->
|
||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||
@@ -226,6 +243,54 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Preset « externe » : le chat part vers une API OpenAI-compatible distante
|
||||
(OpenAI, Groq, OpenRouter, un vLLM sur une autre machine…) au lieu du
|
||||
llama-server local. C'est un preset comme un autre — il apparaît dans la
|
||||
même liste, se bascule pareil — mais ses réglages n'ont rien à voir avec
|
||||
ceux d'un modèle local : ni quantification, ni couches GPU, ni moteur.
|
||||
D'où une fenêtre à part plutôt que des champs grisés dans l'éditeur. -->
|
||||
<div id="ext-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeExternal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:520px;display:flex;flex-direction:column;max-height:90vh">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong id="ext-title">API externe</strong>
|
||||
<button onclick="closeExternal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div class="pe-body" style="padding:0">
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Connexion</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row stack plain"><input id="ext-name" class="pe-inp" placeholder="Nom du preset (ex. Groq 70B)"></div>
|
||||
<div class="pe-row stack">
|
||||
<span class="pe-row-l">URL<span class="pe-sub">base (…/v1) ou URL complète des complétions</span></span>
|
||||
<input id="ext-url" class="pe-inp" placeholder="https://api.groq.com/openai/v1" spellcheck="false">
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<span class="pe-row-l">Modèle<span class="pe-sub">le nom exact attendu par l'API</span></span>
|
||||
<input id="ext-model" class="pe-inp" placeholder="llama-3.3-70b-versatile" spellcheck="false">
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<span class="pe-row-l">Clé d'API<span class="pe-sub" id="ext-key-sub">vide = aucune authentification</span></span>
|
||||
<input id="ext-key" class="pe-inp" type="password" autocomplete="off" spellcheck="false" placeholder="sk-…" oninput="extKeyTouched=true">
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Contexte<span class="pe-sub">pilote la jauge et le seuil de compactage</span></span>
|
||||
<span class="pe-row-c"><input id="ext-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768"><span class="pe-unit">tok</span></span>
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<button id="ext-test" class="pe-link" onclick="testExternal()">Tester la connexion</button>
|
||||
<div id="ext-test-out" class="pe-note"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:space-between;gap:6px">
|
||||
<button id="ext-del" onclick="deleteExternal()" class="danger" style="display:none">supprimer</button>
|
||||
<span style="flex:1"></span>
|
||||
<button onclick="closeExternal()">annuler</button>
|
||||
<button id="ext-save" onclick="saveExternal()" style="border-color:var(--accent);color:var(--accent)">enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
||||
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
||||
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
||||
@@ -245,6 +310,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div class="set-shell">
|
||||
<nav class="set-nav" id="set-nav" aria-label="sections des réglages">
|
||||
<div class="set-nav-h">Intelligence artificielle</div>
|
||||
<button data-pane="projets" onclick="settingsShow('projets')">Projets</button>
|
||||
<button data-pane="config" onclick="settingsShow('config')">Config active</button>
|
||||
<button data-pane="presets" onclick="settingsShow('presets')">Presets</button>
|
||||
<button data-pane="agent" onclick="settingsShow('agent')"><span>Mode agent</span><span id="agent-badge" class="sbadge"></span></button>
|
||||
@@ -274,17 +340,36 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
||||
</section>
|
||||
<!-- Projets : un chantier = une mémoire, ses discussions et ses trackers. La
|
||||
BASCULE se fait dans la barre latérale (geste quotidien) ; ici on gère
|
||||
(créer, décrire, renommer, supprimer), ce qui est rare. -->
|
||||
<section class="set-pane" data-pane="projets">
|
||||
<div class="set-pane-h"><span>Projets</span></div>
|
||||
<div class="muted" style="font-size:12px;line-height:1.5;margin:0 2px 8px">Chaque projet a sa <b>mémoire</b>, ses <b>discussions</b> et ses <b>trackers</b>, séparés des autres. La <b>description</b> est donnée à l'IA au début de chaque discussion du projet : elle sait sur quoi elle travaille sans qu'on le lui réexplique.</div>
|
||||
<div class="slist">
|
||||
<div class="rowbody">
|
||||
<div id="proj-list" class="mcp-list"></div>
|
||||
<button class="addbtn" style="margin-top:6px" onclick="newProject()">+ ajouter un projet</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="config" id="cfg-details">
|
||||
<div class="set-pane-h"><span>Config active</span></div>
|
||||
<div id="cfg" class="slist">…</div>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="presets">
|
||||
<div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div>
|
||||
<div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openExternal('')" title="ajouter une API externe" aria-label="ajouter une API externe"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16a5.5 5.5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/></svg></button><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div>
|
||||
<div class="slist"><div id="presets" class="rowlist"></div></div>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="agent">
|
||||
<div class="set-pane-h"><span>Mode agent</span></div>
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||
<!-- Notifications Web Push. Indépendantes du mode agent (une réponse de chat
|
||||
prête est utile à savoir sans aucun outil), donc HORS du groupe grisé.
|
||||
À régler sur CHAQUE appareil : l'abonnement appartient au navigateur. -->
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="push-toggle" onchange="togglePush()"><span class="slider"></span></span> me prévenir quand c'est prêt<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Le serveur envoie une <b>notification</b> à la fin d'une réponse et à la fin d'une <b>tâche planifiée</b>, même l'app fermée ou le téléphone verrouillé. Demande <b>HTTPS</b> (ou localhost). Sur iPhone : <b>ajoute d'abord Loki à l'écran d'accueil</b>, puis active depuis l'app installée. À régler sur chaque appareil.</span></span></label>
|
||||
<div class="rowbody"><div id="push-status" class="muted" style="font-size:11px"></div></div>
|
||||
</div>
|
||||
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
||||
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
||||
entier est grisé/inerte quand l'agent est off (voir setAgentGate). -->
|
||||
@@ -298,6 +383,22 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<option value="off">désactivée</option>
|
||||
</select>
|
||||
</label>
|
||||
<!-- Chiffrement au repos. La clé est celle de pilotage de CET appareil
|
||||
(localStorage) : le serveur n'en a que l'empreinte, il ne peut pas
|
||||
ouvrir le coffre seul. Couvre les pages, les discussions, les blocs
|
||||
archivés au compactage et les trackers. -->
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="mem-enc-toggle" onchange="toggleMemEncrypt()"><span class="slider"></span></span> chiffrer la mémoire sur le disque<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Chiffre en <b>AES-256-GCM</b> les pages mémoire, les discussions, les blocs archivés et les trackers sous <code>/data</code>. La clé de données est enfermée dans un coffre (Argon2id) ouvert par la <b>clé de pilotage de cet appareil</b> ou par une <b>clé de récupération</b> donnée une seule fois à l'activation. Un <b>snapshot</b> est pris avant chaque bascule, et rien n'est supprimé avant relecture vérifiée.</span></span></label>
|
||||
<div class="rowbody"><div id="mem-enc-status" class="muted" style="font-size:11px"></div></div>
|
||||
<!-- Sauvegarde : le MÊME paquet chiffré que l'amont envoie à son relais,
|
||||
mais en fichier local — rien ne quitte la machine sans qu'on le
|
||||
demande. Nécessite le chiffrement actif : c'est sa clé qui scelle. -->
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Sauvegarde chiffrée</span>
|
||||
<span class="pe-row-c">
|
||||
<button onclick="backupExport()">exporter</button>
|
||||
<button onclick="backupImport()">importer</button>
|
||||
</span>
|
||||
</div>
|
||||
<div id="mem-pages-bar" class="pages-bar" onclick="toggleMemPages()">
|
||||
<span class="pages-title">Voir la mémoire <span id="mem-count" class="mem-count"></span></span>
|
||||
<button class="addbtn" onclick="event.stopPropagation();openMem('')" title="ajouter une page mémoire">+ ajouter</button>
|
||||
@@ -311,6 +412,16 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
<div id="mem-list" class="mem-list rowlist muted" style="font-size:12px"></div>
|
||||
</div>
|
||||
<!-- Trackers : le 3e type de mémoire. Rangé sous « Mémoire » parce que
|
||||
c'en est une — mais séparé des pages, parce qu'on ne les lit pas
|
||||
pareil : une page se lit en entier, un tracker jamais. -->
|
||||
<div id="track-bar" class="pages-bar" onclick="toggleTrackers()">
|
||||
<span class="pages-title">Trackers <span id="track-count" class="mem-count"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Données <b>datées qui s'accumulent</b> : compteurs, relevés, journaux d'événements (poids, abonnés, relevés de compteur…). L'IA y ajoute des points et les consulte par niveaux — jamais en entier, quelle que soit l'ancienneté. La <b>dernière valeur</b> de chaque tracker lui est donnée d'emblée : elle répond sans appeler d'outil.</span></span></span>
|
||||
<button class="addbtn" onclick="event.stopPropagation();newTracker()" title="créer un tracker">+ ajouter</button>
|
||||
</div>
|
||||
<div id="track-body" hidden>
|
||||
<div id="track-list" class="mem-list rowlist muted" style="font-size:12px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="net-block" class="gated-group">
|
||||
@@ -349,6 +460,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Pilotage de navigateur (computer use) : axe de capacité à part, sous le
|
||||
mode agent comme l'accès internet — un outil qui clique et tape dans une
|
||||
page fait des actions RÉELLES, même niveau de confiance que bash. -->
|
||||
<div id="computer-block" class="gated-group">
|
||||
<div class="subhead">Contrôle du navigateur<span id="computer-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Donne à l'IA le contrôle d'un <b>navigateur</b> dans le conteneur (outils <b>browser_open / browser_find / browser_click / browser_type / browser_key / browser_scroll</b>). L'IA lit les éléments <b>numérotés</b> de la page et agit par numéro — aucune vision requise, ça marche avec de petits modèles. <code>browser_screenshot</code> s'ajoute si la vision est active. Utilise le Chromium de Playwright embarqué dans l'image ; à défaut, <code>LOKI_CHROME=<chemin></code>. Actif seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="computer-toggle" onchange="toggleComputer()"><span class="slider"></span></span> laisser l'IA piloter un navigateur</label>
|
||||
<div class="rowbody">
|
||||
<div id="computer-status" class="muted" style="font-size:11px;margin-top:4px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-block" class="gated-group">
|
||||
<div class="subhead">Serveurs MCP<span id="mcp-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Connecte des <b>serveurs MCP</b> (Model Context Protocol) pour donner de nouveaux outils à l'IA : accès fichiers, bases de données, APIs métier… <b>stdio</b> = programme lancé localement (npx/uvx/binaire) · <b>http</b> = serveur distant. ⚠ un serveur stdio exécute du code sur cette machine, au même titre que le shell.</span></span></div>
|
||||
<div class="slist">
|
||||
@@ -450,30 +573,27 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<pre id="lc-log" hidden></pre>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ces trois boutons pilotent le service qui fait tourner llama-server :
|
||||
leur place est ici, avec le moteur, et pas dans une section « Service »
|
||||
à part qui ne contenait qu'eux. -->
|
||||
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).</span></span></div>
|
||||
<!-- Ces boutons pilotent le service qui fait tourner llama-server : leur
|
||||
place est ici, avec le moteur, et pas dans une section « Service » à
|
||||
part qui ne contenait qu'eux. Le dernier, « libérer la VRAM », fait
|
||||
plus qu'arrêter : il éteint aussi la dictée et annonce la mémoire
|
||||
vidéo rendue (c'est le même geste que le bouton du moniteur). -->
|
||||
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).<br><b>Libérer la VRAM</b> arrête le moteur <i>et</i> la dictée, puis annonce la mémoire vidéo rendue : c'est ce qu'il faut quand une autre application réclame la carte. Le même bouton vit sur les jauges, en pied de barre latérale.</span></span></div>
|
||||
<div class="slist"><div class="rowbody">
|
||||
<div class="row" style="margin:0">
|
||||
<button onclick="act('start')">démarrer</button>
|
||||
<button onclick="act('restart')">redémarrer</button>
|
||||
<button onclick="act('stop')">arrêter</button>
|
||||
<button onclick="vramUnload(false)" title="décharge le modèle et rend la mémoire vidéo à la machine">libérer la VRAM</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
<!-- Dictée vocale. Réglages de SERVEUR : le modèle chargé et le GPU occupé
|
||||
sont des propriétés de la machine, identiques pour tous les onglets.
|
||||
<!-- Dictée vocale. Réglages de SERVEUR : le modèle chargé est une propriété
|
||||
de la machine, identique pour tous les onglets.
|
||||
Voir js/24-dictate-settings.js et internal/loki/dictate_*.go. -->
|
||||
<section class="set-pane" data-pane="dictee">
|
||||
<div class="set-pane-h"><span>Dictée</span></div>
|
||||
<div class="subhead">Matériel<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">whisper tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après <b>dix minutes</b> sans dictée — il ne garde pas la VRAM occupée pour rien. Le GPU choisi est transmis par <code>CUDA_VISIBLE_DEVICES</code>.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span>processeur ou GPU</span>
|
||||
<select id="dic-device" class="sctl"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="subhead">Modèle<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les modèles ne sont <b>pas</b> dans l'image : ils sont téléchargés à la demande dans <code>/data/whisper/</code> et survivent aux recréations du conteneur. Plus le modèle est gros, mieux il comprend — et plus il occupe de mémoire.</span></span></div>
|
||||
<div class="subhead">Modèle<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Transcription par <b>Parakeet</b> (NVIDIA), servie par sherpa-onnx. Le modèle n'est <b>pas</b> dans l'image : il est téléchargé à la demande dans <code>/data/asr/</code> et survit aux recréations du conteneur. Il tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après <b>dix minutes</b> sans dictée. Sur le <b>processeur</b> : la carte reste au moteur de chat, et un modèle de cette taille n'en a pas besoin sur des tranches de quelques secondes.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span>modèle</span>
|
||||
<select id="dic-model" class="sctl"></select>
|
||||
@@ -483,18 +603,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span id="dic-dl-state" class="dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Reconnaissance</div>
|
||||
<div class="subhead">Reconnaissance<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Il n'y a pas de réglage de <b>langue</b> : Parakeet la détecte lui-même, phrase par phrase. Le modèle v3 couvre 25 langues européennes dont le français ; le v2 ne comprend que l'anglais.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span>langue</span>
|
||||
<select id="dic-lang" class="sctl">
|
||||
<option value="fr">français</option>
|
||||
<option value="en">anglais</option>
|
||||
<option value="de">allemand</option>
|
||||
<option value="es">espagnol</option>
|
||||
<option value="it">italien</option>
|
||||
<option value="auto">détection automatique</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="switchrow"><span>réactivité<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Longueur des tranches transcrites au fil de la dictée. Court = le texte arrive plus tôt, avec moins de contexte donc plus d'erreurs. Long = mieux reconnu, mais plus d'attente.</span></span></span>
|
||||
<select id="dic-react" class="sctl">
|
||||
<option value="court">court</option>
|
||||
@@ -549,6 +659,15 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<section class="set-pane" data-pane="appearance">
|
||||
<div class="set-pane-h"><span>Apparence</span></div>
|
||||
<div class="slist">
|
||||
<!-- Langue de l'interface. La source est française ; « English » applique
|
||||
un dictionnaire sur la coque (navigation, libellés, boutons) — ce qui
|
||||
n'y figure pas reste en français plutôt que d'afficher une clé. -->
|
||||
<label class="switchrow"><span>Langue</span>
|
||||
<select id="ui-lang" class="sctl" onchange="setUiLang(this.value)">
|
||||
<option value="fr">Français</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="switchrow rev"><span>mode sombre</span>
|
||||
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
||||
</label>
|
||||
@@ -694,6 +813,29 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Détail d'un tracker : ses points, du plus récent au plus ancien. La LISTE
|
||||
(panneau Mémoire) ne charge jamais les points — un tracker peut en compter
|
||||
des milliers ; ils n'arrivent qu'ici, à l'ouverture. -->
|
||||
<div id="track-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeTracker()">
|
||||
<div class="modal-card" style="max-width:600px">
|
||||
<div class="modal-head">
|
||||
<strong id="track-modal-title">Tracker</strong>
|
||||
<button class="modal-x" onclick="closeTracker()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="track-events" class="mcp-list"></div>
|
||||
<button class="addbtn" style="margin-top:6px" onclick="addTrackerPoint()">+ ajouter un point</button>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<div class="foot-l">
|
||||
<button onclick="renameTracker()">renommer</button>
|
||||
<button onclick="moveTracker()">déplacer</button>
|
||||
<button class="danger" onclick="deleteTracker()">supprimer</button>
|
||||
</div>
|
||||
<div class="foot-r"><button onclick="closeTracker()">fermer</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<div class="modal-head">
|
||||
@@ -819,11 +961,34 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Type de tâche : une consigne exécutée par l'IA, ou un script du dossier
|
||||
protégé lancé SEUL — sans charger le modèle ni consommer de tokens.
|
||||
Une sauvegarde, une synchro, un nettoyage n'ont rien à demander à un
|
||||
LLM ; les faire passer par lui coûtait un chargement de modèle. -->
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Type</div>
|
||||
<div class="pe-seg">
|
||||
<label><input type="radio" name="task-kind" value="agent" onchange="taskKindUI('agent')"><span>Consigne IA</span></label>
|
||||
<label><input type="radio" name="task-kind" value="script" onchange="taskKindUI('script')"><span>Script seul</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group" id="task-prompt-group">
|
||||
<div class="pe-gh">Consigne</div>
|
||||
<textarea id="task-prompt" class="pe-inp task-consigne" spellcheck="false" placeholder="Ce que l'IA doit faire, et comment livrer le résultat. Tu peux détailler librement : contexte, étapes, format du compte-rendu attendu…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="pe-group" id="task-script-group" style="display:none">
|
||||
<div class="pe-gh">Script</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">À exécuter</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="task-script"></select></span></span>
|
||||
</div>
|
||||
<div class="pe-row stack plain"><div id="task-script-hint" class="muted" style="font-size:11px"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Preset</div>
|
||||
<div class="pe-list">
|
||||
@@ -831,6 +996,13 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-l">Modèle utilisé</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="task-preset"></select></span></span>
|
||||
</div>
|
||||
<!-- Projet visé : gouverne la MÉMOIRE et les TRACKERS que la tâche voit
|
||||
quand elle s'exécute, pas sa visibilité — le panneau reste global,
|
||||
sinon une tâche semblerait déplanifiée en changeant de projet. -->
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Projet visé</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="task-project"></select></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -974,6 +1146,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
|
||||
</div>
|
||||
<!-- Modèles installés. Un .gguf survit à la suppression de son preset
|
||||
(c'est voulu : on le réutilise dans un autre). Encore faut-il
|
||||
pouvoir le REPRENDRE et, un jour, l'effacer — sans ça les
|
||||
fichiers gardés devenaient invisibles et indéboulonnables. -->
|
||||
<div id="m-inst-toggle" class="pe-row" style="cursor:pointer" onclick="toggleInstalled()">
|
||||
<span class="pe-row-l">Modèles installés<span class="pe-sub">les .gguf présents sur le disque — et de quoi en effacer un</span></span>
|
||||
<span class="pe-row-c"><span id="m-inst-state" class="pe-sub"></span><span id="m-inst-caret" class="pe-caret"></span></span>
|
||||
</div>
|
||||
<div id="m-inst-body" class="pe-row stack" style="display:none">
|
||||
<div id="m-inst-list"></div>
|
||||
</div>
|
||||
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
|
||||
touche qu'une fois, le jour où un dépôt à accès restreint refuse
|
||||
le téléchargement. Il vaut aussi pour la recherche, d'où sa place
|
||||
@@ -994,31 +1177,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Moteur</div>
|
||||
<!-- « Image » n'apparaît qu'en conteneur (moteur fourni), où
|
||||
« Précompilé » et « Compilé » sont au contraire masqués : ils
|
||||
désignent des installations que Loki ne fait pas ici. -->
|
||||
<div class="pe-seg">
|
||||
<label id="be-lab-image" style="display:none"><input type="radio" name="m-be" value="image" onchange="onBackendMode('image')"><span>Image</span><span class="be-note" id="be-image-note"></span></label>
|
||||
<label id="be-lab-fast"><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Précompilé</span><span class="be-note" id="be-fast-note"></span></label>
|
||||
<label id="be-lab-opt"><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Compilé</span><span class="be-note" id="be-opt-note"></span></label>
|
||||
<label id="be-lab-custom"><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
|
||||
</div>
|
||||
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Backend</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="m-backend-detected" onchange="onPickDetected()"></select></span></span>
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<input id="m-backend-path" class="pe-inp" oninput="onCustomPath()" placeholder="ou un chemin vers llama-server(.exe)">
|
||||
<div class="pe-note" id="be-drop-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cartes graphiques : la liste vient du moteur choisi juste au-dessus,
|
||||
car les noms ET l'ordre dépendent du backend compilé dedans. Le
|
||||
<!-- Cartes graphiques : la liste vient du moteur de la machine (celui de
|
||||
l'image ou celui mis à jour dans Réglages → Moteur — le moteur n'est
|
||||
pas un réglage de preset), car les noms ET l'ordre dépendent du backend
|
||||
compilé dedans. Le
|
||||
groupe reste masqué tant qu'on n'a pas AU MOINS DEUX cartes : avec
|
||||
une seule, il n'y a rien à arbitrer. -->
|
||||
<div class="pe-group" id="m-gpu-group" hidden>
|
||||
|
||||
@@ -18,6 +18,14 @@ async function loadStatus(){
|
||||
el.className='statuspill '+cls;
|
||||
el.innerHTML='<span class="dot"></span>'+txt;
|
||||
MODEL_READY = !!(s.active && s.health);
|
||||
// Le bouton « Libérer la VRAM » suit l'état RÉEL du moteur (et pas un drapeau
|
||||
// local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se
|
||||
// lire ici aussi, sinon le bouton propose de libérer une carte déjà libre.
|
||||
ENGINE_ACTIVE = !!s.active;
|
||||
// Un moteur qui a PLANTÉ au chargement n'est pas « déchargé » : sans ce
|
||||
// drapeau, syncSendBtn proposerait de recharger un modèle qui va replanter.
|
||||
LOAD_ERROR = s.load_error || '';
|
||||
paintVramBtn();
|
||||
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un
|
||||
// message à un modèle qui n'est pas encore chargé (voir syncSendBtn).
|
||||
STATUS_SEEN = true;
|
||||
@@ -182,6 +190,83 @@ async function loadVram(){
|
||||
g.name+' · '+vram));
|
||||
});
|
||||
}
|
||||
// ─── Libérer la mémoire vidéo ───────────────────────────────────────────────
|
||||
// Tant que le moteur tourne, le modèle occupe la VRAM : une autre application
|
||||
// qui réclame la carte (un jeu, un encodage, un autre serveur d'inférence) ne
|
||||
// trouve plus rien à prendre. Ce bouton l'en sort, et le MÊME bouton la
|
||||
// reprend ensuite — le geste existait déjà (« arrêter » le service, au fond des
|
||||
// réglages), mais son nom ne disait pas qu'il rendait la carte.
|
||||
//
|
||||
// ENGINE_ACTIVE vient de /api/status (relu toutes les 5 s), jamais d'un drapeau
|
||||
// posé au clic : sinon un second onglet, ou un arrêt venu du terminal,
|
||||
// afficherait un bouton qui ment.
|
||||
let ENGINE_ACTIVE = true, VRAM_BUSY = false, LOAD_ERROR = '';
|
||||
function paintVramBtn(){
|
||||
const b = document.getElementById('vram-btn');
|
||||
if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy
|
||||
b.disabled = false;
|
||||
b.classList.toggle('reload', !ENGINE_ACTIVE);
|
||||
b.textContent = ENGINE_ACTIVE ? 'Libérer la VRAM' : 'Recharger le modèle';
|
||||
b.title = ENGINE_ACTIVE
|
||||
? 'décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine'
|
||||
: 'relance le moteur : le modèle revient en mémoire vidéo';
|
||||
}
|
||||
function vramBtnClick(){ return ENGINE_ACTIVE ? vramUnload(false) : vramReload(); }
|
||||
function vramBusy(on, label){
|
||||
VRAM_BUSY = on;
|
||||
const b = document.getElementById('vram-btn');
|
||||
if(b){ b.disabled = on; if(on && label) b.textContent = label; }
|
||||
if(!on) paintVramBtn();
|
||||
}
|
||||
// force=true : on a déjà demandé confirmation ET accepté de couper une réponse
|
||||
// en cours d'écriture (le serveur refuse sans ça, voir handleVramUnload).
|
||||
// Les deux gestes sont exclusifs : un second clic (le bouton des réglages
|
||||
// pendant que celui du moniteur travaille) lancerait un « stop » au milieu d'un
|
||||
// démarrage, ou l'inverse. VRAM_BUSY tient lieu de verrou.
|
||||
async function vramUnload(force){
|
||||
if(VRAM_BUSY) return;
|
||||
if(!force){
|
||||
const ok = await askConfirm(
|
||||
'Décharger le modèle ? Le moteur s\'arrête et rend la mémoire vidéo — le chat reste indisponible jusqu\'au rechargement.',
|
||||
{title:'Libérer la VRAM', okText:'Libérer'});
|
||||
if(!ok) return;
|
||||
}
|
||||
vramBusy(true, 'libération…');
|
||||
let r = null;
|
||||
try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){}
|
||||
// Le serveur dit l'état RÉEL après coup : le repeindre depuis l'ancien
|
||||
// ENGINE_ACTIVE (relu seulement toutes les 5 s) afficherait « Libérer » sur
|
||||
// un moteur déjà arrêté.
|
||||
if(r && typeof r.active === 'boolean') ENGINE_ACTIVE = r.active;
|
||||
vramBusy(false);
|
||||
if(r && r.generating){
|
||||
const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?',
|
||||
{title:'Génération en cours', okText:'Décharger quand même'});
|
||||
if(ok) return vramUnload(true);
|
||||
return;
|
||||
}
|
||||
// La mesure d'abord : la dictée peut avoir rendu de la VRAM alors même que le
|
||||
// moteur était déjà arrêté — annoncer « déjà arrêté » nierait ce qui vient
|
||||
// d'être libéré. Sous quelques dizaines de Mo, c'est le bruit du bureau entre
|
||||
// deux lectures, pas une libération : on ne l'annonce pas.
|
||||
if(!r || !r.ok) toast('échec : ' + ((r && r.error) || 'réseau'));
|
||||
else if(r.gpu && r.freed_mb >= 64) toast('VRAM libérée : ' + fmtMo(r.freed_mb));
|
||||
else if(!r.was_active) toast('le moteur était déjà arrêté');
|
||||
else toast('modèle déchargé');
|
||||
loadAll();
|
||||
}
|
||||
function fmtMo(mb){ return mb >= 1024 ? (mb/1024).toFixed(1) + ' Gio' : mb + ' Mo'; }
|
||||
async function vramReload(){
|
||||
if(VRAM_BUSY) return;
|
||||
vramBusy(true, 'démarrage…');
|
||||
let r = null;
|
||||
try{ r = await jpost('/api/vram/reload', {}); }catch(_){}
|
||||
if(r && r.ok) ENGINE_ACTIVE = true;
|
||||
vramBusy(false);
|
||||
if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; }
|
||||
toast('modèle en cours de chargement…');
|
||||
loadAll();
|
||||
}
|
||||
async function loadRam(){
|
||||
const m=await jget('/api/ram');
|
||||
const box=document.getElementById('ram-details');
|
||||
|
||||
@@ -69,6 +69,16 @@ async function loadPresets(){
|
||||
nm.appendChild(document.createTextNode(x.name)); nm.title=x.name;
|
||||
// Second row: quant tag + bench perf, so the title row stays full-width.
|
||||
const meta=document.createElement('div'); meta.className='preset-meta';
|
||||
// Preset externe : ni quant ni bench à montrer — le modèle tourne ailleurs.
|
||||
// On affiche à la place d'où vient la réponse, sans quoi rien à l'écran ne
|
||||
// distinguerait un preset local d'un appel qui part sur internet.
|
||||
if(x.external){
|
||||
const et=document.createElement('span'); et.className='xtag';
|
||||
et.title='API externe — le chat part vers un serveur distant';
|
||||
et.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16a5.5 5.5 0 0 1 7 0"/><circle cx="12" cy="19.5" r="1"/></svg>';
|
||||
et.appendChild(document.createTextNode(x.model || 'externe'));
|
||||
meta.appendChild(et);
|
||||
}
|
||||
if(x.quant){
|
||||
const q=document.createElement('span'); q.className='qtag';
|
||||
q.textContent=x.quant; q.title='quantization';
|
||||
@@ -94,7 +104,10 @@ async function loadPresets(){
|
||||
// lit comme centrée dans son coin, là où le crayon penché tirait de travers.
|
||||
edit.className='preset-edit'; edit.title='réglages du preset';
|
||||
edit.innerHTML='<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
|
||||
edit.onclick=(e)=>{ e.stopPropagation(); openPreset(x.id); };
|
||||
// Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
|
||||
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
|
||||
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
|
||||
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
|
||||
row.appendChild(info); row.appendChild(edit);
|
||||
cont.appendChild(row);
|
||||
});
|
||||
@@ -115,12 +128,13 @@ async function loadAgent(){
|
||||
document.getElementById('mem-count').textContent = memPages.length ? '('+memPages.length+')' : '';
|
||||
document.getElementById('mem-search-row').style.display = memPages.length > MEM_PAGE ? '' : 'none';
|
||||
renderMemList();
|
||||
loadMemEnc();
|
||||
}
|
||||
// Mémoire + accès internet sont des sous-réglages du mode agent : sans agent, ni
|
||||
// les outils mem_* ni les outils web ne sont fournis (voir globalCaps côté Go). On
|
||||
// grise donc ces blocs quand l'agent est off pour que l'UI ne mente pas.
|
||||
function setAgentGate(on){
|
||||
['mem-block','net-block','mcp-block','param-block'].forEach(id=>{
|
||||
['mem-block','net-block','computer-block','mcp-block','param-block'].forEach(id=>{
|
||||
const el=document.getElementById(id); if(el) el.classList.toggle('gated', !on);
|
||||
});
|
||||
}
|
||||
@@ -149,9 +163,18 @@ function renderMemList(){
|
||||
const span=document.createElement('span');
|
||||
const b=document.createElement('b'); b.style.color='var(--text)'; b.textContent=x.name; span.appendChild(b);
|
||||
if(x.desc){ const d=document.createElement('span'); d.className='muted'; d.textContent=' — '+x.desc; span.appendChild(d); }
|
||||
const acts=document.createElement('span'); acts.style.cssText='display:flex;gap:4px;flex:none';
|
||||
const btn=document.createElement('button'); btn.textContent='edit'; btn.style.cssText='margin:0;padding:2px 8px;font-size:11px';
|
||||
btn.onclick=e=>{ e.stopPropagation(); openMem(x.name); };
|
||||
row.appendChild(span); row.appendChild(btn); list.appendChild(row);
|
||||
acts.appendChild(btn);
|
||||
// Déplacer vers un autre projet : proposé seulement s'il y en a un autre, et
|
||||
// jamais sur l'index MEMORY.md, qui appartient au projet et ne se déplace pas.
|
||||
if(typeof PROJECTS!=='undefined' && PROJECTS.length>1 && x.name.toUpperCase()!=='MEMORY.MD'){
|
||||
const mv=document.createElement('button'); mv.textContent='déplacer'; mv.style.cssText='margin:0;padding:2px 8px;font-size:11px';
|
||||
mv.onclick=e=>{ e.stopPropagation(); moveMemPageUI(x.name); };
|
||||
acts.appendChild(mv);
|
||||
}
|
||||
row.appendChild(span); row.appendChild(acts); list.appendChild(row);
|
||||
});
|
||||
if(matches.length > shown.length){
|
||||
const more=document.createElement('div'); more.className='mem-more';
|
||||
@@ -163,6 +186,120 @@ function renderMemList(){
|
||||
}
|
||||
// alias : plusieurs appelants rafraîchissent juste la liste des pages mémoire
|
||||
const loadMem = loadAgent;
|
||||
|
||||
// --- Chiffrement de la mémoire (pages, discussions, blocs archivés, trackers) -
|
||||
//
|
||||
// La clé de chiffrement EST la clé de pilotage (localStorage 'loki.key'), qui ne
|
||||
// vit que sur cet appareil — le serveur n'en garde qu'une empreinte, il ne peut
|
||||
// donc pas ouvrir le coffre seul. Une seule clé pour l'accès ET le chiffrement :
|
||||
// avoir l'interface sous les yeux suffit à déverrouiller, sans second secret à
|
||||
// retenir.
|
||||
let memUnlockDone=false; // tentative de déverrouillage déjà faite ce chargement
|
||||
async function loadMemEnc(){
|
||||
let h; try{ h=await jget('/api/mem/health'); }catch(e){ return; }
|
||||
const tog=document.getElementById('mem-enc-toggle'); if(!tog) return;
|
||||
// La case n'est cochée QUE si TOUT est réellement chiffré (h.fully). Un
|
||||
// chiffrement partiel, raté ou verrouillé-incomplet la laisse décochée : si
|
||||
// elle est cochée, c'est que ça l'est vraiment.
|
||||
tog.checked=!!h.fully;
|
||||
const st=document.getElementById('mem-enc-status');
|
||||
if(st){
|
||||
if(!h.encrypted) st.textContent='pages, discussions, blocs archivés et trackers sont en clair sous /data.';
|
||||
else if(h.locked) st.textContent='chiffré — verrouillé : rien n\'est lisible tant que la clé n\'est pas fournie.';
|
||||
else st.textContent='chiffré et déverrouillé · '+(h.pages|0)+' page(s)'+(h.mixed?' ⚠ certaines pages sont encore en clair':'');
|
||||
}
|
||||
// Résilience : si le drapeau a disparu mais qu'un coffre existe encore
|
||||
// (vault_copies>0), on propose QUAND MÊME le déverrouillage — sinon la
|
||||
// mémoire reste muette et rien ne demande la clé.
|
||||
const flagLost = !h.encrypted && (h.vault_copies|0) > 0;
|
||||
if(((h.encrypted && h.locked) || flagLost) && !memUnlockDone){
|
||||
memUnlockDone=true;
|
||||
const apiKey=localStorage.getItem('loki.key')||'';
|
||||
if(apiKey){ const r=await jpost('/api/mem/unlock',{secret:apiKey}); if(r.ok){ loadMem(); return; } }
|
||||
// Boucle : une clé fausse redemande tout de suite (pas besoin de recharger).
|
||||
while(true){
|
||||
const s2=await askPrompt('Saisis la clé de pilotage de cet appareil, ou la clé de récupération notée à l\'activation.',{title:'Mémoire verrouillée',placeholder:'clé de pilotage ou clé de récupération'});
|
||||
if(!s2){ memUnlockDone=false; break; } // annulé : on redemandera au prochain chargement
|
||||
const r=await jpost('/api/mem/unlock',{secret:s2});
|
||||
if(r.ok){ loadMem(); return; }
|
||||
await askAlert('Clé refusée.',{title:'Refusé'});
|
||||
}
|
||||
}
|
||||
}
|
||||
// --- Sauvegarde chiffrée en fichier (mémoire + presets + réglages) ----------
|
||||
// Le corps est scellé avec la clé de la mémoire ; la restauration ne demande que
|
||||
// la clé de pilotage (ou la clé de récupération). Aucun relais : c'est un
|
||||
// fichier, que tu ranges où tu veux.
|
||||
async function backupExport(){
|
||||
let r;
|
||||
try{ r = await jfetch('/api/backup/export'); }
|
||||
catch(e){ await askAlert('échec : '+e); return; }
|
||||
if(!r.ok){
|
||||
let msg='échec'; try{ const j=await r.json(); msg=j.error||msg; }catch(_){}
|
||||
await askAlert(msg, {title:'Sauvegarde impossible'});
|
||||
return;
|
||||
}
|
||||
const blob = await r.blob();
|
||||
// Le nom vient du serveur (Content-Disposition) ; repli daté côté client.
|
||||
let name = 'loki-backup.lkbk';
|
||||
const cd = r.headers.get('Content-Disposition')||'';
|
||||
const m = /filename="([^"]+)"/.exec(cd);
|
||||
if(m) name = m[1];
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(url), 10000);
|
||||
toast('sauvegarde téléchargée');
|
||||
}
|
||||
async function backupImport(){
|
||||
if(!await askConfirm("La mémoire, les presets et les réglages de CE serveur seront remplacés par ceux du paquet. Un snapshot de la mémoire actuelle est pris avant.",{title:'Restaurer une sauvegarde ?',okText:'Choisir le fichier',danger:true})) return;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = '.lkbk,application/octet-stream';
|
||||
inp.onchange = async () => {
|
||||
const f = inp.files && inp.files[0];
|
||||
if(!f) return;
|
||||
const buf = new Uint8Array(await f.arrayBuffer());
|
||||
// btoa par tranches : un spread sur un tableau de plusieurs Mo fait sauter
|
||||
// la pile d'appels.
|
||||
let bin = '';
|
||||
for(let i=0;i<buf.length;i+=0x8000) bin += String.fromCharCode.apply(null, buf.subarray(i, i+0x8000));
|
||||
const data = btoa(bin);
|
||||
// Boucle : une clé fausse redemande tout de suite.
|
||||
while(true){
|
||||
const secret = await askPrompt("Clé de pilotage de la machine d'origine, ou clé de récupération.",{title:'Restaurer',placeholder:'clé'});
|
||||
if(!secret) return;
|
||||
toast('restauration…');
|
||||
let r; try{ r = await jpost('/api/backup/import',{data, secret}); }catch(e){ await askAlert('échec : '+e); return; }
|
||||
if(r && r.ok){ await askAlert('Sauvegarde restaurée.',{title:'Terminé'}); loadMem(); return; }
|
||||
await askAlert((r&&r.error)||'échec',{title:'Refusé'});
|
||||
}
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
async function toggleMemEncrypt(){
|
||||
const tog=document.getElementById('mem-enc-toggle');
|
||||
if(tog.checked){
|
||||
// Déjà chiffré mais incomplet (verrouillé, ou discussions à migrer) : on ne
|
||||
// relance PAS un chiffrement, on complète en déverrouillant.
|
||||
let cur; try{ cur=await jget('/api/mem/health'); }catch(e){}
|
||||
if(cur && cur.encrypted){ memUnlockDone=false; await loadMemEnc(); return; }
|
||||
const apiKey=localStorage.getItem('loki.key')||'';
|
||||
if(!apiKey){ await askAlert("Le chiffrement utilise la clé de pilotage de cet appareil, et il n'y en a pas. Règle-la d'abord (loki set-web-key), puis reviens.",{title:'Clé de pilotage requise'}); tog.checked=false; return; }
|
||||
const ok=await askConfirm("Les pages mémoire, les discussions, les blocs archivés et les trackers seront chiffrés sur le disque. La clé de cet appareil les ouvre ; une clé de récupération te sera donnée UNE fois — note-la, elle est ta seule porte de secours.",{title:'Chiffrer la mémoire ?',okText:'Chiffrer'});
|
||||
if(!ok){ tog.checked=false; return; }
|
||||
let r; try{ r=await jpost('/api/mem/encrypt',{password:apiKey}); }catch(e){ await askAlert('échec : '+e); tog.checked=false; return; }
|
||||
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=false; loadMemEnc(); return; }
|
||||
memUnlockDone=true;
|
||||
await askAlert("Note cette clé de récupération, elle ne sera plus jamais affichée :\n\n"+r.recovery,{title:'Clé de récupération'});
|
||||
toast('chiffrement activé'); loadMem();
|
||||
} else {
|
||||
const ok=await askConfirm('Tout sera réécrit EN CLAIR sur le disque.',{title:'Déchiffrer la mémoire ?',okText:'Déchiffrer',danger:true});
|
||||
if(!ok){ tog.checked=true; return; }
|
||||
let r; try{ r=await jpost('/api/mem/decrypt',{}); }catch(e){ await askAlert('échec : '+e); tog.checked=true; return; }
|
||||
if(!r.ok){ await askAlert('échec : '+(r.error||'inconnu')); tog.checked=true; loadMemEnc(); return; }
|
||||
toast('chiffrement désactivé'); loadMem();
|
||||
}
|
||||
}
|
||||
async function toggleAgent(){
|
||||
const on=document.getElementById('agent-toggle').checked;
|
||||
if(on && !await askConfirm("L'IA aura un accès shell complet au serveur (bash) et pourra lire/écrire sa mémoire.", {title:'Activer le mode agent ?', okText:'Activer', danger:true})){ document.getElementById('agent-toggle').checked=false; return; }
|
||||
@@ -220,6 +357,32 @@ function renderInternet(s){
|
||||
else { st.innerHTML='⚠ serveur injoignable — les outils web ne seront pas proposés'; }
|
||||
}
|
||||
async function loadInternet(){ renderInternet(await jget('/api/internet')); }
|
||||
// Contrôle du navigateur (outils browser_*). Le serveur dit s'il a trouvé un
|
||||
// binaire Chromium : sans lui, les outils ne sont pas annoncés au modèle, et
|
||||
// l'interrupteur ne servirait qu'à promettre ce qui n'existe pas.
|
||||
function renderComputer(s){
|
||||
const tg=document.getElementById('computer-toggle');
|
||||
if(tg) tg.checked = !!s.enabled;
|
||||
// La pastille ne redit pas l'interrupteur : elle ne signale que l'anomalie
|
||||
// (actif mais aucun navigateur trouvé).
|
||||
if(s.enabled && !s.browser_ok) setBadge('computer-badge','warn','sans navigateur');
|
||||
else setBadge('computer-badge', null);
|
||||
const st=document.getElementById('computer-status');
|
||||
if(!st) return;
|
||||
if(!s.enabled){
|
||||
st.textContent = "inactif : l'IA n'a aucun outil de navigateur" + (s.browser_ok ? '.' : " (aucun navigateur détecté dans le conteneur).");
|
||||
st.style.color='';
|
||||
return;
|
||||
}
|
||||
if(!s.browser_ok){ st.innerHTML = '⚠ aucun Chromium détecté — image bâtie avec PLAYWRIGHT=0 ? sinon définis LOKI_CHROME=<chemin du binaire>'; return; }
|
||||
st.innerHTML = '<span style="color:var(--accent)">✓</span> navigateur prêt, outils de contrôle actifs'
|
||||
+ (s.vision ? '' : ' — vision inactive : browser_screenshot indisponible');
|
||||
}
|
||||
async function loadComputer(){ renderComputer(await jget('/api/computer')); }
|
||||
async function toggleComputer(){
|
||||
const on=document.getElementById('computer-toggle').checked;
|
||||
renderComputer(await jpost('/api/computer',{on}));
|
||||
}
|
||||
// --- Accès OpenAI (endpoint /v1 + clé API des complétions) -----------------
|
||||
let OAI_KEY='', OAI_REVEAL=false;
|
||||
async function copyText(txt, msg){
|
||||
@@ -451,7 +614,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -121,6 +121,9 @@ async function openItem(kind, key){
|
||||
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
|
||||
document.getElementById('m-dir-path').value = '';
|
||||
setModelDirsOpen(false);
|
||||
// Modèles installés : replié aussi, mais son RÉSUMÉ (« 4 modèles · 87 Go »)
|
||||
// est lu tout de suite — c'est lui qui s'affiche sur la ligne repliée.
|
||||
setInstalledOpen(false);
|
||||
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
|
||||
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
|
||||
setHFTokenOpen(false);
|
||||
@@ -161,7 +164,8 @@ async function openItem(kind, key){
|
||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||
populateSettings();
|
||||
attachDownload(); // téléchargement encore en cours côté serveur ?
|
||||
await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(),
|
||||
populateDlDirs(), refreshInstalled()]);
|
||||
if(seq !== openSeq) return;
|
||||
}
|
||||
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
|
||||
@@ -210,12 +214,31 @@ function samePath(a, b){
|
||||
return !!a && !!b && n(a) === n(b);
|
||||
}
|
||||
function baseName(p){ return String(p||'').replace(/\\/g,'/').split('/').pop().trim(); }
|
||||
// Retrouve dans `list` (/api/models) l'entrée que désigne la valeur MODEL= d'un
|
||||
// preset. Trois écritures possibles et toutes légitimes : le chemin complet, la
|
||||
// valeur produite par le sélecteur, ou le SIMPLE NOM DE FICHIER (config écrite à
|
||||
// la main, preset repris d'une autre machine, ancien preset). Le nom simple se
|
||||
// résout comme le fait le moteur — dossier de loki d'abord, puis les autres.
|
||||
//
|
||||
// Sans ce dernier cas, un MODEL=modele.gguf parfaitement valide s'affichait
|
||||
// « introuvable ; ajoute son dossier ci-dessous » alors que le fichier était là,
|
||||
// et le sélecteur ne proposait rien : impossible de refaire un preset sur un
|
||||
// modèle déjà présent sans le retélécharger.
|
||||
function matchModel(cur, list){
|
||||
if(!cur) return null;
|
||||
const all = list || [];
|
||||
const hit = all.find(x => samePath(cur, x.value) || samePath(cur, x.path));
|
||||
if(hit) return hit;
|
||||
const b = baseName(cur);
|
||||
return all.find(x => x.home && samePath(b, x.name)) || all.find(x => samePath(b, x.name)) || null;
|
||||
}
|
||||
async function populateModelPicker(){
|
||||
const sel = document.getElementById('m-model');
|
||||
const list = await jget('/api/models');
|
||||
const cur = currentModelInTextarea();
|
||||
const hit = matchModel(cur, list);
|
||||
// Les modèles peuvent venir de plusieurs dossiers (disque externe…) : on les
|
||||
// regroupe par dossier, LOKI_HOME d'abord (l'API les renvoie dans cet ordre).
|
||||
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
|
||||
const groups = [];
|
||||
for(const m of (list||[])){
|
||||
let g = groups.find(x => x.dir === m.dir);
|
||||
@@ -227,9 +250,7 @@ async function populateModelPicker(){
|
||||
for(const g of groups){
|
||||
let opts = '';
|
||||
for(const m of g.items){
|
||||
// Le preset peut référencer le chemin complet OU le simple nom de fichier.
|
||||
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
|
||||
(g.home && samePath(baseName(cur), m.name)) ? ' selected' : '';
|
||||
const on = (m === hit) ? ' selected' : '';
|
||||
if(on) matched = true;
|
||||
// Modèle en plusieurs fichiers : l'API ne renvoie que la première tranche
|
||||
// (les autres ne se lancent pas seules) et la taille de la famille entière.
|
||||
@@ -376,6 +397,141 @@ async function removeModelDir(p){
|
||||
toast('dossier retiré');
|
||||
await Promise.all([populateModelDirs(), populateModelPicker(), populateDlDirs()]);
|
||||
}
|
||||
|
||||
// ---- Modèles installés : ce qu'il y a sur le disque, et comment l'effacer ---
|
||||
// La route /api/models/delete existait depuis longtemps ; AUCUN bouton ne
|
||||
// l'appelait. Le seul moment où un .gguf pouvait disparaître, c'était en cochant
|
||||
// « supprimer aussi le fichier » à la suppression de son preset — la case pas
|
||||
// cochée, le fichier devenait inatteignable : 40 Go sur le disque que plus rien
|
||||
// dans l'interface ne savait ni nommer ni reprendre. D'où cette liste.
|
||||
//
|
||||
// Elle dit aussi QUI se sert de chaque fichier : le modèle en service ne
|
||||
// s'efface pas (le moteur l'a ouvert), et un modèle nommé par d'autres presets
|
||||
// prévient avant, en les nommant.
|
||||
let installedList = [];
|
||||
function setInstalledOpen(open){
|
||||
const b = document.getElementById('m-inst-body');
|
||||
const c = document.getElementById('m-inst-caret');
|
||||
if(!b) return;
|
||||
b.style.display = open ? '' : 'none';
|
||||
if(c) c.classList.toggle('open', open);
|
||||
// Relu à chaque dépliage : un modèle a pu arriver (téléchargement) ou partir
|
||||
// depuis l'ouverture de la fenêtre, et une liste périmée ferait cliquer
|
||||
// « supprimer » sur un fichier qui n'est plus là.
|
||||
if(open) refreshInstalled();
|
||||
}
|
||||
function toggleInstalled(){
|
||||
setInstalledOpen(document.getElementById('m-inst-body').style.display === 'none');
|
||||
}
|
||||
// Recharge la liste ET le résumé de la ligne repliée (« 4 modèles · 87 Go »),
|
||||
// qui reste juste même quand la section n'est pas dépliée.
|
||||
async function refreshInstalled(){
|
||||
try{ installedList = await jget('/api/models') || []; }catch(_){ return; }
|
||||
const st = document.getElementById('m-inst-state');
|
||||
if(st){
|
||||
const total = installedList.reduce((a, m) => a + (m.size||0), 0);
|
||||
st.textContent = installedList.length
|
||||
? installedList.length + ' modèle' + (installedList.length>1?'s':'') + ' · ' + fmtSize(total)
|
||||
: 'aucun';
|
||||
}
|
||||
const b = document.getElementById('m-inst-body');
|
||||
if(b && b.style.display !== 'none') renderInstalled();
|
||||
}
|
||||
// Construit en DOM et jamais en innerHTML : ces noms de fichiers viennent du
|
||||
// disque de l'utilisateur, pas de nous.
|
||||
function renderInstalled(){
|
||||
const box = document.getElementById('m-inst-list');
|
||||
if(!box) return;
|
||||
box.textContent = '';
|
||||
if(!installedList.length){
|
||||
const d = document.createElement('div');
|
||||
d.className = 'pe-note muted';
|
||||
d.textContent = 'Aucun .gguf dans les dossiers déclarés.';
|
||||
box.append(d);
|
||||
return;
|
||||
}
|
||||
const list = document.createElement('div');
|
||||
list.className = 'mdl-list';
|
||||
for(const m of installedList){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mdl-row';
|
||||
const n = document.createElement('span');
|
||||
n.className = 'mdl-name';
|
||||
n.textContent = m.name;
|
||||
n.title = m.path;
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'mdl-meta';
|
||||
meta.textContent = fmtSize(m.size||0)
|
||||
+ (m.shards > 1 ? ' · ' + m.shards + ' fichiers' : '')
|
||||
+ (m.home ? '' : ' · ' + m.dir);
|
||||
row.append(n, meta);
|
||||
// Ce qui EMPÊCHE ou ce qui COÛTE : le modèle en service ne s'efface pas, et
|
||||
// celui que des presets nomment se signale avant le clic, pas après.
|
||||
if(m.active){
|
||||
const b = document.createElement('span');
|
||||
b.className = 'mdl-tag on';
|
||||
b.textContent = 'en service';
|
||||
b.title = 'Ce modèle est celui de la configuration en cours : bascule sur un autre preset avant de l’effacer.';
|
||||
row.append(b);
|
||||
} else if((m.used||[]).length){
|
||||
const b = document.createElement('span');
|
||||
b.className = 'mdl-tag';
|
||||
b.textContent = m.used.length + ' preset' + (m.used.length>1?'s':'');
|
||||
b.title = 'Utilisé par : ' + m.used.join(', ');
|
||||
row.append(b);
|
||||
}
|
||||
if((m.missing||[]).length){
|
||||
const b = document.createElement('span');
|
||||
b.className = 'mdl-tag warn';
|
||||
b.textContent = m.missing.length + ' tranche' + (m.missing.length>1?'s':'') + ' manquante' + (m.missing.length>1?'s':'');
|
||||
b.title = 'Téléchargement incomplet : ' + m.missing.join(', ');
|
||||
row.append(b);
|
||||
}
|
||||
const del = document.createElement('button');
|
||||
del.className = 'pe-link';
|
||||
del.textContent = 'supprimer';
|
||||
del.disabled = !!m.active;
|
||||
del.onclick = () => deleteInstalled(m);
|
||||
row.append(del);
|
||||
list.append(row);
|
||||
}
|
||||
box.append(list);
|
||||
}
|
||||
async function deleteInstalled(m){
|
||||
if(m.active){
|
||||
toast('modèle en service — bascule sur un autre preset d’abord');
|
||||
return;
|
||||
}
|
||||
const known = m.used || [];
|
||||
const why = known.length
|
||||
? '\n\nIl est référencé par : ' + known.join(', ') + '. Ces presets ne démarreront plus tant qu’un autre modèle n’y sera pas choisi.'
|
||||
: '';
|
||||
const what = (m.shards > 1 ? ' et ses ' + m.shards + ' fichiers' : '');
|
||||
if(!await askConfirm('Effacer « ' + m.name + ' » (' + fmtSize(m.size||0) + ')' + what + ' du disque ?' + why
|
||||
+ '\n\nIrréversible : il faudra le retélécharger.',
|
||||
{title:'Supprimer le modèle', okText:'Supprimer', danger:true})) return;
|
||||
// force dès le premier envoi quand les presets concernés étaient DÉJÀ dans la
|
||||
// confirmation : redemander la même chose une seconde fois ne protège de rien.
|
||||
let r = await jpost('/api/models/delete', {name: m.path, force: known.length > 0});
|
||||
// Le serveur connaît des références que la liste affichée ignorait (preset créé
|
||||
// entre-temps, depuis un autre onglet) : là, la question se pose vraiment.
|
||||
if(!r.ok && (r.used||[]).length){
|
||||
if(!await askConfirm('Ce modèle est référencé par : ' + r.used.join(', ') + '.',
|
||||
{title:'Supprimer quand même ?', okText:'Supprimer', danger:true})) return;
|
||||
r = await jpost('/api/models/delete', {name: m.path, force: true});
|
||||
}
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
toast('modèle supprimé — ' + fmtSize(r.freed||0) + ' libérés');
|
||||
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(),
|
||||
populateModelDirs(), refreshInstalled()]);
|
||||
}
|
||||
// Un .gguf donné est-il déjà sur le disque, complet ? Sert avant de proposer un
|
||||
// téléchargement (recherche Hugging Face).
|
||||
async function modelIsOnDisk(name){
|
||||
let list = [];
|
||||
try{ list = await jget('/api/models') || []; }catch(_){ return false; }
|
||||
return !!list.find(m => samePath(m.name, baseName(name)) && !(m.missing||[]).length);
|
||||
}
|
||||
function onPickModel(){
|
||||
const val = document.getElementById('m-model').value;
|
||||
if(!val) return;
|
||||
@@ -417,11 +573,11 @@ async function populateMmproj(){
|
||||
const list = await jget('/api/models');
|
||||
const cur = currentMmprojInTextarea();
|
||||
const items = (list||[]).filter(m => isMmprojName(m.name));
|
||||
const hit = matchModel(cur, items);
|
||||
let html = '<option value="">— aucune —</option>';
|
||||
let matched = false;
|
||||
for(const m of items){
|
||||
const on = samePath(cur, m.value) || samePath(cur, m.path) ||
|
||||
samePath(baseName(cur), m.name) ? ' selected' : '';
|
||||
const on = (m === hit) ? ' selected' : '';
|
||||
if(on) matched = true;
|
||||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||
}
|
||||
@@ -433,111 +589,18 @@ async function populateMmproj(){
|
||||
sel.innerHTML = html;
|
||||
}
|
||||
|
||||
// Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé)
|
||||
// qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel
|
||||
// backend fait tourner ce modèle — la barre latérale ne fait qu'installer.
|
||||
function currentBinInTextarea(){
|
||||
return readEnvKey(document.getElementById('m-content').value, 'BIN');
|
||||
}
|
||||
// Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows).
|
||||
// Sert à la ligne MOTEUR du panneau d'état (05-status.js).
|
||||
function sameBinPath(a, b){
|
||||
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
||||
return !!a && !!b && n(a) === n(b);
|
||||
}
|
||||
// Écrit (ou remplace) la ligne BIN= dans le contenu du preset.
|
||||
function setBinInTextarea(val){
|
||||
const ta = document.getElementById('m-content');
|
||||
if(/^\s*BIN\s*=.*$/m.test(ta.value)) ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"');
|
||||
else ta.value = 'BIN="'+val+'"\n' + ta.value;
|
||||
}
|
||||
// Le moteur précompilé s'installe dans un dossier versionné (…/llama-b10280/) :
|
||||
// le BIN d'un preset écrit avant une mise à jour ne vaut plus le chemin courant,
|
||||
// alors qu'il désigne bien ce moteur. On reconnaît donc l'appartenance au
|
||||
// dossier, sinon tous les presets retombaient en « personnalisé » à chaque MAJ.
|
||||
function underDir(p, dir){
|
||||
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
||||
return !!p && !!dir && n(p).startsWith(n(dir)+'/');
|
||||
}
|
||||
let beFastPath = '', beOptPath = '', beFastDir = '', beImagePath = '';
|
||||
async function populateBackend(){
|
||||
// Chemins des deux moteurs gérés + liste des backends détectés (dossier loki).
|
||||
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
|
||||
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
|
||||
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
|
||||
beOptPath = lc.bin || '';
|
||||
// Conteneur : le moteur vient de l'image. « Précompilé » et « Compilé »
|
||||
// désignent des installations que Loki ne fait pas ici — les proposer ne
|
||||
// menait qu'à « installez d'abord… ». On les remplace par « Image ».
|
||||
beImagePath = lc.provided ? (lc.provided_bin || '') : '';
|
||||
document.getElementById('be-lab-image').style.display = beImagePath ? '' : 'none';
|
||||
document.getElementById('be-lab-fast').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-lab-opt').style.display = beImagePath ? 'none' : '';
|
||||
// En conteneur, « Personnalisé » ne mène nulle part (rien à compiler, et le
|
||||
// moteur se met à jour par la carte Moteur des réglages) : on le retire.
|
||||
document.getElementById('be-lab-custom').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-image-note').textContent = beImagePath ? 'llama.cpp officiel' : '';
|
||||
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
|
||||
// le dossier backends de loki (l'utilisateur peut y déposer son propre build).
|
||||
const detected = await jget('/api/backends');
|
||||
const sel = document.getElementById('m-backend-detected');
|
||||
let html = '<option value="">— ou choisir un backend détecté —</option>';
|
||||
for(const b of (detected||[])) html += '<option value="'+b.path+'">'+b.name+'</option>';
|
||||
sel.innerHTML = html;
|
||||
document.getElementById('be-drop-hint').textContent = lc.backends_dir
|
||||
? ('Astuce : déposez votre binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : '';
|
||||
// Sélectionne l'option correspondant au BIN actuel du preset.
|
||||
const cur = currentBinInTextarea();
|
||||
let mode = 'custom';
|
||||
// Un preset sans BIN hérite du moteur global : en conteneur c'est celui de
|
||||
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ». Et comme
|
||||
// l'option Personnalisé n'y est plus proposée, TOUT BIN non reconnu retombe
|
||||
// aussi sur l'image (un vieux preset pointant un chemin mort redevient sain).
|
||||
if(beImagePath && (sameBinPath(cur, beImagePath) || !cur)) mode = 'image';
|
||||
else if(beImagePath) mode = 'image';
|
||||
else if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
|
||||
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
|
||||
const radio = document.querySelector('input[name=m-be][value='+mode+']');
|
||||
if(radio) radio.checked = true;
|
||||
toggleBackendCustom(mode);
|
||||
if(mode === 'custom') document.getElementById('m-backend-path').value = cur;
|
||||
// Attendu (et non lancé dans le vide) : la liste des GPU change la hauteur du
|
||||
// bloc « moteur ». Sans ce await, elle arrivait APRÈS la levée du voile de
|
||||
// chargement et on voyait le bloc bouger tout seul.
|
||||
await loadGpuDevices();
|
||||
}
|
||||
function toggleBackendCustom(mode){
|
||||
document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none';
|
||||
}
|
||||
function onBackendMode(mode){
|
||||
toggleBackendCustom(mode);
|
||||
if(mode === 'image'){
|
||||
if(!beImagePath){ toast('aucun moteur fourni par l\'image'); return; }
|
||||
setBinInTextarea(beImagePath); toast('moteur : llama.cpp de l\'image');
|
||||
loadGpuDevices();
|
||||
} else if(mode === 'fast'){
|
||||
if(!beFastPath){ toast('installez d\'abord llama.cpp précompilé (section MOTEUR)'); return; }
|
||||
setBinInTextarea(beFastPath); toast('moteur : llama.cpp précompilé');
|
||||
loadGpuDevices();
|
||||
} else if(mode === 'opt'){
|
||||
if(!beOptPath){ toast('installez d\'abord llama.cpp compilé (section MOTEUR)'); return; }
|
||||
setBinInTextarea(beOptPath); toast('moteur : llama.cpp compilé');
|
||||
loadGpuDevices();
|
||||
}
|
||||
// custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend
|
||||
}
|
||||
function onCustomPath(){
|
||||
const v = document.getElementById('m-backend-path').value.trim();
|
||||
if(v){ setBinInTextarea(v); loadGpuDevices(); }
|
||||
}
|
||||
function onPickDetected(){
|
||||
const v = document.getElementById('m-backend-detected').value;
|
||||
if(!v) return;
|
||||
document.getElementById('m-backend-path').value = v;
|
||||
setBinInTextarea(v); toast('moteur personnalisé');
|
||||
loadGpuDevices();
|
||||
}
|
||||
|
||||
// --- Cartes graphiques (--device / --tensor-split) --------------------------
|
||||
// Le moteur n'est PAS un réglage de preset : c'est celui de la machine (l'image,
|
||||
// ou la version mise à jour dans Réglages → Moteur), et applyPresetFile le
|
||||
// conserve à la bascule. L'éditeur n'offre donc plus de choix de moteur — il
|
||||
// interroge celui qui tourne pour lister les cartes.
|
||||
// Ces réglages vivent dans le PRESET, pas dans une variable d'environnement :
|
||||
// --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que
|
||||
// CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles
|
||||
@@ -551,14 +614,18 @@ const GPU_THUMB = 16;
|
||||
let gpuDevices = [];
|
||||
const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture
|
||||
|
||||
// Interroge le moteur du preset (--list-devices) : les noms de device et leur
|
||||
// ordre lui appartiennent, une liste issue de nvidia-smi désignerait la mauvaise
|
||||
// carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la réponse n'est
|
||||
// pas là, et le reste s'il y a moins de deux cartes : rien à arbitrer, et ça
|
||||
// évite un encart qui surgit après coup.
|
||||
// Interroge le moteur de la machine (--list-devices) : les noms de device et
|
||||
// leur ordre lui appartiennent, une liste issue de nvidia-smi désignerait la
|
||||
// mauvaise carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la
|
||||
// réponse n'est pas là, et le reste s'il y a moins de deux cartes : rien à
|
||||
// arbitrer, et ça évite un encart qui surgit après coup.
|
||||
// Chemin du moteur courant (config_bin de /api/llamacpp), mémorisé au
|
||||
// préchauffage : l'éditeur s'en sert sans refaire l'appel.
|
||||
let engineBin = '';
|
||||
// Remplit le cache sans rien peindre (appelé au chargement de la page pour le
|
||||
// moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt.
|
||||
async function prefetchGpuDevices(bin){
|
||||
if(bin) engineBin = bin;
|
||||
if(!bin || gpuCache[bin]) return;
|
||||
try{
|
||||
const r = await jpost('/api/backends/devices', {bin});
|
||||
@@ -566,16 +633,24 @@ async function prefetchGpuDevices(bin){
|
||||
}catch(_){}
|
||||
}
|
||||
|
||||
// Moteur courant : ce que le préchauffage a vu, sinon on le demande (éditeur
|
||||
// ouvert avant que le statut llama.cpp ne soit arrivé).
|
||||
async function currentEngineBin(){
|
||||
if(engineBin) return engineBin;
|
||||
try{ const lc = await jget('/api/llamacpp'); engineBin = lc.config_bin || ''; }catch(_){}
|
||||
return engineBin;
|
||||
}
|
||||
|
||||
async function loadGpuDevices(){
|
||||
const group = document.getElementById('m-gpu-group');
|
||||
const bin = currentBinInTextarea();
|
||||
const bin = await currentEngineBin();
|
||||
if(!bin){ group.hidden = true; gpuDevices = []; return; }
|
||||
if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; }
|
||||
let r = {};
|
||||
try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; }
|
||||
// Le moteur a pu changer pendant l'appel (clic rapide) : on ne peint que si la
|
||||
// réponse concerne toujours le moteur affiché.
|
||||
if(currentBinInTextarea() !== bin) return;
|
||||
// Le moteur a pu changer pendant l'appel (mise à jour en cours) : on ne peint
|
||||
// que si la réponse concerne toujours le moteur courant.
|
||||
if(engineBin !== bin) return;
|
||||
gpuDevices = (r.ok && r.devices) ? r.devices : [];
|
||||
if(r.ok) gpuCache[bin] = gpuDevices;
|
||||
renderGpu();
|
||||
@@ -908,6 +983,21 @@ function closeModal(){ hideModal('modal'); document.getElementById('modal').clas
|
||||
async function saveItem(){
|
||||
const K = KINDS[editingKind];
|
||||
const name = document.getElementById('m-name').value.trim();
|
||||
// Interrupteur « Raisonnement » : on matérialise son état dans le preset AVANT
|
||||
// de lire le contenu. onchange ne part que sur une interaction : un switch
|
||||
// laissé sur off à la création n'écrivait donc jamais REASONING=, et la clé
|
||||
// ABSENTE laisse le moteur raisonner malgré l'interrupteur affiché sur off
|
||||
// (amont, issue #46). Décoché → REASONING=off explicite. Coché → on, sauf si
|
||||
// une valeur active plus précise (auto/deepseek) est déjà là : on la garde.
|
||||
if(editingKind==='preset'){
|
||||
const rsw = document.getElementById('s-reasoning');
|
||||
if(rsw){
|
||||
const cur = cfgReadKey('REASONING');
|
||||
const active = /^(on|1|true|auto|deepseek)$/i.test(cur);
|
||||
if(!rsw.checked){ if(!/^off$/i.test(cur)) cfgWriteKey('REASONING', 'off'); }
|
||||
else if(!active){ cfgWriteKey('REASONING', 'on'); }
|
||||
}
|
||||
}
|
||||
const content = document.getElementById('m-content').value;
|
||||
if(!name){ toast('nom requis'); return; }
|
||||
// Presets: keyed by id (filename); duplicate display names are allowed.
|
||||
@@ -917,6 +1007,10 @@ async function saveItem(){
|
||||
: {name, old: editingKey, content};
|
||||
const r = await jpost(K.saveUrl, payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
// Preset EN SERVICE modifié : le serveur l'a réappliqué et relance le moteur
|
||||
// (SavePresetApplying). On le dit, et on rafraîchit l'état global — la carte
|
||||
// du chat doit montrer le nouveau contexte, pas l'ancien.
|
||||
if(r.applied){ toast('enregistré et appliqué — redémarrage…'); closeModal(); K.reload(); if(typeof loadAll==='function') loadAll(); return; }
|
||||
toast('enregistré'); closeModal(); K.reload();
|
||||
}
|
||||
async function delItem(){
|
||||
@@ -939,8 +1033,11 @@ async function delItem(){
|
||||
const r = await jpost(K.delUrl, payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
if(delModel){
|
||||
if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
|
||||
else if(r.modelDeleted) toast('preset + modèle supprimés');
|
||||
// modelError est renseigné même quand le fichier A ÉTÉ supprimé (il servait
|
||||
// aussi à d'autres presets) : on regarde donc modelDeleted d'abord.
|
||||
if(r.modelDeleted) toast('preset + modèle supprimés — ' + fmtSize(r.freed||0) + ' libérés'
|
||||
+ (r.modelError ? ' (' + r.modelError + ')' : ''));
|
||||
else if(r.modelError) toast('preset supprimé, modèle : ' + r.modelError);
|
||||
else toast('supprimé (aucun modèle référencé)');
|
||||
} else { toast('supprimé'); }
|
||||
closeModal(); K.reload();
|
||||
@@ -994,6 +1091,22 @@ async function populateDlDirs(){
|
||||
// ensemble — deux transferts concurrents sur la même liaison ne vont pas plus
|
||||
// vite et brouillent la progression affichée.
|
||||
let dlQueue = [];
|
||||
// Un .gguf est disponible (il vient d'arriver, ou il était déjà là) : on le
|
||||
// sélectionne dans le bon champ du preset. Un projecteur (mmproj) va dans
|
||||
// « Vision », un modèle dans le sélecteur de modèle — d'après son nom de
|
||||
// fichier. Le fichier a pu atterrir hors du dossier loki : l'option porte alors
|
||||
// le chemin complet, pas le simple nom.
|
||||
async function selectInstalled(fname){
|
||||
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs(), refreshInstalled()]);
|
||||
const pick = (id, cb)=>{
|
||||
const s = document.getElementById(id);
|
||||
if(!s) return false;
|
||||
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
|
||||
if(!o) return false;
|
||||
s.value = o.value; cb(); return true;
|
||||
};
|
||||
return isMmprojName(fname) ? pick('m-mmproj', onPickMmproj) : pick('m-model', onPickModel);
|
||||
}
|
||||
async function startDownload(){
|
||||
const url = document.getElementById('m-hf-url').value.trim();
|
||||
if(!url){ toast('colle un lien .gguf'); return; }
|
||||
@@ -1011,6 +1124,20 @@ async function startDownloadURL(url){
|
||||
// Sonde d'abord : on connaît la taille exacte du fichier et l'espace restant
|
||||
// AVANT d'écrire quoi que ce soit, plutôt que d'échouer à 90 % du transfert.
|
||||
const p = await jpost('/api/models/download/probe', {url, dir});
|
||||
// Modèle DÉJÀ sur le disque : il n'y a rien à télécharger, il y a un modèle à
|
||||
// choisir. C'était le cul-de-sac — un .gguf gardé après la suppression de son
|
||||
// preset ne pouvait plus servir à en refaire un : le téléchargement répondait
|
||||
// « le modèle existe déjà » en rouge et s'arrêtait là.
|
||||
if(p.ok && p.exists){
|
||||
e.bar.style.display = 'none';
|
||||
e.btn.disabled = false;
|
||||
const done = await selectInstalled(p.filename);
|
||||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(p.filename)
|
||||
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '')
|
||||
+ (p.size > 0 ? ' (' + fmtSize(p.size) + ')' : '') + '</span>';
|
||||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||||
return;
|
||||
}
|
||||
if(!p.ok || !p.enough){
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(p.error||'espace insuffisant')+'</span>';
|
||||
e.bar.style.display = 'none'; e.btn.disabled=false;
|
||||
@@ -1026,9 +1153,19 @@ async function startDownloadURL(url){
|
||||
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
|
||||
const r = await jpost('/api/models/download', {url, dir});
|
||||
if(!r.ok){
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+escHtml(r.error||'')+'</span>';
|
||||
e.bar.style.display = 'none'; e.btn.disabled=false; dlQueue = []; return;
|
||||
}
|
||||
// Le serveur peut constater lui aussi que tout est déjà là (fichier apparu
|
||||
// entre la sonde et l'envoi, ou lien collé à la main sans passer par la sonde).
|
||||
if(r.exists){
|
||||
e.bar.style.display = 'none'; e.btn.disabled = false;
|
||||
const done = await selectInstalled(r.filename);
|
||||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ ' + escHtml(r.filename)
|
||||
+ ' est déjà sur le disque' + (done ? ' — sélectionné' : '') + '</span>';
|
||||
if(dlQueue.length) startDownloadURL(dlQueue.shift());
|
||||
return;
|
||||
}
|
||||
watchDownload(r.filename);
|
||||
}
|
||||
// Annule le téléchargement en cours : le serveur coupe les connexions et
|
||||
@@ -1067,18 +1204,7 @@ function watchDownload(fname){
|
||||
e.prog.innerHTML = '<span style="color:var(--ok)">✓ '+fname+' téléchargé ('+fmtSize(st.done)+')</span>';
|
||||
e.bar.className = 'pe-bar done'; e.bar.firstElementChild.style.width = '100%';
|
||||
stop();
|
||||
await Promise.all([populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
// Un projecteur (mmproj) se sélectionne dans le champ Vision, un modèle dans
|
||||
// le sélecteur de modèle — d'après le nom du fichier téléchargé. Le fichier
|
||||
// a pu atterrir hors du dossier loki : l'option porte alors le chemin
|
||||
// complet, pas le simple nom de fichier.
|
||||
const pick = (id, cb)=>{
|
||||
const s = document.getElementById(id);
|
||||
const o = Array.from(s.options).find(o => samePath(baseName(o.value), fname));
|
||||
if(o){ s.value = o.value; cb(); }
|
||||
};
|
||||
if(isMmprojName(fname)) pick('m-mmproj', onPickMmproj);
|
||||
else pick('m-model', onPickModel);
|
||||
await selectInstalled(fname);
|
||||
// Suite de la file : c'est ici que le projecteur part, une fois le modèle
|
||||
// arrivé. L'ordre compte — le champ Vision ne se remplit que si le modèle
|
||||
// est déjà sélectionné.
|
||||
@@ -1195,10 +1321,19 @@ function fmtCount(n){
|
||||
|
||||
async function hfPickRepo(repo){
|
||||
hfMsg('lecture du dépôt…');
|
||||
let r;
|
||||
try{ r = await jget('/api/hf/files?repo='+encodeURIComponent(repo)); }
|
||||
let r, onDisk = [];
|
||||
try{
|
||||
// Les .gguf déjà présents sont demandés EN MÊME TEMPS que l'arborescence :
|
||||
// c'est ce qui permet de dire « déjà installé » sur la ligne d'un quant au
|
||||
// lieu de le découvrir après le clic, par un refus.
|
||||
[r, onDisk] = await Promise.all([
|
||||
jget('/api/hf/files?repo='+encodeURIComponent(repo)),
|
||||
jget('/api/models').catch(()=>[]),
|
||||
]);
|
||||
}
|
||||
catch(_){ hfMsg('Hugging Face injoignable', true); return; }
|
||||
if(!r.ok){ hfMsg(r.error||'dépôt illisible', true); return; }
|
||||
const installed = n => (onDisk||[]).find(m => samePath(m.name, baseName(n)) && !(m.missing||[]).length);
|
||||
hfRepoFiles = r;
|
||||
if(!r.models || !r.models.length){ hfMsg('ce dépôt ne publie aucun modèle GGUF utilisable'); return; }
|
||||
|
||||
@@ -1253,7 +1388,8 @@ async function hfPickRepo(repo){
|
||||
mmChk = document.createElement('input');
|
||||
mmChk.type = 'checkbox'; mmChk.checked = true;
|
||||
const t = document.createElement('span');
|
||||
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')';
|
||||
t.textContent = ' installer aussi le projecteur vision — '+mm.name+' ('+fmtSize(mm.size)+')'
|
||||
+ (installed(mm.name) ? ' — déjà sur le disque' : '');
|
||||
note.append(mmChk, t);
|
||||
} else {
|
||||
note.textContent = 'Ce dépôt ne publie pas de projecteur : ce modèle n’aura pas la vision.';
|
||||
@@ -1271,6 +1407,17 @@ async function hfPickRepo(repo){
|
||||
const meta = document.createElement('span'); meta.className = 'hf-meta';
|
||||
meta.textContent = fmtSize(m.size) + (m.shards > 1 ? ' · '+m.shards+' fichiers' : '');
|
||||
row.append(n, meta);
|
||||
// Déjà sur le disque : la pastille le dit AVANT le clic, et le clic ne
|
||||
// télécharge alors rien — il sélectionne le fichier présent. Le verdict
|
||||
// mémoire n'a plus grand-chose à arbitrer dans ce cas, mais il reste
|
||||
// affiché : « juste » vaut d'être su même sur un modèle déjà installé.
|
||||
if(installed(m.name)){
|
||||
const here = document.createElement('span');
|
||||
here.className = 'hf-fit here';
|
||||
here.textContent = 'déjà installé';
|
||||
here.title = 'Ce fichier est déjà dans un de tes dossiers de modèles : le choisir le sélectionne, sans rien retélécharger.';
|
||||
row.appendChild(here);
|
||||
}
|
||||
if(m.verdict){
|
||||
const fit = document.createElement('span');
|
||||
fit.className = 'hf-fit ' + m.verdict;
|
||||
@@ -1302,7 +1449,10 @@ function hfBestProjector(list){
|
||||
// Installe : le modèle d'abord, le projecteur ensuite (la file s'en charge). Le
|
||||
// champ Vision du preset ne se remplit correctement que dans cet ordre.
|
||||
async function hfInstall(model, projector){
|
||||
if(model.verdict === 'trop' && !await askConfirm(
|
||||
// Le fichier est là : rien à télécharger, donc rien à confirmer — la sonde le
|
||||
// constate côté serveur et startDownloadURL le sélectionne.
|
||||
const here = await modelIsOnDisk(model.name);
|
||||
if(!here && model.verdict === 'trop' && !await askConfirm(
|
||||
(model.why||'Ce modèle dépasse la mémoire disponible.')+'\n\nLe téléchargement fonctionnera, mais le moteur risque de ne pas le charger.',
|
||||
{title:'Installer quand même ?', okText:'Installer', danger:true})) return;
|
||||
dlQueue = projector ? [projector.url] : [];
|
||||
|
||||
@@ -269,6 +269,10 @@ function renderToolMsg(el, tu){
|
||||
git_status: {lbl:'git', head:'git status'},
|
||||
git_diff: {lbl:'git', head:'git diff'},
|
||||
git_clone: {lbl:'git', head:'git clone'},
|
||||
// Mémoire longue de la conversation : blocs archivés au compactage. Étiquette
|
||||
// distincte de « mémoire » (les pages MEMORY), ce n'est pas la même chose.
|
||||
recall: {lbl:'archive', head:'rappel d’un bloc archivé'},
|
||||
recall_search: {lbl:'archive', head:'recherche dans l’archive'},
|
||||
};
|
||||
// Outils MCP (nom mcp__<serveur>__<outil>) : en-tête = nom du serveur, libellé lisible,
|
||||
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
|
||||
@@ -282,7 +286,10 @@ function renderToolMsg(el, tu){
|
||||
meta = meta || {lbl:'mémoire', head:'mémoire'};
|
||||
let lbl = meta.lbl;
|
||||
// Indication du volume de la réponse de l'outil (~tokens, estimation 1 tok ≈ 4 car).
|
||||
if(tu.result){ lbl += ' · ~' + Math.max(1, Math.round(tu.result.length/4)) + ' tok'; }
|
||||
// Taille RÉELLE (result_chars) quand le flux n'a envoyé qu'un aperçu — sinon
|
||||
// la bulle affichait toujours le même chiffre, celui du plafond d'aperçu.
|
||||
const realChars = tu.result_chars || (tu.result ? tu.result.length : 0);
|
||||
if(realChars){ lbl += ' · ~' + Math.max(1, Math.round(realChars/4)) + ' tok'; }
|
||||
setLabel(el, lbl);
|
||||
// Volume de l'écriture (final si le diff est là, provisoire pendant la frappe)
|
||||
// reporté sur l'étiquette, pour rester lisible bulle repliée.
|
||||
@@ -361,8 +368,49 @@ function renderToolMsg(el, tu){
|
||||
const sub=document.createElement('div'); sub.className='tool-sub'; sub.textContent='réponse';
|
||||
body.appendChild(sub);
|
||||
const pre=document.createElement('pre');
|
||||
const code=document.createElement('code'); code.textContent=tu.result;
|
||||
pre.appendChild(code); body.appendChild(pre);
|
||||
const code=document.createElement('code');
|
||||
const preview=tu.result, rid=tu.result_id, PREVIEW=1600;
|
||||
// Deux cas :
|
||||
// - result_id présent : le flux n'a envoyé qu'un aperçu (résultat coupé),
|
||||
// « voir plus » CHARGE le reste à la demande via l'API ;
|
||||
// - sinon : le résultat est déjà complet ; s'il est long, « voir plus » le
|
||||
// déplie localement (rien à charger).
|
||||
if(rid){
|
||||
code.textContent=preview+'…';
|
||||
pre.appendChild(code);
|
||||
const more=document.createElement('button');
|
||||
more.className='tool-more'; more.type='button'; more.textContent='voir plus';
|
||||
let full=null, open=false;
|
||||
more.onclick=async(e)=>{ e.stopPropagation();
|
||||
if(full===null){
|
||||
more.disabled=true; more.textContent='…';
|
||||
// Résultat introuvable côté serveur (compacté, autre discussion) : on
|
||||
// garde l'aperçu plutôt que de vider la bulle.
|
||||
try{ const j=await jget('/api/chat/tool-result?id='+encodeURIComponent(rid)); full=(j&&j.result)||preview; }
|
||||
catch(_){ full=preview; }
|
||||
more.disabled=false;
|
||||
}
|
||||
open=!open;
|
||||
code.textContent = open ? full : preview+'…';
|
||||
pre.classList.toggle('expanded', open);
|
||||
more.textContent = open ? 'voir moins' : 'voir plus';
|
||||
};
|
||||
body.appendChild(toolBlock(pre, more));
|
||||
} else if(preview.length>PREVIEW){
|
||||
code.textContent=preview.slice(0,PREVIEW)+'…';
|
||||
pre.appendChild(code);
|
||||
const more=document.createElement('button');
|
||||
more.className='tool-more'; more.type='button'; more.textContent='voir plus';
|
||||
let open=false;
|
||||
more.onclick=(e)=>{ e.stopPropagation(); open=!open;
|
||||
code.textContent = open ? preview : preview.slice(0,PREVIEW)+'…';
|
||||
pre.classList.toggle('expanded', open);
|
||||
more.textContent = open ? 'voir moins' : 'voir plus';
|
||||
};
|
||||
body.appendChild(toolBlock(pre, more));
|
||||
} else {
|
||||
code.textContent=preview; pre.appendChild(code); body.appendChild(pre);
|
||||
}
|
||||
} else if(!tu.done && !tu.typing){
|
||||
const wait=document.createElement('div'); wait.className='tool-wait'; wait.textContent='exécution en cours…';
|
||||
body.appendChild(wait);
|
||||
@@ -374,10 +422,26 @@ function renderToolMsg(el, tu){
|
||||
const bw = el.querySelector('.bodywrap');
|
||||
if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
|
||||
}
|
||||
// Bloc d'un résultat d'outil : le pre scrolle (max-height), donc la barre
|
||||
// d'actions vit sur un bloc-parent NON scrollant, ancrée en bas à droite — elle
|
||||
// reste au coin quoi qu'on scrolle dans le résultat. La barre réunit « voir
|
||||
// plus » (passé ici) et, ajouté ensuite par addCopyButtons, « copier ».
|
||||
function toolBlock(pre, more){
|
||||
const block=document.createElement('div'); block.className='tool-block';
|
||||
block.appendChild(pre);
|
||||
const bar=document.createElement('div'); bar.className='tool-actions';
|
||||
bar.appendChild(more);
|
||||
block.appendChild(bar);
|
||||
return block;
|
||||
}
|
||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||
function addCopyButtons(root){
|
||||
root.querySelectorAll('pre').forEach(pre=>{
|
||||
if(pre.querySelector('.copybtn')) return;
|
||||
// Résultat d'outil : le pre est enrobé dans .tool-block et la barre
|
||||
// d'actions est SŒUR du pre (pas dedans) ; on cherche donc le copier dans
|
||||
// ce périmètre.
|
||||
const scope = pre.closest('.tool-block') || pre;
|
||||
if(scope.querySelector('.copybtn')) return;
|
||||
// Pas de bouton copier sur un diff : on copierait les préfixes + / - .
|
||||
if(pre.classList.contains('diff')) return;
|
||||
const btn=document.createElement('button');
|
||||
@@ -390,7 +454,9 @@ function addCopyButtons(root){
|
||||
btn.textContent='copié ✓'; btn.classList.add('done');
|
||||
setTimeout(()=>{ btn.textContent='copier'; btn.classList.remove('done'); },1500);
|
||||
};
|
||||
pre.appendChild(btn);
|
||||
// S'il y a une barre d'actions (résultat d'outil avec « voir plus »), le
|
||||
// bouton copier s'y range à côté ; sinon il se colle en bas à droite du bloc.
|
||||
(scope.querySelector('.tool-actions')||pre).appendChild(btn);
|
||||
});
|
||||
}
|
||||
// Vider la discussion POUR TOUS LES APPAREILS : le serveur vide le fil et
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
// suit le direct. Fermer l'onglet n'arrête plus la génération (détachée côté
|
||||
// serveur) ; se reconnecter rejoue tout le fil, détails compris.
|
||||
let lastSeq=0, streamAbort=null;
|
||||
// REPLAY_FULL : demander le journal ENTIER au prochain abonnement. Le serveur
|
||||
// ne rejoue par défaut que la fin d'une longue discussion (voir replayTailMax) ;
|
||||
// le bandeau « charger le début » rouvre le flux avec ce drapeau.
|
||||
let REPLAY_FULL=false;
|
||||
// Bulle « en attente » : affichée EN GRIS dès l'appui sur envoyer, avant tout
|
||||
// aller-retour réseau. Le message ne disparaît donc plus de l'écran entre la
|
||||
// frappe et la réponse du serveur. Elle s'éclaircit (classe retirée) quand
|
||||
@@ -277,11 +281,23 @@ function syncSendBtn(){
|
||||
// répond pas du tout) — dans le doute on laisse la main.
|
||||
const ready = !STATUS_SEEN || MODEL_READY;
|
||||
sb.disabled = !ready;
|
||||
sb.title = ready ? '' : 'le modèle n\'est pas encore chargé';
|
||||
// Moteur ARRÊTÉ (VRAM libérée à la demande, ou service coupé) : dire « le
|
||||
// modèle charge » serait faux, et on attendrait un chargement qui ne viendra
|
||||
// jamais. On nomme alors le geste qui remet le modèle en mémoire.
|
||||
// Trois raisons de ne pas être prêt, trois messages : planté au chargement
|
||||
// (recharger ne ferait que replanter — la cause est affichée dans le
|
||||
// moniteur), déchargé à la demande, ou simplement en train de charger.
|
||||
const failed = !ready && !!LOAD_ERROR;
|
||||
const unloaded = !ready && !failed && !ENGINE_ACTIVE;
|
||||
sb.title = ready ? '' : failed ? 'le modèle n\'a pas pu charger — voir l\'erreur dans le moniteur'
|
||||
: (unloaded ? 'le modèle est déchargé — recharge-le pour écrire'
|
||||
: 'le modèle n\'est pas encore chargé');
|
||||
const hint=document.getElementById('sendhint');
|
||||
if(hint){
|
||||
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
|
||||
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
|
||||
: failed ? 'Le modèle n\'a pas pu charger — l\'erreur est affichée dans le moniteur, en bas de la barre latérale.'
|
||||
: unloaded ? 'Modèle déchargé — « Recharger le modèle » dans le moniteur, en bas de la barre latérale.'
|
||||
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
|
||||
hint.classList.toggle('waiting', !ready);
|
||||
}
|
||||
}
|
||||
@@ -391,10 +407,44 @@ function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoot
|
||||
// bulle d'outil) doit voir le texte complet.
|
||||
function settleBlocks(){ smoothSnap(); flushRender(); }
|
||||
|
||||
// showReplayBanner affiche, en tête du fil, le bandeau « début masqué ». Un clic
|
||||
// coupe le flux et se réabonne depuis zéro en demandant TOUT le journal.
|
||||
function showReplayBanner(hidden){
|
||||
const c=chatEl(); if(!c) return;
|
||||
let b=document.getElementById('replay-banner');
|
||||
if(!b){
|
||||
b=document.createElement('div');
|
||||
b.id='replay-banner'; b.className='replay-banner';
|
||||
c.insertBefore(b, c.firstChild);
|
||||
}
|
||||
b.textContent='';
|
||||
const txt=document.createElement('span');
|
||||
txt.textContent='Début de la discussion masqué ('+hidden+' événements) — ';
|
||||
const btn=document.createElement('button');
|
||||
btn.type='button'; btn.className='replay-more'; btn.textContent='charger le début';
|
||||
btn.onclick=()=>{ btn.disabled=true; btn.textContent='chargement…'; loadFullReplay(); };
|
||||
b.appendChild(txt); b.appendChild(btn);
|
||||
}
|
||||
|
||||
// loadFullReplay relance l'abonnement depuis le début, journal entier. On repart
|
||||
// d'un fil VIDE : rejouer par-dessus l'existant ferait doublon.
|
||||
function loadFullReplay(){
|
||||
REPLAY_FULL=true; lastSeq=0; REPLAYING=true;
|
||||
if(streamAbort) streamAbort.abort();
|
||||
const c=chatEl(); if(c){ c.innerHTML=''; c.style.opacity='0'; }
|
||||
PENDING=null; newTurn();
|
||||
setChatLoading('chargement du début de la conversation…');
|
||||
connectStream();
|
||||
}
|
||||
|
||||
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
|
||||
// piloté par le serveur et rejouable à l'identique.
|
||||
function handleDelta(d){
|
||||
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
|
||||
// Replay borné : le serveur a sauté le début d'une longue discussion. On
|
||||
// l'annonce en tête du fil, avec le bouton qui va le chercher. Rien n'est
|
||||
// perdu : le journal complet est sur le disque.
|
||||
if(typeof d.replay_truncated==='number'){ showReplayBanner(d.replay_truncated); return; }
|
||||
// Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
|
||||
// `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
|
||||
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
|
||||
@@ -454,7 +504,8 @@ function handleDelta(d){
|
||||
if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
|
||||
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
|
||||
paintTurnClock(); // fige le total au-dessus de la carte
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
||||
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||
@@ -492,7 +543,8 @@ function handleDelta(d){
|
||||
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
||||
// tour (collapseAll) la fermerait quand même.
|
||||
if(tu.image && T.pendingToolEl){ const i=T.turnCollapsibles.indexOf(T.pendingToolEl); if(i>=0) T.turnCollapsibles.splice(i,1); }
|
||||
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||||
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem();
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); }
|
||||
return; }
|
||||
if(d.drop_reasoning){
|
||||
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
||||
@@ -543,7 +595,7 @@ async function connectStream(){
|
||||
while(document.hidden){ await new Promise(res=>setTimeout(res, 500)); }
|
||||
streamAbort=new AbortController();
|
||||
try{
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq}),signal:streamAbort.signal});
|
||||
const r=await jfetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({from:lastSeq, full:REPLAY_FULL}),signal:streamAbort.signal});
|
||||
if(REPLAYING) setChatLoading('chargement de la conversation…');
|
||||
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
|
||||
while(true){
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
// 11-external.js — presets « externes » : le chat part vers une API
|
||||
// OpenAI-compatible distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre
|
||||
// machine…) au lieu du llama-server local.
|
||||
//
|
||||
// Un preset externe vit dans la même liste que les autres et se bascule pareil.
|
||||
// Mais ses réglages n'ont rien de commun avec ceux d'un modèle local — ni
|
||||
// quantification, ni couches GPU, ni moteur, ni projecteur vision : la machine
|
||||
// distante décide de tout ça. D'où cette fenêtre à part, plutôt qu'un éditeur de
|
||||
// preset aux trois quarts grisé.
|
||||
|
||||
// La clé n'est JAMAIS renvoyée en clair par le serveur : le champ s'affiche
|
||||
// vide même quand une clé est enregistrée. Un champ vide ne vaut donc pas
|
||||
// « efface la clé » — il faut y avoir touché. Sans ce drapeau, rouvrir la
|
||||
// fenêtre pour corriger une faute de frappe dans l'URL effaçait la clé au
|
||||
// passage, et le preset tombait en 401 au message suivant.
|
||||
let extKeyTouched = false;
|
||||
let extEditing = '';
|
||||
|
||||
function closeExternal(){ hideModal('ext-modal'); }
|
||||
|
||||
async function openExternal(id){
|
||||
extEditing = id || '';
|
||||
extKeyTouched = false;
|
||||
const set = (el, v) => { const e = document.getElementById(el); if(e) e.value = v; };
|
||||
document.getElementById('ext-title').textContent = id ? 'API externe' : 'Nouvelle API externe';
|
||||
document.getElementById('ext-del').style.display = id ? '' : 'none';
|
||||
document.getElementById('ext-test-out').textContent = '';
|
||||
document.getElementById('ext-key-sub').textContent = 'vide = aucune authentification';
|
||||
set('ext-name',''); set('ext-url',''); set('ext-model',''); set('ext-key',''); set('ext-ctx','');
|
||||
showModal('ext-modal');
|
||||
if(!id) return;
|
||||
let d = {};
|
||||
try{ d = await jget('/api/preset/external?id=' + encodeURIComponent(id)); }catch(_){ return; }
|
||||
if(extEditing !== id) return; // une autre ouverture a pris la main
|
||||
set('ext-name', d.name || ''); set('ext-url', d.url || '');
|
||||
set('ext-model', d.model || ''); set('ext-ctx', d.ctx || '');
|
||||
if(d.hasKey){
|
||||
document.getElementById('ext-key-sub').textContent =
|
||||
'une clé est enregistrée — laisse vide pour la conserver';
|
||||
}
|
||||
}
|
||||
|
||||
// Le corps commun aux deux routes (enregistrement et test).
|
||||
function extPayload(){
|
||||
return {
|
||||
id: extEditing,
|
||||
name: (document.getElementById('ext-name').value || '').trim(),
|
||||
url: (document.getElementById('ext-url').value || '').trim(),
|
||||
model: (document.getElementById('ext-model').value || '').trim(),
|
||||
key: document.getElementById('ext-key').value || '',
|
||||
ctx: (document.getElementById('ext-ctx').value || '').trim(),
|
||||
keyTouched: extKeyTouched,
|
||||
};
|
||||
}
|
||||
|
||||
// Tester AVANT d'enregistrer n'est pas un confort : une URL mal collée ou une
|
||||
// clé expirée ne se voit sinon qu'au premier message, sous la forme d'un code
|
||||
// HTTP nu au milieu d'une conversation.
|
||||
async function testExternal(){
|
||||
const out = document.getElementById('ext-test-out');
|
||||
const btn = document.getElementById('ext-test');
|
||||
const p = extPayload();
|
||||
if(!p.url || !p.model){ out.innerHTML = '<span style="color:var(--err)">URL et modèle requis</span>'; return; }
|
||||
btn.disabled = true;
|
||||
out.textContent = 'appel de test en cours…';
|
||||
let r = {};
|
||||
try{ r = await jpost('/api/preset/external/test', p); }
|
||||
catch(_){ r = {ok:false, error:'réseau'}; }
|
||||
btn.disabled = false;
|
||||
out.innerHTML = r.ok
|
||||
? '<span style="color:var(--ok)">✓ l’API répond</span>'
|
||||
: '<span style="color:var(--err)">' + escHtml((r.status ? r.status + ' — ' : '') + (r.error || 'échec')) + '</span>';
|
||||
}
|
||||
|
||||
async function saveExternal(){
|
||||
const p = extPayload();
|
||||
if(!p.name){ toast('nom requis'); return; }
|
||||
if(!p.url){ toast('URL requise'); return; }
|
||||
if(!p.model){ toast('modèle requis'); return; }
|
||||
const r = await jpost('/api/preset/external/save', p);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
toast('enregistré');
|
||||
closeExternal();
|
||||
loadPresets();
|
||||
}
|
||||
|
||||
// Même route de suppression que n'importe quel preset — et même refus si c'est
|
||||
// celui en service. Pas de case « supprimer aussi le .gguf » : il n'y a pas de
|
||||
// fichier, le modèle est chez quelqu'un d'autre.
|
||||
async function deleteExternal(){
|
||||
if(!extEditing) return;
|
||||
const name = (document.getElementById('ext-name').value || '').trim() || extEditing;
|
||||
if(!await askConfirm('Supprimer le preset « ' + name + ' » ?',
|
||||
{title:'Suppression', okText:'Supprimer', danger:true})) return;
|
||||
const r = await jpost('/api/preset/delete', {id: extEditing});
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
toast('supprimé');
|
||||
closeExternal();
|
||||
loadPresets();
|
||||
}
|
||||
@@ -365,6 +365,7 @@ window.addEventListener('drop', e=>{
|
||||
// rouge, elle dit déjà ce qui s'est passé.
|
||||
async function attachPaths(){
|
||||
await Promise.all(ATTACH.filter(a=>a.state==='queued').map(uploadAttach));
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); // les dépôts apparaissent dans le panneau Fichiers
|
||||
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
|
||||
}
|
||||
renderAttach();
|
||||
@@ -263,6 +263,21 @@ function filesOnConvChange(){
|
||||
if(FILES_OPEN) loadFiles('');
|
||||
}
|
||||
|
||||
// Quelque chose a pu changer sur disque : un outil vient de rendre son résultat
|
||||
// (l'agent écrit un rapport, une capture, un script), le tour est fini, une
|
||||
// pièce jointe vient d'être déposée. Le panneau se redessine tout seul —
|
||||
// jusqu'ici il fallait cliquer « rafraîchir » pour voir apparaître ce que
|
||||
// l'agent venait d'écrire. Regroupé : une rafale d'appels d'outils ne
|
||||
// déclenche qu'une lecture. Panneau fermé ou rejeu du journal : rien à faire,
|
||||
// l'ouverture recharge de toute façon.
|
||||
let filesRefreshT = 0;
|
||||
function filesOnActivity(){
|
||||
if(!FILES_OPEN) return;
|
||||
if(typeof REPLAYING !== 'undefined' && REPLAYING) return;
|
||||
clearTimeout(filesRefreshT);
|
||||
filesRefreshT = setTimeout(()=>{ filesRefreshT = 0; loadFiles(); }, 400);
|
||||
}
|
||||
|
||||
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
|
||||
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
|
||||
document.addEventListener('DOMContentLoaded', initFiles);
|
||||
|
||||
@@ -11,6 +11,9 @@ let tasksList = [];
|
||||
let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé)
|
||||
let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune)
|
||||
let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale)
|
||||
let TASK_PROJECTS = []; // projets disponibles (sélecteur « projet visé »)
|
||||
let TASK_SCRIPTS = []; // scripts durables disponibles (tâche « script seul »)
|
||||
let TASK_SCRIPTS_DIR = ''; // chemin du dossier de scripts, pour l'aide affichée
|
||||
let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche)
|
||||
let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche)
|
||||
let tasksPollTimer = null;
|
||||
@@ -33,6 +36,9 @@ function renderTasks(r){
|
||||
const paused = !!(r && r.paused);
|
||||
const agentOn = !!(r && r.agent);
|
||||
TASK_PRESETS = (r && r.presets) || [];
|
||||
TASK_PROJECTS = (r && r.projects) || [];
|
||||
TASK_SCRIPTS = (r && r.scripts) || [];
|
||||
TASK_SCRIPTS_DIR = (r && r.scripts_dir) || 'le dossier de scripts';
|
||||
TASK_MEM_ON = !r || r.mem_on !== false;
|
||||
TASK_WEB_ON = !r || r.web_on !== false;
|
||||
TASK_RUNNING = (r && r.running_id) || '';
|
||||
@@ -192,7 +198,13 @@ function openTask(id){
|
||||
// stockés en négation) ; sur une nouvelle on suit l'état global de la machine.
|
||||
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
|
||||
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
|
||||
// Type : IA par défaut. Une tâche script n'a ni consigne ni preset utile.
|
||||
setTaskKind(t && t.kind === 'script' ? 'script' : 'agent');
|
||||
fillScriptSelect(t ? (t.script||'') : '');
|
||||
fillPresetSelect(t ? (t.preset||'') : '');
|
||||
// Nouvelle tâche : le projet ACTIF est proposé — on crée presque toujours une
|
||||
// tâche pour le chantier qu'on a sous les yeux.
|
||||
fillTaskProjectSelect(t ? (t.project||'') : (typeof PROJ_ACTIVE!=='undefined' ? PROJ_ACTIVE : ''));
|
||||
|
||||
// Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle :
|
||||
// « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours).
|
||||
@@ -265,6 +277,65 @@ function fillPresetSelect(selected){
|
||||
sel.value = selected || '';
|
||||
}
|
||||
|
||||
// fillTaskProjectSelect peuple le sélecteur « projet visé ». Pas d'option vide :
|
||||
// une tâche appartient toujours à un projet — le serveur retombe sur le projet
|
||||
// actif si rien n'est envoyé, autant le montrer explicitement.
|
||||
function fillTaskProjectSelect(selected){
|
||||
const sel = document.getElementById('task-project');
|
||||
if(!sel) return;
|
||||
sel.textContent = '';
|
||||
TASK_PROJECTS.forEach(p=>{
|
||||
const o = document.createElement('option'); o.value = p.slug; o.textContent = p.name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected;
|
||||
}
|
||||
|
||||
// fillScriptSelect peuple le sélecteur de script avec les fichiers du dossier
|
||||
// protégé (LOKI_HOME/scripts). Dossier vide = on le dit et on laisse le
|
||||
// sélecteur vide : l'enregistrement sera refusé côté serveur, autant l'annoncer.
|
||||
function fillScriptSelect(selected){
|
||||
const sel = document.getElementById('task-script');
|
||||
if(!sel) return;
|
||||
sel.textContent = '';
|
||||
TASK_SCRIPTS.forEach(sc=>{
|
||||
const o = document.createElement('option'); o.value = sc.name;
|
||||
o.textContent = sc.name + (sc.size ? ' · ' + fmtSize(sc.size) : '');
|
||||
sel.appendChild(o);
|
||||
});
|
||||
if(selected && [...sel.options].some(o=>o.value===selected)) sel.value = selected;
|
||||
const hint = document.getElementById('task-script-hint');
|
||||
if(hint){
|
||||
hint.textContent = TASK_SCRIPTS.length
|
||||
? 'Scripts de ' + TASK_SCRIPTS_DIR + ' — l\'IA peut y écrire les siens.'
|
||||
: 'Aucun script dans ' + TASK_SCRIPTS_DIR + ' : demande à l\'IA d\'y en écrire un, ou dépose-le toi-même.';
|
||||
}
|
||||
}
|
||||
|
||||
// setTaskKind bascule le formulaire entre « consigne IA » et « script seul ».
|
||||
function setTaskKind(kind){
|
||||
const r = document.querySelector('input[name="task-kind"][value="'+kind+'"]');
|
||||
if(r) r.checked = true;
|
||||
taskKindUI(kind);
|
||||
}
|
||||
|
||||
// taskKindUI n'affiche que les champs du type choisi : une tâche script n'a pas
|
||||
// de consigne, et le preset ne la concerne pas (aucun modèle n'est chargé).
|
||||
function taskKindUI(kind){
|
||||
const script = kind === 'script';
|
||||
const pg = document.getElementById('task-prompt-group');
|
||||
const sg = document.getElementById('task-script-group');
|
||||
if(pg) pg.style.display = script ? 'none' : '';
|
||||
if(sg) sg.style.display = script ? '' : 'none';
|
||||
const pr = document.getElementById('task-preset');
|
||||
if(pr) pr.closest('.pe-row').style.display = script ? 'none' : '';
|
||||
}
|
||||
|
||||
function taskKind(){
|
||||
const r = document.querySelector('input[name="task-kind"]:checked');
|
||||
return (r && r.value === 'script') ? 'script' : 'agent';
|
||||
}
|
||||
|
||||
function setTaskFreqMode(mode){
|
||||
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
|
||||
if(r) r.checked = true;
|
||||
@@ -300,17 +371,24 @@ function buildSchedule(){
|
||||
|
||||
async function saveTask(){
|
||||
const name = document.getElementById('task-name').value.trim();
|
||||
const kind = taskKind();
|
||||
const script = (document.getElementById('task-script')||{}).value || '';
|
||||
const prompt = document.getElementById('task-prompt').value.trim();
|
||||
const schedule = buildSchedule();
|
||||
const enabled = document.getElementById('task-enabled').checked;
|
||||
const preset = document.getElementById('task-preset').value;
|
||||
const project = (document.getElementById('task-project')||{}).value || '';
|
||||
const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
|
||||
const no_mem = !document.getElementById('task-mem').checked;
|
||||
const no_web = !document.getElementById('task-web').checked;
|
||||
const st = document.getElementById('task-modal-status');
|
||||
if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; }
|
||||
if(!name){ st.textContent = 'nom obligatoire'; st.style.color = 'var(--err)'; return; }
|
||||
if(kind === 'script' ? !script : !prompt){
|
||||
st.textContent = kind === 'script' ? 'choisis un script' : 'consigne obligatoire';
|
||||
st.style.color = 'var(--err)'; return;
|
||||
}
|
||||
if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; }
|
||||
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, tz, no_mem, no_web});
|
||||
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, kind, script, prompt, schedule, enabled, preset, project, tz, no_mem, no_web});
|
||||
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
|
||||
closeTask();
|
||||
await loadTasks();
|
||||
|
||||
@@ -15,6 +15,14 @@ let SET_LAST = 'config';
|
||||
const SET_HOOKS = {
|
||||
'engine-log': () => { loadSvcLog(); showPaths(); },
|
||||
'dictee': () => { loadDictateSettings(); },
|
||||
// Les compteurs d'un projet (discussions, pages, trackers) bougent en
|
||||
// permanence : on les relit à l'ouverture plutôt que d'afficher ceux du
|
||||
// chargement de la page.
|
||||
'projets': () => { loadProjects(); },
|
||||
// Le prompt système appartient au PRESET actif : changer de modèle change le
|
||||
// texte à afficher. On le relit à l'ouverture du panneau plutôt que de garder
|
||||
// celui du chargement de la page, qui serait alors celui d'un autre preset.
|
||||
'sysprompt': () => { loadSys(); },
|
||||
};
|
||||
function openSettings(pane){
|
||||
showModal('settings-modal');
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// ─── Dictée vocale ───────────────────────────────────────────────────────────
|
||||
// Bouton micro de la carte de saisie : un clic enregistre, un second arrête et
|
||||
// transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur —
|
||||
// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer
|
||||
// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe →
|
||||
// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve.
|
||||
// le moteur de transcription ne lit que du PCM, et encoder au bon format évite
|
||||
// d'embarquer ffmpeg dans l'image. La transcription est locale (POST
|
||||
// /api/transcribe → Parakeet côté serveur). Le texte s'ajoute au champ sans
|
||||
// écraser ce qui s'y trouve.
|
||||
//
|
||||
// ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS
|
||||
// ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on
|
||||
@@ -67,7 +68,7 @@ async function dictateStop(){
|
||||
}
|
||||
|
||||
// Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV
|
||||
// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple :
|
||||
// 16 kHz mono 16 bits — le format d’entrée du moteur de dictée. Décimation simple :
|
||||
// pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant.
|
||||
function encodeWav16k(chunks, fromRate){
|
||||
const toRate = 16000;
|
||||
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user