mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 23:18:13 +02:00
Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d4f5378c0 | ||
|
|
9d2ff33943 | ||
|
|
475d523e4a | ||
|
|
3edd356f34 | ||
|
|
dfdee26e4c | ||
|
|
cfe70f2b70 | ||
|
|
b4f4135101 | ||
|
|
6f6a3218ad | ||
|
|
78c258ab45 | ||
|
|
33de4a1144 | ||
|
|
009b585e75 | ||
|
|
895aa67366 | ||
|
|
08eaf10a34 | ||
|
|
e177d54e39 | ||
|
|
66b53c8255 | ||
|
|
3991e68fc9 | ||
|
|
cc218ccfd7 | ||
|
|
d8ed86bbba | ||
|
|
748328db25 | ||
|
|
8e88a73d91 | ||
|
|
f4969de9f7 | ||
|
|
5d391c4f73 | ||
|
|
3cd4facb0e | ||
|
|
3624194b39 | ||
|
|
2fa05fdaf7 | ||
|
|
e0ed0a32af | ||
|
|
a19b490b6a | ||
|
|
393986faa6 |
No files matched your search
@@ -16,6 +16,17 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Depuis que l'image compile whisper.cpp avec CUDA, le build empile
|
||||
# nvidia/cuda:*-devel (~6 Go), l'image runtime CUDA de llama.cpp (~4 Go)
|
||||
# et Chromium/Playwright (~1 Go) — pour ~14 Go libres sur un runner neuf.
|
||||
# Ces trois répertoires ne servent à rien ici et rendent une dizaine de
|
||||
# gigaoctets en moins d'une minute.
|
||||
- name: Libérer de l'espace disque
|
||||
run: |
|
||||
df -h /
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
df -h /
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
@@ -45,5 +56,8 @@ jobs:
|
||||
LOKI_VERSION=${{ github.sha }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
# Le cache de couches réutilise 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.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+86
-13
@@ -14,6 +14,18 @@
|
||||
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
|
||||
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
|
||||
|
||||
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
|
||||
# appartient à l'étape où il apparaît ; les « ARG 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"
|
||||
|
||||
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
|
||||
FROM golang:1.25 AS gobuild
|
||||
WORKDIR /src
|
||||
@@ -31,19 +43,77 @@ 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-cli (dictée vocale) ───────────────────────────
|
||||
# whisper.cpp compilé CPU seul, en statique : un binaire unique, aucune .so à
|
||||
# trimballer, et pas de compilation CUDA (des minutes de build pour un gain
|
||||
# nul sur des dictées de quelques secondes). Ubuntu 22.04 : glibc plus
|
||||
# ancienne que l'image runtime, donc compatible quoi qu'elle embarque.
|
||||
FROM ubuntu:22.04 AS whisperbuild
|
||||
# ── Étape 1 bis : whisper-server (dictée vocale) ────────────────────────
|
||||
# DEUX binaires sont construits, pas un. LLAMACPP_IMAGE accepte la variante
|
||||
# CPU de l'image amont (voir ligne 11) : sur cette base, les bibliothèques
|
||||
# CUDA sont absentes et un binaire lié à CUDA ne démarre pas du tout —
|
||||
# l'éditeur de liens échoue avant la première instruction, donc aucun repli
|
||||
# n'est possible depuis l'intérieur du programme. Loki choisit à l'exécution
|
||||
# (whisperServerBin, dictate_server.go).
|
||||
#
|
||||
# whisper-server et non whisper-cli : le modèle reste chargé entre deux
|
||||
# dictées. L'ancien chemin le relisait depuis le disque à chaque phrase, ce
|
||||
# qui dominait le temps de réponse.
|
||||
#
|
||||
# ⚠️ GGML_NATIVE=OFF est OBLIGATOIRE sur LES DEUX cibles. Par défaut ggml
|
||||
# compile en -march=native, c'est-à-dire pour le processeur DU RUNNER DE
|
||||
# BUILD — un Xeon récent chez GitHub, avec AVX-512 et AMX. Le binaire partait
|
||||
# alors sur une machine qui n'a pas ces instructions et mourait d'un SIGILL en
|
||||
# pleine transcription : « AMX is not ready to be used! », puis plus rien,
|
||||
# l'interface affichant un échec sans raison. OFF retombe sur la ligne de base
|
||||
# AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis 2013.
|
||||
# Ubuntu 22.04 : glibc plus ancienne que l'image runtime, donc compatible quoi
|
||||
# qu'elle embarque.
|
||||
FROM ubuntu:22.04 AS whisperbuild-cpu
|
||||
ARG WHISPER_CMAKE_FLAGS
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# 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 -DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_TESTS=OFF \
|
||||
&& cmake --build /w/build -j --target whisper-cli
|
||||
&& 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" )
|
||||
|
||||
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
||||
FROM ${LLAMACPP_IMAGE} AS runtime
|
||||
@@ -98,9 +168,11 @@ 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). Le modèle (~190 Mo) n'est PAS dans
|
||||
# l'image : téléchargé au premier usage dans /data/whisper/.
|
||||
COPY --from=whisperbuild /w/build/bin/whisper-cli /usr/local/bin/whisper-cli
|
||||
# Dictée vocale (POST /api/transcribe). Les modèles (190 Mo à 1,1 Go) ne sont
|
||||
# PAS dans l'image : téléchargés à la demande dans /data/whisper/.
|
||||
# Deux binaires : voir l'étape whisperbuild-cpu pour la raison.
|
||||
COPY --from=whisperbuild-cpu /w/build/bin/whisper-server /usr/local/bin/whisper-server-cpu
|
||||
COPY --from=whisperbuild-cuda /w/build/bin/whisper-server /usr/local/bin/whisper-server-cuda
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
||||
|
||||
@@ -109,7 +181,8 @@ ENV LOKI_CONTAINER=1 \
|
||||
LOKI_HOME=/data \
|
||||
LOKI_MODEL_DIRS=/models \
|
||||
LOKI_ENGINE_BIN=/app/llama-server \
|
||||
LOKI_WHISPER_BIN=/usr/local/bin/whisper-cli \
|
||||
LOKI_WHISPER_SERVER_CPU=/usr/local/bin/whisper-server-cpu \
|
||||
LOKI_WHISPER_SERVER_CUDA=/usr/local/bin/whisper-server-cuda \
|
||||
LD_LIBRARY_PATH=/app \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
@@ -290,6 +290,12 @@ Ajoutées par ce fork :
|
||||
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
|
||||
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
|
||||
visibles en pied de colonne.
|
||||
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
|
||||
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
|
||||
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
|
||||
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
|
||||
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
|
||||
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
|
||||
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
|
||||
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
|
||||
du moteur. Conséquence directe : l'API est joignable partout où l'interface
|
||||
|
||||
+19
-1
@@ -18,7 +18,25 @@ fi
|
||||
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
|
||||
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
|
||||
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
|
||||
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
|
||||
#
|
||||
# SAUF si l'utilisateur a mis le moteur à jour depuis l'interface : celui-là
|
||||
# vit sous $LOKI_HOME/engine/<version>/ (volume de données, il survit au
|
||||
# conteneur) et c'est lui qu'il a choisi. Le reposer sur /app à chaque
|
||||
# redémarrage ramenait silencieusement l'ancien moteur — un modèle qui
|
||||
# chargeait la veille ne chargeait plus le lendemain, « unknown model
|
||||
# architecture », sans que rien n'ait été touché. On ne le garde que s'il
|
||||
# existe encore : un dossier supprimé à la main ne doit pas laisser BIN dans
|
||||
# le vide.
|
||||
cur=$(loki config get BIN)
|
||||
case "$cur" in
|
||||
"$LOKI_HOME"/engine/*/llama-server) [ -x "$cur" ] || cur="" ;;
|
||||
*) cur="" ;;
|
||||
esac
|
||||
if [ -n "$cur" ]; then
|
||||
echo "[entrypoint] moteur mis à jour conservé : $cur"
|
||||
else
|
||||
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
|
||||
fi
|
||||
|
||||
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
|
||||
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
# Dictée en temps réel, configurable selon le matériel
|
||||
|
||||
*Conception validée le 21 août 2026.*
|
||||
|
||||
## Le problème
|
||||
|
||||
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
|
||||
défauts la rendent pénible :
|
||||
|
||||
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
|
||||
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
|
||||
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
|
||||
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
|
||||
|
||||
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
|
||||
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
|
||||
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
|
||||
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
|
||||
un rechargement complet du modèle toutes les quelques secondes.
|
||||
|
||||
## Ce qu'on construit
|
||||
|
||||
Trois choses, dans cet ordre de dépendance :
|
||||
|
||||
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
|
||||
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
|
||||
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
|
||||
|
||||
## 1. Le moteur
|
||||
|
||||
### whisper-server plutôt que whisper-cli
|
||||
|
||||
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
|
||||
Loki le supervise comme il supervise déjà `llama-server`.
|
||||
|
||||
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
|
||||
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
|
||||
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
|
||||
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
|
||||
modèle ; les suivants sont immédiats.
|
||||
|
||||
### Choix du GPU
|
||||
|
||||
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
|
||||
pas de code à maintenir dans ggml :
|
||||
|
||||
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
|
||||
- valeur vide → CPU seul
|
||||
|
||||
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
|
||||
|
||||
### Compilation : deux binaires, pas un
|
||||
|
||||
L'étape `whisperbuild` produit **deux** binaires :
|
||||
|
||||
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
|
||||
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
|
||||
|
||||
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
|
||||
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
|
||||
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
|
||||
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
|
||||
CUDA condamnerait donc la variante CPU de l'image.
|
||||
|
||||
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
|
||||
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
|
||||
le disant, plutôt que de laisser la dictée morte.
|
||||
|
||||
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
|
||||
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
|
||||
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
|
||||
|
||||
## 2. Le découpage
|
||||
|
||||
### On coupe dans les silences, jamais à heure fixe
|
||||
|
||||
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
|
||||
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
|
||||
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
|
||||
l'améliorer.
|
||||
|
||||
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
|
||||
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
|
||||
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
|
||||
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
|
||||
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
|
||||
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
|
||||
est borné par la coupure forcée.
|
||||
|
||||
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
|
||||
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
|
||||
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
|
||||
pas voir le texte se figer.
|
||||
|
||||
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
|
||||
dont personne ne connaît l'interaction :
|
||||
|
||||
| Réglage | Réserve minimale | Coupure forcée |
|
||||
|---|---|---|
|
||||
| Court | 1 s | 5 s |
|
||||
| Moyen (défaut) | 1,5 s | 8 s |
|
||||
| Long | 3 s | 15 s |
|
||||
|
||||
### La logique vit en Go
|
||||
|
||||
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
|
||||
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
|
||||
|
||||
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
|
||||
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
|
||||
Et le navigateur redevient bête : capter, envoyer, afficher.
|
||||
|
||||
### Contrat de la socket
|
||||
|
||||
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
|
||||
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
|
||||
|
||||
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
|
||||
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
|
||||
sous les yeux.
|
||||
|
||||
### Langue forcée par défaut
|
||||
|
||||
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
|
||||
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
|
||||
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
|
||||
|
||||
## 3. Le panneau Dictée
|
||||
|
||||
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
|
||||
« Identité ». Suit le gabarit des panneaux existants.
|
||||
|
||||
| Réglage | Contenu |
|
||||
|---|---|
|
||||
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
|
||||
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
|
||||
| **Langue** | Français par défaut, auto et autres langues disponibles |
|
||||
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
|
||||
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
|
||||
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
|
||||
|
||||
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
|
||||
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
|
||||
progression et suppression, en généralisant le mécanisme mono-modèle existant.
|
||||
|
||||
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
|
||||
le jeton Hugging Face.
|
||||
|
||||
## Les pannes, nommées
|
||||
|
||||
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
|
||||
|
||||
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
|
||||
d'échouer sec
|
||||
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
|
||||
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
|
||||
l'utilisateur est prévenu
|
||||
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
|
||||
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
|
||||
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
|
||||
dans la mauvaise direction.
|
||||
|
||||
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
|
||||
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
|
||||
|
||||
## Tests
|
||||
|
||||
En Go, sur ce qui peut réellement casser :
|
||||
|
||||
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
|
||||
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
|
||||
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
|
||||
modèle, langue
|
||||
- **l'aller-retour des réglages** dans `bkState`
|
||||
- **l'extinction sur inactivité**
|
||||
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
|
||||
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
|
||||
|
||||
## Ce que les tests ne prouveront pas
|
||||
|
||||
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
|
||||
échantillon de voix n'est disponible côté développement. Les tests établissent que le
|
||||
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
|
||||
`small` reste une constatation d'usage.
|
||||
|
||||
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
|
||||
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
|
||||
@@ -539,7 +539,11 @@ func dlSourceError(resp *http.Response, dlURL string) error {
|
||||
case 404:
|
||||
switch code {
|
||||
case "EntryNotFound":
|
||||
return fmt.Errorf("fichier absent du dépôt (HTTP 404) — la révision a pu être réécrite depuis que le lien a été copié")
|
||||
// Nommer le fichier : sur un modèle en fragments, c'est souvent UN
|
||||
// fragment qui manque — certains dépôts n'en publient que six sur
|
||||
// sept et livrent la table PLE à part, à lier soi-même — et sans
|
||||
// le nom, on cherche une révision réécrite qui n'existe pas.
|
||||
return fmt.Errorf("fichier %s absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", dlFileLabel(dlURL))
|
||||
case "RevisionNotFound":
|
||||
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
|
||||
}
|
||||
@@ -555,6 +559,20 @@ func dlSourceError(resp *http.Response, dlURL string) error {
|
||||
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
|
||||
}
|
||||
|
||||
// dlFileLabel : « nom.gguf » entre guillemets pour un message, ou vide si le
|
||||
// lien n'a pas de nom de fichier lisible.
|
||||
func dlFileLabel(dlURL string) string {
|
||||
u, err := url.Parse(dlURL)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
name := path.Base(u.Path)
|
||||
if name == "" || name == "/" || name == "." {
|
||||
return ""
|
||||
}
|
||||
return "« " + name + " »"
|
||||
}
|
||||
|
||||
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
|
||||
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
|
||||
// accepter, qui n'existent que là-bas.
|
||||
|
||||
@@ -172,7 +172,15 @@ func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
|
||||
},
|
||||
{
|
||||
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
|
||||
want: []string{"absent du dépôt", "404"},
|
||||
want: []string{"« m.gguf » absent du dépôt", "404"},
|
||||
},
|
||||
{
|
||||
// Dépôt qui publie six fragments sur sept (table PLE livrée à part) :
|
||||
// le message doit NOMMER le fragment manquant, sinon on cherche une
|
||||
// révision réécrite qui n'existe pas.
|
||||
name: "fragment absent", resp: resp(404, "EntryNotFound"),
|
||||
url: "https://huggingface.co/x/y-GGUF/resolve/main/Q2_K/y-Q2_K-00007-of-00007.gguf",
|
||||
want: []string{"« y-Q2_K-00007-of-00007.gguf » absent du dépôt", "fragment jamais publié"},
|
||||
},
|
||||
{
|
||||
name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
|
||||
|
||||
@@ -225,9 +225,38 @@ func applyPresetFile(target string) error {
|
||||
}
|
||||
next[k] = v
|
||||
}
|
||||
// Le moteur suit la machine, pas le preset (voir presetImposesEngine).
|
||||
if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) {
|
||||
next["BIN"] = cur["BIN"]
|
||||
}
|
||||
return WriteConfig(next)
|
||||
}
|
||||
|
||||
// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ?
|
||||
//
|
||||
// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du
|
||||
// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque
|
||||
// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de
|
||||
// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et
|
||||
// un modèle qui chargeait la veille mourait sur « unknown model architecture »
|
||||
// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur
|
||||
// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un
|
||||
// preset : le courant reste.
|
||||
//
|
||||
// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go :
|
||||
// « un backend custom se choisit par modèle »), lui, est un vrai choix par
|
||||
// modèle : le preset gagne.
|
||||
func presetImposesEngine(p string) bool {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" || engineOwns(p) || prebuiltOwns(p) {
|
||||
return false
|
||||
}
|
||||
if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SwitchToPreset installe le preset et redémarre le service. Les réglages
|
||||
// « appareil » (preservedKeys) sont conservés à travers la bascule.
|
||||
func SwitchToPreset(target string) error {
|
||||
@@ -287,6 +316,10 @@ func cmdSwitch(args []string) error {
|
||||
// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à
|
||||
// tout nettoyer à la main. On repart d'une base vide : les valeurs non
|
||||
// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…).
|
||||
//
|
||||
// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) :
|
||||
// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le
|
||||
// moteur mis à jour à la première bascule.
|
||||
var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
|
||||
|
||||
// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
|
||||
@@ -294,9 +327,11 @@ func newPresetSeed() map[string]string {
|
||||
cur := ReadConfig()
|
||||
seed := map[string]string{}
|
||||
for _, k := range newPresetSeedKeys {
|
||||
if v := cur[k]; v != "" {
|
||||
seed[k] = v
|
||||
v := cur[k]
|
||||
if v == "" || (k == "BIN" && !presetImposesEngine(v)) {
|
||||
continue
|
||||
}
|
||||
seed[k] = v
|
||||
}
|
||||
return seed
|
||||
}
|
||||
@@ -331,6 +366,41 @@ func SavePreset(id, name, content string) (string, error) {
|
||||
return id, os.WriteFile(p, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE,
|
||||
// installe aussitôt la nouvelle version comme configuration active (mêmes
|
||||
// règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas —
|
||||
// l'appelant redémarre alors le moteur.
|
||||
//
|
||||
// Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du
|
||||
// chat qui affiche toujours 32768. Seul le fichier du preset avait changé ;
|
||||
// config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun
|
||||
// preset n'était détecté actif (empreinte différente). Éditer le preset en
|
||||
// service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou
|
||||
// nouveau, lui, n'est qu'un fichier : rien ne bouge.
|
||||
//
|
||||
// « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui
|
||||
// porte l'empreinte de la configuration courante.
|
||||
func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) {
|
||||
wasActive := false
|
||||
if id != "" {
|
||||
if old, rerr := ReadPreset(id); rerr == nil {
|
||||
wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig())
|
||||
}
|
||||
}
|
||||
newID, err = SavePreset(id, name, content)
|
||||
if err != nil || !wasActive {
|
||||
return newID, false, err
|
||||
}
|
||||
p, err := safePresetPath(newID)
|
||||
if err != nil {
|
||||
return newID, false, err
|
||||
}
|
||||
if err := applyPresetFile(p); err != nil {
|
||||
return newID, false, err
|
||||
}
|
||||
return newID, true, nil
|
||||
}
|
||||
|
||||
// DeletePreset removes a preset by id; refuses if it is the active config.
|
||||
func DeletePreset(id string) error {
|
||||
p, err := safePresetPath(id)
|
||||
|
||||
@@ -68,6 +68,64 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Le moteur suit la machine. Vécu : moteur mis à jour depuis l'interface
|
||||
// (BIN → /data/engine/server-cuda-b10680), Qwen3.8-Flash-Next charge ; on
|
||||
// change de modèle — le preset, créé avant, porte BIN=/app/llama-server — et
|
||||
// le vieux moteur revient : « unknown model architecture: 'qwen4exp' ». Seul
|
||||
// un backend personnalisé, choix par modèle, a le droit d'imposer son BIN.
|
||||
func TestSwitchToPresetGardeLeMoteur(t *testing.T) {
|
||||
home := testHome(t)
|
||||
t.Setenv("LOKI_ENGINE_BIN", "/app/llama-server")
|
||||
write := func(p, body string) {
|
||||
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engine := filepath.Join(home, "engine", "server-cuda-b10680", "llama-server")
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
|
||||
|
||||
// Preset d'avant la mise à jour : il porte le moteur de l'image.
|
||||
image := filepath.Join(home, "image.env")
|
||||
write(image, "MODEL=nouveau.gguf\nBIN=/app/llama-server\n")
|
||||
if err := applyPresetFile(image); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != engine {
|
||||
t.Errorf("BIN = %q après bascule, attendu le moteur mis à jour %q", got, engine)
|
||||
}
|
||||
// Preset muet sur le moteur : idem.
|
||||
muet := filepath.Join(home, "muet.env")
|
||||
write(muet, "MODEL=autre.gguf\n")
|
||||
if err := applyPresetFile(muet); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != engine {
|
||||
t.Errorf("BIN = %q après preset muet, attendu %q conservé", got, engine)
|
||||
}
|
||||
// Backend personnalisé : le preset gagne.
|
||||
custom := filepath.Join(home, "custom.env")
|
||||
write(custom, "MODEL=exotique.gguf\nBIN=/opt/mon-fork/llama-server\n")
|
||||
if err := applyPresetFile(custom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; got != "/opt/mon-fork/llama-server" {
|
||||
t.Errorf("BIN = %q, attendu le backend personnalisé du preset", got)
|
||||
}
|
||||
// Un nouveau preset ne fige pas ce backend-là… si, justement : c'est un
|
||||
// choix par modèle. Mais il ne fige ni l'image ni un moteur téléchargé.
|
||||
if seed := newPresetSeed(); seed["BIN"] != "/opt/mon-fork/llama-server" {
|
||||
t.Errorf("seed BIN = %q, attendu le backend personnalisé", seed["BIN"])
|
||||
}
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
|
||||
if seed := newPresetSeed(); seed["BIN"] != "" {
|
||||
t.Errorf("seed BIN = %q, un moteur téléchargé ne doit pas être figé dans un preset", seed["BIN"])
|
||||
}
|
||||
setConfig(t, "MODEL=ancien.gguf\nBIN=/app/llama-server\n")
|
||||
if seed := newPresetSeed(); seed["BIN"] != "" {
|
||||
t.Errorf("seed BIN = %q, le moteur de l'image ne doit pas être figé dans un preset", seed["BIN"])
|
||||
}
|
||||
}
|
||||
|
||||
// Un preset qui définit LUI-MÊME la sélection de cartes doit gagner sur celle
|
||||
// de la machine : « FABLE 2 GPU » impose CUDA_VISIBLE_DEVICES=1,0 pour que son
|
||||
// --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait
|
||||
@@ -102,3 +160,54 @@ func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) {
|
||||
t.Errorf("WEB_ENGINE = %q, attendu go (réglage machine, jamais dicté par un preset)", cfg["WEB_ENGINE"])
|
||||
}
|
||||
}
|
||||
|
||||
// Modifier le preset ACTIF doit changer la configuration courante. Vécu : CTX
|
||||
// passé à 100000 dans l'éditeur, « enregistré » — et la carte du chat qui
|
||||
// affiche toujours 32768, parce que seul le fichier du preset avait bougé ;
|
||||
// config.env, lui, gardait l'ancienne valeur et plus aucun preset n'était
|
||||
// détecté actif. Un preset qui n'est pas celui en service, ou un preset
|
||||
// nouveau, ne touche pas à la configuration.
|
||||
func TestModifierLePresetActifLApplique(t *testing.T) {
|
||||
testHome(t)
|
||||
dir := presetsDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(name, body string) {
|
||||
if err := os.WriteFile(filepath.Join(dir, name+".env"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=32768\n")
|
||||
write("autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=4096\n")
|
||||
setConfig(t, "MODEL=foo.gguf\nCTX=32768\nMEM_MODE=always\n")
|
||||
|
||||
// Le preset en service : la config suit, les réglages machine restent.
|
||||
id, applied, err := SavePresetApplying("actif", "Actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=100000\n")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if id != "actif" || !applied {
|
||||
t.Fatalf("id=%q applied=%v, attendu actif/true", id, applied)
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
if cfg["CTX"] != "100000" || cfg["MEM_MODE"] != "always" {
|
||||
t.Fatalf("configuration non suivie : %v", cfg)
|
||||
}
|
||||
|
||||
// Un autre preset : le fichier change, la config non.
|
||||
if _, applied, err = SavePresetApplying("autre", "Autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=8192\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied || ReadConfig()["CTX"] != "100000" {
|
||||
t.Fatalf("un preset inactif a touché la configuration (applied=%v, CTX=%q)", applied, ReadConfig()["CTX"])
|
||||
}
|
||||
|
||||
// Un preset nouveau : jamais appliqué à la volée.
|
||||
if _, applied, err = SavePresetApplying("", "Neuf", "MODEL=baz.gguf\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if applied || ReadConfig()["MODEL"] != "foo.gguf" {
|
||||
t.Fatalf("un preset nouveau a touché la configuration (applied=%v)", applied)
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,21 @@ func (c *Conversation) compactLogLocked() {
|
||||
bufKey, toks, ts0, seq0 = "", 0, 0, 0
|
||||
}
|
||||
for _, ev := range c.Log {
|
||||
// Outils : un appel s'écrit en de nombreux événements (annonce done=false,
|
||||
// frappe du corps, streaming des arguments) jusqu'au done=true, qui porte
|
||||
// déjà l'état FINAL (résultat + diff). Les intermédiaires ne servent qu'à
|
||||
// l'affichage EN DIRECT ; les garder dans le journal le faisait enfler sur
|
||||
// une conversation agentique (gros write, appels en chaîne) jusqu'à
|
||||
// maxLogEvents → troncature des plus VIEUX événements, donc perte des
|
||||
// premiers messages à l'affichage. On ne conserve donc que le done=true
|
||||
// (même politique que coalesceReplay au replay/export).
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
flush()
|
||||
if done, _ := tu["done"].(bool); done {
|
||||
out = append(out, ev)
|
||||
}
|
||||
continue
|
||||
}
|
||||
key := textKey(ev.Delta)
|
||||
if key == "" {
|
||||
flush()
|
||||
@@ -777,11 +792,15 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
|
||||
continue
|
||||
}
|
||||
_, isTool := ev.Delta["tool_used"].(map[string]any)
|
||||
// Tout événement NON-outil clôt une éventuelle bulle d'outil en attente, pour
|
||||
// préserver l'ordre (l'outil non terminé s'affiche avant ce qui le suit).
|
||||
if !isTool {
|
||||
flushTool()
|
||||
}
|
||||
// ⚠️ On ne « flushe » PAS l'annonce d'outil en attente sur un événement
|
||||
// non-outil. Un appel d'outil s'écrit dans le journal en DEUX temps : une
|
||||
// annonce (done=false, juste « je vais lancer X ») puis le résultat
|
||||
// (done=true). Si un événement non-outil (stats, un bout de raisonnement)
|
||||
// se glisse ENTRE les deux, flusher ici émettait l'annonce PUIS le done →
|
||||
// le même outil apparaissait DEUX fois (à l'export Markdown et au replay).
|
||||
// On garde donc l'annonce en attente : le done la remplace (émis une seule
|
||||
// fois), et si aucun done n'arrive jamais (outil interrompu par un stop),
|
||||
// le flushTool final l'émet une seule fois, en fin de fil.
|
||||
// Delta texte ? (une seule clé content ou reasoning_content, valeur string)
|
||||
key := ""
|
||||
if s, ok := ev.Delta["content"].(string); ok {
|
||||
@@ -852,6 +871,17 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
snapshot := append([]LogEvent(nil), c.Log...)
|
||||
epoch := c.epoch
|
||||
c.mu.Unlock()
|
||||
// ⚠️ Garde-fou anti-« premiers messages manquants au changement de session ».
|
||||
// Le client se réabonne avec from=lastSeq (le dernier Seq qu'il a vu). Les Seq
|
||||
// ne sont PAS globaux : ouvrir une session PLUS ANCIENNE charge un journal dont
|
||||
// les Seq sont plus bas. Si le client garde un from élevé (hérité de la session
|
||||
// qu'il quittait) et se reconnecte, coalesceReplay saute tout (Seq <= from) →
|
||||
// la conversation ouverte apparaît tronquée, voire vide. En session normale, le
|
||||
// client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il
|
||||
// l'est, son curseur vient d'une autre session → on repart du début.
|
||||
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
|
||||
from = 0
|
||||
}
|
||||
last := from
|
||||
for _, ev := range coalesceReplay(snapshot, from) {
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -225,3 +225,87 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "suite"})
|
||||
waitSeq(t, got, 503)
|
||||
}
|
||||
|
||||
// --- Régressions v0.12.7 : journal d'affichage (outils, replay, changement de session) ---
|
||||
|
||||
// compactLogLocked doit coalescer les événements tool_used d'un même appel en un
|
||||
// seul (le done=true, qui porte l'état final) : les intermédiaires (annonce, frappe
|
||||
// du corps) ne servent qu'à l'affichage en direct et faisaient enfler le journal
|
||||
// jusqu'à la troncature des premiers messages.
|
||||
func TestCompactLogCoalesceTools(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.Log = []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"user": "édite"}},
|
||||
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f"}}},
|
||||
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "typing": true, "body": "a"}}},
|
||||
{Seq: 4, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "result": "ok", "done": true}}},
|
||||
{Seq: 5, Delta: map[string]any{"content": "fini"}},
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.compactLogLocked()
|
||||
c.mu.Unlock()
|
||||
nTool := 0
|
||||
for _, ev := range c.Log {
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
nTool++
|
||||
if done, _ := tu["done"].(bool); !done {
|
||||
t.Error("un tool_used non terminé a survécu à la compaction")
|
||||
}
|
||||
}
|
||||
}
|
||||
if nTool != 1 {
|
||||
t.Fatalf("compactLogLocked garde %d tool_used au lieu de 1", nTool)
|
||||
}
|
||||
}
|
||||
|
||||
// coalesceReplay ne doit émettre un outil qu'UNE fois même si un événement non-outil
|
||||
// (stats, un bout de raisonnement) tombe entre l'annonce (done=false) et le done=true.
|
||||
func TestCoalesceReplayNoDoubleTool(t *testing.T) {
|
||||
events := []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls"}}},
|
||||
{Seq: 2, Delta: map[string]any{"stats": map[string]any{"gen_tokens": 3}}},
|
||||
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls", "result": "x", "done": true}}},
|
||||
}
|
||||
n := 0
|
||||
for _, ev := range coalesceReplay(events, 0) {
|
||||
if _, ok := ev["tool_used"]; ok {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("outil émis %d fois au replay (attendu 1)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Un abonné qui arrive avec un `from` SUPÉRIEUR au dernier Seq du journal (curseur
|
||||
// hérité d'une autre session, plus récente) doit obtenir le fil COMPLET, pas rien :
|
||||
// sans ce garde-fou, ouvrir une session plus ancienne la montrait tronquée/vide.
|
||||
func TestSubscribeStaleFromCrossSession(t *testing.T) {
|
||||
c := newTestConv()
|
||||
c.appendDelta(c.epoch, map[string]any{"user": "premier message"})
|
||||
c.appendDelta(c.epoch, map[string]any{"content": "réponse"})
|
||||
// Le journal va jusqu'à Seq 2 ; on s'abonne avec from=9999 (curseur d'une autre session).
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var got []map[string]any
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
c.Subscribe(ctx, 9999, func(ev map[string]any) bool {
|
||||
got = append(got, ev)
|
||||
if ev["caught_up"] != nil {
|
||||
cancel()
|
||||
}
|
||||
return true
|
||||
})
|
||||
close(done)
|
||||
}()
|
||||
<-done
|
||||
seen := ""
|
||||
for _, ev := range got {
|
||||
if u, ok := ev["user"].(string); ok {
|
||||
seen += u
|
||||
}
|
||||
}
|
||||
if !strings.Contains(seen, "premier message") {
|
||||
t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen)
|
||||
}
|
||||
}
|
||||
@@ -103,12 +103,78 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
|
||||
Version: Version,
|
||||
ExportedAt: time.Now().Format(time.RFC3339),
|
||||
CtxUsed: ctxUsed,
|
||||
Messages: msgs,
|
||||
Messages: stripInlineImages(msgs),
|
||||
Log: filterLog(log, o),
|
||||
}
|
||||
return json.MarshalIndent(p, "", " ")
|
||||
}
|
||||
|
||||
// stripInlineImages remplace les images en base64 (data URI) portées par les
|
||||
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
|
||||
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
|
||||
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
|
||||
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
|
||||
// d'affichage non plus (il ne porte pas les images). On ne touche donc qu'ici, et
|
||||
// sur une COPIE : les messages appartiennent à la conversation vivante.
|
||||
func stripInlineImages(msgs []Message) []Message {
|
||||
out := make([]Message, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out[i] = m
|
||||
out[i].Content = stripInlineImagesValue(m.Content)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stripInlineImagesValue(v any) any {
|
||||
// Une partie image (format OpenAI) : {"type":"image_url","image_url":{"url":"data:…"}}.
|
||||
// On copie la partie et on élide l'URL data: en gardant l'en-tête (type MIME) lisible.
|
||||
elide := func(part map[string]any) map[string]any {
|
||||
cp := make(map[string]any, len(part))
|
||||
for k, val := range part {
|
||||
cp[k] = val
|
||||
}
|
||||
if iu, ok := cp["image_url"].(map[string]any); ok {
|
||||
c2 := make(map[string]any, len(iu))
|
||||
for k, val := range iu {
|
||||
c2[k] = val
|
||||
}
|
||||
if url, ok := c2["url"].(string); ok && strings.HasPrefix(url, "data:") {
|
||||
c2["url"] = elideDataURI(url)
|
||||
}
|
||||
cp["image_url"] = c2
|
||||
}
|
||||
return cp
|
||||
}
|
||||
switch parts := v.(type) {
|
||||
case []map[string]any: // message fraîchement produit
|
||||
out := make([]map[string]any, len(parts))
|
||||
for i, p := range parts {
|
||||
out[i] = elide(p)
|
||||
}
|
||||
return out
|
||||
case []any: // message relu depuis la base (JSON) : []any de maps
|
||||
out := make([]any, len(parts))
|
||||
for i, p := range parts {
|
||||
if mp, ok := p.(map[string]any); ok {
|
||||
out[i] = elide(mp)
|
||||
} else {
|
||||
out[i] = p
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return v // simple chaîne (cas courant) : rien à faire
|
||||
}
|
||||
|
||||
// elideDataURI garde l'en-tête « data:<mime>;base64, » (lisible) et remplace le
|
||||
// corps base64 par sa taille, pour que l'export reste informatif sans le poids.
|
||||
func elideDataURI(url string) string {
|
||||
if i := strings.Index(url, ","); i >= 0 {
|
||||
return url[:i+1] + fmt.Sprintf("<élidé — %s>", humanBytes(int64(len(url)-i-1)))
|
||||
}
|
||||
return "<image élidée>"
|
||||
}
|
||||
|
||||
// filterLog applique au journal les MÊMES cases que le Markdown. Les deux
|
||||
// formats avaient auparavant des options distinctes (le JSON proposait un
|
||||
// obscur « journal d'affichage » à la place des trois autres) : personne ne
|
||||
|
||||
@@ -258,3 +258,35 @@ func TestCmdExportEcritLeFichier(t *testing.T) {
|
||||
t.Fatal("option inconnue acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// L'export JSON d'un message multimodal (vision) doit ÉLIDER le base64 des images :
|
||||
// sinon le fichier pesait plusieurs Mo par image. Le Markdown, lui, n'a jamais porté
|
||||
// les images (juste les noms de fichiers).
|
||||
func TestExportJSONElideImages(t *testing.T) {
|
||||
t.Cleanup(func() { conv.Reset() })
|
||||
big := strings.Repeat("A", 300000)
|
||||
conv.mu.Lock()
|
||||
conv.Messages = []Message{
|
||||
{Role: "user", Content: []map[string]any{
|
||||
{"type": "text", "text": "regarde"},
|
||||
{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64," + big}},
|
||||
}},
|
||||
{Role: "assistant", Content: "vu"},
|
||||
}
|
||||
conv.Log = []LogEvent{{Seq: 1, TS: 1, Delta: map[string]any{"user": "regarde"}}}
|
||||
conv.Seq = 1
|
||||
conv.mu.Unlock()
|
||||
b, err := conv.ExportJSON(defaultExportOpts())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(b), big) {
|
||||
t.Error("le base64 brut d'une image est encore présent dans le JSON")
|
||||
}
|
||||
if len(b) > 10000 {
|
||||
t.Errorf("JSON toujours énorme malgré l'élision : %d octets", len(b))
|
||||
}
|
||||
if !strings.Contains(string(b), "élidé") {
|
||||
t.Error("marqueur d'élision absent du JSON")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package loki
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -98,36 +99,93 @@ func MemList() []MemPage {
|
||||
// MemSearch cherche les termes de la requête dans le nom + le contenu de chaque
|
||||
// page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de
|
||||
// recherche : à compléter par mem_read sur la page la plus pertinente.
|
||||
//
|
||||
// Classement en deux temps, pour ne PAS laisser une grosse page bourrée d'un mot
|
||||
// fréquent écraser une petite page qui matche VRAIMENT la requête :
|
||||
// 1. Couverture d'abord : les pages qui contiennent le PLUS de termes distincts
|
||||
// de la requête passent devant (chercher « copine Nathan » doit remonter la
|
||||
// page qui a les deux mots, pas celle qui répète « Nathan » 40 fois).
|
||||
// 2. À couverture égale, score pondéré par la RARETÉ du terme (IDF) : un mot
|
||||
// rare et discriminant (« copine ») pèse bien plus qu'un mot omniprésent
|
||||
// (« Nathan »). La fréquence est amortie (log) pour que le bourrage ne gagne
|
||||
// pas, et un match dans le NOM ou le TITRE est fortement bonifié.
|
||||
func MemSearch(query string, limit int) []MemHit {
|
||||
if limit <= 0 || limit > 30 {
|
||||
limit = 8
|
||||
}
|
||||
terms := strings.Fields(strings.ToLower(query))
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
score int
|
||||
terms := uniqueTerms(strings.ToLower(query))
|
||||
|
||||
type doc struct {
|
||||
p MemPage
|
||||
name string // minuscules
|
||||
title string // minuscules
|
||||
content string // brut (pour l'extrait)
|
||||
hay string // minuscules : nom + contenu
|
||||
}
|
||||
var ranked []scored
|
||||
docs := make([]doc, 0)
|
||||
df := make(map[string]int) // nb de pages contenant chaque terme
|
||||
for _, p := range MemList() {
|
||||
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
|
||||
content := string(b)
|
||||
hay := strings.ToLower(p.Name + "\n" + content)
|
||||
score := 0
|
||||
d := doc{
|
||||
p: p,
|
||||
name: strings.ToLower(p.Name),
|
||||
title: strings.ToLower(p.Title),
|
||||
content: content,
|
||||
hay: strings.ToLower(p.Name + "\n" + content),
|
||||
}
|
||||
docs = append(docs, d)
|
||||
for _, t := range terms {
|
||||
score += strings.Count(hay, t)
|
||||
if strings.Contains(strings.ToLower(p.Name), t) {
|
||||
score += 3 // bonus si le terme est dans le nom de la page
|
||||
if strings.Contains(d.hay, t) {
|
||||
df[t]++
|
||||
}
|
||||
}
|
||||
if score == 0 && query != "" {
|
||||
}
|
||||
n := len(docs)
|
||||
|
||||
type scored struct {
|
||||
hit MemHit
|
||||
matched int // termes distincts trouvés (clé de tri primaire)
|
||||
score float64 // pertinence pondérée (clé secondaire)
|
||||
}
|
||||
var ranked []scored
|
||||
for _, d := range docs {
|
||||
matched := 0
|
||||
score := 0.0
|
||||
for _, t := range terms {
|
||||
cnt := strings.Count(d.hay, t)
|
||||
if cnt == 0 {
|
||||
continue
|
||||
}
|
||||
matched++
|
||||
// IDF : rare = discriminant. +1 pour qu'un terme fréquent compte encore.
|
||||
idf := math.Log(float64(n+1)/float64(df[t]+1)) + 1
|
||||
// Fréquence amortie : 10 occurrences ne valent pas 10x une occurrence.
|
||||
tf := 1.0 + math.Log(float64(cnt))
|
||||
field := 1.0
|
||||
if strings.Contains(d.name, t) {
|
||||
field += 4 // le terme est dans le NOM de la page
|
||||
}
|
||||
if strings.Contains(d.title, t) {
|
||||
field += 2 // ... ou dans son TITRE
|
||||
}
|
||||
score += idf * tf * field
|
||||
}
|
||||
if matched == 0 && len(terms) > 0 {
|
||||
continue
|
||||
}
|
||||
ranked = append(ranked, scored{
|
||||
hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)},
|
||||
score: score,
|
||||
hit: MemHit{File: d.p.Name, Title: d.p.Title, Snippet: snippetAround(d.content, terms)},
|
||||
matched: matched,
|
||||
score: score,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].matched != ranked[j].matched {
|
||||
return ranked[i].matched > ranked[j].matched // couverture d'abord
|
||||
}
|
||||
return ranked[i].score > ranked[j].score
|
||||
})
|
||||
out := []MemHit{}
|
||||
for i, r := range ranked {
|
||||
if i >= limit {
|
||||
@@ -138,6 +196,19 @@ func MemSearch(query string, limit int) []MemHit {
|
||||
return out
|
||||
}
|
||||
|
||||
// uniqueTerms découpe une requête en mots distincts (dédupliqués, ordre gardé).
|
||||
func uniqueTerms(q string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, t := range strings.Fields(q) {
|
||||
if !seen[t] {
|
||||
seen[t] = true
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme.
|
||||
func snippetAround(content string, terms []string) string {
|
||||
flat := strings.Join(strings.Fields(content), " ")
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Reproduit le bug rapporté : une petite page dont le NOM et le TITRE contiennent
|
||||
// « copine » ne remontait pas pour la requête « copine Nathan », noyée par de
|
||||
// grosses pages qui répètent « Nathan » des dizaines de fois. Le classement doit
|
||||
// mettre en tête la page qui matche le PLUS de termes distincts (couverture),
|
||||
// puis pondérer par la rareté.
|
||||
func TestMemSearchCoverageBeatsFrequency(t *testing.T) {
|
||||
testHome(t)
|
||||
|
||||
// Deux pages « bruyantes » bourrées de « Nathan », sans « copine ».
|
||||
if err := MemAdd("business-nathan.md", "# Business Nathan\n"+strings.Repeat("Nathan veut un business. ", 40)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("guide-ajean.md", "# Guide AJEAN\n"+strings.Repeat("projet de Nathan. ", 30)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// La petite page cible : matche les DEUX termes de la requête.
|
||||
if err := MemAdd("profil-copine-nathan.md", "# Profil : Copine de Nathan\n- Prénom : Bao\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hits := MemSearch("copine Nathan", 8)
|
||||
if len(hits) == 0 {
|
||||
t.Fatal("aucun résultat pour « copine Nathan »")
|
||||
}
|
||||
if hits[0].File != "profil-copine-nathan.md" {
|
||||
var names []string
|
||||
for _, h := range hits {
|
||||
names = append(names, h.File)
|
||||
}
|
||||
t.Fatalf("la page copine devrait être 1re (matche les 2 termes), obtenu ordre : %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Une requête mono-terme rare doit privilégier un match dans le nom/titre.
|
||||
func TestMemSearchNameTitleBoost(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = MemAdd("notes-diverses.md", "# Notes\nUn jour Bao est passée dire bonjour, puis repartie.\n")
|
||||
_ = MemAdd("profil-copine.md", "# Profil : Copine\n- Prénom : Bao\n")
|
||||
hits := MemSearch("copine", 8)
|
||||
if len(hits) == 0 || hits[0].File != "profil-copine.md" {
|
||||
t.Fatalf("la page dont le nom/titre porte « copine » devrait être 1re, obtenu : %+v", hits)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Réglages de la dictée vocale — modèle, langue, matériel, réactivité.
|
||||
//
|
||||
// Ils vivent dans le magasin clé/valeur (bkState), comme le jeton Hugging
|
||||
// Face : ce sont des réglages de SERVEUR, pas de navigateur. Le modèle chargé
|
||||
// et le GPU occupé sont des propriétés de la machine, identiques pour tous les
|
||||
// onglets ouverts.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dictateCfgKey = "dictate_cfg"
|
||||
|
||||
// DictateCfg : Device vaut "cpu" ou un indice de GPU en décimal ("0", "1"),
|
||||
// dans l'ordre de nvidia-smi — le même que celui affiché par /api/vram.
|
||||
type DictateCfg struct {
|
||||
Model string `json:"model"`
|
||||
Lang string `json:"lang"`
|
||||
Device string `json:"device"`
|
||||
Reactivity string `json:"reactivity"`
|
||||
}
|
||||
|
||||
// withDefauts comble les champs vides. Appliqué à la LECTURE plutôt qu'à
|
||||
// l'écriture : une version future qui ajoute un champ trouvera les réglages
|
||||
// déjà enregistrés sans champ correspondant, et doit pouvoir le combler.
|
||||
func (c DictateCfg) withDefauts() DictateCfg {
|
||||
if c.Model == "" {
|
||||
// large-v3-turbo : meilleur rapport qualité/vitesse en français à ce
|
||||
// jour. Le défaut penche vers la qualité.
|
||||
c.Model = "large-v3-turbo-q5_0"
|
||||
}
|
||||
if c.Lang == "" {
|
||||
// Pas "auto" : sur des tranches de quelques secondes la détection se
|
||||
// trompe, et une langue mal détectée produit du charabia — des suites
|
||||
// de caractères géorgiens ont été observées en production.
|
||||
c.Lang = "fr"
|
||||
}
|
||||
if c.Device == "" {
|
||||
c.Device = "cpu"
|
||||
}
|
||||
if c.Reactivity == "" {
|
||||
c.Reactivity = "moyen"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func dictateCfgLoad() DictateCfg {
|
||||
var c DictateCfg
|
||||
getJSON(bkState, dictateCfgKey, &c)
|
||||
return c.withDefauts()
|
||||
}
|
||||
|
||||
func dictateCfgSave(c DictateCfg) error {
|
||||
c = c.withDefauts()
|
||||
// Valider ICI plutôt qu'au démarrage du serveur : un modèle hors catalogue
|
||||
// accepté en silence ne se manifesterait qu'au premier clic sur le micro,
|
||||
// loin du geste qui l'a causé.
|
||||
if _, ok := whisperCatalogue[c.Model]; !ok {
|
||||
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return putJSON(bkState, dictateCfgKey, c)
|
||||
}
|
||||
|
||||
// cudaVisibleDevices traduit le réglage en valeur de CUDA_VISIBLE_DEVICES.
|
||||
// Vide = aucun GPU visible = whisper tourne sur le CPU. Toute valeur qui n'est
|
||||
// pas un indice positif retombe sur le CPU : mieux vaut une dictée lente qu'un
|
||||
// GPU choisi au hasard sur une machine dont on ne sait rien.
|
||||
func (c DictateCfg) cudaVisibleDevices() string {
|
||||
n, err := strconv.Atoi(c.Device)
|
||||
if err != nil || n < 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// chunkBounds : réserve minimale avant de couper, et coupure forcée quand
|
||||
// aucun silence n'arrive. Consommé par le découpeur du plan « temps réel ».
|
||||
func (c DictateCfg) chunkBounds() (time.Duration, time.Duration) {
|
||||
switch c.Reactivity {
|
||||
case "court":
|
||||
return 1000 * time.Millisecond, 5 * time.Second
|
||||
case "long":
|
||||
return 3 * time.Second, 15 * time.Second
|
||||
default:
|
||||
return 1500 * time.Millisecond, 8 * time.Second
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDictateCfgDefauts(t *testing.T) {
|
||||
c := DictateCfg{}.withDefauts()
|
||||
if c.Model != "large-v3-turbo-q5_0" {
|
||||
t.Errorf("modèle par défaut = %q, attendu large-v3-turbo-q5_0", c.Model)
|
||||
}
|
||||
if c.Lang != "fr" {
|
||||
t.Errorf("langue par défaut = %q, attendu fr — la détection auto se trompe sur les tranches courtes", c.Lang)
|
||||
}
|
||||
if c.Device != "cpu" {
|
||||
t.Errorf("matériel par défaut = %q, attendu cpu (aucun GPU supposé)", c.Device)
|
||||
}
|
||||
if c.Reactivity != "moyen" {
|
||||
t.Errorf("réactivité par défaut = %q, attendu moyen", c.Reactivity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCudaVisibleDevices(t *testing.T) {
|
||||
cas := []struct{ device, want string }{
|
||||
{"cpu", ""},
|
||||
{"0", "0"},
|
||||
{"1", "1"},
|
||||
{"", ""},
|
||||
{"bidon", ""},
|
||||
{"-1", ""},
|
||||
}
|
||||
for _, c := range cas {
|
||||
got := DictateCfg{Device: c.device}.cudaVisibleDevices()
|
||||
if got != c.want {
|
||||
t.Errorf("cudaVisibleDevices(%q) = %q, attendu %q", c.device, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkBounds(t *testing.T) {
|
||||
cas := []struct {
|
||||
react string
|
||||
min, max time.Duration
|
||||
}{
|
||||
{"court", 1000 * time.Millisecond, 5 * time.Second},
|
||||
{"moyen", 1500 * time.Millisecond, 8 * time.Second},
|
||||
{"long", 3 * time.Second, 15 * time.Second},
|
||||
{"inconnu", 1500 * time.Millisecond, 8 * time.Second},
|
||||
}
|
||||
for _, c := range cas {
|
||||
min, max := DictateCfg{Reactivity: c.react}.chunkBounds()
|
||||
if min != c.min || max != c.max {
|
||||
t.Errorf("chunkBounds(%q) = %v/%v, attendu %v/%v", c.react, min, max, c.min, c.max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictateCfgAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
in := DictateCfg{Model: "medium-q5_0", Lang: "en", Device: "1", Reactivity: "long"}
|
||||
if err := dictateCfgSave(in); err != nil {
|
||||
t.Fatalf("enregistrement : %v", err)
|
||||
}
|
||||
if out := dictateCfgLoad(); out != in {
|
||||
t.Errorf("relu %+v, attendu %+v", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle hors catalogue accepté en silence ne se manifesterait qu'au premier
|
||||
// clic sur le micro, loin du geste qui l'a causé.
|
||||
func TestDictateCfgSaveRefuseModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "modele-inexistant", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err == nil {
|
||||
t.Error("un modèle hors catalogue doit être refusé")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans réglage enregistré, la lecture doit rendre les défauts — et non le zéro
|
||||
// de la structure, qui donnerait un modèle vide à whisper-server.
|
||||
func TestDictateCfgLoadSansRien(t *testing.T) {
|
||||
testHome(t)
|
||||
c := dictateCfgLoad()
|
||||
if c.Model == "" || c.Lang == "" || c.Device == "" || c.Reactivity == "" {
|
||||
t.Errorf("lecture à vide = %+v, aucun champ ne doit rester vide", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Catalogue des modèles de dictée. Aucun n'est embarqué dans l'image : ils
|
||||
// pèsent de 190 Mo à 1,1 Go et vivent dans <LOKI_HOME>/whisper/, donc dans le
|
||||
// volume /data — ils survivent aux recréations du conteneur.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Les modèles ggml officiels de whisper.cpp.
|
||||
const whisperModelBase = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
|
||||
|
||||
type whisperModel struct {
|
||||
Nom string // libellé affiché
|
||||
Fichier string // nom du .bin, identique en local et chez Hugging Face
|
||||
Octets int64 // taille du téléchargement, pour prévenir avant de le lancer
|
||||
VRAMMo int // ordre de grandeur occupé sur le GPU, pour choisir en connaissance de cause
|
||||
}
|
||||
|
||||
// Les tailles sont celles publiées par le dépôt amont. Elles servent à
|
||||
// informer avant un téléchargement, pas à vérifier le fichier : une taille qui
|
||||
// dérive d'une release à l'autre ne doit pas casser la dictée.
|
||||
var whisperCatalogue = map[string]whisperModel{
|
||||
"small-q5_1": {Nom: "small — rapide, correct", Fichier: "ggml-small-q5_1.bin", Octets: 190_000_000, VRAMMo: 600},
|
||||
"medium-q5_0": {Nom: "medium — bon compromis", Fichier: "ggml-medium-q5_0.bin", Octets: 539_000_000, VRAMMo: 1400},
|
||||
"large-v3-turbo-q5_0": {Nom: "large-v3-turbo — recommandé", Fichier: "ggml-large-v3-turbo-q5_0.bin", Octets: 574_000_000, VRAMMo: 1600},
|
||||
"large-v3-q5_0": {Nom: "large-v3 — le plus précis, le plus lent", Fichier: "ggml-large-v3-q5_0.bin", Octets: 1_080_000_000, VRAMMo: 3600},
|
||||
}
|
||||
|
||||
// whisperModelPathFor : chemin local. Vide si l'identifiant est inconnu —
|
||||
// l'appelant doit traiter ce cas plutôt que de bâtir un chemin sur une chaîne
|
||||
// arbitraire venue d'une requête HTTP.
|
||||
func whisperModelPathFor(id string) string {
|
||||
m, ok := whisperCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(LokiHome(), "whisper", m.Fichier)
|
||||
}
|
||||
|
||||
func whisperModelURLFor(id string) string {
|
||||
m, ok := whisperCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return whisperModelBase + m.Fichier
|
||||
}
|
||||
|
||||
func whisperModelPresent(id string) bool {
|
||||
p := whisperModelPathFor(id)
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && st.Size() > 0
|
||||
}
|
||||
|
||||
// whisperCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
|
||||
// l'ordre dans lequel se pose la question du compromis.
|
||||
func whisperCatalogueTrie() []map[string]any {
|
||||
out := make([]map[string]any, 0, len(whisperCatalogue))
|
||||
for id, m := range whisperCatalogue {
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "nom": m.Nom, "octets": m.Octets,
|
||||
"vram_mo": m.VRAMMo, "present": whisperModelPresent(id),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogueContientLeDefautEtLAncien(t *testing.T) {
|
||||
// Le défaut doit exister, sinon dictateCfgSave refuse sa propre valeur par
|
||||
// défaut et plus aucun réglage n'est enregistrable.
|
||||
if _, ok := whisperCatalogue["large-v3-turbo-q5_0"]; !ok {
|
||||
t.Error("le modèle par défaut large-v3-turbo-q5_0 est absent du catalogue")
|
||||
}
|
||||
// small-q5_1 est le modèle déjà téléchargé sur les installations
|
||||
// existantes : le retirer le rendrait insélectionnable alors que son
|
||||
// fichier est là.
|
||||
if _, ok := whisperCatalogue["small-q5_1"]; !ok {
|
||||
t.Error("small-q5_1 est absent : les installations existantes l'ont déjà sur disque")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPathEtURL(t *testing.T) {
|
||||
testHome(t)
|
||||
p := whisperModelPathFor("small-q5_1")
|
||||
if filepath.Base(p) != "ggml-small-q5_1.bin" {
|
||||
t.Errorf("chemin = %q, le fichier doit s'appeler ggml-small-q5_1.bin", p)
|
||||
}
|
||||
// Le chemin DOIT rester celui d'avant, sinon les installations existantes
|
||||
// re-téléchargent 190 Mo pour rien.
|
||||
if filepath.Base(filepath.Dir(p)) != "whisper" {
|
||||
t.Errorf("chemin = %q, le dossier doit rester <LOKI_HOME>/whisper", p)
|
||||
}
|
||||
u := whisperModelURLFor("small-q5_1")
|
||||
if !strings.HasSuffix(u, "/ggml-small-q5_1.bin") {
|
||||
t.Errorf("URL = %q, doit finir par /ggml-small-q5_1.bin", u)
|
||||
}
|
||||
if !strings.HasPrefix(u, "https://") {
|
||||
t.Errorf("URL = %q, doit être en HTTPS", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPathInconnuVide(t *testing.T) {
|
||||
testHome(t)
|
||||
if p := whisperModelPathFor("nawak"); p != "" {
|
||||
t.Errorf("un modèle hors catalogue doit donner un chemin vide, pas %q", p)
|
||||
}
|
||||
if u := whisperModelURLFor("nawak"); u != "" {
|
||||
t.Errorf("URL d'un modèle inconnu = %q, attendu vide", u)
|
||||
}
|
||||
if whisperModelPresent("nawak") {
|
||||
t.Error("un modèle inconnu ne peut pas être présent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogueTrieParTaille(t *testing.T) {
|
||||
testHome(t)
|
||||
l := whisperCatalogueTrie()
|
||||
if len(l) < 4 {
|
||||
t.Fatalf("catalogue de %d entrées, au moins 4 attendues", len(l))
|
||||
}
|
||||
var prec int64
|
||||
for i, m := range l {
|
||||
o, _ := m["octets"].(int64)
|
||||
if i > 0 && o < prec {
|
||||
t.Errorf("entrée %d : catalogue non trié par taille croissante", i)
|
||||
}
|
||||
prec = o
|
||||
for _, clef := range []string{"id", "nom", "octets", "vram_mo", "present"} {
|
||||
if _, ok := m[clef]; !ok {
|
||||
t.Errorf("entrée %d : clé %q manquante — l'UI en a besoin", i, clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigRouteAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
corps := strings.NewReader(`{"model":"small-q5_1","lang":"en","device":"1","reactivity":"court"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("POST = %d, corps %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("GET", "/api/dictate/config", nil))
|
||||
var got DictateCfg
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
if got.Device != "1" || got.Lang != "en" || got.Model != "small-q5_1" || got.Reactivity != "court" {
|
||||
t.Errorf("relu %+v, attendu le réglage enregistré", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le refus doit venir de la ROUTE, pas seulement du magasin : sinon l'UI reçoit
|
||||
// un 200 et croit son réglage pris en compte.
|
||||
func TestConfigRouteRefuseModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
corps := strings.NewReader(`{"model":"nawak","lang":"fr","device":"cpu","reactivity":"moyen"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||
if rec.Code == 200 {
|
||||
t.Errorf("modèle hors catalogue accepté (%d) : %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRouteCorpsIllisible(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", strings.NewReader("pas du json")))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("code = %d, attendu 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsRoute(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateModels(rec, httptest.NewRequest("GET", "/api/dictate/models", nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET = %d", rec.Code)
|
||||
}
|
||||
var l []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &l); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
if len(l) < 4 {
|
||||
t.Errorf("%d modèles renvoyés, au moins 4 attendus", len(l))
|
||||
}
|
||||
}
|
||||
|
||||
// Un identifiant inconnu ne doit pas lancer de téléchargement : whisperDownload
|
||||
// bâtirait une URL vide et le message d'erreur n'arriverait qu'en arrière-plan.
|
||||
func TestDownloadRouteRefuseInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
corps := strings.NewReader(`{"id":"nawak"}`)
|
||||
handleDictateDownload(rec, httptest.NewRequest("POST", "/api/dictate/models/download", corps))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("code = %d, attendu 400 pour un modèle inconnu", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRoute(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateState(rec, httptest.NewRequest("GET", "/api/dictate/state", nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET = %d", rec.Code)
|
||||
}
|
||||
var e map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
for _, clef := range []string{"actif", "modele", "present", "device", "dl"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante dans l'état", clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un audio trop court est rejeté AVANT toute tentative de lancer whisper-server :
|
||||
// inutile de charger un modèle pour un clic parasite.
|
||||
func TestTranscribeAudioTropCourt(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleTranscribe(rec, httptest.NewRequest("POST", "/api/transcribe", strings.NewReader("court")))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("code = %d, attendu 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
// Supervision de whisper-server : le modèle est chargé UNE fois et reste en
|
||||
// mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
|
||||
//
|
||||
// L'ancien chemin lançait whisper-cli par requête. Le chargement du modèle
|
||||
// dominait le temps de réponse (~3 s pour 3,4 s d'audio), et découper la
|
||||
// dictée en tranches par-dessus ce mécanisme aurait rechargé le modèle toutes
|
||||
// les quelques secondes.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// whisperServerBin : deux binaires sont livrés, pas un.
|
||||
//
|
||||
// L'image runtime peut être bâtie sur la variante CPU de llama.cpp
|
||||
// (LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server). Sur cette base les
|
||||
// bibliothèques CUDA sont absentes, et un binaire lié à CUDA ne démarre pas du
|
||||
// tout : l'éditeur de liens échoue avant la première instruction, il n'y a
|
||||
// donc aucun repli possible depuis l'intérieur du programme. D'où deux
|
||||
// binaires et un choix fait ici.
|
||||
func whisperServerBin(gpu bool) string {
|
||||
env, defaut := "LOKI_WHISPER_SERVER_CPU", "/usr/local/bin/whisper-server-cpu"
|
||||
if gpu {
|
||||
env, defaut = "LOKI_WHISPER_SERVER_CUDA", "/usr/local/bin/whisper-server-cuda"
|
||||
}
|
||||
for _, p := range []string{os.Getenv(env), defaut} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if gpu {
|
||||
// Pas de binaire CUDA : l'appelant retombe sur le CPU en le disant.
|
||||
return ""
|
||||
}
|
||||
if p, err := exec.LookPath("whisper-server"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func whisperServerArgs(c DictateCfg, port int) ([]string, error) {
|
||||
model := whisperModelPathFor(c.Model)
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return []string{
|
||||
"-m", model,
|
||||
"-l", c.Lang,
|
||||
// 127.0.0.1 et pas 0.0.0.0 : whisper-server n'a aucune
|
||||
// authentification. Sur un conteneur au réseau partagé, l'exposer
|
||||
// offrirait la transcription à tout le réseau.
|
||||
"--host", "127.0.0.1",
|
||||
"--port", strconv.Itoa(port),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// whisperServerEnv pose CUDA_VISIBLE_DEVICES en REMPLAÇANT toute valeur déjà
|
||||
// présente. Une variable dupliquée laisse le gagnant dépendre de la libc, et
|
||||
// le choix de GPU deviendrait un coup de dé.
|
||||
func whisperServerEnv(c DictateCfg, base []string) []string {
|
||||
const clef = "CUDA_VISIBLE_DEVICES="
|
||||
out := make([]string, 0, len(base)+1)
|
||||
for _, v := range base {
|
||||
if !strings.HasPrefix(v, clef) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
// Valeur vide en mode CPU : masque TOUS les GPU. Retirer la variable
|
||||
// ferait l'inverse — ggml les verrait tous.
|
||||
return append(out, clef+c.cudaVisibleDevices())
|
||||
}
|
||||
|
||||
// portLibre demande au système un port disponible puis le relâche. Coder un
|
||||
// port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre
|
||||
// la fermeture et la reprise par whisper-server est négligeable devant le
|
||||
// risque d'un conflit permanent.
|
||||
func portLibre() (int, error) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = l.Close() }()
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
|
||||
// whisperMarqueur reconnaît les annotations que whisper produit à la place
|
||||
// d'un texte : [BLANK_AUDIO], (silence), [SOUND]… Un contenu entièrement
|
||||
// entouré de crochets ou de parenthèses n'est jamais de la parole dictée, et
|
||||
// le laisser passer le collerait tel quel dans le champ de saisie.
|
||||
func whisperMarqueur(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
|
||||
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
|
||||
}
|
||||
|
||||
// whisperInferSur prend l'URL de base en paramètre : c'est ce qui rend l'appel
|
||||
// testable sans lancer de processus.
|
||||
func whisperInferSur(ctx context.Context, base string, wav []byte) (string, error) {
|
||||
var corps bytes.Buffer
|
||||
mw := multipart.NewWriter(&corps)
|
||||
part, err := mw.CreateFormFile("file", "dictee.wav")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(wav); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mw.WriteField("response_format", "json"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/inference", &corps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
brut, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("whisper-server a répondu %d : %s", resp.StatusCode, lastLine(string(brut)))
|
||||
}
|
||||
var j struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(brut, &j); err != nil {
|
||||
return "", fmt.Errorf("réponse illisible de whisper-server : %w", err)
|
||||
}
|
||||
if whisperMarqueur(j.Text) {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(j.Text), nil
|
||||
}
|
||||
|
||||
// ─── Supervision ─────────────────────────────────────────────────────────────
|
||||
|
||||
// whisperIdle : au-delà, le serveur s'éteint et rend sa VRAM. Une dictée n'est
|
||||
// pas un service permanent ; garder un modèle chargé toute la journée pour
|
||||
// quelques phrases prive le moteur de chat de mémoire GPU.
|
||||
const whisperIdle = 10 * time.Minute
|
||||
|
||||
// whisperReady : plafond d'attente au démarrage. Un modèle de 1 Go lu depuis un
|
||||
// disque lent puis poussé sur le GPU prend du temps ; échouer trop tôt
|
||||
// afficherait une panne là où il n'y a qu'une lenteur.
|
||||
const whisperReady = 120 * time.Second
|
||||
|
||||
var wsrvMu sync.Mutex
|
||||
|
||||
// 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
|
||||
// l'interrompre. Nil hors démarrage.
|
||||
var wsrvDemarrage atomic.Pointer[exec.Cmd]
|
||||
|
||||
var wsrv struct {
|
||||
cmd *exec.Cmd
|
||||
port int
|
||||
cfg DictateCfg
|
||||
log *bytes.Buffer
|
||||
timer *time.Timer
|
||||
note string // pourquoi on ne tourne pas sur le matériel demandé
|
||||
depuis time.Time
|
||||
}
|
||||
|
||||
// whisperEnsure garantit un serveur vivant pour la configuration COURANTE, et
|
||||
// renvoie son port. Si les réglages ont changé depuis le lancement, l'ancien
|
||||
// est arrêté : c'est ce qui rend un changement de modèle ou de GPU effectif
|
||||
// sans redémarrer Loki.
|
||||
func whisperEnsure() (int, error) {
|
||||
cfg := dictateCfgLoad()
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
|
||||
whisperTouchLocked()
|
||||
return wsrv.port, nil
|
||||
}
|
||||
whisperStopLocked()
|
||||
return whisperStartLocked(cfg)
|
||||
}
|
||||
|
||||
func whisperStartLocked(cfg DictateCfg) (int, error) {
|
||||
if !whisperModelPresent(cfg.Model) {
|
||||
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
|
||||
}
|
||||
note := ""
|
||||
gpu := cfg.cudaVisibleDevices() != ""
|
||||
bin := whisperServerBin(gpu)
|
||||
if bin == "" && gpu {
|
||||
// Image bâtie sans CUDA : on le DIT, au lieu de laisser croire que le
|
||||
// GPU choisi est utilisé.
|
||||
note = "binaire CUDA absent de cette image — dictée sur le processeur"
|
||||
gpu = false
|
||||
cfg.Device = "cpu"
|
||||
bin = whisperServerBin(false)
|
||||
}
|
||||
if bin == "" {
|
||||
return 0, fmt.Errorf("whisper-server introuvable (image à reconstruire)")
|
||||
}
|
||||
port, err := portLibre()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aucun port libre pour whisper-server : %w", err)
|
||||
}
|
||||
args, err := whisperServerArgs(cfg, port)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
log := &bytes.Buffer{}
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = whisperServerEnv(cfg, os.Environ())
|
||||
cmd.Stdout, cmd.Stderr = log, log
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("démarrage de whisper-server : %w", err)
|
||||
}
|
||||
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad()
|
||||
wsrv.log, wsrv.note, wsrv.depuis = log, note, time.Now()
|
||||
wsrvDemarrage.Store(cmd)
|
||||
err = whisperAttendPret(cmd, port, log)
|
||||
wsrvDemarrage.Store(nil)
|
||||
if err != nil {
|
||||
whisperStopLocked()
|
||||
return 0, err
|
||||
}
|
||||
whisperTouchLocked()
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// whisperAttendPret sonde le serveur jusqu'à ce qu'il réponde. Si le processus
|
||||
// meurt entre-temps, on rapporte COMMENT il est mort : sur une mort par
|
||||
// signal, la dernière ligne du journal est celle d'avant le coup fatal — elle
|
||||
// ressemble à une explication sans en être une (leçon de la PR #18).
|
||||
func whisperAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
|
||||
fin := time.Now().Add(whisperReady)
|
||||
url := "http://127.0.0.1:" + strconv.Itoa(port) + "/"
|
||||
mort := make(chan error, 1)
|
||||
go func() { mort <- cmd.Wait() }()
|
||||
for time.Now().Before(fin) {
|
||||
select {
|
||||
case err := <-mort:
|
||||
msg := whisperExitReason(err)
|
||||
if l := lastLine(log.String()); l != "" {
|
||||
msg += " — dernière sortie : " + l
|
||||
}
|
||||
return fmt.Errorf("whisper-server s'est arrêté au démarrage : %s", msg)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
|
||||
cancel()
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("whisper-server n'a pas répondu en %s", whisperReady)
|
||||
}
|
||||
|
||||
func whisperTouchLocked() {
|
||||
if wsrv.timer != nil {
|
||||
wsrv.timer.Stop()
|
||||
}
|
||||
wsrv.timer = time.AfterFunc(whisperIdle, whisperShutdown)
|
||||
}
|
||||
|
||||
func whisperStopLocked() {
|
||||
if wsrv.timer != nil {
|
||||
wsrv.timer.Stop()
|
||||
wsrv.timer = nil
|
||||
}
|
||||
if wsrv.cmd != nil && wsrv.cmd.Process != nil {
|
||||
_ = wsrv.cmd.Process.Kill()
|
||||
}
|
||||
wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil
|
||||
wsrv.cfg, wsrv.note = DictateCfg{}, ""
|
||||
}
|
||||
|
||||
// whisperShutdown arrête le serveur. Appelé par la minuterie d'inactivité et
|
||||
// à chaque changement de réglages — sans ça, le serveur continuerait de
|
||||
// tourner avec l'ancien modèle et le nouveau réglage semblerait sans effet.
|
||||
func whisperShutdown() {
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
whisperStopLocked()
|
||||
}
|
||||
|
||||
// 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é.
|
||||
//
|
||||
// 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() {
|
||||
for i := 0; i < 20; i++ {
|
||||
if wsrvMu.TryLock() {
|
||||
whisperStopLocked()
|
||||
wsrvMu.Unlock()
|
||||
return
|
||||
}
|
||||
if cmd := wsrvDemarrage.Load(); cmd != nil && cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// whisperInfer garantit le serveur puis lui soumet le WAV.
|
||||
func whisperInfer(ctx context.Context, wav []byte) (string, error) {
|
||||
port, err := whisperEnsure()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return whisperInferSur(ctx, "http://127.0.0.1:"+strconv.Itoa(port), wav)
|
||||
}
|
||||
|
||||
func whisperEtat() map[string]any {
|
||||
cfg := dictateCfgLoad()
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
etat := map[string]any{
|
||||
"actif": wsrv.cmd != nil,
|
||||
"modele": cfg.Model,
|
||||
"present": whisperModelPresent(cfg.Model),
|
||||
"device": cfg.Device,
|
||||
"note": wsrv.note,
|
||||
}
|
||||
if wsrv.cmd != nil {
|
||||
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
|
||||
}
|
||||
return etat
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func argVal(args []string, flag string) (string, bool) {
|
||||
for i, a := range args {
|
||||
if a == flag && i+1 < len(args) {
|
||||
return args[i+1], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestServerArgs(t *testing.T) {
|
||||
testHome(t)
|
||||
c := DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu"}.withDefauts()
|
||||
args, err := whisperServerArgs(c, 8091)
|
||||
if err != nil {
|
||||
t.Fatalf("construction des arguments : %v", err)
|
||||
}
|
||||
if v, ok := argVal(args, "-m"); !ok || !strings.HasSuffix(v, "ggml-small-q5_1.bin") {
|
||||
t.Errorf("-m = %q, doit pointer sur le .bin du modèle choisi", v)
|
||||
}
|
||||
if v, ok := argVal(args, "-l"); !ok || v != "fr" {
|
||||
t.Errorf("-l = %q, attendu fr", v)
|
||||
}
|
||||
if v, ok := argVal(args, "--port"); !ok || v != "8091" {
|
||||
t.Errorf("--port = %q, attendu 8091", v)
|
||||
}
|
||||
// whisper-server n'a aucune authentification : l'exposer sur 0.0.0.0
|
||||
// ouvrirait la transcription à tout le réseau.
|
||||
if v, ok := argVal(args, "--host"); !ok || v != "127.0.0.1" {
|
||||
t.Errorf("--host = %q, attendu 127.0.0.1", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerArgsModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if _, err := whisperServerArgs(DictateCfg{Model: "nawak", Lang: "fr", Device: "cpu"}, 8091); err == nil {
|
||||
t.Error("un modèle hors catalogue doit produire une erreur, pas une commande avec -m vide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerEnvGPU(t *testing.T) {
|
||||
base := []string{"PATH=/usr/bin", "CUDA_VISIBLE_DEVICES=7", "HOME=/root"}
|
||||
|
||||
env := whisperServerEnv(DictateCfg{Device: "1"}, base)
|
||||
if !contientExact(env, "CUDA_VISIBLE_DEVICES=1") {
|
||||
t.Errorf("env = %v, doit contenir CUDA_VISIBLE_DEVICES=1", env)
|
||||
}
|
||||
// L'ancienne valeur doit être REMPLACÉE, pas doublée : avec deux
|
||||
// occurrences, le gagnant dépend de l'implémentation — le choix de
|
||||
// l'utilisateur ne doit pas dépendre de ça.
|
||||
if n := compte(env, "CUDA_VISIBLE_DEVICES="); n != 1 {
|
||||
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
|
||||
}
|
||||
if !contientExact(env, "PATH=/usr/bin") {
|
||||
t.Error("le reste de l'environnement doit être conservé")
|
||||
}
|
||||
|
||||
env = whisperServerEnv(DictateCfg{Device: "cpu"}, base)
|
||||
if !contientExact(env, "CUDA_VISIBLE_DEVICES=") {
|
||||
t.Errorf("env = %v, en mode CPU la variable doit être posée VIDE (et non retirée) pour masquer tous les GPU", env)
|
||||
}
|
||||
}
|
||||
|
||||
func contientExact(l []string, s string) bool {
|
||||
for _, v := range l {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func compte(l []string, prefixe string) int {
|
||||
n := 0
|
||||
for _, v := range l {
|
||||
if strings.HasPrefix(v, prefixe) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestPortLibre(t *testing.T) {
|
||||
p, err := portLibre()
|
||||
if err != nil {
|
||||
t.Fatalf("portLibre : %v", err)
|
||||
}
|
||||
if p < 1024 || p > 65535 {
|
||||
t.Errorf("port = %d, hors de la plage utilisable", p)
|
||||
}
|
||||
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
|
||||
if err != nil {
|
||||
t.Fatalf("port %d annoncé libre mais inutilisable : %v", p, err)
|
||||
}
|
||||
_ = l.Close()
|
||||
}
|
||||
|
||||
func TestInferParseLaReponse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/inference" {
|
||||
t.Errorf("chemin appelé = %q, attendu /inference", r.URL.Path)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
|
||||
t.Errorf("Content-Type = %q, whisper-server attend du multipart", ct)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"text":" bonjour ceci est un test "}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
||||
if err != nil {
|
||||
t.Fatalf("whisperInferSur : %v", err)
|
||||
}
|
||||
if txt != "bonjour ceci est un test" {
|
||||
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// Sur du silence, whisper renvoie ses marqueurs internes. Les laisser passer
|
||||
// les collerait tels quels dans le champ de saisie — c'est ce qui est arrivé
|
||||
// avec [BLANK_AUDIO].
|
||||
func TestInferFiltreLesMarqueurs(t *testing.T) {
|
||||
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"text":"` + marqueur + `"}`))
|
||||
}))
|
||||
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
||||
srv.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
|
||||
}
|
||||
if txt != "" {
|
||||
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferErreurHTTP(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux")); err == nil {
|
||||
t.Error("un 500 de whisper-server doit remonter une erreur")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle absent doit être annoncé comme tel, pas produire un lancement de
|
||||
// whisper-server voué à mourir sur un fichier introuvable.
|
||||
func TestEnsureModeleAbsent(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := whisperEnsure()
|
||||
if err == nil {
|
||||
t.Fatal("un modèle absent doit produire une erreur")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absent") {
|
||||
t.Errorf("erreur = %q, elle doit dire que le modèle est absent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEtatSansServeur(t *testing.T) {
|
||||
testHome(t)
|
||||
e := whisperEtat()
|
||||
if e["actif"] != false {
|
||||
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
|
||||
}
|
||||
for _, clef := range []string{"modele", "present", "device", "note"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
+409
-148
@@ -311,6 +311,10 @@ details>summary::-webkit-details-marker{display:none}
|
||||
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
|
||||
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
|
||||
details[open]>summary>.preset-add{display:inline-block}
|
||||
/* Les panneaux de réglages ne sont plus des <details>/<summary> mais des
|
||||
<section class="set-pane"> : sans cette règle, le « + » des Presets restait
|
||||
en display:none et il n'y avait plus aucun moyen d'en créer un. */
|
||||
.set-pane-h>.preset-add{display:inline-block}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
|
||||
.tag.ok{color:var(--ok);border-color:var(--ok)}
|
||||
.tag.err{color:var(--err);border-color:var(--err)}
|
||||
@@ -782,9 +786,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.lc-update-link{color:var(--dim);text-decoration:none;cursor:pointer}
|
||||
.lc-update-link:hover{color:var(--accent)}
|
||||
/* Choix du moteur dans l'éditeur de modèle (3 options : rapide/optimisé/perso) */
|
||||
.be-opts{display:flex;flex-direction:column;gap:7px;margin:6px 0 8px}
|
||||
.be-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer}
|
||||
.be-opt input{margin:0;flex-shrink:0}
|
||||
.be-note{font-size:11px;color:var(--dim);margin-left:auto}
|
||||
|
||||
/* ===== Éditeur de preset — listes groupées façon Réglages iOS ======== */
|
||||
@@ -876,7 +877,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:3px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s}
|
||||
.pe-seg>label:has(input:checked){background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.22)}
|
||||
.pe-seg input{display:none}
|
||||
.pe-seg .be-note{display:none}
|
||||
.pe-seg-note{font-size:11px;color:var(--dim);padding:0 2px;min-height:14px}
|
||||
.pe-note{font-size:11px;color:var(--dim);line-height:1.45}
|
||||
/* Barre de progression (téléchargement de modèle). Sans total connu, elle
|
||||
@@ -1716,6 +1716,16 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* --- Moniteur machine (pied de barre latérale) ----------------------- */
|
||||
.perf-head{display:flex;align-items:center;gap:8px;margin-bottom:11px;
|
||||
font-size:14px;font-weight:600;color:var(--text);letter-spacing:-.01em}
|
||||
/* Bouton du moniteur (« Libérer la VRAM » / « Recharger le modèle ») : poussé
|
||||
à droite du titre, discret tant qu'on ne le cherche pas. En mode rechargement
|
||||
il prend la couleur d'accent — le moteur est arrêté, c'est l'action qui
|
||||
compte à ce moment-là. */
|
||||
.perf-act{margin-left:auto;flex:none;padding:3px 8px;font-size:11px;line-height:1.4;
|
||||
color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;
|
||||
white-space:nowrap}
|
||||
.perf-act:hover:not(:disabled){color:var(--text)}
|
||||
.perf-act:disabled{opacity:.55;cursor:progress}
|
||||
.perf-act.reload{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent)}
|
||||
.perf-row{margin:0 0 14px}
|
||||
.perf-l{display:flex;align-items:baseline;gap:8px;font-size:12.5px;color:var(--text);margin-bottom:6px}
|
||||
.perf-l b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
@@ -2227,7 +2237,11 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
|
||||
bloc entier se replie d'un clic. -->
|
||||
<div id="side-perf">
|
||||
<div class="perf-head"><span>Performance</span></div>
|
||||
<!-- « Libérer la VRAM » est posé LÀ, sur les jauges : c'est en les regardant
|
||||
qu'on décide de rendre la carte à une autre application. Le libellé
|
||||
bascule en « Recharger le modèle » dès que le moteur est arrêté — voir
|
||||
paintVramBtn (js/05-status.js). -->
|
||||
<div class="perf-head"><span>Performance</span><button id="vram-btn" class="perf-act" onclick="vramBtnClick()" title="décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine">Libérer la VRAM</button></div>
|
||||
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2331,7 +2345,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
||||
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
||||
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles), puis se redessine seul
|
||||
à chaque résultat d'outil, fin de tour ou dépôt (filesOnActivity) — un ReadDir à chaque
|
||||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||||
utile à personne. -->
|
||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||
@@ -2390,6 +2405,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<div class="set-nav-h">Moteur</div>
|
||||
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
|
||||
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
|
||||
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
|
||||
<div class="set-nav-h">Application</div>
|
||||
<button data-pane="identity" onclick="settingsShow('identity')">Identité</button>
|
||||
<button data-pane="appearance" onclick="settingsShow('appearance')">Apparence</button>
|
||||
@@ -2586,18 +2602,76 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
<pre id="lc-log" hidden></pre>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ces trois boutons pilotent le service qui fait tourner llama-server :
|
||||
leur place est ici, avec le moteur, et pas dans une section « Service »
|
||||
à part qui ne contenait qu'eux. -->
|
||||
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).</span></span></div>
|
||||
<!-- Ces boutons pilotent le service qui fait tourner llama-server : leur
|
||||
place est ici, avec le moteur, et pas dans une section « Service » à
|
||||
part qui ne contenait qu'eux. Le dernier, « libérer la VRAM », fait
|
||||
plus qu'arrêter : il éteint aussi la dictée et annonce la mémoire
|
||||
vidéo rendue (c'est le même geste que le bouton du moniteur). -->
|
||||
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).<br><b>Libérer la VRAM</b> arrête le moteur <i>et</i> la dictée, puis annonce la mémoire vidéo rendue : c'est ce qu'il faut quand une autre application réclame la carte. Le même bouton vit sur les jauges, en pied de barre latérale.</span></span></div>
|
||||
<div class="slist"><div class="rowbody">
|
||||
<div class="row" style="margin:0">
|
||||
<button onclick="act('start')">démarrer</button>
|
||||
<button onclick="act('restart')">redémarrer</button>
|
||||
<button onclick="act('stop')">arrêter</button>
|
||||
<button onclick="vramUnload(false)" title="décharge le modèle et rend la mémoire vidéo à la machine">libérer la VRAM</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
<!-- Dictée vocale. Réglages de SERVEUR : le modèle chargé et le GPU occupé
|
||||
sont des propriétés de la machine, identiques pour tous les onglets.
|
||||
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="slist">
|
||||
<label class="switchrow"><span>modèle</span>
|
||||
<select id="dic-model" class="sctl"></select>
|
||||
</label>
|
||||
<div class="rowbody">
|
||||
<button id="dic-dl" class="btn" onclick="dictateDownload()">Télécharger ce modèle</button>
|
||||
<span id="dic-dl-state" class="dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Reconnaissance</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>
|
||||
<option value="moyen">moyen</option>
|
||||
<option value="long">long</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="rowbody">
|
||||
<button class="btn primary" onclick="dictateSave()">Enregistrer</button>
|
||||
<span id="dic-save-state" class="dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Vérifier</div>
|
||||
<div class="slist">
|
||||
<div class="rowbody">
|
||||
<button id="dic-test" class="btn" onclick="dictateTestMic()">Tester le micro (3 s)</button>
|
||||
</div>
|
||||
<div id="dic-test-out" class="dim"></div>
|
||||
<div id="dic-state" class="dim"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
|
||||
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
|
||||
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
|
||||
@@ -3075,31 +3149,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Moteur</div>
|
||||
<!-- « Image » n'apparaît qu'en conteneur (moteur fourni), où
|
||||
« Précompilé » et « Compilé » sont au contraire masqués : ils
|
||||
désignent des installations que Loki ne fait pas ici. -->
|
||||
<div class="pe-seg">
|
||||
<label id="be-lab-image" style="display:none"><input type="radio" name="m-be" value="image" onchange="onBackendMode('image')"><span>Image</span><span class="be-note" id="be-image-note"></span></label>
|
||||
<label id="be-lab-fast"><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Précompilé</span><span class="be-note" id="be-fast-note"></span></label>
|
||||
<label id="be-lab-opt"><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Compilé</span><span class="be-note" id="be-opt-note"></span></label>
|
||||
<label id="be-lab-custom"><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
|
||||
</div>
|
||||
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Backend</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="m-backend-detected" onchange="onPickDetected()"></select></span></span>
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<input id="m-backend-path" class="pe-inp" oninput="onCustomPath()" placeholder="ou un chemin vers llama-server(.exe)">
|
||||
<div class="pe-note" id="be-drop-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cartes graphiques : la liste vient du moteur choisi juste au-dessus,
|
||||
car les noms ET l'ordre dépendent du backend compilé dedans. Le
|
||||
<!-- Cartes graphiques : la liste vient du moteur de la machine (celui de
|
||||
l'image ou celui mis à jour dans Réglages → Moteur — le moteur n'est
|
||||
pas un réglage de preset), car les noms ET l'ordre dépendent du backend
|
||||
compilé dedans. Le
|
||||
groupe reste masqué tant qu'on n'a pas AU MOINS DEUX cartes : avec
|
||||
une seule, il n'y a rien à arbitrer. -->
|
||||
<div class="pe-group" id="m-gpu-group" hidden>
|
||||
@@ -3645,6 +3698,14 @@ async function loadStatus(){
|
||||
el.className='statuspill '+cls;
|
||||
el.innerHTML='<span class="dot"></span>'+txt;
|
||||
MODEL_READY = !!(s.active && s.health);
|
||||
// Le bouton « Libérer la VRAM » suit l'état RÉEL du moteur (et pas un drapeau
|
||||
// local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se
|
||||
// lire ici aussi, sinon le bouton propose de libérer une carte déjà libre.
|
||||
ENGINE_ACTIVE = !!s.active;
|
||||
// Un moteur qui a PLANTÉ au chargement n'est pas « déchargé » : sans ce
|
||||
// drapeau, syncSendBtn proposerait de recharger un modèle qui va replanter.
|
||||
LOAD_ERROR = s.load_error || '';
|
||||
paintVramBtn();
|
||||
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un
|
||||
// message à un modèle qui n'est pas encore chargé (voir syncSendBtn).
|
||||
STATUS_SEEN = true;
|
||||
@@ -3809,6 +3870,83 @@ async function loadVram(){
|
||||
g.name+' · '+vram));
|
||||
});
|
||||
}
|
||||
// ─── Libérer la mémoire vidéo ───────────────────────────────────────────────
|
||||
// Tant que le moteur tourne, le modèle occupe la VRAM : une autre application
|
||||
// qui réclame la carte (un jeu, un encodage, un autre serveur d'inférence) ne
|
||||
// trouve plus rien à prendre. Ce bouton l'en sort, et le MÊME bouton la
|
||||
// reprend ensuite — le geste existait déjà (« arrêter » le service, au fond des
|
||||
// réglages), mais son nom ne disait pas qu'il rendait la carte.
|
||||
//
|
||||
// ENGINE_ACTIVE vient de /api/status (relu toutes les 5 s), jamais d'un drapeau
|
||||
// posé au clic : sinon un second onglet, ou un arrêt venu du terminal,
|
||||
// afficherait un bouton qui ment.
|
||||
let ENGINE_ACTIVE = true, VRAM_BUSY = false, LOAD_ERROR = '';
|
||||
function paintVramBtn(){
|
||||
const b = document.getElementById('vram-btn');
|
||||
if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy
|
||||
b.disabled = false;
|
||||
b.classList.toggle('reload', !ENGINE_ACTIVE);
|
||||
b.textContent = ENGINE_ACTIVE ? 'Libérer la VRAM' : 'Recharger le modèle';
|
||||
b.title = ENGINE_ACTIVE
|
||||
? 'décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine'
|
||||
: 'relance le moteur : le modèle revient en mémoire vidéo';
|
||||
}
|
||||
function vramBtnClick(){ return ENGINE_ACTIVE ? vramUnload(false) : vramReload(); }
|
||||
function vramBusy(on, label){
|
||||
VRAM_BUSY = on;
|
||||
const b = document.getElementById('vram-btn');
|
||||
if(b){ b.disabled = on; if(on && label) b.textContent = label; }
|
||||
if(!on) paintVramBtn();
|
||||
}
|
||||
// force=true : on a déjà demandé confirmation ET accepté de couper une réponse
|
||||
// en cours d'écriture (le serveur refuse sans ça, voir handleVramUnload).
|
||||
// Les deux gestes sont exclusifs : un second clic (le bouton des réglages
|
||||
// pendant que celui du moniteur travaille) lancerait un « stop » au milieu d'un
|
||||
// démarrage, ou l'inverse. VRAM_BUSY tient lieu de verrou.
|
||||
async function vramUnload(force){
|
||||
if(VRAM_BUSY) return;
|
||||
if(!force){
|
||||
const ok = await askConfirm(
|
||||
'Décharger le modèle ? Le moteur s\'arrête et rend la mémoire vidéo — le chat reste indisponible jusqu\'au rechargement.',
|
||||
{title:'Libérer la VRAM', okText:'Libérer'});
|
||||
if(!ok) return;
|
||||
}
|
||||
vramBusy(true, 'libération…');
|
||||
let r = null;
|
||||
try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){}
|
||||
// Le serveur dit l'état RÉEL après coup : le repeindre depuis l'ancien
|
||||
// ENGINE_ACTIVE (relu seulement toutes les 5 s) afficherait « Libérer » sur
|
||||
// un moteur déjà arrêté.
|
||||
if(r && typeof r.active === 'boolean') ENGINE_ACTIVE = r.active;
|
||||
vramBusy(false);
|
||||
if(r && r.generating){
|
||||
const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?',
|
||||
{title:'Génération en cours', okText:'Décharger quand même'});
|
||||
if(ok) return vramUnload(true);
|
||||
return;
|
||||
}
|
||||
// La mesure d'abord : la dictée peut avoir rendu de la VRAM alors même que le
|
||||
// moteur était déjà arrêté — annoncer « déjà arrêté » nierait ce qui vient
|
||||
// d'être libéré. Sous quelques dizaines de Mo, c'est le bruit du bureau entre
|
||||
// deux lectures, pas une libération : on ne l'annonce pas.
|
||||
if(!r || !r.ok) toast('échec : ' + ((r && r.error) || 'réseau'));
|
||||
else if(r.gpu && r.freed_mb >= 64) toast('VRAM libérée : ' + fmtMo(r.freed_mb));
|
||||
else if(!r.was_active) toast('le moteur était déjà arrêté');
|
||||
else toast('modèle déchargé');
|
||||
loadAll();
|
||||
}
|
||||
function fmtMo(mb){ return mb >= 1024 ? (mb/1024).toFixed(1) + ' Gio' : mb + ' Mo'; }
|
||||
async function vramReload(){
|
||||
if(VRAM_BUSY) return;
|
||||
vramBusy(true, 'démarrage…');
|
||||
let r = null;
|
||||
try{ r = await jpost('/api/vram/reload', {}); }catch(_){}
|
||||
if(r && r.ok) ENGINE_ACTIVE = true;
|
||||
vramBusy(false);
|
||||
if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; }
|
||||
toast('modèle en cours de chargement…');
|
||||
loadAll();
|
||||
}
|
||||
async function loadRam(){
|
||||
const m=await jget('/api/ram');
|
||||
const box=document.getElementById('ram-details');
|
||||
@@ -4476,7 +4614,7 @@ async function openItem(kind, key){
|
||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||
populateSettings();
|
||||
attachDownload(); // téléchargement encore en cours côté serveur ?
|
||||
await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
if(seq !== openSeq) return;
|
||||
}
|
||||
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
|
||||
@@ -4748,111 +4886,18 @@ async function populateMmproj(){
|
||||
sel.innerHTML = html;
|
||||
}
|
||||
|
||||
// Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé)
|
||||
// qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel
|
||||
// backend fait tourner ce modèle — la barre latérale ne fait qu'installer.
|
||||
function currentBinInTextarea(){
|
||||
return readEnvKey(document.getElementById('m-content').value, 'BIN');
|
||||
}
|
||||
// Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows).
|
||||
// Sert à la ligne MOTEUR du panneau d'état (05-status.js).
|
||||
function sameBinPath(a, b){
|
||||
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
||||
return !!a && !!b && n(a) === n(b);
|
||||
}
|
||||
// Écrit (ou remplace) la ligne BIN= dans le contenu du preset.
|
||||
function setBinInTextarea(val){
|
||||
const ta = document.getElementById('m-content');
|
||||
if(/^\s*BIN\s*=.*$/m.test(ta.value)) ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"');
|
||||
else ta.value = 'BIN="'+val+'"\n' + ta.value;
|
||||
}
|
||||
// Le moteur précompilé s'installe dans un dossier versionné (…/llama-b10280/) :
|
||||
// le BIN d'un preset écrit avant une mise à jour ne vaut plus le chemin courant,
|
||||
// alors qu'il désigne bien ce moteur. On reconnaît donc l'appartenance au
|
||||
// dossier, sinon tous les presets retombaient en « personnalisé » à chaque MAJ.
|
||||
function underDir(p, dir){
|
||||
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
||||
return !!p && !!dir && n(p).startsWith(n(dir)+'/');
|
||||
}
|
||||
let beFastPath = '', beOptPath = '', beFastDir = '', beImagePath = '';
|
||||
async function populateBackend(){
|
||||
// Chemins des deux moteurs gérés + liste des backends détectés (dossier loki).
|
||||
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
|
||||
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
|
||||
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
|
||||
beOptPath = lc.bin || '';
|
||||
// Conteneur : le moteur vient de l'image. « Précompilé » et « Compilé »
|
||||
// désignent des installations que Loki ne fait pas ici — les proposer ne
|
||||
// menait qu'à « installez d'abord… ». On les remplace par « Image ».
|
||||
beImagePath = lc.provided ? (lc.provided_bin || '') : '';
|
||||
document.getElementById('be-lab-image').style.display = beImagePath ? '' : 'none';
|
||||
document.getElementById('be-lab-fast').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-lab-opt').style.display = beImagePath ? 'none' : '';
|
||||
// En conteneur, « Personnalisé » ne mène nulle part (rien à compiler, et le
|
||||
// moteur se met à jour par la carte Moteur des réglages) : on le retire.
|
||||
document.getElementById('be-lab-custom').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-image-note').textContent = beImagePath ? 'llama.cpp officiel' : '';
|
||||
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
|
||||
// le dossier backends de loki (l'utilisateur peut y déposer son propre build).
|
||||
const detected = await jget('/api/backends');
|
||||
const sel = document.getElementById('m-backend-detected');
|
||||
let html = '<option value="">— ou choisir un backend détecté —</option>';
|
||||
for(const b of (detected||[])) html += '<option value="'+b.path+'">'+b.name+'</option>';
|
||||
sel.innerHTML = html;
|
||||
document.getElementById('be-drop-hint').textContent = lc.backends_dir
|
||||
? ('Astuce : déposez votre binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : '';
|
||||
// Sélectionne l'option correspondant au BIN actuel du preset.
|
||||
const cur = currentBinInTextarea();
|
||||
let mode = 'custom';
|
||||
// Un preset sans BIN hérite du moteur global : en conteneur c'est celui de
|
||||
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ». Et comme
|
||||
// l'option Personnalisé n'y est plus proposée, TOUT BIN non reconnu retombe
|
||||
// aussi sur l'image (un vieux preset pointant un chemin mort redevient sain).
|
||||
if(beImagePath && (sameBinPath(cur, beImagePath) || !cur)) mode = 'image';
|
||||
else if(beImagePath) mode = 'image';
|
||||
else if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
|
||||
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
|
||||
const radio = document.querySelector('input[name=m-be][value='+mode+']');
|
||||
if(radio) radio.checked = true;
|
||||
toggleBackendCustom(mode);
|
||||
if(mode === 'custom') document.getElementById('m-backend-path').value = cur;
|
||||
// Attendu (et non lancé dans le vide) : la liste des GPU change la hauteur du
|
||||
// bloc « moteur ». Sans ce await, elle arrivait APRÈS la levée du voile de
|
||||
// chargement et on voyait le bloc bouger tout seul.
|
||||
await loadGpuDevices();
|
||||
}
|
||||
function toggleBackendCustom(mode){
|
||||
document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none';
|
||||
}
|
||||
function onBackendMode(mode){
|
||||
toggleBackendCustom(mode);
|
||||
if(mode === 'image'){
|
||||
if(!beImagePath){ toast('aucun moteur fourni par l\'image'); return; }
|
||||
setBinInTextarea(beImagePath); toast('moteur : llama.cpp de l\'image');
|
||||
loadGpuDevices();
|
||||
} else if(mode === 'fast'){
|
||||
if(!beFastPath){ toast('installez d\'abord llama.cpp précompilé (section MOTEUR)'); return; }
|
||||
setBinInTextarea(beFastPath); toast('moteur : llama.cpp précompilé');
|
||||
loadGpuDevices();
|
||||
} else if(mode === 'opt'){
|
||||
if(!beOptPath){ toast('installez d\'abord llama.cpp compilé (section MOTEUR)'); return; }
|
||||
setBinInTextarea(beOptPath); toast('moteur : llama.cpp compilé');
|
||||
loadGpuDevices();
|
||||
}
|
||||
// custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend
|
||||
}
|
||||
function onCustomPath(){
|
||||
const v = document.getElementById('m-backend-path').value.trim();
|
||||
if(v){ setBinInTextarea(v); loadGpuDevices(); }
|
||||
}
|
||||
function onPickDetected(){
|
||||
const v = document.getElementById('m-backend-detected').value;
|
||||
if(!v) return;
|
||||
document.getElementById('m-backend-path').value = v;
|
||||
setBinInTextarea(v); toast('moteur personnalisé');
|
||||
loadGpuDevices();
|
||||
}
|
||||
|
||||
// --- Cartes graphiques (--device / --tensor-split) --------------------------
|
||||
// Le moteur n'est PAS un réglage de preset : c'est celui de la machine (l'image,
|
||||
// ou la version mise à jour dans Réglages → Moteur), et applyPresetFile le
|
||||
// conserve à la bascule. L'éditeur n'offre donc plus de choix de moteur — il
|
||||
// interroge celui qui tourne pour lister les cartes.
|
||||
// Ces réglages vivent dans le PRESET, pas dans une variable d'environnement :
|
||||
// --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que
|
||||
// CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles
|
||||
@@ -4866,14 +4911,18 @@ const GPU_THUMB = 16;
|
||||
let gpuDevices = [];
|
||||
const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture
|
||||
|
||||
// Interroge le moteur du preset (--list-devices) : les noms de device et leur
|
||||
// ordre lui appartiennent, une liste issue de nvidia-smi désignerait la mauvaise
|
||||
// carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la réponse n'est
|
||||
// pas là, et le reste s'il y a moins de deux cartes : rien à arbitrer, et ça
|
||||
// évite un encart qui surgit après coup.
|
||||
// Interroge le moteur de la machine (--list-devices) : les noms de device et
|
||||
// leur ordre lui appartiennent, une liste issue de nvidia-smi désignerait la
|
||||
// mauvaise carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la
|
||||
// réponse n'est pas là, et le reste s'il y a moins de deux cartes : rien à
|
||||
// arbitrer, et ça évite un encart qui surgit après coup.
|
||||
// Chemin du moteur courant (config_bin de /api/llamacpp), mémorisé au
|
||||
// préchauffage : l'éditeur s'en sert sans refaire l'appel.
|
||||
let engineBin = '';
|
||||
// Remplit le cache sans rien peindre (appelé au chargement de la page pour le
|
||||
// moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt.
|
||||
async function prefetchGpuDevices(bin){
|
||||
if(bin) engineBin = bin;
|
||||
if(!bin || gpuCache[bin]) return;
|
||||
try{
|
||||
const r = await jpost('/api/backends/devices', {bin});
|
||||
@@ -4881,16 +4930,24 @@ async function prefetchGpuDevices(bin){
|
||||
}catch(_){}
|
||||
}
|
||||
|
||||
// Moteur courant : ce que le préchauffage a vu, sinon on le demande (éditeur
|
||||
// ouvert avant que le statut llama.cpp ne soit arrivé).
|
||||
async function currentEngineBin(){
|
||||
if(engineBin) return engineBin;
|
||||
try{ const lc = await jget('/api/llamacpp'); engineBin = lc.config_bin || ''; }catch(_){}
|
||||
return engineBin;
|
||||
}
|
||||
|
||||
async function loadGpuDevices(){
|
||||
const group = document.getElementById('m-gpu-group');
|
||||
const bin = currentBinInTextarea();
|
||||
const bin = await currentEngineBin();
|
||||
if(!bin){ group.hidden = true; gpuDevices = []; return; }
|
||||
if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; }
|
||||
let r = {};
|
||||
try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; }
|
||||
// Le moteur a pu changer pendant l'appel (clic rapide) : on ne peint que si la
|
||||
// réponse concerne toujours le moteur affiché.
|
||||
if(currentBinInTextarea() !== bin) return;
|
||||
// Le moteur a pu changer pendant l'appel (mise à jour en cours) : on ne peint
|
||||
// que si la réponse concerne toujours le moteur courant.
|
||||
if(engineBin !== bin) return;
|
||||
gpuDevices = (r.ok && r.devices) ? r.devices : [];
|
||||
if(r.ok) gpuCache[bin] = gpuDevices;
|
||||
renderGpu();
|
||||
@@ -5223,6 +5280,21 @@ function closeModal(){ hideModal('modal'); document.getElementById('modal').clas
|
||||
async function saveItem(){
|
||||
const K = KINDS[editingKind];
|
||||
const name = document.getElementById('m-name').value.trim();
|
||||
// Interrupteur « Raisonnement » : on matérialise son état dans le preset AVANT
|
||||
// de lire le contenu. onchange ne part que sur une interaction : un switch
|
||||
// laissé sur off à la création n'écrivait donc jamais REASONING=, et la clé
|
||||
// ABSENTE laisse le moteur raisonner malgré l'interrupteur affiché sur off
|
||||
// (amont, issue #46). Décoché → REASONING=off explicite. Coché → on, sauf si
|
||||
// une valeur active plus précise (auto/deepseek) est déjà là : on la garde.
|
||||
if(editingKind==='preset'){
|
||||
const rsw = document.getElementById('s-reasoning');
|
||||
if(rsw){
|
||||
const cur = cfgReadKey('REASONING');
|
||||
const active = /^(on|1|true|auto|deepseek)$/i.test(cur);
|
||||
if(!rsw.checked){ if(!/^off$/i.test(cur)) cfgWriteKey('REASONING', 'off'); }
|
||||
else if(!active){ cfgWriteKey('REASONING', 'on'); }
|
||||
}
|
||||
}
|
||||
const content = document.getElementById('m-content').value;
|
||||
if(!name){ toast('nom requis'); return; }
|
||||
// Presets: keyed by id (filename); duplicate display names are allowed.
|
||||
@@ -5232,6 +5304,10 @@ async function saveItem(){
|
||||
: {name, old: editingKey, content};
|
||||
const r = await jpost(K.saveUrl, payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
// Preset EN SERVICE modifié : le serveur l'a réappliqué et relance le moteur
|
||||
// (SavePresetApplying). On le dit, et on rafraîchit l'état global — la carte
|
||||
// du chat doit montrer le nouveau contexte, pas l'ancien.
|
||||
if(r.applied){ toast('enregistré et appliqué — redémarrage…'); closeModal(); K.reload(); if(typeof loadAll==='function') loadAll(); return; }
|
||||
toast('enregistré'); closeModal(); K.reload();
|
||||
}
|
||||
async function delItem(){
|
||||
@@ -6410,11 +6486,23 @@ function syncSendBtn(){
|
||||
// répond pas du tout) — dans le doute on laisse la main.
|
||||
const ready = !STATUS_SEEN || MODEL_READY;
|
||||
sb.disabled = !ready;
|
||||
sb.title = ready ? '' : 'le modèle n\'est pas encore chargé';
|
||||
// Moteur ARRÊTÉ (VRAM libérée à la demande, ou service coupé) : dire « le
|
||||
// modèle charge » serait faux, et on attendrait un chargement qui ne viendra
|
||||
// jamais. On nomme alors le geste qui remet le modèle en mémoire.
|
||||
// Trois raisons de ne pas être prêt, trois messages : planté au chargement
|
||||
// (recharger ne ferait que replanter — la cause est affichée dans le
|
||||
// moniteur), déchargé à la demande, ou simplement en train de charger.
|
||||
const failed = !ready && !!LOAD_ERROR;
|
||||
const unloaded = !ready && !failed && !ENGINE_ACTIVE;
|
||||
sb.title = ready ? '' : failed ? 'le modèle n\'a pas pu charger — voir l\'erreur dans le moniteur'
|
||||
: (unloaded ? 'le modèle est déchargé — recharge-le pour écrire'
|
||||
: 'le modèle n\'est pas encore chargé');
|
||||
const hint=document.getElementById('sendhint');
|
||||
if(hint){
|
||||
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
|
||||
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
|
||||
: failed ? 'Le modèle n\'a pas pu charger — l\'erreur est affichée dans le moniteur, en bas de la barre latérale.'
|
||||
: unloaded ? 'Modèle déchargé — « Recharger le modèle » dans le moniteur, en bas de la barre latérale.'
|
||||
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
|
||||
hint.classList.toggle('waiting', !ready);
|
||||
}
|
||||
}
|
||||
@@ -6587,7 +6675,8 @@ function handleDelta(d){
|
||||
if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
|
||||
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
|
||||
paintTurnClock(); // fige le total au-dessus de la carte
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
||||
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||
@@ -6625,7 +6714,8 @@ function handleDelta(d){
|
||||
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
||||
// tour (collapseAll) la fermerait quand même.
|
||||
if(tu.image && T.pendingToolEl){ const i=T.turnCollapsibles.indexOf(T.pendingToolEl); if(i>=0) T.turnCollapsibles.splice(i,1); }
|
||||
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||||
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem();
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); }
|
||||
return; }
|
||||
if(d.drop_reasoning){
|
||||
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
||||
@@ -7842,6 +7932,7 @@ window.addEventListener('drop', e=>{
|
||||
// rouge, elle dit déjà ce qui s'est passé.
|
||||
async function attachPaths(){
|
||||
await Promise.all(ATTACH.filter(a=>a.state==='queued').map(uploadAttach));
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); // les dépôts apparaissent dans le panneau Fichiers
|
||||
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
|
||||
}
|
||||
renderAttach();
|
||||
@@ -8110,6 +8201,21 @@ function filesOnConvChange(){
|
||||
if(FILES_OPEN) loadFiles('');
|
||||
}
|
||||
|
||||
// Quelque chose a pu changer sur disque : un outil vient de rendre son résultat
|
||||
// (l'agent écrit un rapport, une capture, un script), le tour est fini, une
|
||||
// pièce jointe vient d'être déposée. Le panneau se redessine tout seul —
|
||||
// jusqu'ici il fallait cliquer « rafraîchir » pour voir apparaître ce que
|
||||
// l'agent venait d'écrire. Regroupé : une rafale d'appels d'outils ne
|
||||
// déclenche qu'une lecture. Panneau fermé ou rejeu du journal : rien à faire,
|
||||
// l'ouverture recharge de toute façon.
|
||||
let filesRefreshT = 0;
|
||||
function filesOnActivity(){
|
||||
if(!FILES_OPEN) return;
|
||||
if(typeof REPLAYING !== 'undefined' && REPLAYING) return;
|
||||
clearTimeout(filesRefreshT);
|
||||
filesRefreshT = setTimeout(()=>{ filesRefreshT = 0; loadFiles(); }, 400);
|
||||
}
|
||||
|
||||
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
|
||||
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
|
||||
document.addEventListener('DOMContentLoaded', initFiles);
|
||||
@@ -9170,6 +9276,7 @@ let SET_LAST = 'config';
|
||||
// l'ancien ontoggle des <details>.
|
||||
const SET_HOOKS = {
|
||||
'engine-log': () => { loadSvcLog(); showPaths(); },
|
||||
'dictee': () => { loadDictateSettings(); },
|
||||
};
|
||||
function openSettings(pane){
|
||||
showModal('settings-modal');
|
||||
@@ -9246,7 +9353,7 @@ async function dictateStop(){
|
||||
// renvoie la raison. La cacher derrière « 0 % » laissait l'utilisateur
|
||||
// cliquer indéfiniment sur un micro qui n'écrira jamais rien.
|
||||
toast(j.error ? 'modèle de dictée : échec du téléchargement — ' + j.error
|
||||
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — réessaie dans un instant');
|
||||
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — suivi dans Paramètres → Dictée');
|
||||
return;
|
||||
}
|
||||
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
|
||||
@@ -9283,4 +9390,158 @@ function encodeWav16k(chunks, fromRate){
|
||||
new Int16Array(buf, 44).set(pcm);
|
||||
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/*.
|
||||
let DIC_CFG = null;
|
||||
|
||||
async function loadDictateSettings(){
|
||||
const [cfg, modeles, gpus] = 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.
|
||||
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.value = cfg.model || '';
|
||||
|
||||
document.getElementById('dic-lang').value = cfg.lang || 'fr';
|
||||
document.getElementById('dic-react').value = cfg.reactivity || 'moyen';
|
||||
refreshDictateState();
|
||||
}
|
||||
|
||||
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');
|
||||
el.textContent = 'enregistrement…';
|
||||
const r = await jfetch('/api/dictate/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfg)});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
|
||||
DIC_CFG = cfg;
|
||||
// Le serveur de dictée a été arrêté côté Loki : le prochain clic sur le
|
||||
// micro le relancera avec ces réglages. On le dit, sinon la première dictée
|
||||
// suivante paraît anormalement lente sans raison visible.
|
||||
el.textContent = 'enregistré — la prochaine dictée rechargera le modèle';
|
||||
refreshDictateState();
|
||||
}
|
||||
|
||||
async function dictateDownload(){
|
||||
const id = document.getElementById('dic-model').value;
|
||||
const el = document.getElementById('dic-dl-state');
|
||||
el.textContent = 'démarrage…';
|
||||
const r = await jfetch('/api/dictate/models/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
|
||||
suivreTelechargement();
|
||||
}
|
||||
|
||||
// Sondage tant que le téléchargement tourne. Un modèle de 1 Go prend plusieurs
|
||||
// minutes : sans progression affichée, l'utilisateur ne sait pas s'il se passe
|
||||
// quelque chose.
|
||||
async function suivreTelechargement(){
|
||||
const el = document.getElementById('dic-dl-state');
|
||||
const r = await jfetch('/api/dictate/models/download');
|
||||
const j = await r.json().catch(()=>({}));
|
||||
if(j.error){ el.textContent = 'échec : ' + j.error; return; }
|
||||
if(j.running){
|
||||
el.textContent = `téléchargement de ${j.id} — ${j.pct||0} %`;
|
||||
setTimeout(suivreTelechargement, 1000);
|
||||
return;
|
||||
}
|
||||
el.textContent = 'téléchargé';
|
||||
loadDictateSettings();
|
||||
}
|
||||
|
||||
async function refreshDictateState(){
|
||||
const el = document.getElementById('dic-state');
|
||||
if(!el) return;
|
||||
const j = await jfetch('/api/dictate/state').then(r => r.json()).catch(()=>null);
|
||||
if(!j){ el.textContent = ''; return; }
|
||||
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);
|
||||
if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`);
|
||||
el.textContent = bouts.join(' · ');
|
||||
}
|
||||
|
||||
// Test du micro : capte 3 s, mesure le niveau réel et transcrit, SANS toucher
|
||||
// au champ de saisie. C'est le diagnostic qu'il fallait bricoler à la main
|
||||
// quand la dictée ne rendait rien — niveau nul, micro muet, serveur en panne
|
||||
// se ressemblent tous de l'extérieur.
|
||||
async function dictateTestMic(){
|
||||
const out = document.getElementById('dic-test-out');
|
||||
const btn = document.getElementById('dic-test');
|
||||
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
|
||||
out.textContent = 'micro bloqué par le navigateur : il faut HTTPS (ou localhost)';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
out.textContent = 'écoute… parle maintenant (3 s)';
|
||||
let stream;
|
||||
try{
|
||||
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
|
||||
}catch(e){ out.textContent = 'accès au micro refusé'; btn.disabled = false; return; }
|
||||
const ctx = new (window.AudioContext||window.webkitAudioContext)();
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
const node = ctx.createScriptProcessor(4096, 1, 1);
|
||||
const chunks = [];
|
||||
node.onaudioprocess = e => chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
|
||||
src.connect(node); node.connect(ctx.destination);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
try{ src.disconnect(); node.disconnect(); }catch(_){}
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
const rate = ctx.sampleRate;
|
||||
try{ ctx.close(); }catch(_){}
|
||||
|
||||
let n = 0; for(const c of chunks) n += c.length;
|
||||
let somme = 0, crete = 0;
|
||||
for(const c of chunks) for(let i = 0; i < c.length; i++){
|
||||
const v = c[i]; somme += v*v;
|
||||
const a = Math.abs(v); if(a > crete) crete = a;
|
||||
}
|
||||
const rms = Math.sqrt(somme / (n||1));
|
||||
const niveau = rms < 0.002 ? 'QUASI MUET' : rms < 0.01 ? 'faible' : 'correct';
|
||||
out.textContent = `niveau ${niveau} (rms ${rms.toFixed(4)}, crête ${crete.toFixed(3)}) — transcription…`;
|
||||
|
||||
const wav = encodeWav16k(chunks, rate);
|
||||
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body: wav});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
const tete = `niveau ${niveau} (rms ${rms.toFixed(4)}) — `;
|
||||
if(!r.ok) out.textContent = tete + (j.error || 'échec de la transcription');
|
||||
else if(!(j.text||'').trim()) out.textContent = tete + 'rien compris' + (rms < 0.002 ? ' (le micro n’a rien capté : vérifie l’entrée choisie par le système)' : '');
|
||||
else out.textContent = tete + '« ' + j.text.trim() + ' »';
|
||||
btn.disabled = false;
|
||||
refreshDictateState();
|
||||
}
|
||||
</script></body></html>
|
||||
@@ -90,7 +90,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
|
||||
bloc entier se replie d'un clic. -->
|
||||
<div id="side-perf">
|
||||
<div class="perf-head"><span>Performance</span></div>
|
||||
<!-- « Libérer la VRAM » est posé LÀ, sur les jauges : c'est en les regardant
|
||||
qu'on décide de rendre la carte à une autre application. Le libellé
|
||||
bascule en « Recharger le modèle » dès que le moteur est arrêté — voir
|
||||
paintVramBtn (js/05-status.js). -->
|
||||
<div class="perf-head"><span>Performance</span><button id="vram-btn" class="perf-act" onclick="vramBtnClick()" title="décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine">Libérer la VRAM</button></div>
|
||||
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -194,7 +198,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
||||
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
||||
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles), puis se redessine seul
|
||||
à chaque résultat d'outil, fin de tour ou dépôt (filesOnActivity) — un ReadDir à chaque
|
||||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||||
utile à personne. -->
|
||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||
@@ -253,6 +258,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div class="set-nav-h">Moteur</div>
|
||||
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
|
||||
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
|
||||
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
|
||||
<div class="set-nav-h">Application</div>
|
||||
<button data-pane="identity" onclick="settingsShow('identity')">Identité</button>
|
||||
<button data-pane="appearance" onclick="settingsShow('appearance')">Apparence</button>
|
||||
@@ -449,18 +455,76 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<pre id="lc-log" hidden></pre>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ces trois boutons pilotent le service qui fait tourner llama-server :
|
||||
leur place est ici, avec le moteur, et pas dans une section « Service »
|
||||
à part qui ne contenait qu'eux. -->
|
||||
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).</span></span></div>
|
||||
<!-- Ces boutons pilotent le service qui fait tourner llama-server : leur
|
||||
place est ici, avec le moteur, et pas dans une section « Service » à
|
||||
part qui ne contenait qu'eux. Le dernier, « libérer la VRAM », fait
|
||||
plus qu'arrêter : il éteint aussi la dictée et annonce la mémoire
|
||||
vidéo rendue (c'est le même geste que le bouton du moniteur). -->
|
||||
<div class="subhead">Service du moteur<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Démarre, redémarre ou arrête <b>llama-server</b> — le processus qui charge le modèle et répond. Un redémarrage recharge le modèle du preset actif (quelques secondes à quelques minutes selon sa taille).<br><b>Libérer la VRAM</b> arrête le moteur <i>et</i> la dictée, puis annonce la mémoire vidéo rendue : c'est ce qu'il faut quand une autre application réclame la carte. Le même bouton vit sur les jauges, en pied de barre latérale.</span></span></div>
|
||||
<div class="slist"><div class="rowbody">
|
||||
<div class="row" style="margin:0">
|
||||
<button onclick="act('start')">démarrer</button>
|
||||
<button onclick="act('restart')">redémarrer</button>
|
||||
<button onclick="act('stop')">arrêter</button>
|
||||
<button onclick="vramUnload(false)" title="décharge le modèle et rend la mémoire vidéo à la machine">libérer la VRAM</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</section>
|
||||
<!-- Dictée vocale. Réglages de SERVEUR : le modèle chargé et le GPU occupé
|
||||
sont des propriétés de la machine, identiques pour tous les onglets.
|
||||
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="slist">
|
||||
<label class="switchrow"><span>modèle</span>
|
||||
<select id="dic-model" class="sctl"></select>
|
||||
</label>
|
||||
<div class="rowbody">
|
||||
<button id="dic-dl" class="btn" onclick="dictateDownload()">Télécharger ce modèle</button>
|
||||
<span id="dic-dl-state" class="dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Reconnaissance</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>
|
||||
<option value="moyen">moyen</option>
|
||||
<option value="long">long</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="rowbody">
|
||||
<button class="btn primary" onclick="dictateSave()">Enregistrer</button>
|
||||
<span id="dic-save-state" class="dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Vérifier</div>
|
||||
<div class="slist">
|
||||
<div class="rowbody">
|
||||
<button id="dic-test" class="btn" onclick="dictateTestMic()">Tester le micro (3 s)</button>
|
||||
</div>
|
||||
<div id="dic-test-out" class="dim"></div>
|
||||
<div id="dic-state" class="dim"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
|
||||
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
|
||||
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
|
||||
@@ -938,31 +1002,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Moteur</div>
|
||||
<!-- « Image » n'apparaît qu'en conteneur (moteur fourni), où
|
||||
« Précompilé » et « Compilé » sont au contraire masqués : ils
|
||||
désignent des installations que Loki ne fait pas ici. -->
|
||||
<div class="pe-seg">
|
||||
<label id="be-lab-image" style="display:none"><input type="radio" name="m-be" value="image" onchange="onBackendMode('image')"><span>Image</span><span class="be-note" id="be-image-note"></span></label>
|
||||
<label id="be-lab-fast"><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Précompilé</span><span class="be-note" id="be-fast-note"></span></label>
|
||||
<label id="be-lab-opt"><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Compilé</span><span class="be-note" id="be-opt-note"></span></label>
|
||||
<label id="be-lab-custom"><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
|
||||
</div>
|
||||
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Backend</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="m-backend-detected" onchange="onPickDetected()"></select></span></span>
|
||||
</div>
|
||||
<div class="pe-row stack">
|
||||
<input id="m-backend-path" class="pe-inp" oninput="onCustomPath()" placeholder="ou un chemin vers llama-server(.exe)">
|
||||
<div class="pe-note" id="be-drop-hint"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cartes graphiques : la liste vient du moteur choisi juste au-dessus,
|
||||
car les noms ET l'ordre dépendent du backend compilé dedans. Le
|
||||
<!-- Cartes graphiques : la liste vient du moteur de la machine (celui de
|
||||
l'image ou celui mis à jour dans Réglages → Moteur — le moteur n'est
|
||||
pas un réglage de preset), car les noms ET l'ordre dépendent du backend
|
||||
compilé dedans. Le
|
||||
groupe reste masqué tant qu'on n'a pas AU MOINS DEUX cartes : avec
|
||||
une seule, il n'y a rien à arbitrer. -->
|
||||
<div class="pe-group" id="m-gpu-group" hidden>
|
||||
|
||||
@@ -18,6 +18,14 @@ async function loadStatus(){
|
||||
el.className='statuspill '+cls;
|
||||
el.innerHTML='<span class="dot"></span>'+txt;
|
||||
MODEL_READY = !!(s.active && s.health);
|
||||
// Le bouton « Libérer la VRAM » suit l'état RÉEL du moteur (et pas un drapeau
|
||||
// local) : un arrêt venu d'un autre onglet, du terminal ou d'un job doit se
|
||||
// lire ici aussi, sinon le bouton propose de libérer une carte déjà libre.
|
||||
ENGINE_ACTIVE = !!s.active;
|
||||
// Un moteur qui a PLANTÉ au chargement n'est pas « déchargé » : sans ce
|
||||
// drapeau, syncSendBtn proposerait de recharger un modèle qui va replanter.
|
||||
LOAD_ERROR = s.load_error || '';
|
||||
paintVramBtn();
|
||||
// Le bouton d'envoi suit l'état du moteur : inutile de pouvoir envoyer un
|
||||
// message à un modèle qui n'est pas encore chargé (voir syncSendBtn).
|
||||
STATUS_SEEN = true;
|
||||
@@ -182,6 +190,83 @@ async function loadVram(){
|
||||
g.name+' · '+vram));
|
||||
});
|
||||
}
|
||||
// ─── Libérer la mémoire vidéo ───────────────────────────────────────────────
|
||||
// Tant que le moteur tourne, le modèle occupe la VRAM : une autre application
|
||||
// qui réclame la carte (un jeu, un encodage, un autre serveur d'inférence) ne
|
||||
// trouve plus rien à prendre. Ce bouton l'en sort, et le MÊME bouton la
|
||||
// reprend ensuite — le geste existait déjà (« arrêter » le service, au fond des
|
||||
// réglages), mais son nom ne disait pas qu'il rendait la carte.
|
||||
//
|
||||
// ENGINE_ACTIVE vient de /api/status (relu toutes les 5 s), jamais d'un drapeau
|
||||
// posé au clic : sinon un second onglet, ou un arrêt venu du terminal,
|
||||
// afficherait un bouton qui ment.
|
||||
let ENGINE_ACTIVE = true, VRAM_BUSY = false, LOAD_ERROR = '';
|
||||
function paintVramBtn(){
|
||||
const b = document.getElementById('vram-btn');
|
||||
if(!b || VRAM_BUSY) return; // pendant l'opération, le libellé appartient à vramBusy
|
||||
b.disabled = false;
|
||||
b.classList.toggle('reload', !ENGINE_ACTIVE);
|
||||
b.textContent = ENGINE_ACTIVE ? 'Libérer la VRAM' : 'Recharger le modèle';
|
||||
b.title = ENGINE_ACTIVE
|
||||
? 'décharge le modèle et arrête le moteur — la mémoire vidéo repart à la machine'
|
||||
: 'relance le moteur : le modèle revient en mémoire vidéo';
|
||||
}
|
||||
function vramBtnClick(){ return ENGINE_ACTIVE ? vramUnload(false) : vramReload(); }
|
||||
function vramBusy(on, label){
|
||||
VRAM_BUSY = on;
|
||||
const b = document.getElementById('vram-btn');
|
||||
if(b){ b.disabled = on; if(on && label) b.textContent = label; }
|
||||
if(!on) paintVramBtn();
|
||||
}
|
||||
// force=true : on a déjà demandé confirmation ET accepté de couper une réponse
|
||||
// en cours d'écriture (le serveur refuse sans ça, voir handleVramUnload).
|
||||
// Les deux gestes sont exclusifs : un second clic (le bouton des réglages
|
||||
// pendant que celui du moniteur travaille) lancerait un « stop » au milieu d'un
|
||||
// démarrage, ou l'inverse. VRAM_BUSY tient lieu de verrou.
|
||||
async function vramUnload(force){
|
||||
if(VRAM_BUSY) return;
|
||||
if(!force){
|
||||
const ok = await askConfirm(
|
||||
'Décharger le modèle ? Le moteur s\'arrête et rend la mémoire vidéo — le chat reste indisponible jusqu\'au rechargement.',
|
||||
{title:'Libérer la VRAM', okText:'Libérer'});
|
||||
if(!ok) return;
|
||||
}
|
||||
vramBusy(true, 'libération…');
|
||||
let r = null;
|
||||
try{ r = await jpost('/api/vram/unload', {force: !!force}); }catch(_){}
|
||||
// Le serveur dit l'état RÉEL après coup : le repeindre depuis l'ancien
|
||||
// ENGINE_ACTIVE (relu seulement toutes les 5 s) afficherait « Libérer » sur
|
||||
// un moteur déjà arrêté.
|
||||
if(r && typeof r.active === 'boolean') ENGINE_ACTIVE = r.active;
|
||||
vramBusy(false);
|
||||
if(r && r.generating){
|
||||
const ok = await askConfirm('Une réponse est en cours d\'écriture : décharger maintenant l\'interrompt. Continuer ?',
|
||||
{title:'Génération en cours', okText:'Décharger quand même'});
|
||||
if(ok) return vramUnload(true);
|
||||
return;
|
||||
}
|
||||
// La mesure d'abord : la dictée peut avoir rendu de la VRAM alors même que le
|
||||
// moteur était déjà arrêté — annoncer « déjà arrêté » nierait ce qui vient
|
||||
// d'être libéré. Sous quelques dizaines de Mo, c'est le bruit du bureau entre
|
||||
// deux lectures, pas une libération : on ne l'annonce pas.
|
||||
if(!r || !r.ok) toast('échec : ' + ((r && r.error) || 'réseau'));
|
||||
else if(r.gpu && r.freed_mb >= 64) toast('VRAM libérée : ' + fmtMo(r.freed_mb));
|
||||
else if(!r.was_active) toast('le moteur était déjà arrêté');
|
||||
else toast('modèle déchargé');
|
||||
loadAll();
|
||||
}
|
||||
function fmtMo(mb){ return mb >= 1024 ? (mb/1024).toFixed(1) + ' Gio' : mb + ' Mo'; }
|
||||
async function vramReload(){
|
||||
if(VRAM_BUSY) return;
|
||||
vramBusy(true, 'démarrage…');
|
||||
let r = null;
|
||||
try{ r = await jpost('/api/vram/reload', {}); }catch(_){}
|
||||
if(r && r.ok) ENGINE_ACTIVE = true;
|
||||
vramBusy(false);
|
||||
if(!r || !r.ok){ toast('échec : ' + ((r && r.error) || 'réseau')); return; }
|
||||
toast('modèle en cours de chargement…');
|
||||
loadAll();
|
||||
}
|
||||
async function loadRam(){
|
||||
const m=await jget('/api/ram');
|
||||
const box=document.getElementById('ram-details');
|
||||
|
||||
@@ -161,7 +161,7 @@ async function openItem(kind, key){
|
||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||
populateSettings();
|
||||
attachDownload(); // téléchargement encore en cours côté serveur ?
|
||||
await Promise.all([populateBackend(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
await Promise.all([loadGpuDevices(), populateModelPicker(), populateMmproj(), populateDlDirs()]);
|
||||
if(seq !== openSeq) return;
|
||||
}
|
||||
// On lève le voile SANS transition (voir la note dans styles.css), puis on rend
|
||||
@@ -433,111 +433,18 @@ async function populateMmproj(){
|
||||
sel.innerHTML = html;
|
||||
}
|
||||
|
||||
// Choix du moteur PAR MODÈLE : 3 options (précompilé / compilé / personnalisé)
|
||||
// qui réécrivent la ligne BIN= du preset. C'est LE point où on décide quel
|
||||
// backend fait tourner ce modèle — la barre latérale ne fait qu'installer.
|
||||
function currentBinInTextarea(){
|
||||
return readEnvKey(document.getElementById('m-content').value, 'BIN');
|
||||
}
|
||||
// Compare deux chemins de binaire en neutralisant séparateurs et casse (Windows).
|
||||
// Sert à la ligne MOTEUR du panneau d'état (05-status.js).
|
||||
function sameBinPath(a, b){
|
||||
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
||||
return !!a && !!b && n(a) === n(b);
|
||||
}
|
||||
// Écrit (ou remplace) la ligne BIN= dans le contenu du preset.
|
||||
function setBinInTextarea(val){
|
||||
const ta = document.getElementById('m-content');
|
||||
if(/^\s*BIN\s*=.*$/m.test(ta.value)) ta.value = ta.value.replace(/^\s*BIN\s*=.*$/m, 'BIN="'+val+'"');
|
||||
else ta.value = 'BIN="'+val+'"\n' + ta.value;
|
||||
}
|
||||
// Le moteur précompilé s'installe dans un dossier versionné (…/llama-b10280/) :
|
||||
// le BIN d'un preset écrit avant une mise à jour ne vaut plus le chemin courant,
|
||||
// alors qu'il désigne bien ce moteur. On reconnaît donc l'appartenance au
|
||||
// dossier, sinon tous les presets retombaient en « personnalisé » à chaque MAJ.
|
||||
function underDir(p, dir){
|
||||
const n = x => String(x||'').replace(/\\/g,'/').replace(/\/+$/,'').toLowerCase();
|
||||
return !!p && !!dir && n(p).startsWith(n(dir)+'/');
|
||||
}
|
||||
let beFastPath = '', beOptPath = '', beFastDir = '', beImagePath = '';
|
||||
async function populateBackend(){
|
||||
// Chemins des deux moteurs gérés + liste des backends détectés (dossier loki).
|
||||
let lc = {}; try{ lc = await jget('/api/llamacpp'); }catch(_){}
|
||||
beFastPath = (lc.prebuilt && lc.prebuilt.bin) || '';
|
||||
beFastDir = (lc.prebuilt && lc.prebuilt.dir) || '';
|
||||
beOptPath = lc.bin || '';
|
||||
// Conteneur : le moteur vient de l'image. « Précompilé » et « Compilé »
|
||||
// désignent des installations que Loki ne fait pas ici — les proposer ne
|
||||
// menait qu'à « installez d'abord… ». On les remplace par « Image ».
|
||||
beImagePath = lc.provided ? (lc.provided_bin || '') : '';
|
||||
document.getElementById('be-lab-image').style.display = beImagePath ? '' : 'none';
|
||||
document.getElementById('be-lab-fast').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-lab-opt').style.display = beImagePath ? 'none' : '';
|
||||
// En conteneur, « Personnalisé » ne mène nulle part (rien à compiler, et le
|
||||
// moteur se met à jour par la carte Moteur des réglages) : on le retire.
|
||||
document.getElementById('be-lab-custom').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-image-note').textContent = beImagePath ? 'llama.cpp officiel' : '';
|
||||
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
|
||||
// le dossier backends de loki (l'utilisateur peut y déposer son propre build).
|
||||
const detected = await jget('/api/backends');
|
||||
const sel = document.getElementById('m-backend-detected');
|
||||
let html = '<option value="">— ou choisir un backend détecté —</option>';
|
||||
for(const b of (detected||[])) html += '<option value="'+b.path+'">'+b.name+'</option>';
|
||||
sel.innerHTML = html;
|
||||
document.getElementById('be-drop-hint').textContent = lc.backends_dir
|
||||
? ('Astuce : déposez votre binaire dans un sous-dossier de '+lc.backends_dir+' pour le voir apparaître ci-dessus.') : '';
|
||||
// Sélectionne l'option correspondant au BIN actuel du preset.
|
||||
const cur = currentBinInTextarea();
|
||||
let mode = 'custom';
|
||||
// Un preset sans BIN hérite du moteur global : en conteneur c'est celui de
|
||||
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ». Et comme
|
||||
// l'option Personnalisé n'y est plus proposée, TOUT BIN non reconnu retombe
|
||||
// aussi sur l'image (un vieux preset pointant un chemin mort redevient sain).
|
||||
if(beImagePath && (sameBinPath(cur, beImagePath) || !cur)) mode = 'image';
|
||||
else if(beImagePath) mode = 'image';
|
||||
else if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
|
||||
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
|
||||
const radio = document.querySelector('input[name=m-be][value='+mode+']');
|
||||
if(radio) radio.checked = true;
|
||||
toggleBackendCustom(mode);
|
||||
if(mode === 'custom') document.getElementById('m-backend-path').value = cur;
|
||||
// Attendu (et non lancé dans le vide) : la liste des GPU change la hauteur du
|
||||
// bloc « moteur ». Sans ce await, elle arrivait APRÈS la levée du voile de
|
||||
// chargement et on voyait le bloc bouger tout seul.
|
||||
await loadGpuDevices();
|
||||
}
|
||||
function toggleBackendCustom(mode){
|
||||
document.getElementById('m-backend-custom').style.display = (mode==='custom') ? 'block' : 'none';
|
||||
}
|
||||
function onBackendMode(mode){
|
||||
toggleBackendCustom(mode);
|
||||
if(mode === 'image'){
|
||||
if(!beImagePath){ toast('aucun moteur fourni par l\'image'); return; }
|
||||
setBinInTextarea(beImagePath); toast('moteur : llama.cpp de l\'image');
|
||||
loadGpuDevices();
|
||||
} else if(mode === 'fast'){
|
||||
if(!beFastPath){ toast('installez d\'abord llama.cpp précompilé (section MOTEUR)'); return; }
|
||||
setBinInTextarea(beFastPath); toast('moteur : llama.cpp précompilé');
|
||||
loadGpuDevices();
|
||||
} else if(mode === 'opt'){
|
||||
if(!beOptPath){ toast('installez d\'abord llama.cpp compilé (section MOTEUR)'); return; }
|
||||
setBinInTextarea(beOptPath); toast('moteur : llama.cpp compilé');
|
||||
loadGpuDevices();
|
||||
}
|
||||
// custom : on attend que l'utilisateur saisisse un chemin / choisisse un backend
|
||||
}
|
||||
function onCustomPath(){
|
||||
const v = document.getElementById('m-backend-path').value.trim();
|
||||
if(v){ setBinInTextarea(v); loadGpuDevices(); }
|
||||
}
|
||||
function onPickDetected(){
|
||||
const v = document.getElementById('m-backend-detected').value;
|
||||
if(!v) return;
|
||||
document.getElementById('m-backend-path').value = v;
|
||||
setBinInTextarea(v); toast('moteur personnalisé');
|
||||
loadGpuDevices();
|
||||
}
|
||||
|
||||
// --- Cartes graphiques (--device / --tensor-split) --------------------------
|
||||
// Le moteur n'est PAS un réglage de preset : c'est celui de la machine (l'image,
|
||||
// ou la version mise à jour dans Réglages → Moteur), et applyPresetFile le
|
||||
// conserve à la bascule. L'éditeur n'offre donc plus de choix de moteur — il
|
||||
// interroge celui qui tourne pour lister les cartes.
|
||||
// Ces réglages vivent dans le PRESET, pas dans une variable d'environnement :
|
||||
// --device est compris par TOUS les backends (CUDA, Vulkan, ROCm), alors que
|
||||
// CUDA_VISIBLE_DEVICES n'a aucun effet sur un moteur Vulkan — d'où des modèles
|
||||
@@ -551,14 +458,18 @@ const GPU_THUMB = 16;
|
||||
let gpuDevices = [];
|
||||
const gpuCache = {}; // bin -> devices : évite de relancer le moteur à chaque ouverture
|
||||
|
||||
// Interroge le moteur du preset (--list-devices) : les noms de device et leur
|
||||
// ordre lui appartiennent, une liste issue de nvidia-smi désignerait la mauvaise
|
||||
// carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la réponse n'est
|
||||
// pas là, et le reste s'il y a moins de deux cartes : rien à arbitrer, et ça
|
||||
// évite un encart qui surgit après coup.
|
||||
// Interroge le moteur de la machine (--list-devices) : les noms de device et
|
||||
// leur ordre lui appartiennent, une liste issue de nvidia-smi désignerait la
|
||||
// mauvaise carte sur un backend Vulkan. Le groupe reste MASQUÉ tant que la
|
||||
// réponse n'est pas là, et le reste s'il y a moins de deux cartes : rien à
|
||||
// arbitrer, et ça évite un encart qui surgit après coup.
|
||||
// Chemin du moteur courant (config_bin de /api/llamacpp), mémorisé au
|
||||
// préchauffage : l'éditeur s'en sert sans refaire l'appel.
|
||||
let engineBin = '';
|
||||
// Remplit le cache sans rien peindre (appelé au chargement de la page pour le
|
||||
// moteur actif) : à l'ouverture de l'éditeur, l'encart est déjà prêt.
|
||||
async function prefetchGpuDevices(bin){
|
||||
if(bin) engineBin = bin;
|
||||
if(!bin || gpuCache[bin]) return;
|
||||
try{
|
||||
const r = await jpost('/api/backends/devices', {bin});
|
||||
@@ -566,16 +477,24 @@ async function prefetchGpuDevices(bin){
|
||||
}catch(_){}
|
||||
}
|
||||
|
||||
// Moteur courant : ce que le préchauffage a vu, sinon on le demande (éditeur
|
||||
// ouvert avant que le statut llama.cpp ne soit arrivé).
|
||||
async function currentEngineBin(){
|
||||
if(engineBin) return engineBin;
|
||||
try{ const lc = await jget('/api/llamacpp'); engineBin = lc.config_bin || ''; }catch(_){}
|
||||
return engineBin;
|
||||
}
|
||||
|
||||
async function loadGpuDevices(){
|
||||
const group = document.getElementById('m-gpu-group');
|
||||
const bin = currentBinInTextarea();
|
||||
const bin = await currentEngineBin();
|
||||
if(!bin){ group.hidden = true; gpuDevices = []; return; }
|
||||
if(gpuCache[bin]){ gpuDevices = gpuCache[bin]; renderGpu(); return; }
|
||||
let r = {};
|
||||
try{ r = await jpost('/api/backends/devices', {bin}); }catch(_){ r = {ok:false}; }
|
||||
// Le moteur a pu changer pendant l'appel (clic rapide) : on ne peint que si la
|
||||
// réponse concerne toujours le moteur affiché.
|
||||
if(currentBinInTextarea() !== bin) return;
|
||||
// Le moteur a pu changer pendant l'appel (mise à jour en cours) : on ne peint
|
||||
// que si la réponse concerne toujours le moteur courant.
|
||||
if(engineBin !== bin) return;
|
||||
gpuDevices = (r.ok && r.devices) ? r.devices : [];
|
||||
if(r.ok) gpuCache[bin] = gpuDevices;
|
||||
renderGpu();
|
||||
@@ -908,6 +827,21 @@ function closeModal(){ hideModal('modal'); document.getElementById('modal').clas
|
||||
async function saveItem(){
|
||||
const K = KINDS[editingKind];
|
||||
const name = document.getElementById('m-name').value.trim();
|
||||
// Interrupteur « Raisonnement » : on matérialise son état dans le preset AVANT
|
||||
// de lire le contenu. onchange ne part que sur une interaction : un switch
|
||||
// laissé sur off à la création n'écrivait donc jamais REASONING=, et la clé
|
||||
// ABSENTE laisse le moteur raisonner malgré l'interrupteur affiché sur off
|
||||
// (amont, issue #46). Décoché → REASONING=off explicite. Coché → on, sauf si
|
||||
// une valeur active plus précise (auto/deepseek) est déjà là : on la garde.
|
||||
if(editingKind==='preset'){
|
||||
const rsw = document.getElementById('s-reasoning');
|
||||
if(rsw){
|
||||
const cur = cfgReadKey('REASONING');
|
||||
const active = /^(on|1|true|auto|deepseek)$/i.test(cur);
|
||||
if(!rsw.checked){ if(!/^off$/i.test(cur)) cfgWriteKey('REASONING', 'off'); }
|
||||
else if(!active){ cfgWriteKey('REASONING', 'on'); }
|
||||
}
|
||||
}
|
||||
const content = document.getElementById('m-content').value;
|
||||
if(!name){ toast('nom requis'); return; }
|
||||
// Presets: keyed by id (filename); duplicate display names are allowed.
|
||||
@@ -917,6 +851,10 @@ async function saveItem(){
|
||||
: {name, old: editingKey, content};
|
||||
const r = await jpost(K.saveUrl, payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
// Preset EN SERVICE modifié : le serveur l'a réappliqué et relance le moteur
|
||||
// (SavePresetApplying). On le dit, et on rafraîchit l'état global — la carte
|
||||
// du chat doit montrer le nouveau contexte, pas l'ancien.
|
||||
if(r.applied){ toast('enregistré et appliqué — redémarrage…'); closeModal(); K.reload(); if(typeof loadAll==='function') loadAll(); return; }
|
||||
toast('enregistré'); closeModal(); K.reload();
|
||||
}
|
||||
async function delItem(){
|
||||
|
||||
@@ -277,11 +277,23 @@ function syncSendBtn(){
|
||||
// répond pas du tout) — dans le doute on laisse la main.
|
||||
const ready = !STATUS_SEEN || MODEL_READY;
|
||||
sb.disabled = !ready;
|
||||
sb.title = ready ? '' : 'le modèle n\'est pas encore chargé';
|
||||
// Moteur ARRÊTÉ (VRAM libérée à la demande, ou service coupé) : dire « le
|
||||
// modèle charge » serait faux, et on attendrait un chargement qui ne viendra
|
||||
// jamais. On nomme alors le geste qui remet le modèle en mémoire.
|
||||
// Trois raisons de ne pas être prêt, trois messages : planté au chargement
|
||||
// (recharger ne ferait que replanter — la cause est affichée dans le
|
||||
// moniteur), déchargé à la demande, ou simplement en train de charger.
|
||||
const failed = !ready && !!LOAD_ERROR;
|
||||
const unloaded = !ready && !failed && !ENGINE_ACTIVE;
|
||||
sb.title = ready ? '' : failed ? 'le modèle n\'a pas pu charger — voir l\'erreur dans le moniteur'
|
||||
: (unloaded ? 'le modèle est déchargé — recharge-le pour écrire'
|
||||
: 'le modèle n\'est pas encore chargé');
|
||||
const hint=document.getElementById('sendhint');
|
||||
if(hint){
|
||||
hint.textContent = ready ? 'Entrée pour envoyer · Maj+Entrée = nouvelle ligne'
|
||||
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
|
||||
: failed ? 'Le modèle n\'a pas pu charger — l\'erreur est affichée dans le moniteur, en bas de la barre latérale.'
|
||||
: unloaded ? 'Modèle déchargé — « Recharger le modèle » dans le moniteur, en bas de la barre latérale.'
|
||||
: 'Le modèle charge — envoi possible dès qu\'il est prêt.';
|
||||
hint.classList.toggle('waiting', !ready);
|
||||
}
|
||||
}
|
||||
@@ -454,7 +466,8 @@ function handleDelta(d){
|
||||
if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
|
||||
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
|
||||
paintTurnClock(); // fige le total au-dessus de la carte
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
||||
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||
@@ -492,7 +505,8 @@ function handleDelta(d){
|
||||
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
||||
// tour (collapseAll) la fermerait quand même.
|
||||
if(tu.image && T.pendingToolEl){ const i=T.turnCollapsibles.indexOf(T.pendingToolEl); if(i>=0) T.turnCollapsibles.splice(i,1); }
|
||||
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||||
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem();
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); }
|
||||
return; }
|
||||
if(d.drop_reasoning){
|
||||
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
||||
|
||||
@@ -365,6 +365,7 @@ window.addEventListener('drop', e=>{
|
||||
// rouge, elle dit déjà ce qui s'est passé.
|
||||
async function attachPaths(){
|
||||
await Promise.all(ATTACH.filter(a=>a.state==='queued').map(uploadAttach));
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); // les dépôts apparaissent dans le panneau Fichiers
|
||||
return ATTACH.filter(a=>a.state==='ok'&&a.path).map(a=>a.path);
|
||||
}
|
||||
renderAttach();
|
||||
@@ -263,6 +263,21 @@ function filesOnConvChange(){
|
||||
if(FILES_OPEN) loadFiles('');
|
||||
}
|
||||
|
||||
// Quelque chose a pu changer sur disque : un outil vient de rendre son résultat
|
||||
// (l'agent écrit un rapport, une capture, un script), le tour est fini, une
|
||||
// pièce jointe vient d'être déposée. Le panneau se redessine tout seul —
|
||||
// jusqu'ici il fallait cliquer « rafraîchir » pour voir apparaître ce que
|
||||
// l'agent venait d'écrire. Regroupé : une rafale d'appels d'outils ne
|
||||
// déclenche qu'une lecture. Panneau fermé ou rejeu du journal : rien à faire,
|
||||
// l'ouverture recharge de toute façon.
|
||||
let filesRefreshT = 0;
|
||||
function filesOnActivity(){
|
||||
if(!FILES_OPEN) return;
|
||||
if(typeof REPLAYING !== 'undefined' && REPLAYING) return;
|
||||
clearTimeout(filesRefreshT);
|
||||
filesRefreshT = setTimeout(()=>{ filesRefreshT = 0; loadFiles(); }, 400);
|
||||
}
|
||||
|
||||
// Date courte : l'heure pour aujourd'hui, la date sinon. Ce qu'on veut savoir,
|
||||
// c'est « est-ce que ça vient du tour que je viens de lancer ? ».
|
||||
document.addEventListener('DOMContentLoaded', initFiles);
|
||||
|
||||
@@ -14,6 +14,7 @@ let SET_LAST = 'config';
|
||||
// l'ancien ontoggle des <details>.
|
||||
const SET_HOOKS = {
|
||||
'engine-log': () => { loadSvcLog(); showPaths(); },
|
||||
'dictee': () => { loadDictateSettings(); },
|
||||
};
|
||||
function openSettings(pane){
|
||||
showModal('settings-modal');
|
||||
|
||||
@@ -53,7 +53,7 @@ async function dictateStop(){
|
||||
// renvoie la raison. La cacher derrière « 0 % » laissait l'utilisateur
|
||||
// cliquer indéfiniment sur un micro qui n'écrira jamais rien.
|
||||
toast(j.error ? 'modèle de dictée : échec du téléchargement — ' + j.error
|
||||
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — réessaie dans un instant');
|
||||
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — suivi dans Paramètres → Dictée');
|
||||
return;
|
||||
}
|
||||
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
// ─── 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/*.
|
||||
let DIC_CFG = null;
|
||||
|
||||
async function loadDictateSettings(){
|
||||
const [cfg, modeles, gpus] = 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.
|
||||
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.value = cfg.model || '';
|
||||
|
||||
document.getElementById('dic-lang').value = cfg.lang || 'fr';
|
||||
document.getElementById('dic-react').value = cfg.reactivity || 'moyen';
|
||||
refreshDictateState();
|
||||
}
|
||||
|
||||
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');
|
||||
el.textContent = 'enregistrement…';
|
||||
const r = await jfetch('/api/dictate/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfg)});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
|
||||
DIC_CFG = cfg;
|
||||
// Le serveur de dictée a été arrêté côté Loki : le prochain clic sur le
|
||||
// micro le relancera avec ces réglages. On le dit, sinon la première dictée
|
||||
// suivante paraît anormalement lente sans raison visible.
|
||||
el.textContent = 'enregistré — la prochaine dictée rechargera le modèle';
|
||||
refreshDictateState();
|
||||
}
|
||||
|
||||
async function dictateDownload(){
|
||||
const id = document.getElementById('dic-model').value;
|
||||
const el = document.getElementById('dic-dl-state');
|
||||
el.textContent = 'démarrage…';
|
||||
const r = await jfetch('/api/dictate/models/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
|
||||
suivreTelechargement();
|
||||
}
|
||||
|
||||
// Sondage tant que le téléchargement tourne. Un modèle de 1 Go prend plusieurs
|
||||
// minutes : sans progression affichée, l'utilisateur ne sait pas s'il se passe
|
||||
// quelque chose.
|
||||
async function suivreTelechargement(){
|
||||
const el = document.getElementById('dic-dl-state');
|
||||
const r = await jfetch('/api/dictate/models/download');
|
||||
const j = await r.json().catch(()=>({}));
|
||||
if(j.error){ el.textContent = 'échec : ' + j.error; return; }
|
||||
if(j.running){
|
||||
el.textContent = `téléchargement de ${j.id} — ${j.pct||0} %`;
|
||||
setTimeout(suivreTelechargement, 1000);
|
||||
return;
|
||||
}
|
||||
el.textContent = 'téléchargé';
|
||||
loadDictateSettings();
|
||||
}
|
||||
|
||||
async function refreshDictateState(){
|
||||
const el = document.getElementById('dic-state');
|
||||
if(!el) return;
|
||||
const j = await jfetch('/api/dictate/state').then(r => r.json()).catch(()=>null);
|
||||
if(!j){ el.textContent = ''; return; }
|
||||
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);
|
||||
if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`);
|
||||
el.textContent = bouts.join(' · ');
|
||||
}
|
||||
|
||||
// Test du micro : capte 3 s, mesure le niveau réel et transcrit, SANS toucher
|
||||
// au champ de saisie. C'est le diagnostic qu'il fallait bricoler à la main
|
||||
// quand la dictée ne rendait rien — niveau nul, micro muet, serveur en panne
|
||||
// se ressemblent tous de l'extérieur.
|
||||
async function dictateTestMic(){
|
||||
const out = document.getElementById('dic-test-out');
|
||||
const btn = document.getElementById('dic-test');
|
||||
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
|
||||
out.textContent = 'micro bloqué par le navigateur : il faut HTTPS (ou localhost)';
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
out.textContent = 'écoute… parle maintenant (3 s)';
|
||||
let stream;
|
||||
try{
|
||||
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
|
||||
}catch(e){ out.textContent = 'accès au micro refusé'; btn.disabled = false; return; }
|
||||
const ctx = new (window.AudioContext||window.webkitAudioContext)();
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
const node = ctx.createScriptProcessor(4096, 1, 1);
|
||||
const chunks = [];
|
||||
node.onaudioprocess = e => chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
|
||||
src.connect(node); node.connect(ctx.destination);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
try{ src.disconnect(); node.disconnect(); }catch(_){}
|
||||
stream.getTracks().forEach(t => t.stop());
|
||||
const rate = ctx.sampleRate;
|
||||
try{ ctx.close(); }catch(_){}
|
||||
|
||||
let n = 0; for(const c of chunks) n += c.length;
|
||||
let somme = 0, crete = 0;
|
||||
for(const c of chunks) for(let i = 0; i < c.length; i++){
|
||||
const v = c[i]; somme += v*v;
|
||||
const a = Math.abs(v); if(a > crete) crete = a;
|
||||
}
|
||||
const rms = Math.sqrt(somme / (n||1));
|
||||
const niveau = rms < 0.002 ? 'QUASI MUET' : rms < 0.01 ? 'faible' : 'correct';
|
||||
out.textContent = `niveau ${niveau} (rms ${rms.toFixed(4)}, crête ${crete.toFixed(3)}) — transcription…`;
|
||||
|
||||
const wav = encodeWav16k(chunks, rate);
|
||||
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body: wav});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
const tete = `niveau ${niveau} (rms ${rms.toFixed(4)}) — `;
|
||||
if(!r.ok) out.textContent = tete + (j.error || 'échec de la transcription');
|
||||
else if(!(j.text||'').trim()) out.textContent = tete + 'rien compris' + (rms < 0.002 ? ' (le micro n’a rien capté : vérifie l’entrée choisie par le système)' : '');
|
||||
else out.textContent = tete + '« ' + j.text.trim() + ' »';
|
||||
btn.disabled = false;
|
||||
refreshDictateState();
|
||||
}
|
||||
@@ -280,6 +280,10 @@ details>summary::-webkit-details-marker{display:none}
|
||||
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
|
||||
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
|
||||
details[open]>summary>.preset-add{display:inline-block}
|
||||
/* Les panneaux de réglages ne sont plus des <details>/<summary> mais des
|
||||
<section class="set-pane"> : sans cette règle, le « + » des Presets restait
|
||||
en display:none et il n'y avait plus aucun moyen d'en créer un. */
|
||||
.set-pane-h>.preset-add{display:inline-block}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
|
||||
.tag.ok{color:var(--ok);border-color:var(--ok)}
|
||||
.tag.err{color:var(--err);border-color:var(--err)}
|
||||
@@ -751,9 +755,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.lc-update-link{color:var(--dim);text-decoration:none;cursor:pointer}
|
||||
.lc-update-link:hover{color:var(--accent)}
|
||||
/* Choix du moteur dans l'éditeur de modèle (3 options : rapide/optimisé/perso) */
|
||||
.be-opts{display:flex;flex-direction:column;gap:7px;margin:6px 0 8px}
|
||||
.be-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--text);cursor:pointer}
|
||||
.be-opt input{margin:0;flex-shrink:0}
|
||||
.be-note{font-size:11px;color:var(--dim);margin-left:auto}
|
||||
|
||||
/* ===== Éditeur de preset — listes groupées façon Réglages iOS ======== */
|
||||
@@ -845,7 +846,6 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:3px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s}
|
||||
.pe-seg>label:has(input:checked){background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.22)}
|
||||
.pe-seg input{display:none}
|
||||
.pe-seg .be-note{display:none}
|
||||
.pe-seg-note{font-size:11px;color:var(--dim);padding:0 2px;min-height:14px}
|
||||
.pe-note{font-size:11px;color:var(--dim);line-height:1.45}
|
||||
/* Barre de progression (téléchargement de modèle). Sans total connu, elle
|
||||
@@ -1685,6 +1685,16 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* --- Moniteur machine (pied de barre latérale) ----------------------- */
|
||||
.perf-head{display:flex;align-items:center;gap:8px;margin-bottom:11px;
|
||||
font-size:14px;font-weight:600;color:var(--text);letter-spacing:-.01em}
|
||||
/* Bouton du moniteur (« Libérer la VRAM » / « Recharger le modèle ») : poussé
|
||||
à droite du titre, discret tant qu'on ne le cherche pas. En mode rechargement
|
||||
il prend la couleur d'accent — le moteur est arrêté, c'est l'action qui
|
||||
compte à ce moment-là. */
|
||||
.perf-act{margin-left:auto;flex:none;padding:3px 8px;font-size:11px;line-height:1.4;
|
||||
color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;
|
||||
white-space:nowrap}
|
||||
.perf-act:hover:not(:disabled){color:var(--text)}
|
||||
.perf-act:disabled{opacity:.55;cursor:progress}
|
||||
.perf-act.reload{color:var(--accent);border-color:color-mix(in srgb,var(--accent) 45%,transparent)}
|
||||
.perf-row{margin:0 0 14px}
|
||||
.perf-l{display:flex;align-items:baseline;gap:8px;font-size:12.5px;color:var(--text);margin-bottom:6px}
|
||||
.perf-l b{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
|
||||
+21
-23
@@ -8,7 +8,6 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -170,28 +169,15 @@ func handleServiceLog(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"log": serviceLogTail(n)})
|
||||
}
|
||||
|
||||
// handleVram expose l'état des cartes à l'interface (jauges du moniteur).
|
||||
// La lecture nvidia-smi elle-même vit dans web_vram.go, avec le déchargement
|
||||
// de la VRAM qui s'en sert pour chiffrer la mémoire rendue.
|
||||
func handleVram(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := hideCmd(exec.Command("nvidia-smi",
|
||||
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
|
||||
"--format=csv,noheader,nounits")).Output()
|
||||
gpus := []map[string]any{}
|
||||
if err == nil {
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) != 5 {
|
||||
continue
|
||||
}
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
used, _ := strconv.Atoi(parts[1])
|
||||
total, _ := strconv.Atoi(parts[2])
|
||||
util, _ := strconv.Atoi(parts[3])
|
||||
temp, _ := strconv.Atoi(parts[4])
|
||||
gpus = append(gpus, map[string]any{
|
||||
"name": parts[0], "used": used, "total": total, "util": util, "temp": temp,
|
||||
})
|
||||
}
|
||||
for _, g := range gpuStats() {
|
||||
gpus = append(gpus, map[string]any{
|
||||
"name": g.Name, "used": g.Used, "total": g.Total, "util": g.Util, "temp": g.Temp,
|
||||
})
|
||||
}
|
||||
sendJSON(w, 200, gpus)
|
||||
}
|
||||
@@ -437,12 +423,24 @@ func handlePresetSave(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
newID, err := SavePreset(req.ID, req.Name, req.Content)
|
||||
// Le preset en service modifié est réappliqué sur-le-champ, moteur relancé
|
||||
// en arrière-plan comme pour une bascule (handleSwitch) : la réponse ne
|
||||
// doit pas attendre l'arrêt de llama-server.
|
||||
newID, applied, err := SavePresetApplying(req.ID, req.Name, req.Content)
|
||||
if err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name})
|
||||
if applied {
|
||||
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID)
|
||||
fmt.Println(dim("[info] redémarrage du service..."))
|
||||
go func() {
|
||||
if err := serviceAction("restart"); err != nil {
|
||||
fmt.Printf("%s redémarrage après modification du preset: %v\n", red("[ERREUR]"), err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "id": newID, "name": req.Name, "applied": applied})
|
||||
}
|
||||
|
||||
func handlePresetDelete(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@@ -75,6 +76,45 @@ func devCachePut(bin string, devs []map[string]any) {
|
||||
devCacheMu.Unlock()
|
||||
}
|
||||
|
||||
// Cache PERSISTANT de la dernière énumération RÉUSSIE (exit 0) de --list-devices,
|
||||
// par clé moteur+CVD. But : quand le moteur tourne et sature déjà une carte, un
|
||||
// nouvel appel --list-devices peut PLANTER (CUDA out of memory en initialisant le
|
||||
// device plein) et ne renvoyer qu'une partie des cartes — l'UI perdait alors le
|
||||
// tensor split (slider caché faute de 2e GPU) après un simple redémarrage de
|
||||
// l'interface, qui vide le cache mémoire. On garde donc sur disque la dernière
|
||||
// liste complète : identité, ordre et mémoire TOTALE sont des faits matériels
|
||||
// stables issus du moteur lui-même (pas de nvidia-smi, dont l'ordre peut différer,
|
||||
// voir l'en-tête de ce fichier). Seule la mémoire LIBRE y est périmée, ce qui est
|
||||
// sans importance pour choisir les cartes et régler la répartition.
|
||||
var devPersistMu sync.Mutex
|
||||
|
||||
func devPersistPath() string { return filepath.Join(LokiHome(), "devices.json") }
|
||||
|
||||
func devPersistLoad() map[string][]map[string]any {
|
||||
m := map[string][]map[string]any{}
|
||||
if b, err := os.ReadFile(devPersistPath()); err == nil {
|
||||
_ = json.Unmarshal(b, &m)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func devPersistGet(key string) ([]map[string]any, bool) {
|
||||
devPersistMu.Lock()
|
||||
defer devPersistMu.Unlock()
|
||||
d, ok := devPersistLoad()[key]
|
||||
return d, ok && len(d) > 0
|
||||
}
|
||||
|
||||
func devPersistPut(key string, devs []map[string]any) {
|
||||
devPersistMu.Lock()
|
||||
defer devPersistMu.Unlock()
|
||||
m := devPersistLoad()
|
||||
m[key] = devs
|
||||
if b, err := json.MarshalIndent(m, "", " "); err == nil {
|
||||
_ = os.WriteFile(devPersistPath(), b, 0o644)
|
||||
}
|
||||
}
|
||||
|
||||
// fillMissingMemory complète les mémoires que le moteur n'a pas su lire. Quand
|
||||
// une carte est déjà saturée par le modèle en cours, llama.cpp annonce 0 Mio —
|
||||
// l'UI n'avait alors rien à afficher pour elle, ce qui donnait une liste
|
||||
@@ -156,26 +196,60 @@ func handleBackendDevices(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "ce chemin n'est pas un llama-server"})
|
||||
return
|
||||
}
|
||||
if devs, ok := devCacheGet(bin); ok {
|
||||
// L'ordre d'énumération DOIT être celui du serveur en marche, sinon la liste
|
||||
// affichée (et donc le --tensor-split que l'utilisateur règle carte par carte)
|
||||
// se retrouve inversée par rapport à la réalité. Le moteur réel force
|
||||
// CUDA_DEVICE_ORDER=PCI_BUS_ID (+ le filtre CUDA_VISIBLE_DEVICES) dans
|
||||
// backend_serve.go ; par défaut CUDA classe « le plus rapide d'abord », ce qui
|
||||
// peut être l'ordre INVERSE. On reproduit donc le même environnement ici.
|
||||
cfg := ReadConfig()
|
||||
cvd := cfg["CUDA_VISIBLE_DEVICES"]
|
||||
cacheKey := bin + "\x00" + cvd
|
||||
if devs, ok := devCacheGet(cacheKey); ok {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "devices": devs})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
cmd := hideCmd(exec.CommandContext(ctx, bin, "--list-devices"))
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
env := libraryPathEnv(filepath.Dir(bin))
|
||||
if cvd != "" {
|
||||
env = append(env, "CUDA_VISIBLE_DEVICES="+cvd, "CUDA_DEVICE_ORDER=PCI_BUS_ID")
|
||||
}
|
||||
cmd.Env = env
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil && len(out) == 0 {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": "le moteur n'a pas répondu : " + err.Error()})
|
||||
devs := parseListDevices(string(out))
|
||||
// err != nil = le moteur est sorti en erreur (typiquement il a PLANTÉ en OOM
|
||||
// sur une carte déjà pleine pendant qu'il l'énumérait, cf. « CUDA error: out of
|
||||
// memory »). La sortie est alors TRONQUÉE : on ne peut pas s'y fier (il manque
|
||||
// des cartes). On rend plutôt la dernière liste complète connue, pour ne pas
|
||||
// perdre le tensor split pendant que le moteur tourne.
|
||||
if err != nil {
|
||||
if good, ok := devPersistGet(cacheKey); ok {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "devices": good, "stale": true})
|
||||
return
|
||||
}
|
||||
if len(devs) == 0 {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": "le moteur n'a pas répondu : " + err.Error()})
|
||||
return
|
||||
}
|
||||
// Pas de repli disponible : on rend ce qu'on a lu, sans le figer (ni cache
|
||||
// mémoire ni persistant) puisque la liste est probablement incomplète.
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "devices": devs, "stale": true})
|
||||
return
|
||||
}
|
||||
devs := parseListDevices(string(out))
|
||||
fillMissingMemory(devs)
|
||||
// Quand une carte est déjà saturée par le modèle en cours, le moteur peut
|
||||
// annoncer 0 Mio de mémoire : c'est une lecture transitoire, on ne la fige
|
||||
// pas dans le cache (sinon l'UI affiche « 0 Go » pendant dix minutes).
|
||||
if !hasZeroMemory(devs) {
|
||||
devCachePut(bin, devs)
|
||||
devCachePut(cacheKey, devs)
|
||||
}
|
||||
// Énumération propre (exit 0) = liste complète et faisant foi : on la garde sur
|
||||
// disque comme repli pour les futurs appels où le moteur, chargé, ferait planter
|
||||
// --list-devices.
|
||||
if len(devs) > 0 {
|
||||
devPersistPut(cacheKey, devs)
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "devices": devs})
|
||||
}
|
||||
@@ -168,9 +168,17 @@ 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/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
|
||||
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/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
|
||||
api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
|
||||
api("/api/vram", handleVram)
|
||||
// Rendre la carte à la machine : le modèle occupe la VRAM tant que le moteur
|
||||
// tourne, et une autre application qui en a besoin ne trouve rien.
|
||||
api("/api/vram/unload", handleVramUnload) // arrête moteur + dictée, rapporte la VRAM rendue
|
||||
api("/api/vram/reload", handleVramReload) // relance le moteur (le modèle se recharge)
|
||||
api("/api/ram", handleRam)
|
||||
api("/api/config", handleConfigEnv)
|
||||
api("/api/paths", handlePaths)
|
||||
|
||||
+148
-99
@@ -1,65 +1,53 @@
|
||||
// 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-cli (whisper.cpp, même famille que llama.cpp), compilé dans
|
||||
// l'image Docker. Le modèle (ggml-small-q5_1, ~190 Mo, multilingue) 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 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.
|
||||
//
|
||||
// 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.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const whisperModelURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q5_1.bin"
|
||||
|
||||
// whisperBin localise whisper-cli : variable d'environnement (posée par le
|
||||
// Dockerfile), sinon le PATH. Vide = dictée indisponible (image trop ancienne,
|
||||
// ou poste de dev sans whisper.cpp) — le handler le dit clairement.
|
||||
func whisperBin() string {
|
||||
if p := os.Getenv("LOKI_WHISPER_BIN"); p != "" {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("whisper-cli"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func whisperModelPath() string {
|
||||
return filepath.Join(LokiHome(), "whisper", "ggml-small-q5_1.bin")
|
||||
}
|
||||
|
||||
// Téléchargement du modèle : UNE goroutine, progression consultable. Le premier
|
||||
// POST le déclenche et répond 503 {downloading, pct} ; l'UI invite à réessayer.
|
||||
// Téléchargement d'un modèle : UNE goroutine à la fois, progression
|
||||
// consultable. Le premier POST le déclenche et répond 503 {downloading, pct} ;
|
||||
// l'UI invite à réessayer.
|
||||
var wspMu sync.Mutex
|
||||
var wspDl struct {
|
||||
id string
|
||||
running bool
|
||||
pct int
|
||||
err string
|
||||
}
|
||||
|
||||
func whisperStartDownload() {
|
||||
func whisperStartDownload(id string) {
|
||||
wspMu.Lock()
|
||||
defer wspMu.Unlock()
|
||||
if wspDl.running {
|
||||
return
|
||||
}
|
||||
wspDl.running, wspDl.pct, wspDl.err = true, 0, ""
|
||||
if whisperModelURLFor(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()
|
||||
err := whisperDownload(id)
|
||||
wspMu.Lock()
|
||||
wspDl.running = false
|
||||
if err != nil {
|
||||
@@ -69,19 +57,24 @@ func whisperStartDownload() {
|
||||
}()
|
||||
}
|
||||
|
||||
func whisperDownload() error {
|
||||
dst := whisperModelPath()
|
||||
func whisperDownload(id string) error {
|
||||
dst := whisperModelPathFor(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(whisperModelURL)
|
||||
resp, err := http.Get(whisperModelURLFor(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("téléchargement du modèle whisper : 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 {
|
||||
@@ -94,8 +87,8 @@ func whisperDownload() error {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return werr
|
||||
}
|
||||
done += int64(n)
|
||||
@@ -109,83 +102,139 @@ func whisperDownload() error {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
_ = f.Close()
|
||||
_ = os.Remove(tmp)
|
||||
return rerr
|
||||
}
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
func handleTranscribe(w http.ResponseWriter, r *http.Request) {
|
||||
bin := whisperBin()
|
||||
if bin == "" {
|
||||
sendJSON(w, 501, map[string]any{"error": "dictée indisponible : whisper-cli absent (image à reconstruire, ou poste sans whisper.cpp)"})
|
||||
return
|
||||
}
|
||||
model := whisperModelPath()
|
||||
if _, err := os.Stat(model); err != nil {
|
||||
wspMu.Lock()
|
||||
derr := wspDl.err
|
||||
pct := wspDl.pct
|
||||
wspMu.Unlock()
|
||||
if derr != "" {
|
||||
// Échec précédent : on le dit ET on relance — un réseau revenu suffit.
|
||||
wspMu.Lock()
|
||||
wspDl.err = ""
|
||||
wspMu.Unlock()
|
||||
whisperStartDownload()
|
||||
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
|
||||
return
|
||||
}
|
||||
whisperStartDownload()
|
||||
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
|
||||
return
|
||||
}
|
||||
// whisperDlEtat : instantané de la progression, pour l'UI et pour les 503.
|
||||
func whisperDlEtat() (id string, running bool, pct int, errMsg string) {
|
||||
wspMu.Lock()
|
||||
defer wspMu.Unlock()
|
||||
return wspDl.id, wspDl.running, wspDl.pct, wspDl.err
|
||||
}
|
||||
|
||||
func handleTranscribe(w http.ResponseWriter, r *http.Request) {
|
||||
audio, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
|
||||
if err != nil || len(audio) < 1000 {
|
||||
sendJSON(w, 400, map[string]any{"error": "audio vide ou trop court"})
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "loki-dictee-*.wav")
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := tmp.Write(audio); err != nil {
|
||||
tmp.Close()
|
||||
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
// -l auto : détection de langue (le français sort en français) ; -nt sans
|
||||
// horodatages ; -np sans bavardage de chargement. CPU seul : une dictée de
|
||||
// quelques dizaines de secondes se transcrit en 1-3 s avec small-q5_1.
|
||||
threads := runtime.NumCPU()
|
||||
if threads > 8 {
|
||||
threads = 8
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, "-m", model, "-f", tmpPath, "-l", "auto", "-nt", "-np", "-t", strconv.Itoa(threads))
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
cfg := dictateCfgLoad()
|
||||
if !whisperModelPresent(cfg.Model) {
|
||||
_, _, pct, derr := whisperDlEtat()
|
||||
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)
|
||||
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 500, map[string]any{"error": "whisper-cli : " + msg})
|
||||
whisperStartDownload(cfg.Model)
|
||||
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"text": strings.TrimSpace(string(out))})
|
||||
// Le chargement du modèle au premier appel peut être long ; la
|
||||
// transcription elle-même ne l'est pas.
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||||
defer cancel()
|
||||
txt, err := whisperInfer(ctx, audio)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"text": txt})
|
||||
}
|
||||
|
||||
// ─── Réglages de la dictée ───────────────────────────────────────────────────
|
||||
|
||||
func handleDictateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var c DictateCfg
|
||||
if err := json.NewDecoder(r.Body).Decode(&c); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"error": "requête illisible"})
|
||||
return
|
||||
}
|
||||
if err := dictateCfgSave(c); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Sans ça, le serveur continuerait de tourner avec l'ancien modèle et
|
||||
// le nouveau réglage semblerait sans effet.
|
||||
whisperShutdown()
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, dictateCfgLoad())
|
||||
}
|
||||
|
||||
func handleDictateModels(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, whisperCatalogueTrie())
|
||||
}
|
||||
|
||||
func handleDictateDownload(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if r.Method == http.MethodPost {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"error": "requête illisible"})
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(req.ID)
|
||||
if whisperModelURLFor(id) == "" {
|
||||
sendJSON(w, 400, map[string]any{"error": "modèle de dictée inconnu : " + id})
|
||||
return
|
||||
}
|
||||
whisperStartDownload(id)
|
||||
}
|
||||
id, running, pct, errMsg := whisperDlEtat()
|
||||
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["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.
|
||||
// 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 {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
if err == nil {
|
||||
return "arrêt sans erreur"
|
||||
}
|
||||
return err.Error()
|
||||
}
|
||||
st := ee.ProcessState.String()
|
||||
if strings.Contains(st, "signal:") {
|
||||
return st + " (binaire compilé pour un autre processeur : image à reconstruire)"
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// lastLine : la dernière ligne non vide. whisper bavarde beaucoup 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")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if l := strings.TrimSpace(lines[i]); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
_, apres, ok := strings.Cut(string(b), "ARG WHISPER_CMAKE_FLAGS=")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestDockerfileArgFlagsGlobal(t *testing.T) {
|
||||
b, err := os.ReadFile("../../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatalf("lecture du Dockerfile : %v", err)
|
||||
}
|
||||
src := string(b)
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
func clefs(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestLastLine(t *testing.T) {
|
||||
cas := []struct{ in, want string }{
|
||||
{"", ""},
|
||||
{" \n\n ", ""},
|
||||
{"une seule ligne", "une seule ligne"},
|
||||
{"AMX is not ready to be used!\nread_audio_data: ...\n", "read_audio_data: ..."},
|
||||
{"fin utile\n\n \n", "fin utile"},
|
||||
}
|
||||
for _, c := range cas {
|
||||
if got := lastLine(c.in); got != c.want {
|
||||
t.Errorf("lastLine(%q) = %q, attendu %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// web_vram.go — rendre la mémoire vidéo à la machine, à la demande.
|
||||
//
|
||||
// Tant que le moteur tourne, le modèle occupe la VRAM : une autre application
|
||||
// (jeu, encodage, entraînement, un second serveur d'inférence…) qui réclame la
|
||||
// carte ne trouve plus rien à prendre. Le geste existait déjà — arrêter le
|
||||
// service — mais il vivait dans les réglages sous le nom « arrêter », qui ne
|
||||
// dit pas qu'il libère la carte, et il laissait tourner le serveur de dictée
|
||||
// qui, lui aussi, garde de la VRAM quand il est allumé sur GPU.
|
||||
//
|
||||
// POST /api/vram/unload → arrête le moteur ET la dictée, puis rapporte la
|
||||
// mémoire réellement rendue (before/after/freed)
|
||||
// POST /api/vram/reload → relance le moteur (le modèle se recharge)
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// gpuStat : une carte vue par nvidia-smi. Mémoire en Mo (unité de nvidia-smi,
|
||||
// et celle que /api/vram expose déjà à l'interface).
|
||||
type gpuStat struct {
|
||||
Name string
|
||||
Used int
|
||||
Total int
|
||||
Util int
|
||||
Temp int
|
||||
}
|
||||
|
||||
// gpuStats interroge nvidia-smi. Renvoie nil quand il est absent (machine sans
|
||||
// GPU NVIDIA, Mac, CPU seul) : l'absence de mesure n'est pas une erreur, elle
|
||||
// prive juste le déchargement de son bilan chiffré.
|
||||
func gpuStats() []gpuStat {
|
||||
out, err := hideCmd(exec.Command("nvidia-smi",
|
||||
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
|
||||
"--format=csv,noheader,nounits")).Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var gpus []gpuStat
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
parts := strings.Split(line, ",")
|
||||
if len(parts) != 5 {
|
||||
continue
|
||||
}
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
g := gpuStat{Name: parts[0]}
|
||||
g.Used, _ = strconv.Atoi(parts[1])
|
||||
g.Total, _ = strconv.Atoi(parts[2])
|
||||
g.Util, _ = strconv.Atoi(parts[3])
|
||||
g.Temp, _ = strconv.Atoi(parts[4])
|
||||
gpus = append(gpus, g)
|
||||
}
|
||||
return gpus
|
||||
}
|
||||
|
||||
// gpuUsedMB : VRAM occupée, toutes cartes confondues.
|
||||
func gpuUsedMB(gpus []gpuStat) int {
|
||||
used := 0
|
||||
for _, g := range gpus {
|
||||
used += g.Used
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
// gpuSettle attend que la VRAM ait fini de redescendre avant de la relire. Le
|
||||
// pilote ne rend pas la mémoire à l'instant où le processus meurt — taskkill et
|
||||
// Process.Kill reviennent avant même que CUDA ait commencé son ménage — et une
|
||||
// lecture prise trop tôt rapporterait « 0 Mo libérés » après un déchargement
|
||||
// qui a pourtant marché. Trois règles, chacune née d'un faux bilan :
|
||||
//
|
||||
// - une mesure ratée (nvidia-smi absent, ou en pleine réinitialisation juste
|
||||
// après l'arrêt) n'est PAS « 0 Mo » : on la saute et on garde la dernière
|
||||
// lecture valide — la compter à zéro annoncerait tout le modèle comme
|
||||
// libéré alors que rien n'a bougé ;
|
||||
// - un palier n'est concluant qu'une fois la baisse commencée : avant, c'est
|
||||
// le pilote qui n'a pas encore réagi, pas la mémoire qui a fini de partir ;
|
||||
// - deux lectures égales de suite après la baisse, et on rend la valeur.
|
||||
// Sans baisse du tout, on rend la dernière lecture après ~4 s.
|
||||
//
|
||||
// sample renvoie (Mo occupés, lecture réussie). before : la lecture prise
|
||||
// avant l'arrêt, qui sert de repère à « la baisse a commencé ».
|
||||
func gpuSettle(before int, sample func() (int, bool), pause time.Duration) int {
|
||||
last, lu := before, false
|
||||
baisse, palier := false, 0
|
||||
for i := 0; i < 8; i++ {
|
||||
time.Sleep(pause)
|
||||
cur, ok := sample()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cur < before {
|
||||
baisse = true
|
||||
}
|
||||
if lu && cur == last {
|
||||
palier++
|
||||
} else {
|
||||
palier = 0
|
||||
}
|
||||
last, lu = cur, true
|
||||
if baisse && palier >= 1 {
|
||||
return cur
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func gpuUsedSettled(before int) int {
|
||||
return gpuSettle(before, func() (int, bool) {
|
||||
g := gpuStats()
|
||||
return gpuUsedMB(g), g != nil
|
||||
}, 500*time.Millisecond)
|
||||
}
|
||||
|
||||
// engineNeedsStop : faut-il envoyer « stop » ? Actif, évidemment. Mais sous
|
||||
// systemd, « is-active » ne répond « active » qu'une fois le service établi :
|
||||
// une unité en train de (re)démarrer — Restart=on-failure sur un modèle qui
|
||||
// meurt au chargement — répond « activating », et la sauter laisserait systemd
|
||||
// relancer llama-server toutes les trois secondes, GPU repris à chaque tour,
|
||||
// pendant que l'interface affirme « le moteur était déjà arrêté ». On garde la
|
||||
// condition pour le bilan, mais élargie aux états transitoires.
|
||||
func engineNeedsStop() bool {
|
||||
if serviceIsActive() {
|
||||
return true
|
||||
}
|
||||
if !systemdAvailable() {
|
||||
return false
|
||||
}
|
||||
out, _ := exec.Command("systemctl", "is-active", serviceName()).Output()
|
||||
switch strings.TrimSpace(string(out)) {
|
||||
case "activating", "reloading", "deactivating":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// postOnly refuse tout sauf POST. Ces routes changent l'état de la machine —
|
||||
// elles arrêtent des processus — et un simple GET ne doit pas y suffire : sans
|
||||
// clé de pilotage configurée, une balise <img> sur une page tierce visitée par
|
||||
// hasard suffirait sinon à couper le moteur.
|
||||
func postOnly(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Method == http.MethodPost {
|
||||
return true
|
||||
}
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
sendJSON(w, 405, map[string]any{"ok": false, "error": "méthode " + r.Method + " refusée : POST attendu"})
|
||||
return false
|
||||
}
|
||||
|
||||
// ansiRe / plainErr : les messages de preflightEngine sont écrits pour le
|
||||
// terminal (bold() y glisse des séquences ANSI). Envoyés tels quels à
|
||||
// l'interface, ils y affichent des « [1m » au milieu de la phrase.
|
||||
var ansiRe = regexp.MustCompile("\x1b\\[[0-9;]*m")
|
||||
|
||||
func plainErr(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
return ansiRe.ReplaceAllString(err.Error(), "")
|
||||
}
|
||||
|
||||
// handleVramUnload arrête le moteur (et la dictée) pour rendre la mémoire
|
||||
// vidéo. Refuse pendant une génération — la couper perdrait la réponse en
|
||||
// cours — sauf si l'appelant insiste avec {force:true}.
|
||||
func handleVramUnload(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if !req.Force && conv.isGenerating() {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "generating": true,
|
||||
"error": "une réponse est en cours d'écriture — décharger maintenant l'interromprait"})
|
||||
return
|
||||
}
|
||||
gpus := gpuStats()
|
||||
before := gpuUsedMB(gpus)
|
||||
wasActive := engineNeedsStop()
|
||||
|
||||
var errs []string
|
||||
if wasActive {
|
||||
if err := serviceAction("stop"); err != nil {
|
||||
errs = append(errs, "arrêt du moteur : "+plainErr(err))
|
||||
}
|
||||
}
|
||||
// La dictée tourne dans un processus séparé, allumé à la demande et éteint
|
||||
// après dix minutes d'inactivité : sur GPU, elle occupe la carte pendant tout
|
||||
// ce temps. Libérer la VRAM sans elle ne libérerait pas tout. La variante
|
||||
// « 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()
|
||||
|
||||
after := before
|
||||
if gpus != nil {
|
||||
after = gpuUsedSettled(before)
|
||||
}
|
||||
freed := before - after
|
||||
if freed < 0 {
|
||||
freed = 0
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": len(errs) == 0,
|
||||
"error": strings.Join(errs, " ; "),
|
||||
"was_active": wasActive,
|
||||
"active": serviceIsActive(),
|
||||
"gpu": gpus != nil,
|
||||
"before_mb": before,
|
||||
"used_mb": after,
|
||||
"freed_mb": freed,
|
||||
})
|
||||
}
|
||||
|
||||
// handleVramReload relance le moteur après un déchargement. Le préflight évite
|
||||
// de démarrer un service condamné à mourir en boucle (BIN ou MODEL absents) :
|
||||
// mieux vaut la vraie raison tout de suite qu'un « chargement… » sans fin.
|
||||
func handleVramReload(w http.ResponseWriter, r *http.Request) {
|
||||
if !postOnly(w, r) {
|
||||
return
|
||||
}
|
||||
if serviceIsActive() {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "already": true})
|
||||
return
|
||||
}
|
||||
if err := preflightEngine(); err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": plainErr(err)})
|
||||
return
|
||||
}
|
||||
if err := serviceAction("start"); err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": plainErr(err)})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// gpuSettle : le bilan « VRAM libérée » est calculé sur ces lectures. Chaque cas
|
||||
// est un faux bilan qu'on a vu — ou qu'on aurait vu — dans l'interface.
|
||||
func TestGpuSettle(t *testing.T) {
|
||||
seq := func(vals ...int) func() (int, bool) {
|
||||
i := 0
|
||||
return func() (int, bool) {
|
||||
if i >= len(vals) {
|
||||
return vals[len(vals)-1], true
|
||||
}
|
||||
v := vals[i]
|
||||
i++
|
||||
if v < 0 { // lecture ratée (nvidia-smi absent ou en réinitialisation)
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
seq []int
|
||||
want int
|
||||
}{
|
||||
// Le pilote met un temps à réagir : palier AVANT la baisse, puis la
|
||||
// mémoire part. L'ancienne version rendait 20000 dès le premier palier.
|
||||
{"palier avant la baisse", []int{20000, 20000, 12000, 3000, 500, 500}, 500},
|
||||
// Baisse immédiate et régulière, puis stable.
|
||||
{"baisse puis stable", []int{15000, 8000, 400, 400}, 400},
|
||||
// Une lecture ratée au milieu n'est pas « 0 Mo » : on la saute.
|
||||
{"lecture ratée ignorée", []int{14000, -1, 600, 600}, 600},
|
||||
// Toutes les lectures ratées : on rend « avant », donc 0 Mo libérés,
|
||||
// plutôt que d'annoncer 14 Go rendus.
|
||||
{"aucune lecture", []int{-1, -1, -1, -1, -1, -1, -1, -1}, 14000},
|
||||
// Rien ne baisse (moteur déjà arrêté) : dernière lecture valide.
|
||||
{"rien ne bouge", []int{14000, 14000, 14000, 14000, 14000, 14000, 14000, 14000}, 14000},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := gpuSettle(14000, seq(c.seq...), 0); got != c.want {
|
||||
t.Errorf("gpuSettle = %d, attendu %d", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Les deux routes arrêtent ou relancent des processus : un GET — une balise
|
||||
// <img> sur une page tierce, sans clé de pilotage — ne doit rien déclencher.
|
||||
func TestVramRoutesRefusentGET(t *testing.T) {
|
||||
for path, h := range map[string]http.HandlerFunc{
|
||||
"/api/vram/unload": handleVramUnload,
|
||||
"/api/vram/reload": handleVramReload,
|
||||
} {
|
||||
rec := httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != 405 {
|
||||
t.Errorf("%s GET : code %d, attendu 405", path, rec.Code)
|
||||
}
|
||||
if rec.Header().Get("Allow") != "POST" {
|
||||
t.Errorf("%s GET : en-tête Allow %q, attendu POST", path, rec.Header().Get("Allow"))
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "POST attendu") {
|
||||
t.Errorf("%s GET : corps %q sans explication", path, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user