mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e88a73d91 | ||
|
|
f4969de9f7 | ||
|
|
5d391c4f73 | ||
|
|
3cd4facb0e | ||
|
|
3624194b39 | ||
|
|
2fa05fdaf7 | ||
|
|
e0ed0a32af | ||
|
|
a19b490b6a | ||
|
|
393986faa6 | ||
|
|
2585d2c2e1 | ||
|
|
fedbaf32a0 | ||
|
|
cbf8635c61 | ||
|
|
69bf8d985d | ||
|
|
50fccdc9ad | ||
|
|
66072e4047 | ||
|
|
62573ff972 | ||
|
|
481028b023 | ||
|
|
86d711e772 | ||
|
|
ad107ed284 | ||
|
|
43489769f4 | ||
|
|
a333603ca6 | ||
|
|
f4090059f1 |
No files matched your search
@@ -16,6 +16,17 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Setup Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
|||||||
+62
@@ -31,6 +31,61 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
|
|||||||
# limité à cette étape de build.
|
# limité à cette étape de build.
|
||||||
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
||||||
|
|
||||||
|
# ── Étape 1 bis : whisper-server (dictée vocale) ────────────────────────
|
||||||
|
# DEUX binaires sont construits, pas un. LLAMACPP_IMAGE accepte la variante
|
||||||
|
# CPU de l'image amont (voir ligne 11) : sur cette base, les bibliothèques
|
||||||
|
# CUDA sont absentes et un binaire lié à CUDA ne démarre pas du tout —
|
||||||
|
# l'éditeur de liens échoue avant la première instruction, donc aucun repli
|
||||||
|
# n'est possible depuis l'intérieur du programme. Loki choisit à l'exécution
|
||||||
|
# (whisperServerBin, dictate_server.go).
|
||||||
|
#
|
||||||
|
# whisper-server et non whisper-cli : le modèle reste chargé entre deux
|
||||||
|
# dictées. L'ancien chemin le relisait depuis le disque à chaque phrase, ce
|
||||||
|
# qui dominait le temps de réponse.
|
||||||
|
#
|
||||||
|
# ⚠️ GGML_NATIVE=OFF est OBLIGATOIRE sur LES DEUX cibles. Par défaut ggml
|
||||||
|
# compile en -march=native, c'est-à-dire pour le processeur DU RUNNER DE
|
||||||
|
# BUILD — un Xeon récent chez GitHub, avec AVX-512 et AMX. Le binaire partait
|
||||||
|
# alors sur une machine qui n'a pas ces instructions et mourait d'un SIGILL en
|
||||||
|
# pleine transcription : « AMX is not ready to be used! », puis plus rien,
|
||||||
|
# l'interface affichant un échec sans raison. OFF retombe sur la ligne de base
|
||||||
|
# AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis 2013.
|
||||||
|
ARG WHISPER_CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
|
||||||
|
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \
|
||||||
|
-DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \
|
||||||
|
-DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF"
|
||||||
|
|
||||||
|
# Ubuntu 22.04 : glibc plus ancienne que l'image runtime, donc compatible quoi
|
||||||
|
# qu'elle embarque.
|
||||||
|
FROM ubuntu:22.04 AS whisperbuild-cpu
|
||||||
|
ARG WHISPER_CMAKE_FLAGS
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential cmake git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
|
||||||
|
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} \
|
||||||
|
&& cmake --build /w/build -j"$(nproc)" --target whisper-server
|
||||||
|
|
||||||
|
# Version CUDA. La MAJEURE de CUDA doit correspondre à celle de l'image
|
||||||
|
# runtime : le binaire est lié dynamiquement à libcudart, fournie par
|
||||||
|
# LLAMACPP_IMAGE et non par cette étape.
|
||||||
|
#
|
||||||
|
# ⚠️ -j"$(nproc)" et JAMAIS -j nu. Avec Make, « -j » sans nombre autorise un
|
||||||
|
# parallélisme ILLIMITÉ. ggml-cuda compte ~200 fichiers d'instanciation de
|
||||||
|
# gabarits, et chaque nvcc réclame 1 à 2 Go : le runner GitHub (16 Go) était
|
||||||
|
# tué par l'OOM après avoir lancé 92 % des compilations en treize secondes,
|
||||||
|
# sans écrire la moindre ligne d'erreur. L'étape CPU y survivait — peu de
|
||||||
|
# fichiers, compilation légère — ce qui rendait le piège invisible jusqu'ici.
|
||||||
|
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 AS whisperbuild-cuda
|
||||||
|
ARG WHISPER_CMAKE_FLAGS
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential cmake git ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
|
||||||
|
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} -DGGML_CUDA=ON \
|
||||||
|
&& cmake --build /w/build -j"$(nproc)" --target whisper-server \
|
||||||
|
&& strip /w/build/bin/whisper-server
|
||||||
|
|
||||||
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
||||||
FROM ${LLAMACPP_IMAGE} AS runtime
|
FROM ${LLAMACPP_IMAGE} AS runtime
|
||||||
|
|
||||||
@@ -84,6 +139,11 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
|||||||
|
|
||||||
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
||||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
||||||
|
# Dictée vocale (POST /api/transcribe). Les modèles (190 Mo à 1,1 Go) ne sont
|
||||||
|
# PAS dans l'image : téléchargés à la demande dans /data/whisper/.
|
||||||
|
# Deux binaires : voir l'étape whisperbuild-cpu pour la raison.
|
||||||
|
COPY --from=whisperbuild-cpu /w/build/bin/whisper-server /usr/local/bin/whisper-server-cpu
|
||||||
|
COPY --from=whisperbuild-cuda /w/build/bin/whisper-server /usr/local/bin/whisper-server-cuda
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
||||||
|
|
||||||
@@ -92,6 +152,8 @@ ENV LOKI_CONTAINER=1 \
|
|||||||
LOKI_HOME=/data \
|
LOKI_HOME=/data \
|
||||||
LOKI_MODEL_DIRS=/models \
|
LOKI_MODEL_DIRS=/models \
|
||||||
LOKI_ENGINE_BIN=/app/llama-server \
|
LOKI_ENGINE_BIN=/app/llama-server \
|
||||||
|
LOKI_WHISPER_SERVER_CPU=/usr/local/bin/whisper-server-cpu \
|
||||||
|
LOKI_WHISPER_SERVER_CUDA=/usr/local/bin/whisper-server-cuda \
|
||||||
LD_LIBRARY_PATH=/app \
|
LD_LIBRARY_PATH=/app \
|
||||||
NVIDIA_VISIBLE_DEVICES=all \
|
NVIDIA_VISIBLE_DEVICES=all \
|
||||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||||
|
|||||||
@@ -320,8 +320,24 @@ Ajoutées par ce fork :
|
|||||||
`AGENT_BUDGET=off` le désactive complètement.
|
`AGENT_BUDGET=off` le désactive complètement.
|
||||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||||
dans le fil.
|
dans le fil.
|
||||||
- **Paramètres** : les réglages d'application (identité, apparence, accès
|
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
|
||||||
OpenAI, actions) sont regroupés à part des réglages d'IA.
|
volets — la nav des sections à gauche (IA, moteur, application), le panneau
|
||||||
|
choisi à droite. La barre latérale ne garde que les discussions (les plus
|
||||||
|
récentes en tête) et le moniteur machine.
|
||||||
|
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
|
||||||
|
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
|
||||||
|
survit au rechargement, et un vieux tour garde le modèle de l'époque.
|
||||||
|
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
|
||||||
|
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
|
||||||
|
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
|
||||||
|
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
|
||||||
|
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
|
||||||
|
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
|
||||||
|
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
|
||||||
|
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
|
||||||
|
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
|
||||||
|
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
|
||||||
|
l'affichage ne se fige plus.
|
||||||
|
|
||||||
Retirées par ce fork :
|
Retirées par ce fork :
|
||||||
|
|
||||||
|
|||||||
+43
-9
@@ -1,19 +1,53 @@
|
|||||||
La grande nouveauté de cette version : AJEAN sait enfin voir les images.
|
# Loki 0.12.0
|
||||||
|
|
||||||
## La vision, configurable par preset
|
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
|
||||||
|
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
|
||||||
|
|
||||||
Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul.
|
## Le sélecteur de l'en-tête charge vraiment un modèle
|
||||||
|
|
||||||
Le champ ne liste que les projecteurs (les fichiers dont le nom contient `mmproj`), pas les modèles de plusieurs Go, pour que le choix reste lisible. Et s'il te manque un projecteur, le champ « télécharger un modèle » accepte aussi un lien vers un `mmproj` : une fois récupéré, il se place directement dans le champ Vision.
|
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
|
||||||
|
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
|
||||||
|
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
|
||||||
|
créé, le sélecteur n'offrait aucun choix.
|
||||||
|
|
||||||
## Les images arrivent vraiment au modèle
|
## Dictée vocale
|
||||||
|
|
||||||
Avant, une image collée dans le chat était simplement déposée comme un fichier dans le dossier de travail, à charge pour le modèle de l'ouvrir avec ses outils (ce qui ne donnait qu'un tas d'octets illisibles). Désormais, quand un projecteur est configuré, l'image part au modèle en contenu multimodal : il la voit, et peut en parler.
|
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
|
||||||
|
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
|
||||||
|
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
|
||||||
|
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
|
||||||
|
|
||||||
|
## Cartes raisonnement/outils : taille fixe, défilement qui suit
|
||||||
|
|
||||||
|
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
|
||||||
|
génération — remonter lire le début est respecté. Les cartes d'outil suivent
|
||||||
|
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
|
||||||
|
Sur un raisonnement géant, seul le bas du bloc est re-rendu en direct (le
|
||||||
|
texte complet est posé à la fin) : l'affichage ne se fige plus.
|
||||||
|
|
||||||
|
## Mesures
|
||||||
|
|
||||||
|
- La carte du temps total affiche la **vitesse moyenne de la conversation**
|
||||||
|
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
|
||||||
|
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
|
||||||
|
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
|
||||||
|
seulement les octets lus).
|
||||||
|
|
||||||
|
## Interface
|
||||||
|
|
||||||
|
- Discussions triées par date de **création** (récentes en tête) : une
|
||||||
|
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
|
||||||
|
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
|
||||||
|
Performance.
|
||||||
|
|
||||||
|
## Rappel 0.11.0
|
||||||
|
|
||||||
|
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
|
||||||
|
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
|
||||||
|
l'interface ; tâches planifiées reprises de l'amont.
|
||||||
|
|
||||||
## Mise à jour
|
## Mise à jour
|
||||||
|
|
||||||
```
|
```
|
||||||
ajean update
|
docker compose pull && docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
Non vérifié sur cette version : le résultat final dépend d'un modèle vision et de son projecteur compatibles (Qwen2.5-VL, Gemma 3, etc.). À noter, l'image reste dans l'historique de la conversation et repart au moteur à chaque tour tant que la conversation dure.
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -2,14 +2,14 @@
|
|||||||
"FixedFileInfo": {
|
"FixedFileInfo": {
|
||||||
"FileVersion": {
|
"FileVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 9,
|
"Minor": 12,
|
||||||
"Patch": 4,
|
"Patch": 3,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"ProductVersion": {
|
"ProductVersion": {
|
||||||
"Major": 0,
|
"Major": 0,
|
||||||
"Minor": 9,
|
"Minor": 12,
|
||||||
"Patch": 4,
|
"Patch": 3,
|
||||||
"Build": 0
|
"Build": 0
|
||||||
},
|
},
|
||||||
"FileFlagsMask": "3f",
|
"FileFlagsMask": "3f",
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||||
"OriginalFilename": "loki.exe",
|
"OriginalFilename": "loki.exe",
|
||||||
"ProductName": "Loki",
|
"ProductName": "Loki",
|
||||||
"ProductVersion": "0.9.4",
|
"ProductVersion": "0.12.3",
|
||||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||||
},
|
},
|
||||||
"VarFileInfo": {
|
"VarFileInfo": {
|
||||||
|
|||||||
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.
|
||||||
+198
-23
@@ -8,6 +8,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,15 +24,163 @@ import (
|
|||||||
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
|
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
|
||||||
// tort qu'il ne gère pas le drapeau.
|
// tort qu'il ne gère pas le drapeau.
|
||||||
func binSupportsReasoningFlag(bin string) bool {
|
func binSupportsReasoningFlag(bin string) bool {
|
||||||
|
return strings.Contains(binHelp(bin), "--reasoning ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
|
||||||
|
// capacités se déduisent de cette aide (--reasoning, --load-mode, « -ngl auto »)
|
||||||
|
// et relancer le moteur à chaque question coûterait quelques secondes de plus au
|
||||||
|
// démarrage. Aide illisible = chaîne vide : tous les tests de capacité répondent
|
||||||
|
// « non », c'est-à-dire « ne prends aucun risque, garde la forme historique ».
|
||||||
|
var (
|
||||||
|
helpMu sync.Mutex
|
||||||
|
helpCache = map[string]string{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func binHelp(bin string) string {
|
||||||
|
helpMu.Lock()
|
||||||
|
defer helpMu.Unlock()
|
||||||
|
if h, ok := helpCache[bin]; ok {
|
||||||
|
return h
|
||||||
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, bin, "--help")
|
cmd := exec.CommandContext(ctx, bin, "--help")
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
cmd.Stdout, cmd.Stderr = &out, &out
|
cmd.Stdout, cmd.Stderr = &out, &out
|
||||||
if err := cmd.Run(); err != nil && out.Len() == 0 {
|
h := ""
|
||||||
return false // aide illisible : on ne prend pas le risque
|
if err := cmd.Run(); err == nil || out.Len() > 0 {
|
||||||
|
h = out.String()
|
||||||
}
|
}
|
||||||
return strings.Contains(out.String(), "--reasoning ")
|
helpCache[bin] = h
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
|
||||||
|
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
|
||||||
|
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
|
||||||
|
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
|
||||||
|
func binSupportsLoadMode(bin string) bool {
|
||||||
|
return strings.Contains(binHelp(bin), "--load-mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
|
||||||
|
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
|
||||||
|
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
|
||||||
|
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
|
||||||
|
func binSupportsNGLAuto(bin string) bool {
|
||||||
|
h := binHelp(bin)
|
||||||
|
i := strings.Index(h, "--n-gpu-layers")
|
||||||
|
if i < 0 {
|
||||||
|
i = strings.Index(h, "-ngl")
|
||||||
|
}
|
||||||
|
if i < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
end := i + 400
|
||||||
|
if end > len(h) {
|
||||||
|
end = len(h)
|
||||||
|
}
|
||||||
|
return strings.Contains(h[i:end], "'auto'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
|
||||||
|
//
|
||||||
|
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
|
||||||
|
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
|
||||||
|
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
|
||||||
|
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
|
||||||
|
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
|
||||||
|
// grossier mais insensible à la mise en page.
|
||||||
|
func binFitsLayersItself(bin string) bool {
|
||||||
|
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
|
||||||
|
// note à afficher quand Loki a substitué quelque chose. Fonction pure : la seule
|
||||||
|
// question qui demande le moteur (« sait-il répartir les couches tout seul ? »)
|
||||||
|
// arrive déjà tranchée, ce qui la rend testable sans lancer llama-server.
|
||||||
|
func nglArgs(ngl string, fitsItself bool) ([]string, string) {
|
||||||
|
ngl = strings.TrimSpace(ngl)
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(ngl, "auto"):
|
||||||
|
return nil, "" // rien du tout : le moteur décide seul
|
||||||
|
case ngl == "999" && fitsItself:
|
||||||
|
return []string{"-ngl", "auto"},
|
||||||
|
"NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la " +
|
||||||
|
"VRAM libre. NGL=all pour forcer l'ancien comportement."
|
||||||
|
case ngl != "":
|
||||||
|
return []string{"-ngl", ngl}, ""
|
||||||
|
case fitsItself:
|
||||||
|
return []string{"-ngl", "auto"}, ""
|
||||||
|
default:
|
||||||
|
// Moteur ancien : omettre -ngl le ferait tourner 100 % CPU.
|
||||||
|
return []string{"-ngl", "999"}, ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasAnyFlag dit si l'utilisateur a déjà posé l'un de ces drapeaux dans
|
||||||
|
// EXTRA_ARGS. Loki ajoute plusieurs valeurs par défaut (--parallel, -ngl) : les
|
||||||
|
// ajouter EN PLUS de celles de l'utilisateur fait râler le moteur (« argument
|
||||||
|
// specified multiple times ») et, pire, c'est la DERNIÈRE occurrence qui gagne
|
||||||
|
// — le réglage explicite du preset serait donc silencieusement écrasé par le
|
||||||
|
// défaut de Loki. Quand l'utilisateur a tranché, on se tait.
|
||||||
|
func hasAnyFlag(args []string, flags ...string) bool {
|
||||||
|
for _, a := range args {
|
||||||
|
name := a
|
||||||
|
if i := strings.IndexByte(name, '='); i > 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
for _, f := range flags {
|
||||||
|
if name == f {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
|
||||||
|
// --load-mode, sur les moteurs qui le connaissent :
|
||||||
|
//
|
||||||
|
// --mlock → --load-mode mlock (garder le modèle en RAM)
|
||||||
|
// --no-mmap → --load-mode none (tout charger, pas de mmap)
|
||||||
|
// --mmap → --load-mode mmap
|
||||||
|
//
|
||||||
|
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
|
||||||
|
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
|
||||||
|
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
|
||||||
|
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
|
||||||
|
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||||
|
if !supportsLoadMode {
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
explicit := hasAnyFlag(args, "--load-mode")
|
||||||
|
mlock, nommap, mmap := false, false, false
|
||||||
|
out := make([]string, 0, len(args)+2)
|
||||||
|
for _, a := range args {
|
||||||
|
switch a {
|
||||||
|
case "--mlock":
|
||||||
|
mlock = true
|
||||||
|
case "--no-mmap":
|
||||||
|
nommap = true
|
||||||
|
case "--mmap":
|
||||||
|
mmap = true
|
||||||
|
default:
|
||||||
|
out = append(out, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if explicit || !(mlock || nommap || mmap) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
mode := "mmap"
|
||||||
|
switch {
|
||||||
|
case mlock:
|
||||||
|
mode = "mlock"
|
||||||
|
case nommap:
|
||||||
|
mode = "none"
|
||||||
|
}
|
||||||
|
return append(out, "--load-mode", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// cmdServe replaces the historic start.sh: read config.env, build the
|
// cmdServe replaces the historic start.sh: read config.env, build the
|
||||||
@@ -99,6 +248,12 @@ func cmdServe(args []string) error {
|
|||||||
ktv := get("KV_TYPE_K", kv)
|
ktv := get("KV_TYPE_K", kv)
|
||||||
vtv := get("KV_TYPE_V", kv)
|
vtv := get("KV_TYPE_V", kv)
|
||||||
|
|
||||||
|
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
|
||||||
|
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
|
||||||
|
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
|
||||||
|
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
|
||||||
|
extra := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
|
||||||
|
|
||||||
llmArgs := []string{bin,
|
llmArgs := []string{bin,
|
||||||
"-m", model,
|
"-m", model,
|
||||||
"-c", get("CTX", "32768"),
|
"-c", get("CTX", "32768"),
|
||||||
@@ -108,24 +263,44 @@ func cmdServe(args []string) error {
|
|||||||
"-ub", get("UBATCH", "512"),
|
"-ub", get("UBATCH", "512"),
|
||||||
"--host", get("HOST", "0.0.0.0"),
|
"--host", get("HOST", "0.0.0.0"),
|
||||||
"--port", get("PORT", "8080"),
|
"--port", get("PORT", "8080"),
|
||||||
// --parallel 1 EXPLICITE : les llama-server récents ouvrent 4 slots par
|
|
||||||
// défaut, soit des tampons de calcul GPU ×4 — pour un serveur mono-
|
|
||||||
// utilisateur comme Loki, c'est de la VRAM brûlée pour rien. Vécu : une
|
|
||||||
// config 27B/60k ctx qui tournait très bien est morte en « cudaMalloc
|
|
||||||
// failed: out of memory » après une mise à jour du moteur, uniquement à
|
|
||||||
// cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui veut
|
|
||||||
// vraiment servir plusieurs requêtes à la fois.
|
|
||||||
"--parallel", get("PARALLEL", "1"),
|
|
||||||
}
|
}
|
||||||
// NGL=auto : on n'envoie PAS -ngl, et llama.cpp choisit lui-même combien de
|
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
|
||||||
// couches tiennent dans la VRAM libre (common_fit_params). Il s'en abstient
|
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
|
||||||
// dès que la valeur est imposée — « n_gpu_layers already set by user to 999,
|
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
|
||||||
// abort » — d'où des cudaMalloc en échec, un repli partiel sur le CPU et un
|
// Vécu : une config 27B/60k ctx qui tournait très bien est morte en
|
||||||
// débit effondré alors que le GPU tourne à 100 %.
|
// « cudaMalloc failed: out of memory » après une mise à jour du moteur,
|
||||||
// Le défaut reste 999 (tout sur le GPU) : omettre -ngl sur un llama.cpp
|
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
|
||||||
// antérieur à cet ajustement automatique le ferait tourner 100 % CPU.
|
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
|
||||||
if ngl := get("NGL", "999"); !strings.EqualFold(ngl, "auto") {
|
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
|
||||||
llmArgs = append(llmArgs, "-ngl", ngl)
|
if !hasAnyFlag(extra, "--parallel", "-np") {
|
||||||
|
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
|
||||||
|
}
|
||||||
|
// Couches GPU. Quatre cas, dans cet ordre :
|
||||||
|
//
|
||||||
|
// NGL=auto → rien du tout (le moteur décide seul)
|
||||||
|
// NGL=999 → -ngl auto sur un moteur récent (voir plus bas)
|
||||||
|
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche pour de vrai)
|
||||||
|
// clé absente → -ngl auto si le moteur le propose, sinon -ngl 999
|
||||||
|
//
|
||||||
|
// Imposer un NOMBRE désarme common_fit_params, qui mesure la VRAM libre et
|
||||||
|
// choisit combien de couches y tiennent : « n_gpu_layers already set by user
|
||||||
|
// to 999, abort ». Le moteur pousse alors tout sur le GPU, échoue en
|
||||||
|
// cudaMalloc ou se replie à moitié sur le CPU — débit effondré, GPU à 100 %.
|
||||||
|
//
|
||||||
|
// 999 est traité à part parce que ce n'a JAMAIS été un nombre de couches :
|
||||||
|
// c'est la sentinelle historique « toutes », semée dans config.env par
|
||||||
|
// defaultConfig() sur chaque installation neuve. La quasi-totalité des
|
||||||
|
// configurations la portent donc sans que personne ne l'ait choisie — la
|
||||||
|
// traiter comme un choix délibéré, c'est condamner tout le monde à l'abandon
|
||||||
|
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
|
||||||
|
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
|
||||||
|
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
|
||||||
|
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||||
|
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
|
||||||
|
if note != "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
|
||||||
|
}
|
||||||
|
llmArgs = append(llmArgs, args...)
|
||||||
}
|
}
|
||||||
if ktv != "" {
|
if ktv != "" {
|
||||||
llmArgs = append(llmArgs, "-ctk", ktv)
|
llmArgs = append(llmArgs, "-ctk", ktv)
|
||||||
@@ -187,9 +362,9 @@ func cmdServe(args []string) error {
|
|||||||
if k, _ := effectiveAPIKeyErr(); k != "" {
|
if k, _ := effectiveAPIKeyErr(); k != "" {
|
||||||
llmArgs = append(llmArgs, "--api-key", k)
|
llmArgs = append(llmArgs, "--api-key", k)
|
||||||
}
|
}
|
||||||
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
|
// EXTRA_ARGS (déjà découpé plus haut comme le ferait le shell — les guillemets
|
||||||
// together so a path with spaces stays one argument.
|
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
|
||||||
llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
|
llmArgs = append(llmArgs, extra...)
|
||||||
|
|
||||||
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
||||||
// mmproj-F16.gguf) still resolve.
|
// mmproj-F16.gguf) still resolve.
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHasAnyFlag(t *testing.T) {
|
||||||
|
args := []string{"--parallel", "4", "--load-mode=mlock", "-fa"}
|
||||||
|
if !hasAnyFlag(args, "--parallel", "-np") {
|
||||||
|
t.Fatal("--parallel devrait être détecté")
|
||||||
|
}
|
||||||
|
// Forme --clé=valeur : le nom seul compte.
|
||||||
|
if !hasAnyFlag(args, "--load-mode") {
|
||||||
|
t.Fatal("--load-mode=… devrait être détecté")
|
||||||
|
}
|
||||||
|
if hasAnyFlag(args, "-ngl", "--n-gpu-layers") {
|
||||||
|
t.Fatal("-ngl absent, ne devrait pas être détecté")
|
||||||
|
}
|
||||||
|
// Une VALEUR qui ressemble à un drapeau ne doit pas compter comme tel :
|
||||||
|
// ici "-ngl" n'est pas posé, seul --parallel l'est.
|
||||||
|
if hasAnyFlag(nil, "--parallel") {
|
||||||
|
t.Fatal("liste vide : rien à détecter")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLoadFlags(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
in []string
|
||||||
|
supports bool
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
|
||||||
|
[]string{"--mlock", "--no-mmap"}},
|
||||||
|
{"mlock traduit", []string{"--mlock", "-fa"}, true,
|
||||||
|
[]string{"-fa", "--load-mode", "mlock"}},
|
||||||
|
{"no-mmap traduit", []string{"--no-mmap"}, true,
|
||||||
|
[]string{"--load-mode", "none"}},
|
||||||
|
{"mmap traduit", []string{"--mmap"}, true,
|
||||||
|
[]string{"--load-mode", "mmap"}},
|
||||||
|
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
|
||||||
|
[]string{"--load-mode", "mlock"}},
|
||||||
|
{"--load-mode explicite gagne, vieux drapeaux retirés",
|
||||||
|
[]string{"--mlock", "--load-mode", "dio"}, true,
|
||||||
|
[]string{"--load-mode", "dio"}},
|
||||||
|
{"rien à traduire", []string{"-fa", "--n-cpu-moe", "8"}, true,
|
||||||
|
[]string{"-fa", "--n-cpu-moe", "8"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := normalizeLoadFlags(c.in, c.supports)
|
||||||
|
if !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Fatalf("got %q, want %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNGLArgs(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
ngl string
|
||||||
|
fitsItself bool
|
||||||
|
want []string
|
||||||
|
wantNote bool
|
||||||
|
}{
|
||||||
|
// Le cas qui motive tout : defaultConfig() sème NGL=999 sur chaque
|
||||||
|
// installation neuve. Personne ne l'a choisi, et le moteur récent
|
||||||
|
// abandonne son calcul de VRAM dès qu'on lui impose un nombre.
|
||||||
|
{"sentinelle 999, moteur récent", "999", true, []string{"-ngl", "auto"}, true},
|
||||||
|
{"sentinelle 999, moteur ancien", "999", false, []string{"-ngl", "999"}, false},
|
||||||
|
{"nombre choisi, jamais touché", "28", true, []string{"-ngl", "28"}, false},
|
||||||
|
{"nombre choisi, moteur ancien", "28", false, []string{"-ngl", "28"}, false},
|
||||||
|
{"all reste all", "all", true, []string{"-ngl", "all"}, false},
|
||||||
|
{"auto : aucun drapeau", "auto", true, nil, false},
|
||||||
|
{"auto insensible à la casse", "AUTO", true, nil, false},
|
||||||
|
{"espaces parasites autour de la sentinelle", " 999 ", true, []string{"-ngl", "auto"}, true},
|
||||||
|
{"clé absente, moteur récent", "", true, []string{"-ngl", "auto"}, false},
|
||||||
|
{"clé absente, moteur ancien", "", false, []string{"-ngl", "999"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, note := nglArgs(c.ngl, c.fitsItself)
|
||||||
|
if !reflect.DeepEqual(got, c.want) {
|
||||||
|
t.Fatalf("args = %q, want %q", got, c.want)
|
||||||
|
}
|
||||||
|
if (note != "") != c.wantNote {
|
||||||
|
t.Fatalf("note = %q, en voulait-on une ? %v", note, c.wantNote)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Réglages de la dictée vocale — modèle, langue, matériel, réactivité.
|
||||||
|
//
|
||||||
|
// Ils vivent dans le magasin clé/valeur (bkState), comme le jeton Hugging
|
||||||
|
// Face : ce sont des réglages de SERVEUR, pas de navigateur. Le modèle chargé
|
||||||
|
// et le GPU occupé sont des propriétés de la machine, identiques pour tous les
|
||||||
|
// onglets ouverts.
|
||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dictateCfgKey = "dictate_cfg"
|
||||||
|
|
||||||
|
// DictateCfg : Device vaut "cpu" ou un indice de GPU en décimal ("0", "1"),
|
||||||
|
// dans l'ordre de nvidia-smi — le même que celui affiché par /api/vram.
|
||||||
|
type DictateCfg struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Lang string `json:"lang"`
|
||||||
|
Device string `json:"device"`
|
||||||
|
Reactivity string `json:"reactivity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// withDefauts comble les champs vides. Appliqué à la LECTURE plutôt qu'à
|
||||||
|
// l'écriture : une version future qui ajoute un champ trouvera les réglages
|
||||||
|
// déjà enregistrés sans champ correspondant, et doit pouvoir le combler.
|
||||||
|
func (c DictateCfg) withDefauts() DictateCfg {
|
||||||
|
if c.Model == "" {
|
||||||
|
// large-v3-turbo : meilleur rapport qualité/vitesse en français à ce
|
||||||
|
// jour. Le défaut penche vers la qualité.
|
||||||
|
c.Model = "large-v3-turbo-q5_0"
|
||||||
|
}
|
||||||
|
if c.Lang == "" {
|
||||||
|
// Pas "auto" : sur des tranches de quelques secondes la détection se
|
||||||
|
// trompe, et une langue mal détectée produit du charabia — des suites
|
||||||
|
// de caractères géorgiens ont été observées en production.
|
||||||
|
c.Lang = "fr"
|
||||||
|
}
|
||||||
|
if c.Device == "" {
|
||||||
|
c.Device = "cpu"
|
||||||
|
}
|
||||||
|
if c.Reactivity == "" {
|
||||||
|
c.Reactivity = "moyen"
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func dictateCfgLoad() DictateCfg {
|
||||||
|
var c DictateCfg
|
||||||
|
getJSON(bkState, dictateCfgKey, &c)
|
||||||
|
return c.withDefauts()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dictateCfgSave(c DictateCfg) error {
|
||||||
|
c = c.withDefauts()
|
||||||
|
// Valider ICI plutôt qu'au démarrage du serveur : un modèle hors catalogue
|
||||||
|
// accepté en silence ne se manifesterait qu'au premier clic sur le micro,
|
||||||
|
// loin du geste qui l'a causé.
|
||||||
|
if _, ok := whisperCatalogue[c.Model]; !ok {
|
||||||
|
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||||
|
}
|
||||||
|
return putJSON(bkState, dictateCfgKey, c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cudaVisibleDevices traduit le réglage en valeur de CUDA_VISIBLE_DEVICES.
|
||||||
|
// Vide = aucun GPU visible = whisper tourne sur le CPU. Toute valeur qui n'est
|
||||||
|
// pas un indice positif retombe sur le CPU : mieux vaut une dictée lente qu'un
|
||||||
|
// GPU choisi au hasard sur une machine dont on ne sait rien.
|
||||||
|
func (c DictateCfg) cudaVisibleDevices() string {
|
||||||
|
n, err := strconv.Atoi(c.Device)
|
||||||
|
if err != nil || n < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// chunkBounds : réserve minimale avant de couper, et coupure forcée quand
|
||||||
|
// aucun silence n'arrive. Consommé par le découpeur du plan « temps réel ».
|
||||||
|
func (c DictateCfg) chunkBounds() (time.Duration, time.Duration) {
|
||||||
|
switch c.Reactivity {
|
||||||
|
case "court":
|
||||||
|
return 1000 * time.Millisecond, 5 * time.Second
|
||||||
|
case "long":
|
||||||
|
return 3 * time.Second, 15 * time.Second
|
||||||
|
default:
|
||||||
|
return 1500 * time.Millisecond, 8 * time.Second
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDictateCfgDefauts(t *testing.T) {
|
||||||
|
c := DictateCfg{}.withDefauts()
|
||||||
|
if c.Model != "large-v3-turbo-q5_0" {
|
||||||
|
t.Errorf("modèle par défaut = %q, attendu large-v3-turbo-q5_0", c.Model)
|
||||||
|
}
|
||||||
|
if c.Lang != "fr" {
|
||||||
|
t.Errorf("langue par défaut = %q, attendu fr — la détection auto se trompe sur les tranches courtes", c.Lang)
|
||||||
|
}
|
||||||
|
if c.Device != "cpu" {
|
||||||
|
t.Errorf("matériel par défaut = %q, attendu cpu (aucun GPU supposé)", c.Device)
|
||||||
|
}
|
||||||
|
if c.Reactivity != "moyen" {
|
||||||
|
t.Errorf("réactivité par défaut = %q, attendu moyen", c.Reactivity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCudaVisibleDevices(t *testing.T) {
|
||||||
|
cas := []struct{ device, want string }{
|
||||||
|
{"cpu", ""},
|
||||||
|
{"0", "0"},
|
||||||
|
{"1", "1"},
|
||||||
|
{"", ""},
|
||||||
|
{"bidon", ""},
|
||||||
|
{"-1", ""},
|
||||||
|
}
|
||||||
|
for _, c := range cas {
|
||||||
|
got := DictateCfg{Device: c.device}.cudaVisibleDevices()
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("cudaVisibleDevices(%q) = %q, attendu %q", c.device, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChunkBounds(t *testing.T) {
|
||||||
|
cas := []struct {
|
||||||
|
react string
|
||||||
|
min, max time.Duration
|
||||||
|
}{
|
||||||
|
{"court", 1000 * time.Millisecond, 5 * time.Second},
|
||||||
|
{"moyen", 1500 * time.Millisecond, 8 * time.Second},
|
||||||
|
{"long", 3 * time.Second, 15 * time.Second},
|
||||||
|
{"inconnu", 1500 * time.Millisecond, 8 * time.Second},
|
||||||
|
}
|
||||||
|
for _, c := range cas {
|
||||||
|
min, max := DictateCfg{Reactivity: c.react}.chunkBounds()
|
||||||
|
if min != c.min || max != c.max {
|
||||||
|
t.Errorf("chunkBounds(%q) = %v/%v, attendu %v/%v", c.react, min, max, c.min, c.max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDictateCfgAllerRetour(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
in := DictateCfg{Model: "medium-q5_0", Lang: "en", Device: "1", Reactivity: "long"}
|
||||||
|
if err := dictateCfgSave(in); err != nil {
|
||||||
|
t.Fatalf("enregistrement : %v", err)
|
||||||
|
}
|
||||||
|
if out := dictateCfgLoad(); out != in {
|
||||||
|
t.Errorf("relu %+v, attendu %+v", out, in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un modèle hors catalogue accepté en silence ne se manifesterait qu'au premier
|
||||||
|
// clic sur le micro, loin du geste qui l'a causé.
|
||||||
|
func TestDictateCfgSaveRefuseModeleInconnu(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
if err := dictateCfgSave(DictateCfg{Model: "modele-inexistant", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err == nil {
|
||||||
|
t.Error("un modèle hors catalogue doit être refusé")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sans réglage enregistré, la lecture doit rendre les défauts — et non le zéro
|
||||||
|
// de la structure, qui donnerait un modèle vide à whisper-server.
|
||||||
|
func TestDictateCfgLoadSansRien(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
c := dictateCfgLoad()
|
||||||
|
if c.Model == "" || c.Lang == "" || c.Device == "" || c.Reactivity == "" {
|
||||||
|
t.Errorf("lecture à vide = %+v, aucun champ ne doit rester vide", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// Catalogue des modèles de dictée. Aucun n'est embarqué dans l'image : ils
|
||||||
|
// pèsent de 190 Mo à 1,1 Go et vivent dans <LOKI_HOME>/whisper/, donc dans le
|
||||||
|
// volume /data — ils survivent aux recréations du conteneur.
|
||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Les modèles ggml officiels de whisper.cpp.
|
||||||
|
const whisperModelBase = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
|
||||||
|
|
||||||
|
type whisperModel struct {
|
||||||
|
Nom string // libellé affiché
|
||||||
|
Fichier string // nom du .bin, identique en local et chez Hugging Face
|
||||||
|
Octets int64 // taille du téléchargement, pour prévenir avant de le lancer
|
||||||
|
VRAMMo int // ordre de grandeur occupé sur le GPU, pour choisir en connaissance de cause
|
||||||
|
}
|
||||||
|
|
||||||
|
// Les tailles sont celles publiées par le dépôt amont. Elles servent à
|
||||||
|
// informer avant un téléchargement, pas à vérifier le fichier : une taille qui
|
||||||
|
// dérive d'une release à l'autre ne doit pas casser la dictée.
|
||||||
|
var whisperCatalogue = map[string]whisperModel{
|
||||||
|
"small-q5_1": {Nom: "small — rapide, correct", Fichier: "ggml-small-q5_1.bin", Octets: 190_000_000, VRAMMo: 600},
|
||||||
|
"medium-q5_0": {Nom: "medium — bon compromis", Fichier: "ggml-medium-q5_0.bin", Octets: 539_000_000, VRAMMo: 1400},
|
||||||
|
"large-v3-turbo-q5_0": {Nom: "large-v3-turbo — recommandé", Fichier: "ggml-large-v3-turbo-q5_0.bin", Octets: 574_000_000, VRAMMo: 1600},
|
||||||
|
"large-v3-q5_0": {Nom: "large-v3 — le plus précis, le plus lent", Fichier: "ggml-large-v3-q5_0.bin", Octets: 1_080_000_000, VRAMMo: 3600},
|
||||||
|
}
|
||||||
|
|
||||||
|
// whisperModelPathFor : chemin local. Vide si l'identifiant est inconnu —
|
||||||
|
// l'appelant doit traiter ce cas plutôt que de bâtir un chemin sur une chaîne
|
||||||
|
// arbitraire venue d'une requête HTTP.
|
||||||
|
func whisperModelPathFor(id string) string {
|
||||||
|
m, ok := whisperCatalogue[id]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Join(LokiHome(), "whisper", m.Fichier)
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperModelURLFor(id string) string {
|
||||||
|
m, ok := whisperCatalogue[id]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return whisperModelBase + m.Fichier
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperModelPresent(id string) bool {
|
||||||
|
p := whisperModelPathFor(id)
|
||||||
|
if p == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
st, err := os.Stat(p)
|
||||||
|
return err == nil && st.Size() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// whisperCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
|
||||||
|
// l'ordre dans lequel se pose la question du compromis.
|
||||||
|
func whisperCatalogueTrie() []map[string]any {
|
||||||
|
out := make([]map[string]any, 0, len(whisperCatalogue))
|
||||||
|
for id, m := range whisperCatalogue {
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"id": id, "nom": m.Nom, "octets": m.Octets,
|
||||||
|
"vram_mo": m.VRAMMo, "present": whisperModelPresent(id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool {
|
||||||
|
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCatalogueContientLeDefautEtLAncien(t *testing.T) {
|
||||||
|
// Le défaut doit exister, sinon dictateCfgSave refuse sa propre valeur par
|
||||||
|
// défaut et plus aucun réglage n'est enregistrable.
|
||||||
|
if _, ok := whisperCatalogue["large-v3-turbo-q5_0"]; !ok {
|
||||||
|
t.Error("le modèle par défaut large-v3-turbo-q5_0 est absent du catalogue")
|
||||||
|
}
|
||||||
|
// small-q5_1 est le modèle déjà téléchargé sur les installations
|
||||||
|
// existantes : le retirer le rendrait insélectionnable alors que son
|
||||||
|
// fichier est là.
|
||||||
|
if _, ok := whisperCatalogue["small-q5_1"]; !ok {
|
||||||
|
t.Error("small-q5_1 est absent : les installations existantes l'ont déjà sur disque")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelPathEtURL(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
p := whisperModelPathFor("small-q5_1")
|
||||||
|
if filepath.Base(p) != "ggml-small-q5_1.bin" {
|
||||||
|
t.Errorf("chemin = %q, le fichier doit s'appeler ggml-small-q5_1.bin", p)
|
||||||
|
}
|
||||||
|
// Le chemin DOIT rester celui d'avant, sinon les installations existantes
|
||||||
|
// re-téléchargent 190 Mo pour rien.
|
||||||
|
if filepath.Base(filepath.Dir(p)) != "whisper" {
|
||||||
|
t.Errorf("chemin = %q, le dossier doit rester <LOKI_HOME>/whisper", p)
|
||||||
|
}
|
||||||
|
u := whisperModelURLFor("small-q5_1")
|
||||||
|
if !strings.HasSuffix(u, "/ggml-small-q5_1.bin") {
|
||||||
|
t.Errorf("URL = %q, doit finir par /ggml-small-q5_1.bin", u)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(u, "https://") {
|
||||||
|
t.Errorf("URL = %q, doit être en HTTPS", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelPathInconnuVide(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
if p := whisperModelPathFor("nawak"); p != "" {
|
||||||
|
t.Errorf("un modèle hors catalogue doit donner un chemin vide, pas %q", p)
|
||||||
|
}
|
||||||
|
if u := whisperModelURLFor("nawak"); u != "" {
|
||||||
|
t.Errorf("URL d'un modèle inconnu = %q, attendu vide", u)
|
||||||
|
}
|
||||||
|
if whisperModelPresent("nawak") {
|
||||||
|
t.Error("un modèle inconnu ne peut pas être présent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCatalogueTrieParTaille(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
l := whisperCatalogueTrie()
|
||||||
|
if len(l) < 4 {
|
||||||
|
t.Fatalf("catalogue de %d entrées, au moins 4 attendues", len(l))
|
||||||
|
}
|
||||||
|
var prec int64
|
||||||
|
for i, m := range l {
|
||||||
|
o, _ := m["octets"].(int64)
|
||||||
|
if i > 0 && o < prec {
|
||||||
|
t.Errorf("entrée %d : catalogue non trié par taille croissante", i)
|
||||||
|
}
|
||||||
|
prec = o
|
||||||
|
for _, clef := range []string{"id", "nom", "octets", "vram_mo", "present"} {
|
||||||
|
if _, ok := m[clef]; !ok {
|
||||||
|
t.Errorf("entrée %d : clé %q manquante — l'UI en a besoin", i, clef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigRouteAllerRetour(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
corps := strings.NewReader(`{"model":"small-q5_1","lang":"en","device":"1","reactivity":"court"}`)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("POST = %d, corps %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
rec = httptest.NewRecorder()
|
||||||
|
handleDictateConfig(rec, httptest.NewRequest("GET", "/api/dictate/config", nil))
|
||||||
|
var got DictateCfg
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("réponse illisible : %v", err)
|
||||||
|
}
|
||||||
|
if got.Device != "1" || got.Lang != "en" || got.Model != "small-q5_1" || got.Reactivity != "court" {
|
||||||
|
t.Errorf("relu %+v, attendu le réglage enregistré", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le refus doit venir de la ROUTE, pas seulement du magasin : sinon l'UI reçoit
|
||||||
|
// un 200 et croit son réglage pris en compte.
|
||||||
|
func TestConfigRouteRefuseModeleInconnu(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
corps := strings.NewReader(`{"model":"nawak","lang":"fr","device":"cpu","reactivity":"moyen"}`)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||||
|
if rec.Code == 200 {
|
||||||
|
t.Errorf("modèle hors catalogue accepté (%d) : %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigRouteCorpsIllisible(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", strings.NewReader("pas du json")))
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Errorf("code = %d, attendu 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModelsRoute(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleDictateModels(rec, httptest.NewRequest("GET", "/api/dictate/models", nil))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("GET = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var l []map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &l); err != nil {
|
||||||
|
t.Fatalf("réponse illisible : %v", err)
|
||||||
|
}
|
||||||
|
if len(l) < 4 {
|
||||||
|
t.Errorf("%d modèles renvoyés, au moins 4 attendus", len(l))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un identifiant inconnu ne doit pas lancer de téléchargement : whisperDownload
|
||||||
|
// bâtirait une URL vide et le message d'erreur n'arriverait qu'en arrière-plan.
|
||||||
|
func TestDownloadRouteRefuseInconnu(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
corps := strings.NewReader(`{"id":"nawak"}`)
|
||||||
|
handleDictateDownload(rec, httptest.NewRequest("POST", "/api/dictate/models/download", corps))
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Errorf("code = %d, attendu 400 pour un modèle inconnu", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStateRoute(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleDictateState(rec, httptest.NewRequest("GET", "/api/dictate/state", nil))
|
||||||
|
if rec.Code != 200 {
|
||||||
|
t.Fatalf("GET = %d", rec.Code)
|
||||||
|
}
|
||||||
|
var e map[string]any
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||||
|
t.Fatalf("réponse illisible : %v", err)
|
||||||
|
}
|
||||||
|
for _, clef := range []string{"actif", "modele", "present", "device", "dl"} {
|
||||||
|
if _, ok := e[clef]; !ok {
|
||||||
|
t.Errorf("clé %q manquante dans l'état", clef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un audio trop court est rejeté AVANT toute tentative de lancer whisper-server :
|
||||||
|
// inutile de charger un modèle pour un clic parasite.
|
||||||
|
func TestTranscribeAudioTropCourt(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
handleTranscribe(rec, httptest.NewRequest("POST", "/api/transcribe", strings.NewReader("court")))
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Errorf("code = %d, attendu 400", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
// Supervision de whisper-server : le modèle est chargé UNE fois et reste en
|
||||||
|
// mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
|
||||||
|
//
|
||||||
|
// L'ancien chemin lançait whisper-cli par requête. Le chargement du modèle
|
||||||
|
// dominait le temps de réponse (~3 s pour 3,4 s d'audio), et découper la
|
||||||
|
// dictée en tranches par-dessus ce mécanisme aurait rechargé le modèle toutes
|
||||||
|
// les quelques secondes.
|
||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// whisperServerBin : deux binaires sont livrés, pas un.
|
||||||
|
//
|
||||||
|
// L'image runtime peut être bâtie sur la variante CPU de llama.cpp
|
||||||
|
// (LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server). Sur cette base les
|
||||||
|
// bibliothèques CUDA sont absentes, et un binaire lié à CUDA ne démarre pas du
|
||||||
|
// tout : l'éditeur de liens échoue avant la première instruction, il n'y a
|
||||||
|
// donc aucun repli possible depuis l'intérieur du programme. D'où deux
|
||||||
|
// binaires et un choix fait ici.
|
||||||
|
func whisperServerBin(gpu bool) string {
|
||||||
|
env, defaut := "LOKI_WHISPER_SERVER_CPU", "/usr/local/bin/whisper-server-cpu"
|
||||||
|
if gpu {
|
||||||
|
env, defaut = "LOKI_WHISPER_SERVER_CUDA", "/usr/local/bin/whisper-server-cuda"
|
||||||
|
}
|
||||||
|
for _, p := range []string{os.Getenv(env), defaut} {
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if gpu {
|
||||||
|
// Pas de binaire CUDA : l'appelant retombe sur le CPU en le disant.
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath("whisper-server"); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperServerArgs(c DictateCfg, port int) ([]string, error) {
|
||||||
|
model := whisperModelPathFor(c.Model)
|
||||||
|
if model == "" {
|
||||||
|
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||||
|
}
|
||||||
|
return []string{
|
||||||
|
"-m", model,
|
||||||
|
"-l", c.Lang,
|
||||||
|
// 127.0.0.1 et pas 0.0.0.0 : whisper-server n'a aucune
|
||||||
|
// authentification. Sur un conteneur au réseau partagé, l'exposer
|
||||||
|
// offrirait la transcription à tout le réseau.
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
"--port", strconv.Itoa(port),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// whisperServerEnv pose CUDA_VISIBLE_DEVICES en REMPLAÇANT toute valeur déjà
|
||||||
|
// présente. Une variable dupliquée laisse le gagnant dépendre de la libc, et
|
||||||
|
// le choix de GPU deviendrait un coup de dé.
|
||||||
|
func whisperServerEnv(c DictateCfg, base []string) []string {
|
||||||
|
const clef = "CUDA_VISIBLE_DEVICES="
|
||||||
|
out := make([]string, 0, len(base)+1)
|
||||||
|
for _, v := range base {
|
||||||
|
if !strings.HasPrefix(v, clef) {
|
||||||
|
out = append(out, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Valeur vide en mode CPU : masque TOUS les GPU. Retirer la variable
|
||||||
|
// ferait l'inverse — ggml les verrait tous.
|
||||||
|
return append(out, clef+c.cudaVisibleDevices())
|
||||||
|
}
|
||||||
|
|
||||||
|
// portLibre demande au système un port disponible puis le relâche. Coder un
|
||||||
|
// port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre
|
||||||
|
// la fermeture et la reprise par whisper-server est négligeable devant le
|
||||||
|
// risque d'un conflit permanent.
|
||||||
|
func portLibre() (int, error) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer func() { _ = l.Close() }()
|
||||||
|
return l.Addr().(*net.TCPAddr).Port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// whisperMarqueur reconnaît les annotations que whisper produit à la place
|
||||||
|
// d'un texte : [BLANK_AUDIO], (silence), [SOUND]… Un contenu entièrement
|
||||||
|
// entouré de crochets ou de parenthèses n'est jamais de la parole dictée, et
|
||||||
|
// le laisser passer le collerait tel quel dans le champ de saisie.
|
||||||
|
func whisperMarqueur(s string) bool {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
|
||||||
|
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// whisperInferSur prend l'URL de base en paramètre : c'est ce qui rend l'appel
|
||||||
|
// testable sans lancer de processus.
|
||||||
|
func whisperInferSur(ctx context.Context, base string, wav []byte) (string, error) {
|
||||||
|
var corps bytes.Buffer
|
||||||
|
mw := multipart.NewWriter(&corps)
|
||||||
|
part, err := mw.CreateFormFile("file", "dictee.wav")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if _, err := part.Write(wav); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := mw.WriteField("response_format", "json"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := mw.Close(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/inference", &corps)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
brut, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("whisper-server a répondu %d : %s", resp.StatusCode, lastLine(string(brut)))
|
||||||
|
}
|
||||||
|
var j struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(brut, &j); err != nil {
|
||||||
|
return "", fmt.Errorf("réponse illisible de whisper-server : %w", err)
|
||||||
|
}
|
||||||
|
if whisperMarqueur(j.Text) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(j.Text), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Supervision ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// whisperIdle : au-delà, le serveur s'éteint et rend sa VRAM. Une dictée n'est
|
||||||
|
// pas un service permanent ; garder un modèle chargé toute la journée pour
|
||||||
|
// quelques phrases prive le moteur de chat de mémoire GPU.
|
||||||
|
const whisperIdle = 10 * time.Minute
|
||||||
|
|
||||||
|
// whisperReady : plafond d'attente au démarrage. Un modèle de 1 Go lu depuis un
|
||||||
|
// disque lent puis poussé sur le GPU prend du temps ; échouer trop tôt
|
||||||
|
// afficherait une panne là où il n'y a qu'une lenteur.
|
||||||
|
const whisperReady = 120 * time.Second
|
||||||
|
|
||||||
|
var wsrvMu sync.Mutex
|
||||||
|
var wsrv struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
port int
|
||||||
|
cfg DictateCfg
|
||||||
|
log *bytes.Buffer
|
||||||
|
timer *time.Timer
|
||||||
|
note string // pourquoi on ne tourne pas sur le matériel demandé
|
||||||
|
depuis time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// whisperEnsure garantit un serveur vivant pour la configuration COURANTE, et
|
||||||
|
// renvoie son port. Si les réglages ont changé depuis le lancement, l'ancien
|
||||||
|
// est arrêté : c'est ce qui rend un changement de modèle ou de GPU effectif
|
||||||
|
// sans redémarrer Loki.
|
||||||
|
func whisperEnsure() (int, error) {
|
||||||
|
cfg := dictateCfgLoad()
|
||||||
|
wsrvMu.Lock()
|
||||||
|
defer wsrvMu.Unlock()
|
||||||
|
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
|
||||||
|
whisperTouchLocked()
|
||||||
|
return wsrv.port, nil
|
||||||
|
}
|
||||||
|
whisperStopLocked()
|
||||||
|
return whisperStartLocked(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperStartLocked(cfg DictateCfg) (int, error) {
|
||||||
|
if !whisperModelPresent(cfg.Model) {
|
||||||
|
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
|
||||||
|
}
|
||||||
|
note := ""
|
||||||
|
gpu := cfg.cudaVisibleDevices() != ""
|
||||||
|
bin := whisperServerBin(gpu)
|
||||||
|
if bin == "" && gpu {
|
||||||
|
// Image bâtie sans CUDA : on le DIT, au lieu de laisser croire que le
|
||||||
|
// GPU choisi est utilisé.
|
||||||
|
note = "binaire CUDA absent de cette image — dictée sur le processeur"
|
||||||
|
gpu = false
|
||||||
|
cfg.Device = "cpu"
|
||||||
|
bin = whisperServerBin(false)
|
||||||
|
}
|
||||||
|
if bin == "" {
|
||||||
|
return 0, fmt.Errorf("whisper-server introuvable (image à reconstruire)")
|
||||||
|
}
|
||||||
|
port, err := portLibre()
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("aucun port libre pour whisper-server : %w", err)
|
||||||
|
}
|
||||||
|
args, err := whisperServerArgs(cfg, port)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
log := &bytes.Buffer{}
|
||||||
|
cmd := exec.Command(bin, args...)
|
||||||
|
cmd.Env = whisperServerEnv(cfg, os.Environ())
|
||||||
|
cmd.Stdout, cmd.Stderr = log, log
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return 0, fmt.Errorf("démarrage de whisper-server : %w", err)
|
||||||
|
}
|
||||||
|
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad()
|
||||||
|
wsrv.log, wsrv.note, wsrv.depuis = log, note, time.Now()
|
||||||
|
if err := whisperAttendPret(cmd, port, log); err != nil {
|
||||||
|
whisperStopLocked()
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
whisperTouchLocked()
|
||||||
|
return port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// whisperAttendPret sonde le serveur jusqu'à ce qu'il réponde. Si le processus
|
||||||
|
// meurt entre-temps, on rapporte COMMENT il est mort : sur une mort par
|
||||||
|
// signal, la dernière ligne du journal est celle d'avant le coup fatal — elle
|
||||||
|
// ressemble à une explication sans en être une (leçon de la PR #18).
|
||||||
|
func whisperAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
|
||||||
|
fin := time.Now().Add(whisperReady)
|
||||||
|
url := "http://127.0.0.1:" + strconv.Itoa(port) + "/"
|
||||||
|
mort := make(chan error, 1)
|
||||||
|
go func() { mort <- cmd.Wait() }()
|
||||||
|
for time.Now().Before(fin) {
|
||||||
|
select {
|
||||||
|
case err := <-mort:
|
||||||
|
msg := whisperExitReason(err)
|
||||||
|
if l := lastLine(log.String()); l != "" {
|
||||||
|
msg += " — dernière sortie : " + l
|
||||||
|
}
|
||||||
|
return fmt.Errorf("whisper-server s'est arrêté au démarrage : %s", msg)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
|
||||||
|
cancel()
|
||||||
|
if err == nil {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("whisper-server n'a pas répondu en %s", whisperReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperTouchLocked() {
|
||||||
|
if wsrv.timer != nil {
|
||||||
|
wsrv.timer.Stop()
|
||||||
|
}
|
||||||
|
wsrv.timer = time.AfterFunc(whisperIdle, whisperShutdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperStopLocked() {
|
||||||
|
if wsrv.timer != nil {
|
||||||
|
wsrv.timer.Stop()
|
||||||
|
wsrv.timer = nil
|
||||||
|
}
|
||||||
|
if wsrv.cmd != nil && wsrv.cmd.Process != nil {
|
||||||
|
_ = wsrv.cmd.Process.Kill()
|
||||||
|
}
|
||||||
|
wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil
|
||||||
|
wsrv.cfg, wsrv.note = DictateCfg{}, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// whisperShutdown arrête le serveur. Appelé par la minuterie d'inactivité et
|
||||||
|
// à chaque changement de réglages — sans ça, le serveur continuerait de
|
||||||
|
// tourner avec l'ancien modèle et le nouveau réglage semblerait sans effet.
|
||||||
|
func whisperShutdown() {
|
||||||
|
wsrvMu.Lock()
|
||||||
|
defer wsrvMu.Unlock()
|
||||||
|
whisperStopLocked()
|
||||||
|
}
|
||||||
|
|
||||||
|
// whisperInfer garantit le serveur puis lui soumet le WAV.
|
||||||
|
func whisperInfer(ctx context.Context, wav []byte) (string, error) {
|
||||||
|
port, err := whisperEnsure()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return whisperInferSur(ctx, "http://127.0.0.1:"+strconv.Itoa(port), wav)
|
||||||
|
}
|
||||||
|
|
||||||
|
func whisperEtat() map[string]any {
|
||||||
|
cfg := dictateCfgLoad()
|
||||||
|
wsrvMu.Lock()
|
||||||
|
defer wsrvMu.Unlock()
|
||||||
|
etat := map[string]any{
|
||||||
|
"actif": wsrv.cmd != nil,
|
||||||
|
"modele": cfg.Model,
|
||||||
|
"present": whisperModelPresent(cfg.Model),
|
||||||
|
"device": cfg.Device,
|
||||||
|
"note": wsrv.note,
|
||||||
|
}
|
||||||
|
if wsrv.cmd != nil {
|
||||||
|
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
|
||||||
|
}
|
||||||
|
return etat
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func argVal(args []string, flag string) (string, bool) {
|
||||||
|
for i, a := range args {
|
||||||
|
if a == flag && i+1 < len(args) {
|
||||||
|
return args[i+1], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerArgs(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
c := DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu"}.withDefauts()
|
||||||
|
args, err := whisperServerArgs(c, 8091)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("construction des arguments : %v", err)
|
||||||
|
}
|
||||||
|
if v, ok := argVal(args, "-m"); !ok || !strings.HasSuffix(v, "ggml-small-q5_1.bin") {
|
||||||
|
t.Errorf("-m = %q, doit pointer sur le .bin du modèle choisi", v)
|
||||||
|
}
|
||||||
|
if v, ok := argVal(args, "-l"); !ok || v != "fr" {
|
||||||
|
t.Errorf("-l = %q, attendu fr", v)
|
||||||
|
}
|
||||||
|
if v, ok := argVal(args, "--port"); !ok || v != "8091" {
|
||||||
|
t.Errorf("--port = %q, attendu 8091", v)
|
||||||
|
}
|
||||||
|
// whisper-server n'a aucune authentification : l'exposer sur 0.0.0.0
|
||||||
|
// ouvrirait la transcription à tout le réseau.
|
||||||
|
if v, ok := argVal(args, "--host"); !ok || v != "127.0.0.1" {
|
||||||
|
t.Errorf("--host = %q, attendu 127.0.0.1", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerArgsModeleInconnu(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
if _, err := whisperServerArgs(DictateCfg{Model: "nawak", Lang: "fr", Device: "cpu"}, 8091); err == nil {
|
||||||
|
t.Error("un modèle hors catalogue doit produire une erreur, pas une commande avec -m vide")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerEnvGPU(t *testing.T) {
|
||||||
|
base := []string{"PATH=/usr/bin", "CUDA_VISIBLE_DEVICES=7", "HOME=/root"}
|
||||||
|
|
||||||
|
env := whisperServerEnv(DictateCfg{Device: "1"}, base)
|
||||||
|
if !contientExact(env, "CUDA_VISIBLE_DEVICES=1") {
|
||||||
|
t.Errorf("env = %v, doit contenir CUDA_VISIBLE_DEVICES=1", env)
|
||||||
|
}
|
||||||
|
// L'ancienne valeur doit être REMPLACÉE, pas doublée : avec deux
|
||||||
|
// occurrences, le gagnant dépend de l'implémentation — le choix de
|
||||||
|
// l'utilisateur ne doit pas dépendre de ça.
|
||||||
|
if n := compte(env, "CUDA_VISIBLE_DEVICES="); n != 1 {
|
||||||
|
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
|
||||||
|
}
|
||||||
|
if !contientExact(env, "PATH=/usr/bin") {
|
||||||
|
t.Error("le reste de l'environnement doit être conservé")
|
||||||
|
}
|
||||||
|
|
||||||
|
env = whisperServerEnv(DictateCfg{Device: "cpu"}, base)
|
||||||
|
if !contientExact(env, "CUDA_VISIBLE_DEVICES=") {
|
||||||
|
t.Errorf("env = %v, en mode CPU la variable doit être posée VIDE (et non retirée) pour masquer tous les GPU", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contientExact(l []string, s string) bool {
|
||||||
|
for _, v := range l {
|
||||||
|
if v == s {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func compte(l []string, prefixe string) int {
|
||||||
|
n := 0
|
||||||
|
for _, v := range l {
|
||||||
|
if strings.HasPrefix(v, prefixe) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPortLibre(t *testing.T) {
|
||||||
|
p, err := portLibre()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("portLibre : %v", err)
|
||||||
|
}
|
||||||
|
if p < 1024 || p > 65535 {
|
||||||
|
t.Errorf("port = %d, hors de la plage utilisable", p)
|
||||||
|
}
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("port %d annoncé libre mais inutilisable : %v", p, err)
|
||||||
|
}
|
||||||
|
_ = l.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferParseLaReponse(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/inference" {
|
||||||
|
t.Errorf("chemin appelé = %q, attendu /inference", r.URL.Path)
|
||||||
|
}
|
||||||
|
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
|
||||||
|
t.Errorf("Content-Type = %q, whisper-server attend du multipart", ct)
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"text":" bonjour ceci est un test "}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("whisperInferSur : %v", err)
|
||||||
|
}
|
||||||
|
if txt != "bonjour ceci est un test" {
|
||||||
|
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sur du silence, whisper renvoie ses marqueurs internes. Les laisser passer
|
||||||
|
// les collerait tels quels dans le champ de saisie — c'est ce qui est arrivé
|
||||||
|
// avec [BLANK_AUDIO].
|
||||||
|
func TestInferFiltreLesMarqueurs(t *testing.T) {
|
||||||
|
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"text":"` + marqueur + `"}`))
|
||||||
|
}))
|
||||||
|
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
||||||
|
srv.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
|
||||||
|
}
|
||||||
|
if txt != "" {
|
||||||
|
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInferErreurHTTP(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(500)
|
||||||
|
_, _ = w.Write([]byte("boom"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
if _, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux")); err == nil {
|
||||||
|
t.Error("un 500 de whisper-server doit remonter une erreur")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un modèle absent doit être annoncé comme tel, pas produire un lancement de
|
||||||
|
// whisper-server voué à mourir sur un fichier introuvable.
|
||||||
|
func TestEnsureModeleAbsent(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
if err := dictateCfgSave(DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := whisperEnsure()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("un modèle absent doit produire une erreur")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "absent") {
|
||||||
|
t.Errorf("erreur = %q, elle doit dire que le modèle est absent", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEtatSansServeur(t *testing.T) {
|
||||||
|
testHome(t)
|
||||||
|
e := whisperEtat()
|
||||||
|
if e["actif"] != false {
|
||||||
|
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
|
||||||
|
}
|
||||||
|
for _, clef := range []string{"modele", "present", "device", "note"} {
|
||||||
|
if _, ok := e[clef]; !ok {
|
||||||
|
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Version = "0.9.4"
|
const Version = "0.12.3"
|
||||||
|
|
||||||
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
|
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
|
||||||
func Main() {
|
func Main() {
|
||||||
|
|||||||
@@ -20,6 +20,24 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// procVmRSS lit la mémoire résidente (octets) d'un process — /proc/<pid>/status,
|
||||||
|
// ligne « VmRSS: N kB ». 0 si illisible.
|
||||||
|
func procVmRSS(pid int) int64 {
|
||||||
|
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
if v, ok := strings.CutPrefix(line, "VmRSS:"); ok {
|
||||||
|
v = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB"))
|
||||||
|
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
|
||||||
|
return n * 1024
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// engineLoadPct renvoie le pourcentage estimé de chargement du modèle, ou -1
|
// engineLoadPct renvoie le pourcentage estimé de chargement du modèle, ou -1
|
||||||
// quand il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue).
|
// quand il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue).
|
||||||
func engineLoadPct() int {
|
func engineLoadPct() int {
|
||||||
@@ -54,6 +72,16 @@ func engineLoadPct() int {
|
|||||||
if read < 0 {
|
if read < 0 {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
|
// Modèle déjà dans le cache disque (redémarrage à chaud) : llama-server ne
|
||||||
|
// LIT plus rien sur le disque — les pages viennent du cache, read_bytes
|
||||||
|
// reste à zéro et le pourcentage restait figé à 0 jusqu'au « prêt ». La
|
||||||
|
// mémoire résidente (VmRSS), elle, grandit quoi qu'il arrive : les pages du
|
||||||
|
// .gguf touchées via mmap y comptent, cache ou pas. On prend le plus avancé
|
||||||
|
// des deux — approximatif (l'offload GPU peut relâcher des pages), mais le
|
||||||
|
// compteur BOUGE au lieu de mentir.
|
||||||
|
if rss := procVmRSS(pid); rss > read {
|
||||||
|
read = rss
|
||||||
|
}
|
||||||
pct := int(read * 100 / size)
|
pct := int(read * 100 / size)
|
||||||
if pct > 99 {
|
if pct > 99 {
|
||||||
// 100 % n'existe pas ici : c'est /health qui dit « prêt ». Au-delà de 99
|
// 100 % n'existe pas ici : c'est /health qui dit « prêt ». Au-delà de 99
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ var configTemplate = []struct{ key, help string }{
|
|||||||
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
|
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
|
||||||
{"PORT", "port du moteur (défaut 8080)"},
|
{"PORT", "port du moteur (défaut 8080)"},
|
||||||
{"CTX", "taille du contexte (défaut 32768)"},
|
{"CTX", "taille du contexte (défaut 32768)"},
|
||||||
{"NGL", "couches déportées sur le GPU (défaut 999 = tout ; auto = ce qui tient en VRAM)"},
|
{"NGL", "couches déportées sur le GPU (999 ou auto = ce qui tient en VRAM ; all = tout, quitte à saturer)"},
|
||||||
{"BATCH", "batch (défaut 2048)"},
|
{"BATCH", "batch (défaut 2048)"},
|
||||||
{"UBATCH", "micro-batch (défaut 512)"},
|
{"UBATCH", "micro-batch (défaut 512)"},
|
||||||
{"THREADS", "threads CPU, 0 = auto"},
|
{"THREADS", "threads CPU, 0 = auto"},
|
||||||
|
|||||||
+769
-196
File diff suppressed because it is too large.
Load diff
@@ -75,9 +75,194 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
latérale par défaut, c'est les discussions et la machine. Les sections
|
latérale par défaut, c'est les discussions et la machine. Les sections
|
||||||
ouvertes par programme (journal du moteur, installation llama.cpp)
|
ouvertes par programme (journal du moteur, installation llama.cpp)
|
||||||
passent par openDetails(), qui déplie aussi les parents. -->
|
passent par openDetails(), qui déplie aussi les parents. -->
|
||||||
<details id="side-settings"><summary><span class="stitle">Réglages</span></summary>
|
</div><!-- /#side-scroll -->
|
||||||
<div class="sidegroup">
|
<!-- Réglages : tout vit dans une modale (nav de sections à gauche, panneau à
|
||||||
<details id="svc-log-box" ontoggle="if(this.open){loadSvcLog();showPaths()}"><summary><span class="stitle">Journal du moteur</span></summary>
|
droite) — voir js/22-settings-modal.js ; les ids historiques des sections
|
||||||
|
(svc-log-box, lc-details…) sont conservés sur les panneaux. Le bouton est
|
||||||
|
ANCRÉ en pied de barre, juste au-dessus du moniteur machine : toujours au
|
||||||
|
même endroit, quel que soit le nombre de discussions. -->
|
||||||
|
<button id="side-settings-btn" onclick="openSettings()" aria-haspopup="dialog">
|
||||||
|
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.35a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.65 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09c0 .68.4 1.3 1.03 1.56a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.06c.26.63.88 1.03 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.56 1.03z"/></svg>
|
||||||
|
<span>Paramètres</span>
|
||||||
|
</button>
|
||||||
|
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
|
||||||
|
jauges fines — visible en permanence sans jamais disputer la place au
|
||||||
|
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>
|
||||||
|
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="main" style="position:relative">
|
||||||
|
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
|
||||||
|
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
|
||||||
|
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
|
||||||
|
<header id="topbar">
|
||||||
|
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
|
||||||
|
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
||||||
|
</button>
|
||||||
|
<div id="topbar-title">Nouvelle discussion</div>
|
||||||
|
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
||||||
|
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div id="chat"></div>
|
||||||
|
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
|
||||||
|
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
|
||||||
|
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
|
||||||
|
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
|
||||||
|
le replay terminé, sinon il clignoterait au chargement de chaque page).
|
||||||
|
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
|
||||||
|
pour le dessin des lettres. -->
|
||||||
|
<div id="chat-empty" aria-hidden="true">
|
||||||
|
<div id="ce-logo"></div>
|
||||||
|
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
|
||||||
|
pas de civilité ni d'accord qui obligerait à deviner. -->
|
||||||
|
<div id="ce-title">On commence par quoi ?</div>
|
||||||
|
</div>
|
||||||
|
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
|
||||||
|
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
|
||||||
|
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
|
||||||
|
<div id="composer">
|
||||||
|
<div id="ctxmeta">
|
||||||
|
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
|
||||||
|
de code : critères, vérification, LSP). Dans le pied de carte, pas
|
||||||
|
dans la barre de saisie déjà chargée — ça se règle par discussion,
|
||||||
|
pas par message. -->
|
||||||
|
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
|
||||||
|
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
|
||||||
|
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
|
||||||
|
</div>
|
||||||
|
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
||||||
|
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
||||||
|
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
|
||||||
|
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
|
||||||
|
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
||||||
|
<span id="ctx-text" class="muted">0 / 0</span>
|
||||||
|
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
||||||
|
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
|
||||||
|
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
||||||
|
<div id="attach-list"></div>
|
||||||
|
<div id="inputbar">
|
||||||
|
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
||||||
|
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
||||||
|
<!-- Intensité du raisonnement : réglable ici parce que ça se décide au
|
||||||
|
moment d'écrire le message, pas dans un éditeur de preset. -->
|
||||||
|
<select id="think-effort" title="Intensité du raisonnement" aria-label="Intensité du raisonnement" onchange="setThinkEffort(this.value)">
|
||||||
|
<option value="">auto</option>
|
||||||
|
<option value="none">aucune</option>
|
||||||
|
<option value="low">basse</option>
|
||||||
|
<option value="medium">moyenne</option>
|
||||||
|
<option value="high">haute</option>
|
||||||
|
<option value="xhigh">maximale</option>
|
||||||
|
</select>
|
||||||
|
<!-- Dictée vocale : enregistre au micro, transcrit en local (whisper.cpp
|
||||||
|
côté serveur) et pose le texte dans le champ. Voir js/23-dictate.js.
|
||||||
|
Le navigateur n'autorise le micro qu'en HTTPS (ou localhost). -->
|
||||||
|
<button id="mic" onclick="dictateToggle()" title="dicter au micro" aria-label="dicter au micro">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
|
||||||
|
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
|
||||||
|
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
|
||||||
|
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
|
||||||
|
ce qui sort. -->
|
||||||
|
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
|
||||||
|
reste porté par aria-label et l'infobulle — l'action garde son nom
|
||||||
|
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
|
||||||
|
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 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
|
||||||
|
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||||||
|
utile à personne. -->
|
||||||
|
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||||
|
<aside id="files" aria-label="Fichiers de cette discussion">
|
||||||
|
<div class="files-head">
|
||||||
|
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
|
||||||
|
complet reste porté par l'aria-label de la section. -->
|
||||||
|
<span class="stitle">Fichiers</span>
|
||||||
|
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||||
|
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
||||||
|
captures), il faut pouvoir y descendre et en remonter. -->
|
||||||
|
<div id="files-crumb" class="files-crumb"></div>
|
||||||
|
<div id="files-list" class="files-list"></div>
|
||||||
|
<div id="files-foot" class="files-foot muted"></div>
|
||||||
|
</aside>
|
||||||
|
<div id="toast"></div>
|
||||||
|
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
||||||
|
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||||
|
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<strong>Benchmark</strong>
|
||||||
|
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
||||||
|
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
||||||
|
<button onclick="closeBenchModal()">fermer</button>
|
||||||
|
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
||||||
|
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
||||||
|
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
||||||
|
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
||||||
|
<!-- Réglages : modale à deux volets — nav de sections à gauche, LE panneau
|
||||||
|
choisi à droite. Chaque section d'antan (details de la barre latérale) est
|
||||||
|
devenue un panneau .set-pane ; les ids historiques (svc-log-box,
|
||||||
|
lc-details…) sont conservés — openDetails() les résout vers
|
||||||
|
openSettings(panneau), donc pastille d'état et jobs moteur rouvrent la
|
||||||
|
modale au bon endroit. -->
|
||||||
|
<div id="settings-modal" class="modal-ov set-ov" style="display:none" onclick="if(event.target===this)closeSettings()">
|
||||||
|
<div class="modal-card set-card">
|
||||||
|
<div class="modal-head">
|
||||||
|
<strong>Paramètres</strong>
|
||||||
|
<button class="modal-x" onclick="closeSettings()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||||
|
</div>
|
||||||
|
<div class="set-shell">
|
||||||
|
<nav class="set-nav" id="set-nav" aria-label="sections des réglages">
|
||||||
|
<div class="set-nav-h">Intelligence artificielle</div>
|
||||||
|
<button data-pane="config" onclick="settingsShow('config')">Config active</button>
|
||||||
|
<button data-pane="presets" onclick="settingsShow('presets')">Presets</button>
|
||||||
|
<button data-pane="agent" onclick="settingsShow('agent')"><span>Mode agent</span><span id="agent-badge" class="sbadge"></span></button>
|
||||||
|
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
|
||||||
|
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
|
||||||
|
<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>
|
||||||
|
<button data-pane="openai" onclick="settingsShow('openai')">Accès OpenAI</button>
|
||||||
|
<button data-pane="actions" onclick="settingsShow('actions')">Actions</button>
|
||||||
|
</nav>
|
||||||
|
<div class="set-body" id="set-body">
|
||||||
|
<section class="set-pane" data-pane="engine-log" id="svc-log-box">
|
||||||
|
<div class="set-pane-h"><span>Journal du moteur</span></div>
|
||||||
<div class="row" style="justify-content:flex-end;align-items:center">
|
<div class="row" style="justify-content:flex-end;align-items:center">
|
||||||
<button onclick="loadSvcLog()" title="rafraîchir" aria-label="rafraîchir le journal"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
<button onclick="loadSvcLog()" title="rafraîchir" aria-label="rafraîchir le journal"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,14 +273,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<span class="io-cap">Emplacements</span>
|
<span class="io-cap">Emplacements</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
||||||
</details>
|
</section>
|
||||||
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
|
<section class="set-pane" data-pane="config" id="cfg-details">
|
||||||
|
<div class="set-pane-h"><span>Config active</span></div>
|
||||||
<div id="cfg" class="slist">…</div>
|
<div id="cfg" class="slist">…</div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
|
<section class="set-pane" data-pane="presets">
|
||||||
|
<div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div>
|
||||||
<div class="slist"><div id="presets" class="rowlist"></div></div>
|
<div class="slist"><div id="presets" class="rowlist"></div></div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
|
<section class="set-pane" data-pane="agent">
|
||||||
|
<div class="set-pane-h"><span>Mode agent</span></div>
|
||||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||||
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
||||||
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
||||||
@@ -181,8 +369,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
<details id="tasks-details"><summary><span class="stitle">Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></summary>
|
<section class="set-pane" data-pane="tasks" id="tasks-details">
|
||||||
|
<div class="set-pane-h"><span>Tâches planifiées</span></div>
|
||||||
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
|
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
|
||||||
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
|
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
|
||||||
conversation — elle livre son résultat via les outils de l'IA (mail via
|
conversation — elle livre son résultat via les outils de l'IA (mail via
|
||||||
@@ -198,13 +387,15 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
|
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">System prompt</span></summary>
|
<section class="set-pane" data-pane="sysprompt">
|
||||||
|
<div class="set-pane-h"><span>System prompt</span></div>
|
||||||
<div class="slist"><div class="rowbody">
|
<div class="slist"><div class="rowbody">
|
||||||
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
|
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
|
<section class="set-pane" data-pane="engine" id="lc-details">
|
||||||
|
<div class="set-pane-h"><span>Moteur</span></div>
|
||||||
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
|
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
|
||||||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||||
étaient des cartes dans une carte). -->
|
étaient des cartes dans une carte). -->
|
||||||
@@ -220,9 +411,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
||||||
<div id="eng-latest" style="margin-top:6px"></div>
|
<div id="eng-latest" style="margin-top:6px"></div>
|
||||||
<div class="row" style="margin:8px 0 0">
|
<div class="row" style="margin:8px 0 0">
|
||||||
<!-- Pas de bouton « vérifier la version » : la mise à jour fait sa
|
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
|
||||||
propre vérification et dit s'il n'y a rien de neuf. Un bouton de
|
confirmation) ; « mettre à jour » télécharge et bascule. Les deux
|
||||||
moins pour la même information. -->
|
gestes n'engagent pas pareil, chacun son bouton. -->
|
||||||
|
<button onclick="engCheck()">vérifier les mises à jour</button>
|
||||||
<button onclick="engUpdate()">mettre à jour le moteur</button>
|
<button onclick="engUpdate()">mettre à jour le moteur</button>
|
||||||
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,18 +461,71 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<button onclick="act('stop')">arrêter</button>
|
<button onclick="act('stop')">arrêter</button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
</div><!-- /sidegroup -->
|
<!-- 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 ────────────────────────────────────
|
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
|
||||||
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
|
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
|
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
|
||||||
l'application elle-même. -->
|
l'application elle-même. -->
|
||||||
<div class="side-sep">Paramètres</div>
|
|
||||||
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
||||||
empilait 11 arrondis et autant de gouttières : la barre latérale ne
|
empilait 11 arrondis et autant de gouttières : la barre latérale ne
|
||||||
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
|
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
|
||||||
<div class="sidegroup">
|
<section class="set-pane" data-pane="identity" id="ident-details">
|
||||||
<details id="ident-details"><summary><span class="stitle">Identité</span></summary>
|
<div class="set-pane-h"><span>Identité</span></div>
|
||||||
<div class="slist"><div class="rowbody">
|
<div class="slist"><div class="rowbody">
|
||||||
<div class="pe-row">
|
<div class="pe-row">
|
||||||
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
|
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
|
||||||
@@ -300,8 +545,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<div id="p-avatar-ai" class="avpick"></div>
|
<div id="p-avatar-ai" class="avpick"></div>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Apparence</span></summary>
|
<section class="set-pane" data-pane="appearance">
|
||||||
|
<div class="set-pane-h"><span>Apparence</span></div>
|
||||||
<div class="slist">
|
<div class="slist">
|
||||||
<label class="switchrow rev"><span>mode sombre</span>
|
<label class="switchrow rev"><span>mode sombre</span>
|
||||||
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
||||||
@@ -314,8 +560,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
|
||||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
|
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</section>
|
||||||
<details><summary><span class="stitle">Accès OpenAI</span></summary>
|
<section class="set-pane" data-pane="openai">
|
||||||
|
<div class="set-pane-h"><span>Accès OpenAI</span></div>
|
||||||
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer <clé></code>.</span></span></div>
|
||||||
<div class="slist">
|
<div class="slist">
|
||||||
<div class="rowbody">
|
<div class="rowbody">
|
||||||
@@ -351,8 +598,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<button onclick="apiKeyAction('clear')">retirer</button>
|
<button onclick="apiKeyAction('clear')">retirer</button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
<details id="upd-details"><summary><span class="stitle">Actions</span></summary>
|
<section class="set-pane" data-pane="actions" id="upd-details">
|
||||||
|
<div class="set-pane-h"><span>Actions</span></div>
|
||||||
<div class="slist"><div class="rowbody">
|
<div class="slist"><div class="rowbody">
|
||||||
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
|
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
|
||||||
dans loki.db (bbolt, verrouillé tant que le service tourne) : sans le
|
dans loki.db (bbolt, verrouillé tant que le service tourne) : sans le
|
||||||
@@ -368,149 +616,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<button onclick="resetChat()" title="vider la discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/></svg><span>vider la discussion</span></button>
|
<button onclick="resetChat()" title="vider la discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/></svg><span>vider la discussion</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div></div>
|
</div></div>
|
||||||
</details>
|
</section>
|
||||||
</div><!-- /sidegroup -->
|
</div><!-- /.set-body -->
|
||||||
</details><!-- /#side-settings -->
|
</div><!-- /.set-shell -->
|
||||||
</div><!-- /#side-scroll -->
|
|
||||||
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
|
|
||||||
jauges fines — visible en permanence sans jamais disputer la place au
|
|
||||||
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>
|
|
||||||
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="main" style="position:relative">
|
|
||||||
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
|
|
||||||
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
|
|
||||||
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
|
|
||||||
<header id="topbar">
|
|
||||||
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
|
|
||||||
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
|
|
||||||
</button>
|
|
||||||
<div id="topbar-title">Nouvelle discussion</div>
|
|
||||||
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
|
|
||||||
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
|
|
||||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
<div id="chat"></div>
|
|
||||||
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
|
|
||||||
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
|
|
||||||
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
|
|
||||||
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
|
|
||||||
le replay terminé, sinon il clignoterait au chargement de chaque page).
|
|
||||||
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
|
|
||||||
pour le dessin des lettres. -->
|
|
||||||
<div id="chat-empty" aria-hidden="true">
|
|
||||||
<div id="ce-logo"></div>
|
|
||||||
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
|
|
||||||
pas de civilité ni d'accord qui obligerait à deviner. -->
|
|
||||||
<div id="ce-title">On commence par quoi ?</div>
|
|
||||||
</div>
|
|
||||||
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
|
|
||||||
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
|
|
||||||
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
|
|
||||||
<div id="composer">
|
|
||||||
<div id="ctxmeta">
|
|
||||||
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
|
|
||||||
de code : critères, vérification, LSP). Dans le pied de carte, pas
|
|
||||||
dans la barre de saisie déjà chargée — ça se règle par discussion,
|
|
||||||
pas par message. -->
|
|
||||||
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
|
|
||||||
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
|
|
||||||
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
|
|
||||||
</div>
|
|
||||||
<div id="ctxtrack"><div id="ctx-fill"></div></div>
|
|
||||||
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
|
|
||||||
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
|
|
||||||
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
|
|
||||||
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
|
|
||||||
<span id="ctx-text" class="muted">0 / 0</span>
|
|
||||||
<button id="ctx-compact" onclick="compactContext()">compacter</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
|
|
||||||
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
|
|
||||||
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
|
|
||||||
<div id="attach-list"></div>
|
|
||||||
<div id="inputbar">
|
|
||||||
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
|
|
||||||
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
|
|
||||||
<!-- Intensité du raisonnement : réglable ici parce que ça se décide au
|
|
||||||
moment d'écrire le message, pas dans un éditeur de preset. -->
|
|
||||||
<select id="think-effort" title="Intensité du raisonnement" aria-label="Intensité du raisonnement" onchange="setThinkEffort(this.value)">
|
|
||||||
<option value="">auto</option>
|
|
||||||
<option value="none">aucune</option>
|
|
||||||
<option value="low">basse</option>
|
|
||||||
<option value="medium">moyenne</option>
|
|
||||||
<option value="high">haute</option>
|
|
||||||
<option value="xhigh">maximale</option>
|
|
||||||
</select>
|
|
||||||
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
|
|
||||||
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
|
|
||||||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
|
|
||||||
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
|
|
||||||
ce qui sort. -->
|
|
||||||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
|
||||||
</button>
|
|
||||||
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
|
|
||||||
reste porté par aria-label et l'infobulle — l'action garde son nom
|
|
||||||
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
|
|
||||||
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
|
|
||||||
</button>
|
|
||||||
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
|
|
||||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- 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
|
|
||||||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
|
||||||
utile à personne. -->
|
|
||||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
|
||||||
<aside id="files" aria-label="Fichiers de cette discussion">
|
|
||||||
<div class="files-head">
|
|
||||||
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
|
|
||||||
complet reste porté par l'aria-label de la section. -->
|
|
||||||
<span class="stitle">Fichiers</span>
|
|
||||||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
|
||||||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
|
||||||
</div>
|
|
||||||
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
|
||||||
captures), il faut pouvoir y descendre et en remonter. -->
|
|
||||||
<div id="files-crumb" class="files-crumb"></div>
|
|
||||||
<div id="files-list" class="files-list"></div>
|
|
||||||
<div id="files-foot" class="files-foot muted"></div>
|
|
||||||
</aside>
|
|
||||||
<div id="toast"></div>
|
|
||||||
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
|
||||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
|
||||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
|
||||||
<strong>Benchmark</strong>
|
|
||||||
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
|
||||||
</div>
|
|
||||||
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
|
||||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
|
||||||
<button onclick="closeBenchModal()">fermer</button>
|
|
||||||
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
|
||||||
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
|
||||||
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
|
||||||
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
|
||||||
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
||||||
<div class="modal-card" style="max-width:460px">
|
<div class="modal-card" style="max-width:460px">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
@@ -930,7 +1040,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
|||||||
<span class="pe-row-c"><input id="s-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768" oninput="cfgWriteKey('CTX', this.value)"><span class="pe-unit">tok</span></span>
|
<span class="pe-row-c"><input id="s-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768" oninput="cfgWriteKey('CTX', this.value)"><span class="pe-unit">tok</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pe-row">
|
<div class="pe-row">
|
||||||
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 = tout sur le GPU · auto = ce qui tient en VRAM</span></span>
|
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
|
||||||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
|
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="pe-row">
|
<div class="pe-row">
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ async function loadAll(){
|
|||||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
||||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||||
}
|
}
|
||||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||||
@@ -368,6 +368,11 @@ function renderToolMsg(el, tu){
|
|||||||
body.appendChild(wait);
|
body.appendChild(wait);
|
||||||
}
|
}
|
||||||
addCopyButtons(body); scrollMaybe();
|
addCopyButtons(body); scrollMaybe();
|
||||||
|
// Carte à hauteur bornée : tant que l'outil TRAVAILLE, on suit le bas (la
|
||||||
|
// ligne en cours d'écriture, la commande qui se tape). Terminé, on remonte en
|
||||||
|
// tête — c'est le résumé qu'on relit, pas la dernière ligne.
|
||||||
|
const bw = el.querySelector('.bodywrap');
|
||||||
|
if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
|
||||||
}
|
}
|
||||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||||
function addCopyButtons(root){
|
function addCopyButtons(root){
|
||||||
|
|||||||
@@ -89,9 +89,28 @@ function paintTurnClock(){
|
|||||||
}
|
}
|
||||||
const ms = (T.doneTs || nowServer()) - T.startTs;
|
const ms = (T.doneTs || nowServer()) - T.startTs;
|
||||||
if(!(ms > 0)){ el.remove(); return; }
|
if(!(ms > 0)){ el.remove(); return; }
|
||||||
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms);
|
// Vitesse moyenne de la CONVERSATION à côté du temps du tour : le débit
|
||||||
|
// typique de ce modèle sur ce fil, toutes réponses confondues.
|
||||||
|
const avg = (CONV_MS > 1000 && CONV_TOK > 20)
|
||||||
|
? ' · moy ' + (CONV_TOK / (CONV_MS/1000)).toFixed(1) + ' tok/s' : '';
|
||||||
|
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms) + avg;
|
||||||
el.classList.toggle('done', !!T.doneTs);
|
el.classList.toggle('done', !!T.doneTs);
|
||||||
}
|
}
|
||||||
|
// --- Vitesse moyenne de la conversation --------------------------------------
|
||||||
|
// Alimentée par les événements `stats` (journalisés, donc rejoués au
|
||||||
|
// chargement : la moyenne survit au refresh). gen_tokens / gen_ms sont
|
||||||
|
// CUMULATIFS au sein d'une complétion et retombent à la suivante : on n'ajoute
|
||||||
|
// que le delta, et une valeur qui recule signale une nouvelle complétion.
|
||||||
|
let CONV_TOK = 0, CONV_MS = 0, _cgTok = 0, _cgMs = 0;
|
||||||
|
function feedConvSpeed(s){
|
||||||
|
const gt = s.gen_tokens || 0, gms = s.gen_ms || 0;
|
||||||
|
if(!gt) return;
|
||||||
|
if(gt < _cgTok){ _cgTok = 0; _cgMs = 0; }
|
||||||
|
CONV_TOK += gt - _cgTok;
|
||||||
|
CONV_MS += Math.max(0, gms - _cgMs);
|
||||||
|
_cgTok = gt; _cgMs = gms;
|
||||||
|
}
|
||||||
|
function resetConvSpeed(){ CONV_TOK = 0; CONV_MS = 0; _cgTok = 0; _cgMs = 0; }
|
||||||
// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
|
// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
|
||||||
// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
|
// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
|
||||||
// c'est précisément le moment où l'on se demande si ça avance.
|
// c'est précisément le moment où l'on se demande si ça avance.
|
||||||
@@ -278,11 +297,15 @@ function syncSendBtn(){
|
|||||||
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
|
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
|
||||||
// tour, erreur, caught_up).
|
// tour, erreur, caught_up).
|
||||||
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
|
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
|
||||||
function scheduleRender(el, text){
|
function scheduleRender(el, text, final){
|
||||||
// Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière
|
// Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière
|
||||||
// valeur, sinon son ultime bout de texte serait perdu.
|
// valeur, sinon son ultime bout de texte serait perdu.
|
||||||
if(renderPending && renderPending.el!==el) flushRender();
|
if(renderPending && renderPending.el!==el) flushRender();
|
||||||
renderPending={el, text};
|
// `final` voyage AVEC le rendu en attente : le timer déjà armé (qui passe
|
||||||
|
// final=false) peut être celui qui consommera ce rendu — sans le drapeau sur
|
||||||
|
// le pending, le rendu de FIN de bloc repassait par le chemin « queue seule »
|
||||||
|
// et le début du raisonnement n'était jamais posé.
|
||||||
|
renderPending={el, text, final:!!final};
|
||||||
if(renderTimer) return;
|
if(renderTimer) return;
|
||||||
// Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser
|
// Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser
|
||||||
// le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher
|
// le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher
|
||||||
@@ -290,15 +313,34 @@ function scheduleRender(el, text){
|
|||||||
// devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms :
|
// devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms :
|
||||||
// le O(n²) est cassé sans jamais figer l'interface.
|
// le O(n²) est cassé sans jamais figer l'interface.
|
||||||
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
|
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
|
||||||
renderTimer=setTimeout(flushRender, delay);
|
renderTimer=setTimeout(()=>flushRender(false), delay);
|
||||||
}
|
}
|
||||||
function flushRender(){
|
// Au-delà de cette taille, un raisonnement EN COURS n'est re-parsé que sur sa
|
||||||
|
// fin : re-parser le bloc entier à chaque tick est en O(n²) et finissait par
|
||||||
|
// figer l'affichage (le texte semblait s'arrêter alors que le moteur débitait).
|
||||||
|
// La carte étant bornée en hauteur et collée en bas, on ne perd rien à l'écran ;
|
||||||
|
// le texte COMPLET est posé au rendu final (frontière de bloc → final=true).
|
||||||
|
const REASON_TAIL=6000;
|
||||||
|
function flushRender(final){
|
||||||
if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; }
|
if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; }
|
||||||
const p=renderPending; renderPending=null;
|
const p=renderPending; renderPending=null;
|
||||||
if(!p) return;
|
if(!p) return;
|
||||||
|
let text=p.text;
|
||||||
|
if(final===false && !p.final && text.length>REASON_TAIL+800 && p.el.closest('.msg.reasoning')){
|
||||||
|
let cut=text.length-REASON_TAIL;
|
||||||
|
const nl=text.indexOf('\n', cut); if(nl>-1 && nl<cut+400) cut=nl+1;
|
||||||
|
text='*… (début replié pendant la génération — le texte complet est posé à la fin)*\n\n'+text.slice(cut);
|
||||||
|
}
|
||||||
|
// Carte bornée (raisonnement/outil) : si on était collé en bas, on y reste —
|
||||||
|
// la carte suit la génération. Un défilement manuel vers le haut est respecté.
|
||||||
|
// ⚠️ p.el est la BULLE (.msg), pas .body : le bodywrap est dedans, pas au-dessus.
|
||||||
|
const bw=p.el.querySelector ? p.el.querySelector(':scope > .bodywrap') : null;
|
||||||
|
const scrollable=!!bw;
|
||||||
|
const stick=scrollable && bw.scrollTop+bw.clientHeight>=bw.scrollHeight-60;
|
||||||
const t0=performance.now();
|
const t0=performance.now();
|
||||||
renderBody(p.el, p.text);
|
renderBody(p.el, text);
|
||||||
lastRenderMs=performance.now()-t0;
|
lastRenderMs=performance.now()-t0;
|
||||||
|
if(scrollable && stick) bw.scrollTop=bw.scrollHeight;
|
||||||
}
|
}
|
||||||
// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé,
|
// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé,
|
||||||
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
|
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
|
||||||
@@ -317,7 +359,7 @@ function smoothReset(){ if(smooth&&smooth.raf) cancelAnimationFrame(smooth.raf);
|
|||||||
// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque
|
// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque
|
||||||
// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de
|
// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de
|
||||||
// texte resterait en retard derrière le curseur de révélation.
|
// texte resterait en retard derrière le curseur de révélation.
|
||||||
function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target); }
|
function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target, true); }
|
||||||
// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse
|
// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse
|
||||||
// de révélation est proportionnelle au RETARD accumulé, avec une constante de
|
// de révélation est proportionnelle au RETARD accumulé, avec une constante de
|
||||||
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
|
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
|
||||||
@@ -375,7 +417,7 @@ function handleDelta(d){
|
|||||||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||||||
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
||||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
// appartiennent à la discussion, le panneau doit changer avec elle.
|
||||||
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
||||||
// --- Mode code (20-mode.js) -----------------------------------------------
|
// --- Mode code (20-mode.js) -----------------------------------------------
|
||||||
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
||||||
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
||||||
@@ -423,7 +465,7 @@ function handleDelta(d){
|
|||||||
// une trace du fil et DOIT être rejouée.
|
// une trace du fil et DOIT être rejouée.
|
||||||
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
|
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
|
||||||
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
|
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
|
||||||
if(d.stats){ T.serverStats=d.stats;
|
if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
|
||||||
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
|
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
|
||||||
// certains llama-server récents : le serveur publie alors un `ctx_used`
|
// certains llama-server récents : le serveur publie alors un `ctx_used`
|
||||||
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
|
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
|
||||||
@@ -433,12 +475,24 @@ function handleDelta(d){
|
|||||||
if(d.tool_used){
|
if(d.tool_used){
|
||||||
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
|
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
|
||||||
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
|
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
|
||||||
|
// Une carte reste OUVERTE tant que SA section travaille (hauteur bornée à
|
||||||
|
// 280 px, défilement qui suit l'écriture), et se replie DÈS qu'elle est
|
||||||
|
// finie : l'outil à son résultat (tu.done, plus bas), le raisonnement quand
|
||||||
|
// le bloc suivant démarre (collapseAll à la création de la carte suivante).
|
||||||
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
|
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
|
||||||
renderToolMsg(T.pendingToolEl, tu);
|
renderToolMsg(T.pendingToolEl, tu);
|
||||||
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
|
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
|
||||||
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
|
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
|
||||||
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
|
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
|
||||||
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
|
||||||
|
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
|
||||||
|
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
|
||||||
|
if(tu.done){
|
||||||
|
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
||||||
|
// 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(); }
|
||||||
return; }
|
return; }
|
||||||
if(d.drop_reasoning){
|
if(d.drop_reasoning){
|
||||||
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ async function loadConversations(){
|
|||||||
let r;
|
let r;
|
||||||
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||||
CONVS = r.conversations || [];
|
CONVS = r.conversations || [];
|
||||||
|
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
|
||||||
|
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
|
||||||
|
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
|
||||||
|
// fichier d'index pour les créations dans la même seconde.
|
||||||
|
const ord = new Map(CONVS.map((c, i) => [c.id, i]));
|
||||||
|
CONVS.sort((a, b) => (b.created || 0) - (a.created || 0)
|
||||||
|
|| ord.get(b.id) - ord.get(a.id));
|
||||||
CONV_ACTIVE = r.active || '';
|
CONV_ACTIVE = r.active || '';
|
||||||
CONV_BUSY = !!r.busy;
|
CONV_BUSY = !!r.busy;
|
||||||
renderConversations();
|
renderConversations();
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ function openDetails(el){
|
|||||||
if(typeof el === 'string') el = document.getElementById(el);
|
if(typeof el === 'string') el = document.getElementById(el);
|
||||||
while(el){
|
while(el){
|
||||||
if(el.tagName === 'DETAILS') el.open = true;
|
if(el.tagName === 'DETAILS') el.open = true;
|
||||||
|
// La cible vit dans un panneau de la modale de réglages : ouvrir la modale
|
||||||
|
// sur ce panneau. C'est ce qui garde tous les appelants historiques
|
||||||
|
// (pastille d'état, jobs moteur) valides depuis la refonte en modale.
|
||||||
|
if(el.classList && el.classList.contains('set-pane') && typeof openSettings === 'function'){
|
||||||
|
openSettings(el.dataset.pane);
|
||||||
|
}
|
||||||
el = el.parentElement;
|
el = el.parentElement;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +52,14 @@ function openDetails(el){
|
|||||||
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
||||||
// confirmation puis relance le moteur).
|
// confirmation puis relance le moteur).
|
||||||
function onModelSwitch(sel){
|
function onModelSwitch(sel){
|
||||||
const n = parseInt(sel.value, 10);
|
const v = sel.value;
|
||||||
|
// Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
|
||||||
|
// active garde son contexte et son échantillonnage, seul MODEL change.
|
||||||
|
if(v && v.startsWith('m:')){
|
||||||
|
switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = parseInt(v, 10);
|
||||||
if(!n) return;
|
if(!n) return;
|
||||||
const name = sel.options[sel.selectedIndex].textContent;
|
const name = sel.options[sel.selectedIndex].textContent;
|
||||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||||
@@ -55,6 +68,22 @@ function onModelSwitch(sel){
|
|||||||
switchTo(n, name);
|
switchTo(n, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
||||||
|
// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
|
||||||
|
// revient sur le modèle réellement chargé.
|
||||||
|
async function switchModelFile(value, name){
|
||||||
|
if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
|
||||||
|
paintModelSwitch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('changement de modèle…');
|
||||||
|
let r = null;
|
||||||
|
try{ r = await jpost('/api/models/use', {value}); }catch(_){}
|
||||||
|
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
|
||||||
|
toast('modèle en cours de chargement…');
|
||||||
|
loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
|
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
|
||||||
//
|
//
|
||||||
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
|
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
|
||||||
@@ -67,12 +96,22 @@ function onModelSwitch(sel){
|
|||||||
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
|
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
|
||||||
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
||||||
// en parallèle (loadAll), aucun ordre n'est garanti.
|
// en parallèle (loadAll), aucun ordre n'est garanti.
|
||||||
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
|
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
|
||||||
|
|
||||||
function renderModelSwitch(presets, active){
|
function renderModelSwitch(presets, active){
|
||||||
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
||||||
paintModelSwitch();
|
paintModelSwitch();
|
||||||
}
|
}
|
||||||
|
// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
|
||||||
|
// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
|
||||||
|
// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
|
||||||
|
async function loadModelSwitchFiles(){
|
||||||
|
try{
|
||||||
|
const r = await jget('/api/models');
|
||||||
|
MS_FILES = Array.isArray(r) ? r : [];
|
||||||
|
}catch(_){ return; }
|
||||||
|
paintModelSwitch();
|
||||||
|
}
|
||||||
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
|
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
|
||||||
function setLoadedModel(name){
|
function setLoadedModel(name){
|
||||||
MS_MODEL = (name || '').trim();
|
MS_MODEL = (name || '').trim();
|
||||||
@@ -99,14 +138,31 @@ function paintModelSwitch(){
|
|||||||
o.selected = true;
|
o.selected = true;
|
||||||
sel.appendChild(o);
|
sel.appendChild(o);
|
||||||
}
|
}
|
||||||
MS_PRESETS.forEach((p, i) => {
|
if(MS_PRESETS.length){
|
||||||
const o = document.createElement('option');
|
const g = document.createElement('optgroup'); g.label = 'Presets';
|
||||||
o.value = String(i + 1);
|
MS_PRESETS.forEach((p, i) => {
|
||||||
o.textContent = p.name;
|
const o = document.createElement('option');
|
||||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
o.value = String(i + 1);
|
||||||
sel.appendChild(o);
|
o.textContent = p.name;
|
||||||
});
|
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||||
sel.disabled = !MS_PRESETS.length;
|
g.appendChild(o);
|
||||||
|
});
|
||||||
|
sel.appendChild(g);
|
||||||
|
}
|
||||||
|
// Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
|
||||||
|
// preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
|
||||||
|
if(MS_FILES.length){
|
||||||
|
const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
|
||||||
|
for(const m of MS_FILES){
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = 'm:' + m.value;
|
||||||
|
o.textContent = modelLabel(m.name);
|
||||||
|
if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
|
||||||
|
g.appendChild(o);
|
||||||
|
}
|
||||||
|
sel.appendChild(g);
|
||||||
|
}
|
||||||
|
sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
|
||||||
sel.title = MS_MODEL
|
sel.title = MS_MODEL
|
||||||
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
||||||
: 'aucun modèle configuré';
|
: 'aucun modèle configuré';
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// ─── Réglages en modale ──────────────────────────────────────────────────────
|
||||||
|
// Toutes les sections de réglages vivent dans une modale à deux volets : la nav
|
||||||
|
// des sections à gauche, LE panneau choisi à droite. La barre latérale ne garde
|
||||||
|
// que les discussions et le moniteur machine — visuellement plus simple, et
|
||||||
|
// chaque section dispose enfin de toute la largeur.
|
||||||
|
//
|
||||||
|
// Les ids historiques des sections (svc-log-box, lc-details, tasks-details…)
|
||||||
|
// sont posés sur les .set-pane : openDetails() (18-shell.js) les résout vers le
|
||||||
|
// bon panneau, si bien que tous les chemins existants — pastille d'état du
|
||||||
|
// moteur, pastille d'installation, jobs llama.cpp — rouvrent la modale au bon
|
||||||
|
// endroit sans changer une ligne de leurs appelants.
|
||||||
|
let SET_LAST = 'config';
|
||||||
|
// Panneaux à contenu paresseux : chargés à l'affichage — l'équivalent de
|
||||||
|
// l'ancien ontoggle des <details>.
|
||||||
|
const SET_HOOKS = {
|
||||||
|
'engine-log': () => { loadSvcLog(); showPaths(); },
|
||||||
|
'dictee': () => { loadDictateSettings(); },
|
||||||
|
};
|
||||||
|
function openSettings(pane){
|
||||||
|
showModal('settings-modal');
|
||||||
|
settingsShow(pane || SET_LAST);
|
||||||
|
}
|
||||||
|
function closeSettings(){ hideModal('settings-modal'); }
|
||||||
|
function settingsShow(pane){
|
||||||
|
SET_LAST = pane;
|
||||||
|
document.querySelectorAll('#set-nav [data-pane]').forEach(b => b.classList.toggle('active', b.dataset.pane === pane));
|
||||||
|
document.querySelectorAll('#set-body .set-pane').forEach(s => s.classList.toggle('active', s.dataset.pane === pane));
|
||||||
|
const body = document.getElementById('set-body');
|
||||||
|
if(body) body.scrollTop = 0;
|
||||||
|
const h = SET_HOOKS[pane]; if(h) h();
|
||||||
|
}
|
||||||
|
// Échap ferme la modale — capture:false, donc les modales à confirmation
|
||||||
|
// (ask-modal, qui écoute en capture) gardent la main quand elles sont ouvertes.
|
||||||
|
document.addEventListener('keydown', e => {
|
||||||
|
if(e.key !== 'Escape') return;
|
||||||
|
const m = document.getElementById('settings-modal');
|
||||||
|
if(m && m.classList.contains('show')) closeSettings();
|
||||||
|
});
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// ─── Dictée vocale ───────────────────────────────────────────────────────────
|
||||||
|
// Bouton micro de la carte de saisie : un clic enregistre, un second arrête et
|
||||||
|
// transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur —
|
||||||
|
// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer
|
||||||
|
// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe →
|
||||||
|
// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve.
|
||||||
|
//
|
||||||
|
// ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS
|
||||||
|
// ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on
|
||||||
|
// l'explique plutôt que d'échouer en silence.
|
||||||
|
let MIC = null; // {ctx, stream, src, node, chunks, rate, timer}
|
||||||
|
|
||||||
|
async function dictateToggle(){
|
||||||
|
if(MIC){ dictateStop(); return; }
|
||||||
|
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
|
||||||
|
toast('micro bloqué par le navigateur : il faut HTTPS (ou localhost)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let stream;
|
||||||
|
try{
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
|
||||||
|
}catch(e){ toast('accès au micro refusé'); return; }
|
||||||
|
const ctx = new (window.AudioContext||window.webkitAudioContext)();
|
||||||
|
const src = ctx.createMediaStreamSource(stream);
|
||||||
|
// ScriptProcessor : déprécié mais universel, et amplement suffisant pour
|
||||||
|
// copier des blocs de PCM (AudioWorklet demanderait un module séparé,
|
||||||
|
// impossible dans un fichier unique servi inline).
|
||||||
|
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);
|
||||||
|
// Garde-fou : une dictée oubliée ne tourne pas des heures.
|
||||||
|
MIC = {ctx, stream, src, node, chunks, rate:ctx.sampleRate, timer:setTimeout(dictateStop, 90000)};
|
||||||
|
document.getElementById('mic').classList.add('rec');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dictateStop(){
|
||||||
|
const m = MIC; if(!m) return; MIC = null;
|
||||||
|
clearTimeout(m.timer);
|
||||||
|
try{ m.src.disconnect(); m.node.disconnect(); }catch(_){}
|
||||||
|
m.stream.getTracks().forEach(t => t.stop());
|
||||||
|
try{ m.ctx.close(); }catch(_){}
|
||||||
|
const btn = document.getElementById('mic');
|
||||||
|
btn.classList.remove('rec'); btn.classList.add('busy'); btn.disabled = true;
|
||||||
|
try{
|
||||||
|
const wav = encodeWav16k(m.chunks, m.rate);
|
||||||
|
if(wav.byteLength < 16000){ toast('rien entendu'); return; } // < 0,5 s
|
||||||
|
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body:wav});
|
||||||
|
const j = await r.json().catch(()=>({}));
|
||||||
|
if(r.status === 503 && j.downloading){
|
||||||
|
// Le serveur relance le téléchargement à chaque tentative ; s'il a déjà
|
||||||
|
// échoué (conteneur sans accès réseau, Hugging Face injoignable), il nous
|
||||||
|
// 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) + ' %) — suivi dans Paramètres → Dictée');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
|
||||||
|
const text = (j.text||'').trim();
|
||||||
|
if(!text){ toast('rien compris — réessaie'); return; }
|
||||||
|
const ta = document.getElementById('input');
|
||||||
|
ta.value = ta.value.trim() ? ta.value.replace(/\s+$/,'') + ' ' + text : text;
|
||||||
|
autoGrow(ta); ta.focus();
|
||||||
|
}catch(_){ toast('erreur réseau'); }
|
||||||
|
finally{ btn.classList.remove('busy'); btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV
|
||||||
|
// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple :
|
||||||
|
// pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant.
|
||||||
|
function encodeWav16k(chunks, fromRate){
|
||||||
|
const toRate = 16000;
|
||||||
|
let n = 0; for(const c of chunks) n += c.length;
|
||||||
|
const all = new Float32Array(n); let o = 0;
|
||||||
|
for(const c of chunks){ all.set(c, o); o += c.length; }
|
||||||
|
const ratio = fromRate / toRate, outN = Math.floor(n / ratio);
|
||||||
|
const pcm = new Int16Array(outN);
|
||||||
|
for(let i = 0; i < outN; i++){
|
||||||
|
const s = Math.max(-1, Math.min(1, all[Math.floor(i*ratio)] || 0));
|
||||||
|
pcm[i] = s < 0 ? s*0x8000 : s*0x7fff;
|
||||||
|
}
|
||||||
|
const buf = new ArrayBuffer(44 + pcm.length*2), v = new DataView(buf);
|
||||||
|
const wr = (off, s) => { for(let i = 0; i < s.length; i++) v.setUint8(off+i, s.charCodeAt(i)); };
|
||||||
|
wr(0,'RIFF'); v.setUint32(4, 36 + pcm.length*2, true); wr(8,'WAVEfmt ');
|
||||||
|
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
|
||||||
|
v.setUint32(24, toRate, true); v.setUint32(28, toRate*2, true);
|
||||||
|
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
|
||||||
|
wr(36,'data'); v.setUint32(40, pcm.length*2, true);
|
||||||
|
new Int16Array(buf, 44).set(pcm);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -224,7 +224,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
|||||||
/* en-tête + bouton d'ajout de skill (toujours visible) */
|
/* en-tête + bouton d'ajout de skill (toujours visible) */
|
||||||
.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px}
|
.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px}
|
||||||
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
|
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
|
||||||
.addbtn{font-size:11px;padding:4px 11px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:999px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
|
/* Même gabarit que les autres boutons des panneaux (angle 3 px, 12 px) : la
|
||||||
|
pilule arrondie détonnait au milieu de boutons rectangulaires. */
|
||||||
|
.addbtn{font-size:12px;padding:6px 10px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
|
||||||
.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)}
|
.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)}
|
||||||
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
|
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
|
||||||
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
|
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
|
||||||
@@ -656,6 +658,16 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
|||||||
/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth)
|
/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth)
|
||||||
→ repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */
|
→ repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */
|
||||||
.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px}
|
.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px}
|
||||||
|
/* Raisonnement / outils : hauteur BORNÉE avec défilement interne. Un long
|
||||||
|
raisonnement (des milliers de tokens) faisait grandir la page de plusieurs
|
||||||
|
écrans ; la carte reste maintenant à taille fixe et suit la génération en
|
||||||
|
défilant toute seule (voir flushRender, 09-stream.js). L'animation de repli
|
||||||
|
garde son overflow:hidden via la hauteur inline — les deux cohabitent. */
|
||||||
|
.msg.reasoning .bodywrap,.msg.tool .bodywrap{max-height:280px;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin}
|
||||||
|
/* Une capture d'écran ne se lit pas par le trou d'une serrure : la carte qui en
|
||||||
|
porte une échappe au plafond (l'image a déjà son propre max-height à 420 px,
|
||||||
|
cliquable plein écran via la visionneuse). */
|
||||||
|
.msg.tool .bodywrap:has(.tool-shot){max-height:none}
|
||||||
.msg.collapsible .bodywrap>.body{min-width:0}
|
.msg.collapsible .bodywrap>.body{min-width:0}
|
||||||
.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0}
|
.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0}
|
||||||
/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine
|
/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine
|
||||||
@@ -1737,7 +1749,7 @@ button:active:not(:disabled){transform:translateY(1px)}
|
|||||||
⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
|
⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
|
||||||
(pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
|
(pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
|
||||||
taille, sa forme et son alignement d'une couche différente. */
|
taille, sa forme et son alignement d'une couche différente. */
|
||||||
#inputbar #attach,#inputbar #files-btn,#send,#stop{
|
#inputbar #attach,#inputbar #files-btn,#inputbar #mic,#send,#stop{
|
||||||
width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
|
width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
|
||||||
display:inline-flex;align-items:center;justify-content:center;align-self:center;
|
display:inline-flex;align-items:center;justify-content:center;align-self:center;
|
||||||
border:0;border-radius:4px;background:transparent;color:var(--dim);
|
border:0;border-radius:4px;background:transparent;color:var(--dim);
|
||||||
@@ -1748,8 +1760,8 @@ button:active:not(:disabled){transform:translateY(1px)}
|
|||||||
#stop{display:none}
|
#stop{display:none}
|
||||||
html[data-busy="1"] #send{display:none}
|
html[data-busy="1"] #send{display:none}
|
||||||
html[data-busy="1"] #stop{display:inline-flex}
|
html[data-busy="1"] #stop{display:inline-flex}
|
||||||
#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px}
|
#inputbar #attach svg,#inputbar #files-btn svg,#inputbar #mic svg,#send svg,#stop svg{display:block;width:18px;height:18px}
|
||||||
#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)}
|
#inputbar #attach:hover,#inputbar #files-btn:hover,#inputbar #mic:hover{background:var(--row-bg-on);color:var(--text)}
|
||||||
html[data-files="1"] #files-btn{color:var(--accent)}
|
html[data-files="1"] #files-btn{color:var(--accent)}
|
||||||
#send{color:var(--accent)}
|
#send{color:var(--accent)}
|
||||||
#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
|
#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
|
||||||
@@ -2021,6 +2033,51 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
|||||||
#criteria-list li.crit-pending{color:var(--dim)}
|
#criteria-list li.crit-pending{color:var(--dim)}
|
||||||
|
|
||||||
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
||||||
|
|
||||||
|
/* Dictée : anneau rouge pulsé pendant l'enregistrement, estompé pendant la
|
||||||
|
transcription. Le gabarit (34 px, fond au survol) vient de la règle commune
|
||||||
|
des boutons de saisie — #mic y est listé avec #attach et #files-btn. */
|
||||||
|
#mic.rec{color:var(--err);animation:micpulse 1.2s ease-in-out infinite}
|
||||||
|
@keyframes micpulse{50%{box-shadow:0 0 0 5px color-mix(in srgb,var(--err) 25%,transparent)}}
|
||||||
|
#mic.busy{opacity:.55}
|
||||||
|
|
||||||
|
/* ── Réglages en modale ─────────────────────────────────────────────────────
|
||||||
|
Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les
|
||||||
|
panneaux réutilisent tels quels les gabarits de la barre latérale (.slist,
|
||||||
|
.switchrow, .subhead…), seuls la coque et le routage sont neufs. */
|
||||||
|
#side-settings-btn{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:10px 14px;flex:none;
|
||||||
|
background:none;border:0;border-top:1px solid var(--border);border-radius:0;box-shadow:none;
|
||||||
|
font-size:13px;color:var(--text);cursor:pointer;text-align:left}
|
||||||
|
#side-settings-btn:hover{background:var(--bubble)}
|
||||||
|
#side-settings-btn svg{flex:none;opacity:.7}
|
||||||
|
.set-card{max-width:860px;height:min(660px,92vh)}
|
||||||
|
.set-shell{display:flex;flex:1;min-height:0}
|
||||||
|
.set-nav{flex:0 0 200px;border-right:1px solid var(--border);overflow-y:auto;
|
||||||
|
padding:10px 8px;display:flex;flex-direction:column;gap:2px}
|
||||||
|
.set-nav-h{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim);margin:12px 8px 4px}
|
||||||
|
.set-nav-h:first-child{margin-top:2px}
|
||||||
|
.set-nav button{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:7px 10px;
|
||||||
|
background:none;border:0;border-radius:3px;box-shadow:none;
|
||||||
|
font-size:12.5px;color:var(--text);cursor:pointer;text-align:left}
|
||||||
|
.set-nav button:hover{background:var(--bubble)}
|
||||||
|
.set-nav button.active{background:var(--bubble);font-weight:600}
|
||||||
|
.set-nav button .sbadge{margin-left:auto}
|
||||||
|
.set-body{flex:1;min-width:0;overflow-y:auto;padding:16px}
|
||||||
|
.set-pane{display:none}
|
||||||
|
.set-pane.active{display:block}
|
||||||
|
.set-pane-h{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin:0 0 12px}
|
||||||
|
.set-pane-h>span:first-child{flex:1;min-width:0}
|
||||||
|
/* Téléphone : la nav passe en rangée défilante au-dessus du panneau, et la
|
||||||
|
modale prend tout l'écran — un panneau de réglages ne se lit pas en timbre. */
|
||||||
|
@media(max-width:720px){
|
||||||
|
.set-ov{padding:0}
|
||||||
|
.set-card{max-width:none;height:100vh;height:100dvh;max-height:none;border-radius:0;border:0}
|
||||||
|
.set-shell{flex-direction:column}
|
||||||
|
.set-nav{flex:none;flex-direction:row;align-items:center;overflow-x:auto;overflow-y:hidden;
|
||||||
|
border-right:0;border-bottom:1px solid var(--border);padding:8px;white-space:nowrap}
|
||||||
|
.set-nav-h{display:none}
|
||||||
|
.set-nav button{width:auto;flex:none}
|
||||||
|
}
|
||||||
/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris,
|
/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris,
|
||||||
pas de capitales forcées) et borné : un nom de .gguf peut être très long,
|
pas de capitales forcées) et borné : un nom de .gguf peut être très long,
|
||||||
il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */
|
il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */
|
||||||
|
|||||||
@@ -940,6 +940,38 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
sendJSON(w, 200, map[string]any{"ok": true})
|
sendJSON(w, 200, map[string]any{"ok": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleModelUse charge un MODÈLE précis (fichier .gguf) sans passer par un
|
||||||
|
// preset : seul MODEL change dans la configuration active — contexte, NGL et
|
||||||
|
// échantillonnage sont conservés — et le service redémarre en arrière-plan,
|
||||||
|
// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête
|
||||||
|
// utilisable même sans avoir créé de preset.
|
||||||
|
func handleModelUse(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Value) == "" {
|
||||||
|
sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle manquant"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Le fichier doit exister AVANT d'écrire la config : une faute de frappe ne
|
||||||
|
// doit pas laisser un moteur qui boucle sur un modèle introuvable.
|
||||||
|
if _, err := resolveServeModelPath(req.Value); err != nil {
|
||||||
|
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := SetConfigKey("MODEL", req.Value); err != nil {
|
||||||
|
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("%s MODEL <- %s\n", green("[ok]"), req.Value)
|
||||||
|
go func() {
|
||||||
|
if err := serviceAction("restart"); err != nil {
|
||||||
|
fmt.Println(red("[err] redémarrage : " + err.Error()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
sendJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
N int `json:"n"`
|
N int `json:"n"`
|
||||||
|
|||||||
@@ -168,7 +168,12 @@ func newWebMux() *http.ServeMux {
|
|||||||
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
|
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
|
||||||
api("/api/ping", handlePing)
|
api("/api/ping", handlePing)
|
||||||
api("/api/status", handleStatus)
|
api("/api/status", handleStatus)
|
||||||
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)
|
api("/api/vram", handleVram)
|
||||||
api("/api/ram", handleRam)
|
api("/api/ram", handleRam)
|
||||||
api("/api/config", handleConfigEnv)
|
api("/api/config", handleConfigEnv)
|
||||||
@@ -178,6 +183,7 @@ func newWebMux() *http.ServeMux {
|
|||||||
api("/api/models", handleModels)
|
api("/api/models", handleModels)
|
||||||
api("/api/models/delete", handleModelDelete)
|
api("/api/models/delete", handleModelDelete)
|
||||||
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
|
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
|
||||||
|
api("/api/models/use", handleModelUse) // charger CE .gguf (sélecteur de l'en-tête)
|
||||||
api("/api/models/download", handleModelDownload)
|
api("/api/models/download", handleModelDownload)
|
||||||
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
||||||
api("/api/models/download/status", handleModelDownloadStatus)
|
api("/api/models/download/status", handleModelDownloadStatus)
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Dictée vocale — POST /api/transcribe : un WAV (16 kHz mono, encodé côté
|
||||||
|
// navigateur) entre, le texte transcrit sort. La transcription est 100 % locale,
|
||||||
|
// par whisper.cpp. Le modèle est téléchargé au premier usage dans
|
||||||
|
// LOKI_HOME/whisper/ — comme le moteur, il ne gonfle pas l'image et survit aux
|
||||||
|
// recréations du conteneur via /data.
|
||||||
|
//
|
||||||
|
// 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"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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(id string) {
|
||||||
|
wspMu.Lock()
|
||||||
|
defer wspMu.Unlock()
|
||||||
|
if wspDl.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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(id)
|
||||||
|
wspMu.Lock()
|
||||||
|
wspDl.running = false
|
||||||
|
if err != nil {
|
||||||
|
wspDl.err = err.Error()
|
||||||
|
}
|
||||||
|
wspMu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
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(whisperModelURLFor(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Errorf("téléchargement du modèle whisper : HTTP %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
// Écriture dans un .part renommé à la fin : un téléchargement interrompu
|
||||||
|
// ne doit pas laisser un .bin tronqué que whisperModelPresent croirait bon.
|
||||||
|
tmp := dst + ".part"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
total := resp.ContentLength
|
||||||
|
var done int64
|
||||||
|
buf := make([]byte, 1<<20)
|
||||||
|
for {
|
||||||
|
n, rerr := resp.Body.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return werr
|
||||||
|
}
|
||||||
|
done += int64(n)
|
||||||
|
if total > 0 {
|
||||||
|
wspMu.Lock()
|
||||||
|
wspDl.pct = int(done * 100 / total)
|
||||||
|
wspMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rerr == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return rerr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmp, dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
whisperStartDownload(cfg.Model)
|
||||||
|
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 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,146 @@
|
|||||||
|
package loki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user