mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -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/
|
||||
+31
@@ -23,6 +23,27 @@ 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-cli (dictée vocale) ───────────────────────────
|
||||
# whisper.cpp compilé CPU seul, en statique : un binaire unique, aucune .so à
|
||||
# trimballer, et pas de compilation CUDA (des minutes de build pour un gain
|
||||
# nul sur des dictées de quelques secondes). Ubuntu 22.04 : glibc plus
|
||||
# ancienne que l'image runtime, donc compatible quoi qu'elle embarque.
|
||||
FROM ubuntu:22.04 AS whisperbuild
|
||||
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 -DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_TESTS=OFF \
|
||||
&& cmake --build /w/build -j --target whisper-cli
|
||||
|
||||
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
|
||||
FROM ${LLAMACPP_IMAGE} AS runtime
|
||||
@@ -43,6 +64,11 @@ 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.
|
||||
@@ -71,6 +97,10 @@ 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). Le modèle (~190 Mo) n'est PAS dans
|
||||
# l'image : téléchargé au premier usage dans /data/whisper/.
|
||||
COPY --from=whisperbuild /w/build/bin/whisper-cli /usr/local/bin/whisper-cli
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
|
||||
|
||||
@@ -79,6 +109,7 @@ ENV LOKI_CONTAINER=1 \
|
||||
LOKI_HOME=/data \
|
||||
LOKI_MODEL_DIRS=/models \
|
||||
LOKI_ENGINE_BIN=/app/llama-server \
|
||||
LOKI_WHISPER_BIN=/usr/local/bin/whisper-cli \
|
||||
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
|
||||
@@ -155,13 +181,54 @@ Héritées d'AJEAN :
|
||||
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
|
||||
- **Agent** : shell, fichiers, workspace (`agent on`).
|
||||
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
|
||||
les serveurs écrits en JavaScript comme en Python.
|
||||
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
|
||||
@@ -173,16 +240,21 @@ Ajoutées par ce fork :
|
||||
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 — envoyé à `llama-server` comme `reasoning_effort`. Réglable **dans la
|
||||
barre de saisie**, parce que ça se décide en écrivant le message : le
|
||||
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. La liste est grisée quand le raisonnement est coupé
|
||||
pour ce modèle.
|
||||
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
|
||||
@@ -248,8 +320,24 @@ Ajoutées par ce fork :
|
||||
`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 :
|
||||
|
||||
@@ -277,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.
|
||||
|
||||
@@ -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") != ""})
|
||||
}
|
||||
+137
-10
@@ -72,17 +72,24 @@ func reasoningActive(v string) bool {
|
||||
|
||||
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
|
||||
// "" (auto) n'y figure pas : c'est l'absence de réglage.
|
||||
var ReasoningEfforts = []string{"none", "low", "medium", "high"}
|
||||
//
|
||||
// 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 —
|
||||
// c'est pourquoi il n'y a pas de repli à prévoir ici, contrairement à un backend
|
||||
// qui rejetterait la requête. En pratique seuls les modèles dont le gabarit
|
||||
// gère `reasoning_effort` (gpt-oss et apparentés) changent de comportement.
|
||||
// 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":
|
||||
@@ -98,6 +105,44 @@ func reasoningEffortValue(v string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -365,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))
|
||||
}
|
||||
@@ -421,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()
|
||||
@@ -436,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)
|
||||
@@ -461,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) {
|
||||
@@ -484,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -710,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)
|
||||
}
|
||||
}
|
||||
+162
-15
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -23,15 +24,128 @@ 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'")
|
||||
}
|
||||
|
||||
// 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 +213,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 +229,42 @@ 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. Trois cas, dans cet ordre :
|
||||
//
|
||||
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche)
|
||||
// NGL=auto → rien du tout (compat : anciens presets)
|
||||
// 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 %.
|
||||
// D'où « auto » par défaut dès que le moteur sait le faire. Sur un moteur
|
||||
// plus ancien, qui ne connaît pas la valeur, on garde 999 : omettre -ngl le
|
||||
// ferait tourner 100 % CPU.
|
||||
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
|
||||
ngl := get("NGL", "")
|
||||
switch {
|
||||
case strings.EqualFold(ngl, "auto"):
|
||||
// rien : le moteur décide seul
|
||||
case ngl != "":
|
||||
llmArgs = append(llmArgs, "-ngl", ngl)
|
||||
case binSupportsNGLAuto(bin):
|
||||
llmArgs = append(llmArgs, "-ngl", "auto")
|
||||
default:
|
||||
llmArgs = append(llmArgs, "-ngl", "999")
|
||||
}
|
||||
}
|
||||
if ktv != "" {
|
||||
llmArgs = append(llmArgs, "-ctk", ktv)
|
||||
@@ -179,9 +326,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,58 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,7 +292,14 @@ 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 :
|
||||
@@ -301,6 +314,20 @@ func (c *Conversation) isGenerating() bool {
|
||||
// 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.")
|
||||
@@ -330,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)})
|
||||
@@ -340,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
|
||||
@@ -352,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,
|
||||
@@ -409,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:
|
||||
@@ -428,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
|
||||
@@ -448,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
|
||||
@@ -455,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 != "":
|
||||
@@ -485,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
|
||||
@@ -763,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"
|
||||
@@ -242,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)."
|
||||
@@ -255,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.
|
||||
@@ -397,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)"
|
||||
}
|
||||
+221
-14
@@ -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.
|
||||
@@ -578,7 +664,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
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.
|
||||
reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
|
||||
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.
|
||||
@@ -587,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{}
|
||||
@@ -599,6 +700,9 @@ 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
|
||||
// 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
|
||||
@@ -614,8 +718,10 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
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
|
||||
@@ -624,9 +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
|
||||
@@ -660,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.
|
||||
@@ -791,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
|
||||
@@ -965,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":
|
||||
@@ -1055,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":
|
||||
@@ -1066,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) {
|
||||
@@ -1081,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
|
||||
@@ -1098,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)
|
||||
@@ -1109,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)
|
||||
@@ -1156,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"]
|
||||
}
|
||||
]
|
||||
@@ -79,3 +79,40 @@ func TestReasoningEffortValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -75,7 +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"},
|
||||
{"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)
|
||||
}
|
||||
}
|
||||
+1743
-200
File diff suppressed because it is too large.
Load diff
@@ -75,9 +75,193 @@ 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>
|
||||
<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 +272,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
|
||||
@@ -181,13 +368,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). -->
|
||||
@@ -203,7 +410,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>
|
||||
@@ -250,18 +460,16 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<button onclick="act('stop')">arrêter</button>
|
||||
</div>
|
||||
</div></div>
|
||||
</details>
|
||||
</div><!-- /sidegroup -->
|
||||
</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>
|
||||
@@ -281,8 +489,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>
|
||||
@@ -295,8 +504,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">
|
||||
@@ -332,8 +542,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
|
||||
@@ -349,140 +560,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)">
|
||||
<!-- 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>
|
||||
</select>
|
||||
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
|
||||
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
|
||||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
||||
</button>
|
||||
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
|
||||
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
|
||||
ce qui sort. -->
|
||||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
</button>
|
||||
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
|
||||
reste porté par aria-label et l'infobulle — l'action garde son nom
|
||||
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
|
||||
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
|
||||
</button>
|
||||
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
||||
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
||||
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
||||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||||
utile à personne. -->
|
||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||
<aside id="files" aria-label="Fichiers de cette discussion">
|
||||
<div class="files-head">
|
||||
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
|
||||
complet reste porté par l'aria-label de la section. -->
|
||||
<span class="stitle">Fichiers</span>
|
||||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
||||
captures), il faut pouvoir y descendre et en remonter. -->
|
||||
<div id="files-crumb" class="files-crumb"></div>
|
||||
<div id="files-list" class="files-list"></div>
|
||||
<div id="files-foot" class="files-foot muted"></div>
|
||||
</aside>
|
||||
<div id="toast"></div>
|
||||
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>Benchmark</strong>
|
||||
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
||||
<button onclick="closeBenchModal()">fermer</button>
|
||||
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
||||
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
||||
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
||||
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
||||
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
||||
<div class="modal-card" style="max-width:460px">
|
||||
<div class="modal-head">
|
||||
@@ -662,6 +744,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">
|
||||
@@ -736,6 +918,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>
|
||||
|
||||
@@ -748,7 +947,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">
|
||||
@@ -859,6 +1058,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<option value="low">basse</option>
|
||||
<option value="medium">moyenne</option>
|
||||
<option value="high">haute</option>
|
||||
<option value="xhigh">maximale</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
@@ -877,6 +1077,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">
|
||||
|
||||
@@ -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(),loadThinkEffort()]);
|
||||
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'));
|
||||
@@ -896,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;
|
||||
@@ -934,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>';
|
||||
@@ -1072,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){
|
||||
@@ -1104,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 À
|
||||
@@ -249,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é.
|
||||
@@ -306,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){
|
||||
@@ -333,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,7 +51,7 @@ 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, startTs:0, doneTs:0, speedText:'' }; }
|
||||
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 ------------------------------------------------
|
||||
@@ -70,7 +70,47 @@ 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(); }
|
||||
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.
|
||||
@@ -206,6 +246,15 @@ function labelTokens(el, role, n, firstTs, lastTs){
|
||||
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().
|
||||
@@ -236,6 +285,112 @@ 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){
|
||||
@@ -245,6 +400,7 @@ function handleDelta(d){
|
||||
// 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);
|
||||
@@ -261,9 +417,23 @@ 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){ stopWorkTimer(); 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
|
||||
@@ -273,18 +443,19 @@ function handleDelta(d){
|
||||
// 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(); T.typingEl=addTyping(); return; }
|
||||
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); stopWorkTimer();
|
||||
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){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); 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,
|
||||
@@ -294,19 +465,37 @@ 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){
|
||||
@@ -315,8 +504,8 @@ function handleDelta(d){
|
||||
// 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=''; resetReasonStats(); }
|
||||
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);
|
||||
@@ -324,9 +513,9 @@ function handleDelta(d){
|
||||
return; }
|
||||
if(d.content){
|
||||
removeTyping();
|
||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; resetContentStats(); }
|
||||
if(d.replace){ T.fullContent=''; resetContentStats(); }
|
||||
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; }
|
||||
|
||||
@@ -14,6 +14,7 @@ const THINK_EFFORT_LABELS = {
|
||||
low: 'basse',
|
||||
medium: 'moyenne',
|
||||
high: 'haute',
|
||||
xhigh: 'maximale',
|
||||
};
|
||||
|
||||
async function loadThinkEffort(){
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,13 @@ 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();
|
||||
|
||||
@@ -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,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);
|
||||
@@ -0,0 +1,345 @@
|
||||
// ─── Tâches planifiées ───────────────────────────────────────────────────────
|
||||
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté.
|
||||
//
|
||||
// L'IA exécute une consigne toute seule sur une fréquence réglable (intervalle
|
||||
// simple ou cron). Chaque tâche tourne ISOLÉE des discussions : elle livre son
|
||||
// résultat via les propres outils de l'IA (mail via MCP, shell…), dans son
|
||||
// dossier de travail à elle. L'interrupteur maître (« suspendre toutes les
|
||||
// tâches ») permet de tout figer d'un coup pour discuter tranquille. Réutilise le
|
||||
// gabarit visuel des serveurs MCP (mcp-row) et celui de l'éditeur de preset (pe-*).
|
||||
let tasksList = [];
|
||||
let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé)
|
||||
let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune)
|
||||
let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale)
|
||||
let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche)
|
||||
let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche)
|
||||
let tasksPollTimer = null;
|
||||
|
||||
async function loadTasks(){ renderTasks(await jget('/api/tasks')); }
|
||||
|
||||
// Tant qu'une tâche tourne, on rafraîchit la liste toutes les 2 s (l'exécution est
|
||||
// isolée du fil : sans sondage, on ne verrait ni son démarrage ni sa fin). On
|
||||
// arrête le sondage dès qu'aucune tâche ne tourne — pas de poll permanent.
|
||||
function ensureTasksPoll(){
|
||||
if(TASK_RUNNING && !tasksPollTimer){
|
||||
tasksPollTimer = setInterval(loadTasks, 2000);
|
||||
} else if(!TASK_RUNNING && tasksPollTimer){
|
||||
clearInterval(tasksPollTimer); tasksPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTasks(r){
|
||||
tasksList = (r && r.tasks) || [];
|
||||
const paused = !!(r && r.paused);
|
||||
const agentOn = !!(r && r.agent);
|
||||
TASK_PRESETS = (r && r.presets) || [];
|
||||
TASK_MEM_ON = !r || r.mem_on !== false;
|
||||
TASK_WEB_ON = !r || r.web_on !== false;
|
||||
TASK_RUNNING = (r && r.running_id) || '';
|
||||
ensureTasksPoll();
|
||||
const pc = document.getElementById('tasks-pause-toggle');
|
||||
if(pc) pc.checked = paused;
|
||||
// Avertissement : sans mode agent, une tâche n'a aucun outil pour agir.
|
||||
const warn = document.getElementById('tasks-agent-warn');
|
||||
if(warn) warn.style.display = (tasksList.length && !agentOn) ? '' : 'none';
|
||||
// Pastille : « en cours » l'emporte, sinon le nombre de tâches actives.
|
||||
const nActive = tasksList.filter(t=>t.enabled).length;
|
||||
if(TASK_RUNNING) setBadge('tasks-badge', 'warn', 'en cours');
|
||||
else setBadge('tasks-badge', nActive ? true : null, nActive ? nActive+' active'+(nActive>1?'s':'') : '');
|
||||
|
||||
const list = document.getElementById('tasks-list');
|
||||
list.textContent = '';
|
||||
if(!tasksList.length){
|
||||
list.innerHTML = '<div class="muted" style="font-size:12px">(aucune tâche)</div>';
|
||||
return;
|
||||
}
|
||||
tasksList.forEach(t=>{
|
||||
const running = (t.id === TASK_RUNNING);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'mcp-row'+(t.enabled?'':' off')+(running?' task-running':'');
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'mcp-dot';
|
||||
if(running){
|
||||
// Point animé pendant l'exécution.
|
||||
dot.classList.add('mcp-dot-ok','task-dot-run');
|
||||
} else if(!t.enabled){ dot.classList.add('mcp-dot-off'); }
|
||||
else if(t.last_run && !t.last_ok){ dot.classList.add('mcp-dot-err'); }
|
||||
else if(t.last_run && t.last_ok){ dot.classList.add('mcp-dot-ok'); }
|
||||
else { dot.classList.add('mcp-dot-off'); }
|
||||
dot.title = running ? 'en cours d’exécution'
|
||||
: (!t.enabled ? 'désactivée'
|
||||
: (t.last_run ? (t.last_ok ? 'dernier passage : ok' : 'dernier passage : échec') : 'jamais exécutée'));
|
||||
|
||||
const info = document.createElement('div');
|
||||
info.className = 'mcp-info';
|
||||
const nm = document.createElement('div'); nm.className = 'mcp-name';
|
||||
nm.textContent = t.name; nm.title = t.name;
|
||||
const meta = document.createElement('div'); meta.className = 'mcp-meta';
|
||||
if(running){
|
||||
const rn = document.createElement('span'); rn.className = 'task-run-badge';
|
||||
rn.textContent = 'en cours…'; meta.appendChild(rn);
|
||||
} else {
|
||||
const fr = document.createElement('span'); fr.className = 'mcp-tag';
|
||||
fr.textContent = scheduleLabel(t.schedule); meta.appendChild(fr);
|
||||
if(t.preset){
|
||||
const pr = document.createElement('span'); pr.className = 'mcp-tag mcp-tag-preset';
|
||||
const pn = (TASK_PRESETS.find(p=>p.id===t.preset)||{}).name || t.preset;
|
||||
pr.textContent = pn; pr.title = 'preset : '+pn; meta.appendChild(pr);
|
||||
}
|
||||
if(t.enabled && t.next_run){
|
||||
const nx = document.createElement('span'); nx.className = 'mcp-tools';
|
||||
nx.textContent = '→ '+taskWhen(t.next_run);
|
||||
nx.title = 'prochaine exécution : '+taskWhen(t.next_run);
|
||||
meta.appendChild(nx);
|
||||
}
|
||||
if(t.last_run && !t.last_ok && t.last_error){
|
||||
const er = document.createElement('span'); er.className = 'mcp-err';
|
||||
er.textContent = 'échec'; er.title = t.last_error;
|
||||
meta.appendChild(er);
|
||||
}
|
||||
}
|
||||
info.appendChild(nm); info.appendChild(meta);
|
||||
|
||||
row.appendChild(dot); row.appendChild(info);
|
||||
|
||||
if(running){
|
||||
// Bouton arrêter : passe par /api/chat/stop (conv.Stop annule le contexte de
|
||||
// la tâche, cf. RunAutonomous).
|
||||
const st = document.createElement('button');
|
||||
st.className = 'task-stop-btn'; st.textContent = 'arrêter';
|
||||
st.onclick = (e)=>{ e.stopPropagation(); stopRunningTask(); };
|
||||
row.appendChild(st);
|
||||
} else {
|
||||
const sw = document.createElement('label'); sw.className = 'switch mcp-switch';
|
||||
sw.onclick = (e)=>e.stopPropagation();
|
||||
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!t.enabled;
|
||||
cb.onchange = ()=>toggleTask(t.id, cb.checked);
|
||||
const sl = document.createElement('span'); sl.className = 'slider';
|
||||
sw.appendChild(cb); sw.appendChild(sl);
|
||||
const edit = document.createElement('button');
|
||||
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎';
|
||||
edit.onclick = (e)=>{ e.stopPropagation(); openTask(t.id); };
|
||||
row.appendChild(sw); row.appendChild(edit);
|
||||
}
|
||||
row.onclick = ()=> running ? null : openTask(t.id);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function stopRunningTask(){
|
||||
await jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{});
|
||||
toast('arrêt de la tâche…');
|
||||
setTimeout(loadTasks, 800);
|
||||
}
|
||||
|
||||
// taskWhen met en forme un epoch ms dans le FUSEAU du navigateur (jj/mm HH:MM) —
|
||||
// le serveur envoie l'horodatage brut, sinon il serait figé en UTC (décalage).
|
||||
// Nom distinct du fmtWhen du panneau Fichiers, qui prend des SECONDES et rend
|
||||
// autre chose : deux vues, deux formats, aucune raison de les confondre.
|
||||
function taskWhen(ms){
|
||||
if(!ms) return '';
|
||||
const d = new Date(ms);
|
||||
const p = n => String(n).padStart(2,'0');
|
||||
return p(d.getDate())+'/'+p(d.getMonth()+1)+' '+p(d.getHours())+':'+p(d.getMinutes());
|
||||
}
|
||||
|
||||
// (fmtDur vit dans 09-stream.js : même unité, même rendu — on ne le redouble pas.)
|
||||
|
||||
// scheduleLabel rend une fréquence lisible pour la pastille de la liste.
|
||||
function scheduleLabel(s){
|
||||
s = (s||'').trim();
|
||||
const m = /^@every\s+(.+)$/i.exec(s);
|
||||
if(m){
|
||||
const d = m[1].trim();
|
||||
const mm = /^(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?$/.exec(d);
|
||||
if(mm){
|
||||
let n = parseInt(mm[1], 10), u = mm[2]; const tm = mm[3];
|
||||
// Anciennes tâches « jours » stockées en heures (multiple de 24).
|
||||
if(u === 'h' && n % 24 === 0 && n >= 24){ n = n/24; u = 'd'; }
|
||||
const ul = {m:'min', h:'h', d:'jour'}[u] || u;
|
||||
let lbl = 'toutes les '+n+' '+ul+((u==='d'&&n>1)?'s':'');
|
||||
if(u==='d' && tm) lbl += ' à '+tm;
|
||||
return lbl;
|
||||
}
|
||||
return 'toutes les '+d;
|
||||
}
|
||||
return 'cron : '+s;
|
||||
}
|
||||
|
||||
async function toggleTask(id, on){
|
||||
await jpost('/api/tasks/toggle', {id, on});
|
||||
await loadTasks();
|
||||
toast(on ? 'tâche activée' : 'tâche désactivée');
|
||||
}
|
||||
|
||||
async function toggleTasksPause(){
|
||||
const on = document.getElementById('tasks-pause-toggle').checked;
|
||||
await jpost('/api/tasks/pause', {on});
|
||||
toast(on ? 'tâches suspendues' : 'tâches réactivées');
|
||||
loadTasks();
|
||||
}
|
||||
|
||||
// Ouvre la modale d'ajout (id='') ou d'édition (id existant).
|
||||
function openTask(id){
|
||||
taskEditing = id || '';
|
||||
const t = id ? tasksList.find(x=>x.id===id) : null;
|
||||
document.getElementById('task-modal-title').textContent = t ? 'Éditer « '+t.name+' »' : 'Nouvelle tâche';
|
||||
document.getElementById('task-name').value = t ? t.name : '';
|
||||
document.getElementById('task-prompt').value = t ? t.prompt : '';
|
||||
document.getElementById('task-enabled').checked = t ? !!t.enabled : true;
|
||||
// Accès mémoire/web : sur une tâche existante on lit son réglage (no_mem/no_web,
|
||||
// stockés en négation) ; sur une nouvelle on suit l'état global de la machine.
|
||||
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
|
||||
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
|
||||
fillPresetSelect(t ? (t.preset||'') : '');
|
||||
|
||||
// Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle :
|
||||
// « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours).
|
||||
const sched = t ? (t.schedule||'') : '@every 2h';
|
||||
const m = /^@every\s+(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?\s*$/i.exec(sched);
|
||||
document.getElementById('task-time').value = '09:00';
|
||||
if(m || !t){
|
||||
setTaskFreqMode('interval');
|
||||
document.getElementById('task-interval-n').value = m ? m[1] : '2';
|
||||
document.getElementById('task-interval-unit').value = m ? m[2] : 'h';
|
||||
if(m && m[3]){ const tm = m[3]; document.getElementById('task-time').value = tm.length===4 ? '0'+tm : tm; }
|
||||
document.getElementById('task-cron').value = '';
|
||||
} else {
|
||||
setTaskFreqMode('cron');
|
||||
document.getElementById('task-cron').value = sched;
|
||||
}
|
||||
taskUnitUI();
|
||||
|
||||
document.getElementById('task-modal-status').textContent = '';
|
||||
renderTaskResult(t);
|
||||
document.getElementById('task-del').style.display = t ? '' : 'none';
|
||||
document.getElementById('task-run').style.display = t ? '' : 'none';
|
||||
showModal('task-modal');
|
||||
}
|
||||
|
||||
// renderTaskResult remplit le panneau « Dernier résultat » avec le compte-rendu
|
||||
// COMPLET (défilable), l'état (ok / échec / en cours) et l'horodatage. Masqué pour
|
||||
// une tâche neuve ou jamais exécutée.
|
||||
function renderTaskResult(t){
|
||||
const block = document.getElementById('task-result-block');
|
||||
const box = document.getElementById('task-result');
|
||||
const when = document.getElementById('task-result-when');
|
||||
box.className = 'task-report';
|
||||
if(!t || (!t.last_run && t.id !== TASK_RUNNING)){ block.style.display = 'none'; return; }
|
||||
block.style.display = '';
|
||||
if(t.id === TASK_RUNNING){
|
||||
when.textContent = '(en cours)';
|
||||
box.classList.add('report-run');
|
||||
box.textContent = 'exécution en cours…';
|
||||
return;
|
||||
}
|
||||
const parts = [];
|
||||
if(t.last_run) parts.push(new Date(t.last_run).toLocaleString());
|
||||
if(t.last_dur_ms) parts.push('en '+fmtDur(t.last_dur_ms));
|
||||
when.textContent = parts.length ? '· '+parts.join(' · ') : '';
|
||||
if(!t.last_ok){
|
||||
box.classList.add('report-err');
|
||||
box.textContent = 'Échec : '+(t.last_error||'erreur inconnue');
|
||||
return;
|
||||
}
|
||||
const rep = (t.last_report||'').trim();
|
||||
if(rep){ box.classList.add('md-report'); box.innerHTML = md(rep); }
|
||||
else { box.classList.add('report-empty'); box.textContent = '(terminée sans texte de compte-rendu — l\'action a été faite via les outils)'; }
|
||||
}
|
||||
|
||||
function closeTask(){ hideModal('task-modal'); taskEditing = null; }
|
||||
|
||||
// fillPresetSelect peuple le sélecteur de preset. Première option = « preset
|
||||
// actif » (vide) : la tâche utilise le modèle en cours au moment de l'exécution.
|
||||
function fillPresetSelect(selected){
|
||||
const sel = document.getElementById('task-preset');
|
||||
sel.textContent = '';
|
||||
const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = 'preset actif';
|
||||
sel.appendChild(opt0);
|
||||
TASK_PRESETS.forEach(p=>{
|
||||
const o = document.createElement('option'); o.value = p.id;
|
||||
o.textContent = p.name + (p.active ? ' (actif)' : '');
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.value = selected || '';
|
||||
}
|
||||
|
||||
function setTaskFreqMode(mode){
|
||||
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
|
||||
if(r) r.checked = true;
|
||||
taskFreqUI(mode);
|
||||
}
|
||||
function taskFreqUI(mode){
|
||||
document.getElementById('task-interval-fields').style.display = mode==='interval' ? '' : 'none';
|
||||
document.getElementById('task-cron-fields').style.display = mode==='cron' ? '' : 'none';
|
||||
taskUnitUI();
|
||||
}
|
||||
// taskUnitUI : la ligne « À (heure) » n'apparaît que pour un intervalle en JOURS
|
||||
// (« tous les N jours à HH:MM »). Sans intérêt pour des minutes/heures.
|
||||
function taskUnitUI(){
|
||||
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value;
|
||||
const unit = document.getElementById('task-interval-unit').value;
|
||||
document.getElementById('task-time-row').style.display = (mode==='interval' && unit==='d') ? '' : 'none';
|
||||
}
|
||||
|
||||
// buildSchedule sérialise l'état du formulaire de fréquence en chaîne serveur.
|
||||
function buildSchedule(){
|
||||
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value || 'interval';
|
||||
if(mode==='cron') return document.getElementById('task-cron').value.trim();
|
||||
const n = parseInt(document.getElementById('task-interval-n').value, 10);
|
||||
const u = document.getElementById('task-interval-unit').value;
|
||||
if(!n || n < 1) return '';
|
||||
// Jours : on ancre à une heure de la journée (« @every Nd@HH:MM »).
|
||||
if(u === 'd'){
|
||||
const tm = document.getElementById('task-time').value || '09:00';
|
||||
return '@every '+n+'d@'+tm;
|
||||
}
|
||||
return '@every '+n+u;
|
||||
}
|
||||
|
||||
async function saveTask(){
|
||||
const name = document.getElementById('task-name').value.trim();
|
||||
const prompt = document.getElementById('task-prompt').value.trim();
|
||||
const schedule = buildSchedule();
|
||||
const enabled = document.getElementById('task-enabled').checked;
|
||||
const preset = document.getElementById('task-preset').value;
|
||||
const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
|
||||
const no_mem = !document.getElementById('task-mem').checked;
|
||||
const no_web = !document.getElementById('task-web').checked;
|
||||
const st = document.getElementById('task-modal-status');
|
||||
if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; }
|
||||
if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; }
|
||||
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, tz, no_mem, no_web});
|
||||
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
|
||||
closeTask();
|
||||
await loadTasks();
|
||||
toast('tâche enregistrée');
|
||||
}
|
||||
|
||||
async function deleteTaskUI(){
|
||||
if(!taskEditing) return;
|
||||
const t = tasksList.find(x=>x.id===taskEditing);
|
||||
if(!await askConfirm('Supprimer la tâche « '+(t?t.name:'')+' » ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return;
|
||||
await jpost('/api/tasks/delete', {id: taskEditing});
|
||||
closeTask();
|
||||
await loadTasks();
|
||||
toast('tâche supprimée');
|
||||
}
|
||||
|
||||
async function runTaskNow(){
|
||||
if(!taskEditing) return;
|
||||
const st = document.getElementById('task-modal-status');
|
||||
const r = await jpost('/api/tasks/run', {id: taskEditing});
|
||||
if(r && r.ok){
|
||||
st.textContent = 'lancée, suis son avancement dans la liste';
|
||||
st.style.color = '';
|
||||
closeTask();
|
||||
// Rafraîchit tout de suite : loadTasks détecte running_id et lance le sondage
|
||||
// qui suivra l'exécution jusqu'à la fin.
|
||||
setTimeout(loadTasks, 400);
|
||||
} else {
|
||||
st.textContent = (r && r.error) || 'impossible de lancer maintenant';
|
||||
st.style.color = 'var(--err)';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// ─── Réglages en modale ──────────────────────────────────────────────────────
|
||||
// Toutes les sections de réglages vivent dans une modale à deux volets : la nav
|
||||
// des sections à gauche, LE panneau choisi à droite. La barre latérale ne garde
|
||||
// que les discussions et le moniteur machine — visuellement plus simple, et
|
||||
// chaque section dispose enfin de toute la largeur.
|
||||
//
|
||||
// Les ids historiques des sections (svc-log-box, lc-details, tasks-details…)
|
||||
// sont posés sur les .set-pane : openDetails() (18-shell.js) les résout vers le
|
||||
// bon panneau, si bien que tous les chemins existants — pastille d'état du
|
||||
// moteur, pastille d'installation, jobs llama.cpp — rouvrent la modale au bon
|
||||
// endroit sans changer une ligne de leurs appelants.
|
||||
let SET_LAST = 'config';
|
||||
// Panneaux à contenu paresseux : chargés à l'affichage — l'équivalent de
|
||||
// l'ancien ontoggle des <details>.
|
||||
const SET_HOOKS = {
|
||||
'engine-log': () => { loadSvcLog(); showPaths(); },
|
||||
};
|
||||
function openSettings(pane){
|
||||
showModal('settings-modal');
|
||||
settingsShow(pane || SET_LAST);
|
||||
}
|
||||
function closeSettings(){ hideModal('settings-modal'); }
|
||||
function settingsShow(pane){
|
||||
SET_LAST = pane;
|
||||
document.querySelectorAll('#set-nav [data-pane]').forEach(b => b.classList.toggle('active', b.dataset.pane === pane));
|
||||
document.querySelectorAll('#set-body .set-pane').forEach(s => s.classList.toggle('active', s.dataset.pane === pane));
|
||||
const body = document.getElementById('set-body');
|
||||
if(body) body.scrollTop = 0;
|
||||
const h = SET_HOOKS[pane]; if(h) h();
|
||||
}
|
||||
// Échap ferme la modale — capture:false, donc les modales à confirmation
|
||||
// (ask-modal, qui écoute en capture) gardent la main quand elles sont ouvertes.
|
||||
document.addEventListener('keydown', e => {
|
||||
if(e.key !== 'Escape') return;
|
||||
const m = document.getElementById('settings-modal');
|
||||
if(m && m.classList.contains('show')) closeSettings();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// ─── Dictée vocale ───────────────────────────────────────────────────────────
|
||||
// Bouton micro de la carte de saisie : un clic enregistre, un second arrête et
|
||||
// transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur —
|
||||
// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer
|
||||
// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe →
|
||||
// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve.
|
||||
//
|
||||
// ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS
|
||||
// ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on
|
||||
// l'explique plutôt que d'échouer en silence.
|
||||
let MIC = null; // {ctx, stream, src, node, chunks, rate, timer}
|
||||
|
||||
async function dictateToggle(){
|
||||
if(MIC){ dictateStop(); return; }
|
||||
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
|
||||
toast('micro bloqué par le navigateur : il faut HTTPS (ou localhost)');
|
||||
return;
|
||||
}
|
||||
let stream;
|
||||
try{
|
||||
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
|
||||
}catch(e){ toast('accès au micro refusé'); return; }
|
||||
const ctx = new (window.AudioContext||window.webkitAudioContext)();
|
||||
const src = ctx.createMediaStreamSource(stream);
|
||||
// ScriptProcessor : déprécié mais universel, et amplement suffisant pour
|
||||
// copier des blocs de PCM (AudioWorklet demanderait un module séparé,
|
||||
// impossible dans un fichier unique servi inline).
|
||||
const node = ctx.createScriptProcessor(4096, 1, 1);
|
||||
const chunks = [];
|
||||
node.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
|
||||
src.connect(node); node.connect(ctx.destination);
|
||||
// Garde-fou : une dictée oubliée ne tourne pas des heures.
|
||||
MIC = {ctx, stream, src, node, chunks, rate:ctx.sampleRate, timer:setTimeout(dictateStop, 90000)};
|
||||
document.getElementById('mic').classList.add('rec');
|
||||
}
|
||||
|
||||
async function dictateStop(){
|
||||
const m = MIC; if(!m) return; MIC = null;
|
||||
clearTimeout(m.timer);
|
||||
try{ m.src.disconnect(); m.node.disconnect(); }catch(_){}
|
||||
m.stream.getTracks().forEach(t => t.stop());
|
||||
try{ m.ctx.close(); }catch(_){}
|
||||
const btn = document.getElementById('mic');
|
||||
btn.classList.remove('rec'); btn.classList.add('busy'); btn.disabled = true;
|
||||
try{
|
||||
const wav = encodeWav16k(m.chunks, m.rate);
|
||||
if(wav.byteLength < 16000){ toast('rien entendu'); return; } // < 0,5 s
|
||||
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body:wav});
|
||||
const j = await r.json().catch(()=>({}));
|
||||
if(r.status === 503 && j.downloading){
|
||||
// Le serveur relance le téléchargement à chaque tentative ; s'il a déjà
|
||||
// échoué (conteneur sans accès réseau, Hugging Face injoignable), il nous
|
||||
// renvoie la raison. La cacher derrière « 0 % » laissait l'utilisateur
|
||||
// cliquer indéfiniment sur un micro qui n'écrira jamais rien.
|
||||
toast(j.error ? 'modèle de dictée : échec du téléchargement — ' + j.error
|
||||
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — réessaie dans un instant');
|
||||
return;
|
||||
}
|
||||
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
|
||||
const text = (j.text||'').trim();
|
||||
if(!text){ toast('rien compris — réessaie'); return; }
|
||||
const ta = document.getElementById('input');
|
||||
ta.value = ta.value.trim() ? ta.value.replace(/\s+$/,'') + ' ' + text : text;
|
||||
autoGrow(ta); ta.focus();
|
||||
}catch(_){ toast('erreur réseau'); }
|
||||
finally{ btn.classList.remove('busy'); btn.disabled = false; }
|
||||
}
|
||||
|
||||
// Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV
|
||||
// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple :
|
||||
// pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant.
|
||||
function encodeWav16k(chunks, fromRate){
|
||||
const toRate = 16000;
|
||||
let n = 0; for(const c of chunks) n += c.length;
|
||||
const all = new Float32Array(n); let o = 0;
|
||||
for(const c of chunks){ all.set(c, o); o += c.length; }
|
||||
const ratio = fromRate / toRate, outN = Math.floor(n / ratio);
|
||||
const pcm = new Int16Array(outN);
|
||||
for(let i = 0; i < outN; i++){
|
||||
const s = Math.max(-1, Math.min(1, all[Math.floor(i*ratio)] || 0));
|
||||
pcm[i] = s < 0 ? s*0x8000 : s*0x7fff;
|
||||
}
|
||||
const buf = new ArrayBuffer(44 + pcm.length*2), v = new DataView(buf);
|
||||
const wr = (off, s) => { for(let i = 0; i < s.length; i++) v.setUint8(off+i, s.charCodeAt(i)); };
|
||||
wr(0,'RIFF'); v.setUint32(4, 36 + pcm.length*2, true); wr(8,'WAVEfmt ');
|
||||
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
|
||||
v.setUint32(24, toRate, true); v.setUint32(28, toRate*2, true);
|
||||
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
|
||||
wr(36,'data'); v.setUint32(40, pcm.length*2, true);
|
||||
new Int16Array(buf, 44).set(pcm);
|
||||
return buf;
|
||||
}
|
||||
+187
-13
@@ -224,7 +224,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
/* en-tête + bouton d'ajout de skill (toujours visible) */
|
||||
.skillshead{display:flex;align-items:center;justify-content:space-between;margin:12px 0 5px}
|
||||
.skillshead>span{font-size:11.5px;color:var(--dim);font-weight:600}
|
||||
.addbtn{font-size:11px;padding:4px 11px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:999px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
|
||||
/* Même gabarit que les autres boutons des panneaux (angle 3 px, 12 px) : la
|
||||
pilule arrondie détonnait au milieu de boutons rectangulaires. */
|
||||
.addbtn{font-size:12px;padding:6px 10px;background:var(--panel);color:var(--dim);border:1px solid var(--border);border-radius:4px;cursor:pointer;line-height:1.3;letter-spacing:.02em}
|
||||
.addbtn:hover{border-color:var(--dim);color:var(--text);background:var(--accent-bg)}
|
||||
.mem-count{font-size:10px;color:var(--dim);font-weight:400;letter-spacing:0;text-transform:none;font-family:var(--font);margin-left:4px}
|
||||
/* Titre de sous-section (Mémoire / Accès internet) : simple libellé, PAS de trait
|
||||
@@ -656,6 +658,16 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
/* height ET width sont animées (en px, pilotées en JS via scrollHeight/scrollWidth)
|
||||
→ repli/dépli fluide en diagonale. overflow:hidden clippe pendant l'animation. */
|
||||
.msg.collapsible .bodywrap{overflow:hidden;transition:height .42s cubic-bezier(.4,0,.2,1),width .42s cubic-bezier(.4,0,.2,1),opacity .3s ease,margin-top .42s cubic-bezier(.4,0,.2,1);opacity:1;margin-top:4px}
|
||||
/* Raisonnement / outils : hauteur BORNÉE avec défilement interne. Un long
|
||||
raisonnement (des milliers de tokens) faisait grandir la page de plusieurs
|
||||
écrans ; la carte reste maintenant à taille fixe et suit la génération en
|
||||
défilant toute seule (voir flushRender, 09-stream.js). L'animation de repli
|
||||
garde son overflow:hidden via la hauteur inline — les deux cohabitent. */
|
||||
.msg.reasoning .bodywrap,.msg.tool .bodywrap{max-height:280px;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin}
|
||||
/* Une capture d'écran ne se lit pas par le trou d'une serrure : la carte qui en
|
||||
porte une échappe au plafond (l'image a déjà son propre max-height à 420 px,
|
||||
cliquable plein écran via la visionneuse). */
|
||||
.msg.tool .bodywrap:has(.tool-shot){max-height:none}
|
||||
.msg.collapsible .bodywrap>.body{min-width:0}
|
||||
.msg.collapsible.collapsed .bodywrap{opacity:0;margin-top:0}
|
||||
/* Composer = un seul bloc cohérent : barre de contexte intégrée en tête (fine
|
||||
@@ -1449,6 +1461,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
.hf-fit.ok{color:var(--ok);border-color:var(--ok)}
|
||||
.hf-fit.juste{color:var(--warn);border-color:var(--warn)}
|
||||
.hf-fit.trop{color:var(--err);border-color:var(--err)}
|
||||
/* Verrou d'accès : ce n'est pas un verdict mémoire, mais ça se lit au même
|
||||
endroit et ça décide autant de la suite — un dépôt gated ne se télécharge
|
||||
pas sans jeton. */
|
||||
.hf-fit.gated{color:var(--warn);border-color:var(--warn);text-transform:none}
|
||||
.hf-head{display:flex;align-items:center;gap:10px;margin-top:6px;font-size:12px}
|
||||
.hf-mm{display:flex;align-items:center;gap:6px;margin-top:8px;font-size:11.5px;
|
||||
color:var(--dim);cursor:pointer}
|
||||
@@ -1461,26 +1477,36 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
la place qui reste — le fil se resserre, il ne passe pas dessous.
|
||||
Le panneau est retiré du flux quand il est fermé (et non simplement décalé) :
|
||||
sinon il conserverait sa gouttière et le fil resterait décentré. */
|
||||
/* Le panneau ne défile PAS en entier : seule la liste le fait. Avec un
|
||||
overflow sur le panneau, l'en-tête, le fil d'Ariane et le pied (occupation
|
||||
disque, bouton .zip) s'en allaient vers le haut dès qu'il y avait quelques
|
||||
fichiers — on perdait les repères et les actions en même temps. */
|
||||
#files{display:none;width:320px;flex-shrink:0;border-left:1px solid var(--border);
|
||||
background:var(--panel);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));
|
||||
overflow-y:auto;flex-direction:column;gap:10px}
|
||||
overflow:hidden;flex-direction:column;gap:10px}
|
||||
html[data-files="1"] #files{display:flex}
|
||||
#files-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:14}
|
||||
.files-head{display:flex;align-items:center;gap:8px}
|
||||
.files-head{display:flex;align-items:center;gap:8px;flex:none}
|
||||
.files-head .stitle{flex:1}
|
||||
#files-refresh,#files-close{margin:0;padding:2px 8px;min-height:0;font-size:12px;
|
||||
background:transparent;border:1px solid transparent;color:var(--dim);border-radius:4px}
|
||||
#files-refresh:hover,#files-close:hover{border-color:var(--border);color:var(--text)}
|
||||
/* Fil d'Ariane : il doit pouvoir revenir à la ligne — un identifiant de
|
||||
discussion dans le chemin dépasse largement 320 px. */
|
||||
.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px}
|
||||
.files-crumb{display:flex;flex-wrap:wrap;align-items:center;gap:2px;font-size:11px;flex:none}
|
||||
.files-crumb-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:3px;
|
||||
background:transparent;color:var(--dim);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.files-crumb-b:hover:not(:disabled){background:var(--row-bg-on);color:var(--text)}
|
||||
.files-crumb-b.on{color:var(--text);cursor:default}
|
||||
.files-crumb-s{color:var(--dim);opacity:.5}
|
||||
.files-list{display:flex;flex-direction:column;flex:1;min-height:0}
|
||||
.files-empty{font-size:12px;line-height:1.5;padding:10px 2px}
|
||||
.files-list{display:flex;flex-direction:column;flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;
|
||||
/* La barre de défilement ne doit pas décoller les lignes du bord : on lui
|
||||
rend la gouttière plutôt que d'ajouter une marge que le panneau sans
|
||||
défilement paierait aussi. */
|
||||
scrollbar-gutter:stable}
|
||||
/* État vide : centré dans la hauteur disponible plutôt que collé en haut d'un
|
||||
grand vide — c'est le cas le plus fréquent à l'ouverture d'une discussion. */
|
||||
.files-empty{font-size:12px;line-height:1.5;padding:24px 2px;text-align:center;margin:auto 0}
|
||||
/* Une ligne = pastille, nom + méta, actions. Les actions n'apparaissent qu'au
|
||||
survol : au repos on veut lire des noms de fichiers, pas une grille de
|
||||
boutons. Sur écran tactile (pas de survol) elles restent visibles. */
|
||||
@@ -1497,8 +1523,8 @@ html[data-files="1"] #files{display:flex}
|
||||
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
|
||||
border:1px solid transparent;border-radius:3px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
|
||||
.files-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||||
.files-foot{font-family:var(--mono);font-size:10px;padding-top:2px;border-top:1px solid var(--border);
|
||||
display:flex;align-items:center;gap:6px;flex-wrap:wrap}
|
||||
.files-foot{font-family:var(--mono);font-size:10px;padding-top:6px;border-top:1px solid var(--border);
|
||||
display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:none;margin-top:auto}
|
||||
/* Détour « hors discussion » : discret, en pied de panneau — c'est une sortie de
|
||||
secours pour les fichiers d'avant le rangement par discussion, pas une vue
|
||||
qu'on propose au même rang que la discussion ouverte. */
|
||||
@@ -1632,12 +1658,18 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
écrasait le titre, exactement comme l'heure avant lui. */
|
||||
.preset>.conv-spin,#topbar>.conv-spin{flex:none;display:inline-flex;align-items:center;
|
||||
justify-content:center;line-height:0;color:var(--accent)}
|
||||
.conv-spin svg{animation:conv-spin 900ms linear infinite}
|
||||
/* L'animation porte sur le CONTENEUR (élément HTML), pas sur le <svg> : sur un
|
||||
élément SVG, l'origine de la rotation dépend de transform-box, dont le défaut
|
||||
a changé au fil des versions — l'anneau restait figé chez certains. Un span
|
||||
inline-flex tourne partout, sans ambiguïté. Les deux propriétés restent
|
||||
posées sur le svg pour le cas où une règle le viserait encore. */
|
||||
.conv-spin{animation:conv-spin 900ms linear infinite;transform-origin:center}
|
||||
.conv-spin svg{transform-box:fill-box;transform-origin:center}
|
||||
@keyframes conv-spin{to{transform:rotate(360deg)}}
|
||||
/* Mouvement réduit : l'anneau ne tourne plus mais respire — l'information reste,
|
||||
le mouvement de rotation disparaît. */
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.conv-spin svg{animation:conv-breathe 1.6s ease-in-out infinite}
|
||||
.conv-spin{animation:conv-breathe 1.6s ease-in-out infinite}
|
||||
@keyframes conv-breathe{0%,100%{opacity:1}50%{opacity:.25}}
|
||||
}
|
||||
/* Dans l'en-tête, l'anneau précède le titre : même gabarit que les boutons
|
||||
@@ -1717,7 +1749,7 @@ button:active:not(:disabled){transform:translateY(1px)}
|
||||
⚠️ Une seule règle pour les quatre. Il y en avait trois générations empilées
|
||||
(pilule large « Envoyer », aplat noir, aplat sauge) : chaque état tirait sa
|
||||
taille, sa forme et son alignement d'une couche différente. */
|
||||
#inputbar #attach,#inputbar #files-btn,#send,#stop{
|
||||
#inputbar #attach,#inputbar #files-btn,#inputbar #mic,#send,#stop{
|
||||
width:34px;height:34px;min-height:34px;flex:none;margin:0;padding:0;
|
||||
display:inline-flex;align-items:center;justify-content:center;align-self:center;
|
||||
border:0;border-radius:4px;background:transparent;color:var(--dim);
|
||||
@@ -1728,8 +1760,8 @@ button:active:not(:disabled){transform:translateY(1px)}
|
||||
#stop{display:none}
|
||||
html[data-busy="1"] #send{display:none}
|
||||
html[data-busy="1"] #stop{display:inline-flex}
|
||||
#inputbar #attach svg,#inputbar #files-btn svg,#send svg,#stop svg{display:block;width:18px;height:18px}
|
||||
#inputbar #attach:hover,#inputbar #files-btn:hover{background:var(--row-bg-on);color:var(--text)}
|
||||
#inputbar #attach svg,#inputbar #files-btn svg,#inputbar #mic svg,#send svg,#stop svg{display:block;width:18px;height:18px}
|
||||
#inputbar #attach:hover,#inputbar #files-btn:hover,#inputbar #mic:hover{background:var(--row-bg-on);color:var(--text)}
|
||||
html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#send{color:var(--accent)}
|
||||
#send:hover:not(:disabled){background:var(--accent-bg);color:var(--accent);opacity:1;transform:none}
|
||||
@@ -1962,3 +1994,145 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
dernière marge. */
|
||||
.perf-row{margin:0 0 12px}
|
||||
.perf-sub{padding:0 0 10px}
|
||||
|
||||
/* --- Mode Chat/Code (20-mode.js) ------------------------------------------
|
||||
Sélecteur segmenté dans le pied de la carte du composeur, puce de
|
||||
suggestion, panneau des critères, badge de rôle, carte de question. */
|
||||
#mode-seg{display:inline-flex;border:1px solid var(--border);border-radius:3px;overflow:hidden;flex:none}
|
||||
#mode-seg button{border:0;background:transparent;color:var(--dim);font-size:10px;letter-spacing:.04em;text-transform:uppercase;padding:2px 8px;cursor:pointer;font-family:inherit}
|
||||
#mode-seg button.on{background:var(--accent-bg);color:var(--accent)}
|
||||
#mode-seg button:not(.on):hover{color:var(--text)}
|
||||
|
||||
/* Bandeaux du composeur (chrono du tour, puce mode code, critères). Ils vivent
|
||||
DANS #composer — qui est en position:absolute — et suivent donc sa colonne
|
||||
(--col). Posés avant lui dans le DOM, ils restaient dans le flux de la page
|
||||
et disparaissaient SOUS la carte de saisie. */
|
||||
.composer-banner{margin:0 var(--col) 6px;padding:6px 12px;border:1px solid var(--border);border-radius:4px;background:var(--panel);color:var(--dim);font-size:12px;font-family:var(--mono)}
|
||||
#turn-clock{display:flex;align-items:center;gap:6px;letter-spacing:.03em}
|
||||
#turn-clock::before{content:'';width:6px;height:6px;border-radius:50%;background:var(--accent);animation:tcpulse 1.4s ease-in-out infinite}
|
||||
#turn-clock.done{color:var(--accent)}
|
||||
#turn-clock.done::before{animation:none;opacity:.6}
|
||||
@keyframes tcpulse{0%,100%{opacity:.25}50%{opacity:1}}
|
||||
|
||||
#code-hint{display:flex;align-items:center;gap:8px;font-size:12px}
|
||||
#code-hint button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:2px 8px;font-size:12px;cursor:pointer;font-family:inherit}
|
||||
#code-hint button:hover{background:var(--accent-bg)}
|
||||
#code-hint #code-hint-no{border-color:var(--border);color:var(--dim);margin-left:auto}
|
||||
|
||||
#criteria-box{padding:0;overflow:hidden;max-height:38vh;overflow-y:auto}
|
||||
#criteria-box.allpass{border-color:color-mix(in srgb,var(--accent) 45%,var(--border))}
|
||||
#criteria-head{padding:6px 12px;color:var(--dim);cursor:pointer;user-select:none;letter-spacing:.03em;position:sticky;top:0;background:var(--panel)}
|
||||
#criteria-head::after{content:' ▾';opacity:.5}
|
||||
#criteria-box.folded #criteria-head::after{content:' ▸'}
|
||||
#criteria-box.allpass #criteria-head{color:var(--accent)}
|
||||
#criteria-box.folded #criteria-list{display:none}
|
||||
#criteria-list{list-style:none;margin:0;padding:0 12px 8px}
|
||||
#criteria-list li{padding:2px 0;color:var(--text)}
|
||||
#criteria-list li.crit-passed{color:var(--accent)}
|
||||
#criteria-list li.crit-failed{color:var(--err,#b3564d)}
|
||||
#criteria-list li.crit-pending{color:var(--dim)}
|
||||
|
||||
.rolebadge{display:inline-block;margin-left:6px;padding:0 6px;border:1px solid var(--accent);border-radius:3px;color:var(--accent);font-size:9px;letter-spacing:.05em;text-transform:uppercase;vertical-align:middle}
|
||||
|
||||
/* Dictée : anneau rouge pulsé pendant l'enregistrement, estompé pendant la
|
||||
transcription. Le gabarit (34 px, fond au survol) vient de la règle commune
|
||||
des boutons de saisie — #mic y est listé avec #attach et #files-btn. */
|
||||
#mic.rec{color:var(--err);animation:micpulse 1.2s ease-in-out infinite}
|
||||
@keyframes micpulse{50%{box-shadow:0 0 0 5px color-mix(in srgb,var(--err) 25%,transparent)}}
|
||||
#mic.busy{opacity:.55}
|
||||
|
||||
/* ── Réglages en modale ─────────────────────────────────────────────────────
|
||||
Deux volets : nav des sections à gauche, LE panneau choisi à droite. Les
|
||||
panneaux réutilisent tels quels les gabarits de la barre latérale (.slist,
|
||||
.switchrow, .subhead…), seuls la coque et le routage sont neufs. */
|
||||
#side-settings-btn{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:10px 14px;flex:none;
|
||||
background:none;border:0;border-top:1px solid var(--border);border-radius:0;box-shadow:none;
|
||||
font-size:13px;color:var(--text);cursor:pointer;text-align:left}
|
||||
#side-settings-btn:hover{background:var(--bubble)}
|
||||
#side-settings-btn svg{flex:none;opacity:.7}
|
||||
.set-card{max-width:860px;height:min(660px,92vh)}
|
||||
.set-shell{display:flex;flex:1;min-height:0}
|
||||
.set-nav{flex:0 0 200px;border-right:1px solid var(--border);overflow-y:auto;
|
||||
padding:10px 8px;display:flex;flex-direction:column;gap:2px}
|
||||
.set-nav-h{font-size:10px;text-transform:uppercase;letter-spacing:.12em;color:var(--dim);margin:12px 8px 4px}
|
||||
.set-nav-h:first-child{margin-top:2px}
|
||||
.set-nav button{display:flex;align-items:center;gap:8px;width:100%;margin:0;padding:7px 10px;
|
||||
background:none;border:0;border-radius:3px;box-shadow:none;
|
||||
font-size:12.5px;color:var(--text);cursor:pointer;text-align:left}
|
||||
.set-nav button:hover{background:var(--bubble)}
|
||||
.set-nav button.active{background:var(--bubble);font-weight:600}
|
||||
.set-nav button .sbadge{margin-left:auto}
|
||||
.set-body{flex:1;min-width:0;overflow-y:auto;padding:16px}
|
||||
.set-pane{display:none}
|
||||
.set-pane.active{display:block}
|
||||
.set-pane-h{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:600;margin:0 0 12px}
|
||||
.set-pane-h>span:first-child{flex:1;min-width:0}
|
||||
/* Téléphone : la nav passe en rangée défilante au-dessus du panneau, et la
|
||||
modale prend tout l'écran — un panneau de réglages ne se lit pas en timbre. */
|
||||
@media(max-width:720px){
|
||||
.set-ov{padding:0}
|
||||
.set-card{max-width:none;height:100vh;height:100dvh;max-height:none;border-radius:0;border:0}
|
||||
.set-shell{flex-direction:column}
|
||||
.set-nav{flex:none;flex-direction:row;align-items:center;overflow-x:auto;overflow-y:hidden;
|
||||
border-right:0;border-bottom:1px solid var(--border);padding:8px;white-space:nowrap}
|
||||
.set-nav-h{display:none}
|
||||
.set-nav button{width:auto;flex:none}
|
||||
}
|
||||
/* Nom du modèle qui a produit la réponse, à côté de « Loki ». Discret (gris,
|
||||
pas de capitales forcées) et borné : un nom de .gguf peut être très long,
|
||||
il s'ellipse au lieu de pousser l'étiquette hors de la bulle. */
|
||||
.msg .label .modelbadge{display:inline-block;margin-left:6px;padding:0 5px;border:1px solid var(--border);border-radius:3px;color:var(--dim);font-size:9px;letter-spacing:.03em;text-transform:none;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle}
|
||||
.msg[data-agent-role="verifier"]{border-left:2px solid var(--accent)}
|
||||
|
||||
.askcard .body{display:flex;flex-direction:column;gap:8px}
|
||||
.askopts{display:flex;flex-wrap:wrap;gap:6px}
|
||||
.askopts button{border:1px solid var(--accent);background:transparent;color:var(--accent);border-radius:3px;padding:4px 10px;font-size:13px;cursor:pointer;font-family:inherit}
|
||||
.askopts button:hover:not(:disabled){background:var(--accent-bg)}
|
||||
.askopts button:disabled{opacity:.5;cursor:default}
|
||||
|
||||
/* Capture d'écran rendue dans la bulle de l'outil (web_screenshot) : l'image
|
||||
ne dépend plus du modèle qui pense — ou non — à la recopier dans sa réponse. */
|
||||
.tool-shot{margin-top:8px}
|
||||
.tool-shot img{max-width:100%;max-height:420px;border:1px solid var(--border);border-radius:4px;display:block;cursor:zoom-in}
|
||||
|
||||
.mcp-tag.mcp-tag-preset{flex:0 1 auto;min-width:2.5em}
|
||||
.task-consigne{min-height:260px;resize:vertical;line-height:1.6;padding:12px}
|
||||
|
||||
/* Tâches planifiées (repris de l'amont AJEAN) : ligne en cours d'exécution + modale d'édition. */
|
||||
.mcp-row.task-running{border-color:var(--accent);background:var(--accent-bg);cursor:default}
|
||||
.task-dot-run{animation:taskpulse 1.1s ease-in-out infinite}
|
||||
@keyframes taskpulse{0%,100%{opacity:1;transform:scale(1)}50%{opacity:.35;transform:scale(.7)}}
|
||||
.task-run-badge{font-size:10px;font-weight:600;color:var(--accent);letter-spacing:.02em}
|
||||
.task-stop-btn{flex:none;margin:0;padding:3px 10px;font-size:11px;border-radius:6px;background:var(--danger-bg);border:1px solid var(--err);color:var(--err)}
|
||||
.task-stop-btn:hover{background:var(--danger-bg);border-color:var(--err);color:var(--err);opacity:.85}
|
||||
/* La modale des tâches emprunte tout le design de l'éditeur de preset (.pe-*) :
|
||||
même largeur, même mise en carte. */
|
||||
#task-modal .modal-card{max-width:520px;overflow:hidden}
|
||||
#task-modal-status:empty{display:none}
|
||||
/* Panneau « Dernier résultat » : le compte-rendu complet de l'IA, lisible et
|
||||
défilable — pas une mini-ligne tronquée. Posé dans une carte .pe-list-like. */
|
||||
.task-report{font-size:13px;line-height:1.55;color:var(--text);background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:11px 14px;max-height:240px;overflow:auto;white-space:pre-wrap;word-break:break-word}
|
||||
.task-report.report-err{border-color:var(--err);color:var(--err)}
|
||||
.task-report.report-run{color:var(--dim);font-style:italic}
|
||||
.task-report.report-empty{color:var(--dim)}
|
||||
/* Compte-rendu rendu en markdown : c'est du HTML de blocs, donc plus de pre-wrap
|
||||
(sinon les sauts de ligne du HTML généré ajoutent des lignes vides). Styles
|
||||
alignés sur ceux des bulles de chat (.msg .body). */
|
||||
.task-report.md-report{white-space:normal}
|
||||
.md-report>*{margin:.6em 0}
|
||||
.md-report>*:first-child{margin-top:0}
|
||||
.md-report>*:last-child{margin-bottom:0}
|
||||
.md-report h1,.md-report h2,.md-report h3,.md-report h4{font-weight:600;line-height:1.25;margin:.9em 0 .4em}
|
||||
.md-report h1{font-size:1.2em}.md-report h2{font-size:1.1em}.md-report h3{font-size:1em}.md-report h4{font-size:.95em}
|
||||
.md-report ul,.md-report ol{padding-left:1.4em;margin:.5em 0}
|
||||
.md-report li{margin:.15em 0}
|
||||
.md-report li>p{margin:0}
|
||||
.md-report code{background:var(--code-bg);padding:1px 5px;border-radius:3px;font-size:.9em;word-break:break-word}
|
||||
.md-report pre{background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
|
||||
.md-report pre code{background:transparent;padding:0;font-size:.85em;line-height:1.4}
|
||||
.md-report blockquote{border-left:3px solid var(--border);padding:0 0 0 10px;margin:.4em 0;color:var(--dim)}
|
||||
.md-report a{color:var(--accent);text-decoration:none}
|
||||
.md-report a:hover{text-decoration:underline}
|
||||
.md-report table{border-collapse:collapse;margin:.5em 0}
|
||||
.md-report th,.md-report td{border:1px solid var(--border);padding:4px 8px}
|
||||
.md-report hr{border:none;border-top:1px solid var(--border);margin:.6em 0}
|
||||
@@ -41,8 +41,25 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// doit être signalée clairement plutôt que de laisser un « chargement… »
|
||||
// perpétuel / un crash-loop muet.
|
||||
loadErr := ""
|
||||
loadPct := -1
|
||||
if !health {
|
||||
loadErr = modelLoadError()
|
||||
// Modèle en cours de chargement (service actif, pas d'échec relevé) :
|
||||
// pourcentage estimé par les octets lus du .gguf (sys_loadpct_linux.go),
|
||||
// pour que « chargement… » bouge au lieu de laisser croire à un plantage.
|
||||
if active && loadErr == "" {
|
||||
loadPct = engineLoadPct()
|
||||
}
|
||||
}
|
||||
// Alerte de persistance : /data non monté en conteneur = données perdues à
|
||||
// la prochaine recréation. Même bandeau que les autres avertissements.
|
||||
warn := appWarning()
|
||||
if dv := dataVolumeWarning(); dv != "" {
|
||||
if warn != "" {
|
||||
warn += " — " + dv
|
||||
} else {
|
||||
warn = dv
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"state": state,
|
||||
@@ -51,8 +68,9 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
"port": LLMPort(),
|
||||
"ctx": ctx,
|
||||
"version": Version,
|
||||
"warn": appWarning(), // ex. App Translocation macOS — vide si tout va bien
|
||||
"load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon
|
||||
"warn": warn, // App Translocation macOS, /data non monté… — vide si tout va bien
|
||||
"load_error": loadErr, // modèle qui ne charge pas (incompat moteur…) — vide sinon
|
||||
"load_pct": loadPct, // % estimé du chargement en cours (-1 = non mesurable)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,6 +115,27 @@ func modelLoadError() string {
|
||||
if reason == "" {
|
||||
reason = "échec du chargement du modèle"
|
||||
}
|
||||
case strings.Contains(low, "out of memory"),
|
||||
strings.Contains(low, "cudamalloc failed"),
|
||||
strings.Contains(low, "failed to allocate"):
|
||||
// Prioritaire sur l'échec générique : la cause mémoire doit gagner.
|
||||
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite"
|
||||
case strings.Contains(low, "ggml_cuda error"), strings.Contains(low, "cuda error"):
|
||||
if reason == "" {
|
||||
reason = "erreur CUDA — GPU indisponible ? (après un redémarrage du serveur : docker restart loki)"
|
||||
}
|
||||
// Backtrace = le moteur a PLANTÉ, pas « il charge encore ». Les motifs
|
||||
// couvrent le dump de llama.cpp (signal + pile libc) : sans eux, un
|
||||
// crash-loop s'affichait « chargement… » pour l'éternité. PAS de motif
|
||||
// « libggml » : les logs de chargement normaux citent les .so
|
||||
// (« loaded CUDA backend from /app/libggml-cuda.so »).
|
||||
case strings.Contains(low, "__libc_start_main"),
|
||||
strings.Contains(l, "SIGSEGV"), strings.Contains(l, "SIGABRT"),
|
||||
strings.Contains(low, "segmentation fault"),
|
||||
strings.Contains(low, "aborted (core dumped)"):
|
||||
if reason == "" {
|
||||
reason = "le moteur a planté au démarrage (backtrace dans le journal)"
|
||||
}
|
||||
}
|
||||
}
|
||||
if loaded || reason == "" {
|
||||
@@ -901,6 +940,38 @@ func handleMemDelete(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleModelUse charge un MODÈLE précis (fichier .gguf) sans passer par un
|
||||
// preset : seul MODEL change dans la configuration active — contexte, NGL et
|
||||
// échantillonnage sont conservés — et le service redémarre en arrière-plan,
|
||||
// même contrat que handleSwitch. C'est ce qui rend le sélecteur de l'en-tête
|
||||
// utilisable même sans avoir créé de preset.
|
||||
func handleModelUse(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Value) == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "modèle manquant"})
|
||||
return
|
||||
}
|
||||
// Le fichier doit exister AVANT d'écrire la config : une faute de frappe ne
|
||||
// doit pas laisser un moteur qui boucle sur un modèle introuvable.
|
||||
if _, err := resolveServeModelPath(req.Value); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := SetConfigKey("MODEL", req.Value); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
fmt.Printf("%s MODEL <- %s\n", green("[ok]"), req.Value)
|
||||
go func() {
|
||||
if err := serviceAction("restart"); err != nil {
|
||||
fmt.Println(red("[err] redémarrage : " + err.Error()))
|
||||
}
|
||||
}()
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handleSwitch(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
N int `json:"n"`
|
||||
|
||||
@@ -36,6 +36,10 @@ func capsFromBody(body chatReq) Caps {
|
||||
if !caps.Agent {
|
||||
caps.Internet = false
|
||||
}
|
||||
// Mode code : réglage PAR DISCUSSION (sélecteur Chat/Code du composeur),
|
||||
// pas une surcharge de requête — même logique de fermeture que le reste :
|
||||
// sans agent, pas de mode code.
|
||||
caps.Code = caps.Agent && convCodeMode()
|
||||
return caps
|
||||
}
|
||||
|
||||
@@ -103,6 +107,7 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
|
||||
code := 503
|
||||
if err == ErrBusy {
|
||||
code = 409
|
||||
err = conv.busyReason() // dit LAQUELLE des deux occupe le modèle
|
||||
}
|
||||
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
@@ -185,6 +190,7 @@ func handleChatCompact(w http.ResponseWriter, r *http.Request) {
|
||||
code := 503
|
||||
if err == ErrBusy {
|
||||
code = 409
|
||||
err = conv.busyReason()
|
||||
}
|
||||
sendJSON(w, code, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package loki
|
||||
|
||||
// web_files_zip.go — téléchargement de TOUS les fichiers d'une discussion en
|
||||
// une archive .zip (bouton du panneau Fichiers).
|
||||
//
|
||||
// GET /api/chat/files/zip?meta=1[&scope=legacy] → {ok, name, size, e2e}
|
||||
// GET /api/chat/files/zip[&scope=legacy] → le zip, brut
|
||||
// GET /api/chat/files/zip?b64=1&offset=&len= → une tranche base64
|
||||
//
|
||||
// Même danse en trois temps que /api/chat/file (web_upload.go), et pour la
|
||||
// même raison : à travers le tunnel chiffré, le binaire ne survit pas — seules
|
||||
// les tranches base64 passent. L'archive est construite dans un fichier
|
||||
// TEMPORAIRE à l'appel `meta` (le client commence toujours par lui), puis les
|
||||
// appels suivants la servent telle quelle : la construire à chaque tranche
|
||||
// donnerait des offsets incohérents si un fichier bouge entre deux tranches.
|
||||
//
|
||||
// Les liens symboliques ne sont PAS suivis (même règle que dirSize) et le
|
||||
// parcours s'arrête à wsWalkLimit entrées : le zip d'une discussion est celui
|
||||
// de ce que le panneau affiche, pas un aspirateur à disque.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// zipPathFor : l'archive temporaire d'une racine donnée. Un nom stable par
|
||||
// discussion — l'appel meta l'écrase, les tranches la relisent.
|
||||
func zipPathFor(scope string) string {
|
||||
id := "legacy"
|
||||
if strings.TrimSpace(scope) != "legacy" {
|
||||
id = safeConvID(convEnsureActive())
|
||||
if id == "" {
|
||||
id = "conv"
|
||||
}
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "loki-files-"+id+".zip")
|
||||
}
|
||||
|
||||
// zipName : le nom proposé au navigateur.
|
||||
func zipName(scope string) string {
|
||||
stamp := time.Now().Format("2006-01-02")
|
||||
if strings.TrimSpace(scope) == "legacy" {
|
||||
return "loki-hors-discussion-" + stamp + ".zip"
|
||||
}
|
||||
// Le titre de la discussion, s'il existe, fait un nom parlant ; l'ID sinon.
|
||||
id := convEnsureActive()
|
||||
title := ""
|
||||
for _, m := range convIndex() {
|
||||
if m.ID == id {
|
||||
title = m.Title
|
||||
break
|
||||
}
|
||||
}
|
||||
slug := slugify(title)
|
||||
if slug == "" {
|
||||
slug = id
|
||||
}
|
||||
return "loki-" + slug + "-" + stamp + ".zip"
|
||||
}
|
||||
|
||||
// slugify réduit un titre à un nom de fichier sûr : minuscules, ascii, tirets.
|
||||
func slugify(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(s)) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case r == ' ' || r == '-' || r == '_' || r == '.':
|
||||
// Pas de tirets en rafale : « Déjà vu » → dj-vu, pas dj---vu.
|
||||
if out := b.String(); out != "" && !strings.HasSuffix(out, "-") {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
if b.Len() >= 40 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
// buildZip archive le contenu de root dans dest. Liens symboliques ignorés,
|
||||
// parcours borné, chemins en avant-slash relatifs à root. En scope
|
||||
// hors-discussion, le dossier des discussions est exclu (il a son propre zip,
|
||||
// discussion par discussion).
|
||||
func buildZip(root, dest string, excludeConvs bool) (files int, err error) {
|
||||
out, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer out.Close()
|
||||
zw := zip.NewWriter(out)
|
||||
defer zw.Close()
|
||||
|
||||
var walk func(dir, rel string) error
|
||||
walk = func(dir, rel string) error {
|
||||
ents, rerr := os.ReadDir(dir)
|
||||
if rerr != nil {
|
||||
return nil // dossier illisible : on archive le reste
|
||||
}
|
||||
for _, e := range ents {
|
||||
if files >= wsWalkLimit {
|
||||
return nil
|
||||
}
|
||||
if e.Type()&os.ModeSymlink != 0 {
|
||||
continue
|
||||
}
|
||||
childRel := e.Name()
|
||||
if rel != "" {
|
||||
childRel = rel + "/" + e.Name()
|
||||
}
|
||||
if e.IsDir() {
|
||||
if excludeConvs && rel == "" && e.Name() == convFilesRoot {
|
||||
continue
|
||||
}
|
||||
if werr := walk(filepath.Join(dir, e.Name()), childRel); werr != nil {
|
||||
return werr
|
||||
}
|
||||
continue
|
||||
}
|
||||
info, ierr := e.Info()
|
||||
if ierr != nil {
|
||||
continue
|
||||
}
|
||||
hdr, herr := zip.FileInfoHeader(info)
|
||||
if herr != nil {
|
||||
continue
|
||||
}
|
||||
hdr.Name = childRel
|
||||
hdr.Method = zip.Deflate
|
||||
w, werr := zw.CreateHeader(hdr)
|
||||
if werr != nil {
|
||||
return werr
|
||||
}
|
||||
f, ferr := os.Open(filepath.Join(dir, e.Name()))
|
||||
if ferr != nil {
|
||||
continue
|
||||
}
|
||||
_, cerr := io.Copy(w, f)
|
||||
f.Close()
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
files++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := walk(root, ""); err != nil {
|
||||
return files, err
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// handleChatFilesZip — voir l'en-tête du fichier.
|
||||
func handleChatFilesZip(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
scope := q.Get("scope")
|
||||
root, _ := filesRoot(scope)
|
||||
tmp := zipPathFor(scope)
|
||||
|
||||
if q.Get("meta") != "" {
|
||||
files, err := buildZip(root, tmp, scope == "legacy")
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if files == 0 {
|
||||
os.Remove(tmp)
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "aucun fichier à archiver"})
|
||||
return
|
||||
}
|
||||
st, err := os.Stat(tmp)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true, "name": zipName(scope), "size": st.Size(), "files": files,
|
||||
"e2e": r.Header.Get(e2eInnerHeader) != "",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
st, err := os.Stat(tmp)
|
||||
if err != nil {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "archive absente — redemande la fiche (meta=1) d'abord"})
|
||||
return
|
||||
}
|
||||
if q.Get("b64") != "" {
|
||||
off, _ := strconv.ParseInt(q.Get("offset"), 10, 64)
|
||||
length, _ := strconv.ParseInt(q.Get("len"), 10, 64)
|
||||
if length <= 0 || length > downloadChunkMax {
|
||||
length = downloadChunkMax
|
||||
}
|
||||
if off < 0 || off > st.Size() {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "position hors du fichier"})
|
||||
return
|
||||
}
|
||||
if off+length > st.Size() {
|
||||
length = st.Size() - off
|
||||
}
|
||||
f, ferr := os.Open(tmp)
|
||||
if ferr != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": ferr.Error()})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, length)
|
||||
// Même lecture que /api/chat/file : ReadAt remplit tout le tampon, et
|
||||
// io.EOF sur la dernière tranche est normal.
|
||||
n, rerr := f.ReadAt(buf, off)
|
||||
if rerr != nil && rerr != io.EOF {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": rerr.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true, "name": zipName(scope), "size": st.Size(), "offset": off,
|
||||
"data": base64.StdEncoding.EncodeToString(buf[:n]),
|
||||
"eof": off+int64(n) >= st.Size(),
|
||||
})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/zip")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", zipName(scope)))
|
||||
w.Header().Set("Content-Length", strconv.FormatInt(st.Size(), 10))
|
||||
f, err := os.Open(tmp)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
io.Copy(w, f)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// L'archive embarque TOUT l'arbre — fichiers, dossiers, sous-dossiers — avec
|
||||
// des chemins relatifs en avant-slash.
|
||||
func TestZipToutLArbre(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "racine.txt"), "a")
|
||||
writeFile(t, filepath.Join(ws, "docs", "note.md"), "b")
|
||||
writeFile(t, filepath.Join(ws, "src", "sub", "deep", "main.go"), "c")
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "out.zip")
|
||||
n, err := buildZip(ws, dest, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 3 {
|
||||
t.Fatalf("%d fichiers archivés, attendu 3", n)
|
||||
}
|
||||
zr, err := zip.OpenReader(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer zr.Close()
|
||||
got := map[string]bool{}
|
||||
for _, f := range zr.File {
|
||||
got[f.Name] = true
|
||||
}
|
||||
for _, want := range []string{"racine.txt", "docs/note.md", "src/sub/deep/main.go"} {
|
||||
if !got[want] {
|
||||
t.Errorf("%s absent de l'archive (contenu : %v)", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le scope hors-discussion n'aspire pas le dossier des discussions.
|
||||
func TestZipLegacyExclutDiscussions(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
root := agentWorkspace()
|
||||
writeFile(t, filepath.Join(root, "orphelin.txt"), "x")
|
||||
writeFile(t, filepath.Join(ws, "dans-discussion.txt"), "y") // sous discussions/
|
||||
|
||||
dest := filepath.Join(t.TempDir(), "out.zip")
|
||||
n, err := buildZip(root, dest, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
zr, err := zip.OpenReader(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, f := range zr.File {
|
||||
if f.Name == "discussions/"+filepath.Base(ws)+"/dans-discussion.txt" || filepath.Dir(f.Name) != "." && f.Name[:12] == "discussions/" {
|
||||
t.Errorf("le dossier des discussions a fuité dans l'archive : %s", f.Name)
|
||||
}
|
||||
}
|
||||
if n < 1 {
|
||||
t.Fatal("orphelin.txt manquant")
|
||||
}
|
||||
}
|
||||
|
||||
// Une discussion vide : 404 propre, pas d'archive fantôme.
|
||||
func TestZipVide(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
dest := filepath.Join(t.TempDir(), "out.zip")
|
||||
n, err := buildZip(ws, dest, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("%d fichiers dans une discussion vide", n)
|
||||
}
|
||||
_ = os.Remove(dest)
|
||||
}
|
||||
|
||||
// slugify : titres réels → noms de fichiers sûrs.
|
||||
func TestSlugify(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Corrige le bug d'auth": "corrige-le-bug-dauth",
|
||||
" Déjà vu ! ": "dj-vu",
|
||||
"": "",
|
||||
"UPPER case.and.dots": "upper-case-and-dots",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := slugify(in); got != want {
|
||||
t.Errorf("slugify(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+59
-2
@@ -4,12 +4,14 @@ package loki
|
||||
//
|
||||
// GET /api/hf/search?q=… dépôts GGUF correspondants
|
||||
// GET /api/hf/files?repo=auteur/dépôt modèles, projecteurs et drafts du dépôt
|
||||
// GET|POST /api/hf/token jeton Hugging Face (dépôts verrouillés)
|
||||
//
|
||||
// Les deux renvoient aussi le matériel local, pour que l'interface n'ait pas à
|
||||
// le demander séparément et n'affiche jamais une liste de tailles sans le
|
||||
// budget en face.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -26,7 +28,7 @@ func handleHFSearch(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 502, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "hardware": detectHardware(), "repos": repos})
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "hardware": detectHardware(), "repos": repos, "hf_token": hfTokenSet()})
|
||||
}
|
||||
|
||||
// handleHFFiles liste les .gguf d'un dépôt, chaque modèle portant son verdict
|
||||
@@ -67,10 +69,65 @@ func handleHFFiles(w http.ResponseWriter, r *http.Request) {
|
||||
// mmproj dans CE dépôt, pas un tag Hugging Face — lequel manque sur des
|
||||
// dépôts qui en publient pourtant un.
|
||||
// Un projecteur ne reçoit pas de verdict : il ne se charge jamais seul.
|
||||
// `gated` et `hf_token` servent le même but que `vision` : dire AVANT le
|
||||
// téléchargement ce qui va se passer. Un dépôt verrouillé se lit sans jeton
|
||||
// mais ne se télécharge pas — l'avertir ici évite de choisir un quant, de
|
||||
// lancer 15 Go et de récolter un 401.
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true, "hardware": hw, "ctx": ctxTokens,
|
||||
"repo": list.Repo, "models": list.Models,
|
||||
"projectors": list.Projectors, "drafts": list.Drafts,
|
||||
"vision": len(list.Projectors) > 0,
|
||||
"vision": len(list.Projectors) > 0,
|
||||
"gated": list.Gated,
|
||||
"hf_token": hfTokenSet(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleHFToken lit et enregistre le jeton Hugging Face.
|
||||
//
|
||||
// GET → {ok, set, source:"config"|"env"|"", masked, account}
|
||||
// POST {token:"hf_…"} → vérifie le jeton PUIS l'enregistre
|
||||
// POST {token:""} → efface le jeton enregistré
|
||||
//
|
||||
// Le jeton n'est jamais renvoyé en clair : il ne sert qu'ici, personne n'a
|
||||
// besoin de le recopier depuis Loki. Et il est vérifié AVANT d'être enregistré —
|
||||
// un jeton mal collé qu'on accepte silencieusement redonne exactement le 401
|
||||
// qu'on cherchait à expliquer.
|
||||
func handleHFToken(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "requête illisible"})
|
||||
return
|
||||
}
|
||||
tok := strings.TrimSpace(req.Token)
|
||||
account := ""
|
||||
if tok != "" {
|
||||
var err error
|
||||
if account, err = hfWhoAmI(r.Context(), tok); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := writeHFToken(tok); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, hfTokenState(account))
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, hfTokenState(""))
|
||||
}
|
||||
|
||||
// hfTokenState décrit le jeton pour l'interface. account n'est renseigné qu'au
|
||||
// retour d'un enregistrement : le vérifier à chaque affichage ferait un appel
|
||||
// sortant à l'ouverture de la fenêtre des réglages.
|
||||
func hfTokenState(account string) map[string]any {
|
||||
src := hfTokenSource()
|
||||
return map[string]any{
|
||||
"ok": true, "set": src != "", "source": src,
|
||||
"masked": maskHFToken(hfToken()), "account": account,
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,11 @@ func cmdWeb(args []string) error {
|
||||
}
|
||||
}
|
||||
fmt.Printf("[loki web] http://%s (Ctrl-C pour arrêter)\n", addr)
|
||||
// Perte de données imminente ? Le dire AU DÉMARRAGE, en rouge, dans le
|
||||
// journal du conteneur — l'endroit qu'on regarde quand ça va mal.
|
||||
if dv := dataVolumeWarning(); dv != "" {
|
||||
fmt.Println(red(dv))
|
||||
}
|
||||
if readWebKey() == "" {
|
||||
fmt.Printf("%s API de pilotage NON protégée (aucune clé). Avant de l'exposer sur internet :\n", yellow("[!]"))
|
||||
fmt.Printf(" %s\n", bold("loki set-web-key"))
|
||||
@@ -113,6 +118,9 @@ func newWebMux() *http.ServeMux {
|
||||
// recharge son état pour l'annoncer « interrompue » au lieu de n'afficher
|
||||
// plus rien du tout.
|
||||
lcRestoreOnce.Do(lcRestore)
|
||||
// Planificateur des tâches : une goroutine, un tic par minute, dans le process
|
||||
// qui détient la conversation et le modèle (idempotent, cf. sync.Once).
|
||||
StartTaskScheduler()
|
||||
mux := http.NewServeMux()
|
||||
// Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la
|
||||
// donnée et toutes les actions passent par /api/* qui, lui, exige la clé.
|
||||
@@ -160,6 +168,7 @@ func newWebMux() *http.ServeMux {
|
||||
api := func(path string, h http.HandlerFunc) { mux.HandleFunc(path, requireWebAuth(h)) }
|
||||
api("/api/ping", handlePing)
|
||||
api("/api/status", handleStatus)
|
||||
api("/api/transcribe", handleTranscribe) // dictée vocale (whisper.cpp local)
|
||||
api("/api/service/log", handleServiceLog) // journal du service pour diagnostiquer un modèle qui ne charge pas
|
||||
api("/api/vram", handleVram)
|
||||
api("/api/ram", handleRam)
|
||||
@@ -170,12 +179,14 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/models", handleModels)
|
||||
api("/api/models/delete", handleModelDelete)
|
||||
api("/api/models/dirs", handleModelDirs) // dossiers de modèles (disque externe…)
|
||||
api("/api/models/use", handleModelUse) // charger CE .gguf (sélecteur de l'en-tête)
|
||||
api("/api/models/download", handleModelDownload)
|
||||
api("/api/models/download/probe", handleModelDownloadProbe) // taille + espace libre avant de lancer
|
||||
api("/api/models/download/status", handleModelDownloadStatus)
|
||||
api("/api/models/download/cancel", handleModelDownloadCancel)
|
||||
api("/api/hf/search", handleHFSearch) // chercher un modèle GGUF sur Hugging Face
|
||||
api("/api/hf/files", handleHFFiles) // quants et projecteurs d'un dépôt
|
||||
api("/api/hf/token", handleHFToken) // jeton pour les dépôts à accès restreint
|
||||
api("/api/backends", handleBackends)
|
||||
api("/api/backends/custom", handleBackendsCustom) // backends custom uniquement (hors ⚡/🔧)
|
||||
api("/api/backends/devices", handleBackendDevices) // GPU vus par CE moteur (noms/ordre propres au backend)
|
||||
@@ -225,6 +236,15 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/mcp/tool", handleMCPTool)
|
||||
api("/api/mcp/test", handleMCPTest)
|
||||
api("/api/memory", handleMemoryMode)
|
||||
// Tâches planifiées : l'IA exécute des consignes toute seule sur une fréquence
|
||||
// réglable (tasks.go). Le planificateur tourne dans CE process — celui qui
|
||||
// détient la conversation et parle au moteur.
|
||||
api("/api/tasks", handleTasks)
|
||||
api("/api/tasks/save", handleTaskSave)
|
||||
api("/api/tasks/delete", handleTaskDelete)
|
||||
api("/api/tasks/toggle", handleTaskToggle)
|
||||
api("/api/tasks/pause", handleTasksPause)
|
||||
api("/api/tasks/run", handleTaskRun)
|
||||
api("/api/reasoning-effort", handleReasoningEffort) // intensité réglable depuis la barre de saisie
|
||||
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
||||
api("/api/prefs", handleWebPrefs)
|
||||
@@ -249,6 +269,7 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/chat/send", handleChatSend) // envoie un message (lance la génération détachée)
|
||||
api("/api/chat/upload", handleChatUpload) // dépose un fichier dans le workspace agent (joint au message suivant)
|
||||
api("/api/chat/files", handleChatFiles) // liste un dossier de la discussion ouverte (panneau Fichiers)
|
||||
api("/api/chat/files/zip", handleChatFilesZip) // toute la discussion en une archive .zip
|
||||
api("/api/chat/file/delete", handleChatFileDelete) // supprime un fichier ou un dossier de la discussion
|
||||
api("/api/chat/file", handleChatFile) // télécharge un fichier produit par l'agent (dossier de travail only)
|
||||
api("/api/chat/image", handleChatImage) // affiche une IMAGE du dossier de travail (captures d'écran)
|
||||
@@ -260,10 +281,12 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/conversations/switch", handleConvSwitch)
|
||||
api("/api/conversations/rename", handleConvRename)
|
||||
api("/api/conversations/delete", handleConvDelete)
|
||||
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
||||
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
||||
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
||||
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
||||
api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code
|
||||
api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code)
|
||||
api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte
|
||||
api("/api/chat/state", handleChatState) // instantané léger {seq, generating, ctx_used}
|
||||
api("/api/chat/export", handleChatExport) // téléchargement du fil (?format=md|json)
|
||||
api("/api/e2e/chat", handleE2EChat) // même flux mais chiffré E2E (boîte noire via le relais)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
// web_tasks.go — endpoints de pilotage des tâches planifiées (voir tasks.go).
|
||||
// Tous derrière la clé de pilotage (enregistrés via api(...) dans newWebMux).
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleTasks : GET → liste des tâches + état de l'interrupteur maître + si le
|
||||
// mode agent est actif (sans lui, une tâche n'a aucun outil pour agir).
|
||||
//
|
||||
// Les horodatages (next_run, last_run) partent en epoch ms BRUTS : c'est le
|
||||
// navigateur qui les met en forme, dans le fuseau de l'utilisateur. Les formater
|
||||
// ici les figerait dans le fuseau du SERVEUR (souvent UTC), d'où un décalage
|
||||
// horaire à l'écran.
|
||||
func handleTasks(w http.ResponseWriter, r *http.Request) {
|
||||
tasks := listTasks()
|
||||
conv.mu.Lock()
|
||||
runningID := conv.runningTaskID
|
||||
conv.mu.Unlock()
|
||||
// Presets (id + nom + actif) pour peupler le sélecteur du formulaire de tâche.
|
||||
presets := []map[string]any{}
|
||||
if list, err := ListPresets(); err == nil {
|
||||
for _, p := range list {
|
||||
presets = append(presets, map[string]any{"id": p.ID, "name": p.Name, "active": p.Active})
|
||||
}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"tasks": tasks,
|
||||
"paused": tasksPaused(),
|
||||
"agent": agentEnabled(),
|
||||
"running_id": runningID,
|
||||
"presets": presets,
|
||||
// État global mémoire/web, pour proposer des défauts cohérents à la création.
|
||||
"mem_on": memMode() != MemOff,
|
||||
"web_on": internetEnabled() && crawlReachable(),
|
||||
})
|
||||
}
|
||||
|
||||
// handleTaskSave crée ou met à jour une tâche. Valide le schedule (400 sinon) et
|
||||
// recalcule NextRun. Un ID vide = création.
|
||||
func handleTaskSave(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Prompt string `json:"prompt"`
|
||||
Schedule string `json:"schedule"`
|
||||
TZ string `json:"tz"`
|
||||
Preset string `json:"preset"`
|
||||
NoMem bool `json:"no_mem"`
|
||||
NoWeb bool `json:"no_web"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.Prompt = strings.TrimSpace(req.Prompt)
|
||||
req.Schedule = strings.TrimSpace(req.Schedule)
|
||||
if req.Name == "" || req.Prompt == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "nom et consigne obligatoires"})
|
||||
return
|
||||
}
|
||||
if err := validateSchedule(req.Schedule, req.TZ); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "fréquence invalide : " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var t Task
|
||||
if req.ID != "" {
|
||||
if cur, ok := getTask(req.ID); ok {
|
||||
t = cur // conserve l'historique d'exécution (LastRun/LastReport…)
|
||||
} else {
|
||||
t.ID = req.ID
|
||||
}
|
||||
}
|
||||
if t.ID == "" {
|
||||
t.ID = newTaskID()
|
||||
}
|
||||
t.Name, t.Prompt, t.Enabled = req.Name, req.Prompt, req.Enabled
|
||||
t.TZ, t.Preset = req.TZ, req.Preset
|
||||
t.NoMem, t.NoWeb = req.NoMem, req.NoWeb
|
||||
// Recalcule NextRun si la fréquence a changé (ou à la création).
|
||||
if t.Schedule != req.Schedule || t.NextRun == 0 {
|
||||
t.NextRun = computeNextRun(req.Schedule, req.TZ, time.Now())
|
||||
}
|
||||
t.Schedule = req.Schedule
|
||||
if err := saveTask(t); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "id": t.ID})
|
||||
}
|
||||
|
||||
// handleTaskDelete supprime une tâche.
|
||||
func handleTaskDelete(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if req.ID == "" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "id manquant"})
|
||||
return
|
||||
}
|
||||
if err := deleteTask(req.ID); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleTaskToggle active/désactive UNE tâche. En l'activant, on (re)pose son
|
||||
// NextRun pour qu'elle reparte proprement.
|
||||
func handleTaskToggle(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
On bool `json:"on"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
t, ok := getTask(req.ID)
|
||||
if !ok {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "tâche introuvable"})
|
||||
return
|
||||
}
|
||||
t.Enabled = req.On
|
||||
if req.On {
|
||||
t.NextRun = computeNextRun(t.Schedule, t.TZ, time.Now())
|
||||
}
|
||||
if err := saveTask(t); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// handleTasksPause pilote l'interrupteur maître ({on:true} = suspendre tout).
|
||||
func handleTasksPause(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
On bool `json:"on"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
if err := setTasksPaused(req.On); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "paused": tasksPaused()})
|
||||
}
|
||||
|
||||
// handleTaskRun lance une tâche MAINTENANT (bouton « tester »). Exécution
|
||||
// détachée : on rend la main tout de suite et on rafraîchit l'état à la fin.
|
||||
// 409 si une génération est déjà en cours.
|
||||
func handleTaskRun(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
t, ok := getTask(req.ID)
|
||||
if !ok {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "tâche introuvable"})
|
||||
return
|
||||
}
|
||||
// Vérifie la disponibilité AVANT de détacher, pour pouvoir répondre 409/503.
|
||||
conv.mu.Lock()
|
||||
busy := conv.Generating
|
||||
conv.mu.Unlock()
|
||||
if busy {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": ErrBusy.Error()})
|
||||
return
|
||||
}
|
||||
if !healthCheck() {
|
||||
sendJSON(w, 503, map[string]any{"ok": false, "error": errModelLoading.Error()})
|
||||
return
|
||||
}
|
||||
go runTask(t)
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// Dictée vocale — POST /api/transcribe : un WAV (16 kHz mono, encodé côté
|
||||
// navigateur) entre, le texte transcrit sort. La transcription est 100 % locale,
|
||||
// par whisper-cli (whisper.cpp, même famille que llama.cpp), compilé dans
|
||||
// l'image Docker. Le modèle (ggml-small-q5_1, ~190 Mo, multilingue) est
|
||||
// téléchargé au premier usage dans LOKI_HOME/whisper/ — comme le moteur, il ne
|
||||
// gonfle pas l'image et survit aux recréations du conteneur via /data.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const whisperModelURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small-q5_1.bin"
|
||||
|
||||
// whisperBin localise whisper-cli : variable d'environnement (posée par le
|
||||
// Dockerfile), sinon le PATH. Vide = dictée indisponible (image trop ancienne,
|
||||
// ou poste de dev sans whisper.cpp) — le handler le dit clairement.
|
||||
func whisperBin() string {
|
||||
if p := os.Getenv("LOKI_WHISPER_BIN"); p != "" {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if p, err := exec.LookPath("whisper-cli"); err == nil {
|
||||
return p
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func whisperModelPath() string {
|
||||
return filepath.Join(LokiHome(), "whisper", "ggml-small-q5_1.bin")
|
||||
}
|
||||
|
||||
// Téléchargement du modèle : UNE goroutine, progression consultable. Le premier
|
||||
// POST le déclenche et répond 503 {downloading, pct} ; l'UI invite à réessayer.
|
||||
var wspMu sync.Mutex
|
||||
var wspDl struct {
|
||||
running bool
|
||||
pct int
|
||||
err string
|
||||
}
|
||||
|
||||
func whisperStartDownload() {
|
||||
wspMu.Lock()
|
||||
defer wspMu.Unlock()
|
||||
if wspDl.running {
|
||||
return
|
||||
}
|
||||
wspDl.running, wspDl.pct, wspDl.err = true, 0, ""
|
||||
go func() {
|
||||
err := whisperDownload()
|
||||
wspMu.Lock()
|
||||
wspDl.running = false
|
||||
if err != nil {
|
||||
wspDl.err = err.Error()
|
||||
}
|
||||
wspMu.Unlock()
|
||||
}()
|
||||
}
|
||||
|
||||
func whisperDownload() error {
|
||||
dst := whisperModelPath()
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := http.Get(whisperModelURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 200 {
|
||||
return fmt.Errorf("téléchargement du modèle whisper : HTTP %d", resp.StatusCode)
|
||||
}
|
||||
tmp := dst + ".part"
|
||||
f, err := os.Create(tmp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
total := resp.ContentLength
|
||||
var done int64
|
||||
buf := make([]byte, 1<<20)
|
||||
for {
|
||||
n, rerr := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := f.Write(buf[:n]); werr != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return werr
|
||||
}
|
||||
done += int64(n)
|
||||
if total > 0 {
|
||||
wspMu.Lock()
|
||||
wspDl.pct = int(done * 100 / total)
|
||||
wspMu.Unlock()
|
||||
}
|
||||
}
|
||||
if rerr == io.EOF {
|
||||
break
|
||||
}
|
||||
if rerr != nil {
|
||||
f.Close()
|
||||
os.Remove(tmp)
|
||||
return rerr
|
||||
}
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, dst)
|
||||
}
|
||||
|
||||
func handleTranscribe(w http.ResponseWriter, r *http.Request) {
|
||||
bin := whisperBin()
|
||||
if bin == "" {
|
||||
sendJSON(w, 501, map[string]any{"error": "dictée indisponible : whisper-cli absent (image à reconstruire, ou poste sans whisper.cpp)"})
|
||||
return
|
||||
}
|
||||
model := whisperModelPath()
|
||||
if _, err := os.Stat(model); err != nil {
|
||||
wspMu.Lock()
|
||||
derr := wspDl.err
|
||||
pct := wspDl.pct
|
||||
wspMu.Unlock()
|
||||
if derr != "" {
|
||||
// Échec précédent : on le dit ET on relance — un réseau revenu suffit.
|
||||
wspMu.Lock()
|
||||
wspDl.err = ""
|
||||
wspMu.Unlock()
|
||||
whisperStartDownload()
|
||||
sendJSON(w, 503, map[string]any{"downloading": true, "pct": 0, "error": derr})
|
||||
return
|
||||
}
|
||||
whisperStartDownload()
|
||||
sendJSON(w, 503, map[string]any{"downloading": true, "pct": pct})
|
||||
return
|
||||
}
|
||||
|
||||
audio, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 32<<20))
|
||||
if err != nil || len(audio) < 1000 {
|
||||
sendJSON(w, 400, map[string]any{"error": "audio vide ou trop court"})
|
||||
return
|
||||
}
|
||||
tmp, err := os.CreateTemp("", "loki-dictee-*.wav")
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
defer os.Remove(tmpPath)
|
||||
if _, err := tmp.Write(audio); err != nil {
|
||||
tmp.Close()
|
||||
sendJSON(w, 500, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
tmp.Close()
|
||||
|
||||
// -l auto : détection de langue (le français sort en français) ; -nt sans
|
||||
// horodatages ; -np sans bavardage de chargement. CPU seul : une dictée de
|
||||
// quelques dizaines de secondes se transcrit en 1-3 s avec small-q5_1.
|
||||
threads := runtime.NumCPU()
|
||||
if threads > 8 {
|
||||
threads = 8
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, bin, "-m", model, "-f", tmpPath, "-l", "auto", "-nt", "-np", "-t", strconv.Itoa(threads))
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(stderr.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
sendJSON(w, 500, map[string]any{"error": "whisper-cli : " + msg})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"text": strings.TrimSpace(string(out))})
|
||||
}
|
||||
Reference in new issue
Block a user