mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Compare commits
43
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 | ||
|
|
69e89e5c2f | ||
|
|
3d8a72754c | ||
|
|
88e1b38582 | ||
|
|
781520afd8 | ||
|
|
0a3af60715 | ||
|
|
ebe04f9d61 |
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
|
||||
@@ -172,13 +239,22 @@ Ajoutées par ce fork :
|
||||
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
|
||||
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
|
||||
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
|
||||
- **Intensité du raisonnement** : en plus de l'interrupteur *Raisonnement*,
|
||||
l'éditeur de preset expose un niveau — auto / aucune / basse / moyenne / haute
|
||||
— envoyé à `llama-server` comme `reasoning_effort`. Réglage **par preset**,
|
||||
donc de fait par modèle. `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 le sous-titre du réglage le dit plutôt que de promettre un effet.
|
||||
- **Intensité du raisonnement** : un niveau — auto / aucune / basse / moyenne /
|
||||
haute / maximale — envoyé à `llama-server` comme `reasoning_effort`. Réglable
|
||||
**dans la barre de saisie**, parce que ça se décide en écrivant le message : le
|
||||
changement s'applique au message suivant, sans redémarrer le moteur. L'éditeur
|
||||
de preset garde le même réglage comme **défaut du modèle** ; appliquer un
|
||||
preset reprend donc la main sur le choix fait à la volée. `none` coupe le
|
||||
raisonnement ; les autres valeurs sont passées au gabarit jinja du modèle, ce
|
||||
qui ne change le comportement que des modèles qui les lisent (gpt-oss et
|
||||
apparentés) — ailleurs c'est ignoré sans erreur, et l'interface le dit plutôt
|
||||
que de promettre un effet. Aucun gabarit ne les connaît toutes (gpt-oss :
|
||||
basse/moyenne/haute ; Qwen3.8 : basse/moyenne/maximale) et certains **refusent**
|
||||
celles qu'ils ne connaissent pas, avec une erreur 500 qui tuait le tour : loki
|
||||
lit alors les niveaux annoncés par le refus, **repli sur le plus proche** (haute
|
||||
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
|
||||
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
|
||||
raisonnement est coupé pour ce modèle.
|
||||
- **Discussions multiples** : historique complet dans la barre latérale, titre
|
||||
repris du premier message (renommable), suppression. **Chaque discussion a son
|
||||
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
|
||||
@@ -233,10 +309,35 @@ Ajoutées par ce fork :
|
||||
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
|
||||
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
|
||||
pouvait donc qu'échouer.
|
||||
- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une
|
||||
recherche légitime est pire que la laisser durer — mais au-delà de 24 appels
|
||||
sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui
|
||||
demande de conclure. Le rappel revient tous les 24 appels, en durcissant le
|
||||
ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière.
|
||||
Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf
|
||||
le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois
|
||||
les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` —
|
||||
`AGENT_BUDGET=off` le désactive complètement.
|
||||
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
|
||||
dans le fil.
|
||||
- **Paramètres** : les réglages d'application (identité, apparence, accès
|
||||
OpenAI, actions) sont regroupés à part des réglages d'IA.
|
||||
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
|
||||
volets — la nav des sections à gauche (IA, moteur, application), le panneau
|
||||
choisi à droite. La barre latérale ne garde que les discussions (les plus
|
||||
récentes en tête) et le moniteur machine.
|
||||
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
|
||||
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
|
||||
survit au rechargement, et un vieux tour garde le modèle de l'époque.
|
||||
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
|
||||
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
|
||||
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
|
||||
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
|
||||
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
|
||||
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
|
||||
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
|
||||
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
|
||||
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
|
||||
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
|
||||
l'affichage ne se fige plus.
|
||||
|
||||
Retirées par ce fork :
|
||||
|
||||
@@ -264,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,12 +292,42 @@ func (c *Conversation) state() map[string]any {
|
||||
turns++
|
||||
}
|
||||
}
|
||||
return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
|
||||
st := map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
|
||||
// Occupé PAR UNE TÂCHE : l'interface l'annonce (« tâche en cours ») plutôt que
|
||||
// d'afficher un tour qui n'apparaîtra jamais dans le fil.
|
||||
if c.runningTaskID != "" {
|
||||
st["task_id"] = c.runningTaskID
|
||||
st["task_name"] = c.runningTaskName
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// isGenerating dit si un tour est en cours. Sert à la LISTE des discussions :
|
||||
// elle doit pouvoir montrer laquelle travaille dès le chargement de la page,
|
||||
// sans attendre que le rejeu du flux SSE ait rattrapé son retard.
|
||||
func (c *Conversation) isGenerating() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.Generating
|
||||
}
|
||||
|
||||
// ErrBusy : une génération est déjà en cours (un seul tour à la fois).
|
||||
var ErrBusy = fmt.Errorf("génération en cours")
|
||||
|
||||
// busyReason explique le refus à l'utilisateur. Sans ça, un message envoyé
|
||||
// pendant qu'une TÂCHE planifiée tourne renvoyait « génération en cours » alors
|
||||
// que le fil est vide et que rien ne bouge à l'écran — incompréhensible. On
|
||||
// nomme la tâche et on dit comment reprendre la main.
|
||||
func (c *Conversation) busyReason() error {
|
||||
c.mu.Lock()
|
||||
name := c.runningTaskName
|
||||
c.mu.Unlock()
|
||||
if name == "" {
|
||||
return ErrBusy
|
||||
}
|
||||
return fmt.Errorf("la tâche planifiée « %s » occupe le modèle — attends la fin ou arrête-la (bouton stop)", name)
|
||||
}
|
||||
|
||||
// errModelLoading est renvoyée telle quelle à l'utilisateur, dans le chat : ce
|
||||
// n'est pas un défaut mais une attente, et le message doit le dire.
|
||||
var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.")
|
||||
@@ -321,6 +357,15 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
prompt = "Prends-en connaissance."
|
||||
}
|
||||
// Mode code : fige le rôle du tour (plan demandé → planner, sinon builder).
|
||||
// La détection de bascule (puce « passer en mode Code ? ») est émise après
|
||||
// la bulle utilisateur, plus bas.
|
||||
if caps.Code && caps.Role == "" {
|
||||
caps.Role = "builder"
|
||||
if wantsPlan(text) {
|
||||
caps.Role = "planner"
|
||||
}
|
||||
}
|
||||
// Content = simple texte d'ordinaire ; format multimodal (texte + images) quand
|
||||
// la vision est active et qu'une pièce jointe est une image (userMessageContent).
|
||||
c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)})
|
||||
@@ -331,10 +376,22 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
// si le process meurt en pleine génération (crash, restart après MAJ), le
|
||||
// message de l'utilisateur survit au lieu de disparaître avec le tour.
|
||||
delta := map[string]any{"user": text}
|
||||
// Nom du modèle qui va produire la réponse : journalisé avec la borne de
|
||||
// tour, donc rejoué au chargement — la carte de réponse garde son modèle
|
||||
// après un rafraîchissement, et un vieux tour garde le modèle de l'époque,
|
||||
// pas celui chargé aujourd'hui.
|
||||
if m := chatModelName(); m != "" {
|
||||
delta["model"] = m
|
||||
}
|
||||
if len(files) > 0 {
|
||||
delta["files"] = files
|
||||
}
|
||||
c.appendDelta(epoch, delta)
|
||||
// En mode chat, un message qui ressemble à une tâche de code fait
|
||||
// apparaître la puce « passer en mode Code ? » (une fois par discussion).
|
||||
if !caps.Code {
|
||||
maybeCodeHint(c, epoch, text)
|
||||
}
|
||||
c.persist()
|
||||
if temperature == 0 {
|
||||
temperature = 0.7
|
||||
@@ -343,6 +400,22 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
|
||||
return nil
|
||||
}
|
||||
|
||||
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
|
||||
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
|
||||
func chatModelName() string {
|
||||
m := strings.TrimSpace(ReadConfig()["MODEL"])
|
||||
if m == "" {
|
||||
return ""
|
||||
}
|
||||
if i := strings.LastIndexAny(m, "/\\"); i >= 0 {
|
||||
m = m[i+1:]
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(m), ".gguf") {
|
||||
m = m[:len(m)-len(".gguf")]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// generate exécute un tour complet et journalise chaque événement. Détaché : la
|
||||
// fermeture du navigateur n'a aucun effet ici, seul /stop (cancel) l'interrompt.
|
||||
// epoch est capturé au StartTurn : si un Reset survient pendant la génération,
|
||||
@@ -400,6 +473,13 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// Non-nil = elle remplace l'historique (elle contient déjà le tour en cours).
|
||||
var newBase []Message
|
||||
var content strings.Builder
|
||||
// Le comptage EXACT du contexte vient de `usage.prompt_tokens` (option
|
||||
// include_usage). Tous les moteurs ne le renvoient pas — un llama-server
|
||||
// récent a cessé de le faire, et la jauge est restée bloquée à zéro sur des
|
||||
// discussions de plusieurs dizaines de milliers de tokens. On note donc si
|
||||
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
|
||||
// déjà la compaction), approximative mais jamais absente.
|
||||
sawUsage := false
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
@@ -419,6 +499,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.NewHistory != nil:
|
||||
// Compaction faite en cours de tour : elle remplace la base au lieu de
|
||||
@@ -439,6 +522,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur
|
||||
// et la décision de compactage au tour suivant.
|
||||
if ev.Stats.PromptTokensTotal > 0 {
|
||||
sawUsage = true
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
@@ -446,6 +530,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||
case ev.Ask != nil:
|
||||
// Question structurée (outil ask) : carte à boutons dans l'UI.
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||
case ev.Reasoning != "":
|
||||
@@ -476,6 +563,32 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
stale := c.epoch != epoch
|
||||
c.mu.Unlock()
|
||||
|
||||
// Moteur muet sur l'usage : on publie l'estimation, sinon la jauge reste à
|
||||
// zéro et l'utilisateur ne voit jamais son contexte se remplir — ni la
|
||||
// compaction arriver.
|
||||
if !stale && !sawUsage {
|
||||
est := estimateTokens(msgs)
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = est
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.appendDelta(epoch, map[string]any{"ctx_used": est})
|
||||
ctxUsed = est
|
||||
}
|
||||
|
||||
// Boucle du contrat (mode code) : vérification indépendante des critères,
|
||||
// corrections, re-vérification — AVANT de rendre la main. Voir
|
||||
// code_verify.go. No-op hors mode code ou sans critères.
|
||||
if !stale && ctx.Err() == nil {
|
||||
c.codeVerifyLoop(ctx, caps, temperature, epoch)
|
||||
c.mu.Lock()
|
||||
msgs = append([]Message(nil), c.Messages...)
|
||||
ctxUsed = c.CtxUsed
|
||||
stale = c.epoch != epoch
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Compaction de FIN DE TOUR. C'était LE trou : le seuil n'était testé qu'au
|
||||
// DÉBUT d'un tour, avec le contexte du tour précédent. Le tour qui fait
|
||||
// franchir le seuil se termine donc à 81% et… rien. La jauge reste haute, seul
|
||||
@@ -754,6 +867,16 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
|
||||
if !emit(map[string]any{"caught_up": true}) {
|
||||
return
|
||||
}
|
||||
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les
|
||||
// événements du fil, et `ctx_used` n'y figure qu'aux tours où il a changé —
|
||||
// souvent hors de la fenêtre rejouée. Sans cet envoi, toute page rechargée
|
||||
// affichait « 0 / N (0 %) » sur une discussion pourtant bien remplie.
|
||||
c.mu.Lock()
|
||||
used := c.CtxUsed
|
||||
c.mu.Unlock()
|
||||
if used > 0 && !emit(map[string]any{"ctx_used": used}) {
|
||||
return
|
||||
}
|
||||
// Le padding doit venir APRÈS caught_up, pas dessus. Un proxy (Cloudflare) garde
|
||||
// toujours le DERNIER bout de flux en tampon jusqu'au prochain flush (~2-3 s). En
|
||||
// envoyant un gros pad juste après, ce sont ses octets — et non le dernier vrai
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -105,6 +106,11 @@ func screenshotVisionNote() string {
|
||||
// modèle, ou avec un modèle sans vision (gpt-oss), le gabarit sérialise le
|
||||
// base64 de l'image en TEXTE — la requête vue en production pesait 55 000
|
||||
// tokens pour 32 768 de contexte, et tous les tours suivants échouaient.
|
||||
//
|
||||
// La sonde est un appel interne comme les autres : elle DOIT s'authentifier.
|
||||
// Sans l'en-tête Bearer, un moteur protégé par API_KEY répond 401 et la sonde
|
||||
// conclut « pas de vision » — le projecteur était bien chargé, les images ne
|
||||
// partaient jamais et le modèle brodait à partir du seul chemin de fichier.
|
||||
var (
|
||||
visionProbeMu sync.Mutex
|
||||
visionProbeAt time.Time
|
||||
@@ -120,7 +126,12 @@ func engineSeesImages() bool {
|
||||
visionProbeAt = time.Now()
|
||||
visionProbeSeen = false
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/props", LLMPort()))
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/props", LLMPort()), nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
authHeader(req)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
@@ -232,7 +243,7 @@ func safeSlug(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
func toolWebScreenshot(args map[string]any) string {
|
||||
func toolWebScreenshot(args map[string]any, caps Caps) string {
|
||||
bin := playwrightBin()
|
||||
if bin == "" {
|
||||
return "[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0)."
|
||||
@@ -245,6 +256,13 @@ func toolWebScreenshot(args map[string]any) string {
|
||||
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
|
||||
return "[erreur] url invalide : elle doit commencer par http:// ou https://"
|
||||
}
|
||||
// Accès internet coupé : la capture reste offerte au MODE CODE, mais bornée
|
||||
// aux adresses locales — voir sa propre page de dev, pas le web. Sans cette
|
||||
// borne, l'outil rouvrirait par la fenêtre ce que l'interrupteur ferme.
|
||||
if !caps.Internet && !isLocalURL(url) {
|
||||
return "[refusé] L'accès internet est coupé : la capture ne marche que sur une adresse locale " +
|
||||
"(localhost / 127.0.0.1), par exemple ton serveur de dev. Active l'accès internet pour le web."
|
||||
}
|
||||
// width n'est plus déclaré dans le schéma (budget de préambule), mais reste
|
||||
// honoré s'il arrive quand même : un modèle qui l'invente obtient le
|
||||
// comportement attendu plutôt qu'un paramètre ignoré en silence.
|
||||
@@ -387,3 +405,19 @@ func lastLines(s string, n int) string {
|
||||
}
|
||||
return strings.Join(keep, " · ")
|
||||
}
|
||||
|
||||
// isLocalURL : l'adresse désigne-t-elle cette machine ? Sert de borne quand
|
||||
// l'accès internet est coupé mais que le mode code doit pouvoir photographier
|
||||
// son propre serveur de dev.
|
||||
func isLocalURL(raw string) bool {
|
||||
u, err := neturl.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
host := u.Hostname()
|
||||
switch host {
|
||||
case "localhost", "127.0.0.1", "::1", "0.0.0.0":
|
||||
return true
|
||||
}
|
||||
return strings.HasSuffix(host, ".localhost")
|
||||
}
|
||||
@@ -232,6 +232,11 @@ func convDelete(id string) error {
|
||||
}
|
||||
convIndexSave(next)
|
||||
_ = putBytes(bkChat, convKey(id), nil)
|
||||
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
|
||||
// elle, et ses critères/mode/puce partent avec ses messages.
|
||||
stopConvJobs(id)
|
||||
critDrop(id)
|
||||
dropConvMode(id)
|
||||
// Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a
|
||||
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
|
||||
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
|
||||
|
||||
@@ -44,6 +44,18 @@ func baseSystemPrompt(caps Caps) string {
|
||||
// « Loki » avec une majuscule : c'est un nom propre, et le modèle recopie
|
||||
// littéralement la casse d'ici quand il se présente (« je suis loki »).
|
||||
b.WriteString("You are Loki, an expert assistant operating directly on this machine with real tools.")
|
||||
// Mode code : le prompt du rôle (builder/planner/verifier…) prend la suite
|
||||
// de l'identité. Court par construction (voir code_roles.go) — la règle
|
||||
// « ne pas regonfler » vaut aussi pour lui.
|
||||
if caps.Code {
|
||||
role := caps.Role
|
||||
if role == "" {
|
||||
role = "builder"
|
||||
}
|
||||
if rp := rolePrompt(role); rp != "" {
|
||||
b.WriteString("\n\n" + rp)
|
||||
}
|
||||
}
|
||||
if caps.Mem == MemAlways {
|
||||
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
|
||||
}
|
||||
@@ -149,7 +161,7 @@ func machineSystemPrompt(caps Caps) string {
|
||||
if u, err := user.Current(); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
cwd := convWorkspace()
|
||||
cwd := agentCwd()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
|
||||
@@ -161,7 +173,11 @@ func machineSystemPrompt(caps Caps) string {
|
||||
}
|
||||
b.WriteString(".")
|
||||
if cwd != "" {
|
||||
b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder loki was launched from.")
|
||||
// Formulation reprise de l'amont AJEAN (v0.10.2) : dire ce que le dossier
|
||||
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
|
||||
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
|
||||
// aux « installations » en /usr/local/bin qui échouent faute de root.
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder when the user explicitly named that location.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -195,7 +211,7 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
|
||||
// such file or directory » incompréhensible, et ce jusqu'au redémarrage. On
|
||||
// le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
|
||||
// tout refuser.
|
||||
if ws := convWorkspace(); ws != "" {
|
||||
if ws := agentCwd(); ws != "" {
|
||||
if err := os.MkdirAll(ws, 0o755); err == nil {
|
||||
cmd.Dir = ws
|
||||
}
|
||||
@@ -258,6 +274,10 @@ func fileWrite(path, content string) string {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
@@ -292,16 +312,28 @@ func fileEdit(path, oldText, newText string) string {
|
||||
return "[erreur] old vide"
|
||||
}
|
||||
path = resolveAgentPath(path)
|
||||
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
|
||||
mu := fileMu(path)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
content := string(b)
|
||||
n := strings.Count(content, oldText)
|
||||
// Fichier en CRLF, modèle en LF : la recherche exacte échouait toujours.
|
||||
// On compare alors en LF normalisé, et on réécrit dans le style du fichier
|
||||
// (préservation des fins de ligne — repris des tests line-ending d'OpenFox).
|
||||
crlf := strings.Contains(content, "\r\n")
|
||||
search, oldN, newN := content, oldText, newText
|
||||
if crlf && !strings.Contains(oldText, "\r\n") {
|
||||
search = strings.ReplaceAll(content, "\r\n", "\n")
|
||||
}
|
||||
n := strings.Count(search, oldN)
|
||||
if n == 0 {
|
||||
// Modification déjà en place : on le dit clairement plutôt que de renvoyer
|
||||
// une erreur, sinon le modèle croit avoir échoué et recommence.
|
||||
if newText != "" && strings.Contains(content, newText) {
|
||||
if newN != "" && strings.Contains(search, newN) {
|
||||
return "[ok] déjà à jour — le fichier contient déjà cette modification"
|
||||
}
|
||||
return "[erreur] old introuvable dans le fichier"
|
||||
@@ -309,7 +341,11 @@ func fileEdit(path, oldText, newText string) string {
|
||||
if n > 1 {
|
||||
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
|
||||
}
|
||||
updated := strings.Replace(content, oldText, newText, 1)
|
||||
updated := strings.Replace(search, oldN, newN, 1)
|
||||
if search != content {
|
||||
// La comparaison s'est faite en LF : on remet le fichier en CRLF.
|
||||
updated = strings.ReplaceAll(updated, "\n", "\r\n")
|
||||
}
|
||||
// Préserve les permissions d'origine (un script 0755 doit rester exécutable).
|
||||
mode := os.FileMode(0o644)
|
||||
if fi, err := os.Stat(path); err == nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// Dossier de travail du mode agent.
|
||||
@@ -21,7 +22,8 @@ import (
|
||||
// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif
|
||||
// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour
|
||||
// qu'un fichier appartienne à la discussion où il est né et disparaisse avec
|
||||
// elle. La racine reste la borne de sécurité des téléchargements.
|
||||
// elle — ou celui de la tâche planifiée en cours (agentCwd, plus bas). La racine
|
||||
// reste la borne de sécurité des téléchargements.
|
||||
|
||||
const workspaceEnv = "LOKI_WORKSPACE"
|
||||
|
||||
@@ -73,6 +75,30 @@ func workspaceCandidates() []string {
|
||||
return c
|
||||
}
|
||||
|
||||
// Dossier de travail imposé pendant l'exécution d'une TÂCHE planifiée
|
||||
// (tasks_run.go). Une tâche tourne hors de toute discussion : sans ça, ses
|
||||
// fichiers atterriraient dans le dossier de la discussion qui se trouve ouverte
|
||||
// — et changeraient de place en cours de route si l'utilisateur en changeait.
|
||||
//
|
||||
// La bascule est un état de processus et non de goroutine, ce que Go ne sait pas
|
||||
// faire autrement ; c'est sûr ici parce qu'une SEULE génération tourne à la fois
|
||||
// (le verrou de conv), et parce que seuls les points d'entrée des OUTILS lisent
|
||||
// agentCwd(). Ce que voit l'utilisateur — panneau Fichiers, dépôts, liens des
|
||||
// messages — continue de passer par convWorkspace() et reste donc sur sa
|
||||
// discussion, même pendant qu'une tâche écrit ailleurs.
|
||||
var taskWS atomic.Value // string
|
||||
|
||||
func setTaskWorkspace(dir string) { taskWS.Store(dir) }
|
||||
|
||||
// agentCwd = le dossier où l'agent travaille MAINTENANT : celui de la tâche en
|
||||
// cours s'il y en a une, sinon celui de la discussion ouverte.
|
||||
func agentCwd() string {
|
||||
if d, _ := taskWS.Load().(string); d != "" {
|
||||
return d
|
||||
}
|
||||
return convWorkspace()
|
||||
}
|
||||
|
||||
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
|
||||
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
|
||||
// discussion ouverte.
|
||||
@@ -89,5 +115,5 @@ func resolveAgentPath(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(convWorkspace(), filepath.FromSlash(p))
|
||||
return filepath.Join(agentCwd(), filepath.FromSlash(p))
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
package loki
|
||||
|
||||
// code_criteria.go — critères d'acceptation du mode code (contrat d'exécution,
|
||||
// repris du session-metadata/criteria d'OpenFox, réécrit en Go — voir
|
||||
// NOTICE.md). Les critères sont posés au début d'une tâche (par le modèle via
|
||||
// l'outil `criteria`, ou à la main dans l'UI), puis la passe de vérification
|
||||
// (code_verify.go) les fait passer à `passed` ou `failed`. Le tour de build ne
|
||||
// se termine « vraiment » que quand tout est passed — c'est le contrat.
|
||||
//
|
||||
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
|
||||
// la discussion, sa suppression les emporte.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Criterion struct {
|
||||
ID int `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Status string `json:"status"` // pending | passed | failed
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
const critMaxCount = 20
|
||||
|
||||
func critKey(convID string) string { return "crit:" + convID }
|
||||
|
||||
func critList(convID string) []Criterion {
|
||||
var list []Criterion
|
||||
if b := getBytes(bkChat, critKey(convID)); len(b) > 0 {
|
||||
_ = json.Unmarshal(b, &list)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
func critSave(convID string, list []Criterion) {
|
||||
if len(list) == 0 {
|
||||
_ = putBytes(bkChat, critKey(convID), nil) // nil supprime la clé
|
||||
return
|
||||
}
|
||||
b, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = putBytes(bkChat, critKey(convID), b)
|
||||
}
|
||||
|
||||
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
|
||||
|
||||
// critAllPassed : le contrat est rempli. Une liste vide ne compte pas comme
|
||||
// remplie — il n'y a simplement pas de contrat.
|
||||
func critAllPassed(list []Criterion) bool {
|
||||
if len(list) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, c := range list {
|
||||
if c.Status != "passed" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func critPending(list []Criterion) int {
|
||||
n := 0
|
||||
for _, c := range list {
|
||||
if c.Status != "passed" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// critRender : la liste formatée pour un prompt (builder ou verifier).
|
||||
func critRender(list []Criterion) string {
|
||||
var b strings.Builder
|
||||
for _, c := range list {
|
||||
mark := "[ ]"
|
||||
switch c.Status {
|
||||
case "passed":
|
||||
mark = "[✓]"
|
||||
case "failed":
|
||||
mark = "[✗]"
|
||||
}
|
||||
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
|
||||
if c.Note != "" {
|
||||
fmt.Fprintf(&b, " — %s", c.Note)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func criteriaTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "criteria",
|
||||
Description: "Manage the acceptance criteria of the current task (the contract that defines DONE). " +
|
||||
"action=add posits new criteria (texts[]), action=set updates one (id + status passed|failed|pending, optional note), " +
|
||||
"action=list shows them, action=clear removes them all. Set criteria BEFORE building; only the verification pass may mark passed.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
|
||||
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
|
||||
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
|
||||
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
|
||||
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
|
||||
},
|
||||
"required": []string{"action"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolCriteria exécute l'outil. allowPass : seul le passage de VÉRIFICATION a
|
||||
// le droit de marquer `passed` (sinon le builder s'auto-valide et le contrat ne
|
||||
// contraint plus rien).
|
||||
func toolCriteria(args map[string]any, allowPass bool) string {
|
||||
convID := convEnsureActive()
|
||||
action, _ := args["action"].(string)
|
||||
list := critList(convID)
|
||||
switch action {
|
||||
case "list", "":
|
||||
if len(list) == 0 {
|
||||
return "[aucun critère]"
|
||||
}
|
||||
return critRender(list)
|
||||
case "add":
|
||||
raw, _ := args["texts"].([]any)
|
||||
next := 1
|
||||
for _, c := range list {
|
||||
if c.ID >= next {
|
||||
next = c.ID + 1
|
||||
}
|
||||
}
|
||||
added := 0
|
||||
for _, r := range raw {
|
||||
s, ok := r.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
if len(list) >= critMaxCount {
|
||||
break
|
||||
}
|
||||
list = append(list, Criterion{ID: next, Text: strings.TrimSpace(s), Status: "pending"})
|
||||
next++
|
||||
added++
|
||||
}
|
||||
if added == 0 {
|
||||
return "[erreur] aucun critère fourni (texts)"
|
||||
}
|
||||
critSave(convID, list)
|
||||
critNotify(list)
|
||||
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
|
||||
case "set":
|
||||
idf, _ := args["id"].(float64)
|
||||
id := int(idf)
|
||||
status, _ := args["status"].(string)
|
||||
note, _ := args["note"].(string)
|
||||
if status == "passed" && !allowPass {
|
||||
return "[refusé] Seule la passe de vérification peut marquer un critère passed. Termine ton implémentation ; la vérification suivra."
|
||||
}
|
||||
for i := range list {
|
||||
if list[i].ID != id {
|
||||
continue
|
||||
}
|
||||
if status != "" {
|
||||
list[i].Status = status
|
||||
}
|
||||
if note != "" {
|
||||
list[i].Note = strings.TrimSpace(note)
|
||||
}
|
||||
critSave(convID, list)
|
||||
critNotify(list)
|
||||
return "[ok] critère #" + fmt.Sprint(id) + " → " + list[i].Status
|
||||
}
|
||||
return fmt.Sprintf("[erreur] critère #%d inconnu", id)
|
||||
case "clear":
|
||||
critDrop(convID)
|
||||
critNotify(nil)
|
||||
return "[ok] critères effacés"
|
||||
}
|
||||
return "[erreur] action inconnue : " + action
|
||||
}
|
||||
|
||||
// critNotify pousse la liste à l'UI (panneau critères) via le journal de la
|
||||
// conversation. Best-effort : hors génération il n'y a pas d'epoch en cours,
|
||||
// on publie sur l'epoch courant.
|
||||
func critNotify(list []Criterion) {
|
||||
if conv == nil {
|
||||
return
|
||||
}
|
||||
conv.mu.Lock()
|
||||
epoch := conv.epoch
|
||||
conv.mu.Unlock()
|
||||
if list == nil {
|
||||
list = []Criterion{}
|
||||
}
|
||||
conv.appendDelta(epoch, map[string]any{"criteria": list, "ts": time.Now().UnixMilli()})
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cycle de vie des critères : add → set → clear, avec la garde allowPass.
|
||||
func TestCriteresCycleDeVie(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
|
||||
out := toolCriteria(map[string]any{"action": "add", "texts": []any{"le test passe", "le build compile"}}, false)
|
||||
if !strings.Contains(out, "2 critère(s)") {
|
||||
t.Fatalf("ajout raté : %s", out)
|
||||
}
|
||||
list := critList(id)
|
||||
if len(list) != 2 || list[0].ID != 1 || list[1].ID != 2 {
|
||||
t.Fatalf("liste inattendue : %+v", list)
|
||||
}
|
||||
// Le builder (allowPass=false) ne peut PAS s'auto-valider.
|
||||
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, false)
|
||||
if !strings.Contains(out, "[refusé]") {
|
||||
t.Fatalf("auto-validation acceptée : %s", out)
|
||||
}
|
||||
// Le vérificateur (allowPass=true), si.
|
||||
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, true)
|
||||
if !strings.Contains(out, "passed") || strings.Contains(out, "[refusé]") {
|
||||
t.Fatalf("validation vérificateur refusée : %s", out)
|
||||
}
|
||||
// failed reste ouvert à tous (le builder peut constater un échec).
|
||||
out = toolCriteria(map[string]any{"action": "set", "id": float64(2), "status": "failed", "note": "le build casse"}, false)
|
||||
if !strings.Contains(out, "failed") {
|
||||
t.Fatalf("échec non enregistré : %s", out)
|
||||
}
|
||||
list = critList(id)
|
||||
if critAllPassed(list) {
|
||||
t.Fatal("allPassed avec un failed")
|
||||
}
|
||||
if critPending(list) != 1 {
|
||||
t.Fatalf("pending = %d, attendu 1", critPending(list))
|
||||
}
|
||||
// Rendu : les statuts sont visibles.
|
||||
r := critRender(list)
|
||||
if !strings.Contains(r, "[✓] #1") || !strings.Contains(r, "[✗] #2") || !strings.Contains(r, "le build casse") {
|
||||
t.Fatalf("rendu incomplet :\n%s", r)
|
||||
}
|
||||
toolCriteria(map[string]any{"action": "clear"}, false)
|
||||
if len(critList(id)) != 0 {
|
||||
t.Fatal("clear sans effet")
|
||||
}
|
||||
}
|
||||
|
||||
// Une liste vide n'est pas un contrat rempli.
|
||||
func TestContratVide(t *testing.T) {
|
||||
if critAllPassed(nil) {
|
||||
t.Fatal("liste vide considérée comme remplie")
|
||||
}
|
||||
}
|
||||
|
||||
// Le plafond de critères tient.
|
||||
func TestCriteresPlafond(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
var texts []any
|
||||
for i := 0; i < critMaxCount+10; i++ {
|
||||
texts = append(texts, "critère")
|
||||
}
|
||||
toolCriteria(map[string]any{"action": "add", "texts": texts}, false)
|
||||
if n := len(critList(id)); n != critMaxCount {
|
||||
t.Fatalf("%d critères, plafond %d", n, critMaxCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package loki
|
||||
|
||||
// code_git.go — outils git natifs du mode code : git_status, git_diff,
|
||||
// git_clone. Le modèle les avait via bash, mais des outils dédiés donnent une
|
||||
// sortie bornée, un libellé propre dans le fil, et un clone qui atterrit
|
||||
// TOUJOURS dans le dossier de la discussion (jamais ailleurs sur le disque).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const gitMaxOutput = 8000
|
||||
|
||||
func gitStatusTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "git_status",
|
||||
Description: "git status of the working folder (branch + changed files, porcelain format).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func gitDiffTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "git_diff",
|
||||
Description: "git diff of the working folder (unstaged + staged). Optional file to diff a single path.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Limit the diff to this path"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func gitCloneTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "git_clone",
|
||||
Description: "Clone a git repository (https:// or git@) INTO the working folder of this discussion.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"url": map[string]any{"type": "string", "description": "Repository URL"},
|
||||
"dir": map[string]any{"type": "string", "description": "Target folder name (default: repo name)"},
|
||||
},
|
||||
"required": []string{"url"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// runGit exécute git dans le dossier de la discussion, sortie bornée.
|
||||
func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
return "[erreur] git n'est pas installé sur cette machine"
|
||||
}
|
||||
cctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(cctx, "git", args...)
|
||||
cmd.Dir = agentCwd()
|
||||
// Jamais d'invite interactive (mot de passe, hôte inconnu) : un process
|
||||
// serveur n'a personne pour y répondre, il faut échouer vite et le dire.
|
||||
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes")
|
||||
out, err := cmd.CombinedOutput()
|
||||
s := tailRunes(strings.TrimSpace(string(out)), gitMaxOutput)
|
||||
if cctx.Err() == context.DeadlineExceeded {
|
||||
return "[timeout] git " + strings.Join(args, " ")
|
||||
}
|
||||
if err != nil {
|
||||
if s == "" {
|
||||
s = err.Error()
|
||||
}
|
||||
return "[erreur] " + s
|
||||
}
|
||||
if s == "" {
|
||||
s = "(aucune sortie)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func toolGitStatus(ctx context.Context) string {
|
||||
return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch")
|
||||
}
|
||||
|
||||
func toolGitDiff(ctx context.Context, args map[string]any) string {
|
||||
// Un seul appel qui montre TOUT ce qui a changé : index + arbre de travail.
|
||||
// HEAD peut ne pas exister (dépôt tout neuf) : on retombe sur le diff simple.
|
||||
gitArgs := []string{"diff", "HEAD"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
gitArgs = append(gitArgs, "--", file)
|
||||
}
|
||||
out := runGit(ctx, 30*time.Second, gitArgs...)
|
||||
if strings.HasPrefix(out, "[erreur]") && strings.Contains(out, "HEAD") {
|
||||
fallback := []string{"diff"}
|
||||
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
|
||||
fallback = append(fallback, "--", file)
|
||||
}
|
||||
out = runGit(ctx, 30*time.Second, fallback...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gitURLRe : https:// ou git@hôte: — pas de chemins locaux ni de protocoles
|
||||
// exotiques (file://, ext:: qui exécute une commande arbitraire).
|
||||
var gitURLRe = regexp.MustCompile(`^(https://[\w.\-]+(:\d+)?/|git@[\w.\-]+:)[\w.\-~/]+$`)
|
||||
|
||||
func toolGitClone(ctx context.Context, args map[string]any) string {
|
||||
url, _ := args["url"].(string)
|
||||
url = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(url), ".git")) + ".git"
|
||||
if !gitURLRe.MatchString(strings.TrimSuffix(url, ".git")) && !gitURLRe.MatchString(url) {
|
||||
return "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés"
|
||||
}
|
||||
dir, _ := args["dir"].(string)
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
base := filepath.Base(strings.TrimSuffix(url, ".git"))
|
||||
dir = base
|
||||
}
|
||||
// Le clone atterrit DANS la discussion, quoi qu'il arrive.
|
||||
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
|
||||
return "[erreur] dossier cible invalide"
|
||||
}
|
||||
target := filepath.Join(agentCwd(), dir)
|
||||
if _, err := os.Stat(target); err == nil {
|
||||
return "[erreur] le dossier " + dir + " existe déjà"
|
||||
}
|
||||
out := runGit(ctx, 5*time.Minute, "clone", "--", url, target)
|
||||
if strings.HasPrefix(out, "[erreur]") || strings.HasPrefix(out, "[timeout]") {
|
||||
return out
|
||||
}
|
||||
return fmt.Sprintf("[ok] cloné dans %s/\n%s", dir, out)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package loki
|
||||
|
||||
// code_jobs.go — commandes shell d'ARRIÈRE-PLAN pour le mode code : bash_bg
|
||||
// lance un process détaché (serveur de dev, watcher, build long) et rend la
|
||||
// main tout de suite ; bash_tail relit sa sortie et son état. Sans ça, le
|
||||
// modèle lance `./serveur &` dans bash et le tour reste suspendu aux tubes
|
||||
// (voir le commentaire WaitDelay de runShell).
|
||||
//
|
||||
// La sortie va dans un fichier du dossier de la discussion
|
||||
// (.loki/jobs/<id>.log) : elle survit au tour, et le panneau Fichiers la montre.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bgJob struct {
|
||||
ID string
|
||||
Command string
|
||||
LogPath string
|
||||
Cmd *exec.Cmd
|
||||
Started time.Time
|
||||
|
||||
mu sync.Mutex
|
||||
done bool
|
||||
exit int
|
||||
}
|
||||
|
||||
var (
|
||||
jobsMu sync.Mutex
|
||||
jobs = map[string]*bgJob{}
|
||||
jobSeq int
|
||||
)
|
||||
|
||||
func bashBgTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "bash_bg",
|
||||
Description: "Start a shell command in the BACKGROUND (dev server, watcher, long build) and return immediately with a job id. Output goes to a log file; check it with bash_tail. Use this instead of appending '&' in bash.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"command": map[string]any{"type": "string", "description": "The command"},
|
||||
},
|
||||
"required": []string{"command"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func bashTailTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "bash_tail",
|
||||
Description: "Read the last lines of a background job's output and its status (running/exited). kill:true stops the job.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "Job id from bash_bg"},
|
||||
"lines": map[string]any{"type": "integer", "description": "Lines to show (default 40, max 200)"},
|
||||
"kill": map[string]any{"type": "boolean", "description": "Stop the job"},
|
||||
},
|
||||
"required": []string{"id"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolBashBg lance la commande détachée. Même politique que bash (commandes
|
||||
// dangereuses refusées), même dossier de départ (discussion courante).
|
||||
func toolBashBg(args map[string]any) string {
|
||||
command, _ := args["command"].(string)
|
||||
if strings.TrimSpace(command) == "" {
|
||||
return "[erreur] commande vide"
|
||||
}
|
||||
if reason := dangerousCommand(command); reason != "" {
|
||||
return refusedCommandResult(reason)
|
||||
}
|
||||
ws := agentCwd()
|
||||
logDir := filepath.Join(ws, ".loki", "jobs")
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
jobsMu.Lock()
|
||||
jobSeq++
|
||||
id := fmt.Sprintf("job%d", jobSeq)
|
||||
jobsMu.Unlock()
|
||||
logPath := filepath.Join(logDir, id+".log")
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
// Contexte de FOND, pas celui du tour : le job doit survivre à la fin du
|
||||
// tour — c'est sa raison d'être. Il s'arrête par bash_tail kill:true, ou
|
||||
// avec le conteneur.
|
||||
cmd := newShellCmdDetached(command)
|
||||
cmd.Dir = ws
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
if err := cmd.Start(); err != nil {
|
||||
logFile.Close()
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
j := &bgJob{ID: id, Command: command, LogPath: logPath, Cmd: cmd, Started: time.Now()}
|
||||
jobsMu.Lock()
|
||||
jobs[id] = j
|
||||
jobsMu.Unlock()
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
logFile.Close()
|
||||
j.mu.Lock()
|
||||
j.done = true
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
j.exit = ee.ExitCode()
|
||||
}
|
||||
j.mu.Unlock()
|
||||
}()
|
||||
return fmt.Sprintf("[ok] job %s lancé (pid %d) — sortie dans .loki/jobs/%s.log, suis-la avec bash_tail", id, cmd.Process.Pid, id)
|
||||
}
|
||||
|
||||
// toolBashTail relit la fin du journal d'un job et son état.
|
||||
func toolBashTail(args map[string]any) string {
|
||||
id, _ := args["id"].(string)
|
||||
jobsMu.Lock()
|
||||
j := jobs[id]
|
||||
jobsMu.Unlock()
|
||||
if j == nil {
|
||||
return "[erreur] job inconnu : " + id
|
||||
}
|
||||
if kill, _ := args["kill"].(bool); kill {
|
||||
j.mu.Lock()
|
||||
running := !j.done
|
||||
j.mu.Unlock()
|
||||
if running && j.Cmd.Process != nil {
|
||||
_ = j.Cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
lines := 40
|
||||
if v, ok := args["lines"].(float64); ok && int(v) > 0 {
|
||||
lines = int(v)
|
||||
}
|
||||
if lines > 200 {
|
||||
lines = 200
|
||||
}
|
||||
b, err := os.ReadFile(j.LogPath)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
all := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
||||
if len(all) > lines {
|
||||
all = all[len(all)-lines:]
|
||||
}
|
||||
j.mu.Lock()
|
||||
status := "en cours"
|
||||
if j.done {
|
||||
status = fmt.Sprintf("terminé (exit %d)", j.exit)
|
||||
}
|
||||
j.mu.Unlock()
|
||||
out := fmt.Sprintf("job %s — %s — depuis %s\ncommande : %s\n", j.ID, status, time.Since(j.Started).Round(time.Second), j.Command)
|
||||
tail := strings.Join(all, "\n")
|
||||
if strings.TrimSpace(tail) == "" {
|
||||
tail = "(aucune sortie pour l'instant)"
|
||||
}
|
||||
return out + "\n" + tailRunes(tail, toolMaxOutput)
|
||||
}
|
||||
|
||||
// stopConvJobs tue les jobs d'une discussion (suppression / vidage de la
|
||||
// discussion : ses fichiers partent, ses process aussi).
|
||||
func stopConvJobs(convID string) {
|
||||
prefix := filepath.Join(agentWorkspace(), convFilesRoot, safeConvID(convID))
|
||||
jobsMu.Lock()
|
||||
defer jobsMu.Unlock()
|
||||
for id, j := range jobs {
|
||||
if strings.HasPrefix(j.LogPath, prefix) {
|
||||
j.mu.Lock()
|
||||
running := !j.done
|
||||
j.mu.Unlock()
|
||||
if running && j.Cmd.Process != nil {
|
||||
_ = j.Cmd.Process.Kill()
|
||||
}
|
||||
delete(jobs, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package loki
|
||||
|
||||
// code_mode.go — le mode Chat/Code d'une discussion. Bascule EXPLICITE
|
||||
// (sélecteur dans le pied du composeur), persistée par discussion ; en mode
|
||||
// chat, une détection côté serveur peut SUGGÉRER la bascule (puce ignorable
|
||||
// dans l'UI) mais ne l'impose jamais — un faux positif coûterait des tours de
|
||||
// planification/vérification sur un simple bonjour.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func modeKey(convID string) string { return "mode:" + convID }
|
||||
func hintKey(convID string) string { return "hinted:" + convID }
|
||||
|
||||
// convCodeMode : la discussion active est-elle en mode code ?
|
||||
func convCodeMode() bool { return getStr(bkChat, modeKey(convEnsureActive())) == "code" }
|
||||
|
||||
func setConvMode(convID, mode string) error {
|
||||
if mode != "code" {
|
||||
mode = "" // chat = défaut = clé absente
|
||||
}
|
||||
return putStr(bkChat, modeKey(convID), mode)
|
||||
}
|
||||
|
||||
// dropConvMode : appelé à la suppression d'une discussion.
|
||||
func dropConvMode(convID string) {
|
||||
_ = putStr(bkChat, modeKey(convID), "")
|
||||
_ = putStr(bkChat, hintKey(convID), "")
|
||||
}
|
||||
|
||||
// --- Détection « ça ressemble à du code » ------------------------------------
|
||||
// Côté serveur, AUCUN token : ni consigne dans le prompt, ni appel modèle.
|
||||
// Un indice suffit ; la puce de l'UI est gratuite à ignorer.
|
||||
|
||||
var codeHintRes = []*regexp.Regexp{
|
||||
// Un bloc de code clôturé.
|
||||
regexp.MustCompile("```"),
|
||||
// Un chemin de fichier source.
|
||||
regexp.MustCompile(`(?i)\b[\w./\\-]+\.(go|py|js|jsx|ts|tsx|rs|java|kt|c|h|cpp|hpp|cs|rb|php|swift|sh|ps1|sql|html|css|scss|vue|svelte|toml|yaml|yml|json|dockerfile|makefile)\b`),
|
||||
// Verbes / vocabulaire de tâche de code.
|
||||
regexp.MustCompile(`(?i)\b(corrige|implémente|implemente|refactor|refactorise|débogue|debug|compile|stack ?trace|segfault|exception|traceback|unit ?test|pull ?request|merge|commit|clone)\b`),
|
||||
regexp.MustCompile(`(?i)\b(bug|erreur de compilation|tests? (échoue|cassé)|fonction|classe|module|api rest|endpoint)\b`),
|
||||
}
|
||||
|
||||
// looksLikeCode dit si un message utilisateur ressemble à une tâche de code.
|
||||
// Le dépôt git déjà cloné dans la discussion est aussi un indice fort.
|
||||
func looksLikeCode(text string) bool {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return false
|
||||
}
|
||||
for _, re := range codeHintRes {
|
||||
if re.MatchString(text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// convHasRepo : un dépôt git vit déjà dans le dossier de la discussion.
|
||||
func convHasRepo() bool {
|
||||
ws := agentCwd()
|
||||
if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
entries, err := os.ReadDir(ws)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(ws, e.Name(), ".git")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y
|
||||
// prête. Une seule fois par discussion : une suggestion répétée est du bruit.
|
||||
func maybeCodeHint(c *Conversation, epoch int, text string) {
|
||||
if convCodeMode() || !agentEnabled() {
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
if getBool(bkChat, hintKey(id)) {
|
||||
return
|
||||
}
|
||||
if !looksLikeCode(text) && !convHasRepo() {
|
||||
return
|
||||
}
|
||||
_ = putBool(bkChat, hintKey(id), true)
|
||||
c.appendDelta(epoch, map[string]any{"code_hint": true})
|
||||
}
|
||||
|
||||
// --- API ---------------------------------------------------------------------
|
||||
|
||||
// handleChatMode — GET : {mode, criteria} de la discussion active ;
|
||||
// POST {"mode":"code"|"chat"} : bascule (et pousse le nouvel état aux abonnés).
|
||||
func handleChatMode(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var body struct {
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Mode != "code" && body.Mode != "chat" {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu (chat|code)"})
|
||||
return
|
||||
}
|
||||
id := convEnsureActive()
|
||||
if err := setConvMode(id, body.Mode); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Pousse la bascule à tous les appareils abonnés (le sélecteur doit
|
||||
// suivre, comme le fil lui-même).
|
||||
conv.mu.Lock()
|
||||
epoch := conv.epoch
|
||||
conv.mu.Unlock()
|
||||
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode})
|
||||
return
|
||||
}
|
||||
mode := "chat"
|
||||
if convCodeMode() {
|
||||
mode = "code"
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"mode": mode,
|
||||
"criteria": critList(convEnsureActive()),
|
||||
})
|
||||
}
|
||||
|
||||
// handleChatCriteria — POST : édition manuelle des critères depuis l'UI.
|
||||
// {"criteria":[{"id":1,"text":"…","status":"pending"},…]} remplace la liste.
|
||||
func handleChatCriteria(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Criteria []Criterion `json:"criteria"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
var clean []Criterion
|
||||
next := 1
|
||||
for _, c := range body.Criteria {
|
||||
if strings.TrimSpace(c.Text) == "" || len(clean) >= critMaxCount {
|
||||
continue
|
||||
}
|
||||
if c.ID <= 0 {
|
||||
c.ID = next
|
||||
}
|
||||
if c.ID >= next {
|
||||
next = c.ID + 1
|
||||
}
|
||||
switch c.Status {
|
||||
case "pending", "passed", "failed":
|
||||
default:
|
||||
c.Status = "pending"
|
||||
}
|
||||
clean = append(clean, c)
|
||||
}
|
||||
critSave(convEnsureActive(), clean)
|
||||
critNotify(clean)
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "criteria": clean})
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// La bascule est persistée par discussion et retombe sur chat par défaut.
|
||||
func TestModeParDiscussion(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
id := convEnsureActive()
|
||||
if convCodeMode() {
|
||||
t.Fatal("mode code par défaut")
|
||||
}
|
||||
if err := setConvMode(id, "code"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !convCodeMode() {
|
||||
t.Fatal("bascule sans effet")
|
||||
}
|
||||
dropConvMode(id)
|
||||
if convCodeMode() {
|
||||
t.Fatal("mode code après nettoyage")
|
||||
}
|
||||
}
|
||||
|
||||
// La détection reconnaît une tâche de code, pas la conversation ordinaire.
|
||||
func TestDetectionCode(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"corrige le bug dans main.go",
|
||||
"j'ai une stack trace : Exception in thread",
|
||||
"```python\nprint('x')\n```",
|
||||
"implémente la fonction de tri",
|
||||
"le build casse avec une erreur de compilation",
|
||||
} {
|
||||
if !looksLikeCode(s) {
|
||||
t.Errorf("non détecté : %q", s)
|
||||
}
|
||||
}
|
||||
for _, s := range []string{
|
||||
"quelle est la capitale de l'Australie ?",
|
||||
"raconte-moi une histoire",
|
||||
"quel temps fait-il demain ?",
|
||||
"",
|
||||
} {
|
||||
if looksLikeCode(s) {
|
||||
t.Errorf("détecté à tort : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les rôles embarqués existent tous, et le prompt du builder est court —
|
||||
// la règle « ne pas regonfler » de baseSystemPrompt s'applique aussi ici.
|
||||
func TestRolesEmbarques(t *testing.T) {
|
||||
for _, r := range roleNames {
|
||||
p := rolePrompt(r)
|
||||
if p == "" {
|
||||
t.Errorf("rôle %s : prompt vide", r)
|
||||
}
|
||||
if len(p) > 2500 {
|
||||
t.Errorf("rôle %s : prompt trop long (%d octets)", r, len(p))
|
||||
}
|
||||
}
|
||||
if rolePrompt("inexistant") != "" {
|
||||
t.Error("rôle inconnu devrait rendre vide")
|
||||
}
|
||||
}
|
||||
|
||||
// wantsPlan : demande de plan reconnue, implémentation directe non.
|
||||
func TestWantsPlan(t *testing.T) {
|
||||
if !wantsPlan("Fais un plan avant de coder") {
|
||||
t.Error("plan non reconnu")
|
||||
}
|
||||
if wantsPlan("corrige le bug maintenant") {
|
||||
t.Error("plan détecté à tort")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package loki
|
||||
|
||||
// code_policy.go — politique d'exécution des outils de l'agent (repris dans
|
||||
// l'esprit de tool-policy/path-security d'OpenFox, réécrit en Go — voir
|
||||
// NOTICE.md).
|
||||
//
|
||||
// Deux gardes indépendantes :
|
||||
// - dangerousCommand : liste courte de commandes CATASTROPHIQUES (effacement
|
||||
// de disque, arrêt machine, fork bomb). Toujours active, mode chat compris :
|
||||
// aucune de ces commandes n'a d'usage légitime lancée par un modèle. Le
|
||||
// refus explique quoi faire (l'utilisateur la lance lui-même).
|
||||
// - codePathAllowed : en MODE CODE, tous les chemins des outils fichiers sont
|
||||
// bornés au dossier de la discussion — liens symboliques résolus des deux
|
||||
// côtés, comme le panneau Fichiers (relWithin). Le mode chat garde le
|
||||
// comportement historique (chemins absolus honorés) : c'est le mode code
|
||||
// qui promet un bac à sable, pas l'agent généraliste.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// dangerousPatterns : motifs (insensibles à la casse) de commandes refusées.
|
||||
// Volontairement ÉTROIT : viser la perte de données irréversible et l'arrêt de
|
||||
// la machine, pas la moindre écriture. Un motif trop large rend l'agent
|
||||
// inutilisable et pousse à tout désactiver.
|
||||
var dangerousPatterns = []struct {
|
||||
re *regexp.Regexp
|
||||
reason string
|
||||
}{
|
||||
// rm -rf / (ou ~, ou une racine de lecteur) — pas un rm -rf d'un sous-dossier.
|
||||
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s*$`), "efface la racine du disque ou le home"},
|
||||
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s`), "efface la racine du disque ou le home"},
|
||||
// Écriture directe sur un périphérique bloc / formatage.
|
||||
{regexp.MustCompile(`(?i)\bmkfs(\.[a-z0-9]+)?\b`), "formate un système de fichiers"},
|
||||
{regexp.MustCompile(`(?i)\bdd\b[^\n]*\bof=/dev/`), "écrit directement sur un périphérique disque"},
|
||||
{regexp.MustCompile(`(?i)>\s*/dev/sd[a-z]\b`), "écrit directement sur un périphérique disque"},
|
||||
// Arrêt / redémarrage de la machine (ici : le conteneur, donc tout Loki).
|
||||
// Ancré en position de COMMANDE (début, après ; && | ou sudo) : le mot
|
||||
// « reboot » dans un grep ou un texte n'est pas une commande.
|
||||
{regexp.MustCompile(`(?i)(^|[;&|]\s*|\bsudo\s+)(shutdown|poweroff|reboot|halt)\b`), "arrête ou redémarre la machine"},
|
||||
// Fork bomb classique.
|
||||
{regexp.MustCompile(`:\(\)\s*\{\s*:\|:`), "fork bomb"},
|
||||
// chmod/chown récursif sur la racine.
|
||||
{regexp.MustCompile(`(?i)\bch(mod|own)\b[^\n]*-[a-z]*R[a-z]*\s+[^\s]+\s+/\s*$`), "change récursivement les droits de la racine"},
|
||||
// Windows : suppression récursive de la racine d'un lecteur, format.
|
||||
{regexp.MustCompile(`(?i)\b(rd|rmdir)\s+/s\b[^\n]*\s[a-z]:\\?\s*$`), "efface la racine d'un lecteur"},
|
||||
{regexp.MustCompile(`(?i)\bdel\s+(/[fsq]\s+)*[a-z]:\\\*`), "efface la racine d'un lecteur"},
|
||||
{regexp.MustCompile(`(?i)\bformat\s+[a-z]:`), "formate un lecteur"},
|
||||
}
|
||||
|
||||
// dangerousCommand renvoie la raison du refus si la commande correspond à un
|
||||
// motif interdit, "" sinon.
|
||||
func dangerousCommand(cmd string) string {
|
||||
for _, p := range dangerousPatterns {
|
||||
if p.re.MatchString(cmd) {
|
||||
return p.reason
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// refusedCommandResult formate le refus renvoyé au modèle : dire POURQUOI et
|
||||
// QUOI FAIRE, sinon il réessaie en boucle avec des variantes.
|
||||
func refusedCommandResult(reason string) string {
|
||||
return "[refusé] Commande bloquée par la politique de sécurité : " + reason + ". " +
|
||||
"Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal."
|
||||
}
|
||||
|
||||
// codePathAllowed dit si un chemin (déjà résolu par resolveAgentPath) est DANS
|
||||
// le dossier de la discussion courante. Utilisé par les outils fichiers en mode
|
||||
// code uniquement.
|
||||
func codePathAllowed(abs string) bool {
|
||||
ws := agentCwd()
|
||||
if ws == "" {
|
||||
return false
|
||||
}
|
||||
abs = filepath.Clean(abs)
|
||||
// Le fichier peut ne pas exister encore (write) : EvalSymlinks échouerait.
|
||||
// On teste alors son ancêtre le plus proche existant — c'est LUI qui doit
|
||||
// être dans le workspace, liens symboliques résolus.
|
||||
probe := abs
|
||||
for {
|
||||
if _, err := os.Stat(probe); err == nil {
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(probe)
|
||||
if parent == probe {
|
||||
break
|
||||
}
|
||||
probe = parent
|
||||
}
|
||||
if !sameOrWithin(ws, probe) {
|
||||
return false
|
||||
}
|
||||
// L'ancêtre est dans le workspace ; le chemin FINAL ne doit pas en
|
||||
// ressortir par des ".." lexicaux non encore résolus.
|
||||
rel, err := filepath.Rel(ws, abs)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// sameOrWithin : p est root lui-même, ou dedans — liens symboliques résolus
|
||||
// des deux côtés (relWithin refuse « . », d'où ce cousin qui l'accepte : la
|
||||
// racine du workspace est un chemin légitime pour grep/glob).
|
||||
func sameOrWithin(root, p string) bool {
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = r
|
||||
}
|
||||
if q, err := filepath.EvalSymlinks(p); err == nil {
|
||||
p = q
|
||||
}
|
||||
rel, err := filepath.Rel(root, p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
}
|
||||
|
||||
// refusedPathResult : refus de chemin hors du bac à sable du mode code.
|
||||
func refusedPathResult(path string) string {
|
||||
return "[refusé] " + path + " est hors du dossier de cette discussion. " +
|
||||
"En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
|
||||
}
|
||||
|
||||
// codeWriteGuard regroupe les gardes du mode code AVANT une écriture locale
|
||||
// (write/edit) : borne de chemin, puis tracker « lu avant d'écrire ».
|
||||
// Renvoie "" si tout est bon, sinon le message de refus pour le modèle.
|
||||
// En mode chat, aucune garde : comportement historique de l'agent.
|
||||
func codeWriteGuard(caps Caps, file string, forWrite bool) string {
|
||||
if !caps.Code {
|
||||
return ""
|
||||
}
|
||||
path := resolveAgentPath(file)
|
||||
if !codePathAllowed(path) {
|
||||
return refusedPathResult(file)
|
||||
}
|
||||
return trackerCheck(path, forWrite)
|
||||
}
|
||||
|
||||
// --- Mutex par fichier -------------------------------------------------------
|
||||
// Sérialise les écritures concurrentes sur un même fichier (write/edit). Sans
|
||||
// parallélisme d'outils aujourd'hui le coût est nul, mais le jour où deux tours
|
||||
// ou un job d'arrière-plan écrivent le même fichier, la corruption est évitée.
|
||||
|
||||
var fileMuMap sync.Map // clé : chemin nettoyé → *sync.Mutex
|
||||
|
||||
func fileMu(path string) *sync.Mutex {
|
||||
key := filepath.Clean(path)
|
||||
if m, ok := fileMuMap.Load(key); ok {
|
||||
return m.(*sync.Mutex)
|
||||
}
|
||||
m, _ := fileMuMap.LoadOrStore(key, &sync.Mutex{})
|
||||
return m.(*sync.Mutex)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La politique doit bloquer les commandes catastrophiques…
|
||||
func TestCommandesDangereusesBloquees(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"rm -rf /",
|
||||
"rm -fr / --no-preserve-root",
|
||||
"sudo rm -rf /*",
|
||||
"mkfs.ext4 /dev/sda1",
|
||||
"dd if=/dev/zero of=/dev/sda",
|
||||
"echo pwned > /dev/sda",
|
||||
"shutdown -h now",
|
||||
"reboot",
|
||||
":(){ :|:& };:",
|
||||
"format c:",
|
||||
"rd /s /q C:\\",
|
||||
} {
|
||||
if dangerousCommand(cmd) == "" {
|
||||
t.Errorf("aurait dû être bloquée : %q", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// … et laisser passer le travail normal, y compris les rm ciblés.
|
||||
func TestCommandesNormalesAutorisees(t *testing.T) {
|
||||
for _, cmd := range []string{
|
||||
"ls -la",
|
||||
"go test ./...",
|
||||
"rm -rf node_modules",
|
||||
"rm -rf ./dist",
|
||||
"git rm --cached fichier.txt",
|
||||
"grep -r 'reboot the router' docs/",
|
||||
"npm run build",
|
||||
"dd if=disque.img of=copie.img",
|
||||
"echo bonjour > notes.txt",
|
||||
} {
|
||||
if r := dangerousCommand(cmd); r != "" {
|
||||
t.Errorf("bloquée à tort (%s) : %q", r, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// En mode code, les chemins sont bornés au dossier de la discussion :
|
||||
// dedans OK, dehors refusé, y compris par remontée de ../.
|
||||
func TestCodePathBorne(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
inside := filepath.Join(ws, "src", "main.go")
|
||||
if !codePathAllowed(inside) {
|
||||
t.Error("chemin intérieur refusé")
|
||||
}
|
||||
if !codePathAllowed(filepath.Join(ws, "nouveau.txt")) {
|
||||
t.Error("fichier à créer (inexistant) refusé")
|
||||
}
|
||||
outside := filepath.Join(filepath.Dir(ws), "ailleurs.txt")
|
||||
if codePathAllowed(outside) {
|
||||
t.Error("chemin extérieur accepté")
|
||||
}
|
||||
if codePathAllowed(filepath.Join(ws, "..", "..", "etc", "passwd")) {
|
||||
t.Error("remontée ../ acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
// codeWriteGuard : inactif en mode chat, actif en mode code (borne + tracker).
|
||||
func TestCodeWriteGuard(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
chat := Caps{Agent: true}
|
||||
code := Caps{Agent: true, Code: true}
|
||||
|
||||
// Mode chat : aucune garde, même hors workspace.
|
||||
if msg := codeWriteGuard(chat, "/tmp/hors-discussion.txt", true); msg != "" {
|
||||
t.Errorf("mode chat gardé à tort : %s", msg)
|
||||
}
|
||||
// Mode code : hors workspace refusé.
|
||||
if msg := codeWriteGuard(code, filepath.Join(filepath.Dir(ws), "x.txt"), true); msg == "" {
|
||||
t.Error("écriture hors workspace acceptée en mode code")
|
||||
}
|
||||
// Fichier existant jamais lu : write refusé (tracker).
|
||||
target := filepath.Join(ws, "present.txt")
|
||||
if err := os.WriteFile(target, []byte("v1"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := codeWriteGuard(code, "present.txt", true); !strings.Contains(msg, "pas lu") {
|
||||
t.Errorf("écrasement sans lecture accepté : %q", msg)
|
||||
}
|
||||
// Après lecture : autorisé.
|
||||
trackerNoteRead(target)
|
||||
if msg := codeWriteGuard(code, "present.txt", true); msg != "" {
|
||||
t.Errorf("refusé après lecture : %s", msg)
|
||||
}
|
||||
// Création d'un fichier neuf : pas de lecture préalable exigée.
|
||||
if msg := codeWriteGuard(code, "neuf.txt", true); msg != "" {
|
||||
t.Errorf("création refusée : %s", msg)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package loki
|
||||
|
||||
// code_retry.go — filets « auto-retry » (repris des auto-retry patterns
|
||||
// d'OpenFox, réécrits en Go — voir NOTICE.md) : quand le modèle termine son
|
||||
// tour sur un texte qui contient un APPEL D'OUTIL TEXTUEL (halluciné, donc
|
||||
// jamais exécuté), on relance le tour UNE fois avec une consigne corrective au
|
||||
// lieu de laisser l'utilisateur lire un pseudo-JSON d'appel d'outil.
|
||||
//
|
||||
// Typique des petits modèles très quantifiés : `default_api:bash`,
|
||||
// `<tool_call>{...}</tool_call>`, un bloc ```tool_code```… Le modèle VOULAIT
|
||||
// agir ; il a juste raté la syntaxe du protocole. Le nudge « pensé sans agir »
|
||||
// existant ne les attrapait pas : le tour a bien produit du contenu.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
|
||||
// serrés — un faux positif relancerait un tour parfaitement bon.
|
||||
var defaultRetryPatterns = []string{
|
||||
`<tool_call>`,
|
||||
`</tool_call>`,
|
||||
`<\|tool_call\|>`,
|
||||
"```tool_code",
|
||||
"```tool_call",
|
||||
`\bdefault_api[.:]\w+`,
|
||||
`\bfunctions\.\w+\s*\(`,
|
||||
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
|
||||
`\[TOOL_REQUEST\]`,
|
||||
}
|
||||
|
||||
// Compilés à la première utilisation, pas à l'init du package : la surcharge
|
||||
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
|
||||
// choisi son rôle (CLI, serveur…).
|
||||
var (
|
||||
retryOnce sync.Once
|
||||
retryRes []*regexp.Regexp
|
||||
)
|
||||
|
||||
func retryPatterns() []*regexp.Regexp {
|
||||
retryOnce.Do(func() { retryRes = compileRetryPatterns() })
|
||||
return retryRes
|
||||
}
|
||||
|
||||
func compileRetryPatterns() []*regexp.Regexp {
|
||||
pats := defaultRetryPatterns
|
||||
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
|
||||
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
|
||||
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
|
||||
if raw := getStr(bkState, "retry_patterns"); raw != "" {
|
||||
var custom []string
|
||||
if json.Unmarshal([]byte(raw), &custom) == nil && len(custom) > 0 {
|
||||
pats = custom
|
||||
}
|
||||
}
|
||||
var out []*regexp.Regexp
|
||||
for _, p := range pats {
|
||||
if re, err := regexp.Compile("(?mi)" + p); err == nil {
|
||||
out = append(out, re)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// textualToolCall dit si le texte final du tour contient un appel d'outil
|
||||
// écrit en toutes lettres au lieu d'être émis par le protocole.
|
||||
func textualToolCall(content string) bool {
|
||||
if content == "" {
|
||||
return false
|
||||
}
|
||||
for _, re := range retryPatterns() {
|
||||
if re.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// retryCorrective : le message réinjecté pour relancer le tour.
|
||||
const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " +
|
||||
"Tool calls must go through the tool-call protocol, never in the answer text. " +
|
||||
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
|
||||
@@ -0,0 +1,34 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Les appels d'outils écrits en texte doivent être détectés…
|
||||
func TestAppelOutilTextuelDetecte(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"<tool_call>{\"name\":\"bash\"}</tool_call>",
|
||||
"Je vais lancer default_api:bash pour vérifier.",
|
||||
"default_api.run_shell(command='ls')",
|
||||
"functions.bash({\"command\": \"ls\"})",
|
||||
"```tool_code\nprint('x')\n```",
|
||||
"{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}",
|
||||
} {
|
||||
if !textualToolCall(s) {
|
||||
t.Errorf("non détecté : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// … sans jamais relancer un tour normal (code cité, JSON quelconque).
|
||||
func TestReponseNormaleNonRelancee(t *testing.T) {
|
||||
for _, s := range []string{
|
||||
"Voici la réponse : le fichier contient trois fonctions.",
|
||||
"```go\nfunc main() {}\n```",
|
||||
"Le JSON de config : {\"name\": \"loki\", \"port\": 8090}",
|
||||
"La fonction bash() de ce script accepte un paramètre.",
|
||||
"",
|
||||
} {
|
||||
if textualToolCall(s) {
|
||||
t.Errorf("relance à tort : %q", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package loki
|
||||
|
||||
// code_roles.go — registre des rôles du mode code. Chaque rôle est un prompt
|
||||
// Markdown EMBARQUÉ dans le binaire (agents/*.md), comme le catalogue MCP :
|
||||
// pas d'appel réseau, pour en changer on édite le fichier et on recompile.
|
||||
// Démarche reprise du registre d'agents d'OpenFox (builder/planner/explorer/
|
||||
// verifier/code-reviewer — voir NOTICE.md), prompts réécrits COURTS : un
|
||||
// préambule verbeux fait sur-raisonner les modèles locaux (voir le commentaire
|
||||
// de baseSystemPrompt).
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed agents/*.md
|
||||
var rolesFS embed.FS
|
||||
|
||||
// roleNames : les rôles connus. builder et verifier sont pilotés par la boucle
|
||||
// du mode code (code_verify.go) ; planner s'active quand l'utilisateur demande
|
||||
// un plan ; explorer et code-reviewer sont disponibles pour des passes
|
||||
// manuelles futures.
|
||||
var roleNames = []string{"builder", "planner", "explorer", "verifier", "code-reviewer"}
|
||||
|
||||
// rolePrompt renvoie le prompt d'un rôle, "" si inconnu.
|
||||
func rolePrompt(name string) string {
|
||||
b, err := rolesFS.ReadFile("agents/" + name + ".md")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// planIntentRe-like : l'utilisateur demande-t-il un PLAN plutôt qu'une
|
||||
// implémentation ? Heuristique simple sur le début du message.
|
||||
func wantsPlan(text string) bool {
|
||||
low := strings.ToLower(text)
|
||||
for _, kw := range []string{"fais un plan", "fait un plan", "propose un plan", "planifie", "plan d'implémentation", "plan d'action", "make a plan", "plan first"} {
|
||||
if strings.Contains(low, kw) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
package loki
|
||||
|
||||
// code_tools.go — outils fichiers du MODE CODE : read, grep, glob, ask.
|
||||
// Inspirés des outils d'OpenFox (read/grep/ask — voir NOTICE.md), réécrits en
|
||||
// Go. Sans eux, le modèle lit les fichiers au `cat` via bash : sortie non
|
||||
// bornée, pas de numéros de ligne, et aucun moyen de savoir ce qu'il a lu
|
||||
// (voir code_tracker.go).
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
readMaxLines = 1000 // lignes par appel (offset/limit pour la suite)
|
||||
readDefLines = 400
|
||||
readMaxLineLen = 500 // une ligne minifiée ne doit pas manger le contexte
|
||||
grepMaxHits = 60
|
||||
globMaxHits = 200
|
||||
)
|
||||
|
||||
// skipDirs : dossiers ignorés par grep/glob — dépendances et artefacts, jamais
|
||||
// du code à lire. `.git` compris : son contenu binaire pollue toute recherche.
|
||||
var skipDirs = map[string]bool{
|
||||
".git": true, "node_modules": true, "vendor": true, "dist": true,
|
||||
"build": true, "target": true, "__pycache__": true, ".venv": true,
|
||||
"venv": true, ".next": true, ".cache": true,
|
||||
}
|
||||
|
||||
func readTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "read",
|
||||
Description: "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"file": map[string]any{"type": "string", "description": "Path (relative to the working folder)"},
|
||||
"offset": map[string]any{"type": "integer", "description": "Start line (default 1)"},
|
||||
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Lines (default %d, max %d)", readDefLines, readMaxLines)},
|
||||
},
|
||||
"required": []string{"file"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func grepTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "grep",
|
||||
Description: "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{"type": "string", "description": "Go regexp"},
|
||||
"path": map[string]any{"type": "string", "description": "File or directory to search (default .)"},
|
||||
"glob": map[string]any{"type": "string", "description": "Only files matching this glob (e.g. *.go)"},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func globTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "glob",
|
||||
Description: "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"pattern": map[string]any{"type": "string", "description": "Glob, e.g. src/**/*.ts"},
|
||||
},
|
||||
"required": []string{"pattern"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func askTool() Tool {
|
||||
return Tool{
|
||||
Type: "function",
|
||||
Function: ToolFunction{
|
||||
Name: "ask",
|
||||
Description: "Ask the user a clarifying question with 2-4 short options, then END your turn. Their answer arrives as the next user message. Use when a decision is genuinely theirs (scope, destructive change, ambiguous requirement) — not for things you can check yourself.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"question": map[string]any{"type": "string", "description": "The question"},
|
||||
"options": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "2-4 short answer options"},
|
||||
},
|
||||
"required": []string{"question"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// toolRead lit un fichier avec numéros de ligne. Enregistre la lecture dans le
|
||||
// tracker (code_tracker.go) — c'est ce qui autorise edit/write ensuite.
|
||||
func toolRead(args map[string]any, codeMode bool) string {
|
||||
file, _ := args["file"].(string)
|
||||
if strings.TrimSpace(file) == "" {
|
||||
return "[erreur] chemin vide"
|
||||
}
|
||||
path := resolveAgentPath(file)
|
||||
if codeMode && !codePathAllowed(path) {
|
||||
return refusedPathResult(file)
|
||||
}
|
||||
offset, limit := 1, readDefLines
|
||||
if v, ok := args["offset"].(float64); ok && int(v) > 0 {
|
||||
offset = int(v)
|
||||
}
|
||||
if v, ok := args["limit"].(float64); ok && int(v) > 0 {
|
||||
limit = int(v)
|
||||
}
|
||||
if limit > readMaxLines {
|
||||
limit = readMaxLines
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "[erreur] " + err.Error()
|
||||
}
|
||||
if isBinary(b) {
|
||||
fi, _ := os.Stat(path)
|
||||
size := int64(len(b))
|
||||
if fi != nil {
|
||||
size = fi.Size()
|
||||
}
|
||||
return fmt.Sprintf("[binaire] %s (%d octets) — pas un fichier texte", file, size)
|
||||
}
|
||||
lines := strings.Split(string(b), "\n")
|
||||
total := len(lines)
|
||||
// Fichier fini par \n : Split laisse une dernière entrée vide, pas une ligne.
|
||||
if total > 0 && lines[total-1] == "" {
|
||||
total--
|
||||
}
|
||||
if offset > total {
|
||||
return fmt.Sprintf("[erreur] offset %d > %d lignes", offset, total)
|
||||
}
|
||||
end := offset - 1 + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
var out strings.Builder
|
||||
for i := offset - 1; i < end; i++ {
|
||||
l := lines[i]
|
||||
if utf8.RuneCountInString(l) > readMaxLineLen {
|
||||
r := []rune(l)
|
||||
l = string(r[:readMaxLineLen]) + "…"
|
||||
}
|
||||
fmt.Fprintf(&out, "%d: %s\n", i+1, l)
|
||||
}
|
||||
if end < total {
|
||||
fmt.Fprintf(&out, "… (%d lignes restantes — relis avec offset=%d)\n", total-end, end+1)
|
||||
}
|
||||
trackerNoteRead(path)
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// toolGrep cherche un motif regexp dans les fichiers.
|
||||
func toolGrep(args map[string]any, codeMode bool) string {
|
||||
pattern, _ := args["pattern"].(string)
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return "[erreur] motif vide"
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return "[erreur] motif invalide : " + err.Error()
|
||||
}
|
||||
root := agentCwd()
|
||||
if p, _ := args["path"].(string); strings.TrimSpace(p) != "" && p != "." {
|
||||
root = resolveAgentPath(p)
|
||||
}
|
||||
if codeMode && !codePathAllowed(root) {
|
||||
return refusedPathResult(root)
|
||||
}
|
||||
globPat, _ := args["glob"].(string)
|
||||
var hits []string
|
||||
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil // dossier illisible : on continue ailleurs
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if globPat != "" {
|
||||
if ok, _ := filepath.Match(globPat, d.Name()); !ok {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if len(hits) >= grepMaxHits {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
b, rerr := os.ReadFile(path)
|
||||
if rerr != nil || isBinary(b) {
|
||||
return nil
|
||||
}
|
||||
rel, rrr := filepath.Rel(root, path)
|
||||
if rrr != nil {
|
||||
rel = path
|
||||
}
|
||||
for i, line := range strings.Split(string(b), "\n") {
|
||||
if re.MatchString(line) {
|
||||
if utf8.RuneCountInString(line) > readMaxLineLen {
|
||||
r := []rune(line)
|
||||
line = string(r[:readMaxLineLen]) + "…"
|
||||
}
|
||||
hits = append(hits, fmt.Sprintf("%s:%d: %s", filepath.ToSlash(rel), i+1, strings.TrimSpace(line)))
|
||||
if len(hits) >= grepMaxHits {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil && len(hits) == 0 {
|
||||
return "[erreur] " + walkErr.Error()
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return "[aucun résultat]"
|
||||
}
|
||||
out := strings.Join(hits, "\n")
|
||||
if len(hits) >= grepMaxHits {
|
||||
out += fmt.Sprintf("\n… (plafond de %d résultats atteint — affine le motif)", grepMaxHits)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// toolGlob liste les fichiers correspondant à un motif, plus récents d'abord.
|
||||
func toolGlob(args map[string]any, codeMode bool) string {
|
||||
pattern, _ := args["pattern"].(string)
|
||||
if strings.TrimSpace(pattern) == "" {
|
||||
return "[erreur] motif vide"
|
||||
}
|
||||
root := agentCwd()
|
||||
re, err := globToRegexp(pattern)
|
||||
if err != nil {
|
||||
return "[erreur] motif invalide : " + err.Error()
|
||||
}
|
||||
type hit struct {
|
||||
rel string
|
||||
mod int64
|
||||
}
|
||||
var hits []hit
|
||||
filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
|
||||
if werr != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
rel, rerr := filepath.Rel(root, path)
|
||||
if rerr != nil {
|
||||
return nil
|
||||
}
|
||||
rel = filepath.ToSlash(rel)
|
||||
if !re.MatchString(rel) {
|
||||
return nil
|
||||
}
|
||||
info, ierr := d.Info()
|
||||
mod := int64(0)
|
||||
if ierr == nil {
|
||||
mod = info.ModTime().UnixNano()
|
||||
}
|
||||
hits = append(hits, hit{rel, mod})
|
||||
return nil
|
||||
})
|
||||
if len(hits) == 0 {
|
||||
return "[aucun fichier]"
|
||||
}
|
||||
sort.Slice(hits, func(i, j int) bool { return hits[i].mod > hits[j].mod })
|
||||
if len(hits) > globMaxHits {
|
||||
hits = hits[:globMaxHits]
|
||||
}
|
||||
var out strings.Builder
|
||||
for _, h := range hits {
|
||||
out.WriteString(h.rel + "\n")
|
||||
}
|
||||
return strings.TrimRight(out.String(), "\n")
|
||||
}
|
||||
|
||||
// globToRegexp convertit un glob (avec **) en expression régulière sur des
|
||||
// chemins en séparateurs '/'. `**` = n'importe quelle profondeur, `*` = tout
|
||||
// sauf '/', `?` = un caractère sauf '/'.
|
||||
func globToRegexp(pattern string) (*regexp.Regexp, error) {
|
||||
pattern = strings.TrimPrefix(filepath.ToSlash(pattern), "./")
|
||||
var b strings.Builder
|
||||
b.WriteString("^")
|
||||
i := 0
|
||||
for i < len(pattern) {
|
||||
c := pattern[i]
|
||||
switch c {
|
||||
case '*':
|
||||
if strings.HasPrefix(pattern[i:], "**/") {
|
||||
b.WriteString(`(?:[^/]+/)*`)
|
||||
i += 3
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(pattern[i:], "**") {
|
||||
b.WriteString(`.*`)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
b.WriteString(`[^/]*`)
|
||||
case '?':
|
||||
b.WriteString(`[^/]`)
|
||||
case '.', '+', '(', ')', '|', '[', ']', '{', '}', '^', '$', '\\':
|
||||
b.WriteString(`\` + string(c))
|
||||
default:
|
||||
b.WriteByte(c)
|
||||
}
|
||||
i++
|
||||
}
|
||||
b.WriteString("$")
|
||||
return regexp.Compile(b.String())
|
||||
}
|
||||
|
||||
// isBinary : heuristique simple — un NUL dans les premiers 8 Kio.
|
||||
func isBinary(b []byte) bool {
|
||||
n := len(b)
|
||||
if n > 8192 {
|
||||
n = 8192
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
if b[i] == 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toolAsk formate la « réponse » de l'outil ask : la question part vers l'UI
|
||||
// via un événement dédié (voir runChat) ; ici on dit au modèle de clore son
|
||||
// tour et d'attendre.
|
||||
func toolAsk(args map[string]any) string {
|
||||
q, _ := args["question"].(string)
|
||||
if strings.TrimSpace(q) == "" {
|
||||
return "[erreur] question vide"
|
||||
}
|
||||
return "[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message."
|
||||
}
|
||||
|
||||
// askOptions extrait les options (chaînes) des arguments de l'outil ask.
|
||||
func askOptions(args map[string]any) []string {
|
||||
raw, _ := args["options"].([]any)
|
||||
var opts []string
|
||||
for _, o := range raw {
|
||||
if s, ok := o.(string); ok && strings.TrimSpace(s) != "" {
|
||||
opts = append(opts, s)
|
||||
}
|
||||
}
|
||||
if len(opts) > 4 {
|
||||
opts = opts[:4]
|
||||
}
|
||||
return opts
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// read numérote depuis 1, respecte offset/limit et annonce le reste.
|
||||
func TestReadOffsetLimit(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
var lines []string
|
||||
for i := 1; i <= 10; i++ {
|
||||
lines = append(lines, "ligne")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ws, "f.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := toolRead(map[string]any{"file": "f.txt", "offset": float64(3), "limit": float64(2)}, true)
|
||||
if !strings.Contains(out, "3: ligne") || !strings.Contains(out, "4: ligne") {
|
||||
t.Fatalf("lignes 3-4 attendues :\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "5: ligne") {
|
||||
t.Fatalf("limite ignorée :\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "restantes") {
|
||||
t.Fatalf("suite non annoncée :\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// read refuse un binaire et le dit, au lieu de déverser du bruit.
|
||||
func TestReadBinaire(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
if err := os.WriteFile(filepath.Join(ws, "bin.dat"), []byte{0x00, 0x01, 0x02}, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out := toolRead(map[string]any{"file": "bin.dat"}, true); !strings.Contains(out, "[binaire]") {
|
||||
t.Fatalf("binaire non détecté : %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// read enregistre la lecture : le tracker doit ensuite autoriser l'écriture.
|
||||
func TestReadNourritLeTracker(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
path := filepath.Join(ws, "suivi.txt")
|
||||
if err := os.WriteFile(path, []byte("contenu"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := trackerCheck(path, true); msg == "" {
|
||||
t.Fatal("écriture acceptée avant lecture")
|
||||
}
|
||||
toolRead(map[string]any{"file": "suivi.txt"}, true)
|
||||
if msg := trackerCheck(path, true); msg != "" {
|
||||
t.Fatalf("écriture refusée après lecture : %s", msg)
|
||||
}
|
||||
// Fichier modifié APRÈS la lecture : refus (lecture périmée).
|
||||
future := time.Now().Add(2 * time.Second)
|
||||
if err := os.Chtimes(path, future, future); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if msg := trackerCheck(path, true); !strings.Contains(msg, "changé") {
|
||||
t.Fatalf("lecture périmée non détectée : %q", msg)
|
||||
}
|
||||
}
|
||||
|
||||
// grep trouve, borne et ignore les dossiers de dépendances.
|
||||
func TestGrep(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "a.go"), "package main\nfunc Cible() {}\n")
|
||||
writeFile(t, filepath.Join(ws, "node_modules", "x.js"), "Cible aussi\n")
|
||||
out := toolGrep(map[string]any{"pattern": "Cible"}, true)
|
||||
if !strings.Contains(out, "a.go:2") {
|
||||
t.Fatalf("correspondance manquée :\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "node_modules") {
|
||||
t.Fatalf("node_modules aurait dû être ignoré :\n%s", out)
|
||||
}
|
||||
if out := toolGrep(map[string]any{"pattern": "IntrouvableXYZ"}, true); !strings.Contains(out, "aucun résultat") {
|
||||
t.Fatalf("absence non signalée : %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// glob : ** traverse les dossiers, tri par modification décroissante.
|
||||
func TestGlob(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
writeFile(t, filepath.Join(ws, "src", "a.ts"), "a")
|
||||
writeFile(t, filepath.Join(ws, "src", "sub", "b.ts"), "b")
|
||||
writeFile(t, filepath.Join(ws, "autre.txt"), "c")
|
||||
out := toolGlob(map[string]any{"pattern": "**/*.ts"}, true)
|
||||
if !strings.Contains(out, "src/a.ts") || !strings.Contains(out, "src/sub/b.ts") {
|
||||
t.Fatalf("fichiers .ts manquants :\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "autre.txt") {
|
||||
t.Fatalf("autre.txt ne matche pas **/*.ts :\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// globToRegexp : cas aux limites du **.
|
||||
func TestGlobToRegexp(t *testing.T) {
|
||||
cases := []struct {
|
||||
pattern, path string
|
||||
want bool
|
||||
}{
|
||||
{"*.go", "main.go", true},
|
||||
{"*.go", "src/main.go", false},
|
||||
{"**/*.go", "src/deep/main.go", true},
|
||||
{"**/*.go", "main.go", true}, // **/ = zéro dossier ou plus
|
||||
{"src/**", "src/a/b/c.txt", true},
|
||||
{"src/*.ts", "src/a.ts", true},
|
||||
{"src/*.ts", "src/sub/a.ts", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
re, err := globToRegexp(c.pattern)
|
||||
if err != nil {
|
||||
t.Fatalf("%s : %v", c.pattern, err)
|
||||
}
|
||||
if got := re.MatchString(c.path); got != c.want {
|
||||
t.Errorf("%s sur %s : %v, attendu %v", c.pattern, c.path, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fileEdit préserve les fins de ligne CRLF d'un fichier quand le modèle
|
||||
// envoie du LF (et n'introduit jamais de mélange).
|
||||
func TestEditPreserveCRLF(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
path := filepath.Join(ws, "w.txt")
|
||||
if err := os.WriteFile(path, []byte("un\r\ndeux\r\ntrois\r\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := fileEdit(path, "deux", "DEUX")
|
||||
if !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("édition refusée : %s", out)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if string(b) != "un\r\nDEUX\r\ntrois\r\n" {
|
||||
t.Fatalf("fins de ligne perdues : %q", b)
|
||||
}
|
||||
}
|
||||
|
||||
// fileEdit multi-lignes en LF sur un fichier CRLF : la normalisation couvre
|
||||
// aussi le old qui contient des sauts de ligne.
|
||||
func TestEditCRLFMultiligne(t *testing.T) {
|
||||
ws := withWorkspace(t)
|
||||
path := filepath.Join(ws, "m.txt")
|
||||
if err := os.WriteFile(path, []byte("a\r\nb\r\nc\r\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := fileEdit(path, "a\nb", "a\nB")
|
||||
if !strings.HasPrefix(out, "[ok]") {
|
||||
t.Fatalf("édition refusée : %s", out)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if string(b) != "a\r\nB\r\nc\r\n" {
|
||||
t.Fatalf("résultat inattendu : %q", b)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package loki
|
||||
|
||||
// code_tracker.go — suivi « lu avant d'écrire » (file-tracker d'OpenFox, réécrit
|
||||
// en Go — voir NOTICE.md). Le modèle doit avoir LU un fichier (outil read) avant
|
||||
// de le modifier (edit) ou de l'écraser (write sur un fichier existant), et sa
|
||||
// lecture doit être plus récente que la dernière modification du fichier sur le
|
||||
// disque. Ça bloque les deux corruptions silencieuses classiques :
|
||||
// - réécrire un fichier jamais ouvert (le modèle « devine » son contenu) ;
|
||||
// - éditer d'après une lecture périmée (le fichier a changé entre-temps —
|
||||
// autre outil, job d'arrière-plan, utilisateur).
|
||||
//
|
||||
// L'état est en mémoire, par discussion : un redémarrage oublie tout, et le
|
||||
// modèle relit — c'est le comportement voulu, pas un bug.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
trackerMu sync.Mutex
|
||||
trackerReads = map[string]time.Time{} // conv + chemin → instant de la lecture
|
||||
)
|
||||
|
||||
func trackerKey(path string) string {
|
||||
return convEnsureActive() + "\x00" + filepath.Clean(path)
|
||||
}
|
||||
|
||||
// trackerNoteRead enregistre qu'un fichier vient d'être lu (outil read).
|
||||
func trackerNoteRead(path string) {
|
||||
trackerMu.Lock()
|
||||
trackerReads[trackerKey(path)] = time.Now()
|
||||
trackerMu.Unlock()
|
||||
}
|
||||
|
||||
// trackerNoteWrite : après une écriture réussie, la version sur disque est celle
|
||||
// que le modèle vient de produire — il la « connaît ». Sans cette note, éditer
|
||||
// un fichier qu'on vient soi-même d'écrire serait refusé.
|
||||
func trackerNoteWrite(path string) {
|
||||
trackerNoteRead(path)
|
||||
}
|
||||
|
||||
// trackerCheck vérifie qu'une modification est sûre. forWrite distingue write
|
||||
// (autorisé sur un fichier INEXISTANT sans lecture préalable — création) de
|
||||
// edit (le fichier doit exister ET avoir été lu).
|
||||
// Renvoie "" si c'est bon, sinon le message de refus pour le modèle.
|
||||
func trackerCheck(path string, forWrite bool) string {
|
||||
fi, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
if forWrite {
|
||||
return "" // création d'un fichier neuf : rien à relire
|
||||
}
|
||||
return "" // edit sur fichier absent : fileEdit renverra sa propre erreur claire
|
||||
}
|
||||
trackerMu.Lock()
|
||||
readAt, seen := trackerReads[trackerKey(path)]
|
||||
trackerMu.Unlock()
|
||||
if !seen {
|
||||
return "[refusé] " + path + " existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
|
||||
}
|
||||
if fi.ModTime().After(readAt) {
|
||||
return "[refusé] " + path + " a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
package loki
|
||||
|
||||
// code_verify.go — la boucle du contrat en mode code : build → VÉRIFICATION →
|
||||
// correction → re-vérification, jusqu'à ce que tous les critères passent ou
|
||||
// que le plafond de cycles soit atteint. Reprise de la boucle
|
||||
// builder/verifier d'OpenFox (voir NOTICE.md), adaptée au fil unique de Loki :
|
||||
//
|
||||
// - la passe de vérification tourne sur un CONTEXTE ISOLÉ (prompt du rôle +
|
||||
// critères + tâche), pas sur l'historique complet — même modèle, mais il
|
||||
// n'a pas « écrit » le code sous les yeux, et surtout ça ne gonfle pas le
|
||||
// contexte de la discussion ;
|
||||
// - seule cette passe a le droit de marquer un critère `passed`
|
||||
// (toolCriteria, allowPass) ;
|
||||
// - les corrections, elles, repartent dans l'historique NORMAL du fil : le
|
||||
// builder doit se souvenir de ce qu'il a fait.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// codeMaxFixTurns : nombre de tours de CORRECTION après échec de vérification.
|
||||
// Au-delà, on s'arrête et on dit ce qui manque — un modèle qui n'y arrive pas
|
||||
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
|
||||
const codeMaxFixTurns = 2
|
||||
|
||||
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
|
||||
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
|
||||
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
convID := convEnsureActive()
|
||||
list := critList(convID)
|
||||
if len(list) == 0 || critAllPassed(list) {
|
||||
return
|
||||
}
|
||||
// En phase de PLAN, on ne vérifie pas : rien n'a été construit.
|
||||
if caps.Role == "planner" {
|
||||
return
|
||||
}
|
||||
for fix := 0; ; fix++ {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
c.runVerifyPass(ctx, caps, temperature, epoch)
|
||||
list = critList(convID)
|
||||
if critAllPassed(list) {
|
||||
c.appendDelta(epoch, map[string]any{"verify_done": "passed"})
|
||||
return
|
||||
}
|
||||
if fix >= codeMaxFixTurns {
|
||||
c.appendDelta(epoch, map[string]any{"verify_done": "gave_up"})
|
||||
// Le fil doit dire où on en est — sinon l'utilisateur voit une
|
||||
// vérification échouée et aucun mot dessus.
|
||||
c.appendDelta(epoch, map[string]any{"content": "\n\n_(vérification : " +
|
||||
fmt.Sprint(critPending(list)) + " critère(s) non satisfaits après " +
|
||||
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
|
||||
return
|
||||
}
|
||||
c.runFixTurn(ctx, caps, temperature, epoch, list)
|
||||
}
|
||||
}
|
||||
|
||||
// runVerifyPass lance la passe de vérification sur un contexte ISOLÉ.
|
||||
func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature float64, epoch int) {
|
||||
vcaps := caps
|
||||
vcaps.Role = "verifier"
|
||||
// Marqueur de rôle pour l'UI : les bulles qui suivent portent le badge.
|
||||
c.appendDelta(epoch, map[string]any{"role": "verifier"})
|
||||
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
||||
|
||||
convID := convEnsureActive()
|
||||
list := critList(convID)
|
||||
task := c.lastUserText()
|
||||
var user strings.Builder
|
||||
user.WriteString("Task:\n" + task + "\n\nAcceptance criteria:\n" + critRender(list))
|
||||
user.WriteString("\n\nVerify each non-passed criterion now (git_diff first), and record every verdict with the criteria tool.")
|
||||
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: rolePrompt("verifier") + "\n\n" + machineSystemPrompt(vcaps)},
|
||||
{Role: "user", Content: user.String()},
|
||||
}
|
||||
// Trace ISOLÉE : ni persistée dans c.Messages, ni compactée — elle vit et
|
||||
// meurt dans cette passe. Seuls les deltas d'affichage sortent.
|
||||
_, _ = runChat(ctx, msgs, temperature, vcaps, func(ev StreamEvent) bool {
|
||||
c.forwardStream(ev, epoch)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
|
||||
// critères en échec. Sa trace est persistée comme un tour ordinaire.
|
||||
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
|
||||
var failed []Criterion
|
||||
for _, cr := range list {
|
||||
if cr.Status != "passed" {
|
||||
failed = append(failed, cr)
|
||||
}
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"role": "builder"})
|
||||
defer c.appendDelta(epoch, map[string]any{"role": ""})
|
||||
|
||||
fixMsg := Message{Role: "user", Content: "Verification failed on these criteria:\n" + critRender(failed) +
|
||||
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed."}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.epoch != epoch {
|
||||
c.mu.Unlock()
|
||||
return
|
||||
}
|
||||
c.Messages = append(c.Messages, fixMsg)
|
||||
msgs := append([]Message(nil), c.Messages...)
|
||||
c.mu.Unlock()
|
||||
|
||||
final := msgs
|
||||
if sp := readSysPrompt(); sp != "" {
|
||||
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
|
||||
}
|
||||
var content strings.Builder
|
||||
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
|
||||
if ev.Content != "" {
|
||||
content.WriteString(ev.Content)
|
||||
}
|
||||
c.forwardStream(ev, epoch)
|
||||
return true
|
||||
})
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.Messages = append(c.Messages, extra...)
|
||||
if s := content.String(); strings.TrimSpace(s) != "" {
|
||||
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
|
||||
}
|
||||
}
|
||||
c.mu.Unlock()
|
||||
c.persist()
|
||||
}
|
||||
|
||||
// forwardStream relaie les événements d'un runChat secondaire (vérification,
|
||||
// correction) vers le journal d'affichage — même mapping que generate(), sans
|
||||
// la gestion de compaction (ces passes n'en déclenchent pas : contexte court).
|
||||
func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
|
||||
case ev.ToolUsed != nil:
|
||||
tu := map[string]any{
|
||||
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
|
||||
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
|
||||
}
|
||||
if ev.ToolUsed.Body != "" {
|
||||
tu["body"] = ev.ToolUsed.Body
|
||||
}
|
||||
if len(ev.ToolUsed.Diff) > 0 {
|
||||
tu["diff"] = ev.ToolUsed.Diff
|
||||
}
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"tool_used": tu})
|
||||
case ev.Ask != nil:
|
||||
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
|
||||
case ev.Stats != nil:
|
||||
if ev.Stats.PromptTokensTotal > 0 {
|
||||
c.mu.Lock()
|
||||
if c.epoch == epoch {
|
||||
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
|
||||
}
|
||||
c.mu.Unlock()
|
||||
}
|
||||
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
|
||||
case ev.DropReasoning:
|
||||
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
|
||||
case ev.Reasoning != "":
|
||||
c.appendDelta(epoch, map[string]any{"reasoning_content": ev.Reasoning})
|
||||
case ev.Content != "":
|
||||
c.appendDelta(epoch, map[string]any{"content": ev.Content})
|
||||
}
|
||||
}
|
||||
|
||||
// lastUserText retrouve le dernier message utilisateur du fil (la tâche).
|
||||
func (c *Conversation) lastUserText() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for i := len(c.Messages) - 1; i >= 0; i-- {
|
||||
if c.Messages[i].Role != "user" {
|
||||
continue
|
||||
}
|
||||
if s, ok := c.Messages[i].Content.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return "(tâche non retrouvée — vérifie d'après le diff)"
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package loki
|
||||
|
||||
// llm_budget.go — budget SOUPLE d'appels d'outils par tour.
|
||||
//
|
||||
// Le plafond dur (TOOL_LIMIT) et l'anti-boucle ont été retirés en v0.6.3 : ils
|
||||
// coupaient des tours parfaitement légitimes, une recherche enchaînant sans
|
||||
// problème des dizaines d'appels. Mais il ne restait plus RIEN entre « le
|
||||
// modèle converge » et « le modèle tourne en rond pendant une heure » — vu en
|
||||
// production : ~50 appels d'outil, 55 minutes, le modèle relisant cinq fois les
|
||||
// mêmes fonctions pour re-dériver une conclusion qu'il avait déjà écrite.
|
||||
//
|
||||
// La déduplication d'appels identiques (repeatedCallResult) ne rattrape pas ce
|
||||
// cas : sa clé est « nom + arguments bruts », et relire le même fichier par
|
||||
// tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés
|
||||
// différentes. Élargir cette clé à la CIBLE serait pire que le mal — deux
|
||||
// tranches d'un même fichier renvoient un contenu différent, les confondre
|
||||
// casserait toute lecture paginée légitime.
|
||||
//
|
||||
// D'où un budget qui ne coupe rien : au-delà d'un palier, on RAPPELLE au modèle
|
||||
// combien d'appels il a déjà faits et on lui demande de conclure. Le tour reste
|
||||
// entièrement sous son contrôle — c'est de la pression, pas une barrière.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// agentBudgetDefault : nombre d'appels d'outils au-delà duquel un tour cesse
|
||||
// d'être une recherche et commence à ressembler à du sur-place. Assez haut pour
|
||||
// qu'une vraie enquête (lire dix fichiers, croiser trois pages web) passe sans
|
||||
// jamais voir le rappel.
|
||||
const agentBudgetDefault = 24
|
||||
|
||||
// agentBudget lit le palier dans config.env (AGENT_BUDGET). 0 — ou une valeur
|
||||
// off/false/no/non — désactive complètement le mécanisme.
|
||||
func agentBudget() int {
|
||||
v := strings.ToLower(strings.TrimSpace(ReadConfig()["AGENT_BUDGET"]))
|
||||
switch v {
|
||||
case "":
|
||||
return agentBudgetDefault
|
||||
case "off", "false", "no", "non", "disable", "disabled":
|
||||
return 0
|
||||
}
|
||||
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
||||
return n
|
||||
}
|
||||
return agentBudgetDefault
|
||||
}
|
||||
|
||||
// budgetNudge renvoie le rappel à injecter quand le tour vient de franchir un
|
||||
// palier, ou "" s'il n'y a rien à dire.
|
||||
//
|
||||
// calls : appels d'outils exécutés depuis le début du tour
|
||||
// budget : taille d'un palier (0 = mécanisme désactivé)
|
||||
// sent : rappels déjà envoyés dans ce tour
|
||||
//
|
||||
// Les paliers sont à budget, 2×budget, 3×budget… : la pression revient donc
|
||||
// régulièrement au lieu de s'éteindre après un avertissement ignoré. Le coût en
|
||||
// contexte est négligeable (une phrase toutes les 24 requêtes) et le message est
|
||||
// AJOUTÉ EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté
|
||||
// llama-server.
|
||||
//
|
||||
// Le texte est en anglais comme le reste du protocole d'outils : c'est la langue
|
||||
// dans laquelle les modèles suivent le mieux une consigne impérative, quelle que
|
||||
// soit celle de la conversation.
|
||||
func budgetNudge(calls, budget, sent int) string {
|
||||
if budget <= 0 || calls < budget*(sent+1) {
|
||||
return ""
|
||||
}
|
||||
n := fmt.Sprintf("%d tool calls", calls)
|
||||
switch sent {
|
||||
case 0:
|
||||
return "[system] You have made " + n + " in this single turn. That is a lot: you almost certainly have enough to answer already. " +
|
||||
"Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing."
|
||||
case 1:
|
||||
return "[system] " + n + " now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. " +
|
||||
"Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further."
|
||||
default:
|
||||
return "[system] " + n + ". Stop. Write your final answer in this message. Do not call another tool."
|
||||
}
|
||||
}
|
||||
|
||||
// logBudget trace UNE ligne par rappel sur la sortie d'erreur (donc dans
|
||||
// `journalctl -u loki-ui`), comme logCompact. Sans ça, un tour qui part en
|
||||
// vrille est invisible côté serveur : on ne voit qu'une génération qui dure.
|
||||
func logBudget(calls, budget, sent int) {
|
||||
fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n",
|
||||
calls, budget, sent)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Le budget ne se déclenche pas avant son palier, et il revient à chaque
|
||||
// palier suivant : un modèle qui ignore le premier rappel doit en recevoir un
|
||||
// second, plus ferme.
|
||||
func TestBudgetNudgePaliers(t *testing.T) {
|
||||
const budget = 24
|
||||
cases := []struct {
|
||||
calls, sent int
|
||||
want bool
|
||||
}{
|
||||
{0, 0, false},
|
||||
{1, 0, false},
|
||||
{23, 0, false},
|
||||
{24, 0, true}, // 1er palier
|
||||
{40, 1, false}, // rappel déjà envoyé, palier suivant pas atteint
|
||||
{48, 1, true}, // 2e palier
|
||||
{72, 2, true}, // 3e palier
|
||||
{95, 3, false},
|
||||
{96, 3, true}, // 4e palier : la pression ne s'éteint pas
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := budgetNudge(c.calls, budget, c.sent) != ""
|
||||
if got != c.want {
|
||||
t.Errorf("budgetNudge(calls=%d, sent=%d) = %v, attendu %v", c.calls, c.sent, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le message durcit à chaque rappel : trois textes distincts, tous porteurs du
|
||||
// nombre d'appels (c'est LUI qui informe le modèle, pas le ton).
|
||||
func TestBudgetNudgeEscalade(t *testing.T) {
|
||||
const budget = 10
|
||||
seen := map[string]bool{}
|
||||
for sent := 0; sent < 3; sent++ {
|
||||
m := budgetNudge(budget*(sent+1), budget, sent)
|
||||
if m == "" {
|
||||
t.Fatalf("rappel n°%d vide", sent+1)
|
||||
}
|
||||
if seen[m] {
|
||||
t.Errorf("rappel n°%d identique à un précédent : %q", sent+1, m)
|
||||
}
|
||||
seen[m] = true
|
||||
}
|
||||
// Au-delà, on réutilise le texte le plus ferme plutôt que d'en inventer un
|
||||
// nouveau à chaque palier (à nombre d'appels égal, le message est le même).
|
||||
if a, b := budgetNudge(50, budget, 3), budgetNudge(50, budget, 4); a != b {
|
||||
t.Errorf("les rappels tardifs devraient partager le même texte :\n%q\n%q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// budget=0 (AGENT_BUDGET=off) : plus aucun rappel, quel que soit le nombre
|
||||
// d'appels — le mécanisme doit pouvoir être totalement coupé.
|
||||
func TestBudgetNudgeDesactive(t *testing.T) {
|
||||
for _, calls := range []int{0, 24, 500, 10000} {
|
||||
if m := budgetNudge(calls, 0, 0); m != "" {
|
||||
t.Errorf("budget désactivé mais rappel émis à %d appels : %q", calls, m)
|
||||
}
|
||||
}
|
||||
if m := budgetNudge(100, -5, 0); m != "" {
|
||||
t.Errorf("budget négatif devrait être inerte, obtenu %q", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentBudgetConfig(t *testing.T) {
|
||||
testHome(t)
|
||||
set := func(v string) {
|
||||
cfg := ReadConfig()
|
||||
cfg["AGENT_BUDGET"] = v
|
||||
if err := WriteConfig(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
val string
|
||||
want int
|
||||
}{
|
||||
{"", agentBudgetDefault},
|
||||
{"40", 40},
|
||||
{"0", 0},
|
||||
{"off", 0},
|
||||
{"NON", 0},
|
||||
{"n'importe quoi", agentBudgetDefault}, // valeur illisible : on ne l'invente pas
|
||||
{"-3", agentBudgetDefault},
|
||||
} {
|
||||
set(c.val)
|
||||
if got := agentBudget(); got != c.want {
|
||||
t.Errorf("AGENT_BUDGET=%q → %d, attendu %d", c.val, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+235
-18
@@ -194,6 +194,14 @@ type Caps struct {
|
||||
// Mem = mode d'accès à la mémoire persistante (off / ondemand / always),
|
||||
// indépendant du mode agent. Voir MemMode.
|
||||
Mem MemMode
|
||||
// Code = mode code actif pour cette discussion (sélecteur Chat/Code) :
|
||||
// ajoute les outils codeur (read/grep/glob, git, critères, jobs) et le
|
||||
// prompt de rôle. Requiert Agent.
|
||||
Code bool
|
||||
// Role = rôle du tour en mode code : "" ou "builder" (défaut), "planner",
|
||||
// "verifier" (passe de vérification, seule autorisée à marquer un critère
|
||||
// passed), "explorer", "code-reviewer". Voir code_roles.go.
|
||||
Role string
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
@@ -261,12 +269,71 @@ func steerSystem(msgs []Message, hint string) []Message {
|
||||
return append([]Message{{Role: "system", Content: hint}}, msgs...)
|
||||
}
|
||||
|
||||
// normalizeSystemMessages garantit un UNIQUE message système, en tête, dans la
|
||||
// séquence ENVOYÉE au moteur. Repris de l'amont AJEAN (v0.9.8, issue #26).
|
||||
//
|
||||
// steerSystem (ci-dessus) empêche déjà loki d'en poser un en cours de route, mais
|
||||
// il ne couvre que SES propres consignes : un historique venu d'ailleurs (import,
|
||||
// preset, conversation migrée, agent de code qui compose sa propre séquence) peut
|
||||
// encore porter deux systèmes, ou un système ailleurs qu'en position 0. Les
|
||||
// gabarits stricts — Qwen3.x en --jinja — répondent alors « System message must
|
||||
// be at the beginning », un 500 qui tue le tour sans rien expliquer.
|
||||
//
|
||||
// La séquence renvoyée est une COPIE : l'historique d'origine garde sa forme pour
|
||||
// l'affichage, la persistance et la compaction. Un modèle qui accepte le système
|
||||
// n'importe où n'est pas gêné de le recevoir en tête — la normalisation est donc
|
||||
// sans risque pour tous, et sans effet sur une conversation déjà normale (payload
|
||||
// identique, garanti par test).
|
||||
func normalizeSystemMessages(msgs []Message) []Message {
|
||||
var sys []string
|
||||
sawSystem := false
|
||||
rest := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
if s, ok := m.Content.(string); ok {
|
||||
sawSystem = true
|
||||
if strings.TrimSpace(s) != "" {
|
||||
sys = append(sys, s)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Contenu système non textuel (cas théorique) : on le préserve tel quel
|
||||
// plutôt que de le perdre.
|
||||
}
|
||||
rest = append(rest, m)
|
||||
}
|
||||
// Aucun message système : rien à réordonner, on rend l'entrée telle quelle —
|
||||
// pas de copie inutile sur le chemin le plus fréquent.
|
||||
if !sawSystem {
|
||||
return msgs
|
||||
}
|
||||
// Des systèmes existaient mais tous vides : on les a retirés (un système vide
|
||||
// hors position 0 casserait tout autant), sans en réinsérer.
|
||||
if len(sys) == 0 {
|
||||
return rest
|
||||
}
|
||||
out := make([]Message, 0, len(rest)+1)
|
||||
out = append(out, Message{Role: "system", Content: strings.Join(sys, "\n\n")})
|
||||
return append(out, rest...)
|
||||
}
|
||||
|
||||
// EnabledTools returns the tools to advertise on the next inference call.
|
||||
func EnabledTools(caps Caps) []Tool {
|
||||
tools := []Tool{}
|
||||
// Passe de VÉRIFICATION du mode code : jeu d'outils fermé, en lecture
|
||||
// seule + bash (lancer les tests) + critères. Ni write/edit (elle ne
|
||||
// corrige pas), ni mémoire ni web (hors sujet, et chaque schéma coûte du
|
||||
// contexte).
|
||||
if caps.Code && caps.Role == "verifier" {
|
||||
return []Tool{bashTool(), readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool(), criteriaTool()}
|
||||
}
|
||||
if caps.Agent {
|
||||
tools = append(tools, bashTool(), writeTool(), editTool())
|
||||
}
|
||||
if caps.Code {
|
||||
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
|
||||
bashBgTool(), bashTailTool(), gitStatusTool(), gitDiffTool(), gitCloneTool(), criteriaTool())
|
||||
}
|
||||
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
|
||||
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
|
||||
if caps.Mem != MemOff {
|
||||
@@ -277,12 +344,16 @@ func EnabledTools(caps Caps) []Tool {
|
||||
// donc prompt et outils restent cohérents — pas de web_search halluciné.
|
||||
if caps.Agent && caps.Internet {
|
||||
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
|
||||
// Capture d'écran : seulement si Playwright est réellement présent dans
|
||||
// l'image. Annoncer un outil absent enverrait le modèle en boucle de
|
||||
// réessai sur un échec systématique.
|
||||
if screenshotAvailable() {
|
||||
tools = append(tools, webScreenshotTool())
|
||||
}
|
||||
}
|
||||
// Capture d'écran : seulement si Playwright est réellement présent dans
|
||||
// l'image (annoncer un outil absent enverrait le modèle en boucle de
|
||||
// réessai). Offerte aussi SANS accès internet quand le mode code est actif :
|
||||
// photographier son propre serveur de dev est une vérification locale, pas
|
||||
// une sortie sur le web — et sans elle, le modèle croyait n'avoir aucun
|
||||
// navigateur et perdait des minutes à installer puppeteer. La borne aux
|
||||
// adresses locales vit dans toolWebScreenshot.
|
||||
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
|
||||
tools = append(tools, webScreenshotTool())
|
||||
}
|
||||
// Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
|
||||
// Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
|
||||
@@ -305,6 +376,7 @@ type StreamEvent struct {
|
||||
Reasoning string
|
||||
ToolUsed *ToolUsedEvent
|
||||
Stats *StatsEvent
|
||||
Ask *AskEvent
|
||||
Err error
|
||||
// DropReasoning demande à l'UI de retirer la dernière bulle de raisonnement :
|
||||
// le modèle a « pensé sans agir » et on relance le tour, ce raisonnement-là
|
||||
@@ -324,6 +396,14 @@ type StreamEvent struct {
|
||||
// ajouter.
|
||||
NewHistory []Message
|
||||
}
|
||||
|
||||
// AskEvent : l'outil ask — question structurée posée à l'utilisateur, rendue
|
||||
// par l'UI comme une carte à boutons. Le tour se termine juste après.
|
||||
type AskEvent struct {
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type ToolUsedEvent struct {
|
||||
Name string
|
||||
Label string // user-visible summary (skill name or the command)
|
||||
@@ -338,6 +418,12 @@ type ToolUsedEvent struct {
|
||||
// Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose
|
||||
// (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-).
|
||||
Diff []DiffLine
|
||||
// Image : chemin (relatif au dossier de travail) d'une image PRODUITE par
|
||||
// l'outil — aujourd'hui la capture de web_screenshot. L'UI l'affiche dans la
|
||||
// bulle de l'outil. Sans ça, la capture n'apparaissait QUE si le modèle
|
||||
// pensait à recopier la ligne markdown rendue par l'outil : un petit modèle
|
||||
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
|
||||
Image string
|
||||
}
|
||||
|
||||
// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI.
|
||||
@@ -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,14 +700,28 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
|
||||
// explicite au lieu d'afficher « pas de réponse ».
|
||||
nudged := false
|
||||
// Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours
|
||||
// parfaitement légitimes (une recherche enchaîne facilement des dizaines
|
||||
// d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule
|
||||
// le contexte — c'est un choix assumé.
|
||||
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
|
||||
// relance par tour, comme le nudge.
|
||||
patternRetried := false
|
||||
// Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond
|
||||
// d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà
|
||||
// d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui
|
||||
// demande de conclure — de la pression, pas une barrière. Sans ça, un modèle
|
||||
// qui tourne en rond n'avait rien en face de lui sauf le bouton stop.
|
||||
toolRuns, budgetNudges, budget := 0, 0, agentBudget()
|
||||
for iter := 0; ; iter++ {
|
||||
// Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté
|
||||
// llama-server reste valide, seul le nouveau message est à traiter.
|
||||
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
|
||||
budgetNudges++
|
||||
logBudget(toolRuns, budget, budgetNudges)
|
||||
messages = append(messages, Message{Role: "user", Content: msg})
|
||||
}
|
||||
payload := map[string]any{
|
||||
"model": "loki",
|
||||
"messages": messages,
|
||||
"model": "loki",
|
||||
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
|
||||
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
|
||||
"messages": normalizeSystemMessages(messages),
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
|
||||
@@ -615,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
|
||||
@@ -651,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.
|
||||
@@ -782,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
|
||||
@@ -946,6 +1095,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
|
||||
if ctx.Err() != nil {
|
||||
return extra, nil
|
||||
}
|
||||
toolRuns++ // alimente le budget souple (voir budgetNudge)
|
||||
var args map[string]any
|
||||
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
|
||||
// Derive the human label (command / skill name) up front so we can
|
||||
@@ -955,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":
|
||||
@@ -1045,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":
|
||||
@@ -1056,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) {
|
||||
@@ -1071,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
|
||||
@@ -1088,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)
|
||||
@@ -1099,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)
|
||||
@@ -1146,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)
|
||||
}
|
||||
}
|
||||
+2273
-309
File diff suppressed because it is too large.
Load diff
@@ -62,8 +62,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div id="conv-list" class="slist"></div>
|
||||
</details>
|
||||
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
|
||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
|
||||
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
|
||||
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
|
||||
OU par son propre chevron — c'est une section comme les autres, sinon on ne
|
||||
trouvait pas comment la refermer.
|
||||
@@ -75,9 +75,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,131 +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)">
|
||||
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
|
||||
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
|
||||
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
|
||||
</button>
|
||||
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
|
||||
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
|
||||
ce qui sort. -->
|
||||
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
</button>
|
||||
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
|
||||
reste porté par aria-label et l'infobulle — l'action garde son nom
|
||||
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
|
||||
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
|
||||
</button>
|
||||
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
|
||||
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
|
||||
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
|
||||
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
|
||||
chargement de page pour un panneau fermé serait payé par tout le monde et
|
||||
utile à personne. -->
|
||||
<div id="files-backdrop" onclick="toggleFiles()"></div>
|
||||
<aside id="files" aria-label="Fichiers de cette discussion">
|
||||
<div class="files-head">
|
||||
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
|
||||
complet reste porté par l'aria-label de la section. -->
|
||||
<span class="stitle">Fichiers</span>
|
||||
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
|
||||
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
|
||||
captures), il faut pouvoir y descendre et en remonter. -->
|
||||
<div id="files-crumb" class="files-crumb"></div>
|
||||
<div id="files-list" class="files-list"></div>
|
||||
<div id="files-foot" class="files-foot muted"></div>
|
||||
</aside>
|
||||
<div id="toast"></div>
|
||||
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
|
||||
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<strong>Benchmark</strong>
|
||||
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
|
||||
</div>
|
||||
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
|
||||
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
|
||||
<button onclick="closeBenchModal()">fermer</button>
|
||||
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
|
||||
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
|
||||
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
|
||||
mêmes pour les deux formats : le format ne décide que du contenant. -->
|
||||
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
|
||||
<div class="modal-card" style="max-width:460px">
|
||||
<div class="modal-head">
|
||||
@@ -653,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">
|
||||
@@ -727,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>
|
||||
|
||||
@@ -739,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">
|
||||
@@ -842,7 +1050,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<div class="pe-row" id="s-reasoning-effort-row">
|
||||
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">envoyée au gabarit du modèle — auto = ne rien imposer</span></span>
|
||||
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">défaut de ce preset — ajustable à la volée depuis la barre de saisie</span></span>
|
||||
<span class="pe-row-c">
|
||||
<select id="s-reasoning-effort" class="pe-val" onchange="cfgWriteKey('REASONING_EFFORT', this.value)">
|
||||
<option value="">auto</option>
|
||||
@@ -850,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>
|
||||
@@ -868,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">
|
||||
|
||||
@@ -26,6 +26,9 @@ const ICONS = {
|
||||
plus: '<path d="M12 5.5v13"/><path d="M5.5 12h13"/>',
|
||||
chevron: '<path d="M9.5 5.5 16 12l-6.5 6.5"/>',
|
||||
search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.4-4.4"/>',
|
||||
// Anneau ouvert : c'est la ROTATION (CSS) qui en fait un indicateur d'activité,
|
||||
// le dessin reste au même trait que le reste du jeu.
|
||||
spinner: '<path d="M12 3.5a8.5 8.5 0 1 1-8.5 8.5"/>',
|
||||
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2"/><path d="M12 19.3v2.2"/><path d="M4.2 4.2l1.6 1.6"/><path d="M18.2 18.2l1.6 1.6"/><path d="M2.5 12h2.2"/><path d="M19.3 12h2.2"/><path d="M4.2 19.8l1.6-1.6"/><path d="M18.2 5.8l1.6-1.6"/>',
|
||||
};
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ async function loadStatus(){
|
||||
let cls='err', txt='arrêté';
|
||||
if(s.active && s.health){ cls='ok'; txt='prêt'; }
|
||||
else if(s.load_error){ cls='err'; txt='erreur'; }
|
||||
else if(s.active){ cls='loading'; txt='chargement…'; }
|
||||
else if(s.active){
|
||||
cls='loading'; txt='chargement…';
|
||||
// Pourcentage estimé (octets du modèle déjà lus) : voir « ça avance »
|
||||
// plutôt qu'un « chargement… » figé qui ressemble à un plantage.
|
||||
if(typeof s.load_pct==='number' && s.load_pct>=0) txt='chargement '+s.load_pct+' %';
|
||||
}
|
||||
el.className='statuspill '+cls;
|
||||
el.innerHTML='<span class="dot"></span>'+txt;
|
||||
MODEL_READY = !!(s.active && s.health);
|
||||
|
||||
@@ -451,7 +451,7 @@ async function loadAll(){
|
||||
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
|
||||
// suppression de module doit donc retirer son appel de cette ligne, et le test
|
||||
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
|
||||
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
|
||||
}
|
||||
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
|
||||
@@ -101,6 +101,7 @@ async function openItem(kind, key){
|
||||
// Model picker is preset-only: it edits the MODEL= line of the preset.
|
||||
const modelRow = document.getElementById('m-model-row');
|
||||
const settingsRow = document.getElementById('m-settings-row');
|
||||
const samplingRow = document.getElementById('m-sampling-row');
|
||||
const rawHead = document.getElementById('m-raw-head');
|
||||
const rawToggle = document.getElementById('m-raw-toggle');
|
||||
const rawBody = document.getElementById('m-raw-body');
|
||||
@@ -108,6 +109,7 @@ async function openItem(kind, key){
|
||||
if(kind === 'preset'){
|
||||
modelRow.style.display = 'flex';
|
||||
settingsRow.style.display = 'flex';
|
||||
if(samplingRow) samplingRow.style.display = 'flex';
|
||||
document.getElementById('m-hf-url').value = '';
|
||||
resetDlUI();
|
||||
resetHfUI();
|
||||
@@ -119,9 +121,14 @@ async function openItem(kind, key){
|
||||
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
|
||||
document.getElementById('m-dir-path').value = '';
|
||||
setModelDirsOpen(false);
|
||||
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
|
||||
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
|
||||
setHFTokenOpen(false);
|
||||
loadHFToken();
|
||||
} else {
|
||||
modelRow.style.display = 'none';
|
||||
settingsRow.style.display = 'none';
|
||||
if(samplingRow) samplingRow.style.display = 'none';
|
||||
// Page mémoire : le contenu EST le champ principal — affiché en clair.
|
||||
rawHead.textContent = 'Contenu';
|
||||
rawToggle.style.display = 'none';
|
||||
@@ -258,6 +265,70 @@ function setModelDirsOpen(open){
|
||||
function toggleModelDirs(){
|
||||
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
|
||||
}
|
||||
|
||||
// ---- Jeton Hugging Face ----------------------------------------------------
|
||||
// Un dépôt « gated » se lit sans jeton mais ne se télécharge pas. Le jeton se
|
||||
// posait uniquement par la variable d'environnement HF_TOKEN : en conteneur, ça
|
||||
// voulait dire éditer un docker-compose et recréer le conteneur pour répondre à
|
||||
// un 401 découvert ici. Il se règle donc à l'endroit où le problème apparaît.
|
||||
function setHFTokenOpen(open){
|
||||
const b = document.getElementById('m-hft-body');
|
||||
const c = document.getElementById('m-hft-caret');
|
||||
if(!b) return;
|
||||
b.style.display = open ? '' : 'none';
|
||||
if(c) c.classList.toggle('open', open);
|
||||
}
|
||||
function toggleHFToken(){
|
||||
setHFTokenOpen(document.getElementById('m-hft-body').style.display === 'none');
|
||||
}
|
||||
async function loadHFToken(){
|
||||
let d = {};
|
||||
try{ d = await jget('/api/hf/token'); }catch(_){ return; }
|
||||
renderHFToken(d);
|
||||
}
|
||||
// Le jeton n'est jamais renvoyé en clair par le serveur : on n'affiche que sa
|
||||
// forme masquée et sa provenance. « env » se signale à part — l'interface ne
|
||||
// peut pas effacer une variable du conteneur, promettre le contraire par un
|
||||
// bouton « Retirer » serait mentir.
|
||||
function renderHFToken(d){
|
||||
const st = document.getElementById('m-hft-state');
|
||||
const note = document.getElementById('m-hft-note');
|
||||
const clear = document.getElementById('m-hft-clear');
|
||||
const inp = document.getElementById('m-hft-input');
|
||||
if(!st || !note || !clear || !inp) return;
|
||||
inp.value = '';
|
||||
const src = (d && d.source) || '';
|
||||
st.textContent = src ? (d.masked || 'enregistré') : 'aucun';
|
||||
clear.style.display = src === 'config' ? '' : 'none';
|
||||
if(src === 'env'){
|
||||
note.textContent = 'Jeton fourni par la variable d’environnement HF_TOKEN. En enregistrer un ici le remplacera.';
|
||||
} else if(src === 'config'){
|
||||
note.textContent = 'Jeton enregistré' + (d.account ? ' — compte ' + d.account : '')
|
||||
+ '. Un dépôt à accès restreint exige EN PLUS d’avoir accepté ses conditions sur huggingface.co avec ce compte.';
|
||||
} else {
|
||||
note.textContent = 'Sans jeton, les dépôts à accès restreint répondent 401 au téléchargement. Crée un jeton en lecture sur huggingface.co/settings/tokens, accepte les conditions du dépôt, puis colle-le ici.';
|
||||
}
|
||||
}
|
||||
// Vérifié côté serveur AVANT d'être enregistré : un jeton mal collé accepté en
|
||||
// silence rendrait exactement le 401 qu'on cherche à expliquer.
|
||||
async function saveHFToken(){
|
||||
const inp = document.getElementById('m-hft-input');
|
||||
const tok = (inp.value || '').trim();
|
||||
if(!tok){ toast('colle un jeton (hf_…)'); return; }
|
||||
toast('vérification du jeton…');
|
||||
const r = await jpost('/api/hf/token', {token: tok});
|
||||
if(!r || !r.ok){ toast('jeton refusé : ' + ((r && r.error) || '')); return; }
|
||||
renderHFToken(r);
|
||||
toast(r.account ? 'jeton enregistré — compte ' + r.account : 'jeton enregistré');
|
||||
}
|
||||
async function clearHFToken(){
|
||||
if(!await askConfirm('Les dépôts à accès restreint répondront de nouveau 401 au téléchargement.',
|
||||
{title:'Retirer le jeton Hugging Face ?', okText:'Retirer'})) return;
|
||||
const r = await jpost('/api/hf/token', {token: ''});
|
||||
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || '')); return; }
|
||||
renderHFToken(r);
|
||||
toast('jeton retiré');
|
||||
}
|
||||
async function populateModelDirs(){
|
||||
const box = document.getElementById('m-dirs-list');
|
||||
if(!box) return;
|
||||
@@ -273,7 +344,9 @@ async function populateModelDirs(){
|
||||
const info = document.createElement('span');
|
||||
info.className = 'muted';
|
||||
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
|
||||
const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
|
||||
// « ~ » quand le système de fichiers approxime (FUSE/shfs d'Unraid,
|
||||
// overlay, réseau) : le chiffre est indicatif, il ne bloque plus rien.
|
||||
const free = x.free >= 0 ? ', ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : '';
|
||||
info.textContent = '(' + n + free + (x.home ? ', dossier loki' : '') + ')';
|
||||
row.append(p, info);
|
||||
if(!x.home){
|
||||
@@ -399,6 +472,9 @@ async function populateBackend(){
|
||||
document.getElementById('be-lab-image').style.display = beImagePath ? '' : 'none';
|
||||
document.getElementById('be-lab-fast').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-lab-opt').style.display = beImagePath ? 'none' : '';
|
||||
// En conteneur, « Personnalisé » ne mène nulle part (rien à compiler, et le
|
||||
// moteur se met à jour par la carte Moteur des réglages) : on le retire.
|
||||
document.getElementById('be-lab-custom').style.display = beImagePath ? 'none' : '';
|
||||
document.getElementById('be-image-note').textContent = beImagePath ? 'llama.cpp officiel' : '';
|
||||
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
|
||||
// le dossier backends de loki (l'utilisateur peut y déposer son propre build).
|
||||
@@ -413,8 +489,11 @@ async function populateBackend(){
|
||||
const cur = currentBinInTextarea();
|
||||
let mode = 'custom';
|
||||
// Un preset sans BIN hérite du moteur global : en conteneur c'est celui de
|
||||
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ».
|
||||
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ». Et comme
|
||||
// l'option Personnalisé n'y est plus proposée, TOUT BIN non reconnu retombe
|
||||
// aussi sur l'image (un vieux preset pointant un chemin mort redevient sain).
|
||||
if(beImagePath && (sameBinPath(cur, beImagePath) || !cur)) mode = 'image';
|
||||
else if(beImagePath) mode = 'image';
|
||||
else if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
|
||||
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
|
||||
const radio = document.querySelector('input[name=m-be][value='+mode+']');
|
||||
@@ -729,6 +808,14 @@ function populateSettings(){
|
||||
set('s-ubatch', cfgReadKey('UBATCH'));
|
||||
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
|
||||
set('s-kv', cfgReadKey('KV_TYPE'));
|
||||
// Échantillonnage : envoyé PAR REQUÊTE (applySampling côté serveur), donc pas
|
||||
// besoin de redémarrer le moteur pour qu'un changement prenne effet.
|
||||
set('s-temp', cfgReadKey('TEMP'));
|
||||
set('s-topp', cfgReadKey('TOP_P'));
|
||||
set('s-topk', cfgReadKey('TOP_K'));
|
||||
set('s-minp', cfgReadKey('MIN_P'));
|
||||
set('s-presp', cfgReadKey('PRESENCE_PENALTY'));
|
||||
set('s-reppen', cfgReadKey('REPEAT_PENALTY'));
|
||||
set('s-moe', eaGetValued('--n-cpu-moe'));
|
||||
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
|
||||
setSpecType(eaGetValued('--spec-type'));
|
||||
@@ -755,9 +842,12 @@ function populateSettings(){
|
||||
eff.value = (cfgReadKey('REASONING_EFFORT')||'').toLowerCase();
|
||||
const effSub = document.getElementById('s-reasoning-effort-sub');
|
||||
if(effSub){
|
||||
// Deux emplacements, deux portées : ici c'est le défaut ENREGISTRÉ avec le
|
||||
// preset (donc rattaché au modèle) ; la liste de la barre de saisie le
|
||||
// dévie pour la suite de la session, jusqu'au prochain preset appliqué.
|
||||
effSub.textContent = eff.value
|
||||
? 'envoyée au gabarit du modèle — sans effet si son gabarit l\'ignore'
|
||||
: 'envoyée au gabarit du modèle — auto = ne rien imposer';
|
||||
? 'défaut de ce preset — sans effet si le gabarit du modèle l\'ignore'
|
||||
: 'défaut de ce preset — ajustable à la volée depuis la barre de saisie';
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
@@ -893,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;
|
||||
@@ -931,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>';
|
||||
@@ -1069,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){
|
||||
@@ -1101,8 +1211,36 @@ async function hfPickRepo(repo){
|
||||
back.onclick = hfSearch;
|
||||
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
|
||||
head.append(back, title);
|
||||
if(r.gated){
|
||||
const g = document.createElement('span');
|
||||
g.className = 'hf-fit gated'; g.textContent = 'accès restreint';
|
||||
head.appendChild(g);
|
||||
}
|
||||
o.appendChild(head);
|
||||
|
||||
// Avertissement en toutes lettres : la pastille seule ne dit pas quoi faire,
|
||||
// et c'est ici que le téléchargement se déclenche.
|
||||
if(r.gated){
|
||||
const warn = document.createElement('div');
|
||||
warn.className = 'pe-note';
|
||||
warn.style.color = r.hf_token ? 'var(--warn)' : 'var(--err)';
|
||||
warn.textContent = hfGatedHint(r.hf_token);
|
||||
o.appendChild(warn);
|
||||
// Le réglage est plus bas dans la même fenêtre : y mener directement évite
|
||||
// de le chercher, et c'est le seul geste qui débloque ce dépôt.
|
||||
if(!r.hf_token){
|
||||
const go = document.createElement('button');
|
||||
go.className = 'pe-link'; go.textContent = 'Renseigner un jeton Hugging Face';
|
||||
go.onclick = ()=>{
|
||||
setHFTokenOpen(true);
|
||||
const inp = document.getElementById('m-hft-input');
|
||||
document.getElementById('m-hft-toggle').scrollIntoView({block:'center', behavior:'smooth'});
|
||||
if(inp) inp.focus();
|
||||
};
|
||||
o.appendChild(go);
|
||||
}
|
||||
}
|
||||
|
||||
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
|
||||
// encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un
|
||||
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
|
||||
|
||||
@@ -56,9 +56,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
|
||||
});
|
||||
});
|
||||
|
||||
// Badge de rôle du mode code (builder / verifier) : posé par 09-stream.js à
|
||||
// réception d'un delta {role}, repris par addMsg sur les bulles suivantes.
|
||||
let ROLE_BADGE='';
|
||||
function addMsg(role, text){
|
||||
const el=document.createElement('div');
|
||||
el.className='msg '+role;
|
||||
if(ROLE_BADGE && (role==='assistant'||role==='reasoning'||role==='tool')) el.dataset.agentRole=ROLE_BADGE;
|
||||
const collapsible = (role==='reasoning' || role==='tool');
|
||||
// .body must be a real block so <p>/<pre>/<ul> margins behave properly.
|
||||
if(collapsible){
|
||||
@@ -72,6 +76,11 @@ function addMsg(role, text){
|
||||
if((role==='user'||role==='assistant') && typeof paintLabel==='function'){
|
||||
paintLabel(el.querySelector('.label'), role);
|
||||
}
|
||||
// Badge visible sur la bulle assistant pendant une passe de rôle.
|
||||
if(ROLE_BADGE && role==='assistant'){
|
||||
const b=document.createElement('span'); b.className='rolebadge'; b.textContent=ROLE_BADGE==='verifier'?'vérification':ROLE_BADGE;
|
||||
el.querySelector('.label').appendChild(b);
|
||||
}
|
||||
}
|
||||
el.querySelector('.body').textContent=text;
|
||||
// Bulles techniques (raisonnement, outils, mémoire) : elles se posent CÔTE À
|
||||
@@ -153,13 +162,21 @@ function setLabelCounts(el, add, del){
|
||||
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
|
||||
lab.appendChild(cnt);
|
||||
}
|
||||
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki
|
||||
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
|
||||
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
|
||||
// par la préférence d'affichage « masquer la vitesse de génération » (CSS).
|
||||
function setStats(el, text){
|
||||
// Ligne de mesures sous une réponse. Les étiquettes VOUS/Loki sont masquées dans
|
||||
// cette mise en page, donc les chiffres qu'on y écrivait avaient disparu : ils
|
||||
// ont leur propre ligne, discrète, sous le texte.
|
||||
//
|
||||
// Deux informations de nature différente y cohabitent, d'où deux éléments :
|
||||
// .worktime — le TEMPS DE TRAVAIL du tour (de la question à la fin de la
|
||||
// réponse, outils et raisonnement compris) ;
|
||||
// .spd — les mesures de vitesse (prefill / decode).
|
||||
// Seule la seconde est escamotée par « masquer la vitesse de génération » : la
|
||||
// durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté en
|
||||
// temps. La ligne entière disparaît si elle ne porte plus que de la vitesse.
|
||||
function setStats(el, work, speed){
|
||||
if(!el) return;
|
||||
let s = el.querySelector(':scope > .statline');
|
||||
if(!work && !speed){ if(s) s.textContent=''; return; }
|
||||
if(!s){
|
||||
s=document.createElement('div'); s.className='statline';
|
||||
// Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois
|
||||
@@ -169,7 +186,18 @@ function setStats(el, text){
|
||||
if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in');
|
||||
el.appendChild(s);
|
||||
}
|
||||
s.textContent = text;
|
||||
// Les deux travées sont RÉUTILISÉES d'un rafraîchissement à l'autre : la ligne
|
||||
// est repeinte à chaque token reçu, y recréer des éléments ferait travailler la
|
||||
// mise en page des dizaines de fois par seconde pour rien.
|
||||
let w = s.querySelector(':scope > .worktime');
|
||||
let p = s.querySelector(':scope > .spd');
|
||||
if(!w){ w=document.createElement('span'); w.className='worktime'; s.appendChild(w); }
|
||||
if(!p){ p=document.createElement('span'); p.className='spd'; s.appendChild(p); }
|
||||
w.textContent = work || '';
|
||||
// Le séparateur voyage AVEC la vitesse : masquer celle-ci ne doit pas laisser
|
||||
// un « · » orphelin en bout de durée.
|
||||
p.textContent = speed ? ((work ? ' \u00b7 ' : '') + speed) : '';
|
||||
s.classList.toggle('has-work', !!work);
|
||||
}
|
||||
function bodyOf(el){ return el.querySelector('.body'); }
|
||||
// Render markdown into a message body in place; safe because md() escapes HTML.
|
||||
@@ -191,6 +219,13 @@ function hydrateImages(root){
|
||||
img.dataset.hydrated = '1';
|
||||
const src = img.getAttribute('src');
|
||||
img.classList.add('chatimg');
|
||||
// Nom lisible pour la loupe : la fin du chemin visé, pas l'URL d'API.
|
||||
let name = img.getAttribute('alt') || '';
|
||||
if(!name){
|
||||
const m = /[?&]path=([^&]*)/.exec(src);
|
||||
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
|
||||
}
|
||||
bindZoom(img, name);
|
||||
const cached = IMG_CACHE.get(src);
|
||||
if(cached){ img.src = cached; return; }
|
||||
try{
|
||||
@@ -223,6 +258,17 @@ function renderToolMsg(el, tu){
|
||||
web_open: {lbl:'page web', head:'ouverture'},
|
||||
web_read: {lbl:'page web', head:'lecture'},
|
||||
web_grep: {lbl:'page web', head:'recherche'},
|
||||
// Outils du mode code : chacun son étiquette, pas le fallback mémoire.
|
||||
read: {lbl:'lecture', head:'lecture'},
|
||||
grep: {lbl:'recherche', head:'grep'},
|
||||
glob: {lbl:'fichiers', head:'glob'},
|
||||
criteria: {lbl:'critères', head:'critères'},
|
||||
ask: {lbl:'question', head:'question'},
|
||||
bash_bg: {lbl:'terminal', head:'commande (fond)'},
|
||||
bash_tail: {lbl:'terminal', head:'suivi de job'},
|
||||
git_status: {lbl:'git', head:'git status'},
|
||||
git_diff: {lbl:'git', head:'git diff'},
|
||||
git_clone: {lbl:'git', head:'git clone'},
|
||||
};
|
||||
// Outils MCP (nom mcp__<serveur>__<outil>) : en-tête = nom du serveur, libellé lisible,
|
||||
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
|
||||
@@ -280,6 +326,21 @@ function renderToolMsg(el, tu){
|
||||
// la ligne en cours (max-height côté CSS l'empêche de pousser le fil).
|
||||
pre.scrollTop = pre.scrollHeight;
|
||||
}
|
||||
// Capture d'écran : l'image est rendue ICI, dans la bulle de l'outil. Elle ne
|
||||
// dépend donc plus de la bonne volonté du modèle à recopier la ligne markdown
|
||||
// — il l'oubliait, et l'utilisateur ne voyait jamais ce qu'il avait demandé.
|
||||
if(tu.image){
|
||||
const wrap=document.createElement('div'); wrap.className='tool-shot';
|
||||
const img=document.createElement('img');
|
||||
// src (et non data-src) : hydrateImages sélectionne sur src, puis le
|
||||
// remplace par un blob obtenu avec la clé de pilotage.
|
||||
img.setAttribute('src', '/api/chat/image?path='+encodeURIComponent(tu.image));
|
||||
img.alt = tu.image.split('/').pop();
|
||||
img.loading = 'lazy';
|
||||
wrap.appendChild(img);
|
||||
body.appendChild(wrap);
|
||||
hydrateImages(wrap);
|
||||
}
|
||||
// Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert,
|
||||
// retirées en rouge, contexte en gris — comme un diff de terminal.
|
||||
if(tu.diff && tu.diff.length){
|
||||
@@ -307,6 +368,11 @@ function renderToolMsg(el, tu){
|
||||
body.appendChild(wait);
|
||||
}
|
||||
addCopyButtons(body); scrollMaybe();
|
||||
// Carte à hauteur bornée : tant que l'outil TRAVAILLE, on suit le bas (la
|
||||
// ligne en cours d'écriture, la commande qui se tape). Terminé, on remonte en
|
||||
// tête — c'est le résumé qu'on relit, pas la dernière ligne.
|
||||
const bw = el.querySelector('.bodywrap');
|
||||
if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
|
||||
}
|
||||
// Inject a "copier" button into every <pre> code block (idempotent).
|
||||
function addCopyButtons(root){
|
||||
|
||||
@@ -51,8 +51,107 @@ function confirmPending(text){
|
||||
let REPLAYING=true;
|
||||
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
||||
let T=null;
|
||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; }
|
||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
|
||||
newTurn();
|
||||
|
||||
// --- Temps de travail du tour ------------------------------------------------
|
||||
// « Combien de temps l'IA a-t-elle travaillé ? » se lit sous la réponse, de la
|
||||
// question envoyée à la fin du tour — raisonnement, appels d'outils et attentes
|
||||
// compris. La mesure est prise sur les HORODATAGES SERVEUR (l'événement `user`
|
||||
// puis `turn_done`) : elle est donc exacte en direct comme au rejeu du journal,
|
||||
// où tout arrive d'un bloc côté client.
|
||||
//
|
||||
// Pendant le tour il n'y a pas encore de `turn_done` : le compteur avance à la
|
||||
// seconde. Il ne peut pas se baser sur l'horloge du navigateur telle quelle (un
|
||||
// téléphone n'est pas à la même heure que la machine), on garde donc l'ÉCART
|
||||
// entre les deux, réévalué à chaque événement reçu en direct.
|
||||
let TS_SKEW = 0; // horloge client − horloge serveur (ms)
|
||||
function nowServer(){ return Date.now() - TS_SKEW; }
|
||||
let WORK_TIMER = null;
|
||||
function startWorkTimer(){ if(WORK_TIMER || REPLAYING) return; WORK_TIMER=setInterval(tickWork, 1000); }
|
||||
function stopWorkTimer(){ if(WORK_TIMER){ clearInterval(WORK_TIMER); WORK_TIMER=null; } }
|
||||
function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); paintTurnClock(); }
|
||||
|
||||
// Chrono du TOUR COMPLET, affiché au-dessus de la carte de saisie : le temps
|
||||
// entre l'envoi du message et le moment où l'on peut reparler — outils, passes
|
||||
// de vérification et compaction compris. Les durées portées par les cartes ne
|
||||
// disent que la génération de texte ; celle-ci répond à « ça a pris combien de
|
||||
// temps, en tout ? ».
|
||||
function paintTurnClock(){
|
||||
const composer = document.getElementById('composer');
|
||||
if(!composer || !T.startTs) return;
|
||||
let el = document.getElementById('turn-clock');
|
||||
if(!el){
|
||||
el = document.createElement('div');
|
||||
el.id = 'turn-clock';
|
||||
el.className = 'composer-banner';
|
||||
composer.insertBefore(el, composer.firstChild);
|
||||
}
|
||||
const ms = (T.doneTs || nowServer()) - T.startTs;
|
||||
if(!(ms > 0)){ el.remove(); return; }
|
||||
// Vitesse moyenne de la CONVERSATION à côté du temps du tour : le débit
|
||||
// typique de ce modèle sur ce fil, toutes réponses confondues.
|
||||
const avg = (CONV_MS > 1000 && CONV_TOK > 20)
|
||||
? ' · moy ' + (CONV_TOK / (CONV_MS/1000)).toFixed(1) + ' tok/s' : '';
|
||||
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms) + avg;
|
||||
el.classList.toggle('done', !!T.doneTs);
|
||||
}
|
||||
// --- Vitesse moyenne de la conversation --------------------------------------
|
||||
// Alimentée par les événements `stats` (journalisés, donc rejoués au
|
||||
// chargement : la moyenne survit au refresh). gen_tokens / gen_ms sont
|
||||
// CUMULATIFS au sein d'une complétion et retombent à la suivante : on n'ajoute
|
||||
// que le delta, et une valeur qui recule signale une nouvelle complétion.
|
||||
let CONV_TOK = 0, CONV_MS = 0, _cgTok = 0, _cgMs = 0;
|
||||
function feedConvSpeed(s){
|
||||
const gt = s.gen_tokens || 0, gms = s.gen_ms || 0;
|
||||
if(!gt) return;
|
||||
if(gt < _cgTok){ _cgTok = 0; _cgMs = 0; }
|
||||
CONV_TOK += gt - _cgTok;
|
||||
CONV_MS += Math.max(0, gms - _cgMs);
|
||||
_cgTok = gt; _cgMs = gms;
|
||||
}
|
||||
function resetConvSpeed(){ CONV_TOK = 0; CONV_MS = 0; _cgTok = 0; _cgMs = 0; }
|
||||
// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
|
||||
// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
|
||||
// c'est précisément le moment où l'on se demande si ça avance.
|
||||
function paintTypingClock(){
|
||||
const el = T.typingEl;
|
||||
if(!el || el.classList.contains('compacting')) return; // le compactage a son propre libellé
|
||||
if(!T.startTs) return;
|
||||
const ms = nowServer() - T.startTs;
|
||||
if(!(ms > 1500)) return; // pas de compteur pour une réponse immédiate
|
||||
let c = el.querySelector('.wclock');
|
||||
if(!c){ c=document.createElement('span'); c.className='wclock'; el.appendChild(c); }
|
||||
c.textContent = fmtDur(ms);
|
||||
}
|
||||
// Durée lisible : dixièmes sous 10 s (un tour court se juge à la fraction),
|
||||
// secondes rondes ensuite, puis minutes et heures. Le séparateur décimal reste
|
||||
// le POINT — la durée voisine des « 21.5 tok/s » sur la même ligne, une virgule
|
||||
// y ferait deux conventions à trois mots d'écart.
|
||||
function fmtDur(ms){
|
||||
if(!(ms > 0)) return '';
|
||||
if(ms < 10000) return (ms/1000).toFixed(1) + ' s';
|
||||
const t = Math.round(ms/1000);
|
||||
if(t < 60) return t + ' s';
|
||||
const m = Math.floor(t/60), sec = t % 60;
|
||||
if(m < 60) return m + ' min ' + String(sec).padStart(2, '0') + ' s';
|
||||
return Math.floor(m/60) + ' h ' + String(m % 60).padStart(2, '0') + ' min';
|
||||
}
|
||||
// Libellé de durée du tour courant, vide tant qu'il n'y a rien à montrer (tour
|
||||
// non commencé, ou horloges trop désaccordées pour que l'écart ait un sens).
|
||||
function workLabel(){
|
||||
if(!T.startTs) return '';
|
||||
const ms = (T.doneTs || nowServer()) - T.startTs;
|
||||
if(!(ms > 300)) return '';
|
||||
return 'travail ' + fmtDur(ms);
|
||||
}
|
||||
// Repeint la ligne de mesures de la réponse : durée d'abord, vitesse ensuite.
|
||||
// `speed` non fourni = on garde le dernier libellé de vitesse connu (c'est le
|
||||
// cas du tic de seconde, qui ne fait avancer que la durée).
|
||||
function paintStats(el, speed){
|
||||
if(speed !== undefined) T.speedText = speed;
|
||||
setStats(el, workLabel(), T.speedText);
|
||||
}
|
||||
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
||||
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
||||
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
|
||||
@@ -111,9 +210,23 @@ function renderStats(el, s){
|
||||
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
|
||||
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
|
||||
// bouton de repli, on y écrit comme avant.
|
||||
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(parts).join(' · '));
|
||||
else setStats(el, parts.join(' · '));
|
||||
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · '));
|
||||
else paintStats(el, parts.join(' · '));
|
||||
}
|
||||
// --- Compteurs de bulle -----------------------------------------------------
|
||||
// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle
|
||||
// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les
|
||||
// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le
|
||||
// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier
|
||||
// token — exécution des outils, pages web et prefill compris.
|
||||
//
|
||||
// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait
|
||||
// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur
|
||||
// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite
|
||||
// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert,
|
||||
// aujourd'hui comme demain.
|
||||
function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; }
|
||||
function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; }
|
||||
// Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à
|
||||
// partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération,
|
||||
// donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un
|
||||
@@ -122,18 +235,34 @@ function labelTokens(el, role, n, firstTs, lastTs){
|
||||
if(!el) return;
|
||||
const secs=(lastTs-firstTs)/1000;
|
||||
const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok';
|
||||
// Bulle de raisonnement : sa propre durée de génération, comme le « réfléchi
|
||||
// pendant… » d'un fil de discussion — elle reste lisible une fois repliée.
|
||||
const d = (secs>0.05 && n>1) ? fmtDur(lastTs-firstTs) : '';
|
||||
// Bulle technique : son étiquette EST le bouton de repli, les mesures y vont.
|
||||
// Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures
|
||||
// vont dans la ligne dédiée — comme le font déjà les stats de fin de tour
|
||||
// (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct
|
||||
// par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement.
|
||||
if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m);
|
||||
else setStats(el, m);
|
||||
if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · '));
|
||||
else paintStats(el, m);
|
||||
}
|
||||
// Badge du modèle sur une bulle de réponse : le nom voyage avec la borne de
|
||||
// tour (T.model), on le dépose dans dataset.model pour qu'applyIdentity — qui
|
||||
// repeint les libellés de zéro — puisse le reposer (voir paintLabel).
|
||||
function tagModel(el){
|
||||
if(!el || !T.model) return;
|
||||
el.dataset.model = T.model;
|
||||
const l = el.querySelector('.label');
|
||||
if(l && !l.querySelector('.modelbadge') && typeof modelBadge==='function') l.appendChild(modelBadge(T.model));
|
||||
}
|
||||
// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
|
||||
// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
|
||||
// L'état final est appliqué une seule fois au caught_up via syncSendBtn().
|
||||
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
|
||||
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn();
|
||||
// La liste des discussions montre laquelle travaille : elle doit suivre l'état
|
||||
// MÊME pendant le rejeu (une page rechargée en cours de génération doit voir
|
||||
// l'anneau tourner sans attendre le premier événement en direct).
|
||||
if(typeof convSyncBusy==='function') convSyncBusy(); }
|
||||
function syncSendBtn(){
|
||||
const sb=document.getElementById('send');
|
||||
// ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux
|
||||
@@ -156,15 +285,130 @@ function syncSendBtn(){
|
||||
hint.classList.toggle('waiting', !ready);
|
||||
}
|
||||
}
|
||||
// ─── Rendu du bloc en cours : cadencé, puis lissé ────────────────────────────
|
||||
// Repris de l'amont AJEAN (v0.9.5 issue #24, puis v0.10.5), adapté.
|
||||
//
|
||||
// Re-parser le Markdown du bloc ENTIER à chaque token est en O(n²) : sur un long
|
||||
// raisonnement (des milliers de tokens) l'interface se met à ramer, les tokens
|
||||
// semblent arriver au ralenti — alors que le moteur, lui, débite toujours autant
|
||||
// — et un simple rafraîchissement « répare » tout, puisqu'il rend le bloc une
|
||||
// seule fois. On coalesce donc : le texte s'accumule (concaténation, quasi
|
||||
// gratuite) et on ne re-rend qu'à intervalle borné. Le rendu final exact est
|
||||
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
|
||||
// tour, erreur, caught_up).
|
||||
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
|
||||
function scheduleRender(el, text, final){
|
||||
// Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière
|
||||
// valeur, sinon son ultime bout de texte serait perdu.
|
||||
if(renderPending && renderPending.el!==el) flushRender();
|
||||
// `final` voyage AVEC le rendu en attente : le timer déjà armé (qui passe
|
||||
// final=false) peut être celui qui consommera ce rendu — sans le drapeau sur
|
||||
// le pending, le rendu de FIN de bloc repassait par le chemin « queue seule »
|
||||
// et le début du raisonnement n'était jamais posé.
|
||||
renderPending={el, text, final:!!final};
|
||||
if(renderTimer) return;
|
||||
// Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser
|
||||
// le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher
|
||||
// 16 ms ≈ 60 img/s, l'apparition reste fluide token par token. Quand le bloc
|
||||
// devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms :
|
||||
// le O(n²) est cassé sans jamais figer l'interface.
|
||||
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
|
||||
renderTimer=setTimeout(()=>flushRender(false), delay);
|
||||
}
|
||||
// Au-delà de cette taille, un raisonnement EN COURS n'est re-parsé que sur sa
|
||||
// fin : re-parser le bloc entier à chaque tick est en O(n²) et finissait par
|
||||
// figer l'affichage (le texte semblait s'arrêter alors que le moteur débitait).
|
||||
// La carte étant bornée en hauteur et collée en bas, on ne perd rien à l'écran ;
|
||||
// le texte COMPLET est posé au rendu final (frontière de bloc → final=true).
|
||||
const REASON_TAIL=6000;
|
||||
function flushRender(final){
|
||||
if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; }
|
||||
const p=renderPending; renderPending=null;
|
||||
if(!p) return;
|
||||
let text=p.text;
|
||||
if(final===false && !p.final && text.length>REASON_TAIL+800 && p.el.closest('.msg.reasoning')){
|
||||
let cut=text.length-REASON_TAIL;
|
||||
const nl=text.indexOf('\n', cut); if(nl>-1 && nl<cut+400) cut=nl+1;
|
||||
text='*… (début replié pendant la génération — le texte complet est posé à la fin)*\n\n'+text.slice(cut);
|
||||
}
|
||||
// Carte bornée (raisonnement/outil) : si on était collé en bas, on y reste —
|
||||
// la carte suit la génération. Un défilement manuel vers le haut est respecté.
|
||||
// ⚠️ p.el est la BULLE (.msg), pas .body : le bodywrap est dedans, pas au-dessus.
|
||||
const bw=p.el.querySelector ? p.el.querySelector(':scope > .bodywrap') : null;
|
||||
const scrollable=!!bw;
|
||||
const stick=scrollable && bw.scrollTop+bw.clientHeight>=bw.scrollHeight-60;
|
||||
const t0=performance.now();
|
||||
renderBody(p.el, text);
|
||||
lastRenderMs=performance.now()-t0;
|
||||
if(scrollable && stick) bw.scrollTop=bw.scrollHeight;
|
||||
}
|
||||
// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé,
|
||||
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
|
||||
function cancelRender(){ if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; } renderPending=null; }
|
||||
// Lissage d'apparition (« machine à écrire »). Le décodage spéculatif (MTP) rend
|
||||
// les tokens PAR RAFALES — plusieurs acceptés d'un coup, puis une pause : le
|
||||
// texte grandit par paquets et saute à l'écran. On découple donc l'ARRIVÉE (les
|
||||
// rafales du moteur) de l'AFFICHAGE : `target` = tout ce qui est reçu, `shown`
|
||||
// avance à cadence régulière (requestAnimationFrame), et on n'affiche que le
|
||||
// préfixe révélé. Le rendu passe TOUJOURS par scheduleRender pour conserver le
|
||||
// garde-fou anti-O(n²) ci-dessus.
|
||||
// UNIQUEMENT EN DIRECT : au rejeu tout est posé d'un bloc, sinon relire un fil
|
||||
// deviendrait une lente réécriture caractère par caractère.
|
||||
let smooth=null, smoothLast=0; // {el, target, shown, raf}
|
||||
function smoothReset(){ if(smooth&&smooth.raf) cancelAnimationFrame(smooth.raf); smooth=null; smoothLast=0; }
|
||||
// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque
|
||||
// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de
|
||||
// texte resterait en retard derrière le curseur de révélation.
|
||||
function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target, true); }
|
||||
// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse
|
||||
// de révélation est proportionnelle au RETARD accumulé, avec une constante de
|
||||
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
|
||||
// donne un écoulement CONTINU malgré les rafales, et se vide en douceur quand la
|
||||
// génération s'arrête. Un plancher garantit un progrès même sur un retard
|
||||
// minuscule, sans jamais figer.
|
||||
const SMOOTH_TAU=260; // ms — plus grand = plus lisse mais traîne davantage
|
||||
function smoothStep(ts){
|
||||
if(!smooth) return;
|
||||
if(!smoothLast) smoothLast=ts;
|
||||
const dt=Math.min(120, ts-smoothLast); smoothLast=ts;
|
||||
const remaining=smooth.target.length - smooth.shown;
|
||||
if(remaining<=0){ smooth.raf=null; smoothLast=0; return; }
|
||||
let adv=remaining*dt/SMOOTH_TAU; // vitesse ∝ retard
|
||||
if(adv<0.4) adv=0.4; // progrès minimal
|
||||
smooth.shown=Math.min(smooth.target.length, smooth.shown+Math.ceil(adv));
|
||||
scheduleRender(smooth.el, smooth.target.slice(0, smooth.shown));
|
||||
smooth.raf=requestAnimationFrame(smoothStep);
|
||||
}
|
||||
function smoothFeed(el, target){
|
||||
if(smooth && smooth.el!==el) smoothSnap(); // changement de bloc : solder l'ancien
|
||||
if(!smooth) smooth={el, target, shown:0, raf:null};
|
||||
smooth.target=target;
|
||||
if(!smooth.raf) smooth.raf=requestAnimationFrame(smoothStep);
|
||||
}
|
||||
// Rend un bloc en streaming : lissé en direct, instantané au rejeu.
|
||||
function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoothFeed(el, full); }
|
||||
// Solde tout ce qui est en vol : le bloc est terminé, la suite (stats, repli,
|
||||
// bulle d'outil) doit voir le texte complet.
|
||||
function settleBlocks(){ smoothSnap(); flushRender(); }
|
||||
|
||||
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
|
||||
// piloté par le serveur et rejouable à l'identique.
|
||||
function handleDelta(d){
|
||||
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
|
||||
// Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
|
||||
// `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
|
||||
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
|
||||
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
|
||||
if(d.caught_up){
|
||||
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
|
||||
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
|
||||
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
|
||||
setChatLoading(null);
|
||||
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
|
||||
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
|
||||
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
|
||||
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
|
||||
if(busy && T.startTs && !T.doneTs) startWorkTimer();
|
||||
// Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra
|
||||
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
|
||||
syncChatEmpty();
|
||||
@@ -173,21 +417,45 @@ function handleDelta(d){
|
||||
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
|
||||
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
|
||||
// appartiennent à la discussion, le panneau doit changer avec elle.
|
||||
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
|
||||
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
|
||||
// --- Mode code (20-mode.js) -----------------------------------------------
|
||||
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
|
||||
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
|
||||
if(d.criteria!==undefined){ if(typeof renderCriteria==='function') renderCriteria(d.criteria); return; }
|
||||
// Bascule de rôle (mode code) : les blocs en cours appartiennent au rôle
|
||||
// précédent — on les solde avant de les oublier, sinon leur dernier fragment
|
||||
// resterait en vol et ne s'afficherait jamais.
|
||||
if(d.role!==undefined){ settleBlocks(); ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; }
|
||||
if(d.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; }
|
||||
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
|
||||
if(d.user!==undefined){
|
||||
newTurn();
|
||||
// Modèle du tour : posé par le serveur sur la borne de tour (journalisée),
|
||||
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque
|
||||
// bulle de réponse du tour.
|
||||
T.model = d.model || '';
|
||||
let el=PENDING;
|
||||
if(!confirmPending(d.user)) el=addMsg('user', d.user);
|
||||
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
|
||||
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
|
||||
// bulle neuve — sinon elles apparaîtraient en double.
|
||||
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
|
||||
setBusy(true); T.typingEl=addTyping(); return; }
|
||||
// Départ du chronomètre : l'horodatage SERVEUR du message, pas l'heure
|
||||
// locale — c'est ce qui rend la durée juste au rejeu comme en direct.
|
||||
T.startTs = d.ts || 0; T.doneTs = 0;
|
||||
setBusy(true); startWorkTimer(); paintTurnClock(); T.typingEl=addTyping(); return; }
|
||||
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
|
||||
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
|
||||
// replay, qui rejoue tous les tours passés d'un bloc.
|
||||
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
|
||||
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
|
||||
// le temps réellement passé et non continuer d'avancer.
|
||||
T.doneTs = d.ts || T.doneTs || 0;
|
||||
if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
|
||||
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
|
||||
paintTurnClock(); // fige le total au-dessus de la carte
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
|
||||
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
|
||||
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
|
||||
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
|
||||
@@ -197,29 +465,47 @@ function handleDelta(d){
|
||||
// une trace du fil et DOIT être rejouée.
|
||||
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
|
||||
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
|
||||
if(d.stats){ T.serverStats=d.stats;
|
||||
if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
|
||||
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
|
||||
// certains llama-server récents : le serveur publie alors un `ctx_used`
|
||||
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
|
||||
// zéro ici, on la laisse simplement inchangée.
|
||||
if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
|
||||
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
|
||||
if(d.tool_used){
|
||||
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
|
||||
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
|
||||
// Une carte reste OUVERTE tant que SA section travaille (hauteur bornée à
|
||||
// 280 px, défilement qui suit l'écriture), et se replie DÈS qu'elle est
|
||||
// finie : l'outil à son résultat (tu.done, plus bas), le raisonnement quand
|
||||
// le bloc suivant démarre (collapseAll à la création de la carte suivante).
|
||||
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
|
||||
renderToolMsg(T.pendingToolEl, tu);
|
||||
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
|
||||
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
|
||||
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
|
||||
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||||
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
|
||||
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
|
||||
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
|
||||
if(tu.done){
|
||||
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
|
||||
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
|
||||
// tour (collapseAll) la fermerait quand même.
|
||||
if(tu.image && T.pendingToolEl){ const i=T.turnCollapsibles.indexOf(T.pendingToolEl); if(i>=0) T.turnCollapsibles.splice(i,1); }
|
||||
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
|
||||
return; }
|
||||
if(d.drop_reasoning){
|
||||
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
|
||||
if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; }
|
||||
return; }
|
||||
if(d.reasoning_content){
|
||||
killTyping('reasoning');
|
||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
|
||||
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
|
||||
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
|
||||
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
|
||||
// au lieu de concaténer, sinon le texte apparaît en double.
|
||||
if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; }
|
||||
showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason);
|
||||
if(d.replace){ smoothSnap(); T.fullReason=''; resetReasonStats(); }
|
||||
showTyping('reasoning'); T.fullReason+=d.reasoning_content; feedBlock(T.reasonEl, T.fullReason);
|
||||
// d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs
|
||||
// tokens d'un coup. Sinon (direct), 1 token, ts0=ts.
|
||||
if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1);
|
||||
@@ -227,9 +513,9 @@ function handleDelta(d){
|
||||
return; }
|
||||
if(d.content){
|
||||
removeTyping();
|
||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; }
|
||||
if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; }
|
||||
T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent);
|
||||
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); tagModel(T.contentEl); T.fullContent=''; resetContentStats(); }
|
||||
if(d.replace){ smoothSnap(); T.fullContent=''; resetContentStats(); }
|
||||
T.fullContent+=d.content; feedBlock(T.contentEl, T.fullContent);
|
||||
if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1);
|
||||
labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);
|
||||
return; }
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// ─── Intensité du raisonnement (barre de saisie) ─────────────────────────────
|
||||
// Le même réglage existe dans l'éditeur de preset, mais il y est figé avec le
|
||||
// preset : le changer demande d'ouvrir les réglages, éditer, enregistrer. Ici il
|
||||
// s'applique au message suivant, sans redémarrer le moteur — `reasoning_effort`
|
||||
// voyage dans le corps de la requête, pas dans la ligne de commande.
|
||||
//
|
||||
// La valeur part dans la config vivante. Appliquer un preset la réécrit : le
|
||||
// réglage reste donc attaché au modèle, cette liste ne fait que le dévier à la
|
||||
// volée.
|
||||
|
||||
const THINK_EFFORT_LABELS = {
|
||||
'': 'auto',
|
||||
none: 'aucune',
|
||||
low: 'basse',
|
||||
medium: 'moyenne',
|
||||
high: 'haute',
|
||||
xhigh: 'maximale',
|
||||
};
|
||||
|
||||
async function loadThinkEffort(){
|
||||
try{ applyThinkEffort(await jget('/api/reasoning-effort')); }
|
||||
catch(e){ /* endpoint indisponible : la liste garde sa valeur par défaut */ }
|
||||
}
|
||||
|
||||
function applyThinkEffort(r){
|
||||
const sel = document.getElementById('think-effort');
|
||||
if(!sel || !r || !r.ok) return;
|
||||
sel.value = r.value || '';
|
||||
// Raisonnement coupé au niveau du preset : le niveau n'a plus de prise. On
|
||||
// grise plutôt que de masquer, pour que le réglage reste trouvable.
|
||||
sel.disabled = !r.on;
|
||||
sel.title = r.on
|
||||
? 'Intensité du raisonnement — envoyée au gabarit du modèle'
|
||||
: 'Raisonnement désactivé pour ce modèle (réglages → Raisonnement)';
|
||||
}
|
||||
|
||||
async function setThinkEffort(value){
|
||||
const r = await jpost('/api/reasoning-effort', {value});
|
||||
if(!r || !r.ok){ toast((r && r.error) || 'réglage refusé'); return; }
|
||||
applyThinkEffort(r);
|
||||
toast('raisonnement : '+(THINK_EFFORT_LABELS[r.value||''] || r.value));
|
||||
}
|
||||
@@ -26,12 +26,28 @@ function fmtSize(n){
|
||||
if(n >= 1024) return Math.round(n/1024)+' Ko';
|
||||
return n+' o';
|
||||
}
|
||||
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
|
||||
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
|
||||
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
|
||||
// une vignette cassée qui retombe sur son texte de repli.
|
||||
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
|
||||
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
|
||||
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
|
||||
// objet visuel des deux côtés, seul le contexte (CSS) change.
|
||||
//
|
||||
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
|
||||
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
|
||||
// vers le serveur à ce stade).
|
||||
function fileChip(name, size, opts){
|
||||
opts=opts||{};
|
||||
const chip=document.createElement('div');
|
||||
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
|
||||
if(opts.thumb){
|
||||
const t=document.createElement('img');
|
||||
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
|
||||
chip.appendChild(t);
|
||||
chip.classList.add('has-thumb');
|
||||
}
|
||||
const n=document.createElement('span');
|
||||
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
|
||||
const s=document.createElement('span');
|
||||
@@ -49,12 +65,31 @@ function fileChip(name, size, opts){
|
||||
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
|
||||
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
|
||||
// un bloc bâtard, moitié texte moitié pastilles.
|
||||
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
|
||||
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
|
||||
// balise <img> ne peut pas porter la clé de pilotage.
|
||||
function msgImgTile(f){
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-img';
|
||||
const img=document.createElement('img');
|
||||
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
|
||||
img.alt=f.name||''; img.title=f.name||'';
|
||||
box.appendChild(img);
|
||||
return box;
|
||||
}
|
||||
function addMsgFiles(el, files){
|
||||
if(!el || !files || !files.length) return;
|
||||
const box=document.createElement('div');
|
||||
box.className='msg-files';
|
||||
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
for(const f of files){
|
||||
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
|
||||
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
|
||||
// apprendre — le nom vit dans l'infobulle et dans la loupe.
|
||||
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
|
||||
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
|
||||
}
|
||||
el.parentNode.insertBefore(box, el);
|
||||
hydrateImages(box);
|
||||
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
|
||||
markEmptyMsg(el);
|
||||
return box;
|
||||
@@ -202,11 +237,18 @@ function renderAttach(){
|
||||
sizeText: a.state==='err' ? 'échec'
|
||||
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
|
||||
: fmtSize(a.size),
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
|
||||
thumb: a.thumb,
|
||||
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
|
||||
}));
|
||||
}
|
||||
}
|
||||
function clearAttach(){ ATTACH=[]; renderAttach(); }
|
||||
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
|
||||
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
|
||||
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
|
||||
function dropThumb(a){
|
||||
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
|
||||
}
|
||||
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
|
||||
// Ce qui part avec le message, pour l'afficher dans sa bulle.
|
||||
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
|
||||
|
||||
@@ -277,7 +319,11 @@ function addFiles(files){
|
||||
for(const f of files||[]){
|
||||
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
|
||||
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
|
||||
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
|
||||
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
|
||||
// source d'image disponible à ce stade.
|
||||
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
|
||||
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
|
||||
}
|
||||
renderAttach();
|
||||
}
|
||||
|
||||
@@ -86,6 +86,16 @@ function renderFoot(foot, r){
|
||||
n.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
|
||||
foot.appendChild(n);
|
||||
foot.title = r.root || '';
|
||||
// Tout emporter d'un coup : la discussion entière (dossiers et sous-dossiers
|
||||
// compris) en une archive .zip. Absent quand il n'y a rien à archiver.
|
||||
if(r.count > 0){
|
||||
const z = document.createElement('button');
|
||||
z.className = 'files-scope';
|
||||
z.textContent = '.zip';
|
||||
z.title = 'tout télécharger en une archive .zip';
|
||||
z.onclick = ()=>downloadFilesZip(z);
|
||||
foot.appendChild(z);
|
||||
}
|
||||
if(FILES_SCOPE !== 'legacy' && !r.legacy) return;
|
||||
const b = document.createElement('button');
|
||||
b.className = 'files-scope';
|
||||
@@ -199,6 +209,51 @@ async function removeFile(e){
|
||||
loadFiles();
|
||||
}
|
||||
|
||||
// Télécharge TOUT le contenu affiché (discussion ou hors-discussion) en .zip.
|
||||
// Même danse que downloadWorkspaceFile (14-attach.js) : la fiche meta d'abord —
|
||||
// c'est elle qui CONSTRUIT l'archive côté serveur — puis le brut en direct, ou
|
||||
// les tranches base64 derrière le tunnel chiffré.
|
||||
async function downloadFilesZip(btn){
|
||||
if(btn){ btn.disabled = true; btn.textContent = '…'; }
|
||||
try{
|
||||
const m = await jfetch('/api/chat/files/zip?meta=1&scope='+encodeURIComponent(FILES_SCOPE));
|
||||
const meta = await m.json().catch(()=>({}));
|
||||
if(!m.ok || !meta.ok){ toast('archive impossible : '+(meta.error||('HTTP '+m.status))); return; }
|
||||
let blob;
|
||||
if(meta.e2e){
|
||||
blob = await fetchZipB64(meta.size||0);
|
||||
} else {
|
||||
const r = await jfetch('/api/chat/files/zip?scope='+encodeURIComponent(FILES_SCOPE));
|
||||
if(!r.ok){ let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} toast('archive impossible : '+msg); return; }
|
||||
blob = await r.blob();
|
||||
}
|
||||
const u = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = u; a.download = meta.name || 'loki-fichiers.zip';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(()=>URL.revokeObjectURL(u), 10000);
|
||||
toast(meta.files + ' fichier'+(meta.files>1?'s':'')+' archivé'+(meta.files>1?'s':''));
|
||||
}catch(e){ toast('archive impossible : '+((e&&e.message)||'erreur')); }
|
||||
finally{ if(btn){ btn.disabled = false; btn.textContent = '.zip'; } }
|
||||
}
|
||||
|
||||
// Tranches base64 du zip (tunnel chiffré) — cousin de fetchFileB64.
|
||||
async function fetchZipB64(size){
|
||||
const parts = [];
|
||||
let off = 0;
|
||||
do{
|
||||
const r = await jfetch('/api/chat/files/zip?b64=1&len='+DL_CHUNK+'&offset='+off+'&scope='+encodeURIComponent(FILES_SCOPE));
|
||||
const j = await r.json();
|
||||
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
|
||||
const bytes = b64ToBytes(j.data||'');
|
||||
parts.push(bytes);
|
||||
if(!bytes.length && !j.eof) throw new Error('transfert interrompu');
|
||||
off = (j.offset||0) + bytes.length;
|
||||
if(j.eof) break;
|
||||
} while(off < size);
|
||||
return new Blob(parts, {type:'application/zip'});
|
||||
}
|
||||
|
||||
// Changement de discussion (bascule, « vider »), signalé par le flux SSE :
|
||||
// le panneau montre les fichiers de la discussion OUVERTE, il doit donc être
|
||||
// redessiné. On revient aussi à sa racine — le sous-dossier affiché était celui
|
||||
|
||||
@@ -14,12 +14,25 @@
|
||||
// titre, dates, nombre d'échanges — jamais les messages.
|
||||
|
||||
let CONVS = [], CONV_ACTIVE = '';
|
||||
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
|
||||
// premier affichage : le flux SSE n'a pas encore rejoué son journal, mais la
|
||||
// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
|
||||
// route, ou ouverte depuis un autre appareil).
|
||||
let CONV_BUSY = false;
|
||||
|
||||
async function loadConversations(){
|
||||
let r;
|
||||
try{ r = await jget('/api/conversations'); }catch(_){ return; }
|
||||
CONVS = r.conversations || [];
|
||||
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
|
||||
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
|
||||
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
|
||||
// fichier d'index pour les créations dans la même seconde.
|
||||
const ord = new Map(CONVS.map((c, i) => [c.id, i]));
|
||||
CONVS.sort((a, b) => (b.created || 0) - (a.created || 0)
|
||||
|| ord.get(b.id) - ord.get(a.id));
|
||||
CONV_ACTIVE = r.active || '';
|
||||
CONV_BUSY = !!r.busy;
|
||||
renderConversations();
|
||||
}
|
||||
|
||||
@@ -37,11 +50,16 @@ function renderConversations(){
|
||||
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
|
||||
cont.appendChild(e);
|
||||
syncTopbarTitle();
|
||||
convSyncBusy();
|
||||
return;
|
||||
}
|
||||
for(const c of list){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
|
||||
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
|
||||
// qui travaille sans redessiner la liste (un redessin relancerait
|
||||
// l'animation de l'anneau à chaque événement du flux).
|
||||
row.dataset.conv = c.id;
|
||||
row.onclick = () => convSwitch(c.id);
|
||||
|
||||
const info = document.createElement('div'); info.className = 'preset-info';
|
||||
@@ -74,6 +92,57 @@ function renderConversations(){
|
||||
cont.appendChild(row);
|
||||
}
|
||||
syncTopbarTitle();
|
||||
convSyncBusy();
|
||||
}
|
||||
|
||||
// --- Discussion au travail : anneau qui tourne ------------------------------
|
||||
// Une seule génération tourne à la fois, et c'est forcément sur la discussion
|
||||
// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
|
||||
// ligne active. Deux sources le renseignent — le flux SSE (`busy`, instantané)
|
||||
// et la liste (`CONV_BUSY`, utile avant que le rejeu ait rattrapé). Une fois le
|
||||
// rattrapage fini, l'état local fait foi : il est plus frais que la liste, qui
|
||||
// n'est rechargée qu'en fin de tour.
|
||||
function convBusyNow(){
|
||||
const live = (typeof busy !== 'undefined') && busy;
|
||||
if(typeof REPLAYING !== 'undefined' && !REPLAYING) return !!live;
|
||||
return CONV_BUSY || !!live;
|
||||
}
|
||||
|
||||
// Pose ou retire l'anneau dans un conteneur, SANS toucher au reste de son
|
||||
// contenu : réécrire l'hôte remettrait l'animation à zéro.
|
||||
function convSpinToggle(host, on, before){
|
||||
if(!host) return;
|
||||
let sp = host.querySelector(':scope > .conv-spin');
|
||||
if(on && !sp){
|
||||
sp = document.createElement('span');
|
||||
sp.className = 'conv-spin';
|
||||
sp.title = 'l\u2019agent travaille sur cette discussion\u2026';
|
||||
sp.setAttribute('role', 'img');
|
||||
sp.setAttribute('aria-label', 'génération en cours');
|
||||
sp.appendChild(icon('spinner', 14));
|
||||
if(before) host.insertBefore(sp, before); else host.appendChild(sp);
|
||||
} else if(!on && sp){
|
||||
sp.remove();
|
||||
}
|
||||
}
|
||||
|
||||
// Rafraîchit l'indicateur partout où il se montre : la ligne de la discussion
|
||||
// concernée, et l'en-tête (la barre latérale est escamotée sur téléphone — sans
|
||||
// ça, rien ne dirait que ça travaille).
|
||||
function convSyncBusy(){
|
||||
const on = convBusyNow();
|
||||
const cont = document.getElementById('conv-list');
|
||||
if(cont){
|
||||
for(const row of cont.querySelectorAll('.preset')){
|
||||
const mine = on && row.dataset.conv === CONV_ACTIVE;
|
||||
row.classList.toggle('working', mine);
|
||||
convSpinToggle(row, mine, row.firstChild);
|
||||
}
|
||||
}
|
||||
// Dans l'en-tête, l'anneau est un FRÈRE du titre : celui-ci tronque son texte
|
||||
// à l'ellipse, un enfant y serait rogné avec lui sur un titre long.
|
||||
const tb = document.getElementById('topbar-title');
|
||||
convSpinToggle(tb && tb.parentNode, on, tb);
|
||||
}
|
||||
|
||||
// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
|
||||
|
||||
@@ -25,6 +25,21 @@ function paintLabel(label, role){
|
||||
const who = document.createElement('span'); who.className = 'who';
|
||||
who.textContent = role === 'user' ? (IDENT.name || 'toi') : 'Loki';
|
||||
label.appendChild(av); label.appendChild(who);
|
||||
// Badge du modèle : quelle IA a produit CETTE réponse. Le nom vit dans
|
||||
// dataset.model (posé par tagModel, 09-stream.js) — c'est ce qui le fait
|
||||
// survivre au repeint des libellés lors d'un changement d'identité.
|
||||
if(role === 'assistant' && label.parentElement && label.parentElement.dataset.model){
|
||||
label.appendChild(modelBadge(label.parentElement.dataset.model));
|
||||
}
|
||||
}
|
||||
|
||||
// modelBadge fabrique la pastille du nom de modèle (partagée paintLabel/tagModel).
|
||||
function modelBadge(name){
|
||||
const b = document.createElement('span');
|
||||
b.className = 'modelbadge';
|
||||
b.textContent = name;
|
||||
b.title = 'modèle : ' + name;
|
||||
return b;
|
||||
}
|
||||
|
||||
async function loadIdentity(){
|
||||
|
||||
@@ -38,6 +38,12 @@ function openDetails(el){
|
||||
if(typeof el === 'string') el = document.getElementById(el);
|
||||
while(el){
|
||||
if(el.tagName === 'DETAILS') el.open = true;
|
||||
// La cible vit dans un panneau de la modale de réglages : ouvrir la modale
|
||||
// sur ce panneau. C'est ce qui garde tous les appelants historiques
|
||||
// (pastille d'état, jobs moteur) valides depuis la refonte en modale.
|
||||
if(el.classList && el.classList.contains('set-pane') && typeof openSettings === 'function'){
|
||||
openSettings(el.dataset.pane);
|
||||
}
|
||||
el = el.parentElement;
|
||||
}
|
||||
}
|
||||
@@ -46,7 +52,14 @@ function openDetails(el){
|
||||
// à cliquer la ligne correspondante dans les réglages (switchTo demande
|
||||
// confirmation puis relance le moteur).
|
||||
function onModelSwitch(sel){
|
||||
const n = parseInt(sel.value, 10);
|
||||
const v = sel.value;
|
||||
// Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
|
||||
// active garde son contexte et son échantillonnage, seul MODEL change.
|
||||
if(v && v.startsWith('m:')){
|
||||
switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
|
||||
return;
|
||||
}
|
||||
const n = parseInt(v, 10);
|
||||
if(!n) return;
|
||||
const name = sel.options[sel.selectedIndex].textContent;
|
||||
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
|
||||
@@ -55,6 +68,22 @@ function onModelSwitch(sel){
|
||||
switchTo(n, name);
|
||||
}
|
||||
|
||||
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
|
||||
// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
|
||||
// revient sur le modèle réellement chargé.
|
||||
async function switchModelFile(value, name){
|
||||
if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
|
||||
paintModelSwitch();
|
||||
return;
|
||||
}
|
||||
toast('changement de modèle…');
|
||||
let r = null;
|
||||
try{ r = await jpost('/api/models/use', {value}); }catch(_){}
|
||||
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
|
||||
toast('modèle en cours de chargement…');
|
||||
loadAll();
|
||||
}
|
||||
|
||||
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
|
||||
//
|
||||
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
|
||||
@@ -67,12 +96,22 @@ function onModelSwitch(sel){
|
||||
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
|
||||
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
|
||||
// en parallèle (loadAll), aucun ordre n'est garanti.
|
||||
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
|
||||
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
|
||||
|
||||
function renderModelSwitch(presets, active){
|
||||
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
|
||||
paintModelSwitch();
|
||||
}
|
||||
// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
|
||||
// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
|
||||
// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
|
||||
async function loadModelSwitchFiles(){
|
||||
try{
|
||||
const r = await jget('/api/models');
|
||||
MS_FILES = Array.isArray(r) ? r : [];
|
||||
}catch(_){ return; }
|
||||
paintModelSwitch();
|
||||
}
|
||||
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
|
||||
function setLoadedModel(name){
|
||||
MS_MODEL = (name || '').trim();
|
||||
@@ -99,14 +138,31 @@ function paintModelSwitch(){
|
||||
o.selected = true;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
MS_PRESETS.forEach((p, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(i + 1);
|
||||
o.textContent = p.name;
|
||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.disabled = !MS_PRESETS.length;
|
||||
if(MS_PRESETS.length){
|
||||
const g = document.createElement('optgroup'); g.label = 'Presets';
|
||||
MS_PRESETS.forEach((p, i) => {
|
||||
const o = document.createElement('option');
|
||||
o.value = String(i + 1);
|
||||
o.textContent = p.name;
|
||||
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
|
||||
g.appendChild(o);
|
||||
});
|
||||
sel.appendChild(g);
|
||||
}
|
||||
// Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
|
||||
// preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
|
||||
if(MS_FILES.length){
|
||||
const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
|
||||
for(const m of MS_FILES){
|
||||
const o = document.createElement('option');
|
||||
o.value = 'm:' + m.value;
|
||||
o.textContent = modelLabel(m.name);
|
||||
if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
|
||||
g.appendChild(o);
|
||||
}
|
||||
sel.appendChild(g);
|
||||
}
|
||||
sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
|
||||
sel.title = MS_MODEL
|
||||
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
|
||||
: 'aucun modèle configuré';
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// ===== Loupe d'image =======================================================
|
||||
// Les images du fil sont PLAFONNÉES (CSS .chatimg / .msg-img) : sans plafond,
|
||||
// une capture sortait à sa taille native et mangeait tout l'écran, ce qui rendait
|
||||
// la conversation illisible. Le plafond ne se paie donc pas en information : un
|
||||
// clic rouvre l'image ici, aussi grande que la fenêtre le permet.
|
||||
//
|
||||
// On réutilise la source DÉJÀ chargée par la vignette (blob: hydraté par
|
||||
// hydrateImages, ou objectURL local d'une pièce jointe en attente) : rien n'est
|
||||
// re-téléchargé, et ça marche derrière la clé de pilotage — qu'une balise <img>
|
||||
// ne sait pas porter, d'où le blob: en premier lieu.
|
||||
function lightboxEl(){
|
||||
let el=document.getElementById('lightbox');
|
||||
if(el) return el;
|
||||
el=document.createElement('div');
|
||||
el.id='lightbox';
|
||||
el.innerHTML='<button type="button" class="lb-x" title="fermer" aria-label="fermer">×</button>'
|
||||
+'<img alt=""><div class="lb-name"></div>';
|
||||
// Clic sur le FOND (ou sur le ×) = fermer ; clic sur l'image = ne rien faire,
|
||||
// sinon on referme dès qu'on veut la regarder de près.
|
||||
el.onclick=e=>{ if(e.target===el || e.target.classList.contains('lb-x')) closeLightbox(); };
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
// Capture (3e argument à true) : sans elle, Échap serait d'abord vu par les
|
||||
// écouteurs des modales déjà en place et la loupe ne se fermerait pas.
|
||||
function _lbKey(e){
|
||||
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); closeLightbox(); }
|
||||
}
|
||||
function openLightbox(src, name){
|
||||
if(!src) return;
|
||||
const el=lightboxEl(), img=el.querySelector('img');
|
||||
img.src=src;
|
||||
img.alt=name||'';
|
||||
el.querySelector('.lb-name').textContent=name||'';
|
||||
showModal(el);
|
||||
document.addEventListener('keydown', _lbKey, true);
|
||||
}
|
||||
function closeLightbox(){
|
||||
const el=document.getElementById('lightbox'); if(!el) return;
|
||||
document.removeEventListener('keydown', _lbKey, true);
|
||||
hideModal(el);
|
||||
// La source n'est lâchée qu'APRÈS la transition : la vider tout de suite
|
||||
// laissait un cadre vide clignoter pendant la fermeture.
|
||||
setTimeout(()=>{
|
||||
if(!el.classList.contains('show')) el.querySelector('img').removeAttribute('src');
|
||||
}, 200);
|
||||
}
|
||||
// Rend une image cliquable (idempotent : renderBody reconstruit le DOM à chaque
|
||||
// delta du streaming, et hydrateImages nous rappelle sur les mêmes éléments).
|
||||
//
|
||||
// On lit currentSrc/src AU CLIC et non à la liaison : l'hydratation remplace la
|
||||
// source par un blob: après coup, et figer l'URL /api/chat/image ici rouvrirait
|
||||
// une image que le navigateur n'a pas le droit de charger seul.
|
||||
function bindZoom(img, name){
|
||||
if(!img || img.dataset.zoom) return;
|
||||
img.dataset.zoom='1';
|
||||
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// 20-mode.js — le mode Chat/Code : sélecteur du composeur, puce de suggestion,
|
||||
// panneau des critères d'acceptation, carte de question (outil ask).
|
||||
// La bascule est PAR DISCUSSION et vit côté serveur (/api/chat/mode) : tous
|
||||
// les appareils voient le même mode, comme le fil lui-même.
|
||||
|
||||
let CURRENT_MODE = 'chat';
|
||||
|
||||
function modeSeg(){ return document.getElementById('mode-seg'); }
|
||||
|
||||
function paintMode(){
|
||||
const seg = modeSeg();
|
||||
if(!seg) return;
|
||||
seg.querySelector('[data-mode="chat"]').classList.toggle('on', CURRENT_MODE!=='code');
|
||||
seg.querySelector('[data-mode="code"]').classList.toggle('on', CURRENT_MODE==='code');
|
||||
// Le panneau des critères n'a de sens qu'en mode code.
|
||||
const box = document.getElementById('criteria-box');
|
||||
if(box && CURRENT_MODE!=='code') box.style.display='none';
|
||||
}
|
||||
|
||||
// Bascule demandée depuis CE navigateur.
|
||||
async function setMode(mode){
|
||||
if(mode===CURRENT_MODE) return;
|
||||
try{
|
||||
const r = await jfetch('/api/chat/mode', {method:'POST', body: JSON.stringify({mode})});
|
||||
const j = await r.json();
|
||||
if(j.ok){ CURRENT_MODE = j.mode; paintMode(); hideCodeHint(); }
|
||||
}catch(e){ toast('bascule impossible : '+e); }
|
||||
}
|
||||
|
||||
// Bascule reçue du serveur (autre appareil, ou écho de la nôtre).
|
||||
function applyModeDelta(mode){
|
||||
CURRENT_MODE = mode==='code' ? 'code' : 'chat';
|
||||
paintMode();
|
||||
if(CURRENT_MODE==='code') hideCodeHint();
|
||||
}
|
||||
|
||||
// Recharge le mode + critères au chargement et à chaque bascule de discussion.
|
||||
async function modeOnConvChange(){
|
||||
try{
|
||||
const r = await jfetch('/api/chat/mode');
|
||||
const j = await r.json();
|
||||
if(!j.ok) return;
|
||||
CURRENT_MODE = j.mode==='code' ? 'code' : 'chat';
|
||||
paintMode();
|
||||
renderCriteria(j.criteria||[]);
|
||||
hideCodeHint();
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// --- Puce « passer en mode Code ? » -----------------------------------------
|
||||
// Émise par le serveur (delta code_hint) quand un message de mode chat
|
||||
// ressemble à une tâche de code. Ignorable, jamais bloquante.
|
||||
|
||||
// Insère un bandeau DANS le composeur, au-dessus de la barre de saisie.
|
||||
// #composer est en position:absolute : un élément posé AVANT lui dans le DOM
|
||||
// reste dans le flux normal de la page et se retrouve donc CACHÉ dessous. Ses
|
||||
// enfants, eux, suivent sa colonne (--col) et son empilement.
|
||||
function composerBanner(id){
|
||||
const composer = document.getElementById('composer');
|
||||
if(!composer) return null;
|
||||
let el = document.getElementById(id);
|
||||
if(!el){
|
||||
el = document.createElement('div');
|
||||
el.id = id;
|
||||
el.className = 'composer-banner';
|
||||
composer.insertBefore(el, composer.firstChild);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
function showCodeHint(){
|
||||
if(CURRENT_MODE==='code' || document.getElementById('code-hint')) return;
|
||||
const chip = composerBanner('code-hint');
|
||||
if(!chip) return;
|
||||
chip.innerHTML = 'ça ressemble à une tâche de code — <button type="button" id="code-hint-yes">passer en mode Code</button><button type="button" id="code-hint-no" title="ignorer" aria-label="ignorer">✕</button>';
|
||||
chip.querySelector('#code-hint-yes').onclick = ()=>{ setMode('code'); };
|
||||
chip.querySelector('#code-hint-no').onclick = hideCodeHint;
|
||||
}
|
||||
|
||||
function hideCodeHint(){
|
||||
const chip = document.getElementById('code-hint');
|
||||
if(chip) chip.remove();
|
||||
}
|
||||
|
||||
// --- Panneau des critères ----------------------------------------------------
|
||||
// Rendu par les deltas {criteria:[...]} (outil criteria, passe de vérif, ou
|
||||
// édition manuelle). Repliable ; en-tête = compteur passés / total.
|
||||
|
||||
function renderCriteria(list){
|
||||
let box = document.getElementById('criteria-box');
|
||||
if(!list || !list.length){ if(box) box.remove(); return; }
|
||||
if(CURRENT_MODE!=='code'){ if(box) box.remove(); return; }
|
||||
const fresh = !box;
|
||||
box = composerBanner('criteria-box');
|
||||
if(!box) return;
|
||||
if(fresh){
|
||||
box.innerHTML = '<div id="criteria-head"></div><ul id="criteria-list"></ul>';
|
||||
box.querySelector('#criteria-head').onclick = ()=> box.classList.toggle('folded');
|
||||
// Repliée par défaut : cinq critères dépliés mangeaient la moitié de
|
||||
// l'écran au-dessus de la saisie. L'en-tête porte déjà le compteur.
|
||||
box.classList.add('folded');
|
||||
}
|
||||
box.style.display='';
|
||||
const passed = list.filter(c=>c.status==='passed').length;
|
||||
box.querySelector('#criteria-head').textContent =
|
||||
'critères ' + passed + '/' + list.length + (passed===list.length ? ' ✓' : '');
|
||||
box.classList.toggle('allpass', passed===list.length);
|
||||
const ul = box.querySelector('#criteria-list');
|
||||
ul.innerHTML='';
|
||||
for(const c of list){
|
||||
const li = document.createElement('li');
|
||||
li.className = 'crit-'+(c.status||'pending');
|
||||
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
|
||||
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
|
||||
ul.appendChild(li);
|
||||
}
|
||||
}
|
||||
|
||||
function onVerifyDone(status){
|
||||
if(status==='passed') toast('vérification : tous les critères passent ✓');
|
||||
}
|
||||
|
||||
// --- Carte de question (outil ask) ------------------------------------------
|
||||
// Le modèle a posé une question structurée et clos son tour : la carte propose
|
||||
// ses options en boutons ; cliquer envoie la réponse comme un message normal.
|
||||
|
||||
function renderAskCard(ask){
|
||||
if(!ask || !ask.question) return;
|
||||
removeTyping();
|
||||
const el = addMsg('assistant', '');
|
||||
el.classList.add('askcard');
|
||||
const body = el.querySelector('.body');
|
||||
body.textContent = ask.question;
|
||||
if(ask.options && ask.options.length){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'askopts';
|
||||
for(const opt of ask.options){
|
||||
const b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.textContent = opt;
|
||||
b.onclick = ()=>{
|
||||
// La réponse part comme un message utilisateur ordinaire.
|
||||
const input = document.getElementById('input');
|
||||
input.value = opt;
|
||||
send();
|
||||
row.querySelectorAll('button').forEach(x=>x.disabled=true);
|
||||
};
|
||||
row.appendChild(b);
|
||||
}
|
||||
body.appendChild(row);
|
||||
}
|
||||
scrollMaybe();
|
||||
}
|
||||
|
||||
// Init : état du mode au chargement de la page.
|
||||
window.addEventListener('DOMContentLoaded', modeOnConvChange);
|
||||
@@ -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;
|
||||
}
|
||||
+361
-111
@@ -143,11 +143,11 @@ select{color-scheme:inherit}
|
||||
option,optgroup{background:var(--panel);color:var(--text)}
|
||||
.side{width:320px;border-right:1px solid var(--border);padding:16px;padding-bottom:calc(16px + env(safe-area-inset-bottom));overflow-y:auto;flex-shrink:0}
|
||||
.main{flex:1;display:flex;flex-direction:column;min-width:0}
|
||||
#menubtn{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(10px + env(safe-area-inset-left));z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:6px;font-size:18px;cursor:pointer}
|
||||
#menubtn{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(10px + env(safe-area-inset-left));z-index:20;background:var(--panel);border:1px solid var(--border);color:var(--text);width:40px;height:40px;border-radius:3px;font-size:18px;cursor:pointer}
|
||||
#backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:10}
|
||||
/* Pastille « installation en cours » : suit exactement les règles d'affichage
|
||||
du bouton ☰ (elle ne sert que quand le panneau est en tiroir, donc caché). */
|
||||
#lc-chip{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(58px + env(safe-area-inset-left));z-index:20;max-width:60vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--panel);border:1px solid var(--accent);color:var(--accent);height:40px;padding:0 12px;border-radius:6px;font-size:12px;cursor:pointer}
|
||||
#lc-chip{display:none;position:fixed;top:calc(10px + env(safe-area-inset-top));left:calc(58px + env(safe-area-inset-left));z-index:20;max-width:60vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--panel);border:1px solid var(--accent);color:var(--accent);height:40px;padding:0 12px;border-radius:3px;font-size:12px;cursor:pointer}
|
||||
#lc-chip.failed{border-color:var(--err);color:var(--err)}
|
||||
@media (max-width:720px){
|
||||
.side{position:fixed;inset:0 auto 0 0;width:80%;max-width:320px;z-index:15;transform:translateX(-100%);transition:transform .2s;background:var(--bg);padding-top:calc(16px + env(safe-area-inset-top));padding-left:calc(16px + env(safe-area-inset-left))}
|
||||
@@ -170,7 +170,7 @@ option,optgroup{background:var(--panel);color:var(--text)}
|
||||
/* Desktop : menu plus large et bulles (badges) un peu plus grosses. */
|
||||
@media (min-width:721px){
|
||||
.side{width:380px}
|
||||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:9px}
|
||||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag{font-size:11px;padding:2px 9px;border-radius:4px}
|
||||
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg{width:11px;height:11px}
|
||||
}
|
||||
h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
@@ -193,7 +193,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
sans souris. */
|
||||
.conv-acts{display:flex;gap:2px;opacity:0;transition:opacity .12s}
|
||||
.preset:hover .conv-acts,.preset:focus-within .conv-acts{opacity:1}
|
||||
.conv-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer}
|
||||
.conv-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}
|
||||
.conv-acts .iconbtn:hover{border-color:var(--border);color:var(--text)}
|
||||
/* Séparateur de groupe dans la barre latérale : sépare les réglages d'IA des
|
||||
réglages d'application, sans imbriquer un second niveau de repli (illisible
|
||||
@@ -203,7 +203,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
/* Choix d'avatar : une grille de pastilles emoji, celle retenue encadrée. */
|
||||
.avpick{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
|
||||
.avpick button{font-size:15px;line-height:1;padding:5px 6px;background:var(--panel);
|
||||
border:1px solid var(--border);border-radius:8px;cursor:pointer}
|
||||
border:1px solid var(--border);border-radius:4px;cursor:pointer}
|
||||
.avpick button:hover{border-color:var(--accent)}
|
||||
.avpick button.sel{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 15%,transparent)}
|
||||
/* Avatar + nom en tête de bulle : l'emoji garde sa taille propre, le nom hérite
|
||||
@@ -212,7 +212,7 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
.msg .label .who{text-transform:none;letter-spacing:.04em;font-size:11px}
|
||||
/* Captures d'écran et images de l'agent : bornées à la largeur de la bulle, et
|
||||
cliquables pour ouvrir en grand dans un onglet. */
|
||||
.msg .body img{max-width:100%;height:auto;border-radius:8px;border:1px solid var(--border);display:block;margin:8px 0}
|
||||
.msg .body img{max-width:100%;height:auto;border-radius:4px;border:1px solid var(--border);display:block;margin:8px 0}
|
||||
/* pastille d'état service, en haut à droite de la barre latérale */
|
||||
.statuspill{display:inline-flex;align-items:center;gap:6px;font-size:11px;padding:3px 10px;border-radius:999px;border:1px solid var(--border);background:var(--panel);color:var(--dim);font-weight:600;white-space:nowrap;cursor:pointer}
|
||||
.statuspill.ok{color:var(--ok);border-color:color-mix(in srgb,var(--ok) 45%,transparent);background:color-mix(in srgb,var(--ok) 12%,transparent)}
|
||||
@@ -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
|
||||
@@ -235,15 +237,15 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
.pages-bar{display:flex;align-items:center;gap:8px;cursor:pointer;user-select:none;padding:7px 0;margin-top:4px}
|
||||
.pages-bar .pages-title{font-size:11.5px;color:var(--dim);font-weight:600}
|
||||
.pages-bar .addbtn{margin-left:auto}
|
||||
.mem-search{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 9px;font:inherit;font-size:12px;margin:0 0 6px}
|
||||
.mem-search{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:5px 9px;font:inherit;font-size:12px;margin:0 0 6px}
|
||||
.mem-search:focus{outline:none;border-color:var(--accent)}
|
||||
.mem-list{max-height:240px;overflow-y:auto;overscroll-behavior:contain}
|
||||
.mem-more{font-size:11px;color:var(--dim);text-align:center;padding:6px 0 2px}
|
||||
.ask-overlay{position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:60;display:flex;align-items:center;justify-content:center;padding:20px}
|
||||
.ask-box{background:var(--bg);border:1px solid var(--border);border-radius:10px;width:100%;max-width:420px;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,.18)}
|
||||
.ask-box{background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:420px;padding:20px;box-shadow:0 12px 40px rgba(0,0,0,.18)}
|
||||
.ask-title{font-size:15px;font-weight:600;color:var(--text);margin-bottom:8px}
|
||||
.ask-msg{font-size:13px;color:var(--dim);line-height:1.5;white-space:pre-wrap;word-break:break-word}
|
||||
.ask-input{width:100%;box-sizing:border-box;margin-top:12px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:9px 11px;font:inherit;font-size:13px}
|
||||
.ask-input{width:100%;box-sizing:border-box;margin-top:12px;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:9px 11px;font:inherit;font-size:13px}
|
||||
.ask-input:focus{outline:none;border-color:var(--accent)}
|
||||
.ask-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}
|
||||
.ask-actions button{margin:0;padding:7px 16px}
|
||||
@@ -275,17 +277,17 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
.switch input:focus-visible + .slider{box-shadow:0 0 0 2px var(--bg),0 0 0 4px var(--accent)}
|
||||
h2{margin:20px 0 8px;font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;font-weight:600}
|
||||
details>summary::-webkit-details-marker{display:none}
|
||||
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;cursor:pointer}
|
||||
.preset-add{display:none;font-size:14px;padding:0 8px;margin:0 8px 0 auto;line-height:1.4;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;cursor:pointer}
|
||||
.preset-add:hover{border-color:var(--accent);color:var(--accent)}
|
||||
details[open]>summary>.preset-add{display:inline-block}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
|
||||
.tag{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;background:var(--panel);border:1px solid var(--border)}
|
||||
.tag.ok{color:var(--ok);border-color:var(--ok)}
|
||||
.tag.err{color:var(--err);border-color:var(--err)}
|
||||
button{background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 10px;font:inherit;cursor:pointer;margin:2px}
|
||||
button{background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:6px 10px;font:inherit;cursor:pointer;margin:2px}
|
||||
button:hover{border-color:var(--accent);color:var(--accent)}
|
||||
button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.row{display:flex;gap:4px;flex-wrap:wrap}
|
||||
.preset{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;margin:4px 0;cursor:pointer;background:var(--panel)}
|
||||
.preset{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 10px;border:1px solid var(--border);border-radius:3px;margin:4px 0;cursor:pointer;background:var(--panel)}
|
||||
.preset:hover{border-color:var(--accent)}
|
||||
.preset.active{border-color:var(--accent);color:var(--accent);border-width:1.5px;padding:5.5px 9.5px}
|
||||
.preset>span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
@@ -301,9 +303,9 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
/* La carte (.slist) laisse volontairement déborder son contenu (menus), elle ne
|
||||
rogne donc pas ses coins : c'est la LIGNE de bord qui porte l'arrondi, sinon
|
||||
le voile dépasse l'angle de la carte. */
|
||||
.rowlist:first-child>.preset:first-child{border-radius:8px 8px 0 0}
|
||||
.rowlist:last-child>.preset:last-child{border-radius:0 0 8px 8px}
|
||||
.rowlist:first-child:last-child>.preset:only-child{border-radius:8px}
|
||||
.rowlist:first-child>.preset:first-child{border-radius:4px 4px 0 0}
|
||||
.rowlist:last-child>.preset:last-child{border-radius:0 0 4px 4px}
|
||||
.rowlist:first-child:last-child>.preset:only-child{border-radius:4px}
|
||||
/* Liseré de gauche : un trait DROIT (pas un arc) qui court sur TOUTE la hauteur
|
||||
de la ligne. Donc pas de box-shadow interne (l'arrondi le courberait) mais un
|
||||
élément à part, aux bords francs, que l'arrondi de la ligne de bord se
|
||||
@@ -355,15 +357,15 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
margin:0 -2px 0 0;padding:4px;line-height:0;background:transparent;border:none;opacity:.55}
|
||||
.preset-edit:hover{opacity:1;color:var(--accent);background:transparent}
|
||||
.preset-meta{display:flex;align-items:center;gap:6px;margin-top:3px;flex-wrap:wrap}
|
||||
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em;text-transform:uppercase}
|
||||
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em}
|
||||
.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:8px;letter-spacing:.03em}
|
||||
.qtag{flex-shrink:0;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em;text-transform:uppercase}
|
||||
.rtag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
.btag{flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
/* Petit « ? » d'aide : bulle au survol/clic au lieu d'un pavé de texte permanent. */
|
||||
.help{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;margin-left:6px;flex-shrink:0;font-size:10px;font-weight:600;line-height:1;color:var(--dim);border:1px solid var(--border);border-radius:50%;cursor:help;user-select:none;vertical-align:middle}
|
||||
.help:hover{color:var(--accent);border-color:var(--accent)}
|
||||
/* La bulle est ancrée à la ligne (.switchrow position:relative), pas au « ? »,
|
||||
pour rester alignée au bord gauche du panneau et ne pas déborder. */
|
||||
.help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.15);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;font-family:var(--font);cursor:auto}
|
||||
.help>.tip{display:none;position:absolute;z-index:40;left:0;right:0;top:calc(100% + 2px);padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:3px;font-size:11px;font-weight:400;line-height:1.4;color:var(--text);box-shadow:0 4px 16px rgba(0,0,0,.15);white-space:normal;text-align:left;text-transform:none;letter-spacing:normal;font-family:var(--font);cursor:auto}
|
||||
.help:hover>.tip,.help:focus>.tip,.help.open>.tip{display:block}
|
||||
.kv{display:flex;justify-content:space-between;gap:10px;padding:9px 12px;border-bottom:1px solid var(--border);font-size:13px;min-height:38px;align-items:center}
|
||||
.kv span:first-child{color:var(--text);flex-shrink:0}
|
||||
@@ -378,7 +380,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
#chat{flex:1;min-height:0;overflow-y:auto;overscroll-behavior:contain;padding:20px;display:flex;flex-direction:column;gap:14px}
|
||||
:root{--col:max(20px,calc(50% - 450px))}
|
||||
@media (max-width:720px){#chat{padding:60px 8px 12px}}
|
||||
.msg{max-width:80%;padding:10px 14px;border-radius:10px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
|
||||
.msg{max-width:80%;padding:10px 14px;border-radius:4px;white-space:pre-wrap;word-wrap:break-word;line-height:1.45}
|
||||
.msg.assistant,.msg.reasoning{white-space:normal}
|
||||
.msg .body{overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.user{align-self:flex-end;background:var(--bubble);color:var(--text)}
|
||||
@@ -394,8 +396,8 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.msg .body li{margin:.15em 0}
|
||||
.msg .body li>p{margin:0}
|
||||
.msg.assistant .body code{background:var(--code-bg);padding:1px 5px;border-radius:3px;font-size:.9em;overflow-wrap:anywhere;word-break:break-word}
|
||||
.msg.assistant .body pre,.msg.reasoning .body pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
|
||||
.copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:5px;opacity:.5;transition:opacity .15s,color .15s}
|
||||
.msg.assistant .body pre,.msg.reasoning .body pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:10px;overflow-x:auto;max-width:100%;margin:.5em 0}
|
||||
.copybtn{position:absolute;bottom:6px;right:6px;font-size:11px;padding:3px 8px;margin:0;background:var(--panel);border:1px solid var(--border);color:var(--dim);border-radius:3px;opacity:.5;transition:opacity .15s,color .15s}
|
||||
.msg.assistant .body pre:hover .copybtn,.msg.reasoning .body pre:hover .copybtn{opacity:1}
|
||||
.copybtn:hover{border-color:var(--accent);color:var(--accent)}
|
||||
.copybtn.done{color:var(--ok);border-color:var(--ok);opacity:1}
|
||||
@@ -429,7 +431,7 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.diff-count .a{color:var(--add)}
|
||||
.diff-count .d{color:var(--del)}
|
||||
.msg.tool .tool-sub{font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:.1em;margin:8px 0 4px}
|
||||
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:6px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
|
||||
.msg.tool pre{position:relative;background:var(--code-bg);border:1px solid var(--border);border-radius:3px;padding:8px 10px;overflow:auto;margin:0;max-height:280px}
|
||||
.msg.tool pre code{background:transparent;padding:0;font-size:.82em;line-height:1.4;white-space:pre}
|
||||
.msg.tool .tool-caret{color:var(--accent);animation:toolblink 1s step-end infinite}
|
||||
.msg.tool .tool-wait{font-size:12px;color:var(--dim);margin-top:8px;animation:toolblink 1.2s ease-in-out infinite}
|
||||
@@ -444,7 +446,12 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
mini-bannière au-dessus de la saisie doublonnait les 3 points animés — deux
|
||||
éléments pour un seul état d'attente. Ici les points passent en accent et un
|
||||
libellé apparaît à côté : un seul objet à l'écran, au bon endroit. */
|
||||
.msg.typing.compacting span:not(.tlabel){background:var(--accent)}
|
||||
.msg.typing.compacting span:not(.tlabel):not(.wclock){background:var(--accent)}
|
||||
/* Compteur de travail sur l'indicateur « … » : il annule la mise en forme de
|
||||
pastille que `.msg.typing span` applique à TOUT span de la bulle. */
|
||||
.msg.typing .wclock{width:auto;height:auto;border-radius:0;background:none;animation:none;
|
||||
font-family:var(--mono);font-size:11px;letter-spacing:.02em;color:var(--dim);
|
||||
margin-left:7px;white-space:nowrap}
|
||||
.msg.typing .tlabel{width:auto;height:auto;border-radius:0;background:none;animation:tlabelfade 1.6s ease-in-out infinite;font-size:12px;color:var(--accent);margin-left:3px;white-space:nowrap}
|
||||
@keyframes tlabelfade{0%,100%{opacity:.6}50%{opacity:1}}
|
||||
/* Trace laissée DANS le fil quand le contexte a été compacté : un séparateur
|
||||
@@ -567,7 +574,7 @@ details>summary+.subhead{margin-top:4px}
|
||||
rapport à la ligne, un débordement caché les TRONQUERAIT — elles doivent
|
||||
pouvoir déborder de la carte. Les lignes n'ont pas de fond propre, l'arrondi
|
||||
n'a donc rien à rogner. */
|
||||
.slist{background:var(--panel);border:1px solid var(--border);border-radius:9px;
|
||||
.slist{background:var(--panel);border:1px solid var(--border);border-radius:4px;
|
||||
overflow:visible;margin:0 0 8px}
|
||||
.slist:last-child{margin-bottom:0}
|
||||
/* Contenu libre (champ + boutons) : le rembourrage passe sur la carte. */
|
||||
@@ -616,10 +623,10 @@ details>summary+.subhead{margin-top:4px}
|
||||
|
||||
/* --- Champs et boutons --- */
|
||||
.sctl,.io-input{width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);
|
||||
border-radius:7px;padding:7px 9px;font:inherit;font-size:12.5px}
|
||||
border-radius:3px;padding:7px 9px;font:inherit;font-size:12.5px}
|
||||
.sctl:focus,.io-input:focus{outline:none;border-color:var(--dim)}
|
||||
textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.side button:not(.preset-edit):not(.addbtn):not(.preset-add):not(.copybtn):not(.iconbtn){font-size:12px;padding:6px 10px;border-radius:7px}
|
||||
.side button:not(.preset-edit):not(.addbtn):not(.preset-add):not(.copybtn):not(.iconbtn){font-size:12px;padding:6px 10px;border-radius:3px}
|
||||
.side .row{display:flex;gap:6px;align-items:center;margin:6px 0}
|
||||
.side .row .sctl,.side .row .io-input{flex:1;min-width:0}
|
||||
/* Bouton d'action à côté d'un champ (copier, ouvrir) : un CARRÉ à la hauteur du
|
||||
@@ -627,7 +634,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
posait de travers et changeait de largeur d'une police à l'autre. */
|
||||
.iconbtn{flex:none;display:inline-flex;align-items:center;justify-content:center;
|
||||
width:34px;height:34px;padding:0;margin:0;color:var(--dim);background:var(--panel);
|
||||
border:1px solid var(--border);border-radius:9px;cursor:pointer}
|
||||
border:1px solid var(--border);border-radius:4px;cursor:pointer}
|
||||
.iconbtn:hover{color:var(--text);border-color:var(--dim);background:var(--accent-bg)}
|
||||
.iconbtn svg{width:15px;height:15px;display:block}
|
||||
.side .btn-danger-o{border-color:var(--err);color:var(--err)}
|
||||
@@ -651,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
|
||||
@@ -670,10 +687,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
#sendhint{padding:0 12px 6px;font-size:10px;text-align:center;opacity:.7}
|
||||
/* Moteur pas encore prêt : l'aide clavier cède la place à l'état du chargement. */
|
||||
#sendhint.waiting{opacity:1;color:var(--dim)}
|
||||
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
|
||||
#input{flex:1;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:10px;font:inherit;resize:none;min-height:56px;max-height:200px;overflow-y:hidden}
|
||||
#input:focus{outline:none;border-color:var(--accent)}
|
||||
.muted{color:var(--dim);font-size:13px}
|
||||
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:8px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)}
|
||||
#toast{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:50;background:var(--panel);border:1px solid var(--border);padding:8px 16px;border-radius:4px;opacity:0;transition:opacity .2s;pointer-events:none;box-shadow:0 8px 30px rgba(0,0,0,.18)}
|
||||
#toast.show{opacity:1}
|
||||
@keyframes spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
|
||||
/* iOS Safari auto-zoome au focus sur tout champ < 16px. On force 16px sur les
|
||||
@@ -697,7 +714,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
rétrécit le contenu. Padding interne normal → largeur utile pleine. */
|
||||
.side{width:300px;max-width:300px;left:env(safe-area-inset-left);padding:calc(14px + env(safe-area-inset-top)) 14px calc(14px + env(safe-area-inset-bottom)) 14px}
|
||||
#chat{padding:48px 10px 6px;gap:7px}
|
||||
.msg{padding:6px 10px;border-radius:8px;max-width:78%}
|
||||
.msg{padding:6px 10px;border-radius:4px;max-width:78%}
|
||||
#chat .msg.assistant,#chat .msg.reasoning{max-width:88%}
|
||||
#inputbar{padding:5px 8px;gap:6px}
|
||||
/* PAS de font-size < 16px ici : iOS Safari zoome à la sélection du champ. On
|
||||
@@ -724,7 +741,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.lc-mode-t{font-size:13px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:6px}
|
||||
/* « conseillé » : pilule PLEINE (texte inversé). En contour, elle héritait du
|
||||
gris atténué du thème monochrome et devenait illisible en mode sombre. */
|
||||
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:8px;padding:1px 6px}
|
||||
.lc-reco{font-size:9px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;background:var(--text);color:var(--bg);border:1px solid var(--text);border-radius:4px;padding:1px 6px}
|
||||
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
|
||||
/* Hauteur réservée : la ligne d'état arrive après l'appel réseau, sans elle
|
||||
les trois cartes grandissaient d'un cran une fois l'état connu. */
|
||||
@@ -753,7 +770,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
affichée avec une seule carte). */
|
||||
.pe-group[hidden]{display:none}
|
||||
.pe-gh{font-size:12px;font-weight:600;color:var(--dim);padding:0 6px}
|
||||
.pe-list{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
|
||||
.pe-list{background:var(--panel);border:1px solid var(--border);border-radius:3px;overflow:hidden}
|
||||
/* Ligne : libellé à gauche, contrôle à droite, séparateur fin entre lignes. */
|
||||
.pe-row{display:flex;align-items:center;gap:14px;min-height:48px;padding:9px 15px}
|
||||
/* Séparateur porté par la ligne SUIVANTE (border-top), pas par la précédente
|
||||
@@ -806,9 +823,9 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.gpu-legend i.alt{background:color-mix(in srgb,var(--accent) 42%,transparent)}
|
||||
.gpu-shares{display:flex;flex-wrap:wrap;gap:10px}
|
||||
.gpu-share{display:flex;align-items:center;gap:5px;font-size:12px;color:var(--dim)}
|
||||
.gpu-share input{width:62px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:7px;padding:5px 7px;font:inherit;text-align:right}
|
||||
.gpu-share input{width:62px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:5px 7px;font:inherit;text-align:right}
|
||||
.gpu-share input:focus{outline:none;border-color:var(--accent)}
|
||||
.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:9px 11px;font:inherit;transition:border-color .12s,box-shadow .12s}
|
||||
.pe-inp{width:100%;box-sizing:border-box;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:9px 11px;font:inherit;transition:border-color .12s,box-shadow .12s}
|
||||
.pe-inp:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
.pe-row.stack.plain .pe-inp{border:none;background:transparent;padding:6px 0;font-size:15px}
|
||||
.pe-row.stack.plain .pe-inp:focus{box-shadow:none}
|
||||
@@ -819,13 +836,13 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
/* Le bouton reprend EXACTEMENT le cadre du champ (mêmes coins, même bordure) :
|
||||
sans ça, un thème qui arrondit tous les boutons (ChatGPT) le détachait du
|
||||
champ, avec un coin rond au milieu de la paire. */
|
||||
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-radius:0 8px 8px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s,background .12s}
|
||||
.pe-inpbtn button{flex:none;margin:0;border:1px solid var(--border);border-radius:0 4px 4px 0;background:var(--panel);color:var(--dim);padding:0 15px;cursor:pointer;font-size:15px;transition:color .12s,border-color .12s,background .12s}
|
||||
.pe-inpbtn button:hover:not(:disabled){color:var(--bg);background:var(--accent);border-color:var(--accent)}
|
||||
.pe-inpbtn:focus-within .pe-inp,.pe-inpbtn:focus-within button{border-color:var(--accent)}
|
||||
.pe-inpbtn button:disabled{opacity:.5;cursor:default}
|
||||
/* Contrôle segmenté (moteur) façon iOS. */
|
||||
.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:9px;padding:3px;width:100%}
|
||||
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:7px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s}
|
||||
.pe-seg{display:flex;gap:3px;background:color-mix(in srgb,var(--dim) 16%,transparent);border-radius:4px;padding:3px;width:100%}
|
||||
.pe-seg>label{flex:1;text-align:center;font-size:13px;font-weight:500;padding:7px 4px;border-radius:3px;cursor:pointer;color:var(--dim);transition:background .12s,color .12s}
|
||||
.pe-seg>label:has(input:checked){background:var(--panel);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.22)}
|
||||
.pe-seg input{display:none}
|
||||
.pe-seg .be-note{display:none}
|
||||
@@ -851,7 +868,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.pe-switch input:checked+.pe-sl::before{transform:translateX(16px);background:var(--bg)}
|
||||
.pe-switch input:focus-visible+.pe-sl{box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
/* Repli « avancé » / config brute : une ligne cliquable dans un groupe. */
|
||||
.pe-adv{background:var(--panel);border:1px solid var(--border);border-radius:12px;overflow:hidden}
|
||||
.pe-adv{background:var(--panel);border:1px solid var(--border);border-radius:3px;overflow:hidden}
|
||||
.pe-adv>summary{cursor:pointer;font-size:14px;color:var(--text);padding:13px 15px;list-style:none;display:flex;align-items:center;user-select:none}
|
||||
.pe-adv>summary::-webkit-details-marker{display:none}
|
||||
.pe-adv>summary::after{content:"";margin-left:auto;width:7px;height:7px;border-right:1.5px solid var(--dim);border-bottom:1.5px solid var(--dim);transform:translateY(-2px) rotate(45deg);transition:transform .15s}
|
||||
@@ -874,7 +891,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
align-items:center;gap:8px;flex-wrap:nowrap}
|
||||
.pe-foot .foot-l{display:flex;align-items:center;gap:8px;min-width:0}
|
||||
.pe-foot .foot-r{display:flex;gap:8px;flex:none}
|
||||
.pe-btn{margin:0;padding:7px 15px;font-size:12.5px;border-radius:8px;font-weight:600;cursor:pointer;
|
||||
.pe-btn{margin:0;padding:7px 15px;font-size:12.5px;border-radius:4px;font-weight:600;cursor:pointer;
|
||||
border:1px solid var(--border);background:var(--panel);color:var(--text);white-space:nowrap}
|
||||
.pe-btn:hover{border-color:var(--dim)}
|
||||
.pe-btn.primary{border-color:var(--accent);background:var(--accent);color:var(--bg)}
|
||||
@@ -883,7 +900,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
autant que « enregistrer » alors que c'est l'action rare — et à trois boutons
|
||||
larges, le pied passait à deux lignes sur téléphone. */
|
||||
.pe-del{flex:none;display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;
|
||||
margin:0;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:var(--err);cursor:pointer}
|
||||
margin:0;padding:0;border:1px solid transparent;border-radius:4px;background:transparent;color:var(--err);cursor:pointer}
|
||||
.pe-del svg{width:15px;height:15px;display:block}
|
||||
.pe-del:hover{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 40%,transparent)}
|
||||
/* Option d'une confirmation : une PASTILLE à cocher, pas une case système suivie
|
||||
@@ -914,7 +931,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
@media (prefers-reduced-motion:reduce){
|
||||
.modal-ov,.ask-overlay,.modal-card,.ask-box{transition:none}
|
||||
}
|
||||
.modal-card{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:12px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.18)}
|
||||
.modal-card{position:relative;background:var(--bg);border:1px solid var(--border);border-radius:3px;width:100%;max-width:600px;max-height:90vh;display:flex;flex-direction:column;box-shadow:0 16px 50px rgba(0,0,0,.18)}
|
||||
.modal-head{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||
.modal-head strong{font-size:14px}
|
||||
/* Croix de fermeture : un CARRÉ, pas un rectangle. Avec un padding asymétrique
|
||||
@@ -935,7 +952,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
preset au nouveau SOUS LES YEUX, une fois le chargement terminé. */
|
||||
.modal-ov.loading .pe-body *,.modal-ov.no-anim .pe-body *{transition:none!important}
|
||||
.modal-load{display:none;position:absolute;inset:var(--mh,52px) 0 0 0;z-index:2;
|
||||
align-items:center;justify-content:center;background:var(--bg);border-radius:0 0 12px 12px}
|
||||
align-items:center;justify-content:center;background:var(--bg);border-radius:0 0 5px 5px}
|
||||
.modal-ov.loading .modal-load{display:flex}
|
||||
/* visibility (et non display) : le formulaire garde sa place, donc la carte a
|
||||
déjà sa taille finale — la révéler ne provoque plus d'à-coup de hauteur. */
|
||||
@@ -956,10 +973,10 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.fld-l{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--dim)}
|
||||
.fld-l .opt{text-transform:none;letter-spacing:0;font-weight:400;opacity:.65}
|
||||
.fld-hint{font-size:11px;color:var(--dim);margin-top:2px}
|
||||
.ctl{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:7px;padding:9px 11px;font:inherit}
|
||||
.ctl{width:100%;box-sizing:border-box;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:3px;padding:9px 11px;font:inherit}
|
||||
.ctl:focus{outline:none;border-color:var(--accent)}
|
||||
.ta{min-height:220px;resize:vertical;line-height:1.5}
|
||||
.card{border:1px solid var(--border);border-radius:10px;padding:13px;display:flex;flex-direction:column;gap:13px}
|
||||
.card{border:1px solid var(--border);border-radius:4px;padding:13px;display:flex;flex-direction:column;gap:13px}
|
||||
.card-h{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--accent)}
|
||||
.row{display:flex;gap:6px}
|
||||
.row .ctl{flex:1;min-width:0}
|
||||
@@ -969,7 +986,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.input-btn .btn-in{flex-shrink:0;margin:0;padding:9px 13px;border-left:0;border-top-left-radius:0;border-bottom-left-radius:0}
|
||||
.input-btn .btn-in:hover{border-left:0}
|
||||
.seg{display:flex;gap:6px}
|
||||
.seg>label{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;min-height:46px;padding:6px 4px;border:1px solid var(--border);border-radius:8px;cursor:pointer;font-size:12px;background:var(--panel);transition:border-color .12s,background .12s,color .12s}
|
||||
.seg>label{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:2px;text-align:center;min-height:46px;padding:6px 4px;border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:12px;background:var(--panel);transition:border-color .12s,background .12s,color .12s}
|
||||
.seg>label:hover{border-color:var(--accent)}
|
||||
.seg>label:has(input:checked){border-color:var(--accent);background:var(--accent-bg);color:var(--accent)}
|
||||
.seg input{display:none}
|
||||
@@ -981,12 +998,12 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.lc-logbar{display:flex;align-items:center;gap:6px;cursor:pointer;user-select:none;font-size:12px;color:var(--dim);font-weight:600;padding:6px 0;margin-top:2px}
|
||||
.lc-logbar #lc-log-caret{font-size:9px;transition:transform .12s;display:inline-block}
|
||||
.lc-logbar.open #lc-log-caret{transform:rotate(90deg)}
|
||||
#lc-log{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
|
||||
#lc-log{background:var(--panel);border:1px solid var(--border);border-radius:3px;padding:8px;font-size:10px;line-height:1.45;max-height:220px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
|
||||
.lc-spin{display:inline-block;animation:spin 1s linear infinite}
|
||||
/* Serveurs MCP */
|
||||
.mcp-list{display:flex;flex-direction:column;gap:4px}
|
||||
|
||||
.mcp-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel);cursor:pointer}
|
||||
.mcp-row{display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--border);border-radius:3px;background:var(--panel);cursor:pointer}
|
||||
.mcp-row:hover{border-color:var(--accent)}
|
||||
.mcp-row.off{opacity:.6}
|
||||
.mcp-dot{flex:none;width:8px;height:8px;border-radius:50%}
|
||||
@@ -996,7 +1013,7 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.mcp-info{flex:1;min-width:0}
|
||||
.mcp-name{font-size:12px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.mcp-meta{display:flex;align-items:center;gap:6px;margin-top:2px}
|
||||
.mcp-tag{font-size:10px;padding:1px 7px;border-radius:9px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
||||
.mcp-tag{font-size:10px;padding:1px 7px;border-radius:4px;border:1px solid var(--border);color:var(--dim);text-transform:uppercase;letter-spacing:.04em}
|
||||
/* Version du moteur : même pastille, mais SANS majuscules forcées — un numéro
|
||||
de build et un hash de commit se recopient et se comparent à l'œil, et
|
||||
« AAAA111 » n'est pas le commit « aaaa111 ». Le monospace vient de là aussi. */
|
||||
@@ -1007,26 +1024,34 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
.mcp-edit{flex:none;padding:2px 5px;font-size:13px;line-height:1;background:transparent;border:none;opacity:.5}
|
||||
.mcp-edit:hover{opacity:1;color:var(--accent)}
|
||||
/* Liste des outils MCP dans la modale */
|
||||
/* Sélecteur d'intensité du raisonnement, dans la barre de saisie. Discret par
|
||||
défaut (même poids visuel que les icônes voisines), il ne s'affirme qu'au
|
||||
survol ou quand une intensité est imposée. */
|
||||
#think-effort{flex:none;align-self:center;height:22px;padding:0 4px;font-size:11px;
|
||||
color:var(--dim);background:transparent;border:1px solid transparent;border-radius:4px;cursor:pointer}
|
||||
#think-effort:hover:not(:disabled){color:var(--text);border-color:var(--border)}
|
||||
#think-effort:disabled{opacity:.4;cursor:not-allowed}
|
||||
|
||||
.mcp-actions{display:flex;gap:6px;margin-top:6px}
|
||||
.mcp-cat-tabs{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px}
|
||||
.mcp-cat{font-size:11px;padding:2px 9px;border-radius:9px;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer}
|
||||
.mcp-cat{font-size:11px;padding:2px 9px;border-radius:4px;border:1px solid var(--border);background:transparent;color:var(--dim);cursor:pointer}
|
||||
.mcp-cat:hover{border-color:var(--accent);color:var(--text)}
|
||||
.mcp-cat.on{border-color:var(--accent);color:var(--accent)}
|
||||
.mcp-cat-list{display:flex;flex-direction:column;gap:4px;max-height:52vh;overflow:auto}
|
||||
.mcp-cat-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--border);border-radius:6px;background:var(--panel)}
|
||||
.mcp-cat-row{display:flex;align-items:center;gap:8px;padding:7px 10px;border:1px solid var(--border);border-radius:3px;background:var(--panel)}
|
||||
.mcp-cat-row:hover{border-color:var(--accent)}
|
||||
.mcp-cat-desc{font-size:11px;color:var(--dim);margin-top:2px;line-height:1.35}
|
||||
.mcp-cat-cmd{font-size:10px;color:var(--dim);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:340px}
|
||||
.mcp-cat-use{flex:none}
|
||||
.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:6px;padding:6px}
|
||||
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:5px;cursor:pointer}
|
||||
.mcp-tools-list{display:flex;flex-direction:column;gap:2px;max-height:240px;overflow:auto;border:1px solid var(--border);border-radius:3px;padding:6px}
|
||||
.mcp-tool-row{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:3px;cursor:pointer}
|
||||
.mcp-tool-row:hover{background:var(--panel)}
|
||||
.mcp-tool-switch{flex:none;transform:scale(.85)}
|
||||
.mcp-tool-name{font-size:12px;color:var(--text);word-break:break-all}
|
||||
|
||||
/* Accès OpenAI / Accès distant : champs lecture seule (URL, clé) + libellés
|
||||
compacts, pour une présentation homogène et sans surcharge visuelle. */
|
||||
.io-input{flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:9px;padding:8px 10px;font:inherit;font-size:13px}
|
||||
.io-input{flex:1;min-width:0;background:var(--panel);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px 10px;font:inherit;font-size:13px}
|
||||
|
||||
/* Modale MCP (ajout/édition) : les champs des groupes stdio/http n'avaient aucun
|
||||
espacement vertical (le conteneur n'est pas un flex à gap) → ils se touchaient.
|
||||
@@ -1053,7 +1078,7 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
|
||||
/* Réponses de l'assistant : pas de cadre, pas de fond — juste du texte. */
|
||||
#chat .msg.assistant{background:transparent;border:none;padding:0;max-width:100%;line-height:1.7}
|
||||
/* Question : pastille arrondie à droite, texte lisible sur fond clair OU sombre. */
|
||||
#chat .msg.user{background:var(--bubble);color:var(--text);border-radius:20px;padding:11px 18px;max-width:75%;line-height:1.6}
|
||||
#chat .msg.user{background:var(--bubble);color:var(--text);border-radius:3px;padding:11px 18px;max-width:75%;line-height:1.6}
|
||||
#chat .msg.user .label{color:var(--dim)}
|
||||
/* Libellés : l'amont les masquait tous ici, les mots « user » / « assistant »
|
||||
n'ajoutant rien à l'alignement. Ils portent désormais un avatar et un prénom
|
||||
@@ -1071,7 +1096,12 @@ h1{color:var(--text);font-weight:600;letter-spacing:-.01em}
|
||||
#chat .msg .statline{margin-top:10px;font-family:var(--mono);font-size:10.5px;letter-spacing:.02em;
|
||||
color:var(--dim);opacity:.75}
|
||||
#chat .msg .statline:empty{display:none}
|
||||
html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
/* « Masquer la vitesse de génération » ne masque QUE la vitesse : le temps de
|
||||
travail du tour (.worktime) n'est pas une mesure de moteur, c'est ce que la
|
||||
réponse a coûté. Une ligne qui ne portait que de la vitesse disparaît en
|
||||
entier — sans .has-work il ne resterait qu'un blanc. */
|
||||
html[data-hide-stats="1"] #chat .msg .statline>.spd{display:none}
|
||||
html[data-hide-stats="1"] #chat .msg .statline:not(.has-work){display:none}
|
||||
/* Arrivée en fondu montant plutôt qu'un surgissement sec. L'opacité finale (.75)
|
||||
est celle de la règle ci-dessus : l'animation doit y ATTERRIR, sinon la ligne
|
||||
clignote en repassant à sa valeur de repos. */
|
||||
@@ -1079,10 +1109,10 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
@keyframes statin{from{opacity:0;transform:translateY(3px)}to{opacity:.75;transform:none}}
|
||||
@media (prefers-reduced-motion:reduce){#chat .msg .statline-in{animation:none}}
|
||||
/* Raisonnement / outils : encart gris discret plutôt qu'un cadre pointillé. */
|
||||
#chat .msg.reasoning,#chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:14px;color:var(--dim);padding:12px 16px}
|
||||
#chat .msg.reasoning,#chat .msg.tool{background:var(--panel);border:1px solid var(--border);border-radius:3px;color:var(--dim);padding:12px 16px}
|
||||
#chat .msg.reasoning{color:var(--dim)}
|
||||
#chat .msg.tool .tool-head{color:var(--text)}
|
||||
#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{border-radius:12px;padding:12px 14px}
|
||||
#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{border-radius:3px;padding:12px 14px}
|
||||
#chat .msg.assistant .body a{text-decoration:underline;text-underline-offset:2px}
|
||||
/* Composer = UNE seule carte posée PAR-DESSUS le fil : la conversation
|
||||
continue de défiler DERRIÈRE elle et s'efface en fondu (dégradé vers le fond)
|
||||
@@ -1110,7 +1140,7 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
trou en PWA iOS. */
|
||||
#chat{padding-bottom:calc(var(--composer-h,170px) + 12px)}
|
||||
#scrollbtn{bottom:calc(var(--composer-h,170px) + 10px);border-radius:999px}
|
||||
#inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:26px 26px 0 0}
|
||||
#inputbar{order:1;margin:0 var(--col);padding:8px 8px 8px 16px;gap:8px;align-items:flex-end;background:var(--panel);border:1px solid var(--border);border-bottom:none;border-radius:4px 4px 0 0}
|
||||
#input{background:transparent;border:none;border-radius:0;padding:9px 0;min-height:36px;line-height:18px;max-height:160px}
|
||||
/* Pas de cadre qui s'assombrit au focus : la carte garde exactement la même
|
||||
bordure, seule la saisie change (curseur). */
|
||||
@@ -1120,7 +1150,7 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
(le navigateur l'écrase, d'où les coins bizarres) : on laisse donc la carte
|
||||
découper la jauge dans sa propre forme — la barre est bien épaisse ET rognée
|
||||
exactement sur la courbe, symétrique du haut. */
|
||||
#ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 26px 26px;overflow:hidden}
|
||||
#ctxmeta{order:2;margin:0 var(--col);padding:6px 16px 15px;gap:10px;background:var(--panel);border:1px solid var(--border);border-top:none;border-radius:0 0 8px 8px;overflow:hidden}
|
||||
/* ⚠️ NE PAS piloter la position du composer en JS quand le clavier s'ouvre
|
||||
(tenté puis retiré le 2026-07-28, deux fois) : ni asservir la hauteur de la
|
||||
coque à visualViewport.height, ni décaler le composer de la hauteur du
|
||||
@@ -1193,9 +1223,48 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
alignée à droite comme elle. Rangée et bulle restent deux objets distincts,
|
||||
séparés par l'espacement normal du fil — les rapprocher les faisait lire comme
|
||||
une seule bulle difforme qui déborde vers le haut. */
|
||||
/* align-items:flex-start : depuis que la rangée peut contenir une VIGNETTE, une
|
||||
pastille texte voisine s'étirait à sa hauteur (étirement par défaut du flex) —
|
||||
et, arrondie à 999px, se retrouvait en gros ovale. Chaque objet garde sa
|
||||
hauteur propre. */
|
||||
#chat .msg-files{align-self:flex-end;display:flex;flex-wrap:wrap;justify-content:flex-end;
|
||||
gap:6px;max-width:75%}
|
||||
align-items:flex-start;gap:6px;max-width:75%}
|
||||
#chat .msg-files .chip-file{background:var(--bubble);max-width:100%}
|
||||
/* Vignette dans une pastille (composeur) : assez grande pour reconnaître la
|
||||
capture qu'on vient de coller, assez petite pour que la rangée reste une
|
||||
rangée de pastilles et pas une planche-contact. */
|
||||
.chip-file .cf-thumb{flex:none;width:26px;height:26px;object-fit:cover;border-radius:3px;
|
||||
background:var(--panel)}
|
||||
.chip-file.has-thumb{padding-left:4px}
|
||||
/* Pièce jointe IMAGE d'un message envoyé : la vignette remplace la pastille.
|
||||
Plafonnée pour ne pas déséquilibrer le fil ; cliquable pour la voir en grand. */
|
||||
#chat .msg-files .msg-img{display:flex;max-width:100%;border-radius:4px;overflow:hidden;
|
||||
border:1px solid var(--border);background:var(--panel)}
|
||||
/* margin/border remis à zéro : .chatimg (posée par hydrateImages) s'applique
|
||||
aussi ici, or le cadre est déjà porté par la tuile. */
|
||||
#chat .msg-files .msg-img img{display:block;max-width:200px;max-height:160px;
|
||||
width:auto;height:auto;object-fit:contain;cursor:zoom-in;margin:0;border:none;border-radius:0}
|
||||
/* Images posées par l'IA dans sa réponse (captures, /api/chat/image). Sans ce
|
||||
plafond elles sortaient à leur taille NATIVE : une capture d'écran remplissait
|
||||
la colonne entière et poussait le texte hors de vue. */
|
||||
#chat .chatimg{display:block;max-width:100%;max-height:280px;width:auto;height:auto;
|
||||
margin:6px 0;border:1px solid var(--border);border-radius:4px;cursor:zoom-in}
|
||||
/* Loupe : l'image reprend toute la fenêtre, moins une marge. Le fond ferme au
|
||||
clic (curseur zoom-out), l'image non — on veut pouvoir la regarder de près. */
|
||||
#lightbox{position:fixed;inset:0;z-index:80;display:none;align-items:center;
|
||||
justify-content:center;padding:28px;background:rgba(0,0,0,.72);cursor:zoom-out;
|
||||
opacity:0;transition:opacity .16s ease}
|
||||
#lightbox.show{opacity:1}
|
||||
#lightbox img{max-width:100%;max-height:100%;width:auto;height:auto;object-fit:contain;
|
||||
border-radius:4px;box-shadow:0 16px 50px rgba(0,0,0,.45);cursor:default}
|
||||
#lightbox .lb-x{position:absolute;top:10px;right:14px;margin:0;padding:0;
|
||||
width:36px;height:36px;display:flex;align-items:center;justify-content:center;
|
||||
font-size:22px;line-height:1;border:none;border-radius:50%;
|
||||
background:rgba(0,0,0,.35);color:#fff}
|
||||
#lightbox .lb-x:hover{background:rgba(0,0,0,.6)}
|
||||
#lightbox .lb-name{position:absolute;left:0;right:0;bottom:10px;text-align:center;
|
||||
color:#fff;opacity:.72;font-size:12px;pointer-events:none}
|
||||
@media (prefers-reduced-motion:reduce){#lightbox{transition:none}}
|
||||
/* Lien de téléchargement dans une réponse : un lien normal, mais la flèche dit
|
||||
qu'il ramène un fichier plutôt qu'il n'ouvre une page. */
|
||||
#chat .msg .filelink{cursor:pointer}
|
||||
@@ -1206,16 +1275,16 @@ html[data-hide-stats="1"] #chat .msg .statline{display:none}
|
||||
#dropveil{position:absolute;inset:0;z-index:20;display:none;align-items:center;
|
||||
justify-content:center;padding:24px;background:rgba(0,0,0,.28);pointer-events:none}
|
||||
#dropveil.show{display:flex}
|
||||
#dropveil>div{padding:22px 30px;border:2px dashed var(--border);border-radius:20px;
|
||||
#dropveil>div{padding:22px 30px;border:2px dashed var(--border);border-radius:3px;
|
||||
background:var(--panel);color:var(--text);font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.18)}
|
||||
/* Reste de l'UI : coins arrondis et contrastes doux, dans le même esprit. */
|
||||
button,.mem-search{border-radius:10px}
|
||||
button,.mem-search{border-radius:4px}
|
||||
/* Champ + bouton accolés : on ne garde l'arrondi que sur les coins extérieurs. */
|
||||
.pe-inpbtn .pe-inp{border-radius:10px 0 0 10px}
|
||||
.pe-inpbtn button{border-radius:0 10px 10px 0}
|
||||
.input-btn .ctl{border-radius:10px 0 0 10px}
|
||||
.input-btn .btn-in{border-radius:0 10px 10px 0}
|
||||
.preset{border-radius:12px}
|
||||
.pe-inpbtn .pe-inp{border-radius:4px 0 0 4px}
|
||||
.pe-inpbtn button{border-radius:0 4px 4px 0}
|
||||
.input-btn .ctl{border-radius:4px 0 0 4px}
|
||||
.input-btn .btn-in{border-radius:0 4px 4px 0}
|
||||
.preset{border-radius:3px}
|
||||
/* Les presets restent des cartes (fond + bordure) : à plat, sans fond ni
|
||||
cadre, la liste ne se lisait plus comme des lignes distinctes. L'actif se
|
||||
signale par sa bordure — un simple voile d'accent (7 % de blanc sur fond
|
||||
@@ -1236,7 +1305,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
|
||||
/* ===== Fenêtres et sélecteurs =====================================
|
||||
Coins arrondis et ombre douce plutôt qu'un cadre marqué. */
|
||||
.modal-card,.ask-box,.modal-box,.pe-list,.pe-adv{border-radius:12px}
|
||||
.modal-card,.ask-box,.modal-box,.pe-list,.pe-adv{border-radius:3px}
|
||||
.modal-card,.ask-box{box-shadow:0 8px 30px rgba(0,0,0,.12)}
|
||||
/* Sélecteurs segmentés : noir plein quand c'est coché, pas de couleur. */
|
||||
.pe-seg>label:has(input:checked),.seg>label:has(input:checked){background:var(--text);border-color:var(--text);color:var(--bg)}
|
||||
@@ -1279,7 +1348,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
/* Barre latérale : pile de cartes, sans filet ni bordure de colonne. */
|
||||
.side{width:312px;border-right:0;padding:0;background:transparent;
|
||||
display:flex;flex-direction:column;gap:12px;overflow-y:auto;overscroll-behavior:contain}
|
||||
.sidecard,.side>details{background:var(--panel);border:0;border-top:0;border-radius:20px;
|
||||
.sidecard,.side>details{background:var(--panel);border:0;border-top:0;border-radius:3px;
|
||||
box-shadow:var(--shadow);margin:0;padding:2px 16px}
|
||||
.sidecard{padding:15px 18px 14px;display:flex;flex-direction:column;gap:2px}
|
||||
.side>details[open]{padding-bottom:14px}
|
||||
@@ -1289,10 +1358,10 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
.side-sep{border-top:0;margin:10px 0 0;padding:0 6px}
|
||||
/* Blocs encastrés DANS une carte : un ton plus chaud, sans bordure — deux
|
||||
cadres imbriqués alourdiraient inutilement. */
|
||||
.side .slist{background:var(--row-bg);border-color:transparent;border-radius:14px}
|
||||
.side .slist{background:var(--row-bg);border-color:transparent;border-radius:3px}
|
||||
|
||||
/* Colonne principale : une seule grande carte qui contient fil et saisie. */
|
||||
.main{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);overflow:hidden}
|
||||
.main{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);overflow:hidden}
|
||||
#chat{padding-top:26px;padding-inline:max(20px,calc(50% - 360px))}
|
||||
/* Le dégradé qui fait disparaître le fil sous la carte de saisie doit fondre
|
||||
vers le PANNEAU, pas vers le fond de la coque. */
|
||||
@@ -1314,13 +1383,13 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
3 px sur le seul bord gauche se lisait mal et déséquilibrait la ligne. La
|
||||
bordure transparente est portée par tous les états : la sélection ne décale
|
||||
donc pas le contenu d'un pixel. */
|
||||
.side .preset{border:1px solid transparent;border-radius:14px;background:transparent}
|
||||
.side .preset{border:1px solid transparent;border-radius:3px;background:transparent}
|
||||
.side .preset.active{background:var(--row-bg-on);border-color:var(--accent)}
|
||||
.side .preset:hover{background:var(--row-bg)}
|
||||
|
||||
/* Bulle de question : coin bas-droit rentré, comme une bulle qui pointe vers
|
||||
son auteur. */
|
||||
#chat .msg.user{border-radius:20px 20px 6px 20px}
|
||||
#chat .msg.user{border-radius:4px}
|
||||
}
|
||||
/* Action principale en sauge : c'est la couleur d'action de la charte, et elle
|
||||
se distingue enfin du texte (le bouton reprenait --text, donc un brun sombre
|
||||
@@ -1359,7 +1428,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
l'application de l'autre, tiennent maintenant chacune dans UNE carte, avec un
|
||||
filet discret entre les lignes. */
|
||||
@media (min-width:721px){
|
||||
.sidegroup{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
|
||||
.sidegroup{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);
|
||||
padding:2px 16px;display:flex;flex-direction:column}
|
||||
/* Dans un groupe, les sections ne sont plus des cartes : elles redeviennent
|
||||
des lignes, séparées par un filet — sauf la première. */
|
||||
@@ -1376,7 +1445,7 @@ button:hover{border-color:var(--dim);color:var(--text)}
|
||||
quantifications, et la modale ne doit pas s'étirer jusqu'à sortir de
|
||||
l'écran. */
|
||||
.hf-list{display:flex;flex-direction:column;max-height:220px;overflow-y:auto;
|
||||
border:1px solid var(--border);border-radius:12px;margin-top:6px}
|
||||
border:1px solid var(--border);border-radius:3px;margin-top:6px}
|
||||
.hf-row{display:flex;align-items:center;gap:10px;padding:7px 12px;cursor:pointer;
|
||||
border-top:1px solid var(--border);font-size:12px}
|
||||
.hf-row:first-child{border-top:0}
|
||||
@@ -1392,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}
|
||||
@@ -1404,30 +1477,40 @@ 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:8px}
|
||||
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-b{margin:0;padding:2px 6px;min-height:0;font-size:11px;border:0;border-radius:7px;
|
||||
.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. */
|
||||
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:12px;font-size:12px}
|
||||
.files-row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:3px;font-size:12px}
|
||||
.files-row:hover{background:var(--row-bg-on)}
|
||||
.files-ic{flex:none;width:12px;text-align:center;color:var(--dim)}
|
||||
.files-row.isdir .files-ic{color:var(--accent)}
|
||||
@@ -1438,15 +1521,15 @@ html[data-files="1"] #files{display:flex}
|
||||
.files-row:hover .files-acts,.files-row:focus-within .files-acts{opacity:1}
|
||||
@media (hover:none){.files-acts{opacity:1}}
|
||||
.files-acts .iconbtn{font-size:11px;line-height:1;padding:4px 6px;background:transparent;
|
||||
border:1px solid transparent;border-radius:6px;color:var(--dim);cursor:pointer;margin:0;min-height:0}
|
||||
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. */
|
||||
.files-scope{margin:0;padding:2px 6px;min-height:0;font-family:var(--mono);font-size:10px;
|
||||
background:transparent;border:1px solid var(--border);border-radius:7px;color:var(--dim)}
|
||||
background:transparent;border:1px solid var(--border);border-radius:3px;color:var(--dim)}
|
||||
.files-scope:hover{color:var(--text);background:var(--row-bg-on)}
|
||||
/* Téléchargement en cours : downloadWorkspaceFile pose .busy sur la ligne. */
|
||||
.files-row.busy{opacity:.55;pointer-events:none}
|
||||
@@ -1469,7 +1552,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
}
|
||||
@media (min-width:721px){
|
||||
/* Dans la charte « Terre », le panneau est une carte comme les autres. */
|
||||
#files{background:var(--panel);border-left-color:transparent;border-radius:20px;
|
||||
#files{background:var(--panel);border-left-color:transparent;border-radius:3px;
|
||||
box-shadow:var(--shadow);margin:0}
|
||||
}
|
||||
|
||||
@@ -1525,7 +1608,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#topbar-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
||||
font-size:13.5px;font-weight:500;color:var(--text)}
|
||||
#topbar .iconbtn{width:30px;height:30px;flex:none;display:inline-flex;align-items:center;
|
||||
justify-content:center;border:0;border-radius:9px;background:transparent;color:var(--dim);
|
||||
justify-content:center;border:0;border-radius:4px;background:transparent;color:var(--dim);
|
||||
cursor:pointer;margin:0;padding:0;transition:background .15s,color .15s,transform .1s}
|
||||
#topbar .iconbtn:hover{background:var(--row-bg-on);color:var(--text)}
|
||||
#topbar .iconbtn:active{transform:scale(.92)}
|
||||
@@ -1549,7 +1632,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* La liste EST la barre latérale : titre à gauche, heure à droite, une ligne.
|
||||
Le reste (réglages) descend sous un unique repli. */
|
||||
#conv-search{width:calc(100% - 24px);font:inherit;font-size:12.5px;color:var(--text);
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:9px;
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:4px;
|
||||
padding:7px 11px;margin:0 12px 8px}
|
||||
#conv-search::placeholder{color:var(--dim)}
|
||||
#conv-search:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-bg)}
|
||||
@@ -1567,6 +1650,31 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
color:var(--dim);letter-spacing:-.01em;overflow:visible}
|
||||
.side .preset:hover .conv-when{opacity:0}
|
||||
.conv-empty{font-size:12px;color:var(--dim);padding:8px 11px}
|
||||
/* Discussion au travail : un anneau qui tourne, à gauche du titre. C'est le seul
|
||||
élément animé de la liste — il désigne sans ambiguïté le fil sur lequel un
|
||||
agent est en train de travailler, y compris depuis un autre appareil. */
|
||||
/* ⚠️ Sélecteur en DEUX classes : `.preset>span` (plus haut) pose flex:1 sur tout
|
||||
span enfant d'une ligne — l'anneau prenait sinon la moitié de la largeur et
|
||||
é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)}
|
||||
/* 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{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
|
||||
voisins pour ne pas décaler la ligne quand il apparaît et disparaît. */
|
||||
#topbar>.conv-spin{width:18px}
|
||||
/* Repli des réglages : tout ce qui n'est pas la conversation tient sous cette
|
||||
seule ligne, pour que la barre latérale de la maquette (discussions +
|
||||
machine) reste la vue par défaut. */
|
||||
@@ -1603,7 +1711,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
⚠️ Ne concerne pas les boutons-icônes de la saisie et de l'en-tête (#send,
|
||||
#stop, #attach, #menubtn…) : ils posent `border:0` PLUS BAS dans la feuille
|
||||
et gardent leur allure nue, qui est la bonne pour un pictogramme seul. */
|
||||
button{background:var(--panel);border-color:var(--border);border-radius:9px;
|
||||
button{background:var(--panel);border-color:var(--border);border-radius:4px;
|
||||
transition:background .15s ease,color .15s ease,border-color .15s ease,transform .08s ease}
|
||||
button:hover{background:var(--row-bg);border-color:var(--dim);color:var(--text)}
|
||||
button:active:not(:disabled){transform:translateY(1px)}
|
||||
@@ -1614,11 +1722,11 @@ button:active:not(:disabled){transform:translateY(1px)}
|
||||
nu flotte sans ancrage, et la frontière entre deux réponses se perd. */
|
||||
#chat{padding-top:22px}
|
||||
#chat .msg.assistant{background:var(--bubble);border:0;
|
||||
border-radius:10px;padding:15px 17px;max-width:100%;box-shadow:var(--shadow-card)}
|
||||
border-radius:4px;padding:15px 17px;max-width:100%;box-shadow:var(--shadow-card)}
|
||||
#chat .msg.assistant.errmsg{background:var(--danger-bg);border-color:color-mix(in srgb,var(--err) 45%,transparent)}
|
||||
/* Question : aplat de sauge, coin bas-droit rentré. --on-accent garantit le
|
||||
contraste dans les DEUX variantes. */
|
||||
#chat .msg.user{background:var(--user-bg);color:var(--on-user);border-radius:10px;
|
||||
#chat .msg.user{background:var(--user-bg);color:var(--on-user);border-radius:4px;
|
||||
padding:13px 16px;max-width:62%;box-shadow:var(--shadow-card)}
|
||||
#chat .msg.user .label{color:var(--on-user);opacity:.75}
|
||||
#chat .msg.user .body a{color:var(--on-user);text-decoration:underline}
|
||||
@@ -1641,10 +1749,10 @@ 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:10px;background:transparent;color:var(--dim);
|
||||
border:0;border-radius:4px;background:transparent;color:var(--dim);
|
||||
transition:background .15s ease,color .15s ease,transform .08s ease}
|
||||
/* Envoyer / arrêter : un seul des deux à la fois, décidé par data-busy (posé par
|
||||
syncSendBtn). C'est la feuille qui garde la main sur `display` — un style
|
||||
@@ -1652,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}
|
||||
@@ -1665,8 +1773,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
à droite du texte : c'est là qu'on les cherche, et le pied de carte redevient
|
||||
ce qu'il doit être — une jauge de contexte, rien d'autre. */
|
||||
#inputbar{align-items:flex-end;padding:8px 8px 8px 18px;gap:4px;box-shadow:var(--shadow-card);
|
||||
border-radius:14px 14px 0 0}
|
||||
#ctxmeta{border-radius:0 0 14px 14px}
|
||||
border-radius:3px 3px 0 0}
|
||||
#ctxmeta{border-radius:0 0 5px 5px}
|
||||
#ctxmeta{padding-bottom:12px}
|
||||
#ctxtrack{border-radius:999px}
|
||||
|
||||
@@ -1687,8 +1795,8 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#side-scroll>details,#side-scroll>.sidegroup{background:transparent;box-shadow:none;
|
||||
border-radius:0;padding:0;margin:0}
|
||||
#side-scroll>details>summary{height:34px}
|
||||
.side .slist{background:transparent;border-color:transparent;border-radius:12px}
|
||||
.side #side-settings .slist{background:var(--row-bg);border-radius:12px}
|
||||
.side .slist{background:transparent;border-color:transparent;border-radius:3px}
|
||||
.side #side-settings .slist{background:var(--row-bg);border-radius:3px}
|
||||
/* Le fil n'est plus une carte : il EST la page. */
|
||||
.main{background:transparent;border-radius:0;box-shadow:none;overflow:visible}
|
||||
#composer{background:linear-gradient(to bottom,transparent 0,var(--bg) var(--fade))}
|
||||
@@ -1701,7 +1809,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
au tiers de la place disponible. */
|
||||
.side .preset{position:relative}
|
||||
.side .preset .conv-acts{position:absolute;right:6px;top:50%;transform:translateY(-50%);
|
||||
margin:0;background:var(--row-bg-on);border-radius:9px;padding:1px}
|
||||
margin:0;background:var(--row-bg-on);border-radius:4px;padding:1px}
|
||||
.side .preset.active .conv-acts{background:var(--row-bg)}
|
||||
.side .preset .preset-info{overflow:hidden}
|
||||
/* La sélection est un aplat, pas un cadre : un liseré autour d'une ligne de
|
||||
@@ -1723,10 +1831,10 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* Le ☰ suit la charte des boutons : pas de contour, un fond qui apparaît au
|
||||
survol. Il flotte au-dessus de l'en-tête, il doit donc en reprendre le fond
|
||||
pour ne pas laisser voir le fil défiler dessous. */
|
||||
#menubtn{background:var(--bg);border:0;color:var(--dim);border-radius:10px;
|
||||
#menubtn{background:var(--bg);border:0;color:var(--dim);border-radius:4px;
|
||||
width:34px;height:34px;font-size:16px;top:calc(11px + env(safe-area-inset-top));left:9px}
|
||||
#menubtn:hover{background:var(--row-bg-on);color:var(--text)}
|
||||
#lc-chip{border-radius:10px;height:34px;top:calc(11px + env(safe-area-inset-top));left:calc(50px + env(safe-area-inset-left))}
|
||||
#lc-chip{border-radius:4px;height:34px;top:calc(11px + env(safe-area-inset-top));left:calc(50px + env(safe-area-inset-left))}
|
||||
/* Jauges : la piste doit se VOIR, sinon une barre presque vide ressemble à un
|
||||
trait souligné sous l'intitulé. Valeurs relevées sur la maquette. */
|
||||
.perf-track{background:var(--track)}
|
||||
@@ -1743,7 +1851,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#chat .msg.assistant .body pre,#chat .msg.reasoning .body pre,#chat .msg.tool pre{
|
||||
background:var(--code-bg);border:1px solid color-mix(in srgb,var(--text) 10%,transparent)}
|
||||
#chat .msg.assistant .body code{background:var(--code-bg);
|
||||
border:1px solid color-mix(in srgb,var(--text) 7%,transparent);border-radius:5px}
|
||||
border:1px solid color-mix(in srgb,var(--text) 7%,transparent);border-radius:3px}
|
||||
#chat .msg.assistant .body pre code{border:0;background:transparent}
|
||||
/* --- Titres de la barre latérale ------------------------------------
|
||||
La maquette n'a qu'un style de titre : casse normale, demi-gras, taille du
|
||||
@@ -1829,7 +1937,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
#chat-actions-clear,.row>button>svg{vertical-align:-2px}
|
||||
.row>button:has(svg){display:inline-flex;align-items:center;gap:7px}
|
||||
/* Le « + » des sections : même carré que les autres commandes de la barre. */
|
||||
.preset-add{width:26px;height:26px;border-radius:8px}
|
||||
.preset-add{width:26px;height:26px;border-radius:4px}
|
||||
/* Étiquettes de groupe : même traitement que les titres de section — la
|
||||
maquette n'a qu'un style de titre, et le monospace en capitales de la charte
|
||||
précédente criait au milieu des formulaires. */
|
||||
@@ -1861,7 +1969,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
/* La liste EST la carte, au MÊME gabarit que .sidegroup (les sections de
|
||||
réglages) : fond de panneau, rayon de 20, ombre discrète. Les lignes sont
|
||||
rognées par overflow au lieu d'arrondir la première et la dernière à la main. */
|
||||
#conv-list{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
|
||||
#conv-list{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);
|
||||
overflow:hidden;margin:0}
|
||||
.side .preset{border-radius:0;margin:0;padding:10px 12px}
|
||||
.side .preset:not(:last-child){border-bottom:1px solid var(--border)}
|
||||
@@ -1877,7 +1985,7 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
couple que « Paramètres » + sa liste dans les réglages. */
|
||||
#side-perf{padding:12px 12px 12px;padding-bottom:calc(12px + env(safe-area-inset-bottom))}
|
||||
.perf-head{margin:0 2px 7px;font-size:12.5px;font-weight:600;color:var(--dim)}
|
||||
.perf-body{background:var(--panel);border-radius:20px;box-shadow:var(--shadow);
|
||||
.perf-body{background:var(--panel);border-radius:3px;box-shadow:var(--shadow);
|
||||
padding:14px 15px 3px}
|
||||
/* ⚠️ Pas de remise à zéro sur :last-child — les jauges vivent dans DEUX
|
||||
conteneurs (les cartes graphiques, puis la mémoire vive). La dernière jauge
|
||||
@@ -1886,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}
|
||||
+116
-2
@@ -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 == "" {
|
||||
@@ -703,6 +742,49 @@ func handleMCP(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "servers": list})
|
||||
}
|
||||
|
||||
// handleReasoningEffort lit (GET) ou règle (POST) l'intensité du raisonnement.
|
||||
//
|
||||
// Même rôle que le sélecteur de l'éditeur de preset, mais applicable depuis la
|
||||
// barre de saisie sans rouvrir les réglages : la valeur part dans la config
|
||||
// vivante, elle est relue à chaque requête au moteur (llm_client.go). Aucun
|
||||
// redémarrage du moteur — `reasoning_effort` voyage dans le corps de la requête.
|
||||
//
|
||||
// Attention : appliquer un preset réécrit TOUTE la config, donc la valeur du
|
||||
// preset reprend la main à ce moment-là. C'est voulu — le réglage reste attaché
|
||||
// au modèle, la barre de saisie ne fait que le changer à la volée.
|
||||
func handleReasoningEffort(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
v := strings.ToLower(strings.TrimSpace(req.Value))
|
||||
// Liste blanche : cette clé finit dans une requête au moteur, on n'y
|
||||
// laisse pas passer une chaîne arbitraire venue du navigateur.
|
||||
ok := v == ""
|
||||
for _, allowed := range ReasoningEfforts {
|
||||
if v == allowed {
|
||||
ok = true
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "valeur inconnue : " + v})
|
||||
return
|
||||
}
|
||||
if err := SetConfigKey("REASONING_EFFORT", v); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
cfg := ReadConfig()
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"value": reasoningEffortValue(cfg["REASONING_EFFORT"]),
|
||||
"on": reasoningActive(cfg["REASONING"]),
|
||||
"choices": ReasoningEfforts,
|
||||
})
|
||||
}
|
||||
|
||||
// handleMCPCatalog renvoie le catalogue embarqué de serveurs MCP connus.
|
||||
// Lecture seule : rien n'est installé ici, l'UI ne fait que préremplir la modale
|
||||
// d'ajout avec la commande de l'entrée choisie.
|
||||
@@ -858,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
|
||||
@@ -130,7 +135,11 @@ func handleConvList(w http.ResponseWriter, r *http.Request) {
|
||||
if list == nil {
|
||||
list = []convMeta{}
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"conversations": list, "active": active})
|
||||
// `busy` = un tour est en cours SUR LA DISCUSSION ACTIVE (il n'y a qu'une
|
||||
// génération à la fois, sur le fil ouvert). L'UI s'en sert pour animer la
|
||||
// ligne concernée dès le chargement, avant même que le flux SSE ait rejoué
|
||||
// le journal — sinon la page reste muette pendant qu'un agent travaille.
|
||||
sendJSON(w, 200, map[string]any{"conversations": list, "active": active, "busy": conv.isGenerating()})
|
||||
}
|
||||
|
||||
func handleConvNew(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -181,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,48 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// La liste des discussions doit dire LAQUELLE travaille : c'est ce qui fait
|
||||
// tourner l'anneau dans la barre latérale dès le chargement de la page, sans
|
||||
// attendre que le flux SSE ait rejoué son journal.
|
||||
func TestConvListRapporteLaGeneration(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
|
||||
busy := func() bool {
|
||||
rr := httptest.NewRecorder()
|
||||
handleConvList(rr, httptest.NewRequest("GET", "/api/conversations", nil))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("code %d : %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Active string `json:"active"`
|
||||
Busy bool `json:"busy"`
|
||||
}
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Active == "" {
|
||||
t.Fatal("aucune discussion active dans la liste")
|
||||
}
|
||||
return out.Busy
|
||||
}
|
||||
|
||||
if busy() {
|
||||
t.Fatal("busy=true alors qu'aucune génération ne tourne")
|
||||
}
|
||||
conv.mu.Lock()
|
||||
conv.Generating = true
|
||||
conv.mu.Unlock()
|
||||
defer func() {
|
||||
conv.mu.Lock()
|
||||
conv.Generating = false
|
||||
conv.mu.Unlock()
|
||||
}()
|
||||
if !busy() {
|
||||
t.Fatal("busy=false alors qu'une génération tourne")
|
||||
}
|
||||
}
|
||||
@@ -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,7 +236,17 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/mcp/tool", handleMCPTool)
|
||||
api("/api/mcp/test", handleMCPTest)
|
||||
api("/api/memory", handleMemoryMode)
|
||||
api("/api/network", handleNetwork) // écoute LAN du moteur + pare-feu (Windows)
|
||||
// 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)
|
||||
api("/api/sysprompt", handleSysPrompt)
|
||||
// Alias rétro-compat : l'ancien portail ajean.link (dépôt loki-relay) pilote
|
||||
@@ -248,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)
|
||||
@@ -259,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