mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 06:28:11 +02:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30ce3a4e86 | ||
|
|
a463ccbd9f | ||
|
|
393004cb06 | ||
|
|
7d614324a0 | ||
|
|
853e59c085 | ||
|
|
e7fb63b1dd | ||
|
|
6aaf9360b4 | ||
|
|
c2a2cc5748 | ||
|
|
ac6d8ad144 | ||
|
|
5016396998 | ||
|
|
502f23e833 | ||
|
|
634cfb61f3 | ||
|
|
ecff7de5a3 | ||
|
|
127013b32b | ||
|
|
4a9a5a1712 | ||
|
|
5dd14c9de5 | ||
|
|
5d37bef0ce | ||
|
|
5b834d54b2 | ||
|
|
c24472b91f | ||
|
|
911ffbfeaf | ||
|
|
5f7203a935 | ||
|
|
4b659ac186 | ||
|
|
ac40a2556a | ||
|
|
a325e98dad | ||
|
|
f0fef91773 | ||
|
|
a4db6d4da1 | ||
|
|
d50c8a56c6 | ||
|
|
432308c5b2 | ||
|
|
c888afb493 |
No files matched your search
+5
-1
@@ -87,8 +87,12 @@ LABEL org.opencontainers.image.revision="${LOKI_VERSION}" \
|
||||
# via npx ; tini en PID 1. curl et libgomp1 sont déjà dans l'image amont.
|
||||
# Node 22 depuis NodeSource et non depuis Ubuntu (qui livre Node 18) : Playwright
|
||||
# et la plupart des serveurs MCP réclament Node ≥ 20.
|
||||
# python3 + python3-venv : l'installeur et le serveur du moteur Strata
|
||||
# (backend_strata.go) sont en Python ≥ 3.10 et créent leur propre .venv dans
|
||||
# /data/strata. Le moteur et le modèle ne sont PAS dans l'image : téléchargés
|
||||
# à la demande depuis Paramètres → Moteur.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git tini ca-certificates curl gnupg \
|
||||
git tini ca-certificates curl gnupg python3 python3-venv \
|
||||
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -52,6 +52,16 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`,
|
||||
**pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki
|
||||
expose à la place un export/import de paquet chiffré en fichier local.
|
||||
|
||||
## Moteur Strata
|
||||
|
||||
Le second moteur de Loki (`internal/loki/backend_strata.go`) est
|
||||
**[Strata](https://github.com/Niko1221/Strata)** (MIT), spécialisé dans
|
||||
Qwen3.8-Flash-Next. Son intégration est reprise d'AJEAN (« AJEAN MoE 1.0 »,
|
||||
`backend_moe.go`) : Loki télécharge le paquet figé publié par AJEAN sur sa
|
||||
release `moe-v1.0` (sources de Strata et binaire Linux CUDA 12 compilé, avec
|
||||
les modifications d'AJEAN), vérifié par SHA-256. Ni Strata ni le modèle ne sont
|
||||
inclus dans l'image : ils sont téléchargés à la demande.
|
||||
|
||||
## Services externes
|
||||
|
||||
Le tunnel d'accès distant continue de pointer vers **ajean.link**, le relais
|
||||
|
||||
@@ -93,6 +93,33 @@ la recherche qu'au téléchargement. À défaut, la variable d'environnement
|
||||
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
|
||||
n'est envoyé qu'aux adresses Hugging Face.
|
||||
|
||||
## Choisir son moteur : llama.cpp ou Strata
|
||||
|
||||
Loki a deux moteurs, et le choix se fait **par preset** (clé `ENGINE`) :
|
||||
|
||||
- **llama.cpp** (par défaut) — n'importe quel GGUF, réglages complets dans
|
||||
l'éditeur de preset.
|
||||
- **[Strata](https://github.com/Niko1221/Strata)** — spécialisé dans
|
||||
**Qwen3.8 Flash Next** (MoE de 125 B) : les experts sont répartis entre
|
||||
VRAM, RAM et disque pour faire tourner ce modèle sur une carte de joueur.
|
||||
Intégration reprise d'AJEAN (paquet figé « AJEAN MoE 1.0 », vérifié par
|
||||
SHA-256).
|
||||
|
||||
**Paramètres → Moteur → Strata** : la fenêtre lit la machine (cartes, RAM,
|
||||
disque), propose la version (Classique ou Swift 1.5) et la qualité qui tient,
|
||||
puis installe et active le modèle en un clic (téléchargement de 66 à 84 Go dans
|
||||
`/data/strata`). Le modèle installé devient un preset comme les autres :
|
||||
basculer dessus lance Strata, basculer sur un autre preset revient à llama.cpp.
|
||||
Son engrenage ouvre les réglages propres à Strata (contexte jusqu'à 262 144,
|
||||
cache KV fp16/8 bits, prédiction MTP, lecture des images, carte d'appoint).
|
||||
|
||||
Pré-requis : Linux + NVIDIA RTX 20 ou plus récente (8 Go de VRAM minimum, une
|
||||
deuxième carte est utilisée comme carte d'appoint), environ 46 Go de RAM pour
|
||||
la plus petite qualité, pilote compatible CUDA 12.8. Le conteneur doit pouvoir
|
||||
verrouiller la mémoire : `ulimits: memlock: -1` (déjà présent dans les
|
||||
fichiers compose fournis). Le benchmark et l'optimiseur ne s'appliquent qu'à
|
||||
llama.cpp.
|
||||
|
||||
## Installation sur Unraid
|
||||
|
||||
L'image est construite et publiée par GitHub Actions sur GHCR
|
||||
@@ -356,6 +383,242 @@ Ajoutées par ce fork :
|
||||
→ 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.
|
||||
- **Performance** : cache de prompts, slots, décodage spéculatif, placement
|
||||
MoE et multi-GPU, optimiseur — tout est rassemblé dans la section
|
||||
[Performance](#performance), avec la table des clés et leur défaut.
|
||||
- **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
|
||||
déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les
|
||||
chemins relatifs du modèle y sont résolus. Changer de discussion change donc
|
||||
les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que
|
||||
le disque ne se remplisse pas en silence.
|
||||
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
|
||||
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
|
||||
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
|
||||
(Chromium via Playwright, inclus dans l'image). Les captures partent en JPEG
|
||||
et sont plafonnées à 20 fichiers / 40 Mo par discussion. La description de
|
||||
l'outil suit la capacité **réelle** du moteur, sondée sur `/props` : sans
|
||||
vision effective, elle dit au modèle « tu ne vois pas l'image » plutôt que de
|
||||
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
|
||||
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
|
||||
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
|
||||
- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
|
||||
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
|
||||
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
|
||||
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
|
||||
supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
|
||||
sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le
|
||||
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
|
||||
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
|
||||
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
|
||||
- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
|
||||
et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
|
||||
aucune requête vers un service de polices. Deux variantes : claire par défaut,
|
||||
**Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
|
||||
L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
|
||||
changement de preset ne demande plus d'ouvrir les réglages) ; la barre
|
||||
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
|
||||
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
|
||||
visibles en pied de colonne.
|
||||
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
|
||||
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
|
||||
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
|
||||
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
|
||||
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
|
||||
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
|
||||
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
|
||||
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
|
||||
du moteur. Conséquence directe : l'API est joignable partout où l'interface
|
||||
l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de
|
||||
second port ni ouvrir le moteur. L'amont annonçait `http://<ip>:8080/v1`, une
|
||||
adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP
|
||||
détectée est celle du bridge Docker).
|
||||
- Authentification par la **clé API** du panneau (`Authorization: Bearer …`),
|
||||
vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et
|
||||
l'interface le dit en rouge.
|
||||
- **Adresse publique** : un champ où saisir son domaine, pour le cas du
|
||||
reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse
|
||||
affichée suit celle du navigateur.
|
||||
- **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki
|
||||
honore `X-Forwarded-Proto` pour annoncer une adresse en `https`.
|
||||
- L'ancienne exposition publique via le relais de l'amont
|
||||
(`<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.
|
||||
- **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 :
|
||||
|
||||
- **Accès distant via [ajean.link](https://ajean.link)** : la section de
|
||||
l'interface et son module JS sont supprimés — un conteneur derrière son
|
||||
propre réseau n'en a pas l'usage. Le code serveur du relais reste en place
|
||||
mais **inerte** (aucun jeton, aucune section pour en fournir un) : le retirer
|
||||
créerait un conflit à chaque reprise de l'amont.
|
||||
- **Postes distants** (faire agir l'agent sur un autre PC appairé) : bouton du
|
||||
composeur, modales d'appairage et module JS supprimés. Même traitement que
|
||||
ci-dessus — les routes `/api/node/*` subsistent mais plus rien ne peut
|
||||
générer de code d'appairage, donc aucun poste ne peut se connecter.
|
||||
- **Catalogue de modèles distant** : il interrogeait `ajean.link/models.json`,
|
||||
sa route n'avait aucun consommateur et son repli embarqué datait de 2024. La
|
||||
recherche Hugging Face le remplace.
|
||||
|
||||
## Performance
|
||||
|
||||
Le but : des tours plus courts **sans dénaturer le modèle**. Rien ici ne
|
||||
touche à la quantification des poids, à l'échantillonnage, à la température, au
|
||||
budget de raisonnement ni au contenu du contexte. Tout ce qui changerait ce
|
||||
qu'envoie Loki au modèle, le placement des poids ou les slots du moteur est
|
||||
**désactivé par défaut**, derrière une clé ; sans clé, la requête et la ligne de
|
||||
commande restent celles d'avant. Les clés se posent dans le preset (éditeur) ou
|
||||
par `loki config set CLÉ valeur` ; celles marquées « moteur relancé » demandent
|
||||
un redémarrage du moteur.
|
||||
|
||||
### Actif par défaut, sans perte
|
||||
|
||||
- **Cache de prompts dimensionné** (`--cache-ram`) : agrandi pour un modèle
|
||||
tout-GPU d'après l'état mesuré dans le GGUF, jamais sous le défaut du moteur ;
|
||||
et le slot est effacé après un sous-agent, une vérification, une tâche ou un
|
||||
bench, pour que la conversation revienne du cache RAM au lieu d'être
|
||||
recalculée.
|
||||
- **Préfixe stable** : rappels de Loki gardés dans l'historique tels
|
||||
qu'envoyés, relance après un 500 sans toucher aux outils ni au système, ligne
|
||||
MCP présente dès le premier tour, ordre des trackers fixe.
|
||||
- **Comptage du contexte** sans le raisonnement jamais renvoyé : la compaction
|
||||
(avec perte) part moins tôt, et son résumé n'est plus amputé de l'état
|
||||
d'avancement.
|
||||
- **Threads CPU** : sans `THREADS`, le moteur prend ses cœurs physiques (plus
|
||||
tous les threads logiques) ; conteneur à l'étroit (cpuset, quota) borné.
|
||||
- **Deux GPU** : `CUDA_SCALE_LAUNCH_QUEUES=4x` quand le pipeline entre cartes
|
||||
est possible (même calcul, seul le prompt peut gagner).
|
||||
- **Points de reprise des hybrides** sur un moteur officiel antérieur à
|
||||
`b10864` : `--checkpoint-min-step 2048` d'office si la RAM le permet, et un
|
||||
avis « moteur trop ancien » ; l'encart « version recommandée » propose la mise
|
||||
à jour (sur clic seulement, voir [Mettre à jour llama.cpp](#mettre-à-jour-llamacpp-sans-reconstruire-limage)).
|
||||
- **Côté Go** : écriture d'un gros fichier en temps linéaire, persistance de la
|
||||
discussion hors du chemin du premier jeton, un seul `nvidia-smi` partagé
|
||||
(moniteur et éditeur de preset), stockage Unraid servi par FUSE signalé.
|
||||
- **Mesure** : télémétrie par complétion (cache repris, recalculé, brouillon
|
||||
accepté) dans l'interface et `GET /api/perf/summary`, sonde du gabarit de
|
||||
chat (lecture seule, `/apply-template`), bench honnête en tâche de fond.
|
||||
- **Garde-fous** : un cache KV quantifié, `--context-shift` ou `--cache-reuse`
|
||||
posés à la main sont signalés ; un mode de chargement résident voué à
|
||||
l'échec est refusé (`LOAD_GUARD`) ; avis de placement MoE dans l'éditeur.
|
||||
|
||||
### Les clés
|
||||
|
||||
| Clé | Défaut | Effet | Prix, zone grise |
|
||||
|---|---|---|---|
|
||||
| `CACHE_RAM` | auto | cache de prompts en RAM hôte (`--cache-ram`, Mio), copie exacte des conversations quittées | `-1` = RAM non bornée (déconseillé avec un MoE mmappé), `0` = coupé |
|
||||
| `CACHE_ISOLATE` | actif | efface le slot après un travail annexe (sous-agent, vérification, tâche, bench) | `off` pour couper ; sans objet avec `SIDE_SLOT` |
|
||||
| `THREADS` / `THREADS_BATCH` | vide = cœurs physiques | `-t` / `-tb` | — |
|
||||
| `CUDA_LAUNCH_QUEUES` | auto (4x sur ≥ 2 GPU si pipeline) | file de lancements CUDA | `off`, ou `0.25x`…`4x` imposé ; réglage de machine |
|
||||
| `CTX_CHECKPOINTS` | moteur (32) | points de reprise par slot d'un hybride | 70 à 200 Mio de RAM hôte chacun |
|
||||
| `CKPT_MIN_STEP` | moteur (2048 d'office : hybride, moteur officiel < `b10864`) | espacement minimal des points de reprise | — |
|
||||
| `LOAD_GUARD` | actif | refuse un `--load-mode` résident qui ne tient pas en RAM | `off` = lancer quand même |
|
||||
| `REASONING_ECHO` | off | renvoie au moteur local la réflexion du modèle, au format entraîné | plus de contexte par tour, compaction plus tôt |
|
||||
| `REASONING_PRESERVE` | vide = défaut du moteur | `--reasoning-preserve` / `--no-reasoning-preserve` | change le rendu : `off` rend l'ancien historique à Qwen3.6, mais change celui de Qwen3.8 |
|
||||
| `PROJ_SNAPSHOT` | off | bloc projet figé par discussion, changements en `<context_update>` ; date et dossier sortent du système | le modèle lit les changements en tête du message suivant |
|
||||
| `PREWARM` | off | `on` : prépare le prochain tour pendant que tu lis ; `full` : aussi la discussion qu'on ouvre | une requête de plus au moteur au repos (1 jeton, jeté) |
|
||||
| `COMPACT_CONTINUATION` | off | le résumé de compaction prolonge le prompt en cache au lieu d'une transcription à froid | repli sur l'ancien chemin au moindre écart |
|
||||
| `KEEP_TURN_IMAGES` | off | garde dans l'historique les images montrées par les outils | **zone grise** : plus de contexte, compaction plus tôt ; une API externe refacture les images |
|
||||
| `NUDGE_IN_TOOL` | off | le rappel de budget d'outils part au bout du dernier résultat d'outil | **zone grise** : un modèle peut moins bien suivre une consigne lue dans un outil |
|
||||
| `SIDE_SLOT` | off (moteur relancé) | second slot pour les travaux annexes, la discussion garde le sien | cache KV et état récurrent en double en VRAM |
|
||||
| `SLOT_PERSIST` | off | état du slot gardé sur disque à la bascule de preset, rechargé au retour | 2 fichiers de 8 Gio au plus ; refusé avec `SPEC` |
|
||||
| `SPEC` | off (moteur relancé) | décodage spéculatif : `auto`, `mtp`, `ngram`, `mtp+ngram` | même distribution, pas le même texte au bit près ; MTP : 1 à 2 Go de VRAM |
|
||||
| `MODEL_DRAFT` | — | tête MTP publiée à part ou petit modèle brouillon | VRAM |
|
||||
| `SPEC_N_MAX` | moteur (3) | jetons anticipés par étape | — |
|
||||
| `SPEC_SAMPLING` | greedy (exact) | `probabilistic` seulement avec `SPEC=mtp` ou `mtp+ngram` | — |
|
||||
| `SPLIT_MODE` | couches | `tensor` : parallélisme de tenseurs entre cartes CUDA | expérimental : décodage plus rapide, prefill plus lent |
|
||||
| `FIT_TARGET` | 1024 Mio par carte | marge laissée libre par le placement auto (`--fit-target`) | — |
|
||||
| `OP_OFFLOAD_MIN_BATCH` | moteur (32) | lot à partir duquel les experts MoE sur CPU sont recopiés vers le GPU | à mesurer |
|
||||
| `CUDA_GRAPH_OPT` | off | branches Q/K/V en parallèle au décodage | expérimental ; `off` puis redémarrage s'il plante |
|
||||
| `KV_TYPE` (`_K`, `_V`) | f16 | quantification du cache KV | **zone grise** : `q8_0` modifie légèrement les sorties, `q4_0` perte mesurable — jamais posé par Loki |
|
||||
| `BATCH` / `UBATCH` | 2048 / 512 | lots du prefill | MoE aux experts en RAM : `UBATCH` 2048+ |
|
||||
| `LOKI_PERF_LOG` (environnement) | absent | une ligne `[perf]` par complétion sur stderr | — |
|
||||
|
||||
Outils sans clé, qui ne font rien d'eux-mêmes :
|
||||
|
||||
| Outil | Ce qu'il fait |
|
||||
|---|---|
|
||||
| **Bench** (bouton, `loki bench`, `--full`) | mesure en tâche de fond, à profondeur réelle avec le mode complet (prefill à froid, tours qui reprennent le cache, decode) |
|
||||
| **Optimiseur** (`loki tune`, bouton « Optimiser… ») | cherche lots, threads, marges, placement (`--placement`) et options opt-in (`--opt-in`) sur un moteur d'essai ; rien n'est écrit sans un clic |
|
||||
| **Dupliquer en placement auto…** (éditeur, MoE) | copie du preset où `--fit` place les experts au lieu de `-ot` / `--n-cpu-moe` |
|
||||
| **Version recommandée** (panneau Moteur) | met à jour llama.cpp vers un build ≥ `b10864`, sur clic, avec retour à la version précédente |
|
||||
|
||||
### Ordre de test conseillé
|
||||
|
||||
Une étape à la fois, mesurée avant la suivante :
|
||||
|
||||
1. **Bench complet** du preset tel quel : la référence.
|
||||
2. **`PREWARM=on` et `COMPACT_CONTINUATION=on`**, puis une vraie session de
|
||||
travail ; `GET /api/perf/summary` dit, par nature de requête
|
||||
(`kinds.main`…), combien de fois et de jetons le cache a été perdu
|
||||
(`lost_events`, `lost_tokens`) et le prefill par tour.
|
||||
3. **Optimiseur** (« Optimiser… ») sur le preset en service : lots, threads,
|
||||
marges.
|
||||
4. **MoE aux experts en RAM** : « Dupliquer en placement auto… », bascule sur
|
||||
la copie, `successfully fit params` au journal, bench complet des deux.
|
||||
5. **Mise à jour du moteur** vers la version recommandée si l'encart s'affiche
|
||||
(points de reprise des hybrides, MTP rapide) ; vérifier le rendu du gabarit
|
||||
dans le panneau.
|
||||
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP : bench, puis
|
||||
`draft_rate` dans `/api/perf/summary`.
|
||||
|
||||
### Ce qui a été écarté, et pourquoi
|
||||
|
||||
- **`--cache-reuse`** : réutilise des morceaux de cache calculés sous un autre
|
||||
contexte, donc change les sorties ; llama.cpp le coupe d'ailleurs pour les
|
||||
hybrides et le multimodal. Signalé s'il est posé à la main.
|
||||
- **`--context-shift`** à la place de la compaction : retire des jetons du
|
||||
contexte (perte), et ne marche pas sur les hybrides. Signalé lui aussi.
|
||||
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
|
||||
mesurablement, pour un gain d'environ 2 Gio sur un 27B hybride (16 couches
|
||||
sur 64 portent du KV). Reste un choix explicite et étiqueté (`KV_TYPE`).
|
||||
- Aussi : `-sm tensor` par défaut (prefill plus lent, expérimental), plus de
|
||||
slots par défaut (OOM : états récurrents par slot), `--cache-ram -1` (RAM
|
||||
non bornée), et tout réglage d'échantillonnage ou de quantification « pour
|
||||
aller plus vite ».
|
||||
|
||||
### Détail des clés et des outils
|
||||
|
||||
- **Bench** (bouton du preset, `loki bench`, `--full` pour le mode complet) :
|
||||
en tâche de fond (`POST /api/bench`, progression et annulation), chat,
|
||||
tâches et compaction refusés le temps de la mesure. Le mode rapide mesure une
|
||||
ligne courte avec le gabarit, le raisonnement et l'échantillonnage du preset ;
|
||||
le mode complet ajoute un prefill à froid à profondeur réelle (jusqu'à 32 k
|
||||
jetons, 16 k si des poids tournent sur CPU) et trois tours qui reprennent le
|
||||
cache — reprise lue dans `cache_n`, jamais supposée. Rien n'est enregistré
|
||||
sans réponses valides et timings réels ; l'empreinte du preset accompagne
|
||||
chaque mesure.
|
||||
- **Raisonnement renvoyé au modèle** (clé `REASONING_ECHO`, **off** par défaut,
|
||||
`loki config set REASONING_ECHO on`) : le raisonnement que le moteur local a
|
||||
séparé (`reasoning_content`) est gardé avec chaque message et renvoyé au même
|
||||
@@ -379,12 +642,19 @@ Ajoutées par ce fork :
|
||||
tracker, et le texte **complet** d'une description ou d'un `AGENTS.md` modifié
|
||||
— rien n'est perdu, seul ce petit bloc est à calculer. Une ligne du prompt
|
||||
système (présente seulement avec la clé) dit au modèle que ces blocs viennent
|
||||
de Loki et que le plus récent l'emporte. Le bloc est repris tout neuf, et les
|
||||
de Loki et que le plus récent l'emporte. La date du jour et le dossier de
|
||||
travail de la discussion (ou, pour un poste distant ciblé, son nom, son
|
||||
dossier et s'il est hors ligne) quittent aussi le prompt système pour ce
|
||||
bloc, les consignes restant dans le système : système et outils deviennent
|
||||
les mêmes d'une discussion à l'autre, et passer minuit n'ajoute qu'une ligne
|
||||
de mise à jour au lieu de tout recalculer. Le bloc est repris tout neuf, et les
|
||||
anciens `<context_update>` retirés, quand le début du prompt change de toute
|
||||
façon : compaction, système ou outils modifiés (date, réglages), redémarrage de
|
||||
façon : compaction, système ou outils modifiés (réglages, mode), redémarrage de
|
||||
Loki, changement de modèle, de preset, de projet, de mode mémoire ou de mode
|
||||
Code — et dès que les mises à jour accumulées deviennent trop longues. Les
|
||||
tâches planifiées gardent le bloc à jour à chaque tour ; sans agent, rien du
|
||||
tâches planifiées gardent le bloc à jour à chaque tour et, comme les
|
||||
sous-agents, la vérification et le terminal, leur système d'avant, date et
|
||||
dossier compris ; sans agent, rien du
|
||||
projet n'est envoyé ; un preset externe (API) n'est jamais concerné. Le titre, l'export JSON, le résumé de compaction et la
|
||||
passe de vérification ne voient pas ces blocs.
|
||||
- **Préchauffage du prochain tour** (clé `PREWARM`, **off** par défaut,
|
||||
@@ -466,8 +736,10 @@ Ajoutées par ce fork :
|
||||
et tout le tour est rendu autrement), ce rappel fait recalculer toute la
|
||||
boucle d'outils du tour. Avec `on`, le rappel part au bout du dernier résultat
|
||||
d'outil, et seul ce bout est à calculer — sur le moteur local, et seulement
|
||||
si la sonde de gabarit a conclu que le rendu de ce modèle bouge (« inconnu » :
|
||||
message à part, comme sans la clé). Le texte du rappel ne change pas, sa
|
||||
si la sonde de gabarit a conclu que le rendu de ce modèle bouge pour la forme
|
||||
de cette requête — mêmes outils, `chat_template_kwargs` et niveau de
|
||||
raisonnement (« inconnu » ou forme jamais sondée : message à part, comme
|
||||
sans la clé). Le texte du rappel ne change pas, sa
|
||||
place si : un modèle entraîné à se méfier des consignes lues dans une sortie
|
||||
d'outil peut moins bien le suivre. À comparer (tours qui concluent après le
|
||||
rappel) avant de l'adopter.
|
||||
@@ -639,8 +911,16 @@ Ajoutées par ce fork :
|
||||
de 80 % (llama.cpp #26110 : swap, décodage de 25 à 7,5 t/s ; mlock : processus
|
||||
tué) ; **refus** au lancement seulement quand la borne basse dépasse 90 %
|
||||
(échec certain), avec la raison en clair, aussi affichée par l'interface.
|
||||
VRAM inconnue (Mac Intel, AMD, Vulkan, `--device`) ou serveurs `--rpc` :
|
||||
avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ;
|
||||
Le refus sort avec le code 78, que l'unité systemd écrite par
|
||||
`loki install` ne relance pas (`RestartPreventExitStatus=78`) ; une unité
|
||||
d'une version précédente (ou launchd) reçoit une sortie sans erreur pour ne
|
||||
pas boucler — `sudo systemctl edit loki-engine` (section `[Service]`,
|
||||
`RestartPreventExitStatus=78`) met l'unité à jour.
|
||||
La VRAM qui fonde un refus est celle des cartes que le moteur liste
|
||||
lui-même (`--list-devices`, lu seulement quand un refus se profile sur la
|
||||
VRAM NVIDIA) : un moteur Vulkan sur cartes mixtes NVIDIA + AMD compte les
|
||||
deux. Liste illisible, VRAM inconnue (Mac Intel, AMD seule, `--device`) ou
|
||||
serveurs `--rpc` : avertissement, jamais de refus. `mmap` et `auto` ne sont jamais concernés ;
|
||||
`LLAMA_ARG_NO_MMAP`/`LLAMA_ARG_MLOCK` ne comptent que sur un moteur ancien
|
||||
(un moteur à `--load-mode` les ignore). Rien ne change dans la ligne de
|
||||
commande.
|
||||
@@ -664,11 +944,17 @@ Ajoutées par ce fork :
|
||||
claire ; les tâches planifiées attendent. Un verrou dont le propriétaire est
|
||||
mort (Loki tué en plein essai) est écarté avant tout démarrage du moteur :
|
||||
l'essai orphelin n'est arrêté que si PID, instant de démarrage ET binaire
|
||||
concordent (`taskkill /T` sous Windows), et le moteur est relancé au
|
||||
démarrage de l'interface s'il tournait avant. **Refus d'entrée** : preset
|
||||
concordent (`taskkill /T` sous Windows), et le moteur est relancé s'il
|
||||
tournait avant — au démarrage de l'interface, puis toutes les 30 s par le
|
||||
processus web (un `stat` tant qu'aucun verrou n'existe) : un `loki tune` tué
|
||||
par `kill -9` ne laisse plus le moteur arrêté. **Refus d'entrée** : preset
|
||||
externe, aucun preset actif, génération, tâche, bench ou job `bash_bg` en
|
||||
cours, moteur occupé (`/slots`) — en ligne de commande, seul ce dernier
|
||||
contrôle voit le processus web : préférer le bouton. **Essais** (descente étape par étape depuis
|
||||
contrôle voit le processus web : préférer le bouton. En ligne de commande
|
||||
aussi, un moteur qui ne serait pas celui de ce `LOKI_HOME` (il répond alors
|
||||
que ce dossier le dit arrêté, refuse sa clé d'API ou sert un autre `MODEL` :
|
||||
typiquement `sudo loki tune`, qui perd un `LOKI_HOME` exporté) fait
|
||||
refuser, avec la marche à suivre. **Essais** (descente étape par étape depuis
|
||||
la meilleure configuration du moment, chaque axe seulement si l'aide du
|
||||
moteur et la machine le permettent) : placement — seulement avec « inclure
|
||||
le placement » / `--placement` : `--fit` à la place des experts placés à la
|
||||
@@ -724,110 +1010,6 @@ Ajoutées par ce fork :
|
||||
decode) — sinon l'ancienne version est rétablie et le moteur relancé. Pendant
|
||||
la mesure, le chat est indisponible : prévoir 10 à 40 minutes selon la taille
|
||||
du modèle (un modèle relu depuis un disque lent recharge à chaque essai).
|
||||
- **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
|
||||
déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les
|
||||
chemins relatifs du modèle y sont résolus. Changer de discussion change donc
|
||||
les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que
|
||||
le disque ne se remplisse pas en silence.
|
||||
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
|
||||
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
|
||||
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
|
||||
(Chromium via Playwright, inclus dans l'image). Les captures partent en JPEG
|
||||
et sont plafonnées à 20 fichiers / 40 Mo par discussion. La description de
|
||||
l'outil suit la capacité **réelle** du moteur, sondée sur `/props` : sans
|
||||
vision effective, elle dit au modèle « tu ne vois pas l'image » plutôt que de
|
||||
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
|
||||
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
|
||||
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
|
||||
- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
|
||||
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
|
||||
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
|
||||
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
|
||||
supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
|
||||
sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le
|
||||
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
|
||||
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
|
||||
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
|
||||
- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
|
||||
et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
|
||||
aucune requête vers un service de polices. Deux variantes : claire par défaut,
|
||||
**Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
|
||||
L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
|
||||
changement de preset ne demande plus d'ouvrir les réglages) ; la barre
|
||||
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
|
||||
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
|
||||
visibles en pied de colonne.
|
||||
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
|
||||
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
|
||||
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
|
||||
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
|
||||
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
|
||||
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
|
||||
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
|
||||
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
|
||||
du moteur. Conséquence directe : l'API est joignable partout où l'interface
|
||||
l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de
|
||||
second port ni ouvrir le moteur. L'amont annonçait `http://<ip>:8080/v1`, une
|
||||
adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP
|
||||
détectée est celle du bridge Docker).
|
||||
- Authentification par la **clé API** du panneau (`Authorization: Bearer …`),
|
||||
vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et
|
||||
l'interface le dit en rouge.
|
||||
- **Adresse publique** : un champ où saisir son domaine, pour le cas du
|
||||
reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse
|
||||
affichée suit celle du navigateur.
|
||||
- **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki
|
||||
honore `X-Forwarded-Proto` pour annoncer une adresse en `https`.
|
||||
- L'ancienne exposition publique via le relais de l'amont
|
||||
(`<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.
|
||||
- **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 :
|
||||
|
||||
- **Accès distant via [ajean.link](https://ajean.link)** : la section de
|
||||
l'interface et son module JS sont supprimés — un conteneur derrière son
|
||||
propre réseau n'en a pas l'usage. Le code serveur du relais reste en place
|
||||
mais **inerte** (aucun jeton, aucune section pour en fournir un) : le retirer
|
||||
créerait un conflit à chaque reprise de l'amont.
|
||||
- **Postes distants** (faire agir l'agent sur un autre PC appairé) : bouton du
|
||||
composeur, modales d'appairage et module JS supprimés. Même traitement que
|
||||
ci-dessus — les routes `/api/node/*` subsistent mais plus rien ne peut
|
||||
générer de code d'appairage, donc aucun poste ne peut se connecter.
|
||||
- **Catalogue de modèles distant** : il interrogeait `ajean.link/models.json`,
|
||||
sa route n'avait aucun consommateur et son repli embarqué datait de 2024. La
|
||||
recherche Hugging Face le remplace.
|
||||
|
||||
## Différences avec l'amont
|
||||
|
||||
@@ -904,6 +1086,40 @@ courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intac
|
||||
Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
|
||||
solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
|
||||
|
||||
**Version recommandée.** Quand le moteur officiel qui tourne est antérieur à
|
||||
`b10864`, le panneau affiche un encart qui dit ce que la mise à jour apporte :
|
||||
les points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next) ne sont plus
|
||||
évincés trop tôt — jusqu'à ~8 k jetons recalculés à chaque reprise —, le MTP
|
||||
rapide (graphes CUDA) arrive à `b11009` et le MTP de qwen4exp à `b11331`. Le
|
||||
décodage spéculatif reste coupé tant que `SPEC` n'est pas posé. L'encart se
|
||||
décide sans réseau, sur le build du moteur (jamais pour un llama.cpp compilé ou
|
||||
un fork, dont le numéro ne prouve rien). Le bouton **Mettre à jour vers la
|
||||
version recommandée** n'interroge le registre qu'au clic : il prend le dernier
|
||||
build publié, vérifie qu'il atteint `b10864` et que son tag figé
|
||||
(`server-cuda-bNNNNN`) existe bien, puis demande confirmation. Sans réseau, il
|
||||
le dit et n'installe rien. Rien n'est jamais mis à jour d'office, ni au
|
||||
démarrage.
|
||||
|
||||
**Revenir à la version précédente.** Après une mise à jour, la version
|
||||
téléchargée qui tournait juste avant est gardée (les plus anciennes partent,
|
||||
~450 Mo chacune) ; le bouton **Revenir à la version précédente** y ramène sans
|
||||
réseau, et la version quittée devient à son tour « la précédente ».
|
||||
|
||||
**Le rendu du raisonnement.** Depuis `b10763`, llama-server garde par défaut la
|
||||
réflexion des tours passés dans le gabarit (`preserve_reasoning`). Un gabarit
|
||||
qui la retirait de lui-même (Qwen3.6, gabarits à `clear_thinking`) la rend alors
|
||||
— vide, puisque Loki ne la renvoie pas sans `REASONING_ECHO` : le modèle
|
||||
relirait un historique où il n'a jamais réfléchi. Avant de basculer vers un tel
|
||||
moteur, Loki lit le gabarit chargé et le verdict de la sonde de gabarit ; si le
|
||||
prompt rendu va changer et que `REASONING_PRESERVE` n'est pas posé, la
|
||||
confirmation l'explique et propose de poser `REASONING_PRESERVE=off` dans le
|
||||
preset actif (case cochée seulement quand le changement est établi :
|
||||
sur un gabarit qui garde déjà la réflexion, comme Qwen3.8, `off` changerait à
|
||||
son tour le rendu). L'utilisateur choisit. Après la bascule, le rendu de
|
||||
quelques conversations synthétiques est comparé, via `/apply-template`, entre
|
||||
l'ancien et le nouveau moteur une fois le modèle chargé : le panneau dit s'il
|
||||
est identique, ou montre le premier écart.
|
||||
|
||||
Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
|
||||
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
|
||||
|
||||
|
||||
+128
-72
@@ -1,93 +1,149 @@
|
||||
# Loki 0.14.0
|
||||
# Loki 0.15.0
|
||||
|
||||
Loki rattrape AJEAN jusqu'à la 0.17.6 et reprend une série d'idées d'OpenFox
|
||||
pour le mode Code. Le gros du travail est invisible : des discussions qui ne se
|
||||
bloquent plus sur un contexte plein, des réponses qui survivent à une coupure
|
||||
réseau, un agent de code qui gaspille moins de tours.
|
||||
Une version entièrement consacrée à la vitesse, avec une règle tenue de bout en
|
||||
bout : **ne jamais dénaturer le modèle**. Rien ne touche à la quantification
|
||||
des poids, à l'échantillonnage, à la température, au budget de raisonnement ni
|
||||
au contenu du contexte. Ce qui est sans perte est actif d'office ; tout ce qui
|
||||
changerait ce que voit le modèle, le placement des poids ou les slots du moteur
|
||||
attend une clé. Sans clé, la requête et la ligne de commande restent celles
|
||||
d'avant.
|
||||
|
||||
## Le contexte ne bloque plus la discussion
|
||||
La section **Performance** du README rassemble toutes les clés, leur défaut et
|
||||
leur prix.
|
||||
|
||||
Sur une fenêtre de 65k, un tour qui lisait plusieurs gros fichiers d'un coup
|
||||
passait de 60 % à plus de 130 % sans jamais compacter, et le moteur répondait
|
||||
par un 400 qui figeait la discussion.
|
||||
## Actif d'office, sans perte
|
||||
|
||||
- Le compactage en cours de tour compte aussi les résultats d'outils que le
|
||||
moteur n'a pas encore vus.
|
||||
- Tout résultat d'outil est borné à 30 000 caractères **pour le modèle** ;
|
||||
l'interface garde le résultat complet (« voir plus »).
|
||||
- En dernier recours, les gros résultats sont tronqués puis les plus vieux
|
||||
échanges retirés, au lieu de laisser remonter l'erreur.
|
||||
- Le compactage ne fait plus répondre deux fois à une vieille question, et le
|
||||
texte écrit avant un appel d'outil n'est plus rangé en double.
|
||||
**La conversation n'est plus recalculée pour rien.**
|
||||
|
||||
## Presets : API compatible OpenAI
|
||||
- **Cache de prompts dimensionné** : le moteur garde en RAM une copie exacte des
|
||||
conversations qu'il quitte. Son défaut ne tenait pas une conversation de 30 à
|
||||
65 k jetons à côté d'un vérificateur ou d'un sous-agent : il l'évinçait, et
|
||||
tout était recalculé (30 à 80 s sur un 27B). Il est maintenant agrandi pour un
|
||||
modèle tout-GPU, et le slot est effacé après chaque travail annexe pour que la
|
||||
conversation revienne du cache.
|
||||
- **Préfixe stable** : les rappels de Loki entrent dans l'historique tels
|
||||
qu'envoyés, la relance après un 500 ne change plus les outils, la ligne MCP ne
|
||||
manque plus au premier tour après un démarrage, l'ordre des trackers ne bouge
|
||||
plus. Chacun de ces écarts faisait recalculer toute la boucle d'outils.
|
||||
- **Compaction moins tôt** : le raisonnement que Loki ne renvoie jamais ne
|
||||
compte plus dans le contexte. La compaction (qui perd de l'information)
|
||||
partait vers 44-47 k au lieu de 49 k sur une fenêtre de 65 k. Son résumé n'est
|
||||
plus amputé de l'état d'avancement.
|
||||
- **Hybrides (Qwen3.5/3.6) sur un moteur ancien** : `--checkpoint-min-step 2048`
|
||||
d'office quand la RAM le permet, pour ne plus retomber jusqu'à ~8 k jetons en
|
||||
arrière à chaque reprise.
|
||||
|
||||
Les presets d'API externe gagnent ce qui leur manquait :
|
||||
**Le matériel est mieux employé.**
|
||||
|
||||
- une case **« le modèle accepte les images »** pour un modèle distant
|
||||
multimodal ;
|
||||
- plus de `chat_template_kwargs` (propre à llama.cpp) envoyé à l'API : une API
|
||||
stricte répondait 400 et **chaque compactage échouait** ;
|
||||
- **reprise automatique** quand le flux se coupe en pleine réponse (Wi-Fi, VPN,
|
||||
proxy) : jusqu'à 5 fois, le modèle reprend là où il s'était arrêté ;
|
||||
- un 401, 429 ou 502 n'est plus pris pour un appel d'outil mal formé ;
|
||||
- le débit est mesuré même quand l'API ne le donne pas ; le benchmark refuse de
|
||||
mesurer un moteur local qui n'est pas celui du preset ; le badge des réponses
|
||||
porte le nom du modèle distant.
|
||||
- **Threads CPU** : « auto » veut dire les cœurs physiques, plus tous les
|
||||
threads logiques — c'est le décodage des experts MoE sur CPU qui payait.
|
||||
- **Deux GPU** : file de lancements CUDA élargie quand le pipeline entre cartes
|
||||
est possible (même calcul ; seul le prompt peut gagner).
|
||||
|
||||
## Mode Code
|
||||
**Loki lui-même pèse moins.**
|
||||
|
||||
- **La vérification attend que le builder ait fini.** Il marque chaque critère
|
||||
`completed` une fois fait ; tant qu'il en reste d'ouverts, il est relancé au
|
||||
lieu de payer une passe de vérification sur un travail inachevé. Plus aucune
|
||||
vérification ne part quand il vient de poser une question.
|
||||
- **Écriture refusée dès son chemin** : un `write` sur un fichier jamais lu
|
||||
était refusé après tout le fichier généré. Le chemin passe maintenant en
|
||||
premier et le refus coupe la génération aussitôt.
|
||||
- **Appel d'outil écrit en texte** (`<tool_call>`, format XML de Qwen3-Coder) :
|
||||
repéré pendant le flux, coupé, et la relance cite l'extrait fautif.
|
||||
- `read_file`, `str_replace`, `old_string`… appris d'autres agents sont
|
||||
traduits vers les vrais outils.
|
||||
- Les consignes du dépôt (**AGENTS.md**, CLAUDE.md) font partie du contexte.
|
||||
- Terminal : `cmd | tail -N` rend le **vrai** code de sortie, la sortie déjà
|
||||
produite est gardée au délai dépassé, les couleurs ANSI sont retirées.
|
||||
- `git_status` / `git_diff` trouvent le dépôt cloné dans un sous-dossier.
|
||||
- Le compactage garde les fichiers touchés, les erreurs résolues et l'état des
|
||||
critères.
|
||||
- Écrire un gros fichier ne coûte plus un temps quadratique côté Go (jusqu'à
|
||||
8 s de CPU volées au décodage sur un MoE).
|
||||
- L'enregistrement de la discussion ne retarde plus le premier jeton.
|
||||
- Un seul `nvidia-smi` pour tous les onglets, aucun pour un onglet caché, un
|
||||
seul à l'ouverture de l'éditeur de preset.
|
||||
- Unraid : un `/data` ou `/models` servi par la couche FUSE est signalé, avec
|
||||
le remède.
|
||||
|
||||
## Tâches et chat
|
||||
**On peut enfin mesurer.**
|
||||
|
||||
- **« Rappelle-moi dans 20 minutes »** : `task_create` accepte `in_minutes` ou
|
||||
`at`, à l'heure du navigateur (le conteneur tourne en UTC).
|
||||
- **Écrire pendant que l'IA répond** : le message part en file et le modèle en
|
||||
tient compte dans la suite de sa réponse ; deux appareils qui envoient en
|
||||
même temps ne se refusent plus.
|
||||
- Longues discussions : au chargement, les 20 derniers échanges, le début d'un
|
||||
clic ; la liste des discussions se dessine par pages et montre la nouvelle
|
||||
dès le premier message.
|
||||
- Mémoire : un mode par projet, et un quatrième, « recherche ».
|
||||
- Appels d'outils parallèles séparés selon leur index ; bascule de preset par
|
||||
identifiant ; un raisonnement qui reprend après la réponse a sa propre bulle.
|
||||
- Chaque réponse dit ce qu'elle a repris du cache, recalculé et accepté du
|
||||
brouillon ; `GET /api/perf/summary` en fait la synthèse par nature de requête
|
||||
(`lost_events`, `lost_tokens` : le cache perdu, et après quoi).
|
||||
- **Bench** honnête, en tâche de fond : mode complet à profondeur réelle, tours
|
||||
qui reprennent le cache, rien d'enregistré sans timings réels.
|
||||
- Une sonde du gabarit de chat dit, sans rien envoyer au modèle, si le rendu
|
||||
reste stable d'un message à l'autre.
|
||||
|
||||
## Sécurité et données
|
||||
**Garde-fous.** Un cache KV quantifié, `--context-shift` ou `--cache-reuse`
|
||||
posés à la main sont signalés. Un mode de chargement résident voué à l'échec
|
||||
(poids restés en RAM au-delà de 90 %) est refusé avec la raison en clair —
|
||||
`LOAD_GUARD=off` pour passer outre ; le refus n'est plus relancé en boucle par
|
||||
systemd, et un moteur Vulkan sur cartes mixtes n'est plus refusé à tort.
|
||||
|
||||
- **Un site tiers ne peut plus piloter Loki** depuis le navigateur (Origin /
|
||||
Sec-Fetch-Site, DNS rebinding). ⚠️ Derrière un reverse proxy par nom de
|
||||
domaine, définis une clé de pilotage ou `LOKI_TRUSTED_HOSTS` **avant** la mise
|
||||
à jour, et vérifie que le proxy transmet l'en-tête `Host` d'origine.
|
||||
- **Chiffrement de la mémoire** : l'activer rendait illisibles la discussion
|
||||
active et le mode Code. Corrigé ; une base déjà touchée est réparée au
|
||||
déverrouillage. Les résultats d'outils (« voir plus ») et les images du fil
|
||||
sont désormais chiffrés eux aussi, et les images effacées après 24 h.
|
||||
## En opt-in : à essayer, à mesurer
|
||||
|
||||
## Moteur
|
||||
| Clé | Ce qu'elle fait |
|
||||
|---|---|
|
||||
| `PREWARM=on` | prépare le prochain tour pendant que tu lis (`full` : aussi la discussion qu'on ouvre) |
|
||||
| `COMPACT_CONTINUATION=on` | le résumé de compaction prolonge le prompt en cache au lieu d'être calculé à froid |
|
||||
| `PROJ_SNAPSHOT=on` | bloc projet figé par discussion, changements livrés à part ; date et dossier sortent du système |
|
||||
| `REASONING_ECHO=on` | renvoie au moteur local la réflexion du modèle, au format entraîné |
|
||||
| `SPEC=auto` / `mtp` | décodage spéculatif MTP (tête détectée dans le fichier, garde-fous VRAM) |
|
||||
| `SPEC=ngram` / `mtp+ngram` | n-grammes du contexte, utiles en mode Code |
|
||||
| `SIDE_SLOT=on` | second slot pour les travaux annexes (VRAM en double) |
|
||||
| `SLOT_PERSIST=on` | état du slot gardé à la bascule de preset |
|
||||
| `SPLIT_MODE=tensor` | parallélisme de tenseurs entre cartes (expérimental) |
|
||||
| `FIT_TARGET`, `OP_OFFLOAD_MIN_BATCH`, `CUDA_GRAPH_OPT` | réglages d'expert pour cartes inégales et MoE |
|
||||
| `KEEP_TURN_IMAGES=on` | **zone grise** : garde les images des outils dans l'historique |
|
||||
| `NUDGE_IN_TOOL=on` | **zone grise** : rappel de budget dans le résultat d'outil |
|
||||
|
||||
- `--mlock` seul ne coupe plus le mmap (OOM au chargement).
|
||||
- Port déjà occupé refusé avec un message clair ; cache KV lent signalé.
|
||||
- Réponses web compressées en gzip.
|
||||
Et deux outils qui ne font rien d'eux-mêmes :
|
||||
|
||||
- **Optimiseur** (`loki tune`, bouton « Optimiser… ») : cherche lots, threads,
|
||||
marges et placement sur un moteur d'essai isolé ; rien n'est écrit sans un
|
||||
clic, et l'application est vérifiée puis défaite si le moteur ne tient pas.
|
||||
- **« Dupliquer en placement auto… »** pour un MoE aux experts placés à la
|
||||
main : une copie du preset où `--fit` les répartit.
|
||||
|
||||
## Ordre de test conseillé
|
||||
|
||||
Une étape à la fois, mesurée avant la suivante :
|
||||
|
||||
1. **Bench complet** du preset tel quel : la référence.
|
||||
2. **`PREWARM` + `COMPACT_CONTINUATION`**, une vraie session de travail, puis
|
||||
`/api/perf/summary` : le cache perdu (`lost`) doit baisser.
|
||||
3. **Optimiseur** sur le preset en service.
|
||||
4. **MoE** : « Dupliquer en placement auto… », bench complet des deux.
|
||||
5. **Mise à jour du moteur** vers la version recommandée (encart du panneau
|
||||
Moteur).
|
||||
6. **`SPEC=auto`** (ou `mtp`) si le modèle a une tête MTP.
|
||||
|
||||
## Moteur : version recommandée et retour arrière
|
||||
|
||||
Quand le moteur officiel est antérieur à `b10864`, le panneau Moteur dit ce
|
||||
qu'une mise à jour apporte (points de reprise des hybrides, MTP rapide) et la
|
||||
propose — sur clic seulement, version vérifiée sur le registre. La version
|
||||
quittée est gardée : **Revenir à la version précédente** y ramène sans réseau.
|
||||
|
||||
Depuis `b10763`, llama-server garde par défaut la réflexion des tours passés :
|
||||
un gabarit comme Qwen3.6 la rendrait vide. Avant de basculer, Loki le détecte et
|
||||
propose `REASONING_PRESERVE=off` ; après, il compare le rendu de l'ancien et du
|
||||
nouveau moteur et signale le premier écart.
|
||||
|
||||
## Ce qui a été écarté
|
||||
|
||||
- **`--cache-reuse`** : réutilise du cache calculé sous un autre contexte, donc
|
||||
change les sorties.
|
||||
- **`--context-shift`** : retire des jetons du contexte, et ne marche pas sur
|
||||
les hybrides.
|
||||
- **Cache KV quantifié par défaut** : `q8_0` n'est pas exact, `q4_0` perd
|
||||
mesurablement, pour ~2 Gio gagnés sur un 27B hybride. Reste un choix
|
||||
explicite (`KV_TYPE`).
|
||||
|
||||
## Autres corrections
|
||||
|
||||
- Une compaction en plein tour ne fait plus disparaître le rappel des pages
|
||||
mémoire lues.
|
||||
- Un `loki tune` tué net ne laisse plus le moteur arrêté ; lancé avec un autre
|
||||
`LOKI_HOME` que l'interface (`sudo`), il refuse au lieu de charger un essai à
|
||||
côté d'un moteur vivant.
|
||||
- L'ancienne option « n-grammes (mod) » de l'éditeur rejoint `SPEC=ngram`, avec
|
||||
une migration proposée (jamais faite d'office).
|
||||
- Accès refusé : l'interface dit pourquoi et comment le lever.
|
||||
|
||||
## Mise à jour
|
||||
|
||||
```
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
Installation systemd (`loki install`) : l'unité du moteur gagne
|
||||
`RestartPreventExitStatus=78`. `loki update` ne réécrit pas les unités ;
|
||||
`sudo loki install`, ou `sudo systemctl edit loki-engine` avec cette ligne dans
|
||||
`[Service]`, la met à jour.
|
||||
Binary file not shown.
Binary file not shown.
@@ -2,13 +2,13 @@
|
||||
"FixedFileInfo": {
|
||||
"FileVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 14,
|
||||
"Minor": 15,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
"ProductVersion": {
|
||||
"Major": 0,
|
||||
"Minor": 14,
|
||||
"Minor": 15,
|
||||
"Patch": 0,
|
||||
"Build": 0
|
||||
},
|
||||
@@ -25,7 +25,7 @@
|
||||
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
|
||||
"OriginalFilename": "loki.exe",
|
||||
"ProductName": "Loki",
|
||||
"ProductVersion": "0.14.0",
|
||||
"ProductVersion": "0.15.0",
|
||||
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
|
||||
},
|
||||
"VarFileInfo": {
|
||||
|
||||
@@ -64,4 +64,8 @@ services:
|
||||
- /mnt/user/appdata/loki/data:/data
|
||||
# /models : dossier de .gguf déposés à la main (gros fichiers)
|
||||
- /mnt/user/appdata/loki/models:/models
|
||||
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
|
||||
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||
ulimits:
|
||||
memlock: -1
|
||||
restart: unless-stopped
|
||||
@@ -37,4 +37,8 @@ services:
|
||||
- driver: nvidia
|
||||
count: all
|
||||
capabilities: ["gpu"]
|
||||
# Moteur Strata (Qwen3.8 Flash Next) : il verrouille ses experts en RAM
|
||||
# (mlock, 30 à 50 Go). La limite Docker par défaut (64 Ko) l'en empêche.
|
||||
ulimits:
|
||||
memlock: -1
|
||||
restart: unless-stopped
|
||||
@@ -44,20 +44,45 @@ func emitBuildLine(line string) {
|
||||
// Sert à téer la sortie des commandes de runStep quand un sink est actif.
|
||||
// (mutex : stdout et stderr d'une même commande peuvent écrire en parallèle)
|
||||
type sinkWriter struct {
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
mu sync.Mutex
|
||||
buf []byte
|
||||
lastProg time.Time // dernière ligne de progression (\r) émise
|
||||
}
|
||||
|
||||
// sinkProgressEvery : une barre de progression (tqdm, téléchargements de
|
||||
// l'installeur de Strata) se redessine sur place avec \r, sans jamais finir sa
|
||||
// ligne. On en montre une version toutes les 10 s : sans elle, un téléchargement
|
||||
// de 76 Go laissait le journal figé pendant une heure ; toutes, elles le noieraient.
|
||||
const sinkProgressEvery = 10 * time.Second
|
||||
|
||||
func (s *sinkWriter) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.buf = append(s.buf, p...)
|
||||
for {
|
||||
i := strings.IndexByte(string(s.buf), '\n')
|
||||
i := strings.IndexAny(string(s.buf), "\r\n")
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
|
||||
if s.buf[i] == '\n' {
|
||||
emitBuildLine(strings.TrimRight(string(s.buf[:i]), "\r"))
|
||||
s.buf = s.buf[i+1:]
|
||||
continue
|
||||
}
|
||||
// \r : fin de ligne Windows (\r\n) ou barre qui se redessine. Le \n peut
|
||||
// arriver dans l'écriture suivante : on attend de le savoir.
|
||||
if i+1 >= len(s.buf) {
|
||||
break
|
||||
}
|
||||
if s.buf[i+1] == '\n' {
|
||||
emitBuildLine(string(s.buf[:i]))
|
||||
s.buf = s.buf[i+2:]
|
||||
continue
|
||||
}
|
||||
if prog := strings.TrimSpace(string(s.buf[:i])); prog != "" && time.Since(s.lastProg) >= sinkProgressEvery {
|
||||
s.lastProg = time.Now()
|
||||
emitBuildLine(prog)
|
||||
}
|
||||
s.buf = s.buf[i+1:]
|
||||
}
|
||||
return len(p), nil
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Une barre de progression (\r sans \n) doit apparaître dans le journal, sans
|
||||
// le noyer ; \r\n reste une fin de ligne ordinaire, même coupé entre deux écritures.
|
||||
func TestSinkWriterProgression(t *testing.T) {
|
||||
var got []string
|
||||
setBuildSink(func(l string) { got = append(got, l) })
|
||||
defer setBuildSink(nil)
|
||||
|
||||
s := &sinkWriter{}
|
||||
s.Write([]byte("Step 5\r"))
|
||||
s.Write([]byte("\n modèle : 1 / 76 GB\r modèle : 2 / 76 GB\r"))
|
||||
s.Write([]byte(" modèle : 3 / 76 GB\r"))
|
||||
s.lastProg = time.Now().Add(-time.Minute) // 10 s écoulées
|
||||
s.Write([]byte(" modèle : 40 / 76 GB\r[ok] fini\n"))
|
||||
|
||||
// « 3 » finissait sa propre écriture : il attendait de savoir si un saut de
|
||||
// ligne suivait, et part donc avec l'écriture suivante.
|
||||
want := []string{"Step 5", "modèle : 1 / 76 GB", "modèle : 3 / 76 GB", "[ok] fini"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("lignes %q, attendu %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// Preset externe actif : `loki serve` n'a aucun moteur local à lancer. Il
|
||||
// sortait en erreur « MODEL non défini », que systemd (Restart=on-failure)
|
||||
// relançait en boucle (amont #95).
|
||||
func TestServeExitsCleanlyForExternalPreset(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{extKeyFlag: "1", "PORT": "8080"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmdServe(nil); err != nil {
|
||||
t.Fatalf("loki serve sur un preset externe : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Le pré-vol refusait `loki start` / `loki restart` sur un preset externe,
|
||||
// en invitant à réinstaller llama.cpp.
|
||||
func TestPreflightAcceptsExternalPreset(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{extKeyFlag: "1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := preflightEngine(); err != nil {
|
||||
t.Fatalf("pré-vol refusé sur un preset externe : %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Strata : le pré-vol vérifie sa configuration à lui, pas BIN/MODEL.
|
||||
func TestPreflightStrataWithoutConfig(t *testing.T) {
|
||||
testHome(t)
|
||||
if err := WriteConfig(map[string]string{"ENGINE": "strata"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := preflightEngine()
|
||||
if err == nil {
|
||||
t.Fatal("un preset Strata sans STRATA_CONFIG devrait être refusé")
|
||||
}
|
||||
if want := "STRATA_CONFIG"; !contains(err.Error(), want) {
|
||||
t.Fatalf("message attendu sur %s : %v", want, err)
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,69 @@ func ListPresets() ([]Preset, error) {
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, nil
|
||||
return applyPresetOrder(out), nil
|
||||
}
|
||||
|
||||
// presetOrderKey : ordre d'affichage choisi par glisser-déposer dans l'UI
|
||||
// (repris d'AJEAN v0.10.9), une liste d'IDs persistée en base.
|
||||
const presetOrderKey = "preset_order"
|
||||
|
||||
func loadPresetOrder() []string {
|
||||
var ids []string
|
||||
getJSON(bkState, presetOrderKey, &ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
func savePresetOrder(ids []string) error { return putJSON(bkState, presetOrderKey, ids) }
|
||||
|
||||
// applyPresetOrder réordonne `list` (déjà trié par nom) selon l'ordre stocké :
|
||||
// d'abord les presets classés dans l'ordre choisi, puis les non classés
|
||||
// (fraîchement créés) dans leur ordre alphabétique. Tri STABLE.
|
||||
func applyPresetOrder(list []Preset) []Preset {
|
||||
order := loadPresetOrder()
|
||||
if len(order) == 0 {
|
||||
return list
|
||||
}
|
||||
pos := map[string]int{}
|
||||
for i, id := range order {
|
||||
pos[id] = i
|
||||
}
|
||||
sort.SliceStable(list, func(i, j int) bool {
|
||||
pi, oki := pos[list[i].ID]
|
||||
pj, okj := pos[list[j].ID]
|
||||
if oki && okj {
|
||||
return pi < pj
|
||||
}
|
||||
if oki != okj {
|
||||
return oki // un preset classé passe avant un non classé
|
||||
}
|
||||
return false // deux non classés : l'ordre alphabétique reste
|
||||
})
|
||||
return list
|
||||
}
|
||||
|
||||
// presetCtx : la taille de contexte d'un preset, formatée pour la pastille de
|
||||
// la liste (« 32K », « 128K », « 1M »). CTX absent = 32768, le défaut de serve.
|
||||
func presetCtx(content string) string {
|
||||
raw := strings.TrimSpace(parseEnv(content)["CTX"])
|
||||
if raw == "" {
|
||||
raw = "32768"
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return ""
|
||||
}
|
||||
// Multiple de 1024 (32768, 131072…) : écrit comme on le pense, « 32K »,
|
||||
// « 128K » ; sinon décimal, comme on le tape (40000 → « 40K »).
|
||||
switch {
|
||||
case n >= 1024 && n%1024 == 0 && n < 1<<20:
|
||||
return strconv.Itoa(n/1024) + "K"
|
||||
case n >= 1000000:
|
||||
return strings.TrimSuffix(strconv.FormatFloat(float64(n)/1000000, 'f', 1, 64), ".0") + "M"
|
||||
case n >= 1000:
|
||||
return strconv.Itoa((n+500)/1000) + "K"
|
||||
}
|
||||
return strconv.Itoa(n)
|
||||
}
|
||||
|
||||
// uniquePresetID derives a unique filename id from a display name, appending
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Ordre choisi par glisser-déposer : les presets classés d'abord, dans cet
|
||||
// ordre ; les nouveaux (non classés) ensuite, par nom.
|
||||
func TestPresetOrder(t *testing.T) {
|
||||
testHome(t)
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
for _, id := range []string{"alpha", "bravo", "charlie", "delta"} {
|
||||
if err := os.WriteFile(filepath.Join(presetsDir(), id+".env"), []byte("# NAME="+id+"\nCTX=4096\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ids := func() []string {
|
||||
list, err := ListPresets()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []string
|
||||
for _, p := range list {
|
||||
out = append(out, p.ID)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if got := ids(); got[0] != "alpha" || got[3] != "delta" {
|
||||
t.Fatalf("sans ordre enregistré, tri par nom attendu : %v", got)
|
||||
}
|
||||
if err := savePresetOrder([]string{"charlie", "alpha"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"charlie", "alpha", "bravo", "delta"}
|
||||
got := ids()
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("ordre %v, attendu %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresetCtx(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "32K", // défaut de serve : 32768
|
||||
"CTX=40000\n": "40K",
|
||||
"CTX=\"131072\"\n": "128K",
|
||||
"CTX=1000000\n": "1M",
|
||||
"CTX=1500000\n": "1.5M",
|
||||
"CTX=512\n": "512",
|
||||
"CTX=abc\n": "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := presetCtx(in); got != want {
|
||||
t.Errorf("presetCtx(%q) = %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -272,6 +272,20 @@ func cmdServe(args []string) error {
|
||||
}
|
||||
cfg = c
|
||||
}
|
||||
// API externe : aucun moteur local. On sort SANS erreur, sinon systemd
|
||||
// (Restart=on-failure) relance en boucle « MODEL non défini » (amont #95).
|
||||
if isExternalConfig(cfg) {
|
||||
fmt.Println("[info] preset externe actif : pas de moteur local à lancer")
|
||||
return nil
|
||||
}
|
||||
// Preset ENGINE=strata (backend_strata.go) : un autre moteur, lancé par son
|
||||
// propre serveur. L'optimiseur ne règle que llama-server.
|
||||
if isStrataConfig(cfg) {
|
||||
if trialDir != "" {
|
||||
return fmt.Errorf("l'optimiseur ne s'applique pas à un preset Strata")
|
||||
}
|
||||
return serveStrata(cfg)
|
||||
}
|
||||
bin := cfg["BIN"]
|
||||
if bin == "" {
|
||||
return fmt.Errorf("BIN non défini — lance « loki edit »")
|
||||
@@ -361,6 +375,10 @@ func cmdServe(args []string) error {
|
||||
probeSideSlotEnv(cfg, &si)
|
||||
// SPLIT_MODE=tensor seulement : cartes listées par CE moteur, dans son ordre.
|
||||
probeSplit(cfg, bin, &si)
|
||||
// LOAD_GUARD : un refus envisagé sur la VRAM NVIDIA se décide sur les
|
||||
// cartes du moteur (Vulkan sur cartes mixtes, ROCm…). Sinon rien n'est lu.
|
||||
normExtra, _ := normalizeLoadFlags(extra, helpSupportsLoadMode(si.Help))
|
||||
probeLoadDevices(cfg, normExtra, bin, &si)
|
||||
spec := specMode(cfg)
|
||||
if spec != "off" && spec != "" {
|
||||
probeSpec(cfg, &si)
|
||||
@@ -410,7 +428,7 @@ func cmdServe(args []string) error {
|
||||
// Chargement résident avec plus de poids en RAM que la machine n'en a :
|
||||
// échec certain (voir loadModeRisk), LOAD_GUARD=off pour passer outre.
|
||||
if _, refuse := loadModeRisk(cfg, llmArgs, si); refuse != "" {
|
||||
return fmt.Errorf("%s", refuse)
|
||||
return loadGuardRefusal(refuse, trialDir != "")
|
||||
}
|
||||
|
||||
// Essai : écoute sur 127.0.0.1 et le port libre choisi par l'optimiseur,
|
||||
@@ -459,6 +477,10 @@ func cmdServe(args []string) error {
|
||||
|
||||
fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n",
|
||||
bin, filepath.Base(model), port)
|
||||
// Ligne de commande réellement lancée, gardée pour l'interface (amont #108).
|
||||
if trialDir == "" {
|
||||
recordEngineCmdline(env, llmArgs)
|
||||
}
|
||||
|
||||
// Hand off to the llama-server process. On Unix this replaces the current
|
||||
// process (exec); on Windows it runs as a child and waits. See sys_platform_*.go.
|
||||
@@ -489,7 +511,11 @@ type serveSysInfo struct {
|
||||
RAMAvailMiB int64 // RAM libre au lancement
|
||||
VRAMMiB int64 // VRAM NVIDIA des cartes visibles
|
||||
UnifiedMem bool // macOS : RAM et VRAM ne font qu'une (voir loadModeRisk)
|
||||
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
|
||||
// VRAM des cartes listées par le moteur lui-même (--list-devices), lue
|
||||
// seulement quand LOAD_GUARD envisage un refus (probeLoadDevices) ; 0 =
|
||||
// non lue : un avis au plus, jamais de refus sur la seule VRAM NVIDIA.
|
||||
EngineVRAMMiB int64
|
||||
SlotDir string // dossier de --slot-save-path, créé et vérifié ; vide = pas de drapeau
|
||||
|
||||
// Build d'un moteur officiel (engineBuildTrusted), lu seulement pour un
|
||||
// modèle hybride ou SPEC=auto ; 0 = inconnu ou non lu : ni avis ni drapeau
|
||||
@@ -501,6 +527,7 @@ type serveSysInfo struct {
|
||||
Draft string // MODEL_DRAFT résolu et vérifié ; vide = absent ou introuvable
|
||||
DraftErr string // pourquoi MODEL_DRAFT n'a pas été trouvé
|
||||
DraftGGUF *GGUFInfo // métadonnées du brouillon ; nil = illisibles
|
||||
DraftIsModel bool // MODEL_DRAFT désigne le modèle principal lui-même (voir specArgs)
|
||||
SpecAutoBlocked string // un essai automatique précédent a échoué : la raison
|
||||
|
||||
// Cartes listées par le moteur (--list-devices), lues seulement pour
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -121,17 +122,54 @@ func cgroupMemMiB(fsys fs.FS) (limit, current int64) {
|
||||
// nvidiaVRAMMiB : VRAM totale des cartes que llama-server verra. Borné dans le
|
||||
// temps comme nvidiaGPUCount ; au moindre doute, 0.
|
||||
func nvidiaVRAMMiB(cvd string, pciOrder bool, timeout time.Duration) int64 {
|
||||
if !hasTool("nvidia-smi") {
|
||||
out, ok := smiTotals.get(time.Now, timeout)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return vramFromSMI(out, cvd, pciOrder)
|
||||
}
|
||||
|
||||
// nvidiaTotalsQuery : la lecture brute des mémoires totales. Variable pour
|
||||
// les tests.
|
||||
var nvidiaTotalsQuery = func(timeout time.Duration) (string, error) {
|
||||
if !hasTool("nvidia-smi") {
|
||||
return "", exec.ErrNotFound
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.total",
|
||||
"--format=csv,noheader,nounits")).Output()
|
||||
if err != nil {
|
||||
return 0
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
// smiTotalsCache : la lecture des mémoires totales, partagée quelques
|
||||
// secondes. L'éditeur de preset demande à l'ouverture la taille du cache de
|
||||
// prompts (et celle du second slot) et les avis MoE, en parallèle : chacun
|
||||
// lançait SON nvidia-smi pour une réponse identique — la mémoire totale d'une
|
||||
// carte ne bouge pas. Le verrou tenu pendant la lecture fait attendre les
|
||||
// appels simultanés sur la même ; la sélection des cartes (CUDA_VISIBLE_DEVICES)
|
||||
// s'applique après, sur la sortie brute. Un échec est gardé aussi, le temps du
|
||||
// délai : la même salve ne relance pas trois lectures qui échoueront.
|
||||
type smiTotalsCache struct {
|
||||
mu sync.Mutex
|
||||
exp time.Time
|
||||
out string
|
||||
ok bool
|
||||
}
|
||||
|
||||
const smiTotalsTTL = 10 * time.Second
|
||||
|
||||
var smiTotals smiTotalsCache
|
||||
|
||||
func (c *smiTotalsCache) get(now func() time.Time, timeout time.Duration) (string, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if now().Before(c.exp) {
|
||||
return c.out, c.ok
|
||||
}
|
||||
return vramFromSMI(string(out), cvd, pciOrder)
|
||||
out, err := nvidiaTotalsQuery(timeout)
|
||||
c.out, c.ok, c.exp = out, err == nil, now().Add(smiTotalsTTL)
|
||||
return c.out, c.ok
|
||||
}
|
||||
|
||||
// vramFromSMI additionne la VRAM des cartes sélectionnées par
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
)
|
||||
|
||||
const helpCacheRAM = helpRecent + `-cram, --cache-ram N set the maximum cache size in MiB (default: 8192, -1 - no limit, 0 - disable)
|
||||
@@ -262,3 +266,54 @@ func TestPrepareSlotDir(t *testing.T) {
|
||||
t.Fatalf("slots est un fichier : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// L'éditeur demande en parallèle le cache de prompts, le second slot et les
|
||||
// avis MoE : une seule lecture nvidia-smi pour tous, la sélection des cartes
|
||||
// appliquée ensuite. Après le délai, une lecture neuve ; un échec n'est pas
|
||||
// relancé dans la même salve.
|
||||
func TestNvidiaVRAMShared(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
fail := false
|
||||
prevQ := nvidiaTotalsQuery
|
||||
nvidiaTotalsQuery = func(time.Duration) (string, error) {
|
||||
calls.Add(1)
|
||||
time.Sleep(20 * time.Millisecond) // les appels simultanés attendent la même lecture
|
||||
if fail {
|
||||
return "", errors.New("exit status 9")
|
||||
}
|
||||
return "0, 16311\n1, 12288\n", nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
nvidiaTotalsQuery = prevQ
|
||||
smiTotals.mu.Lock()
|
||||
smiTotals.exp = time.Time{} // les tests suivants relisent nvidia-smi
|
||||
smiTotals.mu.Unlock()
|
||||
})
|
||||
expire := func() {
|
||||
smiTotals.mu.Lock()
|
||||
smiTotals.exp = time.Time{}
|
||||
smiTotals.mu.Unlock()
|
||||
}
|
||||
expire()
|
||||
var wg sync.WaitGroup
|
||||
got := make([]int64, 3)
|
||||
for i, cvd := range []string{"", "0", "1"} {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); got[i] = nvidiaVRAMMiB(cvd, true, time.Second) }()
|
||||
}
|
||||
wg.Wait()
|
||||
if n := calls.Load(); n != 1 {
|
||||
t.Errorf("%d lectures nvidia-smi pour trois demandes simultanées", n)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []int64{16311 + 12288, 16311, 12288}) {
|
||||
t.Errorf("sélection des cartes : %v", got)
|
||||
}
|
||||
expire()
|
||||
fail = true
|
||||
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 {
|
||||
t.Errorf("échec : %d, attendu 0", v)
|
||||
}
|
||||
if v := nvidiaVRAMMiB("", true, time.Second); v != 0 || calls.Load() != 2 {
|
||||
t.Errorf("échec relancé dans la même salve : %d lectures", calls.Load())
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,53 @@ func loadGuardOff(cfg map[string]string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// loadGuardExitStatus : le code de sortie de « loki serve » quand LOAD_GUARD
|
||||
// refuse. Le refus est certain et ne dépend que du preset et de la machine :
|
||||
// relancer ne changerait rien. Sous systemd, Restart=on-failure relançait
|
||||
// pourtant toutes les trois secondes, sans fin ; l'unité écrite par
|
||||
// « loki install » porte donc RestartPreventExitStatus pour ce code-là
|
||||
// (EX_CONFIG de sysexits.h : erreur de configuration). Le conteneur, lui, ne
|
||||
// relance jamais le moteur de lui-même.
|
||||
const loadGuardExitStatus = 78
|
||||
|
||||
// unitPreventsRefusalRestart : l'unité systemd (son texte) s'abstient-elle de
|
||||
// relancer sur loadGuardExitStatus ? Une unité écrite par une version
|
||||
// d'avant ne le fait pas.
|
||||
func unitPreventsRefusalRestart(unit string) bool {
|
||||
for _, line := range strings.Split(unit, "\n") {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(line), "=")
|
||||
if !ok || strings.TrimSpace(k) != "RestartPreventExitStatus" {
|
||||
continue
|
||||
}
|
||||
for _, f := range strings.Fields(v) {
|
||||
if f == strconv.Itoa(loadGuardExitStatus) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// loadGuardRefusal : l'erreur rendue par cmdServe pour un refus, avec son code
|
||||
// de sortie. Un superviseur qui relancerait quand même sur ce code (unité
|
||||
// systemd d'une version d'avant, launchd, qui relance toute sortie non nulle)
|
||||
// reçoit 0 : une sortie « propre » n'est pas relancée, et la raison reste en
|
||||
// clair dans le journal, où l'interface la reconnaît (modelLoadError). Le
|
||||
// moteur d'essai de l'optimiseur garde le vrai code : c'est lui qui dit
|
||||
// l'échec à « loki tune ».
|
||||
func loadGuardRefusal(refuse string, trial bool) error {
|
||||
code := loadGuardExitStatus
|
||||
if !trial {
|
||||
if restarts, hint := supervisorRestartsRefusal(); restarts {
|
||||
code = 0
|
||||
if hint != "" {
|
||||
fmt.Fprintln(os.Stderr, "[loki serve] "+hint)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &exitStatusError{code: code, err: fmt.Errorf("%s", refuse)}
|
||||
}
|
||||
|
||||
// loadModeRisk : un mode de chargement résident avec trop de poids en RAM.
|
||||
// Fonction pure ; args est la ligne finale (ou EXTRA_ARGS normalisé).
|
||||
//
|
||||
@@ -257,7 +304,8 @@ func loadGuardOff(cfg map[string]string) bool {
|
||||
// - warn : l'estimation dépasse 80 % — on prévient, on lance.
|
||||
//
|
||||
// Un refus fait sortir « loki serve » en erreur, comme un modèle introuvable :
|
||||
// mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau.
|
||||
// mieux vaut la raison en clair qu'un moteur tué en boucle par le noyau. Le code
|
||||
// de sortie (loadGuardRefusal) empêche le superviseur de relancer en boucle.
|
||||
func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn, refuse string) {
|
||||
if isExternalConfig(cfg) {
|
||||
return "", ""
|
||||
@@ -277,20 +325,32 @@ func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn,
|
||||
// Serveurs RPC : une part des poids part sur d'autres machines, la VRAM
|
||||
// locale ne borne plus rien — inconnu, donc un avis au plus, jamais un refus.
|
||||
remote := hasAnyFlag(args, "--rpc") || strings.TrimSpace(si.ArgEnv["LLAMA_ARG_RPC"]) != ""
|
||||
low, est, upTo := int64(0), modelMiB, true
|
||||
low, est, upTo, unsure := int64(0), modelMiB, true, false
|
||||
switch {
|
||||
case remote:
|
||||
case si.UnifiedMem:
|
||||
low, est, upTo = modelMiB, modelMiB, false
|
||||
case si.VRAMMiB > 0:
|
||||
low = max(modelMiB-si.VRAMMiB, 0)
|
||||
case si.EngineVRAMMiB > 0:
|
||||
// Les cartes que le moteur liste lui-même : rien au-delà ne peut
|
||||
// recevoir de poids, la borne basse est sûre.
|
||||
low = max(modelMiB-si.EngineVRAMMiB, 0)
|
||||
est, upTo = low, false
|
||||
case si.VRAMMiB > 0:
|
||||
// nvidia-smi seulement : un moteur Vulkan sur cartes mixtes NVIDIA +
|
||||
// AMD en utilise davantage, un moteur ROCm d'autres. Une estimation,
|
||||
// jamais une borne : un avis au plus (voir probeLoadDevices).
|
||||
est, upTo, unsure = max(modelMiB-si.VRAMMiB, 0), false, true
|
||||
}
|
||||
ram := si.RAMMiB
|
||||
what := "environ "
|
||||
if upTo {
|
||||
what = "jusqu'à "
|
||||
}
|
||||
if unsure && est*10 > ram*9 && !loadGuardOff(cfg) {
|
||||
return fmt.Sprintf("%s : environ %s de poids en RAM (modèle %s, VRAM NVIDIA %s) pour %s — échec probable ; "+
|
||||
"refus seulement si les cartes listées par le moteur le confirment, lancement sinon ; "+
|
||||
"--load-mode mmap est plus sûr", mode, gibText(est), gibText(modelMiB), gibText(si.VRAMMiB), gibText(ram)), ""
|
||||
}
|
||||
if low*10 > ram*9 {
|
||||
msg := fmt.Sprintf("%s : au moins %s de poids restent en RAM (modèle %s) pour %s de RAM, limite du conteneur "+
|
||||
"comprise — échec certain : swap et décodage effondré, ou processus tué (llama.cpp #26110). "+
|
||||
@@ -307,6 +367,50 @@ func loadModeRisk(cfg map[string]string, args []string, si serveSysInfo) (warn,
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// loadGuardNeedsDevices : un refus se profile sur la seule VRAM NVIDIA
|
||||
// (nvidia-smi) ? Il ne sera prononcé que sur les cartes du moteur lui-même —
|
||||
// à lire d'abord (probeLoadDevices). Fonction pure.
|
||||
func loadGuardNeedsDevices(cfg map[string]string, args []string, si serveSysInfo) bool {
|
||||
if si.EngineVRAMMiB > 0 || si.VRAMMiB <= 0 {
|
||||
return false
|
||||
}
|
||||
si.EngineVRAMMiB = si.VRAMMiB
|
||||
_, refuse := loadModeRisk(cfg, args, si)
|
||||
return refuse != ""
|
||||
}
|
||||
|
||||
// engineDevsVRAM : la VRAM des cartes listées par le moteur, toutes
|
||||
// additionnées — sans --device, llama.cpp ne place rien ailleurs, et une carte
|
||||
// qu'il listerait sans s'en servir (iGPU) ne fait que rendre le refus plus
|
||||
// rare. 0 (inconnu) si la liste est vide ou si une carte annonce 0 Mio (déjà
|
||||
// pleine : lecture transitoire, voir handleBackendDevices).
|
||||
func engineDevsVRAM(devs []splitDev) int64 {
|
||||
var total int64
|
||||
for _, d := range devs {
|
||||
if d.TotalMiB <= 0 {
|
||||
return 0
|
||||
}
|
||||
total += d.TotalMiB
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// probeLoadDevices : seulement quand loadGuardNeedsDevices — un mode de
|
||||
// chargement résident qu'on s'apprête à refuser. Les cartes de SPLIT_MODE si
|
||||
// elles sont déjà lues, sinon --list-devices dans l'environnement du
|
||||
// lancement (sélection GPU comprise). Échec de lecture : EngineVRAMMiB reste
|
||||
// à 0, loadModeRisk avertit sans refuser. args : EXTRA_ARGS normalisé.
|
||||
func probeLoadDevices(cfg map[string]string, args []string, bin string, si *serveSysInfo) {
|
||||
if !loadGuardNeedsDevices(cfg, args, *si) {
|
||||
return
|
||||
}
|
||||
devs := si.SplitDevs
|
||||
if len(devs) == 0 {
|
||||
devs = listEngineDevices(bin)
|
||||
}
|
||||
si.EngineVRAMMiB = engineDevsVRAM(devs)
|
||||
}
|
||||
|
||||
// previewServeHelp : l'aide supposée d'un moteur récent, pour les aperçus de
|
||||
// l'éditeur (le process web ne lance pas le moteur pour lire son aide). Le
|
||||
// lancement, lui, décide sur la vraie aide.
|
||||
@@ -336,6 +440,14 @@ func moePreview(content string) (notes []string, load string) {
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
probeCacheRAM(cfg, extra, &si)
|
||||
norm, _ := normalizeLoadFlags(extra, true)
|
||||
// Les cartes du moteur, sans le lancer : la dernière liste complète qu'il a
|
||||
// donnée à l'éditeur (devices.json). Absente : un avis, pas un refus.
|
||||
if loadGuardNeedsDevices(cfg, norm, si) {
|
||||
bin := prebuiltResolveBin(firstNonEmpty(cfg["BIN"], ReadConfig()["BIN"]))
|
||||
if list, ok := devPersistGet(bin + "\x00" + cfg["CUDA_VISIBLE_DEVICES"]); ok {
|
||||
si.EngineVRAMMiB = engineDevsVRAM(splitDevsFrom(list))
|
||||
}
|
||||
}
|
||||
warn, refuse := loadModeRisk(cfg, norm, si)
|
||||
if refuse != "" {
|
||||
warn = "refusé au lancement — " + refuse
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -109,10 +111,10 @@ func TestLoadModeRisk(t *testing.T) {
|
||||
{name: "sans drapeau : rien",
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
|
||||
{name: "none, 66 Gio certains en RAM pour 60 : refus (#26110)",
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
|
||||
{name: "même chose, LOAD_GUARD=off : avertissement seulement",
|
||||
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
|
||||
{name: "mlock, 54 Gio en RAM pour 64 : avertissement",
|
||||
args: "--load-mode mlock", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
|
||||
{name: "dense tout en VRAM sur une machine à peu de RAM : rien (pas de faux positif)",
|
||||
@@ -122,11 +124,11 @@ func TestLoadModeRisk(t *testing.T) {
|
||||
{name: "macOS, mémoire unifiée : tout le modèle compte",
|
||||
args: "--load-mode mmap+mlock", si: serveSysInfo{ModelBytes: 60 * gib, RAMMiB: 64 << 10, UnifiedMem: true}, refuse: true},
|
||||
{name: "moteur ancien : --no-mmap",
|
||||
args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
|
||||
args: "--no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10}, refuse: true},
|
||||
{name: "moteur ancien : --mlock --no-mmap",
|
||||
args: "--mlock --no-mmap", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 28 << 10, RAMMiB: 64 << 10}, warn: true},
|
||||
{name: "LLAMA_ARG_NO_MMAP dans l'environnement",
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10,
|
||||
ArgEnv: map[string]string{"LLAMA_ARG_NO_MMAP": "1"}}, refuse: true},
|
||||
// Corrections de relecture du lot 2 : pas de refus sur une borne fausse.
|
||||
{name: "LLAMA_ARG_NO_MMAP sans effet sur un moteur à --load-mode : rien",
|
||||
@@ -138,7 +140,16 @@ func TestLoadModeRisk(t *testing.T) {
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10,
|
||||
ArgEnv: map[string]string{"LLAMA_ARG_RPC": "10.0.0.2:50052"}}, warn: true},
|
||||
{name: "limite du conteneur (RAM effective) : refus",
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, VRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true},
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 40 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 16 << 10}, refuse: true},
|
||||
// Lot 3 : la VRAM comptée est celle des cartes du moteur ; nvidia-smi
|
||||
// seul ne fait jamais refuser.
|
||||
{name: "VRAM NVIDIA seule, cartes du moteur non lues : avertissement, jamais de refus",
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
|
||||
{name: "VRAM NVIDIA seule, LOAD_GUARD=off : l'avis ordinaire",
|
||||
cfg: map[string]string{"LOAD_GUARD": "off"}, args: "--load-mode none",
|
||||
si: serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}, warn: true},
|
||||
{name: "Vulkan, NVIDIA 12 Gio + AMD 16 Gio listées par le moteur : rien (faux refus sur la seule VRAM NVIDIA)",
|
||||
args: "--load-mode none", si: serveSysInfo{ModelBytes: 70 * gib, VRAMMiB: 12 << 10, EngineVRAMMiB: 28 << 10, RAMMiB: 60 << 10}},
|
||||
{name: "taille inconnue : rien",
|
||||
args: "--load-mode none", si: serveSysInfo{VRAMMiB: 16 << 10, RAMMiB: 16 << 10}},
|
||||
}
|
||||
@@ -154,6 +165,9 @@ func TestLoadModeRisk(t *testing.T) {
|
||||
if refuse != "" && !strings.Contains(refuse, "LOAD_GUARD=off") {
|
||||
t.Errorf("%s : refus sans clé d'échappement : %q", c.name, refuse)
|
||||
}
|
||||
if loadGuardOff(cfg) && strings.Contains(warn, "refus") {
|
||||
t.Errorf("%s : refus évoqué avec LOAD_GUARD=off : %q", c.name, warn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,7 +287,7 @@ func TestJoinArgsRoundTrip(t *testing.T) {
|
||||
// plus haut dans le journal.
|
||||
func TestModelLoadErrorLoadGuard(t *testing.T) {
|
||||
_, refuse := loadModeRisk(map[string]string{}, splitArgs("--load-mode none"),
|
||||
serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10})
|
||||
serveSysInfo{ModelBytes: 82 * gib, EngineVRAMMiB: 16 << 10, RAMMiB: 60 << 10})
|
||||
log := "llama_model_load: loading model\nmain: model loaded\nserver is listening\n[ERREUR] " + refuse + "\n"
|
||||
if got := modelLoadErrorFrom(log); !strings.Contains(got, "LOAD_GUARD=off") {
|
||||
t.Errorf("refus non signalé : %q", got)
|
||||
@@ -283,3 +297,93 @@ func TestModelLoadErrorLoadGuard(t *testing.T) {
|
||||
t.Errorf("refus ancien encore signalé : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Un refus de LOAD_GUARD ne doit pas être relancé en boucle : l'unité systemd
|
||||
// écrite par « loki install » exclut son code de sortie, une unité d'avant ne
|
||||
// le fait pas.
|
||||
func TestUnitPreventsRefusalRestart(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
unit string
|
||||
want bool
|
||||
}{
|
||||
{"[Service]\nRestart=on-failure\nRestartSec=3\n", false},
|
||||
{"[Service]\nRestart=on-failure\nRestartPreventExitStatus=78\n", true},
|
||||
{"[Service]\n RestartPreventExitStatus = 1 78 SIGKILL\n", true},
|
||||
{"[Service]\nRestartPreventExitStatus=178\n", false},
|
||||
{"[Service]\n# RestartPreventExitStatus=78\n", false},
|
||||
} {
|
||||
if got := unitPreventsRefusalRestart(c.unit); got != c.want {
|
||||
t.Errorf("%q : %v, attendu %v", c.unit, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le refus sort avec son propre code (pas 1), y compris enveloppé ; une erreur
|
||||
// ordinaire garde 1. Le moteur d'essai garde toujours le vrai code.
|
||||
func TestLoadGuardRefusalExitStatus(t *testing.T) {
|
||||
err := loadGuardRefusal("refus ; "+loadGuardMarker, true)
|
||||
if got := exitStatusOf(err); got != loadGuardExitStatus {
|
||||
t.Errorf("essai : code %d, attendu %d", got, loadGuardExitStatus)
|
||||
}
|
||||
if !strings.Contains(err.Error(), loadGuardMarker) {
|
||||
t.Errorf("raison perdue : %q", err)
|
||||
}
|
||||
if got := exitStatusOf(fmt.Errorf("enveloppé : %w", err)); got != loadGuardExitStatus {
|
||||
t.Errorf("enveloppé : code %d", got)
|
||||
}
|
||||
if got := exitStatusOf(errors.New("modèle introuvable")); got != 1 {
|
||||
t.Errorf("erreur ordinaire : code %d, attendu 1", got)
|
||||
}
|
||||
// Hors superviseur (les tests ne tournent ni sous systemd ni sous launchd) :
|
||||
// le vrai code aussi.
|
||||
if restarts, _ := supervisorRestartsRefusal(); !restarts {
|
||||
if got := exitStatusOf(loadGuardRefusal("refus", false)); got != loadGuardExitStatus {
|
||||
t.Errorf("hors superviseur : code %d", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le refus envisagé sur la VRAM NVIDIA attend les cartes du moteur ; rien
|
||||
// n'est lu sans refus en vue.
|
||||
func TestLoadGuardNeedsDevices(t *testing.T) {
|
||||
none := splitArgs("--load-mode none")
|
||||
base := serveSysInfo{ModelBytes: 82 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}
|
||||
if !loadGuardNeedsDevices(map[string]string{}, none, base) {
|
||||
t.Error("refus en vue sur nvidia-smi : cartes du moteur non demandées")
|
||||
}
|
||||
confirmed := base
|
||||
confirmed.EngineVRAMMiB = 16 << 10
|
||||
for name, c := range map[string]struct {
|
||||
cfg map[string]string
|
||||
args []string
|
||||
si serveSysInfo
|
||||
}{
|
||||
"déjà lues": {map[string]string{}, none, confirmed},
|
||||
"LOAD_GUARD=off": {map[string]string{"LOAD_GUARD": "off"}, none, base},
|
||||
"mmap": {map[string]string{}, splitArgs("--load-mode mmap"), base},
|
||||
"VRAM inconnue": {map[string]string{}, none, serveSysInfo{ModelBytes: 82 * gib, RAMMiB: 60 << 10}},
|
||||
"loin du seuil": {map[string]string{}, none, serveSysInfo{ModelBytes: 30 * gib, VRAMMiB: 16 << 10, RAMMiB: 60 << 10}},
|
||||
} {
|
||||
if loadGuardNeedsDevices(c.cfg, c.args, c.si) {
|
||||
t.Errorf("%s : cartes demandées sans refus en vue", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La VRAM des cartes du moteur : toutes additionnées, inconnue dès qu'une
|
||||
// carte annonce 0 Mio (déjà pleine) ; devices.json relu en JSON (float64).
|
||||
func TestEngineDevsVRAM(t *testing.T) {
|
||||
mixed := splitDevsFrom([]map[string]any{
|
||||
{"id": "Vulkan0", "total_mib": 12288},
|
||||
{"id": "Vulkan1", "total_mib": float64(16368)},
|
||||
})
|
||||
if got := engineDevsVRAM(mixed); got != 12288+16368 {
|
||||
t.Errorf("cartes mixtes : %d", got)
|
||||
}
|
||||
if got := engineDevsVRAM([]splitDev{{"CUDA0", 12288}, {"CUDA1", 0}}); got != 0 {
|
||||
t.Errorf("carte à 0 Mio : %d, attendu inconnu", got)
|
||||
}
|
||||
if got := engineDevsVRAM(nil); got != 0 {
|
||||
t.Errorf("liste vide : %d", got)
|
||||
}
|
||||
}
|
||||
@@ -113,6 +113,11 @@ func probeSpec(cfg map[string]string, si *serveSysInfo) {
|
||||
return
|
||||
}
|
||||
si.Draft = p
|
||||
if a, err := os.Stat(p); err == nil {
|
||||
if b, err := os.Stat(si.Model); err == nil && os.SameFile(a, b) {
|
||||
si.DraftIsModel = true
|
||||
}
|
||||
}
|
||||
if g, err := ggufMeta(p); err == nil {
|
||||
si.DraftGGUF = &g
|
||||
}
|
||||
@@ -241,6 +246,23 @@ func specAutoBlocker(cfg map[string]string, extra []string, si serveSysInfo) str
|
||||
// et s'ils viennent de SPEC=auto (cmdServe pose alors le jeton de tentative).
|
||||
// Fonction pure ; cmdServe a déjà résolu MODEL_DRAFT et lu les GGUF.
|
||||
func specArgs(cfg map[string]string, extra []string, si serveSysInfo) (args, notes []string, auto bool) {
|
||||
// MODEL_DRAFT = le modèle principal lui-même (choisi dans la liste des
|
||||
// brouillons de l'éditeur) : -md recharge alors les 27B une seconde fois
|
||||
// (common_speculative_init_result charge tout fichier -md comme un modèle
|
||||
// à part), quand la tête MTP intégrée se greffe sur le modèle déjà en VRAM.
|
||||
// Vécu : « allocating 9628.34 MiB on device 0 » pour le brouillon, moteur
|
||||
// mort ; ou, quand ça passait, des couches renvoyées sur le CPU et 5 t/s au
|
||||
// lieu de 22. On l'ignore : même tête, sans les poids en double.
|
||||
if si.DraftIsModel && strings.TrimSpace(cfg["MODEL_DRAFT"]) != "" {
|
||||
c := make(map[string]string, len(cfg))
|
||||
for k, v := range cfg {
|
||||
c[k] = v
|
||||
}
|
||||
delete(c, "MODEL_DRAFT")
|
||||
args, notes, auto = specArgs(c, extra, si)
|
||||
return args, append([]string{"MODEL_DRAFT est le modèle principal lui-même : ignoré — la tête MTP intégrée " +
|
||||
"sert de brouillon sans recharger les poids (retire MODEL_DRAFT du preset)"}, notes...), auto
|
||||
}
|
||||
mode := specMode(cfg)
|
||||
draftKey := strings.TrimSpace(cfg["MODEL_DRAFT"])
|
||||
switch mode {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -107,6 +108,13 @@ func TestSpecArgs(t *testing.T) {
|
||||
s.Draft = "/models/mtp-Qwen3.6-27B-Q8_0.gguf"
|
||||
s.DraftGGUF = &GGUFInfo{Arch: "qwen35", BlockCount: 65, HasNextNTensor: true}
|
||||
}},
|
||||
{name: "MODEL_DRAFT = le modèle lui-même : pas de -md, la tête intégrée, et on le dit",
|
||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3.6-27B-MTP.gguf"), notes: 2, want: mtp,
|
||||
si: func(s *serveSysInfo) {
|
||||
s.Draft = s.Model
|
||||
s.DraftGGUF = s.GGUF
|
||||
s.DraftIsModel = true
|
||||
}},
|
||||
{name: "MODEL_DRAFT petit modèle : -md + draft-simple, sinon chargé pour rien",
|
||||
cfg: with(forced, "MODEL_DRAFT", "Qwen3-0.6B.gguf"), notes: 1,
|
||||
want: []string{"-md", "/models/Qwen3-0.6B.gguf", "--spec-type", "draft-simple", "--spec-draft-sampling", "greedy"},
|
||||
@@ -493,3 +501,26 @@ func TestSynthAcceptanceWarned(t *testing.T) {
|
||||
t.Errorf("variable : %q", n)
|
||||
}
|
||||
}
|
||||
|
||||
// probeSpec reconnaît un MODEL_DRAFT qui désigne le modèle principal.
|
||||
func TestProbeSpecDraftIsModel(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
dir := t.TempDir()
|
||||
model := dir + "/Swift-27B.gguf"
|
||||
other := dir + "/mtp-Swift.gguf"
|
||||
for _, p := range []string{model, other} {
|
||||
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
si := serveSysInfo{Model: model}
|
||||
probeSpec(map[string]string{"MODEL_DRAFT": model}, &si)
|
||||
if !si.DraftIsModel {
|
||||
t.Errorf("MODEL_DRAFT = le modèle, non reconnu (Draft=%q, err=%q)", si.Draft, si.DraftErr)
|
||||
}
|
||||
si = serveSysInfo{Model: model}
|
||||
probeSpec(map[string]string{"MODEL_DRAFT": other}, &si)
|
||||
if si.DraftIsModel || si.Draft != other {
|
||||
t.Errorf("tête à part prise pour le modèle : %+v", si)
|
||||
}
|
||||
}
|
||||
@@ -98,17 +98,39 @@ func probeSplit(cfg map[string]string, bin string, si *serveSysInfo) {
|
||||
if v := os.Getenv("GGML_CUDA_ALLREDUCE"); v != "" {
|
||||
si.UserEnv["GGML_CUDA_ALLREDUCE"] = v
|
||||
}
|
||||
si.SplitDevs = listEngineDevices(bin)
|
||||
}
|
||||
|
||||
// listEngineDevices : les cartes que CE moteur voit (--list-devices), dans
|
||||
// l'environnement du processus — celui du vrai lancement quand cmdServe
|
||||
// appelle. nil si le moteur échoue : sortie tronquée (carte pleine, moteur
|
||||
// planté), on ne s'y fie pas.
|
||||
func listEngineDevices(bin string) []splitDev {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
out, err := hideCmd(exec.CommandContext(ctx, bin, "--list-devices")).CombinedOutput()
|
||||
if err != nil {
|
||||
return // sortie tronquée (carte pleine, moteur planté) : on ne s'y fie pas
|
||||
return nil
|
||||
}
|
||||
for _, d := range parseListDevices(string(out)) {
|
||||
return splitDevsFrom(parseListDevices(string(out)))
|
||||
}
|
||||
|
||||
// splitDevsFrom : les cartes de parseListDevices (ou de devices.json, relu en
|
||||
// JSON : les nombres y sont des float64).
|
||||
func splitDevsFrom(list []map[string]any) []splitDev {
|
||||
var devs []splitDev
|
||||
for _, d := range list {
|
||||
id, _ := d["id"].(string)
|
||||
total, _ := d["total_mib"].(int)
|
||||
si.SplitDevs = append(si.SplitDevs, splitDev{ID: id, TotalMiB: int64(total)})
|
||||
var total int64
|
||||
switch v := d["total_mib"].(type) {
|
||||
case int:
|
||||
total = int64(v)
|
||||
case float64:
|
||||
total = int64(v)
|
||||
}
|
||||
devs = append(devs, splitDev{ID: id, TotalMiB: total})
|
||||
}
|
||||
return devs
|
||||
}
|
||||
|
||||
// splitPlan décide de SPLIT_MODE=tensor pour ce lancement. Fonction pure.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,379 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func strataArgs(t *testing.T, cfg map[string]any) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for _, a := range cfg["args"].([]any) {
|
||||
out = append(out, a.(string))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func argAfter(args []string, flag string) string {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag {
|
||||
return args[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigApplyTuning(t *testing.T) {
|
||||
base := map[string]any{
|
||||
"exe": "/x/engine-cuda12/strata",
|
||||
"args": []any{"--pack", "/p", "--prefill", "8192", "--max-context", "131072"},
|
||||
"port": 8080.0,
|
||||
}
|
||||
out, err := strataBuildConfig(base, map[string]string{"PORT": "8081", "CTX": "65536"}, "k", "ram")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
for flag, want := range map[string]string{"--prefill": "16384", "--short-read": "150", "--prompt-cache-root": "256", "--max-context": "65536", "--pack": "/p"} {
|
||||
if got := argAfter(a, flag); got != want {
|
||||
t.Errorf("%s = %q, attendu %q (args %v)", flag, got, want, a)
|
||||
}
|
||||
}
|
||||
if strings.Count(strings.Join(a, " "), "--prefill") != 1 {
|
||||
t.Errorf("--prefill dupliqué : %v", a)
|
||||
}
|
||||
if strataHasArg(a, "--remote-expert-opt") || strataHasArg(a, "--expert-cache-device1") {
|
||||
t.Errorf("aide 2e carte sans 2e carte : %v", a)
|
||||
}
|
||||
if out["port"] != 8081 || out["api_key"] != "k" || out["host"] != "127.0.0.1" {
|
||||
t.Errorf("port/clé/hôte : %v %v %v", out["port"], out["api_key"], out["host"])
|
||||
}
|
||||
// la config de l'installeur n'est pas modifiée
|
||||
if base["args"].([]any)[3] != "8192" {
|
||||
t.Error("la config de base a été modifiée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigHelperGPU(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p", "--mmap-experts", "--vram-reserve-mib", "700"}, "gpu": 1.0,
|
||||
"vision": map[string]any{"exe": "v", "gpu": true}}
|
||||
out, err := strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "0"}, "", "ram")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--expert-cache-device1") != "auto" || !strataHasArg(a, "--remote-expert-opt") {
|
||||
t.Errorf("aide 2e carte absente : %v", a)
|
||||
}
|
||||
if strataHasArg(a, "--vram-reserve-mib") || strataHasArg(a, "700") {
|
||||
t.Errorf("réserve de VRAM gardée : %v", a)
|
||||
}
|
||||
if _, has := out["api_key"]; has {
|
||||
t.Error("api_key posée sans clé")
|
||||
}
|
||||
if _, has := out["gpu"]; has {
|
||||
t.Error("clé gpu gardée avec une carte d'aide (le serveur en ferait un --layer-split)")
|
||||
}
|
||||
if out["vision"].(map[string]any)["cuda_device"] != 0 {
|
||||
t.Errorf("encodeur pas sur la carte d'aide : %v", out["vision"])
|
||||
}
|
||||
if out["env"].(map[string]any)["STRATA_PLE_BATCH"] != "0" {
|
||||
t.Errorf("STRATA_PLE_BATCH absent : %v", out["env"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataCudaDevices(t *testing.T) {
|
||||
cases := []struct {
|
||||
main, helper, want string
|
||||
}{
|
||||
{"1", "0", "1,0"},
|
||||
{"0", "-1", "0"},
|
||||
{"0", "", "0"},
|
||||
{"", "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": c.main, "STRATA_HELPER_GPU": c.helper})
|
||||
if got != c.want {
|
||||
t.Errorf("main=%q helper=%q : %q, attendu %q", c.main, c.helper, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataRecommend(t *testing.T) {
|
||||
// la machine de test : 5060 Ti 16 Go + 3070 8 Go, 47 Go de RAM, disque large
|
||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, AvailGB: 47, DiskFreeGB: 400,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||
if got := strataRecommend("swift", env); got != "IQ3_XXS" {
|
||||
t.Errorf("swift : %q, attendu IQ3_XXS", got)
|
||||
}
|
||||
// IQ3_S demande de garder 50 Go d'experts en RAM : pas sur 47 Go
|
||||
if got := strataRecommend("qwen", env); got != "IQ3_XXS" {
|
||||
t.Errorf("qwen 47 Go : %q, attendu IQ3_XXS", got)
|
||||
}
|
||||
f := strataFitFor(strataQuants["IQ3_XXS"], env, 0)
|
||||
// trop juste pour tout garder en RAM : experts lus depuis le disque (le mode
|
||||
// hors RAM demande un experts.bin, qu'une installation neuve n'a pas)
|
||||
if !f.OK || !f.Mmap || f.Drop {
|
||||
t.Errorf("IQ3_XXS sur 47 Go : ok=%v mmap=%v drop=%v (attendu ok, disque)", f.OK, f.Mmap, f.Drop)
|
||||
}
|
||||
// 64 Go : IQ3_S tient en RAM
|
||||
env.RAMGB, env.AvailGB = 64, 64
|
||||
if got := strataRecommend("qwen", env); got != "IQ3_S" {
|
||||
t.Errorf("qwen 64 Go : %q, attendu IQ3_S", got)
|
||||
}
|
||||
// 16 Go de RAM et une seule carte de 8 Go : rien ne tient
|
||||
small := strataEnv{Supported: true, Main: 0, Helper: -1, RAMGB: 16, DiskFreeGB: 400,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}}}
|
||||
if got := strataRecommend("swift", small); got != "" {
|
||||
t.Errorf("petite machine : %q, attendu aucune recommandation", got)
|
||||
}
|
||||
// disque trop petit
|
||||
env.DiskFreeGB = 50
|
||||
if f := strataFitFor(strataQuants["Q2_0"], env, 0); f.OK {
|
||||
t.Error("Q2_0 accepté avec 50 Go de disque")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataPresetName(t *testing.T) {
|
||||
if got := strataPresetName("swift", "IQ3_XXS"); got != "STRATA FLASH NEXT SWIFT 1.5 IQ3_XXS" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
if got := strataPresetName("qwen", "Q2_0"); got != "STRATA FLASH NEXT CLASSIQUE Q2_0" {
|
||||
t.Errorf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataFitCountsExistingData(t *testing.T) {
|
||||
env := strataEnv{Supported: true, Main: 1, Helper: 0, RAMGB: 47, DiskFreeGB: 32,
|
||||
GPUs: []strataGPU{{Index: 0, VRAMGB: 8, Arch: 86}, {Index: 1, VRAMGB: 16, Arch: 120}}}
|
||||
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 120); !f.OK {
|
||||
t.Errorf("données déjà présentes, refusé quand même : %s", f.Why)
|
||||
}
|
||||
if f := strataFitFor(strataQuants["IQ3_XXS"], env, 0); f.OK {
|
||||
t.Error("32 Go libres sans données : accepté")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigPresetOverrides(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--kv", "int8", "--spec", "4"}}
|
||||
out, _ := strataBuildConfig(base, map[string]string{"STRATA_KV": "fp16", "STRATA_SPEC": "3"}, "", "ram")
|
||||
a := strataArgs(t, out)
|
||||
if argAfter(a, "--kv") != "fp16" || argAfter(a, "--spec") != "3" {
|
||||
t.Errorf("%v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataDetailsFor(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
base := filepath.Join(home, "base.json")
|
||||
if err := os.WriteFile(base, []byte(`{"args":["--pack","/p","--kv","int8","--spec","4","--max-context","131072"],"vision":{"exe":"v"}}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = os.MkdirAll(presetsDir(), 0o755)
|
||||
preset := "ENGINE=strata\nSTRATA_CONFIG=" + base + "\nSTRATA_MAIN_GPU=1\nSTRATA_HELPER_GPU=0\nSTRATA_MMAP=1\nSTRATA_KV=fp16\nSTRATA_KV_RESIDENT=32768\nSTRATA_SPEC=3\n"
|
||||
if err := os.WriteFile(filepath.Join(presetsDir(), "X.env"), []byte(preset), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := strataEnv{GPUs: []strataGPU{{Index: 0, Name: "NVIDIA GeForce RTX 3070", VRAMGB: 8}, {Index: 1, Name: "NVIDIA GeForce RTX 5060 Ti", VRAMGB: 16}}}
|
||||
d := strataDetailsFor("X", env)
|
||||
if d == nil {
|
||||
t.Fatal("pas de détails")
|
||||
}
|
||||
if d.KV != "fp16" || d.KVResident != "32768" || d.Spec != "3" || d.Ctx != "131072" || !d.Mmap ||
|
||||
d.MainGPU != "RTX 5060 Ti (16 Go)" || d.HelperGPU != "RTX 3070 (8 Go)" || d.VisionGPU != "RTX 3070 (8 Go)" ||
|
||||
d.ShortRead != "150" || d.CacheRoot != "256" || d.Prefill != "16384" {
|
||||
t.Errorf("%+v", *d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataHaveGBPerChoice(t *testing.T) {
|
||||
t.Setenv("LOKI_HOME", t.TempDir())
|
||||
write := func(rel string, n int) {
|
||||
p := filepath.Join(strataDataDir(), rel)
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
if err := os.WriteFile(p, make([]byte, n), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write("models/swift-IQ3_XXS/a.gguf", 3000)
|
||||
write("packs/swift-iq3_xxs/experts.bin", 2000)
|
||||
write("mtp/rt/x", 500)
|
||||
write("models/IQ2_XS/b.gguf", 7000) // un autre choix : ne compte pas pour swift IQ3_XXS
|
||||
if got := strataHaveGB("swift", "IQ3_XXS") * 1e9; got != 5500 {
|
||||
t.Errorf("swift IQ3_XXS : %v octets, attendu 5500", got)
|
||||
}
|
||||
if got := strataHaveGB("swift", "IQ2_XS") * 1e9; got != 500 {
|
||||
t.Errorf("swift IQ2_XS : %v octets, attendu 500 (seulement la tête MTP commune)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettings(t *testing.T) {
|
||||
in := "# NAME=x\nENGINE=strata\nCTX=131072\nSTRATA_KV=fp16\nSTRATA_SPEC=3\nSTRATA_HELPER_GPU=0\n"
|
||||
out, err := strataApplySettings(in, strataSettingsReq{Ctx: 65536, KV: "int8", Spec: 4, Vision: false, Helper: false})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := parseEnv(out)
|
||||
if cfg["CTX"] != "65536" || cfg["STRATA_KV"] != "int8" || cfg["STRATA_SPEC"] != "4" || cfg["STRATA_VISION"] != "0" ||
|
||||
cfg["STRATA_HELPER"] != "0" || cfg["STRATA_HELPER_GPU"] != "0" || cfg["ENGINE"] != "strata" {
|
||||
t.Errorf("%q", out)
|
||||
}
|
||||
if strings.Count(out, "CTX=") != 1 {
|
||||
t.Errorf("CTX dupliqué : %q", out)
|
||||
}
|
||||
if strataHelperOn(cfg) || strataCudaDevices(map[string]string{"STRATA_MAIN_GPU": "1", "STRATA_HELPER_GPU": "0", "STRATA_HELPER": "0"}) != "1" {
|
||||
t.Error("carte d'aide coupée mais encore utilisée")
|
||||
}
|
||||
base := map[string]any{"args": []any{"--vision", "--pack", "/p"}, "vision": map[string]any{"exe": "v"}}
|
||||
b, _ := strataBuildConfig(base, cfg, "", "ram")
|
||||
if strataHasArg(strataArgs(t, b), "--vision") || b["vision"] != nil || strataHasArg(strataArgs(t, b), "--expert-cache-device1") {
|
||||
t.Errorf("vision / aide coupées mais présentes : %v", b)
|
||||
}
|
||||
for _, bad := range []strataSettingsReq{{Ctx: 1000, KV: "fp16", Spec: 3}, {Ctx: 32768, KV: "q2", Spec: 3}, {Ctx: 32768, KV: "fp16", Spec: 9}} {
|
||||
if _, err := strataApplySettings(in, bad); err == nil {
|
||||
t.Errorf("accepté : %+v", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineOf(t *testing.T) {
|
||||
cases := map[string]string{"": engineLlamacpp, "llamacpp": engineLlamacpp, "strata": engineStrata, " Strata ": engineStrata, "moe": engineStrata, "autre": engineLlamacpp}
|
||||
for in, want := range cases {
|
||||
if got := engineOf(map[string]string{"ENGINE": in}); got != want {
|
||||
t.Errorf("ENGINE=%q : %q, attendu %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelLoadErrorStrata(t *testing.T) {
|
||||
ok := strataServeMarker + " (paquet AJEAN MoE 1.0)\nloading the model (the first start takes a minute or two) ...\nready: http://127.0.0.1:8080/v1 (OpenAI: /v1/chat/completions)\n"
|
||||
if got := modelLoadErrorFrom(ok); got != "" {
|
||||
t.Errorf("Strata prêt signalé en échec : %q", got)
|
||||
}
|
||||
crash := strataServeMarker + "\nTraceback (most recent call last):\n File \"server.py\", line 1\nRuntimeError: boom\n"
|
||||
if got := modelLoadErrorFrom(crash); !strings.Contains(got, "Strata") {
|
||||
t.Errorf("trace Python non signalée : %q", got)
|
||||
}
|
||||
// une trace d'un lancement PRÉCÉDENT ne compte pas
|
||||
if got := modelLoadErrorFrom(crash + strataServeMarker + "\nloading the model ...\n"); got != "" {
|
||||
t.Errorf("ancienne trace prise pour l'échec en cours : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Où vont les experts : décidé sur la RAM DISPONIBLE. Vécu : 62 Go dont 18 pris
|
||||
// ailleurs, mode « hors RAM » choisi sur la RAM totale, moteur tué par le noyau.
|
||||
func TestStrataResolveExperts(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("LOKI_HOME", home)
|
||||
pack := filepath.Join(home, "pack")
|
||||
_ = os.MkdirAll(pack, 0o755)
|
||||
args := []string{"--pack", pack}
|
||||
cfg := map[string]string{"STRATA_CONFIG": "/x/strata-swift-iq3_xxs.json", "STRATA_HELPER_GPU": "1", "STRATA_DROP": "1"}
|
||||
// IQ3_XXS : 42,9 Go d'experts
|
||||
cases := []struct {
|
||||
experts string
|
||||
avail float64
|
||||
want string
|
||||
}{
|
||||
{"", 44, "disk"}, // le cas vécu : 44 Go disponibles, pas de experts.bin
|
||||
{"", 60, "ram"}, // assez de RAM pour tout charger avec la marge
|
||||
{"auto", 52.8, "disk"},
|
||||
{"disk", 60, "disk"}, // choix de l'utilisateur
|
||||
{"ram", 20, "ram"}, // choix de l'utilisateur, même à l'étroit
|
||||
}
|
||||
for _, c := range cases {
|
||||
cfg["STRATA_EXPERTS"] = c.experts
|
||||
if got := strataResolveExperts(cfg, args, c.avail, 12); got != c.want {
|
||||
t.Errorf("experts=%q dispo=%.0f : %s, attendu %s", c.experts, c.avail, got, c.want)
|
||||
}
|
||||
}
|
||||
// experts.bin présent (moteur du paquet) : le mode hors RAM et le mappage deviennent possibles
|
||||
_ = os.WriteFile(filepath.Join(pack, "experts.bin"), []byte("x"), 0o644)
|
||||
_ = os.MkdirAll(filepath.Join(strataSrcDir(), "engine-cuda12"), 0o755)
|
||||
_ = os.WriteFile(strataEngineMarker(), []byte(strataEngineAsset+"\n"), 0o644)
|
||||
cfg["STRATA_EXPERTS"] = ""
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "drop" {
|
||||
t.Errorf("44 Go + carte d'appoint 12 Go + experts.bin : %s, attendu drop", got)
|
||||
}
|
||||
if got := strataResolveExperts(cfg, args, 30, 12); got != "arena" {
|
||||
t.Errorf("30 Go + experts.bin : %s, attendu arena", got)
|
||||
}
|
||||
cfg["STRATA_HELPER"] = "0"
|
||||
if got := strataResolveExperts(cfg, args, 44, 12); got != "arena" {
|
||||
t.Errorf("carte d'appoint coupée : %s, attendu arena", got)
|
||||
}
|
||||
cfg["STRATA_EXPERTS"] = "disk"
|
||||
if got := strataResolveExperts(cfg, args, 60, 12); got != "arena" {
|
||||
t.Errorf("disque demandé avec experts.bin : %s, attendu arena", got)
|
||||
}
|
||||
// quant inconnu : on ne parie pas sur la RAM
|
||||
if got := strataResolveExperts(map[string]string{"STRATA_CONFIG": "/x/autre.json"}, nil, 500, 0); got != "disk" {
|
||||
t.Errorf("quant inconnu : %s, attendu disk", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataBuildConfigModes(t *testing.T) {
|
||||
base := map[string]any{"args": []any{"--pack", "/p", "--resident-experts", "--pcie-frac", "0.3"}, "env": map[string]any{"X": "1"}}
|
||||
cfg := map[string]string{"STRATA_HELPER_GPU": "1"}
|
||||
env := func(m map[string]any) map[string]any { return m["env"].(map[string]any) }
|
||||
|
||||
out, _ := strataBuildConfig(base, cfg, "", "disk")
|
||||
a := strataArgs(t, out)
|
||||
if !strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || argAfter(a, "--pcie-frac") != "0" ||
|
||||
env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["X"] != "1" {
|
||||
t.Errorf("disk : %v %v", a, env(out))
|
||||
}
|
||||
out, _ = strataBuildConfig(base, cfg, "", "ram")
|
||||
a = strataArgs(t, out)
|
||||
if strataHasArg(a, "--mmap-experts") || strataHasArg(a, "--resident-experts") || env(out)["STRATA_ARENA_MMAP"] != nil || env(out)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Errorf("ram : %v %v", a, env(out))
|
||||
}
|
||||
out, _ = strataBuildConfig(base, cfg, "", "arena")
|
||||
if a = strataArgs(t, out); argAfter(a, "--pcie-frac") != "0" || env(out)["STRATA_ARENA_MMAP"] != "1" {
|
||||
t.Errorf("arena : %v %v", a, env(out))
|
||||
}
|
||||
out, _ = strataBuildConfig(base, cfg, "", "drop")
|
||||
if a = strataArgs(t, out); strataHasArg(a, "--pcie-frac") || env(out)["STRATA_REMOTE_DROP"] != "1" {
|
||||
t.Errorf("drop : la part PCIe doit être automatique : %v %v", a, env(out))
|
||||
}
|
||||
// drop sans carte d'appoint : rien de spécial, c'est le mode ram
|
||||
out, _ = strataBuildConfig(base, map[string]string{"STRATA_HELPER_GPU": "-1"}, "", "drop")
|
||||
if env(out)["STRATA_REMOTE_DROP"] != nil {
|
||||
t.Error("mode hors RAM sans carte d'appoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataApplySettingsExperts(t *testing.T) {
|
||||
in := "ENGINE=strata\nSTRATA_EXPERTS=auto\n"
|
||||
r := strataSettingsReq{Ctx: 131072, KV: "fp16", Spec: 3, Experts: "disk"}
|
||||
out, err := strataApplySettings(in, r)
|
||||
if err != nil || parseEnv(out)["STRATA_EXPERTS"] != "disk" {
|
||||
t.Fatalf("%v %q", err, out)
|
||||
}
|
||||
r.Experts = "nvme"
|
||||
if _, err := strataApplySettings(in, r); err == nil {
|
||||
t.Error("placement inconnu accepté")
|
||||
}
|
||||
r.Experts = ""
|
||||
if out, _ := strataApplySettings(in, r); parseEnv(out)["STRATA_EXPERTS"] != "auto" {
|
||||
t.Errorf("vide doit laisser le choix en place : %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Vécu : bascule de Strata vers llama-server, l'encodeur de vision ne trouve
|
||||
// plus de VRAM. La pile d'appels cite « load_model » : elle ne doit pas passer
|
||||
// pour une nouvelle tentative qui effacerait la cause, écrite juste avant.
|
||||
func TestModelLoadErrorOOMBeforeBacktrace(t *testing.T) {
|
||||
log := "0.00.247.285 I srv load_model: loading model '/data/models/Swift.gguf'\n" +
|
||||
"4.47.957.764 E ggml_backend_cuda_buffer_type_alloc_buffer: allocating 884.62 MiB on device 0: cudaMalloc failed: out of memory\n" +
|
||||
"/app/ggml/src/ggml-backend.cpp:328: GGML_ASSERT(buffer) failed\n" +
|
||||
"/data/engine/libmtmd.so.0(mtmd_init_from_file+0x31)[0x14bd4fea4c01]\n" +
|
||||
"/data/engine/libllama-server-impl.so(_ZN19server_context_impl10load_modelER13common_params+0x7ff)[0x14bd50b9f1ff]\n" +
|
||||
"/lib/x86_64-linux-gnu/libc.so.6(__libc_start_main+0x8b)[0x14bd5059a28b]\n"
|
||||
if got := modelLoadErrorFrom(log); !strings.Contains(got, "mémoire GPU insuffisante") {
|
||||
t.Errorf("cause mémoire perdue derrière la pile d'appels : %q", got)
|
||||
}
|
||||
}
|
||||
@@ -55,10 +55,13 @@ type tuneProc struct {
|
||||
|
||||
// tuneOwner : le contenu du verrou.
|
||||
type tuneOwner struct {
|
||||
Owner tuneProc `json:"owner"`
|
||||
Via string `json:"via"` // « web » ou « cli »
|
||||
Phase string `json:"phase"` // « essais » ou « application »
|
||||
Since int64 `json:"since"`
|
||||
Owner tuneProc `json:"owner"`
|
||||
Via string `json:"via"` // « web » ou « cli »
|
||||
Phase string `json:"phase"` // « essais » ou « application »
|
||||
Since int64 `json:"since"`
|
||||
// Nonce : distingue deux verrous pris par le même processus dans la même
|
||||
// seconde (tuneHeldKey). Absent des verrous d'une version d'avant : 0.
|
||||
Nonce int64 `json:"nonce,omitempty"`
|
||||
Trial *tuneProc `json:"trial,omitempty"`
|
||||
// MainWasActive : le vrai moteur tournait avant l'optimisation. Un verrou
|
||||
// périmé trouvé au démarrage du processus web le relance.
|
||||
@@ -143,7 +146,16 @@ func tuneActive() (tuneOwner, bool) {
|
||||
// tuneHeld : verrous pris par CE processus et pas encore rendus (tuneHeldKey).
|
||||
var tuneHeld sync.Map
|
||||
|
||||
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d", o.Via, o.Since) }
|
||||
// tuneFileMu sérialise, DANS CE PROCESSUS, les opérations sur le fichier de
|
||||
// verrou : prise, réécriture, rendu, et le déplacement-relecture du ménage
|
||||
// (tuneReapStale). Sans lui, le ménage pouvait déplacer un verrou vivant
|
||||
// pendant que release() le cherchait : release, ne le trouvant plus, rendait
|
||||
// l'inscription ; le ménage le remettait ensuite en place, désormais orphelin,
|
||||
// et le ménage suivant le prenait pour une optimisation interrompue (moteur
|
||||
// relancé, preset « rétabli »). Vu en CI : TestTuneRecoverNeverReapsOwnLock.
|
||||
var tuneFileMu sync.Mutex
|
||||
|
||||
func tuneHeldKey(o tuneOwner) string { return fmt.Sprintf("%s|%d|%d", o.Via, o.Since, o.Nonce) }
|
||||
|
||||
// tuneOwnerAlive : le propriétaire d'un verrou vit-il encore ? Un verrou qui
|
||||
// porte NOTRE PID sans être l'un des nôtres vient d'un processus mort dont le
|
||||
@@ -190,8 +202,15 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
return nil, err
|
||||
}
|
||||
l := &tuneLock{path: path, owner: tuneOwner{Owner: selfProc(), Via: via, Phase: "essais",
|
||||
Since: time.Now().Unix(), MainWasActive: mainActive}}
|
||||
Since: time.Now().Unix(), Nonce: time.Now().UnixNano(), MainWasActive: mainActive}}
|
||||
// Inscrit comme nôtre AVANT que le fichier n'existe : la reprise du
|
||||
// processus web (tuneRecoverWatch, toutes les 30 s) tient pour mort un
|
||||
// verrou qui porte notre PID sans être inscrit, et l'écarterait entre la
|
||||
// création et l'inscription.
|
||||
key := tuneHeldKey(l.owner)
|
||||
tuneHeld.Store(key, true)
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
tuneFileMu.Lock()
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
b, _ := json.Marshal(l.owner)
|
||||
@@ -199,27 +218,40 @@ func tuneLockAcquire(via string, mainActive bool) (*tuneLock, error) {
|
||||
cerr := f.Close()
|
||||
if werr != nil || cerr != nil {
|
||||
_ = os.Remove(path)
|
||||
tuneHeld.Delete(key)
|
||||
tuneFileMu.Unlock()
|
||||
return nil, fmt.Errorf("verrou de l'optimiseur : %v", errors.Join(werr, cerr))
|
||||
}
|
||||
l.held = true
|
||||
tuneHeld.Store(tuneHeldKey(l.owner), true)
|
||||
tuneFileMu.Unlock()
|
||||
return l, nil
|
||||
}
|
||||
tuneFileMu.Unlock()
|
||||
if !os.IsExist(err) {
|
||||
tuneHeld.Delete(key)
|
||||
return nil, err
|
||||
}
|
||||
if o, busy := tuneActive(); busy {
|
||||
tuneHeld.Delete(key)
|
||||
return nil, fmt.Errorf("une optimisation tourne déjà (%s, PID %d, depuis %s)", o.Via, o.Owner.PID,
|
||||
time.Unix(o.Since, 0).Format("15:04"))
|
||||
}
|
||||
tuneReapStale()
|
||||
}
|
||||
tuneHeld.Delete(key)
|
||||
return nil, errors.New("verrou de l'optimiseur indisponible : réessaie")
|
||||
}
|
||||
|
||||
// write réécrit le verrou d'un bloc (fichier voisin puis renommage) : un
|
||||
// lecteur ne voit jamais un JSON à moitié écrit.
|
||||
func (l *tuneLock) write() error {
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
return l.writeLocked()
|
||||
}
|
||||
|
||||
// writeLocked : write, tuneFileMu déjà tenu par l'appelant.
|
||||
func (l *tuneLock) writeLocked() error {
|
||||
b, err := json.Marshal(l.owner)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -267,14 +299,47 @@ func (l *tuneLock) setBackup(backup, id, name string) {
|
||||
}
|
||||
|
||||
// release rend le verrou — seulement s'il est encore le nôtre.
|
||||
//
|
||||
// La reprise du processus web (tuneRecoverWatch) tourne toutes les 30 s,
|
||||
// pendant que ce même processus prend et rend ses verrous. Elle tient pour
|
||||
// mort un verrou à notre PID qui n'est plus inscrit (tuneHeld) : si le fichier
|
||||
// survivait à l'inscription — sous Windows, un fichier qu'un autre lit à cet
|
||||
// instant ne se supprime pas —, elle l'écarterait, et en phase « application »
|
||||
// défairait le preset tout juste vérifié (tuneUndoApply), ou relancerait le
|
||||
// moteur. Le verrou est donc d'abord réécrit NEUTRE (rien à défaire, rien à
|
||||
// relancer, aucun essai), puis retiré, et l'inscription ne part qu'après.
|
||||
func (l *tuneLock) release() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
if l.held {
|
||||
// Un fichier absent un instant (renommé par un ménage qui le remet en
|
||||
// place, voir tuneReapStale) n'est pas un fichier rendu : on relit.
|
||||
ours := false
|
||||
for i := 0; i < 5; i++ {
|
||||
if o, ok := readTuneOwner(l.path); ok {
|
||||
ours = sameTuneLock(o, l.owner)
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if ours {
|
||||
l.owner.Phase, l.owner.Trial, l.owner.MainWasActive = "rendu", nil, false
|
||||
l.owner.Backup, l.owner.PresetID, l.owner.PresetName = "", "", ""
|
||||
_ = l.writeLocked()
|
||||
for i := 0; i < 5; i++ {
|
||||
if err := os.Remove(l.path); err == nil || os.IsNotExist(err) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
l.held = false
|
||||
tuneHeld.Delete(tuneHeldKey(l.owner))
|
||||
return
|
||||
}
|
||||
if o, ok := readTuneOwner(l.path); ok && o.Owner.PID == l.owner.Owner.PID && o.Owner.Start == l.owner.Owner.Start {
|
||||
if o, ok := readTuneOwner(l.path); ok && sameTuneLock(o, l.owner) {
|
||||
_ = os.Remove(l.path)
|
||||
}
|
||||
}
|
||||
@@ -285,6 +350,24 @@ func (l *tuneLock) release() {
|
||||
// périmé). Le renommage d'abord : de deux processus qui trouvent le même verrou
|
||||
// périmé, un seul l'emporte, et aucun ne retire un verrou neuf posé entre-temps.
|
||||
func tuneReapStale() (mainWasActive bool) {
|
||||
o, ok := tuneReapClaim()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
||||
tuneKillTree(o.Trial.PID, nil)
|
||||
}
|
||||
tuneUndoApply(o)
|
||||
return o.MainWasActive
|
||||
}
|
||||
|
||||
// tuneReapClaim : la partie de tuneReapStale qui touche au fichier, sous
|
||||
// tuneFileMu — lecture, déplacement, relecture, remise en place. Rend le
|
||||
// verrou périmé effectivement retiré (ok=false : rien à écarter).
|
||||
func tuneReapClaim() (tuneOwner, bool) {
|
||||
tuneFileMu.Lock()
|
||||
defer tuneFileMu.Unlock()
|
||||
path := tuneLockPath()
|
||||
o, ok := readTuneOwner(path)
|
||||
if !ok {
|
||||
@@ -295,22 +378,35 @@ func tuneReapStale() (mainWasActive bool) {
|
||||
if fi, err := os.Stat(path); err == nil && time.Since(fi.ModTime()) > tuneLockFreshness {
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
if tuneOwnerAlive(o) {
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
stale := fmt.Sprintf("%s.stale-%d-%d", path, os.Getpid(), time.Now().UnixNano())
|
||||
if os.Rename(path, stale) != nil {
|
||||
return false
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
defer os.Remove(stale)
|
||||
if o.Trial != nil && tuneProcAlive(*o.Trial) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] essai orphelin (PID %d) d'une optimisation interrompue : arrêt\n", o.Trial.PID)
|
||||
tuneKillTree(o.Trial.PID, nil)
|
||||
// Entre la lecture et le renommage, le verrou lu a pu être rendu et un
|
||||
// neuf, vivant, pris à sa place : c'est alors CELUI-LÀ qu'on vient de
|
||||
// déplacer. La reprise du processus web tournant toutes les 30 s, pendant
|
||||
// que ce même processus prend et rend ses verrous, ce n'est plus un cas
|
||||
// d'école. On relit donc ce qu'on a déplacé ; un autre verrou est remis en
|
||||
// place (un lien : jamais par-dessus un verrou posé entre-temps).
|
||||
if got, ok := readTuneOwner(stale); !ok || !sameTuneLock(got, o) || tuneOwnerAlive(got) {
|
||||
if err := os.Link(stale, path); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] verrou vivant déplacé par erreur, remise en place impossible : %v\n", err)
|
||||
}
|
||||
return tuneOwner{}, false
|
||||
}
|
||||
tuneUndoApply(o)
|
||||
return o.MainWasActive
|
||||
return o, true
|
||||
}
|
||||
|
||||
// sameTuneLock : a et b désignent-ils la même prise de verrou (propriétaire et
|
||||
// instant de la prise), quelle que soit sa phase ?
|
||||
func sameTuneLock(a, b tuneOwner) bool {
|
||||
return a.Owner.PID == b.Owner.PID && a.Owner.Start == b.Owner.Start && tuneHeldKey(a) == tuneHeldKey(b)
|
||||
}
|
||||
|
||||
// tuneUndoApply : une application interrompue avant sa vérification (Loki tué
|
||||
@@ -332,20 +428,50 @@ func tuneUndoApply(o tuneOwner) {
|
||||
fmt.Fprintf(os.Stderr, "[loki tune] application interrompue avant sa vérification : ancienne version du preset rétablie\n")
|
||||
}
|
||||
|
||||
// tuneRecoverAtBoot : au démarrage du processus web, une optimisation
|
||||
// interrompue (Loki tué en plein essai) a laissé le vrai moteur arrêté et peut-
|
||||
// tuneRecoverStale : une optimisation interrompue (Loki redémarré en plein
|
||||
// essai, « loki tune » tué par kill -9) a laissé le vrai moteur arrêté et peut-
|
||||
// être un essai en VRAM. On arrête l'essai, puis on relance le moteur s'il
|
||||
// tournait avant — ce que l'optimisation aurait fait en finissant.
|
||||
func tuneRecoverAtBoot() {
|
||||
// tournait avant — ce que l'optimisation aurait fait en finissant. Sans
|
||||
// verrou, un simple stat : rien d'autre n'est lu.
|
||||
func tuneRecoverStale() {
|
||||
if _, err := os.Stat(tuneLockPath()); err != nil {
|
||||
return
|
||||
}
|
||||
if !tuneReapStale() || externalActive() || serviceIsActive() {
|
||||
if tuneReapStale() {
|
||||
tuneRecoverStart()
|
||||
}
|
||||
}
|
||||
|
||||
// tuneRecoverStart relance le moteur après une optimisation interrompue, sauf
|
||||
// preset externe, moteur déjà reparti ou configuration incomplète. Variable
|
||||
// pour les tests.
|
||||
var tuneRecoverStart = func() {
|
||||
if externalActive() || serviceIsActive() {
|
||||
return
|
||||
}
|
||||
if err := preflightEngine(); err != nil {
|
||||
return
|
||||
}
|
||||
fmt.Println(dim("[loki tune] optimisation interrompue : relance du moteur"))
|
||||
_ = serviceActionOS("start")
|
||||
// serviceAction, pas serviceActionOS : la reprise tourne désormais toutes
|
||||
// les 30 s, et une optimisation neuve a pu prendre le verrou entre le
|
||||
// ménage et ce démarrage — tuneGuard le voit, et le moteur reste arrêté
|
||||
// pour elle.
|
||||
_ = serviceAction("start")
|
||||
}
|
||||
|
||||
// tuneRecoverEvery : la période de la reprise dans le processus web.
|
||||
const tuneRecoverEvery = 30 * time.Second
|
||||
|
||||
// tuneRecoverWatch : au démarrage du processus web, puis à chaque tic. Le
|
||||
// démarrage seul ne suffisait pas : un « loki tune » en ligne de commande tué
|
||||
// sans ses defers (kill -9, terminal perdu) laissait le moteur arrêté jusqu'au
|
||||
// prochain redémarrage de l'interface. Le tic ne coûte qu'un stat tant
|
||||
// qu'aucun verrou n'existe ; pendant une optimisation vivante, une lecture du
|
||||
// verrou et de l'identité de son propriétaire.
|
||||
func tuneRecoverWatch(tick <-chan time.Time) {
|
||||
tuneRecoverStale()
|
||||
for range tick {
|
||||
tuneRecoverStale()
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,9 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
|
||||
if externalActive() {
|
||||
return errors.New("optimisation indisponible : le preset actif est une API externe")
|
||||
}
|
||||
if strataActive() {
|
||||
return errors.New("optimisation indisponible : le preset actif tourne sur Strata, réglé par sa propre fenêtre")
|
||||
}
|
||||
if err := tuneGuard(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -197,6 +200,17 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
|
||||
bgJobsRunning())
|
||||
}
|
||||
}
|
||||
// En ligne de commande : le moteur qui répond sur le port de CE LOKI_HOME
|
||||
// est-il bien le sien ? (Le processus web, lui, fait foi pour son moteur.)
|
||||
if !inWeb {
|
||||
cfg := ReadConfig()
|
||||
ours, _ := resolveServeModelPath(strings.TrimSpace(cfg["MODEL"]))
|
||||
auth := tuneAuth()
|
||||
if err := tuneForeignEngine(ctx, fmt.Sprintf("http://localhost:%d", LLMPort()), serviceIsActive(),
|
||||
func(r *http.Request) { auth(r.Header.Set) }, ours); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if serviceIsActive() {
|
||||
e := benchEngine{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: tuneAuth(), client: http.DefaultClient}
|
||||
if err := e.idle(ctx); err != nil {
|
||||
@@ -206,6 +220,58 @@ func tunePreflight(ctx context.Context, inWeb bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tuneForeignEngine : « loki tune » et le processus web avec des LOKI_HOME
|
||||
// différents (sudo qui retire la variable, LOKI_HOME exporté dans un seul
|
||||
// shell) ne se voient pas : le verrou de l'optimisation tombe dans un dossier
|
||||
// que l'interface ne lit pas, et l'arrêt du « vrai » moteur vise celui d'une
|
||||
// autre configuration. L'essai chargeait alors à côté d'un moteur bien vivant
|
||||
// — VRAM saturée, mesures fausses — et l'interface pouvait relancer le sien
|
||||
// en pleine mesure. On regarde donc ce qui répond sur le port de CE
|
||||
// LOKI_HOME :
|
||||
//
|
||||
// - un serveur répond alors que ce LOKI_HOME dit son moteur arrêté : un autre
|
||||
// Loki, ou un llama-server orphelin ;
|
||||
// - le moteur refuse la clé d'API de ce LOKI_HOME (401 sur /props), ou sert
|
||||
// un autre fichier que son MODEL : le moteur d'une autre configuration.
|
||||
//
|
||||
// Tout ce qui ne conclut pas (pas de réponse, /props absent d'un moteur
|
||||
// ancien, chemin illisible d'ici) laisse passer : seul un désaccord constaté
|
||||
// refuse.
|
||||
func tuneForeignEngine(ctx context.Context, base string, active bool, auth func(*http.Request), ourModel string) error {
|
||||
p := tplProber{base: base, auth: auth, client: &http.Client{Timeout: 2 * time.Second}}
|
||||
// /health d'un llama-server : 200, 503 en chargement, 401 derrière une clé
|
||||
// sur certaines versions. Rien n'écoute, ou un autre service (404…) : aucun
|
||||
// moteur à craindre.
|
||||
var he *tplHTTPError
|
||||
if _, err := p.do(ctx, http.MethodGet, "/health", nil); err != nil &&
|
||||
(!errors.As(err, &he) || (he.status != http.StatusServiceUnavailable && he.status != http.StatusUnauthorized)) {
|
||||
return nil
|
||||
}
|
||||
home := LokiHome()
|
||||
foreign := func(why string) error {
|
||||
return fmt.Errorf("%s, alors que LOKI_HOME=%s — un autre Loki tourne sans doute avec un autre LOKI_HOME "+
|
||||
"(sudo retire la variable) : relance avec le sien (LOKI_HOME=… loki tune) ou utilise le bouton "+
|
||||
"« Optimiser… » de l'interface ; un llama-server orphelin, lui, s'arrête à la main", why, home)
|
||||
}
|
||||
if !active {
|
||||
return foreign("un moteur répond sur " + base + " sans que ce dossier le suive")
|
||||
}
|
||||
props, err := p.props(ctx)
|
||||
switch {
|
||||
case errors.As(err, &he) && he.status == http.StatusUnauthorized:
|
||||
return foreign("le moteur de " + base + " refuse la clé d'API de ce dossier")
|
||||
case err != nil || props.ModelPath == "" || ourModel == "":
|
||||
return nil
|
||||
}
|
||||
a, errA := os.Stat(props.ModelPath)
|
||||
b, errB := os.Stat(ourModel)
|
||||
if errA == nil && errB == nil && !os.SameFile(a, b) {
|
||||
return foreign(fmt.Sprintf("le moteur de %s sert %s, pas le MODEL de ce dossier (%s ; config.env modifiée "+
|
||||
"sans redémarrage ? « loki restart »)", base, filepath.Base(props.ModelPath), filepath.Base(ourModel)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tunePhysCores : cœurs physiques (Linux ; ailleurs inconnu, l'axe threads est
|
||||
// sauté). Un conteneur à l'étroit (cpuset, quota) donne sa propre limite.
|
||||
func tunePhysCores(probe *tuneProbe) int {
|
||||
|
||||
@@ -4,12 +4,15 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -832,3 +835,168 @@ func TestTuneUndoInterruptedApply(t *testing.T) {
|
||||
t.Error("configuration modifiée hors phase d'application")
|
||||
}
|
||||
}
|
||||
|
||||
// Reprise dans le processus web : un « loki tune » tué sans ses defers ne
|
||||
// laisse plus le moteur arrêté jusqu'au prochain démarrage de l'interface. Le
|
||||
// tic suivant écarte le verrou périmé et relance le moteur — une fois, et
|
||||
// seulement s'il tournait avant. Sans verrou, ni lecture ni relance.
|
||||
func TestTuneRecoverWatch(t *testing.T) {
|
||||
testHome(t)
|
||||
alive := true
|
||||
prevAlive, prevStart := tuneProcAlive, tuneRecoverStart
|
||||
tuneProcAlive = func(p tuneProc) bool { return alive && p.PID == os.Getpid() }
|
||||
starts := 0
|
||||
tuneRecoverStart = func() { starts++ }
|
||||
t.Cleanup(func() { tuneProcAlive, tuneRecoverStart = prevAlive, prevStart })
|
||||
|
||||
tick := make(chan time.Time)
|
||||
done := make(chan struct{})
|
||||
go func() { tuneRecoverWatch(tick); close(done) }()
|
||||
// Un envoi est reçu quand le tic précédent a fini : deux de suite, et le
|
||||
// premier est traité en entier.
|
||||
step := func() { tick <- time.Now() }
|
||||
step()
|
||||
step()
|
||||
if starts != 0 {
|
||||
t.Fatal("relance sans verrou")
|
||||
}
|
||||
l, err := tuneLockAcquire("cli", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
step()
|
||||
step()
|
||||
if starts != 0 {
|
||||
t.Fatal("relance pendant une optimisation vivante")
|
||||
}
|
||||
if _, err := os.Stat(tuneLockPath()); err != nil {
|
||||
t.Fatal("verrou vivant retiré")
|
||||
}
|
||||
alive = false // kill -9 : le verrou reste, son propriétaire est mort
|
||||
step()
|
||||
step()
|
||||
if starts != 1 {
|
||||
t.Fatalf("relances : %d, attendu 1", starts)
|
||||
}
|
||||
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
|
||||
t.Error("verrou périmé non retiré")
|
||||
}
|
||||
l.release()
|
||||
|
||||
// Le moteur ne tournait pas avant : verrou retiré, pas de relance.
|
||||
alive = true
|
||||
if _, err := tuneLockAcquire("cli", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alive = false
|
||||
step()
|
||||
step()
|
||||
close(tick)
|
||||
<-done
|
||||
if starts != 1 {
|
||||
t.Errorf("relance d'un moteur qui ne tournait pas : %d", starts)
|
||||
}
|
||||
if _, err := os.Stat(tuneLockPath()); !os.IsNotExist(err) {
|
||||
t.Error("verrou périmé non retiré")
|
||||
}
|
||||
}
|
||||
|
||||
// « loki tune » d'un autre LOKI_HOME que le processus web (sudo) : le moteur
|
||||
// qui répond sur le port de ce dossier n'est pas le sien — refus en clair.
|
||||
// Tout ce qui ne conclut pas laisse passer.
|
||||
func TestTuneForeignEngine(t *testing.T) {
|
||||
home := testHome(t)
|
||||
ours := filepath.Join(home, "ours.gguf")
|
||||
other := filepath.Join(home, "other.gguf")
|
||||
for _, p := range []string{ours, other} {
|
||||
if err := os.WriteFile(p, []byte("GGUF"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
engine := func(health, props int, model string) *httptest.Server {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/health":
|
||||
w.WriteHeader(health)
|
||||
case "/props":
|
||||
w.WriteHeader(props)
|
||||
if props == http.StatusOK {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"model_path": model})
|
||||
}
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
closed := httptest.NewServer(http.NotFoundHandler())
|
||||
closed.Close()
|
||||
ctx := context.Background()
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
base string
|
||||
active bool
|
||||
refused bool
|
||||
}{
|
||||
{"rien n'écoute", closed.URL, false, false},
|
||||
{"autre service (404) sur le port, moteur arrêté", engine(404, 404, "").URL, false, false},
|
||||
{"moteur qui répond, ce dossier le dit arrêté", engine(200, 200, ours).URL, false, true},
|
||||
{"moteur en chargement (503), ce dossier le dit arrêté", engine(503, 503, "").URL, false, true},
|
||||
{"notre moteur", engine(200, 200, ours).URL, true, false},
|
||||
{"un autre MODEL", engine(200, 200, other).URL, true, true},
|
||||
{"clé d'API refusée", engine(200, 401, "").URL, true, true},
|
||||
{"/props absent (moteur ancien)", engine(200, 404, "").URL, true, false},
|
||||
{"chemin illisible d'ici", engine(200, 200, filepath.Join(home, "absent.gguf")).URL, true, false},
|
||||
} {
|
||||
err := tuneForeignEngine(ctx, c.base, c.active, nil, ours)
|
||||
if (err != nil) != c.refused {
|
||||
t.Errorf("%s : %v", c.name, err)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "LOKI_HOME="+home) {
|
||||
t.Errorf("%s : message sans LOKI_HOME : %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// La reprise du processus web tourne toutes les 30 s, pendant que ce même
|
||||
// processus prend et rend ses propres verrous (bouton « Optimiser… »,
|
||||
// application d'un résultat). Elle ne doit jamais tenir l'un d'eux pour
|
||||
// périmé : entre la création du fichier et l'inscription, ou entre
|
||||
// l'inscription retirée et le fichier retiré, elle aurait défait un preset
|
||||
// tout juste vérifié (phase « application ») et relancé le moteur.
|
||||
func TestTuneRecoverNeverReapsOwnLock(t *testing.T) {
|
||||
testHome(t)
|
||||
prevAlive, prevStart := tuneProcAlive, tuneRecoverStart
|
||||
tuneProcAlive = func(p tuneProc) bool { return p.PID == os.Getpid() }
|
||||
var starts atomic.Int32
|
||||
tuneRecoverStart = func() { starts.Add(1) }
|
||||
t.Cleanup(func() { tuneProcAlive, tuneRecoverStart = prevAlive, prevStart })
|
||||
|
||||
stop := make(chan struct{})
|
||||
reaped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(reaped)
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
tuneRecoverStale()
|
||||
}
|
||||
}
|
||||
}()
|
||||
for i := 0; i < 60; i++ {
|
||||
l, err := tuneLockAcquire("apply", true)
|
||||
if err != nil {
|
||||
t.Fatalf("tour %d : %v", i, err)
|
||||
}
|
||||
l.setPhase("application")
|
||||
l.release()
|
||||
}
|
||||
close(stop)
|
||||
<-reaped
|
||||
if n := starts.Load(); n != 0 {
|
||||
t.Errorf("verrou vivant de ce processus écarté %d fois", n)
|
||||
}
|
||||
}
|
||||
@@ -76,19 +76,17 @@ func cmdChat(args []string) error {
|
||||
fmt.Println(dim("[contexte compacté pour tenir dans la fenêtre]"))
|
||||
}
|
||||
sent, tools := prepareTurn(msgs, caps)
|
||||
hist := msgs
|
||||
extra, err := runChatTools(context.Background(), sent, tools, 0.7, caps, func(ev StreamEvent) bool {
|
||||
switch {
|
||||
case ev.Err != nil:
|
||||
fmt.Printf("\n%s\n", red("[erreur] "+ev.Err.Error()))
|
||||
case ev.NewHistory != nil:
|
||||
// Compaction survenue pendant le tour : elle REMPLACE l'historique (elle
|
||||
// contient déjà le tour en cours), préfixe système injecté retiré. Sans
|
||||
// ça le terminal repartirait du fil complet au tour suivant.
|
||||
base := ev.NewHistory
|
||||
for len(base) > 0 && base[0].Role == "system" {
|
||||
base = base[1:]
|
||||
}
|
||||
msgs = append([]Message(nil), base...)
|
||||
// contient déjà le tour en cours), préfixe système injecté retiré —
|
||||
// le /sys de l'utilisateur, lui, reste (historyFromView). Sans ça le
|
||||
// terminal repartirait du fil complet au tour suivant.
|
||||
msgs = historyFromView(ev.NewHistory, hist)
|
||||
fmt.Println(dim("\n[contexte compacté pour tenir dans la fenêtre]"))
|
||||
case ev.Stats != nil:
|
||||
stats = ev.Stats
|
||||
@@ -117,7 +115,7 @@ func cmdChat(args []string) error {
|
||||
case "edit":
|
||||
icon = "✏️"
|
||||
verb = "édition"
|
||||
case "web_search", "web_open", "web_read", "web_grep":
|
||||
case "web_search", "web_images", "web_open", "web_read", "web_grep":
|
||||
icon = "🌐"
|
||||
verb = "web"
|
||||
case "web_screenshot":
|
||||
|
||||
@@ -354,6 +354,17 @@ func compactLog(log []LogEvent) []LogEvent {
|
||||
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
|
||||
flush()
|
||||
if done, _ := tu["done"].(bool); done {
|
||||
// L'aperçu du navigateur (shot) n'est que du direct : on ne le
|
||||
// garde pas une fois le tour fini (voir cu_autoshot.go).
|
||||
if _, has := tu["shot"]; has {
|
||||
cp := make(map[string]any, len(tu))
|
||||
for k, v := range tu {
|
||||
if k != "shot" {
|
||||
cp[k] = v
|
||||
}
|
||||
}
|
||||
ev.Delta = map[string]any{"tool_used": cp}
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
continue
|
||||
@@ -438,6 +449,47 @@ func (c *Conversation) compactAndPublish(ctx context.Context, epoch int, phase s
|
||||
return compacted, true
|
||||
}
|
||||
|
||||
// historyFromView rend l'historique à ranger après une compaction en cours de
|
||||
// tour : la vue publiée (NewHistory), sans les messages système que la vue
|
||||
// d'envoi avait posés en tête — préambule et prompt du preset, consignes du
|
||||
// dépôt, contexte du projet ou bloc figé.
|
||||
//
|
||||
// Retirer TOUS les systèmes de tête effaçait aussi ceux de l'historique
|
||||
// lui-même : le rappel des pages mémoire lues ([MEMORY PAGES REMINDER]), posé
|
||||
// par une compaction de début de tour, disparaissait à la première compaction
|
||||
// en cours de tour, et le modèle perdait la liste des pages à relire. Un
|
||||
// système de tête n'est donc gardé que s'il figurait en tête de hist,
|
||||
// l'historique dont la vue est partie : la compaction garde la tête telle
|
||||
// quelle, et la vue d'envoi n'y ajoute que ses propres messages. Seule
|
||||
// exception, le premier : InjectSkills a pu fusionner le préambule devant un
|
||||
// système de l'historique (« préambule\n\nsystème ») — on rend alors celui-ci.
|
||||
func historyFromView(view, hist []Message) []Message {
|
||||
own := map[string]int{}
|
||||
for _, m := range hist {
|
||||
if m.Role != "system" {
|
||||
break
|
||||
}
|
||||
own[msgText(m)]++
|
||||
}
|
||||
var out []Message
|
||||
i := 0
|
||||
for ; i < len(view) && view[i].Role == "system"; i++ {
|
||||
k := msgText(view[i])
|
||||
if own[k] > 0 {
|
||||
own[k]--
|
||||
out = append(out, view[i])
|
||||
continue
|
||||
}
|
||||
if i == 0 && len(hist) > 0 && hist[0].Role == "system" {
|
||||
if h := msgText(hist[0]); h != "" && own[h] > 0 && strings.HasSuffix(k, "\n\n"+h) {
|
||||
own[h]--
|
||||
out = append(out, hist[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(out, view[i:]...)
|
||||
}
|
||||
|
||||
// convState renvoie un instantané léger (pour /api/chat/state).
|
||||
func (c *Conversation) state() map[string]any {
|
||||
c.mu.Lock()
|
||||
@@ -848,6 +900,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
// projet est celui, figé, de la discussion, et ses changements partent en
|
||||
// tête de ce message — après la compaction de début de tour, sous c.mu.
|
||||
sent, tools, snapTurn := c.turnView(caps, epoch, msgs, caps.Agent)
|
||||
hist := msgs // l'historique d'où part la vue : voir historyFromView
|
||||
runCtx := ctx
|
||||
if snapTurn != nil {
|
||||
runCtx = withProjSnapTurn(ctx, snapTurn)
|
||||
@@ -903,6 +956,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
if ev.ToolUsed.Image != "" {
|
||||
tu["image"] = ev.ToolUsed.Image
|
||||
}
|
||||
if ev.ToolUsed.Shot != "" {
|
||||
tu["shot"] = ev.ToolUsed.Shot
|
||||
}
|
||||
// Aperçu seulement dans le flux : taille réelle + id pour « voir plus ».
|
||||
if ev.ToolUsed.ResultChars > 0 {
|
||||
tu["result_chars"] = ev.ToolUsed.ResultChars
|
||||
@@ -913,15 +969,12 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
|
||||
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
|
||||
// s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire le
|
||||
// préfixe système injecté à la volée (prompt perso + skills, fusionnés en
|
||||
// UN message system en tête) : il n'appartient pas à l'historique persisté
|
||||
// et doit rester modifiable à chaud.
|
||||
base := ev.NewHistory
|
||||
for len(base) > 0 && base[0].Role == "system" {
|
||||
base = base[1:]
|
||||
}
|
||||
newBase = append([]Message(nil), base...)
|
||||
// s'ajouter à l'ancienne (voir StreamEvent.NewHistory). On retire ce
|
||||
// que la vue d'envoi a posé en tête (prompt perso + préambule, contexte
|
||||
// du projet) : il n'appartient pas à l'historique persisté et doit
|
||||
// rester modifiable à chaud. Le rappel des pages lues, lui, en fait
|
||||
// partie (historyFromView).
|
||||
newBase = historyFromView(ev.NewHistory, hist)
|
||||
case ev.Echo != nil:
|
||||
echo = ev.Echo
|
||||
case ev.Compacting != nil:
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package loki
|
||||
|
||||
// chat_envctx.go — PROJ_SNAPSHOT : la date et le dossier de travail sortent du
|
||||
// prompt système pour entrer dans le bloc projet figé de la discussion.
|
||||
//
|
||||
// Pourquoi. Le système (préambule + briefing machine) et les outils forment le
|
||||
// début de chaque requête ; tant qu'ils ne bougent pas, le moteur reprend son
|
||||
// cache. Deux valeurs y bougeaient pourtant : la date — à minuit, tout le prompt
|
||||
// d'une discussion en cours est recalculé — et le dossier de travail, propre à
|
||||
// chaque discussion — deux discussions ne partageaient donc rien, pas même le
|
||||
// système. Avec la clé, le système ne garde que des consignes (« ton dossier de
|
||||
// travail est dans ton contexte ») et les valeurs partent dans le bloc projet,
|
||||
// en tête du premier message utilisateur, avec le reste du contexte figé.
|
||||
//
|
||||
// Rien n'est mémorisé au-delà du bloc lui-même : chaque tour relit la date, le
|
||||
// dossier et la cible, et tout écart part en <context_update> dans le message
|
||||
// suivant — une ligne pour un changement de jour, le bloc entier sinon (poste
|
||||
// distant passé hors ligne, autre cible, autre dossier). Les mêmes faits, à une
|
||||
// autre place ; aucun n'est retenu par Loki plus longtemps qu'avant.
|
||||
//
|
||||
// Seul un tour de discussion avec la clé y passe (assembleTurn pose
|
||||
// caps.envInCtx). Une tâche planifiée, un sous-agent, la vérification du mode
|
||||
// Code, le terminal gardent leur système d'avant, date et dossier compris.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// envContextPrefix ouvre le message (isProjectSystem le range avec le contexte
|
||||
// du projet). Assez particulier pour qu'aucun prompt de preset ne commence ainsi.
|
||||
const envContextPrefix = "Environment (from Loki)"
|
||||
|
||||
// nodeOfflineLine : l'avertissement d'un poste distant hors ligne — dans le
|
||||
// système sans la clé, dans le bloc projet avec.
|
||||
const nodeOfflineLine = "⚠ It is currently OFFLINE: those tools will fail until it reconnects. Tell the user instead of trying repeatedly."
|
||||
|
||||
// envNow : l'horloge du bloc (remplaçable en test).
|
||||
var envNow = time.Now
|
||||
|
||||
// envDateLine : la date du jour, avec l'année et l'année périmée que la
|
||||
// consigne web du système désignait jusque-là par leur valeur.
|
||||
func envDateLine(day string) string {
|
||||
year := day
|
||||
if len(day) >= 4 {
|
||||
year = day[:4]
|
||||
}
|
||||
return "Date: " + day + " (the current year is " + year + ", not " + prevYear(year) + ")."
|
||||
}
|
||||
|
||||
// envMachineFacts : les valeurs du briefing machine qui varient — dossier de la
|
||||
// discussion, ou poste distant (nom, système, dossier, état). "" sans agent.
|
||||
// Les consignes qui les accompagnent restent dans machineSystemPrompt.
|
||||
func envMachineFacts(caps Caps) string {
|
||||
if !caps.Agent {
|
||||
return ""
|
||||
}
|
||||
if tgt, ok := nodeTargetMetaGet(); ok {
|
||||
s := "Machine: REMOTE node " + tgt.name
|
||||
if tgt.os != "" {
|
||||
s += " (" + tgt.os + ")"
|
||||
}
|
||||
if tgt.root != "" {
|
||||
s += ", working folder " + tgt.root
|
||||
}
|
||||
s += "."
|
||||
if !tgt.connected {
|
||||
s += " " + nodeOfflineLine
|
||||
}
|
||||
return s
|
||||
}
|
||||
if cwd := agentCwd(); cwd != "" {
|
||||
return "Working folder (cwd): " + cwd + "."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// envCapture : la date (si le préambule en portait une) et les valeurs machine
|
||||
// de ce tour.
|
||||
func envCapture(caps Caps) (day, facts string) {
|
||||
if caps.Agent || caps.Mem != MemOff {
|
||||
day = envNow().Format("2006-01-02")
|
||||
}
|
||||
return day, envMachineFacts(caps)
|
||||
}
|
||||
|
||||
// envContextMessage : le message du bloc, ok=false s'il n'a rien à dire.
|
||||
func envContextMessage(day, facts string) (Message, bool) {
|
||||
var lines []string
|
||||
if day != "" {
|
||||
lines = append(lines, envDateLine(day))
|
||||
}
|
||||
if facts != "" {
|
||||
lines = append(lines, facts)
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return Message{}, false
|
||||
}
|
||||
return Message{Role: "system", Content: envContextPrefix + ":\n" + strings.Join(lines, "\n")}, true
|
||||
}
|
||||
|
||||
// envDelta : la mise à jour entre l'environnement annoncé et le vivant. Un
|
||||
// changement de jour seul tient en une ligne ; tout autre écart renvoie le
|
||||
// message entier.
|
||||
func envDelta(oldDay, oldFacts, day, facts string) string {
|
||||
switch {
|
||||
case oldFacts == facts && oldDay == day:
|
||||
return ""
|
||||
case oldFacts == facts && day != "" && oldDay != "":
|
||||
return "New day — " + envDateLine(day) + "\n"
|
||||
}
|
||||
if m, ok := envContextMessage(day, facts); ok {
|
||||
return "Current version, replaces any earlier one — " + msgText(m) + "\n"
|
||||
}
|
||||
return "Environment: removed.\n"
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sysPlaceholders remplace dans un prompt système les valeurs propres à la
|
||||
// machine et au jour (date, année, hôte, utilisateur, dossiers) par des jetons
|
||||
// fixes : ce qui reste doit être identique, octet pour octet, d'une machine à
|
||||
// l'autre.
|
||||
func sysPlaceholders(s string) string {
|
||||
now := time.Now()
|
||||
year := now.Format("2006")
|
||||
host, _ := os.Hostname()
|
||||
if host == "" {
|
||||
host = "unknown"
|
||||
}
|
||||
who := ""
|
||||
if u, err := user.Current(); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
pairs := []string{
|
||||
now.Format("2006-01-02"), "<DATE>",
|
||||
scriptsDir(), "<SCRIPTS>",
|
||||
agentCwd(), "<CWD>",
|
||||
"host=" + host, "host=<HOST>",
|
||||
runtime.GOOS + "/" + runtime.GOARCH, "<OS>/<ARCH>",
|
||||
"The shell is " + agentTargetShellName(), "The shell is <SHELL>",
|
||||
}
|
||||
if who != "" {
|
||||
pairs = append(pairs, "user="+who, "user=<USER>")
|
||||
}
|
||||
pairs = append(pairs, year, "<YEAR>", prevYear(year), "<PREVYEAR>")
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
s = strings.ReplaceAll(s, pairs[i], pairs[i+1])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sysOf(t *testing.T, msgs []Message) string {
|
||||
t.Helper()
|
||||
if len(msgs) == 0 || msgs[0].Role != "system" {
|
||||
t.Fatalf("pas de système en tête : %+v", msgs)
|
||||
}
|
||||
s, _ := msgs[0].Content.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
// Gabarits du système SANS PROJ_SNAPSHOT, relevés sur le code d'avant le
|
||||
// déplacement de la date et du dossier de travail : ils ne doivent pas bouger
|
||||
// d'un octet (valeurs du jour et de la machine remplacées par des jetons).
|
||||
var sysGolden = map[string]string{
|
||||
"agent": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\nThe shell is <SHELL>: use its syntax.\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. 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 (except your scripts folder) when the user explicitly named that location.",
|
||||
"internet": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\nThe shell is <SHELL>: use its syntax.\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nWeb: web_open first, then web_read/web_grep on it.\nYour training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\nToday is in <YEAR>. If a query needs a year use ONLY <YEAR>, never a remembered past year like <PREVYEAR> — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. 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 (except your scripts folder) when the user explicitly named that location.",
|
||||
"ondemand": "You are Loki, an expert assistant operating directly on this machine with real tools.\n\nThe shell is <SHELL>: use its syntax.\n\nMemory is ON-DEMAND: you have the mem_* tools but do NOT read or write memory on your own. Call mem_search/mem_read only when the user explicitly asks you to recall or look something up, and mem_add/mem_edit only when the user explicitly asks you to remember something. Otherwise leave memory untouched and answer directly.\nFor anything about the system or files, use bash instead of guessing. Act immediately — call the right tool, then answer. Never end your turn after only thinking. Be concise.\nTo give the user a file, link it in Markdown with its path relative to your working directory — [le rapport](rapport.pdf) — which downloads it. A raw server path is useless: they read you in a browser.\nYou can schedule work for yourself with task_create — only for what must recur or happen later, never a one-off you can do now. A script from your scripts folder runs as a task with no model at all.\n\nDate: <DATE>\n\nMachine: host=<HOST>, <OS>/<ARCH>, user=<USER>, cwd=<CWD>. This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder <SCRIPTS> instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. 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 (except your scripts folder) when the user explicitly named that location.",
|
||||
"memseule": "You are Loki, an expert assistant operating directly on this machine with real tools. You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.\n\n\nManaging your memory is part of the job, not optional:\n- Save anything worth keeping (a preference, fact, decision, how-to) with mem_add, or mem_edit to update a page — on your own, without being asked.\n- The memory index (page list) is in your context: when a page looks relevant, mem_read it directly. mem_search is only for finding something by content.\n\nDate: <DATE>",
|
||||
}
|
||||
|
||||
func TestSystemeSansCleInchange(t *testing.T) {
|
||||
projSnapSetup(t, false)
|
||||
cases := map[string]Caps{
|
||||
"agent": {Agent: true, Mem: MemAlways},
|
||||
"internet": {Agent: true, Internet: true, Mem: MemAlways},
|
||||
"ondemand": {Agent: true, Mem: MemOnDemand},
|
||||
"memseule": {Mem: MemAlways},
|
||||
}
|
||||
for name, caps := range cases {
|
||||
got, _ := prepareTurn([]Message{um("salut")}, caps)
|
||||
s := sysPlaceholders(sysOf(t, got))
|
||||
if want, ok := sysGolden[name]; !ok || s != want {
|
||||
t.Errorf("%s : système changé sans la clé\nreçu %q\nattendu %q", name, s, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toolsOf : le champ tools d'une requête, tel qu'envoyé.
|
||||
func toolsOf(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
var p struct {
|
||||
Tools json.RawMessage `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(p.Tools)
|
||||
}
|
||||
|
||||
// Avec la clé : deux discussions dans deux dossiers ont le MÊME système et les
|
||||
// mêmes outils ; la date et le dossier sont dans le bloc projet de chacune.
|
||||
func TestEnvCtxSystemeCommunAuxDiscussions(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
t.Cleanup(func() { setTaskWorkspace("") })
|
||||
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
|
||||
var bodies []string
|
||||
dirs := []string{t.TempDir(), t.TempDir()}
|
||||
for _, d := range dirs {
|
||||
setTaskWorkspace(d)
|
||||
bodies = append(bodies, playTurn(t, m, newTestConv(), caps, "salut"))
|
||||
}
|
||||
s0, s1 := systemContent(t, bodies[0]), systemContent(t, bodies[1])
|
||||
if s0 != s1 || toolsOf(t, bodies[0]) != toolsOf(t, bodies[1]) {
|
||||
t.Fatalf("système ou outils différents d'une discussion à l'autre :\n%q\n%q", s0, s1)
|
||||
}
|
||||
today := time.Now().Format("2006-01-02")
|
||||
year := today[:4]
|
||||
for _, v := range []string{today, year, dirs[0], dirs[1], "cwd="} {
|
||||
if strings.Contains(s0, v) {
|
||||
t.Fatalf("%q encore dans le système : %q", v, s0)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(s0, "Your working folder (cwd) is given in your context") || !strings.Contains(s0, scriptsDir()) {
|
||||
t.Fatalf("consignes du dossier de travail perdues : %q", s0)
|
||||
}
|
||||
for i, b := range bodies {
|
||||
u := userContents(t, b)[0]
|
||||
if !strings.Contains(u, envContextPrefix+":\n"+envDateLine(today)) || !strings.Contains(u, "Working folder (cwd): "+dirs[i]+".") {
|
||||
t.Fatalf("discussion %d : date ou dossier absents du bloc : %q", i, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Changement de jour : une ligne de mise à jour, une seule fois ; système et
|
||||
// bloc figé inchangés, donc tout le début du prompt reste en cache.
|
||||
func TestEnvCtxChangementDeJour(t *testing.T) {
|
||||
m := projSnapSetup(t, true)
|
||||
day := time.Date(2026, 12, 31, 23, 50, 0, 0, time.Local)
|
||||
envNow = func() time.Time { return day }
|
||||
t.Cleanup(func() { envNow = time.Now })
|
||||
c := newTestConv()
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
b1 := playTurn(t, m, c, caps, "salut")
|
||||
day = day.Add(20 * time.Minute)
|
||||
b2 := playTurn(t, m, c, caps, "et maintenant ?")
|
||||
b3 := playTurn(t, m, c, caps, "merci")
|
||||
if systemContent(t, b1) != systemContent(t, b2) || systemContent(t, b2) != systemContent(t, b3) {
|
||||
t.Fatal("le système a changé avec le jour")
|
||||
}
|
||||
u1, u2, u3 := userContents(t, b1), userContents(t, b2), userContents(t, b3)
|
||||
if u1[0] != u2[0] || u2[0] != u3[0] || !strings.Contains(u1[0], "Date: 2026-12-31 (the current year is 2026, not 2025).") {
|
||||
t.Fatalf("bloc figé modifié ou sans date : %q / %q", u1[0], u2[0])
|
||||
}
|
||||
want := ctxUpdOpen
|
||||
last := u2[len(u2)-1]
|
||||
if !strings.HasPrefix(last, want) || !strings.Contains(last, "New day — Date: 2027-01-01 (the current year is 2027, not 2026).") {
|
||||
t.Fatalf("mise à jour du jour attendue : %q", last)
|
||||
}
|
||||
if u3[1] != last || strings.Contains(u3[len(u3)-1], ctxUpdOpen) {
|
||||
t.Fatalf("la mise à jour doit partir une fois, puis rester telle quelle : %q", u3)
|
||||
}
|
||||
}
|
||||
|
||||
// Tâches, sous-agents, vérification, terminal : la clé ne change rien à leur
|
||||
// système, qui garde la date et le dossier.
|
||||
func TestEnvCtxHorsDiscussionInchange(t *testing.T) {
|
||||
projSnapSetup(t, true)
|
||||
caps := Caps{Agent: true, Mem: MemAlways}
|
||||
got, _ := prepareTurn([]Message{um("tâche")}, caps)
|
||||
s := sysOf(t, got)
|
||||
if !strings.Contains(s, "\nDate: "+time.Now().Format("2006-01-02")) || !strings.Contains(s, ", cwd="+agentCwd()+".") {
|
||||
t.Fatalf("système d'une tâche modifié par la clé : %q", s)
|
||||
}
|
||||
if mp := machineSystemPrompt(caps); !strings.Contains(mp, "cwd="+agentCwd()) {
|
||||
t.Fatalf("briefing d'un sous-agent sans dossier : %q", mp)
|
||||
}
|
||||
}
|
||||
|
||||
// Poste distant passé hors ligne, autre dossier : le bloc entier repart, rien
|
||||
// n'est retenu d'avant.
|
||||
func TestEnvDelta(t *testing.T) {
|
||||
if d := envDelta("2026-10-04", "x", "2026-10-04", "x"); d != "" {
|
||||
t.Fatalf("rien de changé : %q", d)
|
||||
}
|
||||
if d := envDelta("2026-10-04", "Working folder (cwd): /a.", "2026-10-04", "Machine: REMOTE node pc. "+nodeOfflineLine); !strings.Contains(d, "Current version") || !strings.Contains(d, nodeOfflineLine) || !strings.Contains(d, "Date: 2026-10-04") {
|
||||
t.Fatalf("bloc entier attendu : %q", d)
|
||||
}
|
||||
if d := envDelta("2026-10-04", "x", "2026-10-05", "x"); d != "New day — "+envDateLine("2026-10-05")+"\n" {
|
||||
t.Fatalf("une ligne attendue : %q", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Compaction EN COURS de tour : la vue publiée perd ce que la vue d'envoi avait
|
||||
// posé en tête (préambule, prompt du preset, contexte du projet), mais garde le
|
||||
// rappel des pages lues, qui appartient à l'historique.
|
||||
func TestCompactionEnTourGardeLeRappel(t *testing.T) {
|
||||
for _, on := range []bool{false, true} {
|
||||
m := projSnapSetup(t, on)
|
||||
if err := saveSysPrompt("Réponds en français."); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := MemAdd("nas.md", "# NAS\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reminder, _ := buildReminderMessage([]string{"nas.md"})
|
||||
c := newTestConv()
|
||||
c.Messages = []Message{reminder}
|
||||
for i := 0; i < 12; i++ {
|
||||
c.Messages = append(c.Messages, um("continue "+strings.Repeat("x", 600)), am("étape "+strings.Repeat("détail ", 300)))
|
||||
}
|
||||
n := len(m.all())
|
||||
m.mu.Lock()
|
||||
m.reply = func(k int, _ string) (int, string) {
|
||||
if k == n {
|
||||
return 400, `{"error":{"code":400,"message":"request (99999 tokens) exceeds the available context size"}}`
|
||||
}
|
||||
return 200, sseChunk("ok.") + sseFinal("stop", 100, 2)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
playTurn(t, m, c, Caps{Agent: true, Mem: MemAlways}, "et le NAS ?")
|
||||
if len(m.all()) < n+2 {
|
||||
t.Fatalf("clé %v : pas de requête rejouée après compaction", on)
|
||||
}
|
||||
if len(c.Messages) == 0 || !reflect.DeepEqual(c.Messages[0], reminder) {
|
||||
t.Fatalf("clé %v : rappel des pages lues perdu : %+v", on, c.Messages[0])
|
||||
}
|
||||
for _, msg := range c.Messages[1:] {
|
||||
if msg.Role == "system" {
|
||||
t.Fatalf("clé %v : système injecté rangé dans l'historique : %q", on, msgText(msg))
|
||||
}
|
||||
}
|
||||
if len(c.Messages) >= 25 {
|
||||
t.Fatalf("clé %v : compaction non rangée (%d messages)", on, len(c.Messages))
|
||||
}
|
||||
if err := saveSysPrompt(""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryFromView(t *testing.T) {
|
||||
rem := Message{Role: "system", Content: memReminderPrefix + " Memory pages you read earlier: a.md"}
|
||||
sys := Message{Role: "system", Content: "/sys perso"}
|
||||
u := um("q")
|
||||
cases := []struct {
|
||||
name string
|
||||
view, hist []Message
|
||||
want []Message
|
||||
}{
|
||||
{"injectés seuls", []Message{{Role: "system", Content: "préambule"}, {Role: "system", Content: projectContextPrefix + " x"}, u}, []Message{u}, []Message{u}},
|
||||
{"rappel gardé", []Message{{Role: "system", Content: "préambule"}, {Role: "system", Content: projectContextPrefix + " x"}, rem, u}, []Message{rem, u}, []Message{rem, u}},
|
||||
{"système fusionné rendu", []Message{{Role: "system", Content: "préambule\n\n/sys perso"}, u}, []Message{sys, u}, []Message{sys, u}},
|
||||
{"sans système", []Message{u}, []Message{u}, []Message{u}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := historyFromView(tc.view, tc.hist); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("%s : %+v, attendu %+v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,7 +297,7 @@ func cmdInternet(args []string) error {
|
||||
if err := setInternetEnabled(true); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_open/web_read/web_grep (si le mode agent est actif)")
|
||||
fmt.Println(green("[ok]") + " accès internet activé — l'IA dispose de web_search/web_images/web_open/web_read/web_grep (si le mode agent est actif)")
|
||||
case "off":
|
||||
if err := setInternetEnabled(false); err != nil {
|
||||
return err
|
||||
@@ -352,7 +352,7 @@ func cmdInternet(args []string) error {
|
||||
fmt.Printf("%s état: %s\n", cyan("Accès internet"), state)
|
||||
if webEngine() == engineGo {
|
||||
fmt.Printf(" moteur : %s (aucune installation, pas de rendu JavaScript)\n", bold("intégré"))
|
||||
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
|
||||
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf(" moteur : %s\n", bold("crawl4ai"))
|
||||
@@ -366,7 +366,7 @@ func cmdInternet(args []string) error {
|
||||
reach = green("joignable")
|
||||
}
|
||||
fmt.Printf(" serveur : %s (%s)\n", bold(u), reach)
|
||||
fmt.Printf(" outils : web_search, web_open, web_read, web_grep\n")
|
||||
fmt.Printf(" outils : web_search, web_images, web_open, web_read, web_grep\n")
|
||||
default:
|
||||
return fmt.Errorf("usage: loki internet [on|off|status|engine <go|crawl4ai>|url <url>|key <clé>]")
|
||||
}
|
||||
|
||||
@@ -407,36 +407,65 @@ func TestKeptImagesDebutDeTour(t *testing.T) {
|
||||
}
|
||||
|
||||
// NUDGE_IN_TOOL : seulement avec la clé, sur le moteur local, si la sonde a dit
|
||||
// « instable » pour CE modèle.
|
||||
// « instable » pour CE modèle et pour la forme de CETTE requête.
|
||||
func TestNudgeInToolOn(t *testing.T) {
|
||||
freshTplProbe(t)
|
||||
cfg := map[string]string{"MODEL": "/models/qwen35.gguf"}
|
||||
tools := []Tool{readTool(), grepTool()}
|
||||
kwargs := map[string]any{"enable_thinking": true}
|
||||
set := func(r tplProbeResult) {
|
||||
tplProbeMu.Lock()
|
||||
tplProbeLast = &r
|
||||
tplProbeKeepShape(newTplShape(tools, kwargs, "").hash(), r)
|
||||
tplProbeMu.Unlock()
|
||||
}
|
||||
on := func(cfg map[string]string, ep chatEndpoint) bool { return nudgeInToolOn(cfg, ep, tools, kwargs, "") }
|
||||
set(tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo})
|
||||
if nudgeInToolOn(cfg, chatEndpoint{}) {
|
||||
if on(cfg, chatEndpoint{}) {
|
||||
t.Fatal("actif sans la clé")
|
||||
}
|
||||
cfg["NUDGE_IN_TOOL"] = "on"
|
||||
if !nudgeInToolOn(cfg, chatEndpoint{}) {
|
||||
if !on(cfg, chatEndpoint{}) {
|
||||
t.Fatal("inactif avec la clé sur un gabarit instable")
|
||||
}
|
||||
if nudgeInToolOn(cfg, chatEndpoint{External: true}) {
|
||||
if on(cfg, chatEndpoint{External: true}) {
|
||||
t.Fatal("actif sur un preset externe")
|
||||
}
|
||||
// Le verdict « instable » d'une forme ne vaut pas pour une autre : autres
|
||||
// outils, autres kwargs ou autre niveau de raisonnement — inconnu, repli.
|
||||
for name, other := range map[string]func() bool{
|
||||
"autres outils": func() bool { return nudgeInToolOn(cfg, chatEndpoint{}, []Tool{readTool()}, kwargs, "") },
|
||||
"autres kwargs": func() bool {
|
||||
return nudgeInToolOn(cfg, chatEndpoint{}, tools, map[string]any{"enable_thinking": false}, "")
|
||||
},
|
||||
"autre effort": func() bool { return nudgeInToolOn(cfg, chatEndpoint{}, tools, kwargs, "high") },
|
||||
} {
|
||||
if other() {
|
||||
t.Errorf("%s : actif sur le verdict d'une autre forme", name)
|
||||
}
|
||||
}
|
||||
for _, r := range []tplProbeResult{
|
||||
{Model: "qwen35.gguf", PrefixStable: tplUnknown},
|
||||
{Model: "qwen35.gguf", PrefixStable: tplYes},
|
||||
{Model: "autre.gguf", PrefixStable: tplNo},
|
||||
} {
|
||||
set(r)
|
||||
if nudgeInToolOn(cfg, chatEndpoint{}) {
|
||||
if on(cfg, chatEndpoint{}) {
|
||||
t.Fatalf("actif pour %+v", r)
|
||||
}
|
||||
}
|
||||
// La dernière forme sondée (tplProbeLast) ne décide plus : une autre forme
|
||||
// sondée « stable » ensuite ne change rien au verdict de la nôtre, et
|
||||
// inversement.
|
||||
set(tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo})
|
||||
tplProbeMu.Lock()
|
||||
last := tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplYes}
|
||||
tplProbeLast = &last
|
||||
tplProbeKeepShape(newTplShape(nil, kwargs, "").hash(), last)
|
||||
tplProbeMu.Unlock()
|
||||
if !on(cfg, chatEndpoint{}) {
|
||||
t.Error("verdict de notre forme écrasé par la dernière forme sondée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgeIntoTool(t *testing.T) {
|
||||
@@ -468,11 +497,17 @@ func TestRappelDeBudgetDansLeResultat(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Le verdict de la sonde pour la forme exacte de la requête du tour : mêmes
|
||||
// outils, mêmes chat_template_kwargs, même niveau de raisonnement.
|
||||
caps := Caps{Agent: true}
|
||||
_, effort, _, kwargs := turnReasoning(ReadConfig())
|
||||
tplProbeMu.Lock()
|
||||
tplProbeLast = &tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}
|
||||
r := tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}
|
||||
tplProbeLast = &r
|
||||
tplProbeKeepShape(newTplShape(EnabledTools(caps), kwargs, effort).hash(), r)
|
||||
tplProbeMu.Unlock()
|
||||
reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop)
|
||||
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true })
|
||||
extra, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, caps, func(StreamEvent) bool { return true })
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -486,6 +521,32 @@ func TestRappelDeBudgetDansLeResultat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Le verdict « instable » d'une AUTRE forme (dernière sondée, ici sans
|
||||
// outils) ne décide pas pour ce tour : rappel à part, comme sans la clé.
|
||||
func TestRappelDeBudgetVerdictDUneAutreForme(t *testing.T) {
|
||||
withWorkspace(t)
|
||||
freshTplProbe(t)
|
||||
for k, v := range map[string]string{"AGENT_BUDGET": "1", "NUDGE_IN_TOOL": "on", "MODEL": "/models/qwen35.gguf"} {
|
||||
if err := SetConfigKey(k, v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
_, effort, _, kwargs := turnReasoning(ReadConfig())
|
||||
tplProbeMu.Lock()
|
||||
r := tplProbeResult{Model: "qwen35.gguf", PrefixStable: tplNo}
|
||||
tplProbeLast = &r
|
||||
tplProbeKeepShape(newTplShape(nil, kwargs, effort).hash(), r)
|
||||
tplProbeMu.Unlock()
|
||||
reqs := scriptedServer(t, sseGlobCall, sseChunk("fini")+sseStop)
|
||||
if _, err := runChat(t.Context(), []Message{um("cherche")}, 0.7, Caps{Agent: true}, func(StreamEvent) bool { return true }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent := reqMessages(t, reqs()[1])
|
||||
if last := sent[len(sent)-1]; last.Role != "user" {
|
||||
t.Fatalf("rappel glissé dans le résultat sur le verdict d'une autre forme : %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
// En cours de tour, au seuil : les images gardées partent d'abord, et le
|
||||
// résumé n'est pas demandé si leur retrait suffit.
|
||||
func TestKeepImagesRetireesEnCoursDeTour(t *testing.T) {
|
||||
|
||||
@@ -22,12 +22,14 @@ package loki
|
||||
// - rien n'est perdu : lignes ajoutées, modifiées, retirées pour les données
|
||||
// faites de lignes (pages, trackers), texte COMPLET pour la prose
|
||||
// (description, AGENTS.md). Une ligne fixe du système dit que ces blocs
|
||||
// viennent de Loki et que le plus récent l'emporte.
|
||||
// viennent de Loki et que le plus récent l'emporte ;
|
||||
// - la date du jour et le dossier de travail quittent le système pour ce bloc
|
||||
// (chat_envctx.go) : un changement de jour devient une ligne de mise à jour.
|
||||
//
|
||||
// Le bloc est rafraîchi — copie neuve, blocs <context_update> retirés de tout
|
||||
// l'historique — seulement quand le début du prompt change de toute façon :
|
||||
// compaction (début, fin de tour, manuelle, en cours de tour, réduction forcée),
|
||||
// système ou outils modifiés (date du jour, réglages, mode), cache froid
|
||||
// système ou outils modifiés (réglages, mode, cible), cache froid
|
||||
// (redémarrage de Loki, changement de modèle, de preset ou de réglage moteur),
|
||||
// projet, nom, mode mémoire, mode Code ou dépôt changés, textes des en-têtes
|
||||
// changés. Et quand les mises à jour accumulées dépassent un seuil : au-delà, le
|
||||
@@ -66,7 +68,7 @@ func projSnapEnabled(cfg map[string]string) bool {
|
||||
// Textes propres au bloc figé. Leur empreinte entre dans la clé de validité :
|
||||
// une mise à jour de Loki qui les reformule rafraîchit les instantanés persistés.
|
||||
const (
|
||||
projSnapFormat = "1"
|
||||
projSnapFormat = "2"
|
||||
memIndexFrozenTail = " Later <context_update> blocks from Loki update it."
|
||||
trackerFrozenHead = " — your trackers, each with its latest point at that time; later <context_update> blocks from Loki replace these lines. " +
|
||||
"To say which trackers exist or give a latest value, use this list with those updates — do NOT call tracker with no arguments to list them. " +
|
||||
@@ -89,6 +91,8 @@ func projSnapNow() string { return time.Now().Format("2006-01-02 15:04") }
|
||||
// simple hash : c'est contre lui qu'on calcule la mise à jour suivante, y
|
||||
// compris après un redémarrage.
|
||||
type projState struct {
|
||||
Day string `json:"day,omitempty"` // date annoncée (chat_envctx.go)
|
||||
Env string `json:"env,omitempty"` // valeurs machine annoncées
|
||||
Code string `json:"code,omitempty"` // consignes du dépôt, message complet
|
||||
Desc string `json:"desc,omitempty"` // description du projet
|
||||
MemOn bool `json:"mem_on,omitempty"` // index mémoire présent
|
||||
@@ -122,9 +126,15 @@ type projLive struct {
|
||||
|
||||
// projSnapCapture lit le contexte projet UNE fois et en tire le bloc (même
|
||||
// ordre que l'assemblage d'un tour : consignes du dépôt, description, index
|
||||
// mémoire, trackers) et son état.
|
||||
// mémoire, trackers — précédés ici de la date et du dossier de travail, que le
|
||||
// système ne porte plus avec la clé) et son état.
|
||||
func projSnapCapture(caps Caps, asOf string) projLive {
|
||||
var lv projLive
|
||||
// Date et dossier de travail, sortis du système (chat_envctx.go).
|
||||
lv.state.Day, lv.state.Env = envCapture(caps)
|
||||
if m, ok := envContextMessage(lv.state.Day, lv.state.Env); ok {
|
||||
lv.msgs = append(lv.msgs, m)
|
||||
}
|
||||
if m, ok := codeInstructionsMessage(caps); ok {
|
||||
lv.msgs = append(lv.msgs, m)
|
||||
lv.state.Code, _ = m.Content.(string)
|
||||
@@ -224,7 +234,7 @@ func projSnapKey(caps Caps, cfg map[string]string) string {
|
||||
w(d)
|
||||
w(agentCwd())
|
||||
}
|
||||
for _, s := range []string{memIndexFrozenTail, trackerFrozenHead, ctxUpdOpen, ctxUpdClose, projSnapSystemLine} {
|
||||
for _, s := range []string{envContextPrefix, nodeOfflineLine, memIndexFrozenTail, trackerFrozenHead, ctxUpdOpen, ctxUpdClose, projSnapSystemLine} {
|
||||
w(s)
|
||||
}
|
||||
return fmt.Sprintf("%016x", h.Sum64())
|
||||
@@ -268,6 +278,7 @@ func projDelta(old projState, lv projLive, asOf string) string {
|
||||
full := func(m Message) {
|
||||
b.WriteString("Current version, replaces any earlier one — " + msgText(m) + "\n")
|
||||
}
|
||||
b.WriteString(envDelta(old.Day, old.Env, cur.Day, cur.Env))
|
||||
if old.Code != cur.Code {
|
||||
if cur.Code == "" {
|
||||
b.WriteString("Repository instructions: removed.\n")
|
||||
@@ -569,6 +580,9 @@ func (c *Conversation) assembleTurn(caps Caps, epoch int, msgs []Message, inject
|
||||
return sent, tools, nil
|
||||
}
|
||||
|
||||
// Date et dossier de travail : dans le bloc, plus dans le système
|
||||
// (chat_envctx.go). Cette copie de caps ne sert qu'à ce tour de discussion.
|
||||
caps.envInCtx = true
|
||||
// Lectures (disque, base, git) hors du verrou de la conversation.
|
||||
asOf := projSnapNow()
|
||||
live := projSnapCapture(caps, asOf)
|
||||
|
||||
@@ -117,7 +117,13 @@ func baseSystemPrompt(caps Caps, tools []Tool) string {
|
||||
year := time.Now().Format("2006")
|
||||
b.WriteString("\nWeb: web_open first, then web_read/web_grep on it.\n")
|
||||
b.WriteString("Your training data is stale. For ANY question about recent/latest/current things (releases, versions, news, prices, scores, 'since when') call web_search BEFORE writing any date or version, and match what you actually read.\n")
|
||||
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
|
||||
if caps.envInCtx {
|
||||
// Même consigne, sans la valeur : l'année est dans la ligne de date du
|
||||
// contexte (envContextMessage), qui nomme aussi l'année périmée.
|
||||
b.WriteString("Today's date is in your context. If a query needs a year use ONLY the current year, never a remembered past year — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
|
||||
} else {
|
||||
b.WriteString("Today is in " + year + ". If a query needs a year use ONLY " + year + ", never a remembered past year like " + prevYear(year) + " — it biases results toward stale pages; better still, omit the year. Don't hedge ('probably') about a fact a tool can verify — search instead.\n")
|
||||
}
|
||||
}
|
||||
if caps.Agent && caps.ComputerUse {
|
||||
b.WriteString(cuPromptLine())
|
||||
@@ -134,6 +140,11 @@ func baseSystemPrompt(caps Caps, tools []Tool) string {
|
||||
if caps.Agent && projSnapEnabled(ReadConfig()) {
|
||||
b.WriteString(projSnapSystemLine)
|
||||
}
|
||||
// PROJ_SNAPSHOT : la date part dans le bloc projet figé (chat_envctx.go). Ici,
|
||||
// elle changeait le système à minuit — tout le prompt recalculé.
|
||||
if caps.envInCtx {
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
b.WriteString("\nDate: " + time.Now().Format("2006-01-02"))
|
||||
return b.String()
|
||||
}
|
||||
@@ -161,23 +172,33 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// l'hôte du serveur et se trompe de machine.
|
||||
if tgt, ok := nodeTargetMetaGet(); ok {
|
||||
var b strings.Builder
|
||||
b.WriteString("Machine: you are operating on a REMOTE node named " + tgt.name)
|
||||
if tgt.os != "" {
|
||||
b.WriteString(" (" + tgt.os + ")")
|
||||
// PROJ_SNAPSHOT (caps.envInCtx) : nom, système, dossier et état du poste
|
||||
// sont dans le bloc projet (envMachineFacts) ; ici, les seules consignes.
|
||||
if caps.envInCtx {
|
||||
b.WriteString("Machine: you are operating on a REMOTE node (name, OS, working folder and status are in your context)")
|
||||
} else {
|
||||
b.WriteString("Machine: you are operating on a REMOTE node named " + tgt.name)
|
||||
if tgt.os != "" {
|
||||
b.WriteString(" (" + tgt.os + ")")
|
||||
}
|
||||
}
|
||||
b.WriteString(". Your bash, write and edit tools run on THAT machine — a different computer than this server.")
|
||||
if strings.HasPrefix(strings.ToLower(tgt.os), "windows") {
|
||||
b.WriteString(" Its shell is cmd.exe: use cmd syntax (never bash idioms like ls, 2>nul, single quotes, or 'cmd //c'). To create a file, use the write tool, never echo/type into it.")
|
||||
}
|
||||
if tgt.root != "" {
|
||||
b.WriteString(" File paths in write/edit resolve inside its working folder " + tgt.root + "; read/write are confined there.")
|
||||
if caps.envInCtx {
|
||||
b.WriteString(" File paths in write/edit resolve inside its working folder; read/write are confined there.")
|
||||
} else {
|
||||
b.WriteString(" File paths in write/edit resolve inside its working folder " + tgt.root + "; read/write are confined there.")
|
||||
}
|
||||
}
|
||||
// Le poste peut tourner en compte de service (Windows: LocalSystem) : les
|
||||
// variables d'environnement personnelles (%USERPROFILE%, $HOME) ne désignent
|
||||
// PAS forcément l'utilisateur interactif. Utiliser des chemins absolus.
|
||||
b.WriteString(" It may run as a background service account, so %USERPROFILE%/$HOME may not point to the interactive user — prefer absolute paths (e.g. C:\\Users\\<name>\\...).")
|
||||
if !tgt.connected {
|
||||
b.WriteString(" ⚠ It is currently OFFLINE: those tools will fail until it reconnects. Tell the user instead of trying repeatedly.")
|
||||
if !tgt.connected && !caps.envInCtx {
|
||||
b.WriteString(" " + nodeOfflineLine)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -196,7 +217,10 @@ func machineSystemPrompt(caps Caps) string {
|
||||
if who != "" {
|
||||
b.WriteString(", user=" + who)
|
||||
}
|
||||
if cwd != "" {
|
||||
// PROJ_SNAPSHOT (caps.envInCtx) : le chemin, propre à chaque discussion, est
|
||||
// dans le bloc projet (envMachineFacts) ; hôte et compte, les mêmes pour
|
||||
// toutes, restent ici avec les consignes.
|
||||
if cwd != "" && !caps.envInCtx {
|
||||
b.WriteString(", cwd=" + cwd)
|
||||
}
|
||||
b.WriteString(".")
|
||||
@@ -205,7 +229,12 @@ func machineSystemPrompt(caps Caps) string {
|
||||
// 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 scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. 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 (except your scripts folder) when the user explicitly named that location.")
|
||||
if caps.envInCtx {
|
||||
b.WriteString(" Your working folder (cwd) is given in your context: relative paths in write/edit/bash resolve there")
|
||||
} else {
|
||||
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here")
|
||||
}
|
||||
b.WriteString(", and it is the DEFAULT place for scratch work — notes, outputs, a clone, a test. But it is DISPOSABLE: deleting the discussion wipes it. Any script you want to KEEP (or schedule), write it into your scripts folder " + scriptsDir() + " instead — a separate folder a workspace wipe won't touch; you write and run scripts there normally. 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 (except your scripts folder) when the user explicitly named that location.")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
+170
-11
@@ -15,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Sonde de gabarit : que fait le gabarit de chat du modèle chargé de ce qu'on
|
||||
@@ -165,14 +166,44 @@ var (
|
||||
// Attente entre deux essais sur un 503 (modèle en chargement).
|
||||
tplProbeBackoff = time.Second
|
||||
|
||||
tplProbeMu sync.Mutex
|
||||
tplProbeCache = map[string]tplProbeResult{}
|
||||
tplProbeLast *tplProbeResult
|
||||
tplProbeMu sync.Mutex
|
||||
tplProbeCache = map[string]tplProbeResult{}
|
||||
tplProbeLast *tplProbeResult
|
||||
// tplProbeByShape : le dernier verdict de chaque forme de requête
|
||||
// (tplShape.hash). tplProbeLast est celui de la dernière forme sondée,
|
||||
// quelle qu'elle soit : bon pour l'affichage, pas pour décider d'une
|
||||
// requête d'une autre forme (tplCapsFor).
|
||||
tplProbeByShape = map[string]tplProbeResult{}
|
||||
tplProbeRunning bool
|
||||
tplProbeTried time.Time
|
||||
tplProbeLogged string
|
||||
// tplProbeShapeLast : la forme de la dernière requête sondée. C'est elle
|
||||
// que rejoue la vérification du rendu autour d'une mise à jour du moteur
|
||||
// (web_engine_reco.go) : mêmes outils, mêmes chat_template_kwargs que les
|
||||
// vraies requêtes, sans rien garder de leur texte.
|
||||
tplProbeShapeLast *tplShape
|
||||
)
|
||||
|
||||
// tplLastShape : la forme de la dernière requête sondée, ou une requête nue
|
||||
// (sans outils ni kwargs) si aucune complétion n'a encore eu lieu.
|
||||
func tplLastShape() tplShape {
|
||||
tplProbeMu.Lock()
|
||||
defer tplProbeMu.Unlock()
|
||||
if tplProbeShapeLast != nil {
|
||||
return *tplProbeShapeLast
|
||||
}
|
||||
return newTplShape(nil, nil, "")
|
||||
}
|
||||
|
||||
// tplLocalProber : le prober du moteur local, comme celui de la sonde.
|
||||
func tplLocalProber() tplProber {
|
||||
return tplProber{
|
||||
base: fmt.Sprintf("http://localhost:%d", LLMPort()),
|
||||
auth: authHeader,
|
||||
client: &http.Client{Timeout: tplProbeTimeout},
|
||||
}
|
||||
}
|
||||
|
||||
// tplProbeKick : appelé par runChat après une complétion COMPLÈTE du fil
|
||||
// principal, sur le moteur local, avec ce qu'elle a envoyé. Ne bloque jamais :
|
||||
// au plus une sonde en vol, au plus une vérification par minute (la plupart se
|
||||
@@ -215,16 +246,30 @@ func tplCapsCurrent() (tplProbeResult, bool) {
|
||||
return *tplProbeLast, true
|
||||
}
|
||||
|
||||
// tplCapsFor : le dernier verdict pour CETTE forme de requête (mêmes outils,
|
||||
// mêmes chat_template_kwargs, même reasoning_effort). Pas de verdict pour elle
|
||||
// — jamais sondée, ou sortie du cache — : rien, et l'appelant se replie comme
|
||||
// sur « inconnu ». Rien non plus pour un preset externe.
|
||||
func tplCapsFor(shape tplShape) (tplProbeResult, bool) {
|
||||
if externalActive() {
|
||||
return tplProbeResult{}, false
|
||||
}
|
||||
tplProbeMu.Lock()
|
||||
defer tplProbeMu.Unlock()
|
||||
r, ok := tplProbeByShape[shape.hash()]
|
||||
return r, ok
|
||||
}
|
||||
|
||||
// tplProbeEnsure : verdict pour la forme donnée, depuis le cache ou une sonde.
|
||||
func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult {
|
||||
if resolveChatEndpoint().External {
|
||||
return tplUnknownResult("preset externe : pas de sonde")
|
||||
}
|
||||
p := tplProber{
|
||||
base: fmt.Sprintf("http://localhost:%d", LLMPort()),
|
||||
auth: authHeader,
|
||||
client: &http.Client{Timeout: tplProbeTimeout},
|
||||
}
|
||||
tplProbeMu.Lock()
|
||||
s := shape
|
||||
tplProbeShapeLast = &s
|
||||
tplProbeMu.Unlock()
|
||||
p := tplLocalProber()
|
||||
if !p.healthy(ctx) {
|
||||
// Moteur absent ou en chargement : rien à conclure, rien à ranger.
|
||||
return tplUnknownResult("moteur pas prêt (/health)")
|
||||
@@ -232,7 +277,7 @@ func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult {
|
||||
props, err := p.props(ctx)
|
||||
if err != nil {
|
||||
r := tplUnknownResult("/props illisible : " + err.Error())
|
||||
tplProbeStore("", r)
|
||||
tplProbeStore("", shape.hash(), r)
|
||||
return r
|
||||
}
|
||||
h := fnv.New64a()
|
||||
@@ -245,6 +290,7 @@ func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult {
|
||||
tplProbeMu.Lock()
|
||||
if r, ok := tplProbeCache[key]; ok {
|
||||
tplProbeLast = &r
|
||||
tplProbeKeepShape(shape.hash(), r)
|
||||
tplProbeMu.Unlock()
|
||||
return r
|
||||
}
|
||||
@@ -265,14 +311,30 @@ func tplProbeEnsure(ctx context.Context, shape tplShape) tplProbeResult {
|
||||
if fresh {
|
||||
fmt.Fprintln(os.Stderr, line)
|
||||
}
|
||||
tplProbeStore(key, r)
|
||||
tplProbeStore(key, shape.hash(), r)
|
||||
return r
|
||||
}
|
||||
|
||||
func tplProbeStore(key string, r tplProbeResult) {
|
||||
// tplProbeKeepShape note le verdict d'une forme (tplProbeMu tenu). Au plus
|
||||
// tplProbeCacheMax formes : la plus ancienne cède sa place.
|
||||
func tplProbeKeepShape(shape string, r tplProbeResult) {
|
||||
if _, ok := tplProbeByShape[shape]; !ok && len(tplProbeByShape) >= tplProbeCacheMax {
|
||||
oldest := ""
|
||||
for k, v := range tplProbeByShape {
|
||||
if oldest == "" || v.At.Before(tplProbeByShape[oldest].At) {
|
||||
oldest = k
|
||||
}
|
||||
}
|
||||
delete(tplProbeByShape, oldest)
|
||||
}
|
||||
tplProbeByShape[shape] = r
|
||||
}
|
||||
|
||||
func tplProbeStore(key, shape string, r tplProbeResult) {
|
||||
tplProbeMu.Lock()
|
||||
defer tplProbeMu.Unlock()
|
||||
tplProbeLast = &r
|
||||
tplProbeKeepShape(shape, r)
|
||||
if key == "" || !r.cacheable {
|
||||
return
|
||||
}
|
||||
@@ -525,3 +587,100 @@ func (p tplProber) probe(ctx context.Context, shape tplShape) tplProbeResult {
|
||||
r.cacheable = definitive
|
||||
return r
|
||||
}
|
||||
|
||||
// --- Rendu comparé d'un moteur à l'autre ----------------------------------------
|
||||
//
|
||||
// Une mise à jour du moteur peut changer le prompt rendu sans qu'aucun réglage
|
||||
// de Loki ne bouge : depuis b10763, llama-server active preserve_reasoning par
|
||||
// défaut, et un gabarit qui ne gardait pas la réflexion des tours passés
|
||||
// (Qwen3.6) se met à la rendre — vide, puisque Loki ne la renvoie pas sans
|
||||
// REASONING_ECHO. Le modèle relirait alors un historique où il n'a jamais
|
||||
// réfléchi. tplRenderPrint fige le rendu de quelques conversations
|
||||
// synthétiques ; le même relevé avant et après la bascule dit si le prompt a
|
||||
// changé, et où.
|
||||
|
||||
// tplRenderSep sépare les rendus dans l'empreinte ; le nom qui suit dit de
|
||||
// quelle conversation vient un écart.
|
||||
const tplRenderSep = "\n\x00--- "
|
||||
|
||||
// tplRenderPrint : les rendus, concaténés, de trois conversations synthétiques
|
||||
// sur la forme donnée — l'historique tel que Loki l'envoie par défaut (sans
|
||||
// reasoning_content), le même avec reasoning_content (REASONING_ECHO), et un
|
||||
// tour d'outil suivi d'une question, amorce de réponse comprise. Seul
|
||||
// /apply-template est appelé : sans état, sans slot, sans effet sur le cache.
|
||||
func tplRenderPrint(ctx context.Context, p tplProber, shape tplShape) (string, error) {
|
||||
sys := tplMsg{Role: "system", Content: tplMarkSys}
|
||||
userA := tplMsg{Role: "user", Content: tplMarkA}
|
||||
userB := tplMsg{Role: "user", Content: tplMarkB}
|
||||
name := shape.first
|
||||
if name == "" {
|
||||
name = "lookup"
|
||||
}
|
||||
convs := []struct {
|
||||
name string
|
||||
msgs []tplMsg
|
||||
gen bool
|
||||
}{
|
||||
{"historique sans raisonnement", []tplMsg{sys, userA, {Role: "assistant", Content: tplMarkC1}, userB}, true},
|
||||
{"historique avec raisonnement", []tplMsg{sys, userA,
|
||||
{Role: "assistant", Content: tplMarkC1, ReasoningContent: tplMarkR1}, userB}, true},
|
||||
{"tour d'outil", []tplMsg{sys, userA,
|
||||
{Role: "assistant", Content: tplMarkC1,
|
||||
ToolCalls: []ToolCall{{ID: tplCallID, Type: "function", Function: ToolCallFunc{Name: name, Arguments: "{}"}}}},
|
||||
{Role: "tool", Content: tplMarkT1, ToolCallID: tplCallID}, userB}, true},
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, c := range convs {
|
||||
out, err := p.render(ctx, shape, c.msgs, c.gen)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s : %w", c.name, err)
|
||||
}
|
||||
b.WriteString(tplRenderSep + c.name + " ---\n")
|
||||
b.WriteString(out)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// tplRenderDiff compare deux empreintes de tplRenderPrint. Identiques : true.
|
||||
// Sinon, la conversation où elles divergent et un court extrait de part et
|
||||
// d'autre du premier écart (texte synthétique : aucun contenu de discussion).
|
||||
func tplRenderDiff(before, after string) (bool, string) {
|
||||
if before == after {
|
||||
return true, ""
|
||||
}
|
||||
i := 0
|
||||
for i < len(before) && i < len(after) && before[i] == after[i] {
|
||||
i++
|
||||
}
|
||||
section := ""
|
||||
if k := strings.LastIndex(before[:i], tplRenderSep); k >= 0 {
|
||||
rest := before[k+len(tplRenderSep):]
|
||||
if e := strings.Index(rest, " ---"); e >= 0 {
|
||||
section = rest[:e]
|
||||
}
|
||||
}
|
||||
excerpt := func(s string) string {
|
||||
from := max(0, i-40)
|
||||
to := min(len(s), i+60)
|
||||
// Bornes sur des débuts de caractère : un extrait ne coupe pas un rune.
|
||||
for from > 0 && !utf8.RuneStart(s[from]) {
|
||||
from--
|
||||
}
|
||||
for to < len(s) && !utf8.RuneStart(s[to]) {
|
||||
to++
|
||||
}
|
||||
x := strings.NewReplacer("\n", "⏎", "\x00", "").Replace(s[from:to])
|
||||
if from > 0 {
|
||||
x = "…" + x
|
||||
}
|
||||
if to < len(s) {
|
||||
x += "…"
|
||||
}
|
||||
return x
|
||||
}
|
||||
d := fmt.Sprintf("avant « %s » / après « %s »", excerpt(before), excerpt(after))
|
||||
if section != "" {
|
||||
d = section + " : " + d
|
||||
}
|
||||
return false, d
|
||||
}
|
||||
@@ -29,9 +29,11 @@ func freshTplProbe(t *testing.T) {
|
||||
tplProbeMu.Lock()
|
||||
tplProbeCache = map[string]tplProbeResult{}
|
||||
tplProbeLast = nil
|
||||
tplProbeByShape = map[string]tplProbeResult{}
|
||||
tplProbeRunning = false
|
||||
tplProbeTried = time.Time{}
|
||||
tplProbeLogged = ""
|
||||
tplProbeShapeLast = nil
|
||||
tplProbeMu.Unlock()
|
||||
}
|
||||
reset()
|
||||
@@ -50,6 +52,8 @@ func freshTplProbe(t *testing.T) {
|
||||
type fakeTpl struct {
|
||||
mu sync.Mutex
|
||||
mode string
|
||||
tpl string // texte du gabarit annoncé par /props (défaut : mode)
|
||||
build string // build_info annoncé par /props (défaut : b7000-abc)
|
||||
key string // Bearer exigé hors /health
|
||||
applyStatus []int // statuts imposés aux prochains /apply-template (0 = rendu)
|
||||
applyBody string
|
||||
@@ -115,7 +119,15 @@ func (f *fakeTpl) handler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
switch r.URL.Path {
|
||||
case "/props":
|
||||
fmt.Fprintf(w, `{"build_info":"b7000-abc","model_path":"/models/Qwen3-8B-Q8_0.gguf","chat_template":%q}`, f.mode)
|
||||
tpl := f.tpl
|
||||
if tpl == "" {
|
||||
tpl = f.mode
|
||||
}
|
||||
build := f.build
|
||||
if build == "" {
|
||||
build = "b7000-abc"
|
||||
}
|
||||
fmt.Fprintf(w, `{"build_info":%q,"model_path":"/models/Qwen3-8B-Q8_0.gguf","chat_template":%q}`, build, tpl)
|
||||
case "/apply-template":
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
@@ -447,3 +459,33 @@ func TestSondeGabaritDeclencheeParRunChat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Le verdict est rangé par forme de requête : la dernière forme sondée ne
|
||||
// décide pas pour une autre (NUDGE_IN_TOOL), et une forme jamais sondée n'a
|
||||
// pas de verdict.
|
||||
func TestSondeGabaritVerdictParForme(t *testing.T) {
|
||||
testHome(t)
|
||||
freshTplProbe(t)
|
||||
f := &fakeTpl{mode: "qwen3"}
|
||||
f.start(t)
|
||||
a := newTplShape(probeTools(), nil, "")
|
||||
b := newTplShape(nil, nil, "")
|
||||
if r := tplProbeEnsure(context.Background(), a); r.PrefixStable != tplNo {
|
||||
t.Fatalf("forme A : %+v", r)
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.mode = "keep"
|
||||
f.mu.Unlock()
|
||||
if r := tplProbeEnsure(context.Background(), b); r.PrefixStable != tplYes {
|
||||
t.Fatalf("forme B : %+v", r)
|
||||
}
|
||||
if r, ok := tplCapsCurrent(); !ok || r.PrefixStable != tplYes {
|
||||
t.Errorf("affichage : la dernière sondée, B : %+v", r)
|
||||
}
|
||||
if r, ok := tplCapsFor(a); !ok || r.PrefixStable != tplNo {
|
||||
t.Errorf("forme A écrasée par B : %+v %v", r, ok)
|
||||
}
|
||||
if _, ok := tplCapsFor(newTplShape(probeTools(), map[string]any{"enable_thinking": false}, "")); ok {
|
||||
t.Error("verdict pour une forme jamais sondée")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package loki
|
||||
|
||||
// web_images : la recherche d'images DuckDuckGo, pendant de web_search. Sans
|
||||
// elle, « montre-moi des photos de X » obligeait le modèle à ouvrir des pages
|
||||
// dans le navigateur et à en renvoyer des captures (lent, et des images de
|
||||
// pages au lieu de photos).
|
||||
//
|
||||
// DuckDuckGo sert les images par i.js, qui exige un jeton « vqd » lu dans la
|
||||
// page de recherche. Même client HTTP que le moteur intégré, quel que soit le
|
||||
// moteur web actif : Crawl4AI n'apporterait rien à un appel JSON.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func webImagesTool() Tool {
|
||||
return Tool{Type: "function", Function: ToolFunction{
|
||||
// Schéma minimal : le préambule a un budget (chat_prompt_size_test.go).
|
||||
// limit n'est pas annoncé ; toolWebImages l'accepte encore (6 par défaut).
|
||||
Name: "web_images",
|
||||
Description: "Find images.",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"query": map[string]any{"type": "string"},
|
||||
},
|
||||
"required": []string{"query"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
type imageResult struct {
|
||||
Image string `json:"image"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
}
|
||||
|
||||
var ddgVqdRe = regexp.MustCompile(`vqd=["']?([0-9-]+)`)
|
||||
|
||||
func ddgGet(ctx context.Context, target, referer string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", goFetchUA)
|
||||
req.Header.Set("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")
|
||||
if referer != "" {
|
||||
req.Header.Set("Referer", referer)
|
||||
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
|
||||
}
|
||||
resp, err := goHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("échec de la requête : %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(io.LimitReader(resp.Body, goFetchMaxBytes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func duckduckgoImages(query string, limit int) ([]imageResult, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), goFetchTimeout)
|
||||
defer cancel()
|
||||
page := "https://duckduckgo.com/?q=" + url.QueryEscape(query) + "&iax=images&ia=images"
|
||||
body, err := ddgGet(ctx, page, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := ddgVqdRe.FindSubmatch(body)
|
||||
if m == nil {
|
||||
return nil, fmt.Errorf("jeton DuckDuckGo introuvable (défi anti-bot ?)")
|
||||
}
|
||||
api := "https://duckduckgo.com/i.js?l=fr-fr&o=json&f=,,,,,&p=1&q=" + url.QueryEscape(query) + "&vqd=" + string(m[1])
|
||||
body, err = ddgGet(ctx, api, "https://duckduckgo.com/")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Results []imageResult `json:"results"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return nil, fmt.Errorf("réponse illisible : %v", err)
|
||||
}
|
||||
var out []imageResult
|
||||
seen := map[string]bool{}
|
||||
for _, it := range r.Results {
|
||||
if it.Image == "" || seen[it.Image] || !strings.HasPrefix(it.Image, "http") {
|
||||
continue
|
||||
}
|
||||
seen[it.Image] = true
|
||||
out = append(out, it)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func toolWebImages(args map[string]any) string {
|
||||
query, _ := args["query"].(string)
|
||||
limit := 6
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
if limit < 1 {
|
||||
limit = 1
|
||||
}
|
||||
if limit > 20 {
|
||||
limit = 20
|
||||
}
|
||||
results, err := duckduckgoImages(query, limit)
|
||||
if err != nil {
|
||||
return "❌ Recherche d'images échouée : " + err.Error()
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return fmt.Sprintf("Aucune image pour « %s »", query)
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Images : %s\n%d résultat(s) DuckDuckGo\n\n", query, len(results))
|
||||
for i, r := range results {
|
||||
fmt.Fprintf(&b, "%d. %s (%dx%d)\n image : %s\n page : %s\n\n", i+1, r.Title, r.Width, r.Height, r.Image, r.URL)
|
||||
}
|
||||
b.WriteString("To show an image to the user, write  in your answer.")
|
||||
return b.String()
|
||||
}
|
||||
@@ -177,13 +177,10 @@ func (c *Conversation) runBuilderTurn(ctx context.Context, caps Caps, temperatur
|
||||
}
|
||||
if ev.NewHistory != nil {
|
||||
// Préfixe système injecté retiré, comme dans generate : il n'appartient
|
||||
// pas à l'historique persisté. Ignorer cet événement laissait la
|
||||
// compaction perdue et c.Messages incohérent (fil complet + tour).
|
||||
base := ev.NewHistory
|
||||
for len(base) > 0 && base[0].Role == "system" {
|
||||
base = base[1:]
|
||||
}
|
||||
newBase = append([]Message(nil), base...)
|
||||
// pas à l'historique persisté (le rappel des pages lues, si). Ignorer
|
||||
// cet événement laissait la compaction perdue et c.Messages incohérent
|
||||
// (fil complet + tour).
|
||||
newBase = historyFromView(ev.NewHistory, msgs)
|
||||
}
|
||||
if ev.Echo != nil {
|
||||
echo = ev.Echo
|
||||
|
||||
@@ -225,6 +225,7 @@ func cdpLaunch() (*cdpSession, error) {
|
||||
args = append([]string{"--headless=new"}, args...)
|
||||
}
|
||||
cmd := exec.Command(bin, args...)
|
||||
prepareBoundChild(cmd) // tué avec Loki, même brutalement (sys_childbind_*.go)
|
||||
if err := cmd.Start(); err != nil {
|
||||
os.RemoveAll(userDir)
|
||||
return nil, err
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package loki
|
||||
|
||||
// cu_autoshot.go — aperçu EN DIRECT du navigateur piloté (computer use). Après
|
||||
// chaque action browser_*, on capture la page pour que l'UTILISATEUR voie ce que
|
||||
// fait l'IA. C'est de l'interface seulement :
|
||||
// - jamais envoyé au modèle (zéro token, vision non requise) ;
|
||||
// - jamais écrit sur le disque : un petit anneau en mémoire vive ;
|
||||
// - retiré du journal en fin de tour (compactLogLocked), et l'UI efface la carte
|
||||
// à la fin du tour. Une capture à GARDER passe toujours par
|
||||
// browser_screenshot, qui l'enregistre dans le dossier de travail.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
_ "image/png"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const cuShotRing = 8 // captures gardées en RAM (l'UI n'affiche que la dernière)
|
||||
|
||||
var (
|
||||
cuShotMu sync.Mutex
|
||||
cuShotData = map[string][]byte{}
|
||||
cuShotIDs []string
|
||||
)
|
||||
|
||||
// cuAutoShotTool : outils après lesquels on capture (ceux qui changent la page).
|
||||
func cuAutoShotTool(name string) bool {
|
||||
switch name {
|
||||
case "browser_open", "browser_click", "browser_click_xy", "browser_type", "browser_key", "browser_scroll", "browser_find":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func cuLog(format string, a ...any) { fmt.Fprintf(os.Stderr, "[cu] "+format+"\n", a...) }
|
||||
|
||||
// cuAutoShot capture la page en pleine résolution (JPEG de bonne qualité) et
|
||||
// renvoie l'identifiant de la capture en mémoire ("" si impossible : jamais
|
||||
// bloquant pour l'outil).
|
||||
func cuAutoShot() string {
|
||||
s, err := cdpGet()
|
||||
if err != nil {
|
||||
cuLog("aperçu : navigateur indisponible : %v", err)
|
||||
return ""
|
||||
}
|
||||
raw, err := s.screenshot()
|
||||
if err != nil {
|
||||
cuLog("aperçu : capture impossible : %v", err)
|
||||
return ""
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
cuLog("aperçu : image illisible (%d octets) : %v", len(raw), err)
|
||||
return ""
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil {
|
||||
return ""
|
||||
}
|
||||
id := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||
cuShotMu.Lock()
|
||||
cuShotData[id] = buf.Bytes()
|
||||
cuShotIDs = append(cuShotIDs, id)
|
||||
for len(cuShotIDs) > cuShotRing {
|
||||
delete(cuShotData, cuShotIDs[0])
|
||||
cuShotIDs = cuShotIDs[1:]
|
||||
}
|
||||
cuShotMu.Unlock()
|
||||
return id
|
||||
}
|
||||
|
||||
// handleCUShot (GET ?id=) : une capture en base64 (JSON, pour traverser aussi le
|
||||
// tunnel chiffré du relais). 404 si elle est déjà sortie de l'anneau.
|
||||
func handleCUShot(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
cuShotMu.Lock()
|
||||
b := cuShotData[id]
|
||||
cuShotMu.Unlock()
|
||||
if b == nil {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "capture expirée"})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "mime": "image/jpeg", "data": base64.StdEncoding.EncodeToString(b)})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package loki
|
||||
|
||||
import "testing"
|
||||
|
||||
// L'aperçu du navigateur (shot) n'est que du direct : la compaction de fin de
|
||||
// tour le retire du journal, le reste de l'événement d'outil est gardé.
|
||||
func TestCompactLogRetireLAperçuNavigateur(t *testing.T) {
|
||||
log := []LogEvent{
|
||||
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": false}}},
|
||||
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "browser_click", "done": true, "result": "ok", "shot": "abc"}}},
|
||||
}
|
||||
out := compactLog(log)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("un seul événement d'outil attendu, %d", len(out))
|
||||
}
|
||||
tu := out[0].Delta["tool_used"].(map[string]any)
|
||||
if _, has := tu["shot"]; has {
|
||||
t.Fatal("l'aperçu aurait dû quitter le journal")
|
||||
}
|
||||
if tu["result"] != "ok" || tu["name"] != "browser_click" {
|
||||
t.Fatalf("événement abîmé : %v", tu)
|
||||
}
|
||||
if _, has := log[1].Delta["tool_used"].(map[string]any)["shot"]; !has {
|
||||
t.Fatal("le journal d'origine ne doit pas être modifié en place")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCUAutoShotTool(t *testing.T) {
|
||||
for _, n := range []string{"browser_open", "browser_click", "browser_type", "browser_scroll"} {
|
||||
if !cuAutoShotTool(n) {
|
||||
t.Errorf("%s devrait déclencher l'aperçu", n)
|
||||
}
|
||||
}
|
||||
for _, n := range []string{"browser_screenshot", "browser_snapshot", "bash", "web_images"} {
|
||||
if cuAutoShotTool(n) {
|
||||
t.Errorf("%s ne devrait pas déclencher l'aperçu", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package loki
|
||||
|
||||
// engine_cmdline.go — ce que l'interface montre du dernier lancement du moteur
|
||||
// (repris d'AJEAN, #108 et #43) :
|
||||
//
|
||||
// /api/engine/cmdline la commande exacte lancée par `loki serve` (variables
|
||||
// d'environnement posées par Loki comprises), copiable
|
||||
// pour la vérifier ou la partager ; clé API masquée
|
||||
// /api/model/layers le nombre de couches du modèle, lu dans l'en-tête
|
||||
// GGUF : la valeur de NGL qui met tout sur le GPU
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// engineCmdlineKey : dernière commande du moteur (bkState), lue par l'UI.
|
||||
const engineCmdlineKey = "engine_cmdline"
|
||||
|
||||
// engineCmdline met les arguments sous forme copiable dans un terminal :
|
||||
// variables d'environnement en tête (KEY=VAL, triées), guillemets autour de ce
|
||||
// qui contient un espace, clé API masquée.
|
||||
func engineCmdline(env map[string]string, args []string) string {
|
||||
quote := func(a string) string {
|
||||
if a == "" || strings.ContainsAny(a, " \t\"'") {
|
||||
return `"` + strings.ReplaceAll(a, `"`, `\"`) + `"`
|
||||
}
|
||||
return a
|
||||
}
|
||||
var out []string
|
||||
keys := make([]string, 0, len(env))
|
||||
for k := range env {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
out = append(out, k+"="+quote(env[k]))
|
||||
}
|
||||
for i, a := range args {
|
||||
if i > 0 && (args[i-1] == "--api-key" || args[i-1] == "--api-key-file") {
|
||||
a = "<clé masquée>"
|
||||
}
|
||||
out = append(out, quote(a))
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// recordEngineCmdline garde la commande du lancement en cours. Jamais pour un
|
||||
// essai de l'optimiseur : il écraserait celle du vrai moteur.
|
||||
func recordEngineCmdline(env map[string]string, args []string) {
|
||||
_ = putBytes(bkState, engineCmdlineKey, []byte(engineCmdline(env, args)))
|
||||
}
|
||||
|
||||
// handleEngineCmdline (GET) : la commande du dernier lancement du moteur local.
|
||||
func handleEngineCmdline(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "cmdline": string(getBytes(bkState, engineCmdlineKey))})
|
||||
}
|
||||
|
||||
// handleModelLayers (GET ?model=…, sinon MODEL de la config) : nombre de blocs
|
||||
// du modèle. llama.cpp en offloade block_count + 1 (la couche de sortie en
|
||||
// plus) : c'est la valeur de -ngl qui met tout le modèle sur le GPU.
|
||||
func handleModelLayers(w http.ResponseWriter, r *http.Request) {
|
||||
model := strings.TrimSpace(r.URL.Query().Get("model"))
|
||||
if model == "" {
|
||||
model = strings.TrimSpace(ReadConfig()["MODEL"])
|
||||
}
|
||||
if model == "" {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": "aucun modèle"})
|
||||
return
|
||||
}
|
||||
path, err := resolveServeModelPath(model)
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
info, err := ggufMeta(path)
|
||||
if err != nil || info.BlockCount <= 0 {
|
||||
msg := "nombre de couches absent de l'en-tête"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": msg})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "layers": info.BlockCount, "ngl_max": info.BlockCount + 1})
|
||||
}
|
||||
@@ -828,6 +828,12 @@ func runBench(ctx context.Context, opts benchOpts, progress benchProgress) (*ben
|
||||
if externalActive() {
|
||||
return nil, fmt.Errorf("benchmark indisponible : le preset actif est une API externe")
|
||||
}
|
||||
// Le protocole s'appuie sur /tokenize et sur la reprise du cache de
|
||||
// llama-server ; Strata n'a pas le premier et garde sa conversation en cache
|
||||
// malgré cache_prompt:false (la lecture serait comptée deux fois trop vite).
|
||||
if strataActive() {
|
||||
return nil, fmt.Errorf("benchmark indisponible : le preset actif tourne sur Strata (le débit s'affiche sous chaque réponse)")
|
||||
}
|
||||
if err := tuneGuard(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -233,6 +233,11 @@ type Caps struct {
|
||||
// ComputerUse = pilotage du navigateur du conteneur (outils browser_*).
|
||||
// Requiert aussi Agent (mêmes actions réelles que bash). Voir computer_use.go.
|
||||
ComputerUse bool
|
||||
// envInCtx : la date et le dossier de travail partent dans le bloc projet
|
||||
// figé (PROJ_SNAPSHOT, chat_envctx.go), pas dans le système. Posé par le seul
|
||||
// assemblage d'un tour de discussion avec la clé ; jamais pour une tâche, un
|
||||
// sous-agent ou la vérification, qui gardent le système d'avant.
|
||||
envInCtx bool
|
||||
}
|
||||
|
||||
// globalCaps reads the machine-wide config — the default when a request doesn't
|
||||
@@ -384,9 +389,9 @@ func normalizeSystemMessages(msgs []Message) []Message {
|
||||
|
||||
// isProjectSystem : message système propre au projet actif (description, index
|
||||
// mémoire, trackers) ou à la conversation (rappel des pages mémoire lues, posé
|
||||
// au compactage), à sortir du bloc système commun (voir
|
||||
// normalizeSystemMessages) : ce bloc doit rester identique partout pour rester
|
||||
// en cache.
|
||||
// au compactage ; date et dossier de travail avec PROJ_SNAPSHOT), à sortir
|
||||
// du bloc système commun (voir normalizeSystemMessages) : ce bloc doit rester
|
||||
// identique partout pour rester en cache.
|
||||
func isProjectSystem(m Message) bool {
|
||||
s, ok := m.Content.(string)
|
||||
if !ok || m.Role != "system" {
|
||||
@@ -396,7 +401,8 @@ func isProjectSystem(m Message) bool {
|
||||
strings.HasPrefix(s, memIndexPrefix) ||
|
||||
strings.HasPrefix(s, trackerIndexPrefix) ||
|
||||
strings.HasPrefix(s, memReminderPrefix) ||
|
||||
strings.HasPrefix(s, codeInstructionsPrefix)
|
||||
strings.HasPrefix(s, codeInstructionsPrefix) ||
|
||||
strings.HasPrefix(s, envContextPrefix)
|
||||
}
|
||||
|
||||
// prependToFirstUser place ctx en tête du premier message user de msgs (modifié
|
||||
@@ -488,7 +494,7 @@ func EnabledTools(caps Caps) []Tool {
|
||||
// caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
|
||||
// donc prompt et outils restent cohérents — pas de web_search halluciné.
|
||||
if caps.Agent && caps.Internet {
|
||||
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
|
||||
tools = append(tools, webSearchTool(), webImagesTool(), 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
|
||||
@@ -596,6 +602,10 @@ type ToolUsedEvent struct {
|
||||
// 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
|
||||
// Shot : identifiant de la capture du navigateur prise après une action
|
||||
// browser_* (cu_autoshot.go). Aperçu EN DIRECT seulement : gardé en RAM,
|
||||
// jamais envoyé au modèle, retiré du journal en fin de tour.
|
||||
Shot string
|
||||
// ResultChars : taille RÉELLE du résultat (en runes), même quand Result n'en
|
||||
// porte qu'un APERÇU. Sert au compteur « ~N tok » de la bulle.
|
||||
ResultChars int
|
||||
@@ -1059,6 +1069,12 @@ func friendlyLLMError(err error) error {
|
||||
}
|
||||
|
||||
func errEngineDown() error {
|
||||
// Un refus de connexion ne veut pas toujours dire « il charge encore » : si
|
||||
// le journal montre que le dernier lancement a échoué, c'est cela qu'il faut
|
||||
// dire — réessayer dans quelques secondes n'y changera rien.
|
||||
if msg := modelLoadError(); msg != "" {
|
||||
return fmt.Errorf("⚠️ %s", msg)
|
||||
}
|
||||
return fmt.Errorf("⚠️ Le moteur (llama-server) ne répond pas sur le port %d. Il est probablement en train de démarrer ou de charger le modèle — réessaie dans quelques secondes.", LLMPort())
|
||||
}
|
||||
|
||||
@@ -1097,7 +1113,7 @@ func isNetTimeout(err error) bool {
|
||||
func toolCallLabel(name string, args map[string]any) string {
|
||||
label := ""
|
||||
switch name {
|
||||
case "mem_search", "web_search", "recall_search":
|
||||
case "mem_search", "web_search", "web_images", "recall_search":
|
||||
label, _ = args["query"].(string)
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
|
||||
label, _ = args["file"].(string)
|
||||
@@ -1411,8 +1427,10 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
// NUDGE_IN_TOOL (opt-in, gabarits dont le rendu bouge quand un message
|
||||
// user s'ajoute) : au bout du dernier résultat d'outil. Sinon, ou si
|
||||
// la forme ne s'y prête pas, un message à part comme toujours.
|
||||
// La sonde ne connaît que la forme normale (outils annoncés, ni coupés
|
||||
// ni neutralisés) : une relance d'une autre forme se replie.
|
||||
placed := false
|
||||
if nudgeInToolOn(chatCfg, ep) {
|
||||
if !disableTools && !toolChoiceNone && nudgeInToolOn(chatCfg, ep, tools, reasoningKwargs, reasoningEffort) {
|
||||
messages, extra, placed = nudgeIntoTool(messages, extra, msg)
|
||||
}
|
||||
if !placed {
|
||||
@@ -1868,7 +1886,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
if cur := toolCalls[0]; cur != nil {
|
||||
key := "command"
|
||||
switch cur.Function.Name {
|
||||
case "mem_search", "web_search", "recall_search":
|
||||
case "mem_search", "web_search", "web_images", "recall_search":
|
||||
key = "query"
|
||||
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
|
||||
key = "file"
|
||||
@@ -2486,6 +2504,8 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
}
|
||||
case "web_search":
|
||||
result = capWebOutput(toolWebSearch(args))
|
||||
case "web_images":
|
||||
result = capWebOutput(toolWebImages(args))
|
||||
case "web_open":
|
||||
result = capWebOutput(toolWebOpen(args))
|
||||
case "web_read":
|
||||
@@ -2527,8 +2547,14 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
result = "[erreur] outil inconnu: " + tc.Function.Name
|
||||
}
|
||||
}
|
||||
// Aperçu en direct du navigateur piloté (repris d'AJEAN) : une
|
||||
// capture après chaque action qui change la page, pour l'UI seule.
|
||||
liveShot := ""
|
||||
if !strings.HasPrefix(result, "[erreur]") {
|
||||
doneCalls[callKey] = result
|
||||
if cuAutoShotTool(tc.Function.Name) {
|
||||
liveShot = cuAutoShot()
|
||||
}
|
||||
}
|
||||
// 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.
|
||||
@@ -2536,7 +2562,7 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools
|
||||
if tc.Function.Name == "web_screenshot" {
|
||||
shot = capturedRelPath(result)
|
||||
}
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot}, result)})
|
||||
cb(StreamEvent{ToolUsed: fillToolResult(&ToolUsedEvent{Name: tc.Function.Name, Label: label, Done: true, Diff: diff, Added: diffAdd, Removed: diffDel, Image: shot, Shot: liveShot}, result)})
|
||||
// Plafond pour le MODÈLE seulement : l'UI vient de recevoir le
|
||||
// résultat complet (aperçu + « voir plus »).
|
||||
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: capToolResult(result)}
|
||||
|
||||
@@ -174,9 +174,11 @@ func withTrailingHint(msgs []Message, hint string) []Message {
|
||||
// l'opt-in, à n'activer qu'après avoir comparé l'obéissance aux rappels.
|
||||
//
|
||||
// Moteur local seulement, et seulement si la sonde de gabarit
|
||||
// (chat_tplprobe.go) a conclu « préfixe instable » pour CE modèle : « inconnu »
|
||||
// ou un autre modèle laissent le message à part.
|
||||
func nudgeInToolOn(cfg map[string]string, ep chatEndpoint) bool {
|
||||
// (chat_tplprobe.go) a conclu « préfixe instable » pour CE modèle et pour la
|
||||
// forme de CETTE requête (outils, chat_template_kwargs, reasoning_effort : le
|
||||
// rendu en dépend). « inconnu », un autre modèle ou une forme jamais sondée
|
||||
// laissent le message à part.
|
||||
func nudgeInToolOn(cfg map[string]string, ep chatEndpoint, tools []Tool, kwargs map[string]any, effort string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(cfg["NUDGE_IN_TOOL"])) {
|
||||
case "on", "1", "true", "yes", "oui":
|
||||
default:
|
||||
@@ -185,7 +187,7 @@ func nudgeInToolOn(cfg map[string]string, ep chatEndpoint) bool {
|
||||
if ep.External {
|
||||
return false
|
||||
}
|
||||
r, ok := tplCapsCurrent()
|
||||
r, ok := tplCapsFor(newTplShape(tools, kwargs, effort))
|
||||
return ok && r.PrefixStable == tplNo && r.Model != "" && r.Model == reasoningEchoModel(cfg)
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,11 @@ func oaiHandler() http.Handler {
|
||||
Rewrite: func(pr *httputil.ProxyRequest) {
|
||||
pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())})
|
||||
pr.Out.Host = pr.In.Host
|
||||
// Strata refuse (403) un Host qu'il ne connaît pas (protection contre
|
||||
// le DNS rebinding) : il se voit appelé sous son propre nom.
|
||||
if strataActive() {
|
||||
pr.Out.Host = pr.Out.URL.Host
|
||||
}
|
||||
},
|
||||
ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) {
|
||||
sendOAIError(w, http.StatusBadGateway,
|
||||
|
||||
+123
-17
@@ -259,52 +259,158 @@ func DisableMemEncryption() error {
|
||||
// page, et on ne baisse le drapeau qu'à la toute fin. Ainsi une interruption
|
||||
// laisse un état parfaitement lisible et RE-JOUABLE (le drapeau est encore
|
||||
// levé, donc DisableMemEncryption peut être relancé).
|
||||
if err := decryptAllPages(); err != nil {
|
||||
return finishDecryption()
|
||||
}
|
||||
|
||||
// finishDecryption : fichiers (pages, images des conversations), puis
|
||||
// conversations, puis retrait de la clé, dans cet ordre et seulement si tout ce
|
||||
// qui précède a réussi. Sûr à rejouer. Partagé avec la reprise au démarrage, qui
|
||||
// retirait la clé après les seules pages : les conversations restaient
|
||||
// chiffrées sans plus aucun moyen de les rouvrir (amont cac0cda).
|
||||
func finishDecryption() error {
|
||||
bad, err := decryptAllPagesSkipping()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decryptChatStores(); err != nil {
|
||||
badKV, err := decryptChatStoresSkipping()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Toutes les pages sont en clair et vérifiées : on peut baisser le drapeau,
|
||||
// retirer le keyvault et purger la DEK.
|
||||
bad = append(bad, badKV...)
|
||||
if len(bad) > 0 {
|
||||
// Valeurs illisibles avec la clé actuelle : déjà inaccessibles avant ce
|
||||
// déchiffrement. On les met en quarantaine (toujours chiffrées) avec une
|
||||
// copie du keyvault, pour pouvoir encore les rouvrir plus tard, puis on
|
||||
// les retire de la base pour que le reste fonctionne en clair.
|
||||
if err := quarantineUnreadable(bad); err != nil {
|
||||
return fmt.Errorf("mise en quarantaine des valeurs illisibles impossible, déchiffrement interrompu (rien n'est perdu, il peut être relancé) : %w", err)
|
||||
}
|
||||
for _, u := range bad {
|
||||
if u.Bucket != "fichier" { // un fichier reste en place, sa copie est en quarantaine
|
||||
_ = putBytes(u.Bucket, u.Key, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tout est en clair et vérifié : on peut baisser le drapeau, retirer le
|
||||
// keyvault et purger la DEK.
|
||||
if err := SetConfigKey("MEM_ENCRYPTED", ""); err != nil {
|
||||
return err
|
||||
}
|
||||
removeVault()
|
||||
clearMemDEK()
|
||||
clearMigrationJournal()
|
||||
if n := scrubEncryptedResidue(); n > 0 {
|
||||
fmt.Fprintf(os.Stderr, "[mémoire] %d copie(s) .bak chiffrée(s), illisibles sans la clé, retirée(s)\n", n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptAllPages réécrit en clair toutes les pages encore chiffrées, DANS TOUS LES
|
||||
// PROJETS, en vérifiant chacune. Exige la DEK en RAM. Sûr à rejouer (idempotent).
|
||||
func decryptAllPages() error {
|
||||
for _, p := range allMemPageFiles() {
|
||||
// decryptAllPagesSkipping réécrit en clair tous les fichiers encore chiffrés de
|
||||
// la mémoire (tous projets) et des images de conversation, en vérifiant chacun.
|
||||
// Exige la DEK en RAM. Sûr à rejouer. Un fichier indéchiffrable est laissé tel
|
||||
// quel et renvoyé au lieu de tout arrêter.
|
||||
func decryptAllPagesSkipping() ([]unreadableValue, error) {
|
||||
var bad []unreadableValue
|
||||
for _, p := range allMemFilesForDecrypt() {
|
||||
raw, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lecture %s : %w", p, err)
|
||||
return bad, fmt.Errorf("lecture %s : %w", p, err)
|
||||
}
|
||||
if !looksEncrypted(raw) {
|
||||
continue // déjà en clair
|
||||
}
|
||||
plain, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("déchiffrement %s impossible, on n'écrase rien : %w", p, err)
|
||||
bad = append(bad, unreadableValue{Bucket: "fichier", Key: p, Blob: raw, Error: err.Error()})
|
||||
continue // on n'écrase rien
|
||||
}
|
||||
if old, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(p+".bak", old, 0o600)
|
||||
}
|
||||
if err := memWriteFileVerified(p, plain, 0o600); err != nil {
|
||||
return fmt.Errorf("écriture claire %s : %w", p, err)
|
||||
return bad, fmt.Errorf("écriture claire %s : %w", p, err)
|
||||
}
|
||||
back, err := os.ReadFile(p)
|
||||
if err != nil || looksEncrypted(back) || string(back) != string(plain) {
|
||||
return fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
|
||||
return bad, fmt.Errorf("vérification post-déchiffrement de %s échouée", p)
|
||||
}
|
||||
}
|
||||
return bad, nil
|
||||
}
|
||||
|
||||
// allMemFilesForDecrypt : TOUS les fichiers de la mémoire, sous-dossiers compris,
|
||||
// et les images de conversation (chatimg/, chiffrées comme le fil), sauf les
|
||||
// copies .bak et les écritures en cours (.tmp). Ne déchiffrer que les pages .md
|
||||
// laissait le reste chiffré sans plus aucune clé une fois le keyvault retiré.
|
||||
func allMemFilesForDecrypt() []string {
|
||||
var out []string
|
||||
for _, root := range []string{projectsRoot(), chatImgDir()} {
|
||||
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || strings.HasSuffix(d.Name(), ".bak") || strings.HasSuffix(d.Name(), ".tmp") {
|
||||
return nil
|
||||
}
|
||||
out = append(out, p)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// quarantineUnreadable écrit les valeurs indéchiffrables, encore chiffrées, dans
|
||||
// un dossier daté de LOKI_HOME, avec une copie du keyvault à côté : elles
|
||||
// restent récupérables si l'ancienne clé est retrouvée.
|
||||
func quarantineUnreadable(bad []unreadableValue) error {
|
||||
dir := filepath.Join(LokiHome(), "chiffre-illisible-"+time.Now().Format("20060102-150405"))
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := json.MarshalIndent(bad, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := memWriteFileAtomic(filepath.Join(dir, "valeurs.json"), raw, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, p := range []string{vaultPathPrimary(), vaultPathBackup()} {
|
||||
if b, err := os.ReadFile(p); err == nil {
|
||||
_ = memWriteFileAtomic(filepath.Join(dir, filepath.Base(p)), b, 0o600)
|
||||
}
|
||||
}
|
||||
if b := getBytes(bkState, vaultDBKey); len(b) > 0 {
|
||||
_ = memWriteFileAtomic(filepath.Join(dir, "keyvault-base.json"), b, 0o600)
|
||||
}
|
||||
for _, u := range bad {
|
||||
fmt.Fprintf(os.Stderr, "[mémoire] illisible avec la clé actuelle, mis en quarantaine : %s / %s (%s)\n", u.Bucket, u.Key, u.Error)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[mémoire] %d valeur(s) en quarantaine dans %s\n", len(bad), dir)
|
||||
return nil
|
||||
}
|
||||
|
||||
// scrubEncryptedResidue retire les .bak encore CHIFFRÉS de la mémoire une fois
|
||||
// le chiffrement désactivé : la clé est partie avec le keyvault, ils ne pourront
|
||||
// plus jamais être lus, et ce sont des copies de pages qui viennent d'être
|
||||
// remises en clair. Pendant symétrique de scrubPlaintextResidue. Les valeurs en
|
||||
// quarantaine et les snapshots vivent hors de memory/ : jamais touchés. Rien
|
||||
// n'est fait tant que le chiffrement est actif.
|
||||
func scrubEncryptedResidue() int {
|
||||
if memEncActive() {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, root := range []string{projectsRoot(), chatImgDir()} {
|
||||
_ = filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
|
||||
return nil
|
||||
}
|
||||
if b, err := os.ReadFile(p); err == nil && looksEncrypted(b) && os.Remove(p) == nil {
|
||||
n++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// resumeMemMigration reprend une migration interrompue au démarrage. Pour une
|
||||
// migration "encrypt", si la DEK est déjà en RAM (déverrouillée), on rejoue le
|
||||
// (re)chiffrement des pages claires ; sinon on laisse le journal en place pour un
|
||||
@@ -328,12 +434,12 @@ func resumeMemMigration() {
|
||||
// encore levé, on termine proprement (écrit le clair restant), puis on
|
||||
// baisse le drapeau. Sans DEK, on laisse le journal : l'état reste lisible
|
||||
// et l'écran de santé signalera le mélange.
|
||||
// Même chemin complet que DisableMemEncryption : la reprise retirait la clé
|
||||
// après les seules pages, en laissant les conversations chiffrées sans
|
||||
// plus aucun moyen de les rouvrir.
|
||||
if memEncActive() && memUnlocked() {
|
||||
if err := decryptAllPages(); err == nil {
|
||||
_ = SetConfigKey("MEM_ENCRYPTED", "")
|
||||
removeVault()
|
||||
clearMemDEK()
|
||||
clearMigrationJournal()
|
||||
if err := finishDecryption(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mémoire] reprise du déchiffrement : %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -261,3 +262,84 @@ func TestChiffrementGardeLesPointeursLisibles(t *testing.T) {
|
||||
t.Fatalf("pointeur chiffré non réparé : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Une seule valeur indéchiffrable bloquait toute la désactivation, et les images
|
||||
// de conversation (chatimg/) restaient chiffrées sans clé une fois le keyvault
|
||||
// retiré. Désormais : tout ce qui se lit est déchiffré, l'illisible part en
|
||||
// quarantaine, et la désactivation aboutit.
|
||||
func TestDisableMemEncryptionSurvitAuxValeursIllisibles(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pass-de-test-1234"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img := []byte("\x89PNG octets d'image de test")
|
||||
ref, err := storeChatImage(img, "image/png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
imgPath := filepath.Join(chatImgDir(), strings.TrimPrefix(ref, imgRefScheme))
|
||||
if raw, _ := os.ReadFile(imgPath); !looksEncrypted(raw) {
|
||||
t.Fatal("l'image devrait être chiffrée")
|
||||
}
|
||||
other := make([]byte, 32)
|
||||
other[0] = 7
|
||||
alien, err := encPage(other, []byte("conversation chiffrée avec une autre clé"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := putBytes(bkChat, "vieille", alien); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := DisableMemEncryption(); err != nil {
|
||||
t.Fatalf("la désactivation aurait dû aboutir : %v", err)
|
||||
}
|
||||
if memEncActive() {
|
||||
t.Fatal("le chiffrement devrait être désactivé")
|
||||
}
|
||||
if b, _ := os.ReadFile(imgPath); string(b) != string(img) {
|
||||
t.Fatalf("image pas déchiffrée : %q", b)
|
||||
}
|
||||
if v := getBytes(bkChat, "vieille"); len(v) != 0 {
|
||||
t.Fatal("la valeur illisible aurait dû quitter la base")
|
||||
}
|
||||
q, _ := filepath.Glob(filepath.Join(LokiHome(), "chiffre-illisible-*", "valeurs.json"))
|
||||
if len(q) != 1 {
|
||||
t.Fatalf("quarantaine attendue, trouvée : %v", q)
|
||||
}
|
||||
if qb, _ := os.ReadFile(q[0]); !strings.Contains(string(qb), "vieille") {
|
||||
t.Fatal("la quarantaine doit contenir la valeur illisible")
|
||||
}
|
||||
if baks, _ := filepath.Glob(filepath.Join(chatImgDir(), "*.bak")); len(baks) != 0 {
|
||||
t.Fatalf(".bak chiffrés restés derrière : %v", baks)
|
||||
}
|
||||
}
|
||||
|
||||
// Déchiffrement interrompu puis repris au démarrage : la reprise retirait la clé
|
||||
// après les seules pages, et les conversations restaient chiffrées pour toujours.
|
||||
func TestResumeInterruptedDecryptDechiffreLesConversations(t *testing.T) {
|
||||
testHome(t)
|
||||
clearMemDEK()
|
||||
seedPages(t)
|
||||
if _, err := EnableMemEncryption("pw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enc, err := encodeMemContent([]byte(`{"titre":"conversation"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := putBytes(bkChat, "conv:x", enc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeMigrationJournal("decrypt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resumeMemMigration()
|
||||
if memEncActive() {
|
||||
t.Fatal("la reprise aurait dû terminer le déchiffrement")
|
||||
}
|
||||
if v := getBytes(bkChat, "conv:x"); string(v) != `{"titre":"conversation"}` {
|
||||
t.Fatalf("conversation restée chiffrée après la reprise : %q", v)
|
||||
}
|
||||
}
|
||||
+29
-10
@@ -186,20 +186,38 @@ func reencryptChatStores() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// decryptChatStores remet en clair les buckets de conversation. Exige la DEK.
|
||||
func decryptChatStores() error {
|
||||
// unreadableValue : une valeur chiffrée qui ne se déchiffre pas avec la clé
|
||||
// actuelle (écrite avec une autre clé, ou abîmée). Gardée telle quelle.
|
||||
type unreadableValue struct {
|
||||
Bucket string `json:"bucket"`
|
||||
Key string `json:"key"`
|
||||
Blob []byte `json:"blob"` // base64 en JSON
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// decryptChatStoresSkipping remet en clair les buckets de conversation (exige
|
||||
// la DEK) : déchiffre tout ce qui peut l'être et RENVOIE ce qui
|
||||
// ne le peut pas, au lieu de s'arrêter au premier échec : une seule valeur
|
||||
// illisible bloquait toute la désactivation du chiffrement, sans dire laquelle
|
||||
// (amont, 2026-10-03).
|
||||
func decryptChatStoresSkipping() ([]unreadableValue, error) {
|
||||
persistQ.flush() // même raison que reencryptChatStores
|
||||
var bad []unreadableValue
|
||||
for _, b := range encryptedBuckets {
|
||||
if err := decryptBucket(b); err != nil {
|
||||
return err
|
||||
u, err := decryptBucket(b)
|
||||
if err != nil {
|
||||
return bad, err
|
||||
}
|
||||
bad = append(bad, u...)
|
||||
}
|
||||
return nil
|
||||
return bad, nil
|
||||
}
|
||||
|
||||
// decryptBucket remet en clair toutes les valeurs chiffrées d'un bucket. Exige
|
||||
// la DEK en RAM. Sûr à rejouer.
|
||||
func decryptBucket(bucket string) error {
|
||||
// la DEK en RAM. Sûr à rejouer. Une valeur indéchiffrable est laissée telle
|
||||
// quelle et renvoyée ; seule une erreur d'écriture est fatale.
|
||||
func decryptBucket(bucket string) ([]unreadableValue, error) {
|
||||
var bad []unreadableValue
|
||||
for k, v := range allKV(bucket) {
|
||||
raw := []byte(v)
|
||||
if !looksEncrypted(raw) {
|
||||
@@ -207,11 +225,12 @@ func decryptBucket(bucket string) error {
|
||||
}
|
||||
plain, err := decodeMemContent(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
bad = append(bad, unreadableValue{Bucket: bucket, Key: k, Blob: raw, Error: err.Error()})
|
||||
continue
|
||||
}
|
||||
if err := putBytes(bucket, k, plain); err != nil {
|
||||
return err
|
||||
return bad, err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return bad, nil
|
||||
}
|
||||
@@ -472,6 +472,9 @@ func newLinkHandler(mux *http.ServeMux) http.Handler {
|
||||
base := lp.Director
|
||||
lp.Director = func(req *http.Request) {
|
||||
base(req)
|
||||
// Le moteur se voit appelé sous son propre nom, pas sous celui du relais :
|
||||
// Strata refuse (403) un Host inconnu (protection DNS rebinding).
|
||||
req.Host = llama.Host
|
||||
// Le client distant n'a pas la clé API de llama-server ; on l'injecte ici
|
||||
// (l'auth réelle est faite par le relais via la clé de liaison du compte).
|
||||
if apiKey != "" {
|
||||
|
||||
+23
-2
@@ -4,13 +4,14 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const Version = "0.14.0"
|
||||
const Version = "0.15.0"
|
||||
|
||||
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
|
||||
func Main() {
|
||||
@@ -202,10 +203,30 @@ Env:
|
||||
func mustExit(err error) {
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[err]", err)
|
||||
os.Exit(1)
|
||||
os.Exit(exitStatusOf(err))
|
||||
}
|
||||
}
|
||||
|
||||
// exitStatusError : une erreur qui choisit son code de sortie. Seul usage :
|
||||
// le refus de LOAD_GUARD, que le superviseur du moteur ne doit pas relancer
|
||||
// (voir loadGuardExitStatus).
|
||||
type exitStatusError struct {
|
||||
code int
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *exitStatusError) Error() string { return e.err.Error() }
|
||||
func (e *exitStatusError) Unwrap() error { return e.err }
|
||||
|
||||
// exitStatusOf : le code choisi par l'erreur, 1 sinon — comme toujours.
|
||||
func exitStatusOf(err error) int {
|
||||
var es *exitStatusError
|
||||
if errors.As(err, &es) {
|
||||
return es.code
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// LokiHome resolves the Loki data directory.
|
||||
// Précédence : $LOKI_HOME → /etc/default/loki (unix) → défaut plateforme.
|
||||
func LokiHome() string {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
// Lier le Chrome du computer use à la vie de Loki (repris d'AJEAN) : le noyau
|
||||
// lui envoie SIGKILL quand Loki meurt, même tué brutalement. Sans ça, un Chrome
|
||||
// orphelin restait dans le conteneur après un redémarrage de l'UI. Ses
|
||||
// sous-processus suivent la mort du processus principal de Chrome.
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func prepareBoundChild(cmd *exec.Cmd) {
|
||||
if cmd.SysProcAttr == nil {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{}
|
||||
}
|
||||
cmd.SysProcAttr.Pdeathsig = syscall.SIGKILL
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package loki
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// Hors Linux, pas d'équivalent simple à Pdeathsig : le navigateur est fermé à
|
||||
// l'arrêt propre de Loki. (AJEAN passe par un job object sous Windows.)
|
||||
func prepareBoundChild(cmd *exec.Cmd) {}
|
||||
@@ -48,6 +48,15 @@ func unitArgs() map[string]string {
|
||||
return map[string]string{serviceName(): "serve", uiUnitName: "web"}
|
||||
}
|
||||
|
||||
// supervisorRestartsRefusal : sous launchd (le LaunchDaemon du moteur, que
|
||||
// launchd désigne par XPC_SERVICE_NAME), KeepAlive/SuccessfulExit=false relance
|
||||
// TOUTE sortie non nulle — aucune exception par code n'existe. Un refus de
|
||||
// LOAD_GUARD y sort donc sans erreur (voir loadGuardRefusal). L'app de bureau
|
||||
// lance le moteur elle-même et ne le relance pas : code normal.
|
||||
func supervisorRestartsRefusal() (bool, string) {
|
||||
return os.Getenv("XPC_SERVICE_NAME") == "com.loki."+serviceName(), ""
|
||||
}
|
||||
|
||||
func installServices(targetUser, lokiHome string) error {
|
||||
exe := installedExePath()
|
||||
for name, arg := range unitArgs() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// sys_install_linux.go — la part systemd de l'installation. Le parcours commun
|
||||
@@ -31,6 +32,12 @@ WorkingDirectory=%s
|
||||
ExecStart=%s
|
||||
Restart=on-failure
|
||||
RestartSec=3
|
||||
# llama-server ignore SIGTERM en pleine génération : sans borne, une bascule de
|
||||
# preset restait bloquée 90 s (défaut systemd) avant le SIGKILL.
|
||||
TimeoutStopSec=5
|
||||
# Lancement refusé d'avance par LOAD_GUARD (le mode de chargement ne tient pas
|
||||
# en RAM) : relancer n'y changerait rien.
|
||||
RestartPreventExitStatus=78
|
||||
|
||||
# Priorité CPU : on remonte le process pour qu'il ne soit pas dépriorisé face
|
||||
# aux tâches de fond (sampling/orchestration côté CPU pèsent sur le débit même
|
||||
@@ -63,6 +70,40 @@ RestartSec=5
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
|
||||
// engineUnitPath : l'unité du moteur telle que « loki install » l'écrit.
|
||||
func engineUnitPath() string { return "/etc/systemd/system/" + serviceName() + ".service" }
|
||||
|
||||
// supervisorRestartsRefusal : « loki serve » tourne-t-il sous une unité
|
||||
// systemd qui le relancerait sur loadGuardExitStatus ? C'est le cas d'une unité
|
||||
// écrite avant que Loki ne pose RestartPreventExitStatus : « loki update » ne
|
||||
// réécrit pas les unités (il tourne sans droits root), seul « loki install »
|
||||
// le fait. Reconnu à coup sûr : systemd pour parent, INVOCATION_ID posé par
|
||||
// systemd, et notre unité lisible sans la directive. Dans le doute, non : le
|
||||
// code de sortie reste celui d'une erreur.
|
||||
func supervisorRestartsRefusal() (bool, string) {
|
||||
if !systemdAvailable() || os.Getenv("INVOCATION_ID") == "" || os.Getppid() != 1 {
|
||||
return false, ""
|
||||
}
|
||||
b, err := os.ReadFile(engineUnitPath())
|
||||
if err != nil {
|
||||
return false, ""
|
||||
}
|
||||
// Les compléments (« systemctl edit ») comptent aussi.
|
||||
unit := string(b)
|
||||
drops, _ := filepath.Glob(engineUnitPath() + ".d/*.conf")
|
||||
for _, p := range drops {
|
||||
if d, err := os.ReadFile(p); err == nil {
|
||||
unit += "\n" + string(d)
|
||||
}
|
||||
}
|
||||
if unitPreventsRefusalRestart(unit) {
|
||||
return false, ""
|
||||
}
|
||||
return true, fmt.Sprintf("unité %s d'une version précédente : sortie sans erreur pour que systemd ne relance pas "+
|
||||
"en boucle — « sudo systemctl edit %s », section [Service], RestartPreventExitStatus=%d (ou « sudo loki install ») "+
|
||||
"la met à jour", serviceName(), serviceName(), loadGuardExitStatus)
|
||||
}
|
||||
|
||||
func installServices(targetUser, lokiHome string) error {
|
||||
svc, exe := serviceName(), installedExePath()
|
||||
units := map[string]string{
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// L'unité du moteur écrite par « loki install » ne relance pas un refus de
|
||||
// LOAD_GUARD : sinon systemd le relançait toutes les trois secondes, sans fin.
|
||||
func TestEngineUnitPreventsRefusalRestart(t *testing.T) {
|
||||
if !unitPreventsRefusalRestart(engineUnitTemplate) {
|
||||
t.Fatal("RestartPreventExitStatus absent de l'unité du moteur")
|
||||
}
|
||||
if !strings.Contains(engineUnitTemplate, "Restart=on-failure") {
|
||||
t.Fatal("les autres échecs doivent toujours être relancés")
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ import (
|
||||
// service itself is managed by the PID-file supervisor in sys_service_windows.go
|
||||
// (loki start / stop / status), which needs no admin rights.
|
||||
|
||||
// supervisorRestartsRefusal : aucun superviseur ne relance le moteur sous
|
||||
// Windows (fichier PID, voir sys_service_windows.go) — code de sortie normal.
|
||||
func supervisorRestartsRefusal() (bool, string) { return false, "" }
|
||||
|
||||
func cmdInstall(args []string) error {
|
||||
lokiHome := LokiHome()
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// procRef : un processus repéré par son PID ET son instant de démarrage — un
|
||||
// PID recyclé pendant l'attente ne passe pas pour le même processus.
|
||||
type procRef struct {
|
||||
pid int
|
||||
start string
|
||||
}
|
||||
|
||||
// procStat : les champs de /proc/<pid>/stat utiles ici.
|
||||
type procStat struct {
|
||||
state string
|
||||
ppid, pgrp, sid int
|
||||
start string
|
||||
}
|
||||
|
||||
// readProcStat lit /proc/<pid>/stat. Le nom (champ 2, entre parenthèses) peut
|
||||
// contenir des blancs et des parenthèses : on repart de la DERNIÈRE fermante.
|
||||
func readProcStat(pid int) (procStat, bool) {
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
if err != nil {
|
||||
return procStat{}, false
|
||||
}
|
||||
return parseProcStat(string(b))
|
||||
}
|
||||
|
||||
func parseProcStat(s string) (procStat, bool) {
|
||||
i := strings.LastIndexByte(s, ')')
|
||||
if i < 0 {
|
||||
return procStat{}, false
|
||||
}
|
||||
// f[0] est le champ 3 (état), f[1] ppid, f[2] groupe, f[3] session, f[19]
|
||||
// l'instant de démarrage (champ 22).
|
||||
f := strings.Fields(s[i+1:])
|
||||
if len(f) < 20 {
|
||||
return procStat{}, false
|
||||
}
|
||||
ppid, _ := strconv.Atoi(f[1])
|
||||
pgrp, _ := strconv.Atoi(f[2])
|
||||
sid, _ := strconv.Atoi(f[3])
|
||||
return procStat{state: f[0], ppid: ppid, pgrp: pgrp, sid: sid, start: f[19]}, true
|
||||
}
|
||||
|
||||
// procTable : tous les processus vivants (les zombies ont fini de tourner : leur
|
||||
// mémoire, VRAM comprise, est déjà rendue).
|
||||
func procTable() map[int]procStat {
|
||||
ents, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
t := make(map[int]procStat, len(ents))
|
||||
for _, e := range ents {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st, ok := readProcStat(pid); ok && st.state != "Z" {
|
||||
t[pid] = st
|
||||
}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// procTreeOf : le chef root et tout ce qui en descend — sa session et son
|
||||
// groupe (Setsid au lancement du service), plus les descendants par la
|
||||
// filiation, pour un enfant qui aurait pris sa propre session (le serveur
|
||||
// Python de Strata lance son moteur dans un processus à part). À relever AVANT
|
||||
// d'envoyer le moindre signal : une fois le chef mort, ses orphelins sont
|
||||
// rattachés à PID 1 et la filiation est perdue.
|
||||
func procTreeOf(t map[int]procStat, root, self int) []procRef {
|
||||
in := map[int]bool{}
|
||||
for pid, st := range t {
|
||||
if pid != self && (pid == root || st.sid == root || st.pgrp == root) {
|
||||
in[pid] = true
|
||||
}
|
||||
}
|
||||
// Fermeture par la filiation, jusqu'à ce que plus rien ne s'ajoute.
|
||||
for grew := true; grew; {
|
||||
grew = false
|
||||
for pid, st := range t {
|
||||
if !in[pid] && pid != self && in[st.ppid] {
|
||||
in[pid] = true
|
||||
grew = true
|
||||
}
|
||||
}
|
||||
}
|
||||
refs := make([]procRef, 0, len(in))
|
||||
for pid := range in {
|
||||
refs = append(refs, procRef{pid: pid, start: t[pid].start})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// procRefAlive : le processus tourne encore (ni disparu, ni zombie, ni
|
||||
// remplacé par un autre sous le même PID).
|
||||
func procRefAlive(r procRef) bool {
|
||||
st, ok := readProcStat(r.pid)
|
||||
return ok && st.state != "Z" && st.start == r.start
|
||||
}
|
||||
|
||||
func procRefsAlive(refs []procRef) []procRef {
|
||||
var alive []procRef
|
||||
for _, r := range refs {
|
||||
if procRefAlive(r) {
|
||||
alive = append(alive, r)
|
||||
}
|
||||
}
|
||||
return alive
|
||||
}
|
||||
|
||||
// stopProcTree arrête le service lancé avec Setsid (pid = chef de session) et
|
||||
// N'EN REVIENT QU'UNE FOIS TOUT L'ARBRE PARTI.
|
||||
//
|
||||
// Attendre le seul chef ne suffit pas : avec Strata, le chef est le serveur
|
||||
// Python, et le moteur qui tient la VRAM (cache d'experts, plusieurs Go) est un
|
||||
// autre processus qui finit de s'arrêter après lui. La relance qui suivait
|
||||
// 500 ms plus tard démarrait alors llama-server sur une carte encore pleine —
|
||||
// « cudaMalloc failed: out of memory » sur un modèle qui tient pourtant — et
|
||||
// Strata mesurait la RAM disponible avec l'ancien moteur encore chargé.
|
||||
//
|
||||
// SIGTERM à tous, grace pour finir proprement, SIGKILL aux survivants, puis
|
||||
// settle au plus pour que le noyau les ait vraiment retirés (un processus
|
||||
// coincé dans une lecture disque ne meurt qu'à la sortie de l'appel système).
|
||||
// Renvoie ceux qui tournent encore au-delà (rien à faire de plus, mais le dire).
|
||||
func stopProcTree(pid int, grace, settle time.Duration) []procRef {
|
||||
refs := procTreeOf(procTable(), pid, os.Getpid())
|
||||
if len(refs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
|
||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||
}
|
||||
for _, r := range refs {
|
||||
_ = syscall.Kill(r.pid, syscall.SIGTERM)
|
||||
}
|
||||
if left := waitProcRefs(refs, grace); len(left) == 0 {
|
||||
return nil
|
||||
}
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
for _, r := range procRefsAlive(refs) {
|
||||
_ = syscall.Kill(r.pid, syscall.SIGKILL)
|
||||
}
|
||||
return waitProcRefs(refs, settle)
|
||||
}
|
||||
|
||||
// waitProcRefs attend que tous aient disparu, au plus wait. Renvoie les survivants.
|
||||
func waitProcRefs(refs []procRef, wait time.Duration) []procRef {
|
||||
deadline := time.Now().Add(wait)
|
||||
for {
|
||||
left := procRefsAlive(refs)
|
||||
if len(left) == 0 || time.Now().After(deadline) {
|
||||
return left
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseProcStat(t *testing.T) {
|
||||
st, ok := parseProcStat("4242 (python3 (x) y) S 4200 4100 4000 0 -1 4194560 0 0 0 0 0 0 0 0 20 0 1 0 987654 0 0")
|
||||
if !ok || st.state != "S" || st.ppid != 4200 || st.pgrp != 4100 || st.sid != 4000 || st.start != "987654" {
|
||||
t.Fatalf("lecture fausse : %+v ok=%v", st, ok)
|
||||
}
|
||||
if _, ok := parseProcStat("4242 (tronqué"); ok {
|
||||
t.Error("ligne sans parenthèse fermante acceptée")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcTreeOf(t *testing.T) {
|
||||
tab := map[int]procStat{
|
||||
100: {ppid: 1, pgrp: 100, sid: 100, start: "a"}, // chef (Setsid)
|
||||
101: {ppid: 100, pgrp: 100, sid: 100, start: "b"},
|
||||
200: {ppid: 101, pgrp: 200, sid: 200, start: "c"}, // moteur dans sa propre session
|
||||
201: {ppid: 200, pgrp: 200, sid: 200, start: "d"},
|
||||
300: {ppid: 1, pgrp: 300, sid: 300, start: "e"}, // étranger
|
||||
400: {ppid: 1, pgrp: 100, sid: 100, start: "f"}, // orphelin rattaché à PID 1
|
||||
500: {ppid: 100, pgrp: 100, sid: 100, start: "g"}, // nous-mêmes
|
||||
}
|
||||
got := map[int]bool{}
|
||||
for _, r := range procTreeOf(tab, 100, 500) {
|
||||
got[r.pid] = true
|
||||
}
|
||||
for _, pid := range []int{100, 101, 200, 201, 400} {
|
||||
if !got[pid] {
|
||||
t.Errorf("PID %d oublié", pid)
|
||||
}
|
||||
}
|
||||
if got[300] || got[500] {
|
||||
t.Errorf("hors de l'arbre pris : %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le chef meurt vite, son enfant (dans une autre session, comme le moteur de
|
||||
// Strata) traîne à s'arrêter : stopProcTree ne revient qu'une fois tout parti.
|
||||
func TestStopProcTreeWaitsForChildren(t *testing.T) {
|
||||
// Le chef lance un enfant qui ignore SIGTERM dans sa propre session, écrit
|
||||
// son PID, puis attend.
|
||||
pidFile := t.TempDir() + "/child"
|
||||
script := `setsid sh -c 'trap "" TERM; echo $$ > ` + pidFile + `; while :; do sleep 0.1; done' & wait`
|
||||
cmd := exec.Command("sh", "-c", script)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Skip("sh indisponible :", err)
|
||||
}
|
||||
go cmd.Wait() // pas de zombie pour le chef
|
||||
var child int
|
||||
for i := 0; i < 50 && child == 0; i++ {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if b, err := os.ReadFile(pidFile); err == nil {
|
||||
child, _ = strconv.Atoi(strings.TrimSpace(string(b)))
|
||||
}
|
||||
}
|
||||
if child == 0 {
|
||||
t.Fatal("l'enfant n'a pas démarré")
|
||||
}
|
||||
st, ok := readProcStat(child)
|
||||
if !ok || st.sid == cmd.Process.Pid {
|
||||
t.Fatalf("l'enfant devait avoir sa propre session : %+v", st)
|
||||
}
|
||||
ref := procRef{pid: child, start: st.start}
|
||||
if left := stopProcTree(cmd.Process.Pid, 300*time.Millisecond, 5*time.Second); len(left) != 0 {
|
||||
t.Fatalf("survivants : %v", left)
|
||||
}
|
||||
if procRefAlive(ref) {
|
||||
t.Fatal("l'enfant tourne encore au retour de stopProcTree")
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,16 @@ func serviceAction(action string) error {
|
||||
// visible que dans le journal.
|
||||
func preflightEngine() error {
|
||||
cfg := ReadConfig()
|
||||
// Preset externe : aucun llama-server local à lancer. Exiger BIN/MODEL
|
||||
// refusait `loki start` et `loki restart` avec un message invitant à
|
||||
// réinstaller llama.cpp (amont #95) ; cmdServe sort déjà proprement.
|
||||
if isExternalConfig(cfg) {
|
||||
return nil
|
||||
}
|
||||
// Strata n'a ni BIN ni MODEL : son installeur a tout rangé (backend_strata.go).
|
||||
if isStrataConfig(cfg) {
|
||||
return strataPreflight(cfg)
|
||||
}
|
||||
bin := strings.TrimSpace(cfg["BIN"])
|
||||
if bin == "" {
|
||||
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
|
||||
|
||||
@@ -65,6 +65,12 @@ func userSvcAction(action string) error {
|
||||
}
|
||||
|
||||
func userSvcStart() error {
|
||||
// Preset externe : `loki serve` sortirait aussitôt (rien à lancer), et
|
||||
// userCheckStarted le prendrait pour un plantage.
|
||||
if isExternalConfig(ReadConfig()) {
|
||||
fmt.Printf("%s preset externe actif : pas de moteur local à démarrer\n", dim("[info]"))
|
||||
return nil
|
||||
}
|
||||
if pid := readServicePID(); pid > 0 && processAlive(pid) {
|
||||
fmt.Printf("%s déjà démarré (PID %d)\n", yellow("[info]"), pid)
|
||||
return nil
|
||||
@@ -119,25 +125,25 @@ func userCheckStarted(pid int) error {
|
||||
|
||||
func userSvcStop(verbose bool) error {
|
||||
pid := readServicePID()
|
||||
if pid <= 0 || !processAlive(pid) {
|
||||
// Le chef parti, ses enfants peuvent tourner encore (moteur de Strata) :
|
||||
// on ne conclut « rien à arrêter » que si l'arbre entier est vide.
|
||||
if pid <= 0 || len(procTreeOf(procTable(), pid, os.Getpid())) == 0 {
|
||||
_ = os.Remove(pidFilePath())
|
||||
if verbose {
|
||||
fmt.Println(yellow("[info]") + " aucun service en cours d'exécution")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Setsid a fait de l'enfant un chef de groupe : le PID négatif vise le
|
||||
// groupe entier, donc llama-server s'arrête avec lui.
|
||||
if err := syscall.Kill(-pid, syscall.SIGTERM); err != nil {
|
||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||
}
|
||||
for i := 0; i < 40 && processAlive(pid); i++ {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
if processAlive(pid) {
|
||||
_ = syscall.Kill(-pid, syscall.SIGKILL)
|
||||
}
|
||||
// Setsid a fait de l'enfant un chef de session : tout ce qu'il a lancé
|
||||
// (llama-server, serveur et moteur de Strata) s'arrête avec lui, et on
|
||||
// attend qu'il soit VRAIMENT parti — la relance qui suit en a besoin pour
|
||||
// trouver la VRAM et la RAM libres.
|
||||
left := stopProcTree(pid, 4*time.Second, 20*time.Second)
|
||||
_ = os.Remove(pidFilePath())
|
||||
if len(left) > 0 {
|
||||
fmt.Fprintf(os.Stderr, "%s %d processus du moteur toujours là après SIGKILL (PID %d…) : "+
|
||||
"la mémoire du GPU peut ne pas être encore rendue\n", yellow("[attention]"), len(left), left[0].pid)
|
||||
}
|
||||
if verbose {
|
||||
fmt.Println(green("[ok]") + " arrêté")
|
||||
}
|
||||
|
||||
+804
-45
File diff suppressed because it is too large.
Load diff
@@ -329,7 +329,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<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" onclick="settingsShow('engine')">Moteur</button>
|
||||
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
|
||||
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
|
||||
<div class="set-nav-h">Application</div>
|
||||
@@ -438,7 +438,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
<div id="net-block" class="gated-group">
|
||||
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||
<div class="subhead">Accès internet<span id="internet-badge" class="sbadge"></span><span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Ajoute les outils <b>web_search / web_images / web_open / web_read / web_grep</b>. Le moteur <b>intégré</b> ne demande aucune installation. Le moteur <b>Crawl4AI</b> exige un serveur que tu héberges toi-même (ex. <code>docker run -d -p 11235:11235 --shm-size=1g unclecode/crawl4ai:latest</code>) mais exécute le JavaScript des pages. Actifs seulement si le <b>mode agent</b> est actif.</span></span></div>
|
||||
<div class="slist">
|
||||
<label class="switchrow"><span class="switch"><input type="checkbox" id="internet-toggle" onchange="toggleInternet()"><span class="slider"></span></span> donner internet à l'IA</label>
|
||||
<!-- Moteur web : « go » = intégré au binaire (rien à installer, pas de rendu
|
||||
@@ -531,7 +531,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div></div>
|
||||
</section>
|
||||
<section class="set-pane" data-pane="engine" id="lc-details">
|
||||
<div class="set-pane-h"><span>Moteur</span></div>
|
||||
<div class="set-pane-h"><span>Moteur</span><span class="muted" style="font-size:12px;font-weight:400">actif : <b id="lc-active-engine">…</b></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). -->
|
||||
@@ -546,6 +546,20 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div>Moteur <b>llama.cpp</b> <span id="eng-build" class="mcp-tag eng-ver"></span> <span id="eng-source" class="mcp-tag"></span></div>
|
||||
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
|
||||
<div id="eng-notice" class="muted" style="margin-top:6px;display:none"></div>
|
||||
<!-- Version recommandée : affichée quand le build officiel qui tourne
|
||||
est antérieur au minimum conseillé (décidé sans réseau). Le tag
|
||||
n'est choisi qu'au clic, et rien ne s'installe sans confirmation. -->
|
||||
<div id="eng-reco" class="eng-reco" style="display:none">
|
||||
<div><b>Version recommandée : b<span id="eng-reco-min"></span> ou plus récente</b>
|
||||
<span class="muted">(installée : b<span id="eng-reco-cur"></span>)</span></div>
|
||||
<ul id="eng-reco-gains"></ul>
|
||||
<div class="muted">Le décodage spéculatif (MTP) reste coupé tant que <code>SPEC</code> n'est pas posé.
|
||||
La version actuelle est gardée pour revenir en arrière.</div>
|
||||
<div class="row" style="margin:6px 0 0">
|
||||
<button onclick="engRecommended()">Mettre à jour vers la version recommandée</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="eng-render" style="margin-top:6px;display:none"></div>
|
||||
<div id="eng-latest" style="margin-top:6px"></div>
|
||||
<div class="row" style="margin:8px 0 0">
|
||||
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
|
||||
@@ -553,6 +567,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
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-prev" style="display:none" onclick="engRollback()">Revenir à la version précédente</button>
|
||||
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
|
||||
<span id="eng-others"></span>
|
||||
</div>
|
||||
@@ -582,6 +597,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<div class="lc-mode-state" id="lc-custom-state"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Deuxième moteur, hors des modes llama.cpp (masqués quand le moteur vient
|
||||
de l'image) : Strata, spécialisé Qwen3.8 Flash Next, paquet figé repris
|
||||
d'AJEAN (backend_strata.go). Le choix du moteur se fait par preset :
|
||||
activer un modèle Strata bascule sur Strata, un autre preset revient à
|
||||
llama.cpp. -->
|
||||
<div class="lc-modes" id="lc-engines">
|
||||
<div class="lc-mode" id="lc-strata" onclick="openStrata()">
|
||||
<div class="lc-mode-t">Strata — Qwen3.8 Flash Next</div>
|
||||
<div class="lc-mode-d">moteur spécialisé pour ce MoE de 125 B (experts répartis entre VRAM, RAM et disque), installation en un clic</div>
|
||||
<div class="lc-mode-state" id="lc-strata-state"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lc-job" class="rowbody" style="display:none">
|
||||
<div id="lc-job-phase" style="font-size:12px;display:flex;align-items:center;gap:6px"></div>
|
||||
<div class="lc-logbar" onclick="lcToggleLog()"><span id="lc-log-caret">▸</span> détails</div>
|
||||
@@ -851,6 +878,46 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="strata-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeStrata()">
|
||||
<div class="modal-card" style="max-width:520px">
|
||||
<div class="modal-head">
|
||||
<strong>Strata — Qwen3.8 Flash Next</strong>
|
||||
<button class="modal-x" onclick="closeStrata()" aria-label="Fermer">×</button>
|
||||
</div>
|
||||
<div class="modal-body mform">
|
||||
<div id="strata-machine" class="muted" style="font-size:12px;line-height:1.5"></div>
|
||||
<div class="fld">
|
||||
<label class="fld-l">Version</label>
|
||||
<div class="seg" id="strata-fams"></div>
|
||||
</div>
|
||||
<div class="fld">
|
||||
<label class="fld-l">Qualité</label>
|
||||
<div class="slist" id="strata-quants"></div>
|
||||
</div>
|
||||
<div id="strata-disk" class="muted" style="font-size:12px;line-height:1.5"></div>
|
||||
<div class="fld" id="strata-settings-fld" hidden>
|
||||
<label class="fld-l">Réglages</label>
|
||||
<div class="slist moe-details" id="strata-settings"></div>
|
||||
</div>
|
||||
<div class="fld" id="strata-details-fld" hidden>
|
||||
<label class="fld-l">Configuration</label>
|
||||
<div class="slist moe-details" id="strata-details"></div>
|
||||
</div>
|
||||
<div id="strata-note" class="muted" style="font-size:11px;line-height:1.5"></div>
|
||||
<div class="muted" style="font-size:11px;line-height:1.5">Moteur <a href="https://github.com/Niko1221/Strata" target="_blank" rel="noopener">Strata</a> (MIT),
|
||||
paquet figé et vérifié (SHA-256) publié par <a href="https://github.com/nathaninline/ajean" target="_blank" rel="noopener">AJEAN</a>.
|
||||
Linux + NVIDIA (RTX 20 ou plus récente). Le modèle est téléchargé dans <code>/data/strata</code>.</div>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
<div class="foot-l"></div>
|
||||
<div class="foot-r">
|
||||
<button onclick="closeStrata()">fermer</button>
|
||||
<button id="strata-save" hidden onclick="strataSaveSettings()">Enregistrer</button>
|
||||
<button class="btn-save" id="strata-go" onclick="strataInstall()">Installer</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="lc-custom-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeCustomBackends()">
|
||||
<div class="modal-card" style="max-width:560px">
|
||||
<div class="modal-head">
|
||||
@@ -1122,6 +1189,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
<span class="pe-row-l">Vision<span class="pe-sub">projecteur mmproj</span></span>
|
||||
<span class="pe-row-c"><span class="pe-selc"><select id="m-mmproj" onchange="onPickMmproj()"></select></span></span>
|
||||
</div>
|
||||
<!-- Repris d'AJEAN v0.10.3 : n'apparaît qu'avec un projecteur choisi. -->
|
||||
<div class="pe-row" id="s-mmproj-cpu-row" style="display:none">
|
||||
<span class="pe-row-l">Projecteur vision sur le CPU<span class="pe-sub">garde le mmproj hors du GPU — libère de la VRAM, images lues plus lentement (--no-mmproj-offload)</span></span>
|
||||
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-mmproj-cpu" onchange="eaToggleFlag('--no-mmproj-offload', this.checked)"><span class="pe-sl"></span></label></span>
|
||||
</div>
|
||||
<!-- Recherche Hugging Face. Le champ « lien direct » juste en dessous
|
||||
reste là : c'est le mode expert (dépôt privé, fichier exotique,
|
||||
lien reçu d'ailleurs). Celui-ci évite d'avoir à quitter Loki, et
|
||||
@@ -1221,7 +1293,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
|
||||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
|
||||
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"><span class="pe-unit" id="s-ngl-max"></span></span>
|
||||
</div>
|
||||
<div class="pe-row">
|
||||
<span class="pe-row-l">Experts MoE sur CPU<span class="pe-sub">vide = non · sur 2 GPU, coupe le pipeline entre cartes (prompt plus lent)</span></span>
|
||||
@@ -1274,7 +1346,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
« MTP + n-grammes » écrivent la clé SPEC,
|
||||
que Loki traduit au lancement (tête MTP détectée dans le fichier,
|
||||
garde-fous VRAM) ; les autres types vont tels quels dans
|
||||
--spec-type. « non » écrit SPEC=off. Le nombre de jetons anticipés
|
||||
--spec-type. « non » écrit SPEC=off. ngram-mod n'y est plus en
|
||||
type brut : c'est « n-grammes du contexte » (SPEC=ngram). Un
|
||||
preset qui le porte encore dans EXTRA_ARGS l'affiche tel quel
|
||||
(setSpecType) avec une migration proposée, jamais faite
|
||||
d'office. Le nombre de jetons anticipés
|
||||
(--spec-draft-n-max) n'a de sens qu'une fois un type choisi, donc
|
||||
sa ligne n'apparaît qu'à ce moment. -->
|
||||
<div class="pe-row">
|
||||
@@ -1293,13 +1369,16 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</optgroup>
|
||||
<optgroup label="N-grammes (sans modèle brouillon)">
|
||||
<option value="ngram-simple">n-grammes</option>
|
||||
<option value="ngram-mod">n-grammes (mod)</option>
|
||||
<option value="ngram-cache">n-grammes (cache)</option>
|
||||
<option value="ngram-map-k">n-grammes (map-k)</option>
|
||||
<option value="ngram-map-k4v">n-grammes (map-k4v)</option>
|
||||
</optgroup>
|
||||
</select></span></span>
|
||||
</div>
|
||||
<div class="pe-row" id="s-spec-migrate-row" style="display:none">
|
||||
<span class="pe-row-l">N-grammes réglés à la main<span class="pe-sub" id="s-spec-migrate-sub"></span></span>
|
||||
<span class="pe-row-c"><button class="pe-link" onclick="migrateSpecNgram()">Passer à SPEC=ngram</button></span>
|
||||
</div>
|
||||
<div class="pe-row" id="s-spec-n-row" style="display:none">
|
||||
<span class="pe-row-l">Jetons anticipés<span class="pe-sub">par étape — défaut 3</span></span>
|
||||
<span class="pe-row-c"><input id="s-spec-n" class="pe-val" type="number" min="1" step="1" placeholder="3" oninput="eaSetValued('--spec-draft-n-max', this.value)"></span>
|
||||
@@ -1395,6 +1474,18 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Prompt système DE CE preset (repris d'AJEAN v0.13.0) : il bascule avec
|
||||
le preset. Rangé en base (sysprompt:<id>), pas dans le .env qui ne
|
||||
porte qu'une ligne par clé. Vide = le prompt global s'applique. -->
|
||||
<div id="m-sys-row" class="pe-group" style="display:none">
|
||||
<div class="pe-gh">Prompt système</div>
|
||||
<div class="pe-list">
|
||||
<div class="pe-row stack">
|
||||
<textarea id="m-sysprompt" class="pe-inp" rows="4" spellcheck="false" placeholder="(optionnel) consigne propre à ce preset — vide = prompt système général"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="m-raw" class="pe-group">
|
||||
<div class="pe-gh" id="m-raw-head">Configuration</div>
|
||||
<div class="pe-list">
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
/*! Sortable 1.15.6 - MIT | git://github.com/SortableJS/Sortable.git */
|
||||
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t=t||self).Sortable=e()}(this,function(){"use strict";function e(e,t){var n,o=Object.keys(e);return Object.getOwnPropertySymbols&&(n=Object.getOwnPropertySymbols(e),t&&(n=n.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),o.push.apply(o,n)),o}function I(o){for(var t=1;t<arguments.length;t++){var i=null!=arguments[t]?arguments[t]:{};t%2?e(Object(i),!0).forEach(function(t){var e,n;e=o,t=i[n=t],n in e?Object.defineProperty(e,n,{value:t,enumerable:!0,configurable:!0,writable:!0}):e[n]=t}):Object.getOwnPropertyDescriptors?Object.defineProperties(o,Object.getOwnPropertyDescriptors(i)):e(Object(i)).forEach(function(t){Object.defineProperty(o,t,Object.getOwnPropertyDescriptor(i,t))})}return o}function o(t){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(t){return typeof t}:function(t){return t&&"function"==typeof Symbol&&t.constructor===Symbol&&t!==Symbol.prototype?"symbol":typeof t})(t)}function a(){return(a=Object.assign||function(t){for(var e=1;e<arguments.length;e++){var n,o=arguments[e];for(n in o)Object.prototype.hasOwnProperty.call(o,n)&&(t[n]=o[n])}return t}).apply(this,arguments)}function i(t,e){if(null==t)return{};var n,o=function(t,e){if(null==t)return{};for(var n,o={},i=Object.keys(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||(o[n]=t[n]);return o}(t,e);if(Object.getOwnPropertySymbols)for(var i=Object.getOwnPropertySymbols(t),r=0;r<i.length;r++)n=i[r],0<=e.indexOf(n)||Object.prototype.propertyIsEnumerable.call(t,n)&&(o[n]=t[n]);return o}function r(t){return function(t){if(Array.isArray(t))return l(t)}(t)||function(t){if("undefined"!=typeof Symbol&&null!=t[Symbol.iterator]||null!=t["@@iterator"])return Array.from(t)}(t)||function(t,e){if(t){if("string"==typeof t)return l(t,e);var n=Object.prototype.toString.call(t).slice(8,-1);return"Map"===(n="Object"===n&&t.constructor?t.constructor.name:n)||"Set"===n?Array.from(t):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?l(t,e):void 0}}(t)||function(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}()}function l(t,e){(null==e||e>t.length)&&(e=t.length);for(var n=0,o=new Array(e);n<e;n++)o[n]=t[n];return o}function t(t){if("undefined"!=typeof window&&window.navigator)return!!navigator.userAgent.match(t)}var y=t(/(?:Trident.*rv[ :]?11\.|msie|iemobile|Windows Phone)/i),w=t(/Edge/i),s=t(/firefox/i),u=t(/safari/i)&&!t(/chrome/i)&&!t(/android/i),c=t(/iP(ad|od|hone)/i),n=t(/chrome/i)&&t(/android/i),d={capture:!1,passive:!1};function h(t,e,n){t.addEventListener(e,n,!y&&d)}function p(t,e,n){t.removeEventListener(e,n,!y&&d)}function f(t,e){if(e&&(">"===e[0]&&(e=e.substring(1)),t))try{if(t.matches)return t.matches(e);if(t.msMatchesSelector)return t.msMatchesSelector(e);if(t.webkitMatchesSelector)return t.webkitMatchesSelector(e)}catch(t){return}}function g(t){return t.host&&t!==document&&t.host.nodeType?t.host:t.parentNode}function P(t,e,n,o){if(t){n=n||document;do{if(null!=e&&(">"!==e[0]||t.parentNode===n)&&f(t,e)||o&&t===n)return t}while(t!==n&&(t=g(t)))}return null}var m,v=/\s+/g;function k(t,e,n){var o;t&&e&&(t.classList?t.classList[n?"add":"remove"](e):(o=(" "+t.className+" ").replace(v," ").replace(" "+e+" "," "),t.className=(o+(n?" "+e:"")).replace(v," ")))}function R(t,e,n){var o=t&&t.style;if(o){if(void 0===n)return document.defaultView&&document.defaultView.getComputedStyle?n=document.defaultView.getComputedStyle(t,""):t.currentStyle&&(n=t.currentStyle),void 0===e?n:n[e];o[e=!(e in o||-1!==e.indexOf("webkit"))?"-webkit-"+e:e]=n+("string"==typeof n?"":"px")}}function b(t,e){var n="";if("string"==typeof t)n=t;else do{var o=R(t,"transform")}while(o&&"none"!==o&&(n=o+" "+n),!e&&(t=t.parentNode));var i=window.DOMMatrix||window.WebKitCSSMatrix||window.CSSMatrix||window.MSCSSMatrix;return i&&new i(n)}function D(t,e,n){if(t){var o=t.getElementsByTagName(e),i=0,r=o.length;if(n)for(;i<r;i++)n(o[i],i);return o}return[]}function O(){var t=document.scrollingElement;return t||document.documentElement}function X(t,e,n,o,i){if(t.getBoundingClientRect||t===window){var r,a,l,s,c,u,d=t!==window&&t.parentNode&&t!==O()?(a=(r=t.getBoundingClientRect()).top,l=r.left,s=r.bottom,c=r.right,u=r.height,r.width):(l=a=0,s=window.innerHeight,c=window.innerWidth,u=window.innerHeight,window.innerWidth);if((e||n)&&t!==window&&(i=i||t.parentNode,!y))do{if(i&&i.getBoundingClientRect&&("none"!==R(i,"transform")||n&&"static"!==R(i,"position"))){var h=i.getBoundingClientRect();a-=h.top+parseInt(R(i,"border-top-width")),l-=h.left+parseInt(R(i,"border-left-width")),s=a+r.height,c=l+r.width;break}}while(i=i.parentNode);return o&&t!==window&&(o=(e=b(i||t))&&e.a,t=e&&e.d,e&&(s=(a/=t)+(u/=t),c=(l/=o)+(d/=o))),{top:a,left:l,bottom:s,right:c,width:d,height:u}}}function Y(t,e,n){for(var o=M(tLine truncated
|
||||
@@ -68,7 +68,8 @@ function _openAsk(kind, message, opts){
|
||||
const chk=document.getElementById('ask-check');
|
||||
chk.style.display = opts.check ? 'inline-flex' : 'none';
|
||||
document.getElementById('ask-check-label').textContent = opts.check || '';
|
||||
document.getElementById('ask-check-input').checked = false;
|
||||
// opts.checked : case cochée d'office (le choix sûr), toujours décochable.
|
||||
document.getElementById('ask-check-input').checked = !!opts.checked;
|
||||
_askCheck=false;
|
||||
const cancel=document.getElementById('ask-cancel'), ok=document.getElementById('ask-ok');
|
||||
cancel.style.display = kind==='alert' ? 'none' : '';
|
||||
|
||||
@@ -315,6 +315,27 @@ async function loadCfg(){
|
||||
const [c, lc] = await Promise.all([jget('/api/config'), jget('/api/llamacpp').catch(()=>null)]);
|
||||
const row=(k,v,title)=>'<div class="kv"><span>'+k+'</span><span title="'+String(title!=null?title:v).replace(/"/g,'"')+'">'+String(v)+'</span></div>';
|
||||
const rows=[];
|
||||
// Strata (backend_strata.go) : la config réelle de lancement, calculée par le
|
||||
// serveur comme au démarrage (/api/strata → details du modèle actif).
|
||||
if(/^(strata|moe)$/i.test((c.ENGINE||'').trim())){
|
||||
const s = await jget('/api/strata').catch(()=>null);
|
||||
let fam=null, fit=null;
|
||||
for(const f of (s&&s.families)||[]){ const x=f.fits.find(x=>x.active); if(x){ fam=f; fit=x; } }
|
||||
const d=fit&&fit.details;
|
||||
rows.push(row('MOTEUR', 'Strata', 'paquet AJEAN MoE '+((s&&s.version)||'')));
|
||||
if(typeof setLoadedModel === 'function') setLoadedModel('');
|
||||
if(fam) rows.push(row('MODEL', 'Qwen3.8 Flash Next '+fam.label+' '+fit.quant.id));
|
||||
if(d){
|
||||
const k=n=>(+n>=1024 ? Math.round(+n/1024)+'K' : n);
|
||||
rows.push(row('CTX', d.ctx));
|
||||
rows.push(row('KV', d.kv+(d.kv_resident ? ' · '+k(d.kv_resident)+' VRAM' : '')));
|
||||
rows.push(row('MTP', d.spec));
|
||||
rows.push(row('EXPERTS', ({drop:'mlock (hors appoint)', ram:'RAM', arena:'experts.bin', disk:'mmap (disque)'})[d.mode] || (d.mmap ? 'mmap' : 'RAM'), d.mode_label||''));
|
||||
if(d.vision_gpu) rows.push(row('VISION', 'on', d.vision_gpu));
|
||||
}else if(c.CTX) rows.push(row('CTX', c.CTX));
|
||||
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
|
||||
return;
|
||||
}
|
||||
if(c.BIN){
|
||||
// Moteur : précompilé / compilé / personnalisé (avec le chemin). Le title garde
|
||||
// toujours le chemin complet, quel que soit le libellé.
|
||||
@@ -336,12 +357,26 @@ async function loadCfg(){
|
||||
// Le modèle chargé alimente aussi le sélecteur de l'en-tête : c'est la seule
|
||||
// source qui le connaisse quand aucun preset ne correspond à la configuration.
|
||||
if(typeof setLoadedModel === 'function') setLoadedModel(c.MODEL || '');
|
||||
// Nombre de couches du modèle (amont #43) : NGL au-delà de ngl_max ne change
|
||||
// rien, en dessous on libère de la VRAM pour le contexte et le cache KV.
|
||||
const ly=c.MODEL ? await jget('/api/model/layers').catch(()=>null) : null;
|
||||
['MODEL','CTX','BATCH','UBATCH','NGL'].filter(k=>c[k]).forEach(k=>{
|
||||
let v=c[k]; if(k==='MODEL') v=v.split('/').pop();
|
||||
let v=c[k]; if(k==='MODEL') v=v.split(/[\\/]/).pop();
|
||||
if(k==='NGL' && ly && ly.ok){ rows.push(row(k, v+' / '+ly.ngl_max, ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)')); return; }
|
||||
rows.push(row(k, v));
|
||||
});
|
||||
// n-cpu-moe : affiché seulement s'il est réellement présent dans EXTRA_ARGS.
|
||||
const m=(c.EXTRA_ARGS||'').match(/--n-cpu-moe\s+(\d+)/);
|
||||
if(m) rows.push(row('N-CPU-MOE', m[1]));
|
||||
document.getElementById('cfg').innerHTML = rows.join('');
|
||||
document.getElementById('cfg').innerHTML = rows.join('') + await engineCmdlineRow();
|
||||
}
|
||||
// Ligne de commande exacte du dernier lancement du moteur (amont #108),
|
||||
// copiable pour la vérifier ou la partager. Clé API masquée côté serveur.
|
||||
let ENGINE_CMDLINE='';
|
||||
async function engineCmdlineRow(){
|
||||
const cl=await jget('/api/engine/cmdline').catch(()=>null);
|
||||
ENGINE_CMDLINE=(cl&&cl.cmdline)||'';
|
||||
if(!ENGINE_CMDLINE) return '';
|
||||
return '<div class="kv"><span>COMMANDE</span><span><button class="pe-link" onclick="copyText(ENGINE_CMDLINE, \'commande copiée\')" title="'
|
||||
+escHtml(ENGINE_CMDLINE)+'">copier la commande du moteur</button></span></div>';
|
||||
}
|
||||
@@ -60,7 +60,9 @@ async function loadPresets(){
|
||||
const row=document.createElement('div');
|
||||
const pend = !x.active && pendingPreset===i+1;
|
||||
row.className='preset'+(x.active?' active':'')+(pend?' pending':'')+(moved?' sel-anim':'');
|
||||
row.onclick=()=>switchTo(i+1, x.name, x.id);
|
||||
row.dataset.id = x.id;
|
||||
// Fin d'un glisser-déposer : le clic qui le termine ne bascule pas le preset.
|
||||
row.onclick=()=>{ if(presetJustDragged) return; switchTo(i+1, x.name, x.id); };
|
||||
const info=document.createElement('div'); info.className='preset-info';
|
||||
const nm=document.createElement('div'); nm.className='preset-name';
|
||||
// Puce de l'actif : un ÉLÉMENT rond en CSS, pas le caractère « ● ». Le glyphe
|
||||
@@ -88,12 +90,27 @@ async function loadPresets(){
|
||||
q.textContent=x.quant; q.title='quantization';
|
||||
meta.appendChild(q);
|
||||
}
|
||||
if(x.reasoning){
|
||||
const rt=document.createElement('span'); rt.className='rtag';
|
||||
rt.title='raisonnement actif ('+x.reasoning+')';
|
||||
rt.innerHTML='<svg viewBox="0 0 24 24" width="10" height="10" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.1V18h6v-1.2c0-.8.4-1.6 1-2.1A7 7 0 0 0 12 2z"/></svg>';
|
||||
rt.appendChild(document.createTextNode(x.reasoning));
|
||||
meta.appendChild(rt);
|
||||
if(x.ctx && !x.external){
|
||||
const c=document.createElement('span'); c.className='ctag';
|
||||
c.title='taille de contexte'; c.textContent=x.ctx;
|
||||
meta.appendChild(c);
|
||||
}
|
||||
// Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
|
||||
// (ampoule + niveau), regroupés en un seul tag.
|
||||
if(x.vision || x.reasoning){
|
||||
const cap=document.createElement('span'); cap.className='captag';
|
||||
const tip=[];
|
||||
if(x.vision){
|
||||
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>');
|
||||
tip.push('lit les images');
|
||||
}
|
||||
if(x.reasoning){
|
||||
cap.insertAdjacentHTML('beforeend','<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6"/><path d="M10 21h4"/><path d="M12 3a6 6 0 0 0-4 10.5c.6.6 1 1.4 1 2.5h6c0-1.1.4-1.9 1-2.5A6 6 0 0 0 12 3z"/></svg>');
|
||||
cap.appendChild(document.createTextNode(x.reasoning));
|
||||
tip.push('raisonnement actif ('+x.reasoning+')');
|
||||
}
|
||||
cap.title=tip.join(' · ');
|
||||
meta.appendChild(cap);
|
||||
}
|
||||
if(x.bench){
|
||||
const bt=document.createElement('span'); bt.className='btag';
|
||||
@@ -114,10 +131,44 @@ async function loadPresets(){
|
||||
// Deux éditeurs, deux mondes : les réglages d'un preset externe (URL, modèle
|
||||
// distant, clé) n'ont rien à voir avec ceux d'un modèle local.
|
||||
edit.title = x.external ? 'réglages de l’API externe' : 'réglages du preset';
|
||||
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : openPreset(x.id); };
|
||||
edit.onclick=(e)=>{ e.stopPropagation(); x.external ? openExternal(x.id) : x.strata ? openStrata(x.id) : openPreset(x.id); };
|
||||
row.appendChild(info); row.appendChild(edit);
|
||||
cont.appendChild(row);
|
||||
});
|
||||
initPresetSortable(cont);
|
||||
}
|
||||
// ─── Réordonnancement des presets (SortableJS, repris d'AJEAN) ───────────────
|
||||
// La ligne entière se déplace, sans poignée. L'ordre est gardé côté serveur
|
||||
// (/api/presets/order) : il vaut aussi pour le sélecteur de l'en-tête.
|
||||
let presetJustDragged = false;
|
||||
let presetSortable = null;
|
||||
function initPresetSortable(cont){
|
||||
if(typeof Sortable==='undefined') return; // lib absente : la liste reste simple
|
||||
if(presetSortable) return; // une instance, sur le conteneur qui persiste
|
||||
presetSortable = Sortable.create(cont, {
|
||||
animation: 160,
|
||||
easing: 'cubic-bezier(.2,.7,.3,1)',
|
||||
// Clone géré par Sortable (et non l'image native, semi-transparente), posé
|
||||
// sur <body> : dans la modale (ancêtre transformé), il apparaissait loin
|
||||
// du curseur.
|
||||
forceFallback: true,
|
||||
fallbackTolerance: 3,
|
||||
fallbackOnBody: true,
|
||||
// Sur mobile, il faut MAINTENIR l'appui : un simple défilement attrapait un preset.
|
||||
delay: 220,
|
||||
delayOnTouchOnly: true,
|
||||
touchStartThreshold: 6,
|
||||
ghostClass: 'preset-ghost',
|
||||
chosenClass: 'preset-chosen',
|
||||
dragClass: 'preset-drag',
|
||||
onStart(){ presetJustDragged = true; },
|
||||
onEnd(){
|
||||
setTimeout(()=>{ presetJustDragged = false; }, 60);
|
||||
const ids=[...cont.children].map(r=>r.dataset.id).filter(Boolean);
|
||||
// Re-rendu une fois l'ordre persisté : les positions (pendingPreset) suivent.
|
||||
jpost('/api/presets/order', {ids}).then(()=>loadPresets()).catch(()=>{});
|
||||
},
|
||||
});
|
||||
}
|
||||
// « Mode agent » = accès machine + skills réunis en un seul interrupteur.
|
||||
// Quand il est actif, un « a » blanc apparaît en fondu devant « loki » → « loki ».
|
||||
@@ -622,7 +673,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(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadComputer(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadStrata(),loadThinkEffort(),loadTasks(),loadProjects()]);
|
||||
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); }
|
||||
@@ -158,6 +158,10 @@ async function openItem(kind, key){
|
||||
const modelRow = document.getElementById('m-model-row');
|
||||
const settingsRow = document.getElementById('m-settings-row');
|
||||
const samplingRow = document.getElementById('m-sampling-row');
|
||||
const sysRow = document.getElementById('m-sys-row');
|
||||
if(sysRow) sysRow.style.display = kind === 'preset' ? '' : 'none';
|
||||
const sysTa = document.getElementById('m-sysprompt');
|
||||
if(sysTa) sysTa.value = '';
|
||||
const rawHead = document.getElementById('m-raw-head');
|
||||
const rawToggle = document.getElementById('m-raw-toggle');
|
||||
const rawBody = document.getElementById('m-raw-body');
|
||||
@@ -216,6 +220,7 @@ async function openItem(kind, key){
|
||||
document.getElementById('m-name').value = display;
|
||||
document.getElementById('m-content').value = d.content || '';
|
||||
if(kind === 'preset'){
|
||||
if(sysTa) sysTa.value = d.sysprompt || '';
|
||||
// Ces deux-là LISENT le contenu : elles doivent passer après son arrivée.
|
||||
document.getElementById('m-quant').value = currentQuantInTextarea();
|
||||
populateSettings();
|
||||
@@ -297,6 +302,10 @@ async function populateModelPicker(){
|
||||
// regroupe par dossier, celui de loki d'abord (l'API les renvoie dans cet ordre).
|
||||
const groups = [];
|
||||
for(const m of (list||[])){
|
||||
// Projecteurs vision : ils ont leur propre sélecteur (Vision) et ne se
|
||||
// lancent pas comme modèle (repris d'AJEAN v0.16.2). Gardé seulement s'il
|
||||
// est DÉJÀ le MODEL du preset, pour ne pas masquer une erreur de saisie.
|
||||
if(isMmprojName(m.name) && m !== hit) continue;
|
||||
let g = groups.find(x => x.dir === m.dir);
|
||||
if(!g){ g = {dir: m.dir, home: m.home, items: []}; groups.push(g); }
|
||||
g.items.push(m);
|
||||
@@ -599,6 +608,20 @@ function onPickModel(){
|
||||
ta.value = 'MODEL="'+val+'"\n' + ta.value;
|
||||
}
|
||||
toast('MODEL='+val);
|
||||
showNglMax(val);
|
||||
}
|
||||
|
||||
// Nombre de couches du modèle du preset (amont #43), lu dans son en-tête GGUF :
|
||||
// affiché à côté du champ NGL (placeholder, « / N » et survol).
|
||||
async function showNglMax(model){
|
||||
const e=document.getElementById('s-ngl'), u=document.getElementById('s-ngl-max'); if(!e) return;
|
||||
e.placeholder='999'; e.title=''; if(u) u.textContent='';
|
||||
if(!model) return;
|
||||
const ly=await jget('/api/model/layers?model='+encodeURIComponent(model)).catch(()=>null);
|
||||
if(!ly || !ly.ok) return;
|
||||
e.placeholder=String(ly.ngl_max);
|
||||
if(u) u.textContent='/ '+ly.ngl_max;
|
||||
e.title=ly.ngl_max+' = tout le modèle sur le GPU ('+ly.layers+' couches + la sortie)';
|
||||
}
|
||||
|
||||
// --- Vision (projecteur multimodal --mmproj) --------------------------------
|
||||
@@ -618,6 +641,21 @@ function onPickMmproj(){
|
||||
// et on centralise le choix dans la clé MMPROJ.
|
||||
eaSetValued('--mmproj', '');
|
||||
cfgWriteKey('MMPROJ', document.getElementById('m-mmproj').value);
|
||||
syncMmprojCpuRow();
|
||||
}
|
||||
// « Projecteur sur le CPU » n'a de sens qu'avec un mmproj chargé : la ligne est
|
||||
// masquée sans vision. Source de vérité = la config, pas le select (rempli
|
||||
// APRÈS cette synchro à l'ouverture du preset). Sans projecteur, le drapeau
|
||||
// resté dans EXTRA_ARGS est retiré.
|
||||
function syncMmprojCpuRow(){
|
||||
const row = document.getElementById('s-mmproj-cpu-row');
|
||||
if(!row) return;
|
||||
const on = !!currentMmprojInTextarea();
|
||||
row.style.display = on ? '' : 'none';
|
||||
if(!on && eaHasFlag('--no-mmproj-offload')){
|
||||
eaToggleFlag('--no-mmproj-offload', false);
|
||||
const c = document.getElementById('s-mmproj-cpu'); if(c) c.checked = false;
|
||||
}
|
||||
}
|
||||
// Un projecteur multimodal se reconnaît à « mmproj » dans son nom : c'est la
|
||||
// convention de nommage universelle (llama.cpp, HF). On ne liste que ceux-là —
|
||||
@@ -1013,6 +1051,7 @@ function populateSettings(){
|
||||
const set = (id,v)=>{ const e=document.getElementById(id); if(e) e.value=v; };
|
||||
set('s-ctx', cfgReadKey('CTX'));
|
||||
set('s-ngl', cfgReadKey('NGL'));
|
||||
showNglMax(cfgReadKey('MODEL'));
|
||||
set('s-threads', cfgReadKey('THREADS'));
|
||||
set('s-batch', cfgReadKey('BATCH'));
|
||||
set('s-ubatch', cfgReadKey('UBATCH'));
|
||||
@@ -1072,6 +1111,8 @@ function populateSettings(){
|
||||
}
|
||||
}
|
||||
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
|
||||
chk('s-mmproj-cpu', eaHasFlag('--no-mmproj-offload'));
|
||||
syncMmprojCpuRow();
|
||||
const lm = eaLoadMode();
|
||||
set('s-loadmode', lm);
|
||||
syncLoadModeSub(lm);
|
||||
@@ -1263,7 +1304,9 @@ function setSpecType(v){
|
||||
v = String(v || '').trim();
|
||||
if(v && ![...sel.options].some(o => o.value === v)){
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v; opt.textContent = v + ' (dans la configuration)';
|
||||
// ngram-mod n'est plus une option : un ancien preset qui le porte en
|
||||
// drapeau brut le montre sous son nom, et syncSpecRow propose SPEC=ngram.
|
||||
opt.value = v; opt.textContent = v === 'ngram-mod' ? 'n-grammes (mod) · réglé à la main dans EXTRA_ARGS' : v + ' (dans la configuration)';
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = v;
|
||||
@@ -1282,8 +1325,45 @@ function syncSpecRow(){
|
||||
// visible pour qu'on puisse le retirer.
|
||||
const dr = document.getElementById('s-draft-row');
|
||||
if(dr) dr.style.display = (sel.value === 'auto' || sel.value === 'draft-mtp' || sel.value === 'mtp+ngram' || cfgReadKey('MODEL_DRAFT')) ? '' : 'none';
|
||||
// Ancien --spec-type ngram-mod brut : migration proposée, ce qu'elle retire
|
||||
// affiché avant le clic.
|
||||
const mr = document.getElementById('s-spec-migrate-row');
|
||||
if(mr){
|
||||
const legacy = sel.value === 'ngram-mod';
|
||||
mr.style.display = legacy ? '' : 'none';
|
||||
const sub = document.getElementById('s-spec-migrate-sub');
|
||||
if(legacy && sub) sub.textContent = '« n-grammes du contexte » (SPEC=ngram) fait la même chose, bornes explicites et garde-fous de Loki en plus (refus si des poids restent en RAM). Retirerait : ' + specManualSplit().removed.join(', ');
|
||||
}
|
||||
return on;
|
||||
}
|
||||
// Drapeaux d'un réglage spéculatif à la main que SPEC=ngram remplace. Restés
|
||||
// dans EXTRA_ARGS, ils feraient ignorer SPEC (--spec-type, --spec-ngram-*) ou
|
||||
// refuser les n-grammes (--spec-draft-*) au lancement.
|
||||
function specManualSplit(){
|
||||
const t = eaTokens(), keep = [], removed = [];
|
||||
for(let i = 0; i < t.length; i++){
|
||||
const f = t[i];
|
||||
if(f === '--spec-type' || f.startsWith('--spec-ngram-') || f.startsWith('--spec-draft-')){
|
||||
const hasVal = i+1 < t.length && !t[i+1].startsWith('-');
|
||||
removed.push(hasVal ? f + ' ' + t[i+1] : f);
|
||||
if(hasVal) i++;
|
||||
continue;
|
||||
}
|
||||
keep.push(f);
|
||||
}
|
||||
return {keep, removed};
|
||||
}
|
||||
// Migration d'un ancien --spec-type ngram-mod vers SPEC=ngram, sur clic
|
||||
// seulement : rien n'est écrit à l'ouverture du preset, qui tourne comme avant
|
||||
// tant qu'on ne l'a pas enregistré après ce clic.
|
||||
function migrateSpecNgram(){
|
||||
eaSetTokens(specManualSplit().keep);
|
||||
const n = document.getElementById('s-spec-n');
|
||||
if(n) n.value = '';
|
||||
cfgWriteKey('SPEC', 'ngram');
|
||||
setSpecType('ngram');
|
||||
toast("n-grammes : SPEC=ngram — enregistre le preset pour l'appliquer");
|
||||
}
|
||||
function onSpecType(){
|
||||
const v = document.getElementById('s-spec').value;
|
||||
const viaKey = v in SPEC_KEY_VALUES;
|
||||
@@ -1303,7 +1383,10 @@ async function populateDraft(){
|
||||
if(!sel) return;
|
||||
const list = await jget('/api/models');
|
||||
const cur = readEnvKey(document.getElementById('m-content').value, 'MODEL_DRAFT');
|
||||
const items = (list||[]).filter(m => !isMmprojName(m.name));
|
||||
// Le modèle principal n'est pas un brouillon : le choisir rechargeait tous
|
||||
// ses poids une seconde fois (-md), quand sa tête MTP s'utilise sans.
|
||||
const main = matchModel(currentModelInTextarea(), list);
|
||||
const items = (list||[]).filter(m => !isMmprojName(m.name) && m !== main);
|
||||
const hit = matchModel(cur, items);
|
||||
let html = '<option value="">— aucun —</option>';
|
||||
let matched = false;
|
||||
@@ -1313,7 +1396,9 @@ async function populateDraft(){
|
||||
html += '<option value="'+escHtml(m.value)+'"'+on+'>'+escHtml(m.name)+' ('+fmtSize(m.size)+')</option>';
|
||||
}
|
||||
if(cur && !matched){
|
||||
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+' (introuvable)</option>';
|
||||
const self = main && matchModel(cur, [main]);
|
||||
html += '<option value="'+escHtml(cur)+'" selected>'+escHtml(baseName(cur)||cur)+
|
||||
(self ? ' (le modèle lui-même : ignoré, choisis « aucun »)' : ' (introuvable)')+'</option>';
|
||||
}
|
||||
sel.innerHTML = html;
|
||||
syncSpecRow();
|
||||
@@ -1367,7 +1452,7 @@ async function saveItem(){
|
||||
// Presets: keyed by id (filename); duplicate display names are allowed.
|
||||
// Skills: keyed by name, rename via `old`.
|
||||
const payload = editingKind==='preset'
|
||||
? {id: editingKey, name, content}
|
||||
? {id: editingKey, name, content, sysprompt: (document.getElementById('m-sysprompt')||{}).value || ''}
|
||||
: {name, old: editingKey, content};
|
||||
const r = await jpost(K.saveUrl, payload);
|
||||
if(!r.ok){ toast('erreur : ' + (r.error||'')); return; }
|
||||
|
||||
@@ -230,7 +230,47 @@ function renderBody(el, text){ const b=bodyOf(el); b.innerHTML = md(encodeMdLink
|
||||
// revoke) : quelques captures par discussion, mémoire négligeable, et un revoke
|
||||
// casserait les rendus suivants qui réutilisent la même entrée.
|
||||
const IMG_CACHE = new Map();
|
||||
// Vignettes du fil (repris d'AJEAN) : ~560 px suffisent à l'affichage, le
|
||||
// serveur les calcule (?thumb=). Chargement DEUX PAR DEUX avec un nouvel essai :
|
||||
// en accès distant, une rafale de captures saturait le tunnel et certaines
|
||||
// n'arrivaient jamais.
|
||||
const CHAT_THUMB_PX = 560;
|
||||
let chatImgActive = 0;
|
||||
const chatImgWaiting = [];
|
||||
function chatImgQueue(job){
|
||||
return new Promise((resolve, reject)=>{
|
||||
const run = async ()=>{
|
||||
chatImgActive++;
|
||||
try{
|
||||
try{ resolve(await job()); }
|
||||
catch(_){ await new Promise(r=>setTimeout(r, 800)); resolve(await job()); }
|
||||
}catch(e){ reject(e); }
|
||||
finally{ chatImgActive--; const next = chatImgWaiting.shift(); if(next) next(); }
|
||||
};
|
||||
if(chatImgActive < 2) run(); else chatImgWaiting.push(run);
|
||||
});
|
||||
}
|
||||
async function chatImgBlob(url){
|
||||
const hit = IMG_CACHE.get(url);
|
||||
if(hit) return hit;
|
||||
const r = await jfetch(url);
|
||||
if(!r.ok) throw new Error(r.status);
|
||||
const obj = URL.createObjectURL(await r.blob());
|
||||
IMG_CACHE.set(url, obj);
|
||||
return obj;
|
||||
}
|
||||
function hydrateImages(root){
|
||||
// Images du web (outil web_images, repris d'AJEAN) : même rendu et même
|
||||
// loupe que les nôtres, sans Referer, et masquées si le site les refuse.
|
||||
root.querySelectorAll('img[src^="http://"],img[src^="https://"]').forEach(img => {
|
||||
if(img.dataset.hydrated) return;
|
||||
img.dataset.hydrated = '1';
|
||||
img.classList.add('chatimg');
|
||||
img.loading = 'lazy';
|
||||
img.referrerPolicy = 'no-referrer';
|
||||
bindZoom(img, img.getAttribute('alt') || '');
|
||||
img.addEventListener('error', ()=>{ img.style.display = 'none'; });
|
||||
});
|
||||
root.querySelectorAll('img[src*="/api/chat/image"]').forEach(async img => {
|
||||
if(img.dataset.hydrated) return;
|
||||
img.dataset.hydrated = '1';
|
||||
@@ -242,15 +282,21 @@ function hydrateImages(root){
|
||||
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);
|
||||
const full = src.startsWith('/') ? src : '/' + src;
|
||||
// Loupe : l'ORIGINAL, chargé seulement à l'agrandissement (le fil n'affiche
|
||||
// qu'une vignette calculée par le serveur).
|
||||
if(!img.dataset.zoom){
|
||||
img.dataset.zoom = '1';
|
||||
img.addEventListener('click', async ()=>{
|
||||
try{ openLightbox(await chatImgBlob(full), name); }
|
||||
catch(_){ openLightbox(img.currentSrc || img.src, name); }
|
||||
});
|
||||
}
|
||||
const thumb = full + (full.includes('?') ? '&' : '?') + 'thumb=' + CHAT_THUMB_PX;
|
||||
const cached = IMG_CACHE.get(thumb);
|
||||
if(cached){ img.src = cached; return; }
|
||||
try{
|
||||
const r = await jfetch(src.startsWith('/') ? src : '/' + src);
|
||||
if(!r.ok) throw new Error(r.status);
|
||||
const url = URL.createObjectURL(await r.blob());
|
||||
IMG_CACHE.set(src, url);
|
||||
img.src = url;
|
||||
img.src = await chatImgQueue(()=>chatImgBlob(thumb));
|
||||
}catch(e){
|
||||
// Pas de remplacement définitif pendant le STREAMING : l'URL peut être
|
||||
// tronquée en cours de frappe (404 transitoire) et le prochain delta
|
||||
@@ -275,6 +321,7 @@ function renderToolMsg(el, tu){
|
||||
write: {lbl:'fichier', head:'écriture'},
|
||||
edit: {lbl:'édition', head:'édition'},
|
||||
web_search: {lbl:'recherche', head:'recherche web'},
|
||||
web_images: {lbl:'images', head:"recherche d'images"},
|
||||
web_open: {lbl:'page web', head:'ouverture'},
|
||||
web_read: {lbl:'page web', head:'lecture'},
|
||||
web_grep: {lbl:'page web', head:'recherche'},
|
||||
|
||||
@@ -84,7 +84,7 @@ function confirmPending(text){
|
||||
let REPLAYING=true;
|
||||
// État de rendu du tour courant, délimité par les événements user / turn_done.
|
||||
let T=null;
|
||||
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
|
||||
function newTurn(){ T={ cuShotEl:null, 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 ------------------------------------------------
|
||||
@@ -187,6 +187,54 @@ function paintStats(el, speed){
|
||||
}
|
||||
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
|
||||
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
|
||||
// Aperçu EN DIRECT du navigateur piloté (repris d'AJEAN). Après chaque action
|
||||
// browser_*, le serveur garde une capture en mémoire (tu.shot = son id). Une
|
||||
// seule carte par tour : la nouvelle image arrive en fondu par-dessus
|
||||
// l'ancienne. Purement de l'interface : la carte s'efface à la fin du tour et
|
||||
// rien n'est gardé. Une capture à conserver passe par browser_screenshot.
|
||||
async function showCuShot(tu){
|
||||
if(REPLAYING) return;
|
||||
let card=T.cuShotEl;
|
||||
if(!card){
|
||||
card=document.createElement('div'); card.className='cu-shot';
|
||||
card.innerHTML='<div class="cu-shot-cap"><svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M3 9h18"/></svg><span class="cu-shot-t">navigateur en direct</span></div><div class="cu-shot-img"></div>';
|
||||
T.cuShotEl=card;
|
||||
}
|
||||
if(tu.name==='browser_open' && tu.label) card.querySelector('.cu-shot-t').textContent=tu.label;
|
||||
// Juste avant l'indicateur d'activité s'il est là, sinon en bas du fil.
|
||||
const chat=chatEl(), anchor=(T.typingEl && T.typingEl.parentNode===chat) ? T.typingEl : null;
|
||||
if(card.parentNode!==chat || card.nextSibling!==anchor){ if(anchor) chat.insertBefore(card, anchor); else chat.appendChild(card); }
|
||||
const my=(card._n=(card._n||0)+1); // ordre d'arrivée des captures
|
||||
let r; try{ r=await jget('/api/chat/cushot?id='+encodeURIComponent(tu.shot)); }catch(_){ return; }
|
||||
if(!r || !r.ok || T.cuShotEl!==card) return; // carte déjà retirée (tour fini)
|
||||
const box=card.querySelector('.cu-shot-img');
|
||||
const img=new Image(); img.alt=''; img.className='cu-frame';
|
||||
img.onclick=()=>openLightbox(img.src, card.querySelector('.cu-shot-t').textContent);
|
||||
img.src='data:'+(r.mime||'image/jpeg')+';base64,'+r.data;
|
||||
// Décodée AVANT d'entrer dans la page : jamais d'image à moitié peinte.
|
||||
try{ await img.decode(); }catch(_){ return; }
|
||||
// Une capture plus récente est déjà affichée (réponses dans le désordre) : on jette.
|
||||
if(T.cuShotEl!==card || my<(card._shown||0)) return;
|
||||
card._shown=my;
|
||||
box.appendChild(img);
|
||||
if(!box.style.aspectRatio) box.style.aspectRatio=img.naturalWidth+' / '+img.naturalHeight;
|
||||
setTimeout(()=>img.classList.add('in'), 20);
|
||||
// Fondu terminé : on retire les images PLUS ANCIENNES seulement.
|
||||
setTimeout(()=>{ let x=img.previousElementSibling; while(x){ const p=x.previousElementSibling; x.remove(); x=p; } }, 480);
|
||||
scrollMaybe();
|
||||
}
|
||||
// Fin du tour : l'aperçu s'efface en douceur. Toutes les cartes du fil, au cas
|
||||
// où un tour précédent en aurait laissé une.
|
||||
function hideCuShot(){
|
||||
if(T) T.cuShotEl=null;
|
||||
const chat=chatEl(); if(!chat) return;
|
||||
chat.querySelectorAll(':scope > .cu-shot').forEach(card=>{
|
||||
if(card.classList.contains('out')) return;
|
||||
card.style.height=card.offsetHeight+'px'; void card.offsetHeight;
|
||||
card.classList.add('out'); card.style.height='0px';
|
||||
setTimeout(()=>card.remove(), 380);
|
||||
});
|
||||
}
|
||||
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
|
||||
// d'ouvrir une bannière à part (on avait les deux en même temps pour un seul
|
||||
// état d'attente). En fin de tour l'indicateur a déjà été retiré : on le
|
||||
@@ -536,7 +584,7 @@ function handleDelta(d){
|
||||
// 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){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
|
||||
if(d.turn_done){ hideCuShot(); 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;
|
||||
@@ -545,7 +593,7 @@ function handleDelta(d){
|
||||
paintTurnClock(); // fige le total au-dessus de la carte
|
||||
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations();
|
||||
if(typeof filesOnActivity==='function') filesOnActivity(); return; }
|
||||
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
|
||||
if(d.error){ hideCuShot(); 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,
|
||||
@@ -582,6 +630,7 @@ function handleDelta(d){
|
||||
// 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 && tu.shot) showCuShot(tu);
|
||||
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
|
||||
|
||||
@@ -57,7 +57,7 @@ let lcSeenEnd = false, lcEndShown = false;
|
||||
function lcChipLabel(action){
|
||||
return {install:'Compilation du moteur', update:'Mise à jour du moteur',
|
||||
prebuilt:'Téléchargement du moteur', engine:'Mise à jour du moteur',
|
||||
custom:'Installation du backend'}[action] || 'Installation du moteur';
|
||||
custom:'Installation du backend', strata:'Installation de Strata'}[action] || 'Installation du moteur';
|
||||
}
|
||||
function lcChipSync(j){
|
||||
const chip = document.getElementById('lc-chip');
|
||||
@@ -137,26 +137,80 @@ async function loadEngine(){
|
||||
prebuilt:'précompilé', custom:'personnalisé'}[s.source] || '';
|
||||
const repo = document.getElementById('eng-repo');
|
||||
if(repo) repo.textContent = s.repo + ':' + (s.variant || 'server');
|
||||
// Version précédente (celle qui tournait avant la dernière bascule) : un
|
||||
// bouton à elle, sans réseau. Les autres boutons ne la répètent pas.
|
||||
const prev = s.previous || null;
|
||||
const prevBtn = document.getElementById('eng-prev');
|
||||
if(prevBtn){
|
||||
prevBtn.style.display = prev ? '' : 'none';
|
||||
if(prev) prevBtn.textContent = 'Revenir à la version précédente'
|
||||
+ (prev.source === 'image' ? ' (moteur de l\'image' + (prev.build ? ', b'+prev.build : '') + ')'
|
||||
: (prev.build ? ' (b'+prev.build+')' : ''));
|
||||
}
|
||||
// Le retour arrière n'a de sens que si on n'est pas déjà dessus.
|
||||
const revert = document.getElementById('eng-revert');
|
||||
if(revert) revert.style.display = (s.image_bin && s.source !== 'image') ? '' : 'none';
|
||||
if(revert) revert.style.display = (s.image_bin && s.source !== 'image' && !(prev && prev.source === 'image')) ? '' : 'none';
|
||||
// Versions téléchargées gardées à côté (la précédente survit à une mise à
|
||||
// jour) : revenir dessus ne demande ni réseau ni que le tag soit encore publié.
|
||||
const others = document.getElementById('eng-others');
|
||||
if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use).map(v=>{
|
||||
if(others) others.innerHTML = (s.installed || []).filter(v=>!v.in_use && !(prev && prev.tag === v.tag)).map(v=>{
|
||||
const tag = String(v.tag).replace(/[^A-Za-z0-9._-]/g,'');
|
||||
return '<button onclick="engUse(\''+tag+'\')">utiliser '+(v.build ? 'b'+v.build : tag)+'</button>';
|
||||
}).join(' ');
|
||||
// Build trop ancien pour les points de reprise des hybrides (voir
|
||||
// engineBuildNotice) : un avis, rien n'est changé d'office ici.
|
||||
// L'encart « version recommandée » reprend l'avis : il le remplace quand il
|
||||
// est affiché.
|
||||
const notice = document.getElementById('eng-notice');
|
||||
if(notice){
|
||||
notice.textContent = s.notice ? '⚠ '+s.notice : '';
|
||||
notice.style.display = s.notice ? '' : 'none';
|
||||
notice.style.display = (s.notice && !s.recommend) ? '' : 'none';
|
||||
}
|
||||
// Encart « version recommandée » : décidé côté serveur sans réseau ; le tag
|
||||
// n'est choisi qu'au clic (engRecommended).
|
||||
const reco = document.getElementById('eng-reco');
|
||||
if(reco){
|
||||
const r = s.recommend;
|
||||
reco.style.display = r ? '' : 'none';
|
||||
if(r){
|
||||
document.getElementById('eng-reco-min').textContent = r.min;
|
||||
document.getElementById('eng-reco-cur').textContent = r.current;
|
||||
document.getElementById('eng-reco-gains').innerHTML = (r.gains||[])
|
||||
.map(g=>'<li>'+String(g).replace(/[<>&]/g,'')+'</li>').join('');
|
||||
}
|
||||
}
|
||||
engRenderCheck(s.render_check);
|
||||
engSay('');
|
||||
}
|
||||
|
||||
// Rendu du gabarit comparé avant/après la dernière bascule (web_engine_reco.go).
|
||||
// « pending » : le nouveau moteur charge encore le modèle — on repasse plus
|
||||
// tard tant que le panneau est ouvert.
|
||||
let engRenderTimer = null;
|
||||
function engRenderCheck(c){
|
||||
const el = document.getElementById('eng-render');
|
||||
if(!el) return;
|
||||
const esc = x=>String(x||'').replace(/[<>&]/g,'');
|
||||
if(!c){ el.style.display = 'none'; return; }
|
||||
el.style.display = '';
|
||||
el.className = c.status === 'changed' ? '' : 'muted';
|
||||
if(c.status === 'pending'){
|
||||
el.innerHTML = '⏳ vérification du rendu du gabarit après '+esc(c.tag)+' (une fois le modèle chargé)…';
|
||||
if(!engRenderTimer) engRenderTimer = setTimeout(()=>{
|
||||
engRenderTimer = null;
|
||||
const pane = document.getElementById('lc-details');
|
||||
if(pane && pane.offsetParent !== null) loadEngine();
|
||||
}, 10000);
|
||||
} else if(c.status === 'same'){
|
||||
el.innerHTML = '✓ rendu du gabarit identique avant et après '+esc(c.tag);
|
||||
} else if(c.status === 'changed'){
|
||||
el.innerHTML = '⚠ <b>le prompt rendu a changé avec '+esc(c.tag)+'</b> — '+esc(c.detail)
|
||||
+ '<br>Si ce sont des blocs de réflexion vides dans l\'historique, <code>REASONING_PRESERVE=off</code> dans le preset rétablit le rendu d\'avant ; « Revenir à la version précédente » annule la mise à jour.';
|
||||
} else {
|
||||
el.innerHTML = 'rendu du gabarit non vérifié après '+esc(c.tag)+(c.detail ? ' : '+esc(c.detail) : '');
|
||||
}
|
||||
}
|
||||
|
||||
// engSay écrit sous la ligne d'état : c'est le résultat d'une vérification, qui
|
||||
// doit rester lisible après le toast (on ne se souvient pas d'un toast).
|
||||
function engSay(html, cls){
|
||||
@@ -182,17 +236,58 @@ async function engCheck(){
|
||||
}
|
||||
}
|
||||
|
||||
async function engUpdate(){
|
||||
if(!await askConfirm('Télécharger la dernière version de llama.cpp (~170 Mo) et l\'utiliser à la place du moteur de l\'image.\n\n'
|
||||
+ 'Le moteur est essayé avant d\'être adopté ; celui de l\'image reste intact. Le moteur redémarre à la fin — la génération en cours sera coupée.',
|
||||
{title:'Mettre à jour le moteur', okText:'Mettre à jour'})) return;
|
||||
async function engUpdate(){ return engPlanAndUpdate(false); }
|
||||
async function engRecommended(){ return engPlanAndUpdate(true); }
|
||||
|
||||
// engPlanAndUpdate : sur clic seulement. Le serveur choisit la version (la
|
||||
// dernière publiée, ou la plus récente ≥ minimum recommandé) et dit si le
|
||||
// prompt rendu va changer (preserve_reasoning par défaut depuis b10763) ; la
|
||||
// confirmation montre les deux, et la case REASONING_PRESERVE=off laisse le
|
||||
// choix à l'utilisateur — cochée d'office seulement quand le changement est
|
||||
// établi, puisque sur un gabarit qui garde déjà la réflexion, off changerait
|
||||
// à son tour le rendu.
|
||||
async function engPlanAndUpdate(recommended){
|
||||
toast(recommended ? 'recherche de la version recommandée…' : 'recherche de la dernière version…');
|
||||
let p;
|
||||
try{ p = await jpost('/api/engine/plan', {recommended}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!p.ok){ engSay('✗ '+String(p.error||'').replace(/[<>&]/g,'')); toast('erreur'); return; }
|
||||
if(p.uptodate){ engSay('Moteur à jour ✓ (b'+p.build+')'); toast('moteur à jour ✓'); return; }
|
||||
const what = p.build ? 'b'+p.build+' ('+p.tag+')' : p.tag;
|
||||
let msg = 'Télécharger llama.cpp '+what+' (~170 Mo) et l\'utiliser à la place du moteur actuel'
|
||||
+ (p.current ? ' (b'+p.current+')' : '') + '.\n\n'
|
||||
+ 'Le moteur est essayé avant d\'être adopté ; '
|
||||
+ (p.keeps ? 'la version actuelle ('+p.keeps+') est gardée' : 'celui de l\'image reste intact')
|
||||
+ ' pour revenir en arrière. Le moteur redémarre à la fin — la génération en cours sera coupée.';
|
||||
const pr = p.preserve || {risk:'no'};
|
||||
const opts = {title: recommended ? 'Version recommandée' : 'Mettre à jour le moteur', okText:'Mettre à jour'};
|
||||
if(pr.risk !== 'no'){
|
||||
msg += '\n\n⚠ Rendu du raisonnement : '+(pr.why||'');
|
||||
opts.check = 'poser REASONING_PRESERVE=off (garde le rendu actuel de l\'historique)';
|
||||
opts.checked = !!pr.suggest;
|
||||
}
|
||||
if(!await askConfirm(msg, opts)) return;
|
||||
const preserve_off = pr.risk !== 'no' && askChecked();
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/update', {}); }catch(_){ toast('erreur réseau'); return; }
|
||||
try{ r = await jpost('/api/engine/update', {tag: p.tag, preserve_off}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
openDetails('lc-details');
|
||||
lcStartPolling();
|
||||
}
|
||||
|
||||
// Retour au moteur d'avant la dernière bascule — sans réseau.
|
||||
async function engRollback(){
|
||||
const prev = (engState && engState.previous) || null;
|
||||
if(!prev) return;
|
||||
const what = prev.source === 'image' ? 'le moteur de l\'image' : (prev.build ? 'b'+prev.build : (prev.tag || prev.bin));
|
||||
if(!await askConfirm('Revenir à la version précédente ('+what+'). Le moteur redémarre.',
|
||||
{title:'Revenir à la version précédente', okText:'Revenir'})) return;
|
||||
let r;
|
||||
try{ r = await jpost('/api/engine/rollback', {}); }catch(_){ toast('erreur réseau'); return; }
|
||||
if(!r.ok){ toast('erreur : '+(r.error||'')); return; }
|
||||
toast('version précédente rétablie — redémarrage en cours');
|
||||
loadAll();
|
||||
}
|
||||
|
||||
// Bascule vers une version déjà installée — « image » = celle d'origine.
|
||||
async function engUse(tag){
|
||||
const image = tag === 'image';
|
||||
@@ -358,10 +453,21 @@ function lcStartPolling(){
|
||||
lcPollJob();
|
||||
}
|
||||
|
||||
// Une seule requête à la fois : l'intervalle d'une seconde relançait un appel
|
||||
// avant la réponse du précédent quand le serveur était occupé (téléchargement,
|
||||
// accès distant), et les deux ajoutaient les MÊMES lignes au journal.
|
||||
let lcPollInflight = false;
|
||||
async function lcPollJob(quiet){
|
||||
if(lcPollInflight) return;
|
||||
lcPollInflight = true;
|
||||
const from = lcLogNext;
|
||||
let j;
|
||||
try{ j = await jget('/api/llamacpp/job?from='+lcLogNext); }catch(_){ return; }
|
||||
try{ j = await jget('/api/llamacpp/job?from='+from); }catch(_){ return; }
|
||||
finally{ lcPollInflight = false; }
|
||||
if(!j.exists) return;
|
||||
// Le journal a été remis à zéro ou avancé entre-temps (nouveau job) : ces
|
||||
// lignes ne suivent plus ce qui est affiché.
|
||||
if(from !== lcLogNext) return;
|
||||
const phaseEl = document.getElementById('lc-job-phase');
|
||||
if(j.lines && j.lines.length){
|
||||
const pre = document.getElementById('lc-log');
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Moteur Strata : installation « en un clic » du moteur spécialisé Qwen3.8 Flash
|
||||
// Next (repris d'AJEAN, « AJEAN MoE »). L'utilisateur choisit la version (Swift
|
||||
// ou Classique) et la qualité (quant) ; tout le reste (cartes, RAM, mmap,
|
||||
// réglages) est déduit côté serveur (backend_strata.go). L'installation est une
|
||||
// tâche comme celles de llama.cpp : même suivi (#lc-job), même pastille.
|
||||
let strataState = null, strataFam = '', strataQuant = '', strataTarget = '';
|
||||
|
||||
async function loadStrata(){
|
||||
let s;
|
||||
try{ s = await jget('/api/strata'); }catch(_){ return; }
|
||||
strataState = s;
|
||||
const row = document.getElementById('lc-strata');
|
||||
if(!row) return;
|
||||
// Hors Linux + NVIDIA, la ligne reste visible mais dit pourquoi : choisir son
|
||||
// moteur, c'est aussi savoir pourquoi l'un des deux n'est pas proposé.
|
||||
const env = s.env || {};
|
||||
row.classList.toggle('moe-off', !env.supported);
|
||||
const st = document.getElementById('lc-strata-state');
|
||||
if(st){
|
||||
const n = (s.installed||[]).length;
|
||||
st.textContent = !env.supported ? (env.reason || 'indisponible sur cette machine')
|
||||
: s.active ? 'moteur actif' + (n > 1 ? ' · '+n+' modèles installés' : '')
|
||||
: n ? n+' modèle(s) installé(s)' : '';
|
||||
}
|
||||
const eng = document.getElementById('lc-active-engine');
|
||||
if(eng) eng.textContent = s.active ? 'Strata' : 'llama.cpp';
|
||||
}
|
||||
|
||||
// presetId (optionnel) : ouverte depuis l'engrenage d'un modèle installé, la
|
||||
// fenêtre se place directement sur lui.
|
||||
async function openStrata(presetId){
|
||||
// état relu à chaque ouverture : juste après une installation, l'ancien
|
||||
// proposait encore « Installer »
|
||||
await loadStrata();
|
||||
if(!strataState) return;
|
||||
const s = strataState, env = s.env || {};
|
||||
if(!env.supported){ toast('Strata : '+(env.reason||'indisponible sur cette machine')); return; }
|
||||
strataTarget = typeof presetId === 'string' ? presetId : '';
|
||||
const cards = (env.gpus||[]).filter(g=>g.index===env.main || g.index===env.helper)
|
||||
.sort((a,b)=>(a.index===env.main?-1:1))
|
||||
.map(g=>g.name.replace(/^NVIDIA GeForce /,'')+' '+Math.round(g.vram_gb)+' Go');
|
||||
document.getElementById('strata-machine').textContent =
|
||||
'Machine : '+cards.join(' + ')+', '+Math.round(env.ram_gb)+' Go de RAM dont '
|
||||
+Math.round(env.avail_gb||env.ram_gb)+' disponibles, '
|
||||
+Math.round(env.disk_free_gb)+' Go de disque libres. Réglages automatiques.';
|
||||
if(!strataFam) strataFam = (s.families[0]||{}).id || '';
|
||||
if(strataTarget){
|
||||
for(const f of s.families){
|
||||
const hit = f.fits.find(x=>x.preset===strataTarget);
|
||||
if(hit){ strataFam = f.id; strataQuant = hit.quant.id; }
|
||||
}
|
||||
}
|
||||
strataRenderFams();
|
||||
strataRenderQuants(!strataTarget);
|
||||
showModal('strata-modal');
|
||||
}
|
||||
function closeStrata(){ hideModal('strata-modal'); }
|
||||
|
||||
function strataRenderFams(){
|
||||
const box = document.getElementById('strata-fams');
|
||||
box.innerHTML = strataState.families.map(f=>
|
||||
'<label><input type="radio" name="strata-fam" value="'+f.id+'"'+(f.id===strataFam?' checked':'')
|
||||
+' onchange="strataFam=this.value;strataRenderQuants(true)">'
|
||||
+'<span>'+escHtml(f.label)+'</span><span class="be-note">'+escHtml(f.about)+'</span></label>').join('');
|
||||
}
|
||||
|
||||
function strataRenderQuants(pickReco){
|
||||
const f = strataState.families.find(x=>x.id===strataFam);
|
||||
if(!f) return;
|
||||
// à l'ouverture : le modèle actif s'il est de cette version, sinon un installé, sinon le conseillé
|
||||
if(pickReco || !f.fits.some(x=>x.quant.id===strataQuant && (x.ok || x.preset))){
|
||||
const cur = f.fits.find(x=>x.active) || f.fits.find(x=>x.preset);
|
||||
strataQuant = cur ? cur.quant.id : (f.recommend || '');
|
||||
}
|
||||
const box = document.getElementById('strata-quants');
|
||||
box.innerHTML = f.fits.map(x=>{
|
||||
const q = x.quant, sel = q.id===strataQuant;
|
||||
const tag = x.active ? 'actif' : x.preset ? 'installé' : q.id===f.recommend ? 'conseillé' : '';
|
||||
const reco = tag ? ' <span class="lc-reco">'+tag+'</span>' : '';
|
||||
const usable = x.ok || !!x.preset; // installé : activable même si l'espace disque manque
|
||||
const desc = x.preset ? escHtml(q.about)
|
||||
: x.ok ? escHtml(q.about)+' · ~'+Math.round(x.disk_gb)+' Go sur le disque'
|
||||
: escHtml(x.why);
|
||||
return '<div class="lc-mode'+(sel?' moe-sel':'')+(usable?'':' moe-off')+'"'
|
||||
+(usable?' onclick="strataQuant=\''+q.id+'\';strataRenderQuants(false)"':'')+'>'
|
||||
+'<div class="lc-mode-t">'+q.id+reco+'</div><div class="lc-mode-d">'+desc+'</div></div>';
|
||||
}).join('');
|
||||
const fit = f.fits.find(x=>x.quant.id===strataQuant);
|
||||
const go = document.getElementById('strata-go');
|
||||
go.disabled = !fit || !!fit.active;
|
||||
go.textContent = fit && fit.active ? 'Déjà actif' : fit && fit.preset ? 'Activer' : 'Installer';
|
||||
strataRenderDisk(fit);
|
||||
strataRenderSettings(fit);
|
||||
strataRenderDetails(fit);
|
||||
document.getElementById('strata-note').textContent = !fit ? 'Aucune version ne tient sur cette machine.'
|
||||
: fit.active ? 'Modèle actif.' : fit.preset ? 'Modèle installé, prêt à être activé.'
|
||||
: fit.mmap ? 'RAM disponible insuffisante pour tout charger : les experts seront lus depuis le disque (mmap, un peu plus lent). Installation et activation automatiques.'
|
||||
: 'Installation et activation automatiques. Premier chargement : quelques minutes.';
|
||||
}
|
||||
|
||||
// Place disque : libre, ce que ce choix occupe, ce qui restera.
|
||||
function strataRenderDisk(fit){
|
||||
const el = document.getElementById('strata-disk');
|
||||
const free = Math.round((strataState.env||{}).disk_free_gb||0);
|
||||
if(!fit){ el.textContent = ''; return; }
|
||||
if(fit.preset){ el.textContent = 'Disque : '+free+' Go libres. Modèle déjà présent.'; return; }
|
||||
const need = Math.round(fit.disk_gb), left = free - need;
|
||||
el.textContent = left >= 5
|
||||
? 'Disque : '+free+' Go libres, ~'+need+' Go requis, ~'+left+' Go restants.'
|
||||
: 'Disque : '+free+' Go libres, ~'+need+' Go requis. Espace insuffisant.';
|
||||
}
|
||||
|
||||
// Réglages modifiables d'un modèle installé.
|
||||
function strataRenderSettings(fit){
|
||||
const fld = document.getElementById('strata-settings-fld'), save = document.getElementById('strata-save');
|
||||
const d = fit && fit.details;
|
||||
fld.hidden = save.hidden = !d;
|
||||
if(!d) return;
|
||||
const sel = (id, opts, cur) => '<select class="ctl" id="'+id+'" style="width:auto">'
|
||||
+ opts.map(([v,l])=>'<option value="'+v+'"'+(String(v)===String(cur)?' selected':'')+'>'+escHtml(l)+'</option>').join('')+'</select>';
|
||||
const sw = (id, on) => '<label class="switch"><input type="checkbox" id="'+id+'"'+(on?' checked':'')+'><span class="slider"></span></label>';
|
||||
const rows = [
|
||||
['Contexte', '<span class="moe-ctx"><input type="range" id="strata-s-ctx" min="32768" max="262144" step="4096" value="'+(+d.ctx||131072)+'" oninput="strataCtxLbl()"><span id="strata-s-ctx-v"></span></span>'],
|
||||
['Cache KV', sel('strata-s-kv', [['fp16', 'fp16 (meilleure qualité)'],['int8', '8 bits (plus léger)']], d.kv)],
|
||||
['Prédiction (MTP)', sel('strata-s-spec', [[2,'2'],[3,'3'],[4,'4']], d.spec)],
|
||||
// Placement des experts : auto décide AU LANCEMENT sur la RAM disponible ;
|
||||
// « disque » ne charge rien en RAM au démarrage (jamais tué faute de RAM).
|
||||
['Experts', sel('strata-s-experts', [['auto','auto (selon la RAM libre)'],['ram','en RAM (le plus rapide)'],['disk','depuis le disque (mmap, économe)']], d.experts||'auto')],
|
||||
['Lire les images', sw('strata-s-vision', d.vision)],
|
||||
];
|
||||
if(d.has_helper) rows.push(["Carte d'appoint", sw('strata-s-helper', d.helper_on)]);
|
||||
document.getElementById('strata-settings').innerHTML = rows.map(([a,b])=>
|
||||
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+b+'</span></div>').join('');
|
||||
strataCtxLbl();
|
||||
}
|
||||
// valeur exacte en jetons (131 072 et non « 128K », source de confusion)
|
||||
function strataCtxLbl(){
|
||||
const r = document.getElementById('strata-s-ctx'), o = document.getElementById('strata-s-ctx-v');
|
||||
if(!r || !o) return;
|
||||
o.textContent = (+r.value).toLocaleString('fr-FR');
|
||||
// partie remplie à gauche de la pastille (la piste est dessinée en CSS)
|
||||
r.style.setProperty('--p', ((r.value - r.min) / (r.max - r.min) * 100) + '%');
|
||||
}
|
||||
|
||||
async function strataSaveSettings(){
|
||||
const f = strataState && strataState.families.find(x=>x.id===strataFam);
|
||||
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
|
||||
if(!fit || !fit.preset) return;
|
||||
const v = id => document.getElementById(id);
|
||||
const body = {preset: fit.preset, ctx: +v('strata-s-ctx').value, kv: v('strata-s-kv').value, spec: +v('strata-s-spec').value,
|
||||
vision: v('strata-s-vision').checked, helper: v('strata-s-helper') ? v('strata-s-helper').checked : false,
|
||||
experts: v('strata-s-experts') ? v('strata-s-experts').value : ''};
|
||||
if(fit.active && !await askConfirm('Le modèle va redémarrer avec ces réglages (quelques minutes).', {title:'Strata — Qwen3.8 Flash Next', okText:'Enregistrer'})) return;
|
||||
const r = await jpost('/api/strata/settings', body);
|
||||
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
|
||||
toast(r.restarted ? 'Réglages enregistrés, redémarrage en cours' : 'Réglages enregistrés');
|
||||
strataTarget = fit.preset;
|
||||
await openStrata(fit.preset);
|
||||
if(typeof loadPresets === 'function') loadPresets();
|
||||
}
|
||||
|
||||
// La config qui tourne (ou tournera) pour un modèle installé.
|
||||
function strataRenderDetails(fit){
|
||||
const fld = document.getElementById('strata-details-fld');
|
||||
const d = fit && fit.details;
|
||||
fld.hidden = !d;
|
||||
if(!d) return;
|
||||
const k = n => (+n >= 1024 ? Math.round(+n/1024)+'K' : n);
|
||||
const rows = [
|
||||
['Carte principale', d.main_gpu],
|
||||
["Carte d'appoint", d.helper_gpu || 'aucune'],
|
||||
['Lecture des images', d.vision_gpu || 'aucune'],
|
||||
['Contexte', (+d.ctx).toLocaleString('fr-FR')+' jetons'],
|
||||
['Cache KV', d.kv + (d.kv_resident ? ' · '+k(d.kv_resident)+' en VRAM, le reste en RAM' : '')],
|
||||
['Prédiction (MTP)', d.spec+' jetons'],
|
||||
['Experts hors GPU', (d.mode_label || (d.mmap ? 'lus depuis le disque (mmap)' : 'chargés en RAM'))
|
||||
+ (fit.active ? ' — dernier lancement' : ' — selon la RAM libre maintenant')],
|
||||
['Lecture des prompts', 'blocs de '+k(d.prefill)+', lecture directe sous '+d.short_read+' jetons'],
|
||||
["Démarrage d'une conversation", 'reprise après le prompt système (dès '+d.cache_root+' jetons)'],
|
||||
['Version du moteur', 'paquet AJEAN MoE '+d.version],
|
||||
];
|
||||
document.getElementById('strata-details').innerHTML = rows.map(([a,b])=>
|
||||
'<div class="moe-kv"><span>'+escHtml(a)+'</span><span>'+escHtml(b)+'</span></div>').join('');
|
||||
}
|
||||
|
||||
async function strataInstall(){
|
||||
const f = strataState && strataState.families.find(x=>x.id===strataFam);
|
||||
const fit = f && f.fits.find(x=>x.quant.id===strataQuant);
|
||||
if(!fit || fit.active) return;
|
||||
// déjà installé : simple bascule, la même que dans la liste des modèles
|
||||
if(fit.preset){
|
||||
closeStrata();
|
||||
await switchTo(-1, f.label+' '+strataQuant, fit.preset);
|
||||
loadStrata();
|
||||
return;
|
||||
}
|
||||
const msg = 'Installer Qwen3.8 Flash Next '+f.label+' '+strataQuant+' sur Strata ? Téléchargement : ~'
|
||||
+Math.round(fit.disk_gb)+' Go. Le modèle sera activé à la fin.';
|
||||
if(!await askConfirm(msg, {title:'Strata — Qwen3.8 Flash Next', okText:'Installer'})) return;
|
||||
const r = await jpost('/api/strata/install', {family:strataFam, quant:strataQuant});
|
||||
if(!r.ok){ toast('Erreur : '+(r.error||'')); return; }
|
||||
closeStrata();
|
||||
const det = document.getElementById('lc-details');
|
||||
if(det && 'open' in det) det.open = true;
|
||||
lcStartPolling();
|
||||
}
|
||||
@@ -215,6 +215,18 @@ function markFileLinks(root){
|
||||
for(const a of root.querySelectorAll('a[href]')){
|
||||
const p=fileLinkPath(a.getAttribute('href'));
|
||||
if(!p) continue;
|
||||
// Lien vers une IMAGE ([le graphique](graph.png)) : on l'affiche au lieu d'un
|
||||
// simple bouton de téléchargement (repris d'AJEAN). hydrateImages, appelé
|
||||
// juste après, la charge en vignette ; un clic ouvre l'original. Formats que
|
||||
// le navigateur affiche seulement (pas de SVG servi tel quel, ni de HEIC).
|
||||
if(/\.(png|jpe?g|gif|webp|bmp|avif)$/i.test(p)){
|
||||
const img=document.createElement('img');
|
||||
img.setAttribute('src', '/api/chat/image?path='+encodeURIComponent(p));
|
||||
img.alt=(a.textContent||'').trim() || p.split('/').pop();
|
||||
img.loading='lazy';
|
||||
a.replaceWith(img);
|
||||
continue;
|
||||
}
|
||||
a.classList.add('filelink');
|
||||
a.removeAttribute('target');
|
||||
a.setAttribute('href','#');
|
||||
|
||||
@@ -108,7 +108,8 @@ function renderModelSwitch(presets, active){
|
||||
async function loadModelSwitchFiles(){
|
||||
try{
|
||||
const r = await jget('/api/models');
|
||||
MS_FILES = Array.isArray(r) ? r : [];
|
||||
// Projecteurs vision exclus : choisis comme modèle, le moteur ne démarre pas.
|
||||
MS_FILES = (Array.isArray(r) ? r : []).filter(m => !/mmproj/i.test(String(m.name||'')));
|
||||
}catch(_){ return; }
|
||||
paintModelSwitch();
|
||||
}
|
||||
|
||||
@@ -38,6 +38,21 @@ const I18N_EN = {
|
||||
'Tâches planifiées': 'Scheduled tasks',
|
||||
'System prompt': 'System prompt',
|
||||
'Moteur llama.cpp': 'llama.cpp engine',
|
||||
// Moteur Strata (10b-strata.js)
|
||||
'Version': 'Version',
|
||||
'Qualité': 'Quality',
|
||||
'Configuration': 'Configuration',
|
||||
'Installer': 'Install',
|
||||
'Activer': 'Activate',
|
||||
'Déjà actif': 'Already active',
|
||||
'Cache KV': 'KV cache',
|
||||
'Prédiction (MTP)': 'Prediction (MTP)',
|
||||
'Lire les images': 'Read images',
|
||||
"Carte d'appoint": 'Helper card',
|
||||
'Carte principale': 'Main card',
|
||||
'conseillé': 'recommended',
|
||||
'installé': 'installed',
|
||||
'actif': 'active',
|
||||
'Journal du moteur': 'Engine log',
|
||||
'Dictée': 'Dictation',
|
||||
'Identité': 'Identity',
|
||||
|
||||
@@ -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,.preset-meta .xtag{font-size:11px;padding:2px 9px;border-radius:4px}
|
||||
.preset-meta .qtag,.preset-meta .rtag,.preset-meta .btag,.preset-meta .xtag,.preset-meta .ctag,.preset-meta .captag{font-size:11px;padding:2px 9px;border-radius:4px}
|
||||
.preset-meta .qtag svg,.preset-meta .rtag svg,.preset-meta .btag svg,.preset-meta .xtag svg{width:11px;height:11px}
|
||||
}
|
||||
h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
@@ -266,6 +266,9 @@ h1{margin:0;font-size:18px;color:var(--accent)}
|
||||
(étiquette comprise), plus sur la carte seule. */
|
||||
.gated-group{margin:0 0 10px;transition:opacity .15s}
|
||||
.gated-group.gated{opacity:.4;pointer-events:none;filter:grayscale(.4)}
|
||||
/* L'aide « ? » reste survolable sur un réglage grisé : c'est justement là qu'on
|
||||
veut savoir à quoi il sert (repris d'AJEAN, #104). */
|
||||
.gated-group.gated .help{pointer-events:auto}
|
||||
.switch{position:relative;display:inline-block;width:36px;height:20px;flex:none}
|
||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
||||
/* Piste décochée : un gris franc (pas --border), sinon le pavé blanc ne se
|
||||
@@ -363,6 +366,17 @@ button:disabled{opacity:.5;cursor:not-allowed}
|
||||
.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: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}
|
||||
.ctag{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}
|
||||
/* Pastille « capacités » (repris d'AJEAN) : vision (œil) et raisonnement
|
||||
(ampoule + niveau). Icônes en em : la bulle a la hauteur des autres tags. */
|
||||
.captag{flex-shrink:0;display:inline-flex;align-items:center;gap:4px;font-size:9px;padding:1px 6px;border:1px solid var(--border);color:var(--dim);border-radius:4px;letter-spacing:.03em}
|
||||
.captag::before{content:"\200b";margin-right:-4px}
|
||||
.captag svg{display:block;width:1.3em;height:1.3em}
|
||||
/* Réordonnancement des presets (SortableJS) : clone plein qui suit le curseur,
|
||||
emplacement cible discret. */
|
||||
.preset-drag{cursor:grabbing;background:var(--row-bg-on);border:none;border-radius:6px;box-shadow:0 6px 18px rgba(0,0,0,.35);outline:none;opacity:1!important}
|
||||
.preset-ghost{opacity:.35}
|
||||
.preset-chosen{cursor:grabbing}
|
||||
/* Preset externe : le seul tag qui prend la couleur d'accent. Il ne décrit pas
|
||||
un réglage mais une DESTINATION — la réponse part sur internet, pas sur la
|
||||
carte graphique d'à côté, et ça mérite de se voir du premier coup d'œil.
|
||||
@@ -767,6 +781,32 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
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:4px;padding:1px 6px}
|
||||
.lc-mode-d{font-size:11px;color:var(--dim);margin-top:3px}
|
||||
/* Strata (10b-strata.js) : liste des qualités, réglages et configuration. */
|
||||
.slist>.lc-modes#lc-engines{border-top:1px solid var(--border)}
|
||||
#lc-strata.moe-off{cursor:default}
|
||||
#lc-strata.moe-off:hover{background:none}
|
||||
#lc-strata.moe-off .lc-mode-t{color:var(--dim)}
|
||||
#strata-quants{overflow:hidden}
|
||||
#strata-quants .lc-mode.moe-sel{background:var(--accent-bg)}
|
||||
#strata-quants .lc-mode.moe-sel .lc-mode-t{color:var(--accent)}
|
||||
#strata-quants .lc-mode.moe-off{cursor:default}
|
||||
#strata-quants .lc-mode.moe-off:hover{background:none}
|
||||
#strata-quants .lc-mode.moe-off .lc-mode-t{color:var(--dim)}
|
||||
.moe-details{padding:4px 10px}
|
||||
.moe-details .moe-kv{display:flex;justify-content:space-between;align-items:center;gap:12px;font-size:12px;padding:5px 0}
|
||||
.moe-details .moe-kv .switch{display:block}
|
||||
.moe-details .moe-kv:not(:last-child){border-bottom:1px solid var(--border)}
|
||||
.moe-details .moe-kv span:first-child{color:var(--dim)}
|
||||
.moe-details .moe-kv span:last-child{text-align:right}
|
||||
.moe-ctx{display:flex;align-items:center;gap:10px}
|
||||
.moe-ctx input[type=range]{width:160px;height:16px;margin:0;padding:0;background:transparent;border:none;-webkit-appearance:none;appearance:none;cursor:ew-resize}
|
||||
.moe-ctx input[type=range]:focus{outline:none}
|
||||
.moe-ctx input[type=range]::-webkit-slider-runnable-track{height:4px;border:none;border-radius:999px;background:linear-gradient(to right,var(--accent) var(--p,50%),color-mix(in srgb,var(--dim) 25%,transparent) var(--p,50%))}
|
||||
.moe-ctx input[type=range]::-moz-range-track{height:4px;border:none;border-radius:999px;background:color-mix(in srgb,var(--dim) 25%,transparent)}
|
||||
.moe-ctx input[type=range]::-moz-range-progress{height:4px;border-radius:999px;background:var(--accent)}
|
||||
.moe-ctx input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:none;border-radius:50%;background:var(--accent)}
|
||||
.moe-ctx input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:var(--accent)}
|
||||
.moe-ctx span{min-width:62px;text-align:right;font-variant-numeric:tabular-nums}
|
||||
/* 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. */
|
||||
.lc-mode-state{font-size:11px;min-height:16px;margin-top:7px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
|
||||
@@ -1038,6 +1078,11 @@ textarea.sctl{min-height:76px;resize:vertical;line-height:1.5}
|
||||
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. */
|
||||
.eng-ver{font-family:var(--mono);text-transform:none;letter-spacing:0;color:var(--text)}
|
||||
/* Encart « version recommandée » du panneau Moteur : un conseil, pas une alerte
|
||||
— bordure discrète, liste serrée des gains. */
|
||||
.eng-reco{margin-top:8px;padding:8px 10px;border:1px solid var(--border);border-radius:4px;background:var(--panel)}
|
||||
.eng-reco ul{margin:4px 0 4px 16px;padding:0}
|
||||
.eng-reco li{margin:1px 0}
|
||||
.mcp-tools{font-size:10px;color:var(--accent)}
|
||||
.mcp-err{font-size:10px;color:var(--err)}
|
||||
.mcp-switch{flex:none}
|
||||
@@ -2225,3 +2270,17 @@ html[data-files="1"] #files-btn{color:var(--accent)}
|
||||
transition:background .15s,color .15s,border-color .15s}
|
||||
#chat .history-more button:hover{color:var(--text);border-color:var(--dim);background:var(--row-bg-on)}
|
||||
#chat .history-more button:disabled{opacity:.6;cursor:default}
|
||||
/* Aperçu en direct du navigateur piloté (09-stream.js showCuShot, repris d'AJEAN). */
|
||||
.cu-shot{flex-shrink:0;align-self:flex-start;width:min(640px,100%);margin:2px 0 6px;overflow:hidden;
|
||||
transition:height .35s cubic-bezier(.2,.7,.3,1),opacity .3s ease,margin .35s ease;animation:cushotcard .3s ease both}
|
||||
.cu-shot.out{opacity:0;margin:0}
|
||||
@keyframes cushotcard{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
|
||||
.cu-shot-cap{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--dim);margin:0 0 6px 2px;min-width:0}
|
||||
.cu-shot-cap svg{flex:none}
|
||||
.cu-shot-t{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cu-shot-img{position:relative;border-radius:12px;overflow:hidden;background:var(--row-bg);
|
||||
box-shadow:0 4px 18px rgba(0,0,0,.10);aspect-ratio:16 / 10}
|
||||
.cu-shot-img img.cu-frame{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;object-position:top;
|
||||
opacity:0;transition:opacity .45s ease;cursor:zoom-in}
|
||||
.cu-shot-img img.cu-frame.in{opacity:1}
|
||||
@media (prefers-reduced-motion:reduce){.cu-shot,.cu-shot-img img.cu-frame{transition:none;animation:none}}
|
||||
@@ -107,9 +107,17 @@ func modelLoadErrorFrom(log string) string {
|
||||
// On ne considère que ce qui suit la DERNIÈRE tentative de chargement.
|
||||
start := 0
|
||||
for i, l := range lines {
|
||||
// Une pile d'appels cite les fonctions traversées
|
||||
// (…server_context_impl10load_model…) : ce n'est pas une nouvelle
|
||||
// tentative, et repartir de là perdait la cause écrite juste avant
|
||||
// (« cudaMalloc failed: out of memory » devenait « a planté »).
|
||||
if isBacktraceFrame(l) {
|
||||
continue
|
||||
}
|
||||
// Un lancement refusé par LOAD_GUARD (backend_serve_moe.go) s'arrête
|
||||
// avant tout chargement : c'est lui, la dernière tentative.
|
||||
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) {
|
||||
if strings.Contains(l, "loading model") || strings.Contains(l, "load_model") || strings.Contains(l, loadGuardMarker) ||
|
||||
strings.Contains(l, strataServeMarker) {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
@@ -117,8 +125,16 @@ func modelLoadErrorFrom(log string) string {
|
||||
for _, l := range lines[start:] {
|
||||
low := strings.ToLower(l)
|
||||
switch {
|
||||
case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"):
|
||||
case strings.Contains(low, "model loaded"), strings.Contains(low, "server is listening"),
|
||||
strings.HasPrefix(l, "ready: http://"): // serveur de Strata prêt
|
||||
loaded = true
|
||||
// Serveur de Strata (Python) : une trace = il s'est arrêté, pas « il charge encore ».
|
||||
case strings.Contains(l, "Traceback (most recent call last)"):
|
||||
if reason == "" {
|
||||
reason = "le serveur de Strata s'est arrêté (trace Python dans le journal)"
|
||||
}
|
||||
case strings.Contains(l, "[strata] config"):
|
||||
reason = "configuration de Strata refusée (détail dans le journal) — réinstalle le modèle"
|
||||
case strings.Contains(low, "failed to create mtp context"),
|
||||
strings.Contains(low, "failed to load draft model"),
|
||||
strings.Contains(low, "failed to initialize speculative decoding context"):
|
||||
@@ -150,7 +166,8 @@ func modelLoadErrorFrom(log string) string {
|
||||
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"
|
||||
reason = "mémoire GPU insuffisante (VRAM) — réduis le contexte (CTX) ou prends une quantification plus petite, " +
|
||||
"et vérifie qu'aucun autre programme n'occupe la carte (nvidia-smi)"
|
||||
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)"
|
||||
@@ -187,6 +204,10 @@ func modelLoadErrorFrom(log string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// isBacktraceFrame : une ligne de pile d'appels de glibc
|
||||
// (« /chemin/lib.so(symbole+0x1c)[0x14bd…] »).
|
||||
func isBacktraceFrame(l string) bool { return strings.Contains(l, ")[0x") }
|
||||
|
||||
// handleServiceLog (GET /api/service/log) : dernières lignes du journal du
|
||||
// service, pour que l'UI puisse montrer POURQUOI le modèle ne se charge pas
|
||||
// (fichier .gguf illisible, VRAM insuffisante, bibliothèque manquante…) au lieu
|
||||
@@ -432,9 +453,27 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
|
||||
if m := strings.TrimSpace(cfg[extKeyModel]); m != "" {
|
||||
item["model"] = m
|
||||
}
|
||||
if strings.TrimSpace(cfg[extKeyVision]) == "1" {
|
||||
item["vision"] = true
|
||||
}
|
||||
out = append(out, item)
|
||||
continue
|
||||
}
|
||||
// Pastilles de la liste : taille de contexte, et l'œil quand le preset
|
||||
// charge un projecteur vision (repris d'AJEAN v0.10.1 / v0.10.9).
|
||||
if c := presetCtx(content); c != "" {
|
||||
item["ctx"] = c
|
||||
}
|
||||
if strings.TrimSpace(parseEnv(content)["MMPROJ"]) != "" {
|
||||
item["vision"] = true
|
||||
}
|
||||
// Strata : réglages dans sa propre fenêtre, pas l'éditeur llama.cpp.
|
||||
if cfg := parseEnv(content); isStrataConfig(cfg) {
|
||||
item["strata"] = true
|
||||
if cfg["STRATA_VISION"] != "0" {
|
||||
item["vision"] = true
|
||||
}
|
||||
}
|
||||
if q := detectQuant(content); q != "" {
|
||||
item["quant"] = q
|
||||
}
|
||||
@@ -465,6 +504,26 @@ func handlePresets(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 200, out)
|
||||
}
|
||||
|
||||
// handlePresetsOrder (POST {ids}) : l'ordre de la liste choisi par glisser-déposer.
|
||||
func handlePresetsOrder(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
IDs []string `json:"ids"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if err := savePresetOrder(req.IDs); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func handlePreset(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimSpace(r.URL.Query().Get("id"))
|
||||
if id == "" {
|
||||
@@ -476,16 +535,20 @@ func handlePreset(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 404, map[string]any{"error": "not found"})
|
||||
return
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content})
|
||||
sendJSON(w, 200, map[string]any{"id": id, "name": presetDisplayName(content, id), "content": content,
|
||||
"sysprompt": getStr(bkState, sysPromptKeyPrefix+id)})
|
||||
}
|
||||
|
||||
// presetSaveReq is the preset editor payload. `id` identifies an existing
|
||||
// preset to update ("" creates a new one); `name` is the display name.
|
||||
// SysPrompt : le prompt système DE CE preset, édité dans l'éditeur (repris
|
||||
// d'AJEAN v0.13.0) ; nil = champ absent, on n'y touche pas.
|
||||
type presetSaveReq struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
DeleteModel bool `json:"deleteModel"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Content string `json:"content"`
|
||||
DeleteModel bool `json:"deleteModel"`
|
||||
SysPrompt *string `json:"sysprompt,omitempty"`
|
||||
}
|
||||
|
||||
// saveReq is the skill editor payload (skills keep name-as-identity + rename).
|
||||
@@ -588,6 +651,12 @@ func handlePresetSave(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.SysPrompt != nil {
|
||||
if err := putStr(bkState, sysPromptKeyPrefix+newID, strings.TrimSpace(*req.SysPrompt)); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
if applied {
|
||||
fmt.Printf("%s config.env <- %s (preset actif modifié)\n", green("[ok]"), newID)
|
||||
fmt.Println(dim("[info] redémarrage du service..."))
|
||||
|
||||
@@ -53,7 +53,9 @@ func capsFromBody(body chatReq) Caps {
|
||||
func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, func()) {
|
||||
mu := &sync.Mutex{}
|
||||
done := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
go func() {
|
||||
defer close(exited)
|
||||
// 4 s (et non 15) : borne le temps qu'un dernier bout de flux peut rester
|
||||
// coincé dans un buffer proxy (Cloudflare) faute d'octets pour le pousser.
|
||||
t := time.NewTicker(4 * time.Second)
|
||||
@@ -64,6 +66,15 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
|
||||
return
|
||||
case <-t.C:
|
||||
mu.Lock()
|
||||
// Arrêt demandé pendant l'attente du verrou : le handler peut déjà
|
||||
// être revenu, et écrire dans son ResponseWriter fait planter le
|
||||
// process (amont #105).
|
||||
select {
|
||||
case <-done:
|
||||
mu.Unlock()
|
||||
return
|
||||
default:
|
||||
}
|
||||
_, err := w.Write([]byte(": ping\n\n"))
|
||||
if flusher != nil {
|
||||
flusher.Flush()
|
||||
@@ -75,7 +86,13 @@ func sseHeartbeat(w http.ResponseWriter, flusher http.Flusher) (*sync.Mutex, fun
|
||||
}
|
||||
}
|
||||
}()
|
||||
return mu, func() { close(done) }
|
||||
var once sync.Once
|
||||
// stop attend la fin de la goroutine : après son retour, plus aucune
|
||||
// écriture ne peut partir vers w.
|
||||
return mu, func() {
|
||||
once.Do(func() { close(done) })
|
||||
<-exited
|
||||
}
|
||||
}
|
||||
|
||||
// runChatStream est désormais un pur ABONNÉ au journal de la conversation serveur :
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
// POST /api/engine/use → bascule vers une version déjà installée
|
||||
// POST /api/engine/remove → supprime une version installée
|
||||
//
|
||||
// La version recommandée, le retour à la version précédente et la garde du
|
||||
// rendu des gabarits vivent dans web_engine_reco.go.
|
||||
//
|
||||
// Jusqu'ici, mettre à jour llama.cpp imposait de reconstruire l'image de Loki —
|
||||
// donc un rebuild complet et un redéploiement pour un composant qui publie
|
||||
// plusieurs versions par jour. Le panneau ne le disait même pas : il annonçait
|
||||
@@ -15,12 +18,14 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// engineUseImage est la valeur de `tag` qui désigne le moteur d'origine, celui
|
||||
@@ -61,6 +66,12 @@ func handleEngineStatus(w http.ResponseWriter, r *http.Request) {
|
||||
// Avis « moteur trop ancien » : seulement pour un build digne de foi
|
||||
// (voir engineBuildTrust), jamais pour un compilé ou un fork.
|
||||
"notice": engineBuildNotice(engineBuildTrust(source, build)),
|
||||
// L'encart « version recommandée » : décidé sans réseau, sur le même
|
||||
// build de confiance. Le choix du tag n'a lieu qu'au clic (/plan).
|
||||
"recommend": engineRecommendation(engineBuildTrust(source, build), engineOCISupported()),
|
||||
"previous": enginePrevious(bin),
|
||||
// Rendu du gabarit comparé avant/après la dernière bascule.
|
||||
"render_check": engineRenderSnapshot(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,17 +103,20 @@ func handleEngineCheck(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handleEngineUpdate lance le job de mise à jour. {tag} permet d'épingler une
|
||||
// version précise ; sans lui, on prend la dernière publiée pour la variante.
|
||||
// {preserve_off:true} : l'utilisateur a choisi de poser REASONING_PRESERVE=off
|
||||
// avant la bascule (voir reasoningPreserveRisk).
|
||||
func handleEngineUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if !engineOCISupported() {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Tag string `json:"tag"`
|
||||
Tag string `json:"tag"`
|
||||
PreserveOff bool `json:"preserve_off"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
tag := strings.TrimSpace(req.Tag)
|
||||
if err := startLcJob("engine", func() { engineRunUpdate(tag) }); err != nil {
|
||||
if err := startLcJob("engine", func() { engineRunUpdate(tag, req.PreserveOff) }); err != nil {
|
||||
sendJSON(w, 409, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
@@ -118,7 +132,7 @@ func engineUnsupportedWhy() string {
|
||||
// L'ordre compte — le seul vrai risque ici est un moteur plus récent que le
|
||||
// runtime CUDA de l'image, et on le découvre au test à blanc, moteur courant
|
||||
// encore intact.
|
||||
func engineRunUpdate(tag string) {
|
||||
func engineRunUpdate(tag string, preserveOff bool) {
|
||||
variant := engineVariant()
|
||||
ancien := currentEngineBin()
|
||||
if tag == "" {
|
||||
@@ -152,15 +166,46 @@ func engineRunUpdate(tag string) {
|
||||
lcFail(fmt.Errorf("%w\n\nLe moteur courant n'a PAS été touché. Cette version demande sans doute un runtime CUDA plus récent que celui de l'image : reconstruis l'image avec un LLAMACPP_IMAGE récent, ou épingle un build antérieur", err))
|
||||
return
|
||||
}
|
||||
if build, commit := engineBuildOf(bin); build > 0 {
|
||||
lcAppend(fmt.Sprintf("moteur testé : build %d (%s)", build, commit))
|
||||
newBuild, commit := engineBuildOf(bin)
|
||||
if newBuild > 0 {
|
||||
lcAppend(fmt.Sprintf("moteur testé : build %d (%s)", newBuild, commit))
|
||||
}
|
||||
|
||||
// Rendu du raisonnement (b10763) : la décision vient de l'utilisateur,
|
||||
// prise avant le clic ; ici on l'applique, ou on rappelle l'avertissement.
|
||||
if preserveOff {
|
||||
msg, err := enginePreserveOff(bin)
|
||||
if err != nil {
|
||||
lcFail(fmt.Errorf("REASONING_PRESERVE=off demandée mais pas posée : %w — moteur courant inchangé ; le nouveau reste installé", err))
|
||||
return
|
||||
}
|
||||
lcAppend(msg)
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
risk := reasoningPreserveRisk(enginePreserveInputs(ctx, newBuild))
|
||||
cancel()
|
||||
if risk.Risk != "no" {
|
||||
lcAppend("[warn] rendu du raisonnement : " + risk.Why)
|
||||
}
|
||||
}
|
||||
|
||||
// Rendu de référence sur le moteur qui tourne encore, pour comparer après.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
ref, haveBefore := engineRenderBefore(ctx)
|
||||
cancel()
|
||||
|
||||
// La vérification d'une bascule précédente parlerait du moteur qu'on quitte.
|
||||
engineRenderCancel()
|
||||
if err := engineSwitchTo(bin); err != nil {
|
||||
lcFail(err)
|
||||
return
|
||||
}
|
||||
engineRememberPrevious(ancien, bin)
|
||||
enginePrune(engineKeepAfterUpdate(bin, ancien, lcAppend), lcAppend)
|
||||
if haveBefore {
|
||||
lcAppend("vérification du rendu du gabarit une fois le modèle chargé (panneau Moteur, journal [moteur])")
|
||||
go engineRenderAfter(ref, tag)
|
||||
}
|
||||
lcDone("moteur mis à jour (" + tag + ")")
|
||||
}
|
||||
|
||||
@@ -231,10 +276,13 @@ func handleEngineUse(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "version non installée : " + tag})
|
||||
return
|
||||
}
|
||||
left := currentEngineBin()
|
||||
if err := SetConfigKey("BIN", bin); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
engineRememberPrevious(left, bin)
|
||||
engineRenderCancel()
|
||||
if serviceIsActive() {
|
||||
_ = serviceAction("restart")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
// web_engine_reco.go — « version recommandée » du panneau Moteur, retour à la
|
||||
// version précédente, et garde du rendu des gabarits à travers une mise à jour.
|
||||
//
|
||||
// POST /api/engine/plan → choisit la version à installer (sur clic) et
|
||||
// dit si le prompt rendu va changer
|
||||
// POST /api/engine/rollback → revient au moteur d'avant la dernière bascule
|
||||
//
|
||||
// RIEN D'AUTOMATIQUE. Aucune de ces routes n'est appelée au démarrage ni par
|
||||
// l'état du panneau : le registre n'est interrogé que sur un clic, et rien ne
|
||||
// s'installe sans la confirmation qui suit. L'encart « version recommandée »
|
||||
// lui-même se décide sans réseau, sur le build du moteur courant.
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// enginePreserveDefaultBuild : premier build où llama-server active
|
||||
// preserve_reasoning quand ni --reasoning-preserve ni
|
||||
// --no-reasoning-preserve n'est passé (PR #28174).
|
||||
enginePreserveDefaultBuild = 10763
|
||||
// engineQwen4MTPBuild : premier build qui sait la tête MTP de qwen4exp
|
||||
// (PR #29761). Cité dans l'encart, jamais activé par Loki : SPEC reste off
|
||||
// tant que l'utilisateur ne le pose pas.
|
||||
engineQwen4MTPBuild = 11331
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Choix de la version recommandée
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineRecommendedTag : le tag figé le plus récent publié pour la variante,
|
||||
// à condition qu'il atteigne engineMinRecommended et qu'il existe vraiment.
|
||||
//
|
||||
// Le registre n'offre pas de « plus récent au-dessus de N » ; lister ses
|
||||
// milliers de tags pour le trouver serait lent et fragile. Le plus récent
|
||||
// au-dessus du minimum, c'est le dernier publié — l'annotation du tag mouvant
|
||||
// le nomme (engineOCILatest) — et on vérifie que son tag figé répond avant de
|
||||
// le proposer : c'est lui qu'on installera, pas le tag mouvant, pour que la
|
||||
// version confirmée soit la version installée.
|
||||
func engineRecommendedTag(variant string) (int, string, error) {
|
||||
build, tag, err := engineOCILatest(variant)
|
||||
if err != nil {
|
||||
return 0, "", fmt.Errorf("registre injoignable, aucune version choisie : %w", err)
|
||||
}
|
||||
if build == 0 {
|
||||
// Sans numéro, impossible de promettre « au moins b10864 » : on ne
|
||||
// propose pas un tag mouvant dont on ne sait pas ce qu'il contient.
|
||||
return 0, "", fmt.Errorf("le registre n'annonce pas le build du tag « %s » : impossible de vérifier qu'il atteint b%d", variant, engineMinRecommended)
|
||||
}
|
||||
if build < engineMinRecommended {
|
||||
return 0, "", fmt.Errorf("dernier build publié pour %s : b%d, antérieur à la version recommandée b%d", variant, build, engineMinRecommended)
|
||||
}
|
||||
if _, _, err := ociResolve(ociEngineRepo, tag); err != nil {
|
||||
return 0, "", fmt.Errorf("%s annoncé mais introuvable sur le registre : %w", tag, err)
|
||||
}
|
||||
return build, tag, nil
|
||||
}
|
||||
|
||||
// engineRecommendation : l'encart du panneau, ou nil. Sans réseau : seulement
|
||||
// le build de confiance du moteur courant (engineBuildTrust — jamais pour un
|
||||
// compilé ou un fork), comparé au minimum recommandé.
|
||||
func engineRecommendation(trusted int, supported bool) map[string]any {
|
||||
if trusted <= 0 || trusted >= engineMinRecommended || !supported {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"current": trusted,
|
||||
"min": engineMinRecommended,
|
||||
"gains": []string{
|
||||
fmt.Sprintf("points de reprise des modèles hybrides (Qwen3.5/3.6, Qwen3-Next…) gardés : plus de ~8 k jetons recalculés à chaque reprise (b%d)", engineMinRecommended),
|
||||
fmt.Sprintf("MTP rapide (graphes CUDA) à partir de b%d", specAutoMinBuild),
|
||||
fmt.Sprintf("MTP de qwen4exp à partir de b%d", engineQwen4MTPBuild),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Rendu du raisonnement : le défaut qui change à b10763
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// preserveIn : ce qu'il faut savoir pour dire si la bascule change le prompt.
|
||||
type preserveIn struct {
|
||||
External bool // preset externe : le gabarit n'est pas rendu par ce moteur
|
||||
Current int // build de confiance du moteur courant (0 = inconnu)
|
||||
Target int // build visé (0 = inconnu)
|
||||
Key string // REASONING_PRESERVE de la configuration active
|
||||
Overridden string // EXTRA_ARGS ou LLAMA_ARG_* qui règlent déjà la question
|
||||
Template string // gabarit chargé (/props)
|
||||
TplKnown bool
|
||||
Preserves tplTri // la sonde, sur le moteur courant
|
||||
}
|
||||
|
||||
// preserveRisk : le verdict, pour l'avertissement et la case à cocher.
|
||||
type preserveRisk struct {
|
||||
Risk string `json:"risk"` // "yes", "no" ou "unknown"
|
||||
Why string `json:"why"`
|
||||
// Suggest : cocher d'office REASONING_PRESERVE=off. Seulement quand le
|
||||
// changement est établi : sur un gabarit qui garde de lui-même la réflexion
|
||||
// (Qwen3.8), « off » changerait à son tour le rendu.
|
||||
Suggest bool `json:"suggest"`
|
||||
}
|
||||
|
||||
// tplPreserveVars : les variables que llama-server pose dans le contexte du
|
||||
// gabarit avec preserve_reasoning (caps_apply_preserve_reasoning). Un gabarit
|
||||
// qui n'en lit aucune rend la même chose sur les deux moteurs.
|
||||
var tplPreserveVars = []string{"preserve_thinking", "clear_thinking", "truncate_history_thinking", "preserve_reasoning"}
|
||||
|
||||
// reasoningPreserveRisk : la bascule vers le build visé change-t-elle le prompt
|
||||
// rendu ? Fonction pure.
|
||||
func reasoningPreserveRisk(in preserveIn) preserveRisk {
|
||||
no := func(why string) preserveRisk { return preserveRisk{Risk: "no", Why: why} }
|
||||
switch {
|
||||
case in.External:
|
||||
return no("preset externe : son gabarit n'est pas rendu par ce moteur")
|
||||
case in.Target > 0 && in.Target < enginePreserveDefaultBuild:
|
||||
return no(fmt.Sprintf("b%d n'a pas encore preserve_reasoning par défaut (b%d)", in.Target, enginePreserveDefaultBuild))
|
||||
case in.Current >= enginePreserveDefaultBuild:
|
||||
return no(fmt.Sprintf("le moteur courant (b%d) a déjà preserve_reasoning par défaut", in.Current))
|
||||
case in.Key != "":
|
||||
return no("REASONING_PRESERVE=" + in.Key + " est posée : le rendu ne dépend pas du défaut du moteur")
|
||||
case in.Overridden != "":
|
||||
return no(in.Overridden + " règle déjà preserve_reasoning")
|
||||
case !in.TplKnown:
|
||||
return preserveRisk{Risk: "unknown", Why: "moteur arrêté ou gabarit illisible : impossible de vérifier. " +
|
||||
"À partir de b" + fmt.Sprint(enginePreserveDefaultBuild) + ", un gabarit de type Qwen3.6 rend la réflexion des tours passés, " +
|
||||
"vide puisque Loki ne la renvoie pas (sans REASONING_ECHO) ; REASONING_PRESERVE=off garde le rendu actuel. " +
|
||||
"À l'inverse, sur un gabarit qui la garde déjà (Qwen3.8), off changerait le rendu."}
|
||||
}
|
||||
tpl := false
|
||||
for _, v := range tplPreserveVars {
|
||||
if strings.Contains(in.Template, v) {
|
||||
tpl = true
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case !tpl:
|
||||
return no("le gabarit chargé ne lit pas preserve_thinking / clear_thinking : rendu inchangé")
|
||||
case in.Preserves == tplYes:
|
||||
return no("le gabarit garde déjà la réflexion des tours passés : rendu inchangé (ne pas poser off)")
|
||||
case in.Preserves == tplNo:
|
||||
return preserveRisk{Risk: "yes", Suggest: true, Why: fmt.Sprintf("le gabarit chargé retire la réflexion des tours passés ; "+
|
||||
"à partir de b%d le moteur la lui fait garder, et sans REASONING_ECHO Loki ne la renvoie pas : chaque tour passé "+
|
||||
"serait rendu avec un bloc de réflexion vide. REASONING_PRESERVE=off garde le rendu actuel.", enginePreserveDefaultBuild)}
|
||||
}
|
||||
return preserveRisk{Risk: "unknown", Why: "le gabarit lit preserve_thinking mais la sonde n'a pas conclu : " +
|
||||
"REASONING_PRESERVE=off garde le rendu actuel s'il retire la réflexion des tours passés"}
|
||||
}
|
||||
|
||||
// preserveOverridden : EXTRA_ARGS ou variable d'environnement qui fixent déjà
|
||||
// preserve_reasoning (mêmes gardes que reasoningPreserveArgs). Vide = aucune.
|
||||
func preserveOverridden(cfg map[string]string) string {
|
||||
extra := splitArgs(cfg["EXTRA_ARGS"])
|
||||
switch {
|
||||
case hasAnyFlag(extra, "--reasoning-preserve", "--no-reasoning-preserve"):
|
||||
return "EXTRA_ARGS"
|
||||
case strings.Contains(strings.Join(flagValues(extra, "--chat-template-kwargs"), " "), "preserve_reasoning"):
|
||||
return "--chat-template-kwargs"
|
||||
case strings.TrimSpace(os.Getenv("LLAMA_ARG_REASONING_PRESERVE")) != "":
|
||||
return "LLAMA_ARG_REASONING_PRESERVE"
|
||||
case strings.Contains(os.Getenv("LLAMA_ARG_CHAT_TEMPLATE_KWARGS"), "preserve_reasoning"):
|
||||
return "LLAMA_ARG_CHAT_TEMPLATE_KWARGS"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// enginePreserveInputs : les lectures de reasoningPreserveRisk. Le moteur
|
||||
// courant n'est interrogé qu'en lecture (/health, /props, /apply-template) :
|
||||
// rien n'y touche au cache de la conversation.
|
||||
func enginePreserveInputs(ctx context.Context, target int) preserveIn {
|
||||
cfg := ReadConfig()
|
||||
in := preserveIn{
|
||||
External: isExternalConfig(cfg),
|
||||
Current: engineBuildTrusted(currentEngineBin()),
|
||||
Target: target,
|
||||
Key: strings.TrimSpace(cfg["REASONING_PRESERVE"]),
|
||||
Overridden: preserveOverridden(cfg),
|
||||
Preserves: tplUnknown,
|
||||
}
|
||||
if in.External || in.Key != "" || in.Overridden != "" || (target > 0 && target < enginePreserveDefaultBuild) {
|
||||
return in // verdict déjà acquis : pas la peine d'interroger le moteur
|
||||
}
|
||||
p := tplLocalProber()
|
||||
if !p.healthy(ctx) {
|
||||
return in
|
||||
}
|
||||
props, err := p.props(ctx)
|
||||
if err != nil {
|
||||
return in
|
||||
}
|
||||
in.Template, in.TplKnown = props.ChatTemplate, props.ChatTemplate != ""
|
||||
in.Preserves = tplProbeEnsure(ctx, tplLastShape()).PreservesHistory
|
||||
return in
|
||||
}
|
||||
|
||||
// engineHelpHas : l'aide du binaire mentionne-t-elle ce drapeau ? Lancé avec
|
||||
// ses bibliothèques à côté, comme le test à blanc : un moteur téléchargé n'a
|
||||
// pas encore servi, binHelp ne le connaît pas.
|
||||
func engineHelpHas(bin, flag string) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
cmd := hideCmd(exec.CommandContext(ctx, bin, "--help"))
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
cmd.Env = libraryPathEnv(filepath.Dir(bin))
|
||||
out, _ := cmd.CombinedOutput()
|
||||
return strings.Contains(string(out), flag)
|
||||
}
|
||||
|
||||
// enginePreserveOff pose REASONING_PRESERVE=off, choisi par l'utilisateur
|
||||
// avant la bascule. Dans le preset actif s'il y en a un — sinon la prochaine
|
||||
// bascule de preset l'effacerait sans un mot —, dans la configuration sinon.
|
||||
// Rien si la clé est déjà posée : c'est un choix antérieur, on ne le contredit
|
||||
// pas.
|
||||
func enginePreserveOff(newBin string) (string, error) {
|
||||
return enginePreserveOffIf(func() bool { return engineHelpHas(newBin, "--no-reasoning-preserve") })
|
||||
}
|
||||
|
||||
// enginePreserveOffIf : le corps d'enginePreserveOff, l'aide du nouveau moteur
|
||||
// passée en fonction (les tests n'ont pas de llama-server à lancer).
|
||||
func enginePreserveOffIf(knowsFlag func() bool) (string, error) {
|
||||
if v := strings.TrimSpace(ReadConfig()["REASONING_PRESERVE"]); v != "" {
|
||||
return "REASONING_PRESERVE=" + v + " déjà posée : inchangée", nil
|
||||
}
|
||||
if !knowsFlag() {
|
||||
return "", fmt.Errorf("le nouveau moteur ne connaît pas --no-reasoning-preserve")
|
||||
}
|
||||
if err := tuneGuard(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p, content, err := tuneActivePreset(); err == nil {
|
||||
if _, _, err := SavePresetApplying(p.ID, p.Name, presetSetKey(content, "REASONING_PRESERVE", "off")); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "REASONING_PRESERVE=off posée dans le preset « " + p.Name + " »", nil
|
||||
}
|
||||
if err := SetConfigKey("REASONING_PRESERVE", "off"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "REASONING_PRESERVE=off posée dans la configuration active (aucun preset ne lui correspond)", nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Vérification du rendu après la bascule
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// engineRenderCheck : le dernier relevé avant/après une bascule de moteur.
|
||||
type engineRenderCheck struct {
|
||||
Status string `json:"status"` // pending, same, changed, unknown
|
||||
Tag string `json:"tag"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
}
|
||||
|
||||
var (
|
||||
engineRenderMu sync.Mutex
|
||||
engineRenderLast *engineRenderCheck
|
||||
// engineRenderGen : la vérification en cours. Une bascule plus récente
|
||||
// (autre mise à jour, retour à la version précédente, autre version
|
||||
// installée) la périme : une vérification d'avant, encore à attendre son
|
||||
// moteur, ne doit ni écraser le relevé suivant ni comparer l'ancien
|
||||
// moteur au moteur d'une autre bascule.
|
||||
engineRenderGen uint64
|
||||
// engineRenderWait : délai laissé au nouveau moteur pour charger le modèle
|
||||
// (un gros MoE en mmap peut demander plusieurs minutes). Variable pour les
|
||||
// tests.
|
||||
engineRenderWait = 20 * time.Minute
|
||||
engineRenderPoll = 5 * time.Second
|
||||
)
|
||||
|
||||
// engineRenderBegin ouvre une vérification : celles d'avant sont périmées.
|
||||
func engineRenderBegin() uint64 {
|
||||
engineRenderMu.Lock()
|
||||
defer engineRenderMu.Unlock()
|
||||
engineRenderGen++
|
||||
return engineRenderGen
|
||||
}
|
||||
|
||||
// engineRenderCancel : une bascule sans vérification (retour à la version
|
||||
// précédente, autre version installée) périme la vérification en cours et
|
||||
// efface son relevé, qui parlerait d'un moteur qui ne tourne plus.
|
||||
func engineRenderCancel() {
|
||||
engineRenderMu.Lock()
|
||||
defer engineRenderMu.Unlock()
|
||||
engineRenderGen++
|
||||
engineRenderLast = nil
|
||||
}
|
||||
|
||||
// engineRenderCurrent : la vérification gen est-elle encore la dernière ?
|
||||
func engineRenderCurrent(gen uint64) bool {
|
||||
engineRenderMu.Lock()
|
||||
defer engineRenderMu.Unlock()
|
||||
return gen == engineRenderGen
|
||||
}
|
||||
|
||||
// engineRenderSet range le relevé de la vérification gen — rien si une
|
||||
// bascule plus récente l'a périmée.
|
||||
func engineRenderSet(gen uint64, c engineRenderCheck) {
|
||||
c.At = time.Now()
|
||||
engineRenderMu.Lock()
|
||||
if gen != engineRenderGen {
|
||||
engineRenderMu.Unlock()
|
||||
return
|
||||
}
|
||||
engineRenderLast = &c
|
||||
engineRenderMu.Unlock()
|
||||
if c.Status != "pending" {
|
||||
line := "[moteur] rendu du gabarit après " + c.Tag + " : " + c.Status
|
||||
if c.Detail != "" {
|
||||
line += " — " + c.Detail
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, line)
|
||||
}
|
||||
}
|
||||
|
||||
func engineRenderSnapshot() *engineRenderCheck {
|
||||
engineRenderMu.Lock()
|
||||
defer engineRenderMu.Unlock()
|
||||
if engineRenderLast == nil {
|
||||
return nil
|
||||
}
|
||||
c := *engineRenderLast
|
||||
return &c
|
||||
}
|
||||
|
||||
// engineRenderRef : le rendu relevé sur le moteur qui tourne encore, la forme
|
||||
// rejouée et le build qu'il annonce (/props).
|
||||
type engineRenderRef struct {
|
||||
text string
|
||||
shape tplShape
|
||||
build string
|
||||
}
|
||||
|
||||
// engineRenderBefore relève le rendu sur le moteur qui tourne encore. ok=false
|
||||
// : moteur arrêté, preset externe ou rendu impossible — pas de comparaison.
|
||||
func engineRenderBefore(ctx context.Context) (engineRenderRef, bool) {
|
||||
ref := engineRenderRef{shape: tplLastShape()}
|
||||
if externalActive() {
|
||||
return ref, false
|
||||
}
|
||||
p := tplLocalProber()
|
||||
if !p.healthy(ctx) {
|
||||
return ref, false
|
||||
}
|
||||
if props, err := p.props(ctx); err == nil {
|
||||
ref.build = props.BuildInfo
|
||||
}
|
||||
fp, err := tplRenderPrint(ctx, p, ref.shape)
|
||||
ref.text = fp
|
||||
return ref, err == nil
|
||||
}
|
||||
|
||||
// engineRenderAfter attend le nouveau moteur (modèle chargé) et compare. En
|
||||
// tâche de fond : le job de mise à jour est fini, la bascule faite ; ceci ne
|
||||
// fait que signaler.
|
||||
//
|
||||
// « Prêt » veut dire /health à 200 ET un build différent de celui relevé
|
||||
// avant : un ancien moteur qui aurait survécu à l'arrêt répondrait aussi, et
|
||||
// comparer l'ancien à lui-même conclurait à tort « identique ».
|
||||
func engineRenderAfter(ref engineRenderRef, tag string) {
|
||||
gen := engineRenderBegin()
|
||||
engineRenderSet(gen, engineRenderCheck{Status: "pending", Tag: tag})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), engineRenderWait)
|
||||
defer cancel()
|
||||
p := tplLocalProber()
|
||||
ready := func() bool {
|
||||
if !p.healthy(ctx) {
|
||||
return false
|
||||
}
|
||||
if ref.build == "" {
|
||||
return true
|
||||
}
|
||||
props, err := p.props(ctx)
|
||||
return err == nil && props.BuildInfo != ref.build
|
||||
}
|
||||
for !ready() {
|
||||
if !engineRenderCurrent(gen) {
|
||||
return // bascule plus récente : plus rien à comparer
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
engineRenderSet(gen, engineRenderCheck{Status: "unknown", Tag: tag, Detail: "le nouveau moteur n'a pas répondu à temps : rendu non vérifié"})
|
||||
return
|
||||
case <-time.After(engineRenderPoll):
|
||||
}
|
||||
}
|
||||
after, err := tplRenderPrint(ctx, p, ref.shape)
|
||||
if err != nil {
|
||||
engineRenderSet(gen, engineRenderCheck{Status: "unknown", Tag: tag, Detail: "rendu impossible sur le nouveau moteur : " + err.Error()})
|
||||
return
|
||||
}
|
||||
if same, d := tplRenderDiff(ref.text, after); same {
|
||||
engineRenderSet(gen, engineRenderCheck{Status: "same", Tag: tag})
|
||||
} else {
|
||||
engineRenderSet(gen, engineRenderCheck{Status: "changed", Tag: tag, Detail: d})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Version précédente
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// enginePrevFile : le moteur d'avant la dernière bascule faite par le panneau.
|
||||
// Un fichier à part, pas une clé de configuration : un preset n'a rien à en
|
||||
// dire, et il ne doit pas changer l'empreinte de la configuration active.
|
||||
func enginePrevFile() string { return filepath.Join(engineDir(), "previous-bin") }
|
||||
|
||||
// engineRememberPrevious note le moteur qu'on quitte. Best-effort : sans ce
|
||||
// fichier, le bouton « revenir » n'apparaît simplement pas.
|
||||
func engineRememberPrevious(left, next string) {
|
||||
if left == "" || samePath(left, next) {
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(engineDir(), 0o755)
|
||||
_ = os.WriteFile(enginePrevFile(), []byte(left+"\n"), 0o644)
|
||||
}
|
||||
|
||||
// enginePrevious : le moteur précédent s'il est encore là et n'est pas celui
|
||||
// qui tourne. Le ménage d'après mise à jour le garde (engineKeepAfterUpdate) ;
|
||||
// une suppression à la main le fait disparaître, et le bouton avec.
|
||||
func enginePrevious(cur string) map[string]any {
|
||||
b, err := os.ReadFile(enginePrevFile())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
bin := strings.TrimSpace(string(b))
|
||||
if bin == "" || !isFile(bin) || samePath(bin, cur) {
|
||||
return nil
|
||||
}
|
||||
out := map[string]any{"bin": bin, "source": engineSource(bin)}
|
||||
if tag := engineTagOf(bin); tag != "" {
|
||||
out["tag"] = tag
|
||||
out["build"] = engineTagBuild(tag)
|
||||
} else {
|
||||
out["build"] = engineBuildCached(bin)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// handleEnginePlan : sur clic, la version qu'on installerait et ce qu'elle
|
||||
// changerait au prompt. {recommended:true} : la plus récente ≥ b10864
|
||||
// réellement publiée ; sinon la dernière publiée. Rien n'est téléchargé.
|
||||
func handleEnginePlan(w http.ResponseWriter, r *http.Request) {
|
||||
// Interroge le registre : POST seulement, comme le clic qui l'appelle.
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "POST requis"})
|
||||
return
|
||||
}
|
||||
if !engineOCISupported() {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": engineUnsupportedWhy()})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Recommended bool `json:"recommended"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
variant := engineVariant()
|
||||
var (
|
||||
build int
|
||||
tag string
|
||||
err error
|
||||
)
|
||||
if req.Recommended {
|
||||
build, tag, err = engineRecommendedTag(variant)
|
||||
} else {
|
||||
build, tag, err = engineOCILatest(variant)
|
||||
}
|
||||
if err != nil {
|
||||
sendJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
cur := currentEngineBin()
|
||||
curBuild, _ := engineBuildOf(cur)
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||
defer cancel()
|
||||
sendJSON(w, 200, map[string]any{
|
||||
"ok": true,
|
||||
"tag": tag,
|
||||
"build": build,
|
||||
"current": curBuild,
|
||||
"uptodate": build > 0 && curBuild == build,
|
||||
"preserve": reasoningPreserveRisk(enginePreserveInputs(ctx, build)),
|
||||
// Ce qui restera pour revenir en arrière : la version téléchargée qui
|
||||
// tourne (gardée par le ménage), ou le moteur de l'image.
|
||||
"keeps": engineTagOf(cur),
|
||||
})
|
||||
}
|
||||
|
||||
// handleEngineRollback revient au moteur d'avant la dernière bascule. Aucun
|
||||
// réseau. Le moteur quitté devient à son tour « le précédent » : deux clics
|
||||
// ramènent où l'on était.
|
||||
func handleEngineRollback(w http.ResponseWriter, r *http.Request) {
|
||||
// Change la configuration et redémarre le moteur : POST seulement.
|
||||
if r.Method != http.MethodPost {
|
||||
sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "POST requis"})
|
||||
return
|
||||
}
|
||||
if tuneDenyHTTP(w) {
|
||||
return
|
||||
}
|
||||
cur := currentEngineBin()
|
||||
prev := enginePrevious(cur)
|
||||
if prev == nil {
|
||||
sendJSON(w, 400, map[string]any{"ok": false, "error": "aucune version précédente conservée"})
|
||||
return
|
||||
}
|
||||
bin := prev["bin"].(string)
|
||||
if err := SetConfigKey("BIN", bin); err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
engineRememberPrevious(cur, bin)
|
||||
engineRenderCancel()
|
||||
if serviceIsActive() {
|
||||
_ = serviceAction("restart")
|
||||
}
|
||||
sendJSON(w, 200, map[string]any{"ok": true, "bin": bin})
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
package loki
|
||||
|
||||
// Version recommandée du moteur, retour à la version précédente, et garde du
|
||||
// rendu des gabarits à travers une mise à jour (web_engine_reco.go).
|
||||
//
|
||||
// Ce qui se joue : ne JAMAIS proposer une version qu'on n'a pas vue publiée,
|
||||
// ne rien faire sans réseau d'autre que le dire, garder un chemin de retour
|
||||
// sans réseau, et ne pas laisser une mise à jour changer en silence le prompt
|
||||
// que voit le modèle.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Le cas nominal : le dernier build publié dépasse le minimum, et c'est son tag
|
||||
// FIGÉ qui est proposé (celui qu'on installera), pas le tag mouvant.
|
||||
func TestVersionRecommandeeChoixDuTag(t *testing.T) {
|
||||
fauxRegistre(t, "b11351", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
|
||||
build, tag, err := engineRecommendedTag("server-cuda")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if build != 11351 || tag != "server-cuda-b11351" {
|
||||
t.Errorf("build=%d tag=%q, attendu 11351 / server-cuda-b11351", build, tag)
|
||||
}
|
||||
}
|
||||
|
||||
// Un registre dont le dernier build est encore sous le minimum, ou qui ne dit
|
||||
// pas son numéro : rien n'est proposé, et l'erreur dit pourquoi.
|
||||
func TestVersionRecommandeeRefusee(t *testing.T) {
|
||||
for _, c := range []struct{ nom, version, attendu string }{
|
||||
{"trop ancienne", "b10700", "antérieur"},
|
||||
{"sans numéro", "", "n'annonce pas le build"},
|
||||
} {
|
||||
t.Run(c.nom, func(t *testing.T) {
|
||||
fauxRegistre(t, c.version, [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
|
||||
_, tag, err := engineRecommendedTag("server-cuda")
|
||||
if err == nil || !strings.Contains(err.Error(), c.attendu) {
|
||||
t.Fatalf("tag=%q err=%v, attendu une erreur « %s »", tag, err, c.attendu)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// L'annotation du tag mouvant annonce un build dont le tag figé n'existe pas
|
||||
// (encore) : on ne propose pas un tag introuvable.
|
||||
func TestVersionRecommandeeTagFigeAbsent(t *testing.T) {
|
||||
index, _ := json.Marshal(map[string]any{
|
||||
"schemaVersion": 2,
|
||||
"layers": []map[string]any{{"digest": "sha256:x", "size": 1}},
|
||||
"annotations": map[string]string{"org.opencontainers.image.version": "b11400"},
|
||||
})
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case strings.HasPrefix(r.URL.Path, "/token"):
|
||||
_, _ = w.Write([]byte(`{"token":"t"}`))
|
||||
case strings.HasSuffix(r.URL.Path, "/manifests/server-cuda"):
|
||||
_, _ = w.Write(index)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
old := ociBase
|
||||
ociBase = srv.URL
|
||||
defer func() { ociBase = old }()
|
||||
|
||||
if _, tag, err := engineRecommendedTag("server-cuda"); err == nil || !strings.Contains(err.Error(), "introuvable") {
|
||||
t.Fatalf("tag=%q err=%v, attendu « introuvable »", tag, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sans réseau : une erreur lisible, aucun tag, rien d'installé.
|
||||
func TestVersionRecommandeeSansReseau(t *testing.T) {
|
||||
testHome(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
srv.Close() // port fermé : connexion refusée
|
||||
old := ociBase
|
||||
ociBase = srv.URL
|
||||
defer func() { ociBase = old }()
|
||||
|
||||
_, tag, err := engineRecommendedTag("server-cuda")
|
||||
if err == nil || tag != "" || !strings.Contains(err.Error(), "registre injoignable") {
|
||||
t.Fatalf("tag=%q err=%v, attendu « registre injoignable »", tag, err)
|
||||
}
|
||||
if got := engineInstalled(); len(got) != 0 {
|
||||
t.Errorf("versions installées sans réseau : %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// L'encart : seulement pour un build de confiance sous le minimum, là où les
|
||||
// images officielles existent. Les gains cités nomment leurs builds.
|
||||
func TestEncartVersionRecommandee(t *testing.T) {
|
||||
r := engineRecommendation(10678, true)
|
||||
if r == nil {
|
||||
t.Fatal("pas d'encart pour b10678")
|
||||
}
|
||||
gains := strings.Join(r["gains"].([]string), "\n")
|
||||
for _, want := range []string{"b10864", "b11009", "b11331", "points de reprise"} {
|
||||
if !strings.Contains(gains, want) {
|
||||
t.Errorf("gains sans « %s » : %s", want, gains)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
build int
|
||||
ok bool
|
||||
}{{engineMinRecommended, true}, {11351, true}, {0, true}, {10678, false}} {
|
||||
if engineRecommendation(c.build, c.ok) != nil {
|
||||
t.Errorf("encart pour build=%d supporté=%v", c.build, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retour arrière : la version quittée est notée, proposée tant qu'elle existe,
|
||||
// et le retour fait de la version quittée à son tour « la précédente ».
|
||||
func TestRetourVersionPrecedente(t *testing.T) {
|
||||
testHome(t)
|
||||
mk := func(tag string) string {
|
||||
d := filepath.Join(engineDir(), tag)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p := filepath.Join(d, "llama-server")
|
||||
if err := os.WriteFile(p, []byte("x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
ancien, nouveau := mk("server-cuda-b10678"), mk("server-cuda-b11351")
|
||||
|
||||
if enginePrevious(nouveau) != nil {
|
||||
t.Fatal("version précédente annoncée avant toute bascule")
|
||||
}
|
||||
// Le ménage d'après mise à jour garde bien l'ancienne (lot 1).
|
||||
if keep := engineKeepAfterUpdate(nouveau, ancien, nil); !keep["server-cuda-b10678"] {
|
||||
t.Fatalf("ménage : l'ancienne version n'est pas gardée (%v)", keep)
|
||||
}
|
||||
engineRememberPrevious(ancien, nouveau)
|
||||
prev := enginePrevious(nouveau)
|
||||
if prev == nil || prev["tag"] != "server-cuda-b10678" || prev["build"] != 10678 {
|
||||
t.Fatalf("précédente = %v", prev)
|
||||
}
|
||||
// Ne jamais proposer de « revenir » sur le moteur qui tourne.
|
||||
if enginePrevious(ancien) != nil {
|
||||
t.Error("la version courante proposée comme précédente")
|
||||
}
|
||||
|
||||
// Le retour par la route : BIN revient, et la version quittée devient la
|
||||
// précédente (deux clics ramènent où l'on était).
|
||||
if err := SetConfigKey("BIN", nouveau); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
handleEngineRollback(rec, httptest.NewRequest("POST", "/api/engine/rollback", strings.NewReader("{}")))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("rollback : HTTP %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got := ReadConfig()["BIN"]; !samePath(got, ancien) {
|
||||
t.Fatalf("BIN = %q après retour, attendu %q", got, ancien)
|
||||
}
|
||||
if p := enginePrevious(ancien); p == nil || p["tag"] != "server-cuda-b11351" {
|
||||
t.Errorf("après retour, précédente = %v", p)
|
||||
}
|
||||
|
||||
// Une version supprimée à la main : plus de bouton, et la route refuse.
|
||||
if err := os.RemoveAll(filepath.Dir(nouveau)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enginePrevious(ancien) != nil {
|
||||
t.Error("version supprimée encore proposée")
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
handleEngineRollback(rec, httptest.NewRequest("POST", "/api/engine/rollback", strings.NewReader("{}")))
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("rollback sans précédente : HTTP %d, attendu 400", rec.Code)
|
||||
}
|
||||
// Les deux routes nouvelles changent la configuration ou interrogent le
|
||||
// registre : jamais sur un GET.
|
||||
for _, h := range []http.HandlerFunc{handleEngineRollback, handleEnginePlan} {
|
||||
rec = httptest.NewRecorder()
|
||||
h(rec, httptest.NewRequest("GET", "/api/engine/x", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("GET : HTTP %d, attendu 405", rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Une bascule plus récente périme la vérification du rendu encore en attente
|
||||
// de son moteur : elle s'arrête sans rien ranger, et son relevé n'écrase pas
|
||||
// celui de la bascule suivante.
|
||||
func TestVerificationDuRenduPerimee(t *testing.T) {
|
||||
testHome(t)
|
||||
freshTplProbe(t)
|
||||
oldPoll, oldWait := engineRenderPoll, engineRenderWait
|
||||
engineRenderPoll, engineRenderWait = time.Millisecond, 10*time.Second
|
||||
t.Cleanup(func() {
|
||||
engineRenderPoll, engineRenderWait = oldPoll, oldWait
|
||||
engineRenderMu.Lock()
|
||||
engineRenderLast = nil
|
||||
engineRenderMu.Unlock()
|
||||
})
|
||||
f := &fakeTpl{mode: "qwen3", build: "b10678-old"}
|
||||
f.start(t)
|
||||
ref, ok := engineRenderBefore(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("rendu de référence impossible")
|
||||
}
|
||||
// L'ancien moteur répond toujours : la vérification attend.
|
||||
done := make(chan struct{})
|
||||
go func() { engineRenderAfter(ref, "server-cuda-b11351"); close(done) }()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
if c := engineRenderSnapshot(); c != nil && c.Status == "pending" {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("vérification jamais commencée")
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
// Retour à la version précédente : vérification périmée, relevé effacé.
|
||||
engineRenderCancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("vérification périmée toujours en attente")
|
||||
}
|
||||
if c := engineRenderSnapshot(); c != nil {
|
||||
t.Errorf("relevé d'une bascule annulée : %+v", c)
|
||||
}
|
||||
// Un relevé d'une génération passée n'écrase rien.
|
||||
gen := engineRenderBegin()
|
||||
engineRenderSet(gen, engineRenderCheck{Status: "same", Tag: "neuf"})
|
||||
engineRenderSet(gen-1, engineRenderCheck{Status: "changed", Tag: "ancien"})
|
||||
if c := engineRenderSnapshot(); c == nil || c.Tag != "neuf" {
|
||||
t.Errorf("relevé écrasé par une vérification périmée : %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Le verdict « le prompt rendu va changer » : seulement quand c'est établi
|
||||
// (gabarit qui lit preserve_thinking ET retire aujourd'hui la réflexion), et
|
||||
// jamais de case cochée d'office ailleurs — sur Qwen3.8, off changerait le rendu.
|
||||
func TestAvertissementRenduRaisonnement(t *testing.T) {
|
||||
qwen36 := "{%- if preserve_thinking is defined and preserve_thinking %}…"
|
||||
base := preserveIn{Current: 10678, Target: 11351, Template: qwen36, TplKnown: true, Preserves: tplNo}
|
||||
for _, c := range []struct {
|
||||
nom string
|
||||
mod func(*preserveIn)
|
||||
risk string
|
||||
suggest bool
|
||||
}{
|
||||
{"Qwen3.6 sur moteur ancien", func(*preserveIn) {}, "yes", true},
|
||||
{"Qwen3.8 garde déjà", func(in *preserveIn) { in.Preserves = tplYes }, "no", false},
|
||||
{"gabarit sans la variable", func(in *preserveIn) { in.Template = "{{ messages }}" }, "no", false},
|
||||
{"clear_thinking (GLM)", func(in *preserveIn) { in.Template = "{% if clear_thinking %}" }, "yes", true},
|
||||
{"sonde sans verdict", func(in *preserveIn) { in.Preserves = tplUnknown }, "unknown", false},
|
||||
{"moteur arrêté", func(in *preserveIn) { in.TplKnown, in.Template = false, "" }, "unknown", false},
|
||||
{"clé posée", func(in *preserveIn) { in.Key = "on" }, "no", false},
|
||||
{"EXTRA_ARGS", func(in *preserveIn) { in.Overridden = "EXTRA_ARGS" }, "no", false},
|
||||
{"cible avant b10763", func(in *preserveIn) { in.Target = 10700 }, "no", false},
|
||||
{"cible inconnue", func(in *preserveIn) { in.Target = 0 }, "yes", true},
|
||||
{"moteur courant déjà ≥ b10763", func(in *preserveIn) { in.Current = 10800 }, "no", false},
|
||||
{"preset externe", func(in *preserveIn) { in.External = true }, "no", false},
|
||||
} {
|
||||
t.Run(c.nom, func(t *testing.T) {
|
||||
in := base
|
||||
c.mod(&in)
|
||||
r := reasoningPreserveRisk(in)
|
||||
if r.Risk != c.risk || r.Suggest != c.suggest || r.Why == "" {
|
||||
t.Errorf("= %+v, attendu risk=%s suggest=%v", r, c.risk, c.suggest)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Les réglages qui fixent déjà preserve_reasoning hors de la clé.
|
||||
func TestPreserveDejaRegle(t *testing.T) {
|
||||
t.Setenv("LLAMA_ARG_REASONING_PRESERVE", "")
|
||||
t.Setenv("LLAMA_ARG_CHAT_TEMPLATE_KWARGS", "")
|
||||
if got := preserveOverridden(map[string]string{"EXTRA_ARGS": "--no-reasoning-preserve -c 4096"}); got != "EXTRA_ARGS" {
|
||||
t.Errorf("EXTRA_ARGS : %q", got)
|
||||
}
|
||||
if got := preserveOverridden(map[string]string{"EXTRA_ARGS": `--chat-template-kwargs {"preserve_reasoning":false}`}); got != "--chat-template-kwargs" {
|
||||
t.Errorf("kwargs : %q", got)
|
||||
}
|
||||
if got := preserveOverridden(map[string]string{}); got != "" {
|
||||
t.Errorf("rien de posé : %q", got)
|
||||
}
|
||||
t.Setenv("LLAMA_ARG_REASONING_PRESERVE", "0")
|
||||
if got := preserveOverridden(map[string]string{}); got != "LLAMA_ARG_REASONING_PRESERVE" {
|
||||
t.Errorf("variable : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Les lectures du verdict sur un vrai (faux) moteur : gabarit et sonde.
|
||||
func TestAvertissementRenduSurLeMoteur(t *testing.T) {
|
||||
t.Setenv("LLAMA_ARG_REASONING_PRESERVE", "")
|
||||
t.Setenv("LLAMA_ARG_CHAT_TEMPLATE_KWARGS", "")
|
||||
for _, c := range []struct{ nom, mode, tpl, risk string }{
|
||||
{"retire la réflexion", "qwen3", "{% if preserve_thinking %}", "yes"},
|
||||
{"la garde déjà", "keep", "{% if preserve_thinking is not defined or preserve_thinking %}", "no"},
|
||||
{"ne lit pas la variable", "qwen3", "", "no"},
|
||||
} {
|
||||
t.Run(c.nom, func(t *testing.T) {
|
||||
testHome(t)
|
||||
freshTplProbe(t)
|
||||
(&fakeTpl{mode: c.mode, tpl: c.tpl}).start(t)
|
||||
r := reasoningPreserveRisk(enginePreserveInputs(context.Background(), 11351))
|
||||
if r.Risk != c.risk {
|
||||
t.Errorf("risk=%s (%s), attendu %s", r.Risk, r.Why, c.risk)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("moteur arrêté", func(t *testing.T) {
|
||||
testHome(t)
|
||||
freshTplProbe(t)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||
u, _ := url.Parse(srv.URL)
|
||||
srv.Close()
|
||||
if err := SetConfigKey("PORT", u.Port()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := reasoningPreserveRisk(enginePreserveInputs(context.Background(), 11351)); r.Risk != "unknown" || r.Suggest {
|
||||
t.Errorf("= %+v, attendu unknown sans case cochée", r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// REASONING_PRESERVE=off, choisi par l'utilisateur : dans le preset actif (il
|
||||
// survit aux bascules), jamais par-dessus une valeur déjà posée, et pas du tout
|
||||
// sur un moteur qui ne connaît pas le drapeau.
|
||||
func TestPoserPreserveOff(t *testing.T) {
|
||||
testHome(t)
|
||||
dir := presetsDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "qwen.env"), []byte("# NAME=Qwen\nMODEL=q.gguf\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setConfig(t, "MODEL=q.gguf\n")
|
||||
oui := func() bool { return true }
|
||||
|
||||
if _, err := enginePreserveOffIf(func() bool { return false }); err == nil {
|
||||
t.Fatal("posée alors que le moteur ne connaît pas --no-reasoning-preserve")
|
||||
}
|
||||
if ReadConfig()["REASONING_PRESERVE"] != "" {
|
||||
t.Fatal("clé écrite malgré le refus")
|
||||
}
|
||||
if _, err := enginePreserveOffIf(oui); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := os.ReadFile(filepath.Join(dir, "qwen.env"))
|
||||
if parseEnv(string(b))["REASONING_PRESERVE"] != "off" || ReadConfig()["REASONING_PRESERVE"] != "off" {
|
||||
t.Fatalf("preset=%q config=%q", b, ReadConfig()["REASONING_PRESERVE"])
|
||||
}
|
||||
|
||||
// Déjà posée (à on) : on ne contredit pas un choix antérieur.
|
||||
setConfig(t, "MODEL=autre.gguf\nREASONING_PRESERVE=on\n")
|
||||
if msg, err := enginePreserveOffIf(oui); err != nil || ReadConfig()["REASONING_PRESERVE"] != "on" {
|
||||
t.Fatalf("msg=%q err=%v valeur=%q", msg, err, ReadConfig()["REASONING_PRESERVE"])
|
||||
}
|
||||
|
||||
// Aucun preset ne correspond : la configuration active.
|
||||
setConfig(t, "MODEL=sans-preset.gguf\n")
|
||||
if _, err := enginePreserveOffIf(oui); err != nil || ReadConfig()["REASONING_PRESERVE"] != "off" {
|
||||
t.Fatalf("err=%v valeur=%q", err, ReadConfig()["REASONING_PRESERVE"])
|
||||
}
|
||||
}
|
||||
|
||||
// La comparaison des rendus : identique, ou le premier écart, avec la
|
||||
// conversation où il se trouve.
|
||||
func TestComparaisonDesRendus(t *testing.T) {
|
||||
a := tplRenderSep + "historique sans raisonnement ---\n<|im_start|>assistant\nloki-sonde-reponse-c1<|im_end|>\n"
|
||||
if same, d := tplRenderDiff(a, a); !same || d != "" {
|
||||
t.Fatalf("identiques : same=%v d=%q", same, d)
|
||||
}
|
||||
b := strings.Replace(a, "assistant\n", "assistant\n<think>\n\n</think>\n\n", 1)
|
||||
same, d := tplRenderDiff(a, b)
|
||||
if same || !strings.Contains(d, "historique sans raisonnement") || !strings.Contains(d, "<think>") {
|
||||
t.Fatalf("same=%v d=%q", same, d)
|
||||
}
|
||||
// Un extrait ne coupe jamais un caractère.
|
||||
_, d = tplRenderDiff(a+"é", a+"è")
|
||||
if !strings.Contains(d, "é") || !strings.Contains(d, "è") {
|
||||
t.Errorf("extrait coupé : %q", d)
|
||||
}
|
||||
}
|
||||
|
||||
// Après la bascule : le rendu relevé sur le nouveau moteur est comparé à celui
|
||||
// d'avant, et l'écart est signalé dans l'état du panneau. Tant que le moteur
|
||||
// qui répond annonce l'ancien build, rien n'est comparé.
|
||||
func TestVerificationDuRenduApresBascule(t *testing.T) {
|
||||
testHome(t)
|
||||
freshTplProbe(t)
|
||||
oldPoll, oldWait := engineRenderPoll, engineRenderWait
|
||||
engineRenderPoll = time.Millisecond
|
||||
t.Cleanup(func() {
|
||||
engineRenderPoll, engineRenderWait = oldPoll, oldWait
|
||||
engineRenderMu.Lock()
|
||||
engineRenderLast = nil
|
||||
engineRenderMu.Unlock()
|
||||
})
|
||||
f := &fakeTpl{mode: "qwen3", build: "b10678-old"}
|
||||
f.start(t)
|
||||
ref, ok := engineRenderBefore(context.Background())
|
||||
if !ok || ref.build != "b10678-old" {
|
||||
t.Fatalf("rendu de référence : ok=%v build=%q", ok, ref.build)
|
||||
}
|
||||
set := func(mode, build string) {
|
||||
f.mu.Lock()
|
||||
f.mode, f.build = mode, build
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// L'ancien moteur répond encore : pas de comparaison, « non vérifié ».
|
||||
engineRenderWait = 50 * time.Millisecond
|
||||
engineRenderAfter(ref, "server-cuda-b11351")
|
||||
if c := engineRenderSnapshot(); c == nil || c.Status != "unknown" {
|
||||
t.Fatalf("ancien moteur toujours là : %+v", c)
|
||||
}
|
||||
engineRenderWait = 10 * time.Second
|
||||
|
||||
// Nouveau build, même gabarit : identique.
|
||||
set("qwen3", "b11351-new")
|
||||
engineRenderAfter(ref, "server-cuda-b11351")
|
||||
if c := engineRenderSnapshot(); c == nil || c.Status != "same" {
|
||||
t.Fatalf("même gabarit : %+v", c)
|
||||
}
|
||||
// Le moteur garde désormais la réflexion des tours passés : changé.
|
||||
set("keep", "b11351-new")
|
||||
engineRenderAfter(ref, "server-cuda-b11351")
|
||||
c := engineRenderSnapshot()
|
||||
if c == nil || c.Status != "changed" || !strings.Contains(c.Detail, "historique avec raisonnement") {
|
||||
t.Fatalf("gabarit changé : %+v", c)
|
||||
}
|
||||
f.assertNoCompletion(t)
|
||||
}
|
||||
@@ -139,9 +139,10 @@ func newWebMux() *http.ServeMux {
|
||||
// Jeton de tentative du décodage spéculatif automatique : effacé dès que le
|
||||
// moteur répond, sans dépendre d'une page ouverte (voir backend_serve_spec.go).
|
||||
startSpecAttemptWatch()
|
||||
// Optimisation interrompue (Loki tué en plein essai) : essai orphelin arrêté,
|
||||
// moteur relancé s'il tournait avant (backend_tune_lock.go).
|
||||
go tuneRecoverAtBoot()
|
||||
// Optimisation interrompue (Loki ou « loki tune » tué en plein essai) : essai
|
||||
// orphelin arrêté, moteur relancé s'il tournait avant (backend_tune_lock.go)
|
||||
// — au démarrage, puis toutes les 30 s (un stat sans verrou).
|
||||
go tuneRecoverWatch(time.NewTicker(tuneRecoverEvery).C)
|
||||
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é.
|
||||
@@ -219,6 +220,8 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/vram/reload", handleVramReload) // relance le moteur (le modèle se recharge)
|
||||
api("/api/ram", handleRam)
|
||||
api("/api/config", handleConfigEnv)
|
||||
api("/api/engine/cmdline", handleEngineCmdline) // commande du dernier lancement du moteur (amont #108)
|
||||
api("/api/model/layers", handleModelLayers) // nombre de couches du modèle, lu dans le GGUF (amont #43)
|
||||
api("/api/paths", handlePaths)
|
||||
api("/api/update", handleUpdateCheck)
|
||||
api("/api/update/apply", handleUpdateApply)
|
||||
@@ -248,12 +251,18 @@ func newWebMux() *http.ServeMux {
|
||||
api("/api/llamacpp/use", handleLlamacppUse) // bascule BIN entre versions déjà installées
|
||||
// Moteur livré par l'image : mise à jour SANS reconstruire l'image, en
|
||||
// extrayant /app de l'image officielle llama.cpp (voir web_engine.go).
|
||||
api("/api/engine", handleEngineStatus) // version qui tourne + versions installées
|
||||
api("/api/engine/check", handleEngineCheck) // dernier build publié vs courant
|
||||
api("/api/engine/update", handleEngineUpdate) // job : téléchargement + essai + bascule
|
||||
api("/api/engine/use", handleEngineUse) // bascule vers une version installée (ou "image")
|
||||
api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée
|
||||
api("/api/engine", handleEngineStatus) // version qui tourne + versions installées
|
||||
api("/api/engine/check", handleEngineCheck) // dernier build publié vs courant
|
||||
api("/api/engine/update", handleEngineUpdate) // job : téléchargement + essai + bascule
|
||||
api("/api/engine/use", handleEngineUse) // bascule vers une version installée (ou "image")
|
||||
api("/api/engine/remove", handleEngineRemove) // supprime une version téléchargée
|
||||
api("/api/engine/plan", handleEnginePlan) // sur clic : version visée + effet sur le rendu du gabarit
|
||||
api("/api/engine/rollback", handleEngineRollback) // revient au moteur d'avant la dernière bascule
|
||||
api("/api/strata", handleStrata) // Strata : machine, catalogue figé, recommandation, modèles installés
|
||||
api("/api/strata/install", handleStrataInstall) // job : paquet figé + installeur de Strata + preset (suivi par /api/llamacpp/job)
|
||||
api("/api/strata/settings", handleStrataSettings) // réglages d'un modèle Strata installé (relance s'il est actif)
|
||||
api("/api/presets", handlePresets)
|
||||
api("/api/presets/order", handlePresetsOrder) // ordre de la liste (glisser-déposer)
|
||||
api("/api/preset", handlePreset)
|
||||
api("/api/preset/save", handlePresetSave)
|
||||
api("/api/preset/cacheram", handlePresetCacheRAM) // aperçu de --cache-ram pour le preset édité
|
||||
@@ -353,6 +362,7 @@ func newWebMux() *http.ServeMux {
|
||||
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)
|
||||
api("/api/chat/cushot", handleCUShot) // aperçu en direct du navigateur piloté (RAM, jamais gardé)
|
||||
api("/api/chat/stop", handleChatStop) // interrompt la génération en cours
|
||||
api("/api/chat/tool-result", handleToolResult) // résultat COMPLET d'un outil (« voir plus »)
|
||||
api("/api/chat/reset", handleChatReset) // vide la discussion courante (pour tous les appareils)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package loki
|
||||
|
||||
// Vignettes des images du fil (voir handleChatImage, paramètre thumb ; repris d'AJEAN). Une
|
||||
// vignette est calculée une fois par fichier et par taille, puis gardée en
|
||||
// mémoire tant que le fichier ne change pas (date et taille).
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "image/gif" // décodeurs enregistrés pour image.Decode
|
||||
_ "image/png"
|
||||
)
|
||||
|
||||
const (
|
||||
thumbMaxSourceBytes = 40 << 20 // au-delà, pas de vignette : le client prendra l'original
|
||||
thumbCacheMax = 300
|
||||
)
|
||||
|
||||
type thumbEntry struct {
|
||||
mod time.Time
|
||||
size int64
|
||||
data []byte
|
||||
}
|
||||
|
||||
var (
|
||||
thumbMu sync.Mutex
|
||||
thumbCache = map[string]thumbEntry{}
|
||||
)
|
||||
|
||||
// chatThumb renvoie une vignette JPEG dont le grand côté vaut px (borné
|
||||
// 64..1600). Erreur si le fichier n'est pas une image décodable ici (webp…) :
|
||||
// le client se rabat alors sur l'original.
|
||||
func chatThumb(path string, st os.FileInfo, px int) ([]byte, string, error) {
|
||||
if px < 64 {
|
||||
px = 64
|
||||
}
|
||||
if px > 1600 {
|
||||
px = 1600
|
||||
}
|
||||
if st.Size() > thumbMaxSourceBytes {
|
||||
return nil, "", fmt.Errorf("image trop lourde pour une vignette")
|
||||
}
|
||||
key := path + "\x00" + strconv.Itoa(px)
|
||||
thumbMu.Lock()
|
||||
if e, ok := thumbCache[key]; ok && e.mod.Equal(st.ModTime()) && e.size == st.Size() {
|
||||
thumbMu.Unlock()
|
||||
return e.data, "image/jpeg", nil
|
||||
}
|
||||
thumbMu.Unlock()
|
||||
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
src, _, err := image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("format non pris en charge pour une vignette")
|
||||
}
|
||||
if o := exifOrientation(raw); o >= 2 && o <= 8 {
|
||||
src = applyOrientation(src, o)
|
||||
}
|
||||
if b := src.Bounds(); b.Dx() > px || b.Dy() > px {
|
||||
src = scaleDownToMax(src, px)
|
||||
}
|
||||
// Fond blanc sous la transparence : en JPEG elle deviendrait noire.
|
||||
flat := image.NewRGBA(src.Bounds())
|
||||
draw.Draw(flat, flat.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
|
||||
draw.Draw(flat, flat.Bounds(), src, src.Bounds().Min, draw.Over)
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, flat, &jpeg.Options{Quality: 78}); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
data := buf.Bytes()
|
||||
|
||||
thumbMu.Lock()
|
||||
if len(thumbCache) >= thumbCacheMax {
|
||||
for k := range thumbCache { // éviction grossière : la place compte plus que l'ordre
|
||||
delete(thumbCache, k)
|
||||
if len(thumbCache) < thumbCacheMax/2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
thumbCache[key] = thumbEntry{mod: st.ModTime(), size: st.Size(), data: data}
|
||||
thumbMu.Unlock()
|
||||
return data, "image/jpeg", nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChatThumbReduitEtCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
p := filepath.Join(dir, "capture.png")
|
||||
src := image.NewNRGBA(image.Rect(0, 0, 2400, 1500))
|
||||
x := uint32(1)
|
||||
for i := range src.Pix { // bruit : se comprime comme une photo, pas comme un motif
|
||||
x ^= x << 13
|
||||
x ^= x >> 17
|
||||
x ^= x << 5
|
||||
src.Pix[i] = byte(x)
|
||||
}
|
||||
src.Set(0, 0, color.NRGBA{0, 0, 0, 0})
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, src)
|
||||
_ = os.WriteFile(p, buf.Bytes(), 0o644)
|
||||
st, _ := os.Stat(p)
|
||||
data, mime, err := chatThumb(p, st, 560)
|
||||
if err != nil || mime != "image/jpeg" {
|
||||
t.Fatalf("vignette : %v %s", err, mime)
|
||||
}
|
||||
img, _, err := image.Decode(bytes.NewReader(data))
|
||||
if err != nil || img.Bounds().Dx() != 560 {
|
||||
t.Fatalf("taille inattendue : %v %v", err, img.Bounds())
|
||||
}
|
||||
if len(data) >= buf.Len() {
|
||||
t.Fatalf("vignette (%d o) pas plus légère que l'original (%d o)", len(data), buf.Len())
|
||||
}
|
||||
again, _, _ := chatThumb(p, st, 560)
|
||||
if &again[0] != &data[0] {
|
||||
t.Fatal("la 2e demande aurait dû venir du cache")
|
||||
}
|
||||
if _, _, err := chatThumb(filepath.Join(dir, "x.webp"), st, 560); err == nil {
|
||||
t.Fatal("un fichier illisible doit renvoyer une erreur (repli client sur l'original)")
|
||||
}
|
||||
}
|
||||
@@ -180,6 +180,10 @@ func visionEnabled() bool {
|
||||
if externalActive() {
|
||||
return externalVisionActive() // modèle distant déclaré multimodal
|
||||
}
|
||||
// Strata : l'encodeur d'images est installé avec le modèle, sans MMPROJ.
|
||||
if strataVisionActive() {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(ReadConfig()["MMPROJ"]) != ""
|
||||
}
|
||||
|
||||
@@ -357,6 +361,21 @@ func handleChatImage(w http.ResponseWriter, r *http.Request) {
|
||||
sendJSON(w, 404, map[string]any{"ok": false, "error": "fichier introuvable"})
|
||||
return
|
||||
}
|
||||
// Vignette légère (?thumb=<px>) : le fil n'a besoin que de ~560 px, pas
|
||||
// d'une capture de 3 Mo — en accès distant, chaque image partait en entier
|
||||
// dans le tunnel chiffré (repris d'AJEAN). Format non décodable ici (webp…) :
|
||||
// on sert l'original.
|
||||
if t := r.URL.Query().Get("thumb"); t != "" {
|
||||
px, _ := strconv.Atoi(t)
|
||||
if data, mime, err := chatThumb(abs, st, px); err == nil {
|
||||
w.Header().Set("Content-Type", mime)
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
w.Header().Set("Cache-Control", "private, max-age=3600")
|
||||
_, _ = w.Write(data)
|
||||
return
|
||||
}
|
||||
}
|
||||
f, err := os.Open(abs)
|
||||
if err != nil {
|
||||
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
|
||||
|
||||
Reference in new issue
Block a user