mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e88a73d91 | ||
|
|
f4969de9f7 | ||
|
|
5d391c4f73 | ||
|
|
3cd4facb0e | ||
|
|
3624194b39 | ||
|
|
2fa05fdaf7 | ||
|
|
e0ed0a32af | ||
|
|
a19b490b6a | ||
|
|
393986faa6 | ||
|
|
2585d2c2e1 | ||
|
|
fedbaf32a0 | ||
|
|
cbf8635c61 | ||
|
|
69bf8d985d | ||
|
|
50fccdc9ad | ||
|
|
66072e4047 | ||
|
|
62573ff972 | ||
|
|
481028b023 | ||
|
|
86d711e772 | ||
|
|
ad107ed284 | ||
|
|
43489769f4 | ||
|
|
a333603ca6 | ||
|
|
f4090059f1 | ||
|
|
b814862f40 | ||
|
|
88dbe5642f | ||
|
|
7f5765160d | ||
|
|
b339cced0a | ||
|
|
d7214d97eb | ||
|
|
0f58ad7b49 | ||
|
|
03ae361ade | ||
|
|
40f34170b9 | ||
|
|
f3b0f78b64 | ||
|
|
6a77999aac | ||
|
|
b405adea6c | ||
|
|
895ff92ed8 | ||
|
|
4e5bc44607 | ||
|
|
0430aa2281 | ||
|
|
608e7f7038 | ||
|
|
cf6e19794d | ||
|
|
7ef2c956c5 | ||
|
|
34218fae52 | ||
|
|
a7587235c6 | ||
|
|
53d23418e9 | ||
|
|
901d518f70 | ||
|
|
c6cc7d300b | ||
|
|
fb908b180b | ||
|
|
11a4aebf37 | ||
|
|
fb1294d42c | ||
|
|
533ff7d0d1 | ||
|
|
69e89e5c2f | ||
|
|
3d8a72754c | ||
|
|
88e1b38582 | ||
|
|
781520afd8 | ||
|
|
0a3af60715 | ||
|
|
ebe04f9d61 | ||
|
|
847aad217b |
No files matched your search
@@ -16,6 +16,17 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Depuis que l'image compile whisper.cpp avec CUDA, le build empile
|
||||
# nvidia/cuda:*-devel (~6 Go), l'image runtime CUDA de llama.cpp (~4 Go)
|
||||
# et Chromium/Playwright (~1 Go) — pour ~14 Go libres sur un runner neuf.
|
||||
# Ces trois répertoires ne servent à rien ici et rendent une dizaine de
|
||||
# gigaoctets en moins d'une minute.
|
||||
- name: Libérer de l'espace disque
|
||||
run: |
|
||||
df -h /
|
||||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
|
||||
df -h /
|
||||
|
||||
- name: Setup Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
@@ -13,3 +13,12 @@
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Ancienne application Python/React (fork pré-Go), plus utilisée : elle traîne
|
||||
# encore sur certains postes de développement et ne doit JAMAIS revenir dans le
|
||||
# dépôt — un « git add -A » distrait y a déjà versé 94 Mo de node_modules.
|
||||
/backend/
|
||||
/frontend/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
node_modules/
|
||||
+80
@@ -23,6 +23,68 @@ COPY cmd/ cmd/
|
||||
COPY internal/ internal/
|
||||
COPY tools/ tools/
|
||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
|
||||
# gopls : serveur LSP Go, consommé par le vérificateur de code du mode code
|
||||
# (lsp.go). Compilé ici parce que l'image finale n'a pas de toolchain Go.
|
||||
# GOTOOLCHAIN=auto : gopls@latest exige régulièrement un Go plus récent que
|
||||
# celui de l'image (v0.23 veut ≥ 1.26 quand l'image est en 1.25) ; en local,
|
||||
# l'installation échouait net. Auto télécharge le toolchain requis — coût
|
||||
# limité à cette étape de build.
|
||||
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
|
||||
|
||||
# ── Étape 1 bis : whisper-server (dictée vocale) ────────────────────────
|
||||
# DEUX binaires sont construits, pas un. LLAMACPP_IMAGE accepte la variante
|
||||
# CPU de l'image amont (voir ligne 11) : sur cette base, les bibliothèques
|
||||
# CUDA sont absentes et un binaire lié à CUDA ne démarre pas du tout —
|
||||
# l'éditeur de liens échoue avant la première instruction, donc aucun repli
|
||||
# n'est possible depuis l'intérieur du programme. Loki choisit à l'exécution
|
||||
# (whisperServerBin, dictate_server.go).
|
||||
#
|
||||
# whisper-server et non whisper-cli : le modèle reste chargé entre deux
|
||||
# dictées. L'ancien chemin le relisait depuis le disque à chaque phrase, ce
|
||||
# qui dominait le temps de réponse.
|
||||
#
|
||||
# ⚠️ GGML_NATIVE=OFF est OBLIGATOIRE sur LES DEUX cibles. Par défaut ggml
|
||||
# compile en -march=native, c'est-à-dire pour le processeur DU RUNNER DE
|
||||
# BUILD — un Xeon récent chez GitHub, avec AVX-512 et AMX. Le binaire partait
|
||||
# alors sur une machine qui n'a pas ces instructions et mourait d'un SIGILL en
|
||||
# pleine transcription : « AMX is not ready to be used! », puis plus rien,
|
||||
# l'interface affichant un échec sans raison. OFF retombe sur la ligne de base
|
||||
# AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis 2013.
|
||||
ARG WHISPER_CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
|
||||
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \
|
||||
-DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \
|
||||
-DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF"
|
||||
|
||||
# Ubuntu 22.04 : glibc plus ancienne que l'image runtime, donc compatible quoi
|
||||
# qu'elle embarque.
|
||||
FROM ubuntu:22.04 AS whisperbuild-cpu
|
||||
ARG WHISPER_CMAKE_FLAGS
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
|
||||
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} \
|
||||
&& cmake --build /w/build -j"$(nproc)" --target whisper-server
|
||||
|
||||
# Version CUDA. La MAJEURE de CUDA doit correspondre à celle de l'image
|
||||
# runtime : le binaire est lié dynamiquement à libcudart, fournie par
|
||||
# LLAMACPP_IMAGE et non par cette étape.
|
||||
#
|
||||
# ⚠️ -j"$(nproc)" et JAMAIS -j nu. Avec Make, « -j » sans nombre autorise un
|
||||
# parallélisme ILLIMITÉ. ggml-cuda compte ~200 fichiers d'instanciation de
|
||||
# gabarits, et chaque nvcc réclame 1 à 2 Go : le runner GitHub (16 Go) était
|
||||
# tué par l'OOM après avoir lancé 92 % des compilations en treize secondes,
|
||||
# sans écrire la moindre ligne d'erreur. L'étape CPU y survivait — peu de
|
||||
# fichiers, compilation légère — ce qui rendait le piège invisible jusqu'ici.
|
||||
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 AS whisperbuild-cuda
|
||||
ARG WHISPER_CMAKE_FLAGS
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake git ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
|
||||
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} -DGGML_CUDA=ON \
|
||||
&& cmake --build /w/build -j"$(nproc)" --target whisper-server \
|
||||
&& strip /w/build/bin/whisper-server
|
||||
|
||||
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
||||
FROM ${LLAMACPP_IMAGE} AS runtime
|
||||
@@ -43,6 +105,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Serveurs LSP du mode code (lsp.go) : TypeScript/JavaScript et Python via
|
||||
# npm, Go via gopls compilé à l'étape 1. Un serveur absent désactive juste son
|
||||
# langage — mais autant livrer l'image complète.
|
||||
RUN npm install -g --no-audit --no-fund typescript typescript-language-server pyright && npm cache clean --force
|
||||
|
||||
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
|
||||
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
|
||||
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
|
||||
|
||||
# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
|
||||
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
|
||||
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
|
||||
@@ -66,6 +138,12 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
|
||||
fi
|
||||
|
||||
COPY --from=gobuild /out/loki /usr/local/bin/loki
|
||||
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
|
||||
# Dictée vocale (POST /api/transcribe). Les modèles (190 Mo à 1,1 Go) ne sont
|
||||
# PAS dans l'image : téléchargés à la demande dans /data/whisper/.
|
||||
# Deux binaires : voir l'étape whisperbuild-cpu pour la raison.
|
||||
COPY --from=whisperbuild-cpu /w/build/bin/whisper-server /usr/local/bin/whisper-server-cpu
|
||||
COPY --from=whisperbuild-cuda /w/build/bin/whisper-server /usr/local/bin/whisper-server-cuda
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
||||
|
||||
@@ -74,6 +152,8 @@ ENV LOKI_CONTAINER=1 \
|
||||
LOKI_HOME=/data \
|
||||
LOKI_MODEL_DIRS=/models \
|
||||
LOKI_ENGINE_BIN=/app/llama-server \
|
||||
LOKI_WHISPER_SERVER_CPU=/usr/local/bin/whisper-server-cpu \
|
||||
LOKI_WHISPER_SERVER_CUDA=/usr/local/bin/whisper-server-cuda \
|
||||
LD_LIBRARY_PATH=/app \
|
||||
NVIDIA_VISIBLE_DEVICES=all \
|
||||
NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
|
||||
@@ -19,6 +19,14 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
|
||||
pour que l'UI puisse redémarrer le moteur (changement de modèle).
|
||||
- **`loki config get/set`** : lecture/écriture non interactive de la
|
||||
configuration, utilisée par l'entrypoint Docker.
|
||||
- **Mode code** (`internal/loki/code_*.go`, `lsp.go`, `agents/`) : agent de
|
||||
code avec critères d'acceptation, passe de vérification indépendante,
|
||||
outils read/grep/glob, politique d'exécution, diagnostics LSP, outils git,
|
||||
jobs d'arrière-plan et relance sur appel d'outil textuel. La conception
|
||||
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
|
||||
patterns, table languages.json) est reprise
|
||||
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
|
||||
pour ce fork ; les prompts de rôles sont des réécritures originales.
|
||||
|
||||
## Services externes
|
||||
|
||||
|
||||
@@ -77,6 +77,22 @@ Le champ **Télécharger un modèle** reste disponible pour coller un lien direc
|
||||
(dépôt privé, fichier hors des conventions). Un dépôt à accès restreint demande
|
||||
la variable d'environnement `HF_TOKEN`.
|
||||
|
||||
Certains dépôts sont **à accès restreint** (« gated ») : leur arborescence se lit
|
||||
sans rien, mais chaque `.gguf` répond `401` tant que les conditions du dépôt
|
||||
n'ont pas été acceptées sur huggingface.co **et** qu'un jeton n'est pas fourni.
|
||||
Loki les marque « accès restreint » dès la liste des résultats et rappelle le
|
||||
geste à faire, plutôt que de laisser choisir une quantification pour échouer au
|
||||
lancement du transfert.
|
||||
|
||||
Le jeton se règle **dans l'interface** : éditeur de preset → *Modèle* → **Jeton
|
||||
Hugging Face**. Il est vérifié auprès de Hugging Face avant d'être enregistré
|
||||
(le compte associé s'affiche), rangé avec les autres secrets sous `/data` — donc
|
||||
il survit aux redémarrages et aux changements de preset — et il sert aussi bien à
|
||||
la recherche qu'au téléchargement. À défaut, la variable d'environnement
|
||||
`HF_TOKEN` reste lue comme avant ; le jeton enregistré dans l'interface a la
|
||||
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
|
||||
n'est envoyé qu'aux adresses Hugging Face.
|
||||
|
||||
## Installation sur Unraid
|
||||
|
||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||
@@ -105,7 +121,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
|
||||
| `LOKI_NGL` | couches GPU initiales | `999` (tout) |
|
||||
| `LOKI_HOME` | données (volume) | `/data` |
|
||||
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
|
||||
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint | — |
|
||||
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
|
||||
|
||||
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
|
||||
`logs`, `restart`, `config`, `bench`, `test`…).
|
||||
@@ -138,6 +154,16 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
|
||||
emplacements différents dès que le partage n'est pas en cache-only ou que le
|
||||
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
|
||||
|
||||
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
|
||||
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
|
||||
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
|
||||
redémarrage de l'array). Vérifie avec la commande `docker inspect` ci-dessus :
|
||||
il doit y avoir une ligne `… → /data` **et** une `… → /models`. S'il n'y en a
|
||||
pas, ton conteneur a été lancé sans mapping (template Docker Unraid incomplet,
|
||||
`docker run` sans `-v`) — recrée-le avec les volumes du compose fourni. Depuis
|
||||
cette version, Loki le détecte au démarrage : bandeau rouge dans l'UI et
|
||||
avertissement en tête du journal du conteneur.
|
||||
|
||||
Autre piège au redémarrage du serveur : Docker relance les conteneurs
|
||||
`restart: unless-stopped` **avant** que le plugin Nvidia Driver ait chargé ses
|
||||
modules. Le journal montre alors des `ERROR: init … result=11` et le moteur
|
||||
@@ -154,13 +180,81 @@ Héritées d'AJEAN :
|
||||
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
|
||||
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
|
||||
ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les
|
||||
lancements suivants partent du cache en quelques secondes.
|
||||
- **Tâches planifiées** : une consigne que l'IA exécute **toute seule**, sur une
|
||||
fréquence réglable (« toutes les 2 h », « tous les jours à 9 h », ou une
|
||||
expression cron). Chaque tâche tourne **isolée des discussions** — elle n'écrit
|
||||
pas dans le fil, travaille dans son propre dossier (`workspace/tasks/<id>/`) et
|
||||
livre son résultat par les outils de l'IA (mail via MCP, shell, fichiers…) ; le
|
||||
compte-rendu du dernier passage est visible dans sa fiche, et **réinjecté** au
|
||||
passage suivant pour la continuité. Réglages par tâche : preset (donc modèle) à
|
||||
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
|
||||
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
|
||||
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
|
||||
tâche attend son tour, et le bouton stop du chat l'interrompt.
|
||||
- **Presets** de configuration par modèle, bench, auto-détection GPU.
|
||||
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
|
||||
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
|
||||
valeurs partent dans **chaque requête** au moteur, pas sur sa ligne de commande :
|
||||
les changer ne demande donc aucun redémarrage. Un champ laissé vide n'envoie
|
||||
rien et llama-server garde son défaut — utile parce que le défaut de llama.cpp
|
||||
(top_k 40, min_p 0.05) est rarement celui que recommande le modèle (Qwen3.8 en
|
||||
réflexion veut top_k 20, min_p 0, temp 1.0, top_p 0.95).
|
||||
- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce
|
||||
fork la sert autrement que l'amont).
|
||||
|
||||
Ajoutées par ce fork :
|
||||
|
||||
- **Mode Code** : chaque discussion a un sélecteur **Chat | Code** dans le pied
|
||||
de la carte de saisie. En mode Code, Loki devient un agent de code : outils
|
||||
`read`/`grep`/`glob` (lecture bornée, numéros de ligne — et un fichier doit
|
||||
avoir été LU avant d'être modifié), outils git (`git_status`, `git_diff`,
|
||||
`git_clone` — le clone atterrit dans le dossier de la discussion), jobs
|
||||
d'arrière-plan (`bash_bg`/`bash_tail` pour un serveur de dev ou un build
|
||||
long), et **critères d'acceptation** : le modèle pose le contrat (2-6
|
||||
critères testables, éditables dans le panneau au-dessus de la saisie), puis
|
||||
une **passe de vérification indépendante** — même modèle, contexte isolé,
|
||||
seule habilitée à marquer un critère « passé » — contrôle le diff et relance
|
||||
la correction jusqu'à ce que tout passe (2 corrections max). Les fichiers
|
||||
modifiés passent au **LSP** (gopls, typescript-language-server, pyright —
|
||||
inclus dans l'image) : les erreurs de compilation reviennent dans le résultat
|
||||
de l'outil, sans lancer de build. Sécurité : commandes catastrophiques
|
||||
refusées (rm -rf /, mkfs, reboot…), chemins bornés au dossier de la
|
||||
discussion en mode Code. En mode Chat, un message qui ressemble à une tâche
|
||||
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
|
||||
jamais bascule automatique. Conception reprise
|
||||
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
|
||||
`NOTICE.md`.
|
||||
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
|
||||
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
|
||||
playwright, context7, github…) avec leur commande déjà renseignée, classés par
|
||||
catégorie. Choisir une entrée **n'installe rien** : ça remplit le formulaire
|
||||
d'ajout, tu relis la commande — qui s'exécutera sur cette machine — puis tu
|
||||
enregistres. Le catalogue est un JSON **embarqué dans le binaire**
|
||||
(`internal/loki/mcp_catalog.json`), donc aucun appel à un annuaire distant :
|
||||
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
|
||||
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
|
||||
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
|
||||
- **Intensité du raisonnement** : un niveau — auto / aucune / basse / moyenne /
|
||||
haute / maximale — envoyé à `llama-server` comme `reasoning_effort`. Réglable
|
||||
**dans la barre de saisie**, parce que ça se décide en écrivant le message : le
|
||||
changement s'applique au message suivant, sans redémarrer le moteur. L'éditeur
|
||||
de preset garde le même réglage comme **défaut du modèle** ; appliquer un
|
||||
preset reprend donc la main sur le choix fait à la volée. `none` coupe le
|
||||
raisonnement ; les autres valeurs sont passées au gabarit jinja du modèle, ce
|
||||
qui ne change le comportement que des modèles qui les lisent (gpt-oss et
|
||||
apparentés) — ailleurs c'est ignoré sans erreur, et l'interface le dit plutôt
|
||||
que de promettre un effet. Aucun gabarit ne les connaît toutes (gpt-oss :
|
||||
basse/moyenne/haute ; Qwen3.8 : basse/moyenne/maximale) et certains **refusent**
|
||||
celles qu'ils ne connaissent pas, avec une erreur 500 qui tuait le tour : loki
|
||||
lit alors les niveaux annoncés par le refus, **repli sur le plus proche** (haute
|
||||
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
|
||||
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
|
||||
raisonnement est coupé pour ce modèle.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
@@ -215,10 +309,35 @@ Ajoutées par ce fork :
|
||||
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
|
||||
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
|
||||
pouvait donc qu'échouer.
|
||||
- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une
|
||||
recherche légitime est pire que la laisser durer — mais au-delà de 24 appels
|
||||
sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui
|
||||
demande de conclure. Le rappel revient tous les 24 appels, en durcissant le
|
||||
ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière.
|
||||
Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf
|
||||
le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois
|
||||
les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` —
|
||||
`AGENT_BUDGET=off` le désactive complètement.
|
||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||
dans le fil.
|
||||
- **Paramètres** : les réglages d'application (identité, apparence, accès
|
||||
OpenAI, actions) sont regroupés à part des réglages d'IA.
|
||||
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
|
||||
volets — la nav des sections à gauche (IA, moteur, application), le panneau
|
||||
choisi à droite. La barre latérale ne garde que les discussions (les plus
|
||||
récentes en tête) et le moniteur machine.
|
||||
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
|
||||
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
|
||||
survit au rechargement, et un vieux tour garde le modèle de l'époque.
|
||||
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
|
||||
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
|
||||
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
|
||||
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
|
||||
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
|
||||
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
|
||||
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
|
||||
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
|
||||
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
|
||||
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
|
||||
l'affichage ne se fige plus.
|
||||
|
||||
Retirées par ce fork :
|
||||
|
||||
@@ -246,6 +365,7 @@ Retirées par ce fork :
|
||||
| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` |
|
||||
| Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié |
|
||||
| Historique de tchat | conversation unique | discussions multiples, titrées et persistées |
|
||||
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
|
||||
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
|
||||
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
|
||||
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
|
||||
|
||||
+43
-9
@@ -1,19 +1,53 @@
|
||||
La grande nouveauté de cette version : AJEAN sait enfin voir les images.
|
||||
# Loki 0.12.0
|
||||
|
||||
## La vision, configurable par preset
|
||||
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
|
||||
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
|
||||
|
||||
Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul.
|
||||
## Le sélecteur de l'en-tête charge vraiment un modèle
|
||||
|
||||
Le champ ne liste que les projecteurs (les fichiers dont le nom contient `mmproj`), pas les modèles de plusieurs Go, pour que le choix reste lisible. Et s'il te manque un projecteur, le champ « télécharger un modèle » accepte aussi un lien vers un `mmproj` : une fois récupéré, il se place directement dans le champ Vision.
|
||||
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
|
||||
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
|
||||
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
|
||||
créé, le sélecteur n'offrait aucun choix.
|
||||
|
||||
## Les images arrivent vraiment au modèle
|
||||
## Dictée vocale
|
||||
|
||||
Avant, une image collée dans le chat était simplement déposée comme un fichier dans le dossier de travail, à charge pour le modèle de l'ouvrir avec ses outils (ce qui ne donnait qu'un tas d'octets illisibles). Désormais, quand un projecteur est configuré, l'image part au modèle en contenu multimodal : il la voit, et peut en parler.
|
||||
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
|
||||
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
|
||||
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
|
||||
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
|
||||
|
||||
## Cartes raisonnement/outils : taille fixe, défilement qui suit
|
||||
|
||||
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
|
||||
génération — remonter lire le début est respecté. Les cartes d'outil suivent
|
||||
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
|
||||
Sur un raisonnement géant, seul le bas du bloc est re-rendu en direct (le
|
||||
texte complet est posé à la fin) : l'affichage ne se fige plus.
|
||||
|
||||
## Mesures
|
||||
|
||||
- La carte du temps total affiche la **vitesse moyenne de la conversation**
|
||||
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
|
||||
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
|
||||
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
|
||||
seulement les octets lus).
|
||||
|
||||
## Interface
|
||||
|
||||
- Discussions triées par date de **création** (récentes en tête) : une
|
||||
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
|
||||
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
|
||||
Performance.
|
||||
|
||||
## Rappel 0.11.0
|
||||
|
||||
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
|
||||
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
|
||||
l'interface ; tâches planifiées reprises de l'amont.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
```
|
||||
ajean update
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Non vérifié sur cette version : le résultat final dépend d'un modèle vision et de son projecteur compatibles (Qwen2.5-VL, Gemma 3, etc.). À noter, l'image reste dans l'historique de la conversation et repart au moteur à chaque tour tant que la conversation dure.
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,14 +2,14 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 9,
|
||||
"Patch": 4,
|
||||
"Minor": 12,
|
||||
"Patch": 3,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 9,
|
||||
"Patch": 4,
|
||||
"Minor": 12,
|
||||
"Patch": 3,
|
||||
"Build": 0
|
||||
},
|
||||
"FileFlagsMask": "3f",
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||
"OriginalFilename": "loki.exe",
|
||||
"ProductName": "Loki",
|
||||
"ProductVersion": "0.9.4",
|
||||
"ProductVersion": "0.12.3",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -4,8 +4,17 @@
|
||||
set -eu
|
||||
|
||||
: "${LOKI_HOME:=/data}"
|
||||
export LOKI_HOME # les sous-commandes loki doivent voir le MEME dossier
|
||||
mkdir -p "$LOKI_HOME"
|
||||
|
||||
# Garde-fou persistance : si /data n'est pas un point de montage, tout ce que
|
||||
# l'utilisateur fera (modèles, discussions, fichiers) mourra avec le conteneur.
|
||||
# On le dit au premier écran du journal — l'UI l'affiche aussi en bandeau.
|
||||
if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
|
||||
echo "[entrypoint] ⚠️ $LOKI_HOME n'est pas un volume monté : les données seront PERDUES à la recréation du conteneur."
|
||||
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
|
||||
fi
|
||||
|
||||
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
|
||||
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
|
||||
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
# Dictée en temps réel, configurable selon le matériel
|
||||
|
||||
*Conception validée le 21 août 2026.*
|
||||
|
||||
## Le problème
|
||||
|
||||
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
|
||||
défauts la rendent pénible :
|
||||
|
||||
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
|
||||
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
|
||||
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
|
||||
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
|
||||
|
||||
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
|
||||
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
|
||||
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
|
||||
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
|
||||
un rechargement complet du modèle toutes les quelques secondes.
|
||||
|
||||
## Ce qu'on construit
|
||||
|
||||
Trois choses, dans cet ordre de dépendance :
|
||||
|
||||
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
|
||||
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
|
||||
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
|
||||
|
||||
## 1. Le moteur
|
||||
|
||||
### whisper-server plutôt que whisper-cli
|
||||
|
||||
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
|
||||
Loki le supervise comme il supervise déjà `llama-server`.
|
||||
|
||||
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
|
||||
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
|
||||
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
|
||||
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
|
||||
modèle ; les suivants sont immédiats.
|
||||
|
||||
### Choix du GPU
|
||||
|
||||
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
|
||||
pas de code à maintenir dans ggml :
|
||||
|
||||
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
|
||||
- valeur vide → CPU seul
|
||||
|
||||
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
|
||||
|
||||
### Compilation : deux binaires, pas un
|
||||
|
||||
L'étape `whisperbuild` produit **deux** binaires :
|
||||
|
||||
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
|
||||
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
|
||||
|
||||
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
|
||||
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
|
||||
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
|
||||
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
|
||||
CUDA condamnerait donc la variante CPU de l'image.
|
||||
|
||||
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
|
||||
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
|
||||
le disant, plutôt que de laisser la dictée morte.
|
||||
|
||||
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
|
||||
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
|
||||
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
|
||||
|
||||
## 2. Le découpage
|
||||
|
||||
### On coupe dans les silences, jamais à heure fixe
|
||||
|
||||
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
|
||||
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
|
||||
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
|
||||
l'améliorer.
|
||||
|
||||
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
|
||||
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
|
||||
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
|
||||
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
|
||||
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
|
||||
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
|
||||
est borné par la coupure forcée.
|
||||
|
||||
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
|
||||
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
|
||||
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
|
||||
pas voir le texte se figer.
|
||||
|
||||
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
|
||||
dont personne ne connaît l'interaction :
|
||||
|
||||
| Réglage | Réserve minimale | Coupure forcée |
|
||||
|---|---|---|
|
||||
| Court | 1 s | 5 s |
|
||||
| Moyen (défaut) | 1,5 s | 8 s |
|
||||
| Long | 3 s | 15 s |
|
||||
|
||||
### La logique vit en Go
|
||||
|
||||
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
|
||||
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
|
||||
|
||||
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
|
||||
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
|
||||
Et le navigateur redevient bête : capter, envoyer, afficher.
|
||||
|
||||
### Contrat de la socket
|
||||
|
||||
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
|
||||
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
|
||||
|
||||
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
|
||||
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
|
||||
sous les yeux.
|
||||
|
||||
### Langue forcée par défaut
|
||||
|
||||
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
|
||||
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
|
||||
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
|
||||
|
||||
## 3. Le panneau Dictée
|
||||
|
||||
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
|
||||
« Identité ». Suit le gabarit des panneaux existants.
|
||||
|
||||
| Réglage | Contenu |
|
||||
|---|---|
|
||||
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
|
||||
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
|
||||
| **Langue** | Français par défaut, auto et autres langues disponibles |
|
||||
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
|
||||
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
|
||||
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
|
||||
|
||||
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
|
||||
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
|
||||
progression et suppression, en généralisant le mécanisme mono-modèle existant.
|
||||
|
||||
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
|
||||
le jeton Hugging Face.
|
||||
|
||||
## Les pannes, nommées
|
||||
|
||||
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
|
||||
|
||||
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
|
||||
d'échouer sec
|
||||
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
|
||||
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
|
||||
l'utilisateur est prévenu
|
||||
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
|
||||
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
|
||||
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
|
||||
dans la mauvaise direction.
|
||||
|
||||
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
|
||||
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
|
||||
|
||||
## Tests
|
||||
|
||||
En Go, sur ce qui peut réellement casser :
|
||||
|
||||
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
|
||||
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
|
||||
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
|
||||
modèle, langue
|
||||
- **l'aller-retour des réglages** dans `bkState`
|
||||
- **l'extinction sur inactivité**
|
||||
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
|
||||
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
|
||||
|
||||
## Ce que les tests ne prouveront pas
|
||||
|
||||
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
|
||||
échantillon de voix n'est disponible côté développement. Les tests établissent que le
|
||||
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
|
||||
`small` reste une constatation d'usage.
|
||||
|
||||
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
|
||||
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
|
||||
@@ -0,0 +1,15 @@
|
||||
You are in CODE MODE: an autonomous coding agent working in this discussion's folder.
|
||||
|
||||
Method — in this order:
|
||||
1. If the task has no acceptance criteria yet, set them FIRST with the criteria tool (action=add): 2-6 short, testable statements of what DONE means. For a trivial task (one obvious change), skip criteria and just do it.
|
||||
2. Explore before you change: read the relevant files (read, grep, glob, git_status). Never edit a file you have not read.
|
||||
3. Implement with edit (small patches) or write (new files). Match the style of the surrounding code.
|
||||
4. Prove it: run the build/tests with bash. A change that was never run is not done. For a web page or UI, start the dev server with bash_bg then use web_screenshot on its localhost URL — a headless Chromium is ALREADY installed, so NEVER install puppeteer, playwright or a browser.
|
||||
5. Report briefly what changed and how you verified it.
|
||||
|
||||
Rules:
|
||||
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
|
||||
- Write each file COMPLETE in a single write call. Many small writes waste turns.
|
||||
- You may NOT mark a criterion passed — the verification pass does that.
|
||||
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
|
||||
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
|
||||
@@ -0,0 +1,5 @@
|
||||
You are the CODE REVIEW pass of code mode. Review the current changes (git_diff) for real defects.
|
||||
|
||||
Look for, in this order: correctness bugs (wrong logic, unhandled errors, races), security issues (injection, path traversal, secrets), then significant simplifications. Ignore style and taste.
|
||||
|
||||
For each finding: file:line, one-line problem, one-line fix. If the diff is sound, say so in one sentence. Do not modify anything.
|
||||
@@ -0,0 +1,3 @@
|
||||
You are the EXPLORATION pass of code mode. Answer questions about the codebase without modifying anything.
|
||||
|
||||
Use read, grep, glob and git tools to locate and understand code. Cite files as path:line. Be concise: report what you found and where, not everything you read. If the answer spans several places, list them with one line each.
|
||||
@@ -0,0 +1,8 @@
|
||||
You are the PLANNING pass of code mode. Break the user's task down BEFORE any code is written.
|
||||
|
||||
Produce:
|
||||
1. A short restatement of the goal (one sentence).
|
||||
2. Acceptance criteria via the criteria tool (action=add): 2-6 short, testable statements of what DONE means.
|
||||
3. The implementation steps, ordered, with the files each step touches.
|
||||
|
||||
Explore the code (read, grep, glob, git_status) as needed to make the plan concrete — real file paths, real function names. Do not modify anything. End by asking the user to confirm the plan or amend it.
|
||||
@@ -0,0 +1,12 @@
|
||||
You are the VERIFICATION pass of code mode. You independently check the acceptance criteria against the actual code — you did not write it, trust nothing.
|
||||
|
||||
For each criterion still pending or failed:
|
||||
1. Understand what it actually requires.
|
||||
2. Check the real changes: git_diff, read the touched files, run the relevant build/test command with bash when applicable.
|
||||
3. Record the verdict with the criteria tool: action=set, status=passed (satisfied) or failed (with a note saying exactly what is missing — actionable, one line).
|
||||
|
||||
Rules:
|
||||
- Trivial or non-code criteria that are plainly satisfied: pass them without exploring.
|
||||
- Only fail a criterion that genuinely misses its requirement; a different-but-valid implementation passes.
|
||||
- Do NOT fix anything yourself. You only verify and report.
|
||||
- Be fast: check the diff first, read only what the criteria require.
|
||||
@@ -134,7 +134,7 @@ func detectBuildPlan() buildPlan {
|
||||
}
|
||||
|
||||
// Vulkan (GPU générique) — utile sur Intel/AMD sans ROCm.
|
||||
if hasTool("glslc") && (isFile("/usr/lib/x86_64-linux-gnu/libvulkan.so.1") || hasTool("vulkaninfo")) {
|
||||
if hasTool("glslc") && (hasVulkanLib() || hasTool("vulkaninfo")) {
|
||||
p.backend = "vulkan"
|
||||
p.flags = append(p.flags, "-DGGML_VULKAN=ON")
|
||||
return p
|
||||
@@ -457,6 +457,33 @@ func hasTool(name string) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// hasVulkanLib détecte le chargeur Vulkan (libvulkan) sans supposer un unique
|
||||
// chemin de distribution. Repris de l'amont AJEAN (issues #28, #29) : l'ancienne
|
||||
// détection testait en dur le chemin Debian multiarch
|
||||
// (/usr/lib/x86_64-linux-gnu/libvulkan.so.1), invisible sur Fedora/RHEL/Atomic où
|
||||
// la bibliothèque vit dans /usr/lib64 — le plan de build retombait alors sur le
|
||||
// CPU sur une machine parfaitement capable. On interroge d'abord ldconfig (la
|
||||
// source de vérité du linker dynamique), puis une liste de chemins connus.
|
||||
func hasVulkanLib() bool {
|
||||
// ldconfig -p liste les bibliothèques du cache, tous chemins confondus.
|
||||
if out, err := exec.Command("ldconfig", "-p").Output(); err == nil {
|
||||
if strings.Contains(string(out), "libvulkan.so") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, p := range []string{
|
||||
"/usr/lib/x86_64-linux-gnu/libvulkan.so.1", // Debian/Ubuntu multiarch
|
||||
"/usr/lib64/libvulkan.so.1", // Fedora/RHEL/Bazzite
|
||||
"/usr/lib/libvulkan.so.1", // Arch et divers
|
||||
"/lib64/libvulkan.so.1",
|
||||
} {
|
||||
if isFile(p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func requireTools(tools ...string) error {
|
||||
missing := missingTools(tools)
|
||||
if len(missing) == 0 {
|
||||
|
||||
@@ -234,6 +234,61 @@ func splitArgs(s string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// samplingKeys : les paramètres d'échantillonnage réglables par preset. Repris
|
||||
// de l'amont AJEAN (v0.9.5/v0.9.6).
|
||||
//
|
||||
// Ils sont injectés dans CHAQUE requête de complétion (applySampling) et non
|
||||
// passés à llama-server au lancement : une valeur présente dans la requête
|
||||
// l'emporte de toute façon sur le défaut du serveur, donc c'est le seul endroit
|
||||
// où le réglage prend vraiment effet. Sans ça, seule la température voyageait, et
|
||||
// top_k/min_p/… retombaient sur les défauts de llama.cpp — rarement ceux que
|
||||
// recommande le modèle (Qwen3.8 veut top_k 20 / min_p 0, pas 40 / 0.05).
|
||||
//
|
||||
// Correspondance clé de config → champ du corps OpenAI. Une valeur vide vaut
|
||||
// « clé absente » : llama-server garde alors son propre défaut pour ce champ.
|
||||
var samplingKeys = []struct {
|
||||
cfg, field string
|
||||
integer bool
|
||||
}{
|
||||
{"TEMP", "temperature", false},
|
||||
{"TOP_P", "top_p", false},
|
||||
{"TOP_K", "top_k", true},
|
||||
{"MIN_P", "min_p", false},
|
||||
{"PRESENCE_PENALTY", "presence_penalty", false},
|
||||
{"REPEAT_PENALTY", "repeat_penalty", false},
|
||||
}
|
||||
|
||||
// applySampling superpose les paramètres d'échantillonnage du preset au corps
|
||||
// d'une requête de complétion. Les clés vides sont ignorées (le serveur garde son
|
||||
// défaut) ; TEMP, s'il est défini, l'emporte sur la température déjà posée dans le
|
||||
// payload — c'est le réglage recommandé du modèle, et l'interface n'a pas de
|
||||
// curseur de température qui pourrait le contredire.
|
||||
//
|
||||
// Contrairement à l'amont, REASONING_EFFORT n'est PAS traité ici : loki lui
|
||||
// réserve un chemin plus riche (llm_client.go + llm_effort.go — champ de haut
|
||||
// niveau, kwargs du gabarit, repli quand le gabarit refuse le niveau). L'écrire
|
||||
// depuis ici écraserait `chat_template_kwargs`, donc la consigne « aucune » avec.
|
||||
func applySampling(payload map[string]any) { applySamplingFrom(payload, ReadConfig()) }
|
||||
|
||||
// applySamplingFrom est la partie pure d'applySampling : testable sans base.
|
||||
func applySamplingFrom(payload map[string]any, cfg map[string]string) {
|
||||
for _, s := range samplingKeys {
|
||||
v := strings.TrimSpace(cfg[s.cfg])
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
if s.integer {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
payload[s.field] = n
|
||||
}
|
||||
continue
|
||||
}
|
||||
if f, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
payload[s.field] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été
|
||||
// retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le
|
||||
// seul frein.
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -202,7 +203,10 @@ func TestInstallationMoteurDepuisImage(t *testing.T) {
|
||||
if isDir(filepath.Join(dir, "etc")) || isDir(filepath.Join(dir, "usr")) {
|
||||
t.Error("des fichiers hors /app ont été extraits")
|
||||
}
|
||||
if st, err := os.Stat(bin); err != nil || st.Mode()&0o100 == 0 {
|
||||
// Windows n'a pas de bit exécutable : Stat y renvoie 0666/0444 quoi qu'il
|
||||
// arrive, et l'assertion échouait toujours — alors que l'installation, elle,
|
||||
// est correcte (le bit est bien posé côté Linux, là où le moteur tourne).
|
||||
if st, err := os.Stat(bin); err != nil || (runtime.GOOS != "windows" && st.Mode()&0o100 == 0) {
|
||||
t.Errorf("llama-server non exécutable : %v", err)
|
||||
}
|
||||
|
||||
|
||||
+183
-3
@@ -67,6 +67,11 @@ type hfRepo struct {
|
||||
ID string `json:"id"`
|
||||
Downloads int `json:"downloads"`
|
||||
Likes int `json:"likes"`
|
||||
// Gated : le dépôt exige d'avoir accepté ses conditions ET un jeton. Il se
|
||||
// LIT pourtant sans rien (arborescence en 200), et ne refuse qu'au moment du
|
||||
// transfert : sans cette pastille, l'interface propose des quants avec leur
|
||||
// verdict mémoire et le téléchargement échoue en 401 sans prévenir.
|
||||
Gated bool `json:"gated"`
|
||||
}
|
||||
|
||||
// hfEntry — un .gguf installable. Shards > 1 signale une famille de tranches :
|
||||
@@ -85,6 +90,7 @@ type hfEntry struct {
|
||||
// hfListing — le contenu utile d'un dépôt, trié.
|
||||
type hfListing struct {
|
||||
Repo string `json:"repo"`
|
||||
Gated bool `json:"gated"`
|
||||
Models []hfEntry `json:"models"`
|
||||
Projectors []hfEntry `json:"projectors"`
|
||||
Drafts []hfEntry `json:"drafts"`
|
||||
@@ -95,13 +101,115 @@ type hfListing struct {
|
||||
// transfert : sans lui un dépôt gated répond 401 et Loki afficherait « aucun
|
||||
// résultat » là où il faudrait dire « dépôt à accès restreint ».
|
||||
func hfAuth(req *http.Request) {
|
||||
if k := os.Getenv("HF_TOKEN"); k != "" {
|
||||
if k := hfToken(); k != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+k)
|
||||
}
|
||||
req.Header.Set("User-Agent", "loki/"+Version)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
// ---- Jeton Hugging Face -----------------------------------------------------
|
||||
//
|
||||
// Le jeton vivait UNIQUEMENT dans la variable d'environnement HF_TOKEN. En
|
||||
// conteneur, c'était l'exiger au mauvais moment : on découvre qu'un dépôt est
|
||||
// verrouillé depuis l'interface, et il faudrait éditer un docker-compose puis
|
||||
// recréer le conteneur pour y répondre. Il se règle donc aussi dans l'interface,
|
||||
// et se range là où sont déjà les secrets (base d'état) — pas dans config.env,
|
||||
// que le changement de preset réécrit en bloc.
|
||||
//
|
||||
// Priorité : le jeton enregistré d'abord, la variable d'environnement ensuite.
|
||||
// Rien d'enregistré = comportement d'avant, à l'octet près.
|
||||
|
||||
// hfToken renvoie le jeton effectivement utilisé, ou "".
|
||||
func hfToken() string {
|
||||
if t := strings.TrimSpace(getStr(bkState, "hf_token")); t != "" {
|
||||
return t
|
||||
}
|
||||
return strings.TrimSpace(os.Getenv("HF_TOKEN"))
|
||||
}
|
||||
|
||||
// hfTokenSource dit D'OÙ vient le jeton : « config » (enregistré ici), « env »
|
||||
// (HF_TOKEN du conteneur) ou "" (aucun). L'interface en a besoin pour ne pas
|
||||
// proposer d'effacer un jeton qu'elle ne peut pas effacer.
|
||||
func hfTokenSource() string {
|
||||
switch {
|
||||
case strings.TrimSpace(getStr(bkState, "hf_token")) != "":
|
||||
return "config"
|
||||
case strings.TrimSpace(os.Getenv("HF_TOKEN")) != "":
|
||||
return "env"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// hfTokenSet dit si un jeton est disponible, pour que l'interface distingue
|
||||
// « il faut en poser un » de « celui qui est posé ne suffit pas ».
|
||||
func hfTokenSet() bool { return hfToken() != "" }
|
||||
|
||||
// writeHFToken enregistre (ou efface, tok == "") le jeton. Le cache des réponses
|
||||
// Hugging Face est vidé au passage : il contient des listes obtenues SANS jeton,
|
||||
// où un dépôt verrouillé peut manquer ou paraître illisible.
|
||||
func writeHFToken(tok string) error {
|
||||
if err := putStr(bkState, "hf_token", strings.TrimSpace(tok)); err != nil {
|
||||
return err
|
||||
}
|
||||
hfCacheMu.Lock()
|
||||
hfCache = map[string]hfCacheItem{}
|
||||
hfCacheMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// maskHFToken n'affiche qu'assez de caractères pour reconnaître le jeton posé.
|
||||
// Il n'est jamais renvoyé en clair : contrairement à la clé API du serveur,
|
||||
// personne n'a besoin de le recopier depuis Loki.
|
||||
func maskHFToken(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return ""
|
||||
}
|
||||
if len(t) <= 8 {
|
||||
return "…" + t[len(t)-2:]
|
||||
}
|
||||
return t[:3] + "…" + t[len(t)-4:]
|
||||
}
|
||||
|
||||
// hfWhoAmI vérifie un jeton auprès de Hugging Face et renvoie le nom du compte.
|
||||
// Enregistrer un jeton sans le vérifier, c'est déplacer l'échec : on croirait le
|
||||
// problème réglé jusqu'au prochain 401, quinze minutes de téléchargement plus
|
||||
// tard.
|
||||
func hfWhoAmI(ctx context.Context, tok string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, hfTimeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", hfHost+"/api/whoami-v2", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(tok))
|
||||
req.Header.Set("User-Agent", "loki/"+Version)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Hugging Face injoignable : %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == 401 || resp.StatusCode == 403 {
|
||||
return "", fmt.Errorf("jeton refusé par Hugging Face (expiré, révoqué ou mal copié)")
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("Hugging Face a répondu %d", resp.StatusCode)
|
||||
}
|
||||
var who struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := json.Unmarshal(raw, &who); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return who.Name, nil
|
||||
}
|
||||
|
||||
// hfCache : mémo commun à la recherche et aux arborescences, clé = l'URL
|
||||
// appelée. Sans lui, chaque frappe au clavier dans le champ de recherche
|
||||
// produit une requête sortante.
|
||||
@@ -172,29 +280,99 @@ func hfSearch(ctx context.Context, q string) ([]hfRepo, error) {
|
||||
if len(q) > hfMaxQuery {
|
||||
q = q[:hfMaxQuery]
|
||||
}
|
||||
// `expand[]` remplace les champs par défaut de la réponse : `gated` s'y
|
||||
// ajoute, mais downloads et likes doivent alors être redemandés
|
||||
// explicitement, sinon ils disparaissent et la liste perd son classement
|
||||
// lisible.
|
||||
endpoint := hfHost + "/api/models?" + url.Values{
|
||||
"search": {q},
|
||||
"filter": {"gguf"},
|
||||
"limit": {fmt.Sprint(hfMaxRepos)},
|
||||
"sort": {"downloads"},
|
||||
"direction": {"-1"},
|
||||
"expand[]": {"gated", "downloads", "likes"},
|
||||
}.Encode()
|
||||
|
||||
var raw []struct {
|
||||
ID string `json:"id"`
|
||||
Downloads int `json:"downloads"`
|
||||
Likes int `json:"likes"`
|
||||
Gated any `json:"gated"`
|
||||
}
|
||||
if err := hfGetJSON(ctx, endpoint, hfSearchTTL, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]hfRepo, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes})
|
||||
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes, Gated: hfGatedFlag(r.Gated)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hfGatedFlag lit le champ `gated` de l'API, qui n'est PAS un booléen : il vaut
|
||||
// false, "auto" (accepter les conditions suffit) ou "manual" (l'auteur valide
|
||||
// chaque demande). Les deux dernières valeurs verrouillent le téléchargement de
|
||||
// la même façon ; seule la présence d'un verrou nous intéresse ici.
|
||||
func hfGatedFlag(v any) bool {
|
||||
switch g := v.(type) {
|
||||
case bool:
|
||||
return g
|
||||
case string:
|
||||
switch strings.ToLower(strings.TrimSpace(g)) {
|
||||
case "", "false", "none", "no":
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hfRepoGated dit si un dépôt est verrouillé. L'arborescence (hfFiles) ne porte
|
||||
// pas l'information : elle se lit sur la fiche du dépôt, en un appel séparé et
|
||||
// mis en cache comme elle. Une erreur ici ne doit RIEN casser — au pire on
|
||||
// n'affiche pas l'avertissement, et le téléchargement dira lui-même pourquoi il
|
||||
// a été refusé.
|
||||
func hfRepoGated(ctx context.Context, repo string) bool {
|
||||
var info struct {
|
||||
Gated any `json:"gated"`
|
||||
}
|
||||
endpoint := hfHost + "/api/models/" + repo + "?" + url.Values{"expand[]": {"gated"}}.Encode()
|
||||
if err := hfGetJSON(ctx, endpoint, hfFilesTTL, &info); err != nil {
|
||||
return false
|
||||
}
|
||||
return hfGatedFlag(info.Gated)
|
||||
}
|
||||
|
||||
// isHFHost dit si un hôte appartient à Hugging Face. Sert à décider qui a le
|
||||
// droit de recevoir le jeton.
|
||||
func isHFHost(host string) bool {
|
||||
h := strings.ToLower(host)
|
||||
if i := strings.IndexByte(h, ':'); i >= 0 {
|
||||
h = h[:i]
|
||||
}
|
||||
return h == "huggingface.co" || strings.HasSuffix(h, ".huggingface.co") ||
|
||||
h == "hf.co" || strings.HasSuffix(h, ".hf.co")
|
||||
}
|
||||
|
||||
// hfRepoFromURL retrouve « auteur/dépôt » dans un lien de téléchargement
|
||||
// Hugging Face, pour pouvoir le nommer dans un message d'erreur. Renvoie "" si
|
||||
// le lien pointe ailleurs.
|
||||
func hfRepoFromURL(raw string) string {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || !strings.Contains(u.Host, "huggingface.co") {
|
||||
return ""
|
||||
}
|
||||
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
|
||||
if len(segs) < 4 || (segs[2] != "resolve" && segs[2] != "blob") {
|
||||
return ""
|
||||
}
|
||||
repo := segs[0] + "/" + segs[1]
|
||||
if !hfRepoRe.MatchString(repo) {
|
||||
return ""
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
// hfFiles liste les .gguf d'un dépôt et les range par famille.
|
||||
//
|
||||
// `recursive=1` n'est pas un confort : les quants volumineux vivent dans des
|
||||
@@ -229,7 +407,9 @@ func hfFiles(ctx context.Context, repo string) (hfListing, error) {
|
||||
}
|
||||
files = append(files, hfFile{Path: f.Path, Size: n})
|
||||
}
|
||||
return hfClassify(repo, files), nil
|
||||
out := hfClassify(repo, files)
|
||||
out.Gated = hfRepoGated(ctx, repo)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// hfFile — une entrée d'arborescence, réduite à ce dont le classement a besoin.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -205,3 +209,151 @@ func TestFitVerdict(t *testing.T) {
|
||||
t.Errorf("le contexte ne change rien au verdict (%q dans les deux cas)", court)
|
||||
}
|
||||
}
|
||||
|
||||
// Le champ `gated` de l'API n'est pas un booléen : false, "auto" ou "manual".
|
||||
// Le lire comme un bool laissait passer les deux valeurs qui verrouillent
|
||||
// vraiment le téléchargement.
|
||||
func TestHFGatedFlag(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in any
|
||||
want bool
|
||||
}{
|
||||
{nil, false},
|
||||
{false, false},
|
||||
{true, true},
|
||||
{"auto", true},
|
||||
{"manual", true},
|
||||
{"false", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := hfGatedFlag(c.in); got != c.want {
|
||||
t.Errorf("hfGatedFlag(%#v) = %v, attendu %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hfRepoFromURL sert à NOMMER le dépôt dans le message d'erreur : un lien qui
|
||||
// ne vient pas de Hugging Face ne doit rien produire plutôt qu'un nom inventé.
|
||||
func TestHFRepoFromURL(t *testing.T) {
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf", "orcarouter/Qwen3.8-27B-Uncensored-GGUF"},
|
||||
{"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF/blob/main/sub/dir/m.gguf", "ggml-org/Qwen3.8-27B-GGUF"},
|
||||
{"https://example.com/ggml-org/Qwen3.8-27B-GGUF/resolve/main/m.gguf", ""},
|
||||
{"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF", ""},
|
||||
{"pas une url", ""},
|
||||
} {
|
||||
if got := hfRepoFromURL(c.in); got != c.want {
|
||||
t.Errorf("hfRepoFromURL(%q) = %q, attendu %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le jeton enregistré dans l'interface prime sur HF_TOKEN, et son absence
|
||||
// rend exactement le comportement d'avant : la variable d'environnement.
|
||||
func TestHFTokenPrecedence(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv("HF_TOKEN", "")
|
||||
if hfToken() != "" || hfTokenSource() != "" || hfTokenSet() {
|
||||
t.Fatalf("sans rien : jeton=%q source=%q", hfToken(), hfTokenSource())
|
||||
}
|
||||
t.Setenv("HF_TOKEN", "hf_env")
|
||||
if hfToken() != "hf_env" || hfTokenSource() != "env" {
|
||||
t.Fatalf("HF_TOKEN seul : jeton=%q source=%q", hfToken(), hfTokenSource())
|
||||
}
|
||||
if err := writeHFToken(" hf_enregistre "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hfToken() != "hf_enregistre" || hfTokenSource() != "config" {
|
||||
t.Fatalf("jeton enregistré : jeton=%q source=%q", hfToken(), hfTokenSource())
|
||||
}
|
||||
// Retiré ici, la variable du conteneur reprend la main plutôt que de laisser
|
||||
// Loki sans jeton alors que l'environnement en fournit un.
|
||||
if err := writeHFToken(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if hfToken() != "hf_env" || hfTokenSource() != "env" {
|
||||
t.Fatalf("après retrait : jeton=%q source=%q", hfToken(), hfTokenSource())
|
||||
}
|
||||
}
|
||||
|
||||
// Le jeton ne doit jamais ressortir en clair de l'interface.
|
||||
func TestMaskHFToken(t *testing.T) {
|
||||
if got := maskHFToken("hf_abcdefghijklmnop"); got != "hf_…mnop" {
|
||||
t.Errorf("masque = %q", got)
|
||||
}
|
||||
if got := maskHFToken(""); got != "" {
|
||||
t.Errorf("masque d'un jeton absent = %q", got)
|
||||
}
|
||||
if strings.Contains(maskHFToken("hf_abcdefghijklmnop"), "efghij") {
|
||||
t.Error("le masque laisse voir le milieu du jeton")
|
||||
}
|
||||
}
|
||||
|
||||
// Un secret ne part QUE vers Hugging Face : un lien collé vers un autre
|
||||
// hébergeur ne doit pas recevoir le jeton du compte.
|
||||
func TestDLRequestTokenOnlyToHuggingFace(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv("HF_TOKEN", "hf_secret")
|
||||
for _, c := range []struct {
|
||||
url string
|
||||
want bool
|
||||
}{
|
||||
{"https://huggingface.co/a/b/resolve/main/m.gguf", true},
|
||||
{"https://cdn-lfs.huggingface.co/a/b/m.gguf", true},
|
||||
{"https://hf.co/a/b/resolve/main/m.gguf", true},
|
||||
{"https://example.com/m.gguf", false},
|
||||
{"https://huggingface.co.evil.example/m.gguf", false},
|
||||
} {
|
||||
req, err := dlRequest(context.Background(), c.url, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := req.Header.Get("Authorization") != ""; got != c.want {
|
||||
t.Errorf("%s : en-tête Authorization présent=%v, attendu %v", c.url, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La route décrit le jeton sans jamais le rendre, et l'efface sans appeler
|
||||
// Hugging Face (un jeton vide n'a rien à vérifier).
|
||||
func TestHandleHFTokenGetAndClear(t *testing.T) {
|
||||
testHome(t)
|
||||
t.Setenv("HF_TOKEN", "")
|
||||
if err := writeHFToken("hf_abcdefghijklmnop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
call := func(method, body string) map[string]any {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(method, "http://placeholder/api/hf/token", strings.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
handleHFToken(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("%s → HTTP %d : %s", method, w.Code, w.Body.String())
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
got := call("GET", "")
|
||||
if got["set"] != true || got["source"] != "config" {
|
||||
t.Fatalf("état lu = %v", got)
|
||||
}
|
||||
if s, _ := got["masked"].(string); s == "" || strings.Contains(s, "efghij") {
|
||||
t.Fatalf("masque inattendu : %q", s)
|
||||
}
|
||||
if strings.Contains(fmt.Sprint(got), "hf_abcdefghijklmnop") {
|
||||
t.Fatalf("le jeton ressort en clair : %v", got)
|
||||
}
|
||||
|
||||
got = call("POST", `{"token":""}`)
|
||||
if got["set"] != false || got["source"] != "" {
|
||||
t.Fatalf("après effacement = %v", got)
|
||||
}
|
||||
if hfToken() != "" {
|
||||
t.Fatalf("jeton toujours présent : %q", hfToken())
|
||||
}
|
||||
}
|
||||
@@ -264,10 +264,12 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
return
|
||||
}
|
||||
out := []map[string]any{{"path": modelsDir(), "home": true, "count": modelDirCount(modelsDir()), "exists": true, "free": diskFree(modelsDir())}}
|
||||
out := []map[string]any{{"path": modelsDir(), "home": true, "count": modelDirCount(modelsDir()), "exists": true,
|
||||
"free": diskFree(modelsDir()), "free_exact": diskFreeReliable(modelsDir())}}
|
||||
for _, d := range extraModelDirs() {
|
||||
n := modelDirCount(d)
|
||||
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0, "free": diskFree(d)})
|
||||
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0,
|
||||
"free": diskFree(d), "free_exact": diskFreeReliable(d)})
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("LOKI_MODEL_DIRS") != ""})
|
||||
}
|
||||
@@ -70,6 +70,79 @@ func reasoningActive(v string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
|
||||
// "" (auto) n'y figure pas : c'est l'absence de réglage.
|
||||
//
|
||||
// Aucun gabarit ne les connaît toutes — gpt-oss lit low/medium/high, Qwen3.8 lit
|
||||
// low/medium/xhigh — et certains REFUSENT celles qu'ils ne connaissent pas (voir
|
||||
// llm_effort.go, qui traduit alors vers le niveau accepté le plus proche). La
|
||||
// liste reste donc l'union des niveaux courants plutôt que le plus petit
|
||||
// dénominateur commun, sinon le maximum d'un modèle est hors d'atteinte.
|
||||
var ReasoningEfforts = []string{"none", "low", "medium", "high", "xhigh"}
|
||||
|
||||
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
|
||||
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
|
||||
//
|
||||
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
|
||||
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
|
||||
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur ;
|
||||
// un gabarit qui la VALIDE, lui, peut lever une exception jinja et faire
|
||||
// répondre 500 à llama-server (Qwen3.8 sur « high »). Le repli n'est pas ici —
|
||||
// on ne sait pas d'avance ce que le gabarit accepte — mais dans llm_effort.go,
|
||||
// qui apprend la réponse du refus lui-même et rejoue le tour.
|
||||
func reasoningEffortValue(v string) string {
|
||||
switch s := strings.ToLower(strings.TrimSpace(v)); s {
|
||||
case "", "auto", "default":
|
||||
return "" // rien envoyé : le gabarit garde son comportement
|
||||
case "off", "false", "0", "no", "disable", "disabled", "none":
|
||||
return "none"
|
||||
case "low", "medium", "high":
|
||||
return s
|
||||
default:
|
||||
// Valeur inconnue (éditée à la main) : on ne l'invente pas, on la laisse
|
||||
// passer au gabarit, qui l'ignorera s'il ne la connaît pas.
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// reasoningTemplateKwargs construit les `chat_template_kwargs` à joindre à une
|
||||
// requête de chat pour que « aucune » coupe VRAIMENT le raisonnement.
|
||||
//
|
||||
// Pourquoi ce doublon avec `reasoning_effort` : ce champ n'agit que sur les
|
||||
// gabarits qui le LISENT (gpt-oss et apparentés). Un modèle hybride à la Qwen3
|
||||
// ne connaît que `enable_thinking` — on lui envoie `reasoning_effort: none`, son
|
||||
// gabarit l'ignore, et il continue de réfléchir alors que l'interface affiche
|
||||
// « aucune ». Les llama-server récents traduisent eux-mêmes `none` en
|
||||
// `enable_thinking=false`, mais pas les binaires plus anciens, qui laissent
|
||||
// simplement tomber le champ.
|
||||
//
|
||||
// D'où la règle : quand le raisonnement doit être coupé, on le dit dans la
|
||||
// langue que TOUS les gabarits comprennent. `chat_template_kwargs` est passé tel
|
||||
// quel au gabarit jinja ; une clé qu'il n'utilise pas est ignorée sans erreur.
|
||||
// Même procédé que la compaction (chat_compact.go), qui coupe déjà la réflexion
|
||||
// de cette façon.
|
||||
//
|
||||
// off = REASONING explicitement à off. Clé absente ≠ off : sans consigne on ne
|
||||
// touche à rien, le gabarit garde son défaut.
|
||||
// Renvoie nil quand il n'y a rien à imposer.
|
||||
func reasoningTemplateKwargs(off bool, effort string) map[string]any {
|
||||
if off || effort == "none" {
|
||||
return map[string]any{"enable_thinking": false}
|
||||
}
|
||||
if effort != "" {
|
||||
// Niveau explicite : les vieux binaires ne relaient pas le champ de haut
|
||||
// niveau au gabarit, celui-ci passe toujours.
|
||||
return map[string]any{"reasoning_effort": effort}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reasoningExplicitlyOff dit si REASONING interdit le raisonnement. Une clé
|
||||
// absente ou vide n'est PAS une interdiction : c'est l'absence de consigne.
|
||||
func reasoningExplicitlyOff(v string) bool {
|
||||
return strings.TrimSpace(v) != "" && !reasoningActive(v)
|
||||
}
|
||||
|
||||
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
|
||||
// (manual override, with or without a leading '#') wins; otherwise it is
|
||||
// auto-detected from the MODEL= filename. Returns "" when unknown.
|
||||
@@ -337,6 +410,13 @@ func checkDiskSpace(dir string, size int64) error {
|
||||
if size <= 0 || free < 0 {
|
||||
return nil
|
||||
}
|
||||
// Système de fichiers dont statfs approxime (FUSE/shfs d'Unraid, overlay,
|
||||
// réseau) : le chiffre reste affiché, mais il ne peut plus REFUSER un
|
||||
// téléchargement. Vécu : « 11,9 Go libres » annoncés sur un partage Unraid
|
||||
// qui en avait des centaines, et l'installation du modèle bloquée.
|
||||
if !diskFreeReliable(dir) {
|
||||
return nil
|
||||
}
|
||||
if free < size+dlSpaceMargin {
|
||||
return fmt.Errorf("espace insuffisant sur %s : %s libres, %s nécessaires", dir, humanBytes(free), humanBytes(size+dlSpaceMargin))
|
||||
}
|
||||
@@ -393,7 +473,8 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
total += n
|
||||
}
|
||||
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true, "parts": len(urls)}
|
||||
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total,
|
||||
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)}
|
||||
if err := checkDiskSpace(dir, total); err != nil {
|
||||
out["enough"] = false
|
||||
out["error"] = err.Error()
|
||||
@@ -408,8 +489,12 @@ func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// HF gated/private repos may need a token; reuse the same key store if set.
|
||||
if k := os.Getenv("HF_TOKEN"); k != "" {
|
||||
// Dépôt gated/privé : le jeton (réglage de l'interface, sinon HF_TOKEN) est
|
||||
// ce qui débloque le transfert. Il n'accompagne QUE les liens Hugging Face :
|
||||
// un lien collé vers un autre hébergeur n'a aucune raison de recevoir un
|
||||
// secret, et le CDN vers lequel HF redirige est signé, donc n'en a pas besoin
|
||||
// non plus (Go retire l'en-tête au changement de domaine).
|
||||
if k := hfToken(); k != "" && isHFHost(req.URL.Host) {
|
||||
req.Header.Set("Authorization", "Bearer "+k)
|
||||
}
|
||||
req.Header.Set("User-Agent", "loki/"+Version)
|
||||
@@ -433,6 +518,75 @@ func contentRangeTotal(v string) int64 {
|
||||
return n
|
||||
}
|
||||
|
||||
// dlSourceError traduit un refus HTTP en phrase qui dit quoi faire.
|
||||
//
|
||||
// Vécu : un dépôt Hugging Face « gated » (conditions à accepter avant de
|
||||
// télécharger) répond 200 sur son arborescence — Loki liste donc tous ses
|
||||
// quants, avec leur verdict mémoire — puis 401 sur CHAQUE .gguf. « HTTP 401
|
||||
// depuis la source » ne dit alors ni que le dépôt est verrouillé, ni qu'il faut
|
||||
// un jeton, ni où l'accepter : l'utilisateur voit un modèle proposé comme
|
||||
// installable qui échoue sans raison.
|
||||
//
|
||||
// Hugging Face, lui, le dit — dans l'en-tête X-Error-Code (GatedRepo,
|
||||
// RepoNotFound, EntryNotFound…). On le traduit plutôt que de le recopier :
|
||||
// l'interface est en français, et le message d'origine (« Please log in »)
|
||||
// parle d'une session de navigateur qui n'existe pas ici.
|
||||
func dlSourceError(resp *http.Response, dlURL string) error {
|
||||
code := resp.Header.Get("X-Error-Code")
|
||||
switch resp.StatusCode {
|
||||
case 401, 403:
|
||||
return fmt.Errorf("%s%s", dlAccessReason(code, hfRepoFromURL(dlURL)), dlTokenHint())
|
||||
case 404:
|
||||
switch code {
|
||||
case "EntryNotFound":
|
||||
return fmt.Errorf("fichier absent du dépôt (HTTP 404) — la révision a pu être réécrite depuis que le lien a été copié")
|
||||
case "RevisionNotFound":
|
||||
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
|
||||
}
|
||||
return fmt.Errorf("lien introuvable (HTTP 404)")
|
||||
case 416:
|
||||
return fmt.Errorf("la source refuse la plage d'octets demandée (HTTP 416) — fichier modifié pendant le transfert ?")
|
||||
case 429:
|
||||
return fmt.Errorf("trop de requêtes vers la source (HTTP 429) — réessaie dans quelques minutes")
|
||||
}
|
||||
if resp.StatusCode >= 500 {
|
||||
return fmt.Errorf("la source est en panne (HTTP %d) — réessaie plus tard", resp.StatusCode)
|
||||
}
|
||||
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
|
||||
}
|
||||
|
||||
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
|
||||
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
|
||||
// accepter, qui n'existent que là-bas.
|
||||
func dlAccessReason(code, repo string) string {
|
||||
switch code {
|
||||
case "GatedRepo":
|
||||
if repo == "" {
|
||||
return "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co"
|
||||
}
|
||||
return "dépôt à accès restreint : accepte ses conditions sur huggingface.co/" + repo
|
||||
case "RepoNotFound":
|
||||
if repo == "" {
|
||||
return "dépôt privé ou inexistant"
|
||||
}
|
||||
return "dépôt privé ou inexistant : " + repo
|
||||
}
|
||||
if repo != "" {
|
||||
return "accès refusé par Hugging Face sur " + repo
|
||||
}
|
||||
return "accès refusé par la source"
|
||||
}
|
||||
|
||||
// dlTokenHint complète la raison par l'état du jeton. Deux situations opposées
|
||||
// se cachent derrière le même 401 : pas de jeton du tout, ou un jeton qui n'a
|
||||
// pas accès à CE dépôt — et le geste à faire n'est pas le même.
|
||||
func dlTokenHint() string {
|
||||
if !hfTokenSet() {
|
||||
return " — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
|
||||
}
|
||||
return " — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
|
||||
}
|
||||
|
||||
// dlProbe asks the server for the first byte to learn the total size and
|
||||
// whether ranges are supported (206 + Content-Range).
|
||||
func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err error) {
|
||||
@@ -456,7 +610,7 @@ func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err e
|
||||
// Server ignored the Range: single stream, ContentLength is the size.
|
||||
return resp.ContentLength, false, nil
|
||||
default:
|
||||
return 0, false, fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
|
||||
return 0, false, dlSourceError(resp, dlURL)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,8 +836,9 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
|
||||
continue
|
||||
}
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 206 {
|
||||
err := dlSourceError(resp, dlURL)
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == 200 && pos > start {
|
||||
// Resume refused: the body restarts from 0, rewind our bookkeeping.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -135,3 +136,90 @@ func TestRunDownloadParallelAndFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un dépôt Hugging Face verrouillé répond 401 sur le .gguf alors que son
|
||||
// arborescence se lit sans jeton. « HTTP 401 depuis la source » n'apprenait
|
||||
// rien : le message doit nommer le verrou, le dépôt, et le geste à faire.
|
||||
func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
|
||||
const gated = "https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf"
|
||||
resp := func(status int, code string) *http.Response {
|
||||
r := &http.Response{StatusCode: status, Header: http.Header{}}
|
||||
if code != "" {
|
||||
r.Header.Set("X-Error-Code", code)
|
||||
}
|
||||
return r
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
token string
|
||||
resp *http.Response
|
||||
url string
|
||||
want []string
|
||||
absent []string
|
||||
}{
|
||||
{
|
||||
name: "gated sans jeton", resp: resp(401, "GatedRepo"), url: gated,
|
||||
want: []string{"accès restreint", "orcarouter/Qwen3.8-27B-Uncensored-GGUF", "HF_TOKEN"},
|
||||
},
|
||||
{
|
||||
name: "gated avec jeton refusé", token: "hf_xxx", resp: resp(401, "GatedRepo"), url: gated,
|
||||
want: []string{"accès restreint", "n'y donne pas accès"},
|
||||
absent: []string{"renseigne le jeton"},
|
||||
},
|
||||
{
|
||||
name: "dépôt privé ou absent", resp: resp(401, "RepoNotFound"), url: gated,
|
||||
want: []string{"privé ou inexistant", "HF_TOKEN"},
|
||||
},
|
||||
{
|
||||
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
|
||||
want: []string{"absent du dépôt", "404"},
|
||||
},
|
||||
{
|
||||
name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
|
||||
want: []string{"accès refusé par la source"},
|
||||
absent: []string{"huggingface.co"},
|
||||
},
|
||||
{
|
||||
name: "panne de la source", resp: resp(503, ""), url: gated,
|
||||
want: []string{"en panne", "503"},
|
||||
},
|
||||
{
|
||||
name: "code inconnu", resp: resp(418, ""), url: gated,
|
||||
want: []string{"HTTP 418 depuis la source"},
|
||||
},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Setenv("HF_TOKEN", c.token)
|
||||
got := dlSourceError(c.resp, c.url).Error()
|
||||
for _, w := range c.want {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("message %q ne contient pas %q", got, w)
|
||||
}
|
||||
}
|
||||
for _, a := range c.absent {
|
||||
if strings.Contains(got, a) {
|
||||
t.Errorf("message %q contient %q alors qu'il ne devrait pas", got, a)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// La sonde est le premier appel réseau d'une installation : c'est elle qui doit
|
||||
// remonter le refus expliqué, pas un code HTTP nu.
|
||||
func TestDLProbeSurfacesExplainedError(t *testing.T) {
|
||||
t.Setenv("HF_TOKEN", "")
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Error-Code", "GatedRepo")
|
||||
w.WriteHeader(401)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, _, err := dlProbe(context.Background(), srv.URL+"/m.gguf")
|
||||
if err == nil {
|
||||
t.Fatal("un 401 doit faire échouer la sonde")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HF_TOKEN") {
|
||||
t.Errorf("message %q sans indication sur le jeton", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Le sampling du preset doit se retrouver dans le corps de la requête, avec les
|
||||
// bons types (top_k entier, le reste flottant), et TEMP écrase la température
|
||||
// déjà posée. Une clé vide n'ajoute rien : le serveur garde son défaut.
|
||||
func TestApplySamplingFrom(t *testing.T) {
|
||||
payload := map[string]any{"temperature": 0.7}
|
||||
applySamplingFrom(payload, map[string]string{
|
||||
"TEMP": "1.0",
|
||||
"TOP_P": "0.95",
|
||||
"TOP_K": "20",
|
||||
"MIN_P": "0",
|
||||
"REPEAT_PENALTY": "1.05",
|
||||
"PRESENCE_PENALTY": "", // vide → ignoré
|
||||
})
|
||||
if payload["temperature"] != 1.0 {
|
||||
t.Errorf("TEMP doit écraser la température : %v", payload["temperature"])
|
||||
}
|
||||
if payload["top_p"] != 0.95 || payload["min_p"] != 0.0 || payload["repeat_penalty"] != 1.05 {
|
||||
t.Errorf("flottants attendus : %v %v %v", payload["top_p"], payload["min_p"], payload["repeat_penalty"])
|
||||
}
|
||||
if payload["top_k"] != 20 {
|
||||
t.Errorf("top_k doit être un entier 20, pas %#v", payload["top_k"])
|
||||
}
|
||||
if _, ok := payload["presence_penalty"]; ok {
|
||||
t.Error("une clé vide ne doit rien envoyer")
|
||||
}
|
||||
}
|
||||
|
||||
// L'échantillonnage ne touche PAS au raisonnement : ce chemin appartient à
|
||||
// llm_client.go/llm_effort.go, qui y met aussi `enable_thinking`. L'amont écrit
|
||||
// `chat_template_kwargs` ici ; le faire chez nous effacerait la consigne
|
||||
// « aucune » — donc le modèle réfléchirait alors que l'interface dit l'inverse.
|
||||
func TestApplySamplingNeTouchePasAuRaisonnement(t *testing.T) {
|
||||
payload := map[string]any{"chat_template_kwargs": map[string]any{"enable_thinking": false}}
|
||||
applySamplingFrom(payload, map[string]string{"REASONING_EFFORT": "medium", "TOP_K": "20"})
|
||||
kw, ok := payload["chat_template_kwargs"].(map[string]any)
|
||||
if !ok || kw["enable_thinking"] != false || len(kw) != 1 {
|
||||
t.Errorf("chat_template_kwargs modifié : %v", payload["chat_template_kwargs"])
|
||||
}
|
||||
if _, ok := payload["reasoning_effort"]; ok {
|
||||
t.Error("reasoning_effort ne doit pas être posé par l'échantillonnage")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans aucune clé de sampling, le payload n'est pas modifié : on ne force pas de
|
||||
// valeurs qui contrediraient le défaut du modèle.
|
||||
func TestApplySamplingEmpty(t *testing.T) {
|
||||
payload := map[string]any{"temperature": 0.7}
|
||||
applySamplingFrom(payload, map[string]string{})
|
||||
if len(payload) != 1 || payload["temperature"] != 0.7 {
|
||||
t.Errorf("payload modifié à tort : %v", payload)
|
||||
}
|
||||
}
|
||||
+198
-15
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -23,15 +24,163 @@ import (
|
||||
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
|
||||
// tort qu'il ne gère pas le drapeau.
|
||||
func binSupportsReasoningFlag(bin string) bool {
|
||||
return strings.Contains(binHelp(bin), "--reasoning ")
|
||||
}
|
||||
|
||||
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
|
||||
// capacités se déduisent de cette aide (--reasoning, --load-mode, « -ngl auto »)
|
||||
// et relancer le moteur à chaque question coûterait quelques secondes de plus au
|
||||
// démarrage. Aide illisible = chaîne vide : tous les tests de capacité répondent
|
||||
// « non », c'est-à-dire « ne prends aucun risque, garde la forme historique ».
|
||||
var (
|
||||
helpMu sync.Mutex
|
||||
helpCache = map[string]string{}
|
||||
)
|
||||
|
||||
func binHelp(bin string) string {
|
||||
helpMu.Lock()
|
||||
defer helpMu.Unlock()
|
||||
if h, ok := helpCache[bin]; ok {
|
||||
return h
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, "--help")
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &out
|
||||
if err := cmd.Run(); err != nil && out.Len() == 0 {
|
||||
return false // aide illisible : on ne prend pas le risque
|
||||
h := ""
|
||||
if err := cmd.Run(); err == nil || out.Len() > 0 {
|
||||
h = out.String()
|
||||
}
|
||||
return strings.Contains(out.String(), "--reasoning ")
|
||||
helpCache[bin] = h
|
||||
return h
|
||||
}
|
||||
|
||||
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
|
||||
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
|
||||
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
|
||||
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
|
||||
func binSupportsLoadMode(bin string) bool {
|
||||
return strings.Contains(binHelp(bin), "--load-mode")
|
||||
}
|
||||
|
||||
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
|
||||
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
|
||||
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
|
||||
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
|
||||
func binSupportsNGLAuto(bin string) bool {
|
||||
h := binHelp(bin)
|
||||
i := strings.Index(h, "--n-gpu-layers")
|
||||
if i < 0 {
|
||||
i = strings.Index(h, "-ngl")
|
||||
}
|
||||
if i < 0 {
|
||||
return false
|
||||
}
|
||||
end := i + 400
|
||||
if end > len(h) {
|
||||
end = len(h)
|
||||
}
|
||||
return strings.Contains(h[i:end], "'auto'")
|
||||
}
|
||||
|
||||
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
|
||||
//
|
||||
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
|
||||
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
|
||||
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
|
||||
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
|
||||
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
|
||||
// grossier mais insensible à la mise en page.
|
||||
func binFitsLayersItself(bin string) bool {
|
||||
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
|
||||
}
|
||||
|
||||
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
|
||||
// note à afficher quand Loki a substitué quelque chose. Fonction pure : la seule
|
||||
// question qui demande le moteur (« sait-il répartir les couches tout seul ? »)
|
||||
// arrive déjà tranchée, ce qui la rend testable sans lancer llama-server.
|
||||
func nglArgs(ngl string, fitsItself bool) ([]string, string) {
|
||||
ngl = strings.TrimSpace(ngl)
|
||||
switch {
|
||||
case strings.EqualFold(ngl, "auto"):
|
||||
return nil, "" // rien du tout : le moteur décide seul
|
||||
case ngl == "999" && fitsItself:
|
||||
return []string{"-ngl", "auto"},
|
||||
"NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la " +
|
||||
"VRAM libre. NGL=all pour forcer l'ancien comportement."
|
||||
case ngl != "":
|
||||
return []string{"-ngl", ngl}, ""
|
||||
case fitsItself:
|
||||
return []string{"-ngl", "auto"}, ""
|
||||
default:
|
||||
// Moteur ancien : omettre -ngl le ferait tourner 100 % CPU.
|
||||
return []string{"-ngl", "999"}, ""
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnyFlag dit si l'utilisateur a déjà posé l'un de ces drapeaux dans
|
||||
// EXTRA_ARGS. Loki ajoute plusieurs valeurs par défaut (--parallel, -ngl) : les
|
||||
// ajouter EN PLUS de celles de l'utilisateur fait râler le moteur (« argument
|
||||
// specified multiple times ») et, pire, c'est la DERNIÈRE occurrence qui gagne
|
||||
// — le réglage explicite du preset serait donc silencieusement écrasé par le
|
||||
// défaut de Loki. Quand l'utilisateur a tranché, on se tait.
|
||||
func hasAnyFlag(args []string, flags ...string) bool {
|
||||
for _, a := range args {
|
||||
name := a
|
||||
if i := strings.IndexByte(name, '='); i > 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
for _, f := range flags {
|
||||
if name == f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
|
||||
// --load-mode, sur les moteurs qui le connaissent :
|
||||
//
|
||||
// --mlock → --load-mode mlock (garder le modèle en RAM)
|
||||
// --no-mmap → --load-mode none (tout charger, pas de mmap)
|
||||
// --mmap → --load-mode mmap
|
||||
//
|
||||
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
|
||||
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
|
||||
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
|
||||
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
|
||||
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
|
||||
if !supportsLoadMode {
|
||||
return args
|
||||
}
|
||||
explicit := hasAnyFlag(args, "--load-mode")
|
||||
mlock, nommap, mmap := false, false, false
|
||||
out := make([]string, 0, len(args)+2)
|
||||
for _, a := range args {
|
||||
switch a {
|
||||
case "--mlock":
|
||||
mlock = true
|
||||
case "--no-mmap":
|
||||
nommap = true
|
||||
case "--mmap":
|
||||
mmap = true
|
||||
default:
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
if explicit || !(mlock || nommap || mmap) {
|
||||
return out
|
||||
}
|
||||
mode := "mmap"
|
||||
switch {
|
||||
case mlock:
|
||||
mode = "mlock"
|
||||
case nommap:
|
||||
mode = "none"
|
||||
}
|
||||
return append(out, "--load-mode", mode)
|
||||
}
|
||||
|
||||
// cmdServe replaces the historic start.sh: read config.env, build the
|
||||
@@ -99,6 +248,12 @@ func cmdServe(args []string) error {
|
||||
ktv := get("KV_TYPE_K", kv)
|
||||
vtv := get("KV_TYPE_V", kv)
|
||||
|
||||
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
|
||||
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
|
||||
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
|
||||
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
|
||||
extra := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
|
||||
|
||||
llmArgs := []string{bin,
|
||||
"-m", model,
|
||||
"-c", get("CTX", "32768"),
|
||||
@@ -109,15 +264,43 @@ func cmdServe(args []string) error {
|
||||
"--host", get("HOST", "0.0.0.0"),
|
||||
"--port", get("PORT", "8080"),
|
||||
}
|
||||
// NGL=auto : on n'envoie PAS -ngl, et llama.cpp choisit lui-même combien de
|
||||
// couches tiennent dans la VRAM libre (common_fit_params). Il s'en abstient
|
||||
// dès que la valeur est imposée — « n_gpu_layers already set by user to 999,
|
||||
// abort » — d'où des cudaMalloc en échec, un repli partiel sur le CPU et un
|
||||
// débit effondré alors que le GPU tourne à 100 %.
|
||||
// Le défaut reste 999 (tout sur le GPU) : omettre -ngl sur un llama.cpp
|
||||
// antérieur à cet ajustement automatique le ferait tourner 100 % CPU.
|
||||
if ngl := get("NGL", "999"); !strings.EqualFold(ngl, "auto") {
|
||||
llmArgs = append(llmArgs, "-ngl", ngl)
|
||||
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
|
||||
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
|
||||
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
|
||||
// Vécu : une config 27B/60k ctx qui tournait très bien est morte en
|
||||
// « cudaMalloc failed: out of memory » après une mise à jour du moteur,
|
||||
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
|
||||
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
|
||||
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
|
||||
if !hasAnyFlag(extra, "--parallel", "-np") {
|
||||
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
|
||||
}
|
||||
// Couches GPU. Quatre cas, dans cet ordre :
|
||||
//
|
||||
// NGL=auto → rien du tout (le moteur décide seul)
|
||||
// NGL=999 → -ngl auto sur un moteur récent (voir plus bas)
|
||||
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche pour de vrai)
|
||||
// clé absente → -ngl auto si le moteur le propose, sinon -ngl 999
|
||||
//
|
||||
// Imposer un NOMBRE désarme common_fit_params, qui mesure la VRAM libre et
|
||||
// choisit combien de couches y tiennent : « n_gpu_layers already set by user
|
||||
// to 999, abort ». Le moteur pousse alors tout sur le GPU, échoue en
|
||||
// cudaMalloc ou se replie à moitié sur le CPU — débit effondré, GPU à 100 %.
|
||||
//
|
||||
// 999 est traité à part parce que ce n'a JAMAIS été un nombre de couches :
|
||||
// c'est la sentinelle historique « toutes », semée dans config.env par
|
||||
// defaultConfig() sur chaque installation neuve. La quasi-totalité des
|
||||
// configurations la portent donc sans que personne ne l'ait choisie — la
|
||||
// traiter comme un choix délibéré, c'est condamner tout le monde à l'abandon
|
||||
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
|
||||
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
|
||||
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
|
||||
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
|
||||
if note != "" {
|
||||
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
|
||||
}
|
||||
llmArgs = append(llmArgs, args...)
|
||||
}
|
||||
if ktv != "" {
|
||||
llmArgs = append(llmArgs, "-ctk", ktv)
|
||||
@@ -179,9 +362,9 @@ func cmdServe(args []string) error {
|
||||
if k, _ := effectiveAPIKeyErr(); k != "" {
|
||||
llmArgs = append(llmArgs, "--api-key", k)
|
||||
}
|
||||
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
|
||||
// together so a path with spaces stays one argument.
|
||||
llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
|
||||
// EXTRA_ARGS (déjà découpé plus haut comme le ferait le shell — les guillemets
|
||||
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
|
||||
llmArgs = append(llmArgs, extra...)
|
||||
|
||||
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
|
||||
// mmproj-F16.gguf) still resolve.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHasAnyFlag(t *testing.T) {
|
||||
args := []string{"--parallel", "4", "--load-mode=mlock", "-fa"}
|
||||
if !hasAnyFlag(args, "--parallel", "-np") {
|
||||
t.Fatal("--parallel devrait être détecté")
|
||||
}
|
||||
// Forme --clé=valeur : le nom seul compte.
|
||||
if !hasAnyFlag(args, "--load-mode") {
|
||||
t.Fatal("--load-mode=… devrait être détecté")
|
||||
}
|
||||
if hasAnyFlag(args, "-ngl", "--n-gpu-layers") {
|
||||
t.Fatal("-ngl absent, ne devrait pas être détecté")
|
||||
}
|
||||
// Une VALEUR qui ressemble à un drapeau ne doit pas compter comme tel :
|
||||
// ici "-ngl" n'est pas posé, seul --parallel l'est.
|
||||
if hasAnyFlag(nil, "--parallel") {
|
||||
t.Fatal("liste vide : rien à détecter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLoadFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in []string
|
||||
supports bool
|
||||
want []string
|
||||
}{
|
||||
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
|
||||
[]string{"--mlock", "--no-mmap"}},
|
||||
{"mlock traduit", []string{"--mlock", "-fa"}, true,
|
||||
[]string{"-fa", "--load-mode", "mlock"}},
|
||||
{"no-mmap traduit", []string{"--no-mmap"}, true,
|
||||
[]string{"--load-mode", "none"}},
|
||||
{"mmap traduit", []string{"--mmap"}, true,
|
||||
[]string{"--load-mode", "mmap"}},
|
||||
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
|
||||
[]string{"--load-mode", "mlock"}},
|
||||
{"--load-mode explicite gagne, vieux drapeaux retirés",
|
||||
[]string{"--mlock", "--load-mode", "dio"}, true,
|
||||
[]string{"--load-mode", "dio"}},
|
||||
{"rien à traduire", []string{"-fa", "--n-cpu-moe", "8"}, true,
|
||||
[]string{"-fa", "--n-cpu-moe", "8"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := normalizeLoadFlags(c.in, c.supports)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("got %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNGLArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
ngl string
|
||||
fitsItself bool
|
||||
want []string
|
||||
wantNote bool
|
||||
}{
|
||||
// Le cas qui motive tout : defaultConfig() sème NGL=999 sur chaque
|
||||
// installation neuve. Personne ne l'a choisi, et le moteur récent
|
||||
// abandonne son calcul de VRAM dès qu'on lui impose un nombre.
|
||||
{"sentinelle 999, moteur récent", "999", true, []string{"-ngl", "auto"}, true},
|
||||
{"sentinelle 999, moteur ancien", "999", false, []string{"-ngl", "999"}, false},
|
||||
{"nombre choisi, jamais touché", "28", true, []string{"-ngl", "28"}, false},
|
||||
{"nombre choisi, moteur ancien", "28", false, []string{"-ngl", "28"}, false},
|
||||
{"all reste all", "all", true, []string{"-ngl", "all"}, false},
|
||||
{"auto : aucun drapeau", "auto", true, nil, false},
|
||||
{"auto insensible à la casse", "AUTO", true, nil, false},
|
||||
{"espaces parasites autour de la sentinelle", " 999 ", true, []string{"-ngl", "auto"}, true},
|
||||
{"clé absente, moteur récent", "", true, []string{"-ngl", "auto"}, false},
|
||||
{"clé absente, moteur ancien", "", false, []string{"-ngl", "999"}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, note := nglArgs(c.ngl, c.fitsItself)
|
||||
if !reflect.DeepEqual(got, c.want) {
|
||||
t.Fatalf("args = %q, want %q", got, c.want)
|
||||
}
|
||||
if (note != "") != c.wantNote {
|
||||
t.Fatalf("note = %q, en voulait-on une ? %v", note, c.wantNote)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,12 @@ type Conversation struct {
|
||||
Generating bool `json:"-"`
|
||||
cancel context.CancelFunc // annule la génération en cours (/stop)
|
||||
epoch int // incrémenté à chaque reset → invalide les abonnés
|
||||
// Tâche planifiée en cours (tasks_run.go). Le verrou de génération est le
|
||||
// SEUL point partagé entre une tâche de fond et le chat : ces deux champs
|
||||
// disent à l'interface POURQUOI elle est occupée, au lieu de laisser croire
|
||||
// à une génération fantôme dans la discussion ouverte.
|
||||
runningTaskID string
|
||||
runningTaskName string
|
||||
}
|
||||
|
||||
var conv = func() *Conversation {
|
||||
@@ -286,12 +292,42 @@ func (c *Conversation) state() map[string]any {
|
||||
turns++
|
||||
}
|
||||
}
|
||||
return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
|
||||
st := map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
|
||||
// Occupé PAR UNE TÂCHE : l'interface l'annonce (« tâche en cours ») plutôt que
|
||||
// d'afficher un tour qui n'apparaîtra jamais dans le fil.
|
||||
if c.runningTaskID != "" {
|
||||
st["task_id"] = c.runningTaskID
|
||||
st["task_name"] = c.runningTaskName
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// isGenerating dit si un tour est en cours. Sert à la LISTE des discussions :
|
||||
// elle doit pouvoir montrer laquelle travaille dès le chargement de la page,
|
||||
// sans attendre que le rejeu du flux SSE ait rattrapé son retard.
|
||||
func (c *Conversation) isGenerating() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.Generating
|
||||
}
|
||||
|
||||
// ErrBusy : une génération est déjà en cours (un seul tour à la fois).
|
||||
var ErrBusy = fmt.Errorf("génération en cours")
|
||||
|
||||
// busyReason explique le refus à l'utilisateur. Sans ça, un message envoyé
|
||||
// pendant qu'une TÂCHE planifiée tourne renvoyait « génération en cours » alors
|
||||
// que le fil est vide et que rien ne bouge à l'écran — incompréhensible. On
|
||||
// nomme la tâche et on dit comment reprendre la main.
|
||||
func (c *Conversation) busyReason() error {
|
||||
c.mu.Lock()
|
||||
name := c.runningTaskName
|
||||
c.mu.Unlock()
|
||||
if name == "" {
|
||||
return ErrBusy
|
||||
}
|
||||
return fmt.Errorf("la tâche planifiée « %s » occupe le modèle — attends la fin ou arrête-la (bouton stop)", name)
|
||||
}
|
||||
|
||||
// errModelLoading est renvoyée telle quelle à l'utilisateur, dans le chat : ce
|
||||
// n'est pas un défaut mais une attente, et le message doit le dire.
|
||||
var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.")
|
||||
@@ -321,6 +357,15 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "Prends-en connaissance."
|
||||
}
|
||||
// Mode code : fige le rôle du tour (plan demandé → planner, sinon builder).
|
||||
// La détection de bascule (puce « passer en mode Code ? ») est émise après
|
||||
// la bulle utilisateur, plus bas.
|
||||
if caps.Code && caps.Role == "" {
|
||||
caps.Role = "builder"
|
||||
if wantsPlan(text) {
|
||||
caps.Role = "planner"
|
||||
}
|
||||
}
|
||||
// Content = simple texte d'ordinaire ; format multimodal (texte + images) quand
|
||||
// la vision est active et qu'une pièce jointe est une image (userMessageContent).
|
||||
c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)})
|
||||
@@ -331,10 +376,22 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
// si le process meurt en pleine génération (crash, restart après MAJ), le
|
||||
// message de l'utilisateur survit au lieu de disparaître avec le tour.
|
||||
delta := map[string]any{"user": text}
|
||||
// Nom du modèle qui va produire la réponse : journalisé avec la borne de
|
||||
// tour, donc rejoué au chargement — la carte de réponse garde son modèle
|
||||
// après un rafraîchissement, et un vieux tour garde le modèle de l'époque,
|
||||
// pas celui chargé aujourd'hui.
|
||||
if m := chatModelName(); m != "" {
|
||||
delta["model"] = m
|
||||
}
|
||||
if len(files) > 0 {
|
||||
delta["files"] = files
|
||||
}
|
||||
c.appendDelta(epoch, delta)
|
||||
// En mode chat, un message qui ressemble à une tâche de code fait
|
||||
// apparaître la puce « passer en mode Code ? » (une fois par discussion).
|
||||
if !caps.Code {
|
||||
maybeCodeHint(c, epoch, text)
|
||||
}
|
||||
c.persist()
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
@@ -343,6 +400,22 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
return nil
|
||||
}
|
||||
|
||||
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
|
||||
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
|
||||
func chatModelName() string {
|
||||
m := strings.TrimSpace(ReadConfig()["MODEL"])
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.LastIndexAny(m, "/\\"); i >= 0 {
|
||||
m = m[i+1:]
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(m), ".gguf") {
|
||||
m = m[:len(m)-len(".gguf")]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// generate exécute un tour complet et journalise chaque événement. Détaché : la
|
||||
// fermeture du navigateur n'a aucun effet ici, seul /stop (cancel) l'interrompt.
|
||||
// epoch est capturé au StartTurn : si un Reset survient pendant la génération,
|
||||
@@ -400,6 +473,13 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// Non-nil = elle remplace l'historique (elle contient déjà le tour en cours).
|
||||
var newBase []Message
|
||||
var content strings.Builder
|
||||
// Le comptage EXACT du contexte vient de `usage.prompt_tokens` (option
|
||||
// include_usage). Tous les moteurs ne le renvoient pas — un llama-server
|
||||
// récent a cessé de le faire, et la jauge est restée bloquée à zéro sur des
|
||||
// discussions de plusieurs dizaines de milliers de tokens. On note donc si
|
||||
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
|
||||
// déjà la compaction), approximative mais jamais absente.
|
||||
sawUsage := false
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
@@ -419,6 +499,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.NewHistory != nil:
|
||||
// Compaction faite en cours de tour : elle remplace la base au lieu de
|
||||
@@ -439,6 +522,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur
|
||||
// et la décision de compactage au tour suivant.
|
||||
if ev.Stats.PromptTokensTotal > 0 {
|
||||
sawUsage = true
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
@@ -446,6 +530,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||
case ev.Ask != nil:
|
||||
// Question structurée (outil ask) : carte à boutons dans l'UI.
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||
case ev.Reasoning != "":
|
||||
@@ -476,6 +563,32 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
stale := c.epoch != epoch
|
||||
c.mu.Unlock()
|
||||
|
||||
// Moteur muet sur l'usage : on publie l'estimation, sinon la jauge reste à
|
||||
// zéro et l'utilisateur ne voit jamais son contexte se remplir — ni la
|
||||
// compaction arriver.
|
||||
if !stale && !sawUsage {
|
||||
est := estimateTokens(msgs)
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = est
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(epoch, map[string]any{"ctx_used": est})
|
||||
ctxUsed = est
|
||||
}
|
||||
|
||||
// Boucle du contrat (mode code) : vérification indépendante des critères,
|
||||
// corrections, re-vérification — AVANT de rendre la main. Voir
|
||||
// code_verify.go. No-op hors mode code ou sans critères.
|
||||
if !stale && ctx.Err() == nil {
|
||||
c.codeVerifyLoop(ctx, caps, temperature, epoch)
|
||||
c.mu.Lock()
|
||||
msgs = append([]Message(nil), c.Messages...)
|
||||
ctxUsed = c.CtxUsed
|
||||
stale = c.epoch != epoch
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Compaction de FIN DE TOUR. C'était LE trou : le seuil n'était testé qu'au
|
||||
// DÉBUT d'un tour, avec le contexte du tour précédent. Le tour qui fait
|
||||
// franchir le seuil se termine donc à 81% et… rien. La jauge reste haute, seul
|
||||
@@ -754,6 +867,16 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
if !emit(map[string]any{"caught_up": true}) {
|
||||
return
|
||||
}
|
||||
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les
|
||||
// événements du fil, et `ctx_used` n'y figure qu'aux tours où il a changé —
|
||||
// souvent hors de la fenêtre rejouée. Sans cet envoi, toute page rechargée
|
||||
// affichait « 0 / N (0 %) » sur une discussion pourtant bien remplie.
|
||||
c.mu.Lock()
|
||||
used := c.CtxUsed
|
||||
c.mu.Unlock()
|
||||
if used > 0 && !emit(map[string]any{"ctx_used": used}) {
|
||||
return
|
||||
}
|
||||
// Le padding doit venir APRÈS caught_up, pas dessus. Un proxy (Cloudflare) garde
|
||||
// toujours le DERNIER bout de flux en tampon jusqu'au prochain flush (~2-3 s). En
|
||||
// envoyant un gros pad juste après, ce sont ses octets — et non le dernier vrai
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -105,6 +106,11 @@ func screenshotVisionNote() string {
|
||||
// modèle, ou avec un modèle sans vision (gpt-oss), le gabarit sérialise le
|
||||
// base64 de l'image en TEXTE — la requête vue en production pesait 55 000
|
||||
// tokens pour 32 768 de contexte, et tous les tours suivants échouaient.
|
||||
//
|
||||
// La sonde est un appel interne comme les autres : elle DOIT s'authentifier.
|
||||
// Sans l'en-tête Bearer, un moteur protégé par API_KEY répond 401 et la sonde
|
||||
// conclut « pas de vision » — le projecteur était bien chargé, les images ne
|
||||
// partaient jamais et le modèle brodait à partir du seul chemin de fichier.
|
||||
var (
|
||||
visionProbeMu sync.Mutex
|
||||
visionProbeAt time.Time
|
||||
@@ -120,7 +126,12 @@ func engineSeesImages() bool {
|
||||
visionProbeAt = time.Now()
|
||||
visionProbeSeen = false
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/props", LLMPort()))
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/props", LLMPort()), nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
authHeader(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -232,7 +243,7 @@ func safeSlug(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func toolWebScreenshot(args map[string]any) string {
|
||||
func toolWebScreenshot(args map[string]any, caps Caps) string {
|
||||
bin := playwrightBin()
|
||||
if bin == "" {
|
||||
return "[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0)."
|
||||
@@ -245,6 +256,13 @@ func toolWebScreenshot(args map[string]any) string {
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return "[erreur] url invalide : elle doit commencer par http:// ou https://"
|
||||
}
|
||||
// Accès internet coupé : la capture reste offerte au MODE CODE, mais bornée
|
||||
// aux adresses locales — voir sa propre page de dev, pas le web. Sans cette
|
||||
// borne, l'outil rouvrirait par la fenêtre ce que l'interrupteur ferme.
|
||||
if !caps.Internet && !isLocalURL(url) {
|
||||
return "[refusé] L'accès internet est coupé : la capture ne marche que sur une adresse locale " +
|
||||
"(localhost / 127.0.0.1), par exemple ton serveur de dev. Active l'accès internet pour le web."
|
||||
}
|
||||
// width n'est plus déclaré dans le schéma (budget de préambule), mais reste
|
||||
// honoré s'il arrive quand même : un modèle qui l'invente obtient le
|
||||
// comportement attendu plutôt qu'un paramètre ignoré en silence.
|
||||
@@ -387,3 +405,19 @@ func lastLines(s string, n int) string {
|
||||
}
|
||||
return strings.Join(keep, " · ")
|
||||
}
|
||||
|
||||
// isLocalURL : l'adresse désigne-t-elle cette machine ? Sert de borne quand
|
||||
// l'accès internet est coupé mais que le mode code doit pouvoir photographier
|
||||
// son propre serveur de dev.
|
||||
func isLocalURL(raw string) bool {
|
||||
u, err := neturl.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := u.Hostname()
|
||||
switch host {
|
||||
case "localhost", "127.0.0.1", "::1", "0.0.0.0":
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(host, ".localhost")
|
||||
}
|
||||
@@ -232,6 +232,11 @@ func convDelete(id string) error {
|
||||
}
|
||||
convIndexSave(next)
|
||||
_ = putBytes(bkChat, convKey(id), nil)
|
||||
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
|
||||
// elle, et ses critères/mode/puce partent avec ses messages.
|
||||
stopConvJobs(id)
|
||||
critDrop(id)
|
||||
dropConvMode(id)
|
||||
// Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a
|
||||
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
|
||||
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
|
||||
|
||||
@@ -44,6 +44,18 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// « Loki » avec une majuscule : c'est un nom propre, et le modèle recopie
|
||||
// littéralement la casse d'ici quand il se présente (« je suis loki »).
|
||||
b.WriteString("You are Loki, an expert assistant operating directly on this machine with real tools.")
|
||||
// Mode code : le prompt du rôle (builder/planner/verifier…) prend la suite
|
||||
// de l'identité. Court par construction (voir code_roles.go) — la règle
|
||||
// « ne pas regonfler » vaut aussi pour lui.
|
||||
if caps.Code {
|
||||
role := caps.Role
|
||||
if role == "" {
|
||||
role = "builder"
|
||||
}
|
||||
if rp := rolePrompt(role); rp != "" {
|
||||
b.WriteString("\n\n" + rp)
|
||||
}
|
||||
}
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
|
||||
}
|
||||
@@ -149,7 +161,7 @@ func machineSystemPrompt(caps Caps) string {
|
||||
if u, err := user.Current(); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
cwd := convWorkspace()
|
||||
cwd := agentCwd()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
|
||||
@@ -161,7 +173,11 @@ func machineSystemPrompt(caps Caps) string {
|
||||
}
|
||||
b.WriteString(".")
|
||||
if cwd != "" {
|
||||
b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder loki was launched from.")
|
||||
// Formulation reprise de l'amont AJEAN (v0.10.2) : dire ce que le dossier
|
||||
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
|
||||
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
|
||||
// aux « installations » en /usr/local/bin qui échouent faute de root.
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder when the user explicitly named that location.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -195,7 +211,7 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// such file or directory » incompréhensible, et ce jusqu'au redémarrage. On
|
||||
// le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
|
||||
// tout refuser.
|
||||
if ws := convWorkspace(); ws != "" {
|
||||
if ws := agentCwd(); ws != "" {
|
||||
if err := os.MkdirAll(ws, 0o755); err == nil {
|
||||
cmd.Dir = ws
|
||||
}
|
||||
@@ -258,6 +274,10 @@ func fileWrite(path, content string) string {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
@@ -292,16 +312,28 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return "[erreur] old vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
content := string(b)
|
||||
n := strings.Count(content, oldText)
|
||||
// Fichier en CRLF, modèle en LF : la recherche exacte échouait toujours.
|
||||
// On compare alors en LF normalisé, et on réécrit dans le style du fichier
|
||||
// (préservation des fins de ligne — repris des tests line-ending d'OpenFox).
|
||||
crlf := strings.Contains(content, "\r\n")
|
||||
search, oldN, newN := content, oldText, newText
|
||||
if crlf && !strings.Contains(oldText, "\r\n") {
|
||||
search = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
}
|
||||
n := strings.Count(search, oldN)
|
||||
if n == 0 {
|
||||
// Modification déjà en place : on le dit clairement plutôt que de renvoyer
|
||||
// une erreur, sinon le modèle croit avoir échoué et recommence.
|
||||
if newText != "" && strings.Contains(content, newText) {
|
||||
if newN != "" && strings.Contains(search, newN) {
|
||||
return "[ok] déjà à jour — le fichier contient déjà cette modification"
|
||||
}
|
||||
return "[erreur] old introuvable dans le fichier"
|
||||
@@ -309,7 +341,11 @@ func fileEdit(path, oldText, newText string) string {
|
||||
if n > 1 {
|
||||
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
updated := strings.Replace(search, oldN, newN, 1)
|
||||
if search != content {
|
||||
// La comparaison s'est faite en LF : on remet le fichier en CRLF.
|
||||
updated = strings.ReplaceAll(updated, "\n", "\r\n")
|
||||
}
|
||||
// Préserve les permissions d'origine (un script 0755 doit rester exécutable).
|
||||
mode := os.FileMode(0o644)
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Dossier de travail du mode agent.
|
||||
@@ -21,7 +22,8 @@ import (
|
||||
// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif
|
||||
// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour
|
||||
// qu'un fichier appartienne à la discussion où il est né et disparaisse avec
|
||||
// elle. La racine reste la borne de sécurité des téléchargements.
|
||||
// elle — ou celui de la tâche planifiée en cours (agentCwd, plus bas). La racine
|
||||
// reste la borne de sécurité des téléchargements.
|
||||
|
||||
const workspaceEnv = "LOKI_WORKSPACE"
|
||||
|
||||
@@ -73,6 +75,30 @@ func workspaceCandidates() []string {
|
||||
return c
|
||||
}
|
||||
|
||||
// Dossier de travail imposé pendant l'exécution d'une TÂCHE planifiée
|
||||
// (tasks_run.go). Une tâche tourne hors de toute discussion : sans ça, ses
|
||||
// fichiers atterriraient dans le dossier de la discussion qui se trouve ouverte
|
||||
// — et changeraient de place en cours de route si l'utilisateur en changeait.
|
||||
//
|
||||
// La bascule est un état de processus et non de goroutine, ce que Go ne sait pas
|
||||
// faire autrement ; c'est sûr ici parce qu'une SEULE génération tourne à la fois
|
||||
// (le verrou de conv), et parce que seuls les points d'entrée des OUTILS lisent
|
||||
// agentCwd(). Ce que voit l'utilisateur — panneau Fichiers, dépôts, liens des
|
||||
// messages — continue de passer par convWorkspace() et reste donc sur sa
|
||||
// discussion, même pendant qu'une tâche écrit ailleurs.
|
||||
var taskWS atomic.Value // string
|
||||
|
||||
func setTaskWorkspace(dir string) { taskWS.Store(dir) }
|
||||
|
||||
// agentCwd = le dossier où l'agent travaille MAINTENANT : celui de la tâche en
|
||||
// cours s'il y en a une, sinon celui de la discussion ouverte.
|
||||
func agentCwd() string {
|
||||
if d, _ := taskWS.Load().(string); d != "" {
|
||||
return d
|
||||
}
|
||||
return convWorkspace()
|
||||
}
|
||||
|
||||
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
|
||||
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
|
||||
// discussion ouverte.
|
||||
@@ -89,5 +115,5 @@ func resolveAgentPath(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(convWorkspace(), filepath.FromSlash(p))
|
||||
return filepath.Join(agentCwd(), filepath.FromSlash(p))
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package loki
|
||||
|
||||
// code_criteria.go — critères d'acceptation du mode code (contrat d'exécution,
|
||||
// repris du session-metadata/criteria d'OpenFox, réécrit en Go — voir
|
||||
// NOTICE.md). Les critères sont posés au début d'une tâche (par le modèle via
|
||||
// l'outil `criteria`, ou à la main dans l'UI), puis la passe de vérification
|
||||
// (code_verify.go) les fait passer à `passed` ou `failed`. Le tour de build ne
|
||||
// se termine « vraiment » que quand tout est passed — c'est le contrat.
|
||||
//
|
||||
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
|
||||
// la discussion, sa suppression les emporte.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Criterion struct {
|
||||
ID int `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"` // pending | passed | failed
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
const critMaxCount = 20
|
||||
|
||||
func critKey(convID string) string { return "crit:" + convID }
|
||||
|
||||
func critList(convID string) []Criterion {
|
||||
var list []Criterion
|
||||
if b := getBytes(bkChat, critKey(convID)); len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &list)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func critSave(convID string, list []Criterion) {
|
||||
if len(list) == 0 {
|
||||
_ = putBytes(bkChat, critKey(convID), nil) // nil supprime la clé
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, critKey(convID), b)
|
||||
}
|
||||
|
||||
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
|
||||
|
||||
// critAllPassed : le contrat est rempli. Une liste vide ne compte pas comme
|
||||
// remplie — il n'y a simplement pas de contrat.
|
||||
func critAllPassed(list []Criterion) bool {
|
||||
if len(list) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range list {
|
||||
if c.Status != "passed" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func critPending(list []Criterion) int {
|
||||
n := 0
|
||||
for _, c := range list {
|
||||
if c.Status != "passed" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// critRender : la liste formatée pour un prompt (builder ou verifier).
|
||||
func critRender(list []Criterion) string {
|
||||
var b strings.Builder
|
||||
for _, c := range list {
|
||||
mark := "[ ]"
|
||||
switch c.Status {
|
||||
case "passed":
|
||||
mark = "[✓]"
|
||||
case "failed":
|
||||
mark = "[✗]"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
|
||||
if c.Note != "" {
|
||||
fmt.Fprintf(&b, " — %s", c.Note)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func criteriaTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "criteria",
|
||||
Description: "Manage the acceptance criteria of the current task (the contract that defines DONE). " +
|
||||
"action=add posits new criteria (texts[]), action=set updates one (id + status passed|failed|pending, optional note), " +
|
||||
"action=list shows them, action=clear removes them all. Set criteria BEFORE building; only the verification pass may mark passed.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
|
||||
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
|
||||
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
|
||||
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
|
||||
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolCriteria exécute l'outil. allowPass : seul le passage de VÉRIFICATION a
|
||||
// le droit de marquer `passed` (sinon le builder s'auto-valide et le contrat ne
|
||||
// contraint plus rien).
|
||||
func toolCriteria(args map[string]any, allowPass bool) string {
|
||||
convID := convEnsureActive()
|
||||
action, _ := args["action"].(string)
|
||||
list := critList(convID)
|
||||
switch action {
|
||||
case "list", "":
|
||||
if len(list) == 0 {
|
||||
return "[aucun critère]"
|
||||
}
|
||||
return critRender(list)
|
||||
case "add":
|
||||
raw, _ := args["texts"].([]any)
|
||||
next := 1
|
||||
for _, c := range list {
|
||||
if c.ID >= next {
|
||||
next = c.ID + 1
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
for _, r := range raw {
|
||||
s, ok := r.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
if len(list) >= critMaxCount {
|
||||
break
|
||||
}
|
||||
list = append(list, Criterion{ID: next, Text: strings.TrimSpace(s), Status: "pending"})
|
||||
next++
|
||||
added++
|
||||
}
|
||||
if added == 0 {
|
||||
return "[erreur] aucun critère fourni (texts)"
|
||||
}
|
||||
critSave(convID, list)
|
||||
critNotify(list)
|
||||
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
|
||||
case "set":
|
||||
idf, _ := args["id"].(float64)
|
||||
id := int(idf)
|
||||
status, _ := args["status"].(string)
|
||||
note, _ := args["note"].(string)
|
||||
if status == "passed" && !allowPass {
|
||||
return "[refusé] Seule la passe de vérification peut marquer un critère passed. Termine ton implémentation ; la vérification suivra."
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].ID != id {
|
||||
continue
|
||||
}
|
||||
if status != "" {
|
||||
list[i].Status = status
|
||||
}
|
||||
if note != "" {
|
||||
list[i].Note = strings.TrimSpace(note)
|
||||
}
|
||||
critSave(convID, list)
|
||||
critNotify(list)
|
||||
return "[ok] critère #" + fmt.Sprint(id) + " → " + list[i].Status
|
||||
}
|
||||
return fmt.Sprintf("[erreur] critère #%d inconnu", id)
|
||||
case "clear":
|
||||
critDrop(convID)
|
||||
critNotify(nil)
|
||||
return "[ok] critères effacés"
|
||||
}
|
||||
return "[erreur] action inconnue : " + action
|
||||
}
|
||||
|
||||
// critNotify pousse la liste à l'UI (panneau critères) via le journal de la
|
||||
// conversation. Best-effort : hors génération il n'y a pas d'epoch en cours,
|
||||
// on publie sur l'epoch courant.
|
||||
func critNotify(list []Criterion) {
|
||||
if conv == nil {
|
||||
return
|
||||
}
|
||||
conv.mu.Lock()
|
||||
epoch := conv.epoch
|
||||
conv.mu.Unlock()
|
||||
if list == nil {
|
||||
list = []Criterion{}
|
||||
}
|
||||
conv.appendDelta(epoch, map[string]any{"criteria": list, "ts": time.Now().UnixMilli()})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cycle de vie des critères : add → set → clear, avec la garde allowPass.
|
||||
func TestCriteresCycleDeVie(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
|
||||
out := toolCriteria(map[string]any{"action": "add", "texts": []any{"le test passe", "le build compile"}}, false)
|
||||
if !strings.Contains(out, "2 critère(s)") {
|
||||
t.Fatalf("ajout raté : %s", out)
|
||||
}
|
||||
list := critList(id)
|
||||
if len(list) != 2 || list[0].ID != 1 || list[1].ID != 2 {
|
||||
t.Fatalf("liste inattendue : %+v", list)
|
||||
}
|
||||
// Le builder (allowPass=false) ne peut PAS s'auto-valider.
|
||||
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, false)
|
||||
if !strings.Contains(out, "[refusé]") {
|
||||
t.Fatalf("auto-validation acceptée : %s", out)
|
||||
}
|
||||
// Le vérificateur (allowPass=true), si.
|
||||
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, true)
|
||||
if !strings.Contains(out, "passed") || strings.Contains(out, "[refusé]") {
|
||||
t.Fatalf("validation vérificateur refusée : %s", out)
|
||||
}
|
||||
// failed reste ouvert à tous (le builder peut constater un échec).
|
||||
out = toolCriteria(map[string]any{"action": "set", "id": float64(2), "status": "failed", "note": "le build casse"}, false)
|
||||
if !strings.Contains(out, "failed") {
|
||||
t.Fatalf("échec non enregistré : %s", out)
|
||||
}
|
||||
list = critList(id)
|
||||
if critAllPassed(list) {
|
||||
t.Fatal("allPassed avec un failed")
|
||||
}
|
||||
if critPending(list) != 1 {
|
||||
t.Fatalf("pending = %d, attendu 1", critPending(list))
|
||||
}
|
||||
// Rendu : les statuts sont visibles.
|
||||
r := critRender(list)
|
||||
if !strings.Contains(r, "[✓] #1") || !strings.Contains(r, "[✗] #2") || !strings.Contains(r, "le build casse") {
|
||||
t.Fatalf("rendu incomplet :\n%s", r)
|
||||
}
|
||||
toolCriteria(map[string]any{"action": "clear"}, false)
|
||||
if len(critList(id)) != 0 {
|
||||
t.Fatal("clear sans effet")
|
||||
}
|
||||
}
|
||||
|
||||
// Une liste vide n'est pas un contrat rempli.
|
||||
func TestContratVide(t *testing.T) {
|
||||
if critAllPassed(nil) {
|
||||
t.Fatal("liste vide considérée comme remplie")
|
||||
}
|
||||
}
|
||||
|
||||
// Le plafond de critères tient.
|
||||
func TestCriteresPlafond(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
var texts []any
|
||||
for i := 0; i < critMaxCount+10; i++ {
|
||||
texts = append(texts, "critère")
|
||||
}
|
||||
toolCriteria(map[string]any{"action": "add", "texts": texts}, false)
|
||||
if n := len(critList(id)); n != critMaxCount {
|
||||
t.Fatalf("%d critères, plafond %d", n, critMaxCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package loki
|
||||
|
||||
// code_git.go — outils git natifs du mode code : git_status, git_diff,
|
||||
// git_clone. Le modèle les avait via bash, mais des outils dédiés donnent une
|
||||
// sortie bornée, un libellé propre dans le fil, et un clone qui atterrit
|
||||
// TOUJOURS dans le dossier de la discussion (jamais ailleurs sur le disque).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const gitMaxOutput = 8000
|
||||
|
||||
func gitStatusTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "git_status",
|
||||
Description: "git status of the working folder (branch + changed files, porcelain format).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func gitDiffTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "git_diff",
|
||||
Description: "git diff of the working folder (unstaged + staged). Optional file to diff a single path.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Limit the diff to this path"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func gitCloneTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "git_clone",
|
||||
Description: "Clone a git repository (https:// or git@) INTO the working folder of this discussion.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "Repository URL"},
|
||||
"dir": map[string]any{"type": "string", "description": "Target folder name (default: repo name)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runGit exécute git dans le dossier de la discussion, sortie bornée.
|
||||
func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return "[erreur] git n'est pas installé sur cette machine"
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "git", args...)
|
||||
cmd.Dir = agentCwd()
|
||||
// Jamais d'invite interactive (mot de passe, hôte inconnu) : un process
|
||||
// serveur n'a personne pour y répondre, il faut échouer vite et le dire.
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes")
|
||||
out, err := cmd.CombinedOutput()
|
||||
s := tailRunes(strings.TrimSpace(string(out)), gitMaxOutput)
|
||||
if cctx.Err() == context.DeadlineExceeded {
|
||||
return "[timeout] git " + strings.Join(args, " ")
|
||||
}
|
||||
if err != nil {
|
||||
if s == "" {
|
||||
s = err.Error()
|
||||
}
|
||||
return "[erreur] " + s
|
||||
}
|
||||
if s == "" {
|
||||
s = "(aucune sortie)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func toolGitStatus(ctx context.Context) string {
|
||||
return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch")
|
||||
}
|
||||
|
||||
func toolGitDiff(ctx context.Context, args map[string]any) string {
|
||||
// Un seul appel qui montre TOUT ce qui a changé : index + arbre de travail.
|
||||
// HEAD peut ne pas exister (dépôt tout neuf) : on retombe sur le diff simple.
|
||||
gitArgs := []string{"diff", "HEAD"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
gitArgs = append(gitArgs, "--", file)
|
||||
}
|
||||
out := runGit(ctx, 30*time.Second, gitArgs...)
|
||||
if strings.HasPrefix(out, "[erreur]") && strings.Contains(out, "HEAD") {
|
||||
fallback := []string{"diff"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
fallback = append(fallback, "--", file)
|
||||
}
|
||||
out = runGit(ctx, 30*time.Second, fallback...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gitURLRe : https:// ou git@hôte: — pas de chemins locaux ni de protocoles
|
||||
// exotiques (file://, ext:: qui exécute une commande arbitraire).
|
||||
var gitURLRe = regexp.MustCompile(`^(https://[\w.\-]+(:\d+)?/|git@[\w.\-]+:)[\w.\-~/]+$`)
|
||||
|
||||
func toolGitClone(ctx context.Context, args map[string]any) string {
|
||||
url, _ := args["url"].(string)
|
||||
url = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(url), ".git")) + ".git"
|
||||
if !gitURLRe.MatchString(strings.TrimSuffix(url, ".git")) && !gitURLRe.MatchString(url) {
|
||||
return "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés"
|
||||
}
|
||||
dir, _ := args["dir"].(string)
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
base := filepath.Base(strings.TrimSuffix(url, ".git"))
|
||||
dir = base
|
||||
}
|
||||
// Le clone atterrit DANS la discussion, quoi qu'il arrive.
|
||||
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
|
||||
return "[erreur] dossier cible invalide"
|
||||
}
|
||||
target := filepath.Join(agentCwd(), dir)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "[erreur] le dossier " + dir + " existe déjà"
|
||||
}
|
||||
out := runGit(ctx, 5*time.Minute, "clone", "--", url, target)
|
||||
if strings.HasPrefix(out, "[erreur]") || strings.HasPrefix(out, "[timeout]") {
|
||||
return out
|
||||
}
|
||||
return fmt.Sprintf("[ok] cloné dans %s/\n%s", dir, out)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package loki
|
||||
|
||||
// code_jobs.go — commandes shell d'ARRIÈRE-PLAN pour le mode code : bash_bg
|
||||
// lance un process détaché (serveur de dev, watcher, build long) et rend la
|
||||
// main tout de suite ; bash_tail relit sa sortie et son état. Sans ça, le
|
||||
// modèle lance `./serveur &` dans bash et le tour reste suspendu aux tubes
|
||||
// (voir le commentaire WaitDelay de runShell).
|
||||
//
|
||||
// La sortie va dans un fichier du dossier de la discussion
|
||||
// (.loki/jobs/<id>.log) : elle survit au tour, et le panneau Fichiers la montre.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bgJob struct {
|
||||
ID string
|
||||
Command string
|
||||
LogPath string
|
||||
Cmd *exec.Cmd
|
||||
Started time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
done bool
|
||||
exit int
|
||||
}
|
||||
|
||||
var (
|
||||
jobsMu sync.Mutex
|
||||
jobs = map[string]*bgJob{}
|
||||
jobSeq int
|
||||
)
|
||||
|
||||
func bashBgTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "bash_bg",
|
||||
Description: "Start a shell command in the BACKGROUND (dev server, watcher, long build) and return immediately with a job id. Output goes to a log file; check it with bash_tail. Use this instead of appending '&' in bash.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{"type": "string", "description": "The command"},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func bashTailTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "bash_tail",
|
||||
Description: "Read the last lines of a background job's output and its status (running/exited). kill:true stops the job.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "Job id from bash_bg"},
|
||||
"lines": map[string]any{"type": "integer", "description": "Lines to show (default 40, max 200)"},
|
||||
"kill": map[string]any{"type": "boolean", "description": "Stop the job"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolBashBg lance la commande détachée. Même politique que bash (commandes
|
||||
// dangereuses refusées), même dossier de départ (discussion courante).
|
||||
func toolBashBg(args map[string]any) string {
|
||||
command, _ := args["command"].(string)
|
||||
if strings.TrimSpace(command) == "" {
|
||||
return "[erreur] commande vide"
|
||||
}
|
||||
if reason := dangerousCommand(command); reason != "" {
|
||||
return refusedCommandResult(reason)
|
||||
}
|
||||
ws := agentCwd()
|
||||
logDir := filepath.Join(ws, ".loki", "jobs")
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
jobsMu.Lock()
|
||||
jobSeq++
|
||||
id := fmt.Sprintf("job%d", jobSeq)
|
||||
jobsMu.Unlock()
|
||||
logPath := filepath.Join(logDir, id+".log")
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
// Contexte de FOND, pas celui du tour : le job doit survivre à la fin du
|
||||
// tour — c'est sa raison d'être. Il s'arrête par bash_tail kill:true, ou
|
||||
// avec le conteneur.
|
||||
cmd := newShellCmdDetached(command)
|
||||
cmd.Dir = ws
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
j := &bgJob{ID: id, Command: command, LogPath: logPath, Cmd: cmd, Started: time.Now()}
|
||||
jobsMu.Lock()
|
||||
jobs[id] = j
|
||||
jobsMu.Unlock()
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
logFile.Close()
|
||||
j.mu.Lock()
|
||||
j.done = true
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
j.exit = ee.ExitCode()
|
||||
}
|
||||
j.mu.Unlock()
|
||||
}()
|
||||
return fmt.Sprintf("[ok] job %s lancé (pid %d) — sortie dans .loki/jobs/%s.log, suis-la avec bash_tail", id, cmd.Process.Pid, id)
|
||||
}
|
||||
|
||||
// toolBashTail relit la fin du journal d'un job et son état.
|
||||
func toolBashTail(args map[string]any) string {
|
||||
id, _ := args["id"].(string)
|
||||
jobsMu.Lock()
|
||||
j := jobs[id]
|
||||
jobsMu.Unlock()
|
||||
if j == nil {
|
||||
return "[erreur] job inconnu : " + id
|
||||
}
|
||||
if kill, _ := args["kill"].(bool); kill {
|
||||
j.mu.Lock()
|
||||
running := !j.done
|
||||
j.mu.Unlock()
|
||||
if running && j.Cmd.Process != nil {
|
||||
_ = j.Cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
lines := 40
|
||||
if v, ok := args["lines"].(float64); ok && int(v) > 0 {
|
||||
lines = int(v)
|
||||
}
|
||||
if lines > 200 {
|
||||
lines = 200
|
||||
}
|
||||
b, err := os.ReadFile(j.LogPath)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
all := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
||||
if len(all) > lines {
|
||||
all = all[len(all)-lines:]
|
||||
}
|
||||
j.mu.Lock()
|
||||
status := "en cours"
|
||||
if j.done {
|
||||
status = fmt.Sprintf("terminé (exit %d)", j.exit)
|
||||
}
|
||||
j.mu.Unlock()
|
||||
out := fmt.Sprintf("job %s — %s — depuis %s\ncommande : %s\n", j.ID, status, time.Since(j.Started).Round(time.Second), j.Command)
|
||||
tail := strings.Join(all, "\n")
|
||||
if strings.TrimSpace(tail) == "" {
|
||||
tail = "(aucune sortie pour l'instant)"
|
||||
}
|
||||
return out + "\n" + tailRunes(tail, toolMaxOutput)
|
||||
}
|
||||
|
||||
// stopConvJobs tue les jobs d'une discussion (suppression / vidage de la
|
||||
// discussion : ses fichiers partent, ses process aussi).
|
||||
func stopConvJobs(convID string) {
|
||||
prefix := filepath.Join(agentWorkspace(), convFilesRoot, safeConvID(convID))
|
||||
jobsMu.Lock()
|
||||
defer jobsMu.Unlock()
|
||||
for id, j := range jobs {
|
||||
if strings.HasPrefix(j.LogPath, prefix) {
|
||||
j.mu.Lock()
|
||||
running := !j.done
|
||||
j.mu.Unlock()
|
||||
if running && j.Cmd.Process != nil {
|
||||
_ = j.Cmd.Process.Kill()
|
||||
}
|
||||
delete(jobs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package loki
|
||||
|
||||
// code_mode.go — le mode Chat/Code d'une discussion. Bascule EXPLICITE
|
||||
// (sélecteur dans le pied du composeur), persistée par discussion ; en mode
|
||||
// chat, une détection côté serveur peut SUGGÉRER la bascule (puce ignorable
|
||||
// dans l'UI) mais ne l'impose jamais — un faux positif coûterait des tours de
|
||||
// planification/vérification sur un simple bonjour.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func modeKey(convID string) string { return "mode:" + convID }
|
||||
func hintKey(convID string) string { return "hinted:" + convID }
|
||||
|
||||
// convCodeMode : la discussion active est-elle en mode code ?
|
||||
func convCodeMode() bool { return getStr(bkChat, modeKey(convEnsureActive())) == "code" }
|
||||
|
||||
func setConvMode(convID, mode string) error {
|
||||
if mode != "code" {
|
||||
mode = "" // chat = défaut = clé absente
|
||||
}
|
||||
return putStr(bkChat, modeKey(convID), mode)
|
||||
}
|
||||
|
||||
// dropConvMode : appelé à la suppression d'une discussion.
|
||||
func dropConvMode(convID string) {
|
||||
_ = putStr(bkChat, modeKey(convID), "")
|
||||
_ = putStr(bkChat, hintKey(convID), "")
|
||||
}
|
||||
|
||||
// --- Détection « ça ressemble à du code » ------------------------------------
|
||||
// Côté serveur, AUCUN token : ni consigne dans le prompt, ni appel modèle.
|
||||
// Un indice suffit ; la puce de l'UI est gratuite à ignorer.
|
||||
|
||||
var codeHintRes = []*regexp.Regexp{
|
||||
// Un bloc de code clôturé.
|
||||
regexp.MustCompile("```"),
|
||||
// Un chemin de fichier source.
|
||||
regexp.MustCompile(`(?i)\b[\w./\\-]+\.(go|py|js|jsx|ts|tsx|rs|java|kt|c|h|cpp|hpp|cs|rb|php|swift|sh|ps1|sql|html|css|scss|vue|svelte|toml|yaml|yml|json|dockerfile|makefile)\b`),
|
||||
// Verbes / vocabulaire de tâche de code.
|
||||
regexp.MustCompile(`(?i)\b(corrige|implémente|implemente|refactor|refactorise|débogue|debug|compile|stack ?trace|segfault|exception|traceback|unit ?test|pull ?request|merge|commit|clone)\b`),
|
||||
regexp.MustCompile(`(?i)\b(bug|erreur de compilation|tests? (échoue|cassé)|fonction|classe|module|api rest|endpoint)\b`),
|
||||
}
|
||||
|
||||
// looksLikeCode dit si un message utilisateur ressemble à une tâche de code.
|
||||
// Le dépôt git déjà cloné dans la discussion est aussi un indice fort.
|
||||
func looksLikeCode(text string) bool {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return false
|
||||
}
|
||||
for _, re := range codeHintRes {
|
||||
if re.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// convHasRepo : un dépôt git vit déjà dans le dossier de la discussion.
|
||||
func convHasRepo() bool {
|
||||
ws := agentCwd()
|
||||
if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
entries, err := os.ReadDir(ws)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ws, e.Name(), ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y
|
||||
// prête. Une seule fois par discussion : une suggestion répétée est du bruit.
|
||||
func maybeCodeHint(c *Conversation, epoch int, text string) {
|
||||
if convCodeMode() || !agentEnabled() {
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
if getBool(bkChat, hintKey(id)) {
|
||||
return
|
||||
}
|
||||
if !looksLikeCode(text) && !convHasRepo() {
|
||||
return
|
||||
}
|
||||
_ = putBool(bkChat, hintKey(id), true)
|
||||
c.appendDelta(epoch, map[string]any{"code_hint": true})
|
||||
}
|
||||
|
||||
// --- API ---------------------------------------------------------------------
|
||||
|
||||
// handleChatMode — GET : {mode, criteria} de la discussion active ;
|
||||
// POST {"mode":"code"|"chat"} : bascule (et pousse le nouvel état aux abonnés).
|
||||
func handleChatMode(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var body struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Mode != "code" && body.Mode != "chat" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu (chat|code)"})
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
if err := setConvMode(id, body.Mode); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Pousse la bascule à tous les appareils abonnés (le sélecteur doit
|
||||
// suivre, comme le fil lui-même).
|
||||
conv.mu.Lock()
|
||||
epoch := conv.epoch
|
||||
conv.mu.Unlock()
|
||||
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode})
|
||||
return
|
||||
}
|
||||
mode := "chat"
|
||||
if convCodeMode() {
|
||||
mode = "code"
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"mode": mode,
|
||||
"criteria": critList(convEnsureActive()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleChatCriteria — POST : édition manuelle des critères depuis l'UI.
|
||||
// {"criteria":[{"id":1,"text":"…","status":"pending"},…]} remplace la liste.
|
||||
func handleChatCriteria(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Criteria []Criterion `json:"criteria"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
var clean []Criterion
|
||||
next := 1
|
||||
for _, c := range body.Criteria {
|
||||
if strings.TrimSpace(c.Text) == "" || len(clean) >= critMaxCount {
|
||||
continue
|
||||
}
|
||||
if c.ID <= 0 {
|
||||
c.ID = next
|
||||
}
|
||||
if c.ID >= next {
|
||||
next = c.ID + 1
|
||||
}
|
||||
switch c.Status {
|
||||
case "pending", "passed", "failed":
|
||||
default:
|
||||
c.Status = "pending"
|
||||
}
|
||||
clean = append(clean, c)
|
||||
}
|
||||
critSave(convEnsureActive(), clean)
|
||||
critNotify(clean)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "criteria": clean})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// La bascule est persistée par discussion et retombe sur chat par défaut.
|
||||
func TestModeParDiscussion(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
if convCodeMode() {
|
||||
t.Fatal("mode code par défaut")
|
||||
}
|
||||
if err := setConvMode(id, "code"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !convCodeMode() {
|
||||
t.Fatal("bascule sans effet")
|
||||
}
|
||||
dropConvMode(id)
|
||||
if convCodeMode() {
|
||||
t.Fatal("mode code après nettoyage")
|
||||
}
|
||||
}
|
||||
|
||||
// La détection reconnaît une tâche de code, pas la conversation ordinaire.
|
||||
func TestDetectionCode(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"corrige le bug dans main.go",
|
||||
"j'ai une stack trace : Exception in thread",
|
||||
"```python\nprint('x')\n```",
|
||||
"implémente la fonction de tri",
|
||||
"le build casse avec une erreur de compilation",
|
||||
} {
|
||||
if !looksLikeCode(s) {
|
||||
t.Errorf("non détecté : %q", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"quelle est la capitale de l'Australie ?",
|
||||
"raconte-moi une histoire",
|
||||
"quel temps fait-il demain ?",
|
||||
"",
|
||||
} {
|
||||
if looksLikeCode(s) {
|
||||
t.Errorf("détecté à tort : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les rôles embarqués existent tous, et le prompt du builder est court —
|
||||
// la règle « ne pas regonfler » de baseSystemPrompt s'applique aussi ici.
|
||||
func TestRolesEmbarques(t *testing.T) {
|
||||
for _, r := range roleNames {
|
||||
p := rolePrompt(r)
|
||||
if p == "" {
|
||||
t.Errorf("rôle %s : prompt vide", r)
|
||||
}
|
||||
if len(p) > 2500 {
|
||||
t.Errorf("rôle %s : prompt trop long (%d octets)", r, len(p))
|
||||
}
|
||||
}
|
||||
if rolePrompt("inexistant") != "" {
|
||||
t.Error("rôle inconnu devrait rendre vide")
|
||||
}
|
||||
}
|
||||
|
||||
// wantsPlan : demande de plan reconnue, implémentation directe non.
|
||||
func TestWantsPlan(t *testing.T) {
|
||||
if !wantsPlan("Fais un plan avant de coder") {
|
||||
t.Error("plan non reconnu")
|
||||
}
|
||||
if wantsPlan("corrige le bug maintenant") {
|
||||
t.Error("plan détecté à tort")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package loki
|
||||
|
||||
// code_policy.go — politique d'exécution des outils de l'agent (repris dans
|
||||
// l'esprit de tool-policy/path-security d'OpenFox, réécrit en Go — voir
|
||||
// NOTICE.md).
|
||||
//
|
||||
// Deux gardes indépendantes :
|
||||
// - dangerousCommand : liste courte de commandes CATASTROPHIQUES (effacement
|
||||
// de disque, arrêt machine, fork bomb). Toujours active, mode chat compris :
|
||||
// aucune de ces commandes n'a d'usage légitime lancée par un modèle. Le
|
||||
// refus explique quoi faire (l'utilisateur la lance lui-même).
|
||||
// - codePathAllowed : en MODE CODE, tous les chemins des outils fichiers sont
|
||||
// bornés au dossier de la discussion — liens symboliques résolus des deux
|
||||
// côtés, comme le panneau Fichiers (relWithin). Le mode chat garde le
|
||||
// comportement historique (chemins absolus honorés) : c'est le mode code
|
||||
// qui promet un bac à sable, pas l'agent généraliste.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// dangerousPatterns : motifs (insensibles à la casse) de commandes refusées.
|
||||
// Volontairement ÉTROIT : viser la perte de données irréversible et l'arrêt de
|
||||
// la machine, pas la moindre écriture. Un motif trop large rend l'agent
|
||||
// inutilisable et pousse à tout désactiver.
|
||||
var dangerousPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
reason string
|
||||
}{
|
||||
// rm -rf / (ou ~, ou une racine de lecteur) — pas un rm -rf d'un sous-dossier.
|
||||
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s*$`), "efface la racine du disque ou le home"},
|
||||
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s`), "efface la racine du disque ou le home"},
|
||||
// Écriture directe sur un périphérique bloc / formatage.
|
||||
{regexp.MustCompile(`(?i)\bmkfs(\.[a-z0-9]+)?\b`), "formate un système de fichiers"},
|
||||
{regexp.MustCompile(`(?i)\bdd\b[^\n]*\bof=/dev/`), "écrit directement sur un périphérique disque"},
|
||||
{regexp.MustCompile(`(?i)>\s*/dev/sd[a-z]\b`), "écrit directement sur un périphérique disque"},
|
||||
// Arrêt / redémarrage de la machine (ici : le conteneur, donc tout Loki).
|
||||
// Ancré en position de COMMANDE (début, après ; && | ou sudo) : le mot
|
||||
// « reboot » dans un grep ou un texte n'est pas une commande.
|
||||
{regexp.MustCompile(`(?i)(^|[;&|]\s*|\bsudo\s+)(shutdown|poweroff|reboot|halt)\b`), "arrête ou redémarre la machine"},
|
||||
// Fork bomb classique.
|
||||
{regexp.MustCompile(`:\(\)\s*\{\s*:\|:`), "fork bomb"},
|
||||
// chmod/chown récursif sur la racine.
|
||||
{regexp.MustCompile(`(?i)\bch(mod|own)\b[^\n]*-[a-z]*R[a-z]*\s+[^\s]+\s+/\s*$`), "change récursivement les droits de la racine"},
|
||||
// Windows : suppression récursive de la racine d'un lecteur, format.
|
||||
{regexp.MustCompile(`(?i)\b(rd|rmdir)\s+/s\b[^\n]*\s[a-z]:\\?\s*$`), "efface la racine d'un lecteur"},
|
||||
{regexp.MustCompile(`(?i)\bdel\s+(/[fsq]\s+)*[a-z]:\\\*`), "efface la racine d'un lecteur"},
|
||||
{regexp.MustCompile(`(?i)\bformat\s+[a-z]:`), "formate un lecteur"},
|
||||
}
|
||||
|
||||
// dangerousCommand renvoie la raison du refus si la commande correspond à un
|
||||
// motif interdit, "" sinon.
|
||||
func dangerousCommand(cmd string) string {
|
||||
for _, p := range dangerousPatterns {
|
||||
if p.re.MatchString(cmd) {
|
||||
return p.reason
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// refusedCommandResult formate le refus renvoyé au modèle : dire POURQUOI et
|
||||
// QUOI FAIRE, sinon il réessaie en boucle avec des variantes.
|
||||
func refusedCommandResult(reason string) string {
|
||||
return "[refusé] Commande bloquée par la politique de sécurité : " + reason + ". " +
|
||||
"Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal."
|
||||
}
|
||||
|
||||
// codePathAllowed dit si un chemin (déjà résolu par resolveAgentPath) est DANS
|
||||
// le dossier de la discussion courante. Utilisé par les outils fichiers en mode
|
||||
// code uniquement.
|
||||
func codePathAllowed(abs string) bool {
|
||||
ws := agentCwd()
|
||||
if ws == "" {
|
||||
return false
|
||||
}
|
||||
abs = filepath.Clean(abs)
|
||||
// Le fichier peut ne pas exister encore (write) : EvalSymlinks échouerait.
|
||||
// On teste alors son ancêtre le plus proche existant — c'est LUI qui doit
|
||||
// être dans le workspace, liens symboliques résolus.
|
||||
probe := abs
|
||||
for {
|
||||
if _, err := os.Stat(probe); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(probe)
|
||||
if parent == probe {
|
||||
break
|
||||
}
|
||||
probe = parent
|
||||
}
|
||||
if !sameOrWithin(ws, probe) {
|
||||
return false
|
||||
}
|
||||
// L'ancêtre est dans le workspace ; le chemin FINAL ne doit pas en
|
||||
// ressortir par des ".." lexicaux non encore résolus.
|
||||
rel, err := filepath.Rel(ws, abs)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sameOrWithin : p est root lui-même, ou dedans — liens symboliques résolus
|
||||
// des deux côtés (relWithin refuse « . », d'où ce cousin qui l'accepte : la
|
||||
// racine du workspace est un chemin légitime pour grep/glob).
|
||||
func sameOrWithin(root, p string) bool {
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = r
|
||||
}
|
||||
if q, err := filepath.EvalSymlinks(p); err == nil {
|
||||
p = q
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
// refusedPathResult : refus de chemin hors du bac à sable du mode code.
|
||||
func refusedPathResult(path string) string {
|
||||
return "[refusé] " + path + " est hors du dossier de cette discussion. " +
|
||||
"En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
}
|
||||
|
||||
// codeWriteGuard regroupe les gardes du mode code AVANT une écriture locale
|
||||
// (write/edit) : borne de chemin, puis tracker « lu avant d'écrire ».
|
||||
// Renvoie "" si tout est bon, sinon le message de refus pour le modèle.
|
||||
// En mode chat, aucune garde : comportement historique de l'agent.
|
||||
func codeWriteGuard(caps Caps, file string, forWrite bool) string {
|
||||
if !caps.Code {
|
||||
return ""
|
||||
}
|
||||
path := resolveAgentPath(file)
|
||||
if !codePathAllowed(path) {
|
||||
return refusedPathResult(file)
|
||||
}
|
||||
return trackerCheck(path, forWrite)
|
||||
}
|
||||
|
||||
// --- Mutex par fichier -------------------------------------------------------
|
||||
// Sérialise les écritures concurrentes sur un même fichier (write/edit). Sans
|
||||
// parallélisme d'outils aujourd'hui le coût est nul, mais le jour où deux tours
|
||||
// ou un job d'arrière-plan écrivent le même fichier, la corruption est évitée.
|
||||
|
||||
var fileMuMap sync.Map // clé : chemin nettoyé → *sync.Mutex
|
||||
|
||||
func fileMu(path string) *sync.Mutex {
|
||||
key := filepath.Clean(path)
|
||||
if m, ok := fileMuMap.Load(key); ok {
|
||||
return m.(*sync.Mutex)
|
||||
}
|
||||
m, _ := fileMuMap.LoadOrStore(key, &sync.Mutex{})
|
||||
return m.(*sync.Mutex)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La politique doit bloquer les commandes catastrophiques…
|
||||
func TestCommandesDangereusesBloquees(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"rm -rf /",
|
||||
"rm -fr / --no-preserve-root",
|
||||
"sudo rm -rf /*",
|
||||
"mkfs.ext4 /dev/sda1",
|
||||
"dd if=/dev/zero of=/dev/sda",
|
||||
"echo pwned > /dev/sda",
|
||||
"shutdown -h now",
|
||||
"reboot",
|
||||
":(){ :|:& };:",
|
||||
"format c:",
|
||||
"rd /s /q C:\\",
|
||||
} {
|
||||
if dangerousCommand(cmd) == "" {
|
||||
t.Errorf("aurait dû être bloquée : %q", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// … et laisser passer le travail normal, y compris les rm ciblés.
|
||||
func TestCommandesNormalesAutorisees(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"ls -la",
|
||||
"go test ./...",
|
||||
"rm -rf node_modules",
|
||||
"rm -rf ./dist",
|
||||
"git rm --cached fichier.txt",
|
||||
"grep -r 'reboot the router' docs/",
|
||||
"npm run build",
|
||||
"dd if=disque.img of=copie.img",
|
||||
"echo bonjour > notes.txt",
|
||||
} {
|
||||
if r := dangerousCommand(cmd); r != "" {
|
||||
t.Errorf("bloquée à tort (%s) : %q", r, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// En mode code, les chemins sont bornés au dossier de la discussion :
|
||||
// dedans OK, dehors refusé, y compris par remontée de ../.
|
||||
func TestCodePathBorne(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
inside := filepath.Join(ws, "src", "main.go")
|
||||
if !codePathAllowed(inside) {
|
||||
t.Error("chemin intérieur refusé")
|
||||
}
|
||||
if !codePathAllowed(filepath.Join(ws, "nouveau.txt")) {
|
||||
t.Error("fichier à créer (inexistant) refusé")
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(ws), "ailleurs.txt")
|
||||
if codePathAllowed(outside) {
|
||||
t.Error("chemin extérieur accepté")
|
||||
}
|
||||
if codePathAllowed(filepath.Join(ws, "..", "..", "etc", "passwd")) {
|
||||
t.Error("remontée ../ acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// codeWriteGuard : inactif en mode chat, actif en mode code (borne + tracker).
|
||||
func TestCodeWriteGuard(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
chat := Caps{Agent: true}
|
||||
code := Caps{Agent: true, Code: true}
|
||||
|
||||
// Mode chat : aucune garde, même hors workspace.
|
||||
if msg := codeWriteGuard(chat, "/tmp/hors-discussion.txt", true); msg != "" {
|
||||
t.Errorf("mode chat gardé à tort : %s", msg)
|
||||
}
|
||||
// Mode code : hors workspace refusé.
|
||||
if msg := codeWriteGuard(code, filepath.Join(filepath.Dir(ws), "x.txt"), true); msg == "" {
|
||||
t.Error("écriture hors workspace acceptée en mode code")
|
||||
}
|
||||
// Fichier existant jamais lu : write refusé (tracker).
|
||||
target := filepath.Join(ws, "present.txt")
|
||||
if err := os.WriteFile(target, []byte("v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := codeWriteGuard(code, "present.txt", true); !strings.Contains(msg, "pas lu") {
|
||||
t.Errorf("écrasement sans lecture accepté : %q", msg)
|
||||
}
|
||||
// Après lecture : autorisé.
|
||||
trackerNoteRead(target)
|
||||
if msg := codeWriteGuard(code, "present.txt", true); msg != "" {
|
||||
t.Errorf("refusé après lecture : %s", msg)
|
||||
}
|
||||
// Création d'un fichier neuf : pas de lecture préalable exigée.
|
||||
if msg := codeWriteGuard(code, "neuf.txt", true); msg != "" {
|
||||
t.Errorf("création refusée : %s", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package loki
|
||||
|
||||
// code_retry.go — filets « auto-retry » (repris des auto-retry patterns
|
||||
// d'OpenFox, réécrits en Go — voir NOTICE.md) : quand le modèle termine son
|
||||
// tour sur un texte qui contient un APPEL D'OUTIL TEXTUEL (halluciné, donc
|
||||
// jamais exécuté), on relance le tour UNE fois avec une consigne corrective au
|
||||
// lieu de laisser l'utilisateur lire un pseudo-JSON d'appel d'outil.
|
||||
//
|
||||
// Typique des petits modèles très quantifiés : `default_api:bash`,
|
||||
// `<tool_call>{...}</tool_call>`, un bloc ```tool_code```… Le modèle VOULAIT
|
||||
// agir ; il a juste raté la syntaxe du protocole. Le nudge « pensé sans agir »
|
||||
// existant ne les attrapait pas : le tour a bien produit du contenu.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
|
||||
// serrés — un faux positif relancerait un tour parfaitement bon.
|
||||
var defaultRetryPatterns = []string{
|
||||
`<tool_call>`,
|
||||
`</tool_call>`,
|
||||
`<\|tool_call\|>`,
|
||||
"```tool_code",
|
||||
"```tool_call",
|
||||
`\bdefault_api[.:]\w+`,
|
||||
`\bfunctions\.\w+\s*\(`,
|
||||
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
|
||||
`\[TOOL_REQUEST\]`,
|
||||
}
|
||||
|
||||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
||||
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
|
||||
// choisi son rôle (CLI, serveur…).
|
||||
var (
|
||||
retryOnce sync.Once
|
||||
retryRes []*regexp.Regexp
|
||||
)
|
||||
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
retryOnce.Do(func() { retryRes = compileRetryPatterns() })
|
||||
return retryRes
|
||||
}
|
||||
|
||||
func compileRetryPatterns() []*regexp.Regexp {
|
||||
pats := defaultRetryPatterns
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
|
||||
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
|
||||
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
|
||||
if raw := getStr(bkState, "retry_patterns"); raw != "" {
|
||||
var custom []string
|
||||
if json.Unmarshal([]byte(raw), &custom) == nil && len(custom) > 0 {
|
||||
pats = custom
|
||||
}
|
||||
}
|
||||
var out []*regexp.Regexp
|
||||
for _, p := range pats {
|
||||
if re, err := regexp.Compile("(?mi)" + p); err == nil {
|
||||
out = append(out, re)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
||||
// écrit en toutes lettres au lieu d'être émis par le protocole.
|
||||
func textualToolCall(content string) bool {
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
for _, re := range retryPatterns() {
|
||||
if re.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// retryCorrective : le message réinjecté pour relancer le tour.
|
||||
const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " +
|
||||
"Tool calls must go through the tool-call protocol, never in the answer text. " +
|
||||
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
||||
@@ -0,0 +1,34 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Les appels d'outils écrits en texte doivent être détectés…
|
||||
func TestAppelOutilTextuelDetecte(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"<tool_call>{\"name\":\"bash\"}</tool_call>",
|
||||
"Je vais lancer default_api:bash pour vérifier.",
|
||||
"default_api.run_shell(command='ls')",
|
||||
"functions.bash({\"command\": \"ls\"})",
|
||||
"```tool_code\nprint('x')\n```",
|
||||
"{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}",
|
||||
} {
|
||||
if !textualToolCall(s) {
|
||||
t.Errorf("non détecté : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// … sans jamais relancer un tour normal (code cité, JSON quelconque).
|
||||
func TestReponseNormaleNonRelancee(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"Voici la réponse : le fichier contient trois fonctions.",
|
||||
"```go\nfunc main() {}\n```",
|
||||
"Le JSON de config : {\"name\": \"loki\", \"port\": 8090}",
|
||||
"La fonction bash() de ce script accepte un paramètre.",
|
||||
"",
|
||||
} {
|
||||
if textualToolCall(s) {
|
||||
t.Errorf("relance à tort : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package loki
|
||||
|
||||
// code_roles.go — registre des rôles du mode code. Chaque rôle est un prompt
|
||||
// Markdown EMBARQUÉ dans le binaire (agents/*.md), comme le catalogue MCP :
|
||||
// pas d'appel réseau, pour en changer on édite le fichier et on recompile.
|
||||
// Démarche reprise du registre d'agents d'OpenFox (builder/planner/explorer/
|
||||
// verifier/code-reviewer — voir NOTICE.md), prompts réécrits COURTS : un
|
||||
// préambule verbeux fait sur-raisonner les modèles locaux (voir le commentaire
|
||||
// de baseSystemPrompt).
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed agents/*.md
|
||||
var rolesFS embed.FS
|
||||
|
||||
// roleNames : les rôles connus. builder et verifier sont pilotés par la boucle
|
||||
// du mode code (code_verify.go) ; planner s'active quand l'utilisateur demande
|
||||
// un plan ; explorer et code-reviewer sont disponibles pour des passes
|
||||
// manuelles futures.
|
||||
var roleNames = []string{"builder", "planner", "explorer", "verifier", "code-reviewer"}
|
||||
|
||||
// rolePrompt renvoie le prompt d'un rôle, "" si inconnu.
|
||||
func rolePrompt(name string) string {
|
||||
b, err := rolesFS.ReadFile("agents/" + name + ".md")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// planIntentRe-like : l'utilisateur demande-t-il un PLAN plutôt qu'une
|
||||
// implémentation ? Heuristique simple sur le début du message.
|
||||
func wantsPlan(text string) bool {
|
||||
low := strings.ToLower(text)
|
||||
for _, kw := range []string{"fais un plan", "fait un plan", "propose un plan", "planifie", "plan d'implémentation", "plan d'action", "make a plan", "plan first"} {
|
||||
if strings.Contains(low, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package loki
|
||||
|
||||
// code_tools.go — outils fichiers du MODE CODE : read, grep, glob, ask.
|
||||
// Inspirés des outils d'OpenFox (read/grep/ask — voir NOTICE.md), réécrits en
|
||||
// Go. Sans eux, le modèle lit les fichiers au `cat` via bash : sortie non
|
||||
// bornée, pas de numéros de ligne, et aucun moyen de savoir ce qu'il a lu
|
||||
// (voir code_tracker.go).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
readMaxLines = 1000 // lignes par appel (offset/limit pour la suite)
|
||||
readDefLines = 400
|
||||
readMaxLineLen = 500 // une ligne minifiée ne doit pas manger le contexte
|
||||
grepMaxHits = 60
|
||||
globMaxHits = 200
|
||||
)
|
||||
|
||||
// skipDirs : dossiers ignorés par grep/glob — dépendances et artefacts, jamais
|
||||
// du code à lire. `.git` compris : son contenu binaire pollue toute recherche.
|
||||
var skipDirs = map[string]bool{
|
||||
".git": true, "node_modules": true, "vendor": true, "dist": true,
|
||||
"build": true, "target": true, "__pycache__": true, ".venv": true,
|
||||
"venv": true, ".next": true, ".cache": true,
|
||||
}
|
||||
|
||||
func readTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "read",
|
||||
Description: "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Path (relative to the working folder)"},
|
||||
"offset": map[string]any{"type": "integer", "description": "Start line (default 1)"},
|
||||
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Lines (default %d, max %d)", readDefLines, readMaxLines)},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func grepTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "grep",
|
||||
Description: "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{"type": "string", "description": "Go regexp"},
|
||||
"path": map[string]any{"type": "string", "description": "File or directory to search (default .)"},
|
||||
"glob": map[string]any{"type": "string", "description": "Only files matching this glob (e.g. *.go)"},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func globTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "glob",
|
||||
Description: "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{"type": "string", "description": "Glob, e.g. src/**/*.ts"},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func askTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "ask",
|
||||
Description: "Ask the user a clarifying question with 2-4 short options, then END your turn. Their answer arrives as the next user message. Use when a decision is genuinely theirs (scope, destructive change, ambiguous requirement) — not for things you can check yourself.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"question": map[string]any{"type": "string", "description": "The question"},
|
||||
"options": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "2-4 short answer options"},
|
||||
},
|
||||
"required": []string{"question"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolRead lit un fichier avec numéros de ligne. Enregistre la lecture dans le
|
||||
// tracker (code_tracker.go) — c'est ce qui autorise edit/write ensuite.
|
||||
func toolRead(args map[string]any, codeMode bool) string {
|
||||
file, _ := args["file"].(string)
|
||||
if strings.TrimSpace(file) == "" {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path := resolveAgentPath(file)
|
||||
if codeMode && !codePathAllowed(path) {
|
||||
return refusedPathResult(file)
|
||||
}
|
||||
offset, limit := 1, readDefLines
|
||||
if v, ok := args["offset"].(float64); ok && int(v) > 0 {
|
||||
offset = int(v)
|
||||
}
|
||||
if v, ok := args["limit"].(float64); ok && int(v) > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
if limit > readMaxLines {
|
||||
limit = readMaxLines
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if isBinary(b) {
|
||||
fi, _ := os.Stat(path)
|
||||
size := int64(len(b))
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
return fmt.Sprintf("[binaire] %s (%d octets) — pas un fichier texte", file, size)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
total := len(lines)
|
||||
// Fichier fini par \n : Split laisse une dernière entrée vide, pas une ligne.
|
||||
if total > 0 && lines[total-1] == "" {
|
||||
total--
|
||||
}
|
||||
if offset > total {
|
||||
return fmt.Sprintf("[erreur] offset %d > %d lignes", offset, total)
|
||||
}
|
||||
end := offset - 1 + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
var out strings.Builder
|
||||
for i := offset - 1; i < end; i++ {
|
||||
l := lines[i]
|
||||
if utf8.RuneCountInString(l) > readMaxLineLen {
|
||||
r := []rune(l)
|
||||
l = string(r[:readMaxLineLen]) + "…"
|
||||
}
|
||||
fmt.Fprintf(&out, "%d: %s\n", i+1, l)
|
||||
}
|
||||
if end < total {
|
||||
fmt.Fprintf(&out, "… (%d lignes restantes — relis avec offset=%d)\n", total-end, end+1)
|
||||
}
|
||||
trackerNoteRead(path)
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// toolGrep cherche un motif regexp dans les fichiers.
|
||||
func toolGrep(args map[string]any, codeMode bool) string {
|
||||
pattern, _ := args["pattern"].(string)
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return "[erreur] motif vide"
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return "[erreur] motif invalide : " + err.Error()
|
||||
}
|
||||
root := agentCwd()
|
||||
if p, _ := args["path"].(string); strings.TrimSpace(p) != "" && p != "." {
|
||||
root = resolveAgentPath(p)
|
||||
}
|
||||
if codeMode && !codePathAllowed(root) {
|
||||
return refusedPathResult(root)
|
||||
}
|
||||
globPat, _ := args["glob"].(string)
|
||||
var hits []string
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // dossier illisible : on continue ailleurs
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if globPat != "" {
|
||||
if ok, _ := filepath.Match(globPat, d.Name()); !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if len(hits) >= grepMaxHits {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
b, rerr := os.ReadFile(path)
|
||||
if rerr != nil || isBinary(b) {
|
||||
return nil
|
||||
}
|
||||
rel, rrr := filepath.Rel(root, path)
|
||||
if rrr != nil {
|
||||
rel = path
|
||||
}
|
||||
for i, line := range strings.Split(string(b), "\n") {
|
||||
if re.MatchString(line) {
|
||||
if utf8.RuneCountInString(line) > readMaxLineLen {
|
||||
r := []rune(line)
|
||||
line = string(r[:readMaxLineLen]) + "…"
|
||||
}
|
||||
hits = append(hits, fmt.Sprintf("%s:%d: %s", filepath.ToSlash(rel), i+1, strings.TrimSpace(line)))
|
||||
if len(hits) >= grepMaxHits {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil && len(hits) == 0 {
|
||||
return "[erreur] " + walkErr.Error()
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return "[aucun résultat]"
|
||||
}
|
||||
out := strings.Join(hits, "\n")
|
||||
if len(hits) >= grepMaxHits {
|
||||
out += fmt.Sprintf("\n… (plafond de %d résultats atteint — affine le motif)", grepMaxHits)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolGlob liste les fichiers correspondant à un motif, plus récents d'abord.
|
||||
func toolGlob(args map[string]any, codeMode bool) string {
|
||||
pattern, _ := args["pattern"].(string)
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return "[erreur] motif vide"
|
||||
}
|
||||
root := agentCwd()
|
||||
re, err := globToRegexp(pattern)
|
||||
if err != nil {
|
||||
return "[erreur] motif invalide : " + err.Error()
|
||||
}
|
||||
type hit struct {
|
||||
rel string
|
||||
mod int64
|
||||
}
|
||||
var hits []hit
|
||||
filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, path)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !re.MatchString(rel) {
|
||||
return nil
|
||||
}
|
||||
info, ierr := d.Info()
|
||||
mod := int64(0)
|
||||
if ierr == nil {
|
||||
mod = info.ModTime().UnixNano()
|
||||
}
|
||||
hits = append(hits, hit{rel, mod})
|
||||
return nil
|
||||
})
|
||||
if len(hits) == 0 {
|
||||
return "[aucun fichier]"
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].mod > hits[j].mod })
|
||||
if len(hits) > globMaxHits {
|
||||
hits = hits[:globMaxHits]
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, h := range hits {
|
||||
out.WriteString(h.rel + "\n")
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// globToRegexp convertit un glob (avec **) en expression régulière sur des
|
||||
// chemins en séparateurs '/'. `**` = n'importe quelle profondeur, `*` = tout
|
||||
// sauf '/', `?` = un caractère sauf '/'.
|
||||
func globToRegexp(pattern string) (*regexp.Regexp, error) {
|
||||
pattern = strings.TrimPrefix(filepath.ToSlash(pattern), "./")
|
||||
var b strings.Builder
|
||||
b.WriteString("^")
|
||||
i := 0
|
||||
for i < len(pattern) {
|
||||
c := pattern[i]
|
||||
switch c {
|
||||
case '*':
|
||||
if strings.HasPrefix(pattern[i:], "**/") {
|
||||
b.WriteString(`(?:[^/]+/)*`)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(pattern[i:], "**") {
|
||||
b.WriteString(`.*`)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
b.WriteString(`[^/]*`)
|
||||
case '?':
|
||||
b.WriteString(`[^/]`)
|
||||
case '.', '+', '(', ')', '|', '[', ']', '{', '}', '^', '$', '\\':
|
||||
b.WriteString(`\` + string(c))
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
i++
|
||||
}
|
||||
b.WriteString("$")
|
||||
return regexp.Compile(b.String())
|
||||
}
|
||||
|
||||
// isBinary : heuristique simple — un NUL dans les premiers 8 Kio.
|
||||
func isBinary(b []byte) bool {
|
||||
n := len(b)
|
||||
if n > 8192 {
|
||||
n = 8192
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if b[i] == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolAsk formate la « réponse » de l'outil ask : la question part vers l'UI
|
||||
// via un événement dédié (voir runChat) ; ici on dit au modèle de clore son
|
||||
// tour et d'attendre.
|
||||
func toolAsk(args map[string]any) string {
|
||||
q, _ := args["question"].(string)
|
||||
if strings.TrimSpace(q) == "" {
|
||||
return "[erreur] question vide"
|
||||
}
|
||||
return "[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message."
|
||||
}
|
||||
|
||||
// askOptions extrait les options (chaînes) des arguments de l'outil ask.
|
||||
func askOptions(args map[string]any) []string {
|
||||
raw, _ := args["options"].([]any)
|
||||
var opts []string
|
||||
for _, o := range raw {
|
||||
if s, ok := o.(string); ok && strings.TrimSpace(s) != "" {
|
||||
opts = append(opts, s)
|
||||
}
|
||||
}
|
||||
if len(opts) > 4 {
|
||||
opts = opts[:4]
|
||||
}
|
||||
return opts
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// read numérote depuis 1, respecte offset/limit et annonce le reste.
|
||||
func TestReadOffsetLimit(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
var lines []string
|
||||
for i := 1; i <= 10; i++ {
|
||||
lines = append(lines, "ligne")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ws, "f.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := toolRead(map[string]any{"file": "f.txt", "offset": float64(3), "limit": float64(2)}, true)
|
||||
if !strings.Contains(out, "3: ligne") || !strings.Contains(out, "4: ligne") {
|
||||
t.Fatalf("lignes 3-4 attendues :\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "5: ligne") {
|
||||
t.Fatalf("limite ignorée :\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "restantes") {
|
||||
t.Fatalf("suite non annoncée :\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// read refuse un binaire et le dit, au lieu de déverser du bruit.
|
||||
func TestReadBinaire(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
if err := os.WriteFile(filepath.Join(ws, "bin.dat"), []byte{0x00, 0x01, 0x02}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := toolRead(map[string]any{"file": "bin.dat"}, true); !strings.Contains(out, "[binaire]") {
|
||||
t.Fatalf("binaire non détecté : %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// read enregistre la lecture : le tracker doit ensuite autoriser l'écriture.
|
||||
func TestReadNourritLeTracker(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
path := filepath.Join(ws, "suivi.txt")
|
||||
if err := os.WriteFile(path, []byte("contenu"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := trackerCheck(path, true); msg == "" {
|
||||
t.Fatal("écriture acceptée avant lecture")
|
||||
}
|
||||
toolRead(map[string]any{"file": "suivi.txt"}, true)
|
||||
if msg := trackerCheck(path, true); msg != "" {
|
||||
t.Fatalf("écriture refusée après lecture : %s", msg)
|
||||
}
|
||||
// Fichier modifié APRÈS la lecture : refus (lecture périmée).
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(path, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := trackerCheck(path, true); !strings.Contains(msg, "changé") {
|
||||
t.Fatalf("lecture périmée non détectée : %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// grep trouve, borne et ignore les dossiers de dépendances.
|
||||
func TestGrep(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "a.go"), "package main\nfunc Cible() {}\n")
|
||||
writeFile(t, filepath.Join(ws, "node_modules", "x.js"), "Cible aussi\n")
|
||||
out := toolGrep(map[string]any{"pattern": "Cible"}, true)
|
||||
if !strings.Contains(out, "a.go:2") {
|
||||
t.Fatalf("correspondance manquée :\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "node_modules") {
|
||||
t.Fatalf("node_modules aurait dû être ignoré :\n%s", out)
|
||||
}
|
||||
if out := toolGrep(map[string]any{"pattern": "IntrouvableXYZ"}, true); !strings.Contains(out, "aucun résultat") {
|
||||
t.Fatalf("absence non signalée : %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// glob : ** traverse les dossiers, tri par modification décroissante.
|
||||
func TestGlob(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "src", "a.ts"), "a")
|
||||
writeFile(t, filepath.Join(ws, "src", "sub", "b.ts"), "b")
|
||||
writeFile(t, filepath.Join(ws, "autre.txt"), "c")
|
||||
out := toolGlob(map[string]any{"pattern": "**/*.ts"}, true)
|
||||
if !strings.Contains(out, "src/a.ts") || !strings.Contains(out, "src/sub/b.ts") {
|
||||
t.Fatalf("fichiers .ts manquants :\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "autre.txt") {
|
||||
t.Fatalf("autre.txt ne matche pas **/*.ts :\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// globToRegexp : cas aux limites du **.
|
||||
func TestGlobToRegexp(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern, path string
|
||||
want bool
|
||||
}{
|
||||
{"*.go", "main.go", true},
|
||||
{"*.go", "src/main.go", false},
|
||||
{"**/*.go", "src/deep/main.go", true},
|
||||
{"**/*.go", "main.go", true}, // **/ = zéro dossier ou plus
|
||||
{"src/**", "src/a/b/c.txt", true},
|
||||
{"src/*.ts", "src/a.ts", true},
|
||||
{"src/*.ts", "src/sub/a.ts", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
re, err := globToRegexp(c.pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("%s : %v", c.pattern, err)
|
||||
}
|
||||
if got := re.MatchString(c.path); got != c.want {
|
||||
t.Errorf("%s sur %s : %v, attendu %v", c.pattern, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fileEdit préserve les fins de ligne CRLF d'un fichier quand le modèle
|
||||
// envoie du LF (et n'introduit jamais de mélange).
|
||||
func TestEditPreserveCRLF(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
path := filepath.Join(ws, "w.txt")
|
||||
if err := os.WriteFile(path, []byte("un\r\ndeux\r\ntrois\r\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := fileEdit(path, "deux", "DEUX")
|
||||
if !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("édition refusée : %s", out)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if string(b) != "un\r\nDEUX\r\ntrois\r\n" {
|
||||
t.Fatalf("fins de ligne perdues : %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// fileEdit multi-lignes en LF sur un fichier CRLF : la normalisation couvre
|
||||
// aussi le old qui contient des sauts de ligne.
|
||||
func TestEditCRLFMultiligne(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
path := filepath.Join(ws, "m.txt")
|
||||
if err := os.WriteFile(path, []byte("a\r\nb\r\nc\r\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := fileEdit(path, "a\nb", "a\nB")
|
||||
if !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("édition refusée : %s", out)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if string(b) != "a\r\nB\r\nc\r\n" {
|
||||
t.Fatalf("résultat inattendu : %q", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package loki
|
||||
|
||||
// code_tracker.go — suivi « lu avant d'écrire » (file-tracker d'OpenFox, réécrit
|
||||
// en Go — voir NOTICE.md). Le modèle doit avoir LU un fichier (outil read) avant
|
||||
// de le modifier (edit) ou de l'écraser (write sur un fichier existant), et sa
|
||||
// lecture doit être plus récente que la dernière modification du fichier sur le
|
||||
// disque. Ça bloque les deux corruptions silencieuses classiques :
|
||||
// - réécrire un fichier jamais ouvert (le modèle « devine » son contenu) ;
|
||||
// - éditer d'après une lecture périmée (le fichier a changé entre-temps —
|
||||
// autre outil, job d'arrière-plan, utilisateur).
|
||||
//
|
||||
// L'état est en mémoire, par discussion : un redémarrage oublie tout, et le
|
||||
// modèle relit — c'est le comportement voulu, pas un bug.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
trackerMu sync.Mutex
|
||||
trackerReads = map[string]time.Time{} // conv + chemin → instant de la lecture
|
||||
)
|
||||
|
||||
func trackerKey(path string) string {
|
||||
return convEnsureActive() + "\x00" + filepath.Clean(path)
|
||||
}
|
||||
|
||||
// trackerNoteRead enregistre qu'un fichier vient d'être lu (outil read).
|
||||
func trackerNoteRead(path string) {
|
||||
trackerMu.Lock()
|
||||
trackerReads[trackerKey(path)] = time.Now()
|
||||
trackerMu.Unlock()
|
||||
}
|
||||
|
||||
// trackerNoteWrite : après une écriture réussie, la version sur disque est celle
|
||||
// que le modèle vient de produire — il la « connaît ». Sans cette note, éditer
|
||||
// un fichier qu'on vient soi-même d'écrire serait refusé.
|
||||
func trackerNoteWrite(path string) {
|
||||
trackerNoteRead(path)
|
||||
}
|
||||
|
||||
// trackerCheck vérifie qu'une modification est sûre. forWrite distingue write
|
||||
// (autorisé sur un fichier INEXISTANT sans lecture préalable — création) de
|
||||
// edit (le fichier doit exister ET avoir été lu).
|
||||
// Renvoie "" si c'est bon, sinon le message de refus pour le modèle.
|
||||
func trackerCheck(path string, forWrite bool) string {
|
||||
fi, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
if forWrite {
|
||||
return "" // création d'un fichier neuf : rien à relire
|
||||
}
|
||||
return "" // edit sur fichier absent : fileEdit renverra sa propre erreur claire
|
||||
}
|
||||
trackerMu.Lock()
|
||||
readAt, seen := trackerReads[trackerKey(path)]
|
||||
trackerMu.Unlock()
|
||||
if !seen {
|
||||
return "[refusé] " + path + " existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
|
||||
}
|
||||
if fi.ModTime().After(readAt) {
|
||||
return "[refusé] " + path + " a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package loki
|
||||
|
||||
// code_verify.go — la boucle du contrat en mode code : build → VÉRIFICATION →
|
||||
// correction → re-vérification, jusqu'à ce que tous les critères passent ou
|
||||
// que le plafond de cycles soit atteint. Reprise de la boucle
|
||||
// builder/verifier d'OpenFox (voir NOTICE.md), adaptée au fil unique de Loki :
|
||||
//
|
||||
// - la passe de vérification tourne sur un CONTEXTE ISOLÉ (prompt du rôle +
|
||||
// critères + tâche), pas sur l'historique complet — même modèle, mais il
|
||||
// n'a pas « écrit » le code sous les yeux, et surtout ça ne gonfle pas le
|
||||
// contexte de la discussion ;
|
||||
// - seule cette passe a le droit de marquer un critère `passed`
|
||||
// (toolCriteria, allowPass) ;
|
||||
// - les corrections, elles, repartent dans l'historique NORMAL du fil : le
|
||||
// builder doit se souvenir de ce qu'il a fait.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// codeMaxFixTurns : nombre de tours de CORRECTION après échec de vérification.
|
||||
// Au-delà, on s'arrête et on dit ce qui manque — un modèle qui n'y arrive pas
|
||||
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
|
||||
const codeMaxFixTurns = 2
|
||||
|
||||
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
|
||||
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
|
||||
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
convID := convEnsureActive()
|
||||
list := critList(convID)
|
||||
if len(list) == 0 || critAllPassed(list) {
|
||||
return
|
||||
}
|
||||
// En phase de PLAN, on ne vérifie pas : rien n'a été construit.
|
||||
if caps.Role == "planner" {
|
||||
return
|
||||
}
|
||||
for fix := 0; ; fix++ {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.runVerifyPass(ctx, caps, temperature, epoch)
|
||||
list = critList(convID)
|
||||
if critAllPassed(list) {
|
||||
c.appendDelta(epoch, map[string]any{"verify_done": "passed"})
|
||||
return
|
||||
}
|
||||
if fix >= codeMaxFixTurns {
|
||||
c.appendDelta(epoch, map[string]any{"verify_done": "gave_up"})
|
||||
// Le fil doit dire où on en est — sinon l'utilisateur voit une
|
||||
// vérification échouée et aucun mot dessus.
|
||||
c.appendDelta(epoch, map[string]any{"content": "\n\n_(vérification : " +
|
||||
fmt.Sprint(critPending(list)) + " critère(s) non satisfaits après " +
|
||||
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
|
||||
return
|
||||
}
|
||||
c.runFixTurn(ctx, caps, temperature, epoch, list)
|
||||
}
|
||||
}
|
||||
|
||||
// runVerifyPass lance la passe de vérification sur un contexte ISOLÉ.
|
||||
func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
vcaps := caps
|
||||
vcaps.Role = "verifier"
|
||||
// Marqueur de rôle pour l'UI : les bulles qui suivent portent le badge.
|
||||
c.appendDelta(epoch, map[string]any{"role": "verifier"})
|
||||
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
||||
|
||||
convID := convEnsureActive()
|
||||
list := critList(convID)
|
||||
task := c.lastUserText()
|
||||
var user strings.Builder
|
||||
user.WriteString("Task:\n" + task + "\n\nAcceptance criteria:\n" + critRender(list))
|
||||
user.WriteString("\n\nVerify each non-passed criterion now (git_diff first), and record every verdict with the criteria tool.")
|
||||
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt("verifier") + "\n\n" + machineSystemPrompt(vcaps)},
|
||||
{Role: "user", Content: user.String()},
|
||||
}
|
||||
// Trace ISOLÉE : ni persistée dans c.Messages, ni compactée — elle vit et
|
||||
// meurt dans cette passe. Seuls les deltas d'affichage sortent.
|
||||
_, _ = runChat(ctx, msgs, temperature, vcaps, func(ev StreamEvent) bool {
|
||||
c.forwardStream(ev, epoch)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
|
||||
// critères en échec. Sa trace est persistée comme un tour ordinaire.
|
||||
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
|
||||
var failed []Criterion
|
||||
for _, cr := range list {
|
||||
if cr.Status != "passed" {
|
||||
failed = append(failed, cr)
|
||||
}
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"role": "builder"})
|
||||
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
||||
|
||||
fixMsg := Message{Role: "user", Content: "Verification failed on these criteria:\n" + critRender(failed) +
|
||||
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed."}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.epoch != epoch {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.Messages = append(c.Messages, fixMsg)
|
||||
msgs := append([]Message(nil), c.Messages...)
|
||||
c.mu.Unlock()
|
||||
|
||||
final := msgs
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
|
||||
}
|
||||
var content strings.Builder
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
content.WriteString(ev.Content)
|
||||
}
|
||||
c.forwardStream(ev, epoch)
|
||||
return true
|
||||
})
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.Messages = append(c.Messages, extra...)
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
}
|
||||
|
||||
// forwardStream relaie les événements d'un runChat secondaire (vérification,
|
||||
// correction) vers le journal d'affichage — même mapping que generate(), sans
|
||||
// la gestion de compaction (ces passes n'en déclenchent pas : contexte court).
|
||||
func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||
case ev.ToolUsed != nil:
|
||||
tu := map[string]any{
|
||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||
}
|
||||
if ev.ToolUsed.Body != "" {
|
||||
tu["body"] = ev.ToolUsed.Body
|
||||
}
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.Ask != nil:
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
case ev.Stats != nil:
|
||||
if ev.Stats.PromptTokensTotal > 0 {
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||
case ev.Reasoning != "":
|
||||
c.appendDelta(epoch, map[string]any{"reasoning_content": ev.Reasoning})
|
||||
case ev.Content != "":
|
||||
c.appendDelta(epoch, map[string]any{"content": ev.Content})
|
||||
}
|
||||
}
|
||||
|
||||
// lastUserText retrouve le dernier message utilisateur du fil (la tâche).
|
||||
func (c *Conversation) lastUserText() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for i := len(c.Messages) - 1; i >= 0; i-- {
|
||||
if c.Messages[i].Role != "user" {
|
||||
continue
|
||||
}
|
||||
if s, ok := c.Messages[i].Content.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return "(tâche non retrouvée — vérifie d'après le diff)"
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Réglages de la dictée vocale — modèle, langue, matériel, réactivité.
|
||||
//
|
||||
// Ils vivent dans le magasin clé/valeur (bkState), comme le jeton Hugging
|
||||
// Face : ce sont des réglages de SERVEUR, pas de navigateur. Le modèle chargé
|
||||
// et le GPU occupé sont des propriétés de la machine, identiques pour tous les
|
||||
// onglets ouverts.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
const dictateCfgKey = "dictate_cfg"
|
||||
|
||||
// DictateCfg : Device vaut "cpu" ou un indice de GPU en décimal ("0", "1"),
|
||||
// dans l'ordre de nvidia-smi — le même que celui affiché par /api/vram.
|
||||
type DictateCfg struct {
|
||||
Model string `json:"model"`
|
||||
Lang string `json:"lang"`
|
||||
Device string `json:"device"`
|
||||
Reactivity string `json:"reactivity"`
|
||||
}
|
||||
|
||||
// withDefauts comble les champs vides. Appliqué à la LECTURE plutôt qu'à
|
||||
// l'écriture : une version future qui ajoute un champ trouvera les réglages
|
||||
// déjà enregistrés sans champ correspondant, et doit pouvoir le combler.
|
||||
func (c DictateCfg) withDefauts() DictateCfg {
|
||||
if c.Model == "" {
|
||||
// large-v3-turbo : meilleur rapport qualité/vitesse en français à ce
|
||||
// jour. Le défaut penche vers la qualité.
|
||||
c.Model = "large-v3-turbo-q5_0"
|
||||
}
|
||||
if c.Lang == "" {
|
||||
// Pas "auto" : sur des tranches de quelques secondes la détection se
|
||||
// trompe, et une langue mal détectée produit du charabia — des suites
|
||||
// de caractères géorgiens ont été observées en production.
|
||||
c.Lang = "fr"
|
||||
}
|
||||
if c.Device == "" {
|
||||
c.Device = "cpu"
|
||||
}
|
||||
if c.Reactivity == "" {
|
||||
c.Reactivity = "moyen"
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func dictateCfgLoad() DictateCfg {
|
||||
var c DictateCfg
|
||||
getJSON(bkState, dictateCfgKey, &c)
|
||||
return c.withDefauts()
|
||||
}
|
||||
|
||||
func dictateCfgSave(c DictateCfg) error {
|
||||
c = c.withDefauts()
|
||||
// Valider ICI plutôt qu'au démarrage du serveur : un modèle hors catalogue
|
||||
// accepté en silence ne se manifesterait qu'au premier clic sur le micro,
|
||||
// loin du geste qui l'a causé.
|
||||
if _, ok := whisperCatalogue[c.Model]; !ok {
|
||||
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return putJSON(bkState, dictateCfgKey, c)
|
||||
}
|
||||
|
||||
// cudaVisibleDevices traduit le réglage en valeur de CUDA_VISIBLE_DEVICES.
|
||||
// Vide = aucun GPU visible = whisper tourne sur le CPU. Toute valeur qui n'est
|
||||
// pas un indice positif retombe sur le CPU : mieux vaut une dictée lente qu'un
|
||||
// GPU choisi au hasard sur une machine dont on ne sait rien.
|
||||
func (c DictateCfg) cudaVisibleDevices() string {
|
||||
n, err := strconv.Atoi(c.Device)
|
||||
if err != nil || n < 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// chunkBounds : réserve minimale avant de couper, et coupure forcée quand
|
||||
// aucun silence n'arrive. Consommé par le découpeur du plan « temps réel ».
|
||||
func (c DictateCfg) chunkBounds() (time.Duration, time.Duration) {
|
||||
switch c.Reactivity {
|
||||
case "court":
|
||||
return 1000 * time.Millisecond, 5 * time.Second
|
||||
case "long":
|
||||
return 3 * time.Second, 15 * time.Second
|
||||
default:
|
||||
return 1500 * time.Millisecond, 8 * time.Second
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDictateCfgDefauts(t *testing.T) {
|
||||
c := DictateCfg{}.withDefauts()
|
||||
if c.Model != "large-v3-turbo-q5_0" {
|
||||
t.Errorf("modèle par défaut = %q, attendu large-v3-turbo-q5_0", c.Model)
|
||||
}
|
||||
if c.Lang != "fr" {
|
||||
t.Errorf("langue par défaut = %q, attendu fr — la détection auto se trompe sur les tranches courtes", c.Lang)
|
||||
}
|
||||
if c.Device != "cpu" {
|
||||
t.Errorf("matériel par défaut = %q, attendu cpu (aucun GPU supposé)", c.Device)
|
||||
}
|
||||
if c.Reactivity != "moyen" {
|
||||
t.Errorf("réactivité par défaut = %q, attendu moyen", c.Reactivity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCudaVisibleDevices(t *testing.T) {
|
||||
cas := []struct{ device, want string }{
|
||||
{"cpu", ""},
|
||||
{"0", "0"},
|
||||
{"1", "1"},
|
||||
{"", ""},
|
||||
{"bidon", ""},
|
||||
{"-1", ""},
|
||||
}
|
||||
for _, c := range cas {
|
||||
got := DictateCfg{Device: c.device}.cudaVisibleDevices()
|
||||
if got != c.want {
|
||||
t.Errorf("cudaVisibleDevices(%q) = %q, attendu %q", c.device, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChunkBounds(t *testing.T) {
|
||||
cas := []struct {
|
||||
react string
|
||||
min, max time.Duration
|
||||
}{
|
||||
{"court", 1000 * time.Millisecond, 5 * time.Second},
|
||||
{"moyen", 1500 * time.Millisecond, 8 * time.Second},
|
||||
{"long", 3 * time.Second, 15 * time.Second},
|
||||
{"inconnu", 1500 * time.Millisecond, 8 * time.Second},
|
||||
}
|
||||
for _, c := range cas {
|
||||
min, max := DictateCfg{Reactivity: c.react}.chunkBounds()
|
||||
if min != c.min || max != c.max {
|
||||
t.Errorf("chunkBounds(%q) = %v/%v, attendu %v/%v", c.react, min, max, c.min, c.max)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDictateCfgAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
in := DictateCfg{Model: "medium-q5_0", Lang: "en", Device: "1", Reactivity: "long"}
|
||||
if err := dictateCfgSave(in); err != nil {
|
||||
t.Fatalf("enregistrement : %v", err)
|
||||
}
|
||||
if out := dictateCfgLoad(); out != in {
|
||||
t.Errorf("relu %+v, attendu %+v", out, in)
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle hors catalogue accepté en silence ne se manifesterait qu'au premier
|
||||
// clic sur le micro, loin du geste qui l'a causé.
|
||||
func TestDictateCfgSaveRefuseModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "modele-inexistant", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err == nil {
|
||||
t.Error("un modèle hors catalogue doit être refusé")
|
||||
}
|
||||
}
|
||||
|
||||
// Sans réglage enregistré, la lecture doit rendre les défauts — et non le zéro
|
||||
// de la structure, qui donnerait un modèle vide à whisper-server.
|
||||
func TestDictateCfgLoadSansRien(t *testing.T) {
|
||||
testHome(t)
|
||||
c := dictateCfgLoad()
|
||||
if c.Model == "" || c.Lang == "" || c.Device == "" || c.Reactivity == "" {
|
||||
t.Errorf("lecture à vide = %+v, aucun champ ne doit rester vide", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Catalogue des modèles de dictée. Aucun n'est embarqué dans l'image : ils
|
||||
// pèsent de 190 Mo à 1,1 Go et vivent dans <LOKI_HOME>/whisper/, donc dans le
|
||||
// volume /data — ils survivent aux recréations du conteneur.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Les modèles ggml officiels de whisper.cpp.
|
||||
const whisperModelBase = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
|
||||
|
||||
type whisperModel struct {
|
||||
Nom string // libellé affiché
|
||||
Fichier string // nom du .bin, identique en local et chez Hugging Face
|
||||
Octets int64 // taille du téléchargement, pour prévenir avant de le lancer
|
||||
VRAMMo int // ordre de grandeur occupé sur le GPU, pour choisir en connaissance de cause
|
||||
}
|
||||
|
||||
// Les tailles sont celles publiées par le dépôt amont. Elles servent à
|
||||
// informer avant un téléchargement, pas à vérifier le fichier : une taille qui
|
||||
// dérive d'une release à l'autre ne doit pas casser la dictée.
|
||||
var whisperCatalogue = map[string]whisperModel{
|
||||
"small-q5_1": {Nom: "small — rapide, correct", Fichier: "ggml-small-q5_1.bin", Octets: 190_000_000, VRAMMo: 600},
|
||||
"medium-q5_0": {Nom: "medium — bon compromis", Fichier: "ggml-medium-q5_0.bin", Octets: 539_000_000, VRAMMo: 1400},
|
||||
"large-v3-turbo-q5_0": {Nom: "large-v3-turbo — recommandé", Fichier: "ggml-large-v3-turbo-q5_0.bin", Octets: 574_000_000, VRAMMo: 1600},
|
||||
"large-v3-q5_0": {Nom: "large-v3 — le plus précis, le plus lent", Fichier: "ggml-large-v3-q5_0.bin", Octets: 1_080_000_000, VRAMMo: 3600},
|
||||
}
|
||||
|
||||
// whisperModelPathFor : chemin local. Vide si l'identifiant est inconnu —
|
||||
// l'appelant doit traiter ce cas plutôt que de bâtir un chemin sur une chaîne
|
||||
// arbitraire venue d'une requête HTTP.
|
||||
func whisperModelPathFor(id string) string {
|
||||
m, ok := whisperCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(LokiHome(), "whisper", m.Fichier)
|
||||
}
|
||||
|
||||
func whisperModelURLFor(id string) string {
|
||||
m, ok := whisperCatalogue[id]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return whisperModelBase + m.Fichier
|
||||
}
|
||||
|
||||
func whisperModelPresent(id string) bool {
|
||||
p := whisperModelPathFor(id)
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && st.Size() > 0
|
||||
}
|
||||
|
||||
// whisperCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
|
||||
// l'ordre dans lequel se pose la question du compromis.
|
||||
func whisperCatalogueTrie() []map[string]any {
|
||||
out := make([]map[string]any, 0, len(whisperCatalogue))
|
||||
for id, m := range whisperCatalogue {
|
||||
out = append(out, map[string]any{
|
||||
"id": id, "nom": m.Nom, "octets": m.Octets,
|
||||
"vram_mo": m.VRAMMo, "present": whisperModelPresent(id),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCatalogueContientLeDefautEtLAncien(t *testing.T) {
|
||||
// Le défaut doit exister, sinon dictateCfgSave refuse sa propre valeur par
|
||||
// défaut et plus aucun réglage n'est enregistrable.
|
||||
if _, ok := whisperCatalogue["large-v3-turbo-q5_0"]; !ok {
|
||||
t.Error("le modèle par défaut large-v3-turbo-q5_0 est absent du catalogue")
|
||||
}
|
||||
// small-q5_1 est le modèle déjà téléchargé sur les installations
|
||||
// existantes : le retirer le rendrait insélectionnable alors que son
|
||||
// fichier est là.
|
||||
if _, ok := whisperCatalogue["small-q5_1"]; !ok {
|
||||
t.Error("small-q5_1 est absent : les installations existantes l'ont déjà sur disque")
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPathEtURL(t *testing.T) {
|
||||
testHome(t)
|
||||
p := whisperModelPathFor("small-q5_1")
|
||||
if filepath.Base(p) != "ggml-small-q5_1.bin" {
|
||||
t.Errorf("chemin = %q, le fichier doit s'appeler ggml-small-q5_1.bin", p)
|
||||
}
|
||||
// Le chemin DOIT rester celui d'avant, sinon les installations existantes
|
||||
// re-téléchargent 190 Mo pour rien.
|
||||
if filepath.Base(filepath.Dir(p)) != "whisper" {
|
||||
t.Errorf("chemin = %q, le dossier doit rester <LOKI_HOME>/whisper", p)
|
||||
}
|
||||
u := whisperModelURLFor("small-q5_1")
|
||||
if !strings.HasSuffix(u, "/ggml-small-q5_1.bin") {
|
||||
t.Errorf("URL = %q, doit finir par /ggml-small-q5_1.bin", u)
|
||||
}
|
||||
if !strings.HasPrefix(u, "https://") {
|
||||
t.Errorf("URL = %q, doit être en HTTPS", u)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelPathInconnuVide(t *testing.T) {
|
||||
testHome(t)
|
||||
if p := whisperModelPathFor("nawak"); p != "" {
|
||||
t.Errorf("un modèle hors catalogue doit donner un chemin vide, pas %q", p)
|
||||
}
|
||||
if u := whisperModelURLFor("nawak"); u != "" {
|
||||
t.Errorf("URL d'un modèle inconnu = %q, attendu vide", u)
|
||||
}
|
||||
if whisperModelPresent("nawak") {
|
||||
t.Error("un modèle inconnu ne peut pas être présent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogueTrieParTaille(t *testing.T) {
|
||||
testHome(t)
|
||||
l := whisperCatalogueTrie()
|
||||
if len(l) < 4 {
|
||||
t.Fatalf("catalogue de %d entrées, au moins 4 attendues", len(l))
|
||||
}
|
||||
var prec int64
|
||||
for i, m := range l {
|
||||
o, _ := m["octets"].(int64)
|
||||
if i > 0 && o < prec {
|
||||
t.Errorf("entrée %d : catalogue non trié par taille croissante", i)
|
||||
}
|
||||
prec = o
|
||||
for _, clef := range []string{"id", "nom", "octets", "vram_mo", "present"} {
|
||||
if _, ok := m[clef]; !ok {
|
||||
t.Errorf("entrée %d : clé %q manquante — l'UI en a besoin", i, clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfigRouteAllerRetour(t *testing.T) {
|
||||
testHome(t)
|
||||
corps := strings.NewReader(`{"model":"small-q5_1","lang":"en","device":"1","reactivity":"court"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("POST = %d, corps %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("GET", "/api/dictate/config", nil))
|
||||
var got DictateCfg
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
if got.Device != "1" || got.Lang != "en" || got.Model != "small-q5_1" || got.Reactivity != "court" {
|
||||
t.Errorf("relu %+v, attendu le réglage enregistré", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le refus doit venir de la ROUTE, pas seulement du magasin : sinon l'UI reçoit
|
||||
// un 200 et croit son réglage pris en compte.
|
||||
func TestConfigRouteRefuseModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
corps := strings.NewReader(`{"model":"nawak","lang":"fr","device":"cpu","reactivity":"moyen"}`)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
|
||||
if rec.Code == 200 {
|
||||
t.Errorf("modèle hors catalogue accepté (%d) : %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRouteCorpsIllisible(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", strings.NewReader("pas du json")))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("code = %d, attendu 400", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelsRoute(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateModels(rec, httptest.NewRequest("GET", "/api/dictate/models", nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET = %d", rec.Code)
|
||||
}
|
||||
var l []map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &l); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
if len(l) < 4 {
|
||||
t.Errorf("%d modèles renvoyés, au moins 4 attendus", len(l))
|
||||
}
|
||||
}
|
||||
|
||||
// Un identifiant inconnu ne doit pas lancer de téléchargement : whisperDownload
|
||||
// bâtirait une URL vide et le message d'erreur n'arriverait qu'en arrière-plan.
|
||||
func TestDownloadRouteRefuseInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
corps := strings.NewReader(`{"id":"nawak"}`)
|
||||
handleDictateDownload(rec, httptest.NewRequest("POST", "/api/dictate/models/download", corps))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("code = %d, attendu 400 pour un modèle inconnu", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateRoute(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleDictateState(rec, httptest.NewRequest("GET", "/api/dictate/state", nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET = %d", rec.Code)
|
||||
}
|
||||
var e map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
|
||||
t.Fatalf("réponse illisible : %v", err)
|
||||
}
|
||||
for _, clef := range []string{"actif", "modele", "present", "device", "dl"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante dans l'état", clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Un audio trop court est rejeté AVANT toute tentative de lancer whisper-server :
|
||||
// inutile de charger un modèle pour un clic parasite.
|
||||
func TestTranscribeAudioTropCourt(t *testing.T) {
|
||||
testHome(t)
|
||||
rec := httptest.NewRecorder()
|
||||
handleTranscribe(rec, httptest.NewRequest("POST", "/api/transcribe", strings.NewReader("court")))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("code = %d, attendu 400", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Supervision de whisper-server : le modèle est chargé UNE fois et reste en
|
||||
// mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
|
||||
//
|
||||
// L'ancien chemin lançait whisper-cli par requête. Le chargement du modèle
|
||||
// dominait le temps de réponse (~3 s pour 3,4 s d'audio), et découper la
|
||||
// dictée en tranches par-dessus ce mécanisme aurait rechargé le modèle toutes
|
||||
// les quelques secondes.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// whisperServerBin : deux binaires sont livrés, pas un.
|
||||
//
|
||||
// L'image runtime peut être bâtie sur la variante CPU de llama.cpp
|
||||
// (LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server). Sur cette base les
|
||||
// bibliothèques CUDA sont absentes, et un binaire lié à CUDA ne démarre pas du
|
||||
// tout : l'éditeur de liens échoue avant la première instruction, il n'y a
|
||||
// donc aucun repli possible depuis l'intérieur du programme. D'où deux
|
||||
// binaires et un choix fait ici.
|
||||
func whisperServerBin(gpu bool) string {
|
||||
env, defaut := "LOKI_WHISPER_SERVER_CPU", "/usr/local/bin/whisper-server-cpu"
|
||||
if gpu {
|
||||
env, defaut = "LOKI_WHISPER_SERVER_CUDA", "/usr/local/bin/whisper-server-cuda"
|
||||
}
|
||||
for _, p := range []string{os.Getenv(env), defaut} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if gpu {
|
||||
// Pas de binaire CUDA : l'appelant retombe sur le CPU en le disant.
|
||||
return ""
|
||||
}
|
||||
if p, err := exec.LookPath("whisper-server"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func whisperServerArgs(c DictateCfg, port int) ([]string, error) {
|
||||
model := whisperModelPathFor(c.Model)
|
||||
if model == "" {
|
||||
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
|
||||
}
|
||||
return []string{
|
||||
"-m", model,
|
||||
"-l", c.Lang,
|
||||
// 127.0.0.1 et pas 0.0.0.0 : whisper-server n'a aucune
|
||||
// authentification. Sur un conteneur au réseau partagé, l'exposer
|
||||
// offrirait la transcription à tout le réseau.
|
||||
"--host", "127.0.0.1",
|
||||
"--port", strconv.Itoa(port),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// whisperServerEnv pose CUDA_VISIBLE_DEVICES en REMPLAÇANT toute valeur déjà
|
||||
// présente. Une variable dupliquée laisse le gagnant dépendre de la libc, et
|
||||
// le choix de GPU deviendrait un coup de dé.
|
||||
func whisperServerEnv(c DictateCfg, base []string) []string {
|
||||
const clef = "CUDA_VISIBLE_DEVICES="
|
||||
out := make([]string, 0, len(base)+1)
|
||||
for _, v := range base {
|
||||
if !strings.HasPrefix(v, clef) {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
// Valeur vide en mode CPU : masque TOUS les GPU. Retirer la variable
|
||||
// ferait l'inverse — ggml les verrait tous.
|
||||
return append(out, clef+c.cudaVisibleDevices())
|
||||
}
|
||||
|
||||
// portLibre demande au système un port disponible puis le relâche. Coder un
|
||||
// port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre
|
||||
// la fermeture et la reprise par whisper-server est négligeable devant le
|
||||
// risque d'un conflit permanent.
|
||||
func portLibre() (int, error) {
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() { _ = l.Close() }()
|
||||
return l.Addr().(*net.TCPAddr).Port, nil
|
||||
}
|
||||
|
||||
// whisperMarqueur reconnaît les annotations que whisper produit à la place
|
||||
// d'un texte : [BLANK_AUDIO], (silence), [SOUND]… Un contenu entièrement
|
||||
// entouré de crochets ou de parenthèses n'est jamais de la parole dictée, et
|
||||
// le laisser passer le collerait tel quel dans le champ de saisie.
|
||||
func whisperMarqueur(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return true
|
||||
}
|
||||
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
|
||||
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
|
||||
}
|
||||
|
||||
// whisperInferSur prend l'URL de base en paramètre : c'est ce qui rend l'appel
|
||||
// testable sans lancer de processus.
|
||||
func whisperInferSur(ctx context.Context, base string, wav []byte) (string, error) {
|
||||
var corps bytes.Buffer
|
||||
mw := multipart.NewWriter(&corps)
|
||||
part, err := mw.CreateFormFile("file", "dictee.wav")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := part.Write(wav); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mw.WriteField("response_format", "json"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/inference", &corps)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
brut, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode != 200 {
|
||||
return "", fmt.Errorf("whisper-server a répondu %d : %s", resp.StatusCode, lastLine(string(brut)))
|
||||
}
|
||||
var j struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(brut, &j); err != nil {
|
||||
return "", fmt.Errorf("réponse illisible de whisper-server : %w", err)
|
||||
}
|
||||
if whisperMarqueur(j.Text) {
|
||||
return "", nil
|
||||
}
|
||||
return strings.TrimSpace(j.Text), nil
|
||||
}
|
||||
|
||||
// ─── Supervision ─────────────────────────────────────────────────────────────
|
||||
|
||||
// whisperIdle : au-delà, le serveur s'éteint et rend sa VRAM. Une dictée n'est
|
||||
// pas un service permanent ; garder un modèle chargé toute la journée pour
|
||||
// quelques phrases prive le moteur de chat de mémoire GPU.
|
||||
const whisperIdle = 10 * time.Minute
|
||||
|
||||
// whisperReady : plafond d'attente au démarrage. Un modèle de 1 Go lu depuis un
|
||||
// disque lent puis poussé sur le GPU prend du temps ; échouer trop tôt
|
||||
// afficherait une panne là où il n'y a qu'une lenteur.
|
||||
const whisperReady = 120 * time.Second
|
||||
|
||||
var wsrvMu sync.Mutex
|
||||
var wsrv struct {
|
||||
cmd *exec.Cmd
|
||||
port int
|
||||
cfg DictateCfg
|
||||
log *bytes.Buffer
|
||||
timer *time.Timer
|
||||
note string // pourquoi on ne tourne pas sur le matériel demandé
|
||||
depuis time.Time
|
||||
}
|
||||
|
||||
// whisperEnsure garantit un serveur vivant pour la configuration COURANTE, et
|
||||
// renvoie son port. Si les réglages ont changé depuis le lancement, l'ancien
|
||||
// est arrêté : c'est ce qui rend un changement de modèle ou de GPU effectif
|
||||
// sans redémarrer Loki.
|
||||
func whisperEnsure() (int, error) {
|
||||
cfg := dictateCfgLoad()
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
|
||||
whisperTouchLocked()
|
||||
return wsrv.port, nil
|
||||
}
|
||||
whisperStopLocked()
|
||||
return whisperStartLocked(cfg)
|
||||
}
|
||||
|
||||
func whisperStartLocked(cfg DictateCfg) (int, error) {
|
||||
if !whisperModelPresent(cfg.Model) {
|
||||
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
|
||||
}
|
||||
note := ""
|
||||
gpu := cfg.cudaVisibleDevices() != ""
|
||||
bin := whisperServerBin(gpu)
|
||||
if bin == "" && gpu {
|
||||
// Image bâtie sans CUDA : on le DIT, au lieu de laisser croire que le
|
||||
// GPU choisi est utilisé.
|
||||
note = "binaire CUDA absent de cette image — dictée sur le processeur"
|
||||
gpu = false
|
||||
cfg.Device = "cpu"
|
||||
bin = whisperServerBin(false)
|
||||
}
|
||||
if bin == "" {
|
||||
return 0, fmt.Errorf("whisper-server introuvable (image à reconstruire)")
|
||||
}
|
||||
port, err := portLibre()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aucun port libre pour whisper-server : %w", err)
|
||||
}
|
||||
args, err := whisperServerArgs(cfg, port)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
log := &bytes.Buffer{}
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = whisperServerEnv(cfg, os.Environ())
|
||||
cmd.Stdout, cmd.Stderr = log, log
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("démarrage de whisper-server : %w", err)
|
||||
}
|
||||
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad()
|
||||
wsrv.log, wsrv.note, wsrv.depuis = log, note, time.Now()
|
||||
if err := whisperAttendPret(cmd, port, log); err != nil {
|
||||
whisperStopLocked()
|
||||
return 0, err
|
||||
}
|
||||
whisperTouchLocked()
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// whisperAttendPret sonde le serveur jusqu'à ce qu'il réponde. Si le processus
|
||||
// meurt entre-temps, on rapporte COMMENT il est mort : sur une mort par
|
||||
// signal, la dernière ligne du journal est celle d'avant le coup fatal — elle
|
||||
// ressemble à une explication sans en être une (leçon de la PR #18).
|
||||
func whisperAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
|
||||
fin := time.Now().Add(whisperReady)
|
||||
url := "http://127.0.0.1:" + strconv.Itoa(port) + "/"
|
||||
mort := make(chan error, 1)
|
||||
go func() { mort <- cmd.Wait() }()
|
||||
for time.Now().Before(fin) {
|
||||
select {
|
||||
case err := <-mort:
|
||||
msg := whisperExitReason(err)
|
||||
if l := lastLine(log.String()); l != "" {
|
||||
msg += " — dernière sortie : " + l
|
||||
}
|
||||
return fmt.Errorf("whisper-server s'est arrêté au démarrage : %s", msg)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
|
||||
cancel()
|
||||
if err == nil {
|
||||
_ = resp.Body.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("whisper-server n'a pas répondu en %s", whisperReady)
|
||||
}
|
||||
|
||||
func whisperTouchLocked() {
|
||||
if wsrv.timer != nil {
|
||||
wsrv.timer.Stop()
|
||||
}
|
||||
wsrv.timer = time.AfterFunc(whisperIdle, whisperShutdown)
|
||||
}
|
||||
|
||||
func whisperStopLocked() {
|
||||
if wsrv.timer != nil {
|
||||
wsrv.timer.Stop()
|
||||
wsrv.timer = nil
|
||||
}
|
||||
if wsrv.cmd != nil && wsrv.cmd.Process != nil {
|
||||
_ = wsrv.cmd.Process.Kill()
|
||||
}
|
||||
wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil
|
||||
wsrv.cfg, wsrv.note = DictateCfg{}, ""
|
||||
}
|
||||
|
||||
// whisperShutdown arrête le serveur. Appelé par la minuterie d'inactivité et
|
||||
// à chaque changement de réglages — sans ça, le serveur continuerait de
|
||||
// tourner avec l'ancien modèle et le nouveau réglage semblerait sans effet.
|
||||
func whisperShutdown() {
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
whisperStopLocked()
|
||||
}
|
||||
|
||||
// whisperInfer garantit le serveur puis lui soumet le WAV.
|
||||
func whisperInfer(ctx context.Context, wav []byte) (string, error) {
|
||||
port, err := whisperEnsure()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return whisperInferSur(ctx, "http://127.0.0.1:"+strconv.Itoa(port), wav)
|
||||
}
|
||||
|
||||
func whisperEtat() map[string]any {
|
||||
cfg := dictateCfgLoad()
|
||||
wsrvMu.Lock()
|
||||
defer wsrvMu.Unlock()
|
||||
etat := map[string]any{
|
||||
"actif": wsrv.cmd != nil,
|
||||
"modele": cfg.Model,
|
||||
"present": whisperModelPresent(cfg.Model),
|
||||
"device": cfg.Device,
|
||||
"note": wsrv.note,
|
||||
}
|
||||
if wsrv.cmd != nil {
|
||||
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
|
||||
}
|
||||
return etat
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func argVal(args []string, flag string) (string, bool) {
|
||||
for i, a := range args {
|
||||
if a == flag && i+1 < len(args) {
|
||||
return args[i+1], true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func TestServerArgs(t *testing.T) {
|
||||
testHome(t)
|
||||
c := DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu"}.withDefauts()
|
||||
args, err := whisperServerArgs(c, 8091)
|
||||
if err != nil {
|
||||
t.Fatalf("construction des arguments : %v", err)
|
||||
}
|
||||
if v, ok := argVal(args, "-m"); !ok || !strings.HasSuffix(v, "ggml-small-q5_1.bin") {
|
||||
t.Errorf("-m = %q, doit pointer sur le .bin du modèle choisi", v)
|
||||
}
|
||||
if v, ok := argVal(args, "-l"); !ok || v != "fr" {
|
||||
t.Errorf("-l = %q, attendu fr", v)
|
||||
}
|
||||
if v, ok := argVal(args, "--port"); !ok || v != "8091" {
|
||||
t.Errorf("--port = %q, attendu 8091", v)
|
||||
}
|
||||
// whisper-server n'a aucune authentification : l'exposer sur 0.0.0.0
|
||||
// ouvrirait la transcription à tout le réseau.
|
||||
if v, ok := argVal(args, "--host"); !ok || v != "127.0.0.1" {
|
||||
t.Errorf("--host = %q, attendu 127.0.0.1", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerArgsModeleInconnu(t *testing.T) {
|
||||
testHome(t)
|
||||
if _, err := whisperServerArgs(DictateCfg{Model: "nawak", Lang: "fr", Device: "cpu"}, 8091); err == nil {
|
||||
t.Error("un modèle hors catalogue doit produire une erreur, pas une commande avec -m vide")
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerEnvGPU(t *testing.T) {
|
||||
base := []string{"PATH=/usr/bin", "CUDA_VISIBLE_DEVICES=7", "HOME=/root"}
|
||||
|
||||
env := whisperServerEnv(DictateCfg{Device: "1"}, base)
|
||||
if !contientExact(env, "CUDA_VISIBLE_DEVICES=1") {
|
||||
t.Errorf("env = %v, doit contenir CUDA_VISIBLE_DEVICES=1", env)
|
||||
}
|
||||
// L'ancienne valeur doit être REMPLACÉE, pas doublée : avec deux
|
||||
// occurrences, le gagnant dépend de l'implémentation — le choix de
|
||||
// l'utilisateur ne doit pas dépendre de ça.
|
||||
if n := compte(env, "CUDA_VISIBLE_DEVICES="); n != 1 {
|
||||
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
|
||||
}
|
||||
if !contientExact(env, "PATH=/usr/bin") {
|
||||
t.Error("le reste de l'environnement doit être conservé")
|
||||
}
|
||||
|
||||
env = whisperServerEnv(DictateCfg{Device: "cpu"}, base)
|
||||
if !contientExact(env, "CUDA_VISIBLE_DEVICES=") {
|
||||
t.Errorf("env = %v, en mode CPU la variable doit être posée VIDE (et non retirée) pour masquer tous les GPU", env)
|
||||
}
|
||||
}
|
||||
|
||||
func contientExact(l []string, s string) bool {
|
||||
for _, v := range l {
|
||||
if v == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func compte(l []string, prefixe string) int {
|
||||
n := 0
|
||||
for _, v := range l {
|
||||
if strings.HasPrefix(v, prefixe) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func TestPortLibre(t *testing.T) {
|
||||
p, err := portLibre()
|
||||
if err != nil {
|
||||
t.Fatalf("portLibre : %v", err)
|
||||
}
|
||||
if p < 1024 || p > 65535 {
|
||||
t.Errorf("port = %d, hors de la plage utilisable", p)
|
||||
}
|
||||
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
|
||||
if err != nil {
|
||||
t.Fatalf("port %d annoncé libre mais inutilisable : %v", p, err)
|
||||
}
|
||||
_ = l.Close()
|
||||
}
|
||||
|
||||
func TestInferParseLaReponse(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/inference" {
|
||||
t.Errorf("chemin appelé = %q, attendu /inference", r.URL.Path)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
|
||||
t.Errorf("Content-Type = %q, whisper-server attend du multipart", ct)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"text":" bonjour ceci est un test "}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
||||
if err != nil {
|
||||
t.Fatalf("whisperInferSur : %v", err)
|
||||
}
|
||||
if txt != "bonjour ceci est un test" {
|
||||
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
|
||||
}
|
||||
}
|
||||
|
||||
// Sur du silence, whisper renvoie ses marqueurs internes. Les laisser passer
|
||||
// les collerait tels quels dans le champ de saisie — c'est ce qui est arrivé
|
||||
// avec [BLANK_AUDIO].
|
||||
func TestInferFiltreLesMarqueurs(t *testing.T) {
|
||||
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"text":"` + marqueur + `"}`))
|
||||
}))
|
||||
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
|
||||
srv.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
|
||||
}
|
||||
if txt != "" {
|
||||
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferErreurHTTP(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(500)
|
||||
_, _ = w.Write([]byte("boom"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux")); err == nil {
|
||||
t.Error("un 500 de whisper-server doit remonter une erreur")
|
||||
}
|
||||
}
|
||||
|
||||
// Un modèle absent doit être annoncé comme tel, pas produire un lancement de
|
||||
// whisper-server voué à mourir sur un fichier introuvable.
|
||||
func TestEnsureModeleAbsent(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := dictateCfgSave(DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := whisperEnsure()
|
||||
if err == nil {
|
||||
t.Fatal("un modèle absent doit produire une erreur")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absent") {
|
||||
t.Errorf("erreur = %q, elle doit dire que le modèle est absent", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEtatSansServeur(t *testing.T) {
|
||||
testHome(t)
|
||||
e := whisperEtat()
|
||||
if e["actif"] != false {
|
||||
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
|
||||
}
|
||||
for _, clef := range []string{"modele", "present", "device", "note"} {
|
||||
if _, ok := e[clef]; !ok {
|
||||
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package loki
|
||||
|
||||
// llm_budget.go — budget SOUPLE d'appels d'outils par tour.
|
||||
//
|
||||
// Le plafond dur (TOOL_LIMIT) et l'anti-boucle ont été retirés en v0.6.3 : ils
|
||||
// coupaient des tours parfaitement légitimes, une recherche enchaînant sans
|
||||
// problème des dizaines d'appels. Mais il ne restait plus RIEN entre « le
|
||||
// modèle converge » et « le modèle tourne en rond pendant une heure » — vu en
|
||||
// production : ~50 appels d'outil, 55 minutes, le modèle relisant cinq fois les
|
||||
// mêmes fonctions pour re-dériver une conclusion qu'il avait déjà écrite.
|
||||
//
|
||||
// La déduplication d'appels identiques (repeatedCallResult) ne rattrape pas ce
|
||||
// cas : sa clé est « nom + arguments bruts », et relire le même fichier par
|
||||
// tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés
|
||||
// différentes. Élargir cette clé à la CIBLE serait pire que le mal — deux
|
||||
// tranches d'un même fichier renvoient un contenu différent, les confondre
|
||||
// casserait toute lecture paginée légitime.
|
||||
//
|
||||
// D'où un budget qui ne coupe rien : au-delà d'un palier, on RAPPELLE au modèle
|
||||
// combien d'appels il a déjà faits et on lui demande de conclure. Le tour reste
|
||||
// entièrement sous son contrôle — c'est de la pression, pas une barrière.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// agentBudgetDefault : nombre d'appels d'outils au-delà duquel un tour cesse
|
||||
// d'être une recherche et commence à ressembler à du sur-place. Assez haut pour
|
||||
// qu'une vraie enquête (lire dix fichiers, croiser trois pages web) passe sans
|
||||
// jamais voir le rappel.
|
||||
const agentBudgetDefault = 24
|
||||
|
||||
// agentBudget lit le palier dans config.env (AGENT_BUDGET). 0 — ou une valeur
|
||||
// off/false/no/non — désactive complètement le mécanisme.
|
||||
func agentBudget() int {
|
||||
v := strings.ToLower(strings.TrimSpace(ReadConfig()["AGENT_BUDGET"]))
|
||||
switch v {
|
||||
case "":
|
||||
return agentBudgetDefault
|
||||
case "off", "false", "no", "non", "disable", "disabled":
|
||||
return 0
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
return agentBudgetDefault
|
||||
}
|
||||
|
||||
// budgetNudge renvoie le rappel à injecter quand le tour vient de franchir un
|
||||
// palier, ou "" s'il n'y a rien à dire.
|
||||
//
|
||||
// calls : appels d'outils exécutés depuis le début du tour
|
||||
// budget : taille d'un palier (0 = mécanisme désactivé)
|
||||
// sent : rappels déjà envoyés dans ce tour
|
||||
//
|
||||
// Les paliers sont à budget, 2×budget, 3×budget… : la pression revient donc
|
||||
// régulièrement au lieu de s'éteindre après un avertissement ignoré. Le coût en
|
||||
// contexte est négligeable (une phrase toutes les 24 requêtes) et le message est
|
||||
// AJOUTÉ EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté
|
||||
// llama-server.
|
||||
//
|
||||
// Le texte est en anglais comme le reste du protocole d'outils : c'est la langue
|
||||
// dans laquelle les modèles suivent le mieux une consigne impérative, quelle que
|
||||
// soit celle de la conversation.
|
||||
func budgetNudge(calls, budget, sent int) string {
|
||||
if budget <= 0 || calls < budget*(sent+1) {
|
||||
return ""
|
||||
}
|
||||
n := fmt.Sprintf("%d tool calls", calls)
|
||||
switch sent {
|
||||
case 0:
|
||||
return "[system] You have made " + n + " in this single turn. That is a lot: you almost certainly have enough to answer already. " +
|
||||
"Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing."
|
||||
case 1:
|
||||
return "[system] " + n + " now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. " +
|
||||
"Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further."
|
||||
default:
|
||||
return "[system] " + n + ". Stop. Write your final answer in this message. Do not call another tool."
|
||||
}
|
||||
}
|
||||
|
||||
// logBudget trace UNE ligne par rappel sur la sortie d'erreur (donc dans
|
||||
// `journalctl -u loki-ui`), comme logCompact. Sans ça, un tour qui part en
|
||||
// vrille est invisible côté serveur : on ne voit qu'une génération qui dure.
|
||||
func logBudget(calls, budget, sent int) {
|
||||
fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n",
|
||||
calls, budget, sent)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Le budget ne se déclenche pas avant son palier, et il revient à chaque
|
||||
// palier suivant : un modèle qui ignore le premier rappel doit en recevoir un
|
||||
// second, plus ferme.
|
||||
func TestBudgetNudgePaliers(t *testing.T) {
|
||||
const budget = 24
|
||||
cases := []struct {
|
||||
calls, sent int
|
||||
want bool
|
||||
}{
|
||||
{0, 0, false},
|
||||
{1, 0, false},
|
||||
{23, 0, false},
|
||||
{24, 0, true}, // 1er palier
|
||||
{40, 1, false}, // rappel déjà envoyé, palier suivant pas atteint
|
||||
{48, 1, true}, // 2e palier
|
||||
{72, 2, true}, // 3e palier
|
||||
{95, 3, false},
|
||||
{96, 3, true}, // 4e palier : la pression ne s'éteint pas
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := budgetNudge(c.calls, budget, c.sent) != ""
|
||||
if got != c.want {
|
||||
t.Errorf("budgetNudge(calls=%d, sent=%d) = %v, attendu %v", c.calls, c.sent, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le message durcit à chaque rappel : trois textes distincts, tous porteurs du
|
||||
// nombre d'appels (c'est LUI qui informe le modèle, pas le ton).
|
||||
func TestBudgetNudgeEscalade(t *testing.T) {
|
||||
const budget = 10
|
||||
seen := map[string]bool{}
|
||||
for sent := 0; sent < 3; sent++ {
|
||||
m := budgetNudge(budget*(sent+1), budget, sent)
|
||||
if m == "" {
|
||||
t.Fatalf("rappel n°%d vide", sent+1)
|
||||
}
|
||||
if seen[m] {
|
||||
t.Errorf("rappel n°%d identique à un précédent : %q", sent+1, m)
|
||||
}
|
||||
seen[m] = true
|
||||
}
|
||||
// Au-delà, on réutilise le texte le plus ferme plutôt que d'en inventer un
|
||||
// nouveau à chaque palier (à nombre d'appels égal, le message est le même).
|
||||
if a, b := budgetNudge(50, budget, 3), budgetNudge(50, budget, 4); a != b {
|
||||
t.Errorf("les rappels tardifs devraient partager le même texte :\n%q\n%q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// budget=0 (AGENT_BUDGET=off) : plus aucun rappel, quel que soit le nombre
|
||||
// d'appels — le mécanisme doit pouvoir être totalement coupé.
|
||||
func TestBudgetNudgeDesactive(t *testing.T) {
|
||||
for _, calls := range []int{0, 24, 500, 10000} {
|
||||
if m := budgetNudge(calls, 0, 0); m != "" {
|
||||
t.Errorf("budget désactivé mais rappel émis à %d appels : %q", calls, m)
|
||||
}
|
||||
}
|
||||
if m := budgetNudge(100, -5, 0); m != "" {
|
||||
t.Errorf("budget négatif devrait être inerte, obtenu %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentBudgetConfig(t *testing.T) {
|
||||
testHome(t)
|
||||
set := func(v string) {
|
||||
cfg := ReadConfig()
|
||||
cfg["AGENT_BUDGET"] = v
|
||||
if err := WriteConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
val string
|
||||
want int
|
||||
}{
|
||||
{"", agentBudgetDefault},
|
||||
{"40", 40},
|
||||
{"0", 0},
|
||||
{"off", 0},
|
||||
{"NON", 0},
|
||||
{"n'importe quoi", agentBudgetDefault}, // valeur illisible : on ne l'invente pas
|
||||
{"-3", agentBudgetDefault},
|
||||
} {
|
||||
set(c.val)
|
||||
if got := agentBudget(); got != c.want {
|
||||
t.Errorf("AGENT_BUDGET=%q → %d, attendu %d", c.val, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
-18
@@ -194,6 +194,14 @@ type Caps struct {
|
||||
// Mem = mode d'accès à la mémoire persistante (off / ondemand / always),
|
||||
// indépendant du mode agent. Voir MemMode.
|
||||
Mem MemMode
|
||||
// Code = mode code actif pour cette discussion (sélecteur Chat/Code) :
|
||||
// ajoute les outils codeur (read/grep/glob, git, critères, jobs) et le
|
||||
// prompt de rôle. Requiert Agent.
|
||||
Code bool
|
||||
// Role = rôle du tour en mode code : "" ou "builder" (défaut), "planner",
|
||||
// "verifier" (passe de vérification, seule autorisée à marquer un critère
|
||||
// passed), "explorer", "code-reviewer". Voir code_roles.go.
|
||||
Role string
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
@@ -261,12 +269,71 @@ func steerSystem(msgs []Message, hint string) []Message {
|
||||
return append([]Message{{Role: "system", Content: hint}}, msgs...)
|
||||
}
|
||||
|
||||
// normalizeSystemMessages garantit un UNIQUE message système, en tête, dans la
|
||||
// séquence ENVOYÉE au moteur. Repris de l'amont AJEAN (v0.9.8, issue #26).
|
||||
//
|
||||
// steerSystem (ci-dessus) empêche déjà loki d'en poser un en cours de route, mais
|
||||
// il ne couvre que SES propres consignes : un historique venu d'ailleurs (import,
|
||||
// preset, conversation migrée, agent de code qui compose sa propre séquence) peut
|
||||
// encore porter deux systèmes, ou un système ailleurs qu'en position 0. Les
|
||||
// gabarits stricts — Qwen3.x en --jinja — répondent alors « System message must
|
||||
// be at the beginning », un 500 qui tue le tour sans rien expliquer.
|
||||
//
|
||||
// La séquence renvoyée est une COPIE : l'historique d'origine garde sa forme pour
|
||||
// l'affichage, la persistance et la compaction. Un modèle qui accepte le système
|
||||
// n'importe où n'est pas gêné de le recevoir en tête — la normalisation est donc
|
||||
// sans risque pour tous, et sans effet sur une conversation déjà normale (payload
|
||||
// identique, garanti par test).
|
||||
func normalizeSystemMessages(msgs []Message) []Message {
|
||||
var sys []string
|
||||
sawSystem := false
|
||||
rest := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
if s, ok := m.Content.(string); ok {
|
||||
sawSystem = true
|
||||
if strings.TrimSpace(s) != "" {
|
||||
sys = append(sys, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Contenu système non textuel (cas théorique) : on le préserve tel quel
|
||||
// plutôt que de le perdre.
|
||||
}
|
||||
rest = append(rest, m)
|
||||
}
|
||||
// Aucun message système : rien à réordonner, on rend l'entrée telle quelle —
|
||||
// pas de copie inutile sur le chemin le plus fréquent.
|
||||
if !sawSystem {
|
||||
return msgs
|
||||
}
|
||||
// Des systèmes existaient mais tous vides : on les a retirés (un système vide
|
||||
// hors position 0 casserait tout autant), sans en réinsérer.
|
||||
if len(sys) == 0 {
|
||||
return rest
|
||||
}
|
||||
out := make([]Message, 0, len(rest)+1)
|
||||
out = append(out, Message{Role: "system", Content: strings.Join(sys, "\n\n")})
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
// EnabledTools returns the tools to advertise on the next inference call.
|
||||
func EnabledTools(caps Caps) []Tool {
|
||||
tools := []Tool{}
|
||||
// Passe de VÉRIFICATION du mode code : jeu d'outils fermé, en lecture
|
||||
// seule + bash (lancer les tests) + critères. Ni write/edit (elle ne
|
||||
// corrige pas), ni mémoire ni web (hors sujet, et chaque schéma coûte du
|
||||
// contexte).
|
||||
if caps.Code && caps.Role == "verifier" {
|
||||
return []Tool{bashTool(), readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool(), criteriaTool()}
|
||||
}
|
||||
if caps.Agent {
|
||||
tools = append(tools, bashTool(), writeTool(), editTool())
|
||||
}
|
||||
if caps.Code {
|
||||
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
|
||||
bashBgTool(), bashTailTool(), gitStatusTool(), gitDiffTool(), gitCloneTool(), criteriaTool())
|
||||
}
|
||||
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
|
||||
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
|
||||
if caps.Mem != MemOff {
|
||||
@@ -277,12 +344,16 @@ func EnabledTools(caps Caps) []Tool {
|
||||
// donc prompt et outils restent cohérents — pas de web_search halluciné.
|
||||
if caps.Agent && caps.Internet {
|
||||
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
|
||||
// Capture d'écran : seulement si Playwright est réellement présent dans
|
||||
// l'image. Annoncer un outil absent enverrait le modèle en boucle de
|
||||
// réessai sur un échec systématique.
|
||||
if screenshotAvailable() {
|
||||
tools = append(tools, webScreenshotTool())
|
||||
}
|
||||
}
|
||||
// Capture d'écran : seulement si Playwright est réellement présent dans
|
||||
// l'image (annoncer un outil absent enverrait le modèle en boucle de
|
||||
// réessai). Offerte aussi SANS accès internet quand le mode code est actif :
|
||||
// photographier son propre serveur de dev est une vérification locale, pas
|
||||
// une sortie sur le web — et sans elle, le modèle croyait n'avoir aucun
|
||||
// navigateur et perdait des minutes à installer puppeteer. La borne aux
|
||||
// adresses locales vit dans toolWebScreenshot.
|
||||
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
|
||||
tools = append(tools, webScreenshotTool())
|
||||
}
|
||||
// Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
|
||||
// Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
|
||||
@@ -305,6 +376,7 @@ type StreamEvent struct {
|
||||
Reasoning string
|
||||
ToolUsed *ToolUsedEvent
|
||||
Stats *StatsEvent
|
||||
Ask *AskEvent
|
||||
Err error
|
||||
// DropReasoning demande à l'UI de retirer la dernière bulle de raisonnement :
|
||||
// le modèle a « pensé sans agir » et on relance le tour, ce raisonnement-là
|
||||
@@ -324,6 +396,14 @@ type StreamEvent struct {
|
||||
// ajouter.
|
||||
NewHistory []Message
|
||||
}
|
||||
|
||||
// AskEvent : l'outil ask — question structurée posée à l'utilisateur, rendue
|
||||
// par l'UI comme une carte à boutons. Le tour se termine juste après.
|
||||
type AskEvent struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ToolUsedEvent struct {
|
||||
Name string
|
||||
Label string // user-visible summary (skill name or the command)
|
||||
@@ -338,6 +418,12 @@ type ToolUsedEvent struct {
|
||||
// Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose
|
||||
// (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-).
|
||||
Diff []DiffLine
|
||||
// Image : chemin (relatif au dossier de travail) d'une image PRODUITE par
|
||||
// l'outil — aujourd'hui la capture de web_screenshot. L'UI l'affiche dans la
|
||||
// bulle de l'outil. Sans ça, la capture n'apparaissait QUE si le modèle
|
||||
// pensait à recopier la ligne markdown rendue par l'outil : un petit modèle
|
||||
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
|
||||
Image string
|
||||
}
|
||||
|
||||
// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI.
|
||||
@@ -574,7 +660,23 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// reasoning is enabled we split that out ourselves so the UI's reasoning
|
||||
// bubble works regardless of backend. The ik_llama.cpp fork already sends
|
||||
// reasoning_content, in which case we leave content untouched.
|
||||
reasoningOn := reasoningActive(ReadConfig()["REASONING"])
|
||||
chatCfg := ReadConfig()
|
||||
reasoningOn := reasoningActive(chatCfg["REASONING"])
|
||||
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
|
||||
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
|
||||
effortWanted := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
|
||||
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
|
||||
// on part alors directement sur la traduction apprise, sans repayer le 500.
|
||||
reasoningEffort := effortResolve(effortWanted)
|
||||
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
|
||||
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
|
||||
// `enable_thinking` qui porte alors la consigne, seul.
|
||||
thinkOff := reasoningExplicitlyOff(chatCfg["REASONING"]) || effortWanted == "none"
|
||||
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
|
||||
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
|
||||
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
|
||||
// modèle réfléchissait pendant que l'interface annonçait le contraire.
|
||||
reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort)
|
||||
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
|
||||
// retry the same turn once with tools removed so the model answers in plain
|
||||
// text from the tool results already gathered, instead of dying mid-chat.
|
||||
@@ -583,6 +685,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// dépassement de la fenêtre de contexte après de gros résultats d'outils), on
|
||||
// compacte l'historique en vol et on rejoue le tour — une seule fois.
|
||||
compactedRetry := false
|
||||
// Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par
|
||||
// tour, comme les autres filets.
|
||||
effortRetried := false
|
||||
// Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas
|
||||
// rejouer deux fois exactement la même écriture dans un même échange.
|
||||
doneCalls := map[string]string{}
|
||||
@@ -595,14 +700,28 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
|
||||
// explicite au lieu d'afficher « pas de réponse ».
|
||||
nudged := false
|
||||
// Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours
|
||||
// parfaitement légitimes (une recherche enchaîne facilement des dizaines
|
||||
// d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule
|
||||
// le contexte — c'est un choix assumé.
|
||||
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
|
||||
// relance par tour, comme le nudge.
|
||||
patternRetried := false
|
||||
// Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond
|
||||
// d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà
|
||||
// d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui
|
||||
// demande de conclure — de la pression, pas une barrière. Sans ça, un modèle
|
||||
// qui tourne en rond n'avait rien en face de lui sauf le bouton stop.
|
||||
toolRuns, budgetNudges, budget := 0, 0, agentBudget()
|
||||
for iter := 0; ; iter++ {
|
||||
// Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté
|
||||
// llama-server reste valide, seul le nouveau message est à traiter.
|
||||
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
|
||||
budgetNudges++
|
||||
logBudget(toolRuns, budget, budgetNudges)
|
||||
messages = append(messages, Message{Role: "user", Content: msg})
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": "loki",
|
||||
"messages": messages,
|
||||
"model": "loki",
|
||||
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
|
||||
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
|
||||
"messages": normalizeSystemMessages(messages),
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
|
||||
@@ -611,6 +730,17 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
|
||||
"stream_options": map[string]any{"include_usage": true},
|
||||
}
|
||||
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
|
||||
// l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les
|
||||
// deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que
|
||||
// c'est bien loki qui a le dernier mot sur `chat_template_kwargs`.
|
||||
applySampling(payload)
|
||||
if reasoningEffort != "" {
|
||||
payload["reasoning_effort"] = reasoningEffort
|
||||
}
|
||||
if reasoningKwargs != nil {
|
||||
payload["chat_template_kwargs"] = reasoningKwargs
|
||||
}
|
||||
if len(tools) > 0 && !disableTools {
|
||||
payload["tools"] = tools
|
||||
// The model sometimes emits parallel tool calls, which this llama.cpp
|
||||
@@ -644,6 +774,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if msg == "" {
|
||||
msg = resp.Status
|
||||
}
|
||||
// Refus du niveau de raisonnement par le gabarit du modèle (Qwen3.8 ne
|
||||
// connaît pas « high », gpt-oss ne connaît pas « xhigh »…). C'est un
|
||||
// refus DÉFINITIF, pas une question de taille de prompt : à traiter
|
||||
// avant la compaction, qui sinon taillait l'historique pour rien puis
|
||||
// échouait quand même. On rejoue le tour avec le niveau accepté (ou
|
||||
// sans le champ), l'historique intact.
|
||||
if !effortRetried && effortWanted != "" {
|
||||
if fixed, ok := effortFromRejection(msg, effortWanted); ok {
|
||||
effortRetried = true
|
||||
effortRemember(effortWanted, fixed)
|
||||
logEffortFallback(effortWanted, fixed)
|
||||
reasoningEffort = fixed
|
||||
reasoningKwargs = reasoningTemplateKwargs(thinkOff, fixed)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Le prompt a peut-être dépassé la fenêtre de contexte : on tente une
|
||||
// compaction en vol et on rejoue le tour (une seule fois) avant tout le
|
||||
// reste. C'est le filet de secours à la Hermes.
|
||||
@@ -775,10 +921,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
switch cur.Function.Name {
|
||||
case "mem_search", "web_search":
|
||||
key = "query"
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write":
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
||||
key = "file"
|
||||
case "web_open", "web_read", "web_grep":
|
||||
key = "url"
|
||||
case "grep", "glob":
|
||||
key = "pattern"
|
||||
case "ask":
|
||||
key = "question"
|
||||
case "criteria":
|
||||
key = "action"
|
||||
case "bash_tail":
|
||||
key = "id"
|
||||
case "git_clone":
|
||||
key = "url"
|
||||
}
|
||||
p := previewArg(cur.Function.Arguments, key)
|
||||
// Corps en cours de frappe pour les outils d'écriture : on le diffuse
|
||||
@@ -939,6 +1095,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if ctx.Err() != nil {
|
||||
return extra, nil
|
||||
}
|
||||
toolRuns++ // alimente le budget souple (voir budgetNudge)
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
// Derive the human label (command / skill name) up front so we can
|
||||
@@ -948,10 +1105,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
switch tc.Function.Name {
|
||||
case "mem_search", "web_search":
|
||||
label, _ = args["query"].(string)
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write":
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
||||
label, _ = args["file"].(string)
|
||||
case "bash":
|
||||
case "bash", "bash_bg":
|
||||
label, _ = args["command"].(string)
|
||||
case "grep", "glob":
|
||||
label, _ = args["pattern"].(string)
|
||||
case "ask":
|
||||
label, _ = args["question"].(string)
|
||||
case "criteria":
|
||||
label, _ = args["action"].(string)
|
||||
case "bash_tail":
|
||||
label, _ = args["id"].(string)
|
||||
case "git_clone":
|
||||
label, _ = args["url"].(string)
|
||||
case "web_open", "web_read":
|
||||
label, _ = args["url"].(string)
|
||||
case "web_grep":
|
||||
@@ -1038,10 +1205,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// Cible = un poste distant : on écrit LÀ-BAS. Pas de diff (on
|
||||
// n'a pas l'ancien contenu du fichier distant).
|
||||
result = nodeCall(tgt, nodeCapWrite, map[string]any{"path": label, "content": content})
|
||||
} else if msg := codeWriteGuard(caps, label, true); msg != "" {
|
||||
result = msg
|
||||
} else {
|
||||
result = fileWrite(label, content)
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
diff = addedDiff(content)
|
||||
trackerNoteWrite(resolveAgentPath(label))
|
||||
result += lspDiagBlock(resolveAgentPath(label), caps)
|
||||
}
|
||||
}
|
||||
case "edit":
|
||||
@@ -1049,13 +1220,41 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
newText, _ := args["new"].(string)
|
||||
if tgt := agentTargetSlug(); tgt != "" {
|
||||
result = nodeEditRemote(tgt, label, oldText, newText)
|
||||
} else if msg := codeWriteGuard(caps, label, false); msg != "" {
|
||||
result = msg
|
||||
} else {
|
||||
result = fileEdit(label, oldText, newText)
|
||||
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
diff = lineDiff(oldText, newText)
|
||||
trackerNoteWrite(resolveAgentPath(label))
|
||||
result += lspDiagBlock(resolveAgentPath(label), caps)
|
||||
}
|
||||
}
|
||||
case "read":
|
||||
result = toolRead(args, caps.Code)
|
||||
case "grep":
|
||||
result = toolGrep(args, caps.Code)
|
||||
case "glob":
|
||||
result = toolGlob(args, caps.Code)
|
||||
case "ask":
|
||||
result = toolAsk(args)
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
q, _ := args["question"].(string)
|
||||
cb(StreamEvent{Ask: &AskEvent{Question: q, Options: askOptions(args)}})
|
||||
}
|
||||
case "bash_bg":
|
||||
result = toolBashBg(args)
|
||||
case "bash_tail":
|
||||
result = toolBashTail(args)
|
||||
case "git_status":
|
||||
result = toolGitStatus(ctx)
|
||||
case "git_diff":
|
||||
result = toolGitDiff(ctx, args)
|
||||
case "git_clone":
|
||||
result = toolGitClone(ctx, args)
|
||||
case "criteria":
|
||||
result = toolCriteria(args, caps.Role == "verifier")
|
||||
case "bash":
|
||||
to := 0
|
||||
switch v := args["timeout"].(type) {
|
||||
@@ -1064,6 +1263,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
case int:
|
||||
to = v
|
||||
}
|
||||
// Politique de sécurité : les commandes catastrophiques sont
|
||||
// refusées AVANT toute exécution, cible distante comprise.
|
||||
if reason := dangerousCommand(label); reason != "" {
|
||||
result = refusedCommandResult(reason)
|
||||
break
|
||||
}
|
||||
if tgt := agentTargetSlug(); tgt != "" {
|
||||
// Cible = un poste distant : la commande s'exécute LÀ-BAS via le
|
||||
// canal du poste (fail-closed : nodeCall renvoie une erreur si le
|
||||
@@ -1081,7 +1286,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
case "web_grep":
|
||||
result = capWebOutput(toolWebGrep(args))
|
||||
case "web_screenshot":
|
||||
result = toolWebScreenshot(args)
|
||||
result = toolWebScreenshot(args, caps)
|
||||
default:
|
||||
if isMCPTool(tc.Function.Name) {
|
||||
result = mcpCall(tc.Function.Name, args)
|
||||
@@ -1092,7 +1297,13 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
doneCalls[callKey] = result
|
||||
}
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff}})
|
||||
// Capture d'écran : l'image part avec l'événement pour être
|
||||
// affichée dans la bulle, quoi que le modèle en fasse ensuite.
|
||||
shot := ""
|
||||
if tc.Function.Name == "web_screenshot" {
|
||||
shot = capturedRelPath(result)
|
||||
}
|
||||
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff, Image: shot}})
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
|
||||
messages = append(messages, toolMsg)
|
||||
extra = append(extra, toolMsg)
|
||||
@@ -1139,6 +1350,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Appel d'outil TEXTUEL (halluciné, jamais exécuté) dans la réponse
|
||||
// finale : on relance le tour UNE fois avec la consigne corrective
|
||||
// (code_retry.go). Le texte fautif reste affiché — le remplacer serait
|
||||
// mentir sur ce qui s'est passé — mais l'historique du modèle garde la
|
||||
// trace ET la correction, donc la vraie réponse suit immédiatement.
|
||||
if !patternRetried && len(tools) > 0 && !disableTools && textualToolCall(assistantContent.String()) {
|
||||
patternRetried = true
|
||||
bad := Message{Role: "assistant", Content: assistantContent.String()}
|
||||
fix := Message{Role: "user", Content: retryCorrective}
|
||||
messages = append(messages, bad, fix)
|
||||
extra = append(extra, bad, fix)
|
||||
continue
|
||||
}
|
||||
// Normal end of turn. If the model produced no visible answer at all
|
||||
// (empty content, e.g. it stopped right after a tool result), say so
|
||||
// instead of leaving the user staring at a silent, finished chat.
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
package loki
|
||||
|
||||
// llm_effort.go — repli quand le gabarit du modèle REFUSE la valeur de
|
||||
// `reasoning_effort`.
|
||||
//
|
||||
// L'hypothèse d'origine (backend_models.go) était : un gabarit jinja qui ne lit
|
||||
// pas `reasoning_effort` l'ignore sans erreur, donc rien à prévoir côté loki.
|
||||
// Elle tombe dès qu'un gabarit VALIDE la valeur au lieu de la subir. Vu en
|
||||
// production sur Qwen3.8-27B :
|
||||
//
|
||||
// {"error":{"code":500,"message":"... raise_exception('Unexpected reasoning
|
||||
// effort ' ~ reasoning_effort) ... Unexpected reasoning effort high. Supported
|
||||
// types are xhigh (default), medium, and low."}}
|
||||
//
|
||||
// Ce gabarit connaît xhigh/medium/low mais PAS « high » — le niveau que
|
||||
// l'interface propose et enregistre par défaut. Résultat : chaque message part
|
||||
// en 500, le tour meurt, et le message d'erreur affiché est une trace jinja.
|
||||
// Pire, le 500 arrivait dans la branche « prompt trop long » de runChat, donc
|
||||
// loki compactait l'historique pour rien avant d'abandonner.
|
||||
//
|
||||
// D'où ce fichier : reconnaître ce refus, traduire le niveau demandé vers celui
|
||||
// que le gabarit accepte (le plus proche sur l'échelle), rejouer le tour SANS
|
||||
// toucher à l'historique, et retenir la traduction pour ce modèle afin de ne
|
||||
// pas repayer l'aller-retour à chaque message.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// effortLadder : les niveaux connus, du plus faible au plus fort. Sert à choisir
|
||||
// un remplaçant PROCHE quand le gabarit refuse la valeur demandée — « high »
|
||||
// devient « xhigh » (voisin immédiat) plutôt que « low ».
|
||||
//
|
||||
// La liste vaut pour tous les gabarits croisés jusqu'ici : gpt-oss lit
|
||||
// low/medium/high, Qwen3.8 lit low/medium/xhigh, certains ajoutent « minimal ».
|
||||
// Une valeur hors échelle n'est pas classable : on retire alors le champ plutôt
|
||||
// que d'inventer un niveau.
|
||||
var effortLadder = []string{"none", "minimal", "low", "medium", "high", "xhigh"}
|
||||
|
||||
// effortRank renvoie la position d'un niveau sur l'échelle, ou -1 s'il est
|
||||
// inconnu.
|
||||
func effortRank(v string) int {
|
||||
v = strings.ToLower(strings.TrimSpace(v))
|
||||
for i, s := range effortLadder {
|
||||
if s == v {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// effortWords capte les niveaux cités dans un message d'erreur. Les bornes de
|
||||
// mot évitent de lire « high » à l'intérieur de « xhigh ».
|
||||
var effortWords = regexp.MustCompile(`\b(none|minimal|low|medium|high|xhigh)\b`)
|
||||
|
||||
// effortRejection dit si le corps d'erreur renvoyé par llama-server est un refus
|
||||
// de la valeur de `reasoning_effort` par le gabarit, et renvoie les niveaux que
|
||||
// ce gabarit déclare accepter (éventuellement vide : tous ne les listent pas).
|
||||
//
|
||||
// La détection reste volontairement large — le texte vient du gabarit du modèle,
|
||||
// pas de llama.cpp, donc sa formulation change d'un modèle à l'autre. Le seul
|
||||
// invariant : il parle de reasoning effort et dit que la valeur ne va pas.
|
||||
func effortRejection(body string) (supported []string, ok bool) {
|
||||
low := strings.ToLower(body)
|
||||
if !strings.Contains(low, "reasoning effort") && !strings.Contains(low, "reasoning_effort") {
|
||||
return nil, false
|
||||
}
|
||||
refus := false
|
||||
for _, w := range []string{"unexpected", "unsupported", "not supported", "invalid", "unknown"} {
|
||||
if strings.Contains(low, w) {
|
||||
refus = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !refus {
|
||||
return nil, false
|
||||
}
|
||||
// Les niveaux acceptés sont annoncés APRÈS « supported » (« Supported types
|
||||
// are xhigh (default), medium, and low. »). Ne lire que cette fin de message
|
||||
// évite de prendre pour une liste le niveau refusé, cité juste avant.
|
||||
if i := strings.LastIndex(low, "supported"); i >= 0 {
|
||||
for _, m := range effortWords.FindAllString(low[i:], -1) {
|
||||
if effortRank(m) >= 0 && !slicesHas(supported, m) {
|
||||
supported = append(supported, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
return supported, true
|
||||
}
|
||||
|
||||
func slicesHas(list []string, v string) bool {
|
||||
for _, s := range list {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// nearestEffort traduit le niveau demandé vers le plus proche parmi ceux que le
|
||||
// gabarit accepte. Renvoie "" quand il n'y a rien de sensé à envoyer : le champ
|
||||
// est alors simplement retiré de la requête, et le gabarit reprend son défaut.
|
||||
//
|
||||
// À égalité de distance on prend le niveau le PLUS FORT : demander « haute » et
|
||||
// se retrouver avec « moyenne » dégrade la réponse en silence, alors qu'un cran
|
||||
// au-dessus ne coûte que du temps de génération.
|
||||
//
|
||||
// « none » n'est jamais traduit : c'est une consigne de couper le raisonnement,
|
||||
// pas une intensité. Un gabarit qui la refuse reçoit déjà `enable_thinking:
|
||||
// false` par chat_template_kwargs (backend_models.go), qui dit la même chose
|
||||
// dans une langue que tous comprennent.
|
||||
func nearestEffort(want string, supported []string) string {
|
||||
want = strings.ToLower(strings.TrimSpace(want))
|
||||
if want == "" || want == "none" {
|
||||
return ""
|
||||
}
|
||||
rank := effortRank(want)
|
||||
if rank < 0 {
|
||||
return ""
|
||||
}
|
||||
best, bestRank := "", -1
|
||||
for _, s := range supported {
|
||||
r := effortRank(s)
|
||||
if r < 0 || s == want {
|
||||
continue // s == want : le gabarit ne peut pas à la fois refuser et accepter
|
||||
}
|
||||
if best == "" || abs(r-rank) < abs(bestRank-rank) || (abs(r-rank) == abs(bestRank-rank) && r > bestRank) {
|
||||
best, bestRank = s, r
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// effortFromRejection combine les deux : à partir du corps d'erreur et du niveau
|
||||
// demandé, renvoie le niveau à réessayer ("" = ne rien envoyer) et true si le
|
||||
// refus a bien été reconnu.
|
||||
func effortFromRejection(body, want string) (string, bool) {
|
||||
supported, ok := effortRejection(body)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return nearestEffort(want, supported), true
|
||||
}
|
||||
|
||||
// effortFallbacks retient les traductions apprises, par modèle : sans ça chaque
|
||||
// message repaierait un aller-retour 500 avant de tomber sur la bonne valeur.
|
||||
// Clé = fichier de modèle + niveau demandé ; la valeur "" est significative
|
||||
// (= ne rien envoyer), d'où sync.Map plutôt qu'une map avec test de zéro.
|
||||
var effortFallbacks sync.Map
|
||||
|
||||
func effortKey(want string) string {
|
||||
return filepath.Base(strings.TrimSpace(ReadConfig()["MODEL"])) + "\x00" + want
|
||||
}
|
||||
|
||||
// effortResolve renvoie le niveau réellement envoyable pour la valeur demandée,
|
||||
// c'est-à-dire la traduction déjà apprise pour ce modèle s'il y en a une.
|
||||
func effortResolve(want string) string {
|
||||
if want == "" {
|
||||
return ""
|
||||
}
|
||||
if v, ok := effortFallbacks.Load(effortKey(want)); ok {
|
||||
return v.(string)
|
||||
}
|
||||
return want
|
||||
}
|
||||
|
||||
// effortRemember enregistre la traduction pour les messages suivants.
|
||||
func effortRemember(want, got string) {
|
||||
if want == "" {
|
||||
return
|
||||
}
|
||||
effortFallbacks.Store(effortKey(want), got)
|
||||
}
|
||||
|
||||
// logEffortFallback trace le repli sur stderr : sinon l'intensité choisie dans
|
||||
// l'interface n'est pas celle qui part au moteur, sans que rien ne le dise.
|
||||
func logEffortFallback(want, got string) {
|
||||
if got == "" {
|
||||
fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — champ retiré pour ce modèle\n", want)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — repli sur %q pour ce modèle\n", want, got)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Le message qui a motivé tout ce fichier : Qwen3.8-27B refuse « high », le
|
||||
// niveau que l'interface enregistre par défaut, et llama-server répond 500.
|
||||
const qwenRefus = `{"error":{"code":500,"message":"\n------------\nWhile executing CallExpression at line 49, column 28 in source:\n...', 'low') %}\n {{- raise_exception('Unexpected reasoning effort ' ~ reason...\n ^\nError: Jinja Exception: Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low.","type":"server_error"}}`
|
||||
|
||||
func TestEffortRejection(t *testing.T) {
|
||||
sup, ok := effortRejection(qwenRefus)
|
||||
if !ok {
|
||||
t.Fatal("refus de reasoning_effort non reconnu")
|
||||
}
|
||||
// « high » est cité AVANT la liste (c'est la valeur refusée) : le lire comme
|
||||
// une valeur acceptée renverrait loki sur la même erreur en boucle.
|
||||
want := map[string]bool{"xhigh": true, "medium": true, "low": true}
|
||||
if len(sup) != len(want) {
|
||||
t.Fatalf("niveaux acceptés = %v, attendu %v", sup, want)
|
||||
}
|
||||
for _, s := range sup {
|
||||
if !want[s] {
|
||||
t.Errorf("niveau %q lu à tort comme accepté (%v)", s, sup)
|
||||
}
|
||||
}
|
||||
// Les autres 500 (prompt trop long, appel d'outil illisible) ne doivent pas
|
||||
// passer par ce filet : ils ont le leur.
|
||||
for _, body := range []string{
|
||||
"",
|
||||
`{"error":{"code":500,"message":"the request exceeds the available context size"}}`,
|
||||
`{"error":{"code":500,"message":"Failed to parse tool call"}}`,
|
||||
// Parle bien de reasoning_effort mais ne le refuse pas.
|
||||
`{"error":{"code":500,"message":"reasoning_effort applied"}}`,
|
||||
} {
|
||||
if _, ok := effortRejection(body); ok {
|
||||
t.Errorf("corps %q pris à tort pour un refus d'intensité", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sans liste de niveaux acceptés, il n'y a rien à deviner : on retire le champ.
|
||||
func TestEffortRejectionSansListe(t *testing.T) {
|
||||
sup, ok := effortRejection("Error: Unknown reasoning effort 'xhigh'")
|
||||
if !ok {
|
||||
t.Fatal("refus non reconnu")
|
||||
}
|
||||
if len(sup) != 0 {
|
||||
t.Fatalf("niveaux acceptés = %v, attendu aucun", sup)
|
||||
}
|
||||
if got := nearestEffort("xhigh", sup); got != "" {
|
||||
t.Errorf("nearestEffort sans liste = %q, attendu \"\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearestEffort(t *testing.T) {
|
||||
qwen := []string{"xhigh", "medium", "low"}
|
||||
oss := []string{"low", "medium", "high"}
|
||||
cases := []struct {
|
||||
want string
|
||||
supported []string
|
||||
expect string
|
||||
}{
|
||||
{"high", qwen, "xhigh"}, // voisin immédiat vers le haut
|
||||
{"xhigh", oss, "high"}, // voisin immédiat vers le bas
|
||||
{"medium", qwen, "low"}, // « medium » refusé quand même : on ne le renvoie pas
|
||||
{"low", []string{"medium", "high"}, "medium"},
|
||||
{"high", nil, ""}, // rien d'annoncé : on retire le champ
|
||||
{"", qwen, ""}, // auto : il n'y avait rien à envoyer
|
||||
{"none", qwen, ""}, // consigne de coupure, pas une intensité
|
||||
{"maximum", qwen, ""}, // hors échelle : on n'invente pas
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := nearestEffort(c.want, c.supported); got != c.expect {
|
||||
t.Errorf("nearestEffort(%q, %v) = %q, attendu %q", c.want, c.supported, got, c.expect)
|
||||
}
|
||||
}
|
||||
// À égalité de distance, le niveau le plus FORT : dégrader en silence est
|
||||
// pire que générer un peu plus longtemps.
|
||||
if got := nearestEffort("medium", []string{"low", "high"}); got != "high" {
|
||||
t.Errorf("égalité de distance = %q, attendu high", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffortFromRejection(t *testing.T) {
|
||||
got, ok := effortFromRejection(qwenRefus, "high")
|
||||
if !ok || got != "xhigh" {
|
||||
t.Fatalf("effortFromRejection = %q, %v ; attendu xhigh, true", got, ok)
|
||||
}
|
||||
if _, ok := effortFromRejection("boom", "high"); ok {
|
||||
t.Error("un 500 quelconque ne doit pas déclencher le repli d'intensité")
|
||||
}
|
||||
}
|
||||
|
||||
// La traduction apprise doit valoir pour les messages SUIVANTS (sinon chaque
|
||||
// message repaie un aller-retour 500) et rester attachée au modèle : un autre
|
||||
// modèle a un autre gabarit, donc d'autres niveaux.
|
||||
func TestEffortMemoire(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{"MODEL": "/data/models/qwen.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := effortResolve("high"); got != "high" {
|
||||
t.Fatalf("sans refus connu : %q, attendu high", got)
|
||||
}
|
||||
effortRemember("high", "xhigh")
|
||||
if got := effortResolve("high"); got != "xhigh" {
|
||||
t.Errorf("après apprentissage : %q, attendu xhigh", got)
|
||||
}
|
||||
if err := WriteConfig(map[string]string{"MODEL": "/data/models/gpt-oss.gguf"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := effortResolve("high"); got != "high" {
|
||||
t.Errorf("autre modèle : %q, attendu high (gabarit inconnu)", got)
|
||||
}
|
||||
// Champ retiré ("") : c'est une réponse apprise, pas une absence de réponse.
|
||||
effortRemember("high", "")
|
||||
if got := effortResolve("high"); got != "" {
|
||||
t.Errorf("repli « champ retiré » : %q, attendu \"\"", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package loki
|
||||
|
||||
// lsp.go — client LSP minimal pour le VÉRIFICATEUR DE CODE : après chaque
|
||||
// write/edit en mode code, les diagnostics du serveur de langage (erreurs de
|
||||
// compilation, types, imports) sont ajoutés AU RÉSULTAT DE L'OUTIL — le modèle
|
||||
// apprend immédiatement que son code ne compile pas, sans lancer de build.
|
||||
// Repris de l'intégration LSP d'OpenFox (manager + languages.json — voir
|
||||
// NOTICE.md), réécrit en Go réduit à l'essentiel : initialize, didOpen/
|
||||
// didChange, publishDiagnostics. Pas de complétion, pas de hover — Loki n'est
|
||||
// pas un éditeur.
|
||||
//
|
||||
// La table langage → serveur vit dans lsp_languages.json (embarquée). Un
|
||||
// serveur absent du PATH désactive silencieusement son langage : le mode code
|
||||
// marche sans LSP, il vérifie juste moins tôt.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed lsp_languages.json
|
||||
var lspLanguagesJSON []byte
|
||||
|
||||
type lspLanguage struct {
|
||||
ID string `json:"id"`
|
||||
Command []string `json:"command"`
|
||||
Extensions []string `json:"extensions"`
|
||||
}
|
||||
|
||||
var (
|
||||
lspLangsOnce sync.Once
|
||||
lspLangs []lspLanguage
|
||||
)
|
||||
|
||||
func lspLanguages() []lspLanguage {
|
||||
lspLangsOnce.Do(func() {
|
||||
_ = json.Unmarshal(lspLanguagesJSON, &lspLangs)
|
||||
})
|
||||
return lspLangs
|
||||
}
|
||||
|
||||
// lspLangFor renvoie la config du langage d'un fichier, nil si aucun.
|
||||
func lspLangFor(path string) *lspLanguage {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range lspLanguages() {
|
||||
for _, e := range lspLangs[i].Extensions {
|
||||
if e == ext {
|
||||
return &lspLangs[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Serveur -----------------------------------------------------------------
|
||||
|
||||
const (
|
||||
lspDiagWait = 2500 * time.Millisecond // attente max des diagnostics
|
||||
lspIdleTimeout = 5 * time.Minute // arrêt d'un serveur inactif
|
||||
lspMaxDiags = 10 // diagnostics montrés au modèle
|
||||
)
|
||||
|
||||
type lspServer struct {
|
||||
lang string
|
||||
root string
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
mu sync.Mutex // sérialise les requêtes (un didOpen+attente à la fois)
|
||||
nextID int
|
||||
version map[string]int // uri → version de document
|
||||
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
|
||||
diagMu sync.Mutex
|
||||
diags map[string][]lspDiagnostic
|
||||
diagCh chan string // uri fraîchement publié
|
||||
lastUsed time.Time
|
||||
dead bool
|
||||
}
|
||||
|
||||
type lspDiagnostic struct {
|
||||
Range struct {
|
||||
Start struct {
|
||||
Line int `json:"line"`
|
||||
Character int `json:"character"`
|
||||
} `json:"start"`
|
||||
} `json:"range"`
|
||||
Severity int `json:"severity"`
|
||||
Message string `json:"message"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
var (
|
||||
lspMu sync.Mutex
|
||||
lspServers = map[string]*lspServer{} // clé : lang + racine
|
||||
)
|
||||
|
||||
// lspDiagBlock : le bloc à AJOUTER au résultat d'un write/edit réussi.
|
||||
// "" si pas de mode code, pas de serveur pour ce langage, ou aucun diagnostic.
|
||||
func lspDiagBlock(absPath string, caps Caps) string {
|
||||
if !caps.Code {
|
||||
return ""
|
||||
}
|
||||
diags := lspDiagnosticsFor(absPath)
|
||||
if diags == "" {
|
||||
return ""
|
||||
}
|
||||
return "\n\n[diagnostics LSP]\n" + diags
|
||||
}
|
||||
|
||||
// lspDiagnosticsFor ouvre (ou met à jour) le document auprès du serveur de son
|
||||
// langage et attend ses diagnostics. Best-effort borné : au pire lspDiagWait.
|
||||
func lspDiagnosticsFor(absPath string) string {
|
||||
lang := lspLangFor(absPath)
|
||||
if lang == nil {
|
||||
return ""
|
||||
}
|
||||
if _, err := exec.LookPath(lang.Command[0]); err != nil {
|
||||
return "" // serveur non installé : le mode code marche sans
|
||||
}
|
||||
root := agentCwd()
|
||||
// Le dépôt cloné est la vraie racine projet quand le fichier est dedans :
|
||||
// gopls a besoin du go.mod, tsserver du tsconfig.
|
||||
if r := projectRootFor(absPath, root); r != "" {
|
||||
root = r
|
||||
}
|
||||
srv, err := lspGet(lang, root)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
diags, ok := srv.check(absPath)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if len(diags) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(diags) > lspMaxDiags {
|
||||
diags = diags[:lspMaxDiags]
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, d := range diags {
|
||||
sev := "info"
|
||||
switch d.Severity {
|
||||
case 1:
|
||||
sev = "error"
|
||||
case 2:
|
||||
sev = "warning"
|
||||
}
|
||||
fmt.Fprintf(&b, "%d:%d [%s] %s\n", d.Range.Start.Line+1, d.Range.Start.Character+1, sev, strings.TrimSpace(d.Message))
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
// projectRootFor remonte du fichier vers ws en cherchant un marqueur de racine
|
||||
// de projet. Renvoie "" si rien trouvé au-dessus du fichier (dans la borne ws).
|
||||
func projectRootFor(absPath, ws string) string {
|
||||
dir := filepath.Dir(absPath)
|
||||
for {
|
||||
for _, marker := range []string{"go.mod", "package.json", "pyproject.toml", ".git"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
if rel, ok := relWithin(ws, dir); !ok || rel == "" || rel == "." {
|
||||
return ""
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return ""
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
|
||||
// lspGet renvoie le serveur (lang, racine), démarré au besoin.
|
||||
func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
key := lang.ID + "\x00" + root
|
||||
lspMu.Lock()
|
||||
defer lspMu.Unlock()
|
||||
if s := lspServers[key]; s != nil && !s.dead {
|
||||
return s, nil
|
||||
}
|
||||
s, err := lspStart(lang, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lspServers[key] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
|
||||
cmd := exec.Command(lang.Command[0], lang.Command[1:]...)
|
||||
cmd.Dir = root
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd.Stderr = io.Discard
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &lspServer{
|
||||
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
|
||||
version: map[string]int{}, diags: map[string][]lspDiagnostic{},
|
||||
diagCh: make(chan string, 16), lastUsed: time.Now(),
|
||||
}
|
||||
go s.readLoop(stdout)
|
||||
go s.idleWatch()
|
||||
// initialize / initialized. La réponse est consommée par readLoop ; on
|
||||
// n'attend pas les capacités — on n'utilise que les diagnostics.
|
||||
s.send("initialize", map[string]any{
|
||||
"processId": nil,
|
||||
"rootUri": fileURI(root),
|
||||
"capabilities": map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"publishDiagnostics": map[string]any{},
|
||||
"synchronization": map[string]any{"didSave": false},
|
||||
},
|
||||
},
|
||||
"workspaceFolders": []map[string]any{{"uri": fileURI(root), "name": filepath.Base(root)}},
|
||||
}, true)
|
||||
s.notify("initialized", map[string]any{})
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// check ouvre (ou met à jour) le document et attend ses diagnostics.
|
||||
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.dead {
|
||||
return nil, false
|
||||
}
|
||||
s.lastUsed = time.Now()
|
||||
content, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
uri := fileURI(absPath)
|
||||
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
|
||||
s.diagMu.Lock()
|
||||
delete(s.diags, uri)
|
||||
s.diagMu.Unlock()
|
||||
v, open := s.version[uri]
|
||||
if !open {
|
||||
s.version[uri] = 1
|
||||
s.notify("textDocument/didOpen", map[string]any{
|
||||
"textDocument": map[string]any{
|
||||
"uri": uri, "languageId": s.lang, "version": 1, "text": string(content),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
v++
|
||||
s.version[uri] = v
|
||||
s.notify("textDocument/didChange", map[string]any{
|
||||
"textDocument": map[string]any{"uri": uri, "version": v},
|
||||
"contentChanges": []map[string]any{{"text": string(content)}},
|
||||
})
|
||||
}
|
||||
// Attend la publication pour CET uri, bornée.
|
||||
deadline := time.After(lspDiagWait)
|
||||
for {
|
||||
select {
|
||||
case got := <-s.diagCh:
|
||||
if got != uri {
|
||||
continue
|
||||
}
|
||||
s.diagMu.Lock()
|
||||
d := s.diags[uri]
|
||||
s.diagMu.Unlock()
|
||||
return d, true
|
||||
case <-deadline:
|
||||
// Pas de publication à temps : certains serveurs ne publient rien
|
||||
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
|
||||
s.diagMu.Lock()
|
||||
d := s.diags[uri]
|
||||
s.diagMu.Unlock()
|
||||
return d, true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop parse les messages LSP (en-têtes Content-Length) et garde les
|
||||
// publishDiagnostics ; tout le reste est ignoré.
|
||||
func (s *lspServer) readLoop(r io.Reader) {
|
||||
br := bufio.NewReaderSize(r, 1<<20)
|
||||
for {
|
||||
length := 0
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
s.markDead()
|
||||
return
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
|
||||
fmt.Sscanf(strings.TrimSpace(v), "%d", &length)
|
||||
}
|
||||
}
|
||||
if length <= 0 || length > 32<<20 {
|
||||
s.markDead()
|
||||
return
|
||||
}
|
||||
buf := make([]byte, length)
|
||||
if _, err := io.ReadFull(br, buf); err != nil {
|
||||
s.markDead()
|
||||
return
|
||||
}
|
||||
var msg struct {
|
||||
Method string `json:"method"`
|
||||
Params struct {
|
||||
URI string `json:"uri"`
|
||||
Diagnostics []lspDiagnostic `json:"diagnostics"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if json.Unmarshal(buf, &msg) != nil {
|
||||
continue
|
||||
}
|
||||
if msg.Method == "textDocument/publishDiagnostics" {
|
||||
s.diagMu.Lock()
|
||||
s.diags[msg.Params.URI] = msg.Params.Diagnostics
|
||||
s.diagMu.Unlock()
|
||||
select {
|
||||
case s.diagCh <- msg.Params.URI:
|
||||
default: // personne n'attend : la publication reste dans s.diags
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *lspServer) idleWatch() {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
s.mu.Lock()
|
||||
idle := time.Since(s.lastUsed) > lspIdleTimeout
|
||||
dead := s.dead
|
||||
s.mu.Unlock()
|
||||
if dead {
|
||||
return
|
||||
}
|
||||
if idle {
|
||||
s.shutdown()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *lspServer) shutdown() {
|
||||
s.markDead()
|
||||
if s.cmd.Process != nil {
|
||||
// shutdown/exit protocolaires seraient plus polis, mais le process peut
|
||||
// être déjà figé — kill borné est fiable et le serveur n'a pas d'état.
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
lspMu.Lock()
|
||||
delete(lspServers, s.lang+"\x00"+s.root)
|
||||
lspMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *lspServer) markDead() {
|
||||
s.mu.Lock()
|
||||
s.dead = true
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *lspServer) send(method string, params any, isRequest bool) {
|
||||
s.nextID++
|
||||
msg := map[string]any{"jsonrpc": "2.0", "method": method, "params": params}
|
||||
if isRequest {
|
||||
msg["id"] = s.nextID
|
||||
}
|
||||
s.writeMsg(msg)
|
||||
}
|
||||
|
||||
func (s *lspServer) notify(method string, params any) { s.send(method, params, false) }
|
||||
|
||||
func (s *lspServer) writeMsg(msg map[string]any) {
|
||||
b, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, err = fmt.Fprintf(s.stdin, "Content-Length: %d\r\n\r\n%s", len(b), b)
|
||||
if err != nil {
|
||||
s.markDead()
|
||||
}
|
||||
}
|
||||
|
||||
// fileURI convertit un chemin absolu en URI file://.
|
||||
func fileURI(path string) string {
|
||||
p := filepath.ToSlash(path)
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
p = "/" + p // Windows : C:\x → /C:/x
|
||||
}
|
||||
return "file://" + p
|
||||
}
|
||||
|
||||
// Pas d'arrêt global : les serveurs LSP sont des enfants du process loki et
|
||||
// meurent avec lui (et l'inactivité de 5 min couvre le reste — idleWatch).
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"id": "go",
|
||||
"command": ["gopls", "serve"],
|
||||
"extensions": [".go"]
|
||||
},
|
||||
{
|
||||
"id": "typescript",
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]
|
||||
},
|
||||
{
|
||||
"id": "python",
|
||||
"command": ["pyright-langserver", "--stdio"],
|
||||
"extensions": [".py"]
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,157 @@
|
||||
package loki
|
||||
|
||||
// Catalogue MCP embarqué.
|
||||
//
|
||||
// Ajouter un serveur MCP demandait jusqu'ici d'écrire soi-même la commande et
|
||||
// ses arguments (`npx -y @scope/paquet …`) dans la modale. Le catalogue fournit
|
||||
// une liste de serveurs connus, déjà renseignés : un clic préremplit la modale,
|
||||
// l'utilisateur relit la commande et enregistre.
|
||||
//
|
||||
// Le catalogue est un JSON EMBARQUÉ dans le binaire (go:embed), pas un appel à
|
||||
// un annuaire distant : cohérent avec un Loki qui tourne hors ligne, et rien de
|
||||
// ce qui s'exécute sur la machine ne vient du réseau. Pour proposer un serveur
|
||||
// de plus : éditer mcp_catalog.json et recompiler.
|
||||
//
|
||||
// SÉCURITÉ : le catalogue ne fait qu'AFFICHER une commande. Il n'installe ni
|
||||
// n'exécute rien tout seul — c'est l'enregistrement du serveur par
|
||||
// l'utilisateur, via la modale existante, qui décide. Même niveau de confiance
|
||||
// que l'outil `bash` du mode agent (voir l'en-tête de mcp_config.go).
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed mcp_catalog.json
|
||||
var mcpCatalogRaw []byte
|
||||
|
||||
// MCPCatalogEntry décrit un serveur proposé par le catalogue. Les champs de
|
||||
// transport reprennent ceux de MCPServerConfig : l'UI peut les recopier tels
|
||||
// quels dans la modale d'ajout.
|
||||
type MCPCatalogEntry struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
Homepage string `json:"homepage,omitempty"`
|
||||
|
||||
// Runtime : binaire nécessaire pour lancer le serveur ("node" → npx,
|
||||
// "python" → uvx). Sert à signaler une entrée non installable ici.
|
||||
Runtime string `json:"runtime,omitempty"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
|
||||
// Secrets : variables d'environnement que l'utilisateur DOIT renseigner
|
||||
// (jetons, clés d'API). L'UI les met en évidence ; elles sont volontairement
|
||||
// vides dans le catalogue.
|
||||
Secrets []string `json:"secrets,omitempty"`
|
||||
|
||||
// Renseignés à la lecture, pas dans le fichier.
|
||||
Installed bool `json:"installed"`
|
||||
RuntimeOK bool `json:"runtimeOk"`
|
||||
}
|
||||
|
||||
type mcpCatalogFile struct {
|
||||
Version int `json:"version"`
|
||||
Servers []MCPCatalogEntry `json:"servers"`
|
||||
}
|
||||
|
||||
var (
|
||||
mcpCatalogOnce sync.Once
|
||||
mcpCatalogParsed []MCPCatalogEntry
|
||||
mcpCatalogParseEr error
|
||||
)
|
||||
|
||||
// mcpCatalogEntries décode le JSON embarqué une seule fois. Le contenu est figé
|
||||
// dans le binaire : inutile de le relire à chaque appel.
|
||||
func mcpCatalogEntries() ([]MCPCatalogEntry, error) {
|
||||
mcpCatalogOnce.Do(func() {
|
||||
var f mcpCatalogFile
|
||||
if err := json.Unmarshal(mcpCatalogRaw, &f); err != nil {
|
||||
mcpCatalogParseEr = fmt.Errorf("catalogue MCP illisible : %w", err)
|
||||
return
|
||||
}
|
||||
mcpCatalogParsed = f.Servers
|
||||
})
|
||||
return mcpCatalogParsed, mcpCatalogParseEr
|
||||
}
|
||||
|
||||
// mcpRuntimeAvailable indique si le binaire qui lance ce type de serveur est
|
||||
// présent. L'image conteneur embarque Node ; uv/uvx peut manquer.
|
||||
func mcpRuntimeAvailable(runtime string) bool {
|
||||
switch runtime {
|
||||
case "node":
|
||||
return lookPathOK("npx")
|
||||
case "python":
|
||||
return lookPathOK("uvx")
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func lookPathOK(bin string) bool {
|
||||
_, err := exec.LookPath(bin)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// MCPCatalog renvoie les entrées du catalogue, chemins résolus et marquées
|
||||
// « déjà installée » (un serveur configuré porte le même nom que l'entrée).
|
||||
func MCPCatalog() ([]MCPCatalogEntry, error) {
|
||||
entries, err := mcpCatalogEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configured, err := LoadMCPConfig()
|
||||
if err != nil {
|
||||
// Une config MCP illisible ne doit pas vider le catalogue : on affiche
|
||||
// les entrées sans savoir lesquelles sont déjà là.
|
||||
configured = map[string]MCPServerConfig{}
|
||||
}
|
||||
|
||||
out := make([]MCPCatalogEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
e.Args = expandCatalogSlice(e.Args)
|
||||
e.Env = expandCatalogMap(e.Env)
|
||||
_, e.Installed = configured[e.ID]
|
||||
e.RuntimeOK = mcpRuntimeAvailable(e.Runtime)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// expandCatalogToken remplace les jetons de chemin du catalogue. Seul
|
||||
// {workspace} existe : c'est le dossier de travail de l'agent, le seul chemin
|
||||
// qu'un serveur du catalogue a une raison légitime de recevoir par défaut.
|
||||
func expandCatalogToken(s string) string {
|
||||
if !strings.Contains(s, "{workspace}") {
|
||||
return s
|
||||
}
|
||||
return strings.ReplaceAll(s, "{workspace}", workspaceDir())
|
||||
}
|
||||
|
||||
func expandCatalogSlice(in []string) []string {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = expandCatalogToken(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func expandCatalogMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return in
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = expandCatalogToken(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
{
|
||||
"version": 1,
|
||||
"note": "Catalogue MCP embarqué (hors ligne, aucun annuaire distant). Éditable à la main : ajoute une entrée pour la proposer dans l'interface, puis relance le build. Le jeton {workspace} est remplacé par le dossier de travail de l'agent. Les entrées « uvx --with mcp<2 … » visent des serveurs publiés avant le SDK MCP 2.0 : sans cet épinglage, ils plantent au démarrage (ImportError McpError).",
|
||||
"servers": [
|
||||
{
|
||||
"id": "filesystem",
|
||||
"name": "Filesystem",
|
||||
"category": "Fichiers",
|
||||
"description": "Lecture, écriture et recherche de fichiers, limitées aux dossiers passés en argument.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-filesystem", "{workspace}"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Git",
|
||||
"category": "Code",
|
||||
"description": "Historique, diff et statut d'un dépôt Git ; commits et branches.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server-git", "--repository", "{workspace}"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "fetch",
|
||||
"name": "Fetch",
|
||||
"category": "Web",
|
||||
"description": "Télécharge une URL et la convertit en Markdown pour le modèle.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-fetch"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "memory",
|
||||
"name": "Memory",
|
||||
"category": "Agent",
|
||||
"description": "Mémoire sous forme de graphe de connaissances (entités, relations, observations).",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-memory"],
|
||||
"env": { "MEMORY_FILE_PATH": "{workspace}/mcp-memory.json" },
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "sequential-thinking",
|
||||
"name": "Sequential Thinking",
|
||||
"category": "Agent",
|
||||
"description": "Structure le raisonnement en étapes révisables. Utile aux petits modèles locaux.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "time",
|
||||
"name": "Time",
|
||||
"category": "Utilitaires",
|
||||
"description": "Heure courante et conversions de fuseaux horaires.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-time"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "sqlite",
|
||||
"name": "SQLite",
|
||||
"category": "Données",
|
||||
"description": "Interroge et modifie une base SQLite locale.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/sqlite",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-sqlite", "--db-path", "{workspace}/agent.db"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "everything",
|
||||
"name": "Everything (test)",
|
||||
"category": "Utilitaires",
|
||||
"description": "Serveur de référence exposant toutes les primitives MCP. Sert à vérifier l'intégration.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-everything"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "duckduckgo",
|
||||
"name": "DuckDuckGo",
|
||||
"category": "Web",
|
||||
"description": "Recherche web via DuckDuckGo, sans clé d'API.",
|
||||
"homepage": "https://pypi.org/project/duckduckgo-mcp-server/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["duckduckgo-mcp-server"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "playwright",
|
||||
"name": "Playwright",
|
||||
"category": "Web",
|
||||
"description": "Pilote un navigateur (navigation, clics, formulaires) via l'arbre d'accessibilité.",
|
||||
"homepage": "https://github.com/microsoft/playwright-mcp",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--headless"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "puppeteer",
|
||||
"name": "Puppeteer",
|
||||
"category": "Web",
|
||||
"description": "Automatisation d'un Chrome sans interface (alternative à Playwright).",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/puppeteer",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "context7",
|
||||
"name": "Context7",
|
||||
"category": "Code",
|
||||
"description": "Documentation à jour des bibliothèques, injectée directement dans le contexte.",
|
||||
"homepage": "https://github.com/upstash/context7",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "github",
|
||||
"name": "GitHub",
|
||||
"category": "Code",
|
||||
"description": "Dépôts, issues et pull requests via l'API GitHub. Nécessite un jeton personnel.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-github"],
|
||||
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" },
|
||||
"secrets": ["GITHUB_PERSONAL_ACCESS_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "postgres",
|
||||
"name": "PostgreSQL",
|
||||
"category": "Données",
|
||||
"description": "Requêtes SQL en lecture seule et inspection du schéma d'une base PostgreSQL.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-postgres",
|
||||
"postgresql://utilisateur:motdepasse@hote:5432/base"
|
||||
],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"name": "Docker",
|
||||
"category": "Infra",
|
||||
"description": "Gère conteneurs et stacks compose. Nécessite l'accès au socket Docker.",
|
||||
"homepage": "https://pypi.org/project/docker-mcp/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "docker-mcp"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
},
|
||||
{
|
||||
"id": "brave-search",
|
||||
"name": "Brave Search",
|
||||
"category": "Web",
|
||||
"description": "Recherche web et locale via l'API Brave. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
|
||||
"env": { "BRAVE_API_KEY": "" },
|
||||
"secrets": ["BRAVE_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "firecrawl",
|
||||
"name": "Firecrawl",
|
||||
"category": "Web",
|
||||
"description": "Scraping et crawl de sites avec extraction structurée. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/firecrawl/firecrawl-mcp-server",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "firecrawl-mcp"],
|
||||
"env": { "FIRECRAWL_API_KEY": "" },
|
||||
"secrets": ["FIRECRAWL_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "notion",
|
||||
"name": "Notion",
|
||||
"category": "Productivité",
|
||||
"description": "Lecture et écriture de pages et bases Notion. Nécessite un jeton d'intégration.",
|
||||
"homepage": "https://github.com/makenotion/notion-mcp-server",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@notionhq/notion-mcp-server"],
|
||||
"env": { "NOTION_TOKEN": "" },
|
||||
"secrets": ["NOTION_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "slack",
|
||||
"name": "Slack",
|
||||
"category": "Productivité",
|
||||
"description": "Lecture des canaux et envoi de messages Slack. Nécessite un jeton de bot.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/slack",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-slack"],
|
||||
"env": { "SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": "" },
|
||||
"secrets": ["SLACK_BOT_TOKEN"]
|
||||
},
|
||||
{
|
||||
"id": "google-maps",
|
||||
"name": "Google Maps",
|
||||
"category": "Utilitaires",
|
||||
"description": "Géocodage, itinéraires et recherche de lieux. Nécessite une clé d'API.",
|
||||
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/google-maps",
|
||||
"runtime": "node",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@modelcontextprotocol/server-google-maps"],
|
||||
"env": { "GOOGLE_MAPS_API_KEY": "" },
|
||||
"secrets": ["GOOGLE_MAPS_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "sentry",
|
||||
"name": "Sentry",
|
||||
"category": "Infra",
|
||||
"description": "Récupère les issues et traces d'erreur d'un projet Sentry.",
|
||||
"homepage": "https://pypi.org/project/mcp-server-sentry/",
|
||||
"runtime": "python",
|
||||
"command": "uvx",
|
||||
"args": ["--with", "mcp<2", "mcp-server-sentry", "--auth-token", "REMPLACE_MOI"],
|
||||
"env": {},
|
||||
"secrets": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le catalogue est embarqué : s'il ne décode pas, le binaire livré est cassé et
|
||||
// le panneau MCP part vide. Ce test attrape une virgule en trop au build.
|
||||
func TestMCPCatalogDecodes(t *testing.T) {
|
||||
entries, err := mcpCatalogEntries()
|
||||
if err != nil {
|
||||
t.Fatalf("catalogue illisible : %v", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
t.Fatal("catalogue vide")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, e := range entries {
|
||||
if e.ID == "" || e.Name == "" || e.Command == "" {
|
||||
t.Errorf("entrée incomplète : %+v", e)
|
||||
}
|
||||
if seen[e.ID] {
|
||||
t.Errorf("id dupliqué : %s", e.ID)
|
||||
}
|
||||
seen[e.ID] = true
|
||||
if e.Description == "" {
|
||||
t.Errorf("%s : description manquante", e.ID)
|
||||
}
|
||||
// Un secret annoncé doit exister comme variable d'environnement, sinon
|
||||
// l'UI met en avant un champ que l'utilisateur ne peut pas remplir.
|
||||
for _, s := range e.Secrets {
|
||||
if _, ok := e.Env[s]; !ok {
|
||||
t.Errorf("%s : secret %q absent de env", e.ID, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les jetons {workspace} doivent disparaître : un serveur lancé avec un chemin
|
||||
// littéral « {workspace} » démarre puis échoue à la première lecture.
|
||||
func TestMCPCatalogExpandsWorkspaceToken(t *testing.T) {
|
||||
entries, err := MCPCatalog()
|
||||
if err != nil {
|
||||
t.Fatalf("MCPCatalog : %v", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
for _, a := range e.Args {
|
||||
if strings.Contains(a, "{workspace}") {
|
||||
t.Errorf("%s : jeton non résolu dans args : %s", e.ID, a)
|
||||
}
|
||||
}
|
||||
for k, v := range e.Env {
|
||||
if strings.Contains(v, "{workspace}") {
|
||||
t.Errorf("%s : jeton non résolu dans env[%s] : %s", e.ID, k, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningEffortValue(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
" ": "",
|
||||
"auto": "",
|
||||
"default": "",
|
||||
"off": "none",
|
||||
"none": "none",
|
||||
"false": "none",
|
||||
"0": "none",
|
||||
"low": "low",
|
||||
"MEDIUM": "medium",
|
||||
" High ": "high",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := reasoningEffortValue(in); got != want {
|
||||
t.Errorf("reasoningEffortValue(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningExplicitlyOff(t *testing.T) {
|
||||
// Clé absente ou vide = pas de consigne, surtout pas une interdiction.
|
||||
for _, v := range []string{"", " ", "on", "auto", "deepseek"} {
|
||||
if reasoningExplicitlyOff(v) {
|
||||
t.Errorf("reasoningExplicitlyOff(%q) = true, attendu false", v)
|
||||
}
|
||||
}
|
||||
for _, v := range []string{"off", "none", "0", "false", " Disabled "} {
|
||||
if !reasoningExplicitlyOff(v) {
|
||||
t.Errorf("reasoningExplicitlyOff(%q) = false, attendu true", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReasoningTemplateKwargs(t *testing.T) {
|
||||
// « aucune » doit parler aux gabarits hybrides, pas seulement à ceux qui
|
||||
// lisent reasoning_effort : sinon le modèle réfléchit quand même.
|
||||
if got := reasoningTemplateKwargs(false, "none"); got == nil || got["enable_thinking"] != false {
|
||||
t.Errorf("effort none : %v, attendu enable_thinking=false", got)
|
||||
}
|
||||
if got := reasoningTemplateKwargs(true, ""); got == nil || got["enable_thinking"] != false {
|
||||
t.Errorf("REASONING=off : %v, attendu enable_thinking=false", got)
|
||||
}
|
||||
// Un niveau explicite voyage aussi dans les kwargs : les binaires anciens ne
|
||||
// relaient pas le champ de haut niveau au gabarit.
|
||||
if got := reasoningTemplateKwargs(false, "high"); got == nil || got["reasoning_effort"] != "high" {
|
||||
t.Errorf("effort high : %v, attendu reasoning_effort=high", got)
|
||||
}
|
||||
if _, ok := reasoningTemplateKwargs(false, "high")["enable_thinking"]; ok {
|
||||
t.Error("un niveau explicite ne doit pas couper le raisonnement")
|
||||
}
|
||||
// Auto = aucune consigne : on n'écrit rien dans la requête.
|
||||
if got := reasoningTemplateKwargs(false, ""); got != nil {
|
||||
t.Errorf("auto : %v, attendu nil", got)
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package loki
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -37,6 +39,13 @@ const (
|
||||
// mcpConnectTimeout borne l'établissement d'une session (handshake initialize
|
||||
// + tools/list). Un serveur qui rame ne doit pas figer le tour de chat.
|
||||
mcpConnectTimeout = 20 * time.Second
|
||||
// mcpConnectTimeoutCold : npx et uvx TÉLÉCHARGENT le paquet du serveur au
|
||||
// premier lancement (le catalogue les utilise tous les deux). Sur un réseau
|
||||
// ou un disque lents, ça dépasse largement 20 s, et l'utilisateur voyait
|
||||
// « enregistré mais connexion échouée : context deadline exceeded » pour un
|
||||
// serveur parfaitement sain. Les lancements suivants profitent du cache et
|
||||
// retombent dans le régime normal.
|
||||
mcpConnectTimeoutCold = 3 * time.Minute
|
||||
// mcpCallTimeout borne un appel d'outil MCP.
|
||||
mcpCallTimeout = 120 * time.Second
|
||||
// mcpMaxOutput cap la sortie renvoyée au modèle (cohérent avec toolMaxOutput).
|
||||
@@ -174,11 +183,11 @@ func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession {
|
||||
m.mu.Unlock()
|
||||
|
||||
// Connexion hors-lock (peut être lente).
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpConnectTimeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg))
|
||||
defer cancel()
|
||||
sess, err := mcpConnect(ctx, name, cfg)
|
||||
if err != nil {
|
||||
s.err = err
|
||||
s.err = mcpFriendlyErr(err, cfg)
|
||||
} else {
|
||||
s.sess = sess
|
||||
lt, lerr := sess.ListTools(ctx, nil)
|
||||
@@ -509,3 +518,30 @@ func MCPStatus() ([]MCPServerStatus, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mcpTimeoutFor choisit le délai de connexion d'un serveur : long pour un
|
||||
// lanceur qui télécharge son paquet à la volée (npx, uvx), court sinon.
|
||||
func mcpTimeoutFor(cfg MCPServerConfig) time.Duration {
|
||||
base := strings.ToLower(filepath.Base(cfg.Command))
|
||||
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
|
||||
if base == "npx" || base == "uvx" {
|
||||
return mcpConnectTimeoutCold
|
||||
}
|
||||
return mcpConnectTimeout
|
||||
}
|
||||
|
||||
// mcpFriendlyErr habille l'erreur de connexion la plus vécue : le délai
|
||||
// dépassé d'un npx/uvx en plein téléchargement. L'erreur brute (« context
|
||||
// deadline exceeded ») envoyait l'utilisateur chercher un bug de config alors
|
||||
// qu'il suffit souvent de réessayer une fois le paquet en cache.
|
||||
func mcpFriendlyErr(err error, cfg MCPServerConfig) error {
|
||||
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
|
||||
return err
|
||||
}
|
||||
base := strings.ToLower(filepath.Base(cfg.Command))
|
||||
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
|
||||
if base == "npx" || base == "uvx" {
|
||||
return fmt.Errorf("délai dépassé — %s télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", base)
|
||||
}
|
||||
return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s", mcpConnectTimeout)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Repris de l'amont AJEAN (v0.9.8) avec le correctif : les tests valent tels
|
||||
// quels, la fonction étant portée à l'identique.
|
||||
//
|
||||
// LE test qui rassure : sur une conversation NORMALE (un seul system en tête,
|
||||
// suivi de user/assistant/tool, appels d'outils et contenu multimodal compris),
|
||||
// la normalisation ne doit RIEN changer — même contenu, même ordre. C'est la
|
||||
// garantie que le fix est invisible pour les 99 % de cas qui marchaient déjà.
|
||||
func TestNormalizeSystemMessagesNoOpOnNormalConversation(t *testing.T) {
|
||||
in := []Message{
|
||||
{Role: "system", Content: "Tu es Loki."},
|
||||
{Role: "user", Content: "cherche la météo"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_1", Type: "function"}}},
|
||||
{Role: "tool", Content: "résultat brut", ToolCallID: "call_1"},
|
||||
{Role: "assistant", Content: "Il fait beau."},
|
||||
// contenu multimodal (image) : Content n'est pas une string
|
||||
{Role: "user", Content: []any{map[string]any{"type": "text", "text": "et demain ?"}}},
|
||||
}
|
||||
// copie profonde de référence pour détecter toute mutation de l'entrée
|
||||
ref := append([]Message(nil), in...)
|
||||
out := normalizeSystemMessages(in)
|
||||
if !reflect.DeepEqual(out, in) {
|
||||
t.Errorf("séquence normale modifiée !\navant: %#v\naprès: %#v", in, out)
|
||||
}
|
||||
if !reflect.DeepEqual(in, ref) {
|
||||
t.Errorf("l'entrée a été mutée (interdit) : %#v", in)
|
||||
}
|
||||
}
|
||||
|
||||
// L'ordre relatif de tous les messages NON-system (et le lien assistant→tool via
|
||||
// ToolCallID) doit être préservé quand on ramène un system égaré en tête.
|
||||
func TestNormalizeSystemMessagesPreservesOrderAndToolLink(t *testing.T) {
|
||||
in := []Message{
|
||||
{Role: "system", Content: "S1"},
|
||||
{Role: "user", Content: "q"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_9"}}},
|
||||
{Role: "tool", Content: "r", ToolCallID: "call_9"},
|
||||
{Role: "system", Content: "n'appelle plus d'outil"}, // égaré, en fin
|
||||
}
|
||||
out := normalizeSystemMessages(in)
|
||||
want := []Message{
|
||||
{Role: "system", Content: "S1\n\nn'appelle plus d'outil"},
|
||||
{Role: "user", Content: "q"},
|
||||
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_9"}}},
|
||||
{Role: "tool", Content: "r", ToolCallID: "call_9"},
|
||||
}
|
||||
if !reflect.DeepEqual(out, want) {
|
||||
t.Errorf("ordre/lien outil cassé :\n%#v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Un system inséré en fin de séquence (relance anti-boucle) ou en double doit
|
||||
// être ramené en un seul bloc, en tête : sinon les gabarits Qwen3.x renvoient
|
||||
// « System message must be at the beginning » (issue #26).
|
||||
func TestNormalizeSystemMessages(t *testing.T) {
|
||||
sys := func(c string) Message { return Message{Role: "system", Content: c} }
|
||||
usr := func(c string) Message { return Message{Role: "user", Content: c} }
|
||||
|
||||
firstRoleSystemOnce := func(t *testing.T, out []Message) {
|
||||
count := 0
|
||||
for i, m := range out {
|
||||
if m.Role == "system" {
|
||||
count++
|
||||
if i != 0 {
|
||||
t.Errorf("system trouvé en position %d, doit être en 0", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
t.Errorf("%d messages system, doit être fusionné en un seul", count)
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("system en fin", func(t *testing.T) {
|
||||
out := normalizeSystemMessages([]Message{sys("A"), usr("q"), sys("stop")})
|
||||
firstRoleSystemOnce(t, out)
|
||||
if out[0].Content != "A\n\nstop" {
|
||||
t.Errorf("fusion attendue 'A\\n\\nstop', obtenu %q", out[0].Content)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("attendu 2 messages, obtenu %d", len(out))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sans system, inchangé", func(t *testing.T) {
|
||||
in := []Message{usr("a"), usr("b")}
|
||||
out := normalizeSystemMessages(in)
|
||||
if len(out) != 2 || out[0].Role != "user" {
|
||||
t.Fatalf("séquence sans system ne doit pas changer : %#v", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("system vide ignoré", func(t *testing.T) {
|
||||
out := normalizeSystemMessages([]Message{sys(" "), usr("q")})
|
||||
if len(out) != 1 || out[0].Role != "user" {
|
||||
t.Fatalf("un system vide ne doit rien ajouter : %#v", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.9.4"
|
||||
const Version = "0.12.3"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
|
||||
func Main() {
|
||||
|
||||
@@ -33,6 +33,7 @@ const (
|
||||
bkPrefs = "prefs" // préférences de l'UI web
|
||||
bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
|
||||
bkChat = "chat" // conversation partagée
|
||||
bkTasks = "tasks" // tâches planifiées (une entrée JSON par tâche)
|
||||
)
|
||||
|
||||
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package loki
|
||||
|
||||
// sys_datavolume.go — garde-fou contre la PERTE DE DONNÉES en conteneur.
|
||||
//
|
||||
// Le symptôme vécu : modèles, discussions et fichiers disparus après un
|
||||
// redémarrage du serveur. La cause, dans tous les cas observés : /data n'est
|
||||
// PAS un volume monté (template Docker sans mapping, `docker run` sans -v,
|
||||
// chemin hôte qui change d'un lancement à l'autre). Tout vit alors dans la
|
||||
// couche d'écriture du conteneur, qui meurt avec lui à la première recréation
|
||||
// (mise à jour d'image, compose down/up, redémarrage de l'array Unraid).
|
||||
//
|
||||
// Loki ne peut pas monter le volume à la place de l'utilisateur, mais il peut
|
||||
// le VOIR et le DIRE : au démarrage en conteneur, si LOKI_HOME n'est pas un
|
||||
// point de montage, on le journalise en rouge et l'UI l'affiche en bandeau.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// dataDirIsMounted dit si LokiHome() est un point de montage (bind mount ou
|
||||
// volume Docker). Lecture de /proc/self/mountinfo — Linux seulement, or c'est
|
||||
// le seul endroit où Loki tourne en conteneur. Hors conteneur : sans objet.
|
||||
//
|
||||
// Renvoie (monté, connu) : connu=false quand on n'a pas pu lire mountinfo —
|
||||
// on ne crie pas au loup sur une simple erreur de lecture.
|
||||
func dataDirIsMounted() (mounted, known bool) {
|
||||
f, err := os.Open("/proc/self/mountinfo")
|
||||
if err != nil {
|
||||
return false, false
|
||||
}
|
||||
defer f.Close()
|
||||
target := filepath.Clean(LokiHome())
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
// Format mountinfo : champs séparés par des espaces, le 5ᵉ (index 4)
|
||||
// est le point de montage. Les espaces dans les chemins sont encodés
|
||||
// \040 — décodés avant comparaison.
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) < 5 {
|
||||
continue
|
||||
}
|
||||
mp := strings.ReplaceAll(fields[4], `\040`, " ")
|
||||
if filepath.Clean(mp) == target {
|
||||
return true, true
|
||||
}
|
||||
}
|
||||
return false, sc.Err() == nil
|
||||
}
|
||||
|
||||
// dataVolumeWarning renvoie le message d'alerte si les données sont en périple
|
||||
// ("" si tout va bien). Évalué au démarrage du serveur web et servi par
|
||||
// /api/status pour le bandeau de l'UI.
|
||||
func dataVolumeWarning() string {
|
||||
if os.Getenv("LOKI_CONTAINER") == "" {
|
||||
return "" // hors conteneur : l'utilisateur gère ses chemins
|
||||
}
|
||||
mounted, known := dataDirIsMounted()
|
||||
if !known || mounted {
|
||||
return ""
|
||||
}
|
||||
return "⚠️ " + LokiHome() + " n'est PAS un volume monté : modèles, discussions et fichiers seront PERDUS à la recréation du conteneur (mise à jour, redémarrage du serveur). " +
|
||||
"Ajoute un mapping de volume — compose : « - /mnt/user/appdata/loki/data:/data » (et « …/models:/models ») ; " +
|
||||
"docker run : « -v /chemin/hote/data:/data ». Voir docker-compose.unraid.yml du dépôt."
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
// sys_disk_fs_linux.go — l'espace libre annoncé par le système est-il DIGNE DE
|
||||
// FOI pour décider d'un téléchargement ?
|
||||
//
|
||||
// Sur Unraid, les partages vivent sous /mnt/user, servi par shfs — un système
|
||||
// de fichiers FUSE qui agrège plusieurs disques et le cache. statfs(2) n'y
|
||||
// renvoie pas l'espace du partage : selon la configuration, c'est celui d'UN
|
||||
// disque, ou du pool de cache. Vécu : « il ne reste que 11,9 Go » sur une
|
||||
// machine qui en a des centaines de libres, et le téléchargement du modèle
|
||||
// REFUSÉ sur cette base.
|
||||
//
|
||||
// Même problème sur les montages réseau (NFS, CIFS) et sur la couche
|
||||
// d'écriture d'un conteneur (overlayfs), qui rapporte le disque virtuel Docker
|
||||
// et non le volume monté dessous.
|
||||
//
|
||||
// On ne cherche pas à corriger le chiffre — c'est impossible depuis l'intérieur
|
||||
// du conteneur. On sait juste dire « ce chiffre est indicatif », et on cesse
|
||||
// alors de bloquer sur lui.
|
||||
|
||||
import "syscall"
|
||||
|
||||
// Magies de systèmes de fichiers dont statfs ment (ou approxime) sur l'espace
|
||||
// libre. Valeurs de <linux/magic.h>.
|
||||
const (
|
||||
fuseSuperMagic = 0x65735546 // FUSE — shfs d'Unraid, sshfs, rclone…
|
||||
overlayfsMagic = 0x794c7630 // couche d'écriture d'un conteneur
|
||||
nfsSuperMagic = 0x6969
|
||||
smbSuperMagic = 0x517b // CIFS/SMB1
|
||||
smb2MagicNumber = 0xfe534d42 // CIFS/SMB2+
|
||||
cephSuperMagic = 0x00c36400
|
||||
glusterfsSuperMag = 0x58465342
|
||||
)
|
||||
|
||||
// diskFreeReliable dit si l'espace libre rapporté pour dir peut servir à
|
||||
// REFUSER une écriture. false = on affiche le chiffre, mais on ne bloque plus.
|
||||
func diskFreeReliable(dir string) bool {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(dir, &st); err != nil {
|
||||
return false
|
||||
}
|
||||
switch uint32(st.Type) {
|
||||
case fuseSuperMagic, overlayfsMagic, nfsSuperMagic,
|
||||
smbSuperMagic, smb2MagicNumber, cephSuperMagic, glusterfsSuperMag:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package loki
|
||||
|
||||
// Hors Linux, on n'a pas de table de magies de systèmes de fichiers à
|
||||
// consulter : l'espace libre rapporté est considéré comme fiable (c'est le cas
|
||||
// des volumes locaux Windows et macOS, les seules cibles concernées ici).
|
||||
func diskFreeReliable(string) bool { return true }
|
||||
@@ -0,0 +1,104 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
// sys_loadpct_linux.go — pourcentage de CHARGEMENT du modèle, pour la pastille
|
||||
// « chargement… » de l'UI qui restait muette de longues minutes sur un gros
|
||||
// GGUF (un 27B Q4 met facilement 1 à 3 minutes à monter en VRAM).
|
||||
//
|
||||
// llama-server n'expose aucun progrès pendant le chargement (/health renvoie
|
||||
// juste 503). Mais le chargement, c'est LIRE le fichier .gguf : on mesure donc
|
||||
// les octets lus par le process moteur (/proc/<pid>/io, read_bytes — les pages
|
||||
// mmap rentrées comptent dedans) et on les rapporte à la taille du fichier.
|
||||
// Approximation honnête : monotone, ~0→100, et surtout elle BOUGE — c'est ce
|
||||
// qui distingue « ça charge » de « c'est planté ».
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// procVmRSS lit la mémoire résidente (octets) d'un process — /proc/<pid>/status,
|
||||
// ligne « VmRSS: N kB ». 0 si illisible.
|
||||
func procVmRSS(pid int) int64 {
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "VmRSS:"); ok {
|
||||
v = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB"))
|
||||
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
|
||||
return n * 1024
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// engineLoadPct renvoie le pourcentage estimé de chargement du modèle, ou -1
|
||||
// quand il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue).
|
||||
func engineLoadPct() int {
|
||||
pid := readServicePID()
|
||||
if pid <= 0 {
|
||||
return -1
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
model := cfg["MODEL"]
|
||||
if model == "" {
|
||||
return -1
|
||||
}
|
||||
path, err := resolveServeModelPath(model)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
size := modelTotalSize(path)
|
||||
if size <= 0 {
|
||||
return -1
|
||||
}
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/io")
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
var read int64 = -1
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
if v, ok := strings.CutPrefix(line, "read_bytes:"); ok {
|
||||
read, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
|
||||
break
|
||||
}
|
||||
}
|
||||
if read < 0 {
|
||||
return -1
|
||||
}
|
||||
// Modèle déjà dans le cache disque (redémarrage à chaud) : llama-server ne
|
||||
// LIT plus rien sur le disque — les pages viennent du cache, read_bytes
|
||||
// reste à zéro et le pourcentage restait figé à 0 jusqu'au « prêt ». La
|
||||
// mémoire résidente (VmRSS), elle, grandit quoi qu'il arrive : les pages du
|
||||
// .gguf touchées via mmap y comptent, cache ou pas. On prend le plus avancé
|
||||
// des deux — approximatif (l'offload GPU peut relâcher des pages), mais le
|
||||
// compteur BOUGE au lieu de mentir.
|
||||
if rss := procVmRSS(pid); rss > read {
|
||||
read = rss
|
||||
}
|
||||
pct := int(read * 100 / size)
|
||||
if pct > 99 {
|
||||
// 100 % n'existe pas ici : c'est /health qui dit « prêt ». Au-delà de 99
|
||||
// (relectures, autres IO du process), on plafonne.
|
||||
pct = 99
|
||||
}
|
||||
return pct
|
||||
}
|
||||
|
||||
// modelTotalSize : taille du modèle, shards compris (model-00001-of-00004.gguf
|
||||
// → somme des tranches présentes — c'est tout ça qu'il faut charger).
|
||||
func modelTotalSize(path string) int64 {
|
||||
if total := shardFamilySize(filepath.Dir(path), filepath.Base(path)); total > 0 {
|
||||
return total
|
||||
}
|
||||
if st, err := os.Stat(path); err == nil {
|
||||
return st.Size()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package loki
|
||||
|
||||
// Hors Linux, pas de /proc/<pid>/io : le pourcentage de chargement n'est pas
|
||||
// mesurable, l'UI garde « chargement… » sans chiffre.
|
||||
func engineLoadPct() int { return -1 }
|
||||
@@ -262,6 +262,12 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "/bin/bash", "-c", command)
|
||||
}
|
||||
|
||||
// newShellCmdDetached : commande shell SANS contexte, pour les jobs
|
||||
// d'arrière-plan (bash_bg) qui doivent survivre au tour qui les a lancés.
|
||||
func newShellCmdDetached(command string) *exec.Cmd {
|
||||
return exec.Command("/bin/bash", "-c", command)
|
||||
}
|
||||
|
||||
// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram. Linux : /proc/meminfo.
|
||||
// macOS : sysctl pour le total, vm_stat pour ce qui est réellement libre (pages
|
||||
// free + inactive + speculative ; le reste — wired, active, compressé — est
|
||||
|
||||
@@ -391,6 +391,12 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
|
||||
return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command))
|
||||
}
|
||||
|
||||
// newShellCmdDetached : commande shell SANS contexte, pour les jobs
|
||||
// d'arrière-plan (bash_bg) qui doivent survivre au tour qui les a lancés.
|
||||
func newShellCmdDetached(command string) *exec.Cmd {
|
||||
return hideCmd(exec.Command("cmd", "/C", command))
|
||||
}
|
||||
|
||||
// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram (GlobalMemoryStatusEx).
|
||||
func ramUsageMB() (used, total int) {
|
||||
var m struct {
|
||||
|
||||
@@ -67,7 +67,7 @@ var configTemplate = []struct{ key, help string }{
|
||||
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
|
||||
{"PORT", "port du moteur (défaut 8080)"},
|
||||
{"CTX", "taille du contexte (défaut 32768)"},
|
||||
{"NGL", "couches déportées sur le GPU (défaut 999 = tout ; auto = ce qui tient en VRAM)"},
|
||||
{"NGL", "couches déportées sur le GPU (999 ou auto = ce qui tient en VRAM ; all = tout, quitte à saturer)"},
|
||||
{"BATCH", "batch (défaut 2048)"},
|
||||
{"UBATCH", "micro-batch (défaut 512)"},
|
||||
{"THREADS", "threads CPU, 0 = auto"},
|
||||
@@ -75,6 +75,13 @@ var configTemplate = []struct{ key, help string }{
|
||||
{"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
|
||||
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
|
||||
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
|
||||
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
|
||||
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
|
||||
{"TOP_P", "noyau de probabilité (top_p) ; vide = défaut du moteur"},
|
||||
{"TOP_K", "top_k ; 0 = désactivé, vide = défaut du moteur"},
|
||||
{"MIN_P", "seuil de probabilité (min_p) ; vide = défaut du moteur"},
|
||||
{"PRESENCE_PENALTY", "pénalité de présence ; vide = défaut du moteur"},
|
||||
{"REPEAT_PENALTY", "pénalité de répétition (1 = neutre) ; vide = défaut du moteur"},
|
||||
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
|
||||
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
|
||||
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package loki
|
||||
|
||||
// tasks.go — les tâches planifiées : des consignes que l'IA exécute toute seule,
|
||||
// en arrière-plan, sur une fréquence réglable (intervalle simple ou cron). Chaque
|
||||
// tâche tourne ISOLÉE de la conversation partagée (voir RunAutonomous dans
|
||||
// tasks_run.go) : elle ne pollue pas le chat et livre son résultat via les propres
|
||||
// outils de l'IA (mail via MCP, shell, web…). L'utilisateur peut désactiver une
|
||||
// tâche, ou tout suspendre d'un coup via l'interrupteur maître (tasks_paused).
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
// Task est une tâche planifiée persistée dans le bucket bkTasks (clé = ID).
|
||||
type Task struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prompt string `json:"prompt"`
|
||||
Schedule string `json:"schedule"` // "@every 2h" | "@every 1d@23:00" | expr cron 5 champs
|
||||
TZ string `json:"tz"` // fuseau IANA du navigateur (ex. "Europe/Paris") pour l'heure
|
||||
Preset string `json:"preset"` // id du preset à activer avant l'exécution (vide = preset actif)
|
||||
// Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON
|
||||
// (tâches créées avant ces champs) garde le comportement historique : accès.
|
||||
NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche
|
||||
NoWeb bool `json:"no_web"` // true = pas d'accès au web pour cette tâche
|
||||
Enabled bool `json:"enabled"`
|
||||
LastRun int64 `json:"last_run"` // epoch ms, 0 = jamais
|
||||
NextRun int64 `json:"next_run"` // epoch ms, 0 = à (re)calculer
|
||||
LastDurMs int64 `json:"last_dur_ms"` // durée du dernier passage en ms
|
||||
LastOK bool `json:"last_ok"`
|
||||
LastError string `json:"last_error"`
|
||||
LastReport string `json:"last_report"` // dernier texte final de l'IA (compte-rendu court)
|
||||
|
||||
Running bool `json:"-"` // transitoire : une exécution est en cours
|
||||
}
|
||||
|
||||
// newTaskID génère un identifiant court et unique.
|
||||
func newTaskID() string {
|
||||
var b [6]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// listTasks renvoie toutes les tâches, triées par nom pour un affichage stable.
|
||||
func listTasks() []Task {
|
||||
var out []Task
|
||||
_ = view(bkTasks, func(b *bolt.Bucket) error {
|
||||
return b.ForEach(func(_, v []byte) error {
|
||||
var t Task
|
||||
if json.Unmarshal(v, &t) == nil {
|
||||
out = append(out, t)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Name != out[j].Name {
|
||||
return out[i].Name < out[j].Name
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// getTask lit une tâche par ID. Renvoie false si absente.
|
||||
func getTask(id string) (Task, bool) {
|
||||
var t Task
|
||||
ok := getJSON(bkTasks, id, &t)
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// saveTask enregistre (crée ou remplace) une tâche.
|
||||
func saveTask(t Task) error { return putJSON(bkTasks, t.ID, t) }
|
||||
|
||||
// deleteTask supprime une tâche par ID.
|
||||
func deleteTask(id string) error { return putBytes(bkTasks, id, nil) }
|
||||
|
||||
// tasksPaused indique si l'interrupteur maître suspend TOUTES les tâches. On
|
||||
// stocke « en pause » plutôt que « actif » pour que l'état par défaut (base
|
||||
// neuve) soit « actif » sans dépendre d'une écriture : c'est un frein, pas un
|
||||
// moteur.
|
||||
func tasksPaused() bool { return getBool(bkState, "tasks_paused") }
|
||||
|
||||
func setTasksPaused(on bool) error { return putBool(bkState, "tasks_paused", on) }
|
||||
|
||||
// computeNextRun calcule le prochain déclenchement d'une tâche à partir de son
|
||||
// Schedule (dans le fuseau tz), relatif à `from`. Renvoie 0 si le schedule est
|
||||
// invalide (la tâche ne se déclenchera alors jamais — l'UI refuse déjà un
|
||||
// schedule invalide à la sauvegarde, ceci n'est qu'un garde-fou).
|
||||
func computeNextRun(schedule, tz string, from time.Time) int64 {
|
||||
next, err := nextAfter(schedule, tz, from)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return next.UnixMilli()
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
package loki
|
||||
|
||||
// tasks_cron.go — planification des tâches, résolution à la minute. Deux formes :
|
||||
//
|
||||
// - Intervalle : "@every <durée>" (ex. "@every 2h", "@every 30m"), parsé par
|
||||
// time.ParseDuration, borné à ≥ 1 minute.
|
||||
// - Cron : 5 champs "min hour dom month dow" avec *, listes a,b, plages a-b et
|
||||
// pas */n (ex. "0 9 * * 1-5" = 9h du lundi au vendredi).
|
||||
//
|
||||
// Aucune dépendance externe : Go n'a pas de lib cron et on n'en ajoute pas pour
|
||||
// si peu. nextAfter avance minute par minute jusqu'au prochain match — largement
|
||||
// assez rapide pour une poignée de tâches évaluées une fois par minute.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// minInterval borne la fréquence : sous la minute, le scheduler (ticker 60 s) ne
|
||||
// pourrait de toute façon pas suivre, et une tâche qui lance une inférence toutes
|
||||
// les quelques secondes n'a pas de sens.
|
||||
const minInterval = time.Minute
|
||||
|
||||
// loc résout un fuseau IANA (ex. « Europe/Paris »). Vide ou introuvable → heure
|
||||
// locale du serveur. Nécessaire parce que le serveur tourne souvent en UTC : sans
|
||||
// ça, « tous les jours à 23h » et les expressions cron se calculeraient en UTC et
|
||||
// se déclencheraient à la mauvaise heure pour l'utilisateur.
|
||||
func loc(tz string) *time.Location {
|
||||
if tz == "" {
|
||||
return time.Local
|
||||
}
|
||||
l, err := time.LoadLocation(tz)
|
||||
if err != nil {
|
||||
return time.Local
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// nextAfter renvoie le prochain instant (strictement après `from`) où `schedule`
|
||||
// se déclenche, dans le fuseau tz. Erreur si le schedule est invalide.
|
||||
//
|
||||
// Formes acceptées :
|
||||
// - "@every 30m" / "@every 2h" : intervalle glissant depuis `from`.
|
||||
// - "@every 1d@23:00" : tous les N jours, ancré à une heure de la journée.
|
||||
// - expression cron 5 champs.
|
||||
func nextAfter(schedule, tz string, from time.Time) (time.Time, error) {
|
||||
s := strings.TrimSpace(schedule)
|
||||
if s == "" {
|
||||
return time.Time{}, fmt.Errorf("fréquence vide")
|
||||
}
|
||||
if rest, ok := cutPrefix(s, "@every"); ok {
|
||||
return nextEvery(strings.TrimSpace(rest), tz, from)
|
||||
}
|
||||
return nextCron(s, tz, from)
|
||||
}
|
||||
|
||||
// nextEvery gère les intervalles « @every … », avec ou sans ancre horaire.
|
||||
func nextEvery(spec, tz string, from time.Time) (time.Time, error) {
|
||||
durPart, timePart, hasTime := strings.Cut(spec, "@")
|
||||
durPart = strings.TrimSpace(durPart)
|
||||
|
||||
// Jours : time.ParseDuration ne connaît pas « d ». On les gère à part, ce qui
|
||||
// permet aussi l'ancre horaire (« tous les N jours à HH:MM »).
|
||||
if n, ok := cutSuffix(durPart, "d"); ok {
|
||||
days, err := strconv.Atoi(n)
|
||||
if err != nil || days < 1 {
|
||||
return time.Time{}, fmt.Errorf("nombre de jours invalide : %q", durPart)
|
||||
}
|
||||
if !hasTime {
|
||||
return from.Add(time.Duration(days) * 24 * time.Hour), nil
|
||||
}
|
||||
hh, mm, err := parseHHMM(timePart)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
l := loc(tz)
|
||||
now := from.In(l)
|
||||
cand := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, l)
|
||||
for !cand.After(now) {
|
||||
cand = cand.AddDate(0, 0, days)
|
||||
}
|
||||
return cand, nil
|
||||
}
|
||||
|
||||
d, err := time.ParseDuration(durPart)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("intervalle invalide (ex. « 2h », « 30m ») : %w", err)
|
||||
}
|
||||
if d < minInterval {
|
||||
return time.Time{}, fmt.Errorf("intervalle minimum : 1 minute")
|
||||
}
|
||||
return from.Add(d), nil
|
||||
}
|
||||
|
||||
// parseHHMM lit « 23:00 » / « 9:30 » en heures/minutes.
|
||||
func parseHHMM(s string) (int, int, error) {
|
||||
hs, ms, ok := strings.Cut(strings.TrimSpace(s), ":")
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("heure invalide (attendu HH:MM) : %q", s)
|
||||
}
|
||||
h, err1 := strconv.Atoi(strings.TrimSpace(hs))
|
||||
m, err2 := strconv.Atoi(strings.TrimSpace(ms))
|
||||
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
||||
return 0, 0, fmt.Errorf("heure hors bornes : %q", s)
|
||||
}
|
||||
return h, m, nil
|
||||
}
|
||||
|
||||
// cutSuffix : strings.CutSuffix (Go 1.20+) réécrit pour rester lisible ici.
|
||||
func cutSuffix(s, suffix string) (string, bool) {
|
||||
if strings.HasSuffix(s, suffix) {
|
||||
return s[:len(s)-len(suffix)], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// validateSchedule vérifie qu'un schedule est acceptable, sans calculer de date.
|
||||
func validateSchedule(schedule, tz string) error {
|
||||
_, err := nextAfter(schedule, tz, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
// cronSpec = les 5 champs compilés en ensembles de valeurs autorisées.
|
||||
type cronSpec struct {
|
||||
min, hour, dom, month, dow map[int]bool
|
||||
}
|
||||
|
||||
// nextCron parse une expression cron 5 champs et cherche le prochain match à la
|
||||
// minute, en partant de la minute qui suit `from`. Une borne (~366 j) évite une
|
||||
// boucle infinie sur une expression jamais satisfiable (ex. « 31 février »).
|
||||
func nextCron(expr, tz string, from time.Time) (time.Time, error) {
|
||||
spec, err := parseCron(expr)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
// On repart de la minute suivante, dans le fuseau visé (l'heure murale de
|
||||
// l'expression cron est celle de l'utilisateur, pas celle du serveur).
|
||||
t := from.In(loc(tz)).Truncate(time.Minute).Add(time.Minute)
|
||||
limit := t.Add(366 * 24 * time.Hour)
|
||||
for ; t.Before(limit); t = t.Add(time.Minute) {
|
||||
if spec.matches(t) {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("fréquence jamais satisfaite : %q", expr)
|
||||
}
|
||||
|
||||
func (s cronSpec) matches(t time.Time) bool {
|
||||
return s.min[t.Minute()] &&
|
||||
s.hour[t.Hour()] &&
|
||||
s.dom[t.Day()] &&
|
||||
s.month[int(t.Month())] &&
|
||||
s.dow[int(t.Weekday())]
|
||||
}
|
||||
|
||||
func parseCron(expr string) (cronSpec, error) {
|
||||
f := strings.Fields(expr)
|
||||
if len(f) != 5 {
|
||||
return cronSpec{}, fmt.Errorf("cron : 5 champs attendus « min heure jour mois jour-semaine », reçu %d", len(f))
|
||||
}
|
||||
var s cronSpec
|
||||
var err error
|
||||
if s.min, err = parseField(f[0], 0, 59); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ minute : %w", err)
|
||||
}
|
||||
if s.hour, err = parseField(f[1], 0, 23); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ heure : %w", err)
|
||||
}
|
||||
if s.dom, err = parseField(f[2], 1, 31); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ jour-du-mois : %w", err)
|
||||
}
|
||||
if s.month, err = parseField(f[3], 1, 12); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ mois : %w", err)
|
||||
}
|
||||
// Jour de la semaine : 0 ET 7 valent dimanche. On normalise 7 → 0.
|
||||
if s.dow, err = parseField(f[4], 0, 7); err != nil {
|
||||
return cronSpec{}, fmt.Errorf("champ jour-semaine : %w", err)
|
||||
}
|
||||
if s.dow[7] {
|
||||
s.dow[0] = true
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// parseField compile un champ cron (*, a, a-b, a,b, */n, a-b/n) en l'ensemble des
|
||||
// valeurs autorisées dans [lo, hi].
|
||||
func parseField(field string, lo, hi int) (map[int]bool, error) {
|
||||
out := map[int]bool{}
|
||||
for _, part := range strings.Split(field, ",") {
|
||||
rng := part
|
||||
step := 1
|
||||
if base, st, ok := strings.Cut(part, "/"); ok {
|
||||
n, err := strconv.Atoi(st)
|
||||
if err != nil || n < 1 {
|
||||
return nil, fmt.Errorf("pas invalide : %q", part)
|
||||
}
|
||||
step = n
|
||||
rng = base
|
||||
}
|
||||
start, end := lo, hi
|
||||
if rng != "*" {
|
||||
if a, b, ok := strings.Cut(rng, "-"); ok {
|
||||
var err error
|
||||
if start, err = atoiRange(a, lo, hi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if end, err = atoiRange(b, lo, hi); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
v, err := atoiRange(rng, lo, hi)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start, end = v, v
|
||||
}
|
||||
}
|
||||
if start > end {
|
||||
return nil, fmt.Errorf("plage inversée : %q", part)
|
||||
}
|
||||
for v := start; v <= end; v += step {
|
||||
out[v] = true
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("champ vide")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func atoiRange(s string, lo, hi int) (int, error) {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("valeur invalide : %q", s)
|
||||
}
|
||||
if v < lo || v > hi {
|
||||
return 0, fmt.Errorf("valeur %d hors bornes [%d-%d]", v, lo, hi)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// cutPrefix reconnaît le préfixe "@every" (insensible à la casse) et renvoie le
|
||||
// reste. strings.CutPrefix existe (Go 1.20+) mais est sensible à la casse.
|
||||
func cutPrefix(s, prefix string) (string, bool) {
|
||||
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
|
||||
return s[len(prefix):], true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNextAfterInterval(t *testing.T) {
|
||||
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
|
||||
got, err := nextAfter("@every 2h", "", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur inattendue : %v", err)
|
||||
}
|
||||
want := from.Add(2 * time.Hour)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("@every 2h : got %v, want %v", got, want)
|
||||
}
|
||||
// Casse tolérée sur le préfixe.
|
||||
if _, err := nextAfter("@EVERY 30m", "", from); err != nil {
|
||||
t.Fatalf("@EVERY 30m devrait être valide : %v", err)
|
||||
}
|
||||
// Sous la minute → refusé.
|
||||
if _, err := nextAfter("@every 10s", "", from); err == nil {
|
||||
t.Fatalf("@every 10s aurait dû être refusé")
|
||||
}
|
||||
if _, err := nextAfter("@every pouet", "", from); err == nil {
|
||||
t.Fatalf("durée invalide aurait dû être refusée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextEveryDays(t *testing.T) {
|
||||
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
|
||||
// Sans ancre horaire : intervalle glissant de N*24h.
|
||||
got, err := nextAfter("@every 3d", "", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
if want := from.Add(72 * time.Hour); !got.Equal(want) {
|
||||
t.Fatalf("@every 3d : got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextEveryDayAtTime(t *testing.T) {
|
||||
// « tous les 1 jour à 23:00 », fuseau Europe/Paris (CEST = UTC+2 en août).
|
||||
from := time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) // 22h à Paris
|
||||
got, err := nextAfter("@every 1d@23:00", "Europe/Paris", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
// 23:00 Paris le 15 = 21:00 UTC.
|
||||
want := time.Date(2026, 8, 15, 21, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("@every 1d@23:00 : got %v, want %v", got.UTC(), want)
|
||||
}
|
||||
// Après 23:00 Paris → le lendemain.
|
||||
from2 := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC) // 23h30 Paris
|
||||
got2, _ := nextAfter("@every 1d@23:00", "Europe/Paris", from2)
|
||||
want2 := time.Date(2026, 8, 16, 21, 0, 0, 0, time.UTC)
|
||||
if !got2.Equal(want2) {
|
||||
t.Fatalf("lendemain : got %v, want %v", got2.UTC(), want2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextCronEveryQuarter(t *testing.T) {
|
||||
from := time.Date(2026, 8, 15, 10, 7, 30, 0, time.UTC)
|
||||
got, err := nextAfter("*/15 * * * *", "", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
want := time.Date(2026, 8, 15, 10, 15, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("*/15 : got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextCronWeekdayMorning(t *testing.T) {
|
||||
// 2026-08-15 est un samedi. « 0 9 * * 1-5 » → lundi 17 août 09:00.
|
||||
from := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
|
||||
got, err := nextAfter("0 9 * * 1-5", "UTC", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
want := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("0 9 * * 1-5 : got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextCronInParisTZ(t *testing.T) {
|
||||
// « 0 9 * * * » à Paris = 07:00 UTC en été.
|
||||
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC)
|
||||
got, err := nextAfter("0 9 * * *", "Europe/Paris", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
want := time.Date(2026, 8, 15, 7, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("cron TZ : got %v, want %v", got.UTC(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextCronSundayBothForms(t *testing.T) {
|
||||
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) // samedi
|
||||
got, err := nextAfter("0 0 * * 7", "UTC", from)
|
||||
if err != nil {
|
||||
t.Fatalf("erreur : %v", err)
|
||||
}
|
||||
want := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("dow=7 : got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCronInvalid(t *testing.T) {
|
||||
cases := []string{
|
||||
"",
|
||||
"* * * *", // 4 champs
|
||||
"* * * * * *", // 6 champs
|
||||
"60 * * * *", // minute hors bornes
|
||||
"* 24 * * *", // heure hors bornes
|
||||
"5-1 * * * *", // plage inversée
|
||||
"*/0 * * * *", // pas nul
|
||||
"abc * * * *", // non numérique
|
||||
"@every", // durée absente
|
||||
"@every 1d@25:00", // heure hors bornes
|
||||
"@every 0d@10:00", // zéro jour
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := validateSchedule(c, ""); err == nil {
|
||||
t.Errorf("schedule %q aurait dû être refusé", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateScheduleOK(t *testing.T) {
|
||||
cases := []string{"@every 1m", "@every 2h", "@every 3d", "@every 1d@23:00", "0 */2 * * *", "30 8 * * 1-5", "0 0 1 * *"}
|
||||
for _, c := range cases {
|
||||
if err := validateSchedule(c, "Europe/Paris"); err != nil {
|
||||
t.Errorf("schedule %q aurait dû être valide : %v", c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package loki
|
||||
|
||||
// tasks_run.go — exécution d'une tâche, ISOLÉE de la discussion ouverte.
|
||||
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté au fonctionnement de loki
|
||||
// (dossier de travail par discussion, modes de la machine).
|
||||
//
|
||||
// Une tâche ne doit ni apparaître dans le chat, ni casser le rejeu SSE, ni entrer
|
||||
// en collision avec un tour utilisateur. On ne partage donc qu'UN SEUL point avec
|
||||
// la conversation : son verrou de génération (conv.Generating), qui garantit
|
||||
// qu'une seule inférence tourne à la fois — llama-server est lancé en
|
||||
// --parallel 1. Tout le reste — messages, journal d'affichage — n'est jamais
|
||||
// touché : la tâche construit son propre fil éphémère et le jette à la fin, ne
|
||||
// gardant que le texte final comme compte-rendu.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RunAutonomous exécute un prompt en arrière-plan, sans laisser de trace dans la
|
||||
// discussion ouverte, et renvoie le texte final produit par l'IA (le compte-rendu).
|
||||
// Prend le MÊME verrou que StartTurn : renvoie ErrBusy si un tour (utilisateur ou
|
||||
// une autre tâche) est déjà en cours, ou errModelLoading si le modèle n'est pas
|
||||
// prêt. caps gouverne l'accès aux outils (agent, mémoire, internet).
|
||||
// lastReport est le compte-rendu du passage précédent (vide au premier) : il est
|
||||
// réinjecté pour donner à l'IA une continuité d'une exécution à l'autre.
|
||||
func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prompt, lastReport string, caps Caps, temperature float64) (string, error) {
|
||||
if !healthCheck() {
|
||||
return "", errModelLoading
|
||||
}
|
||||
// Contexte annulable propre à la tâche, exposé via c.cancel : le bouton stop
|
||||
// du chat (/api/chat/stop → conv.Stop) interrompt donc VRAIMENT une tâche de
|
||||
// fond, au lieu de l'afficher « en cours » sans pouvoir l'arrêter.
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
c.mu.Lock()
|
||||
if c.Generating {
|
||||
c.mu.Unlock()
|
||||
return "", ErrBusy
|
||||
}
|
||||
c.Generating = true
|
||||
c.cancel = cancel
|
||||
c.runningTaskID = taskID
|
||||
c.runningTaskName = taskName
|
||||
c.mu.Unlock()
|
||||
// Libère le verrou quoi qu'il arrive. On ne touche NI c.Messages NI c.Log NI
|
||||
// c.epoch : la discussion ouverte reste totalement à l'écart.
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.Generating = false
|
||||
c.cancel = nil
|
||||
c.runningTaskID = ""
|
||||
c.runningTaskName = ""
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Dossier de travail de la tâche : le sien, pas celui de la discussion
|
||||
// ouverte (chat_workspace.go). Stable d'un passage à l'autre — une tâche qui
|
||||
// tient un fichier de suivi le retrouve la fois suivante.
|
||||
setTaskWorkspace(taskWorkspaceDir(taskID))
|
||||
defer setTaskWorkspace("")
|
||||
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
}
|
||||
msgs := []Message{{Role: "user", Content: prompt}}
|
||||
// Même préambule que la vraie génération : consigne personnelle + préambule
|
||||
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
|
||||
// contexte et les mêmes outils qu'en chat. On préfixe le tout d'une note de
|
||||
// contexte (conscience du mode autonome + mémoire du passage précédent),
|
||||
// fusionnée dans UN SEUL message système en tête — comme l'exigent les
|
||||
// gabarits stricts (normalizeSystemMessages le garantit de toute façon).
|
||||
final := msgs
|
||||
sys := readSysPrompt()
|
||||
note := taskContextNote(taskName, lastReport)
|
||||
switch {
|
||||
case sys != "" && note != "":
|
||||
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, msgs...)
|
||||
case sys != "":
|
||||
final = append([]Message{{Role: "system", Content: sys}}, msgs...)
|
||||
case note != "":
|
||||
final = append([]Message{{Role: "system", Content: note}}, msgs...)
|
||||
}
|
||||
|
||||
var content strings.Builder
|
||||
_, err := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
content.WriteString(ev.Content)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Les messages d'outils ne sont pas conservés : la tâche est éphémère, seul
|
||||
// son compte-rendu survit.
|
||||
return strings.TrimSpace(content.String()), err
|
||||
}
|
||||
|
||||
// taskWorkspaceDir donne (et crée) le dossier de travail d'une tâche, sous la
|
||||
// racine du dossier de travail — jamais dans le dossier d'une discussion, dont
|
||||
// il partagerait le sort à la suppression.
|
||||
func taskWorkspaceDir(taskID string) string {
|
||||
root := agentWorkspace()
|
||||
if taskID == "" {
|
||||
return root
|
||||
}
|
||||
dir := filepath.Join(root, "tasks", taskID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return root
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// taskContextNote construit la note de contexte injectée en tête du fil d'une
|
||||
// tâche : d'abord la conscience du mode (l'IA tourne seule, sans utilisateur en
|
||||
// face), puis — s'il existe — le compte-rendu du passage précédent, pour la
|
||||
// continuité.
|
||||
func taskContextNote(taskName, lastReport string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("[Tâche planifiée")
|
||||
if taskName != "" {
|
||||
b.WriteString(" « " + taskName + " »")
|
||||
}
|
||||
b.WriteString("] Tu es exécuté automatiquement en arrière-plan, sans utilisateur " +
|
||||
"présent pour te répondre. Mène la tâche à son terme de façon autonome, puis " +
|
||||
"termine par un compte-rendu clair et court de ce que tu as fait ou trouvé.")
|
||||
if r := strings.TrimSpace(lastReport); r != "" {
|
||||
if len(r) > reportMax {
|
||||
r = r[:reportMax] + "…"
|
||||
}
|
||||
b.WriteString("\n\nCompte-rendu de ta dernière exécution (pour la continuité — " +
|
||||
"appuie-toi dessus, ne le répète pas tel quel) :\n" + r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// reportMax borne la taille du compte-rendu conservé : le texte final peut être
|
||||
// long, on n'en garde qu'un aperçu pour l'interface et pour la relance suivante.
|
||||
const reportMax = 4000
|
||||
|
||||
// runTask exécute une tâche et met à jour son état persisté (dernier passage,
|
||||
// prochain passage, succès/échec, compte-rendu).
|
||||
func runTask(t Task) {
|
||||
start := time.Now()
|
||||
|
||||
// Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement
|
||||
// du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai
|
||||
// échec de la tâche : la faire tourner sur le mauvais modèle serait pire.
|
||||
if err := ensureTaskPreset(t.Preset); err != nil {
|
||||
recordTaskEnd(t.ID, start, "", fmt.Errorf("changement de preset : %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
report, err := conv.RunAutonomous(context.Background(), t.ID, t.Name, t.Prompt, t.LastReport, taskCaps(t), 0)
|
||||
|
||||
// Occupé, ou modèle pas encore prêt : ce n'est pas un échec de la tâche, juste
|
||||
// un mauvais moment. On ne touche pas à son état (NextRun reste dans le passé)
|
||||
// pour qu'elle soit réessayée au tick suivant.
|
||||
if err == ErrBusy || err == errModelLoading {
|
||||
return
|
||||
}
|
||||
recordTaskEnd(t.ID, start, report, err)
|
||||
}
|
||||
|
||||
// taskCaps dérive les capacités d'une tâche : on part du mode agent de la machine
|
||||
// (qui débloque les outils), puis on applique les réglages propres à la tâche pour
|
||||
// la mémoire et le web. Le web reste borné à ce que la machine offre réellement
|
||||
// (serveur Crawl4AI configuré et joignable) : une tâche ne peut pas l'inventer.
|
||||
//
|
||||
// Le mode code reste OFF : c'est un mode de discussion (rôles, critères,
|
||||
// vérification, puces dans le fil) qui n'a pas de sens sans personne en face.
|
||||
func taskCaps(t Task) Caps {
|
||||
// Mem posé EXPLICITEMENT à MemOff : le zéro de MemMode est la chaîne vide,
|
||||
// que EnabledTools ne reconnaît pas comme « coupée » (il compare à MemOff) —
|
||||
// une tâche sans agent se serait donc vu offrir les outils mem_*.
|
||||
c := Caps{Agent: agentEnabled(), Mem: MemOff}
|
||||
if !c.Agent {
|
||||
return c // agent coupé : aucun outil, mémoire et web n'ont plus de sens
|
||||
}
|
||||
if t.NoWeb {
|
||||
c.Internet = false
|
||||
} else {
|
||||
c.Internet = internetEnabled() && crawlReachable()
|
||||
}
|
||||
if t.NoMem {
|
||||
c.Mem = MemOff
|
||||
} else if m := memMode(); m == MemOff {
|
||||
// La tâche veut la mémoire mais la machine l'a coupée globalement : on donne
|
||||
// au moins les outils à la demande, sans l'injection proactive.
|
||||
c.Mem = MemOnDemand
|
||||
} else {
|
||||
c.Mem = m
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ensureTaskPreset bascule le moteur sur le preset d'id `id` s'il n'est pas déjà
|
||||
// actif, puis attend que le modèle ait rechargé. Vide = rien à faire.
|
||||
func ensureTaskPreset(id string) error {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
list, err := ListPresets()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var target *Preset
|
||||
for i := range list {
|
||||
if list[i].ID == id {
|
||||
target = &list[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return fmt.Errorf("preset introuvable : %s", id)
|
||||
}
|
||||
if target.Active {
|
||||
return nil
|
||||
}
|
||||
if err := SwitchToPreset(target.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
// Le moteur redémarre : on attend qu'il réponde à nouveau (charger un gros
|
||||
// modèle prend un moment).
|
||||
deadline := time.Now().Add(4 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
if healthCheck() {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
return fmt.Errorf("le modèle n'a pas fini de charger après le changement de preset")
|
||||
}
|
||||
|
||||
// recordTaskEnd met à jour l'état persisté d'une tâche après une exécution :
|
||||
// durée, dernier passage, prochain passage, succès/échec, compte-rendu. Relit la
|
||||
// tâche juste avant d'écrire pour ne pas écraser une modification concurrente
|
||||
// (bascule ou édition depuis l'interface pendant l'exécution).
|
||||
func recordTaskEnd(id string, start time.Time, report string, err error) {
|
||||
now := time.Now()
|
||||
cur, ok := getTask(id)
|
||||
if !ok {
|
||||
return // supprimée entre-temps : rien à écrire
|
||||
}
|
||||
cur.LastDurMs = now.Sub(start).Milliseconds()
|
||||
cur.LastRun = now.UnixMilli()
|
||||
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
|
||||
// « interrompue » sans allumer l'indicateur rouge d'erreur.
|
||||
cur.LastOK = true
|
||||
cur.LastError = ""
|
||||
cur.LastReport = "(interrompue manuellement)"
|
||||
} else if err != nil {
|
||||
cur.LastOK = false
|
||||
cur.LastError = err.Error()
|
||||
} else {
|
||||
cur.LastOK = true
|
||||
cur.LastError = ""
|
||||
if len(report) > reportMax {
|
||||
report = report[:reportMax] + "…"
|
||||
}
|
||||
cur.LastReport = report
|
||||
}
|
||||
_ = saveTask(cur)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package loki
|
||||
|
||||
// tasks_sched.go — la boucle de planification. Une seule goroutine, un tick par
|
||||
// minute, dans le process qui possède la conversation et l'UI (ajean-ui /
|
||||
// `ajean link`). Voir StartTaskScheduler, câblée dans newWebMux (web_server.go).
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var taskSchedOnce sync.Once
|
||||
|
||||
// StartTaskScheduler démarre la boucle de planification (idempotent : sûr à
|
||||
// appeler à chaque construction du mux, ne lance qu'une goroutine).
|
||||
func StartTaskScheduler() {
|
||||
taskSchedOnce.Do(func() { go taskSchedLoop() })
|
||||
}
|
||||
|
||||
// taskSchedLoop réveille les tâches dues, une par tick (elles sont de toute façon
|
||||
// sérialisées par le gate de génération). Le tick d'une minute suffit : c'est la
|
||||
// résolution de la planification (voir tasks_cron.go).
|
||||
func taskSchedLoop() {
|
||||
// Premier passage immédiat pour amorcer les NextRun manquants (après un
|
||||
// redémarrage du process, ou pour une tâche fraîchement créée sans NextRun).
|
||||
primeNextRuns()
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
tickTasks(time.Now())
|
||||
}
|
||||
}
|
||||
|
||||
// primeNextRuns renseigne NextRun de toute tâche activée qui n'en a pas (0),
|
||||
// relativement à maintenant — sinon une tâche à NextRun=0 serait considérée « due »
|
||||
// en permanence, ou jamais planifiée selon le sens de la comparaison.
|
||||
func primeNextRuns() {
|
||||
now := time.Now()
|
||||
for _, t := range listTasks() {
|
||||
if t.Enabled && t.NextRun == 0 {
|
||||
t.NextRun = computeNextRun(t.Schedule, t.TZ, now)
|
||||
_ = saveTask(t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tickTasks lance la première tâche due (si l'interrupteur maître n'est pas en
|
||||
// pause). Une seule par tick : lancer plusieurs inférences en parallèle est de
|
||||
// toute façon impossible (gate de génération), et étaler leur départ évite qu'une
|
||||
// rafale de tâches monopolise le modèle d'un coup.
|
||||
func tickTasks(now time.Time) {
|
||||
if tasksPaused() {
|
||||
return
|
||||
}
|
||||
nowMs := now.UnixMilli()
|
||||
for _, t := range listTasks() {
|
||||
if !t.Enabled {
|
||||
continue
|
||||
}
|
||||
if t.NextRun == 0 {
|
||||
// Priming raté (schedule invalide) ou tâche neuve : on tente de le poser,
|
||||
// sans la déclencher ce tour-ci.
|
||||
t.NextRun = computeNextRun(t.Schedule, t.TZ, now)
|
||||
_ = saveTask(t)
|
||||
continue
|
||||
}
|
||||
if t.NextRun <= nowMs {
|
||||
runTask(t) // met à jour LastRun/NextRun/… et persiste
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Aller-retour complet en base : ce qui est enregistré se relit à l'identique,
|
||||
// la liste est triée par nom (affichage stable) et la suppression efface bien.
|
||||
func TestTasksCRUD(t *testing.T) {
|
||||
testHome(t)
|
||||
if got := listTasks(); len(got) != 0 {
|
||||
t.Fatalf("base neuve : %d tâches, attendu 0", len(got))
|
||||
}
|
||||
a := Task{ID: newTaskID(), Name: "zèbre", Prompt: "p", Schedule: "@every 2h", Enabled: true}
|
||||
b := Task{ID: newTaskID(), Name: "abeille", Prompt: "p", Schedule: "0 9 * * 1-5"}
|
||||
if err := saveTask(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := saveTask(b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list := listTasks()
|
||||
if len(list) != 2 || list[0].Name != "abeille" || list[1].Name != "zèbre" {
|
||||
t.Fatalf("liste non triée par nom : %+v", list)
|
||||
}
|
||||
got, ok := getTask(a.ID)
|
||||
if !ok || got.Prompt != "p" || got.Schedule != "@every 2h" || !got.Enabled {
|
||||
t.Fatalf("relecture incorrecte : %+v (ok=%v)", got, ok)
|
||||
}
|
||||
if err := deleteTask(a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := getTask(a.ID); ok {
|
||||
t.Error("tâche supprimée toujours présente")
|
||||
}
|
||||
if len(listTasks()) != 1 {
|
||||
t.Error("la suppression n'a pas retiré la tâche de la liste")
|
||||
}
|
||||
}
|
||||
|
||||
// L'interrupteur maître est un FREIN : base neuve = tâches actives, sans
|
||||
// dépendre d'une écriture préalable.
|
||||
func TestTasksPauseDefautActif(t *testing.T) {
|
||||
testHome(t)
|
||||
if tasksPaused() {
|
||||
t.Error("base neuve : les tâches devraient être actives")
|
||||
}
|
||||
if err := setTasksPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tasksPaused() {
|
||||
t.Error("pause non enregistrée")
|
||||
}
|
||||
_ = setTasksPaused(false)
|
||||
if tasksPaused() {
|
||||
t.Error("reprise non enregistrée")
|
||||
}
|
||||
}
|
||||
|
||||
// Le dossier de travail d'une tâche lui appartient : jamais celui d'une
|
||||
// discussion (qui disparaît avec elle), et stable d'un passage à l'autre — une
|
||||
// tâche qui tient un fichier de suivi doit le retrouver.
|
||||
func TestTaskWorkspaceIsole(t *testing.T) {
|
||||
testHome(t)
|
||||
dir := taskWorkspaceDir("abc123")
|
||||
if dir == agentWorkspace() || dir == convWorkspace() {
|
||||
t.Fatalf("la tâche partage le dossier d'une discussion : %s", dir)
|
||||
}
|
||||
if filepath.Base(dir) != "abc123" || filepath.Base(filepath.Dir(dir)) != "tasks" {
|
||||
t.Errorf("emplacement inattendu : %s", dir)
|
||||
}
|
||||
if again := taskWorkspaceDir("abc123"); again != dir {
|
||||
t.Errorf("dossier instable entre deux passages : %s puis %s", dir, again)
|
||||
}
|
||||
// La bascule ne doit toucher QUE les outils : ce que voit l'utilisateur
|
||||
// (panneau Fichiers, dépôts) reste sur sa discussion.
|
||||
setTaskWorkspace(dir)
|
||||
defer setTaskWorkspace("")
|
||||
if agentCwd() != dir {
|
||||
t.Errorf("agentCwd = %s, attendu %s", agentCwd(), dir)
|
||||
}
|
||||
if convWorkspace() == dir {
|
||||
t.Error("convWorkspace suit la tâche : le panneau Fichiers changerait de dossier en cours de route")
|
||||
}
|
||||
}
|
||||
|
||||
// Les accès d'une tâche : l'agent commande tout, la tâche ne peut que RETIRER
|
||||
// (no_mem/no_web), et le mode code reste hors sujet sans personne en face.
|
||||
func TestTaskCaps(t *testing.T) {
|
||||
testHome(t)
|
||||
if c := taskCaps(Task{}); c.Agent || c.Internet || c.Mem != MemOff {
|
||||
t.Errorf("agent coupé : aucun outil attendu, obtenu %+v", c)
|
||||
}
|
||||
if err := setAgentEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := taskCaps(Task{})
|
||||
if !c.Agent {
|
||||
t.Fatal("mode agent actif : la tâche doit avoir les outils")
|
||||
}
|
||||
if c.Code {
|
||||
t.Error("le mode code n'a pas de sens pour une tâche autonome")
|
||||
}
|
||||
// Par défaut, la tâche suit le mode mémoire de la machine.
|
||||
if c.Mem != memMode() {
|
||||
t.Errorf("mémoire = %q, attendu celle de la machine (%q)", c.Mem, memMode())
|
||||
}
|
||||
// Machine mémoire COUPÉE mais tâche qui la demande : elle garde au moins les
|
||||
// outils à la demande, sans l'injection proactive.
|
||||
if err := setMemMode(MemOff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := taskCaps(Task{}).Mem; got != MemOnDemand {
|
||||
t.Errorf("mémoire = %q, attendu %q", got, MemOnDemand)
|
||||
}
|
||||
if c := taskCaps(Task{NoMem: true, NoWeb: true}); c.Mem != MemOff || c.Internet {
|
||||
t.Errorf("refus explicite non respecté : %+v", c)
|
||||
}
|
||||
// Web : borné à ce que la machine offre réellement — une tâche ne peut pas
|
||||
// l'inventer (aucun serveur Crawl4AI joignable ici).
|
||||
if c := taskCaps(Task{}); c.Internet {
|
||||
t.Error("accès web accordé alors que la machine ne l'offre pas")
|
||||
}
|
||||
}
|
||||
|
||||
// Fin d'exécution : succès, échec et interruption volontaire n'écrivent pas la
|
||||
// même chose — et une interruption n'est PAS un échec (pas d'indicateur rouge).
|
||||
func TestRecordTaskEnd(t *testing.T) {
|
||||
testHome(t)
|
||||
base := Task{ID: "t1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true}
|
||||
if err := saveTask(base); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
start := time.Now().Add(-2 * time.Second)
|
||||
|
||||
recordTaskEnd("t1", start, strings.Repeat("x", reportMax+50), nil)
|
||||
got, _ := getTask("t1")
|
||||
if !got.LastOK || got.LastError != "" {
|
||||
t.Errorf("succès mal enregistré : %+v", got)
|
||||
}
|
||||
if len(got.LastReport) != reportMax+len("…") {
|
||||
t.Errorf("compte-rendu non borné : %d caractères", len(got.LastReport))
|
||||
}
|
||||
if got.LastRun == 0 || got.NextRun <= got.LastRun {
|
||||
t.Errorf("horodatages incohérents : last=%d next=%d", got.LastRun, got.NextRun)
|
||||
}
|
||||
if got.LastDurMs < 1000 {
|
||||
t.Errorf("durée non mesurée : %d ms", got.LastDurMs)
|
||||
}
|
||||
|
||||
recordTaskEnd("t1", start, "", errors.New("boum"))
|
||||
got, _ = getTask("t1")
|
||||
if got.LastOK || got.LastError != "boum" {
|
||||
t.Errorf("échec mal enregistré : %+v", got)
|
||||
}
|
||||
|
||||
recordTaskEnd("t1", start, "", context.Canceled)
|
||||
got, _ = getTask("t1")
|
||||
if !got.LastOK || got.LastError != "" || !strings.Contains(got.LastReport, "interrompue") {
|
||||
t.Errorf("interruption prise pour un échec : %+v", got)
|
||||
}
|
||||
|
||||
// Tâche supprimée entre-temps : on n'en ressuscite pas une.
|
||||
recordTaskEnd("disparue", start, "r", nil)
|
||||
if _, ok := getTask("disparue"); ok {
|
||||
t.Error("recordTaskEnd a recréé une tâche supprimée")
|
||||
}
|
||||
}
|
||||
|
||||
// Le tic ne déclenche rien quand l'interrupteur maître est en pause, et pose le
|
||||
// NextRun manquant sans lancer la tâche pour autant.
|
||||
func TestTickTasks(t *testing.T) {
|
||||
testHome(t)
|
||||
now := time.Now()
|
||||
due := Task{ID: "due", Name: "due", Prompt: "p", Schedule: "@every 1h", Enabled: true,
|
||||
NextRun: now.Add(-time.Minute).UnixMilli()}
|
||||
if err := saveTask(due); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = setTasksPaused(true)
|
||||
tickTasks(now)
|
||||
if got, _ := getTask("due"); got.LastRun != 0 {
|
||||
t.Error("une tâche s'est exécutée alors que tout est suspendu")
|
||||
}
|
||||
_ = setTasksPaused(false)
|
||||
|
||||
// NextRun absent : le tic le pose (relatif à maintenant) sans exécuter.
|
||||
neuve := Task{ID: "neuve", Name: "neuve", Prompt: "p", Schedule: "@every 2h", Enabled: true}
|
||||
if err := saveTask(neuve); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primeNextRuns()
|
||||
got, _ := getTask("neuve")
|
||||
if got.NextRun == 0 || got.LastRun != 0 {
|
||||
t.Errorf("amorçage incorrect : next=%d last=%d", got.NextRun, got.LastRun)
|
||||
}
|
||||
if d := time.UnixMilli(got.NextRun).Sub(now); d < 110*time.Minute || d > 130*time.Minute {
|
||||
t.Errorf("prochain passage à %v, attendu ~2 h", d)
|
||||
}
|
||||
// Tâche désactivée : jamais amorcée, jamais lancée.
|
||||
off := Task{ID: "off", Name: "off", Prompt: "p", Schedule: "@every 1h"}
|
||||
if err := saveTask(off); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primeNextRuns()
|
||||
if got, _ := getTask("off"); got.NextRun != 0 {
|
||||
t.Error("une tâche désactivée a été planifiée")
|
||||
}
|
||||
}
|
||||
|
||||
// Une tâche épinglée sur un preset qui n'existe plus échoue PROPREMENT : elle
|
||||
// est marquée en erreur (et surtout, elle ne tourne pas sur le mauvais modèle).
|
||||
func TestRunTaskPresetIntrouvable(t *testing.T) {
|
||||
testHome(t)
|
||||
tk := Task{ID: "p1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true, Preset: "fantome"}
|
||||
if err := saveTask(tk); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runTask(tk)
|
||||
got, _ := getTask("p1")
|
||||
if got.LastOK || !strings.Contains(got.LastError, "preset") {
|
||||
t.Errorf("échec de bascule mal signalé : %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// La note de contexte dit à l'IA qu'elle tourne seule, et lui redonne son
|
||||
// compte-rendu précédent — borné, pour ne pas manger le contexte.
|
||||
func TestTaskContextNote(t *testing.T) {
|
||||
n := taskContextNote("Veille mails", "")
|
||||
if !strings.Contains(n, "Veille mails") || !strings.Contains(n, "sans utilisateur") {
|
||||
t.Errorf("note incomplète : %q", n)
|
||||
}
|
||||
if strings.Contains(n, "dernière exécution") {
|
||||
t.Error("premier passage : il n'y a pas de compte-rendu à rappeler")
|
||||
}
|
||||
long := taskContextNote("t", strings.Repeat("y", reportMax+100))
|
||||
if !strings.Contains(long, "dernière exécution") {
|
||||
t.Error("le compte-rendu précédent doit être réinjecté")
|
||||
}
|
||||
if len(long) > reportMax+600 {
|
||||
t.Errorf("note non bornée : %d caractères", len(long))
|
||||
}
|
||||
}
|
||||
|
||||
// L'API refuse une tâche bancale AVANT de l'enregistrer : sans ça une fréquence
|
||||
// illisible donnerait une tâche qui ne se déclenche jamais, sans rien dire.
|
||||
func TestHandleTaskSaveValide(t *testing.T) {
|
||||
testHome(t)
|
||||
post := func(body string) (int, map[string]any) {
|
||||
rr := httptest.NewRecorder()
|
||||
handleTaskSave(rr, httptest.NewRequest("POST", "/api/tasks/save", strings.NewReader(body)))
|
||||
var out map[string]any
|
||||
_ = json.Unmarshal(rr.Body.Bytes(), &out)
|
||||
return rr.Code, out
|
||||
}
|
||||
if code, out := post(`{"name":"","prompt":"p","schedule":"@every 1h"}`); code != 400 {
|
||||
t.Errorf("nom vide accepté : %d %v", code, out)
|
||||
}
|
||||
if code, out := post(`{"name":"n","prompt":"","schedule":"@every 1h"}`); code != 400 {
|
||||
t.Errorf("consigne vide acceptée : %d %v", code, out)
|
||||
}
|
||||
if code, out := post(`{"name":"n","prompt":"p","schedule":"tous les jours"}`); code != 400 {
|
||||
t.Errorf("fréquence illisible acceptée : %d %v", code, out)
|
||||
}
|
||||
if len(listTasks()) != 0 {
|
||||
t.Fatal("une tâche refusée a quand même été enregistrée")
|
||||
}
|
||||
|
||||
code, out := post(`{"name":"n","prompt":"p","schedule":"0 9 * * 1-5","tz":"Europe/Paris","enabled":true}`)
|
||||
if code != 200 || out["ok"] != true {
|
||||
t.Fatalf("création refusée : %d %v", code, out)
|
||||
}
|
||||
id, _ := out["id"].(string)
|
||||
got, ok := getTask(id)
|
||||
if !ok || got.TZ != "Europe/Paris" || got.NextRun == 0 {
|
||||
t.Fatalf("tâche mal créée : %+v", got)
|
||||
}
|
||||
|
||||
// Édition : l'historique d'exécution SURVIT (on ne repart pas de zéro parce
|
||||
// qu'on a corrigé une faute dans la consigne).
|
||||
got.LastRun, got.LastReport, got.LastOK = 42, "vu hier", true
|
||||
if err := saveTask(got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code, out := post(`{"id":"` + id + `","name":"n2","prompt":"p2","schedule":"0 9 * * 1-5","enabled":true}`); code != 200 {
|
||||
t.Fatalf("édition refusée : %d %v", code, out)
|
||||
}
|
||||
after, _ := getTask(id)
|
||||
if after.Name != "n2" || after.Prompt != "p2" {
|
||||
t.Errorf("édition non appliquée : %+v", after)
|
||||
}
|
||||
if after.LastRun != 42 || after.LastReport != "vu hier" {
|
||||
t.Errorf("historique perdu à l'édition : %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// Un message envoyé pendant qu'une tâche tourne doit dire LAQUELLE occupe le
|
||||
// modèle : « génération en cours » sur un fil vide et immobile n'explique rien.
|
||||
func TestBusyReasonNommeLaTache(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := conv.busyReason(); err != ErrBusy {
|
||||
t.Errorf("hors tâche : %v, attendu %v", err, ErrBusy)
|
||||
}
|
||||
conv.mu.Lock()
|
||||
conv.runningTaskName = "Veille mails"
|
||||
conv.mu.Unlock()
|
||||
defer func() {
|
||||
conv.mu.Lock()
|
||||
conv.runningTaskName = ""
|
||||
conv.mu.Unlock()
|
||||
}()
|
||||
if err := conv.busyReason(); err == nil || !strings.Contains(err.Error(), "Veille mails") {
|
||||
t.Errorf("la tâche n'est pas nommée : %v", err)
|
||||
}
|
||||
}
|
||||
+2650
-306
File diff suppressed because it is too large.
Load diff
@@ -62,8 +62,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div id="conv-list" class="slist"></div>
|
||||
</details>
|
||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
|
||||
OU par son propre chevron — c'est une section comme les autres, sinon on ne
|
||||
trouvait pas comment la refermer.
|
||||
@@ -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
|
||||
ouvertes par programme (journal du moteur, installation llama.cpp)
|
||||
passent par openDetails(), qui déplie aussi les parents. -->
|
||||
<details id="side-settings"><summary><span class="stitle">Réglages</span></summary>
|
||||
<div class="sidegroup">
|
||||
<details id="svc-log-box" ontoggle="if(this.open){loadSvcLog();showPaths()}"><summary><span class="stitle">Journal du moteur</span></summary>
|
||||
</div><!-- /#side-scroll -->
|
||||
<!-- Réglages : tout vit dans une modale (nav de sections à gauche, panneau à
|
||||
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">
|
||||
<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>
|
||||
@@ -88,14 +273,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="io-cap">Emplacements</span>
|
||||
</div>
|
||||
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
|
||||
</details>
|
||||
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="config" id="cfg-details">
|
||||
<div class="set-pane-h"><span>Config active</span></div>
|
||||
<div id="cfg" class="slist">…</div>
|
||||
</details>
|
||||
<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>
|
||||
<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>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="agent">
|
||||
<div class="set-pane-h"><span>Mode agent</span></div>
|
||||
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
|
||||
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
|
||||
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
|
||||
@@ -168,7 +356,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<!-- Même texte que celui rendu après chargement quand il n'y a aucun
|
||||
serveur : sinon la carte partait vide puis grandissait d'une ligne. -->
|
||||
<div id="mcp-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucun serveur — ajoute un serveur fichiers, une base de données, une API…)</div></div>
|
||||
<button class="addbtn" style="margin-top:6px" onclick="openMcp('')">+ ajouter un serveur</button>
|
||||
<div class="mcp-actions">
|
||||
<button class="addbtn" onclick="openMcpCatalog()">catalogue</button>
|
||||
<button class="addbtn" onclick="openMcp('')">+ ajouter un serveur</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,13 +369,33 @@ 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>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">System prompt</span></summary>
|
||||
</section>
|
||||
<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
|
||||
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
|
||||
MCP, shell…). D'où l'avertissement quand le mode agent est off : sans
|
||||
lui, aucune tâche ne peut agir. -->
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="tasks-pause-toggle" onchange="toggleTasksPause()"><span class="slider"></span></span> suspendre toutes les tâches<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Interrupteur maître : coche pour que plus aucune tâche ne se déclenche. Les tâches gardent leur réglage et repartent quand tu décoches.</span></span></label>
|
||||
</div>
|
||||
<div id="tasks-agent-warn" class="muted" style="display:none;font-size:12px;line-height:1.5;margin:8px 2px">Le <b>mode agent</b> est désactivé : les tâches s'exécuteront mais l'IA n'aura <b>aucun outil</b> pour agir (envoyer un mail, lire des fichiers…). Active le mode agent ci-dessus.</div>
|
||||
<div class="slist">
|
||||
<div class="rowbody">
|
||||
<div id="tasks-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucune tâche — ex. « toutes les 2 h, relève mes mails et prépare des brouillons de réponse »)</div></div>
|
||||
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="sysprompt">
|
||||
<div class="set-pane-h"><span>System prompt</span></div>
|
||||
<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>
|
||||
</div></div>
|
||||
</details>
|
||||
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
|
||||
</section>
|
||||
<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
|
||||
même sujet. Les trois modes sont des LIGNES de cette carte (elles
|
||||
étaient des cartes dans une carte). -->
|
||||
@@ -200,7 +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 id="eng-latest" style="margin-top:6px"></div>
|
||||
<div class="row" style="margin:8px 0 0">
|
||||
<button onclick="engCheck()">vérifier la version</button>
|
||||
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
|
||||
confirmation) ; « mettre à jour » télécharge et bascule. Les deux
|
||||
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 id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
||||
</div>
|
||||
@@ -247,18 +461,71 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<button onclick="act('stop')">arrêter</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
</div><!-- /sidegroup -->
|
||||
</section>
|
||||
<!-- Dictée vocale. Réglages de SERVEUR : le modèle chargé et le GPU occupé
|
||||
sont des propriétés de la machine, identiques pour tous les onglets.
|
||||
Voir js/24-dictate-settings.js et internal/loki/dictate_*.go. -->
|
||||
<section class="set-pane" data-pane="dictee">
|
||||
<div class="set-pane-h"><span>Dictée</span></div>
|
||||
<div class="subhead">Matériel<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">whisper tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après <b>dix minutes</b> sans dictée — il ne garde pas la VRAM occupée pour rien. Le GPU choisi est transmis par <code>CUDA_VISIBLE_DEVICES</code>.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span>processeur ou GPU</span>
|
||||
<select id="dic-device" class="sctl"></select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="subhead">Modèle<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les modèles ne sont <b>pas</b> dans l'image : ils sont téléchargés à la demande dans <code>/data/whisper/</code> et survivent aux recréations du conteneur. Plus le modèle est gros, mieux il comprend — et plus il occupe de mémoire.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span>modèle</span>
|
||||
<select id="dic-model" class="sctl"></select>
|
||||
</label>
|
||||
<div class="rowbody">
|
||||
<button id="dic-dl" class="btn" onclick="dictateDownload()">Télécharger ce modèle</button>
|
||||
<span id="dic-dl-state" class="dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Reconnaissance</div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span>langue</span>
|
||||
<select id="dic-lang" class="sctl">
|
||||
<option value="fr">français</option>
|
||||
<option value="en">anglais</option>
|
||||
<option value="de">allemand</option>
|
||||
<option value="es">espagnol</option>
|
||||
<option value="it">italien</option>
|
||||
<option value="auto">détection automatique</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="switchrow"><span>réactivité<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Longueur des tranches transcrites au fil de la dictée. Court = le texte arrive plus tôt, avec moins de contexte donc plus d'erreurs. Long = mieux reconnu, mais plus d'attente.</span></span></span>
|
||||
<select id="dic-react" class="sctl">
|
||||
<option value="court">court</option>
|
||||
<option value="moyen">moyen</option>
|
||||
<option value="long">long</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="rowbody">
|
||||
<button class="btn primary" onclick="dictateSave()">Enregistrer</button>
|
||||
<span id="dic-save-state" class="dim"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subhead">Vérifier</div>
|
||||
<div class="slist">
|
||||
<div class="rowbody">
|
||||
<button id="dic-test" class="btn" onclick="dictateTestMic()">Tester le micro (3 s)</button>
|
||||
</div>
|
||||
<div id="dic-test-out" class="dim"></div>
|
||||
<div id="dic-state" class="dim"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
|
||||
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
|
||||
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
|
||||
l'application elle-même. -->
|
||||
<div class="side-sep">Paramètres</div>
|
||||
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
|
||||
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. -->
|
||||
<div class="sidegroup">
|
||||
<details id="ident-details"><summary><span class="stitle">Identité</span></summary>
|
||||
<section class="set-pane" data-pane="identity" id="ident-details">
|
||||
<div class="set-pane-h"><span>Identité</span></div>
|
||||
<div class="slist"><div class="rowbody">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
|
||||
@@ -278,8 +545,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div id="p-avatar-ai" class="avpick"></div>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Apparence</span></summary>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="appearance">
|
||||
<div class="set-pane-h"><span>Apparence</span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow rev"><span>mode sombre</span>
|
||||
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
|
||||
@@ -292,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-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>
|
||||
</details>
|
||||
<details><summary><span class="stitle">Accès OpenAI</span></summary>
|
||||
</section>
|
||||
<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="slist">
|
||||
<div class="rowbody">
|
||||
@@ -329,8 +598,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<button onclick="apiKeyAction('clear')">retirer</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
<details id="upd-details"><summary><span class="stitle">Actions</span></summary>
|
||||
</section>
|
||||
<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">
|
||||
<!-- 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
|
||||
@@ -346,131 +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>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
</div><!-- /sidegroup -->
|
||||
</details><!-- /#side-settings -->
|
||||
</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>
|
||||
</section>
|
||||
</div><!-- /.set-body -->
|
||||
</div><!-- /.set-shell -->
|
||||
</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">
|
||||
<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)">
|
||||
<!-- 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:8px;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 class="modal-card" style="max-width:460px">
|
||||
<div class="modal-head">
|
||||
@@ -574,6 +724,23 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-catalog-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcpCatalog()">
|
||||
<div class="modal-card" style="max-width:640px">
|
||||
<div class="modal-head">
|
||||
<strong>Catalogue MCP</strong>
|
||||
<button class="modal-x" onclick="closeMcpCatalog()" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="muted" style="font-size:12px;margin-bottom:8px">
|
||||
Serveurs connus, embarqués dans Loki — aucun appel réseau pour cette liste.
|
||||
« Utiliser » remplit le formulaire d'ajout : la commande s'exécutera sur
|
||||
cette machine, relis-la avant d'enregistrer.
|
||||
</div>
|
||||
<div id="mcp-catalog-tabs" class="mcp-cat-tabs"></div>
|
||||
<div id="mcp-catalog-list" class="mcp-cat-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mcp-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeMcp()">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<div class="modal-head">
|
||||
@@ -633,6 +800,106 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="task-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeTask()">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<strong id="task-modal-title">Tâche planifiée</strong>
|
||||
<button class="modal-x" onclick="closeTask()" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<div class="pe-body">
|
||||
<div id="task-result-block" class="pe-group" style="display:none">
|
||||
<div class="pe-gh">Dernier résultat <span id="task-result-when" class="pe-sub" style="display:inline;margin:0"></span></div>
|
||||
<div id="task-result" class="task-report"></div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Nom</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row stack plain"><input id="task-name" class="pe-inp" placeholder="Nom de la tâche"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Consigne</div>
|
||||
<textarea id="task-prompt" class="pe-inp task-consigne" spellcheck="false" placeholder="Ce que l'IA doit faire, et comment livrer le résultat. Tu peux détailler librement : contexte, étapes, format du compte-rendu attendu…"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Preset</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Modèle utilisé</span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="task-preset"></select></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Fréquence</div>
|
||||
<div class="pe-seg">
|
||||
<label><input type="radio" name="task-freq-mode" value="interval" onchange="taskFreqUI('interval')"><span>Intervalle</span></label>
|
||||
<label><input type="radio" name="task-freq-mode" value="cron" onchange="taskFreqUI('cron')"><span>Horaire précis</span></label>
|
||||
</div>
|
||||
<div class="pe-list" style="margin-top:8px">
|
||||
<div id="task-interval-fields" class="pe-row">
|
||||
<span class="pe-row-l">Toutes les</span>
|
||||
<span class="pe-row-c">
|
||||
<input id="task-interval-n" class="pe-val" type="number" min="1" value="2" style="width:54px">
|
||||
<span class="pe-selc"><select id="task-interval-unit" onchange="taskUnitUI()">
|
||||
<option value="m">minutes</option>
|
||||
<option value="h" selected>heures</option>
|
||||
<option value="d">jours</option>
|
||||
</select></span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="task-time-row" class="pe-row" style="display:none">
|
||||
<span class="pe-row-l">À</span>
|
||||
<span class="pe-row-c"><input id="task-time" class="pe-val" type="time" value="09:00" style="width:110px"></span>
|
||||
</div>
|
||||
<div id="task-cron-fields" class="pe-row stack" style="display:none">
|
||||
<input id="task-cron" class="pe-inp" placeholder="0 9 * * 1-5" spellcheck="false" style="font-family:var(--mono)">
|
||||
<div class="pe-note"><code>minute heure jour mois jour-semaine</code></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-gh">Accès</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Accès à la mémoire</span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-mem" checked><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Accès au web</span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-web" checked><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pe-group">
|
||||
<div class="pe-list">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Tâche active</span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-enabled" checked><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="task-modal-status" class="pe-seg-note" style="padding:0 6px"></div>
|
||||
</div>
|
||||
<div class="pe-foot">
|
||||
<div class="foot-l">
|
||||
<button id="task-del" class="pe-del" onclick="deleteTaskUI()" title="supprimer" aria-label="supprimer" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>
|
||||
</div>
|
||||
<div class="foot-r">
|
||||
<button id="task-run" class="pe-btn" onclick="runTaskNow()" style="display:none">tester maintenant</button>
|
||||
<button class="pe-btn" onclick="closeTask()">annuler</button>
|
||||
<button class="pe-btn primary" onclick="saveTask()">enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="modal" class="modal-ov" style="display:none">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
@@ -707,6 +974,23 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
|
||||
</div>
|
||||
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
|
||||
touche qu'une fois, le jour où un dépôt à accès restreint refuse
|
||||
le téléchargement. Il vaut aussi pour la recherche, d'où sa place
|
||||
dans ce groupe plutôt qu'à côté de la clé API du serveur, qui ne
|
||||
parle pas du tout de la même chose. -->
|
||||
<div id="m-hft-toggle" class="pe-row" style="cursor:pointer" onclick="toggleHFToken()">
|
||||
<span class="pe-row-l">Jeton Hugging Face<span class="pe-sub">dépôts à accès restreint (« gated ») ou privés</span></span>
|
||||
<span class="pe-row-c"><span id="m-hft-state" class="pe-sub"></span><span id="m-hft-caret" class="pe-caret"></span></span>
|
||||
</div>
|
||||
<div id="m-hft-body" class="pe-row stack" style="display:none">
|
||||
<div class="pe-inpbtn">
|
||||
<input id="m-hft-input" class="pe-inp" type="password" autocomplete="off" spellcheck="false" placeholder="hf_…" onkeydown="if(event.key==='Enter'){event.preventDefault();saveHFToken();}">
|
||||
<button id="m-hft-save" onclick="saveHFToken()" title="Vérifier et enregistrer" aria-label="Enregistrer">✓</button>
|
||||
</div>
|
||||
<div id="m-hft-note" class="pe-note"></div>
|
||||
<button id="m-hft-clear" class="pe-link" onclick="clearHFToken()" style="display:none">Retirer le jeton</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -719,7 +1003,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<label id="be-lab-image" style="display:none"><input type="radio" name="m-be" value="image" onchange="onBackendMode('image')"><span>Image</span><span class="be-note" id="be-image-note"></span></label>
|
||||
<label id="be-lab-fast"><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Précompilé</span><span class="be-note" id="be-fast-note"></span></label>
|
||||
<label id="be-lab-opt"><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Compilé</span><span class="be-note" id="be-opt-note"></span></label>
|
||||
<label><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
|
||||
<label id="be-lab-custom"><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
|
||||
</div>
|
||||
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
|
||||
<div class="pe-row">
|
||||
@@ -756,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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
@@ -821,6 +1105,19 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-l">Raisonnement<span class="pe-sub" id="s-reasoning-sub">réflexion étape par étape</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<div class="pe-row" id="s-reasoning-effort-row">
|
||||
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">défaut de ce preset — ajustable à la volée depuis la barre de saisie</span></span>
|
||||
<span class="pe-row-c">
|
||||
<select id="s-reasoning-effort" class="pe-val" onchange="cfgWriteKey('REASONING_EFFORT', 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>
|
||||
</span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Flash attention<span class="pe-sub">plus rapide, moins de VRAM</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-flash" onchange="eaSetValued('--flash-attn', this.checked?'on':'')"><span class="pe-sl"></span></label></span>
|
||||
@@ -836,6 +1133,42 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Échantillonnage (repris de l'amont AJEAN v0.9.5/v0.9.6) : ces valeurs
|
||||
partent dans CHAQUE requête de complétion, pas sur la ligne de commande
|
||||
du moteur — c'est le seul endroit où elles prennent effet. Un champ vide
|
||||
n'envoie rien, llama-server garde son défaut. Les valeurs recommandées
|
||||
dépendent du modèle (Qwen3.8 en réflexion : temp 1.0, top_p 0.95,
|
||||
top_k 20, min_p 0). -->
|
||||
<div id="m-sampling-row" class="pe-group" style="display:none">
|
||||
<div class="pe-gh">Échantillonnage</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Température<span class="pe-sub">vide = défaut du modèle</span></span>
|
||||
<span class="pe-row-c"><input id="s-temp" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('TEMP', this.value)"></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">top_p<span class="pe-sub">noyau de probabilité</span></span>
|
||||
<span class="pe-row-c"><input id="s-topp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('TOP_P', this.value)"></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">top_k<span class="pe-sub">0 = désactivé</span></span>
|
||||
<span class="pe-row-c"><input id="s-topk" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="cfgWriteKey('TOP_K', this.value)"></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">min_p<span class="pe-sub">seuil de probabilité</span></span>
|
||||
<span class="pe-row-c"><input id="s-minp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('MIN_P', this.value)"></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Pénalité de présence<span class="pe-sub">pousse vers de nouveaux mots (0 = off)</span></span>
|
||||
<span class="pe-row-c"><input id="s-presp" class="pe-val" type="number" min="0" max="2" step="0.1" placeholder="—" oninput="cfgWriteKey('PRESENCE_PENALTY', this.value)"></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Pénalité de répétition<span class="pe-sub">anti-répétition (1 = neutre)</span></span>
|
||||
<span class="pe-row-c"><input id="s-reppen" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('REPEAT_PENALTY', this.value)"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-raw" class="pe-group">
|
||||
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
||||
<div class="pe-list">
|
||||
|
||||
@@ -26,6 +26,9 @@ const ICONS = {
|
||||
plus: '<path d="M12 5.5v13"/><path d="M5.5 12h13"/>',
|
||||
chevron: '<path d="M9.5 5.5 16 12l-6.5 6.5"/>',
|
||||
search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.4-4.4"/>',
|
||||
// Anneau ouvert : c'est la ROTATION (CSS) qui en fait un indicateur d'activité,
|
||||
// le dessin reste au même trait que le reste du jeu.
|
||||
spinner: '<path d="M12 3.5a8.5 8.5 0 1 1-8.5 8.5"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2"/><path d="M12 19.3v2.2"/><path d="M4.2 4.2l1.6 1.6"/><path d="M18.2 18.2l1.6 1.6"/><path d="M2.5 12h2.2"/><path d="M19.3 12h2.2"/><path d="M4.2 19.8l1.6-1.6"/><path d="M18.2 5.8l1.6-1.6"/>',
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ async function loadStatus(){
|
||||
let cls='err', txt='arrêté';
|
||||
if(s.active && s.health){ cls='ok'; txt='prêt'; }
|
||||
else if(s.load_error){ cls='err'; txt='erreur'; }
|
||||
else if(s.active){ cls='loading'; txt='chargement…'; }
|
||||
else if(s.active){
|
||||
cls='loading'; txt='chargement…';
|
||||
// Pourcentage estimé (octets du modèle déjà lus) : voir « ça avance »
|
||||
// plutôt qu'un « chargement… » figé qui ressemble à un plantage.
|
||||
if(typeof s.load_pct==='number' && s.load_pct>=0) txt='chargement '+s.load_pct+' %';
|
||||
}
|
||||
el.className='statuspill '+cls;
|
||||
el.innerHTML='<span class="dot"></span>'+txt;
|
||||
MODEL_READY = !!(s.active && s.health);
|
||||
|
||||
@@ -451,7 +451,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]);
|
||||
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
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -101,6 +101,7 @@ async function openItem(kind, key){
|
||||
// Model picker is preset-only: it edits the MODEL= line of the preset.
|
||||
const modelRow = document.getElementById('m-model-row');
|
||||
const settingsRow = document.getElementById('m-settings-row');
|
||||
const samplingRow = document.getElementById('m-sampling-row');
|
||||
const rawHead = document.getElementById('m-raw-head');
|
||||
const rawToggle = document.getElementById('m-raw-toggle');
|
||||
const rawBody = document.getElementById('m-raw-body');
|
||||
@@ -108,6 +109,7 @@ async function openItem(kind, key){
|
||||
if(kind === 'preset'){
|
||||
modelRow.style.display = 'flex';
|
||||
settingsRow.style.display = 'flex';
|
||||
if(samplingRow) samplingRow.style.display = 'flex';
|
||||
document.getElementById('m-hf-url').value = '';
|
||||
resetDlUI();
|
||||
resetHfUI();
|
||||
@@ -119,9 +121,14 @@ async function openItem(kind, key){
|
||||
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
|
||||
document.getElementById('m-dir-path').value = '';
|
||||
setModelDirsOpen(false);
|
||||
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
|
||||
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
|
||||
setHFTokenOpen(false);
|
||||
loadHFToken();
|
||||
} else {
|
||||
modelRow.style.display = 'none';
|
||||
settingsRow.style.display = 'none';
|
||||
if(samplingRow) samplingRow.style.display = 'none';
|
||||
// Page mémoire : le contenu EST le champ principal — affiché en clair.
|
||||
rawHead.textContent = 'Contenu';
|
||||
rawToggle.style.display = 'none';
|
||||
@@ -258,6 +265,70 @@ function setModelDirsOpen(open){
|
||||
function toggleModelDirs(){
|
||||
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
|
||||
}
|
||||
|
||||
// ---- Jeton Hugging Face ----------------------------------------------------
|
||||
// Un dépôt « gated » se lit sans jeton mais ne se télécharge pas. Le jeton se
|
||||
// posait uniquement par la variable d'environnement HF_TOKEN : en conteneur, ça
|
||||
// voulait dire éditer un docker-compose et recréer le conteneur pour répondre à
|
||||
// un 401 découvert ici. Il se règle donc à l'endroit où le problème apparaît.
|
||||
function setHFTokenOpen(open){
|
||||
const b = document.getElementById('m-hft-body');
|
||||
const c = document.getElementById('m-hft-caret');
|
||||
if(!b) return;
|
||||
b.style.display = open ? '' : 'none';
|
||||
if(c) c.classList.toggle('open', open);
|
||||
}
|
||||
function toggleHFToken(){
|
||||
setHFTokenOpen(document.getElementById('m-hft-body').style.display === 'none');
|
||||
}
|
||||
async function loadHFToken(){
|
||||
let d = {};
|
||||
try{ d = await jget('/api/hf/token'); }catch(_){ return; }
|
||||
renderHFToken(d);
|
||||
}
|
||||
// Le jeton n'est jamais renvoyé en clair par le serveur : on n'affiche que sa
|
||||
// forme masquée et sa provenance. « env » se signale à part — l'interface ne
|
||||
// peut pas effacer une variable du conteneur, promettre le contraire par un
|
||||
// bouton « Retirer » serait mentir.
|
||||
function renderHFToken(d){
|
||||
const st = document.getElementById('m-hft-state');
|
||||
const note = document.getElementById('m-hft-note');
|
||||
const clear = document.getElementById('m-hft-clear');
|
||||
const inp = document.getElementById('m-hft-input');
|
||||
if(!st || !note || !clear || !inp) return;
|
||||
inp.value = '';
|
||||
const src = (d && d.source) || '';
|
||||
st.textContent = src ? (d.masked || 'enregistré') : 'aucun';
|
||||
clear.style.display = src === 'config' ? '' : 'none';
|
||||
if(src === 'env'){
|
||||
note.textContent = 'Jeton fourni par la variable d’environnement HF_TOKEN. En enregistrer un ici le remplacera.';
|
||||
} else if(src === 'config'){
|
||||
note.textContent = 'Jeton enregistré' + (d.account ? ' — compte ' + d.account : '')
|
||||
+ '. Un dépôt à accès restreint exige EN PLUS d’avoir accepté ses conditions sur huggingface.co avec ce compte.';
|
||||
} else {
|
||||
note.textContent = 'Sans jeton, les dépôts à accès restreint répondent 401 au téléchargement. Crée un jeton en lecture sur huggingface.co/settings/tokens, accepte les conditions du dépôt, puis colle-le ici.';
|
||||
}
|
||||
}
|
||||
// Vérifié côté serveur AVANT d'être enregistré : un jeton mal collé accepté en
|
||||
// silence rendrait exactement le 401 qu'on cherche à expliquer.
|
||||
async function saveHFToken(){
|
||||
const inp = document.getElementById('m-hft-input');
|
||||
const tok = (inp.value || '').trim();
|
||||
if(!tok){ toast('colle un jeton (hf_…)'); return; }
|
||||
toast('vérification du jeton…');
|
||||
const r = await jpost('/api/hf/token', {token: tok});
|
||||
if(!r || !r.ok){ toast('jeton refusé : ' + ((r && r.error) || '')); return; }
|
||||
renderHFToken(r);
|
||||
toast(r.account ? 'jeton enregistré — compte ' + r.account : 'jeton enregistré');
|
||||
}
|
||||
async function clearHFToken(){
|
||||
if(!await askConfirm('Les dépôts à accès restreint répondront de nouveau 401 au téléchargement.',
|
||||
{title:'Retirer le jeton Hugging Face ?', okText:'Retirer'})) return;
|
||||
const r = await jpost('/api/hf/token', {token: ''});
|
||||
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || '')); return; }
|
||||
renderHFToken(r);
|
||||
toast('jeton retiré');
|
||||
}
|
||||
async function populateModelDirs(){
|
||||
const box = document.getElementById('m-dirs-list');
|
||||
if(!box) return;
|
||||
@@ -273,7 +344,9 @@ async function populateModelDirs(){
|
||||
const info = document.createElement('span');
|
||||
info.className = 'muted';
|
||||
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
|
||||
const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
|
||||
// « ~ » quand le système de fichiers approxime (FUSE/shfs d'Unraid,
|
||||
// overlay, réseau) : le chiffre est indicatif, il ne bloque plus rien.
|
||||
const free = x.free >= 0 ? ', ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : '';
|
||||
info.textContent = '(' + n + free + (x.home ? ', dossier loki' : '') + ')';
|
||||
row.append(p, info);
|
||||
if(!x.home){
|
||||
@@ -399,6 +472,9 @@ async function populateBackend(){
|
||||
document.getElementById('be-lab-image').style.display = beImagePath ? '' : 'none';
|
||||
document.getElementById('be-lab-fast').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-lab-opt').style.display = beImagePath ? 'none' : '';
|
||||
// En conteneur, « Personnalisé » ne mène nulle part (rien à compiler, et le
|
||||
// moteur se met à jour par la carte Moteur des réglages) : on le retire.
|
||||
document.getElementById('be-lab-custom').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-image-note').textContent = beImagePath ? 'llama.cpp officiel' : '';
|
||||
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
|
||||
// le dossier backends de loki (l'utilisateur peut y déposer son propre build).
|
||||
@@ -413,8 +489,11 @@ async function populateBackend(){
|
||||
const cur = currentBinInTextarea();
|
||||
let mode = 'custom';
|
||||
// Un preset sans BIN hérite du moteur global : en conteneur c'est celui de
|
||||
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ».
|
||||
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ». Et comme
|
||||
// l'option Personnalisé n'y est plus proposée, TOUT BIN non reconnu retombe
|
||||
// aussi sur l'image (un vieux preset pointant un chemin mort redevient sain).
|
||||
if(beImagePath && (sameBinPath(cur, beImagePath) || !cur)) mode = 'image';
|
||||
else if(beImagePath) mode = 'image';
|
||||
else if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
|
||||
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
|
||||
const radio = document.querySelector('input[name=m-be][value='+mode+']');
|
||||
@@ -729,6 +808,14 @@ function populateSettings(){
|
||||
set('s-ubatch', cfgReadKey('UBATCH'));
|
||||
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
|
||||
set('s-kv', cfgReadKey('KV_TYPE'));
|
||||
// Échantillonnage : envoyé PAR REQUÊTE (applySampling côté serveur), donc pas
|
||||
// besoin de redémarrer le moteur pour qu'un changement prenne effet.
|
||||
set('s-temp', cfgReadKey('TEMP'));
|
||||
set('s-topp', cfgReadKey('TOP_P'));
|
||||
set('s-topk', cfgReadKey('TOP_K'));
|
||||
set('s-minp', cfgReadKey('MIN_P'));
|
||||
set('s-presp', cfgReadKey('PRESENCE_PENALTY'));
|
||||
set('s-reppen', cfgReadKey('REPEAT_PENALTY'));
|
||||
set('s-moe', eaGetValued('--n-cpu-moe'));
|
||||
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
|
||||
setSpecType(eaGetValued('--spec-type'));
|
||||
@@ -746,6 +833,23 @@ function populateSettings(){
|
||||
rzSub.textContent = rz ? 'réflexion étape par étape'
|
||||
: 'réflexion étape par étape — non précisé : le modèle décide';
|
||||
}
|
||||
// Intensité : passée telle quelle au gabarit jinja du modèle via
|
||||
// `reasoning_effort`. Seuls les gabarits qui la lisent (gpt-oss et apparentés)
|
||||
// changent de comportement ; ailleurs elle est ignorée sans erreur — le
|
||||
// sous-titre le dit pour ne pas laisser croire à un effet garanti.
|
||||
const eff = document.getElementById('s-reasoning-effort');
|
||||
if(eff){
|
||||
eff.value = (cfgReadKey('REASONING_EFFORT')||'').toLowerCase();
|
||||
const effSub = document.getElementById('s-reasoning-effort-sub');
|
||||
if(effSub){
|
||||
// Deux emplacements, deux portées : ici c'est le défaut ENREGISTRÉ avec le
|
||||
// preset (donc rattaché au modèle) ; la liste de la barre de saisie le
|
||||
// dévie pour la suite de la session, jusqu'au prochain preset appliqué.
|
||||
effSub.textContent = eff.value
|
||||
? 'défaut de ce preset — sans effet si le gabarit du modèle l\'ignore'
|
||||
: 'défaut de ce preset — ajustable à la volée depuis la barre de saisie';
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
chk('s-mlock', eaHasFlag('--mlock'));
|
||||
chk('s-nommap', eaHasFlag('--no-mmap'));
|
||||
@@ -879,7 +983,7 @@ async function populateDlDirs(){
|
||||
for(const x of dlDirList){
|
||||
const o = document.createElement('option');
|
||||
o.value = x.path;
|
||||
o.textContent = x.path + (x.free >= 0 ? ' — ' + fmtSize(x.free) + ' libres' : '');
|
||||
o.textContent = x.path + (x.free >= 0 ? ' — ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : '');
|
||||
sel.append(o);
|
||||
}
|
||||
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
|
||||
@@ -917,7 +1021,9 @@ async function startDownloadURL(url){
|
||||
// p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas
|
||||
// les 15 Go du fichier dont le lien a été collé.
|
||||
const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : '';
|
||||
e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '+fmtSize(p.free)+' libres';
|
||||
e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '
|
||||
+ (p.free_exact === false ? '~' : '') + fmtSize(p.free) + ' libres'
|
||||
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
|
||||
const r = await jpost('/api/models/download', {url, dir});
|
||||
if(!r.ok){
|
||||
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
|
||||
@@ -1055,11 +1161,29 @@ async function hfSearch(){
|
||||
const m = document.createElement('span'); m.className = 'hf-meta';
|
||||
m.textContent = fmtCount(rep.downloads)+' ↓';
|
||||
row.append(n, m);
|
||||
// Dépôt verrouillé : il se LIT sans rien, mais chaque .gguf répond 401 au
|
||||
// transfert. La pastille le dit dès la liste — sinon on choisit un quant,
|
||||
// on lance 15 Go et l'erreur tombe à la fin de la sonde.
|
||||
if(rep.gated){
|
||||
const g = document.createElement('span');
|
||||
g.className = 'hf-fit gated';
|
||||
g.textContent = 'accès restreint';
|
||||
g.title = hfGatedHint(r.hf_token);
|
||||
row.appendChild(g);
|
||||
}
|
||||
list.appendChild(row);
|
||||
}
|
||||
o.appendChild(list);
|
||||
}
|
||||
|
||||
// Le geste à faire n'est pas le même selon qu'un jeton est configuré ou non :
|
||||
// sans jeton il en faut un, avec jeton c'est l'accès au dépôt qui manque.
|
||||
function hfGatedHint(hasToken){
|
||||
return hasToken
|
||||
? 'Dépôt à accès restreint : le jeton enregistré sera utilisé. Si le téléchargement échoue en 401, accepte les conditions du dépôt sur huggingface.co avec le compte de ce jeton.'
|
||||
: 'Dépôt à accès restreint : accepte ses conditions sur huggingface.co, puis enregistre un jeton dans « Jeton Hugging Face » plus bas. Sans jeton, le téléchargement échouera en 401.';
|
||||
}
|
||||
|
||||
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
|
||||
// « celui-là est le plus utilisé », pas son compte exact.
|
||||
function fmtCount(n){
|
||||
@@ -1087,8 +1211,36 @@ async function hfPickRepo(repo){
|
||||
back.onclick = hfSearch;
|
||||
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
|
||||
head.append(back, title);
|
||||
if(r.gated){
|
||||
const g = document.createElement('span');
|
||||
g.className = 'hf-fit gated'; g.textContent = 'accès restreint';
|
||||
head.appendChild(g);
|
||||
}
|
||||
o.appendChild(head);
|
||||
|
||||
// Avertissement en toutes lettres : la pastille seule ne dit pas quoi faire,
|
||||
// et c'est ici que le téléchargement se déclenche.
|
||||
if(r.gated){
|
||||
const warn = document.createElement('div');
|
||||
warn.className = 'pe-note';
|
||||
warn.style.color = r.hf_token ? 'var(--warn)' : 'var(--err)';
|
||||
warn.textContent = hfGatedHint(r.hf_token);
|
||||
o.appendChild(warn);
|
||||
// Le réglage est plus bas dans la même fenêtre : y mener directement évite
|
||||
// de le chercher, et c'est le seul geste qui débloque ce dépôt.
|
||||
if(!r.hf_token){
|
||||
const go = document.createElement('button');
|
||||
go.className = 'pe-link'; go.textContent = 'Renseigner un jeton Hugging Face';
|
||||
go.onclick = ()=>{
|
||||
setHFTokenOpen(true);
|
||||
const inp = document.getElementById('m-hft-input');
|
||||
document.getElementById('m-hft-toggle').scrollIntoView({block:'center', behavior:'smooth'});
|
||||
if(inp) inp.focus();
|
||||
};
|
||||
o.appendChild(go);
|
||||
}
|
||||
}
|
||||
|
||||
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
|
||||
// encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un
|
||||
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
|
||||
|
||||
@@ -56,9 +56,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
});
|
||||
|
||||
// Badge de rôle du mode code (builder / verifier) : posé par 09-stream.js à
|
||||
// réception d'un delta {role}, repris par addMsg sur les bulles suivantes.
|
||||
let ROLE_BADGE='';
|
||||
function addMsg(role, text){
|
||||
const el=document.createElement('div');
|
||||
el.className='msg '+role;
|
||||
if(ROLE_BADGE && (role==='assistant'||role==='reasoning'||role==='tool')) el.dataset.agentRole=ROLE_BADGE;
|
||||
const collapsible = (role==='reasoning' || role==='tool');
|
||||
// .body must be a real block so <p>/<pre>/<ul> margins behave properly.
|
||||
if(collapsible){
|
||||
@@ -72,6 +76,11 @@ function addMsg(role, text){
|
||||
if((role==='user'||role==='assistant') && typeof paintLabel==='function'){
|
||||
paintLabel(el.querySelector('.label'), role);
|
||||
}
|
||||
// Badge visible sur la bulle assistant pendant une passe de rôle.
|
||||
if(ROLE_BADGE && role==='assistant'){
|
||||
const b=document.createElement('span'); b.className='rolebadge'; b.textContent=ROLE_BADGE==='verifier'?'vérification':ROLE_BADGE;
|
||||
el.querySelector('.label').appendChild(b);
|
||||
}
|
||||
}
|
||||
el.querySelector('.body').textContent=text;
|
||||
// Bulles techniques (raisonnement, outils, mémoire) : elles se posent CÔTE À
|
||||
@@ -153,13 +162,21 @@ function setLabelCounts(el, add, del){
|
||||
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
|
||||
lab.appendChild(cnt);
|
||||
}
|
||||
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki
|
||||
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
|
||||
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
|
||||
// par la préférence d'affichage « masquer la vitesse de génération » (CSS).
|
||||
function setStats(el, text){
|
||||
// Ligne de mesures sous une réponse. Les étiquettes VOUS/Loki sont masquées dans
|
||||
// cette mise en page, donc les chiffres qu'on y écrivait avaient disparu : ils
|
||||
// ont leur propre ligne, discrète, sous le texte.
|
||||
//
|
||||
// Deux informations de nature différente y cohabitent, d'où deux éléments :
|
||||
// .worktime — le TEMPS DE TRAVAIL du tour (de la question à la fin de la
|
||||
// réponse, outils et raisonnement compris) ;
|
||||
// .spd — les mesures de vitesse (prefill / decode).
|
||||
// Seule la seconde est escamotée par « masquer la vitesse de génération » : la
|
||||
// durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté en
|
||||
// temps. La ligne entière disparaît si elle ne porte plus que de la vitesse.
|
||||
function setStats(el, work, speed){
|
||||
if(!el) return;
|
||||
let s = el.querySelector(':scope > .statline');
|
||||
if(!work && !speed){ if(s) s.textContent=''; return; }
|
||||
if(!s){
|
||||
s=document.createElement('div'); s.className='statline';
|
||||
// Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois
|
||||
@@ -169,7 +186,18 @@ function setStats(el, text){
|
||||
if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in');
|
||||
el.appendChild(s);
|
||||
}
|
||||
s.textContent = text;
|
||||
// Les deux travées sont RÉUTILISÉES d'un rafraîchissement à l'autre : la ligne
|
||||
// est repeinte à chaque token reçu, y recréer des éléments ferait travailler la
|
||||
// mise en page des dizaines de fois par seconde pour rien.
|
||||
let w = s.querySelector(':scope > .worktime');
|
||||
let p = s.querySelector(':scope > .spd');
|
||||
if(!w){ w=document.createElement('span'); w.className='worktime'; s.appendChild(w); }
|
||||
if(!p){ p=document.createElement('span'); p.className='spd'; s.appendChild(p); }
|
||||
w.textContent = work || '';
|
||||
// Le séparateur voyage AVEC la vitesse : masquer celle-ci ne doit pas laisser
|
||||
// un « · » orphelin en bout de durée.
|
||||
p.textContent = speed ? ((work ? ' \u00b7 ' : '') + speed) : '';
|
||||
s.classList.toggle('has-work', !!work);
|
||||
}
|
||||
function bodyOf(el){ return el.querySelector('.body'); }
|
||||
// Render markdown into a message body in place; safe because md() escapes HTML.
|
||||
@@ -191,6 +219,13 @@ function hydrateImages(root){
|
||||
img.dataset.hydrated = '1';
|
||||
const src = img.getAttribute('src');
|
||||
img.classList.add('chatimg');
|
||||
// Nom lisible pour la loupe : la fin du chemin visé, pas l'URL d'API.
|
||||
let name = img.getAttribute('alt') || '';
|
||||
if(!name){
|
||||
const m = /[?&]path=([^&]*)/.exec(src);
|
||||
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
||||
}
|
||||
bindZoom(img, name);
|
||||
const cached = IMG_CACHE.get(src);
|
||||
if(cached){ img.src = cached; return; }
|
||||
try{
|
||||
@@ -223,6 +258,17 @@ function renderToolMsg(el, tu){
|
||||
web_open: {lbl:'page web', head:'ouverture'},
|
||||
web_read: {lbl:'page web', head:'lecture'},
|
||||
web_grep: {lbl:'page web', head:'recherche'},
|
||||
// Outils du mode code : chacun son étiquette, pas le fallback mémoire.
|
||||
read: {lbl:'lecture', head:'lecture'},
|
||||
grep: {lbl:'recherche', head:'grep'},
|
||||
glob: {lbl:'fichiers', head:'glob'},
|
||||
criteria: {lbl:'critères', head:'critères'},
|
||||
ask: {lbl:'question', head:'question'},
|
||||
bash_bg: {lbl:'terminal', head:'commande (fond)'},
|
||||
bash_tail: {lbl:'terminal', head:'suivi de job'},
|
||||
git_status: {lbl:'git', head:'git status'},
|
||||
git_diff: {lbl:'git', head:'git diff'},
|
||||
git_clone: {lbl:'git', head:'git clone'},
|
||||
};
|
||||
// Outils MCP (nom mcp__<serveur>__<outil>) : en-tête = nom du serveur, libellé lisible,
|
||||
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
|
||||
@@ -280,6 +326,21 @@ function renderToolMsg(el, tu){
|
||||
// la ligne en cours (max-height côté CSS l'empêche de pousser le fil).
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
// Capture d'écran : l'image est rendue ICI, dans la bulle de l'outil. Elle ne
|
||||
// dépend donc plus de la bonne volonté du modèle à recopier la ligne markdown
|
||||
// — il l'oubliait, et l'utilisateur ne voyait jamais ce qu'il avait demandé.
|
||||
if(tu.image){
|
||||
const wrap=document.createElement('div'); wrap.className='tool-shot';
|
||||
const img=document.createElement('img');
|
||||
// src (et non data-src) : hydrateImages sélectionne sur src, puis le
|
||||
// remplace par un blob obtenu avec la clé de pilotage.
|
||||
img.setAttribute('src', '/api/chat/image?path='+encodeURIComponent(tu.image));
|
||||
img.alt = tu.image.split('/').pop();
|
||||
img.loading = 'lazy';
|
||||
wrap.appendChild(img);
|
||||
body.appendChild(wrap);
|
||||
hydrateImages(wrap);
|
||||
}
|
||||
// Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert,
|
||||
// retirées en rouge, contexte en gris — comme un diff de terminal.
|
||||
if(tu.diff && tu.diff.length){
|
||||
@@ -307,6 +368,11 @@ function renderToolMsg(el, tu){
|
||||
body.appendChild(wait);
|
||||
}
|
||||
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).
|
||||
function addCopyButtons(root){
|
||||
|
||||
@@ -51,8 +51,107 @@ function confirmPending(text){
|
||||
let REPLAYING=true;
|
||||
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
||||
let T=null;
|
||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; }
|
||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
|
||||
newTurn();
|
||||
|
||||
// --- Temps de travail du tour ------------------------------------------------
|
||||
// « Combien de temps l'IA a-t-elle travaillé ? » se lit sous la réponse, de la
|
||||
// question envoyée à la fin du tour — raisonnement, appels d'outils et attentes
|
||||
// compris. La mesure est prise sur les HORODATAGES SERVEUR (l'événement `user`
|
||||
// puis `turn_done`) : elle est donc exacte en direct comme au rejeu du journal,
|
||||
// où tout arrive d'un bloc côté client.
|
||||
//
|
||||
// Pendant le tour il n'y a pas encore de `turn_done` : le compteur avance à la
|
||||
// seconde. Il ne peut pas se baser sur l'horloge du navigateur telle quelle (un
|
||||
// téléphone n'est pas à la même heure que la machine), on garde donc l'ÉCART
|
||||
// entre les deux, réévalué à chaque événement reçu en direct.
|
||||
let TS_SKEW = 0; // horloge client − horloge serveur (ms)
|
||||
function nowServer(){ return Date.now() - TS_SKEW; }
|
||||
let WORK_TIMER = null;
|
||||
function startWorkTimer(){ if(WORK_TIMER || REPLAYING) return; WORK_TIMER=setInterval(tickWork, 1000); }
|
||||
function stopWorkTimer(){ if(WORK_TIMER){ clearInterval(WORK_TIMER); WORK_TIMER=null; } }
|
||||
function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); paintTurnClock(); }
|
||||
|
||||
// Chrono du TOUR COMPLET, affiché au-dessus de la carte de saisie : le temps
|
||||
// entre l'envoi du message et le moment où l'on peut reparler — outils, passes
|
||||
// de vérification et compaction compris. Les durées portées par les cartes ne
|
||||
// disent que la génération de texte ; celle-ci répond à « ça a pris combien de
|
||||
// temps, en tout ? ».
|
||||
function paintTurnClock(){
|
||||
const composer = document.getElementById('composer');
|
||||
if(!composer || !T.startTs) return;
|
||||
let el = document.getElementById('turn-clock');
|
||||
if(!el){
|
||||
el = document.createElement('div');
|
||||
el.id = 'turn-clock';
|
||||
el.className = 'composer-banner';
|
||||
composer.insertBefore(el, composer.firstChild);
|
||||
}
|
||||
const ms = (T.doneTs || nowServer()) - T.startTs;
|
||||
if(!(ms > 0)){ el.remove(); return; }
|
||||
// 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);
|
||||
}
|
||||
// --- 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
|
||||
// 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.
|
||||
function paintTypingClock(){
|
||||
const el = T.typingEl;
|
||||
if(!el || el.classList.contains('compacting')) return; // le compactage a son propre libellé
|
||||
if(!T.startTs) return;
|
||||
const ms = nowServer() - T.startTs;
|
||||
if(!(ms > 1500)) return; // pas de compteur pour une réponse immédiate
|
||||
let c = el.querySelector('.wclock');
|
||||
if(!c){ c=document.createElement('span'); c.className='wclock'; el.appendChild(c); }
|
||||
c.textContent = fmtDur(ms);
|
||||
}
|
||||
// Durée lisible : dixièmes sous 10 s (un tour court se juge à la fraction),
|
||||
// secondes rondes ensuite, puis minutes et heures. Le séparateur décimal reste
|
||||
// le POINT — la durée voisine des « 21.5 tok/s » sur la même ligne, une virgule
|
||||
// y ferait deux conventions à trois mots d'écart.
|
||||
function fmtDur(ms){
|
||||
if(!(ms > 0)) return '';
|
||||
if(ms < 10000) return (ms/1000).toFixed(1) + ' s';
|
||||
const t = Math.round(ms/1000);
|
||||
if(t < 60) return t + ' s';
|
||||
const m = Math.floor(t/60), sec = t % 60;
|
||||
if(m < 60) return m + ' min ' + String(sec).padStart(2, '0') + ' s';
|
||||
return Math.floor(m/60) + ' h ' + String(m % 60).padStart(2, '0') + ' min';
|
||||
}
|
||||
// Libellé de durée du tour courant, vide tant qu'il n'y a rien à montrer (tour
|
||||
// non commencé, ou horloges trop désaccordées pour que l'écart ait un sens).
|
||||
function workLabel(){
|
||||
if(!T.startTs) return '';
|
||||
const ms = (T.doneTs || nowServer()) - T.startTs;
|
||||
if(!(ms > 300)) return '';
|
||||
return 'travail ' + fmtDur(ms);
|
||||
}
|
||||
// Repeint la ligne de mesures de la réponse : durée d'abord, vitesse ensuite.
|
||||
// `speed` non fourni = on garde le dernier libellé de vitesse connu (c'est le
|
||||
// cas du tic de seconde, qui ne fait avancer que la durée).
|
||||
function paintStats(el, speed){
|
||||
if(speed !== undefined) T.speedText = speed;
|
||||
setStats(el, workLabel(), T.speedText);
|
||||
}
|
||||
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
||||
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
||||
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
|
||||
@@ -111,9 +210,23 @@ function renderStats(el, s){
|
||||
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
|
||||
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
|
||||
// bouton de repli, on y écrit comme avant.
|
||||
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(parts).join(' · '));
|
||||
else setStats(el, parts.join(' · '));
|
||||
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · '));
|
||||
else paintStats(el, parts.join(' · '));
|
||||
}
|
||||
// --- Compteurs de bulle -----------------------------------------------------
|
||||
// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle
|
||||
// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les
|
||||
// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le
|
||||
// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier
|
||||
// token — exécution des outils, pages web et prefill compris.
|
||||
//
|
||||
// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait
|
||||
// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur
|
||||
// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite
|
||||
// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert,
|
||||
// aujourd'hui comme demain.
|
||||
function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; }
|
||||
function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; }
|
||||
// Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à
|
||||
// partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération,
|
||||
// donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un
|
||||
@@ -122,18 +235,34 @@ function labelTokens(el, role, n, firstTs, lastTs){
|
||||
if(!el) return;
|
||||
const secs=(lastTs-firstTs)/1000;
|
||||
const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok';
|
||||
// Bulle de raisonnement : sa propre durée de génération, comme le « réfléchi
|
||||
// pendant… » d'un fil de discussion — elle reste lisible une fois repliée.
|
||||
const d = (secs>0.05 && n>1) ? fmtDur(lastTs-firstTs) : '';
|
||||
// Bulle technique : son étiquette EST le bouton de repli, les mesures y vont.
|
||||
// Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures
|
||||
// vont dans la ligne dédiée — comme le font déjà les stats de fin de tour
|
||||
// (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct
|
||||
// par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement.
|
||||
if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m);
|
||||
else setStats(el, m);
|
||||
if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · '));
|
||||
else paintStats(el, m);
|
||||
}
|
||||
// Badge du modèle sur une bulle de réponse : le nom voyage avec la borne de
|
||||
// tour (T.model), on le dépose dans dataset.model pour qu'applyIdentity — qui
|
||||
// repeint les libellés de zéro — puisse le reposer (voir paintLabel).
|
||||
function tagModel(el){
|
||||
if(!el || !T.model) return;
|
||||
el.dataset.model = T.model;
|
||||
const l = el.querySelector('.label');
|
||||
if(l && !l.querySelector('.modelbadge') && typeof modelBadge==='function') l.appendChild(modelBadge(T.model));
|
||||
}
|
||||
// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
|
||||
// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
|
||||
// L'état final est appliqué une seule fois au caught_up via syncSendBtn().
|
||||
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
|
||||
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn();
|
||||
// La liste des discussions montre laquelle travaille : elle doit suivre l'état
|
||||
// MÊME pendant le rejeu (une page rechargée en cours de génération doit voir
|
||||
// l'anneau tourner sans attendre le premier événement en direct).
|
||||
if(typeof convSyncBusy==='function') convSyncBusy(); }
|
||||
function syncSendBtn(){
|
||||
const sb=document.getElementById('send');
|
||||
// ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux
|
||||
@@ -156,15 +285,130 @@ function syncSendBtn(){
|
||||
hint.classList.toggle('waiting', !ready);
|
||||
}
|
||||
}
|
||||
// ─── Rendu du bloc en cours : cadencé, puis lissé ────────────────────────────
|
||||
// Repris de l'amont AJEAN (v0.9.5 issue #24, puis v0.10.5), adapté.
|
||||
//
|
||||
// Re-parser le Markdown du bloc ENTIER à chaque token est en O(n²) : sur un long
|
||||
// raisonnement (des milliers de tokens) l'interface se met à ramer, les tokens
|
||||
// semblent arriver au ralenti — alors que le moteur, lui, débite toujours autant
|
||||
// — et un simple rafraîchissement « répare » tout, puisqu'il rend le bloc une
|
||||
// seule fois. On coalesce donc : le texte s'accumule (concaténation, quasi
|
||||
// gratuite) et on ne re-rend qu'à intervalle borné. Le rendu final exact est
|
||||
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
|
||||
// tour, erreur, caught_up).
|
||||
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
|
||||
function scheduleRender(el, text, final){
|
||||
// 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.
|
||||
if(renderPending && renderPending.el!==el) flushRender();
|
||||
// `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;
|
||||
// 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
|
||||
// 16 ms ≈ 60 img/s, l'apparition reste fluide token par token. Quand le bloc
|
||||
// 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.
|
||||
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
|
||||
renderTimer=setTimeout(()=>flushRender(false), delay);
|
||||
}
|
||||
// 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; }
|
||||
const p=renderPending; renderPending=null;
|
||||
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();
|
||||
renderBody(p.el, text);
|
||||
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é,
|
||||
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
|
||||
function cancelRender(){ if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; } renderPending=null; }
|
||||
// Lissage d'apparition (« machine à écrire »). Le décodage spéculatif (MTP) rend
|
||||
// les tokens PAR RAFALES — plusieurs acceptés d'un coup, puis une pause : le
|
||||
// texte grandit par paquets et saute à l'écran. On découple donc l'ARRIVÉE (les
|
||||
// rafales du moteur) de l'AFFICHAGE : `target` = tout ce qui est reçu, `shown`
|
||||
// avance à cadence régulière (requestAnimationFrame), et on n'affiche que le
|
||||
// préfixe révélé. Le rendu passe TOUJOURS par scheduleRender pour conserver le
|
||||
// garde-fou anti-O(n²) ci-dessus.
|
||||
// UNIQUEMENT EN DIRECT : au rejeu tout est posé d'un bloc, sinon relire un fil
|
||||
// deviendrait une lente réécriture caractère par caractère.
|
||||
let smooth=null, smoothLast=0; // {el, target, shown, raf}
|
||||
function smoothReset(){ if(smooth&&smooth.raf) cancelAnimationFrame(smooth.raf); smooth=null; smoothLast=0; }
|
||||
// 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
|
||||
// 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, true); }
|
||||
// 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
|
||||
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
|
||||
// donne un écoulement CONTINU malgré les rafales, et se vide en douceur quand la
|
||||
// génération s'arrête. Un plancher garantit un progrès même sur un retard
|
||||
// minuscule, sans jamais figer.
|
||||
const SMOOTH_TAU=260; // ms — plus grand = plus lisse mais traîne davantage
|
||||
function smoothStep(ts){
|
||||
if(!smooth) return;
|
||||
if(!smoothLast) smoothLast=ts;
|
||||
const dt=Math.min(120, ts-smoothLast); smoothLast=ts;
|
||||
const remaining=smooth.target.length - smooth.shown;
|
||||
if(remaining<=0){ smooth.raf=null; smoothLast=0; return; }
|
||||
let adv=remaining*dt/SMOOTH_TAU; // vitesse ∝ retard
|
||||
if(adv<0.4) adv=0.4; // progrès minimal
|
||||
smooth.shown=Math.min(smooth.target.length, smooth.shown+Math.ceil(adv));
|
||||
scheduleRender(smooth.el, smooth.target.slice(0, smooth.shown));
|
||||
smooth.raf=requestAnimationFrame(smoothStep);
|
||||
}
|
||||
function smoothFeed(el, target){
|
||||
if(smooth && smooth.el!==el) smoothSnap(); // changement de bloc : solder l'ancien
|
||||
if(!smooth) smooth={el, target, shown:0, raf:null};
|
||||
smooth.target=target;
|
||||
if(!smooth.raf) smooth.raf=requestAnimationFrame(smoothStep);
|
||||
}
|
||||
// Rend un bloc en streaming : lissé en direct, instantané au rejeu.
|
||||
function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoothFeed(el, full); }
|
||||
// Solde tout ce qui est en vol : le bloc est terminé, la suite (stats, repli,
|
||||
// bulle d'outil) doit voir le texte complet.
|
||||
function settleBlocks(){ smoothSnap(); flushRender(); }
|
||||
|
||||
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
|
||||
// piloté par le serveur et rejouable à l'identique.
|
||||
function handleDelta(d){
|
||||
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
|
||||
// Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
|
||||
// `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
|
||||
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
|
||||
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
|
||||
if(d.caught_up){
|
||||
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
|
||||
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
|
||||
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
|
||||
setChatLoading(null);
|
||||
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
|
||||
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
|
||||
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
|
||||
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
|
||||
if(busy && T.startTs && !T.doneTs) startWorkTimer();
|
||||
// Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra
|
||||
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
|
||||
syncChatEmpty();
|
||||
@@ -173,21 +417,45 @@ function handleDelta(d){
|
||||
// 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
|
||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
||||
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); 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) -----------------------------------------------
|
||||
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
||||
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
||||
if(d.criteria!==undefined){ if(typeof renderCriteria==='function') renderCriteria(d.criteria); return; }
|
||||
// Bascule de rôle (mode code) : les blocs en cours appartiennent au rôle
|
||||
// précédent — on les solde avant de les oublier, sinon leur dernier fragment
|
||||
// resterait en vol et ne s'afficherait jamais.
|
||||
if(d.role!==undefined){ settleBlocks(); ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; }
|
||||
if(d.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; }
|
||||
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
|
||||
if(d.user!==undefined){
|
||||
newTurn();
|
||||
// Modèle du tour : posé par le serveur sur la borne de tour (journalisée),
|
||||
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque
|
||||
// bulle de réponse du tour.
|
||||
T.model = d.model || '';
|
||||
let el=PENDING;
|
||||
if(!confirmPending(d.user)) el=addMsg('user', d.user);
|
||||
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
|
||||
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
|
||||
// bulle neuve — sinon elles apparaîtraient en double.
|
||||
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
|
||||
setBusy(true); T.typingEl=addTyping(); return; }
|
||||
// Départ du chronomètre : l'horodatage SERVEUR du message, pas l'heure
|
||||
// locale — c'est ce qui rend la durée juste au rejeu comme en direct.
|
||||
T.startTs = d.ts || 0; T.doneTs = 0;
|
||||
setBusy(true); startWorkTimer(); paintTurnClock(); T.typingEl=addTyping(); return; }
|
||||
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
||||
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
||||
// replay, qui rejoue tous les tours passés d'un bloc.
|
||||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
|
||||
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
|
||||
// le temps réellement passé et non continuer d'avancer.
|
||||
T.doneTs = d.ts || T.doneTs || 0;
|
||||
if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
|
||||
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
|
||||
paintTurnClock(); // fige le total au-dessus de la carte
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
||||
@@ -197,29 +465,47 @@ function handleDelta(d){
|
||||
// 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.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
|
||||
// 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 à
|
||||
// zéro ici, on la laisse simplement inchangée.
|
||||
if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
|
||||
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
|
||||
if(d.tool_used){
|
||||
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
|
||||
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); }
|
||||
renderToolMsg(T.pendingToolEl, tu);
|
||||
// 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é.
|
||||
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; }
|
||||
if(d.drop_reasoning){
|
||||
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
||||
if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; }
|
||||
return; }
|
||||
if(d.reasoning_content){
|
||||
killTyping('reasoning');
|
||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
|
||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
|
||||
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
|
||||
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
|
||||
// au lieu de concaténer, sinon le texte apparaît en double.
|
||||
if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; }
|
||||
showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason);
|
||||
if(d.replace){ smoothSnap(); T.fullReason=''; resetReasonStats(); }
|
||||
showTyping('reasoning'); T.fullReason+=d.reasoning_content; feedBlock(T.reasonEl, T.fullReason);
|
||||
// d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs
|
||||
// tokens d'un coup. Sinon (direct), 1 token, ts0=ts.
|
||||
if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1);
|
||||
@@ -227,9 +513,9 @@ function handleDelta(d){
|
||||
return; }
|
||||
if(d.content){
|
||||
removeTyping();
|
||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; }
|
||||
if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; }
|
||||
T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent);
|
||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); tagModel(T.contentEl); T.fullContent=''; resetContentStats(); }
|
||||
if(d.replace){ smoothSnap(); T.fullContent=''; resetContentStats(); }
|
||||
T.fullContent+=d.content; feedBlock(T.contentEl, T.fullContent);
|
||||
if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1);
|
||||
labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);
|
||||
return; }
|
||||
|
||||
@@ -78,6 +78,118 @@ async function toggleMcp(name, on){
|
||||
toast(on ? 'serveur activé' : 'serveur désactivé');
|
||||
}
|
||||
|
||||
// ─── Catalogue ───────────────────────────────────────────────────────────────
|
||||
// Liste embarquée de serveurs connus, servie par /api/mcp/catalog. Choisir une
|
||||
// entrée n'installe RIEN : ça ouvre la modale d'ajout préremplie, l'utilisateur
|
||||
// relit la commande (qui s'exécutera sur cette machine) puis enregistre.
|
||||
let mcpCatalog = [];
|
||||
let mcpCatalogFilter = 'Tous';
|
||||
|
||||
async function openMcpCatalog(){
|
||||
showModal('mcp-catalog-modal');
|
||||
const list = document.getElementById('mcp-catalog-list');
|
||||
if(!mcpCatalog.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">chargement…</div>';
|
||||
const r = await jget('/api/mcp/catalog');
|
||||
mcpCatalog = (r && r.catalog) || [];
|
||||
}
|
||||
renderMcpCatalog();
|
||||
}
|
||||
|
||||
function closeMcpCatalog(){ hideModal('mcp-catalog-modal'); }
|
||||
|
||||
function renderMcpCatalog(){
|
||||
const list = document.getElementById('mcp-catalog-list');
|
||||
const tabs = document.getElementById('mcp-catalog-tabs');
|
||||
list.textContent = '';
|
||||
tabs.textContent = '';
|
||||
|
||||
if(!mcpCatalog.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">(catalogue vide)</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cats = ['Tous'].concat([...new Set(mcpCatalog.map(e=>e.category))].sort());
|
||||
cats.forEach(c=>{
|
||||
const b = document.createElement('button');
|
||||
b.className = 'mcp-cat'+(c===mcpCatalogFilter?' on':'');
|
||||
b.textContent = c;
|
||||
b.onclick = ()=>{ mcpCatalogFilter = c; renderMcpCatalog(); };
|
||||
tabs.appendChild(b);
|
||||
});
|
||||
|
||||
mcpCatalog
|
||||
.filter(e=>mcpCatalogFilter==='Tous' || e.category===mcpCatalogFilter)
|
||||
.forEach(e=>{
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mcp-cat-row';
|
||||
|
||||
const info = document.createElement('div'); info.className = 'mcp-info';
|
||||
const nm = document.createElement('div'); nm.className = 'mcp-name';
|
||||
nm.textContent = e.name;
|
||||
if(e.installed){
|
||||
const tag = document.createElement('span'); tag.className = 'mcp-tag';
|
||||
tag.textContent = 'installé'; tag.style.marginLeft = '6px';
|
||||
nm.appendChild(tag);
|
||||
}
|
||||
const desc = document.createElement('div'); desc.className = 'mcp-cat-desc';
|
||||
desc.textContent = e.description;
|
||||
const meta = document.createElement('div'); meta.className = 'mcp-meta';
|
||||
const cmd = document.createElement('code'); cmd.className = 'mcp-cat-cmd';
|
||||
cmd.textContent = [e.command].concat(e.args||[]).join(' ');
|
||||
cmd.title = cmd.textContent;
|
||||
meta.appendChild(cmd);
|
||||
if(!e.runtimeOk){
|
||||
const w = document.createElement('span'); w.className = 'mcp-err';
|
||||
w.textContent = 'runtime '+(e.runtime||'?')+' absent';
|
||||
w.title = e.runtime==='python'
|
||||
? "uv/uvx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer."
|
||||
: "npx n'est pas installé sur cette machine : ce serveur ne pourra pas démarrer.";
|
||||
meta.appendChild(w);
|
||||
}
|
||||
if((e.secrets||[]).length){
|
||||
const s = document.createElement('span'); s.className = 'mcp-tag';
|
||||
s.textContent = 'clé requise'; s.title = e.secrets.join(', ');
|
||||
meta.appendChild(s);
|
||||
}
|
||||
info.appendChild(nm); info.appendChild(desc); info.appendChild(meta);
|
||||
|
||||
const use = document.createElement('button');
|
||||
use.className = 'addbtn mcp-cat-use';
|
||||
use.textContent = e.installed ? 'revoir' : 'utiliser';
|
||||
use.onclick = ()=>{ closeMcpCatalog(); openMcpFromCatalog(e); };
|
||||
|
||||
row.appendChild(info); row.appendChild(use);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
// Préremplit la modale d'ajout à partir d'une entrée du catalogue. Le nom du
|
||||
// serveur = l'id de l'entrée, pour que « déjà installé » se détecte ensuite.
|
||||
function openMcpFromCatalog(e){
|
||||
openMcp(mcpServers.some(s=>s.name===e.id) ? e.id : '');
|
||||
if(!mcpServers.some(s=>s.name===e.id)){
|
||||
document.getElementById('mcp-name').value = e.id;
|
||||
document.querySelector('input[name="mcp-transport"][value="stdio"]').checked = true;
|
||||
mcpTransportUI('stdio');
|
||||
document.getElementById('mcp-command').value = e.command || '';
|
||||
document.getElementById('mcp-args').value = (e.args||[]).join('\n');
|
||||
document.getElementById('mcp-env').value =
|
||||
Object.entries(e.env||{}).map(([k,v])=>k+'='+v).join('\n');
|
||||
document.getElementById('mcp-url').value = '';
|
||||
document.getElementById('mcp-headers').value = '';
|
||||
document.getElementById('mcp-modal-title').textContent = e.name+' — depuis le catalogue';
|
||||
}
|
||||
const st = document.getElementById('mcp-modal-status');
|
||||
if((e.secrets||[]).length){
|
||||
st.textContent = 'renseigne '+e.secrets.join(', ')+' avant d\'enregistrer';
|
||||
st.style.color = 'var(--err)';
|
||||
} else if(!e.runtimeOk){
|
||||
st.textContent = '⚠ runtime '+(e.runtime||'?')+' absent : le serveur ne démarrera pas ici';
|
||||
st.style.color = 'var(--err)';
|
||||
}
|
||||
}
|
||||
|
||||
// Ouvre la modale d'ajout (name='') ou d'édition (nom existant).
|
||||
function openMcp(name){
|
||||
mcpEditing = name || '';
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// ─── Intensité du raisonnement (barre de saisie) ─────────────────────────────
|
||||
// Le même réglage existe dans l'éditeur de preset, mais il y est figé avec le
|
||||
// preset : le changer demande d'ouvrir les réglages, éditer, enregistrer. Ici il
|
||||
// s'applique au message suivant, sans redémarrer le moteur — `reasoning_effort`
|
||||
// voyage dans le corps de la requête, pas dans la ligne de commande.
|
||||
//
|
||||
// La valeur part dans la config vivante. Appliquer un preset la réécrit : le
|
||||
// réglage reste donc attaché au modèle, cette liste ne fait que le dévier à la
|
||||
// volée.
|
||||
|
||||
const THINK_EFFORT_LABELS = {
|
||||
'': 'auto',
|
||||
none: 'aucune',
|
||||
low: 'basse',
|
||||
medium: 'moyenne',
|
||||
high: 'haute',
|
||||
xhigh: 'maximale',
|
||||
};
|
||||
|
||||
async function loadThinkEffort(){
|
||||
try{ applyThinkEffort(await jget('/api/reasoning-effort')); }
|
||||
catch(e){ /* endpoint indisponible : la liste garde sa valeur par défaut */ }
|
||||
}
|
||||
|
||||
function applyThinkEffort(r){
|
||||
const sel = document.getElementById('think-effort');
|
||||
if(!sel || !r || !r.ok) return;
|
||||
sel.value = r.value || '';
|
||||
// Raisonnement coupé au niveau du preset : le niveau n'a plus de prise. On
|
||||
// grise plutôt que de masquer, pour que le réglage reste trouvable.
|
||||
sel.disabled = !r.on;
|
||||
sel.title = r.on
|
||||
? 'Intensité du raisonnement — envoyée au gabarit du modèle'
|
||||
: 'Raisonnement désactivé pour ce modèle (réglages → Raisonnement)';
|
||||
}
|
||||
|
||||
async function setThinkEffort(value){
|
||||
const r = await jpost('/api/reasoning-effort', {value});
|
||||
if(!r || !r.ok){ toast((r && r.error) || 'réglage refusé'); return; }
|
||||
applyThinkEffort(r);
|
||||
toast('raisonnement : '+(THINK_EFFORT_LABELS[r.value||''] || r.value));
|
||||
}
|
||||
@@ -26,12 +26,28 @@ function fmtSize(n){
|
||||
if(n >= 1024) return Math.round(n/1024)+' Ko';
|
||||
return n+' o';
|
||||
}
|
||||
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
|
||||
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
|
||||
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
|
||||
// une vignette cassée qui retombe sur son texte de repli.
|
||||
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
|
||||
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
|
||||
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
|
||||
// objet visuel des deux côtés, seul le contexte (CSS) change.
|
||||
//
|
||||
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
|
||||
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
|
||||
// vers le serveur à ce stade).
|
||||
function fileChip(name, size, opts){
|
||||
opts=opts||{};
|
||||
const chip=document.createElement('div');
|
||||
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
|
||||
if(opts.thumb){
|
||||
const t=document.createElement('img');
|
||||
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
|
||||
chip.appendChild(t);
|
||||
chip.classList.add('has-thumb');
|
||||
}
|
||||
const n=document.createElement('span');
|
||||
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
|
||||
const s=document.createElement('span');
|
||||
@@ -49,12 +65,31 @@ function fileChip(name, size, opts){
|
||||
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
|
||||
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
|
||||
// un bloc bâtard, moitié texte moitié pastilles.
|
||||
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
|
||||
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
|
||||
// balise <img> ne peut pas porter la clé de pilotage.
|
||||
function msgImgTile(f){
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-img';
|
||||
const img=document.createElement('img');
|
||||
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
|
||||
img.alt=f.name||''; img.title=f.name||'';
|
||||
box.appendChild(img);
|
||||
return box;
|
||||
}
|
||||
function addMsgFiles(el, files){
|
||||
if(!el || !files || !files.length) return;
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-files';
|
||||
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
for(const f of files){
|
||||
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
|
||||
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
|
||||
// apprendre — le nom vit dans l'infobulle et dans la loupe.
|
||||
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
|
||||
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
}
|
||||
el.parentNode.insertBefore(box, el);
|
||||
hydrateImages(box);
|
||||
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
|
||||
markEmptyMsg(el);
|
||||
return box;
|
||||
@@ -202,11 +237,18 @@ function renderAttach(){
|
||||
sizeText: a.state==='err' ? 'échec'
|
||||
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
|
||||
: fmtSize(a.size),
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
|
||||
thumb: a.thumb,
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
|
||||
}));
|
||||
}
|
||||
}
|
||||
function clearAttach(){ ATTACH=[]; renderAttach(); }
|
||||
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
|
||||
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
|
||||
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
|
||||
function dropThumb(a){
|
||||
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
|
||||
}
|
||||
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
|
||||
// Ce qui part avec le message, pour l'afficher dans sa bulle.
|
||||
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
|
||||
|
||||
@@ -277,7 +319,11 @@ function addFiles(files){
|
||||
for(const f of files||[]){
|
||||
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
|
||||
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
|
||||
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
|
||||
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
|
||||
// source d'image disponible à ce stade.
|
||||
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
|
||||
}
|
||||
renderAttach();
|
||||
}
|
||||
|
||||
@@ -86,6 +86,16 @@ function renderFoot(foot, r){
|
||||
n.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
|
||||
foot.appendChild(n);
|
||||
foot.title = r.root || '';
|
||||
// Tout emporter d'un coup : la discussion entière (dossiers et sous-dossiers
|
||||
// compris) en une archive .zip. Absent quand il n'y a rien à archiver.
|
||||
if(r.count > 0){
|
||||
const z = document.createElement('button');
|
||||
z.className = 'files-scope';
|
||||
z.textContent = '.zip';
|
||||
z.title = 'tout télécharger en une archive .zip';
|
||||
z.onclick = ()=>downloadFilesZip(z);
|
||||
foot.appendChild(z);
|
||||
}
|
||||
if(FILES_SCOPE !== 'legacy' && !r.legacy) return;
|
||||
const b = document.createElement('button');
|
||||
b.className = 'files-scope';
|
||||
@@ -199,6 +209,51 @@ async function removeFile(e){
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// Télécharge TOUT le contenu affiché (discussion ou hors-discussion) en .zip.
|
||||
// Même danse que downloadWorkspaceFile (14-attach.js) : la fiche meta d'abord —
|
||||
// c'est elle qui CONSTRUIT l'archive côté serveur — puis le brut en direct, ou
|
||||
// les tranches base64 derrière le tunnel chiffré.
|
||||
async function downloadFilesZip(btn){
|
||||
if(btn){ btn.disabled = true; btn.textContent = '…'; }
|
||||
try{
|
||||
const m = await jfetch('/api/chat/files/zip?meta=1&scope='+encodeURIComponent(FILES_SCOPE));
|
||||
const meta = await m.json().catch(()=>({}));
|
||||
if(!m.ok || !meta.ok){ toast('archive impossible : '+(meta.error||('HTTP '+m.status))); return; }
|
||||
let blob;
|
||||
if(meta.e2e){
|
||||
blob = await fetchZipB64(meta.size||0);
|
||||
} else {
|
||||
const r = await jfetch('/api/chat/files/zip?scope='+encodeURIComponent(FILES_SCOPE));
|
||||
if(!r.ok){ let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} toast('archive impossible : '+msg); return; }
|
||||
blob = await r.blob();
|
||||
}
|
||||
const u = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = u; a.download = meta.name || 'loki-fichiers.zip';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(u), 10000);
|
||||
toast(meta.files + ' fichier'+(meta.files>1?'s':'')+' archivé'+(meta.files>1?'s':''));
|
||||
}catch(e){ toast('archive impossible : '+((e&&e.message)||'erreur')); }
|
||||
finally{ if(btn){ btn.disabled = false; btn.textContent = '.zip'; } }
|
||||
}
|
||||
|
||||
// Tranches base64 du zip (tunnel chiffré) — cousin de fetchFileB64.
|
||||
async function fetchZipB64(size){
|
||||
const parts = [];
|
||||
let off = 0;
|
||||
do{
|
||||
const r = await jfetch('/api/chat/files/zip?b64=1&len='+DL_CHUNK+'&offset='+off+'&scope='+encodeURIComponent(FILES_SCOPE));
|
||||
const j = await r.json();
|
||||
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
|
||||
const bytes = b64ToBytes(j.data||'');
|
||||
parts.push(bytes);
|
||||
if(!bytes.length && !j.eof) throw new Error('transfert interrompu');
|
||||
off = (j.offset||0) + bytes.length;
|
||||
if(j.eof) break;
|
||||
} while(off < size);
|
||||
return new Blob(parts, {type:'application/zip'});
|
||||
}
|
||||
|
||||
// Changement de discussion (bascule, « vider »), signalé par le flux SSE :
|
||||
// le panneau montre les fichiers de la discussion OUVERTE, il doit donc être
|
||||
// redessiné. On revient aussi à sa racine — le sous-dossier affiché était celui
|
||||
|
||||
@@ -14,12 +14,25 @@
|
||||
// titre, dates, nombre d'échanges — jamais les messages.
|
||||
|
||||
let CONVS = [], CONV_ACTIVE = '';
|
||||
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
|
||||
// premier affichage : le flux SSE n'a pas encore rejoué son journal, mais la
|
||||
// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
|
||||
// route, ou ouverte depuis un autre appareil).
|
||||
let CONV_BUSY = false;
|
||||
|
||||
async function loadConversations(){
|
||||
let r;
|
||||
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||
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_BUSY = !!r.busy;
|
||||
renderConversations();
|
||||
}
|
||||
|
||||
@@ -37,11 +50,16 @@ function renderConversations(){
|
||||
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
|
||||
cont.appendChild(e);
|
||||
syncTopbarTitle();
|
||||
convSyncBusy();
|
||||
return;
|
||||
}
|
||||
for(const c of list){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
|
||||
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
|
||||
// qui travaille sans redessiner la liste (un redessin relancerait
|
||||
// l'animation de l'anneau à chaque événement du flux).
|
||||
row.dataset.conv = c.id;
|
||||
row.onclick = () => convSwitch(c.id);
|
||||
|
||||
const info = document.createElement('div'); info.className = 'preset-info';
|
||||
@@ -74,6 +92,57 @@ function renderConversations(){
|
||||
cont.appendChild(row);
|
||||
}
|
||||
syncTopbarTitle();
|
||||
convSyncBusy();
|
||||
}
|
||||
|
||||
// --- Discussion au travail : anneau qui tourne ------------------------------
|
||||
// Une seule génération tourne à la fois, et c'est forcément sur la discussion
|
||||
// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
|
||||
// ligne active. Deux sources le renseignent — le flux SSE (`busy`, instantané)
|
||||
// et la liste (`CONV_BUSY`, utile avant que le rejeu ait rattrapé). Une fois le
|
||||
// rattrapage fini, l'état local fait foi : il est plus frais que la liste, qui
|
||||
// n'est rechargée qu'en fin de tour.
|
||||
function convBusyNow(){
|
||||
const live = (typeof busy !== 'undefined') && busy;
|
||||
if(typeof REPLAYING !== 'undefined' && !REPLAYING) return !!live;
|
||||
return CONV_BUSY || !!live;
|
||||
}
|
||||
|
||||
// Pose ou retire l'anneau dans un conteneur, SANS toucher au reste de son
|
||||
// contenu : réécrire l'hôte remettrait l'animation à zéro.
|
||||
function convSpinToggle(host, on, before){
|
||||
if(!host) return;
|
||||
let sp = host.querySelector(':scope > .conv-spin');
|
||||
if(on && !sp){
|
||||
sp = document.createElement('span');
|
||||
sp.className = 'conv-spin';
|
||||
sp.title = 'l\u2019agent travaille sur cette discussion\u2026';
|
||||
sp.setAttribute('role', 'img');
|
||||
sp.setAttribute('aria-label', 'génération en cours');
|
||||
sp.appendChild(icon('spinner', 14));
|
||||
if(before) host.insertBefore(sp, before); else host.appendChild(sp);
|
||||
} else if(!on && sp){
|
||||
sp.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Rafraîchit l'indicateur partout où il se montre : la ligne de la discussion
|
||||
// concernée, et l'en-tête (la barre latérale est escamotée sur téléphone — sans
|
||||
// ça, rien ne dirait que ça travaille).
|
||||
function convSyncBusy(){
|
||||
const on = convBusyNow();
|
||||
const cont = document.getElementById('conv-list');
|
||||
if(cont){
|
||||
for(const row of cont.querySelectorAll('.preset')){
|
||||
const mine = on && row.dataset.conv === CONV_ACTIVE;
|
||||
row.classList.toggle('working', mine);
|
||||
convSpinToggle(row, mine, row.firstChild);
|
||||
}
|
||||
}
|
||||
// Dans l'en-tête, l'anneau est un FRÈRE du titre : celui-ci tronque son texte
|
||||
// à l'ellipse, un enfant y serait rogné avec lui sur un titre long.
|
||||
const tb = document.getElementById('topbar-title');
|
||||
convSpinToggle(tb && tb.parentNode, on, tb);
|
||||
}
|
||||
|
||||
// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
|
||||
|
||||
@@ -25,6 +25,21 @@ function paintLabel(label, role){
|
||||
const who = document.createElement('span'); who.className = 'who';
|
||||
who.textContent = role === 'user' ? (IDENT.name || 'toi') : 'Loki';
|
||||
label.appendChild(av); label.appendChild(who);
|
||||
// Badge du modèle : quelle IA a produit CETTE réponse. Le nom vit dans
|
||||
// dataset.model (posé par tagModel, 09-stream.js) — c'est ce qui le fait
|
||||
// survivre au repeint des libellés lors d'un changement d'identité.
|
||||
if(role === 'assistant' && label.parentElement && label.parentElement.dataset.model){
|
||||
label.appendChild(modelBadge(label.parentElement.dataset.model));
|
||||
}
|
||||
}
|
||||
|
||||
// modelBadge fabrique la pastille du nom de modèle (partagée paintLabel/tagModel).
|
||||
function modelBadge(name){
|
||||
const b = document.createElement('span');
|
||||
b.className = 'modelbadge';
|
||||
b.textContent = name;
|
||||
b.title = 'modèle : ' + name;
|
||||
return b;
|
||||
}
|
||||
|
||||
async function loadIdentity(){
|
||||
|
||||
@@ -38,6 +38,12 @@ function openDetails(el){
|
||||
if(typeof el === 'string') el = document.getElementById(el);
|
||||
while(el){
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +52,14 @@ function openDetails(el){
|
||||
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
||||
// confirmation puis relance le moteur).
|
||||
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;
|
||||
const name = sel.options[sel.selectedIndex].textContent;
|
||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||
@@ -55,6 +68,22 @@ function onModelSwitch(sel){
|
||||
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.
|
||||
//
|
||||
// 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
|
||||
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
||||
// 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){
|
||||
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
||||
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.
|
||||
function setLoadedModel(name){
|
||||
MS_MODEL = (name || '').trim();
|
||||
@@ -99,14 +138,31 @@ function paintModelSwitch(){
|
||||
o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
MS_PRESETS.forEach((p, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(i + 1);
|
||||
o.textContent = p.name;
|
||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.disabled = !MS_PRESETS.length;
|
||||
if(MS_PRESETS.length){
|
||||
const g = document.createElement('optgroup'); g.label = 'Presets';
|
||||
MS_PRESETS.forEach((p, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(i + 1);
|
||||
o.textContent = p.name;
|
||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||
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
|
||||
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
||||
: 'aucun modèle configuré';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// ===== Loupe d'image =======================================================
|
||||
// Les images du fil sont PLAFONNÉES (CSS .chatimg / .msg-img) : sans plafond,
|
||||
// une capture sortait à sa taille native et mangeait tout l'écran, ce qui rendait
|
||||
// la conversation illisible. Le plafond ne se paie donc pas en information : un
|
||||
// clic rouvre l'image ici, aussi grande que la fenêtre le permet.
|
||||
//
|
||||
// On réutilise la source DÉJÀ chargée par la vignette (blob: hydraté par
|
||||
// hydrateImages, ou objectURL local d'une pièce jointe en attente) : rien n'est
|
||||
// re-téléchargé, et ça marche derrière la clé de pilotage — qu'une balise <img>
|
||||
// ne sait pas porter, d'où le blob: en premier lieu.
|
||||
function lightboxEl(){
|
||||
let el=document.getElementById('lightbox');
|
||||
if(el) return el;
|
||||
el=document.createElement('div');
|
||||
el.id='lightbox';
|
||||
el.innerHTML='<button type="button" class="lb-x" title="fermer" aria-label="fermer">×</button>'
|
||||
+'<img alt=""><div class="lb-name"></div>';
|
||||
// Clic sur le FOND (ou sur le ×) = fermer ; clic sur l'image = ne rien faire,
|
||||
// sinon on referme dès qu'on veut la regarder de près.
|
||||
el.onclick=e=>{ if(e.target===el || e.target.classList.contains('lb-x')) closeLightbox(); };
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
// Capture (3e argument à true) : sans elle, Échap serait d'abord vu par les
|
||||
// écouteurs des modales déjà en place et la loupe ne se fermerait pas.
|
||||
function _lbKey(e){
|
||||
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); closeLightbox(); }
|
||||
}
|
||||
function openLightbox(src, name){
|
||||
if(!src) return;
|
||||
const el=lightboxEl(), img=el.querySelector('img');
|
||||
img.src=src;
|
||||
img.alt=name||'';
|
||||
el.querySelector('.lb-name').textContent=name||'';
|
||||
showModal(el);
|
||||
document.addEventListener('keydown', _lbKey, true);
|
||||
}
|
||||
function closeLightbox(){
|
||||
const el=document.getElementById('lightbox'); if(!el) return;
|
||||
document.removeEventListener('keydown', _lbKey, true);
|
||||
hideModal(el);
|
||||
// La source n'est lâchée qu'APRÈS la transition : la vider tout de suite
|
||||
// laissait un cadre vide clignoter pendant la fermeture.
|
||||
setTimeout(()=>{
|
||||
if(!el.classList.contains('show')) el.querySelector('img').removeAttribute('src');
|
||||
}, 200);
|
||||
}
|
||||
// Rend une image cliquable (idempotent : renderBody reconstruit le DOM à chaque
|
||||
// delta du streaming, et hydrateImages nous rappelle sur les mêmes éléments).
|
||||
//
|
||||
// On lit currentSrc/src AU CLIC et non à la liaison : l'hydratation remplace la
|
||||
// source par un blob: après coup, et figer l'URL /api/chat/image ici rouvrirait
|
||||
// une image que le navigateur n'a pas le droit de charger seul.
|
||||
function bindZoom(img, name){
|
||||
if(!img || img.dataset.zoom) return;
|
||||
img.dataset.zoom='1';
|
||||
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// 20-mode.js — le mode Chat/Code : sélecteur du composeur, puce de suggestion,
|
||||
// panneau des critères d'acceptation, carte de question (outil ask).
|
||||
// La bascule est PAR DISCUSSION et vit côté serveur (/api/chat/mode) : tous
|
||||
// les appareils voient le même mode, comme le fil lui-même.
|
||||
|
||||
let CURRENT_MODE = 'chat';
|
||||
|
||||
function modeSeg(){ return document.getElementById('mode-seg'); }
|
||||
|
||||
function paintMode(){
|
||||
const seg = modeSeg();
|
||||
if(!seg) return;
|
||||
seg.querySelector('[data-mode="chat"]').classList.toggle('on', CURRENT_MODE!=='code');
|
||||
seg.querySelector('[data-mode="code"]').classList.toggle('on', CURRENT_MODE==='code');
|
||||
// Le panneau des critères n'a de sens qu'en mode code.
|
||||
const box = document.getElementById('criteria-box');
|
||||
if(box && CURRENT_MODE!=='code') box.style.display='none';
|
||||
}
|
||||
|
||||
// Bascule demandée depuis CE navigateur.
|
||||
async function setMode(mode){
|
||||
if(mode===CURRENT_MODE) return;
|
||||
try{
|
||||
const r = await jfetch('/api/chat/mode', {method:'POST', body: JSON.stringify({mode})});
|
||||
const j = await r.json();
|
||||
if(j.ok){ CURRENT_MODE = j.mode; paintMode(); hideCodeHint(); }
|
||||
}catch(e){ toast('bascule impossible : '+e); }
|
||||
}
|
||||
|
||||
// Bascule reçue du serveur (autre appareil, ou écho de la nôtre).
|
||||
function applyModeDelta(mode){
|
||||
CURRENT_MODE = mode==='code' ? 'code' : 'chat';
|
||||
paintMode();
|
||||
if(CURRENT_MODE==='code') hideCodeHint();
|
||||
}
|
||||
|
||||
// Recharge le mode + critères au chargement et à chaque bascule de discussion.
|
||||
async function modeOnConvChange(){
|
||||
try{
|
||||
const r = await jfetch('/api/chat/mode');
|
||||
const j = await r.json();
|
||||
if(!j.ok) return;
|
||||
CURRENT_MODE = j.mode==='code' ? 'code' : 'chat';
|
||||
paintMode();
|
||||
renderCriteria(j.criteria||[]);
|
||||
hideCodeHint();
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// --- Puce « passer en mode Code ? » -----------------------------------------
|
||||
// Émise par le serveur (delta code_hint) quand un message de mode chat
|
||||
// ressemble à une tâche de code. Ignorable, jamais bloquante.
|
||||
|
||||
// Insère un bandeau DANS le composeur, au-dessus de la barre de saisie.
|
||||
// #composer est en position:absolute : un élément posé AVANT lui dans le DOM
|
||||
// reste dans le flux normal de la page et se retrouve donc CACHÉ dessous. Ses
|
||||
// enfants, eux, suivent sa colonne (--col) et son empilement.
|
||||
function composerBanner(id){
|
||||
const composer = document.getElementById('composer');
|
||||
if(!composer) return null;
|
||||
let el = document.getElementById(id);
|
||||
if(!el){
|
||||
el = document.createElement('div');
|
||||
el.id = id;
|
||||
el.className = 'composer-banner';
|
||||
composer.insertBefore(el, composer.firstChild);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function showCodeHint(){
|
||||
if(CURRENT_MODE==='code' || document.getElementById('code-hint')) return;
|
||||
const chip = composerBanner('code-hint');
|
||||
if(!chip) return;
|
||||
chip.innerHTML = 'ça ressemble à une tâche de code — <button type="button" id="code-hint-yes">passer en mode Code</button><button type="button" id="code-hint-no" title="ignorer" aria-label="ignorer">✕</button>';
|
||||
chip.querySelector('#code-hint-yes').onclick = ()=>{ setMode('code'); };
|
||||
chip.querySelector('#code-hint-no').onclick = hideCodeHint;
|
||||
}
|
||||
|
||||
function hideCodeHint(){
|
||||
const chip = document.getElementById('code-hint');
|
||||
if(chip) chip.remove();
|
||||
}
|
||||
|
||||
// --- Panneau des critères ----------------------------------------------------
|
||||
// Rendu par les deltas {criteria:[...]} (outil criteria, passe de vérif, ou
|
||||
// édition manuelle). Repliable ; en-tête = compteur passés / total.
|
||||
|
||||
function renderCriteria(list){
|
||||
let box = document.getElementById('criteria-box');
|
||||
if(!list || !list.length){ if(box) box.remove(); return; }
|
||||
if(CURRENT_MODE!=='code'){ if(box) box.remove(); return; }
|
||||
const fresh = !box;
|
||||
box = composerBanner('criteria-box');
|
||||
if(!box) return;
|
||||
if(fresh){
|
||||
box.innerHTML = '<div id="criteria-head"></div><ul id="criteria-list"></ul>';
|
||||
box.querySelector('#criteria-head').onclick = ()=> box.classList.toggle('folded');
|
||||
// Repliée par défaut : cinq critères dépliés mangeaient la moitié de
|
||||
// l'écran au-dessus de la saisie. L'en-tête porte déjà le compteur.
|
||||
box.classList.add('folded');
|
||||
}
|
||||
box.style.display='';
|
||||
const passed = list.filter(c=>c.status==='passed').length;
|
||||
box.querySelector('#criteria-head').textContent =
|
||||
'critères ' + passed + '/' + list.length + (passed===list.length ? ' ✓' : '');
|
||||
box.classList.toggle('allpass', passed===list.length);
|
||||
const ul = box.querySelector('#criteria-list');
|
||||
ul.innerHTML='';
|
||||
for(const c of list){
|
||||
const li = document.createElement('li');
|
||||
li.className = 'crit-'+(c.status||'pending');
|
||||
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
|
||||
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
|
||||
ul.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function onVerifyDone(status){
|
||||
if(status==='passed') toast('vérification : tous les critères passent ✓');
|
||||
}
|
||||
|
||||
// --- Carte de question (outil ask) ------------------------------------------
|
||||
// Le modèle a posé une question structurée et clos son tour : la carte propose
|
||||
// ses options en boutons ; cliquer envoie la réponse comme un message normal.
|
||||
|
||||
function renderAskCard(ask){
|
||||
if(!ask || !ask.question) return;
|
||||
removeTyping();
|
||||
const el = addMsg('assistant', '');
|
||||
el.classList.add('askcard');
|
||||
const body = el.querySelector('.body');
|
||||
body.textContent = ask.question;
|
||||
if(ask.options && ask.options.length){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'askopts';
|
||||
for(const opt of ask.options){
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = opt;
|
||||
b.onclick = ()=>{
|
||||
// La réponse part comme un message utilisateur ordinaire.
|
||||
const input = document.getElementById('input');
|
||||
input.value = opt;
|
||||
send();
|
||||
row.querySelectorAll('button').forEach(x=>x.disabled=true);
|
||||
};
|
||||
row.appendChild(b);
|
||||
}
|
||||
body.appendChild(row);
|
||||
}
|
||||
scrollMaybe();
|
||||
}
|
||||
|
||||
// Init : état du mode au chargement de la page.
|
||||
window.addEventListener('DOMContentLoaded', modeOnConvChange);
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user