Compare commits

...
3 Commits
Author SHA1 Message Date
MichaelandClaude Opus 5 54468b3336 Dictée : Parakeet remplace whisper.cpp
Parakeet TDT 0.6B v3 (NVIDIA), servi par sherpa-onnx. La v3 et non la v2 :
c'est la seule des deux qui parle français — la v2 est anglais seul.

CE QUI DISPARAÎT DU DOCKERFILE
Deux étapes de compilation, dont une CUDA de ~200 fichiers nvcc qui a été tuée
par l'OOM du runner plus d'une fois, et avec elles tout l'appareillage de
garde-fous qu'elles réclamaient (GGML_NATIVE=OFF contre le SIGILL en
production, bornage des architectures CUDA, deux binaires CPU/CUDA à choisir à
l'exécution). À la place : le téléchargement d'un binaire statique de 35 Mo,
version épinglée et empreinte SHA-256 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.

CE QUI CHANGE DANS LE CODE
La forme est la même — un processus local supervisé, éteint après dix minutes
d'inactivité. Le dialogue, lui, change : whisper-server exposait du HTTP
multipart, sherpa-onnx n'expose qu'un WebSocket dont le protocole tient en deux
entiers et des flottants. D'où un client WebSocket et une conversion WAV →
float32 côté serveur. Le parcours des blocs du WAV n'est pas du zèle : l'offset
44 codé en dur transforme un bloc LIST intercalé en craquement au début de
chaque phrase.

DEUX RÉGLAGES DISPARAISSENT, ET C'EST LE MOTEUR QUI L'IMPOSE
La LANGUE : Parakeet la détecte lui-même, il n'a aucun drapeau pour la forcer.
Le réglage n'aurait servi qu'à mentir. À surveiller : whisper avait précisément
écarté la détection automatique parce qu'elle se trompait sur des tranches
courtes.
Le GPU : le build livré est le statique CPU. Annoncer un sélecteur de carte
sans pouvoir l'honorer serait pire que de ne rien annoncer — et un modèle de
0,6 B en int8 sur des tranches de quelques secondes n'en a pas besoin, la carte
reste au moteur de chat.

MIGRATION
Un réglage enregistré du temps de whisper retombe sur le défaut au lieu de
casser la dictée. Les modèles ggml de /data/whisper/ ne servent plus à rien
mais ne sont PAS effacés : ce sont des données que personne n'a demandé de
perdre. Ils sont à supprimer à la main.

Le paquet UI regénéré ici couvre aussi les sources du commit précédent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:57 +02:00
MichaelandClaude Opus 5 17f8fa8f34 Mémoire longue (recall), projets et trackers
Trois apports repris d'AJEAN 0.12.9 → 0.13.5, adaptés au fork.

MÉMOIRE LONGUE DE LA CONVERSATION (chat_recall.go)
Le compactage résumait, donc perdait. Chaque gros bloc du torse est désormais
ARCHIVÉ verbatim sous un identifiant court (r7…) dans bbolt AVANT d'être
résumé ; le résumé cite l'id, et le modèle le rappelle avec recall(id) ou le
retrouve par mots-clés avec recall_search. Le contexte reste plat, l'archive
grossit sur disque. En mode code, un fichier lu ou un diff produit tôt dans la
session n'est plus perdu au compactage suivant.
Au passage : garde-fou anti-résumé-dégénéré, budget de résumé indexé sur la
fenêtre, queue ramenée à 20 % (compacter plus large ne coûte plus de perte), et
fin de l'épinglage du 1er message user — le modèle répondait à l'ancienne
demande au lieu de continuer la tâche en cours.

PROJETS (projects.go)
Un projet cloisonne une mémoire, ses discussions et ses trackers. La couture
est memoryDir(), qui pointe sur le projet actif : tout le code mémoire en
hérite sans le savoir. Migration automatique au premier démarrage (memory/*.md
→ memory/generale/, discussions et tâches orphelines rattachées). Une tâche
planifiée vise un projet et l'exécution le force, pour qu'une veille n'écrive
pas dans la mémoire du chantier affiché à l'écran.

TRACKERS (tracker.go)
3e type de mémoire : les données datées qui s'accumulent. On ne les lit jamais
en entier — consultation par niveaux (vue d'ensemble → année → mois →
événements), et la dernière valeur de chaque tracker est donnée d'emblée au
modèle, qui répond sans appeler l'outil.

Aussi : index MEMORY.md tenu par le CODE et injecté en tête de conversation
avec la description du projet (le modèle ne peut plus le désynchroniser) ;
prompt système rattaché au PRESET et non plus global — stocké en base, pas
dans le .env, qui ne saurait pas porter un texte multiligne.

Deux correctifs du lot précédent voyagent ici, faute de pouvoir séparer les
fichiers : msgText signale la présence d'une image au compactage, et le
garde-fou « pensé sans agir » passe à deux relances (nudgeCount/maxNudges).

Le paquet UI est regénéré dans le commit suivant, qui touche les mêmes sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:33 +02:00
MichaelandClaude Opus 5 c45ba615fa Démarrage, captures, postes distants : correctifs portés d'AJEAN
- Au démarrage, une lecture ratée de la base (verrou bbolt transitoire pendant
  le chevauchement des process au redémarrage du conteneur) était confondue
  avec une conversation absente. Pire que l'amont chez nous : getStr rend ""
  dans les deux cas, donc convEnsureActive forgeait un NOUVEL identifiant et
  l'écrasait — le fil en cours devenait orphelin, en silence. On sonde
  désormais la base avec son erreur AVANT toute écriture, on réessaie quatre
  fois, et un échec durable est journalisé sans que rien ne soit touché.

- Une capture d'écran disparaissait sans laisser de trace : stripImageParts
  aplatissait le message en ne gardant que sa légende. Le marqueur
  imageLostMarker rend la perte VISIBLE, pour que le modèle sache reprendre
  une capture au lieu de la redécrire de mémoire.

- maxLogEvents 20000 → 200000 : 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.

- Keepalive WebSocket des postes distants (ping toutes les 25 s, des DEUX
  côtés). Sans lui, un poste au repos était coupé au bout de ~60-100 s par les
  intermédiaires qui ferment les canaux inactifs, puis reconnecté après
  backoff — les déconnexions à répétition sur tous les postes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:33:55 +02:00
54 changed files with 5974 additions and 967 deletions

No files matched your search

+8 -8
View File
@@ -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,8 +56,8 @@ jobs:
LOKI_VERSION=${{ github.sha }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise les étapes whisperbuild-* (compilation
# whisper.cpp CUDA, ~35 min) tant que leurs lignes du Dockerfile ne
# bougent pas : seul le code Go et l'assemblage de l'image se refont.
# 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
+40 -84
View File
@@ -15,16 +15,15 @@
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 WHISPER_CMAKE_FLAGS » nus des
# étapes whisperbuild-* héritent du scope GLOBAL — c'est-à-dire d'ici. Placé
# plus bas, ils héritaient d'une valeur VIDE : cmake tournait sans aucun
# drapeau, ggml se construisait en bibliothèques partagées (échec de lien sur
# libcuda.so.1) et surtout en -march=native — le SIGILL de la PR #18 revenu en
# silence. Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
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"
# 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
@@ -43,77 +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.
# 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/*
# Le grep final vérifie que ggml est bien lié EN STATIQUE : l'image finale ne
# reçoit que le binaire, une .so ggml manquante ne se verrait qu'au premier
# clic sur le micro. Des libs partagées ici = drapeaux non transmis.
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 \
&& ( ! ldd /w/build/bin/whisper-server | grep -E "libggml|libwhisper" )
# Version CUDA : la MÊME que celle de l'image amont (llama.cpp bâtit avec
# CUDA 12.8.1 sur Ubuntu 24.04). Le binaire est lié dynamiquement à libcudart,
# fournie par LLAMACPP_IMAGE et non par cette étape.
#
# ⚠️ 12.8 est un PLANCHER, pas un détail de version. Les GPU Blackwell
# (RTX 50xx, sm_120) n'existent pas pour un nvcc 12.4 : il refuse l'archi, et
# à défaut le binaire ne tourne que par recompilation PTX au chargement, quand
# elle est possible.
#
# ⚠️ -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.
#
# CUDA_ARCHS borne le travail : ggml compile sinon pour TOUTES les
# architectures qu'il connaît, de Maxwell à Blackwell, et chacune multiplie le
# temps de compilation. La liste par défaut couvre Turing à Blackwell
# (RTX 20xx → 50xx) ; l'élargir pour un GPU plus ancien se fait sans toucher
# au fichier : --build-arg CUDA_ARCHS="61;75;86".
FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 AS whisperbuild-cuda
ARG WHISPER_CMAKE_FLAGS
ARG CUDA_ARCHS="75;86;89;120"
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Même vérification statique que l'étape CPU. libcuda.so.1 (le PILOTE) reste
# elle une dépendance dynamique normale : absente du runner de build, injectée
# à l'exécution par le NVIDIA Container Toolkit — l'édition de liens la
# résout via les stubs du toolkit.
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 \
-DCMAKE_CUDA_ARCHITECTURES="${CUDA_ARCHS}" \
&& cmake --build /w/build -j"$(nproc)" --target whisper-server \
&& strip /w/build/bin/whisper-server \
&& ( ! ldd /w/build/bin/whisper-server | grep -E "libggml|libwhisper" )
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
@@ -168,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
@@ -181,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
+8 -1
View File
@@ -414,7 +414,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).
+211 -20
View File
@@ -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,47 @@ 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 reprendre une capture plutôt que deviner.
const imageLostMarker = " [image — not kept in the conversation past this point; take the screenshot again 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 +128,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 +230,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 +258,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 +289,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 +336,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 +346,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 +374,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 +430,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,7 +574,7 @@ 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.`
payload := map[string]any{
@@ -396,7 +587,7 @@ 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).
+10 -3
View File
@@ -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" {
+52 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"sync"
@@ -28,8 +29,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 +79,49 @@ 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
)
// 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
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 {
time.Sleep(loadConvRetryWait)
continue
}
// convEnsureActive reprend au passage le fil unique des versions
// précédentes (clé « conversation ») comme première discussion.
if b, err = getBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
break
}
time.Sleep(loadConvRetryWait)
}
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
}
@@ -479,9 +521,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.
@@ -0,0 +1,146 @@
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)
}
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()
start := time.Now()
LoadConversation()
elapsed := time.Since(start)
if elapsed >= loadConvRetryWait {
t.Fatalf("absence légitime traitée comme une erreur : %v d'attente, aucune reprise ne devait être déclenchée", elapsed)
}
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)
}
}
+19 -3
View File
@@ -282,7 +282,13 @@ func MemAdd(name, content string) error {
return err
}
body := strings.TrimRight(content, "\n") + "\n"
return os.WriteFile(p, []byte(body), 0o644)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
return err
}
// 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
@@ -355,7 +361,13 @@ func MemSave(name, old, content string) error {
return err
}
body := strings.TrimRight(content, "\n") + "\n"
return os.WriteFile(p, []byte(body), 0o644)
if err := os.WriteFile(p, []byte(body), 0o644); 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.
@@ -367,7 +379,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
+13 -1
View File
@@ -17,7 +17,19 @@ 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.
//
// Descriptions déjà réduites au strict nécessaire dans les deux cas. La consigne
// d'origine tient pour la suite : ce budget ne se relève pas pour du prompt.
const promptCharBudget = 8600 // ~2150 tokens, tout allumé
func TestSystemPromptStaysLean(t *testing.T) {
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
+315
View File
@@ -0,0 +1,315 @@
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) {
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
}
return b.Put(recallKey(seq), raw)
})
})
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
}
found = json.Unmarshal(v, &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
if json.Unmarshal(v, &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"},
},
},
}
}
+223
View File
@@ -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)
}
}
+6 -1
View File
@@ -154,6 +154,11 @@ 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 de la capture (« Capture … ») 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'en
// reprendre une. Même raison que dans msgText, autre bout de la chaîne.
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
+37 -3
View File
@@ -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)
}
}
}
+79 -5
View File
@@ -53,10 +53,52 @@ 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 {
@@ -90,7 +132,7 @@ func convEnsureActive() string {
}
_ = 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 +158,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 +184,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(getBytes(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
@@ -179,7 +253,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
+22 -38
View File
@@ -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
+57 -66
View File
@@ -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
View File
@@ -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
}
+145 -62
View File
@@ -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é")
}
}
+7 -7
View File
@@ -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)
+256 -164
View File
@@ -1,21 +1,23 @@
// 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"
@@ -23,22 +25,16 @@ import (
"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
}
@@ -46,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 {
@@ -83,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 {
@@ -101,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
@@ -114,67 +112,173 @@ 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.
// whisperStartLocked garde wsrvMu pendant tout le chargement du modèle ; c'est
// la seule poignée qu'un autre appelant (whisperShutdownVite) a sur lui pour
// 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]
@@ -184,114 +288,101 @@ var wsrv struct {
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()
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, cfg
wsrv.log, wsrv.depuis = log, time.Now()
wsrvDemarrage.Store(cmd)
err = whisperAttendPret(cmd, port, log)
err = asrAttendPret(cmd, port, log)
wsrvDemarrage.Store(nil)
if err != nil {
whisperStopLocked()
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
@@ -300,32 +391,32 @@ 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()
}
// whisperShutdownVite arrête le serveur sans jamais attendre derrière un
// démarrage. whisperShutdown prend wsrvMu, et whisperEnsure le garde pendant
// tout le chargement du modèle — jusqu'à whisperReady (deux minutes) depuis un
// disque lent. Appelé par un geste de l'interface (libérer la VRAM), ce blocage
// ferait abandonner le navigateur alors que le moteur, lui, est déjà arrêté.
// 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 whisperAttendPret, 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 carte, pas une panne.
func whisperShutdownVite() {
// 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() {
whisperStopLocked()
asrStopLocked()
wsrvMu.Unlock()
return
}
@@ -336,25 +427,26 @@ func whisperShutdownVite() {
}
}
// whisperInfer garantit le serveur puis lui soumet le WAV.
func whisperInfer(ctx context.Context, wav []byte) (string, error) {
port, err := whisperEnsure()
// 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())
+309 -100
View File
@@ -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)
}
}
+42 -13
View File
@@ -329,6 +329,10 @@ func EnabledTools(caps Caps) []Tool {
}
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())
}
if caps.Code {
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
@@ -338,6 +342,9 @@ func EnabledTools(caps Caps) []Tool {
// 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),
@@ -697,9 +704,14 @@ 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
@@ -919,10 +931,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":
@@ -1103,10 +1117,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// nothing while a slow shell command runs and looks frozen.
label := ""
switch tc.Function.Name {
case "mem_search", "web_search":
case "mem_search", "web_search", "recall_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
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":
@@ -1152,6 +1171,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
continue
}
switch tc.Function.Name {
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 {
@@ -1368,18 +1393,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 + ")_"})
+234
View File
@@ -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())))
}
+152
View File
@@ -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)
}
}
}
+34
View File
@@ -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)
+450
View File
@@ -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)
}
+257
View File
@@ -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)
}
}
+10 -4
View File
@@ -227,10 +227,16 @@ 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") }
+8
View File
@@ -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é.
+33
View File
@@ -12,6 +12,7 @@ import (
"encoding/hex"
"encoding/json"
"sort"
"strings"
"time"
bolt "go.etcd.io/bbolt"
@@ -25,6 +26,11 @@ 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"`
// 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 +74,33 @@ 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.
func taskProjectOf(t Task) string {
if s := strings.TrimSpace(t.Project); 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)
}
}
// getTask lit une tâche par ID. Renvoie false si absente.
func getTask(id string) (Task, bool) {
var t Task
+15 -4
View File
@@ -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
@@ -154,6 +157,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
+586
View File
@@ -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 !getJSON(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 putJSON(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)
}
+291
View File
@@ -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)
}
}
+551 -54
View File
@@ -1662,6 +1662,16 @@ html[data-files="1"] #files-btn{color:var(--accent)}
/* --- Discussions ----------------------------------------------------- */
/* La liste EST la barre latérale : titre à gauche, heure à droite, une ligne.
Le reste (réglages) descend sous un unique repli. */
/* Ligne du projet actif : posée au-dessus des discussions, qu'elle contient.
Le sélecteur prend toute la largeur restante — un nom de projet doit être
lisible en entier, c'est le repère qui dit dans quel chantier on écrit. */
.projrow{display:flex;align-items:center;gap:6px;padding:10px 12px 8px}
.projrow>select{flex:1;min-width:0;font:inherit;font-size:12.5px;color:var(--text);
background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:6px 9px}
.projrow>select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
/* Le « + » des sections est masqué par défaut et révélé par details[open] : hors
d'un <details>, il faut le rendre visible explicitement. */
.projrow>.preset-add{display:inline-block;margin:0}
#conv-search{width:calc(100% - 24px);font:inherit;font-size:12.5px;color:var(--text);
background:var(--panel);border:1px solid var(--border);border-radius:4px;
padding:7px 11px;margin:0 12px 8px}
@@ -2198,6 +2208,14 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<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.
@@ -2312,7 +2330,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<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">
@@ -2397,6 +2415,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<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>
@@ -2426,6 +2445,19 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</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>
@@ -2463,6 +2495,16 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</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">
@@ -2617,18 +2659,12 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</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>
@@ -2638,18 +2674,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<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>
@@ -2849,6 +2875,29 @@ html[data-files="1"] #files-btn{color:var(--accent)}
</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">
@@ -2986,6 +3035,13 @@ html[data-files="1"] #files-btn{color:var(--accent)}
<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>
@@ -4145,9 +4201,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';
@@ -4447,7 +4512,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(),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); }
@@ -5973,6 +6038,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é.
@@ -8928,6 +8997,7 @@ 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_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;
@@ -8950,6 +9020,7 @@ function renderTasks(r){
const paused = !!(r && r.paused);
const agentOn = !!(r && r.agent);
TASK_PRESETS = (r && r.presets) || [];
TASK_PROJECTS = (r && r.projects) || [];
TASK_MEM_ON = !r || r.mem_on !== false;
TASK_WEB_ON = !r || r.web_on !== false;
TASK_RUNNING = (r && r.running_id) || '';
@@ -9110,6 +9181,9 @@ function openTask(id){
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
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).
@@ -9182,6 +9256,20 @@ 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;
}
function setTaskFreqMode(mode){
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
if(r) r.checked = true;
@@ -9221,13 +9309,14 @@ async function saveTask(){
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(!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, 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();
@@ -9277,6 +9366,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');
@@ -9301,9 +9398,10 @@ document.addEventListener('keydown', e => {
// ─── 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
@@ -9367,7 +9465,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;
@@ -9391,43 +9489,35 @@ function encodeWav16k(chunks, fromRate){
return buf;
}
// ─── Réglages de la dictée ───────────────────────────────────────────────────
// Panneau Paramètres → Dictée : matériel, modèle, langue, réactivité, plus un
// test du micro. Ce sont des réglages de SERVEUR (le modèle chargé et le GPU
// occupé appartiennent à la machine), donc rien n'est gardé en localStorage :
// tout passe par /api/dictate/*.
// Panneau Paramètres → Dictée : modèle, réactivité, plus un test du micro. Ce
// sont des réglages de SERVEUR (le modèle chargé appartient à la machine), donc
// rien n'est gardé en localStorage : tout passe par /api/dictate/*.
//
// Ni langue ni matériel ici, et le moteur l'impose : Parakeet détecte la langue
// lui-même, et le binaire livré est le build CPU. Afficher des sélecteurs qu'on
// ne peut pas honorer serait pire que de ne rien afficher.
let DIC_CFG = null;
async function loadDictateSettings(){
const [cfg, modeles, gpus] = await Promise.all([
const [cfg, modeles] = await Promise.all([
jfetch('/api/dictate/config').then(r => r.json()).catch(()=>({})),
jfetch('/api/dictate/models').then(r => r.json()).catch(()=>[]),
jfetch('/api/vram').then(r => r.json()).catch(()=>[]),
]);
DIC_CFG = cfg || {};
// Matériel : « CPU seul » d'abord — c'est le choix qui marche partout, y
// compris sur une image bâtie sans CUDA.
const dev = document.getElementById('dic-device');
dev.innerHTML = '';
dev.appendChild(new Option('processeur seul', 'cpu'));
(gpus||[]).forEach((g, i) => {
const libre = Math.max(0, (g.total|0) - (g.used|0));
dev.appendChild(new Option(`GPU ${i} — ${g.name} (${libre} Mo libres sur ${g.total|0})`, String(i)));
});
dev.value = cfg.device || 'cpu';
const mod = document.getElementById('dic-model');
mod.innerHTML = '';
(modeles||[]).forEach(m => {
const mo = Math.round(m.octets / 1e6);
// Dire ce qui est déjà là : sans ça, choisir un modèle absent ressemble à
// un réglage instantané alors que c'est un téléchargement de 600 Mo.
// un réglage instantané alors que c'est un téléchargement de ~500 Mo.
// Et dire ce qu'il COMPREND : c'est le vrai critère de choix entre les deux
// (v3 multilingue, v2 anglais seul), pas leur taille, quasi identique.
const etat = m.present ? 'déjà téléchargé' : mo + ' Mo à télécharger';
mod.appendChild(new Option(`${m.nom} — ${etat}, ~${m.vram_mo} Mo de mémoire`, m.id));
mod.appendChild(new Option(`${m.nom} — ${m.langues} · ${etat}`, m.id));
});
mod.value = cfg.model || '';
document.getElementById('dic-lang').value = cfg.lang || 'fr';
document.getElementById('dic-react').value = cfg.reactivity || 'moyen';
refreshDictateState();
}
@@ -9435,8 +9525,6 @@ async function loadDictateSettings(){
async function dictateSave(){
const cfg = {
model: document.getElementById('dic-model').value,
lang: document.getElementById('dic-lang').value,
device: document.getElementById('dic-device').value,
reactivity: document.getElementById('dic-react').value,
};
const el = document.getElementById('dic-save-state');
@@ -9487,10 +9575,9 @@ async function refreshDictateState(){
const bouts = [];
bouts.push(j.actif ? `moteur allumé depuis ${j.depuis_s||0} s` : 'moteur éteint (il démarre au premier clic sur le micro)');
bouts.push(j.present ? `modèle ${j.modele} présent` : `modèle ${j.modele} ABSENT — à télécharger`);
// note : le serveur nous dit quand il n'a pas pu respecter le matériel
// demandé (image sans CUDA, par exemple). Le taire ferait croire que le GPU
// choisi est utilisé.
if(j.note) bouts.push('⚠ ' + j.note);
// Le dire explicitement : sans ça, on cherche un réglage de carte qui
// n'existe pas, ou on croit à une panne devant un GPU inactif.
if(j.device === 'cpu') bouts.push('sur le processeur');
if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`);
el.textContent = bouts.join(' · ');
}
@@ -9544,4 +9631,414 @@ async function dictateTestMic(){
btn.disabled = false;
refreshDictateState();
}
// Projets — un chantier = une mémoire + ses discussions + ses trackers.
//
// Deux surfaces, volontairement asymétriques :
// - le SÉLECTEUR de la barre latérale, qu'on utilise dix fois par jour : il ne
// fait qu'une chose, changer de projet ;
// - le PANNEAU de réglages, qu'on ouvre rarement : créer, renommer, décrire,
// supprimer.
//
// Changer de projet change ce que voit l'IA (mémoire, trackers) ET la liste des
// discussions : le serveur ouvre une discussion du projet d'arrivée, et le flux
// SSE redessine le chat tout seul — comme une bascule de discussion.
let PROJECTS = [], PROJ_ACTIVE = '';
async function loadProjects(){
let r;
try{ r = await jget('/api/projects'); }catch(_){ return; }
if(!r || !r.ok) return;
PROJECTS = r.projects || [];
PROJ_ACTIVE = r.active || '';
renderProjectPicker();
renderProjectList();
}
// Le sélecteur de la barre latérale. Reconstruit à chaque chargement : la liste
// est courte, et un rendu complet évite de synchroniser des options une par une.
function renderProjectPicker(){
const sel = document.getElementById('proj-switch');
if(!sel) return;
sel.textContent = '';
PROJECTS.forEach(p => {
const o = document.createElement('option');
o.value = p.slug;
o.textContent = p.name;
sel.appendChild(o);
});
sel.value = PROJ_ACTIVE;
}
async function onProjectSwitch(sel){
const slug = sel.value;
if(!slug || slug === PROJ_ACTIVE) return;
const r = await jpost('/api/projects', {action:'switch', slug});
if(!r.ok){
// Refusé (génération en cours) : on remet le sélecteur sur le projet réel,
// sinon il affiche un projet où l'on n'est pas.
sel.value = PROJ_ACTIVE;
toast('erreur : ' + (r.error||''));
return;
}
PROJECTS = r.projects || PROJECTS;
PROJ_ACTIVE = r.active || PROJ_ACTIVE;
renderProjectPicker();
renderProjectList();
// La mémoire, les trackers et les discussions appartiennent au projet : tout
// ce qui est à l'écran parle du précédent.
if(typeof loadConversations === 'function') loadConversations();
if(typeof loadAgent === 'function') loadAgent();
if(typeof loadTrackers === 'function') loadTrackers();
}
// Le panneau de gestion. Une ligne par projet, avec ce qu'il contient — c'est ce
// qui permet de décider quoi supprimer sans ouvrir chaque projet.
function renderProjectList(){
const list = document.getElementById('proj-list');
if(!list) return;
list.textContent = '';
PROJECTS.forEach(p => {
const row = document.createElement('div');
row.className = 'preset';
row.style.fontSize = '12px';
const span = document.createElement('span');
const b = document.createElement('b');
b.style.color = 'var(--text)';
b.textContent = p.name;
span.appendChild(b);
if(p.active){
const a = document.createElement('span');
a.className = 'sbadge on';
a.textContent = 'actif';
a.style.marginLeft = '6px';
span.appendChild(a);
}
const d = document.createElement('span');
d.className = 'muted';
d.textContent = ' — ' + p.convs + ' discussion' + (p.convs > 1 ? 's' : '')
+ ' · ' + p.pages + ' page' + (p.pages > 1 ? 's' : '')
+ ' · ' + p.trackers + ' tracker' + (p.trackers > 1 ? 's' : '');
span.appendChild(d);
if(p.desc){
const desc = document.createElement('div');
desc.className = 'muted';
desc.style.cssText = 'font-size:11px;margin-top:2px';
desc.textContent = p.desc;
span.appendChild(desc);
}
const acts = document.createElement('span');
acts.style.cssText = 'display:flex;gap:4px;flex:none';
acts.appendChild(projBtn('décrire', () => editProjectDesc(p)));
acts.appendChild(projBtn('renommer', () => renameProjectUI(p)));
// Le dernier projet n'est pas supprimable côté serveur : ne pas proposer le
// bouton évite une erreur qu'on ne peut pas résoudre.
if(PROJECTS.length > 1) acts.appendChild(projBtn('supprimer', () => deleteProjectUI(p)));
row.appendChild(span);
row.appendChild(acts);
list.appendChild(row);
});
}
function projBtn(label, fn){
const b = document.createElement('button');
b.textContent = label;
b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px';
b.onclick = e => { e.stopPropagation(); fn(); };
return b;
}
// Applique une action et rafraîchit tout ce qui dépend des projets.
async function projectAction(payload, okMsg){
const r = await jpost('/api/projects', payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; }
PROJECTS = r.projects || PROJECTS;
PROJ_ACTIVE = r.active || PROJ_ACTIVE;
renderProjectPicker();
renderProjectList();
if(okMsg) toast(okMsg);
return true;
}
async function newProject(){
const name = await askPrompt('Nom du projet (ex. « Serveur NAS », « Recettes ») :', {title:'Nouveau projet', okText:'Créer'});
if(!name || !name.trim()) return;
await projectAction({action:'create', name:name.trim()}, 'projet créé');
}
async function renameProjectUI(p){
const name = await askPrompt('Nouveau nom :', {title:'Renommer le projet', default:p.name, okText:'Renommer'});
if(!name || !name.trim() || name.trim() === p.name) return;
// Seul le libellé change : les chemins disque, les discussions et les trackers
// restent accrochés au slug d'origine.
await projectAction({action:'rename', slug:p.slug, name:name.trim()}, 'renommé');
}
async function editProjectDesc(p){
const desc = await askPrompt(
'À quoi sert ce projet ? Ce texte est donné à l\'IA au début de chaque discussion du projet.',
{title:'Description du projet', default:p.desc||'', placeholder:'ex. administration du NAS Unraid, docker, sauvegardes', okText:'Enregistrer'});
if(desc === null) return;
await projectAction({action:'desc', slug:p.slug, desc}, 'description enregistrée');
}
async function deleteProjectUI(p){
const ok = await askConfirm(
'Supprimer « ' + p.name + ' » ?\n\nSes ' + p.pages + ' page(s) mémoire, ses '
+ p.convs + ' discussion(s) et ses ' + p.trackers + ' tracker(s) seront effacés. Irréversible.',
{title:'Supprimer le projet', okText:'Supprimer', danger:true});
if(!ok) return;
await projectAction({action:'delete', slug:p.slug}, 'projet supprimé');
if(typeof loadConversations === 'function') loadConversations();
if(typeof loadAgent === 'function') loadAgent();
if(typeof loadTrackers === 'function') loadTrackers();
}
// Déplacer une page mémoire vers un autre projet — appelé depuis la liste des
// pages (06-settings.js). Une page rangée dans le mauvais chantier n'a pas à
// être recopiée à la main pour en changer.
async function moveMemPageUI(name){
const others = PROJECTS.filter(p => p.slug !== PROJ_ACTIVE);
if(!others.length){ toast('aucun autre projet où déplacer'); return; }
const choice = await askPrompt(
'Déplacer « ' + name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'),
{title:'Déplacer la page', default:others[0].name, okText:'Déplacer'});
if(!choice) return;
const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase());
if(!target){ toast('projet inconnu : ' + choice); return; }
const r = await jpost('/api/mem/move', {name, to:target.slug});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('déplacée vers ' + target.name);
if(typeof loadAgent === 'function') loadAgent();
loadProjects();
}
// Trackers — le 3ᵉ type de mémoire : les données DATÉES qui s'accumulent
// (compteurs, relevés, journaux d'événements).
//
// Une page mémoire répond à « qu'est-ce que je sais » ; un tracker répond à
// « comment ça évolue ». D'où deux affichages distincts : la liste montre la
// DERNIÈRE valeur de chaque tracker (c'est presque toujours la question posée),
// le détail montre les points, du plus récent au plus ancien.
//
// La liste ne charge jamais les points : un tracker peut compter des milliers
// d'entrées, et le panneau doit s'ouvrir instantanément.
let TRACKERS = [], TRACK_OPEN = null;
async function loadTrackers(){
let r;
try{ r = await jget('/api/tracker'); }catch(_){ return; }
if(!r || !r.ok) return;
TRACKERS = r.trackers || [];
renderTrackers();
}
function toggleTrackers(){
const body = document.getElementById('track-body');
if(!body) return;
body.hidden = !body.hidden;
if(!body.hidden) loadTrackers();
}
// Date lisible : on n'affiche l'heure que si le point en a une côté serveur —
// un relevé saisi « le 12 mars » ne doit pas s'afficher « 00:00 ».
function trackWhen(ev){
const d = new Date(ev.ts);
const day = d.toLocaleDateString();
return ev.date_only ? day : day + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
}
function renderTrackers(){
const list = document.getElementById('track-list');
const count = document.getElementById('track-count');
if(!list) return;
if(count) count.textContent = TRACKERS.length ? '(' + TRACKERS.length + ')' : '';
list.textContent = '';
if(!TRACKERS.length){
const e = document.createElement('div');
e.className = 'muted';
e.style.fontSize = '12px';
e.textContent = '(aucun tracker — ex. « poids », « abonnés », « relevés compteur »)';
list.appendChild(e);
return;
}
TRACKERS.forEach(t => {
const row = document.createElement('div');
row.className = 'preset';
row.style.fontSize = '12px';
row.onclick = () => openTracker(t.slug);
const span = document.createElement('span');
const b = document.createElement('b');
b.style.color = 'var(--text)';
b.textContent = t.name;
span.appendChild(b);
const d = document.createElement('span');
d.className = 'muted';
// La dernière valeur EST l'information : elle passe avant le compte.
d.textContent = t.last_text ? ' — ' + t.last_text : ' — (vide)';
span.appendChild(d);
const sub = document.createElement('div');
sub.className = 'muted';
sub.style.cssText = 'font-size:11px;margin-top:2px';
sub.textContent = t.count + ' point' + (t.count > 1 ? 's' : '')
+ (t.last_ts ? ' · dernier le ' + new Date(t.last_ts).toLocaleDateString() : '');
span.appendChild(sub);
row.appendChild(span);
list.appendChild(row);
});
}
// --- détail d'un tracker -----------------------------------------------------
async function openTracker(slug){
const r = await jget('/api/tracker?slug=' + encodeURIComponent(slug));
if(!r || !r.ok){ toast('erreur : ' + ((r&&r.error)||'')); return; }
TRACK_OPEN = r.tracker;
document.getElementById('track-modal-title').textContent = TRACK_OPEN.name;
renderTrackerEvents();
showModal('track-modal');
}
function closeTracker(){ hideModal('track-modal'); TRACK_OPEN = null; }
function renderTrackerEvents(){
const body = document.getElementById('track-events');
if(!body || !TRACK_OPEN) return;
body.textContent = '';
const evs = (TRACK_OPEN.events || []).slice().reverse(); // plus récent d'abord
if(!evs.length){
const e = document.createElement('div');
e.className = 'muted';
e.style.fontSize = '12px';
e.textContent = '(aucun point)';
body.appendChild(e);
return;
}
evs.forEach(ev => {
const row = document.createElement('div');
row.className = 'preset';
row.style.fontSize = '12px';
const span = document.createElement('span');
const w = document.createElement('b');
w.style.color = 'var(--text)';
w.textContent = trackWhen(ev);
span.appendChild(w);
const t = document.createElement('span');
t.className = 'muted';
t.textContent = ' — ' + ev.text;
span.appendChild(t);
const acts = document.createElement('span');
acts.style.cssText = 'display:flex;gap:4px;flex:none';
acts.appendChild(trackBtn('modifier', () => editTrackerPoint(ev)));
acts.appendChild(trackBtn('supprimer', () => deleteTrackerPoint(ev)));
row.appendChild(span);
row.appendChild(acts);
body.appendChild(row);
});
}
function trackBtn(label, fn){
const b = document.createElement('button');
b.textContent = label;
b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px';
b.onclick = e => { e.stopPropagation(); fn(); };
return b;
}
// Applique une action sur le tracker ouvert, puis recharge son détail ET la
// liste : les deux affichent la même donnée, ils ne doivent pas diverger.
async function trackerAction(payload, okMsg){
const r = await jpost('/api/tracker', payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; }
TRACKERS = r.trackers || TRACKERS;
renderTrackers();
if(okMsg) toast(okMsg);
return true;
}
// La date est saisie en texte libre à précision variable (« 2026-07-15 »,
// « 2026-07-15 14:30 », vide = maintenant) : c'est le serveur qui la comprend
// (parseWhen), pour que l'outil du modèle et l'interface acceptent exactement
// la même chose.
const TRACK_WHEN_HINT = 'Date (vide = maintenant) — AAAA-MM-JJ ou AAAA-MM-JJ HH:MM';
async function addTrackerPoint(){
if(!TRACK_OPEN) return;
const text = await askPrompt('Valeur ou note du point :', {title:'Ajouter un point', placeholder:'ex. 78,2 kg', okText:'Suivant'});
if(!text || !text.trim()) return;
const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Ajouter'});
if(when === null) return;
if(!await trackerAction({action:'add', name:TRACK_OPEN.name, when, text}, 'point ajouté')) return;
openTracker(TRACK_OPEN.slug);
}
async function newTracker(){
const name = await askPrompt('Nom du tracker (ex. « poids », « abonnés ») :', {title:'Nouveau tracker', okText:'Suivant'});
if(!name || !name.trim()) return;
const text = await askPrompt('Premier point — valeur ou note :', {title:'Premier point', placeholder:'ex. 78,2 kg', okText:'Suivant'});
if(!text || !text.trim()) return;
const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Créer'});
if(when === null) return;
// Un tracker naît de son premier point : pas de coquille vide à remplir plus tard.
await trackerAction({action:'add', name:name.trim(), when, text}, 'tracker créé');
}
async function editTrackerPoint(ev){
if(!TRACK_OPEN) return;
const text = await askPrompt('Valeur ou note :', {title:'Modifier le point', default:ev.text, okText:'Suivant'});
if(text === null) return;
const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', default:'', placeholder:'vide = date inchangée', okText:'Enregistrer'});
if(when === null) return;
if(!await trackerAction({action:'edit', slug:TRACK_OPEN.slug, id:ev.id, text, when}, 'point modifié')) return;
openTracker(TRACK_OPEN.slug);
}
async function deleteTrackerPoint(ev){
if(!TRACK_OPEN) return;
if(!await askConfirm('Supprimer le point du ' + trackWhen(ev) + ' ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return;
if(!await trackerAction({action:'delete', slug:TRACK_OPEN.slug, id:ev.id}, 'point supprimé')) return;
openTracker(TRACK_OPEN.slug);
}
async function renameTracker(){
if(!TRACK_OPEN) return;
const name = await askPrompt('Nouveau nom :', {title:'Renommer le tracker', default:TRACK_OPEN.name, okText:'Renommer'});
if(!name || !name.trim() || name.trim() === TRACK_OPEN.name) return;
// Le slug dérive du nom : le serveur re-clé le tracker si nécessaire, sinon un
// point ajouté ensuite créerait un tracker parallèle sous l'ancien nom.
if(!await trackerAction({action:'rename', slug:TRACK_OPEN.slug, name:name.trim()}, 'renommé')) return;
closeTracker();
}
async function moveTracker(){
if(!TRACK_OPEN) return;
const others = (typeof PROJECTS !== 'undefined' ? PROJECTS : []).filter(p => p.slug !== PROJ_ACTIVE);
if(!others.length){ toast('aucun autre projet où déplacer'); return; }
const choice = await askPrompt(
'Déplacer « ' + TRACK_OPEN.name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'),
{title:'Déplacer le tracker', default:others[0].name, okText:'Déplacer'});
if(!choice) return;
const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase());
if(!target){ toast('projet inconnu : ' + choice); return; }
if(!await trackerAction({action:'move', slug:TRACK_OPEN.slug, to:target.slug}, 'déplacé vers ' + target.name)) return;
closeTracker();
if(typeof loadProjects === 'function') loadProjects();
}
async function deleteTracker(){
if(!TRACK_OPEN) return;
const ok = await askConfirm(
'Supprimer le tracker « ' + TRACK_OPEN.name + ' » et ses ' + TRACK_OPEN.count + ' point(s) ? Irréversible.',
{title:'Supprimer le tracker', okText:'Supprimer', danger:true});
if(!ok) return;
if(!await trackerAction({action:'delete_tracker', slug:TRACK_OPEN.slug}, 'tracker supprimé')) return;
closeTracker();
}
</script></body></html>
+67 -21
View File
@@ -51,6 +51,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.
@@ -165,7 +173,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">
@@ -250,6 +258,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>
@@ -279,6 +288,19 @@ 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>
@@ -316,6 +338,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">
@@ -470,18 +502,12 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</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>
@@ -491,18 +517,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>
@@ -702,6 +718,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">
@@ -839,6 +878,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>
+11 -2
View File
@@ -149,9 +149,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';
@@ -451,7 +460,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(),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); }
@@ -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é.
+21 -1
View File
@@ -11,6 +11,7 @@ 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_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 +34,7 @@ function renderTasks(r){
const paused = !!(r && r.paused);
const agentOn = !!(r && r.agent);
TASK_PRESETS = (r && r.presets) || [];
TASK_PROJECTS = (r && r.projects) || [];
TASK_MEM_ON = !r || r.mem_on !== false;
TASK_WEB_ON = !r || r.web_on !== false;
TASK_RUNNING = (r && r.running_id) || '';
@@ -193,6 +195,9 @@ function openTask(id){
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
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 +270,20 @@ 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;
}
function setTaskFreqMode(mode){
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
if(r) r.checked = true;
@@ -304,13 +323,14 @@ async function saveTask(){
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(!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, 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');
+5 -4
View File
@@ -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;
+15 -26
View File
@@ -1,41 +1,33 @@
// ─── Réglages de la dictée ───────────────────────────────────────────────────
// Panneau Paramètres → Dictée : matériel, modèle, langue, réactivité, plus un
// test du micro. Ce sont des réglages de SERVEUR (le modèle chargé et le GPU
// occupé appartiennent à la machine), donc rien n'est gardé en localStorage :
// tout passe par /api/dictate/*.
// Panneau Paramètres → Dictée : modèle, réactivité, plus un test du micro. Ce
// sont des réglages de SERVEUR (le modèle chargé appartient à la machine), donc
// rien n'est gardé en localStorage : tout passe par /api/dictate/*.
//
// Ni langue ni matériel ici, et le moteur l'impose : Parakeet détecte la langue
// lui-même, et le binaire livré est le build CPU. Afficher des sélecteurs qu'on
// ne peut pas honorer serait pire que de ne rien afficher.
let DIC_CFG = null;
async function loadDictateSettings(){
const [cfg, modeles, gpus] = await Promise.all([
const [cfg, modeles] = await Promise.all([
jfetch('/api/dictate/config').then(r => r.json()).catch(()=>({})),
jfetch('/api/dictate/models').then(r => r.json()).catch(()=>[]),
jfetch('/api/vram').then(r => r.json()).catch(()=>[]),
]);
DIC_CFG = cfg || {};
// Matériel : « CPU seul » d'abord — c'est le choix qui marche partout, y
// compris sur une image bâtie sans CUDA.
const dev = document.getElementById('dic-device');
dev.innerHTML = '';
dev.appendChild(new Option('processeur seul', 'cpu'));
(gpus||[]).forEach((g, i) => {
const libre = Math.max(0, (g.total|0) - (g.used|0));
dev.appendChild(new Option(`GPU ${i} — ${g.name} (${libre} Mo libres sur ${g.total|0})`, String(i)));
});
dev.value = cfg.device || 'cpu';
const mod = document.getElementById('dic-model');
mod.innerHTML = '';
(modeles||[]).forEach(m => {
const mo = Math.round(m.octets / 1e6);
// Dire ce qui est déjà là : sans ça, choisir un modèle absent ressemble à
// un réglage instantané alors que c'est un téléchargement de 600 Mo.
// un réglage instantané alors que c'est un téléchargement de ~500 Mo.
// Et dire ce qu'il COMPREND : c'est le vrai critère de choix entre les deux
// (v3 multilingue, v2 anglais seul), pas leur taille, quasi identique.
const etat = m.present ? 'déjà téléchargé' : mo + ' Mo à télécharger';
mod.appendChild(new Option(`${m.nom} — ${etat}, ~${m.vram_mo} Mo de mémoire`, m.id));
mod.appendChild(new Option(`${m.nom} — ${m.langues} · ${etat}`, m.id));
});
mod.value = cfg.model || '';
document.getElementById('dic-lang').value = cfg.lang || 'fr';
document.getElementById('dic-react').value = cfg.reactivity || 'moyen';
refreshDictateState();
}
@@ -43,8 +35,6 @@ async function loadDictateSettings(){
async function dictateSave(){
const cfg = {
model: document.getElementById('dic-model').value,
lang: document.getElementById('dic-lang').value,
device: document.getElementById('dic-device').value,
reactivity: document.getElementById('dic-react').value,
};
const el = document.getElementById('dic-save-state');
@@ -95,10 +85,9 @@ async function refreshDictateState(){
const bouts = [];
bouts.push(j.actif ? `moteur allumé depuis ${j.depuis_s||0} s` : 'moteur éteint (il démarre au premier clic sur le micro)');
bouts.push(j.present ? `modèle ${j.modele} présent` : `modèle ${j.modele} ABSENT — à télécharger`);
// note : le serveur nous dit quand il n'a pas pu respecter le matériel
// demandé (image sans CUDA, par exemple). Le taire ferait croire que le GPU
// choisi est utilisé.
if(j.note) bouts.push('⚠ ' + j.note);
// Le dire explicitement : sans ça, on cherche un réglage de carte qui
// n'existe pas, ou on croit à une panne devant un GPU inactif.
if(j.device === 'cpu') bouts.push('sur le processeur');
if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`);
el.textContent = bouts.join(' · ');
}
+184
View File
@@ -0,0 +1,184 @@
// Projets — un chantier = une mémoire + ses discussions + ses trackers.
//
// Deux surfaces, volontairement asymétriques :
// - le SÉLECTEUR de la barre latérale, qu'on utilise dix fois par jour : il ne
// fait qu'une chose, changer de projet ;
// - le PANNEAU de réglages, qu'on ouvre rarement : créer, renommer, décrire,
// supprimer.
//
// Changer de projet change ce que voit l'IA (mémoire, trackers) ET la liste des
// discussions : le serveur ouvre une discussion du projet d'arrivée, et le flux
// SSE redessine le chat tout seul — comme une bascule de discussion.
let PROJECTS = [], PROJ_ACTIVE = '';
async function loadProjects(){
let r;
try{ r = await jget('/api/projects'); }catch(_){ return; }
if(!r || !r.ok) return;
PROJECTS = r.projects || [];
PROJ_ACTIVE = r.active || '';
renderProjectPicker();
renderProjectList();
}
// Le sélecteur de la barre latérale. Reconstruit à chaque chargement : la liste
// est courte, et un rendu complet évite de synchroniser des options une par une.
function renderProjectPicker(){
const sel = document.getElementById('proj-switch');
if(!sel) return;
sel.textContent = '';
PROJECTS.forEach(p => {
const o = document.createElement('option');
o.value = p.slug;
o.textContent = p.name;
sel.appendChild(o);
});
sel.value = PROJ_ACTIVE;
}
async function onProjectSwitch(sel){
const slug = sel.value;
if(!slug || slug === PROJ_ACTIVE) return;
const r = await jpost('/api/projects', {action:'switch', slug});
if(!r.ok){
// Refusé (génération en cours) : on remet le sélecteur sur le projet réel,
// sinon il affiche un projet où l'on n'est pas.
sel.value = PROJ_ACTIVE;
toast('erreur : ' + (r.error||''));
return;
}
PROJECTS = r.projects || PROJECTS;
PROJ_ACTIVE = r.active || PROJ_ACTIVE;
renderProjectPicker();
renderProjectList();
// La mémoire, les trackers et les discussions appartiennent au projet : tout
// ce qui est à l'écran parle du précédent.
if(typeof loadConversations === 'function') loadConversations();
if(typeof loadAgent === 'function') loadAgent();
if(typeof loadTrackers === 'function') loadTrackers();
}
// Le panneau de gestion. Une ligne par projet, avec ce qu'il contient — c'est ce
// qui permet de décider quoi supprimer sans ouvrir chaque projet.
function renderProjectList(){
const list = document.getElementById('proj-list');
if(!list) return;
list.textContent = '';
PROJECTS.forEach(p => {
const row = document.createElement('div');
row.className = 'preset';
row.style.fontSize = '12px';
const span = document.createElement('span');
const b = document.createElement('b');
b.style.color = 'var(--text)';
b.textContent = p.name;
span.appendChild(b);
if(p.active){
const a = document.createElement('span');
a.className = 'sbadge on';
a.textContent = 'actif';
a.style.marginLeft = '6px';
span.appendChild(a);
}
const d = document.createElement('span');
d.className = 'muted';
d.textContent = ' — ' + p.convs + ' discussion' + (p.convs > 1 ? 's' : '')
+ ' · ' + p.pages + ' page' + (p.pages > 1 ? 's' : '')
+ ' · ' + p.trackers + ' tracker' + (p.trackers > 1 ? 's' : '');
span.appendChild(d);
if(p.desc){
const desc = document.createElement('div');
desc.className = 'muted';
desc.style.cssText = 'font-size:11px;margin-top:2px';
desc.textContent = p.desc;
span.appendChild(desc);
}
const acts = document.createElement('span');
acts.style.cssText = 'display:flex;gap:4px;flex:none';
acts.appendChild(projBtn('décrire', () => editProjectDesc(p)));
acts.appendChild(projBtn('renommer', () => renameProjectUI(p)));
// Le dernier projet n'est pas supprimable côté serveur : ne pas proposer le
// bouton évite une erreur qu'on ne peut pas résoudre.
if(PROJECTS.length > 1) acts.appendChild(projBtn('supprimer', () => deleteProjectUI(p)));
row.appendChild(span);
row.appendChild(acts);
list.appendChild(row);
});
}
function projBtn(label, fn){
const b = document.createElement('button');
b.textContent = label;
b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px';
b.onclick = e => { e.stopPropagation(); fn(); };
return b;
}
// Applique une action et rafraîchit tout ce qui dépend des projets.
async function projectAction(payload, okMsg){
const r = await jpost('/api/projects', payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; }
PROJECTS = r.projects || PROJECTS;
PROJ_ACTIVE = r.active || PROJ_ACTIVE;
renderProjectPicker();
renderProjectList();
if(okMsg) toast(okMsg);
return true;
}
async function newProject(){
const name = await askPrompt('Nom du projet (ex. « Serveur NAS », « Recettes ») :', {title:'Nouveau projet', okText:'Créer'});
if(!name || !name.trim()) return;
await projectAction({action:'create', name:name.trim()}, 'projet créé');
}
async function renameProjectUI(p){
const name = await askPrompt('Nouveau nom :', {title:'Renommer le projet', default:p.name, okText:'Renommer'});
if(!name || !name.trim() || name.trim() === p.name) return;
// Seul le libellé change : les chemins disque, les discussions et les trackers
// restent accrochés au slug d'origine.
await projectAction({action:'rename', slug:p.slug, name:name.trim()}, 'renommé');
}
async function editProjectDesc(p){
const desc = await askPrompt(
'À quoi sert ce projet ? Ce texte est donné à l\'IA au début de chaque discussion du projet.',
{title:'Description du projet', default:p.desc||'', placeholder:'ex. administration du NAS Unraid, docker, sauvegardes', okText:'Enregistrer'});
if(desc === null) return;
await projectAction({action:'desc', slug:p.slug, desc}, 'description enregistrée');
}
async function deleteProjectUI(p){
const ok = await askConfirm(
'Supprimer « ' + p.name + ' » ?\n\nSes ' + p.pages + ' page(s) mémoire, ses '
+ p.convs + ' discussion(s) et ses ' + p.trackers + ' tracker(s) seront effacés. Irréversible.',
{title:'Supprimer le projet', okText:'Supprimer', danger:true});
if(!ok) return;
await projectAction({action:'delete', slug:p.slug}, 'projet supprimé');
if(typeof loadConversations === 'function') loadConversations();
if(typeof loadAgent === 'function') loadAgent();
if(typeof loadTrackers === 'function') loadTrackers();
}
// Déplacer une page mémoire vers un autre projet — appelé depuis la liste des
// pages (06-settings.js). Une page rangée dans le mauvais chantier n'a pas à
// être recopiée à la main pour en changer.
async function moveMemPageUI(name){
const others = PROJECTS.filter(p => p.slug !== PROJ_ACTIVE);
if(!others.length){ toast('aucun autre projet où déplacer'); return; }
const choice = await askPrompt(
'Déplacer « ' + name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'),
{title:'Déplacer la page', default:others[0].name, okText:'Déplacer'});
if(!choice) return;
const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase());
if(!target){ toast('projet inconnu : ' + choice); return; }
const r = await jpost('/api/mem/move', {name, to:target.slug});
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
toast('déplacée vers ' + target.name);
if(typeof loadAgent === 'function') loadAgent();
loadProjects();
}
+226
View File
@@ -0,0 +1,226 @@
// Trackers — le 3ᵉ type de mémoire : les données DATÉES qui s'accumulent
// (compteurs, relevés, journaux d'événements).
//
// Une page mémoire répond à « qu'est-ce que je sais » ; un tracker répond à
// « comment ça évolue ». D'où deux affichages distincts : la liste montre la
// DERNIÈRE valeur de chaque tracker (c'est presque toujours la question posée),
// le détail montre les points, du plus récent au plus ancien.
//
// La liste ne charge jamais les points : un tracker peut compter des milliers
// d'entrées, et le panneau doit s'ouvrir instantanément.
let TRACKERS = [], TRACK_OPEN = null;
async function loadTrackers(){
let r;
try{ r = await jget('/api/tracker'); }catch(_){ return; }
if(!r || !r.ok) return;
TRACKERS = r.trackers || [];
renderTrackers();
}
function toggleTrackers(){
const body = document.getElementById('track-body');
if(!body) return;
body.hidden = !body.hidden;
if(!body.hidden) loadTrackers();
}
// Date lisible : on n'affiche l'heure que si le point en a une côté serveur —
// un relevé saisi « le 12 mars » ne doit pas s'afficher « 00:00 ».
function trackWhen(ev){
const d = new Date(ev.ts);
const day = d.toLocaleDateString();
return ev.date_only ? day : day + ' ' + d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
}
function renderTrackers(){
const list = document.getElementById('track-list');
const count = document.getElementById('track-count');
if(!list) return;
if(count) count.textContent = TRACKERS.length ? '(' + TRACKERS.length + ')' : '';
list.textContent = '';
if(!TRACKERS.length){
const e = document.createElement('div');
e.className = 'muted';
e.style.fontSize = '12px';
e.textContent = '(aucun tracker — ex. « poids », « abonnés », « relevés compteur »)';
list.appendChild(e);
return;
}
TRACKERS.forEach(t => {
const row = document.createElement('div');
row.className = 'preset';
row.style.fontSize = '12px';
row.onclick = () => openTracker(t.slug);
const span = document.createElement('span');
const b = document.createElement('b');
b.style.color = 'var(--text)';
b.textContent = t.name;
span.appendChild(b);
const d = document.createElement('span');
d.className = 'muted';
// La dernière valeur EST l'information : elle passe avant le compte.
d.textContent = t.last_text ? ' — ' + t.last_text : ' — (vide)';
span.appendChild(d);
const sub = document.createElement('div');
sub.className = 'muted';
sub.style.cssText = 'font-size:11px;margin-top:2px';
sub.textContent = t.count + ' point' + (t.count > 1 ? 's' : '')
+ (t.last_ts ? ' · dernier le ' + new Date(t.last_ts).toLocaleDateString() : '');
span.appendChild(sub);
row.appendChild(span);
list.appendChild(row);
});
}
// --- détail d'un tracker -----------------------------------------------------
async function openTracker(slug){
const r = await jget('/api/tracker?slug=' + encodeURIComponent(slug));
if(!r || !r.ok){ toast('erreur : ' + ((r&&r.error)||'')); return; }
TRACK_OPEN = r.tracker;
document.getElementById('track-modal-title').textContent = TRACK_OPEN.name;
renderTrackerEvents();
showModal('track-modal');
}
function closeTracker(){ hideModal('track-modal'); TRACK_OPEN = null; }
function renderTrackerEvents(){
const body = document.getElementById('track-events');
if(!body || !TRACK_OPEN) return;
body.textContent = '';
const evs = (TRACK_OPEN.events || []).slice().reverse(); // plus récent d'abord
if(!evs.length){
const e = document.createElement('div');
e.className = 'muted';
e.style.fontSize = '12px';
e.textContent = '(aucun point)';
body.appendChild(e);
return;
}
evs.forEach(ev => {
const row = document.createElement('div');
row.className = 'preset';
row.style.fontSize = '12px';
const span = document.createElement('span');
const w = document.createElement('b');
w.style.color = 'var(--text)';
w.textContent = trackWhen(ev);
span.appendChild(w);
const t = document.createElement('span');
t.className = 'muted';
t.textContent = ' — ' + ev.text;
span.appendChild(t);
const acts = document.createElement('span');
acts.style.cssText = 'display:flex;gap:4px;flex:none';
acts.appendChild(trackBtn('modifier', () => editTrackerPoint(ev)));
acts.appendChild(trackBtn('supprimer', () => deleteTrackerPoint(ev)));
row.appendChild(span);
row.appendChild(acts);
body.appendChild(row);
});
}
function trackBtn(label, fn){
const b = document.createElement('button');
b.textContent = label;
b.style.cssText = 'margin:0;padding:2px 8px;font-size:11px';
b.onclick = e => { e.stopPropagation(); fn(); };
return b;
}
// Applique une action sur le tracker ouvert, puis recharge son détail ET la
// liste : les deux affichent la même donnée, ils ne doivent pas diverger.
async function trackerAction(payload, okMsg){
const r = await jpost('/api/tracker', payload);
if(!r.ok){ toast('erreur : ' + (r.error||'')); return false; }
TRACKERS = r.trackers || TRACKERS;
renderTrackers();
if(okMsg) toast(okMsg);
return true;
}
// La date est saisie en texte libre à précision variable (« 2026-07-15 »,
// « 2026-07-15 14:30 », vide = maintenant) : c'est le serveur qui la comprend
// (parseWhen), pour que l'outil du modèle et l'interface acceptent exactement
// la même chose.
const TRACK_WHEN_HINT = 'Date (vide = maintenant) — AAAA-MM-JJ ou AAAA-MM-JJ HH:MM';
async function addTrackerPoint(){
if(!TRACK_OPEN) return;
const text = await askPrompt('Valeur ou note du point :', {title:'Ajouter un point', placeholder:'ex. 78,2 kg', okText:'Suivant'});
if(!text || !text.trim()) return;
const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Ajouter'});
if(when === null) return;
if(!await trackerAction({action:'add', name:TRACK_OPEN.name, when, text}, 'point ajouté')) return;
openTracker(TRACK_OPEN.slug);
}
async function newTracker(){
const name = await askPrompt('Nom du tracker (ex. « poids », « abonnés ») :', {title:'Nouveau tracker', okText:'Suivant'});
if(!name || !name.trim()) return;
const text = await askPrompt('Premier point — valeur ou note :', {title:'Premier point', placeholder:'ex. 78,2 kg', okText:'Suivant'});
if(!text || !text.trim()) return;
const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', placeholder:'vide = maintenant', okText:'Créer'});
if(when === null) return;
// Un tracker naît de son premier point : pas de coquille vide à remplir plus tard.
await trackerAction({action:'add', name:name.trim(), when, text}, 'tracker créé');
}
async function editTrackerPoint(ev){
if(!TRACK_OPEN) return;
const text = await askPrompt('Valeur ou note :', {title:'Modifier le point', default:ev.text, okText:'Suivant'});
if(text === null) return;
const when = await askPrompt(TRACK_WHEN_HINT, {title:'Quand ?', default:'', placeholder:'vide = date inchangée', okText:'Enregistrer'});
if(when === null) return;
if(!await trackerAction({action:'edit', slug:TRACK_OPEN.slug, id:ev.id, text, when}, 'point modifié')) return;
openTracker(TRACK_OPEN.slug);
}
async function deleteTrackerPoint(ev){
if(!TRACK_OPEN) return;
if(!await askConfirm('Supprimer le point du ' + trackWhen(ev) + ' ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return;
if(!await trackerAction({action:'delete', slug:TRACK_OPEN.slug, id:ev.id}, 'point supprimé')) return;
openTracker(TRACK_OPEN.slug);
}
async function renameTracker(){
if(!TRACK_OPEN) return;
const name = await askPrompt('Nouveau nom :', {title:'Renommer le tracker', default:TRACK_OPEN.name, okText:'Renommer'});
if(!name || !name.trim() || name.trim() === TRACK_OPEN.name) return;
// Le slug dérive du nom : le serveur re-clé le tracker si nécessaire, sinon un
// point ajouté ensuite créerait un tracker parallèle sous l'ancien nom.
if(!await trackerAction({action:'rename', slug:TRACK_OPEN.slug, name:name.trim()}, 'renommé')) return;
closeTracker();
}
async function moveTracker(){
if(!TRACK_OPEN) return;
const others = (typeof PROJECTS !== 'undefined' ? PROJECTS : []).filter(p => p.slug !== PROJ_ACTIVE);
if(!others.length){ toast('aucun autre projet où déplacer'); return; }
const choice = await askPrompt(
'Déplacer « ' + TRACK_OPEN.name + ' » vers quel projet ?\n' + others.map(p => '· ' + p.name).join('\n'),
{title:'Déplacer le tracker', default:others[0].name, okText:'Déplacer'});
if(!choice) return;
const target = others.find(p => p.name.toLowerCase() === choice.trim().toLowerCase());
if(!target){ toast('projet inconnu : ' + choice); return; }
if(!await trackerAction({action:'move', slug:TRACK_OPEN.slug, to:target.slug}, 'déplacé vers ' + target.name)) return;
closeTracker();
if(typeof loadProjects === 'function') loadProjects();
}
async function deleteTracker(){
if(!TRACK_OPEN) return;
const ok = await askConfirm(
'Supprimer le tracker « ' + TRACK_OPEN.name + ' » et ses ' + TRACK_OPEN.count + ' point(s) ? Irréversible.',
{title:'Supprimer le tracker', okText:'Supprimer', danger:true});
if(!ok) return;
if(!await trackerAction({action:'delete_tracker', slug:TRACK_OPEN.slug}, 'tracker supprimé')) return;
closeTracker();
}
+10
View File
@@ -1631,6 +1631,16 @@ html[data-files="1"] #files-btn{color:var(--accent)}
/* --- Discussions ----------------------------------------------------- */
/* La liste EST la barre latérale : titre à gauche, heure à droite, une ligne.
Le reste (réglages) descend sous un unique repli. */
/* Ligne du projet actif : posée au-dessus des discussions, qu'elle contient.
Le sélecteur prend toute la largeur restante — un nom de projet doit être
lisible en entier, c'est le repère qui dit dans quel chantier on écrit. */
.projrow{display:flex;align-items:center;gap:6px;padding:10px 12px 8px}
.projrow>select{flex:1;min-width:0;font:inherit;font-size:12.5px;color:var(--text);
background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:6px 9px}
.projrow>select:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
/* Le « + » des sections est masqué par défaut et révélé par details[open] : hors
d'un <details>, il faut le rendre visible explicitement. */
.projrow>.preset-add{display:inline-block;margin:0}
#conv-search{width:calc(100% - 24px);font:inherit;font-size:12.5px;color:var(--text);
background:var(--panel);border:1px solid var(--border);border-radius:4px;
padding:7px 11px;margin:0 12px 8px}
+137
View File
@@ -0,0 +1,137 @@
package loki
// web_projects.go — l'API du hub des projets. Un seul endpoint verbe-orienté
// (/api/projects) plutôt qu'une route par action : le hub fait six choses sur le
// même objet, six routes n'auraient rien clarifié.
//
// Toutes les mutations passent par projects.go, qui porte les invariants (slug
// unique, dernier projet indestructible, réassignation de l'actif).
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// projectDTO est la vue d'un projet renvoyée à l'UI : le registre, plus ce qui
// se compte (discussions, pages, trackers) pour que le hub soit lisible sans
// ouvrir chaque projet.
type projectDTO struct {
Slug string `json:"slug"`
Name string `json:"name"`
Desc string `json:"desc,omitempty"`
Created int64 `json:"created"`
Convs int `json:"convs"`
Pages int `json:"pages"`
Trackers int `json:"trackers"`
Active bool `json:"active"`
}
func projectDTOs() []projectDTO {
active := activeProjectSlug()
// Un seul parcours de l'index des discussions pour tous les projets : compter
// projet par projet relirait la base autant de fois qu'il y a de projets.
convs := map[string]int{}
for _, m := range convIndex() {
convs[convProjectOf(m)]++
}
out := make([]projectDTO, 0, len(listProjects()))
for _, p := range listProjects() {
d := projectDTO{
Slug: p.Slug, Name: p.Name, Desc: p.Desc, Created: p.CreatedAt,
Convs: convs[p.Slug], Trackers: len(trackerKeysForProject(p.Slug)),
Active: p.Slug == active,
}
// Les pages se comptent dans le dossier du projet : on emprunte memoryDir
// le temps du comptage plutôt que de dupliquer la lecture du dossier.
// MEMORY.md est exclu — c'est l'index du projet, pas une page de contenu :
// l'annoncer ferait afficher « 1 page » sur un projet tout neuf et vide.
withProject(p.Slug, func() {
for _, m := range MemList() {
if !isIndexFile(m.Name) {
d.Pages++
}
}
})
out = append(out, d)
}
return out
}
// handleProjects :
//
// GET → {ok, projects, active}
// POST {action, ...} → create | rename | desc | delete | switch
func handleProjects(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()})
case http.MethodPost:
var body struct {
Action string `json:"action"`
Slug string `json:"slug"`
Name string `json:"name"`
Desc string `json:"desc"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
slug := strings.TrimSpace(body.Slug)
var err error
switch strings.ToLower(strings.TrimSpace(body.Action)) {
case "create":
_, err = createProject(body.Name)
case "rename":
err = renameProject(slug, body.Name)
case "desc":
err = setProjectDesc(slug, body.Desc)
case "delete":
// Une génération en cours travaille dans la mémoire du projet actif :
// supprimer sous ses pieds la laisserait écrire dans le vide.
if conv.isGenerating() {
sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"})
return
}
err = deleteProject(slug)
case "switch":
if conv.isGenerating() {
sendJSON(w, 409, map[string]any{"ok": false, "error": "une génération est en cours"})
return
}
err = projectSwitch(slug)
default:
err = fmt.Errorf("action inconnue : %q", body.Action)
}
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "projects": projectDTOs(), "active": activeProjectSlug()})
default:
sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode non autorisée"})
}
}
// handleMemMove déplace une page mémoire vers un autre projet. Séparé de
// /api/projects : c'est une opération sur une PAGE, pas sur un projet.
func handleMemMove(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode non autorisée"})
return
}
var body struct {
Name string `json:"name"`
To string `json:"to"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := moveMemPage(body.Name, activeProjectSlug(), strings.TrimSpace(body.To)); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
}
+11 -2
View File
@@ -99,6 +99,10 @@ func cmdWeb(args []string) error {
var convLoadOnce sync.Once
func newWebMux() *http.ServeMux {
// Projets : amorce « Générale » et migre l'existant (mémoire plate, discussions
// et tâches sans projet). AVANT le chargement de la conversation, qui consulte
// déjà le projet actif. Idempotent : sans effet aux démarrages suivants.
ensureDefaultProject()
// Charge l'état de conversation persisté (une fois par process : loki web ET
// loki link serve appellent newWebMux).
convLoadOnce.Do(LoadConversation)
@@ -168,8 +172,8 @@ func newWebMux() *http.ServeMux {
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
api("/api/ping", handlePing)
api("/api/status", handleStatus)
api("/api/transcribe", handleTranscribe) // dictée vocale (whisper.cpp local)
api("/api/dictate/config", handleDictateConfig) // réglages : modèle, langue, matériel, réactivité
api("/api/transcribe", handleTranscribe) // dictée vocale (Parakeet via sherpa-onnx, local)
api("/api/dictate/config", handleDictateConfig) // réglages : modèle et réactivité
api("/api/dictate/models", handleDictateModels) // catalogue + présence sur disque
api("/api/dictate/models/download", handleDictateDownload) // télécharge un modèle
api("/api/dictate/state", handleDictateState) // serveur allumé, modèle chargé, dernière erreur
@@ -267,6 +271,11 @@ func newWebMux() *http.ServeMux {
api("/api/mem", handleMem)
api("/api/mem/save", handleMemSave)
api("/api/mem/delete", handleMemDelete)
api("/api/mem/move", handleMemMove)
// Projets (mémoire, discussions et tâches cloisonnées) et trackers (3e type de
// mémoire) : un endpoint verbe-orienté chacun, cf. web_projects.go/web_tracker.go.
api("/api/projects", handleProjects)
api("/api/tracker", handleTracker)
api("/api/switch", handleSwitch)
api("/api/start", svcHandler("start"))
api("/api/stop", svcHandler("stop"))
+49 -3
View File
@@ -13,11 +13,57 @@ import (
"strings"
)
// readSysPrompt renvoie le prompt système personnalisé ("" si absent).
func readSysPrompt() string { return getStr(bkState, "sysprompt") }
// Le prompt était GLOBAL : un seul texte pour tous les modèles. En pratique il ne
// l'est pas — la consigne qui va bien à un modèle à raisonnement en dessert un
// petit modèle d'instruction, et changer de preset obligeait à réécrire le champ
// à la main. Il est donc rattaché AU PRESET, et bascule avec lui.
//
// Il vit en base (une clé par preset) et NON dans le fichier .env du preset :
// un .env est lu ligne à ligne, il ne peut pas porter un texte multiligne — et
// un prompt système en fait presque toujours plusieurs.
//
// La clé globale historique reste le REPLI : une installation qui avait un prompt
// le garde tant que le preset actif n'en définit pas, plutôt que de se retrouver
// silencieusement sans consigne après mise à jour.
const (
sysPromptGlobalKey = "sysprompt"
sysPromptKeyPrefix = "sysprompt:"
)
// activePresetID renvoie l'id du preset actif ("" si aucun ne correspond à la
// configuration courante — config bricolée à la main, ou aucun preset créé).
func activePresetID() string {
list, err := ListPresets()
if err != nil {
return ""
}
for _, p := range list {
if p.Active {
return p.ID
}
}
return ""
}
// readSysPrompt renvoie le prompt système à appliquer ("" si aucun).
func readSysPrompt() string {
if id := activePresetID(); id != "" {
if s := strings.TrimSpace(getStr(bkState, sysPromptKeyPrefix+id)); s != "" {
return s
}
}
return getStr(bkState, sysPromptGlobalKey)
}
// saveSysPrompt écrit le prompt système DU PRESET ACTIF. Sans preset identifiable,
// on écrit le global — c'est le seul emplacement qui aura un effet, et le champ
// doit rester utilisable sur une installation sans preset.
func saveSysPrompt(text string) error {
return putStr(bkState, "sysprompt", strings.TrimSpace(text))
text = strings.TrimSpace(text)
if id := activePresetID(); id != "" {
return putStr(bkState, sysPromptKeyPrefix+id, text)
}
return putStr(bkState, sysPromptGlobalKey, text)
}
// handleSysPrompt :
+134
View File
@@ -0,0 +1,134 @@
package loki
import (
"os"
"path/filepath"
"testing"
)
// writeTestPreset pose un preset minimal et renvoie son id. Le contenu doit
// différer d'un preset à l'autre : c'est l'empreinte du contenu qui décide
// lequel est « actif » (presetFingerprint).
func writeTestPreset(t *testing.T, id, model string) string {
t.Helper()
dir := presetsDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := "# NAME=" + id + "\nMODEL=" + model + "\nCTX=8192\n"
if err := os.WriteFile(filepath.Join(dir, id+".env"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return id
}
// Le prompt système appartient au PRESET : basculer de modèle doit changer la
// consigne, pas la traîner d'un modèle à l'autre. C'était le défaut du prompt
// global — une consigne écrite pour un modèle à raisonnement suivait un petit
// modèle d'instruction, qu'elle dessert.
func TestSysPromptSuitLePresetActif(t *testing.T) {
newProjectHome(t)
a := writeTestPreset(t, "modele-a", "/models/a.gguf")
b := writeTestPreset(t, "modele-b", "/models/b.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil {
t.Fatal(err)
}
if id := activePresetID(); id != a {
t.Fatalf("preset actif %q, attendu %q", id, a)
}
if err := saveSysPrompt("réponds en une phrase"); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "réponds en une phrase" {
t.Fatalf("prompt du preset A = %q", got)
}
if err := applyPresetFile(filepath.Join(presetsDir(), b+".env")); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "" {
t.Fatalf("le prompt du preset A a suivi jusqu'au preset B : %q", got)
}
if err := saveSysPrompt("tu es un assistant technique"); err != nil {
t.Fatal(err)
}
// Retour sur A : sa consigne doit être intacte.
if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "réponds en une phrase" {
t.Fatalf("le prompt du preset A n'a pas survécu à l'aller-retour : %q", got)
}
}
// Un prompt multiligne doit passer tel quel : c'est le cas courant, et c'est
// précisément ce qu'un fichier .env (lu ligne à ligne) ne saurait porter — d'où
// le stockage en base.
func TestSysPromptMultiligne(t *testing.T) {
newProjectHome(t)
id := writeTestPreset(t, "modele-a", "/models/a.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), id+".env")); err != nil {
t.Fatal(err)
}
prompt := "Tu es concis.\n\nRègles :\n- pas de préambule\n- du français"
if err := saveSysPrompt(prompt); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != prompt {
t.Fatalf("prompt multiligne altéré :\n%q", got)
}
}
// Repli : une installation sans preset identifiable (config bricolée à la main)
// doit garder un champ qui fonctionne, et retrouver son ancien prompt global.
func TestSysPromptReplieSurLeGlobal(t *testing.T) {
newProjectHome(t)
if id := activePresetID(); id != "" {
t.Fatalf("preset actif %q alors qu'aucun n'existe", id)
}
if err := saveSysPrompt("consigne globale"); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "consigne globale" {
t.Fatalf("prompt global = %q", got)
}
// Un preset apparaît et devient actif : tant qu'il n'a pas SA consigne, le
// global continue de s'appliquer — pas de perte silencieuse après migration.
id := writeTestPreset(t, "modele-a", "/models/a.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), id+".env")); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "consigne globale" {
t.Fatalf("le prompt global n'est plus le repli : %q", got)
}
}
// Supprimer un preset doit emporter sa consigne : sinon elle ressuscite sur un
// preset recréé plus tard sous le même identifiant.
func TestDeletePresetEmporteSonSysPrompt(t *testing.T) {
newProjectHome(t)
a := writeTestPreset(t, "modele-a", "/models/a.gguf")
b := writeTestPreset(t, "modele-b", "/models/b.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil {
t.Fatal(err)
}
if err := saveSysPrompt("consigne du A"); err != nil {
t.Fatal(err)
}
// On quitte A : un preset actif n'est pas supprimable.
if err := applyPresetFile(filepath.Join(presetsDir(), b+".env")); err != nil {
t.Fatal(err)
}
if err := DeletePreset(a); err != nil {
t.Fatal(err)
}
writeTestPreset(t, a, "/models/a.gguf")
if err := applyPresetFile(filepath.Join(presetsDir(), a+".env")); err != nil {
t.Fatal(err)
}
if got := readSysPrompt(); got != "" {
t.Fatalf("la consigne d'un preset supprimé est revenue : %q", got)
}
}
+11
View File
@@ -35,6 +35,8 @@ func handleTasks(w http.ResponseWriter, r *http.Request) {
"agent": agentEnabled(),
"running_id": runningID,
"presets": presets,
// Projets, pour le sélecteur « projet visé » du formulaire de tâche.
"projects": projectDTOs(),
// État global mémoire/web, pour proposer des défauts cohérents à la création.
"mem_on": memMode() != MemOff,
"web_on": internetEnabled() && crawlReachable(),
@@ -51,6 +53,7 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) {
Schedule string `json:"schedule"`
TZ string `json:"tz"`
Preset string `json:"preset"`
Project string `json:"project"`
NoMem bool `json:"no_mem"`
NoWeb bool `json:"no_web"`
Enabled bool `json:"enabled"`
@@ -84,6 +87,14 @@ func handleTaskSave(w http.ResponseWriter, r *http.Request) {
}
t.Name, t.Prompt, t.Enabled = req.Name, req.Prompt, req.Enabled
t.TZ, t.Preset = req.TZ, req.Preset
// Projet visé : vide = le projet actif au moment de l'enregistrement. Une tâche
// créée depuis un projet appartient à ce projet — c'est là que sa mémoire et
// ses trackers l'attendent quand elle tournera (voir runTask).
if p := strings.TrimSpace(req.Project); p != "" && projectExists(p) {
t.Project = p
} else if t.Project == "" {
t.Project = activeProjectSlug()
}
t.NoMem, t.NoWeb = req.NoMem, req.NoWeb
// Recalcule NextRun si la fréquence a changé (ou à la création).
if t.Schedule != req.Schedule || t.NextRun == 0 {
+107
View File
@@ -0,0 +1,107 @@
package loki
// web_tracker.go — l'API du panneau des trackers. Même forme que le hub des
// projets : un endpoint verbe-orienté, les invariants restant dans tracker.go.
//
// Différence avec la vue MODÈLE (trackerView) : l'UI reçoit des données
// structurées, pas de la prose. Le modèle a besoin d'un texte qui lui dit comment
// zoomer ; un panneau, lui, sait afficher une liste.
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// trackerDTO : un tracker pour le panneau. Events n'est rempli que sur le détail
// d'un tracker — la liste ne charge jamais les points, sans quoi ouvrir le
// panneau lirait tout l'historique de tous les trackers.
type trackerDTO 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"`
Events []TrackerEvent `json:"events,omitempty"`
}
func trackerDTOs() []trackerDTO {
list := trackerList()
out := make([]trackerDTO, 0, len(list))
for _, m := range list {
out = append(out, trackerDTO{
Slug: m.Slug, Name: m.Name, Count: m.Count,
FirstTS: m.FirstTS, LastTS: m.LastTS, LastText: m.LastText,
})
}
return out
}
// handleTracker :
//
// GET → {ok, trackers} (liste, sans les points)
// GET ?slug=x → {ok, tracker} (détail, avec ses points)
// POST {action…} → add | edit | delete | delete_tracker | rename | move
func handleTracker(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
slug := strings.TrimSpace(r.URL.Query().Get("slug"))
if slug == "" {
sendJSON(w, 200, map[string]any{"ok": true, "trackers": trackerDTOs()})
return
}
s, ok := trackerLoad(slug)
if !ok {
sendJSON(w, 404, map[string]any{"ok": false, "error": "tracker introuvable"})
return
}
d := trackerDTO{Slug: slug, Name: s.Name, Count: len(s.Events), Events: s.Events}
if n := len(s.Events); n > 0 {
d.FirstTS = s.Events[0].TS
d.LastTS = s.Events[n-1].TS
d.LastText = s.Events[n-1].Text
}
sendJSON(w, 200, map[string]any{"ok": true, "tracker": d})
case http.MethodPost:
var body struct {
Action string `json:"action"`
Slug string `json:"slug"`
Name string `json:"name"`
When string `json:"when"`
Text string `json:"text"`
ID string `json:"id"`
To string `json:"to"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
slug := strings.TrimSpace(body.Slug)
var err error
switch strings.ToLower(strings.TrimSpace(body.Action)) {
case "add":
_, err = trackerAdd(body.Name, body.When, body.Text)
case "edit":
err = trackerEditEvent(slug, body.ID, body.When, body.Text)
case "delete":
err = trackerDeleteEvent(slug, body.ID)
case "delete_tracker":
err = trackerDelete(slug)
case "rename":
err = trackerRename(slug, body.Name)
case "move":
err = trackerMoveToProject(slug, strings.TrimSpace(body.To))
default:
err = fmt.Errorf("action inconnue : %q", body.Action)
}
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true, "trackers": trackerDTOs()})
default:
sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode non autorisée"})
}
}
+60 -69
View File
@@ -1,12 +1,11 @@
// Dictée vocale — POST /api/transcribe : un WAV (16 kHz mono, encodé côté
// navigateur) entre, le texte transcrit sort. La transcription est 100 % locale,
// par whisper.cpp. Le modèle est téléchargé au premier usage dans
// LOKI_HOME/whisper/ — comme le moteur, il ne gonfle pas l'image et survit aux
// recréations du conteneur via /data.
// par Parakeet (NVIDIA) servi par sherpa-onnx. Le modèle est téléchargé au
// premier usage dans LOKI_HOME/asr/ — comme le moteur, il ne gonfle pas l'image
// et survit aux recréations du conteneur via /data.
//
// La transcription elle-même passe par whisper-server (dictate_server.go), qui
// garde le modèle chargé. L'ancien chemin relançait whisper-cli à chaque
// requête, donc rechargeait le modèle à chaque phrase dictée.
// La transcription elle-même passe par le serveur de dictée (dictate_server.go),
// qui garde le modèle chargé entre deux phrases.
package loki
import (
@@ -35,19 +34,19 @@ var wspDl struct {
err string
}
func whisperStartDownload(id string) {
func asrStartDownload(id string) {
wspMu.Lock()
defer wspMu.Unlock()
if wspDl.running {
return
}
if whisperModelURLFor(id) == "" {
if asrModelURLFor(id) == "" {
wspDl.err = "modèle de dictée inconnu : " + id
return
}
wspDl.id, wspDl.running, wspDl.pct, wspDl.err = id, true, 0, ""
go func() {
err := whisperDownload(id)
err := asrDownload(id)
wspMu.Lock()
wspDl.running = false
if err != nil {
@@ -57,65 +56,57 @@ func whisperStartDownload(id string) {
}()
}
func whisperDownload(id string) error {
dst := whisperModelPathFor(id)
// asrDownload télécharge et DÉCOMPRESSE un modèle. Un modèle Parakeet est une
// archive .tar.bz2 qui contient un dossier de quatre fichiers, là où whisper.cpp
// livrait un .bin unique : on décompresse donc au fil du téléchargement plutôt
// que de garder l'archive sur le disque (près de 500 Mo pour rien).
//
// La progression est mesurée sur les octets REÇUS, pas sur les octets écrits :
// c'est le téléchargement qui prend le temps, la décompression suit.
func asrDownload(id string) error {
dst := asrModelDirFor(id)
if dst == "" {
return fmt.Errorf("modèle de dictée inconnu : %s", id)
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
resp, err := http.Get(whisperModelURLFor(id))
resp, err := http.Get(asrModelURLFor(id))
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != 200 {
return fmt.Errorf("téléchargement du modèle whisper : HTTP %d", resp.StatusCode)
return fmt.Errorf("téléchargement du modèle de dictée : HTTP %d", resp.StatusCode)
}
// Écriture dans un .part renommé à la fin : un téléchargement interrompu
// ne doit pas laisser un .bin tronqué que whisperModelPresent croirait bon.
tmp := dst + ".part"
f, err := os.Create(tmp)
if err != nil {
return err
}
total := resp.ContentLength
var done int64
buf := make([]byte, 1<<20)
for {
n, rerr := resp.Body.Read(buf)
if n > 0 {
if _, werr := f.Write(buf[:n]); werr != nil {
_ = f.Close()
_ = os.Remove(tmp)
return werr
}
done += int64(n)
if total > 0 {
wspMu.Lock()
wspDl.pct = int(done * 100 / total)
wspMu.Unlock()
}
}
if rerr == io.EOF {
break
}
if rerr != nil {
_ = f.Close()
_ = os.Remove(tmp)
return rerr
}
}
if err := f.Close(); err != nil {
_ = os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
// asrExtract écrit dans un dossier .part renommé à la fin : une extraction
// interrompue ne laisse jamais un modèle à moitié installé sous son nom
// définitif, que asrModelPresent croirait bon.
return asrExtract(&asrProgress{r: resp.Body, total: resp.ContentLength}, dst)
}
// whisperDlEtat : instantané de la progression, pour l'UI et pour les 503.
func whisperDlEtat() (id string, running bool, pct int, errMsg string) {
// asrProgress compte les octets lus et publie l'avancement. Enveloppe le corps
// de la réponse pour que la barre bouge pendant la décompression, qui consomme
// le flux à son rythme.
type asrProgress struct {
r io.Reader
total int64
lus int64
}
func (p *asrProgress) Read(b []byte) (int, error) {
n, err := p.r.Read(b)
if n > 0 && p.total > 0 {
p.lus += int64(n)
wspMu.Lock()
wspDl.pct = int(p.lus * 100 / p.total)
wspMu.Unlock()
}
return n, err
}
// asrDlEtat : instantané de la progression, pour l'UI et pour les 503.
func asrDlEtat() (id string, running bool, pct int, errMsg string) {
wspMu.Lock()
defer wspMu.Unlock()
return wspDl.id, wspDl.running, wspDl.pct, wspDl.err
@@ -128,18 +119,18 @@ func handleTranscribe(w http.ResponseWriter, r *http.Request) {
return
}
cfg := dictateCfgLoad()
if !whisperModelPresent(cfg.Model) {
_, _, pct, derr := whisperDlEtat()
if !asrModelPresent(cfg.Model) {
_, _, pct, derr := asrDlEtat()
if derr != "" {
// Échec précédent : on le dit ET on relance — un réseau revenu suffit.
wspMu.Lock()
wspDl.err = ""
wspMu.Unlock()
whisperStartDownload(cfg.Model)
asrStartDownload(cfg.Model)
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
return
}
whisperStartDownload(cfg.Model)
asrStartDownload(cfg.Model)
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
return
}
@@ -147,7 +138,7 @@ func handleTranscribe(w http.ResponseWriter, r *http.Request) {
// transcription elle-même ne l'est pas.
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
defer cancel()
txt, err := whisperInfer(ctx, audio)
txt, err := asrInfer(ctx, audio)
if err != nil {
sendJSON(w, 500, map[string]any{"error": err.Error()})
return
@@ -170,7 +161,7 @@ func handleDictateConfig(w http.ResponseWriter, r *http.Request) {
}
// Sans ça, le serveur continuerait de tourner avec l'ancien modèle et
// le nouveau réglage semblerait sans effet.
whisperShutdown()
asrShutdown()
sendJSON(w, 200, map[string]any{"ok": true})
return
}
@@ -178,7 +169,7 @@ func handleDictateConfig(w http.ResponseWriter, r *http.Request) {
}
func handleDictateModels(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, whisperCatalogueTrie())
sendJSON(w, 200, asrCatalogueTrie())
}
func handleDictateDownload(w http.ResponseWriter, r *http.Request) {
@@ -191,28 +182,28 @@ func handleDictateDownload(w http.ResponseWriter, r *http.Request) {
return
}
id := strings.TrimSpace(req.ID)
if whisperModelURLFor(id) == "" {
if asrModelURLFor(id) == "" {
sendJSON(w, 400, map[string]any{"error": "modèle de dictée inconnu : " + id})
return
}
whisperStartDownload(id)
asrStartDownload(id)
}
id, running, pct, errMsg := whisperDlEtat()
id, running, pct, errMsg := asrDlEtat()
sendJSON(w, 200, map[string]any{"id": id, "running": running, "pct": pct, "error": errMsg})
}
func handleDictateState(w http.ResponseWriter, r *http.Request) {
etat := whisperEtat()
id, running, pct, errMsg := whisperDlEtat()
etat := asrEtat()
id, running, pct, errMsg := asrDlEtat()
etat["dl"] = map[string]any{"id": id, "running": running, "pct": pct, "error": errMsg}
sendJSON(w, 200, etat)
}
// whisperExitReason traduit la fin du processus en une phrase utilisable.
// asrExitReason traduit la fin du processus en une phrase utilisable.
// ProcessState.String() dit déjà « exit status 2 » ou « signal: illegal
// instruction » ; le second cas mérite son explication, parce que rien dans la
// dictée ne laisse deviner que le binaire ne tourne pas sur ce processeur.
func whisperExitReason(err error) string {
func asrExitReason(err error) string {
var ee *exec.ExitError
if !errors.As(err, &ee) {
if err == nil {
@@ -227,7 +218,7 @@ func whisperExitReason(err error) string {
return st
}
// lastLine : la dernière ligne non vide. whisper bavarde beaucoup avant de
// lastLine : la dernière ligne non vide. Le serveur de dictée bavarde avant de
// tomber ; seule la fin renseigne, et un pavé ne tient pas dans un bandeau.
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
+72 -156
View File
@@ -3,189 +3,105 @@ package loki
import (
"os"
"regexp"
"strconv"
"strings"
"testing"
)
// etapesWhisperbuild découpe le Dockerfile en étapes « FROM … AS whisperbuild* »
// et rend le texte de chacune.
func etapesWhisperbuild(t *testing.T) map[string]string {
func dockerfileSrc(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
out := map[string]string{}
for _, bloc := range strings.Split(string(b), "\nFROM ") {
entete, reste, ok := strings.Cut(bloc, "\n")
if !ok {
continue
}
_, nom, ok := strings.Cut(entete, " AS ")
if !ok || !strings.HasPrefix(strings.TrimSpace(nom), "whisperbuild") {
continue
}
out[strings.TrimSpace(nom)] = entete + "\n" + reste
}
return out
return string(b)
}
// argWhisperFlags rend la valeur de l'ARG WHISPER_CMAKE_FLAGS, continuations
// de ligne comprises. Vide si l'ARG n'existe pas : les étapes devront alors
// porter les drapeaux en clair, et le test le vérifiera.
func argWhisperFlags(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
// Le binaire de dictée est TÉLÉCHARGÉ, plus compilé. Ce qui remplace les
// garde-fous de compilation d'avant (-march=native, SIGILL en production) est la
// vérification d'empreinte : sans elle, une release remplacée en amont ferait
// tourner un binaire inconnu sur la machine de l'utilisateur, en silence.
func TestDockerfileASRVerifieLEmpreinte(t *testing.T) {
src := dockerfileSrc(t)
if !strings.Contains(src, "sha256sum -c -") {
t.Error("l'archive sherpa-onnx est extraite sans vérification d'empreinte")
}
_, apres, ok := strings.Cut(string(b), "ARG WHISPER_CMAKE_FLAGS=")
if !ok {
return ""
m := regexp.MustCompile(`(?m)^ARG SHERPA_ONNX_SHA256=([0-9a-f]{64})\s*$`).FindStringSubmatch(src)
if m == nil {
t.Fatal("ARG SHERPA_ONNX_SHA256 absent ou pas un sha256 de 64 caractères")
}
var val strings.Builder
for _, ligne := range strings.Split(apres, "\n") {
val.WriteString(" " + ligne)
if !strings.HasSuffix(strings.TrimSpace(ligne), "\\") {
break
}
}
return val.String()
}
// La dictée est morte en production parce que whisper avait été compilé avec
// -march=native, donc pour le processeur du runner GitHub (AVX-512, AMX) et pas
// pour la machine qui fait tourner l'image : SIGILL en pleine transcription.
// Les drapeaux qui l'évitent ne se voient pas à l'exécution — rien ne les
// rappelle au prochain qui touchera cette étape de build. Ce test le fait, sur
// CHAQUE étape, y compris celle ajoutée pour CUDA.
func TestDockerfileWhisperNonNatif(t *testing.T) {
etapes := etapesWhisperbuild(t)
if len(etapes) < 2 {
t.Fatalf("%d étape(s) whisperbuild, 2 attendues (CPU et CUDA) : %v", len(etapes), clefs(etapes))
}
// Les drapeaux communs sont factorisés dans WHISPER_CMAKE_FLAGS. Une étape
// est conforme si elle les porte en clair OU si elle référence cet ARG —
// ce qui compte est qu'ils atteignent cmake, pas qu'ils soient recopiés.
commun := argWhisperFlags(t)
for nom, txt := range etapes {
effectif := txt
if strings.Contains(txt, "${WHISPER_CMAKE_FLAGS}") {
effectif += " " + commun
}
for _, drapeau := range []string{"-DGGML_NATIVE=OFF", "-DGGML_AMX_TILE=OFF", "-DGGML_AVX512=OFF"} {
if !strings.Contains(effectif, drapeau) {
t.Errorf("étape %s : %s absent — le binaire sera compilé pour le processeur du runner et mourra d'un SIGILL ailleurs", nom, drapeau)
}
}
if !strings.Contains(txt, "whisper-server") {
t.Errorf("étape %s : ne construit pas la cible whisper-server", nom)
}
// « -j » nu autorise un parallélisme ILLIMITÉ chez Make. Sur l'étape
// CUDA (~200 nvcc à 1-2 Go pièce), le runner GitHub était tué par
// l'OOM sans écrire une ligne d'erreur. L'étape CPU y survivait, ce
// qui rendait le piège invisible.
for _, ligne := range strings.Split(txt, "\n") {
if !strings.Contains(ligne, "cmake --build") {
continue
}
if regexp.MustCompile(`-j(\s|$|\\)`).MatchString(ligne) {
t.Errorf("étape %s : « cmake --build -j » sans nombre — parallélisme illimité, le runner sera tué par l'OOM. Utiliser -j\"$(nproc)\".", nom)
}
}
if !strings.Contains(src, "${SHERPA_ONNX_SHA256}") {
t.Error("l'empreinte est déclarée mais jamais utilisée")
}
}
// Deux binaires, pas un : sur une image runtime bâtie sans CUDA, 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 le programme.
func TestDockerfileDeuxBinairesWhisper(t *testing.T) {
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
// La version doit être ÉPINGLÉE. « latest » ferait changer le binaire de dictée
// sous les pieds de l'utilisateur au prochain build, sans qu'une seule ligne du
// dépôt ait bougé — et l'empreinte figée ci-dessus casserait le build.
func TestDockerfileASRVersionEpinglee(t *testing.T) {
src := dockerfileSrc(t)
m := regexp.MustCompile(`(?m)^ARG SHERPA_ONNX_VERSION=(\S+)\s*$`).FindStringSubmatch(src)
if m == nil {
t.Fatal("ARG SHERPA_ONNX_VERSION absent")
}
src := string(b)
var avecCuda bool
for nom, txt := range etapesWhisperbuild(t) {
if strings.Contains(txt, "-DGGML_CUDA=ON") {
avecCuda = true
_ = nom
}
}
if !avecCuda {
t.Error("aucune étape whisperbuild ne passe -DGGML_CUDA=ON : la dictée ne pourra jamais utiliser le GPU")
}
for _, bin := range []string{"whisper-server-cpu", "whisper-server-cuda"} {
if !strings.Contains(src, "/usr/local/bin/"+bin) {
t.Errorf("le runtime ne reçoit pas %s — dictate_server.go le cherche à cet emplacement", bin)
}
if !regexp.MustCompile(`^v\d+\.\d+\.\d+$`).MatchString(m[1]) {
t.Errorf("version de sherp-onnx non épinglée : %q", m[1])
}
}
// L'étape CUDA doit être bâtie avec un nvcc qui CONNAÎT les GPU visés. CUDA
// 12.4 ignore Blackwell (RTX 50xx, sm_120) : il refuse l'architecture, et le
// binaire ne tournerait au mieux que par recompilation PTX au chargement.
// 12.8 est le plancher, et c'est aussi la version avec laquelle llama.cpp bâtit
// l'image amont qui fournit libcudart.
func TestDockerfileCudaAssezRecent(t *testing.T) {
const majeurMin, mineurMin = 12, 8
trouve := false
for nom, txt := range etapesWhisperbuild(t) {
m := regexp.MustCompile(`nvidia/cuda:(\d+)\.(\d+)`).FindStringSubmatch(txt)
if m == nil {
continue
}
trouve = true
maj, _ := strconv.Atoi(m[1])
min, _ := strconv.Atoi(m[2])
if maj < majeurMin || (maj == majeurMin && min < mineurMin) {
t.Errorf("étape %s : CUDA %s.%s — trop ancien pour Blackwell (sm_120), il faut au moins %d.%d",
nom, m[1], m[2], majeurMin, mineurMin)
}
// Sans borne d'architectures, ggml compile de Maxwell à Blackwell :
// chaque architecture multiplie le temps de compilation, et le build
// dépassait quarante minutes.
if !strings.Contains(txt, "CMAKE_CUDA_ARCHITECTURES") {
t.Errorf("étape %s : aucune borne CMAKE_CUDA_ARCHITECTURES — la compilation vise toutes les architectures connues", nom)
}
}
if !trouve {
t.Error("aucune étape whisperbuild ne part d'une image nvidia/cuda")
}
}
// L'ARG qui porte les drapeaux doit être déclaré AVANT le premier FROM. Un ARG
// posé entre deux étapes appartient à l'étape où il apparaît ; les « ARG » nus
// des étapes whisperbuild-* héritent alors du scope global — vide. cmake a
// tourné sans aucun drapeau : ggml en bibliothèques partagées (échec de lien),
// et -march=native — le SIGILL de la PR #18 revenu en silence. 51 minutes de
// build pour le découvrir.
// Régression (héritée de whisper) : un ARG déclaré APRÈS le premier FROM
// n'appartient qu'à l'étape où il apparaît. Les « ARG SHERPA_* » nus de l'étape
// asrfetch hériteraient alors d'une valeur vide — URL tronquée, build cassé, et
// la cause invisible dans le journal.
func TestDockerfileArgFlagsGlobal(t *testing.T) {
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
src := string(b)
src := dockerfileSrc(t)
fromLoc := regexp.MustCompile(`(?m)^FROM `).FindStringIndex(src)
// Ancré en début de ligne : une occurrence dans un commentaire ne compte
// pas — c'est précisément ainsi qu'une première version de ce test s'est
// laissée berner par sa propre contre-épreuve.
argLoc := regexp.MustCompile(`(?m)^ARG WHISPER_CMAKE_FLAGS=`).FindStringIndex(src)
fromLoc := regexp.MustCompile(`(?m)^FROM `).FindStringIndex(src)
if argLoc == nil {
t.Fatal("ARG WHISPER_CMAKE_FLAGS= introuvable dans le Dockerfile")
}
if fromLoc != nil && argLoc[0] > fromLoc[0] {
t.Error("ARG WHISPER_CMAKE_FLAGS= est déclaré APRÈS un FROM : les étapes whisperbuild-* hériteront d'une valeur vide et cmake tournera sans aucun drapeau")
for _, arg := range []string{"SHERPA_ONNX_VERSION", "SHERPA_ONNX_SHA256"} {
loc := regexp.MustCompile(`(?m)^ARG ` + arg + `=`).FindStringIndex(src)
if loc == nil {
t.Errorf("ARG %s= introuvable dans le Dockerfile", arg)
continue
}
if fromLoc != nil && loc[0] > fromLoc[0] {
t.Errorf("ARG %s= est déclaré APRÈS un FROM : l'étape asrfetch héritera d'une valeur vide", arg)
}
}
}
func clefs(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
// Le chemin du binaire est lu depuis LOKI_ASR_SERVER (asrServerBin) : l'image
// doit poser la variable ET copier le binaire à cette adresse, sinon la dictée
// échoue au premier clic sur le micro avec « serveur de dictée introuvable ».
func TestDockerfileASRBinaireEtVariableConcordent(t *testing.T) {
src := dockerfileSrc(t)
const chemin = "/usr/local/bin/sherpa-onnx-offline-websocket-server"
if !strings.Contains(src, "COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server "+chemin) {
t.Errorf("le binaire de dictée n'est pas copié vers %s", chemin)
}
if !strings.Contains(src, "LOKI_ASR_SERVER="+chemin) {
t.Errorf("LOKI_ASR_SERVER ne pointe pas sur %s", chemin)
}
}
// whisper.cpp est parti : plus aucune étape ne doit le compiler, et aucune
// variable ne doit prétendre le trouver. Un reste ferait rebâtir 35 minutes de
// CUDA pour un binaire que plus personne n'exécute.
func TestDockerfileSansWhisper(t *testing.T) {
src := dockerfileSrc(t)
for _, motif := range []string{"whisperbuild", "whisper-server", "LOKI_WHISPER_SERVER", "WHISPER_CMAKE_FLAGS"} {
// Les commentaires ont le droit de mentionner l'histoire ; les
// instructions, non. On ne regarde donc que les lignes actives.
for _, ligne := range strings.Split(src, "\n") {
l := strings.TrimSpace(ligne)
if l == "" || strings.HasPrefix(l, "#") {
continue
}
if strings.Contains(l, motif) {
t.Errorf("reste de whisper dans une instruction du Dockerfile (%s) : %s", motif, l)
}
}
}
return out
}
func TestLastLine(t *testing.T) {
@@ -193,7 +109,7 @@ func TestLastLine(t *testing.T) {
{"", ""},
{" \n\n ", ""},
{"une seule ligne", "une seule ligne"},
{"AMX is not ready to be used!\nread_audio_data: ...\n", "read_audio_data: ..."},
{"erreur fatale\nderniere ligne utile\n", "derniere ligne utile"},
{"fin utile\n\n \n", "fin utile"},
}
for _, c := range cas {
+1 -1
View File
@@ -198,7 +198,7 @@ func handleVramUnload(w http.ResponseWriter, r *http.Request) {
// « vite » : ne jamais attendre derrière un démarrage de la dictée, qui
// peut tenir le verrou deux minutes — le navigateur aurait abandonné bien
// avant, moteur pourtant déjà arrêté.
whisperShutdownVite()
asrShutdownVite()
after := before
if gpus != nil {
+33
View File
@@ -36,6 +36,13 @@ const (
maxTimeoutSec = 300
maxOutput = 8000 // caractères de stdout/stderr renvoyés
readMax = 100000 // caractères max d'une lecture de fichier
// nodePingInterval : cadence du keepalive WebSocket. 25 s tient sous les délais
// d'inactivité usuels des proxys et relais (~60-100 s). Doit rester alignée sur
// la constante homonyme côté serveur (node_server.go).
nodePingInterval = 25 * time.Second
// nodePingTimeout : au-delà, le serveur ne répond plus — on ferme, et Run
// reconnecte proprement au lieu de garder une connexion morte.
nodePingTimeout = 10 * time.Second
)
// Config persiste l'appairage et les préférences locales du poste.
@@ -245,6 +252,32 @@ func session(ctx context.Context, cfg Config, quiet bool) error {
if !quiet {
fmt.Printf("[node] connecté ✓ (canal chiffré, en attente des demandes de l'IA)\n")
}
// Keepalive : un ping WebSocket périodique empêche les intermédiaires (relais,
// Caddy, Cloudflare) de couper la connexion quand l'IA ne sollicite pas le
// poste. Sans lui, un canal inactif tombait au bout de ~60-100 s, la session se
// terminait et Run reconnectait après backoff — d'où les déconnexions à
// répétition sur tous les postes. coder/websocket sérialise Ping avec les
// écritures de données : pas de course avec sendEnc.
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
}
}
}
}()
for {
var fr nodewire.Frame
if err := wsjson.Read(ctx, c, &fr); err != nil {