Compare commits

..
Author SHA1 Message Date
MichaelandClaude Opus 5 5d391c4f73 Dictée : panneau de réglages — GPU, modèle, langue, test du micro
Le choix du matériel et du modèle n'existait nulle part : la dictée prenait
le seul modèle codé en dur et le seul chemin possible. Sur une machine à
plusieurs GPU, rien ne permettait de dire lequel prêter à whisper.

Le panneau liste les GPU avec leur VRAM libre (/api/vram donne déjà tout), et
chaque modèle avec sa taille de téléchargement et sa mémoire. Un modèle absent
le dit dans sa propre étiquette : le choisir n'est pas un réglage instantané
mais un transfert de plusieurs centaines de mégaoctets, et le taire ferait
passer l'attente pour une panne.

Le test du micro capte trois secondes, affiche le niveau mesuré puis la
transcription, sans toucher au champ de saisie. Vu de l'extérieur, un micro
muet, un niveau trop faible et un moteur en panne se ressemblent tous — il
fallait bricoler ce diagnostic à la main pour les distinguer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:11:21 +02:00
MichaelandClaude Opus 5 3cd4facb0e Dictée : whisper-server supervisé, modèle chargé une seule fois
Chaque dictée relançait whisper-cli, qui relisait le modèle depuis le disque
avant de transcrire. Ce chargement dominait le temps de réponse — 3,2 s de
calcul pour 3,4 s d'audio — et aucun réglage ne pouvait le rattraper.

whisper-server garde le modèle en mémoire entre deux phrases. Loki le
supervise : démarrage au premier clic sur le micro, extinction après dix
minutes sans dictée. Une dictée n'est pas un service permanent, et garder un
modèle chargé toute la journée priverait le moteur de chat de sa VRAM.

- Réglages serveur (modèle, langue, matériel, réactivité) dans bkState, avec
  leurs routes. La langue par défaut passe de « auto » à « fr » : sur quelques
  secondes d'audio la détection se trompe, et une langue mal détectée produit
  du charabia — des suites de caractères géorgiens ont été observées.
- Catalogue de quatre modèles, de small à large-v3 ; large-v3-turbo par
  défaut. Téléchargement par identifiant, dans un .part renommé à la fin : un
  transfert interrompu ne laisse plus un .bin tronqué qu'on croirait bon.
- Le GPU se choisit par CUDA_VISIBLE_DEVICES, posé en REMPLAÇANT toute valeur
  héritée. Dupliquée, la variable laisse le gagnant dépendre de la libc.
- Les marqueurs de whisper ([BLANK_AUDIO], (silence)) ne sont plus collés dans
  le champ de saisie comme s'ils étaient du texte dicté.

L'image construit DEUX binaires whisper-server, CPU et CUDA. LLAMACPP_IMAGE
accepte la variante CPU de l'image amont ; sur cette base les .so CUDA sont
absentes et un binaire lié à CUDA n'a même pas de quoi démarrer, donc aucun
repli n'est possible depuis le programme. Le garde-fou du Dockerfile vérifie
maintenant les deux étapes : les drapeaux de portabilité de la PR #18 doivent
survivre à l'arrivée de CUDA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:08:12 +02:00
MichaelandClaude Opus 5 3624194b39 Plan : moteur de dictée supervisé et configurable
Sept tâches, de la persistance des réglages au panneau de l'interface. Le
temps réel (WebSocket + découpeur) fait l'objet d'un second plan : ce plan-ci
livre déjà un logiciel utilisable — bon modèle, GPU au choix, lenteur
supprimée — sans le direct.

Contraintes relevées dans le dépôt et inscrites en tête du plan : la CI
compile pour Windows (donc pas de Setsid), staticcheck doit rester à zéro, et
index.html est généré par tools/assemble-ui, jamais édité à la main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:00:43 +02:00
MichaelandClaude Opus 5 2fa05fdaf7 Conception : dictée en temps réel, configurable selon le matériel
La dictée marche depuis la PR #18 mais comprend mal et n'écrit qu'à l'arrêt du
micro. La lenteur est structurelle : chaque requête relance whisper-cli, qui
recharge le modèle depuis le disque. Découper en tranches par-dessus ce
mécanisme reviendrait à recharger le modèle toutes les quelques secondes.

La conception retient whisper-server (modèle chargé une fois, supervisé par
Loki comme llama-server), un découpage en tranches calé sur les silences et
piloté côté Go, et un panneau Dictée pour choisir GPU, modèle, langue et
réactivité.

Deux points tranchés en cours de route :

- Deux binaires whisper-server, CPU et CUDA, pas un seul. LLAMACPP_IMAGE
  accepte la variante CPU de l'image amont ; sur cette base les .so CUDA sont
  absentes et un binaire lié à CUDA n'a même pas de quoi démarrer. Un binaire
  unique condamnerait cette variante.
- Le seuil de silence est adaptatif. Un seuil fixe ne coupe jamais dans une
  pièce calme avec un micro discret, et coupe sans arrêt près d'un ventilateur.

Les drapeaux GGML_NATIVE=OFF restent exigés sur les deux cibles : le SIGILL de
la PR #18 ne doit pas revenir par la porte du build CUDA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 17:18:49 +02:00
LogiFlow e0ed0a32af Merge pull request #18 from R0m1k3/fix/whisper-binaire-non-portable
Dictée : whisper-cli compilé pour la machine de build, pas pour la tienne
2026-08-21 16:30:48 +02:00
MichaelandClaude Opus 5 a19b490b6a Dictée : whisper-cli compilé pour la machine de build, pas pour la tienne
La dictée n'a jamais transcrit une seule phrase. Le micro enregistrait bien —
contexte HTTPS, autorisation accordée, bouton rouge — mais le serveur répondait
500 sur chaque envoi :

    whisper-cli : AMX is not ready to be used!
    read_audio_data: reading audio data from '/tmp/loki-dictee-...wav' ...
    read_audio_data: trying to decode with miniaudio

Deux indices dans ces trois lignes. « AMX is not ready to be used » ne sort que
d'un binaire COMPILÉ avec les instructions AMX, celles d'un Xeon récent — le
runner GitHub. Et le journal s'arrête net : ni « failed to decode », ni texte
transcrit. Le processus ne renvoie pas une erreur, il meurt.

ggml compile en -march=native par défaut. Le binaire partait donc taillé pour
le processeur qui l'avait construit, et rencontrait une instruction illégale
dès qu'on le posait ailleurs.

- GGML_NATIVE=OFF à l'étape whisperbuild, AVX-512 et AMX explicitement coupés.
  Reste la ligne de base AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis
  2013. Un test lit le Dockerfile et garde ces drapeaux : rien à l'exécution ne
  rappellerait leur raison d'être au prochain qui touchera cette étape.
- Le handler ne renvoyait que la dernière ligne de stderr. Sur une mort par
  signal, cette ligne est celle d'AVANT le coup fatal : elle ressemble à une
  explication sans en être une, et envoie chercher du côté de l'audio. Il dit
  maintenant comment le processus a fini — « signal: illegal instruction » vaut
  diagnostic à lui seul.

L'image doit être reconstruite : whisper-cli y est compilé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 16:27:46 +02:00
LogiFlow 393986faa6 Merge pull request #17 from R0m1k3/fix/ngl-sentinelle-999-auto
NGL=999 : la sentinelle « toutes les couches » devient -ngl auto
2026-08-21 10:45:26 +02:00
MichaelandClaude Opus 5 2585d2c2e1 NGL=999 : la sentinelle « toutes les couches » devient -ngl auto
Le correctif précédent ne pouvait pas s'appliquer. Il ne posait « -ngl auto »
que si la clé NGL était ABSENTE de config.env — or defaultConfig() y sème
NGL=999 sur chaque installation neuve. La quasi-totalité des configurations la
portent donc sans que personne ne l'ait choisie, le code la lisait comme un
choix délibéré, et l'abandon revenait intact :

  W common_fit_params: failed to fit params to free device memory:
    n_gpu_layers already set by user to 999, abort

999 n'a jamais été un nombre de couches : c'est la sentinelle historique
« toutes ». Sur un moteur qui sait mesurer la VRAM libre, elle devient donc
« -ngl auto », et Loki le dit sur stderr plutôt que de le faire en douce. Tout
autre nombre reste intouché — c'est un vrai choix. NGL=all force l'ancien
comportement, NGL=auto n'envoie toujours aucun drapeau, et un moteur ancien
reçoit toujours 999 (l'omettre le ferait tourner 100 % CPU).

- La décision sort dans nglArgs(), fonction pure : la seule question qui
  demande le moteur arrive déjà tranchée, donc elle se teste sans lancer
  llama-server. 10 cas couverts.
- binFitsLayersItself() double la lecture fine de l'aide par la présence de
  --load-mode. Les deux sont arrivés dans la même vague ; une description
  reformulée ou une colonne plus large ne doit pas faire conclure « moteur
  incapable » et réimposer 999.
- L'aide de la clé et le sous-titre du champ disent la nouvelle règle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:47:32 +02:00
LogiFlow fedbaf32a0 Merge pull request #16 from R0m1k3/fix/llamacpp-deprecated-flags-dictee
Moteur : drapeaux de chargement à jour, -ngl auto ; dictée qui dit po…
2026-08-21 09:26:46 +02:00
MichaelandClaude Opus 5 cbf8635c61 Moteur : drapeaux de chargement à jour, -ngl auto ; dictée qui dit pourquoi
Les llama-server récents ont fusionné --mlock, --mmap et --no-mmap dans un
seul --load-mode, et refusent désormais d'ajuster les couches GPU dès qu'on
leur impose un nombre : « n_gpu_layers already set by user to 999, abort ».
Le moteur pousse alors tout sur le GPU et meurt en cudaMalloc, ou se replie
à moitié sur le CPU — débit effondré, GPU à 100 %.

- Traduction des drapeaux dépréciés AU LANCEMENT, pas dans le preset :
  --mlock → --load-mode mlock, --no-mmap → --load-mode none, --mmap →
  --load-mode mmap. Les interrupteurs de l'interface continuent d'écrire
  l'ancienne forme, et un moteur antérieur la reçoit telle quelle. Un
  --load-mode écrit à la main dans EXTRA_ARGS gagne sur tout.
- -ngl auto par défaut quand le moteur propose la valeur, 999 sinon (l'omettre
  sur un moteur ancien le ferait tourner 100 % CPU). NGL=<nombre> dans le
  preset reste souverain.
- --parallel et -ngl ne sont plus ajoutés en double quand EXTRA_ARGS les porte
  déjà : le moteur râlait (« specified multiple times ») et, pire, c'est la
  dernière occurrence qui gagne — le réglage explicite du preset était donc
  silencieusement écrasé par le défaut de Loki.
- Les capacités se lisent dans « <bin> --help », une seule fois par binaire
  (binHelp met en cache) au lieu d'un lancement par question.

Dictée : quand le téléchargement du modèle whisper échoue (conteneur sans
réseau, Hugging Face injoignable), le serveur renvoyait bien la raison mais
l'interface affichait quand même « téléchargement (0 %) ». L'erreur cachée
laissait cliquer indéfiniment sur un micro qui n'écrira jamais rien.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 09:26:22 +02:00
MichaelandClaude Fable 5 69bf8d985d Vérification du moteur de retour ; boutons MCP au gabarit ; « Paramètres »
- Le bouton « vérifier les mises à jour » revient dans la carte Moteur : il
  ne fait QUE regarder, là où « mettre à jour » télécharge et bascule après
  confirmation. Deux gestes qui n'engagent pas pareil, deux boutons.
- Les boutons « catalogue » / « + ajouter un serveur » (MCP) étaient des
  pilules arrondies au milieu de boutons rectangulaires : même gabarit que
  leurs voisins (4 px, 12 px, mêmes marges).
- « Réglages » devient « Paramètres » dans la barre latérale et en tête de
  la modale.

Version 0.12.3.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 08:13:21 +02:00
MichaelandClaude Fable 5 50fccdc9ad Captures d'écran : visibles en entier, jamais repliées d'office
La carte d'outil qui porte une capture (web_screenshot) échappe au plafond
de 280 px — l'image se montrait par le trou d'une serrure — et au repli
automatique : l'image EST le résultat, la replier la cachait sitôt prise.
Elle garde son max-height propre (420 px) et s'ouvre plein écran au clic.
Retirée de la liste de repli du tour pour survivre aussi au repli de fin de
tour. Au rejeu du journal, elle arrive repliée comme le reste de
l'historique. Version 0.12.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:58:29 +02:00
MichaelandClaude Fable 5 66072e4047 Cartes de section : ouvertes tant qu'elles travaillent, repliées sitôt finies
Une carte (mémoire, raisonnement, outil…) reste ouverte pendant que SA
section travaille — bornée à 280 px, défilement qui suit l'écriture — et se
replie dès qu'elle est finie : l'outil au retour de son résultat, le
raisonnement quand le bloc suivant démarre, le reste en fin de tour. On
retrouve les fenêtres distinctes par section, sans qu'elles envahissent la
page. Version 0.12.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:47:45 +02:00
MichaelandClaude Fable 5 62573ff972 Version 0.12.0
Sélecteur de modèle fonctionnel, dictée vocale, cartes de raisonnement qui
suivent l'écriture, moyenne tok/s de la conversation, % de chargement réparé
à chaud, tri des discussions par création, bouton Réglages en pied de barre.
Notes de version à jour ; .syso régénérés.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:30:58 +02:00
MichaelandClaude Fable 5 481028b023 Cartes raisonnement qui suivent l'écriture ; micro aligné ; moyenne tok/s
- Le défilement interne des cartes bornées ne suivait PAS la génération :
  le rendu en attente porte la bulle entière (.msg), pas .body — le test
  « parent = bodywrap » ne passait jamais et l'épinglage bas était mort.
  On cherche maintenant le bodywrap DANS la bulle : la carte suit le texte
  au fur et à mesure, et une remontée manuelle est respectée. Les cartes
  d'outil suivent aussi la ligne en cours, puis remontent en tête une fois
  l'appel terminé.

- Bouton micro : il gardait la bordure des boutons génériques, décalé de
  ses voisins. Ajouté aux règles communes des boutons de saisie (34 px,
  sans bordure, fond au survol) avec #attach et #files-btn.

- La carte du temps total (au-dessus de la saisie) affiche la vitesse
  moyenne de la conversation : « en cours — 12 s · moy 21.3 tok/s ».
  Alimentée par les événements stats journalisés (gen_tokens/gen_ms,
  cumulatifs par complétion — seul le delta est ajouté), donc rejouée au
  chargement : la moyenne survit au refresh. Remise à zéro au reset.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:24:07 +02:00
MichaelandClaude Fable 5 86d711e772 Sélecteur de modèle qui charge vraiment ; % de chargement qui bouge
Quatre retouches d'interface :

- Le sélecteur de l'en-tête liste maintenant les MODÈLES du disque (groupe
  « Modèles (.gguf) ») en plus des presets : en choisir un le charge —
  POST /api/models/use écrit MODEL seul (contexte, NGL et échantillonnage
  conservés) et redémarre le service en arrière-plan. Sans preset créé, le
  sélecteur n'offrait aucun choix.

- Pourcentage de chargement : sur un redémarrage à chaud, le modèle est déjà
  dans le cache disque — llama-server ne lit rien (read_bytes reste à 0) et
  la pastille passait de « 0 % » à « prêt » sans jamais monter. On prend le
  plus avancé de read_bytes et de la mémoire résidente (VmRSS), qui grandit
  cache ou pas.

- Discussions triées par date de CRÉATION (récentes en tête) : une
  discussion garde sa place, écrire dans un vieux fil ne le fait plus
  remonter.

- Bouton Réglages ancré en pied de barre latérale, juste au-dessus du
  moniteur Performance — toujours au même endroit, quel que soit le nombre
  de discussions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 22:13:16 +02:00
MichaelandClaude Fable 5 ad107ed284 Dictée vocale : micro dans la carte de saisie, whisper.cpp local
Un bouton micro dans la carte de saisie : un clic enregistre, un second
arrête, transcrit et pose le texte dans le champ sans écraser ce qui s'y
trouve. Anneau rouge pulsé pendant l'enregistrement, garde-fou à 90 s.

La transcription est 100 % locale : POST /api/transcribe → whisper-cli
(whisper.cpp, compilé CPU en statique dans une étape dédiée du Dockerfile).
Le modèle ggml-small-q5_1 (~190 Mo, multilingue) n'est pas dans l'image :
téléchargé au premier usage dans /data/whisper/ avec progression (503
{downloading, pct} en attendant), il survit aux recréations du conteneur.

L'audio est encodé en WAV 16 kHz mono côté navigateur (whisper.cpp ne lit
que du PCM) — pas de ffmpeg dans l'image. Le micro n'existe qu'en contexte
sécurisé (HTTPS ou localhost) : le bouton l'explique au lieu d'échouer en
silence.

Vérifié bout en bout avec le binaire whisper.cpp officiel : téléchargement
du modèle par le handler, puis une sinusoïde 440 Hz transcrite « (beeping) ».

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 19:52:38 +02:00
MichaelandClaude Fable 5 43489769f4 Cartes raisonnement bornées et fluides ; version 0.11.0
Un long raisonnement (des milliers de tokens) faisait grandir la page de
plusieurs écrans et finissait par figer l'affichage : le bloc entier était
re-parsé en Markdown à chaque tick, en O(n²). Deux mesures :

- Les cartes raisonnement/outils ont une hauteur bornée (280 px) avec
  défilement interne, collé en bas pendant la génération (un défilement
  manuel vers le haut est respecté).
- En direct, un bloc de raisonnement géant n'est re-parsé que sur sa fin
  (REASON_TAIL) ; le texte complet est posé au rendu de fin de bloc. La
  finalité voyage avec le rendu en attente (renderPending.final) : le timer
  déjà armé passait final=false et consommait le rendu final en ne posant que
  la queue — le début du raisonnement n'était jamais rendu.

Version 0.11.0 (const, versioninfo, .syso régénérés) ; README et
RELEASE_NOTES à jour sur les nouveautés du fork.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:16:55 +02:00
MichaelandClaude Fable 5 a333603ca6 Réglages en modale à deux volets ; discussions triées par activité
La barre latérale ne garde que les discussions et le moniteur machine : tout
le bloc « Réglages » (11 sections empilées en details) devient une modale
avec sa nav de sections à gauche et LE panneau choisi à droite — chaque
section a enfin toute la largeur, et la barre latérale respire.

Les panneaux réutilisent tels quels les gabarits existants (.slist,
.switchrow, .subhead) ; les ids historiques (svc-log-box, lc-details,
tasks-details…) sont conservés sur les panneaux, et openDetails() les résout
vers openSettings(panneau) : pastille d'état du moteur, pastille
d'installation et jobs llama.cpp rouvrent la modale au bon endroit sans
changer leurs appelants. Le chargement paresseux du journal du moteur
(ancien ontoggle) devient un hook d'affichage du panneau.

Discussions : la plus récente en tête. Le serveur triait déjà par date
d'activité, mais son tri stable laissait une discussion toute neuve SOUS
celle qu'on venait de quitter (touchées dans la même seconde) : l'UI
départage par date de création puis par ordre d'index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 17:05:38 +02:00
Michael f4090059f1 Merge branch 'claude/model-badge-reponses' : nom du modèle sur chaque réponse 2026-08-20 15:58:57 +02:00
MichaelandClaude Fable 5 b814862f40 Nom du modèle affiché à côté de « Loki » sur chaque réponse
Le nom du modèle (fichier .gguf, sans dossier ni extension) est journalisé
avec la borne de tour côté serveur : chaque réponse garde le modèle qui l'a
réellement produite, y compris après un rechargement de page (rejeu du
journal) et même si un autre modèle est chargé depuis.

Côté UI, une pastille discrète s'affiche dans l'étiquette de la bulle de
réponse. Elle vit dans dataset.model pour survivre au repeint des libellés
lors d'un changement d'identité (applyIdentity).

Vérifié en conditions réelles (moteur simulé, rechargement en pleine
génération) : badge, tok/s des bulles et chrono « en cours » sont bien
rejoués après un refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 15:50:51 +02:00
LogiFlow 88dbe5642f Merge pull request #14 from R0m1k3/claude/new-session-hrmmyc
Support Hugging Face gated repositories with configurable tokens
2026-08-20 12:38:03 +02:00
Claude 7f5765160d Jeton Hugging Face réglable dans l'interface
Le jeton ne vivait que dans la variable d'environnement HF_TOKEN : découvrir
depuis l'interface qu'un dépôt est verrouillé, c'était devoir éditer un
docker-compose et recréer le conteneur pour y répondre.

- Éditeur de preset → Modèle → « Jeton Hugging Face » : ligne repliée comme
  « Dossiers de modèles », qui affiche l'état (aucun / masqué / fourni par
  l'environnement). Le bandeau d'un dépôt verrouillé y mène d'un clic.
- Le jeton est VÉRIFIÉ auprès de /api/whoami-v2 avant d'être enregistré (le
  compte s'affiche) : un jeton mal collé accepté en silence rendrait le 401
  qu'on cherchait à expliquer. Il est rangé avec les secrets en base d'état, pas
  dans config.env que le changement de preset réécrit en bloc, et n'est jamais
  renvoyé en clair — seulement masqué.
- Priorité : jeton enregistré, puis HF_TOKEN. Rien d'enregistré = comportement
  d'avant à l'identique. L'enregistrement vide le cache des réponses obtenues
  sans jeton.
- Le jeton n'est envoyé qu'aux adresses Hugging Face : un lien collé vers un
  autre hébergeur n'a aucune raison de recevoir un secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hz1QWmvZqW3t53YC5SJBKC
2026-08-20 10:32:44 +00:00
Claude b339cced0a Dépôts Hugging Face verrouillés : dit lesquels, et pourquoi le 401
Un dépôt « gated » (orcarouter/Qwen3.8-27B-Uncensored-GGUF, vécu) laisse lire
son arborescence sans rien : Loki listait donc ses seize quantifications avec
leur verdict mémoire, puis échouait sur « HTTP 401 depuis la source » au
premier octet. Le message ne disait ni que le dépôt était verrouillé, ni qu'il
fallait accepter ses conditions, ni où poser un jeton.

- Le refus est maintenant traduit à partir de X-Error-Code (GatedRepo,
  RepoNotFound, EntryNotFound…) et nomme le dépôt, l'action à faire et l'état
  du jeton : absent (il en faut un) ou présent mais sans accès. 404, 416, 429
  et les pannes de la source y gagnent aussi une phrase utile.
- Le verrou se voit AVANT de choisir une quantification : la recherche demande
  `expand[]=gated` et la fiche du dépôt est lue à l'ouverture, d'où une
  pastille « accès restreint » dans la liste et un avertissement en toutes
  lettres au-dessus des fichiers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hz1QWmvZqW3t53YC5SJBKC
2026-08-20 10:18:55 +00:00
LogiFlow d7214d97eb Merge pull request #13 from R0m1k3/claude/slot-timing-performance-jqwjgx
Synchronisation avec l'amont AJEAN + tâches planifiées
2026-08-20 11:35:10 +02:00
Claude 0f58ad7b49 Tâches planifiées : l'IA travaille toute seule (repris de l'amont AJEAN)
Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui
nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à
9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA
l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle
avant l'exécution, attente du rechargement), accès mémoire et web réglables,
interrupteur maître pour tout suspendre, bouton « tester maintenant ».

Le planificateur est une goroutine, un tic par minute, dans le process qui
détient la conversation et parle au moteur. Une seule tâche part par tic : de
toute façon une seule inférence tourne à la fois, et étaler les départs évite
qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement
n'est pas un échec — la tâche repasse au tic suivant.

Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni
messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et
le jette, ne gardant que son texte final comme compte-rendu (borné à 4000
caractères, réinjecté au passage suivant pour la continuité).

Deux adaptations, parce que loki n'est pas l'amont :

— Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y
  aurait déposé ses fichiers, et en aurait changé en cours de route si
  l'utilisateur changeait de discussion — pour disparaître avec elle à la
  suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un
  passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS
  (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages
  continuent de suivre la discussion de l'utilisateur.

— Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le
  zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme
  « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le
  mode code reste off : rôles, critères et passe de vérification n'ont pas de
  sens sans personne en face.

Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe
le modèle : « génération en cours » sur un fil vide et immobile n'expliquait
rien.

Interface : section repliable dans les réglages (liste, état, prochain passage,
pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau
« dernier résultat » en markdown. Vérifié dans un vrai navigateur — création,
rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître,
suppression — sans une seule erreur JS.
2026-08-20 09:31:59 +00:00
Claude 03ae361ade Synchronisation avec l'amont AJEAN (v0.9.5 → v0.10.7)
Le fork est parti de la v0.9.4 ; l'amont en est à la v0.10.7. Reprise de ce
qui manque VRAIMENT ici, en laissant de côté ce que loki a déjà résolu à sa
façon (contexte MTP via --parallel 1, jauge de contexte, chrono de tour,
vignettes d'images, ligne d'état de génération).

Rendu du chat cadencé puis lissé (amont v0.9.5 issue #24, v0.10.5). Chaque
token re-parsait le Markdown du bloc ENTIER : du O(n²) qui faisait ramer
l'interface sur un long raisonnement — le moteur débitait toujours autant, mais
les tokens semblaient arriver au ralenti et un simple rafraîchissement
« réparait » tout. Le texte s'accumule désormais et n'est re-rendu qu'à
intervalle adaptatif (16 ms sur un petit bloc, jusqu'à 500 ms sur un énorme),
soldé à chaque frontière (outil, bascule de rôle, fin de tour, erreur, rejeu).
Par-dessus, un lissage d'apparition découple l'arrivée de l'affichage : le
décodage spéculatif rend les tokens par rafales, le texte sautait par paquets ;
il s'écoule maintenant à cadence régulière. Rejeu exclu — relire un fil ne doit
pas être une lente réécriture.

Échantillonnage réglable par preset (amont v0.9.5/v0.9.6) : TEMP, TOP_P, TOP_K,
MIN_P, PRESENCE_PENALTY, REPEAT_PENALTY, injectés dans chaque requête (donc sans
redémarrage du moteur), vide = défaut du serveur. Sans ça seule la température
voyageait et le reste retombait sur les défauts de llama.cpp, rarement ceux que
recommande le modèle. Différence avec l'amont : REASONING_EFFORT n'est PAS
traité là — loki lui réserve un chemin plus riche, et l'écrire ici écraserait
`chat_template_kwargs`, donc la consigne « aucune ».

Un seul message système, en tête, à l'envoi (amont v0.9.8, issue #26).
steerSystem ne couvrait que les consignes de loki ; un historique venu
d'ailleurs peut encore en porter deux, et Qwen3.x en --jinja répond alors
« System message must be at the beginning ». Copie normalisée : l'historique
affiché et persisté garde sa forme.

Détection Vulkan multi-distro (amont issues #28, #29) : le chemin Debian codé en
dur est invisible sur Fedora/RHEL/Atomic, où le plan de build retombait sur le
CPU. ldconfig d'abord, puis les chemins connus.

Dossier de travail (amont v0.10.2) : la consigne dit maintenant ce que le
dossier EST — l'endroit par défaut de tout ce que le modèle produit — et nomme
les dossiers système à ne pas toucher, au lieu d'interdire vaguement d'en sortir.

Non repris : les tâches planifiées (~1200 lignes + interface, à décider), et le
quoting cmd.exe par .bat temporaire (loki tourne en conteneur Linux).
2026-08-20 09:31:59 +00:00
LogiFlow 40f34170b9 Merge pull request #12 from R0m1k3/claude/slot-timing-performance-jqwjgx
Raisonnement : un gabarit qui refuse le niveau ne tue plus le tour
2026-08-20 10:52:12 +02:00
Claude f3b0f78b64 Raisonnement : un gabarit qui refuse le niveau ne tue plus le tour
Qwen3.8-27B valide `reasoning_effort` au lieu de l'ignorer : il connaît
xhigh/medium/low, pas « high » — le niveau que l'interface enregistre par
défaut. Chaque message partait donc en 500, avec une trace jinja affichée
en guise d'erreur, et le 500 tombait dans la branche « prompt trop long »
de runChat : loki compactait l'historique pour rien avant d'abandonner.

Le refus dit lui-même ce que le gabarit accepte. On le lit (llm_effort.go),
on traduit le niveau demandé vers le plus proche sur l'échelle
none/minimal/low/medium/high/xhigh — à égalité, le plus fort, dégrader en
silence étant pire que générer un peu plus longtemps — et on rejoue le tour,
historique intact. Sans liste annoncée, le champ est simplement retiré.
« aucune » n'est jamais traduite : c'est une coupure, portée par
`enable_thinking` que tous les gabarits comprennent.

La traduction est retenue par modèle : les messages suivants ne repaient pas
l'aller-retour. Le repli est tracé sur stderr, sinon l'intensité choisie dans
l'interface n'est pas celle qui part au moteur sans que rien ne le dise.

« maximale » (xhigh) rejoint la liste des niveaux proposés : aucun gabarit ne
les connaît toutes, et sans elle le maximum d'un Qwen3.8 restait hors
d'atteinte. Le repli couvre les gabarits qui la refusent.
2026-08-20 08:49:09 +00:00
MichaelandClaude Opus 5 6a77999aac Retire l'ancienne application Python/React du dépôt
Le commit précédent a versé backend/ et frontend/ dans le dépôt — 94 Mo
de node_modules et d'artefacts de build, restes du fork pré-Go qui
traînaient encore dans le répertoire de travail. Ils ne sont plus suivis,
et .gitignore les refuse désormais avec les caches Python et npm.

L'image Docker n'était pas concernée : le Dockerfile ne copie que cmd/,
internal/, tools/ et les fichiers de module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:29:40 +02:00
MichaelandClaude Opus 5 b405adea6c Jauge de contexte, espace disque sur partage agrégé, panneau Fichiers
Trois défauts sans rapport entre eux, tous visibles à l'écran.

Jauge de contexte bloquée à « 0 / N (0 %) » : le comptage exact vient de
usage.prompt_tokens (include_usage), qu'un llama-server récent a cessé de
renvoyer. On note désormais si l'usage est arrivé ; sinon la fin de tour
publie l'estimation qui pilote déjà la compaction — approximative, mais
jamais absente. Et la souscription envoie la valeur courante juste après
le rattrapage : une page rechargée affichait zéro sur une discussion
pourtant pleine, faute d'événement ctx_used dans la fenêtre rejouée.

Espace disque : « il ne reste que 11,9 Go » sur un partage Unraid qui en
a des centaines. /mnt/user est servi par shfs, un FUSE qui agrège
plusieurs disques — statfs y renvoie l'espace d'un seul, et ce chiffre
REFUSAIT l'installation du modèle. Les systèmes de fichiers qui
approximent (FUSE, overlay, NFS, CIFS, Ceph, Gluster) sont reconnus par
leur magie : leur chiffre reste affiché, précédé d'un « ~ », mais ne
bloque plus rien.

Panneau Fichiers : le panneau entier défilait, emportant vers le haut
l'en-tête, le fil d'Ariane et le pied (occupation disque, bouton .zip)
dès qu'il y avait quelques fichiers. Seule la liste défile maintenant ;
le pied reste en bas et l'état vide se centre.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:28:53 +02:00
MichaelandClaude Opus 5 895ff92ed8 Composeur : bandeaux au-dessus de la carte, chrono du tour, anneau réparé
Trois défauts d'affichage, une cause commune pour deux d'entre eux.

Le panneau des critères et la puce « mode Code » étaient insérés AVANT
#composer, qui est en position:absolute : ils restaient dans le flux de
la page et passaient donc SOUS la carte de saisie. Ils vivent maintenant
DANS le composeur, alignés sur sa colonne, et les critères s'ouvrent
repliés (cinq lignes dépliées mangeaient la moitié de l'écran).

Chrono du tour complet au-dessus de la carte : le temps entre l'envoi du
message et le moment où l'on peut reparler — appels d'outils, passes de
vérification et compaction compris. Les durées portées par les cartes ne
mesurent que la génération de texte ; celle-ci répond à « ça a pris
combien de temps, en tout ? ». Il avance à la seconde puis se fige sur le
total.

Anneau d'activité de la liste des discussions : l'animation portait sur
le <svg>, dont l'origine de rotation dépend de transform-box — défaut
qui a changé au fil des versions, d'où un anneau figé. Elle porte
désormais sur le conteneur HTML, qui tourne partout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:16:28 +02:00
MichaelandClaude Opus 5 4e5bc44607 Captures d'écran : montrées dans la bulle, sans dépendre du modèle
La capture n'apparaissait dans le fil QUE si le modèle recopiait 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 — la capture existait
pourtant bien sur le disque.

L'événement d'outil porte désormais le chemin de l'image et l'interface
la rend directement dans la bulle web_screenshot (cliquable pour la
loupe, comme les autres images du fil). Ce que le modèle en écrit
ensuite reste possible, mais n'est plus la condition de l'affichage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:11:38 +02:00
MichaelandClaude Opus 5 0430aa2281 Mode code : la capture d'écran sans accès internet, bornée au local
Le modèle cherchait à vérifier visuellement son travail, ne trouvait pas
web_screenshot (offert seulement avec l'accès internet), en concluait
« pas de Chromium local » et partait installer puppeteer — alors qu'un
Chromium headless est dans l'image depuis toujours.

L'outil est donc offert aussi au mode code sans internet, mais borné aux
adresses locales (localhost, 127.0.0.1, ::1, *.localhost) : photographier
son propre serveur de dev est une vérification locale, pas une sortie sur
le web — l'interrupteur d'accès internet garde tout son sens. Le prompt
du builder dit explicitement que le navigateur est déjà là et qu'il ne
faut installer aucun paquet de navigateur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:08:41 +02:00
MichaelandClaude Opus 5 608e7f7038 Moteur : --parallel 1 explicite — la VRAM d'avant est de retour
Les llama-server récents ouvrent 4 slots par défaut : des tampons de
calcul GPU multipliés par quatre, pour un serveur qui ne sert qu'un
utilisateur. Résultat vécu après une mise à jour du moteur : une config
27B qui tournait très bien meurt en « cudaMalloc failed: out of
memory » en pleine génération, sans qu'aucun réglage n'ait changé. On
repasse explicitement à un slot — le comportement historique — et
PARALLEL=n reste disponible dans le preset pour qui veut du parallèle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:47:41 +02:00
MichaelandClaude Opus 5 cf6e19794d Journal moteur : un crash-loop s'affiche « erreur », plus « chargement »
Un llama-server qui plantait au démarrage (backtrace __libc_start_main
dans le journal) laissait la pastille sur « chargement 0 % » pour
l'éternité : aucun des motifs de modelLoadError ne reconnaissait un
crash. Ajoutés : backtrace (signal, segfault, abort), mémoire GPU
insuffisante (out of memory / cudaMalloc failed — avec le conseil de
réduire CTX), et erreur CUDA (GPU indisponible après un redémarrage du
serveur). Pas de motif « libggml » : les logs de chargement normaux
citent les .so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 07:21:21 +02:00
MichaelandClaude Opus 5 7ef2c956c5 Chargement du modèle : pourcentage visible ; interface allégée
La pastille « chargement… » restait identique de longues minutes sur un
gros GGUF — indiscernable d'un plantage. llama-server n'expose aucun
progrès, mais charger c'est LIRE le fichier : on rapporte les octets lus
par le process (/proc/<pid>/io, shards compris) à la taille du modèle et
la pastille affiche « chargement 43 % » (rafraîchie toutes les 5 s,
plafonnée à 99 — c'est /health qui dit prêt).

Carte Moteur : bouton « vérifier la version » retiré — « mettre à jour »
fait sa propre vérification et dit s'il n'y a rien de neuf.

Éditeur de preset : en conteneur, l'option de moteur « Personnalisé »
disparaît (rien à compiler dans l'image, la mise à jour passe par la
carte Moteur) ; un BIN non reconnu retombe sur le moteur de l'image.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:19:21 +02:00
MichaelandClaude Opus 5 34218fae52 Mode code : le builder ne re-planifie plus à chaque tour, étiquettes d'outils
Le raisonnement n'étant pas conservé entre les tours (coût de contexte),
un modèle à reasoning fort re-déduisait TOUT son plan avant chaque appel
d'outil — des minutes de <think> pour écrire un fichier de 200 octets. Le
prompt du builder impose maintenant : plan écrit UNE fois dans PLAN.md,
une à deux phrases de réflexion entre les appels, chaque fichier complet
en un seul write.

UI : les outils du mode code (criteria, read, grep, glob, git_*, ask,
bash_bg/bash_tail) ont leurs étiquettes — ils retombaient tous sur
« mémoire ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:53:23 +02:00
MichaelandClaude Opus 5 a7587235c6 Panneau Fichiers : toute la discussion en une archive .zip
Bouton « .zip » dans le pied du panneau : la discussion entière — dossiers
et sous-dossiers compris — part en une archive, nommée d'après le titre de
la discussion. Même danse en trois temps que le téléchargement de fichier
(meta → brut, ou tranches base64 derrière le tunnel chiffré), liens
symboliques ignorés, parcours borné comme le calcul de taille du panneau.
Le scope « hors discussion » a son propre zip, sans le dossier des
discussions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:48:11 +02:00
MichaelandClaude Opus 5 53d23418e9 CI : gofmt + gopls sous Go 1.25 ; MCP : premier lancement npx/uvx patient
- gofmt sur trois fichiers oubliés par le dernier commit (ci/test rouge).
- gopls@latest exige Go ≥ 1.26 alors que l'image de build est en 1.25 avec
  GOTOOLCHAIN=local : l'étape Docker échouait net. GOTOOLCHAIN=auto télécharge
  le toolchain requis, borné à l'étape de build (build-push GHCR rouge).
- Serveurs MCP : npx/uvx téléchargent leur paquet au premier lancement, et le
  délai de connexion de 20 s tombait dessus (« enregistré mais connexion
  échouée : context deadline exceeded » depuis le catalogue). Délai à froid
  de 3 min pour ces deux lanceurs, et erreur de délai réécrite pour dire quoi
  faire (réessayer : le paquet reste en cache).
- README : note sur ce premier lancement, ligne mode Code dans le tableau
  des différences.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 22:09:37 +02:00
MichaelandClaude Opus 5 901d518f70 Mode Code : agent de code avec critères, vérification et LSP
Sélecteur Chat|Code par discussion dans le pied du composeur ; en mode
chat, une détection serveur suggère la bascule (puce ignorable, jamais
automatique). Conception reprise d'OpenFox (MIT), réécrite en Go — voir
NOTICE.md.

- Outils fichiers : read (lignes numérotées, borné), grep, glob, et le
  tracker « lu avant d'écrire » qui refuse write/edit sur un fichier non
  lu ou modifié depuis la lecture ; edit préserve les fins de ligne CRLF.
- Politique d'exécution : commandes catastrophiques refusées (rm -rf /,
  mkfs, reboot…), chemins bornés au dossier de la discussion en mode
  code, mutex par fichier.
- Critères d'acceptation : contrat posé via l'outil criteria (éditable
  dans l'UI), passe de vérification indépendante sur contexte isolé —
  seule habilitée à marquer « passed » — puis corrections plafonnées.
- Rôles embarqués (agents/*.md) : builder, planner, verifier, explorer,
  code-reviewer ; badge de rôle dans le fil.
- LSP : gopls / typescript-language-server / pyright (inclus dans
  l'image), diagnostics injectés dans le retour de write/edit.
- Git natif : git_status, git_diff, git_clone (borné à la discussion).
- Jobs d'arrière-plan bash_bg/bash_tail (serveur de dev, build long).
- Auto-retry : un appel d'outil écrit en texte (default_api:…,
  <tool_call>…) relance le tour une fois avec consigne corrective.
- Outil ask : question à choix rendue en carte à boutons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:58:57 +02:00
MichaelandClaude Opus 5 c6cc7d300b Test moteur OCI : bit exécutable sans objet sous Windows
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:58:04 +02:00
MichaelandClaude Opus 5 fb908b180b Perte de données en conteneur : alerte si /data n'est pas monté
Modèles, discussions et fichiers disparaissaient à chaque recréation du
conteneur quand /data n'était pas un volume : tout vivait dans la couche
éphémère. Loki le détecte maintenant (mountinfo) et le dit — bandeau rouge
dans l'UI (/api/status warn), avertissement en tête du journal, et
l'entrypoint exporte LOKI_HOME pour ses sous-commandes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:58:04 +02:00
LogiFlow 11a4aebf37 Merge pull request #11 from R0m1k3/claude/model-reasoning-without-instruction-9muczs
Raisonnement « aucune » : le dire dans la langue du gabarit
2026-08-18 12:54:27 +02:00
Claude fb1294d42c Raisonnement « aucune » : le dire dans la langue du gabarit
Le sélecteur envoyait seulement `reasoning_effort` dans le corps de la
requête. Ce champ n'agit que sur les gabarits qui le LISENT (gpt-oss et
apparentés). Un modèle hybride à la Qwen3 ne connaît que `enable_thinking` :
il recevait `reasoning_effort: none`, son gabarit l'ignorait, et il
continuait de réfléchir pendant que l'interface affichait « aucune ». Les
llama-server récents traduisent eux-mêmes `none` en `enable_thinking=false`,
mais les binaires plus anciens laissent simplement tomber le champ.

On joint donc `chat_template_kwargs` à la requête — même procédé que la
compaction, qui coupe déjà la réflexion ainsi :

  aucune (ou REASONING=off explicite) → {"enable_thinking": false}
  basse/moyenne/haute                 → {"reasoning_effort": "<niveau>"}
  auto                                → rien, le gabarit garde son défaut

Une clé REASONING absente n'est PAS une interdiction : sans consigne on ne
touche à rien. Une clé inconnue d'un gabarit est ignorée sans erreur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RodUJoPJDBvVKhA6S5msvb
2026-08-18 10:53:09 +00:00
LogiFlow 533ff7d0d1 Merge pull request #10 from R0m1k3/claude/discussion-sidebar-assistant-timing-wcv5li
Claude/discussion sidebar assistant timing wcv5li
2026-08-18 11:45:00 +02:00
Claude 69e89e5c2f Compteur de vitesse par bulle, et budget souple d'appels d'outils
Deux défauts révélés par un tour d'agent d'une heure (~50 appels d'outils)
sur un modèle très quantifié.

1. La vitesse affichée sous les réponses tombait de 17 tok/s à 0,9 au fil du
   tour, ce qui donnait à croire que le moteur s'effondrait. Il n'en était
   rien : un tour d'agent ouvre une bulle NEUVE après chaque appel d'outil
   (le flux repasse contentEl à null), mais les compteurs n'étaient jamais
   remis à zéro. Chaque bulle affichait donc le CUMUL de tout le tour divisé
   par le temps écoulé depuis le tout premier token — exécution des outils,
   pages web et prefill compris. La vitesse convergeait mécaniquement vers
   « tokens générés ÷ durée totale du tour ».

   Les compteurs sont maintenant remis à zéro à la CRÉATION de la bulle, ce
   qui couvre tout chemin qui en ouvre une neuve, aujourd'hui comme demain.
   Rejoué sur un tour synthétique où le moteur décode à 20 tok/s constants
   entre deux outils de deux minutes : 20,5 / 0,6 / 0,5 tok/s avant, 20,5 sur
   les trois bulles après. La durée « travail », elle, reste bien celle du
   tour entier — c'est sa définition.

2. Rien n'exerçait de pression sur un tour qui tourne en rond. Le plafond
   d'itérations avait été retiré en v0.6.3 (il coupait des recherches
   légitimes) et la déduplication d'appels ne rattrape pas ce cas : sa clé est
   « nom + arguments bruts », or relire le même fichier par tranches
   (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés différentes.

   D'où un budget SOUPLE : au-delà de 24 appels d'outils sur un tour, on
   rappelle au modèle combien il en a déjà faits et on lui demande de
   conclure. Le rappel revient à chaque palier en durcissant le ton, et ne
   coupe jamais le tour. Il est ajouté EN FIN d'historique, ce qui laisse
   intact le préfixe déjà en cache côté llama-server, et n'est pas persisté.
   `AGENT_BUDGET` dans config.env règle le palier, `off` le désactive.

   Élargir plutôt la clé de déduplication à la CIBLE de l'appel a été écarté :
   deux tranches d'un même fichier renvoient un contenu différent, les
   confondre casserait toute lecture paginée légitime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5UndZ9DedRXPuAoRXbmDb
2026-08-18 08:56:32 +00:00
Claude 3d8a72754c UI : anneau d'activité dans la liste des discussions, temps de travail sous les réponses
Deux manques dans la présentation d'un tour :

1. Rien ne disait sur QUELLE discussion un agent était en train de
   travailler. La ligne concernée porte maintenant un anneau qui tourne,
   à gauche du titre — et l'en-tête aussi, puisque la barre latérale est
   escamotée sur téléphone. L'état vient de deux sources : le flux SSE
   (instantané) et /api/conversations, qui expose désormais `busy` pour
   que la page ouverte en cours de génération s'anime sans attendre le
   rejeu du journal. L'indicateur est posé et retiré SANS redessiner la
   liste : un redessin relancerait l'animation à chaque événement.

2. Le pied des réponses ne montrait que la vitesse du moteur, jamais le
   temps que le tour avait pris. Il porte maintenant « travail 1 min 04 s »
   — de la question envoyée à la fin du tour, raisonnement, appels
   d'outils et attentes compris. La durée avance à la seconde pendant le
   tour et se fige au turn_done ; elle apparaît aussi sur l'indicateur
   « … », là où il n'y a pas encore de réponse sous laquelle écrire.
   La bulle de raisonnement gagne au passage sa propre durée.

La mesure est prise sur les HORODATAGES SERVEUR (événements `user` et
`turn_done`) : elle est donc juste en direct comme au rejeu, où tout
arrive d'un bloc côté client. Le compteur vivant se recale sur l'écart
entre l'horloge du navigateur et celle du serveur, réévalué à chaque
événement reçu en direct — un téléphone n'est pas à la même heure que la
machine.

« Masquer la vitesse de génération » ne masque plus que la vitesse : la
durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J5UndZ9DedRXPuAoRXbmDb
2026-08-18 08:35:51 +00:00
MichaelandClaude Opus 5 88e1b38582 Images du fil : miniatures des deux côtés, loupe au clic
Une pièce jointe n'affichait que son nom de fichier, et une image posée
par l'IA sortait à sa taille NATIVE — .chatimg portait la classe mais
aucune règle CSS, donc une capture remplissait la colonne et poussait le
texte hors de vue.

- pièce jointe image : vignette dans le composeur (objectURL local, rien
  n'est encore parti au serveur) et tuile 200x160 dans le message envoyé,
  hydratée comme les captures (fetch authentifié + blob:) ;
- images de l'IA plafonnées à 280px de haut ;
- clic sur n'importe quelle image = loupe plein écran (Échap ou clic sur
  le fond pour fermer). La source est relue AU CLIC, l'hydratation
  remplaçant le src par un blob: après coup.

align-items:flex-start sur .msg-files au passage : une pastille texte
voisine d'une vignette s'étirait à sa hauteur et, arrondie à 999px,
devenait un gros ovale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 17:35:54 +02:00
MichaelandClaude Opus 5 781520afd8 Vision : la sonde /props s'authentifie
engineSeesImages interrogeait /props sans en-tête Bearer. Moteur lancé
avec --api-key (backend_serve.go), la sonde recevait 401, concluait
« pas de vision » et les deux appelants — pièces jointes et captures —
retombaient sur le texte seul : aucune image ne partait, projecteur
chargé ou non. Le modèle brodait à partir du seul chemin de fichier.

Vérifié en production : /props authentifié renvoie modalities.vision
true avec le mmproj bien passé au moteur.

authHeader est le helper déjà utilisé par tous les autres appels
internes ; c'était le seul client.Get nu du paquet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:57:30 +02:00
MichaelandClaude Opus 5 0a3af60715 Intensité du raisonnement dans la barre de saisie, angles moins arrondis
Le niveau de raisonnement n'existait que dans l'éditeur de preset : le changer
demandait d'ouvrir les réglages, éditer, enregistrer. Or ça se décide au moment
d'écrire le message.

- Nouvelle route /api/reasoning-effort (GET/POST), calquée sur /api/memory :
  elle écrit REASONING_EFFORT dans la config vivante, relue à chaque requête au
  moteur. Aucun redémarrage — la valeur voyage dans le corps de la requête.
- Liste blanche côté serveur : cette clé finit dans une requête au moteur, on
  n'y laisse pas passer une chaîne arbitraire venue du navigateur.
- La liste est grisée quand le raisonnement est coupé pour ce modèle, plutôt que
  masquée : le réglage reste trouvable, et l'infobulle dit où le rallumer.
- L'éditeur de preset garde le même réglage comme DÉFAUT du modèle. Appliquer un
  preset réécrit toute la config, donc il reprend la main sur le choix fait à la
  volée — les deux sous-titres le disent, sinon la double présence intrigue.

Angles : les rayons allaient de 5 à 26px selon les composants, ce qui donnait
des cartes très rondes. Tout est ramené à 4px (cartes, boutons, modales) et 3px
(petits éléments), y compris les formes multi-valeurs comme la barre de saisie
(26px 26px 0 0 → 3px 3px 0 0). Les pastilles (999px) et les ronds (50%) sont
laissés intacts : ce sont des interrupteurs et des avatars, pas des cartes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:31:37 +02:00
Michael ebe04f9d61 Merge branch 'feat/mcp-catalog-go' 2026-08-17 10:06:19 +02:00
111 changed files with 14878 additions and 722 deletions

No files matched your search

+9
View File
@@ -13,3 +13,12 @@
Thumbs.db
.idea/
.vscode/
# Ancienne application Python/React (fork pré-Go), plus utilisée : elle traîne
# encore sur certains postes de développement et ne doit JAMAIS revenir dans le
# dépôt — un « git add -A » distrait y a déjà versé 94 Mo de node_modules.
/backend/
/frontend/
.pytest_cache/
__pycache__/
node_modules/
+67
View File
@@ -23,6 +23,60 @@ COPY cmd/ cmd/
COPY internal/ internal/
COPY tools/ tools/
RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o /out/loki ./cmd/loki
# gopls : serveur LSP Go, consommé par le vérificateur de code du mode code
# (lsp.go). Compilé ici parce que l'image finale n'a pas de toolchain Go.
# GOTOOLCHAIN=auto : gopls@latest exige régulièrement un Go plus récent que
# celui de l'image (v0.23 veut ≥ 1.26 quand l'image est en 1.25) ; en local,
# l'installation échouait net. Auto télécharge le toolchain requis — coût
# limité à cette étape de build.
RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# ── Étape 1 bis : whisper-server (dictée vocale) ────────────────────────
# DEUX binaires sont construits, pas un. LLAMACPP_IMAGE accepte la variante
# CPU de l'image amont (voir ligne 11) : sur cette base, les bibliothèques
# CUDA sont absentes et un binaire lié à CUDA ne démarre pas du tout —
# l'éditeur de liens échoue avant la première instruction, donc aucun repli
# n'est possible depuis l'intérieur du programme. Loki choisit à l'exécution
# (whisperServerBin, dictate_server.go).
#
# whisper-server et non whisper-cli : le modèle reste chargé entre deux
# dictées. L'ancien chemin le relisait depuis le disque à chaque phrase, ce
# qui dominait le temps de réponse.
#
# ⚠️ GGML_NATIVE=OFF est OBLIGATOIRE sur LES DEUX cibles. Par défaut ggml
# compile en -march=native, c'est-à-dire pour le processeur DU RUNNER DE
# BUILD — un Xeon récent chez GitHub, avec AVX-512 et AMX. Le binaire partait
# alors sur une machine qui n'a pas ces instructions et mourait d'un SIGILL en
# pleine transcription : « AMX is not ready to be used! », puis plus rien,
# l'interface affichant un échec sans raison. OFF retombe sur la ligne de base
# AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis 2013.
ARG WHISPER_CMAKE_FLAGS="-DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=OFF \
-DWHISPER_BUILD_TESTS=OFF -DWHISPER_BUILD_EXAMPLES=ON \
-DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \
-DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF"
# Ubuntu 22.04 : glibc plus ancienne que l'image runtime, donc compatible quoi
# qu'elle embarque.
FROM ubuntu:22.04 AS whisperbuild-cpu
ARG WHISPER_CMAKE_FLAGS
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} \
&& cmake --build /w/build -j --target whisper-server
# Version CUDA. La MAJEURE de CUDA doit correspondre à celle de l'image
# runtime : le binaire est lié dynamiquement à libcudart, fournie par
# LLAMACPP_IMAGE et non par cette étape.
FROM nvidia/cuda:12.4.1-devel-ubuntu22.04 AS whisperbuild-cuda
ARG WHISPER_CMAKE_FLAGS
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
&& cmake -S /w -B /w/build ${WHISPER_CMAKE_FLAGS} -DGGML_CUDA=ON \
&& cmake --build /w/build -j --target whisper-server
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
@@ -43,6 +97,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Serveurs LSP du mode code (lsp.go) : TypeScript/JavaScript et Python via
# npm, Go via gopls compilé à l'étape 1. Un serveur absent désactive juste son
# langage — mais autant livrer l'image complète.
RUN npm install -g --no-audit --no-fund typescript typescript-language-server pyright && npm cache clean --force
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
@@ -71,6 +130,12 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
fi
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
# Dictée vocale (POST /api/transcribe). Les modèles (190 Mo à 1,1 Go) ne sont
# PAS dans l'image : téléchargés à la demande dans /data/whisper/.
# Deux binaires : voir l'étape whisperbuild-cpu pour la raison.
COPY --from=whisperbuild-cpu /w/build/bin/whisper-server /usr/local/bin/whisper-server-cpu
COPY --from=whisperbuild-cuda /w/build/bin/whisper-server /usr/local/bin/whisper-server-cuda
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
@@ -79,6 +144,8 @@ ENV LOKI_CONTAINER=1 \
LOKI_HOME=/data \
LOKI_MODEL_DIRS=/models \
LOKI_ENGINE_BIN=/app/llama-server \
LOKI_WHISPER_SERVER_CPU=/usr/local/bin/whisper-server-cpu \
LOKI_WHISPER_SERVER_CUDA=/usr/local/bin/whisper-server-cuda \
LD_LIBRARY_PATH=/app \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
+8
View File
@@ -19,6 +19,14 @@ La licence d'origine est conservée à l'identique dans [`LICENSE`](LICENSE).
pour que l'UI puisse redémarrer le moteur (changement de modèle).
- **`loki config get/set`** : lecture/écriture non interactive de la
configuration, utilisée par l'entrypoint Docker.
- **Mode code** (`internal/loki/code_*.go`, `lsp.go`, `agents/`) : agent de
code avec critères d'acceptation, passe de vérification indépendante,
outils read/grep/glob, politique d'exécution, diagnostics LSP, outils git,
jobs d'arrière-plan et relance sur appel d'outil textuel. La conception
(contrat builder/verifier, tracker « lu avant d'écrire », auto-retry
patterns, table languages.json) est reprise
d'**[OpenFox](https://github.com/co-l/openfox)** (MIT) et réécrite en Go
pour ce fork ; les prompts de rôles sont des réécritures originales.
## Services externes
+113 -11
View File
@@ -77,6 +77,22 @@ Le champ **Télécharger un modèle** reste disponible pour coller un lien direc
(dépôt privé, fichier hors des conventions). Un dépôt à accès restreint demande
la variable d'environnement `HF_TOKEN`.
Certains dépôts sont **à accès restreint** (« gated ») : leur arborescence se lit
sans rien, mais chaque `.gguf` répond `401` tant que les conditions du dépôt
n'ont pas été acceptées sur huggingface.co **et** qu'un jeton n'est pas fourni.
Loki les marque « accès restreint » dès la liste des résultats et rappelle le
geste à faire, plutôt que de laisser choisir une quantification pour échouer au
lancement du transfert.
Le jeton se règle **dans l'interface** : éditeur de preset → *Modèle* → **Jeton
Hugging Face**. Il est vérifié auprès de Hugging Face avant d'être enregistré
(le compte associé s'affiche), rangé avec les autres secrets sous `/data` — donc
il survit aux redémarrages et aux changements de preset — et il sert aussi bien à
la recherche qu'au téléchargement. À défaut, la variable d'environnement
`HF_TOKEN` reste lue comme avant ; le jeton enregistré dans l'interface a la
priorité. Un jeton en **lecture** suffit (huggingface.co/settings/tokens), et il
n'est envoyé qu'aux adresses Hugging Face.
## Installation sur Unraid
L'image est construite et publiée par GitHub Actions sur GHCR
@@ -105,7 +121,7 @@ redémarrages (volume `/data`). Variables d'environnement du conteneur :
| `LOKI_NGL` | couches GPU initiales | `999` (tout) |
| `LOKI_HOME` | données (volume) | `/data` |
| `LOKI_MODEL_DIRS` | dossiers .gguf additionnels | `/models` |
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint | — |
| `HF_TOKEN` | jeton Hugging Face, pour les dépôts à accès restreint (repli : le jeton réglé dans l'UI prime) | — |
En CLI dans le conteneur : `docker exec -it loki loki status` (aussi :
`logs`, `restart`, `config`, `bench`, `test`…).
@@ -138,6 +154,16 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
emplacements différents dès que le partage n'est pas en cache-only ou que le
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
redémarrage de l'array). Vérifie avec la commande `docker inspect` ci-dessus :
il doit y avoir une ligne `… → /data` **et** une `… → /models`. S'il n'y en a
pas, ton conteneur a été lancé sans mapping (template Docker Unraid incomplet,
`docker run` sans `-v`) — recrée-le avec les volumes du compose fourni. Depuis
cette version, Loki le détecte au démarrage : bandeau rouge dans l'UI et
avertissement en tête du journal du conteneur.
Autre piège au redémarrage du serveur : Docker relance les conteneurs
`restart: unless-stopped` **avant** que le plugin Nvidia Driver ait chargé ses
modules. Le journal montre alors des `ERROR: init … result=11` et le moteur
@@ -155,13 +181,54 @@ Héritées d'AJEAN :
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
- **Agent** : shell, fichiers, workspace (`agent on`).
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
les serveurs écrits en JavaScript comme en Python.
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les
lancements suivants partent du cache en quelques secondes.
- **Tâches planifiées** : une consigne que l'IA exécute **toute seule**, sur une
fréquence réglable (« toutes les 2 h », « tous les jours à 9 h », ou une
expression cron). Chaque tâche tourne **isolée des discussions** — elle n'écrit
pas dans le fil, travaille dans son propre dossier (`workspace/tasks/<id>/`) et
livre son résultat par les outils de l'IA (mail via MCP, shell, fichiers…) ; le
compte-rendu du dernier passage est visible dans sa fiche, et **réinjecté** au
passage suivant pour la continuité. Réglages par tâche : preset (donc modèle) à
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
tâche attend son tour, et le bouton stop du chat l'interrompt.
- **Presets** de configuration par modèle, bench, auto-détection GPU.
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
valeurs partent dans **chaque requête** au moteur, pas sur sa ligne de commande :
les changer ne demande donc aucun redémarrage. Un champ laissé vide n'envoie
rien et llama-server garde son défaut — utile parce que le défaut de llama.cpp
(top_k 40, min_p 0.05) est rarement celui que recommande le modèle (Qwen3.8 en
réflexion veut top_k 20, min_p 0, temp 1.0, top_p 0.95).
- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce
fork la sert autrement que l'amont).
Ajoutées par ce fork :
- **Mode Code** : chaque discussion a un sélecteur **Chat | Code** dans le pied
de la carte de saisie. En mode Code, Loki devient un agent de code : outils
`read`/`grep`/`glob` (lecture bornée, numéros de ligne — et un fichier doit
avoir été LU avant d'être modifié), outils git (`git_status`, `git_diff`,
`git_clone` — le clone atterrit dans le dossier de la discussion), jobs
d'arrière-plan (`bash_bg`/`bash_tail` pour un serveur de dev ou un build
long), et **critères d'acceptation** : le modèle pose le contrat (2-6
critères testables, éditables dans le panneau au-dessus de la saisie), puis
une **passe de vérification indépendante** — même modèle, contexte isolé,
seule habilitée à marquer un critère « passé » — contrôle le diff et relance
la correction jusqu'à ce que tout passe (2 corrections max). Les fichiers
modifiés passent au **LSP** (gopls, typescript-language-server, pyright —
inclus dans l'image) : les erreurs de compilation reviennent dans le résultat
de l'outil, sans lancer de build. Sécurité : commandes catastrophiques
refusées (rm -rf /, mkfs, reboot…), chemins bornés au dossier de la
discussion en mode Code. En mode Chat, un message qui ressemble à une tâche
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
jamais bascule automatique. Conception reprise
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
`NOTICE.md`.
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
playwright, context7, github…) avec leur commande déjà renseignée, classés par
@@ -172,13 +239,22 @@ Ajoutées par ce fork :
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
- **Intensité du raisonnement** : en plus de l'interrupteur *Raisonnement*,
l'éditeur de preset expose un niveau — auto / aucune / basse / moyenne / haute
— envoyé à `llama-server` comme `reasoning_effort`. Réglage **par preset**,
donc de fait par modèle. `none` coupe le raisonnement ; les autres valeurs sont
passées au gabarit jinja du modèle, ce qui ne change le comportement que des
modèles qui les lisent (gpt-oss et apparentés) — ailleurs c'est ignoré sans
erreur, et le sous-titre du réglage le dit plutôt que de promettre un effet.
- **Intensité du raisonnement** : un niveau — auto / aucune / basse / moyenne /
haute / maximale — envoyé à `llama-server` comme `reasoning_effort`. Réglable
**dans la barre de saisie**, parce que ça se décide en écrivant le message : le
changement s'applique au message suivant, sans redémarrer le moteur. L'éditeur
de preset garde le même réglage comme **défaut du modèle** ; appliquer un
preset reprend donc la main sur le choix fait à la volée. `none` coupe le
raisonnement ; les autres valeurs sont passées au gabarit jinja du modèle, ce
qui ne change le comportement que des modèles qui les lisent (gpt-oss et
apparentés) — ailleurs c'est ignoré sans erreur, et l'interface le dit plutôt
que de promettre un effet. Aucun gabarit ne les connaît toutes (gpt-oss :
basse/moyenne/haute ; Qwen3.8 : basse/moyenne/maximale) et certains **refusent**
celles qu'ils ne connaissent pas, avec une erreur 500 qui tuait le tour : loki
lit alors les niveaux annoncés par le refus, **repli sur le plus proche** (haute
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
raisonnement est coupé pour ce modèle.
- **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
@@ -233,10 +309,35 @@ Ajoutées par ce fork :
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
pouvait donc qu'échouer.
- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une
recherche légitime est pire que la laisser durer — mais au-delà de 24 appels
sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui
demande de conclure. Le rappel revient tous les 24 appels, en durcissant le
ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière.
Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf
le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois
les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` —
`AGENT_BUDGET=off` le désactive complètement.
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
dans le fil.
- **Paramètres** : les réglages d'application (identité, apparence, accès
OpenAI, actions) sont regroupés à part des réglages d'IA.
- **Réglages en modale** : tous les réglages vivent dans une fenêtre à deux
volets — la nav des sections à gauche (IA, moteur, application), le panneau
choisi à droite. La barre latérale ne garde que les discussions (les plus
récentes en tête) et le moniteur machine.
- **Nom du modèle sur chaque réponse** : une pastille à côté de « Loki » dit
quel modèle a produit la réponse. Elle est journalisée avec le tour : elle
survit au rechargement, et un vieux tour garde le modèle de l'époque.
- **Dictée vocale** : un bouton micro dans la carte de saisie enregistre,
transcrit **en local** (whisper.cpp, compilé dans l'image ; modèle
`small-q5_1` multilingue ~190 Mo téléchargé au premier usage dans
`/data/whisper/`) et pose le texte dans le champ. ⚠️ le navigateur n'autorise
le micro qu'en **HTTPS** (ou sur `localhost`) — derrière un reverse proxy
TLS, rien à faire ; en `http://IP:8090`, le bouton l'explique.
- **Cartes raisonnement/outils à hauteur bornée** : un long raisonnement ne
fait plus grandir la page de plusieurs écrans — la carte reste à taille fixe
et défile toute seule pendant la génération. Sur un raisonnement géant, seul
le bas du bloc est re-rendu en direct (le texte complet est posé à la fin) :
l'affichage ne se fige plus.
Retirées par ce fork :
@@ -264,6 +365,7 @@ Retirées par ce fork :
| Configuration initiale | `ajean edit` ($EDITOR) | entrypoint + `loki config set` |
| Choix du modèle | lien Hugging Face collé à la main | recherche intégrée + verdict VRAM + projecteur lié |
| Historique de tchat | conversation unique | discussions multiples, titrées et persistées |
| Agent de code | — | mode Code : critères d'acceptation, passe de vérification, LSP, outils git |
| Accès distant | relais chiffré ajean.link | retiré de l'interface |
| Endpoint OpenAI | `:8080/v1` du moteur, ouvert par `network on` | `/v1` servi par Loki sur le port de l'interface, protégé par la clé API |
| Mise à jour | `ajean update` (binaire GitHub) | `docker compose pull` |
+43 -9
View File
@@ -1,19 +1,53 @@
La grande nouveauté de cette version : AJEAN sait enfin voir les images.
# Loki 0.12.0
## La vision, configurable par preset
Le sélecteur de modèle devient un vrai sélecteur, la dictée vocale arrive, et
les cartes de raisonnement suivent l'écriture au lieu d'envahir la page.
Jusqu'ici rien dans l'interface ne permettait de donner des yeux à un modèle. Il fallait glisser l'option `--mmproj` à la main dans la configuration brute, et son chemin n'était même pas résolu comme celui du modèle. L'éditeur de preset gagne un champ **Vision** : tu y choisis le projecteur multimodal (fichier `mmproj`) qui accompagne le modèle, et c'est tout. Au démarrage du moteur, AJEAN le charge tout seul.
## Le sélecteur de l'en-tête charge vraiment un modèle
Le champ ne liste que les projecteurs (les fichiers dont le nom contient `mmproj`), pas les modèles de plusieurs Go, pour que le choix reste lisible. Et s'il te manque un projecteur, le champ « télécharger un modèle » accepte aussi un lien vers un `mmproj` : une fois récupéré, il se place directement dans le champ Vision.
Deux groupes : « Presets » et « Modèles (.gguf) » — tous les fichiers de
`/models` et `/data/models`. Choisir un modèle le charge (seul `MODEL` change,
contexte/NGL/échantillonnage conservés) et redémarre le moteur. Sans preset
créé, le sélecteur n'offrait aucun choix.
## Les images arrivent vraiment au modèle
## Dictée vocale
Avant, une image collée dans le chat était simplement déposée comme un fichier dans le dossier de travail, à charge pour le modèle de l'ouvrir avec ses outils (ce qui ne donnait qu'un tas d'octets illisibles). Désormais, quand un projecteur est configuré, l'image part au modèle en contenu multimodal : il la voit, et peut en parler.
Un bouton micro dans la carte de saisie : tu parles, le texte s'écrit. La
transcription est 100 % locale — whisper.cpp compilé dans l'image, modèle
multilingue téléchargé au premier usage (~190 Mo, dans `/data/whisper/`).
Le navigateur exige HTTPS (ou localhost) pour donner accès au micro.
## Cartes raisonnement/outils : taille fixe, défilement qui suit
Hauteur bornée (280 px) avec défilement interne collé au texte pendant la
génération — remonter lire le début est respecté. Les cartes d'outil suivent
la ligne en cours d'écriture puis remontent en tête une fois l'appel terminé.
Sur un raisonnement géant, seul le bas du bloc est re-rendu en direct (le
texte complet est posé à la fin) : l'affichage ne se fige plus.
## Mesures
- La carte du temps total affiche la **vitesse moyenne de la conversation**
(« en cours — 12 s · moy 21.3 tok/s »), rejouée au rechargement.
- Le **pourcentage de chargement** du modèle bouge aussi sur un redémarrage à
chaud (modèle déjà dans le cache disque : on suit la mémoire résidente, plus
seulement les octets lus).
## Interface
- Discussions triées par date de **création** (récentes en tête) : une
discussion garde sa place, écrire dans un vieux fil ne le fait plus remonter.
- Bouton **Réglages** ancré en pied de barre latérale, au-dessus du moniteur
Performance.
## Rappel 0.11.0
Réglages en modale à deux volets ; nom du modèle sur chaque réponse
(journalisé, il survit au rechargement) ; jeton Hugging Face réglable dans
l'interface ; tâches planifiées reprises de l'amont.
## Mise à jour
```
ajean update
docker compose pull && docker compose up -d
```
Non vérifié sur cette version : le résultat final dépend d'un modèle vision et de son projecteur compatibles (Qwen2.5-VL, Gemma 3, etc.). À noter, l'image reste dans l'historique de la conversation et repart au moteur à chaque tour tant que la conversation dure.
Binary file not shown.
Binary file not shown.
+5 -5
View File
@@ -2,14 +2,14 @@
"FixedFileInfo": {
"FileVersion": {
"Major": 0,
"Minor": 9,
"Patch": 4,
"Minor": 12,
"Patch": 3,
"Build": 0
},
"ProductVersion": {
"Major": 0,
"Minor": 9,
"Patch": 4,
"Minor": 12,
"Patch": 3,
"Build": 0
},
"FileFlagsMask": "3f",
@@ -25,7 +25,7 @@
"LegalCopyright": "Copyright (c) 2026 Loki contributors. MIT License.",
"OriginalFilename": "loki.exe",
"ProductName": "Loki",
"ProductVersion": "0.9.4",
"ProductVersion": "0.12.3",
"Comments": "https://github.com/R0m1k3/Loki — projet open source (MIT)"
},
"VarFileInfo": {
+9
View File
@@ -4,8 +4,17 @@
set -eu
: "${LOKI_HOME:=/data}"
export LOKI_HOME # les sous-commandes loki doivent voir le MEME dossier
mkdir -p "$LOKI_HOME"
# Garde-fou persistance : si /data n'est pas un point de montage, tout ce que
# l'utilisateur fera (modèles, discussions, fichiers) mourra avec le conteneur.
# On le dit au premier écran du journal — l'UI l'affiche aussi en bandeau.
if ! grep -qs " $LOKI_HOME " /proc/self/mountinfo; then
echo "[entrypoint] ⚠️ $LOKI_HOME n'est pas un volume monté : les données seront PERDUES à la recréation du conteneur."
echo "[entrypoint] Ajoute « -v /chemin/hote/data:/data » (docker run) ou le mapping volumes du docker-compose."
fi
# Le chemin du moteur est imposé par l'image (llama-server précompilé de
# l'image officielle llama.cpp, dans /app) : on le (re)pose à chaque boot,
# une mise à jour de l'image ne doit pas laisser un BIN obsolète en base.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,187 @@
# Dictée en temps réel, configurable selon le matériel
*Conception validée le 21 août 2026.*
## Le problème
La dictée fonctionne depuis la correction du binaire non portable (PR #18), mais deux
défauts la rendent pénible :
1. **Elle comprend mal.** Le modèle `small-q5_1` est le plus petit modèle multilingue
utilisable, et `-l auto` lui fait redeviner la langue à chaque appel.
2. **Elle est lente et muette pendant l'enregistrement.** Le texte n'arrive qu'à
l'arrêt du micro, après ~3 s de calcul pour 3,4 s d'audio.
La lenteur a une cause structurelle : chaque `POST /api/transcribe` lance un processus
`whisper-cli` qui **recharge le modèle depuis le disque**, transcrit, puis meurt. Le
chargement domine le temps de réponse. Aucun réglage ne rattrape ça, et le découpage en
tranches — indispensable pour écrire pendant qu'on parle — le rendrait catastrophique :
un rechargement complet du modèle toutes les quelques secondes.
## Ce qu'on construit
Trois choses, dans cet ordre de dépendance :
1. `whisper-server` supervisé par Loki, remplaçant les lancements de `whisper-cli`
2. Un découpage en tranches piloté par le serveur, alimenté par une WebSocket
3. Un panneau **Dictée** dans les réglages : GPU, modèle, langue, réactivité, test du micro
## 1. Le moteur
### whisper-server plutôt que whisper-cli
`whisper.cpp` fournit `whisper-server` : modèle chargé **une fois**, requêtes en HTTP.
Loki le supervise comme il supervise déjà `llama-server`.
**Démarrage paresseux, extinction sur inactivité.** Le serveur démarre au premier clic
sur le micro et s'arrête après ~10 min sans dictée. La machine cible a deux GPU dont on
veut préserver la VRAM : whisper n'a aucune raison d'occuper plusieurs centaines de Mo
pendant qu'on ne dicte pas. Le premier clic après extinction coûte le chargement du
modèle ; les suivants sont immédiats.
### Choix du GPU
`CUDA_VISIBLE_DEVICES` posé sur le processus `whisper-server`. Pas de drapeau à inventer,
pas de code à maintenir dans ggml :
- `CUDA_VISIBLE_DEVICES=1` → whisper ne voit que le second GPU
- valeur vide → CPU seul
Changer le GPU, le modèle ou la langue redémarre le processus. Rien d'autre ne bouge.
### Compilation : deux binaires, pas un
L'étape `whisperbuild` produit **deux** binaires :
- `whisper-server-cpu`, bâti comme aujourd'hui (`ubuntu:22.04`)
- `whisper-server-cuda`, bâti sur une image `nvidia/cuda:*-devel` avec `-DGGML_CUDA=ON`
La raison est dans le Dockerfile lui-même : `LLAMACPP_IMAGE` accepte la variante CPU
(`ghcr.io/ggml-org/llama.cpp:server`, ligne 11). Sur cette base, les bibliothèques CUDA
sont absentes et un binaire lié à CUDA **ne démarre pas du tout** — pas de repli
possible, l'éditeur de liens échoue avant le premier octet de code. Un unique binaire
CUDA condamnerait donc la variante CPU de l'image.
Loki choisit à l'exécution : binaire CUDA si un GPU est demandé *et* que le binaire
démarre, binaire CPU sinon. Un échec de démarrage du binaire CUDA bascule sur le CPU en
le disant, plutôt que de laisser la dictée morte.
**Les drapeaux de portabilité restent sur les deux cibles** : `GGML_NATIVE=OFF`,
`GGML_AVX512=OFF`, `GGML_AMX_*=OFF`. Le bug corrigé en PR #18 — binaire compilé pour le
processeur du runner, SIGILL ailleurs — ne doit pas revenir par la porte du build CUDA.
## 2. Le découpage
### On coupe dans les silences, jamais à heure fixe
Une tranche toutes les N secondes tombe statistiquement au milieu d'un mot une fois sur
deux. Whisper reçoit alors une demi-syllabe sans contexte, l'entrée même qui lui fait
produire du charabia. Couper au mauvais endroit dégraderait la reconnaissance au lieu de
l'améliorer.
Le **seuil de bruit est adaptatif**, pas une constante. Un seuil fixe se trompe des deux
côtés : trop haut dans une pièce silencieuse avec un micro discret, il ne coupe jamais ;
trop bas près d'un ventilateur, il coupe en permanence. Le découpeur mesure donc le
niveau des premières centaines de millisecondes comme plancher de bruit ambiant, le
réévalue en continu sur les passages calmes, et considère « silence » tout ce qui reste
proche de ce plancher. Le cas dégénéré — un plancher qui monte jusqu'à avaler la parole —
est borné par la coupure forcée.
Règle : on ferme la tranche dès qu'on observe **~400 ms de silence** *et*
qu'au moins **1,5 s de parole** est en réserve. Filet de sécurité : au-delà de **~8 s**
sans silence exploitable, on coupe quand même — quelqu'un qui parle sans respirer ne doit
pas voir le texte se figer.
Le réglage « réactivité » pilote ces bornes **ensemble** plutôt que d'exposer des curseurs
dont personne ne connaît l'interaction :
| Réglage | Réserve minimale | Coupure forcée |
|---|---|---|
| Court | 1 s | 5 s |
| Moyen (défaut) | 1,5 s | 8 s |
| Long | 3 s | 15 s |
### La logique vit en Go
Le navigateur ouvre une WebSocket et pousse le PCM au fil de l'eau **sans rien décider**.
Go accumule, détecte les silences, appelle `whisper-server`, renvoie chaque tranche.
Deux raisons. Il n'existe aucun harnais de test JavaScript dans le dépôt, alors que la CI
fait tourner `go test` — et le découpeur est précisément la pièce qui mérite des tests.
Et le navigateur redevient bête : capter, envoyer, afficher.
### Contrat de la socket
- **Client → serveur** : trames binaires, PCM 16 kHz mono 16 bits
- **Serveur → client** : JSON, `{"texte": "..."}` par tranche définitive, `{"erreur": "..."}` sinon
Le champ de saisie **ajoute à la suite, sans jamais réécrire**. Aucune révision d'un texte
déjà affiché : c'est le comportement retenu, contre l'affichage mot à mot qui se corrige
sous les yeux.
### Langue forcée par défaut
Le réglage part sur **français** plutôt que `-l auto`. Sur des tranches de quelques
secondes, la détection automatique se trompe régulièrement — c'est le mécanisme le plus
probable derrière les `ლლლ` géorgiens observés. Le mode auto reste disponible.
## 3. Le panneau Dictée
Nouveau `data-pane="dictee"` dans la modale des réglages, entre « Moteur llama.cpp » et
« Identité ». Suit le gabarit des panneaux existants.
| Réglage | Contenu |
|---|---|
| **Matériel** | GPU détectés avec VRAM libre (`/api/vram`, `/api/backends/devices`), plus « CPU seul » |
| **Modèle** | `large-v3-turbo` q5_0 (574 Mo) par défaut, `medium` q5_0 (539 Mo), `small` q5_1 (190 Mo), `large-v3` q5_0 (1,1 Go) |
| **Langue** | Français par défaut, auto et autres langues disponibles |
| **Réactivité** | Court / Moyen / Long — les bornes ci-dessus |
| **État** | whisper-server allumé ou éteint, modèle chargé, dernière erreur |
| **Test du micro** | Capte 3 s, affiche le niveau réel et la transcription, sans toucher au champ de saisie |
Les modèles restent **hors de l'image**, dans `/data/whisper/` : ils survivent aux
recréations du conteneur et ne gonflent pas le tirage. Téléchargement à la demande avec
progression et suppression, en généralisant le mécanisme mono-modèle existant.
Persistance des réglages : `putStr(bkState, …)`, le magasin clé/valeur déjà utilisé pour
le jeton Hugging Face.
## Les pannes, nommées
Chaque échec dit ce qui a échoué et ce qu'on peut y faire :
- **VRAM insuffisante au démarrage** → on le dit et on propose le repli CPU, plutôt que
d'échouer sec
- **Modèle absent** → téléchargement avec progression, pas un micro qui ne répond pas
- **Socket coupée en pleine dictée** → le texte déjà obtenu reste dans le champ,
l'utilisateur est prévenu
- **whisper-server qui meurt** → on dit **comment** il est mort (code de sortie ou signal),
pas la dernière ligne de son journal. La leçon de la PR #18 : sur une mort par signal,
cette dernière ligne ressemble à une explication sans en être une, et envoie chercher
dans la mauvaise direction.
`POST /api/transcribe` **survit** et passe désormais par `whisper-server`. C'est le chemin
de repli si la WebSocket ne s'établit pas, et ce qui rend le test du micro trivial.
## Tests
En Go, sur ce qui peut réellement casser :
- **le découpeur** — silence franc, parole ininterrompue jusqu'à la coupure forcée,
tranche trop courte rejetée, silence total, bruit de fond continu au-dessus du seuil
- **l'environnement de whisper-server** — indice GPU → `CUDA_VISIBLE_DEVICES`, chemin du
modèle, langue
- **l'aller-retour des réglages** dans `bkState`
- **l'extinction sur inactivité**
- **le garde-fou du Dockerfile**, étendu : `GGML_NATIVE=OFF` conservé sur les deux cibles,
présence des cibles `whisper-server` CPU et CUDA, `GGML_CUDA=ON` sur la seconde
## Ce que les tests ne prouveront pas
**La qualité de reconnaissance ne peut pas être vérifiée automatiquement ici** : aucun
échantillon de voix n'est disponible côté développement. Les tests établissent que le
découpage et la supervision sont corrects ; que `large-v3-turbo` comprenne mieux que
`small` reste une constatation d'usage.
C'est la raison d'être du sélecteur de modèle et du test du micro : rendre le compromis
ajustable par celui qui l'entend, au lieu de le figer dans le code sur une intuition.
+15
View File
@@ -0,0 +1,15 @@
You are in CODE MODE: an autonomous coding agent working in this discussion's folder.
Method — in this order:
1. If the task has no acceptance criteria yet, set them FIRST with the criteria tool (action=add): 2-6 short, testable statements of what DONE means. For a trivial task (one obvious change), skip criteria and just do it.
2. Explore before you change: read the relevant files (read, grep, glob, git_status). Never edit a file you have not read.
3. Implement with edit (small patches) or write (new files). Match the style of the surrounding code.
4. Prove it: run the build/tests with bash. A change that was never run is not done. For a web page or UI, start the dev server with bash_bg then use web_screenshot on its localhost URL — a headless Chromium is ALREADY installed, so NEVER install puppeteer, playwright or a browser.
5. Report briefly what changed and how you verified it.
Rules:
- PLAN ONCE. If the task needs a plan, write it to PLAN.md in ONE short write, then follow it. Between tool calls, think one or two sentences at most — NEVER restate or re-derive the plan: it is in PLAN.md and in your criteria, read them instead.
- Write each file COMPLETE in a single write call. Many small writes waste turns.
- You may NOT mark a criterion passed — the verification pass does that.
- If a command fails, read the error and fix the cause; do not retry the same command unchanged.
- Ask the user (ask tool) only for decisions that are genuinely theirs; decide the rest yourself.
+5
View File
@@ -0,0 +1,5 @@
You are the CODE REVIEW pass of code mode. Review the current changes (git_diff) for real defects.
Look for, in this order: correctness bugs (wrong logic, unhandled errors, races), security issues (injection, path traversal, secrets), then significant simplifications. Ignore style and taste.
For each finding: file:line, one-line problem, one-line fix. If the diff is sound, say so in one sentence. Do not modify anything.
+3
View File
@@ -0,0 +1,3 @@
You are the EXPLORATION pass of code mode. Answer questions about the codebase without modifying anything.
Use read, grep, glob and git tools to locate and understand code. Cite files as path:line. Be concise: report what you found and where, not everything you read. If the answer spans several places, list them with one line each.
+8
View File
@@ -0,0 +1,8 @@
You are the PLANNING pass of code mode. Break the user's task down BEFORE any code is written.
Produce:
1. A short restatement of the goal (one sentence).
2. Acceptance criteria via the criteria tool (action=add): 2-6 short, testable statements of what DONE means.
3. The implementation steps, ordered, with the files each step touches.
Explore the code (read, grep, glob, git_status) as needed to make the plan concrete — real file paths, real function names. Do not modify anything. End by asking the user to confirm the plan or amend it.
+12
View File
@@ -0,0 +1,12 @@
You are the VERIFICATION pass of code mode. You independently check the acceptance criteria against the actual code — you did not write it, trust nothing.
For each criterion still pending or failed:
1. Understand what it actually requires.
2. Check the real changes: git_diff, read the touched files, run the relevant build/test command with bash when applicable.
3. Record the verdict with the criteria tool: action=set, status=passed (satisfied) or failed (with a note saying exactly what is missing — actionable, one line).
Rules:
- Trivial or non-code criteria that are plainly satisfied: pass them without exploring.
- Only fail a criterion that genuinely misses its requirement; a different-but-valid implementation passes.
- Do NOT fix anything yourself. You only verify and report.
- Be fast: check the diff first, read only what the criteria require.
+28 -1
View File
@@ -134,7 +134,7 @@ func detectBuildPlan() buildPlan {
}
// Vulkan (GPU générique) — utile sur Intel/AMD sans ROCm.
if hasTool("glslc") && (isFile("/usr/lib/x86_64-linux-gnu/libvulkan.so.1") || hasTool("vulkaninfo")) {
if hasTool("glslc") && (hasVulkanLib() || hasTool("vulkaninfo")) {
p.backend = "vulkan"
p.flags = append(p.flags, "-DGGML_VULKAN=ON")
return p
@@ -457,6 +457,33 @@ func hasTool(name string) bool {
return err == nil
}
// hasVulkanLib détecte le chargeur Vulkan (libvulkan) sans supposer un unique
// chemin de distribution. Repris de l'amont AJEAN (issues #28, #29) : l'ancienne
// détection testait en dur le chemin Debian multiarch
// (/usr/lib/x86_64-linux-gnu/libvulkan.so.1), invisible sur Fedora/RHEL/Atomic où
// la bibliothèque vit dans /usr/lib64 — le plan de build retombait alors sur le
// CPU sur une machine parfaitement capable. On interroge d'abord ldconfig (la
// source de vérité du linker dynamique), puis une liste de chemins connus.
func hasVulkanLib() bool {
// ldconfig -p liste les bibliothèques du cache, tous chemins confondus.
if out, err := exec.Command("ldconfig", "-p").Output(); err == nil {
if strings.Contains(string(out), "libvulkan.so") {
return true
}
}
for _, p := range []string{
"/usr/lib/x86_64-linux-gnu/libvulkan.so.1", // Debian/Ubuntu multiarch
"/usr/lib64/libvulkan.so.1", // Fedora/RHEL/Bazzite
"/usr/lib/libvulkan.so.1", // Arch et divers
"/lib64/libvulkan.so.1",
} {
if isFile(p) {
return true
}
}
return false
}
func requireTools(tools ...string) error {
missing := missingTools(tools)
if len(missing) == 0 {
+55
View File
@@ -234,6 +234,61 @@ func splitArgs(s string) []string {
return out
}
// samplingKeys : les paramètres d'échantillonnage réglables par preset. Repris
// de l'amont AJEAN (v0.9.5/v0.9.6).
//
// Ils sont injectés dans CHAQUE requête de complétion (applySampling) et non
// passés à llama-server au lancement : une valeur présente dans la requête
// l'emporte de toute façon sur le défaut du serveur, donc c'est le seul endroit
// où le réglage prend vraiment effet. Sans ça, seule la température voyageait, et
// top_k/min_p/… retombaient sur les défauts de llama.cpp — rarement ceux que
// recommande le modèle (Qwen3.8 veut top_k 20 / min_p 0, pas 40 / 0.05).
//
// Correspondance clé de config → champ du corps OpenAI. Une valeur vide vaut
// « clé absente » : llama-server garde alors son propre défaut pour ce champ.
var samplingKeys = []struct {
cfg, field string
integer bool
}{
{"TEMP", "temperature", false},
{"TOP_P", "top_p", false},
{"TOP_K", "top_k", true},
{"MIN_P", "min_p", false},
{"PRESENCE_PENALTY", "presence_penalty", false},
{"REPEAT_PENALTY", "repeat_penalty", false},
}
// applySampling superpose les paramètres d'échantillonnage du preset au corps
// d'une requête de complétion. Les clés vides sont ignorées (le serveur garde son
// défaut) ; TEMP, s'il est défini, l'emporte sur la température déjà posée dans le
// payload — c'est le réglage recommandé du modèle, et l'interface n'a pas de
// curseur de température qui pourrait le contredire.
//
// Contrairement à l'amont, REASONING_EFFORT n'est PAS traité ici : loki lui
// réserve un chemin plus riche (llm_client.go + llm_effort.go — champ de haut
// niveau, kwargs du gabarit, repli quand le gabarit refuse le niveau). L'écrire
// depuis ici écraserait `chat_template_kwargs`, donc la consigne « aucune » avec.
func applySampling(payload map[string]any) { applySamplingFrom(payload, ReadConfig()) }
// applySamplingFrom est la partie pure d'applySampling : testable sans base.
func applySamplingFrom(payload map[string]any, cfg map[string]string) {
for _, s := range samplingKeys {
v := strings.TrimSpace(cfg[s.cfg])
if v == "" {
continue
}
if s.integer {
if n, err := strconv.Atoi(v); err == nil {
payload[s.field] = n
}
continue
}
if f, err := strconv.ParseFloat(v, 64); err == nil {
payload[s.field] = f
}
}
}
// Le plafond d'appels d'outils par tour (TOOL_LIMIT) et l'anti-boucle ont été
// retirés : ils coupaient surtout des tours légitimes. Le bouton stop est le
// seul frein.
+5 -1
View File
@@ -24,6 +24,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -202,7 +203,10 @@ func TestInstallationMoteurDepuisImage(t *testing.T) {
if isDir(filepath.Join(dir, "etc")) || isDir(filepath.Join(dir, "usr")) {
t.Error("des fichiers hors /app ont été extraits")
}
if st, err := os.Stat(bin); err != nil || st.Mode()&0o100 == 0 {
// Windows n'a pas de bit exécutable : Stat y renvoie 0666/0444 quoi qu'il
// arrive, et l'assertion échouait toujours — alors que l'installation, elle,
// est correcte (le bit est bien posé côté Linux, là où le moteur tourne).
if st, err := os.Stat(bin); err != nil || (runtime.GOOS != "windows" && st.Mode()&0o100 == 0) {
t.Errorf("llama-server non exécutable : %v", err)
}
+183 -3
View File
@@ -67,6 +67,11 @@ type hfRepo struct {
ID string `json:"id"`
Downloads int `json:"downloads"`
Likes int `json:"likes"`
// Gated : le dépôt exige d'avoir accepté ses conditions ET un jeton. Il se
// LIT pourtant sans rien (arborescence en 200), et ne refuse qu'au moment du
// transfert : sans cette pastille, l'interface propose des quants avec leur
// verdict mémoire et le téléchargement échoue en 401 sans prévenir.
Gated bool `json:"gated"`
}
// hfEntry — un .gguf installable. Shards > 1 signale une famille de tranches :
@@ -85,6 +90,7 @@ type hfEntry struct {
// hfListing — le contenu utile d'un dépôt, trié.
type hfListing struct {
Repo string `json:"repo"`
Gated bool `json:"gated"`
Models []hfEntry `json:"models"`
Projectors []hfEntry `json:"projectors"`
Drafts []hfEntry `json:"drafts"`
@@ -95,13 +101,115 @@ type hfListing struct {
// transfert : sans lui un dépôt gated répond 401 et Loki afficherait « aucun
// résultat » là où il faudrait dire « dépôt à accès restreint ».
func hfAuth(req *http.Request) {
if k := os.Getenv("HF_TOKEN"); k != "" {
if k := hfToken(); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept", "application/json")
}
// ---- Jeton Hugging Face -----------------------------------------------------
//
// Le jeton vivait UNIQUEMENT dans la variable d'environnement HF_TOKEN. En
// conteneur, c'était l'exiger au mauvais moment : on découvre qu'un dépôt est
// verrouillé depuis l'interface, et il faudrait éditer un docker-compose puis
// recréer le conteneur pour y répondre. Il se règle donc aussi dans l'interface,
// et se range là où sont déjà les secrets (base d'état) — pas dans config.env,
// que le changement de preset réécrit en bloc.
//
// Priorité : le jeton enregistré d'abord, la variable d'environnement ensuite.
// Rien d'enregistré = comportement d'avant, à l'octet près.
// hfToken renvoie le jeton effectivement utilisé, ou "".
func hfToken() string {
if t := strings.TrimSpace(getStr(bkState, "hf_token")); t != "" {
return t
}
return strings.TrimSpace(os.Getenv("HF_TOKEN"))
}
// hfTokenSource dit D'OÙ vient le jeton : « config » (enregistré ici), « env »
// (HF_TOKEN du conteneur) ou "" (aucun). L'interface en a besoin pour ne pas
// proposer d'effacer un jeton qu'elle ne peut pas effacer.
func hfTokenSource() string {
switch {
case strings.TrimSpace(getStr(bkState, "hf_token")) != "":
return "config"
case strings.TrimSpace(os.Getenv("HF_TOKEN")) != "":
return "env"
}
return ""
}
// hfTokenSet dit si un jeton est disponible, pour que l'interface distingue
// « il faut en poser un » de « celui qui est posé ne suffit pas ».
func hfTokenSet() bool { return hfToken() != "" }
// writeHFToken enregistre (ou efface, tok == "") le jeton. Le cache des réponses
// Hugging Face est vidé au passage : il contient des listes obtenues SANS jeton,
// où un dépôt verrouillé peut manquer ou paraître illisible.
func writeHFToken(tok string) error {
if err := putStr(bkState, "hf_token", strings.TrimSpace(tok)); err != nil {
return err
}
hfCacheMu.Lock()
hfCache = map[string]hfCacheItem{}
hfCacheMu.Unlock()
return nil
}
// maskHFToken n'affiche qu'assez de caractères pour reconnaître le jeton posé.
// Il n'est jamais renvoyé en clair : contrairement à la clé API du serveur,
// personne n'a besoin de le recopier depuis Loki.
func maskHFToken(t string) string {
t = strings.TrimSpace(t)
if t == "" {
return ""
}
if len(t) <= 8 {
return "…" + t[len(t)-2:]
}
return t[:3] + "…" + t[len(t)-4:]
}
// hfWhoAmI vérifie un jeton auprès de Hugging Face et renvoie le nom du compte.
// Enregistrer un jeton sans le vérifier, c'est déplacer l'échec : on croirait le
// problème réglé jusqu'au prochain 401, quinze minutes de téléchargement plus
// tard.
func hfWhoAmI(ctx context.Context, tok string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, hfTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", hfHost+"/api/whoami-v2", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(tok))
req.Header.Set("User-Agent", "loki/"+Version)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("Hugging Face injoignable : %v", err)
}
defer resp.Body.Close()
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return "", fmt.Errorf("jeton refusé par Hugging Face (expiré, révoqué ou mal copié)")
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("Hugging Face a répondu %d", resp.StatusCode)
}
var who struct {
Name string `json:"name"`
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", err
}
if err := json.Unmarshal(raw, &who); err != nil {
return "", err
}
return who.Name, nil
}
// hfCache : mémo commun à la recherche et aux arborescences, clé = l'URL
// appelée. Sans lui, chaque frappe au clavier dans le champ de recherche
// produit une requête sortante.
@@ -172,29 +280,99 @@ func hfSearch(ctx context.Context, q string) ([]hfRepo, error) {
if len(q) > hfMaxQuery {
q = q[:hfMaxQuery]
}
// `expand[]` remplace les champs par défaut de la réponse : `gated` s'y
// ajoute, mais downloads et likes doivent alors être redemandés
// explicitement, sinon ils disparaissent et la liste perd son classement
// lisible.
endpoint := hfHost + "/api/models?" + url.Values{
"search": {q},
"filter": {"gguf"},
"limit": {fmt.Sprint(hfMaxRepos)},
"sort": {"downloads"},
"direction": {"-1"},
"expand[]": {"gated", "downloads", "likes"},
}.Encode()
var raw []struct {
ID string `json:"id"`
Downloads int `json:"downloads"`
Likes int `json:"likes"`
Gated any `json:"gated"`
}
if err := hfGetJSON(ctx, endpoint, hfSearchTTL, &raw); err != nil {
return nil, err
}
out := make([]hfRepo, 0, len(raw))
for _, r := range raw {
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes})
out = append(out, hfRepo{ID: r.ID, Downloads: r.Downloads, Likes: r.Likes, Gated: hfGatedFlag(r.Gated)})
}
return out, nil
}
// hfGatedFlag lit le champ `gated` de l'API, qui n'est PAS un booléen : il vaut
// false, "auto" (accepter les conditions suffit) ou "manual" (l'auteur valide
// chaque demande). Les deux dernières valeurs verrouillent le téléchargement de
// la même façon ; seule la présence d'un verrou nous intéresse ici.
func hfGatedFlag(v any) bool {
switch g := v.(type) {
case bool:
return g
case string:
switch strings.ToLower(strings.TrimSpace(g)) {
case "", "false", "none", "no":
return false
}
return true
}
return false
}
// hfRepoGated dit si un dépôt est verrouillé. L'arborescence (hfFiles) ne porte
// pas l'information : elle se lit sur la fiche du dépôt, en un appel séparé et
// mis en cache comme elle. Une erreur ici ne doit RIEN casser — au pire on
// n'affiche pas l'avertissement, et le téléchargement dira lui-même pourquoi il
// a été refusé.
func hfRepoGated(ctx context.Context, repo string) bool {
var info struct {
Gated any `json:"gated"`
}
endpoint := hfHost + "/api/models/" + repo + "?" + url.Values{"expand[]": {"gated"}}.Encode()
if err := hfGetJSON(ctx, endpoint, hfFilesTTL, &info); err != nil {
return false
}
return hfGatedFlag(info.Gated)
}
// isHFHost dit si un hôte appartient à Hugging Face. Sert à décider qui a le
// droit de recevoir le jeton.
func isHFHost(host string) bool {
h := strings.ToLower(host)
if i := strings.IndexByte(h, ':'); i >= 0 {
h = h[:i]
}
return h == "huggingface.co" || strings.HasSuffix(h, ".huggingface.co") ||
h == "hf.co" || strings.HasSuffix(h, ".hf.co")
}
// hfRepoFromURL retrouve « auteur/dépôt » dans un lien de téléchargement
// Hugging Face, pour pouvoir le nommer dans un message d'erreur. Renvoie "" si
// le lien pointe ailleurs.
func hfRepoFromURL(raw string) string {
u, err := url.Parse(raw)
if err != nil || !strings.Contains(u.Host, "huggingface.co") {
return ""
}
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(segs) < 4 || (segs[2] != "resolve" && segs[2] != "blob") {
return ""
}
repo := segs[0] + "/" + segs[1]
if !hfRepoRe.MatchString(repo) {
return ""
}
return repo
}
// hfFiles liste les .gguf d'un dépôt et les range par famille.
//
// `recursive=1` n'est pas un confort : les quants volumineux vivent dans des
@@ -229,7 +407,9 @@ func hfFiles(ctx context.Context, repo string) (hfListing, error) {
}
files = append(files, hfFile{Path: f.Path, Size: n})
}
return hfClassify(repo, files), nil
out := hfClassify(repo, files)
out.Gated = hfRepoGated(ctx, repo)
return out, nil
}
// hfFile — une entrée d'arborescence, réduite à ce dont le classement a besoin.
+152
View File
@@ -1,6 +1,10 @@
package loki
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
)
@@ -205,3 +209,151 @@ func TestFitVerdict(t *testing.T) {
t.Errorf("le contexte ne change rien au verdict (%q dans les deux cas)", court)
}
}
// Le champ `gated` de l'API n'est pas un booléen : false, "auto" ou "manual".
// Le lire comme un bool laissait passer les deux valeurs qui verrouillent
// vraiment le téléchargement.
func TestHFGatedFlag(t *testing.T) {
for _, c := range []struct {
in any
want bool
}{
{nil, false},
{false, false},
{true, true},
{"auto", true},
{"manual", true},
{"false", false},
{"", false},
} {
if got := hfGatedFlag(c.in); got != c.want {
t.Errorf("hfGatedFlag(%#v) = %v, attendu %v", c.in, got, c.want)
}
}
}
// hfRepoFromURL sert à NOMMER le dépôt dans le message d'erreur : un lien qui
// ne vient pas de Hugging Face ne doit rien produire plutôt qu'un nom inventé.
func TestHFRepoFromURL(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf", "orcarouter/Qwen3.8-27B-Uncensored-GGUF"},
{"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF/blob/main/sub/dir/m.gguf", "ggml-org/Qwen3.8-27B-GGUF"},
{"https://example.com/ggml-org/Qwen3.8-27B-GGUF/resolve/main/m.gguf", ""},
{"https://huggingface.co/ggml-org/Qwen3.8-27B-GGUF", ""},
{"pas une url", ""},
} {
if got := hfRepoFromURL(c.in); got != c.want {
t.Errorf("hfRepoFromURL(%q) = %q, attendu %q", c.in, got, c.want)
}
}
}
// Le jeton enregistré dans l'interface prime sur HF_TOKEN, et son absence
// rend exactement le comportement d'avant : la variable d'environnement.
func TestHFTokenPrecedence(t *testing.T) {
testHome(t)
t.Setenv("HF_TOKEN", "")
if hfToken() != "" || hfTokenSource() != "" || hfTokenSet() {
t.Fatalf("sans rien : jeton=%q source=%q", hfToken(), hfTokenSource())
}
t.Setenv("HF_TOKEN", "hf_env")
if hfToken() != "hf_env" || hfTokenSource() != "env" {
t.Fatalf("HF_TOKEN seul : jeton=%q source=%q", hfToken(), hfTokenSource())
}
if err := writeHFToken(" hf_enregistre "); err != nil {
t.Fatal(err)
}
if hfToken() != "hf_enregistre" || hfTokenSource() != "config" {
t.Fatalf("jeton enregistré : jeton=%q source=%q", hfToken(), hfTokenSource())
}
// Retiré ici, la variable du conteneur reprend la main plutôt que de laisser
// Loki sans jeton alors que l'environnement en fournit un.
if err := writeHFToken(""); err != nil {
t.Fatal(err)
}
if hfToken() != "hf_env" || hfTokenSource() != "env" {
t.Fatalf("après retrait : jeton=%q source=%q", hfToken(), hfTokenSource())
}
}
// Le jeton ne doit jamais ressortir en clair de l'interface.
func TestMaskHFToken(t *testing.T) {
if got := maskHFToken("hf_abcdefghijklmnop"); got != "hf_…mnop" {
t.Errorf("masque = %q", got)
}
if got := maskHFToken(""); got != "" {
t.Errorf("masque d'un jeton absent = %q", got)
}
if strings.Contains(maskHFToken("hf_abcdefghijklmnop"), "efghij") {
t.Error("le masque laisse voir le milieu du jeton")
}
}
// Un secret ne part QUE vers Hugging Face : un lien collé vers un autre
// hébergeur ne doit pas recevoir le jeton du compte.
func TestDLRequestTokenOnlyToHuggingFace(t *testing.T) {
testHome(t)
t.Setenv("HF_TOKEN", "hf_secret")
for _, c := range []struct {
url string
want bool
}{
{"https://huggingface.co/a/b/resolve/main/m.gguf", true},
{"https://cdn-lfs.huggingface.co/a/b/m.gguf", true},
{"https://hf.co/a/b/resolve/main/m.gguf", true},
{"https://example.com/m.gguf", false},
{"https://huggingface.co.evil.example/m.gguf", false},
} {
req, err := dlRequest(context.Background(), c.url, "")
if err != nil {
t.Fatal(err)
}
if got := req.Header.Get("Authorization") != ""; got != c.want {
t.Errorf("%s : en-tête Authorization présent=%v, attendu %v", c.url, got, c.want)
}
}
}
// La route décrit le jeton sans jamais le rendre, et l'efface sans appeler
// Hugging Face (un jeton vide n'a rien à vérifier).
func TestHandleHFTokenGetAndClear(t *testing.T) {
testHome(t)
t.Setenv("HF_TOKEN", "")
if err := writeHFToken("hf_abcdefghijklmnop"); err != nil {
t.Fatal(err)
}
call := func(method, body string) map[string]any {
t.Helper()
r := httptest.NewRequest(method, "http://placeholder/api/hf/token", strings.NewReader(body))
w := httptest.NewRecorder()
handleHFToken(w, r)
if w.Code != 200 {
t.Fatalf("%s → HTTP %d : %s", method, w.Code, w.Body.String())
}
var out map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
return out
}
got := call("GET", "")
if got["set"] != true || got["source"] != "config" {
t.Fatalf("état lu = %v", got)
}
if s, _ := got["masked"].(string); s == "" || strings.Contains(s, "efghij") {
t.Fatalf("masque inattendu : %q", s)
}
if strings.Contains(fmt.Sprint(got), "hf_abcdefghijklmnop") {
t.Fatalf("le jeton ressort en clair : %v", got)
}
got = call("POST", `{"token":""}`)
if got["set"] != false || got["source"] != "" {
t.Fatalf("après effacement = %v", got)
}
if hfToken() != "" {
t.Fatalf("jeton toujours présent : %q", hfToken())
}
}
+4 -2
View File
@@ -264,10 +264,12 @@ func handleModelDirs(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 200, map[string]any{"ok": true})
return
}
out := []map[string]any{{"path": modelsDir(), "home": true, "count": modelDirCount(modelsDir()), "exists": true, "free": diskFree(modelsDir())}}
out := []map[string]any{{"path": modelsDir(), "home": true, "count": modelDirCount(modelsDir()), "exists": true,
"free": diskFree(modelsDir()), "free_exact": diskFreeReliable(modelsDir())}}
for _, d := range extraModelDirs() {
n := modelDirCount(d)
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0, "free": diskFree(d)})
out = append(out, map[string]any{"path": d, "home": false, "count": n, "exists": n >= 0,
"free": diskFree(d), "free_exact": diskFreeReliable(d)})
}
sendJSON(w, 200, map[string]any{"ok": true, "dirs": out, "env": os.Getenv("LOKI_MODEL_DIRS") != ""})
}
+137 -10
View File
@@ -72,17 +72,24 @@ func reasoningActive(v string) bool {
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
// "" (auto) n'y figure pas : c'est l'absence de réglage.
var ReasoningEfforts = []string{"none", "low", "medium", "high"}
//
// Aucun gabarit ne les connaît toutes — gpt-oss lit low/medium/high, Qwen3.8 lit
// low/medium/xhigh — et certains REFUSENT celles qu'ils ne connaissent pas (voir
// llm_effort.go, qui traduit alors vers le niveau accepté le plus proche). La
// liste reste donc l'union des niveaux courants plutôt que le plus petit
// dénominateur commun, sinon le maximum d'un modèle est hors d'atteinte.
var ReasoningEfforts = []string{"none", "low", "medium", "high", "xhigh"}
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
//
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur —
// c'est pourquoi il n'y a pas de repli à prévoir ici, contrairement à un backend
// qui rejetterait la requête. En pratique seuls les modèles dont le gabarit
// gère `reasoning_effort` (gpt-oss et apparentés) changent de comportement.
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur ;
// un gabarit qui la VALIDE, lui, peut lever une exception jinja et faire
// répondre 500 à llama-server (Qwen3.8 sur « high »). Le repli n'est pas ici —
// on ne sait pas d'avance ce que le gabarit accepte — mais dans llm_effort.go,
// qui apprend la réponse du refus lui-même et rejoue le tour.
func reasoningEffortValue(v string) string {
switch s := strings.ToLower(strings.TrimSpace(v)); s {
case "", "auto", "default":
@@ -98,6 +105,44 @@ func reasoningEffortValue(v string) string {
}
}
// reasoningTemplateKwargs construit les `chat_template_kwargs` à joindre à une
// requête de chat pour que « aucune » coupe VRAIMENT le raisonnement.
//
// Pourquoi ce doublon avec `reasoning_effort` : ce champ n'agit que sur les
// gabarits qui le LISENT (gpt-oss et apparentés). Un modèle hybride à la Qwen3
// ne connaît que `enable_thinking` — on lui envoie `reasoning_effort: none`, son
// gabarit l'ignore, et il continue de réfléchir alors que l'interface affiche
// « aucune ». Les llama-server récents traduisent eux-mêmes `none` en
// `enable_thinking=false`, mais pas les binaires plus anciens, qui laissent
// simplement tomber le champ.
//
// D'où la règle : quand le raisonnement doit être coupé, on le dit dans la
// langue que TOUS les gabarits comprennent. `chat_template_kwargs` est passé tel
// quel au gabarit jinja ; une clé qu'il n'utilise pas est ignorée sans erreur.
// Même procédé que la compaction (chat_compact.go), qui coupe déjà la réflexion
// de cette façon.
//
// off = REASONING explicitement à off. Clé absente ≠ off : sans consigne on ne
// touche à rien, le gabarit garde son défaut.
// Renvoie nil quand il n'y a rien à imposer.
func reasoningTemplateKwargs(off bool, effort string) map[string]any {
if off || effort == "none" {
return map[string]any{"enable_thinking": false}
}
if effort != "" {
// Niveau explicite : les vieux binaires ne relaient pas le champ de haut
// niveau au gabarit, celui-ci passe toujours.
return map[string]any{"reasoning_effort": effort}
}
return nil
}
// reasoningExplicitlyOff dit si REASONING interdit le raisonnement. Une clé
// absente ou vide n'est PAS une interdiction : c'est l'absence de consigne.
func reasoningExplicitlyOff(v string) bool {
return strings.TrimSpace(v) != "" && !reasoningActive(v)
}
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
// (manual override, with or without a leading '#') wins; otherwise it is
// auto-detected from the MODEL= filename. Returns "" when unknown.
@@ -365,6 +410,13 @@ func checkDiskSpace(dir string, size int64) error {
if size <= 0 || free < 0 {
return nil
}
// Système de fichiers dont statfs approxime (FUSE/shfs d'Unraid, overlay,
// réseau) : le chiffre reste affiché, mais il ne peut plus REFUSER un
// téléchargement. Vécu : « 11,9 Go libres » annoncés sur un partage Unraid
// qui en avait des centaines, et l'installation du modèle bloquée.
if !diskFreeReliable(dir) {
return nil
}
if free < size+dlSpaceMargin {
return fmt.Errorf("espace insuffisant sur %s : %s libres, %s nécessaires", dir, humanBytes(free), humanBytes(size+dlSpaceMargin))
}
@@ -421,7 +473,8 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
}
total += n
}
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true, "parts": len(urls)}
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total,
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)}
if err := checkDiskSpace(dir, total); err != nil {
out["enough"] = false
out["error"] = err.Error()
@@ -436,8 +489,12 @@ func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
if err != nil {
return nil, err
}
// HF gated/private repos may need a token; reuse the same key store if set.
if k := os.Getenv("HF_TOKEN"); k != "" {
// Dépôt gated/privé : le jeton (réglage de l'interface, sinon HF_TOKEN) est
// ce qui débloque le transfert. Il n'accompagne QUE les liens Hugging Face :
// un lien collé vers un autre hébergeur n'a aucune raison de recevoir un
// secret, et le CDN vers lequel HF redirige est signé, donc n'en a pas besoin
// non plus (Go retire l'en-tête au changement de domaine).
if k := hfToken(); k != "" && isHFHost(req.URL.Host) {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "loki/"+Version)
@@ -461,6 +518,75 @@ func contentRangeTotal(v string) int64 {
return n
}
// dlSourceError traduit un refus HTTP en phrase qui dit quoi faire.
//
// Vécu : un dépôt Hugging Face « gated » (conditions à accepter avant de
// télécharger) répond 200 sur son arborescence — Loki liste donc tous ses
// quants, avec leur verdict mémoire — puis 401 sur CHAQUE .gguf. « HTTP 401
// depuis la source » ne dit alors ni que le dépôt est verrouillé, ni qu'il faut
// un jeton, ni où l'accepter : l'utilisateur voit un modèle proposé comme
// installable qui échoue sans raison.
//
// Hugging Face, lui, le dit — dans l'en-tête X-Error-Code (GatedRepo,
// RepoNotFound, EntryNotFound…). On le traduit plutôt que de le recopier :
// l'interface est en français, et le message d'origine (« Please log in »)
// parle d'une session de navigateur qui n'existe pas ici.
func dlSourceError(resp *http.Response, dlURL string) error {
code := resp.Header.Get("X-Error-Code")
switch resp.StatusCode {
case 401, 403:
return fmt.Errorf("%s%s", dlAccessReason(code, hfRepoFromURL(dlURL)), dlTokenHint())
case 404:
switch code {
case "EntryNotFound":
return fmt.Errorf("fichier absent du dépôt (HTTP 404) — la révision a pu être réécrite depuis que le lien a été copié")
case "RevisionNotFound":
return fmt.Errorf("révision introuvable dans le dépôt (HTTP 404)")
}
return fmt.Errorf("lien introuvable (HTTP 404)")
case 416:
return fmt.Errorf("la source refuse la plage d'octets demandée (HTTP 416) — fichier modifié pendant le transfert ?")
case 429:
return fmt.Errorf("trop de requêtes vers la source (HTTP 429) — réessaie dans quelques minutes")
}
if resp.StatusCode >= 500 {
return fmt.Errorf("la source est en panne (HTTP %d) — réessaie plus tard", resp.StatusCode)
}
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
}
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
// accepter, qui n'existent que là-bas.
func dlAccessReason(code, repo string) string {
switch code {
case "GatedRepo":
if repo == "" {
return "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co"
}
return "dépôt à accès restreint : accepte ses conditions sur huggingface.co/" + repo
case "RepoNotFound":
if repo == "" {
return "dépôt privé ou inexistant"
}
return "dépôt privé ou inexistant : " + repo
}
if repo != "" {
return "accès refusé par Hugging Face sur " + repo
}
return "accès refusé par la source"
}
// dlTokenHint complète la raison par l'état du jeton. Deux situations opposées
// se cachent derrière le même 401 : pas de jeton du tout, ou un jeton qui n'a
// pas accès à CE dépôt — et le geste à faire n'est pas le même.
func dlTokenHint() string {
if !hfTokenSet() {
return " — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
}
return " — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
}
// dlProbe asks the server for the first byte to learn the total size and
// whether ranges are supported (206 + Content-Range).
func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err error) {
@@ -484,7 +610,7 @@ func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err e
// Server ignored the Range: single stream, ContentLength is the size.
return resp.ContentLength, false, nil
default:
return 0, false, fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
return 0, false, dlSourceError(resp, dlURL)
}
}
@@ -710,8 +836,9 @@ func dlChunk(ctx context.Context, f *os.File, dlURL string, start, end int64, wh
continue
}
if resp.StatusCode != 200 && resp.StatusCode != 206 {
err := dlSourceError(resp, dlURL)
resp.Body.Close()
return fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
return err
}
if resp.StatusCode == 200 && pos > start {
// Resume refused: the body restarts from 0, rewind our bookkeeping.
+88
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
@@ -135,3 +136,90 @@ func TestRunDownloadParallelAndFallback(t *testing.T) {
}
}
}
// Un dépôt Hugging Face verrouillé répond 401 sur le .gguf alors que son
// arborescence se lit sans jeton. « HTTP 401 depuis la source » n'apprenait
// rien : le message doit nommer le verrou, le dépôt, et le geste à faire.
func TestDLSourceErrorExplainsGatedRepo(t *testing.T) {
const gated = "https://huggingface.co/orcarouter/Qwen3.8-27B-Uncensored-GGUF/resolve/main/m.gguf"
resp := func(status int, code string) *http.Response {
r := &http.Response{StatusCode: status, Header: http.Header{}}
if code != "" {
r.Header.Set("X-Error-Code", code)
}
return r
}
for _, c := range []struct {
name string
token string
resp *http.Response
url string
want []string
absent []string
}{
{
name: "gated sans jeton", resp: resp(401, "GatedRepo"), url: gated,
want: []string{"accès restreint", "orcarouter/Qwen3.8-27B-Uncensored-GGUF", "HF_TOKEN"},
},
{
name: "gated avec jeton refusé", token: "hf_xxx", resp: resp(401, "GatedRepo"), url: gated,
want: []string{"accès restreint", "n'y donne pas accès"},
absent: []string{"renseigne le jeton"},
},
{
name: "dépôt privé ou absent", resp: resp(401, "RepoNotFound"), url: gated,
want: []string{"privé ou inexistant", "HF_TOKEN"},
},
{
name: "fichier absent", resp: resp(404, "EntryNotFound"), url: gated,
want: []string{"absent du dépôt", "404"},
},
{
name: "source hors Hugging Face", resp: resp(403, ""), url: "https://example.com/m.gguf",
want: []string{"accès refusé par la source"},
absent: []string{"huggingface.co"},
},
{
name: "panne de la source", resp: resp(503, ""), url: gated,
want: []string{"en panne", "503"},
},
{
name: "code inconnu", resp: resp(418, ""), url: gated,
want: []string{"HTTP 418 depuis la source"},
},
} {
t.Run(c.name, func(t *testing.T) {
t.Setenv("HF_TOKEN", c.token)
got := dlSourceError(c.resp, c.url).Error()
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Errorf("message %q ne contient pas %q", got, w)
}
}
for _, a := range c.absent {
if strings.Contains(got, a) {
t.Errorf("message %q contient %q alors qu'il ne devrait pas", got, a)
}
}
})
}
}
// La sonde est le premier appel réseau d'une installation : c'est elle qui doit
// remonter le refus expliqué, pas un code HTTP nu.
func TestDLProbeSurfacesExplainedError(t *testing.T) {
t.Setenv("HF_TOKEN", "")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Error-Code", "GatedRepo")
w.WriteHeader(401)
}))
defer srv.Close()
_, _, err := dlProbe(context.Background(), srv.URL+"/m.gguf")
if err == nil {
t.Fatal("un 401 doit faire échouer la sonde")
}
if !strings.Contains(err.Error(), "HF_TOKEN") {
t.Errorf("message %q sans indication sur le jeton", err)
}
}
+56
View File
@@ -0,0 +1,56 @@
package loki
import "testing"
// Le sampling du preset doit se retrouver dans le corps de la requête, avec les
// bons types (top_k entier, le reste flottant), et TEMP écrase la température
// déjà posée. Une clé vide n'ajoute rien : le serveur garde son défaut.
func TestApplySamplingFrom(t *testing.T) {
payload := map[string]any{"temperature": 0.7}
applySamplingFrom(payload, map[string]string{
"TEMP": "1.0",
"TOP_P": "0.95",
"TOP_K": "20",
"MIN_P": "0",
"REPEAT_PENALTY": "1.05",
"PRESENCE_PENALTY": "", // vide → ignoré
})
if payload["temperature"] != 1.0 {
t.Errorf("TEMP doit écraser la température : %v", payload["temperature"])
}
if payload["top_p"] != 0.95 || payload["min_p"] != 0.0 || payload["repeat_penalty"] != 1.05 {
t.Errorf("flottants attendus : %v %v %v", payload["top_p"], payload["min_p"], payload["repeat_penalty"])
}
if payload["top_k"] != 20 {
t.Errorf("top_k doit être un entier 20, pas %#v", payload["top_k"])
}
if _, ok := payload["presence_penalty"]; ok {
t.Error("une clé vide ne doit rien envoyer")
}
}
// L'échantillonnage ne touche PAS au raisonnement : ce chemin appartient à
// llm_client.go/llm_effort.go, qui y met aussi `enable_thinking`. L'amont écrit
// `chat_template_kwargs` ici ; le faire chez nous effacerait la consigne
// « aucune » — donc le modèle réfléchirait alors que l'interface dit l'inverse.
func TestApplySamplingNeTouchePasAuRaisonnement(t *testing.T) {
payload := map[string]any{"chat_template_kwargs": map[string]any{"enable_thinking": false}}
applySamplingFrom(payload, map[string]string{"REASONING_EFFORT": "medium", "TOP_K": "20"})
kw, ok := payload["chat_template_kwargs"].(map[string]any)
if !ok || kw["enable_thinking"] != false || len(kw) != 1 {
t.Errorf("chat_template_kwargs modifié : %v", payload["chat_template_kwargs"])
}
if _, ok := payload["reasoning_effort"]; ok {
t.Error("reasoning_effort ne doit pas être posé par l'échantillonnage")
}
}
// Sans aucune clé de sampling, le payload n'est pas modifié : on ne force pas de
// valeurs qui contrediraient le défaut du modèle.
func TestApplySamplingEmpty(t *testing.T) {
payload := map[string]any{"temperature": 0.7}
applySamplingFrom(payload, map[string]string{})
if len(payload) != 1 || payload["temperature"] != 0.7 {
t.Errorf("payload modifié à tort : %v", payload)
}
}
+198 -15
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
@@ -23,15 +24,163 @@ import (
// échoue à s'exécuter (« libllama-common.so introuvable ») et on conclurait à
// tort qu'il ne gère pas le drapeau.
func binSupportsReasoningFlag(bin string) bool {
return strings.Contains(binHelp(bin), "--reasoning ")
}
// binHelp lit « <bin> --help » UNE fois par binaire et garde le texte. Plusieurs
// capacités se déduisent de cette aide (--reasoning, --load-mode, « -ngl auto »)
// et relancer le moteur à chaque question coûterait quelques secondes de plus au
// démarrage. Aide illisible = chaîne vide : tous les tests de capacité répondent
// « non », c'est-à-dire « ne prends aucun risque, garde la forme historique ».
var (
helpMu sync.Mutex
helpCache = map[string]string{}
)
func binHelp(bin string) string {
helpMu.Lock()
defer helpMu.Unlock()
if h, ok := helpCache[bin]; ok {
return h
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, bin, "--help")
var out bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &out
if err := cmd.Run(); err != nil && out.Len() == 0 {
return false // aide illisible : on ne prend pas le risque
h := ""
if err := cmd.Run(); err == nil || out.Len() > 0 {
h = out.String()
}
return strings.Contains(out.String(), "--reasoning ")
helpCache[bin] = h
return h
}
// binSupportsLoadMode : les moteurs récents ont fusionné --mlock, --mmap et
// --no-mmap dans un seul --load-mode ; les anciens ne connaissent que les trois
// drapeaux d'origine. On traduit donc à l'exécution, sans réécrire le preset :
// le même EXTRA_ARGS reste lançable sur les deux générations de moteur.
func binSupportsLoadMode(bin string) bool {
return strings.Contains(binHelp(bin), "--load-mode")
}
// binSupportsNGLAuto : « -ngl auto » laisse llama.cpp mesurer la VRAM libre et
// choisir le nombre de couches. Imposer un nombre (999 compris) désarme ce
// calcul — « n_gpu_layers already set by user to 999, abort » — et le moteur
// tente alors de tout mettre sur le GPU, quitte à échouer en cudaMalloc.
func binSupportsNGLAuto(bin string) bool {
h := binHelp(bin)
i := strings.Index(h, "--n-gpu-layers")
if i < 0 {
i = strings.Index(h, "-ngl")
}
if i < 0 {
return false
}
end := i + 400
if end > len(h) {
end = len(h)
}
return strings.Contains(h[i:end], "'auto'")
}
// binFitsLayersItself : ce moteur sait-il répartir les couches tout seul ?
//
// La lecture fine de l'aide (binSupportsNGLAuto) reste la source de vérité, mais
// elle dépend de la mise en page d'un texte d'aide — une description reformulée
// ou une colonne plus large, et on conclut « non » sur un moteur parfaitement
// capable, donc on lui réimpose 999 et l'abandon revient. --load-mode est arrivé
// dans la même vague que « -ngl auto » : sa présence sert de second témoin, plus
// grossier mais insensible à la mise en page.
func binFitsLayersItself(bin string) bool {
return binSupportsNGLAuto(bin) || binSupportsLoadMode(bin)
}
// nglArgs traduit la valeur NGL du preset en arguments, et renvoie au passage la
// note à afficher quand Loki a substitué quelque chose. Fonction pure : la seule
// question qui demande le moteur (« sait-il répartir les couches tout seul ? »)
// arrive déjà tranchée, ce qui la rend testable sans lancer llama-server.
func nglArgs(ngl string, fitsItself bool) ([]string, string) {
ngl = strings.TrimSpace(ngl)
switch {
case strings.EqualFold(ngl, "auto"):
return nil, "" // rien du tout : le moteur décide seul
case ngl == "999" && fitsItself:
return []string{"-ngl", "auto"},
"NGL=999 (sentinelle « toutes les couches ») → -ngl auto : ce moteur mesure lui-même la " +
"VRAM libre. NGL=all pour forcer l'ancien comportement."
case ngl != "":
return []string{"-ngl", ngl}, ""
case fitsItself:
return []string{"-ngl", "auto"}, ""
default:
// Moteur ancien : omettre -ngl le ferait tourner 100 % CPU.
return []string{"-ngl", "999"}, ""
}
}
// hasAnyFlag dit si l'utilisateur a déjà posé l'un de ces drapeaux dans
// EXTRA_ARGS. Loki ajoute plusieurs valeurs par défaut (--parallel, -ngl) : les
// ajouter EN PLUS de celles de l'utilisateur fait râler le moteur (« argument
// specified multiple times ») et, pire, c'est la DERNIÈRE occurrence qui gagne
// — le réglage explicite du preset serait donc silencieusement écrasé par le
// défaut de Loki. Quand l'utilisateur a tranché, on se tait.
func hasAnyFlag(args []string, flags ...string) bool {
for _, a := range args {
name := a
if i := strings.IndexByte(name, '='); i > 0 {
name = name[:i]
}
for _, f := range flags {
if name == f {
return true
}
}
}
return false
}
// normalizeLoadFlags traduit les drapeaux de chargement dépréciés vers
// --load-mode, sur les moteurs qui le connaissent :
//
// --mlock → --load-mode mlock (garder le modèle en RAM)
// --no-mmap → --load-mode none (tout charger, pas de mmap)
// --mmap → --load-mode mmap
//
// Les deux à la fois (--mlock --no-mmap) : mlock l'emporte, c'est l'intention la
// plus forte (résident en RAM, jamais rendu au système). Un --load-mode déjà
// écrit à la main dans EXTRA_ARGS gagne sur tout : on retire alors simplement
// les vieux drapeaux. Sur un moteur ancien, rien n'est touché.
func normalizeLoadFlags(args []string, supportsLoadMode bool) []string {
if !supportsLoadMode {
return args
}
explicit := hasAnyFlag(args, "--load-mode")
mlock, nommap, mmap := false, false, false
out := make([]string, 0, len(args)+2)
for _, a := range args {
switch a {
case "--mlock":
mlock = true
case "--no-mmap":
nommap = true
case "--mmap":
mmap = true
default:
out = append(out, a)
}
}
if explicit || !(mlock || nommap || mmap) {
return out
}
mode := "mmap"
switch {
case mlock:
mode = "mlock"
case nommap:
mode = "none"
}
return append(out, "--load-mode", mode)
}
// cmdServe replaces the historic start.sh: read config.env, build the
@@ -99,6 +248,12 @@ func cmdServe(args []string) error {
ktv := get("KV_TYPE_K", kv)
vtv := get("KV_TYPE_V", kv)
// EXTRA_ARGS est lu ICI, avant de composer la ligne de commande : ce que
// l'utilisateur a écrit à la main décide des défauts que Loki a le droit
// d'ajouter (voir hasAnyFlag). Il est aussi traduit vers les drapeaux de
// chargement actuels quand le moteur les attend (voir normalizeLoadFlags).
extra := normalizeLoadFlags(splitArgs(cfg["EXTRA_ARGS"]), binSupportsLoadMode(bin))
llmArgs := []string{bin,
"-m", model,
"-c", get("CTX", "32768"),
@@ -109,15 +264,43 @@ func cmdServe(args []string) error {
"--host", get("HOST", "0.0.0.0"),
"--port", get("PORT", "8080"),
}
// NGL=auto : on n'envoie PAS -ngl, et llama.cpp choisit lui-même combien de
// couches tiennent dans la VRAM libre (common_fit_params). Il s'en abstient
// dès que la valeur est imposée — « n_gpu_layers already set by user to 999,
// abort » — d'où des cudaMalloc en échec, un repli partiel sur le CPU et un
// débit effondré alors que le GPU tourne à 100 %.
// Le défaut reste 999 (tout sur le GPU) : omettre -ngl sur un llama.cpp
// antérieur à cet ajustement automatique le ferait tourner 100 % CPU.
if ngl := get("NGL", "999"); !strings.EqualFold(ngl, "auto") {
llmArgs = append(llmArgs, "-ngl", ngl)
// --parallel 1 EXPLICITE : les llama-server récents ouvrent plusieurs slots
// par défaut, soit des tampons de calcul GPU multipliés d'autant — pour un
// serveur mono-utilisateur comme Loki, c'est de la VRAM brûlée pour rien.
// Vécu : une config 27B/60k ctx qui tournait très bien est morte en
// « cudaMalloc failed: out of memory » après une mise à jour du moteur,
// uniquement à cause de ce nouveau défaut. PARALLEL=n dans le preset pour qui
// veut vraiment servir plusieurs requêtes à la fois — ou --parallel dans
// EXTRA_ARGS, auquel cas on ne redouble pas le drapeau.
if !hasAnyFlag(extra, "--parallel", "-np") {
llmArgs = append(llmArgs, "--parallel", get("PARALLEL", "1"))
}
// Couches GPU. Quatre cas, dans cet ordre :
//
// NGL=auto → rien du tout (le moteur décide seul)
// NGL=999 → -ngl auto sur un moteur récent (voir plus bas)
// NGL=<nombre> → -ngl <nombre> (l'utilisateur tranche pour de vrai)
// clé absente → -ngl auto si le moteur le propose, sinon -ngl 999
//
// Imposer un NOMBRE désarme common_fit_params, qui mesure la VRAM libre et
// choisit combien de couches y tiennent : « n_gpu_layers already set by user
// to 999, abort ». Le moteur pousse alors tout sur le GPU, échoue en
// cudaMalloc ou se replie à moitié sur le CPU — débit effondré, GPU à 100 %.
//
// 999 est traité à part parce que ce n'a JAMAIS été un nombre de couches :
// c'est la sentinelle historique « toutes », semée dans config.env par
// defaultConfig() sur chaque installation neuve. La quasi-totalité des
// configurations la portent donc sans que personne ne l'ait choisie — la
// traiter comme un choix délibéré, c'est condamner tout le monde à l'abandon
// ci-dessus. Sur un moteur qui connaît « auto », 999 devient donc auto, et on
// le DIT sur stderr plutôt que de le faire en douce. Qui veut réellement
// forcer tout sur le GPU écrit NGL=all (ou un nombre qui n'est pas 999).
if !hasAnyFlag(extra, "-ngl", "--n-gpu-layers", "--gpu-layers") {
args, note := nglArgs(get("NGL", ""), binFitsLayersItself(bin))
if note != "" {
fmt.Fprintln(os.Stderr, "[loki serve] "+note)
}
llmArgs = append(llmArgs, args...)
}
if ktv != "" {
llmArgs = append(llmArgs, "-ctk", ktv)
@@ -179,9 +362,9 @@ func cmdServe(args []string) error {
if k, _ := effectiveAPIKeyErr(); k != "" {
llmArgs = append(llmArgs, "--api-key", k)
}
// EXTRA_ARGS is appended verbatim, split like the shell would — quotes kept
// together so a path with spaces stays one argument.
llmArgs = append(llmArgs, splitArgs(cfg["EXTRA_ARGS"])...)
// EXTRA_ARGS (déjà découpé plus haut comme le ferait le shell — les guillemets
// gardent ensemble un chemin qui contient des espaces) ferme la marche.
llmArgs = append(llmArgs, extra...)
// Working dir = LOKI_HOME so relative paths in EXTRA_ARGS (e.g. --mmproj
// mmproj-F16.gguf) still resolve.
+93
View File
@@ -0,0 +1,93 @@
package loki
import (
"reflect"
"testing"
)
func TestHasAnyFlag(t *testing.T) {
args := []string{"--parallel", "4", "--load-mode=mlock", "-fa"}
if !hasAnyFlag(args, "--parallel", "-np") {
t.Fatal("--parallel devrait être détecté")
}
// Forme --clé=valeur : le nom seul compte.
if !hasAnyFlag(args, "--load-mode") {
t.Fatal("--load-mode=… devrait être détecté")
}
if hasAnyFlag(args, "-ngl", "--n-gpu-layers") {
t.Fatal("-ngl absent, ne devrait pas être détecté")
}
// Une VALEUR qui ressemble à un drapeau ne doit pas compter comme tel :
// ici "-ngl" n'est pas posé, seul --parallel l'est.
if hasAnyFlag(nil, "--parallel") {
t.Fatal("liste vide : rien à détecter")
}
}
func TestNormalizeLoadFlags(t *testing.T) {
cases := []struct {
name string
in []string
supports bool
want []string
}{
{"moteur ancien : rien ne bouge", []string{"--mlock", "--no-mmap"}, false,
[]string{"--mlock", "--no-mmap"}},
{"mlock traduit", []string{"--mlock", "-fa"}, true,
[]string{"-fa", "--load-mode", "mlock"}},
{"no-mmap traduit", []string{"--no-mmap"}, true,
[]string{"--load-mode", "none"}},
{"mmap traduit", []string{"--mmap"}, true,
[]string{"--load-mode", "mmap"}},
{"mlock l'emporte sur no-mmap", []string{"--no-mmap", "--mlock"}, true,
[]string{"--load-mode", "mlock"}},
{"--load-mode explicite gagne, vieux drapeaux retirés",
[]string{"--mlock", "--load-mode", "dio"}, true,
[]string{"--load-mode", "dio"}},
{"rien à traduire", []string{"-fa", "--n-cpu-moe", "8"}, true,
[]string{"-fa", "--n-cpu-moe", "8"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := normalizeLoadFlags(c.in, c.supports)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("got %q, want %q", got, c.want)
}
})
}
}
func TestNGLArgs(t *testing.T) {
cases := []struct {
name string
ngl string
fitsItself bool
want []string
wantNote bool
}{
// Le cas qui motive tout : defaultConfig() sème NGL=999 sur chaque
// installation neuve. Personne ne l'a choisi, et le moteur récent
// abandonne son calcul de VRAM dès qu'on lui impose un nombre.
{"sentinelle 999, moteur récent", "999", true, []string{"-ngl", "auto"}, true},
{"sentinelle 999, moteur ancien", "999", false, []string{"-ngl", "999"}, false},
{"nombre choisi, jamais touché", "28", true, []string{"-ngl", "28"}, false},
{"nombre choisi, moteur ancien", "28", false, []string{"-ngl", "28"}, false},
{"all reste all", "all", true, []string{"-ngl", "all"}, false},
{"auto : aucun drapeau", "auto", true, nil, false},
{"auto insensible à la casse", "AUTO", true, nil, false},
{"espaces parasites autour de la sentinelle", " 999 ", true, []string{"-ngl", "auto"}, true},
{"clé absente, moteur récent", "", true, []string{"-ngl", "auto"}, false},
{"clé absente, moteur ancien", "", false, []string{"-ngl", "999"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, note := nglArgs(c.ngl, c.fitsItself)
if !reflect.DeepEqual(got, c.want) {
t.Fatalf("args = %q, want %q", got, c.want)
}
if (note != "") != c.wantNote {
t.Fatalf("note = %q, en voulait-on une ? %v", note, c.wantNote)
}
})
}
}
+124 -1
View File
@@ -56,6 +56,12 @@ type Conversation struct {
Generating bool `json:"-"`
cancel context.CancelFunc // annule la génération en cours (/stop)
epoch int // incrémenté à chaque reset → invalide les abonnés
// Tâche planifiée en cours (tasks_run.go). Le verrou de génération est le
// SEUL point partagé entre une tâche de fond et le chat : ces deux champs
// disent à l'interface POURQUOI elle est occupée, au lieu de laisser croire
// à une génération fantôme dans la discussion ouverte.
runningTaskID string
runningTaskName string
}
var conv = func() *Conversation {
@@ -286,12 +292,42 @@ func (c *Conversation) state() map[string]any {
turns++
}
}
return map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
st := map[string]any{"seq": c.Seq, "generating": c.Generating, "ctx_used": c.CtxUsed, "turns": turns}
// Occupé PAR UNE TÂCHE : l'interface l'annonce (« tâche en cours ») plutôt que
// d'afficher un tour qui n'apparaîtra jamais dans le fil.
if c.runningTaskID != "" {
st["task_id"] = c.runningTaskID
st["task_name"] = c.runningTaskName
}
return st
}
// isGenerating dit si un tour est en cours. Sert à la LISTE des discussions :
// elle doit pouvoir montrer laquelle travaille dès le chargement de la page,
// sans attendre que le rejeu du flux SSE ait rattrapé son retard.
func (c *Conversation) isGenerating() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.Generating
}
// ErrBusy : une génération est déjà en cours (un seul tour à la fois).
var ErrBusy = fmt.Errorf("génération en cours")
// busyReason explique le refus à l'utilisateur. Sans ça, un message envoyé
// pendant qu'une TÂCHE planifiée tourne renvoyait « génération en cours » alors
// que le fil est vide et que rien ne bouge à l'écran — incompréhensible. On
// nomme la tâche et on dit comment reprendre la main.
func (c *Conversation) busyReason() error {
c.mu.Lock()
name := c.runningTaskName
c.mu.Unlock()
if name == "" {
return ErrBusy
}
return fmt.Errorf("la tâche planifiée « %s » occupe le modèle — attends la fin ou arrête-la (bouton stop)", name)
}
// errModelLoading est renvoyée telle quelle à l'utilisateur, dans le chat : ce
// n'est pas un défaut mais une attente, et le message doit le dire.
var errModelLoading = fmt.Errorf("⏳ Le modèle est encore en train de charger — réessaie dans quelques secondes.")
@@ -321,6 +357,15 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
if strings.TrimSpace(prompt) == "" {
prompt = "Prends-en connaissance."
}
// Mode code : fige le rôle du tour (plan demandé → planner, sinon builder).
// La détection de bascule (puce « passer en mode Code ? ») est émise après
// la bulle utilisateur, plus bas.
if caps.Code && caps.Role == "" {
caps.Role = "builder"
if wantsPlan(text) {
caps.Role = "planner"
}
}
// Content = simple texte d'ordinaire ; format multimodal (texte + images) quand
// la vision est active et qu'une pièce jointe est une image (userMessageContent).
c.Messages = append(c.Messages, Message{Role: "user", Content: userMessageContent(files, prompt)})
@@ -331,10 +376,22 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
// si le process meurt en pleine génération (crash, restart après MAJ), le
// message de l'utilisateur survit au lieu de disparaître avec le tour.
delta := map[string]any{"user": text}
// Nom du modèle qui va produire la réponse : journalisé avec la borne de
// tour, donc rejoué au chargement — la carte de réponse garde son modèle
// après un rafraîchissement, et un vieux tour garde le modèle de l'époque,
// pas celui chargé aujourd'hui.
if m := chatModelName(); m != "" {
delta["model"] = m
}
if len(files) > 0 {
delta["files"] = files
}
c.appendDelta(epoch, delta)
// En mode chat, un message qui ressemble à une tâche de code fait
// apparaître la puce « passer en mode Code ? » (une fois par discussion).
if !caps.Code {
maybeCodeHint(c, epoch, text)
}
c.persist()
if temperature == 0 {
temperature = 0.7
@@ -343,6 +400,22 @@ func (c *Conversation) StartTurn(text string, files []attachInfo, caps Caps, tem
return nil
}
// chatModelName renvoie le nom lisible du modèle configuré : fichier sans
// dossier ni extension .gguf (même règle que modelLabel côté UI), "" si aucun.
func chatModelName() string {
m := strings.TrimSpace(ReadConfig()["MODEL"])
if m == "" {
return ""
}
if i := strings.LastIndexAny(m, "/\\"); i >= 0 {
m = m[i+1:]
}
if strings.HasSuffix(strings.ToLower(m), ".gguf") {
m = m[:len(m)-len(".gguf")]
}
return m
}
// generate exécute un tour complet et journalise chaque événement. Détaché : la
// fermeture du navigateur n'a aucun effet ici, seul /stop (cancel) l'interrompt.
// epoch est capturé au StartTurn : si un Reset survient pendant la génération,
@@ -400,6 +473,13 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// Non-nil = elle remplace l'historique (elle contient déjà le tour en cours).
var newBase []Message
var content strings.Builder
// Le comptage EXACT du contexte vient de `usage.prompt_tokens` (option
// include_usage). Tous les moteurs ne le renvoient pas — un llama-server
// récent a cessé de le faire, et la jauge est restée bloquée à zéro sur des
// discussions de plusieurs dizaines de milliers de tokens. On note donc si
// l'usage est arrivé ; sinon on retombe sur l'estimation (celle qui pilote
// déjà la compaction), approximative mais jamais absente.
sawUsage := false
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
switch {
case ev.Err != nil:
@@ -419,6 +499,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
if len(ev.ToolUsed.Diff) > 0 {
tu["diff"] = ev.ToolUsed.Diff
}
if ev.ToolUsed.Image != "" {
tu["image"] = ev.ToolUsed.Image
}
c.appendDelta(epoch, map[string]any{"tool_used": tu})
case ev.NewHistory != nil:
// Compaction faite en cours de tour : elle remplace la base au lieu de
@@ -439,6 +522,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// Taille réelle du contexte (usage.prompt_tokens + généré) pour le compteur
// et la décision de compactage au tour suivant.
if ev.Stats.PromptTokensTotal > 0 {
sawUsage = true
c.mu.Lock()
if c.epoch == epoch {
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
@@ -446,6 +530,9 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
c.mu.Unlock()
}
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.Ask != nil:
// Question structurée (outil ask) : carte à boutons dans l'UI.
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
case ev.DropReasoning:
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
case ev.Reasoning != "":
@@ -476,6 +563,32 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
stale := c.epoch != epoch
c.mu.Unlock()
// Moteur muet sur l'usage : on publie l'estimation, sinon la jauge reste à
// zéro et l'utilisateur ne voit jamais son contexte se remplir — ni la
// compaction arriver.
if !stale && !sawUsage {
est := estimateTokens(msgs)
c.mu.Lock()
if c.epoch == epoch {
c.CtxUsed = est
}
c.mu.Unlock()
c.appendDelta(epoch, map[string]any{"ctx_used": est})
ctxUsed = est
}
// Boucle du contrat (mode code) : vérification indépendante des critères,
// corrections, re-vérification — AVANT de rendre la main. Voir
// code_verify.go. No-op hors mode code ou sans critères.
if !stale && ctx.Err() == nil {
c.codeVerifyLoop(ctx, caps, temperature, epoch)
c.mu.Lock()
msgs = append([]Message(nil), c.Messages...)
ctxUsed = c.CtxUsed
stale = c.epoch != epoch
c.mu.Unlock()
}
// Compaction de FIN DE TOUR. C'était LE trou : le seuil n'était testé qu'au
// DÉBUT d'un tour, avec le contexte du tour précédent. Le tour qui fait
// franchir le seuil se termine donc à 81% et… rien. La jauge reste haute, seul
@@ -754,6 +867,16 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
if !emit(map[string]any{"caught_up": true}) {
return
}
// Taille du contexte connue MAINTENANT : le journal ne rejoue que les
// événements du fil, et `ctx_used` n'y figure qu'aux tours où il a changé —
// souvent hors de la fenêtre rejouée. Sans cet envoi, toute page rechargée
// affichait « 0 / N (0 %) » sur une discussion pourtant bien remplie.
c.mu.Lock()
used := c.CtxUsed
c.mu.Unlock()
if used > 0 && !emit(map[string]any{"ctx_used": used}) {
return
}
// Le padding doit venir APRÈS caught_up, pas dessus. Un proxy (Cloudflare) garde
// toujours le DERNIER bout de flux en tampon jusqu'au prochain flush (~2-3 s). En
// envoyant un gros pad juste après, ce sont ses octets — et non le dernier vrai
+36 -2
View File
@@ -20,6 +20,7 @@ import (
"encoding/json"
"fmt"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
@@ -105,6 +106,11 @@ func screenshotVisionNote() string {
// modèle, ou avec un modèle sans vision (gpt-oss), le gabarit sérialise le
// base64 de l'image en TEXTE — la requête vue en production pesait 55 000
// tokens pour 32 768 de contexte, et tous les tours suivants échouaient.
//
// La sonde est un appel interne comme les autres : elle DOIT s'authentifier.
// Sans l'en-tête Bearer, un moteur protégé par API_KEY répond 401 et la sonde
// conclut « pas de vision » — le projecteur était bien chargé, les images ne
// partaient jamais et le modèle brodait à partir du seul chemin de fichier.
var (
visionProbeMu sync.Mutex
visionProbeAt time.Time
@@ -120,7 +126,12 @@ func engineSeesImages() bool {
visionProbeAt = time.Now()
visionProbeSeen = false
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get(fmt.Sprintf("http://localhost:%d/props", LLMPort()))
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://localhost:%d/props", LLMPort()), nil)
if err != nil {
return false
}
authHeader(req)
resp, err := client.Do(req)
if err != nil {
return false
}
@@ -232,7 +243,7 @@ func safeSlug(s string) string {
return s
}
func toolWebScreenshot(args map[string]any) string {
func toolWebScreenshot(args map[string]any, caps Caps) string {
bin := playwrightBin()
if bin == "" {
return "[erreur] Playwright n'est pas installé dans cette image (bâtie avec PLAYWRIGHT=0)."
@@ -245,6 +256,13 @@ func toolWebScreenshot(args map[string]any) string {
if !strings.HasPrefix(url, "http://") && !strings.HasPrefix(url, "https://") {
return "[erreur] url invalide : elle doit commencer par http:// ou https://"
}
// Accès internet coupé : la capture reste offerte au MODE CODE, mais bornée
// aux adresses locales — voir sa propre page de dev, pas le web. Sans cette
// borne, l'outil rouvrirait par la fenêtre ce que l'interrupteur ferme.
if !caps.Internet && !isLocalURL(url) {
return "[refusé] L'accès internet est coupé : la capture ne marche que sur une adresse locale " +
"(localhost / 127.0.0.1), par exemple ton serveur de dev. Active l'accès internet pour le web."
}
// width n'est plus déclaré dans le schéma (budget de préambule), mais reste
// honoré s'il arrive quand même : un modèle qui l'invente obtient le
// comportement attendu plutôt qu'un paramètre ignoré en silence.
@@ -387,3 +405,19 @@ func lastLines(s string, n int) string {
}
return strings.Join(keep, " · ")
}
// isLocalURL : l'adresse désigne-t-elle cette machine ? Sert de borne quand
// l'accès internet est coupé mais que le mode code doit pouvoir photographier
// son propre serveur de dev.
func isLocalURL(raw string) bool {
u, err := neturl.Parse(raw)
if err != nil {
return false
}
host := u.Hostname()
switch host {
case "localhost", "127.0.0.1", "::1", "0.0.0.0":
return true
}
return strings.HasSuffix(host, ".localhost")
}
+5
View File
@@ -232,6 +232,11 @@ func convDelete(id string) error {
}
convIndexSave(next)
_ = putBytes(bkChat, convKey(id), nil)
// Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec
// elle, et ses critères/mode/puce partent avec ses messages.
stopConvJobs(id)
critDrop(id)
dropConvMode(id)
// Les fichiers de cette discussion — dépôts, captures, ce que l'agent y a
// écrit — n'ont plus rien qui les référence : les garder occuperait le disque
// pour toujours, et plus aucun écran ne permettrait de les retrouver.
+42 -6
View File
@@ -44,6 +44,18 @@ func baseSystemPrompt(caps Caps) string {
// « Loki » avec une majuscule : c'est un nom propre, et le modèle recopie
// littéralement la casse d'ici quand il se présente (« je suis loki »).
b.WriteString("You are Loki, an expert assistant operating directly on this machine with real tools.")
// Mode code : le prompt du rôle (builder/planner/verifier…) prend la suite
// de l'identité. Court par construction (voir code_roles.go) — la règle
// « ne pas regonfler » vaut aussi pour lui.
if caps.Code {
role := caps.Role
if role == "" {
role = "builder"
}
if rp := rolePrompt(role); rp != "" {
b.WriteString("\n\n" + rp)
}
}
if caps.Mem == MemAlways {
b.WriteString(" You evolve with every conversation: you actively maintain a persistent memory so nothing useful is lost between sessions.")
}
@@ -149,7 +161,7 @@ func machineSystemPrompt(caps Caps) string {
if u, err := user.Current(); err == nil {
who = u.Username
}
cwd := convWorkspace()
cwd := agentCwd()
var b strings.Builder
b.WriteString(fmt.Sprintf("Machine: host=%s, %s/%s", host, runtime.GOOS, runtime.GOARCH))
@@ -161,7 +173,11 @@ func machineSystemPrompt(caps Caps) string {
}
b.WriteString(".")
if cwd != "" {
b.WriteString(" Relative paths in write/edit/bash resolve inside this working folder — put scratch files there. Write outside it ONLY with an absolute path the user explicitly asked for; never scatter files into the folder loki was launched from.")
// Formulation reprise de l'amont AJEAN (v0.10.2) : dire ce que le dossier
// EST (l'endroit par défaut de tout ce que le modèle produit) marche mieux
// que d'interdire d'en sortir — et nommer les dossiers système coupe court
// aux « installations » en /usr/local/bin qui échouent faute de root.
b.WriteString(" This is your working folder: relative paths in write/edit/bash resolve here, and it is the DEFAULT place for everything you create — scripts, notes, outputs, even a command-line tool you build. Just use a relative name. Do NOT install or write files into system directories such as /usr/local/bin, /usr, /bin or /etc: those need root and are not yours. Only use an absolute path outside this folder when the user explicitly named that location.")
}
return b.String()
}
@@ -195,7 +211,7 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
// such file or directory » incompréhensible, et ce jusqu'au redémarrage. On
// le recrée au besoin, et à défaut on démarre là où on peut plutôt que de
// tout refuser.
if ws := convWorkspace(); ws != "" {
if ws := agentCwd(); ws != "" {
if err := os.MkdirAll(ws, 0o755); err == nil {
cmd.Dir = ws
}
@@ -258,6 +274,10 @@ func fileWrite(path, content string) string {
return "[erreur] chemin vide"
}
path = resolveAgentPath(path)
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
mu := fileMu(path)
mu.Lock()
defer mu.Unlock()
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "[erreur] " + err.Error()
@@ -292,16 +312,28 @@ func fileEdit(path, oldText, newText string) string {
return "[erreur] old vide"
}
path = resolveAgentPath(path)
// Sérialise les écritures concurrentes sur un même fichier (code_policy.go).
mu := fileMu(path)
mu.Lock()
defer mu.Unlock()
b, err := os.ReadFile(path)
if err != nil {
return "[erreur] " + err.Error()
}
content := string(b)
n := strings.Count(content, oldText)
// Fichier en CRLF, modèle en LF : la recherche exacte échouait toujours.
// On compare alors en LF normalisé, et on réécrit dans le style du fichier
// (préservation des fins de ligne — repris des tests line-ending d'OpenFox).
crlf := strings.Contains(content, "\r\n")
search, oldN, newN := content, oldText, newText
if crlf && !strings.Contains(oldText, "\r\n") {
search = strings.ReplaceAll(content, "\r\n", "\n")
}
n := strings.Count(search, oldN)
if n == 0 {
// Modification déjà en place : on le dit clairement plutôt que de renvoyer
// une erreur, sinon le modèle croit avoir échoué et recommence.
if newText != "" && strings.Contains(content, newText) {
if newN != "" && strings.Contains(search, newN) {
return "[ok] déjà à jour — le fichier contient déjà cette modification"
}
return "[erreur] old introuvable dans le fichier"
@@ -309,7 +341,11 @@ func fileEdit(path, oldText, newText string) string {
if n > 1 {
return fmt.Sprintf("[erreur] old apparaît %d fois — ajoute du contexte pour le rendre unique", n)
}
updated := strings.Replace(content, oldText, newText, 1)
updated := strings.Replace(search, oldN, newN, 1)
if search != content {
// La comparaison s'est faite en LF : on remet le fichier en CRLF.
updated = strings.ReplaceAll(updated, "\n", "\r\n")
}
// Préserve les permissions d'origine (un script 0755 doit rester exécutable).
mode := os.FileMode(0o644)
if fi, err := os.Stat(path); err == nil {
+28 -2
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
)
// Dossier de travail du mode agent.
@@ -21,7 +22,8 @@ import (
// agentWorkspace est la RACINE, commune à tout : le dossier de travail effectif
// est celui de la discussion ouverte (convWorkspace, chat_convfiles.go), pour
// qu'un fichier appartienne à la discussion où il est né et disparaisse avec
// elle. La racine reste la borne de sécurité des téléchargements.
// elle — ou celui de la tâche planifiée en cours (agentCwd, plus bas). La racine
// reste la borne de sécurité des téléchargements.
const workspaceEnv = "LOKI_WORKSPACE"
@@ -73,6 +75,30 @@ func workspaceCandidates() []string {
return c
}
// Dossier de travail imposé pendant l'exécution d'une TÂCHE planifiée
// (tasks_run.go). Une tâche tourne hors de toute discussion : sans ça, ses
// fichiers atterriraient dans le dossier de la discussion qui se trouve ouverte
// — et changeraient de place en cours de route si l'utilisateur en changeait.
//
// La bascule est un état de processus et non de goroutine, ce que Go ne sait pas
// faire autrement ; c'est sûr ici parce qu'une SEULE génération tourne à la fois
// (le verrou de conv), et parce que seuls les points d'entrée des OUTILS lisent
// agentCwd(). Ce que voit l'utilisateur — panneau Fichiers, dépôts, liens des
// messages — continue de passer par convWorkspace() et reste donc sur sa
// discussion, même pendant qu'une tâche écrit ailleurs.
var taskWS atomic.Value // string
func setTaskWorkspace(dir string) { taskWS.Store(dir) }
// agentCwd = le dossier où l'agent travaille MAINTENANT : celui de la tâche en
// cours s'il y en a une, sinon celui de la discussion ouverte.
func agentCwd() string {
if d, _ := taskWS.Load().(string); d != "" {
return d
}
return convWorkspace()
}
// resolveAgentPath résout un chemin fourni par le modèle. Absolu → inchangé ;
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
// discussion ouverte.
@@ -89,5 +115,5 @@ func resolveAgentPath(p string) string {
if filepath.IsAbs(p) {
return p
}
return filepath.Join(convWorkspace(), filepath.FromSlash(p))
return filepath.Join(agentCwd(), filepath.FromSlash(p))
}
+205
View File
@@ -0,0 +1,205 @@
package loki
// code_criteria.go — critères d'acceptation du mode code (contrat d'exécution,
// repris du session-metadata/criteria d'OpenFox, réécrit en Go — voir
// NOTICE.md). Les critères sont posés au début d'une tâche (par le modèle via
// l'outil `criteria`, ou à la main dans l'UI), puis la passe de vérification
// (code_verify.go) les fait passer à `passed` ou `failed`. Le tour de build ne
// se termine « vraiment » que quand tout est passed — c'est le contrat.
//
// Rangés par discussion dans bbolt (bucket chat, clé crit:<id>) : ils suivent
// la discussion, sa suppression les emporte.
import (
"encoding/json"
"fmt"
"strings"
"time"
)
type Criterion struct {
ID int `json:"id"`
Text string `json:"text"`
Status string `json:"status"` // pending | passed | failed
Note string `json:"note,omitempty"`
}
const critMaxCount = 20
func critKey(convID string) string { return "crit:" + convID }
func critList(convID string) []Criterion {
var list []Criterion
if b := getBytes(bkChat, critKey(convID)); len(b) > 0 {
_ = json.Unmarshal(b, &list)
}
return list
}
func critSave(convID string, list []Criterion) {
if len(list) == 0 {
_ = putBytes(bkChat, critKey(convID), nil) // nil supprime la clé
return
}
b, err := json.Marshal(list)
if err != nil {
return
}
_ = putBytes(bkChat, critKey(convID), b)
}
func critDrop(convID string) { _ = putBytes(bkChat, critKey(convID), nil) }
// critAllPassed : le contrat est rempli. Une liste vide ne compte pas comme
// remplie — il n'y a simplement pas de contrat.
func critAllPassed(list []Criterion) bool {
if len(list) == 0 {
return false
}
for _, c := range list {
if c.Status != "passed" {
return false
}
}
return true
}
func critPending(list []Criterion) int {
n := 0
for _, c := range list {
if c.Status != "passed" {
n++
}
}
return n
}
// critRender : la liste formatée pour un prompt (builder ou verifier).
func critRender(list []Criterion) string {
var b strings.Builder
for _, c := range list {
mark := "[ ]"
switch c.Status {
case "passed":
mark = "[✓]"
case "failed":
mark = "[✗]"
}
fmt.Fprintf(&b, "%s #%d %s", mark, c.ID, c.Text)
if c.Note != "" {
fmt.Fprintf(&b, " — %s", c.Note)
}
b.WriteString("\n")
}
return strings.TrimRight(b.String(), "\n")
}
func criteriaTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "criteria",
Description: "Manage the acceptance criteria of the current task (the contract that defines DONE). " +
"action=add posits new criteria (texts[]), action=set updates one (id + status passed|failed|pending, optional note), " +
"action=list shows them, action=clear removes them all. Set criteria BEFORE building; only the verification pass may mark passed.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{"type": "string", "enum": []string{"add", "set", "list", "clear"}},
"texts": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "add: one entry per criterion, short and testable"},
"id": map[string]any{"type": "integer", "description": "set: criterion id"},
"status": map[string]any{"type": "string", "enum": []string{"pending", "passed", "failed"}},
"note": map[string]any{"type": "string", "description": "set: why it failed / how it was verified"},
},
"required": []string{"action"},
},
},
}
}
// toolCriteria exécute l'outil. allowPass : seul le passage de VÉRIFICATION a
// le droit de marquer `passed` (sinon le builder s'auto-valide et le contrat ne
// contraint plus rien).
func toolCriteria(args map[string]any, allowPass bool) string {
convID := convEnsureActive()
action, _ := args["action"].(string)
list := critList(convID)
switch action {
case "list", "":
if len(list) == 0 {
return "[aucun critère]"
}
return critRender(list)
case "add":
raw, _ := args["texts"].([]any)
next := 1
for _, c := range list {
if c.ID >= next {
next = c.ID + 1
}
}
added := 0
for _, r := range raw {
s, ok := r.(string)
if !ok || strings.TrimSpace(s) == "" {
continue
}
if len(list) >= critMaxCount {
break
}
list = append(list, Criterion{ID: next, Text: strings.TrimSpace(s), Status: "pending"})
next++
added++
}
if added == 0 {
return "[erreur] aucun critère fourni (texts)"
}
critSave(convID, list)
critNotify(list)
return fmt.Sprintf("[ok] %d critère(s) ajouté(s)\n%s", added, critRender(list))
case "set":
idf, _ := args["id"].(float64)
id := int(idf)
status, _ := args["status"].(string)
note, _ := args["note"].(string)
if status == "passed" && !allowPass {
return "[refusé] Seule la passe de vérification peut marquer un critère passed. Termine ton implémentation ; la vérification suivra."
}
for i := range list {
if list[i].ID != id {
continue
}
if status != "" {
list[i].Status = status
}
if note != "" {
list[i].Note = strings.TrimSpace(note)
}
critSave(convID, list)
critNotify(list)
return "[ok] critère #" + fmt.Sprint(id) + " → " + list[i].Status
}
return fmt.Sprintf("[erreur] critère #%d inconnu", id)
case "clear":
critDrop(convID)
critNotify(nil)
return "[ok] critères effacés"
}
return "[erreur] action inconnue : " + action
}
// critNotify pousse la liste à l'UI (panneau critères) via le journal de la
// conversation. Best-effort : hors génération il n'y a pas d'epoch en cours,
// on publie sur l'epoch courant.
func critNotify(list []Criterion) {
if conv == nil {
return
}
conv.mu.Lock()
epoch := conv.epoch
conv.mu.Unlock()
if list == nil {
list = []Criterion{}
}
conv.appendDelta(epoch, map[string]any{"criteria": list, "ts": time.Now().UnixMilli()})
}
+73
View File
@@ -0,0 +1,73 @@
package loki
import (
"strings"
"testing"
)
// Cycle de vie des critères : add → set → clear, avec la garde allowPass.
func TestCriteresCycleDeVie(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
out := toolCriteria(map[string]any{"action": "add", "texts": []any{"le test passe", "le build compile"}}, false)
if !strings.Contains(out, "2 critère(s)") {
t.Fatalf("ajout raté : %s", out)
}
list := critList(id)
if len(list) != 2 || list[0].ID != 1 || list[1].ID != 2 {
t.Fatalf("liste inattendue : %+v", list)
}
// Le builder (allowPass=false) ne peut PAS s'auto-valider.
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, false)
if !strings.Contains(out, "[refusé]") {
t.Fatalf("auto-validation acceptée : %s", out)
}
// Le vérificateur (allowPass=true), si.
out = toolCriteria(map[string]any{"action": "set", "id": float64(1), "status": "passed"}, true)
if !strings.Contains(out, "passed") || strings.Contains(out, "[refusé]") {
t.Fatalf("validation vérificateur refusée : %s", out)
}
// failed reste ouvert à tous (le builder peut constater un échec).
out = toolCriteria(map[string]any{"action": "set", "id": float64(2), "status": "failed", "note": "le build casse"}, false)
if !strings.Contains(out, "failed") {
t.Fatalf("échec non enregistré : %s", out)
}
list = critList(id)
if critAllPassed(list) {
t.Fatal("allPassed avec un failed")
}
if critPending(list) != 1 {
t.Fatalf("pending = %d, attendu 1", critPending(list))
}
// Rendu : les statuts sont visibles.
r := critRender(list)
if !strings.Contains(r, "[✓] #1") || !strings.Contains(r, "[✗] #2") || !strings.Contains(r, "le build casse") {
t.Fatalf("rendu incomplet :\n%s", r)
}
toolCriteria(map[string]any{"action": "clear"}, false)
if len(critList(id)) != 0 {
t.Fatal("clear sans effet")
}
}
// Une liste vide n'est pas un contrat rempli.
func TestContratVide(t *testing.T) {
if critAllPassed(nil) {
t.Fatal("liste vide considérée comme remplie")
}
}
// Le plafond de critères tient.
func TestCriteresPlafond(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
var texts []any
for i := 0; i < critMaxCount+10; i++ {
texts = append(texts, "critère")
}
toolCriteria(map[string]any{"action": "add", "texts": texts}, false)
if n := len(critList(id)); n != critMaxCount {
t.Fatalf("%d critères, plafond %d", n, critMaxCount)
}
}
+149
View File
@@ -0,0 +1,149 @@
package loki
// code_git.go — outils git natifs du mode code : git_status, git_diff,
// git_clone. Le modèle les avait via bash, mais des outils dédiés donnent une
// sortie bornée, un libellé propre dans le fil, et un clone qui atterrit
// TOUJOURS dans le dossier de la discussion (jamais ailleurs sur le disque).
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
const gitMaxOutput = 8000
func gitStatusTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "git_status",
Description: "git status of the working folder (branch + changed files, porcelain format).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{},
},
},
}
}
func gitDiffTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "git_diff",
Description: "git diff of the working folder (unstaged + staged). Optional file to diff a single path.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Limit the diff to this path"},
},
},
},
}
}
func gitCloneTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "git_clone",
Description: "Clone a git repository (https:// or git@) INTO the working folder of this discussion.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"url": map[string]any{"type": "string", "description": "Repository URL"},
"dir": map[string]any{"type": "string", "description": "Target folder name (default: repo name)"},
},
"required": []string{"url"},
},
},
}
}
// runGit exécute git dans le dossier de la discussion, sortie bornée.
func runGit(ctx context.Context, timeout time.Duration, args ...string) string {
if _, err := exec.LookPath("git"); err != nil {
return "[erreur] git n'est pas installé sur cette machine"
}
cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
cmd := exec.CommandContext(cctx, "git", args...)
cmd.Dir = agentCwd()
// Jamais d'invite interactive (mot de passe, hôte inconnu) : un process
// serveur n'a personne pour y répondre, il faut échouer vite et le dire.
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_SSH_COMMAND=ssh -oBatchMode=yes")
out, err := cmd.CombinedOutput()
s := tailRunes(strings.TrimSpace(string(out)), gitMaxOutput)
if cctx.Err() == context.DeadlineExceeded {
return "[timeout] git " + strings.Join(args, " ")
}
if err != nil {
if s == "" {
s = err.Error()
}
return "[erreur] " + s
}
if s == "" {
s = "(aucune sortie)"
}
return s
}
func toolGitStatus(ctx context.Context) string {
return runGit(ctx, 20*time.Second, "status", "--porcelain=v1", "--branch")
}
func toolGitDiff(ctx context.Context, args map[string]any) string {
// Un seul appel qui montre TOUT ce qui a changé : index + arbre de travail.
// HEAD peut ne pas exister (dépôt tout neuf) : on retombe sur le diff simple.
gitArgs := []string{"diff", "HEAD"}
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
gitArgs = append(gitArgs, "--", file)
}
out := runGit(ctx, 30*time.Second, gitArgs...)
if strings.HasPrefix(out, "[erreur]") && strings.Contains(out, "HEAD") {
fallback := []string{"diff"}
if file, _ := args["file"].(string); strings.TrimSpace(file) != "" {
fallback = append(fallback, "--", file)
}
out = runGit(ctx, 30*time.Second, fallback...)
}
return out
}
// gitURLRe : https:// ou git@hôte: — pas de chemins locaux ni de protocoles
// exotiques (file://, ext:: qui exécute une commande arbitraire).
var gitURLRe = regexp.MustCompile(`^(https://[\w.\-]+(:\d+)?/|git@[\w.\-]+:)[\w.\-~/]+$`)
func toolGitClone(ctx context.Context, args map[string]any) string {
url, _ := args["url"].(string)
url = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(url), ".git")) + ".git"
if !gitURLRe.MatchString(strings.TrimSuffix(url, ".git")) && !gitURLRe.MatchString(url) {
return "[erreur] URL refusée — seuls https:// et git@hôte: sont acceptés"
}
dir, _ := args["dir"].(string)
dir = strings.TrimSpace(dir)
if dir == "" {
base := filepath.Base(strings.TrimSuffix(url, ".git"))
dir = base
}
// Le clone atterrit DANS la discussion, quoi qu'il arrive.
if strings.Contains(dir, "..") || filepath.IsAbs(dir) {
return "[erreur] dossier cible invalide"
}
target := filepath.Join(agentCwd(), dir)
if _, err := os.Stat(target); err == nil {
return "[erreur] le dossier " + dir + " existe déjà"
}
out := runGit(ctx, 5*time.Minute, "clone", "--", url, target)
if strings.HasPrefix(out, "[erreur]") || strings.HasPrefix(out, "[timeout]") {
return out
}
return fmt.Sprintf("[ok] cloné dans %s/\n%s", dir, out)
}
+191
View File
@@ -0,0 +1,191 @@
package loki
// code_jobs.go — commandes shell d'ARRIÈRE-PLAN pour le mode code : bash_bg
// lance un process détaché (serveur de dev, watcher, build long) et rend la
// main tout de suite ; bash_tail relit sa sortie et son état. Sans ça, le
// modèle lance `./serveur &` dans bash et le tour reste suspendu aux tubes
// (voir le commentaire WaitDelay de runShell).
//
// La sortie va dans un fichier du dossier de la discussion
// (.loki/jobs/<id>.log) : elle survit au tour, et le panneau Fichiers la montre.
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type bgJob struct {
ID string
Command string
LogPath string
Cmd *exec.Cmd
Started time.Time
mu sync.Mutex
done bool
exit int
}
var (
jobsMu sync.Mutex
jobs = map[string]*bgJob{}
jobSeq int
)
func bashBgTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "bash_bg",
Description: "Start a shell command in the BACKGROUND (dev server, watcher, long build) and return immediately with a job id. Output goes to a log file; check it with bash_tail. Use this instead of appending '&' in bash.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string", "description": "The command"},
},
"required": []string{"command"},
},
},
}
}
func bashTailTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "bash_tail",
Description: "Read the last lines of a background job's output and its status (running/exited). kill:true stops the job.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "Job id from bash_bg"},
"lines": map[string]any{"type": "integer", "description": "Lines to show (default 40, max 200)"},
"kill": map[string]any{"type": "boolean", "description": "Stop the job"},
},
"required": []string{"id"},
},
},
}
}
// toolBashBg lance la commande détachée. Même politique que bash (commandes
// dangereuses refusées), même dossier de départ (discussion courante).
func toolBashBg(args map[string]any) string {
command, _ := args["command"].(string)
if strings.TrimSpace(command) == "" {
return "[erreur] commande vide"
}
if reason := dangerousCommand(command); reason != "" {
return refusedCommandResult(reason)
}
ws := agentCwd()
logDir := filepath.Join(ws, ".loki", "jobs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
return "[erreur] " + err.Error()
}
jobsMu.Lock()
jobSeq++
id := fmt.Sprintf("job%d", jobSeq)
jobsMu.Unlock()
logPath := filepath.Join(logDir, id+".log")
logFile, err := os.Create(logPath)
if err != nil {
return "[erreur] " + err.Error()
}
// Contexte de FOND, pas celui du tour : le job doit survivre à la fin du
// tour — c'est sa raison d'être. Il s'arrête par bash_tail kill:true, ou
// avec le conteneur.
cmd := newShellCmdDetached(command)
cmd.Dir = ws
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
logFile.Close()
return "[erreur] " + err.Error()
}
j := &bgJob{ID: id, Command: command, LogPath: logPath, Cmd: cmd, Started: time.Now()}
jobsMu.Lock()
jobs[id] = j
jobsMu.Unlock()
go func() {
err := cmd.Wait()
logFile.Close()
j.mu.Lock()
j.done = true
if ee, ok := err.(*exec.ExitError); ok {
j.exit = ee.ExitCode()
}
j.mu.Unlock()
}()
return fmt.Sprintf("[ok] job %s lancé (pid %d) — sortie dans .loki/jobs/%s.log, suis-la avec bash_tail", id, cmd.Process.Pid, id)
}
// toolBashTail relit la fin du journal d'un job et son état.
func toolBashTail(args map[string]any) string {
id, _ := args["id"].(string)
jobsMu.Lock()
j := jobs[id]
jobsMu.Unlock()
if j == nil {
return "[erreur] job inconnu : " + id
}
if kill, _ := args["kill"].(bool); kill {
j.mu.Lock()
running := !j.done
j.mu.Unlock()
if running && j.Cmd.Process != nil {
_ = j.Cmd.Process.Kill()
}
}
lines := 40
if v, ok := args["lines"].(float64); ok && int(v) > 0 {
lines = int(v)
}
if lines > 200 {
lines = 200
}
b, err := os.ReadFile(j.LogPath)
if err != nil {
return "[erreur] " + err.Error()
}
all := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(all) > lines {
all = all[len(all)-lines:]
}
j.mu.Lock()
status := "en cours"
if j.done {
status = fmt.Sprintf("terminé (exit %d)", j.exit)
}
j.mu.Unlock()
out := fmt.Sprintf("job %s — %s — depuis %s\ncommande : %s\n", j.ID, status, time.Since(j.Started).Round(time.Second), j.Command)
tail := strings.Join(all, "\n")
if strings.TrimSpace(tail) == "" {
tail = "(aucune sortie pour l'instant)"
}
return out + "\n" + tailRunes(tail, toolMaxOutput)
}
// stopConvJobs tue les jobs d'une discussion (suppression / vidage de la
// discussion : ses fichiers partent, ses process aussi).
func stopConvJobs(convID string) {
prefix := filepath.Join(agentWorkspace(), convFilesRoot, safeConvID(convID))
jobsMu.Lock()
defer jobsMu.Unlock()
for id, j := range jobs {
if strings.HasPrefix(j.LogPath, prefix) {
j.mu.Lock()
running := !j.done
j.mu.Unlock()
if running && j.Cmd.Process != nil {
_ = j.Cmd.Process.Kill()
}
delete(jobs, id)
}
}
}
+181
View File
@@ -0,0 +1,181 @@
package loki
// code_mode.go — le mode Chat/Code d'une discussion. Bascule EXPLICITE
// (sélecteur dans le pied du composeur), persistée par discussion ; en mode
// chat, une détection côté serveur peut SUGGÉRER la bascule (puce ignorable
// dans l'UI) mais ne l'impose jamais — un faux positif coûterait des tours de
// planification/vérification sur un simple bonjour.
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
func modeKey(convID string) string { return "mode:" + convID }
func hintKey(convID string) string { return "hinted:" + convID }
// convCodeMode : la discussion active est-elle en mode code ?
func convCodeMode() bool { return getStr(bkChat, modeKey(convEnsureActive())) == "code" }
func setConvMode(convID, mode string) error {
if mode != "code" {
mode = "" // chat = défaut = clé absente
}
return putStr(bkChat, modeKey(convID), mode)
}
// dropConvMode : appelé à la suppression d'une discussion.
func dropConvMode(convID string) {
_ = putStr(bkChat, modeKey(convID), "")
_ = putStr(bkChat, hintKey(convID), "")
}
// --- Détection « ça ressemble à du code » ------------------------------------
// Côté serveur, AUCUN token : ni consigne dans le prompt, ni appel modèle.
// Un indice suffit ; la puce de l'UI est gratuite à ignorer.
var codeHintRes = []*regexp.Regexp{
// Un bloc de code clôturé.
regexp.MustCompile("```"),
// Un chemin de fichier source.
regexp.MustCompile(`(?i)\b[\w./\\-]+\.(go|py|js|jsx|ts|tsx|rs|java|kt|c|h|cpp|hpp|cs|rb|php|swift|sh|ps1|sql|html|css|scss|vue|svelte|toml|yaml|yml|json|dockerfile|makefile)\b`),
// Verbes / vocabulaire de tâche de code.
regexp.MustCompile(`(?i)\b(corrige|implémente|implemente|refactor|refactorise|débogue|debug|compile|stack ?trace|segfault|exception|traceback|unit ?test|pull ?request|merge|commit|clone)\b`),
regexp.MustCompile(`(?i)\b(bug|erreur de compilation|tests? (échoue|cassé)|fonction|classe|module|api rest|endpoint)\b`),
}
// looksLikeCode dit si un message utilisateur ressemble à une tâche de code.
// Le dépôt git déjà cloné dans la discussion est aussi un indice fort.
func looksLikeCode(text string) bool {
if strings.TrimSpace(text) == "" {
return false
}
for _, re := range codeHintRes {
if re.MatchString(text) {
return true
}
}
return false
}
// convHasRepo : un dépôt git vit déjà dans le dossier de la discussion.
func convHasRepo() bool {
ws := agentCwd()
if _, err := os.Stat(filepath.Join(ws, ".git")); err == nil {
return true
}
entries, err := os.ReadDir(ws)
if err != nil {
return false
}
for _, e := range entries {
if !e.IsDir() {
continue
}
if _, err := os.Stat(filepath.Join(ws, e.Name(), ".git")); err == nil {
return true
}
}
return false
}
// maybeCodeHint émet la puce « passer en mode Code ? » si le message s'y
// prête. Une seule fois par discussion : une suggestion répétée est du bruit.
func maybeCodeHint(c *Conversation, epoch int, text string) {
if convCodeMode() || !agentEnabled() {
return
}
id := convEnsureActive()
if getBool(bkChat, hintKey(id)) {
return
}
if !looksLikeCode(text) && !convHasRepo() {
return
}
_ = putBool(bkChat, hintKey(id), true)
c.appendDelta(epoch, map[string]any{"code_hint": true})
}
// --- API ---------------------------------------------------------------------
// handleChatMode — GET : {mode, criteria} de la discussion active ;
// POST {"mode":"code"|"chat"} : bascule (et pousse le nouvel état aux abonnés).
func handleChatMode(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var body struct {
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if body.Mode != "code" && body.Mode != "chat" {
sendJSON(w, 400, map[string]any{"ok": false, "error": "mode inconnu (chat|code)"})
return
}
id := convEnsureActive()
if err := setConvMode(id, body.Mode); err != nil {
sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()})
return
}
// Pousse la bascule à tous les appareils abonnés (le sélecteur doit
// suivre, comme le fil lui-même).
conv.mu.Lock()
epoch := conv.epoch
conv.mu.Unlock()
conv.appendDelta(epoch, map[string]any{"mode": body.Mode})
sendJSON(w, 200, map[string]any{"ok": true, "mode": body.Mode})
return
}
mode := "chat"
if convCodeMode() {
mode = "code"
}
sendJSON(w, 200, map[string]any{
"ok": true,
"mode": mode,
"criteria": critList(convEnsureActive()),
})
}
// handleChatCriteria — POST : édition manuelle des critères depuis l'UI.
// {"criteria":[{"id":1,"text":"…","status":"pending"},…]} remplace la liste.
func handleChatCriteria(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
sendJSON(w, 405, map[string]any{"ok": false, "error": "POST attendu"})
return
}
var body struct {
Criteria []Criterion `json:"criteria"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
var clean []Criterion
next := 1
for _, c := range body.Criteria {
if strings.TrimSpace(c.Text) == "" || len(clean) >= critMaxCount {
continue
}
if c.ID <= 0 {
c.ID = next
}
if c.ID >= next {
next = c.ID + 1
}
switch c.Status {
case "pending", "passed", "failed":
default:
c.Status = "pending"
}
clean = append(clean, c)
}
critSave(convEnsureActive(), clean)
critNotify(clean)
sendJSON(w, 200, map[string]any{"ok": true, "criteria": clean})
}
+74
View File
@@ -0,0 +1,74 @@
package loki
import "testing"
// La bascule est persistée par discussion et retombe sur chat par défaut.
func TestModeParDiscussion(t *testing.T) {
withWorkspace(t)
id := convEnsureActive()
if convCodeMode() {
t.Fatal("mode code par défaut")
}
if err := setConvMode(id, "code"); err != nil {
t.Fatal(err)
}
if !convCodeMode() {
t.Fatal("bascule sans effet")
}
dropConvMode(id)
if convCodeMode() {
t.Fatal("mode code après nettoyage")
}
}
// La détection reconnaît une tâche de code, pas la conversation ordinaire.
func TestDetectionCode(t *testing.T) {
for _, s := range []string{
"corrige le bug dans main.go",
"j'ai une stack trace : Exception in thread",
"```python\nprint('x')\n```",
"implémente la fonction de tri",
"le build casse avec une erreur de compilation",
} {
if !looksLikeCode(s) {
t.Errorf("non détecté : %q", s)
}
}
for _, s := range []string{
"quelle est la capitale de l'Australie ?",
"raconte-moi une histoire",
"quel temps fait-il demain ?",
"",
} {
if looksLikeCode(s) {
t.Errorf("détecté à tort : %q", s)
}
}
}
// Les rôles embarqués existent tous, et le prompt du builder est court —
// la règle « ne pas regonfler » de baseSystemPrompt s'applique aussi ici.
func TestRolesEmbarques(t *testing.T) {
for _, r := range roleNames {
p := rolePrompt(r)
if p == "" {
t.Errorf("rôle %s : prompt vide", r)
}
if len(p) > 2500 {
t.Errorf("rôle %s : prompt trop long (%d octets)", r, len(p))
}
}
if rolePrompt("inexistant") != "" {
t.Error("rôle inconnu devrait rendre vide")
}
}
// wantsPlan : demande de plan reconnue, implémentation directe non.
func TestWantsPlan(t *testing.T) {
if !wantsPlan("Fais un plan avant de coder") {
t.Error("plan non reconnu")
}
if wantsPlan("corrige le bug maintenant") {
t.Error("plan détecté à tort")
}
}
+160
View File
@@ -0,0 +1,160 @@
package loki
// code_policy.go — politique d'exécution des outils de l'agent (repris dans
// l'esprit de tool-policy/path-security d'OpenFox, réécrit en Go — voir
// NOTICE.md).
//
// Deux gardes indépendantes :
// - dangerousCommand : liste courte de commandes CATASTROPHIQUES (effacement
// de disque, arrêt machine, fork bomb). Toujours active, mode chat compris :
// aucune de ces commandes n'a d'usage légitime lancée par un modèle. Le
// refus explique quoi faire (l'utilisateur la lance lui-même).
// - codePathAllowed : en MODE CODE, tous les chemins des outils fichiers sont
// bornés au dossier de la discussion — liens symboliques résolus des deux
// côtés, comme le panneau Fichiers (relWithin). Le mode chat garde le
// comportement historique (chemins absolus honorés) : c'est le mode code
// qui promet un bac à sable, pas l'agent généraliste.
import (
"os"
"path/filepath"
"regexp"
"strings"
"sync"
)
// dangerousPatterns : motifs (insensibles à la casse) de commandes refusées.
// Volontairement ÉTROIT : viser la perte de données irréversible et l'arrêt de
// la machine, pas la moindre écriture. Un motif trop large rend l'agent
// inutilisable et pousse à tout désactiver.
var dangerousPatterns = []struct {
re *regexp.Regexp
reason string
}{
// rm -rf / (ou ~, ou une racine de lecteur) — pas un rm -rf d'un sous-dossier.
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s*$`), "efface la racine du disque ou le home"},
{regexp.MustCompile(`(?i)\brm\s+(-[a-z]*r[a-z]*f[a-z]*|-[a-z]*f[a-z]*r[a-z]*)\s+("?/"?|~/?|/\*|[a-z]:\\?)\s`), "efface la racine du disque ou le home"},
// Écriture directe sur un périphérique bloc / formatage.
{regexp.MustCompile(`(?i)\bmkfs(\.[a-z0-9]+)?\b`), "formate un système de fichiers"},
{regexp.MustCompile(`(?i)\bdd\b[^\n]*\bof=/dev/`), "écrit directement sur un périphérique disque"},
{regexp.MustCompile(`(?i)>\s*/dev/sd[a-z]\b`), "écrit directement sur un périphérique disque"},
// Arrêt / redémarrage de la machine (ici : le conteneur, donc tout Loki).
// Ancré en position de COMMANDE (début, après ; && | ou sudo) : le mot
// « reboot » dans un grep ou un texte n'est pas une commande.
{regexp.MustCompile(`(?i)(^|[;&|]\s*|\bsudo\s+)(shutdown|poweroff|reboot|halt)\b`), "arrête ou redémarre la machine"},
// Fork bomb classique.
{regexp.MustCompile(`:\(\)\s*\{\s*:\|:`), "fork bomb"},
// chmod/chown récursif sur la racine.
{regexp.MustCompile(`(?i)\bch(mod|own)\b[^\n]*-[a-z]*R[a-z]*\s+[^\s]+\s+/\s*$`), "change récursivement les droits de la racine"},
// Windows : suppression récursive de la racine d'un lecteur, format.
{regexp.MustCompile(`(?i)\b(rd|rmdir)\s+/s\b[^\n]*\s[a-z]:\\?\s*$`), "efface la racine d'un lecteur"},
{regexp.MustCompile(`(?i)\bdel\s+(/[fsq]\s+)*[a-z]:\\\*`), "efface la racine d'un lecteur"},
{regexp.MustCompile(`(?i)\bformat\s+[a-z]:`), "formate un lecteur"},
}
// dangerousCommand renvoie la raison du refus si la commande correspond à un
// motif interdit, "" sinon.
func dangerousCommand(cmd string) string {
for _, p := range dangerousPatterns {
if p.re.MatchString(cmd) {
return p.reason
}
}
return ""
}
// refusedCommandResult formate le refus renvoyé au modèle : dire POURQUOI et
// QUOI FAIRE, sinon il réessaie en boucle avec des variantes.
func refusedCommandResult(reason string) string {
return "[refusé] Commande bloquée par la politique de sécurité : " + reason + ". " +
"Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal."
}
// codePathAllowed dit si un chemin (déjà résolu par resolveAgentPath) est DANS
// le dossier de la discussion courante. Utilisé par les outils fichiers en mode
// code uniquement.
func codePathAllowed(abs string) bool {
ws := agentCwd()
if ws == "" {
return false
}
abs = filepath.Clean(abs)
// Le fichier peut ne pas exister encore (write) : EvalSymlinks échouerait.
// On teste alors son ancêtre le plus proche existant — c'est LUI qui doit
// être dans le workspace, liens symboliques résolus.
probe := abs
for {
if _, err := os.Stat(probe); err == nil {
break
}
parent := filepath.Dir(probe)
if parent == probe {
break
}
probe = parent
}
if !sameOrWithin(ws, probe) {
return false
}
// L'ancêtre est dans le workspace ; le chemin FINAL ne doit pas en
// ressortir par des ".." lexicaux non encore résolus.
rel, err := filepath.Rel(ws, abs)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return false
}
return true
}
// sameOrWithin : p est root lui-même, ou dedans — liens symboliques résolus
// des deux côtés (relWithin refuse « . », d'où ce cousin qui l'accepte : la
// racine du workspace est un chemin légitime pour grep/glob).
func sameOrWithin(root, p string) bool {
if r, err := filepath.EvalSymlinks(root); err == nil {
root = r
}
if q, err := filepath.EvalSymlinks(p); err == nil {
p = q
}
rel, err := filepath.Rel(root, p)
if err != nil {
return false
}
return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
}
// refusedPathResult : refus de chemin hors du bac à sable du mode code.
func refusedPathResult(path string) string {
return "[refusé] " + path + " est hors du dossier de cette discussion. " +
"En mode code, tous les fichiers vivent dans le dossier de travail — utilise des chemins relatifs."
}
// codeWriteGuard regroupe les gardes du mode code AVANT une écriture locale
// (write/edit) : borne de chemin, puis tracker « lu avant d'écrire ».
// Renvoie "" si tout est bon, sinon le message de refus pour le modèle.
// En mode chat, aucune garde : comportement historique de l'agent.
func codeWriteGuard(caps Caps, file string, forWrite bool) string {
if !caps.Code {
return ""
}
path := resolveAgentPath(file)
if !codePathAllowed(path) {
return refusedPathResult(file)
}
return trackerCheck(path, forWrite)
}
// --- Mutex par fichier -------------------------------------------------------
// Sérialise les écritures concurrentes sur un même fichier (write/edit). Sans
// parallélisme d'outils aujourd'hui le coût est nul, mais le jour où deux tours
// ou un job d'arrière-plan écrivent le même fichier, la corruption est évitée.
var fileMuMap sync.Map // clé : chemin nettoyé → *sync.Mutex
func fileMu(path string) *sync.Mutex {
key := filepath.Clean(path)
if m, ok := fileMuMap.Load(key); ok {
return m.(*sync.Mutex)
}
m, _ := fileMuMap.LoadOrStore(key, &sync.Mutex{})
return m.(*sync.Mutex)
}
+101
View File
@@ -0,0 +1,101 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
)
// La politique doit bloquer les commandes catastrophiques…
func TestCommandesDangereusesBloquees(t *testing.T) {
for _, cmd := range []string{
"rm -rf /",
"rm -fr / --no-preserve-root",
"sudo rm -rf /*",
"mkfs.ext4 /dev/sda1",
"dd if=/dev/zero of=/dev/sda",
"echo pwned > /dev/sda",
"shutdown -h now",
"reboot",
":(){ :|:& };:",
"format c:",
"rd /s /q C:\\",
} {
if dangerousCommand(cmd) == "" {
t.Errorf("aurait dû être bloquée : %q", cmd)
}
}
}
// … et laisser passer le travail normal, y compris les rm ciblés.
func TestCommandesNormalesAutorisees(t *testing.T) {
for _, cmd := range []string{
"ls -la",
"go test ./...",
"rm -rf node_modules",
"rm -rf ./dist",
"git rm --cached fichier.txt",
"grep -r 'reboot the router' docs/",
"npm run build",
"dd if=disque.img of=copie.img",
"echo bonjour > notes.txt",
} {
if r := dangerousCommand(cmd); r != "" {
t.Errorf("bloquée à tort (%s) : %q", r, cmd)
}
}
}
// En mode code, les chemins sont bornés au dossier de la discussion :
// dedans OK, dehors refusé, y compris par remontée de ../.
func TestCodePathBorne(t *testing.T) {
ws := withWorkspace(t)
inside := filepath.Join(ws, "src", "main.go")
if !codePathAllowed(inside) {
t.Error("chemin intérieur refusé")
}
if !codePathAllowed(filepath.Join(ws, "nouveau.txt")) {
t.Error("fichier à créer (inexistant) refusé")
}
outside := filepath.Join(filepath.Dir(ws), "ailleurs.txt")
if codePathAllowed(outside) {
t.Error("chemin extérieur accepté")
}
if codePathAllowed(filepath.Join(ws, "..", "..", "etc", "passwd")) {
t.Error("remontée ../ acceptée")
}
}
// codeWriteGuard : inactif en mode chat, actif en mode code (borne + tracker).
func TestCodeWriteGuard(t *testing.T) {
ws := withWorkspace(t)
chat := Caps{Agent: true}
code := Caps{Agent: true, Code: true}
// Mode chat : aucune garde, même hors workspace.
if msg := codeWriteGuard(chat, "/tmp/hors-discussion.txt", true); msg != "" {
t.Errorf("mode chat gardé à tort : %s", msg)
}
// Mode code : hors workspace refusé.
if msg := codeWriteGuard(code, filepath.Join(filepath.Dir(ws), "x.txt"), true); msg == "" {
t.Error("écriture hors workspace acceptée en mode code")
}
// Fichier existant jamais lu : write refusé (tracker).
target := filepath.Join(ws, "present.txt")
if err := os.WriteFile(target, []byte("v1"), 0o644); err != nil {
t.Fatal(err)
}
if msg := codeWriteGuard(code, "present.txt", true); !strings.Contains(msg, "pas lu") {
t.Errorf("écrasement sans lecture accepté : %q", msg)
}
// Après lecture : autorisé.
trackerNoteRead(target)
if msg := codeWriteGuard(code, "present.txt", true); msg != "" {
t.Errorf("refusé après lecture : %s", msg)
}
// Création d'un fichier neuf : pas de lecture préalable exigée.
if msg := codeWriteGuard(code, "neuf.txt", true); msg != "" {
t.Errorf("création refusée : %s", msg)
}
}
+84
View File
@@ -0,0 +1,84 @@
package loki
// code_retry.go — filets « auto-retry » (repris des auto-retry patterns
// d'OpenFox, réécrits en Go — voir NOTICE.md) : quand le modèle termine son
// tour sur un texte qui contient un APPEL D'OUTIL TEXTUEL (halluciné, donc
// jamais exécuté), on relance le tour UNE fois avec une consigne corrective au
// lieu de laisser l'utilisateur lire un pseudo-JSON d'appel d'outil.
//
// Typique des petits modèles très quantifiés : `default_api:bash`,
// `<tool_call>{...}</tool_call>`, un bloc ```tool_code```… Le modèle VOULAIT
// agir ; il a juste raté la syntaxe du protocole. Le nudge « pensé sans agir »
// existant ne les attrapait pas : le tour a bien produit du contenu.
import (
"encoding/json"
"regexp"
"sync"
)
// defaultRetryPatterns : motifs d'appels d'outils textuels. Volontairement
// serrés — un faux positif relancerait un tour parfaitement bon.
var defaultRetryPatterns = []string{
`<tool_call>`,
`</tool_call>`,
`<\|tool_call\|>`,
"```tool_code",
"```tool_call",
`\bdefault_api[.:]\w+`,
`\bfunctions\.\w+\s*\(`,
`^\s*\{"name":\s*"(bash|read|grep|glob|edit|write|mem_\w+|web_\w+|git_\w+|criteria|ask)"`,
`\[TOOL_REQUEST\]`,
}
// Compilés à la première utilisation, pas à l'init du package : la surcharge
// vit dans la base bbolt, qu'on ne veut pas ouvrir avant que le process ait
// choisi son rôle (CLI, serveur…).
var (
retryOnce sync.Once
retryRes []*regexp.Regexp
)
func retryPatterns() []*regexp.Regexp {
retryOnce.Do(func() { retryRes = compileRetryPatterns() })
return retryRes
}
func compileRetryPatterns() []*regexp.Regexp {
pats := defaultRetryPatterns
// Surcharge optionnelle : clé d'état `retry_patterns` = tableau JSON de
// regex. Pas d'UI dédiée — c'est un réglage d'expert, posé via
// `loki config` ou l'API ; l'embarqué couvre les cas connus.
if raw := getStr(bkState, "retry_patterns"); raw != "" {
var custom []string
if json.Unmarshal([]byte(raw), &custom) == nil && len(custom) > 0 {
pats = custom
}
}
var out []*regexp.Regexp
for _, p := range pats {
if re, err := regexp.Compile("(?mi)" + p); err == nil {
out = append(out, re)
}
}
return out
}
// textualToolCall dit si le texte final du tour contient un appel d'outil
// écrit en toutes lettres au lieu d'être émis par le protocole.
func textualToolCall(content string) bool {
if content == "" {
return false
}
for _, re := range retryPatterns() {
if re.MatchString(content) {
return true
}
}
return false
}
// retryCorrective : le message réinjecté pour relancer le tour.
const retryCorrective = "Your last answer contained a TOOL CALL WRITTEN AS TEXT — it was never executed. " +
"Tool calls must go through the tool-call protocol, never in the answer text. " +
"Redo it now: emit the real tool call, or answer directly without pretending to call a tool."
+34
View File
@@ -0,0 +1,34 @@
package loki
import "testing"
// Les appels d'outils écrits en texte doivent être détectés…
func TestAppelOutilTextuelDetecte(t *testing.T) {
for _, s := range []string{
"<tool_call>{\"name\":\"bash\"}</tool_call>",
"Je vais lancer default_api:bash pour vérifier.",
"default_api.run_shell(command='ls')",
"functions.bash({\"command\": \"ls\"})",
"```tool_code\nprint('x')\n```",
"{\"name\": \"bash\", \"arguments\": {\"command\": \"ls\"}}",
} {
if !textualToolCall(s) {
t.Errorf("non détecté : %q", s)
}
}
}
// … sans jamais relancer un tour normal (code cité, JSON quelconque).
func TestReponseNormaleNonRelancee(t *testing.T) {
for _, s := range []string{
"Voici la réponse : le fichier contient trois fonctions.",
"```go\nfunc main() {}\n```",
"Le JSON de config : {\"name\": \"loki\", \"port\": 8090}",
"La fonction bash() de ce script accepte un paramètre.",
"",
} {
if textualToolCall(s) {
t.Errorf("relance à tort : %q", s)
}
}
}
+44
View File
@@ -0,0 +1,44 @@
package loki
// code_roles.go — registre des rôles du mode code. Chaque rôle est un prompt
// Markdown EMBARQUÉ dans le binaire (agents/*.md), comme le catalogue MCP :
// pas d'appel réseau, pour en changer on édite le fichier et on recompile.
// Démarche reprise du registre d'agents d'OpenFox (builder/planner/explorer/
// verifier/code-reviewer — voir NOTICE.md), prompts réécrits COURTS : un
// préambule verbeux fait sur-raisonner les modèles locaux (voir le commentaire
// de baseSystemPrompt).
import (
"embed"
"strings"
)
//go:embed agents/*.md
var rolesFS embed.FS
// roleNames : les rôles connus. builder et verifier sont pilotés par la boucle
// du mode code (code_verify.go) ; planner s'active quand l'utilisateur demande
// un plan ; explorer et code-reviewer sont disponibles pour des passes
// manuelles futures.
var roleNames = []string{"builder", "planner", "explorer", "verifier", "code-reviewer"}
// rolePrompt renvoie le prompt d'un rôle, "" si inconnu.
func rolePrompt(name string) string {
b, err := rolesFS.ReadFile("agents/" + name + ".md")
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
// planIntentRe-like : l'utilisateur demande-t-il un PLAN plutôt qu'une
// implémentation ? Heuristique simple sur le début du message.
func wantsPlan(text string) bool {
low := strings.ToLower(text)
for _, kw := range []string{"fais un plan", "fait un plan", "propose un plan", "planifie", "plan d'implémentation", "plan d'action", "make a plan", "plan first"} {
if strings.Contains(low, kw) {
return true
}
}
return false
}
+373
View File
@@ -0,0 +1,373 @@
package loki
// code_tools.go — outils fichiers du MODE CODE : read, grep, glob, ask.
// Inspirés des outils d'OpenFox (read/grep/ask — voir NOTICE.md), réécrits en
// Go. Sans eux, le modèle lit les fichiers au `cat` via bash : sortie non
// bornée, pas de numéros de ligne, et aucun moyen de savoir ce qu'il a lu
// (voir code_tracker.go).
import (
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"unicode/utf8"
)
const (
readMaxLines = 1000 // lignes par appel (offset/limit pour la suite)
readDefLines = 400
readMaxLineLen = 500 // une ligne minifiée ne doit pas manger le contexte
grepMaxHits = 60
globMaxHits = 200
)
// skipDirs : dossiers ignorés par grep/glob — dépendances et artefacts, jamais
// du code à lire. `.git` compris : son contenu binaire pollue toute recherche.
var skipDirs = map[string]bool{
".git": true, "node_modules": true, "vendor": true, "dist": true,
"build": true, "target": true, "__pycache__": true, ".venv": true,
"venv": true, ".next": true, ".cache": true,
}
func readTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "read",
Description: "Read a file with 1-indexed line numbers (offset/limit for long files). ALWAYS use this instead of cat/type: output is bounded and reading a file is REQUIRED before you may edit or overwrite it.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Path (relative to the working folder)"},
"offset": map[string]any{"type": "integer", "description": "Start line (default 1)"},
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Lines (default %d, max %d)", readDefLines, readMaxLines)},
},
"required": []string{"file"},
},
},
}
}
func grepTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "grep",
Description: "Search file contents with a Go regular expression. Returns path:line: text matches (bounded). path may be a file or a directory (default: whole working folder).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{"type": "string", "description": "Go regexp"},
"path": map[string]any{"type": "string", "description": "File or directory to search (default .)"},
"glob": map[string]any{"type": "string", "description": "Only files matching this glob (e.g. *.go)"},
},
"required": []string{"pattern"},
},
},
}
}
func globTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "glob",
Description: "List files matching a glob pattern (** supported, e.g. **/*.go), most recently modified first.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"pattern": map[string]any{"type": "string", "description": "Glob, e.g. src/**/*.ts"},
},
"required": []string{"pattern"},
},
},
}
}
func askTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "ask",
Description: "Ask the user a clarifying question with 2-4 short options, then END your turn. Their answer arrives as the next user message. Use when a decision is genuinely theirs (scope, destructive change, ambiguous requirement) — not for things you can check yourself.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"question": map[string]any{"type": "string", "description": "The question"},
"options": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "description": "2-4 short answer options"},
},
"required": []string{"question"},
},
},
}
}
// toolRead lit un fichier avec numéros de ligne. Enregistre la lecture dans le
// tracker (code_tracker.go) — c'est ce qui autorise edit/write ensuite.
func toolRead(args map[string]any, codeMode bool) string {
file, _ := args["file"].(string)
if strings.TrimSpace(file) == "" {
return "[erreur] chemin vide"
}
path := resolveAgentPath(file)
if codeMode && !codePathAllowed(path) {
return refusedPathResult(file)
}
offset, limit := 1, readDefLines
if v, ok := args["offset"].(float64); ok && int(v) > 0 {
offset = int(v)
}
if v, ok := args["limit"].(float64); ok && int(v) > 0 {
limit = int(v)
}
if limit > readMaxLines {
limit = readMaxLines
}
b, err := os.ReadFile(path)
if err != nil {
return "[erreur] " + err.Error()
}
if isBinary(b) {
fi, _ := os.Stat(path)
size := int64(len(b))
if fi != nil {
size = fi.Size()
}
return fmt.Sprintf("[binaire] %s (%d octets) — pas un fichier texte", file, size)
}
lines := strings.Split(string(b), "\n")
total := len(lines)
// Fichier fini par \n : Split laisse une dernière entrée vide, pas une ligne.
if total > 0 && lines[total-1] == "" {
total--
}
if offset > total {
return fmt.Sprintf("[erreur] offset %d > %d lignes", offset, total)
}
end := offset - 1 + limit
if end > total {
end = total
}
var out strings.Builder
for i := offset - 1; i < end; i++ {
l := lines[i]
if utf8.RuneCountInString(l) > readMaxLineLen {
r := []rune(l)
l = string(r[:readMaxLineLen]) + "…"
}
fmt.Fprintf(&out, "%d: %s\n", i+1, l)
}
if end < total {
fmt.Fprintf(&out, "… (%d lignes restantes — relis avec offset=%d)\n", total-end, end+1)
}
trackerNoteRead(path)
return strings.TrimRight(out.String(), "\n")
}
// toolGrep cherche un motif regexp dans les fichiers.
func toolGrep(args map[string]any, codeMode bool) string {
pattern, _ := args["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return "[erreur] motif vide"
}
re, err := regexp.Compile(pattern)
if err != nil {
return "[erreur] motif invalide : " + err.Error()
}
root := agentCwd()
if p, _ := args["path"].(string); strings.TrimSpace(p) != "" && p != "." {
root = resolveAgentPath(p)
}
if codeMode && !codePathAllowed(root) {
return refusedPathResult(root)
}
globPat, _ := args["glob"].(string)
var hits []string
walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // dossier illisible : on continue ailleurs
}
if d.IsDir() {
if skipDirs[d.Name()] {
return filepath.SkipDir
}
return nil
}
if globPat != "" {
if ok, _ := filepath.Match(globPat, d.Name()); !ok {
return nil
}
}
if len(hits) >= grepMaxHits {
return filepath.SkipAll
}
b, rerr := os.ReadFile(path)
if rerr != nil || isBinary(b) {
return nil
}
rel, rrr := filepath.Rel(root, path)
if rrr != nil {
rel = path
}
for i, line := range strings.Split(string(b), "\n") {
if re.MatchString(line) {
if utf8.RuneCountInString(line) > readMaxLineLen {
r := []rune(line)
line = string(r[:readMaxLineLen]) + "…"
}
hits = append(hits, fmt.Sprintf("%s:%d: %s", filepath.ToSlash(rel), i+1, strings.TrimSpace(line)))
if len(hits) >= grepMaxHits {
return filepath.SkipAll
}
}
}
return nil
})
if walkErr != nil && len(hits) == 0 {
return "[erreur] " + walkErr.Error()
}
if len(hits) == 0 {
return "[aucun résultat]"
}
out := strings.Join(hits, "\n")
if len(hits) >= grepMaxHits {
out += fmt.Sprintf("\n… (plafond de %d résultats atteint — affine le motif)", grepMaxHits)
}
return out
}
// toolGlob liste les fichiers correspondant à un motif, plus récents d'abord.
func toolGlob(args map[string]any, codeMode bool) string {
pattern, _ := args["pattern"].(string)
if strings.TrimSpace(pattern) == "" {
return "[erreur] motif vide"
}
root := agentCwd()
re, err := globToRegexp(pattern)
if err != nil {
return "[erreur] motif invalide : " + err.Error()
}
type hit struct {
rel string
mod int64
}
var hits []hit
filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error {
if werr != nil {
return nil
}
if d.IsDir() {
if skipDirs[d.Name()] {
return filepath.SkipDir
}
return nil
}
rel, rerr := filepath.Rel(root, path)
if rerr != nil {
return nil
}
rel = filepath.ToSlash(rel)
if !re.MatchString(rel) {
return nil
}
info, ierr := d.Info()
mod := int64(0)
if ierr == nil {
mod = info.ModTime().UnixNano()
}
hits = append(hits, hit{rel, mod})
return nil
})
if len(hits) == 0 {
return "[aucun fichier]"
}
sort.Slice(hits, func(i, j int) bool { return hits[i].mod > hits[j].mod })
if len(hits) > globMaxHits {
hits = hits[:globMaxHits]
}
var out strings.Builder
for _, h := range hits {
out.WriteString(h.rel + "\n")
}
return strings.TrimRight(out.String(), "\n")
}
// globToRegexp convertit un glob (avec **) en expression régulière sur des
// chemins en séparateurs '/'. `**` = n'importe quelle profondeur, `*` = tout
// sauf '/', `?` = un caractère sauf '/'.
func globToRegexp(pattern string) (*regexp.Regexp, error) {
pattern = strings.TrimPrefix(filepath.ToSlash(pattern), "./")
var b strings.Builder
b.WriteString("^")
i := 0
for i < len(pattern) {
c := pattern[i]
switch c {
case '*':
if strings.HasPrefix(pattern[i:], "**/") {
b.WriteString(`(?:[^/]+/)*`)
i += 3
continue
}
if strings.HasPrefix(pattern[i:], "**") {
b.WriteString(`.*`)
i += 2
continue
}
b.WriteString(`[^/]*`)
case '?':
b.WriteString(`[^/]`)
case '.', '+', '(', ')', '|', '[', ']', '{', '}', '^', '$', '\\':
b.WriteString(`\` + string(c))
default:
b.WriteByte(c)
}
i++
}
b.WriteString("$")
return regexp.Compile(b.String())
}
// isBinary : heuristique simple — un NUL dans les premiers 8 Kio.
func isBinary(b []byte) bool {
n := len(b)
if n > 8192 {
n = 8192
}
for i := 0; i < n; i++ {
if b[i] == 0 {
return true
}
}
return false
}
// toolAsk formate la « réponse » de l'outil ask : la question part vers l'UI
// via un événement dédié (voir runChat) ; ici on dit au modèle de clore son
// tour et d'attendre.
func toolAsk(args map[string]any) string {
q, _ := args["question"].(string)
if strings.TrimSpace(q) == "" {
return "[erreur] question vide"
}
return "[question posée à l'utilisateur] Termine ton tour MAINTENANT sans appeler d'autre outil : sa réponse arrivera comme prochain message."
}
// askOptions extrait les options (chaînes) des arguments de l'outil ask.
func askOptions(args map[string]any) []string {
raw, _ := args["options"].([]any)
var opts []string
for _, o := range raw {
if s, ok := o.(string); ok && strings.TrimSpace(s) != "" {
opts = append(opts, s)
}
}
if len(opts) > 4 {
opts = opts[:4]
}
return opts
}
+159
View File
@@ -0,0 +1,159 @@
package loki
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// read numérote depuis 1, respecte offset/limit et annonce le reste.
func TestReadOffsetLimit(t *testing.T) {
ws := withWorkspace(t)
var lines []string
for i := 1; i <= 10; i++ {
lines = append(lines, "ligne")
}
if err := os.WriteFile(filepath.Join(ws, "f.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil {
t.Fatal(err)
}
out := toolRead(map[string]any{"file": "f.txt", "offset": float64(3), "limit": float64(2)}, true)
if !strings.Contains(out, "3: ligne") || !strings.Contains(out, "4: ligne") {
t.Fatalf("lignes 3-4 attendues :\n%s", out)
}
if strings.Contains(out, "5: ligne") {
t.Fatalf("limite ignorée :\n%s", out)
}
if !strings.Contains(out, "restantes") {
t.Fatalf("suite non annoncée :\n%s", out)
}
}
// read refuse un binaire et le dit, au lieu de déverser du bruit.
func TestReadBinaire(t *testing.T) {
ws := withWorkspace(t)
if err := os.WriteFile(filepath.Join(ws, "bin.dat"), []byte{0x00, 0x01, 0x02}, 0o644); err != nil {
t.Fatal(err)
}
if out := toolRead(map[string]any{"file": "bin.dat"}, true); !strings.Contains(out, "[binaire]") {
t.Fatalf("binaire non détecté : %s", out)
}
}
// read enregistre la lecture : le tracker doit ensuite autoriser l'écriture.
func TestReadNourritLeTracker(t *testing.T) {
ws := withWorkspace(t)
path := filepath.Join(ws, "suivi.txt")
if err := os.WriteFile(path, []byte("contenu"), 0o644); err != nil {
t.Fatal(err)
}
if msg := trackerCheck(path, true); msg == "" {
t.Fatal("écriture acceptée avant lecture")
}
toolRead(map[string]any{"file": "suivi.txt"}, true)
if msg := trackerCheck(path, true); msg != "" {
t.Fatalf("écriture refusée après lecture : %s", msg)
}
// Fichier modifié APRÈS la lecture : refus (lecture périmée).
future := time.Now().Add(2 * time.Second)
if err := os.Chtimes(path, future, future); err != nil {
t.Fatal(err)
}
if msg := trackerCheck(path, true); !strings.Contains(msg, "changé") {
t.Fatalf("lecture périmée non détectée : %q", msg)
}
}
// grep trouve, borne et ignore les dossiers de dépendances.
func TestGrep(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "a.go"), "package main\nfunc Cible() {}\n")
writeFile(t, filepath.Join(ws, "node_modules", "x.js"), "Cible aussi\n")
out := toolGrep(map[string]any{"pattern": "Cible"}, true)
if !strings.Contains(out, "a.go:2") {
t.Fatalf("correspondance manquée :\n%s", out)
}
if strings.Contains(out, "node_modules") {
t.Fatalf("node_modules aurait dû être ignoré :\n%s", out)
}
if out := toolGrep(map[string]any{"pattern": "IntrouvableXYZ"}, true); !strings.Contains(out, "aucun résultat") {
t.Fatalf("absence non signalée : %s", out)
}
}
// glob : ** traverse les dossiers, tri par modification décroissante.
func TestGlob(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "src", "a.ts"), "a")
writeFile(t, filepath.Join(ws, "src", "sub", "b.ts"), "b")
writeFile(t, filepath.Join(ws, "autre.txt"), "c")
out := toolGlob(map[string]any{"pattern": "**/*.ts"}, true)
if !strings.Contains(out, "src/a.ts") || !strings.Contains(out, "src/sub/b.ts") {
t.Fatalf("fichiers .ts manquants :\n%s", out)
}
if strings.Contains(out, "autre.txt") {
t.Fatalf("autre.txt ne matche pas **/*.ts :\n%s", out)
}
}
// globToRegexp : cas aux limites du **.
func TestGlobToRegexp(t *testing.T) {
cases := []struct {
pattern, path string
want bool
}{
{"*.go", "main.go", true},
{"*.go", "src/main.go", false},
{"**/*.go", "src/deep/main.go", true},
{"**/*.go", "main.go", true}, // **/ = zéro dossier ou plus
{"src/**", "src/a/b/c.txt", true},
{"src/*.ts", "src/a.ts", true},
{"src/*.ts", "src/sub/a.ts", false},
}
for _, c := range cases {
re, err := globToRegexp(c.pattern)
if err != nil {
t.Fatalf("%s : %v", c.pattern, err)
}
if got := re.MatchString(c.path); got != c.want {
t.Errorf("%s sur %s : %v, attendu %v", c.pattern, c.path, got, c.want)
}
}
}
// fileEdit préserve les fins de ligne CRLF d'un fichier quand le modèle
// envoie du LF (et n'introduit jamais de mélange).
func TestEditPreserveCRLF(t *testing.T) {
ws := withWorkspace(t)
path := filepath.Join(ws, "w.txt")
if err := os.WriteFile(path, []byte("un\r\ndeux\r\ntrois\r\n"), 0o644); err != nil {
t.Fatal(err)
}
out := fileEdit(path, "deux", "DEUX")
if !strings.HasPrefix(out, "[ok]") {
t.Fatalf("édition refusée : %s", out)
}
b, _ := os.ReadFile(path)
if string(b) != "un\r\nDEUX\r\ntrois\r\n" {
t.Fatalf("fins de ligne perdues : %q", b)
}
}
// fileEdit multi-lignes en LF sur un fichier CRLF : la normalisation couvre
// aussi le old qui contient des sauts de ligne.
func TestEditCRLFMultiligne(t *testing.T) {
ws := withWorkspace(t)
path := filepath.Join(ws, "m.txt")
if err := os.WriteFile(path, []byte("a\r\nb\r\nc\r\n"), 0o644); err != nil {
t.Fatal(err)
}
out := fileEdit(path, "a\nb", "a\nB")
if !strings.HasPrefix(out, "[ok]") {
t.Fatalf("édition refusée : %s", out)
}
b, _ := os.ReadFile(path)
if string(b) != "a\r\nB\r\nc\r\n" {
t.Fatalf("résultat inattendu : %q", b)
}
}
+67
View File
@@ -0,0 +1,67 @@
package loki
// code_tracker.go — suivi « lu avant d'écrire » (file-tracker d'OpenFox, réécrit
// en Go — voir NOTICE.md). Le modèle doit avoir LU un fichier (outil read) avant
// de le modifier (edit) ou de l'écraser (write sur un fichier existant), et sa
// lecture doit être plus récente que la dernière modification du fichier sur le
// disque. Ça bloque les deux corruptions silencieuses classiques :
// - réécrire un fichier jamais ouvert (le modèle « devine » son contenu) ;
// - éditer d'après une lecture périmée (le fichier a changé entre-temps —
// autre outil, job d'arrière-plan, utilisateur).
//
// L'état est en mémoire, par discussion : un redémarrage oublie tout, et le
// modèle relit — c'est le comportement voulu, pas un bug.
import (
"os"
"path/filepath"
"sync"
"time"
)
var (
trackerMu sync.Mutex
trackerReads = map[string]time.Time{} // conv + chemin → instant de la lecture
)
func trackerKey(path string) string {
return convEnsureActive() + "\x00" + filepath.Clean(path)
}
// trackerNoteRead enregistre qu'un fichier vient d'être lu (outil read).
func trackerNoteRead(path string) {
trackerMu.Lock()
trackerReads[trackerKey(path)] = time.Now()
trackerMu.Unlock()
}
// trackerNoteWrite : après une écriture réussie, la version sur disque est celle
// que le modèle vient de produire — il la « connaît ». Sans cette note, éditer
// un fichier qu'on vient soi-même d'écrire serait refusé.
func trackerNoteWrite(path string) {
trackerNoteRead(path)
}
// trackerCheck vérifie qu'une modification est sûre. forWrite distingue write
// (autorisé sur un fichier INEXISTANT sans lecture préalable — création) de
// edit (le fichier doit exister ET avoir été lu).
// Renvoie "" si c'est bon, sinon le message de refus pour le modèle.
func trackerCheck(path string, forWrite bool) string {
fi, statErr := os.Stat(path)
if statErr != nil {
if forWrite {
return "" // création d'un fichier neuf : rien à relire
}
return "" // edit sur fichier absent : fileEdit renverra sa propre erreur claire
}
trackerMu.Lock()
readAt, seen := trackerReads[trackerKey(path)]
trackerMu.Unlock()
if !seen {
return "[refusé] " + path + " existe mais tu ne l'as pas lu dans cette discussion. Lis-le d'abord (outil read), puis modifie-le."
}
if fi.ModTime().After(readAt) {
return "[refusé] " + path + " a changé sur le disque depuis ta lecture. Relis-le (outil read) avant de le modifier."
}
return ""
}
+195
View File
@@ -0,0 +1,195 @@
package loki
// code_verify.go — la boucle du contrat en mode code : build → VÉRIFICATION →
// correction → re-vérification, jusqu'à ce que tous les critères passent ou
// que le plafond de cycles soit atteint. Reprise de la boucle
// builder/verifier d'OpenFox (voir NOTICE.md), adaptée au fil unique de Loki :
//
// - la passe de vérification tourne sur un CONTEXTE ISOLÉ (prompt du rôle +
// critères + tâche), pas sur l'historique complet — même modèle, mais il
// n'a pas « écrit » le code sous les yeux, et surtout ça ne gonfle pas le
// contexte de la discussion ;
// - seule cette passe a le droit de marquer un critère `passed`
// (toolCriteria, allowPass) ;
// - les corrections, elles, repartent dans l'historique NORMAL du fil : le
// builder doit se souvenir de ce qu'il a fait.
import (
"context"
"fmt"
"strings"
)
// codeMaxFixTurns : nombre de tours de CORRECTION après échec de vérification.
// Au-delà, on s'arrête et on dit ce qui manque — un modèle qui n'y arrive pas
// en 2 corrections tourne en rond, l'utilisateur doit reprendre la main.
const codeMaxFixTurns = 2
// codeVerifyLoop est appelé par generate() à la FIN d'un tour de build en mode
// code. Il ne fait rien s'il n'y a pas de contrat (aucun critère).
func (c *Conversation) codeVerifyLoop(ctx context.Context, caps Caps, temperature float64, epoch int) {
if !caps.Code || caps.Role == "verifier" || ctx.Err() != nil {
return
}
convID := convEnsureActive()
list := critList(convID)
if len(list) == 0 || critAllPassed(list) {
return
}
// En phase de PLAN, on ne vérifie pas : rien n'a été construit.
if caps.Role == "planner" {
return
}
for fix := 0; ; fix++ {
if ctx.Err() != nil {
return
}
c.runVerifyPass(ctx, caps, temperature, epoch)
list = critList(convID)
if critAllPassed(list) {
c.appendDelta(epoch, map[string]any{"verify_done": "passed"})
return
}
if fix >= codeMaxFixTurns {
c.appendDelta(epoch, map[string]any{"verify_done": "gave_up"})
// Le fil doit dire où on en est — sinon l'utilisateur voit une
// vérification échouée et aucun mot dessus.
c.appendDelta(epoch, map[string]any{"content": "\n\n_(vérification : " +
fmt.Sprint(critPending(list)) + " critère(s) non satisfaits après " +
fmt.Sprint(codeMaxFixTurns) + " corrections — voir le panneau des critères)_"})
return
}
c.runFixTurn(ctx, caps, temperature, epoch, list)
}
}
// runVerifyPass lance la passe de vérification sur un contexte ISOLÉ.
func (c *Conversation) runVerifyPass(ctx context.Context, caps Caps, temperature float64, epoch int) {
vcaps := caps
vcaps.Role = "verifier"
// Marqueur de rôle pour l'UI : les bulles qui suivent portent le badge.
c.appendDelta(epoch, map[string]any{"role": "verifier"})
defer c.appendDelta(epoch, map[string]any{"role": ""})
convID := convEnsureActive()
list := critList(convID)
task := c.lastUserText()
var user strings.Builder
user.WriteString("Task:\n" + task + "\n\nAcceptance criteria:\n" + critRender(list))
user.WriteString("\n\nVerify each non-passed criterion now (git_diff first), and record every verdict with the criteria tool.")
msgs := []Message{
{Role: "system", Content: rolePrompt("verifier") + "\n\n" + machineSystemPrompt(vcaps)},
{Role: "user", Content: user.String()},
}
// Trace ISOLÉE : ni persistée dans c.Messages, ni compactée — elle vit et
// meurt dans cette passe. Seuls les deltas d'affichage sortent.
_, _ = runChat(ctx, msgs, temperature, vcaps, func(ev StreamEvent) bool {
c.forwardStream(ev, epoch)
return true
})
}
// runFixTurn relance le BUILDER sur l'historique normal avec la liste des
// critères en échec. Sa trace est persistée comme un tour ordinaire.
func (c *Conversation) runFixTurn(ctx context.Context, caps Caps, temperature float64, epoch int, list []Criterion) {
var failed []Criterion
for _, cr := range list {
if cr.Status != "passed" {
failed = append(failed, cr)
}
}
c.appendDelta(epoch, map[string]any{"role": "builder"})
defer c.appendDelta(epoch, map[string]any{"role": ""})
fixMsg := Message{Role: "user", Content: "Verification failed on these criteria:\n" + critRender(failed) +
"\n\nFix the code so they pass. Address the notes precisely; do not touch what already passed."}
c.mu.Lock()
if c.epoch != epoch {
c.mu.Unlock()
return
}
c.Messages = append(c.Messages, fixMsg)
msgs := append([]Message(nil), c.Messages...)
c.mu.Unlock()
final := msgs
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
}
var content strings.Builder
extra, _ := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
c.forwardStream(ev, epoch)
return true
})
c.mu.Lock()
if c.epoch == epoch {
c.Messages = append(c.Messages, extra...)
if s := content.String(); strings.TrimSpace(s) != "" {
c.Messages = append(c.Messages, Message{Role: "assistant", Content: s})
}
}
c.mu.Unlock()
c.persist()
}
// forwardStream relaie les événements d'un runChat secondaire (vérification,
// correction) vers le journal d'affichage — même mapping que generate(), sans
// la gestion de compaction (ces passes n'en déclenchent pas : contexte court).
func (c *Conversation) forwardStream(ev StreamEvent, epoch int) {
switch {
case ev.Err != nil:
c.appendDelta(epoch, map[string]any{"error": ev.Err.Error()})
case ev.ToolUsed != nil:
tu := map[string]any{
"name": ev.ToolUsed.Name, "label": ev.ToolUsed.Label,
"result": ev.ToolUsed.Result, "done": ev.ToolUsed.Done, "typing": ev.ToolUsed.Typing,
}
if ev.ToolUsed.Body != "" {
tu["body"] = ev.ToolUsed.Body
}
if len(ev.ToolUsed.Diff) > 0 {
tu["diff"] = ev.ToolUsed.Diff
}
if ev.ToolUsed.Image != "" {
tu["image"] = ev.ToolUsed.Image
}
c.appendDelta(epoch, map[string]any{"tool_used": tu})
case ev.Ask != nil:
c.appendDelta(epoch, map[string]any{"ask": ev.Ask})
case ev.Stats != nil:
if ev.Stats.PromptTokensTotal > 0 {
c.mu.Lock()
if c.epoch == epoch {
c.CtxUsed = ev.Stats.PromptTokensTotal + ev.Stats.GenTokens
}
c.mu.Unlock()
}
c.appendDelta(epoch, map[string]any{"stats": ev.Stats})
case ev.DropReasoning:
c.appendDelta(epoch, map[string]any{"drop_reasoning": true})
case ev.Reasoning != "":
c.appendDelta(epoch, map[string]any{"reasoning_content": ev.Reasoning})
case ev.Content != "":
c.appendDelta(epoch, map[string]any{"content": ev.Content})
}
}
// lastUserText retrouve le dernier message utilisateur du fil (la tâche).
func (c *Conversation) lastUserText() string {
c.mu.Lock()
defer c.mu.Unlock()
for i := len(c.Messages) - 1; i >= 0; i-- {
if c.Messages[i].Role != "user" {
continue
}
if s, ok := c.Messages[i].Content.(string); ok {
return s
}
}
return "(tâche non retrouvée — vérifie d'après le diff)"
}
+90
View File
@@ -0,0 +1,90 @@
// Réglages de la dictée vocale — modèle, langue, matériel, réactivité.
//
// Ils vivent dans le magasin clé/valeur (bkState), comme le jeton Hugging
// Face : ce sont des réglages de SERVEUR, pas de navigateur. Le modèle chargé
// et le GPU occupé sont des propriétés de la machine, identiques pour tous les
// onglets ouverts.
package loki
import (
"fmt"
"strconv"
"time"
)
const dictateCfgKey = "dictate_cfg"
// DictateCfg : Device vaut "cpu" ou un indice de GPU en décimal ("0", "1"),
// dans l'ordre de nvidia-smi — le même que celui affiché par /api/vram.
type DictateCfg struct {
Model string `json:"model"`
Lang string `json:"lang"`
Device string `json:"device"`
Reactivity string `json:"reactivity"`
}
// withDefauts comble les champs vides. Appliqué à la LECTURE plutôt qu'à
// l'écriture : une version future qui ajoute un champ trouvera les réglages
// déjà enregistrés sans champ correspondant, et doit pouvoir le combler.
func (c DictateCfg) withDefauts() DictateCfg {
if c.Model == "" {
// large-v3-turbo : meilleur rapport qualité/vitesse en français à ce
// jour. Le défaut penche vers la qualité.
c.Model = "large-v3-turbo-q5_0"
}
if c.Lang == "" {
// Pas "auto" : sur des tranches de quelques secondes la détection se
// trompe, et une langue mal détectée produit du charabia — des suites
// de caractères géorgiens ont été observées en production.
c.Lang = "fr"
}
if c.Device == "" {
c.Device = "cpu"
}
if c.Reactivity == "" {
c.Reactivity = "moyen"
}
return c
}
func dictateCfgLoad() DictateCfg {
var c DictateCfg
getJSON(bkState, dictateCfgKey, &c)
return c.withDefauts()
}
func dictateCfgSave(c DictateCfg) error {
c = c.withDefauts()
// Valider ICI plutôt qu'au démarrage du serveur : un modèle hors catalogue
// accepté en silence ne se manifesterait qu'au premier clic sur le micro,
// loin du geste qui l'a causé.
if _, ok := whisperCatalogue[c.Model]; !ok {
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
}
return putJSON(bkState, dictateCfgKey, c)
}
// cudaVisibleDevices traduit le réglage en valeur de CUDA_VISIBLE_DEVICES.
// Vide = aucun GPU visible = whisper tourne sur le CPU. Toute valeur qui n'est
// pas un indice positif retombe sur le CPU : mieux vaut une dictée lente qu'un
// GPU choisi au hasard sur une machine dont on ne sait rien.
func (c DictateCfg) cudaVisibleDevices() string {
n, err := strconv.Atoi(c.Device)
if err != nil || n < 0 {
return ""
}
return strconv.Itoa(n)
}
// chunkBounds : réserve minimale avant de couper, et coupure forcée quand
// aucun silence n'arrive. Consommé par le découpeur du plan « temps réel ».
func (c DictateCfg) chunkBounds() (time.Duration, time.Duration) {
switch c.Reactivity {
case "court":
return 1000 * time.Millisecond, 5 * time.Second
case "long":
return 3 * time.Second, 15 * time.Second
default:
return 1500 * time.Millisecond, 8 * time.Second
}
}
+87
View File
@@ -0,0 +1,87 @@
package loki
import (
"testing"
"time"
)
func TestDictateCfgDefauts(t *testing.T) {
c := DictateCfg{}.withDefauts()
if c.Model != "large-v3-turbo-q5_0" {
t.Errorf("modèle par défaut = %q, attendu large-v3-turbo-q5_0", c.Model)
}
if c.Lang != "fr" {
t.Errorf("langue par défaut = %q, attendu fr — la détection auto se trompe sur les tranches courtes", c.Lang)
}
if c.Device != "cpu" {
t.Errorf("matériel par défaut = %q, attendu cpu (aucun GPU supposé)", c.Device)
}
if c.Reactivity != "moyen" {
t.Errorf("réactivité par défaut = %q, attendu moyen", c.Reactivity)
}
}
func TestCudaVisibleDevices(t *testing.T) {
cas := []struct{ device, want string }{
{"cpu", ""},
{"0", "0"},
{"1", "1"},
{"", ""},
{"bidon", ""},
{"-1", ""},
}
for _, c := range cas {
got := DictateCfg{Device: c.device}.cudaVisibleDevices()
if got != c.want {
t.Errorf("cudaVisibleDevices(%q) = %q, attendu %q", c.device, got, c.want)
}
}
}
func TestChunkBounds(t *testing.T) {
cas := []struct {
react string
min, max time.Duration
}{
{"court", 1000 * time.Millisecond, 5 * time.Second},
{"moyen", 1500 * time.Millisecond, 8 * time.Second},
{"long", 3 * time.Second, 15 * time.Second},
{"inconnu", 1500 * time.Millisecond, 8 * time.Second},
}
for _, c := range cas {
min, max := DictateCfg{Reactivity: c.react}.chunkBounds()
if min != c.min || max != c.max {
t.Errorf("chunkBounds(%q) = %v/%v, attendu %v/%v", c.react, min, max, c.min, c.max)
}
}
}
func TestDictateCfgAllerRetour(t *testing.T) {
testHome(t)
in := DictateCfg{Model: "medium-q5_0", Lang: "en", Device: "1", Reactivity: "long"}
if err := dictateCfgSave(in); err != nil {
t.Fatalf("enregistrement : %v", err)
}
if out := dictateCfgLoad(); out != in {
t.Errorf("relu %+v, attendu %+v", out, in)
}
}
// Un modèle hors catalogue accepté en silence ne se manifesterait qu'au premier
// clic sur le micro, loin du geste qui l'a causé.
func TestDictateCfgSaveRefuseModeleInconnu(t *testing.T) {
testHome(t)
if err := dictateCfgSave(DictateCfg{Model: "modele-inexistant", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err == nil {
t.Error("un modèle hors catalogue doit être refusé")
}
}
// Sans réglage enregistré, la lecture doit rendre les défauts — et non le zéro
// de la structure, qui donnerait un modèle vide à whisper-server.
func TestDictateCfgLoadSansRien(t *testing.T) {
testHome(t)
c := dictateCfgLoad()
if c.Model == "" || c.Lang == "" || c.Device == "" || c.Reactivity == "" {
t.Errorf("lecture à vide = %+v, aucun champ ne doit rester vide", c)
}
}
+74
View File
@@ -0,0 +1,74 @@
// Catalogue des modèles de dictée. Aucun n'est embarqué dans l'image : ils
// pèsent de 190 Mo à 1,1 Go et vivent dans <LOKI_HOME>/whisper/, donc dans le
// volume /data — ils survivent aux recréations du conteneur.
package loki
import (
"os"
"path/filepath"
"sort"
)
// Les modèles ggml officiels de whisper.cpp.
const whisperModelBase = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/"
type whisperModel struct {
Nom string // libellé affiché
Fichier string // nom du .bin, identique en local et chez Hugging Face
Octets int64 // taille du téléchargement, pour prévenir avant de le lancer
VRAMMo int // ordre de grandeur occupé sur le GPU, pour choisir en connaissance de cause
}
// Les tailles sont celles publiées par le dépôt amont. Elles servent à
// informer avant un téléchargement, pas à vérifier le fichier : une taille qui
// dérive d'une release à l'autre ne doit pas casser la dictée.
var whisperCatalogue = map[string]whisperModel{
"small-q5_1": {Nom: "small — rapide, correct", Fichier: "ggml-small-q5_1.bin", Octets: 190_000_000, VRAMMo: 600},
"medium-q5_0": {Nom: "medium — bon compromis", Fichier: "ggml-medium-q5_0.bin", Octets: 539_000_000, VRAMMo: 1400},
"large-v3-turbo-q5_0": {Nom: "large-v3-turbo — recommandé", Fichier: "ggml-large-v3-turbo-q5_0.bin", Octets: 574_000_000, VRAMMo: 1600},
"large-v3-q5_0": {Nom: "large-v3 — le plus précis, le plus lent", Fichier: "ggml-large-v3-q5_0.bin", Octets: 1_080_000_000, VRAMMo: 3600},
}
// whisperModelPathFor : chemin local. Vide si l'identifiant est inconnu —
// l'appelant doit traiter ce cas plutôt que de bâtir un chemin sur une chaîne
// arbitraire venue d'une requête HTTP.
func whisperModelPathFor(id string) string {
m, ok := whisperCatalogue[id]
if !ok {
return ""
}
return filepath.Join(LokiHome(), "whisper", m.Fichier)
}
func whisperModelURLFor(id string) string {
m, ok := whisperCatalogue[id]
if !ok {
return ""
}
return whisperModelBase + m.Fichier
}
func whisperModelPresent(id string) bool {
p := whisperModelPathFor(id)
if p == "" {
return false
}
st, err := os.Stat(p)
return err == nil && st.Size() > 0
}
// whisperCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
// l'ordre dans lequel se pose la question du compromis.
func whisperCatalogueTrie() []map[string]any {
out := make([]map[string]any, 0, len(whisperCatalogue))
for id, m := range whisperCatalogue {
out = append(out, map[string]any{
"id": id, "nom": m.Nom, "octets": m.Octets,
"vram_mo": m.VRAMMo, "present": whisperModelPresent(id),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
})
return out
}
+75
View File
@@ -0,0 +1,75 @@
package loki
import (
"path/filepath"
"strings"
"testing"
)
func TestCatalogueContientLeDefautEtLAncien(t *testing.T) {
// Le défaut doit exister, sinon dictateCfgSave refuse sa propre valeur par
// défaut et plus aucun réglage n'est enregistrable.
if _, ok := whisperCatalogue["large-v3-turbo-q5_0"]; !ok {
t.Error("le modèle par défaut large-v3-turbo-q5_0 est absent du catalogue")
}
// small-q5_1 est le modèle déjà téléchargé sur les installations
// existantes : le retirer le rendrait insélectionnable alors que son
// fichier est là.
if _, ok := whisperCatalogue["small-q5_1"]; !ok {
t.Error("small-q5_1 est absent : les installations existantes l'ont déjà sur disque")
}
}
func TestModelPathEtURL(t *testing.T) {
testHome(t)
p := whisperModelPathFor("small-q5_1")
if filepath.Base(p) != "ggml-small-q5_1.bin" {
t.Errorf("chemin = %q, le fichier doit s'appeler ggml-small-q5_1.bin", p)
}
// Le chemin DOIT rester celui d'avant, sinon les installations existantes
// re-téléchargent 190 Mo pour rien.
if filepath.Base(filepath.Dir(p)) != "whisper" {
t.Errorf("chemin = %q, le dossier doit rester <LOKI_HOME>/whisper", p)
}
u := whisperModelURLFor("small-q5_1")
if !strings.HasSuffix(u, "/ggml-small-q5_1.bin") {
t.Errorf("URL = %q, doit finir par /ggml-small-q5_1.bin", u)
}
if !strings.HasPrefix(u, "https://") {
t.Errorf("URL = %q, doit être en HTTPS", u)
}
}
func TestModelPathInconnuVide(t *testing.T) {
testHome(t)
if p := whisperModelPathFor("nawak"); p != "" {
t.Errorf("un modèle hors catalogue doit donner un chemin vide, pas %q", p)
}
if u := whisperModelURLFor("nawak"); u != "" {
t.Errorf("URL d'un modèle inconnu = %q, attendu vide", u)
}
if whisperModelPresent("nawak") {
t.Error("un modèle inconnu ne peut pas être présent")
}
}
func TestCatalogueTrieParTaille(t *testing.T) {
testHome(t)
l := whisperCatalogueTrie()
if len(l) < 4 {
t.Fatalf("catalogue de %d entrées, au moins 4 attendues", len(l))
}
var prec int64
for i, m := range l {
o, _ := m["octets"].(int64)
if i > 0 && o < prec {
t.Errorf("entrée %d : catalogue non trié par taille croissante", i)
}
prec = o
for _, clef := range []string{"id", "nom", "octets", "vram_mo", "present"} {
if _, ok := m[clef]; !ok {
t.Errorf("entrée %d : clé %q manquante — l'UI en a besoin", i, clef)
}
}
}
}
+105
View File
@@ -0,0 +1,105 @@
package loki
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func TestConfigRouteAllerRetour(t *testing.T) {
testHome(t)
corps := strings.NewReader(`{"model":"small-q5_1","lang":"en","device":"1","reactivity":"court"}`)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
if rec.Code != 200 {
t.Fatalf("POST = %d, corps %s", rec.Code, rec.Body.String())
}
rec = httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("GET", "/api/dictate/config", nil))
var got DictateCfg
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
if got.Device != "1" || got.Lang != "en" || got.Model != "small-q5_1" || got.Reactivity != "court" {
t.Errorf("relu %+v, attendu le réglage enregistré", got)
}
}
// Le refus doit venir de la ROUTE, pas seulement du magasin : sinon l'UI reçoit
// un 200 et croit son réglage pris en compte.
func TestConfigRouteRefuseModeleInconnu(t *testing.T) {
testHome(t)
corps := strings.NewReader(`{"model":"nawak","lang":"fr","device":"cpu","reactivity":"moyen"}`)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", corps))
if rec.Code == 200 {
t.Errorf("modèle hors catalogue accepté (%d) : %s", rec.Code, rec.Body.String())
}
}
func TestConfigRouteCorpsIllisible(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleDictateConfig(rec, httptest.NewRequest("POST", "/api/dictate/config", strings.NewReader("pas du json")))
if rec.Code != 400 {
t.Errorf("code = %d, attendu 400", rec.Code)
}
}
func TestModelsRoute(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleDictateModels(rec, httptest.NewRequest("GET", "/api/dictate/models", nil))
if rec.Code != 200 {
t.Fatalf("GET = %d", rec.Code)
}
var l []map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &l); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
if len(l) < 4 {
t.Errorf("%d modèles renvoyés, au moins 4 attendus", len(l))
}
}
// Un identifiant inconnu ne doit pas lancer de téléchargement : whisperDownload
// bâtirait une URL vide et le message d'erreur n'arriverait qu'en arrière-plan.
func TestDownloadRouteRefuseInconnu(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
corps := strings.NewReader(`{"id":"nawak"}`)
handleDictateDownload(rec, httptest.NewRequest("POST", "/api/dictate/models/download", corps))
if rec.Code != 400 {
t.Errorf("code = %d, attendu 400 pour un modèle inconnu", rec.Code)
}
}
func TestStateRoute(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleDictateState(rec, httptest.NewRequest("GET", "/api/dictate/state", nil))
if rec.Code != 200 {
t.Fatalf("GET = %d", rec.Code)
}
var e map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
for _, clef := range []string{"actif", "modele", "present", "device", "dl"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante dans l'état", clef)
}
}
}
// Un audio trop court est rejeté AVANT toute tentative de lancer whisper-server :
// inutile de charger un modèle pour un clic parasite.
func TestTranscribeAudioTropCourt(t *testing.T) {
testHome(t)
rec := httptest.NewRecorder()
handleTranscribe(rec, httptest.NewRequest("POST", "/api/transcribe", strings.NewReader("court")))
if rec.Code != 400 {
t.Errorf("code = %d, attendu 400", rec.Code)
}
}
+328
View File
@@ -0,0 +1,328 @@
// Supervision de whisper-server : le modèle est chargé UNE fois et reste en
// mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
//
// L'ancien chemin lançait whisper-cli par requête. Le chargement du modèle
// dominait le temps de réponse (~3 s pour 3,4 s d'audio), et découper la
// dictée en tranches par-dessus ce mécanisme aurait rechargé le modèle toutes
// les quelques secondes.
package loki
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// whisperServerBin : deux binaires sont livrés, pas un.
//
// L'image runtime peut être bâtie sur la variante CPU de llama.cpp
// (LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server). Sur cette base les
// bibliothèques CUDA sont absentes, et un binaire lié à CUDA ne démarre pas du
// tout : l'éditeur de liens échoue avant la première instruction, il n'y a
// donc aucun repli possible depuis l'intérieur du programme. D'où deux
// binaires et un choix fait ici.
func whisperServerBin(gpu bool) string {
env, defaut := "LOKI_WHISPER_SERVER_CPU", "/usr/local/bin/whisper-server-cpu"
if gpu {
env, defaut = "LOKI_WHISPER_SERVER_CUDA", "/usr/local/bin/whisper-server-cuda"
}
for _, p := range []string{os.Getenv(env), defaut} {
if p == "" {
continue
}
if _, err := os.Stat(p); err == nil {
return p
}
}
if gpu {
// Pas de binaire CUDA : l'appelant retombe sur le CPU en le disant.
return ""
}
if p, err := exec.LookPath("whisper-server"); err == nil {
return p
}
return ""
}
func whisperServerArgs(c DictateCfg, port int) ([]string, error) {
model := whisperModelPathFor(c.Model)
if model == "" {
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
}
return []string{
"-m", model,
"-l", c.Lang,
// 127.0.0.1 et pas 0.0.0.0 : whisper-server n'a aucune
// authentification. Sur un conteneur au réseau partagé, l'exposer
// offrirait la transcription à tout le réseau.
"--host", "127.0.0.1",
"--port", strconv.Itoa(port),
}, nil
}
// whisperServerEnv pose CUDA_VISIBLE_DEVICES en REMPLAÇANT toute valeur déjà
// présente. Une variable dupliquée laisse le gagnant dépendre de la libc, et
// le choix de GPU deviendrait un coup de dé.
func whisperServerEnv(c DictateCfg, base []string) []string {
const clef = "CUDA_VISIBLE_DEVICES="
out := make([]string, 0, len(base)+1)
for _, v := range base {
if !strings.HasPrefix(v, clef) {
out = append(out, v)
}
}
// Valeur vide en mode CPU : masque TOUS les GPU. Retirer la variable
// ferait l'inverse — ggml les verrait tous.
return append(out, clef+c.cudaVisibleDevices())
}
// portLibre demande au système un port disponible puis le relâche. Coder un
// port en dur casserait sur une machine qui l'occupe déjà ; la fenêtre entre
// la fermeture et la reprise par whisper-server est négligeable devant le
// risque d'un conflit permanent.
func portLibre() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer func() { _ = l.Close() }()
return l.Addr().(*net.TCPAddr).Port, nil
}
// whisperMarqueur reconnaît les annotations que whisper produit à la place
// d'un texte : [BLANK_AUDIO], (silence), [SOUND]… Un contenu entièrement
// entouré de crochets ou de parenthèses n'est jamais de la parole dictée, et
// le laisser passer le collerait tel quel dans le champ de saisie.
func whisperMarqueur(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return true
}
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
}
// whisperInferSur prend l'URL de base en paramètre : c'est ce qui rend l'appel
// testable sans lancer de processus.
func whisperInferSur(ctx context.Context, base string, wav []byte) (string, error) {
var corps bytes.Buffer
mw := multipart.NewWriter(&corps)
part, err := mw.CreateFormFile("file", "dictee.wav")
if err != nil {
return "", err
}
if _, err := part.Write(wav); err != nil {
return "", err
}
if err := mw.WriteField("response_format", "json"); err != nil {
return "", err
}
if err := mw.Close(); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/inference", &corps)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
brut, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != 200 {
return "", fmt.Errorf("whisper-server a répondu %d : %s", resp.StatusCode, lastLine(string(brut)))
}
var j struct {
Text string `json:"text"`
}
if err := json.Unmarshal(brut, &j); err != nil {
return "", fmt.Errorf("réponse illisible de whisper-server : %w", err)
}
if whisperMarqueur(j.Text) {
return "", nil
}
return strings.TrimSpace(j.Text), nil
}
// ─── Supervision ─────────────────────────────────────────────────────────────
// whisperIdle : au-delà, le serveur s'éteint et rend sa VRAM. Une dictée n'est
// pas un service permanent ; garder un modèle chargé toute la journée pour
// quelques phrases prive le moteur de chat de mémoire GPU.
const whisperIdle = 10 * time.Minute
// whisperReady : plafond d'attente au démarrage. Un modèle de 1 Go lu depuis un
// disque lent puis poussé sur le GPU prend du temps ; échouer trop tôt
// afficherait une panne là où il n'y a qu'une lenteur.
const whisperReady = 120 * time.Second
var wsrvMu sync.Mutex
var wsrv struct {
cmd *exec.Cmd
port int
cfg DictateCfg
log *bytes.Buffer
timer *time.Timer
note string // pourquoi on ne tourne pas sur le matériel demandé
depuis time.Time
}
// whisperEnsure garantit un serveur vivant pour la configuration COURANTE, et
// renvoie son port. Si les réglages ont changé depuis le lancement, l'ancien
// est arrêté : c'est ce qui rend un changement de modèle ou de GPU effectif
// sans redémarrer Loki.
func whisperEnsure() (int, error) {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
whisperTouchLocked()
return wsrv.port, nil
}
whisperStopLocked()
return whisperStartLocked(cfg)
}
func whisperStartLocked(cfg DictateCfg) (int, error) {
if !whisperModelPresent(cfg.Model) {
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
}
note := ""
gpu := cfg.cudaVisibleDevices() != ""
bin := whisperServerBin(gpu)
if bin == "" && gpu {
// Image bâtie sans CUDA : on le DIT, au lieu de laisser croire que le
// GPU choisi est utilisé.
note = "binaire CUDA absent de cette image — dictée sur le processeur"
gpu = false
cfg.Device = "cpu"
bin = whisperServerBin(false)
}
if bin == "" {
return 0, fmt.Errorf("whisper-server introuvable (image à reconstruire)")
}
port, err := portLibre()
if err != nil {
return 0, fmt.Errorf("aucun port libre pour whisper-server : %w", err)
}
args, err := whisperServerArgs(cfg, port)
if err != nil {
return 0, err
}
log := &bytes.Buffer{}
cmd := exec.Command(bin, args...)
cmd.Env = whisperServerEnv(cfg, os.Environ())
cmd.Stdout, cmd.Stderr = log, log
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("démarrage de whisper-server : %w", err)
}
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, dictateCfgLoad()
wsrv.log, wsrv.note, wsrv.depuis = log, note, time.Now()
if err := whisperAttendPret(cmd, port, log); err != nil {
whisperStopLocked()
return 0, err
}
whisperTouchLocked()
return port, nil
}
// whisperAttendPret sonde le serveur jusqu'à ce qu'il réponde. Si le processus
// meurt entre-temps, on rapporte COMMENT il est mort : sur une mort par
// signal, la dernière ligne du journal est celle d'avant le coup fatal — elle
// ressemble à une explication sans en être une (leçon de la PR #18).
func whisperAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
fin := time.Now().Add(whisperReady)
url := "http://127.0.0.1:" + strconv.Itoa(port) + "/"
mort := make(chan error, 1)
go func() { mort <- cmd.Wait() }()
for time.Now().Before(fin) {
select {
case err := <-mort:
msg := whisperExitReason(err)
if l := lastLine(log.String()); l != "" {
msg += " — dernière sortie : " + l
}
return fmt.Errorf("whisper-server s'est arrêté au démarrage : %s", msg)
case <-time.After(200 * time.Millisecond):
}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
resp, err := http.DefaultClient.Do(req.WithContext(ctx))
cancel()
if err == nil {
_ = resp.Body.Close()
return nil
}
}
return fmt.Errorf("whisper-server n'a pas répondu en %s", whisperReady)
}
func whisperTouchLocked() {
if wsrv.timer != nil {
wsrv.timer.Stop()
}
wsrv.timer = time.AfterFunc(whisperIdle, whisperShutdown)
}
func whisperStopLocked() {
if wsrv.timer != nil {
wsrv.timer.Stop()
wsrv.timer = nil
}
if wsrv.cmd != nil && wsrv.cmd.Process != nil {
_ = wsrv.cmd.Process.Kill()
}
wsrv.cmd, wsrv.port, wsrv.log = nil, 0, nil
wsrv.cfg, wsrv.note = DictateCfg{}, ""
}
// whisperShutdown arrête le serveur. Appelé par la minuterie d'inactivité et
// à chaque changement de réglages — sans ça, le serveur continuerait de
// tourner avec l'ancien modèle et le nouveau réglage semblerait sans effet.
func whisperShutdown() {
wsrvMu.Lock()
defer wsrvMu.Unlock()
whisperStopLocked()
}
// whisperInfer garantit le serveur puis lui soumet le WAV.
func whisperInfer(ctx context.Context, wav []byte) (string, error) {
port, err := whisperEnsure()
if err != nil {
return "", err
}
return whisperInferSur(ctx, "http://127.0.0.1:"+strconv.Itoa(port), wav)
}
func whisperEtat() map[string]any {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
etat := map[string]any{
"actif": wsrv.cmd != nil,
"modele": cfg.Model,
"present": whisperModelPresent(cfg.Model),
"device": cfg.Device,
"note": wsrv.note,
}
if wsrv.cmd != nil {
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
}
return etat
}
+188
View File
@@ -0,0 +1,188 @@
package loki
import (
"context"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
)
func argVal(args []string, flag string) (string, bool) {
for i, a := range args {
if a == flag && i+1 < len(args) {
return args[i+1], true
}
}
return "", false
}
func TestServerArgs(t *testing.T) {
testHome(t)
c := DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu"}.withDefauts()
args, err := whisperServerArgs(c, 8091)
if err != nil {
t.Fatalf("construction des arguments : %v", err)
}
if v, ok := argVal(args, "-m"); !ok || !strings.HasSuffix(v, "ggml-small-q5_1.bin") {
t.Errorf("-m = %q, doit pointer sur le .bin du modèle choisi", v)
}
if v, ok := argVal(args, "-l"); !ok || v != "fr" {
t.Errorf("-l = %q, attendu fr", v)
}
if v, ok := argVal(args, "--port"); !ok || v != "8091" {
t.Errorf("--port = %q, attendu 8091", v)
}
// whisper-server n'a aucune authentification : l'exposer sur 0.0.0.0
// ouvrirait la transcription à tout le réseau.
if v, ok := argVal(args, "--host"); !ok || v != "127.0.0.1" {
t.Errorf("--host = %q, attendu 127.0.0.1", v)
}
}
func TestServerArgsModeleInconnu(t *testing.T) {
testHome(t)
if _, err := whisperServerArgs(DictateCfg{Model: "nawak", Lang: "fr", Device: "cpu"}, 8091); err == nil {
t.Error("un modèle hors catalogue doit produire une erreur, pas une commande avec -m vide")
}
}
func TestServerEnvGPU(t *testing.T) {
base := []string{"PATH=/usr/bin", "CUDA_VISIBLE_DEVICES=7", "HOME=/root"}
env := whisperServerEnv(DictateCfg{Device: "1"}, base)
if !contientExact(env, "CUDA_VISIBLE_DEVICES=1") {
t.Errorf("env = %v, doit contenir CUDA_VISIBLE_DEVICES=1", env)
}
// L'ancienne valeur doit être REMPLACÉE, pas doublée : avec deux
// occurrences, le gagnant dépend de l'implémentation — le choix de
// l'utilisateur ne doit pas dépendre de ça.
if n := compte(env, "CUDA_VISIBLE_DEVICES="); n != 1 {
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
}
if !contientExact(env, "PATH=/usr/bin") {
t.Error("le reste de l'environnement doit être conservé")
}
env = whisperServerEnv(DictateCfg{Device: "cpu"}, base)
if !contientExact(env, "CUDA_VISIBLE_DEVICES=") {
t.Errorf("env = %v, en mode CPU la variable doit être posée VIDE (et non retirée) pour masquer tous les GPU", env)
}
}
func contientExact(l []string, s string) bool {
for _, v := range l {
if v == s {
return true
}
}
return false
}
func compte(l []string, prefixe string) int {
n := 0
for _, v := range l {
if strings.HasPrefix(v, prefixe) {
n++
}
}
return n
}
func TestPortLibre(t *testing.T) {
p, err := portLibre()
if err != nil {
t.Fatalf("portLibre : %v", err)
}
if p < 1024 || p > 65535 {
t.Errorf("port = %d, hors de la plage utilisable", p)
}
l, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(p))
if err != nil {
t.Fatalf("port %d annoncé libre mais inutilisable : %v", p, err)
}
_ = l.Close()
}
func TestInferParseLaReponse(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/inference" {
t.Errorf("chemin appelé = %q, attendu /inference", r.URL.Path)
}
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "multipart/form-data") {
t.Errorf("Content-Type = %q, whisper-server attend du multipart", ct)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"text":" bonjour ceci est un test "}`))
}))
defer srv.Close()
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
if err != nil {
t.Fatalf("whisperInferSur : %v", err)
}
if txt != "bonjour ceci est un test" {
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
}
}
// Sur du silence, whisper renvoie ses marqueurs internes. Les laisser passer
// les collerait tels quels dans le champ de saisie — c'est ce qui est arrivé
// avec [BLANK_AUDIO].
func TestInferFiltreLesMarqueurs(t *testing.T) {
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"text":"` + marqueur + `"}`))
}))
txt, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux"))
srv.Close()
if err != nil {
t.Fatalf("whisperInferSur(%q) : %v", marqueur, err)
}
if txt != "" {
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
}
}
}
func TestInferErreurHTTP(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(500)
_, _ = w.Write([]byte("boom"))
}))
defer srv.Close()
if _, err := whisperInferSur(context.Background(), srv.URL, []byte("RIFFfaux")); err == nil {
t.Error("un 500 de whisper-server doit remonter une erreur")
}
}
// Un modèle absent doit être annoncé comme tel, pas produire un lancement de
// whisper-server voué à mourir sur un fichier introuvable.
func TestEnsureModeleAbsent(t *testing.T) {
testHome(t)
if err := dictateCfgSave(DictateCfg{Model: "small-q5_1", Lang: "fr", Device: "cpu", Reactivity: "moyen"}); err != nil {
t.Fatal(err)
}
_, err := whisperEnsure()
if err == nil {
t.Fatal("un modèle absent doit produire une erreur")
}
if !strings.Contains(err.Error(), "absent") {
t.Errorf("erreur = %q, elle doit dire que le modèle est absent", err)
}
}
func TestEtatSansServeur(t *testing.T) {
testHome(t)
e := whisperEtat()
if e["actif"] != false {
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
}
for _, clef := range []string{"modele", "present", "device", "note"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
}
}
}
+91
View File
@@ -0,0 +1,91 @@
package loki
// llm_budget.go — budget SOUPLE d'appels d'outils par tour.
//
// Le plafond dur (TOOL_LIMIT) et l'anti-boucle ont été retirés en v0.6.3 : ils
// coupaient des tours parfaitement légitimes, une recherche enchaînant sans
// problème des dizaines d'appels. Mais il ne restait plus RIEN entre « le
// modèle converge » et « le modèle tourne en rond pendant une heure » — vu en
// production : ~50 appels d'outil, 55 minutes, le modèle relisant cinq fois les
// mêmes fonctions pour re-dériver une conclusion qu'il avait déjà écrite.
//
// La déduplication d'appels identiques (repeatedCallResult) ne rattrape pas ce
// cas : sa clé est « nom + arguments bruts », et relire le même fichier par
// tranches (`sed -n '1,80p'` puis `sed -n '80,160p'`) produit des clés
// différentes. Élargir cette clé à la CIBLE serait pire que le mal — deux
// tranches d'un même fichier renvoient un contenu différent, les confondre
// casserait toute lecture paginée légitime.
//
// D'où un budget qui ne coupe rien : au-delà d'un palier, on RAPPELLE au modèle
// combien d'appels il a déjà faits et on lui demande de conclure. Le tour reste
// entièrement sous son contrôle — c'est de la pression, pas une barrière.
import (
"fmt"
"os"
"strconv"
"strings"
)
// agentBudgetDefault : nombre d'appels d'outils au-delà duquel un tour cesse
// d'être une recherche et commence à ressembler à du sur-place. Assez haut pour
// qu'une vraie enquête (lire dix fichiers, croiser trois pages web) passe sans
// jamais voir le rappel.
const agentBudgetDefault = 24
// agentBudget lit le palier dans config.env (AGENT_BUDGET). 0 — ou une valeur
// off/false/no/non — désactive complètement le mécanisme.
func agentBudget() int {
v := strings.ToLower(strings.TrimSpace(ReadConfig()["AGENT_BUDGET"]))
switch v {
case "":
return agentBudgetDefault
case "off", "false", "no", "non", "disable", "disabled":
return 0
}
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
return n
}
return agentBudgetDefault
}
// budgetNudge renvoie le rappel à injecter quand le tour vient de franchir un
// palier, ou "" s'il n'y a rien à dire.
//
// calls : appels d'outils exécutés depuis le début du tour
// budget : taille d'un palier (0 = mécanisme désactivé)
// sent : rappels déjà envoyés dans ce tour
//
// Les paliers sont à budget, 2×budget, 3×budget… : la pression revient donc
// régulièrement au lieu de s'éteindre après un avertissement ignoré. Le coût en
// contexte est négligeable (une phrase toutes les 24 requêtes) et le message est
// AJOUTÉ EN FIN d'historique, ce qui laisse intact le préfixe déjà en cache côté
// llama-server.
//
// Le texte est en anglais comme le reste du protocole d'outils : c'est la langue
// dans laquelle les modèles suivent le mieux une consigne impérative, quelle que
// soit celle de la conversation.
func budgetNudge(calls, budget, sent int) string {
if budget <= 0 || calls < budget*(sent+1) {
return ""
}
n := fmt.Sprintf("%d tool calls", calls)
switch sent {
case 0:
return "[system] You have made " + n + " in this single turn. That is a lot: you almost certainly have enough to answer already. " +
"Stop exploring and write your final answer now, unless exactly one specific call is genuinely still missing."
case 1:
return "[system] " + n + " now, and you are going in circles. Re-reading a file you have already read, or re-deriving a conclusion you have already written, adds nothing. " +
"Answer NOW with what you have. If something remains uncertain, say so in the answer instead of investigating further."
default:
return "[system] " + n + ". Stop. Write your final answer in this message. Do not call another tool."
}
}
// logBudget trace UNE ligne par rappel sur la sortie d'erreur (donc dans
// `journalctl -u loki-ui`), comme logCompact. Sans ça, un tour qui part en
// vrille est invisible côté serveur : on ne voit qu'une génération qui dure.
func logBudget(calls, budget, sent int) {
fmt.Fprintf(os.Stderr, "[agent] %d appels d'outils sur ce tour (palier=%d) — rappel n°%d envoyé au modèle\n",
calls, budget, sent)
}
+93
View File
@@ -0,0 +1,93 @@
package loki
import "testing"
// Le budget ne se déclenche pas avant son palier, et il revient à chaque
// palier suivant : un modèle qui ignore le premier rappel doit en recevoir un
// second, plus ferme.
func TestBudgetNudgePaliers(t *testing.T) {
const budget = 24
cases := []struct {
calls, sent int
want bool
}{
{0, 0, false},
{1, 0, false},
{23, 0, false},
{24, 0, true}, // 1er palier
{40, 1, false}, // rappel déjà envoyé, palier suivant pas atteint
{48, 1, true}, // 2e palier
{72, 2, true}, // 3e palier
{95, 3, false},
{96, 3, true}, // 4e palier : la pression ne s'éteint pas
}
for _, c := range cases {
got := budgetNudge(c.calls, budget, c.sent) != ""
if got != c.want {
t.Errorf("budgetNudge(calls=%d, sent=%d) = %v, attendu %v", c.calls, c.sent, got, c.want)
}
}
}
// Le message durcit à chaque rappel : trois textes distincts, tous porteurs du
// nombre d'appels (c'est LUI qui informe le modèle, pas le ton).
func TestBudgetNudgeEscalade(t *testing.T) {
const budget = 10
seen := map[string]bool{}
for sent := 0; sent < 3; sent++ {
m := budgetNudge(budget*(sent+1), budget, sent)
if m == "" {
t.Fatalf("rappel n°%d vide", sent+1)
}
if seen[m] {
t.Errorf("rappel n°%d identique à un précédent : %q", sent+1, m)
}
seen[m] = true
}
// Au-delà, on réutilise le texte le plus ferme plutôt que d'en inventer un
// nouveau à chaque palier (à nombre d'appels égal, le message est le même).
if a, b := budgetNudge(50, budget, 3), budgetNudge(50, budget, 4); a != b {
t.Errorf("les rappels tardifs devraient partager le même texte :\n%q\n%q", a, b)
}
}
// budget=0 (AGENT_BUDGET=off) : plus aucun rappel, quel que soit le nombre
// d'appels — le mécanisme doit pouvoir être totalement coupé.
func TestBudgetNudgeDesactive(t *testing.T) {
for _, calls := range []int{0, 24, 500, 10000} {
if m := budgetNudge(calls, 0, 0); m != "" {
t.Errorf("budget désactivé mais rappel émis à %d appels : %q", calls, m)
}
}
if m := budgetNudge(100, -5, 0); m != "" {
t.Errorf("budget négatif devrait être inerte, obtenu %q", m)
}
}
func TestAgentBudgetConfig(t *testing.T) {
testHome(t)
set := func(v string) {
cfg := ReadConfig()
cfg["AGENT_BUDGET"] = v
if err := WriteConfig(cfg); err != nil {
t.Fatal(err)
}
}
for _, c := range []struct {
val string
want int
}{
{"", agentBudgetDefault},
{"40", 40},
{"0", 0},
{"off", 0},
{"NON", 0},
{"n'importe quoi", agentBudgetDefault}, // valeur illisible : on ne l'invente pas
{"-3", agentBudgetDefault},
} {
set(c.val)
if got := agentBudget(); got != c.want {
t.Errorf("AGENT_BUDGET=%q → %d, attendu %d", c.val, got, c.want)
}
}
}
+235 -18
View File
@@ -194,6 +194,14 @@ type Caps struct {
// Mem = mode d'accès à la mémoire persistante (off / ondemand / always),
// indépendant du mode agent. Voir MemMode.
Mem MemMode
// Code = mode code actif pour cette discussion (sélecteur Chat/Code) :
// ajoute les outils codeur (read/grep/glob, git, critères, jobs) et le
// prompt de rôle. Requiert Agent.
Code bool
// Role = rôle du tour en mode code : "" ou "builder" (défaut), "planner",
// "verifier" (passe de vérification, seule autorisée à marquer un critère
// passed), "explorer", "code-reviewer". Voir code_roles.go.
Role string
}
// globalCaps reads the machine-wide config — the default when a request doesn't
@@ -261,12 +269,71 @@ func steerSystem(msgs []Message, hint string) []Message {
return append([]Message{{Role: "system", Content: hint}}, msgs...)
}
// normalizeSystemMessages garantit un UNIQUE message système, en tête, dans la
// séquence ENVOYÉE au moteur. Repris de l'amont AJEAN (v0.9.8, issue #26).
//
// steerSystem (ci-dessus) empêche déjà loki d'en poser un en cours de route, mais
// il ne couvre que SES propres consignes : un historique venu d'ailleurs (import,
// preset, conversation migrée, agent de code qui compose sa propre séquence) peut
// encore porter deux systèmes, ou un système ailleurs qu'en position 0. Les
// gabarits stricts — Qwen3.x en --jinja — répondent alors « System message must
// be at the beginning », un 500 qui tue le tour sans rien expliquer.
//
// La séquence renvoyée est une COPIE : l'historique d'origine garde sa forme pour
// l'affichage, la persistance et la compaction. Un modèle qui accepte le système
// n'importe où n'est pas gêné de le recevoir en tête — la normalisation est donc
// sans risque pour tous, et sans effet sur une conversation déjà normale (payload
// identique, garanti par test).
func normalizeSystemMessages(msgs []Message) []Message {
var sys []string
sawSystem := false
rest := make([]Message, 0, len(msgs))
for _, m := range msgs {
if m.Role == "system" {
if s, ok := m.Content.(string); ok {
sawSystem = true
if strings.TrimSpace(s) != "" {
sys = append(sys, s)
}
continue
}
// Contenu système non textuel (cas théorique) : on le préserve tel quel
// plutôt que de le perdre.
}
rest = append(rest, m)
}
// Aucun message système : rien à réordonner, on rend l'entrée telle quelle —
// pas de copie inutile sur le chemin le plus fréquent.
if !sawSystem {
return msgs
}
// Des systèmes existaient mais tous vides : on les a retirés (un système vide
// hors position 0 casserait tout autant), sans en réinsérer.
if len(sys) == 0 {
return rest
}
out := make([]Message, 0, len(rest)+1)
out = append(out, Message{Role: "system", Content: strings.Join(sys, "\n\n")})
return append(out, rest...)
}
// EnabledTools returns the tools to advertise on the next inference call.
func EnabledTools(caps Caps) []Tool {
tools := []Tool{}
// Passe de VÉRIFICATION du mode code : jeu d'outils fermé, en lecture
// seule + bash (lancer les tests) + critères. Ni write/edit (elle ne
// corrige pas), ni mémoire ni web (hors sujet, et chaque schéma coûte du
// contexte).
if caps.Code && caps.Role == "verifier" {
return []Tool{bashTool(), readTool(), grepTool(), globTool(), gitStatusTool(), gitDiffTool(), criteriaTool()}
}
if caps.Agent {
tools = append(tools, bashTool(), writeTool(), editTool())
}
if caps.Code {
tools = append(tools, readTool(), grepTool(), globTool(), askTool(),
bashBgTool(), bashTailTool(), gitStatusTool(), gitDiffTool(), gitCloneTool(), criteriaTool())
}
// Mémoire = axe indépendant du mode agent : les outils mem_* sont fournis dès
// que le mode mémoire n'est pas « off » (que l'agent soit actif ou non).
if caps.Mem != MemOff {
@@ -277,12 +344,16 @@ func EnabledTools(caps Caps) []Tool {
// donc prompt et outils restent cohérents — pas de web_search halluciné.
if caps.Agent && caps.Internet {
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
// Capture d'écran : seulement si Playwright est réellement présent dans
// l'image. Annoncer un outil absent enverrait le modèle en boucle de
// réessai sur un échec systématique.
if screenshotAvailable() {
tools = append(tools, webScreenshotTool())
}
}
// Capture d'écran : seulement si Playwright est réellement présent dans
// l'image (annoncer un outil absent enverrait le modèle en boucle de
// réessai). Offerte aussi SANS accès internet quand le mode code est actif :
// photographier son propre serveur de dev est une vérification locale, pas
// une sortie sur le web — et sans elle, le modèle croyait n'avoir aucun
// navigateur et perdait des minutes à installer puppeteer. La borne aux
// adresses locales vit dans toolWebScreenshot.
if caps.Agent && (caps.Internet || caps.Code) && screenshotAvailable() {
tools = append(tools, webScreenshotTool())
}
// Outils MCP : serveurs tiers configurés par le propriétaire de la machine.
// Comme bash, ils exécutent du code arbitraire côté hôte → réservés au mode
@@ -305,6 +376,7 @@ type StreamEvent struct {
Reasoning string
ToolUsed *ToolUsedEvent
Stats *StatsEvent
Ask *AskEvent
Err error
// DropReasoning demande à l'UI de retirer la dernière bulle de raisonnement :
// le modèle a « pensé sans agir » et on relance le tour, ce raisonnement-là
@@ -324,6 +396,14 @@ type StreamEvent struct {
// ajouter.
NewHistory []Message
}
// AskEvent : l'outil ask — question structurée posée à l'utilisateur, rendue
// par l'UI comme une carte à boutons. Le tour se termine juste après.
type AskEvent struct {
Question string `json:"question"`
Options []string `json:"options,omitempty"`
}
type ToolUsedEvent struct {
Name string
Label string // user-visible summary (skill name or the command)
@@ -338,6 +418,12 @@ type ToolUsedEvent struct {
// Diff : lignes ajoutées/retirées quand l'outil a MODIFIÉ quelque chose
// (edit, mem_add, mem_edit). L'UI les affiche en vert (+) et rouge (-).
Diff []DiffLine
// Image : chemin (relatif au dossier de travail) d'une image PRODUITE par
// l'outil — aujourd'hui la capture de web_screenshot. L'UI l'affiche dans la
// bulle de l'outil. Sans ça, la capture n'apparaissait QUE si le modèle
// pensait à recopier la ligne markdown rendue par l'outil : un petit modèle
// l'oublie, et l'utilisateur ne voyait jamais l'image qu'il avait demandée.
Image string
}
// shownDisplayMax borne ce qu'un résultat d'outil occupe dans le FLUX vers l'UI.
@@ -578,7 +664,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
reasoningOn := reasoningActive(chatCfg["REASONING"])
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
reasoningEffort := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
effortWanted := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
// on part alors directement sur la traduction apprise, sans repayer le 500.
reasoningEffort := effortResolve(effortWanted)
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
// `enable_thinking` qui porte alors la consigne, seul.
thinkOff := reasoningExplicitlyOff(chatCfg["REASONING"]) || effortWanted == "none"
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
// modèle réfléchissait pendant que l'interface annonçait le contraire.
reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort)
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
// retry the same turn once with tools removed so the model answers in plain
// text from the tool results already gathered, instead of dying mid-chat.
@@ -587,6 +685,9 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// dépassement de la fenêtre de contexte après de gros résultats d'outils), on
// compacte l'historique en vol et on rejoue le tour — une seule fois.
compactedRetry := false
// Repli d'intensité de raisonnement (llm_effort.go) : une seule tentative par
// tour, comme les autres filets.
effortRetried := false
// Appels d'outil déjà exécutés (clé = nom + arguments bruts) : sert à ne pas
// rejouer deux fois exactement la même écriture dans un même échange.
doneCalls := map[string]string{}
@@ -599,14 +700,28 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// (ni réponse, ni tool_call). On relance alors UNE fois le tour avec un nudge
// explicite au lieu d'afficher « pas de réponse ».
nudged := false
// Pas de plafond d'itérations ni d'anti-boucle : ils coupaient des tours
// parfaitement légitimes (une recherche enchaîne facilement des dizaines
// d'appels, parfois identiques). Le seul frein est le bouton stop, qui annule
// le contexte — c'est un choix assumé.
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
// relance par tour, comme le nudge.
patternRetried := false
// Budget SOUPLE d'appels d'outils (llm_budget.go). Toujours pas de plafond
// d'itérations : couper un tour cassait des recherches légitimes. Mais au-delà
// d'un palier on RAPPELLE au modèle combien d'appels il a déjà faits et on lui
// demande de conclure — de la pression, pas une barrière. Sans ça, un modèle
// qui tourne en rond n'avait rien en face de lui sauf le bouton stop.
toolRuns, budgetNudges, budget := 0, 0, agentBudget()
for iter := 0; ; iter++ {
// Rappel injecté EN FIN d'historique : le préfixe déjà en cache côté
// llama-server reste valide, seul le nouveau message est à traiter.
if msg := budgetNudge(toolRuns, budget, budgetNudges); msg != "" {
budgetNudges++
logBudget(toolRuns, budget, budgetNudges)
messages = append(messages, Message{Role: "user", Content: msg})
}
payload := map[string]any{
"model": "loki",
"messages": messages,
"model": "loki",
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
"messages": normalizeSystemMessages(messages),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
@@ -615,9 +730,17 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// contexte (sinon le system prompt déjà en cache n'est pas recompté).
"stream_options": map[string]any{"include_usage": true},
}
// Échantillonnage du preset (top_p/top_k/min_p/pénalités, et TEMP qui
// l'emporte sur la température ci-dessus). Posé AVANT le raisonnement : les
// deux blocs écrivent des clés disjointes, mais l'ordre rend explicite que
// c'est bien loki qui a le dernier mot sur `chat_template_kwargs`.
applySampling(payload)
if reasoningEffort != "" {
payload["reasoning_effort"] = reasoningEffort
}
if reasoningKwargs != nil {
payload["chat_template_kwargs"] = reasoningKwargs
}
if len(tools) > 0 && !disableTools {
payload["tools"] = tools
// The model sometimes emits parallel tool calls, which this llama.cpp
@@ -651,6 +774,22 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if msg == "" {
msg = resp.Status
}
// Refus du niveau de raisonnement par le gabarit du modèle (Qwen3.8 ne
// connaît pas « high », gpt-oss ne connaît pas « xhigh »…). C'est un
// refus DÉFINITIF, pas une question de taille de prompt : à traiter
// avant la compaction, qui sinon taillait l'historique pour rien puis
// échouait quand même. On rejoue le tour avec le niveau accepté (ou
// sans le champ), l'historique intact.
if !effortRetried && effortWanted != "" {
if fixed, ok := effortFromRejection(msg, effortWanted); ok {
effortRetried = true
effortRemember(effortWanted, fixed)
logEffortFallback(effortWanted, fixed)
reasoningEffort = fixed
reasoningKwargs = reasoningTemplateKwargs(thinkOff, fixed)
continue
}
}
// Le prompt a peut-être dépassé la fenêtre de contexte : on tente une
// compaction en vol et on rejoue le tour (une seule fois) avant tout le
// reste. C'est le filet de secours à la Hermes.
@@ -782,10 +921,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
switch cur.Function.Name {
case "mem_search", "web_search":
key = "query"
case "mem_read", "mem_add", "mem_edit", "edit", "write":
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
key = "file"
case "web_open", "web_read", "web_grep":
key = "url"
case "grep", "glob":
key = "pattern"
case "ask":
key = "question"
case "criteria":
key = "action"
case "bash_tail":
key = "id"
case "git_clone":
key = "url"
}
p := previewArg(cur.Function.Arguments, key)
// Corps en cours de frappe pour les outils d'écriture : on le diffuse
@@ -946,6 +1095,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if ctx.Err() != nil {
return extra, nil
}
toolRuns++ // alimente le budget souple (voir budgetNudge)
var args map[string]any
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
// Derive the human label (command / skill name) up front so we can
@@ -955,10 +1105,20 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
switch tc.Function.Name {
case "mem_search", "web_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write":
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff":
label, _ = args["file"].(string)
case "bash":
case "bash", "bash_bg":
label, _ = args["command"].(string)
case "grep", "glob":
label, _ = args["pattern"].(string)
case "ask":
label, _ = args["question"].(string)
case "criteria":
label, _ = args["action"].(string)
case "bash_tail":
label, _ = args["id"].(string)
case "git_clone":
label, _ = args["url"].(string)
case "web_open", "web_read":
label, _ = args["url"].(string)
case "web_grep":
@@ -1045,10 +1205,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Cible = un poste distant : on écrit LÀ-BAS. Pas de diff (on
// n'a pas l'ancien contenu du fichier distant).
result = nodeCall(tgt, nodeCapWrite, map[string]any{"path": label, "content": content})
} else if msg := codeWriteGuard(caps, label, true); msg != "" {
result = msg
} else {
result = fileWrite(label, content)
if !strings.HasPrefix(result, "[erreur]") {
diff = addedDiff(content)
trackerNoteWrite(resolveAgentPath(label))
result += lspDiagBlock(resolveAgentPath(label), caps)
}
}
case "edit":
@@ -1056,13 +1220,41 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
newText, _ := args["new"].(string)
if tgt := agentTargetSlug(); tgt != "" {
result = nodeEditRemote(tgt, label, oldText, newText)
} else if msg := codeWriteGuard(caps, label, false); msg != "" {
result = msg
} else {
result = fileEdit(label, oldText, newText)
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
if !strings.HasPrefix(result, "[erreur]") {
diff = lineDiff(oldText, newText)
trackerNoteWrite(resolveAgentPath(label))
result += lspDiagBlock(resolveAgentPath(label), caps)
}
}
case "read":
result = toolRead(args, caps.Code)
case "grep":
result = toolGrep(args, caps.Code)
case "glob":
result = toolGlob(args, caps.Code)
case "ask":
result = toolAsk(args)
if !strings.HasPrefix(result, "[erreur]") {
q, _ := args["question"].(string)
cb(StreamEvent{Ask: &AskEvent{Question: q, Options: askOptions(args)}})
}
case "bash_bg":
result = toolBashBg(args)
case "bash_tail":
result = toolBashTail(args)
case "git_status":
result = toolGitStatus(ctx)
case "git_diff":
result = toolGitDiff(ctx, args)
case "git_clone":
result = toolGitClone(ctx, args)
case "criteria":
result = toolCriteria(args, caps.Role == "verifier")
case "bash":
to := 0
switch v := args["timeout"].(type) {
@@ -1071,6 +1263,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case int:
to = v
}
// Politique de sécurité : les commandes catastrophiques sont
// refusées AVANT toute exécution, cible distante comprise.
if reason := dangerousCommand(label); reason != "" {
result = refusedCommandResult(reason)
break
}
if tgt := agentTargetSlug(); tgt != "" {
// Cible = un poste distant : la commande s'exécute LÀ-BAS via le
// canal du poste (fail-closed : nodeCall renvoie une erreur si le
@@ -1088,7 +1286,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case "web_grep":
result = capWebOutput(toolWebGrep(args))
case "web_screenshot":
result = toolWebScreenshot(args)
result = toolWebScreenshot(args, caps)
default:
if isMCPTool(tc.Function.Name) {
result = mcpCall(tc.Function.Name, args)
@@ -1099,7 +1297,13 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if !strings.HasPrefix(result, "[erreur]") {
doneCalls[callKey] = result
}
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff}})
// Capture d'écran : l'image part avec l'événement pour être
// affichée dans la bulle, quoi que le modèle en fasse ensuite.
shot := ""
if tc.Function.Name == "web_screenshot" {
shot = capturedRelPath(result)
}
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label, Result: shownResult(result), Done: true, Diff: diff, Image: shot}})
toolMsg := Message{Role: "tool", ToolCallID: tc.ID, Content: result}
messages = append(messages, toolMsg)
extra = append(extra, toolMsg)
@@ -1146,6 +1350,19 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
continue
}
// Appel d'outil TEXTUEL (halluciné, jamais exécuté) dans la réponse
// finale : on relance le tour UNE fois avec la consigne corrective
// (code_retry.go). Le texte fautif reste affiché — le remplacer serait
// mentir sur ce qui s'est passé — mais l'historique du modèle garde la
// trace ET la correction, donc la vraie réponse suit immédiatement.
if !patternRetried && len(tools) > 0 && !disableTools && textualToolCall(assistantContent.String()) {
patternRetried = true
bad := Message{Role: "assistant", Content: assistantContent.String()}
fix := Message{Role: "user", Content: retryCorrective}
messages = append(messages, bad, fix)
extra = append(extra, bad, fix)
continue
}
// Normal end of turn. If the model produced no visible answer at all
// (empty content, e.g. it stopped right after a tool result), say so
// instead of leaving the user staring at a silent, finished chat.
+195
View File
@@ -0,0 +1,195 @@
package loki
// llm_effort.go — repli quand le gabarit du modèle REFUSE la valeur de
// `reasoning_effort`.
//
// L'hypothèse d'origine (backend_models.go) était : un gabarit jinja qui ne lit
// pas `reasoning_effort` l'ignore sans erreur, donc rien à prévoir côté loki.
// Elle tombe dès qu'un gabarit VALIDE la valeur au lieu de la subir. Vu en
// production sur Qwen3.8-27B :
//
// {"error":{"code":500,"message":"... raise_exception('Unexpected reasoning
// effort ' ~ reasoning_effort) ... Unexpected reasoning effort high. Supported
// types are xhigh (default), medium, and low."}}
//
// Ce gabarit connaît xhigh/medium/low mais PAS « high » — le niveau que
// l'interface propose et enregistre par défaut. Résultat : chaque message part
// en 500, le tour meurt, et le message d'erreur affiché est une trace jinja.
// Pire, le 500 arrivait dans la branche « prompt trop long » de runChat, donc
// loki compactait l'historique pour rien avant d'abandonner.
//
// D'où ce fichier : reconnaître ce refus, traduire le niveau demandé vers celui
// que le gabarit accepte (le plus proche sur l'échelle), rejouer le tour SANS
// toucher à l'historique, et retenir la traduction pour ce modèle afin de ne
// pas repayer l'aller-retour à chaque message.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
)
// effortLadder : les niveaux connus, du plus faible au plus fort. Sert à choisir
// un remplaçant PROCHE quand le gabarit refuse la valeur demandée — « high »
// devient « xhigh » (voisin immédiat) plutôt que « low ».
//
// La liste vaut pour tous les gabarits croisés jusqu'ici : gpt-oss lit
// low/medium/high, Qwen3.8 lit low/medium/xhigh, certains ajoutent « minimal ».
// Une valeur hors échelle n'est pas classable : on retire alors le champ plutôt
// que d'inventer un niveau.
var effortLadder = []string{"none", "minimal", "low", "medium", "high", "xhigh"}
// effortRank renvoie la position d'un niveau sur l'échelle, ou -1 s'il est
// inconnu.
func effortRank(v string) int {
v = strings.ToLower(strings.TrimSpace(v))
for i, s := range effortLadder {
if s == v {
return i
}
}
return -1
}
// effortWords capte les niveaux cités dans un message d'erreur. Les bornes de
// mot évitent de lire « high » à l'intérieur de « xhigh ».
var effortWords = regexp.MustCompile(`\b(none|minimal|low|medium|high|xhigh)\b`)
// effortRejection dit si le corps d'erreur renvoyé par llama-server est un refus
// de la valeur de `reasoning_effort` par le gabarit, et renvoie les niveaux que
// ce gabarit déclare accepter (éventuellement vide : tous ne les listent pas).
//
// La détection reste volontairement large — le texte vient du gabarit du modèle,
// pas de llama.cpp, donc sa formulation change d'un modèle à l'autre. Le seul
// invariant : il parle de reasoning effort et dit que la valeur ne va pas.
func effortRejection(body string) (supported []string, ok bool) {
low := strings.ToLower(body)
if !strings.Contains(low, "reasoning effort") && !strings.Contains(low, "reasoning_effort") {
return nil, false
}
refus := false
for _, w := range []string{"unexpected", "unsupported", "not supported", "invalid", "unknown"} {
if strings.Contains(low, w) {
refus = true
break
}
}
if !refus {
return nil, false
}
// Les niveaux acceptés sont annoncés APRÈS « supported » (« Supported types
// are xhigh (default), medium, and low. »). Ne lire que cette fin de message
// évite de prendre pour une liste le niveau refusé, cité juste avant.
if i := strings.LastIndex(low, "supported"); i >= 0 {
for _, m := range effortWords.FindAllString(low[i:], -1) {
if effortRank(m) >= 0 && !slicesHas(supported, m) {
supported = append(supported, m)
}
}
}
return supported, true
}
func slicesHas(list []string, v string) bool {
for _, s := range list {
if s == v {
return true
}
}
return false
}
// nearestEffort traduit le niveau demandé vers le plus proche parmi ceux que le
// gabarit accepte. Renvoie "" quand il n'y a rien de sensé à envoyer : le champ
// est alors simplement retiré de la requête, et le gabarit reprend son défaut.
//
// À égalité de distance on prend le niveau le PLUS FORT : demander « haute » et
// se retrouver avec « moyenne » dégrade la réponse en silence, alors qu'un cran
// au-dessus ne coûte que du temps de génération.
//
// « none » n'est jamais traduit : c'est une consigne de couper le raisonnement,
// pas une intensité. Un gabarit qui la refuse reçoit déjà `enable_thinking:
// false` par chat_template_kwargs (backend_models.go), qui dit la même chose
// dans une langue que tous comprennent.
func nearestEffort(want string, supported []string) string {
want = strings.ToLower(strings.TrimSpace(want))
if want == "" || want == "none" {
return ""
}
rank := effortRank(want)
if rank < 0 {
return ""
}
best, bestRank := "", -1
for _, s := range supported {
r := effortRank(s)
if r < 0 || s == want {
continue // s == want : le gabarit ne peut pas à la fois refuser et accepter
}
if best == "" || abs(r-rank) < abs(bestRank-rank) || (abs(r-rank) == abs(bestRank-rank) && r > bestRank) {
best, bestRank = s, r
}
}
return best
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// effortFromRejection combine les deux : à partir du corps d'erreur et du niveau
// demandé, renvoie le niveau à réessayer ("" = ne rien envoyer) et true si le
// refus a bien été reconnu.
func effortFromRejection(body, want string) (string, bool) {
supported, ok := effortRejection(body)
if !ok {
return "", false
}
return nearestEffort(want, supported), true
}
// effortFallbacks retient les traductions apprises, par modèle : sans ça chaque
// message repaierait un aller-retour 500 avant de tomber sur la bonne valeur.
// Clé = fichier de modèle + niveau demandé ; la valeur "" est significative
// (= ne rien envoyer), d'où sync.Map plutôt qu'une map avec test de zéro.
var effortFallbacks sync.Map
func effortKey(want string) string {
return filepath.Base(strings.TrimSpace(ReadConfig()["MODEL"])) + "\x00" + want
}
// effortResolve renvoie le niveau réellement envoyable pour la valeur demandée,
// c'est-à-dire la traduction déjà apprise pour ce modèle s'il y en a une.
func effortResolve(want string) string {
if want == "" {
return ""
}
if v, ok := effortFallbacks.Load(effortKey(want)); ok {
return v.(string)
}
return want
}
// effortRemember enregistre la traduction pour les messages suivants.
func effortRemember(want, got string) {
if want == "" {
return
}
effortFallbacks.Store(effortKey(want), got)
}
// logEffortFallback trace le repli sur stderr : sinon l'intensité choisie dans
// l'interface n'est pas celle qui part au moteur, sans que rien ne le dise.
func logEffortFallback(want, got string) {
if got == "" {
fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — champ retiré pour ce modèle\n", want)
return
}
fmt.Fprintf(os.Stderr, "[reasoning] gabarit du modèle : intensité %q refusée — repli sur %q pour ce modèle\n", want, got)
}
+119
View File
@@ -0,0 +1,119 @@
package loki
import "testing"
// Le message qui a motivé tout ce fichier : Qwen3.8-27B refuse « high », le
// niveau que l'interface enregistre par défaut, et llama-server répond 500.
const qwenRefus = `{"error":{"code":500,"message":"\n------------\nWhile executing CallExpression at line 49, column 28 in source:\n...', 'low') %}\n {{- raise_exception('Unexpected reasoning effort ' ~ reason...\n ^\nError: Jinja Exception: Unexpected reasoning effort high. Supported types are xhigh (default), medium, and low.","type":"server_error"}}`
func TestEffortRejection(t *testing.T) {
sup, ok := effortRejection(qwenRefus)
if !ok {
t.Fatal("refus de reasoning_effort non reconnu")
}
// « high » est cité AVANT la liste (c'est la valeur refusée) : le lire comme
// une valeur acceptée renverrait loki sur la même erreur en boucle.
want := map[string]bool{"xhigh": true, "medium": true, "low": true}
if len(sup) != len(want) {
t.Fatalf("niveaux acceptés = %v, attendu %v", sup, want)
}
for _, s := range sup {
if !want[s] {
t.Errorf("niveau %q lu à tort comme accepté (%v)", s, sup)
}
}
// Les autres 500 (prompt trop long, appel d'outil illisible) ne doivent pas
// passer par ce filet : ils ont le leur.
for _, body := range []string{
"",
`{"error":{"code":500,"message":"the request exceeds the available context size"}}`,
`{"error":{"code":500,"message":"Failed to parse tool call"}}`,
// Parle bien de reasoning_effort mais ne le refuse pas.
`{"error":{"code":500,"message":"reasoning_effort applied"}}`,
} {
if _, ok := effortRejection(body); ok {
t.Errorf("corps %q pris à tort pour un refus d'intensité", body)
}
}
}
// Sans liste de niveaux acceptés, il n'y a rien à deviner : on retire le champ.
func TestEffortRejectionSansListe(t *testing.T) {
sup, ok := effortRejection("Error: Unknown reasoning effort 'xhigh'")
if !ok {
t.Fatal("refus non reconnu")
}
if len(sup) != 0 {
t.Fatalf("niveaux acceptés = %v, attendu aucun", sup)
}
if got := nearestEffort("xhigh", sup); got != "" {
t.Errorf("nearestEffort sans liste = %q, attendu \"\"", got)
}
}
func TestNearestEffort(t *testing.T) {
qwen := []string{"xhigh", "medium", "low"}
oss := []string{"low", "medium", "high"}
cases := []struct {
want string
supported []string
expect string
}{
{"high", qwen, "xhigh"}, // voisin immédiat vers le haut
{"xhigh", oss, "high"}, // voisin immédiat vers le bas
{"medium", qwen, "low"}, // « medium » refusé quand même : on ne le renvoie pas
{"low", []string{"medium", "high"}, "medium"},
{"high", nil, ""}, // rien d'annoncé : on retire le champ
{"", qwen, ""}, // auto : il n'y avait rien à envoyer
{"none", qwen, ""}, // consigne de coupure, pas une intensité
{"maximum", qwen, ""}, // hors échelle : on n'invente pas
}
for _, c := range cases {
if got := nearestEffort(c.want, c.supported); got != c.expect {
t.Errorf("nearestEffort(%q, %v) = %q, attendu %q", c.want, c.supported, got, c.expect)
}
}
// À égalité de distance, le niveau le plus FORT : dégrader en silence est
// pire que générer un peu plus longtemps.
if got := nearestEffort("medium", []string{"low", "high"}); got != "high" {
t.Errorf("égalité de distance = %q, attendu high", got)
}
}
func TestEffortFromRejection(t *testing.T) {
got, ok := effortFromRejection(qwenRefus, "high")
if !ok || got != "xhigh" {
t.Fatalf("effortFromRejection = %q, %v ; attendu xhigh, true", got, ok)
}
if _, ok := effortFromRejection("boom", "high"); ok {
t.Error("un 500 quelconque ne doit pas déclencher le repli d'intensité")
}
}
// La traduction apprise doit valoir pour les messages SUIVANTS (sinon chaque
// message repaie un aller-retour 500) et rester attachée au modèle : un autre
// modèle a un autre gabarit, donc d'autres niveaux.
func TestEffortMemoire(t *testing.T) {
testHome(t)
if err := WriteConfig(map[string]string{"MODEL": "/data/models/qwen.gguf"}); err != nil {
t.Fatal(err)
}
if got := effortResolve("high"); got != "high" {
t.Fatalf("sans refus connu : %q, attendu high", got)
}
effortRemember("high", "xhigh")
if got := effortResolve("high"); got != "xhigh" {
t.Errorf("après apprentissage : %q, attendu xhigh", got)
}
if err := WriteConfig(map[string]string{"MODEL": "/data/models/gpt-oss.gguf"}); err != nil {
t.Fatal(err)
}
if got := effortResolve("high"); got != "high" {
t.Errorf("autre modèle : %q, attendu high (gabarit inconnu)", got)
}
// Champ retiré ("") : c'est une réponse apprise, pas une absence de réponse.
effortRemember("high", "")
if got := effortResolve("high"); got != "" {
t.Errorf("repli « champ retiré » : %q, attendu \"\"", got)
}
}
+416
View File
@@ -0,0 +1,416 @@
package loki
// lsp.go — client LSP minimal pour le VÉRIFICATEUR DE CODE : après chaque
// write/edit en mode code, les diagnostics du serveur de langage (erreurs de
// compilation, types, imports) sont ajoutés AU RÉSULTAT DE L'OUTIL — le modèle
// apprend immédiatement que son code ne compile pas, sans lancer de build.
// Repris de l'intégration LSP d'OpenFox (manager + languages.json — voir
// NOTICE.md), réécrit en Go réduit à l'essentiel : initialize, didOpen/
// didChange, publishDiagnostics. Pas de complétion, pas de hover — Loki n'est
// pas un éditeur.
//
// La table langage → serveur vit dans lsp_languages.json (embarquée). Un
// serveur absent du PATH désactive silencieusement son langage : le mode code
// marche sans LSP, il vérifie juste moins tôt.
import (
"bufio"
_ "embed"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
//go:embed lsp_languages.json
var lspLanguagesJSON []byte
type lspLanguage struct {
ID string `json:"id"`
Command []string `json:"command"`
Extensions []string `json:"extensions"`
}
var (
lspLangsOnce sync.Once
lspLangs []lspLanguage
)
func lspLanguages() []lspLanguage {
lspLangsOnce.Do(func() {
_ = json.Unmarshal(lspLanguagesJSON, &lspLangs)
})
return lspLangs
}
// lspLangFor renvoie la config du langage d'un fichier, nil si aucun.
func lspLangFor(path string) *lspLanguage {
ext := strings.ToLower(filepath.Ext(path))
if ext == "" {
return nil
}
for i := range lspLanguages() {
for _, e := range lspLangs[i].Extensions {
if e == ext {
return &lspLangs[i]
}
}
}
return nil
}
// --- Serveur -----------------------------------------------------------------
const (
lspDiagWait = 2500 * time.Millisecond // attente max des diagnostics
lspIdleTimeout = 5 * time.Minute // arrêt d'un serveur inactif
lspMaxDiags = 10 // diagnostics montrés au modèle
)
type lspServer struct {
lang string
root string
cmd *exec.Cmd
stdin io.WriteCloser
mu sync.Mutex // sérialise les requêtes (un didOpen+attente à la fois)
nextID int
version map[string]int // uri → version de document
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
diagMu sync.Mutex
diags map[string][]lspDiagnostic
diagCh chan string // uri fraîchement publié
lastUsed time.Time
dead bool
}
type lspDiagnostic struct {
Range struct {
Start struct {
Line int `json:"line"`
Character int `json:"character"`
} `json:"start"`
} `json:"range"`
Severity int `json:"severity"`
Message string `json:"message"`
Source string `json:"source"`
}
var (
lspMu sync.Mutex
lspServers = map[string]*lspServer{} // clé : lang + racine
)
// lspDiagBlock : le bloc à AJOUTER au résultat d'un write/edit réussi.
// "" si pas de mode code, pas de serveur pour ce langage, ou aucun diagnostic.
func lspDiagBlock(absPath string, caps Caps) string {
if !caps.Code {
return ""
}
diags := lspDiagnosticsFor(absPath)
if diags == "" {
return ""
}
return "\n\n[diagnostics LSP]\n" + diags
}
// lspDiagnosticsFor ouvre (ou met à jour) le document auprès du serveur de son
// langage et attend ses diagnostics. Best-effort borné : au pire lspDiagWait.
func lspDiagnosticsFor(absPath string) string {
lang := lspLangFor(absPath)
if lang == nil {
return ""
}
if _, err := exec.LookPath(lang.Command[0]); err != nil {
return "" // serveur non installé : le mode code marche sans
}
root := agentCwd()
// Le dépôt cloné est la vraie racine projet quand le fichier est dedans :
// gopls a besoin du go.mod, tsserver du tsconfig.
if r := projectRootFor(absPath, root); r != "" {
root = r
}
srv, err := lspGet(lang, root)
if err != nil {
return ""
}
diags, ok := srv.check(absPath)
if !ok {
return ""
}
if len(diags) == 0 {
return ""
}
if len(diags) > lspMaxDiags {
diags = diags[:lspMaxDiags]
}
var b strings.Builder
for _, d := range diags {
sev := "info"
switch d.Severity {
case 1:
sev = "error"
case 2:
sev = "warning"
}
fmt.Fprintf(&b, "%d:%d [%s] %s\n", d.Range.Start.Line+1, d.Range.Start.Character+1, sev, strings.TrimSpace(d.Message))
}
return strings.TrimRight(b.String(), "\n")
}
// projectRootFor remonte du fichier vers ws en cherchant un marqueur de racine
// de projet. Renvoie "" si rien trouvé au-dessus du fichier (dans la borne ws).
func projectRootFor(absPath, ws string) string {
dir := filepath.Dir(absPath)
for {
for _, marker := range []string{"go.mod", "package.json", "pyproject.toml", ".git"} {
if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
return dir
}
}
if rel, ok := relWithin(ws, dir); !ok || rel == "" || rel == "." {
return ""
}
parent := filepath.Dir(dir)
if parent == dir {
return ""
}
dir = parent
}
}
// lspGet renvoie le serveur (lang, racine), démarré au besoin.
func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
key := lang.ID + "\x00" + root
lspMu.Lock()
defer lspMu.Unlock()
if s := lspServers[key]; s != nil && !s.dead {
return s, nil
}
s, err := lspStart(lang, root)
if err != nil {
return nil, err
}
lspServers[key] = s
return s, nil
}
func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
cmd := exec.Command(lang.Command[0], lang.Command[1:]...)
cmd.Dir = root
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, err
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
cmd.Stderr = io.Discard
if err := cmd.Start(); err != nil {
return nil, err
}
s := &lspServer{
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
version: map[string]int{}, diags: map[string][]lspDiagnostic{},
diagCh: make(chan string, 16), lastUsed: time.Now(),
}
go s.readLoop(stdout)
go s.idleWatch()
// initialize / initialized. La réponse est consommée par readLoop ; on
// n'attend pas les capacités — on n'utilise que les diagnostics.
s.send("initialize", map[string]any{
"processId": nil,
"rootUri": fileURI(root),
"capabilities": map[string]any{
"textDocument": map[string]any{
"publishDiagnostics": map[string]any{},
"synchronization": map[string]any{"didSave": false},
},
},
"workspaceFolders": []map[string]any{{"uri": fileURI(root), "name": filepath.Base(root)}},
}, true)
s.notify("initialized", map[string]any{})
return s, nil
}
// check ouvre (ou met à jour) le document et attend ses diagnostics.
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.dead {
return nil, false
}
s.lastUsed = time.Now()
content, err := os.ReadFile(absPath)
if err != nil {
return nil, false
}
uri := fileURI(absPath)
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
s.diagMu.Lock()
delete(s.diags, uri)
s.diagMu.Unlock()
v, open := s.version[uri]
if !open {
s.version[uri] = 1
s.notify("textDocument/didOpen", map[string]any{
"textDocument": map[string]any{
"uri": uri, "languageId": s.lang, "version": 1, "text": string(content),
},
})
} else {
v++
s.version[uri] = v
s.notify("textDocument/didChange", map[string]any{
"textDocument": map[string]any{"uri": uri, "version": v},
"contentChanges": []map[string]any{{"text": string(content)}},
})
}
// Attend la publication pour CET uri, bornée.
deadline := time.After(lspDiagWait)
for {
select {
case got := <-s.diagCh:
if got != uri {
continue
}
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
return d, true
case <-deadline:
// Pas de publication à temps : certains serveurs ne publient rien
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
s.diagMu.Lock()
d := s.diags[uri]
s.diagMu.Unlock()
return d, true
}
}
}
// readLoop parse les messages LSP (en-têtes Content-Length) et garde les
// publishDiagnostics ; tout le reste est ignoré.
func (s *lspServer) readLoop(r io.Reader) {
br := bufio.NewReaderSize(r, 1<<20)
for {
length := 0
for {
line, err := br.ReadString('\n')
if err != nil {
s.markDead()
return
}
line = strings.TrimSpace(line)
if line == "" {
break
}
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
fmt.Sscanf(strings.TrimSpace(v), "%d", &length)
}
}
if length <= 0 || length > 32<<20 {
s.markDead()
return
}
buf := make([]byte, length)
if _, err := io.ReadFull(br, buf); err != nil {
s.markDead()
return
}
var msg struct {
Method string `json:"method"`
Params struct {
URI string `json:"uri"`
Diagnostics []lspDiagnostic `json:"diagnostics"`
} `json:"params"`
}
if json.Unmarshal(buf, &msg) != nil {
continue
}
if msg.Method == "textDocument/publishDiagnostics" {
s.diagMu.Lock()
s.diags[msg.Params.URI] = msg.Params.Diagnostics
s.diagMu.Unlock()
select {
case s.diagCh <- msg.Params.URI:
default: // personne n'attend : la publication reste dans s.diags
}
}
}
}
func (s *lspServer) idleWatch() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
s.mu.Lock()
idle := time.Since(s.lastUsed) > lspIdleTimeout
dead := s.dead
s.mu.Unlock()
if dead {
return
}
if idle {
s.shutdown()
return
}
}
}
func (s *lspServer) shutdown() {
s.markDead()
if s.cmd.Process != nil {
// shutdown/exit protocolaires seraient plus polis, mais le process peut
// être déjà figé — kill borné est fiable et le serveur n'a pas d'état.
_ = s.cmd.Process.Kill()
}
lspMu.Lock()
delete(lspServers, s.lang+"\x00"+s.root)
lspMu.Unlock()
}
func (s *lspServer) markDead() {
s.mu.Lock()
s.dead = true
s.mu.Unlock()
}
func (s *lspServer) send(method string, params any, isRequest bool) {
s.nextID++
msg := map[string]any{"jsonrpc": "2.0", "method": method, "params": params}
if isRequest {
msg["id"] = s.nextID
}
s.writeMsg(msg)
}
func (s *lspServer) notify(method string, params any) { s.send(method, params, false) }
func (s *lspServer) writeMsg(msg map[string]any) {
b, err := json.Marshal(msg)
if err != nil {
return
}
_, err = fmt.Fprintf(s.stdin, "Content-Length: %d\r\n\r\n%s", len(b), b)
if err != nil {
s.markDead()
}
}
// fileURI convertit un chemin absolu en URI file://.
func fileURI(path string) string {
p := filepath.ToSlash(path)
if !strings.HasPrefix(p, "/") {
p = "/" + p // Windows : C:\x → /C:/x
}
return "file://" + p
}
// Pas d'arrêt global : les serveurs LSP sont des enfants du process loki et
// meurent avec lui (et l'inactivité de 5 min couvre le reste — idleWatch).
+17
View File
@@ -0,0 +1,17 @@
[
{
"id": "go",
"command": ["gopls", "serve"],
"extensions": [".go"]
},
{
"id": "typescript",
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]
},
{
"id": "python",
"command": ["pyright-langserver", "--stdio"],
"extensions": [".py"]
}
]
+37
View File
@@ -79,3 +79,40 @@ func TestReasoningEffortValue(t *testing.T) {
}
}
}
func TestReasoningExplicitlyOff(t *testing.T) {
// Clé absente ou vide = pas de consigne, surtout pas une interdiction.
for _, v := range []string{"", " ", "on", "auto", "deepseek"} {
if reasoningExplicitlyOff(v) {
t.Errorf("reasoningExplicitlyOff(%q) = true, attendu false", v)
}
}
for _, v := range []string{"off", "none", "0", "false", " Disabled "} {
if !reasoningExplicitlyOff(v) {
t.Errorf("reasoningExplicitlyOff(%q) = false, attendu true", v)
}
}
}
func TestReasoningTemplateKwargs(t *testing.T) {
// « aucune » doit parler aux gabarits hybrides, pas seulement à ceux qui
// lisent reasoning_effort : sinon le modèle réfléchit quand même.
if got := reasoningTemplateKwargs(false, "none"); got == nil || got["enable_thinking"] != false {
t.Errorf("effort none : %v, attendu enable_thinking=false", got)
}
if got := reasoningTemplateKwargs(true, ""); got == nil || got["enable_thinking"] != false {
t.Errorf("REASONING=off : %v, attendu enable_thinking=false", got)
}
// Un niveau explicite voyage aussi dans les kwargs : les binaires anciens ne
// relaient pas le champ de haut niveau au gabarit.
if got := reasoningTemplateKwargs(false, "high"); got == nil || got["reasoning_effort"] != "high" {
t.Errorf("effort high : %v, attendu reasoning_effort=high", got)
}
if _, ok := reasoningTemplateKwargs(false, "high")["enable_thinking"]; ok {
t.Error("un niveau explicite ne doit pas couper le raisonnement")
}
// Auto = aucune consigne : on n'écrit rien dans la requête.
if got := reasoningTemplateKwargs(false, ""); got != nil {
t.Errorf("auto : %v, attendu nil", got)
}
}
+38 -2
View File
@@ -3,10 +3,12 @@ package loki
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
@@ -37,6 +39,13 @@ const (
// mcpConnectTimeout borne l'établissement d'une session (handshake initialize
// + tools/list). Un serveur qui rame ne doit pas figer le tour de chat.
mcpConnectTimeout = 20 * time.Second
// mcpConnectTimeoutCold : npx et uvx TÉLÉCHARGENT le paquet du serveur au
// premier lancement (le catalogue les utilise tous les deux). Sur un réseau
// ou un disque lents, ça dépasse largement 20 s, et l'utilisateur voyait
// « enregistré mais connexion échouée : context deadline exceeded » pour un
// serveur parfaitement sain. Les lancements suivants profitent du cache et
// retombent dans le régime normal.
mcpConnectTimeoutCold = 3 * time.Minute
// mcpCallTimeout borne un appel d'outil MCP.
mcpCallTimeout = 120 * time.Second
// mcpMaxOutput cap la sortie renvoyée au modèle (cohérent avec toolMaxOutput).
@@ -174,11 +183,11 @@ func (m *mcpManager) ensure(name string, cfg MCPServerConfig) *mcpSession {
m.mu.Unlock()
// Connexion hors-lock (peut être lente).
ctx, cancel := context.WithTimeout(context.Background(), mcpConnectTimeout)
ctx, cancel := context.WithTimeout(context.Background(), mcpTimeoutFor(cfg))
defer cancel()
sess, err := mcpConnect(ctx, name, cfg)
if err != nil {
s.err = err
s.err = mcpFriendlyErr(err, cfg)
} else {
s.sess = sess
lt, lerr := sess.ListTools(ctx, nil)
@@ -509,3 +518,30 @@ func MCPStatus() ([]MCPServerStatus, error) {
}
return out, nil
}
// mcpTimeoutFor choisit le délai de connexion d'un serveur : long pour un
// lanceur qui télécharge son paquet à la volée (npx, uvx), court sinon.
func mcpTimeoutFor(cfg MCPServerConfig) time.Duration {
base := strings.ToLower(filepath.Base(cfg.Command))
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
if base == "npx" || base == "uvx" {
return mcpConnectTimeoutCold
}
return mcpConnectTimeout
}
// mcpFriendlyErr habille l'erreur de connexion la plus vécue : le délai
// dépassé d'un npx/uvx en plein téléchargement. L'erreur brute (« context
// deadline exceeded ») envoyait l'utilisateur chercher un bug de config alors
// qu'il suffit souvent de réessayer une fois le paquet en cache.
func mcpFriendlyErr(err error, cfg MCPServerConfig) error {
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
return err
}
base := strings.ToLower(filepath.Base(cfg.Command))
base = strings.TrimSuffix(strings.TrimSuffix(base, ".exe"), ".cmd")
if base == "npx" || base == "uvx" {
return fmt.Errorf("délai dépassé — %s télécharge le paquet du serveur au premier lancement, ce qui peut être long ; clique « tester » pour réessayer (le paquet reste en cache), et vérifie que le conteneur atteint le registre (npm/PyPI)", base)
}
return fmt.Errorf("délai dépassé — le serveur n'a pas répondu au handshake en %s", mcpConnectTimeout)
}
+105
View File
@@ -0,0 +1,105 @@
package loki
import (
"reflect"
"testing"
)
// Repris de l'amont AJEAN (v0.9.8) avec le correctif : les tests valent tels
// quels, la fonction étant portée à l'identique.
//
// LE test qui rassure : sur une conversation NORMALE (un seul system en tête,
// suivi de user/assistant/tool, appels d'outils et contenu multimodal compris),
// la normalisation ne doit RIEN changer — même contenu, même ordre. C'est la
// garantie que le fix est invisible pour les 99 % de cas qui marchaient déjà.
func TestNormalizeSystemMessagesNoOpOnNormalConversation(t *testing.T) {
in := []Message{
{Role: "system", Content: "Tu es Loki."},
{Role: "user", Content: "cherche la météo"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_1", Type: "function"}}},
{Role: "tool", Content: "résultat brut", ToolCallID: "call_1"},
{Role: "assistant", Content: "Il fait beau."},
// contenu multimodal (image) : Content n'est pas une string
{Role: "user", Content: []any{map[string]any{"type": "text", "text": "et demain ?"}}},
}
// copie profonde de référence pour détecter toute mutation de l'entrée
ref := append([]Message(nil), in...)
out := normalizeSystemMessages(in)
if !reflect.DeepEqual(out, in) {
t.Errorf("séquence normale modifiée !\navant: %#v\naprès: %#v", in, out)
}
if !reflect.DeepEqual(in, ref) {
t.Errorf("l'entrée a été mutée (interdit) : %#v", in)
}
}
// L'ordre relatif de tous les messages NON-system (et le lien assistant→tool via
// ToolCallID) doit être préservé quand on ramène un system égaré en tête.
func TestNormalizeSystemMessagesPreservesOrderAndToolLink(t *testing.T) {
in := []Message{
{Role: "system", Content: "S1"},
{Role: "user", Content: "q"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_9"}}},
{Role: "tool", Content: "r", ToolCallID: "call_9"},
{Role: "system", Content: "n'appelle plus d'outil"}, // égaré, en fin
}
out := normalizeSystemMessages(in)
want := []Message{
{Role: "system", Content: "S1\n\nn'appelle plus d'outil"},
{Role: "user", Content: "q"},
{Role: "assistant", Content: "", ToolCalls: []ToolCall{{ID: "call_9"}}},
{Role: "tool", Content: "r", ToolCallID: "call_9"},
}
if !reflect.DeepEqual(out, want) {
t.Errorf("ordre/lien outil cassé :\n%#v", out)
}
}
// Un system inséré en fin de séquence (relance anti-boucle) ou en double doit
// être ramené en un seul bloc, en tête : sinon les gabarits Qwen3.x renvoient
// « System message must be at the beginning » (issue #26).
func TestNormalizeSystemMessages(t *testing.T) {
sys := func(c string) Message { return Message{Role: "system", Content: c} }
usr := func(c string) Message { return Message{Role: "user", Content: c} }
firstRoleSystemOnce := func(t *testing.T, out []Message) {
count := 0
for i, m := range out {
if m.Role == "system" {
count++
if i != 0 {
t.Errorf("system trouvé en position %d, doit être en 0", i)
}
}
}
if count > 1 {
t.Errorf("%d messages system, doit être fusionné en un seul", count)
}
}
t.Run("system en fin", func(t *testing.T) {
out := normalizeSystemMessages([]Message{sys("A"), usr("q"), sys("stop")})
firstRoleSystemOnce(t, out)
if out[0].Content != "A\n\nstop" {
t.Errorf("fusion attendue 'A\\n\\nstop', obtenu %q", out[0].Content)
}
if len(out) != 2 {
t.Fatalf("attendu 2 messages, obtenu %d", len(out))
}
})
t.Run("sans system, inchangé", func(t *testing.T) {
in := []Message{usr("a"), usr("b")}
out := normalizeSystemMessages(in)
if len(out) != 2 || out[0].Role != "user" {
t.Fatalf("séquence sans system ne doit pas changer : %#v", out)
}
})
t.Run("system vide ignoré", func(t *testing.T) {
out := normalizeSystemMessages([]Message{sys(" "), usr("q")})
if len(out) != 1 || out[0].Role != "user" {
t.Fatalf("un system vide ne doit rien ajouter : %#v", out)
}
})
}
+1 -1
View File
@@ -10,7 +10,7 @@ import (
"strings"
)
const Version = "0.9.4"
const Version = "0.12.3"
// Main est le vrai main() du binaire (cmd/loki ne fait que l'appeler).
func Main() {
+1
View File
@@ -33,6 +33,7 @@ const (
bkPrefs = "prefs" // préférences de l'UI web
bkState = "state" // clés, jetons, drapeaux, listes de dossiers, MCP
bkChat = "chat" // conversation partagée
bkTasks = "tasks" // tâches planifiées (une entrée JSON par tâche)
)
// La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré.
+67
View File
@@ -0,0 +1,67 @@
package loki
// sys_datavolume.go — garde-fou contre la PERTE DE DONNÉES en conteneur.
//
// Le symptôme vécu : modèles, discussions et fichiers disparus après un
// redémarrage du serveur. La cause, dans tous les cas observés : /data n'est
// PAS un volume monté (template Docker sans mapping, `docker run` sans -v,
// chemin hôte qui change d'un lancement à l'autre). Tout vit alors dans la
// couche d'écriture du conteneur, qui meurt avec lui à la première recréation
// (mise à jour d'image, compose down/up, redémarrage de l'array Unraid).
//
// Loki ne peut pas monter le volume à la place de l'utilisateur, mais il peut
// le VOIR et le DIRE : au démarrage en conteneur, si LOKI_HOME n'est pas un
// point de montage, on le journalise en rouge et l'UI l'affiche en bandeau.
import (
"bufio"
"os"
"path/filepath"
"strings"
)
// dataDirIsMounted dit si LokiHome() est un point de montage (bind mount ou
// volume Docker). Lecture de /proc/self/mountinfo — Linux seulement, or c'est
// le seul endroit où Loki tourne en conteneur. Hors conteneur : sans objet.
//
// Renvoie (monté, connu) : connu=false quand on n'a pas pu lire mountinfo —
// on ne crie pas au loup sur une simple erreur de lecture.
func dataDirIsMounted() (mounted, known bool) {
f, err := os.Open("/proc/self/mountinfo")
if err != nil {
return false, false
}
defer f.Close()
target := filepath.Clean(LokiHome())
sc := bufio.NewScanner(f)
for sc.Scan() {
// Format mountinfo : champs séparés par des espaces, le 5ᵉ (index 4)
// est le point de montage. Les espaces dans les chemins sont encodés
// \040 — décodés avant comparaison.
fields := strings.Fields(sc.Text())
if len(fields) < 5 {
continue
}
mp := strings.ReplaceAll(fields[4], `\040`, " ")
if filepath.Clean(mp) == target {
return true, true
}
}
return false, sc.Err() == nil
}
// dataVolumeWarning renvoie le message d'alerte si les données sont en périple
// ("" si tout va bien). Évalué au démarrage du serveur web et servi par
// /api/status pour le bandeau de l'UI.
func dataVolumeWarning() string {
if os.Getenv("LOKI_CONTAINER") == "" {
return "" // hors conteneur : l'utilisateur gère ses chemins
}
mounted, known := dataDirIsMounted()
if !known || mounted {
return ""
}
return "⚠️ " + LokiHome() + " n'est PAS un volume monté : modèles, discussions et fichiers seront PERDUS à la recréation du conteneur (mise à jour, redémarrage du serveur). " +
"Ajoute un mapping de volume — compose : « - /mnt/user/appdata/loki/data:/data » (et « …/models:/models ») ; " +
"docker run : « -v /chemin/hote/data:/data ». Voir docker-compose.unraid.yml du dépôt."
}
+50
View File
@@ -0,0 +1,50 @@
//go:build linux
package loki
// sys_disk_fs_linux.go — l'espace libre annoncé par le système est-il DIGNE DE
// FOI pour décider d'un téléchargement ?
//
// Sur Unraid, les partages vivent sous /mnt/user, servi par shfs — un système
// de fichiers FUSE qui agrège plusieurs disques et le cache. statfs(2) n'y
// renvoie pas l'espace du partage : selon la configuration, c'est celui d'UN
// disque, ou du pool de cache. Vécu : « il ne reste que 11,9 Go » sur une
// machine qui en a des centaines de libres, et le téléchargement du modèle
// REFUSÉ sur cette base.
//
// Même problème sur les montages réseau (NFS, CIFS) et sur la couche
// d'écriture d'un conteneur (overlayfs), qui rapporte le disque virtuel Docker
// et non le volume monté dessous.
//
// On ne cherche pas à corriger le chiffre — c'est impossible depuis l'intérieur
// du conteneur. On sait juste dire « ce chiffre est indicatif », et on cesse
// alors de bloquer sur lui.
import "syscall"
// Magies de systèmes de fichiers dont statfs ment (ou approxime) sur l'espace
// libre. Valeurs de <linux/magic.h>.
const (
fuseSuperMagic = 0x65735546 // FUSE — shfs d'Unraid, sshfs, rclone…
overlayfsMagic = 0x794c7630 // couche d'écriture d'un conteneur
nfsSuperMagic = 0x6969
smbSuperMagic = 0x517b // CIFS/SMB1
smb2MagicNumber = 0xfe534d42 // CIFS/SMB2+
cephSuperMagic = 0x00c36400
glusterfsSuperMag = 0x58465342
)
// diskFreeReliable dit si l'espace libre rapporté pour dir peut servir à
// REFUSER une écriture. false = on affiche le chiffre, mais on ne bloque plus.
func diskFreeReliable(dir string) bool {
var st syscall.Statfs_t
if err := syscall.Statfs(dir, &st); err != nil {
return false
}
switch uint32(st.Type) {
case fuseSuperMagic, overlayfsMagic, nfsSuperMagic,
smbSuperMagic, smb2MagicNumber, cephSuperMagic, glusterfsSuperMag:
return false
}
return true
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !linux
package loki
// Hors Linux, on n'a pas de table de magies de systèmes de fichiers à
// consulter : l'espace libre rapporté est considéré comme fiable (c'est le cas
// des volumes locaux Windows et macOS, les seules cibles concernées ici).
func diskFreeReliable(string) bool { return true }
+104
View File
@@ -0,0 +1,104 @@
//go:build linux
package loki
// sys_loadpct_linux.go — pourcentage de CHARGEMENT du modèle, pour la pastille
// « chargement… » de l'UI qui restait muette de longues minutes sur un gros
// GGUF (un 27B Q4 met facilement 1 à 3 minutes à monter en VRAM).
//
// llama-server n'expose aucun progrès pendant le chargement (/health renvoie
// juste 503). Mais le chargement, c'est LIRE le fichier .gguf : on mesure donc
// les octets lus par le process moteur (/proc/<pid>/io, read_bytes — les pages
// mmap rentrées comptent dedans) et on les rapporte à la taille du fichier.
// Approximation honnête : monotone, ~0→100, et surtout elle BOUGE — c'est ce
// qui distingue « ça charge » de « c'est planté ».
import (
"os"
"path/filepath"
"strconv"
"strings"
)
// procVmRSS lit la mémoire résidente (octets) d'un process — /proc/<pid>/status,
// ligne « VmRSS: N kB ». 0 si illisible.
func procVmRSS(pid int) int64 {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status")
if err != nil {
return 0
}
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "VmRSS:"); ok {
v = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB"))
if n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64); err == nil {
return n * 1024
}
}
}
return 0
}
// engineLoadPct renvoie le pourcentage estimé de chargement du modèle, ou -1
// quand il n'est pas mesurable (pas de PID, /proc illisible, taille inconnue).
func engineLoadPct() int {
pid := readServicePID()
if pid <= 0 {
return -1
}
cfg := ReadConfig()
model := cfg["MODEL"]
if model == "" {
return -1
}
path, err := resolveServeModelPath(model)
if err != nil {
return -1
}
size := modelTotalSize(path)
if size <= 0 {
return -1
}
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/io")
if err != nil {
return -1
}
var read int64 = -1
for _, line := range strings.Split(string(b), "\n") {
if v, ok := strings.CutPrefix(line, "read_bytes:"); ok {
read, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
break
}
}
if read < 0 {
return -1
}
// Modèle déjà dans le cache disque (redémarrage à chaud) : llama-server ne
// LIT plus rien sur le disque — les pages viennent du cache, read_bytes
// reste à zéro et le pourcentage restait figé à 0 jusqu'au « prêt ». La
// mémoire résidente (VmRSS), elle, grandit quoi qu'il arrive : les pages du
// .gguf touchées via mmap y comptent, cache ou pas. On prend le plus avancé
// des deux — approximatif (l'offload GPU peut relâcher des pages), mais le
// compteur BOUGE au lieu de mentir.
if rss := procVmRSS(pid); rss > read {
read = rss
}
pct := int(read * 100 / size)
if pct > 99 {
// 100 % n'existe pas ici : c'est /health qui dit « prêt ». Au-delà de 99
// (relectures, autres IO du process), on plafonne.
pct = 99
}
return pct
}
// modelTotalSize : taille du modèle, shards compris (model-00001-of-00004.gguf
// → somme des tranches présentes — c'est tout ça qu'il faut charger).
func modelTotalSize(path string) int64 {
if total := shardFamilySize(filepath.Dir(path), filepath.Base(path)); total > 0 {
return total
}
if st, err := os.Stat(path); err == nil {
return st.Size()
}
return 0
}
+7
View File
@@ -0,0 +1,7 @@
//go:build !linux
package loki
// Hors Linux, pas de /proc/<pid>/io : le pourcentage de chargement n'est pas
// mesurable, l'UI garde « chargement… » sans chiffre.
func engineLoadPct() int { return -1 }
+6
View File
@@ -262,6 +262,12 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
return exec.CommandContext(ctx, "/bin/bash", "-c", command)
}
// newShellCmdDetached : commande shell SANS contexte, pour les jobs
// d'arrière-plan (bash_bg) qui doivent survivre au tour qui les a lancés.
func newShellCmdDetached(command string) *exec.Cmd {
return exec.Command("/bin/bash", "-c", command)
}
// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram. Linux : /proc/meminfo.
// macOS : sysctl pour le total, vm_stat pour ce qui est réellement libre (pages
// free + inactive + speculative ; le reste — wired, active, compressé — est
+6
View File
@@ -391,6 +391,12 @@ func newShellCmd(ctx context.Context, command string) *exec.Cmd {
return hideCmd(exec.CommandContext(ctx, "cmd", "/C", command))
}
// newShellCmdDetached : commande shell SANS contexte, pour les jobs
// d'arrière-plan (bash_bg) qui doivent survivre au tour qui les a lancés.
func newShellCmdDetached(command string) *exec.Cmd {
return hideCmd(exec.Command("cmd", "/C", command))
}
// ramUsageMB renvoie (utilisée, totale) en Mo pour /api/ram (GlobalMemoryStatusEx).
func ramUsageMB() (used, total int) {
var m struct {
+8 -2
View File
@@ -67,7 +67,7 @@ var configTemplate = []struct{ key, help string }{
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
{"PORT", "port du moteur (défaut 8080)"},
{"CTX", "taille du contexte (défaut 32768)"},
{"NGL", "couches déportées sur le GPU (défaut 999 = tout ; auto = ce qui tient en VRAM)"},
{"NGL", "couches déportées sur le GPU (999 ou auto = ce qui tient en VRAM ; all = tout, quitte à saturer)"},
{"BATCH", "batch (défaut 2048)"},
{"UBATCH", "micro-batch (défaut 512)"},
{"THREADS", "threads CPU, 0 = auto"},
@@ -75,7 +75,13 @@ var configTemplate = []struct{ key, help string }{
{"KV_TYPE", "quantization du cache KV (q8_0, q4_0…) ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
{"TOP_P", "noyau de probabilité (top_p) ; vide = défaut du moteur"},
{"TOP_K", "top_k ; 0 = désactivé, vide = défaut du moteur"},
{"MIN_P", "seuil de probabilité (min_p) ; vide = défaut du moteur"},
{"PRESENCE_PENALTY", "pénalité de présence ; vide = défaut du moteur"},
{"REPEAT_PENALTY", "pénalité de répétition (1 = neutre) ; vide = défaut du moteur"},
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
+102
View File
@@ -0,0 +1,102 @@
package loki
// tasks.go — les tâches planifiées : des consignes que l'IA exécute toute seule,
// en arrière-plan, sur une fréquence réglable (intervalle simple ou cron). Chaque
// tâche tourne ISOLÉE de la conversation partagée (voir RunAutonomous dans
// tasks_run.go) : elle ne pollue pas le chat et livre son résultat via les propres
// outils de l'IA (mail via MCP, shell, web…). L'utilisateur peut désactiver une
// tâche, ou tout suspendre d'un coup via l'interrupteur maître (tasks_paused).
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"sort"
"time"
bolt "go.etcd.io/bbolt"
)
// Task est une tâche planifiée persistée dans le bucket bkTasks (clé = ID).
type Task struct {
ID string `json:"id"`
Name string `json:"name"`
Prompt string `json:"prompt"`
Schedule string `json:"schedule"` // "@every 2h" | "@every 1d@23:00" | expr cron 5 champs
TZ string `json:"tz"` // fuseau IANA du navigateur (ex. "Europe/Paris") pour l'heure
Preset string `json:"preset"` // id du preset à activer avant l'exécution (vide = preset actif)
// Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON
// (tâches créées avant ces champs) garde le comportement historique : accès.
NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche
NoWeb bool `json:"no_web"` // true = pas d'accès au web pour cette tâche
Enabled bool `json:"enabled"`
LastRun int64 `json:"last_run"` // epoch ms, 0 = jamais
NextRun int64 `json:"next_run"` // epoch ms, 0 = à (re)calculer
LastDurMs int64 `json:"last_dur_ms"` // durée du dernier passage en ms
LastOK bool `json:"last_ok"`
LastError string `json:"last_error"`
LastReport string `json:"last_report"` // dernier texte final de l'IA (compte-rendu court)
Running bool `json:"-"` // transitoire : une exécution est en cours
}
// newTaskID génère un identifiant court et unique.
func newTaskID() string {
var b [6]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// listTasks renvoie toutes les tâches, triées par nom pour un affichage stable.
func listTasks() []Task {
var out []Task
_ = view(bkTasks, func(b *bolt.Bucket) error {
return b.ForEach(func(_, v []byte) error {
var t Task
if json.Unmarshal(v, &t) == nil {
out = append(out, t)
}
return nil
})
})
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].ID < out[j].ID
})
return out
}
// getTask lit une tâche par ID. Renvoie false si absente.
func getTask(id string) (Task, bool) {
var t Task
ok := getJSON(bkTasks, id, &t)
return t, ok
}
// saveTask enregistre (crée ou remplace) une tâche.
func saveTask(t Task) error { return putJSON(bkTasks, t.ID, t) }
// deleteTask supprime une tâche par ID.
func deleteTask(id string) error { return putBytes(bkTasks, id, nil) }
// tasksPaused indique si l'interrupteur maître suspend TOUTES les tâches. On
// stocke « en pause » plutôt que « actif » pour que l'état par défaut (base
// neuve) soit « actif » sans dépendre d'une écriture : c'est un frein, pas un
// moteur.
func tasksPaused() bool { return getBool(bkState, "tasks_paused") }
func setTasksPaused(on bool) error { return putBool(bkState, "tasks_paused", on) }
// computeNextRun calcule le prochain déclenchement d'une tâche à partir de son
// Schedule (dans le fuseau tz), relatif à `from`. Renvoie 0 si le schedule est
// invalide (la tâche ne se déclenchera alors jamais — l'UI refuse déjà un
// schedule invalide à la sauvegarde, ceci n'est qu'un garde-fou).
func computeNextRun(schedule, tz string, from time.Time) int64 {
next, err := nextAfter(schedule, tz, from)
if err != nil {
return 0
}
return next.UnixMilli()
}
+251
View File
@@ -0,0 +1,251 @@
package loki
// tasks_cron.go — planification des tâches, résolution à la minute. Deux formes :
//
// - Intervalle : "@every <durée>" (ex. "@every 2h", "@every 30m"), parsé par
// time.ParseDuration, borné à ≥ 1 minute.
// - Cron : 5 champs "min hour dom month dow" avec *, listes a,b, plages a-b et
// pas */n (ex. "0 9 * * 1-5" = 9h du lundi au vendredi).
//
// Aucune dépendance externe : Go n'a pas de lib cron et on n'en ajoute pas pour
// si peu. nextAfter avance minute par minute jusqu'au prochain match — largement
// assez rapide pour une poignée de tâches évaluées une fois par minute.
import (
"fmt"
"strconv"
"strings"
"time"
)
// minInterval borne la fréquence : sous la minute, le scheduler (ticker 60 s) ne
// pourrait de toute façon pas suivre, et une tâche qui lance une inférence toutes
// les quelques secondes n'a pas de sens.
const minInterval = time.Minute
// loc résout un fuseau IANA (ex. « Europe/Paris »). Vide ou introuvable → heure
// locale du serveur. Nécessaire parce que le serveur tourne souvent en UTC : sans
// ça, « tous les jours à 23h » et les expressions cron se calculeraient en UTC et
// se déclencheraient à la mauvaise heure pour l'utilisateur.
func loc(tz string) *time.Location {
if tz == "" {
return time.Local
}
l, err := time.LoadLocation(tz)
if err != nil {
return time.Local
}
return l
}
// nextAfter renvoie le prochain instant (strictement après `from`) où `schedule`
// se déclenche, dans le fuseau tz. Erreur si le schedule est invalide.
//
// Formes acceptées :
// - "@every 30m" / "@every 2h" : intervalle glissant depuis `from`.
// - "@every 1d@23:00" : tous les N jours, ancré à une heure de la journée.
// - expression cron 5 champs.
func nextAfter(schedule, tz string, from time.Time) (time.Time, error) {
s := strings.TrimSpace(schedule)
if s == "" {
return time.Time{}, fmt.Errorf("fréquence vide")
}
if rest, ok := cutPrefix(s, "@every"); ok {
return nextEvery(strings.TrimSpace(rest), tz, from)
}
return nextCron(s, tz, from)
}
// nextEvery gère les intervalles « @every … », avec ou sans ancre horaire.
func nextEvery(spec, tz string, from time.Time) (time.Time, error) {
durPart, timePart, hasTime := strings.Cut(spec, "@")
durPart = strings.TrimSpace(durPart)
// Jours : time.ParseDuration ne connaît pas « d ». On les gère à part, ce qui
// permet aussi l'ancre horaire (« tous les N jours à HH:MM »).
if n, ok := cutSuffix(durPart, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days < 1 {
return time.Time{}, fmt.Errorf("nombre de jours invalide : %q", durPart)
}
if !hasTime {
return from.Add(time.Duration(days) * 24 * time.Hour), nil
}
hh, mm, err := parseHHMM(timePart)
if err != nil {
return time.Time{}, err
}
l := loc(tz)
now := from.In(l)
cand := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, l)
for !cand.After(now) {
cand = cand.AddDate(0, 0, days)
}
return cand, nil
}
d, err := time.ParseDuration(durPart)
if err != nil {
return time.Time{}, fmt.Errorf("intervalle invalide (ex. « 2h », « 30m ») : %w", err)
}
if d < minInterval {
return time.Time{}, fmt.Errorf("intervalle minimum : 1 minute")
}
return from.Add(d), nil
}
// parseHHMM lit « 23:00 » / « 9:30 » en heures/minutes.
func parseHHMM(s string) (int, int, error) {
hs, ms, ok := strings.Cut(strings.TrimSpace(s), ":")
if !ok {
return 0, 0, fmt.Errorf("heure invalide (attendu HH:MM) : %q", s)
}
h, err1 := strconv.Atoi(strings.TrimSpace(hs))
m, err2 := strconv.Atoi(strings.TrimSpace(ms))
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return 0, 0, fmt.Errorf("heure hors bornes : %q", s)
}
return h, m, nil
}
// cutSuffix : strings.CutSuffix (Go 1.20+) réécrit pour rester lisible ici.
func cutSuffix(s, suffix string) (string, bool) {
if strings.HasSuffix(s, suffix) {
return s[:len(s)-len(suffix)], true
}
return "", false
}
// validateSchedule vérifie qu'un schedule est acceptable, sans calculer de date.
func validateSchedule(schedule, tz string) error {
_, err := nextAfter(schedule, tz, time.Now())
return err
}
// cronSpec = les 5 champs compilés en ensembles de valeurs autorisées.
type cronSpec struct {
min, hour, dom, month, dow map[int]bool
}
// nextCron parse une expression cron 5 champs et cherche le prochain match à la
// minute, en partant de la minute qui suit `from`. Une borne (~366 j) évite une
// boucle infinie sur une expression jamais satisfiable (ex. « 31 février »).
func nextCron(expr, tz string, from time.Time) (time.Time, error) {
spec, err := parseCron(expr)
if err != nil {
return time.Time{}, err
}
// On repart de la minute suivante, dans le fuseau visé (l'heure murale de
// l'expression cron est celle de l'utilisateur, pas celle du serveur).
t := from.In(loc(tz)).Truncate(time.Minute).Add(time.Minute)
limit := t.Add(366 * 24 * time.Hour)
for ; t.Before(limit); t = t.Add(time.Minute) {
if spec.matches(t) {
return t, nil
}
}
return time.Time{}, fmt.Errorf("fréquence jamais satisfaite : %q", expr)
}
func (s cronSpec) matches(t time.Time) bool {
return s.min[t.Minute()] &&
s.hour[t.Hour()] &&
s.dom[t.Day()] &&
s.month[int(t.Month())] &&
s.dow[int(t.Weekday())]
}
func parseCron(expr string) (cronSpec, error) {
f := strings.Fields(expr)
if len(f) != 5 {
return cronSpec{}, fmt.Errorf("cron : 5 champs attendus « min heure jour mois jour-semaine », reçu %d", len(f))
}
var s cronSpec
var err error
if s.min, err = parseField(f[0], 0, 59); err != nil {
return cronSpec{}, fmt.Errorf("champ minute : %w", err)
}
if s.hour, err = parseField(f[1], 0, 23); err != nil {
return cronSpec{}, fmt.Errorf("champ heure : %w", err)
}
if s.dom, err = parseField(f[2], 1, 31); err != nil {
return cronSpec{}, fmt.Errorf("champ jour-du-mois : %w", err)
}
if s.month, err = parseField(f[3], 1, 12); err != nil {
return cronSpec{}, fmt.Errorf("champ mois : %w", err)
}
// Jour de la semaine : 0 ET 7 valent dimanche. On normalise 7 → 0.
if s.dow, err = parseField(f[4], 0, 7); err != nil {
return cronSpec{}, fmt.Errorf("champ jour-semaine : %w", err)
}
if s.dow[7] {
s.dow[0] = true
}
return s, nil
}
// parseField compile un champ cron (*, a, a-b, a,b, */n, a-b/n) en l'ensemble des
// valeurs autorisées dans [lo, hi].
func parseField(field string, lo, hi int) (map[int]bool, error) {
out := map[int]bool{}
for _, part := range strings.Split(field, ",") {
rng := part
step := 1
if base, st, ok := strings.Cut(part, "/"); ok {
n, err := strconv.Atoi(st)
if err != nil || n < 1 {
return nil, fmt.Errorf("pas invalide : %q", part)
}
step = n
rng = base
}
start, end := lo, hi
if rng != "*" {
if a, b, ok := strings.Cut(rng, "-"); ok {
var err error
if start, err = atoiRange(a, lo, hi); err != nil {
return nil, err
}
if end, err = atoiRange(b, lo, hi); err != nil {
return nil, err
}
} else {
v, err := atoiRange(rng, lo, hi)
if err != nil {
return nil, err
}
start, end = v, v
}
}
if start > end {
return nil, fmt.Errorf("plage inversée : %q", part)
}
for v := start; v <= end; v += step {
out[v] = true
}
}
if len(out) == 0 {
return nil, fmt.Errorf("champ vide")
}
return out, nil
}
func atoiRange(s string, lo, hi int) (int, error) {
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0, fmt.Errorf("valeur invalide : %q", s)
}
if v < lo || v > hi {
return 0, fmt.Errorf("valeur %d hors bornes [%d-%d]", v, lo, hi)
}
return v, nil
}
// cutPrefix reconnaît le préfixe "@every" (insensible à la casse) et renvoie le
// reste. strings.CutPrefix existe (Go 1.20+) mais est sensible à la casse.
func cutPrefix(s, prefix string) (string, bool) {
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
return s[len(prefix):], true
}
return "", false
}
+142
View File
@@ -0,0 +1,142 @@
package loki
import (
"testing"
"time"
)
func TestNextAfterInterval(t *testing.T) {
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
got, err := nextAfter("@every 2h", "", from)
if err != nil {
t.Fatalf("erreur inattendue : %v", err)
}
want := from.Add(2 * time.Hour)
if !got.Equal(want) {
t.Fatalf("@every 2h : got %v, want %v", got, want)
}
// Casse tolérée sur le préfixe.
if _, err := nextAfter("@EVERY 30m", "", from); err != nil {
t.Fatalf("@EVERY 30m devrait être valide : %v", err)
}
// Sous la minute → refusé.
if _, err := nextAfter("@every 10s", "", from); err == nil {
t.Fatalf("@every 10s aurait dû être refusé")
}
if _, err := nextAfter("@every pouet", "", from); err == nil {
t.Fatalf("durée invalide aurait dû être refusée")
}
}
func TestNextEveryDays(t *testing.T) {
from := time.Date(2026, 8, 15, 10, 0, 0, 0, time.UTC)
// Sans ancre horaire : intervalle glissant de N*24h.
got, err := nextAfter("@every 3d", "", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
if want := from.Add(72 * time.Hour); !got.Equal(want) {
t.Fatalf("@every 3d : got %v, want %v", got, want)
}
}
func TestNextEveryDayAtTime(t *testing.T) {
// « tous les 1 jour à 23:00 », fuseau Europe/Paris (CEST = UTC+2 en août).
from := time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) // 22h à Paris
got, err := nextAfter("@every 1d@23:00", "Europe/Paris", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
// 23:00 Paris le 15 = 21:00 UTC.
want := time.Date(2026, 8, 15, 21, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("@every 1d@23:00 : got %v, want %v", got.UTC(), want)
}
// Après 23:00 Paris → le lendemain.
from2 := time.Date(2026, 8, 15, 21, 30, 0, 0, time.UTC) // 23h30 Paris
got2, _ := nextAfter("@every 1d@23:00", "Europe/Paris", from2)
want2 := time.Date(2026, 8, 16, 21, 0, 0, 0, time.UTC)
if !got2.Equal(want2) {
t.Fatalf("lendemain : got %v, want %v", got2.UTC(), want2)
}
}
func TestNextCronEveryQuarter(t *testing.T) {
from := time.Date(2026, 8, 15, 10, 7, 30, 0, time.UTC)
got, err := nextAfter("*/15 * * * *", "", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 15, 10, 15, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("*/15 : got %v, want %v", got, want)
}
}
func TestNextCronWeekdayMorning(t *testing.T) {
// 2026-08-15 est un samedi. « 0 9 * * 1-5 » → lundi 17 août 09:00.
from := time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC)
got, err := nextAfter("0 9 * * 1-5", "UTC", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 17, 9, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("0 9 * * 1-5 : got %v, want %v", got, want)
}
}
func TestNextCronInParisTZ(t *testing.T) {
// « 0 9 * * * » à Paris = 07:00 UTC en été.
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC)
got, err := nextAfter("0 9 * * *", "Europe/Paris", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 15, 7, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("cron TZ : got %v, want %v", got.UTC(), want)
}
}
func TestNextCronSundayBothForms(t *testing.T) {
from := time.Date(2026, 8, 15, 0, 0, 0, 0, time.UTC) // samedi
got, err := nextAfter("0 0 * * 7", "UTC", from)
if err != nil {
t.Fatalf("erreur : %v", err)
}
want := time.Date(2026, 8, 16, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("dow=7 : got %v, want %v", got, want)
}
}
func TestParseCronInvalid(t *testing.T) {
cases := []string{
"",
"* * * *", // 4 champs
"* * * * * *", // 6 champs
"60 * * * *", // minute hors bornes
"* 24 * * *", // heure hors bornes
"5-1 * * * *", // plage inversée
"*/0 * * * *", // pas nul
"abc * * * *", // non numérique
"@every", // durée absente
"@every 1d@25:00", // heure hors bornes
"@every 0d@10:00", // zéro jour
}
for _, c := range cases {
if err := validateSchedule(c, ""); err == nil {
t.Errorf("schedule %q aurait dû être refusé", c)
}
}
}
func TestValidateScheduleOK(t *testing.T) {
cases := []string{"@every 1m", "@every 2h", "@every 3d", "@every 1d@23:00", "0 */2 * * *", "30 8 * * 1-5", "0 0 1 * *"}
for _, c := range cases {
if err := validateSchedule(c, "Europe/Paris"); err != nil {
t.Errorf("schedule %q aurait dû être valide : %v", c, err)
}
}
}
+269
View File
@@ -0,0 +1,269 @@
package loki
// tasks_run.go — exécution d'une tâche, ISOLÉE de la discussion ouverte.
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté au fonctionnement de loki
// (dossier de travail par discussion, modes de la machine).
//
// Une tâche ne doit ni apparaître dans le chat, ni casser le rejeu SSE, ni entrer
// en collision avec un tour utilisateur. On ne partage donc qu'UN SEUL point avec
// la conversation : son verrou de génération (conv.Generating), qui garantit
// qu'une seule inférence tourne à la fois — llama-server est lancé en
// --parallel 1. Tout le reste — messages, journal d'affichage — n'est jamais
// touché : la tâche construit son propre fil éphémère et le jette à la fin, ne
// gardant que le texte final comme compte-rendu.
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// RunAutonomous exécute un prompt en arrière-plan, sans laisser de trace dans la
// discussion ouverte, et renvoie le texte final produit par l'IA (le compte-rendu).
// Prend le MÊME verrou que StartTurn : renvoie ErrBusy si un tour (utilisateur ou
// une autre tâche) est déjà en cours, ou errModelLoading si le modèle n'est pas
// prêt. caps gouverne l'accès aux outils (agent, mémoire, internet).
// lastReport est le compte-rendu du passage précédent (vide au premier) : il est
// réinjecté pour donner à l'IA une continuité d'une exécution à l'autre.
func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prompt, lastReport string, caps Caps, temperature float64) (string, error) {
if !healthCheck() {
return "", errModelLoading
}
// Contexte annulable propre à la tâche, exposé via c.cancel : le bouton stop
// du chat (/api/chat/stop → conv.Stop) interrompt donc VRAIMENT une tâche de
// fond, au lieu de l'afficher « en cours » sans pouvoir l'arrêter.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
c.mu.Lock()
if c.Generating {
c.mu.Unlock()
return "", ErrBusy
}
c.Generating = true
c.cancel = cancel
c.runningTaskID = taskID
c.runningTaskName = taskName
c.mu.Unlock()
// Libère le verrou quoi qu'il arrive. On ne touche NI c.Messages NI c.Log NI
// c.epoch : la discussion ouverte reste totalement à l'écart.
defer func() {
c.mu.Lock()
c.Generating = false
c.cancel = nil
c.runningTaskID = ""
c.runningTaskName = ""
c.mu.Unlock()
}()
// Dossier de travail de la tâche : le sien, pas celui de la discussion
// ouverte (chat_workspace.go). Stable d'un passage à l'autre — une tâche qui
// tient un fichier de suivi le retrouve la fois suivante.
setTaskWorkspace(taskWorkspaceDir(taskID))
defer setTaskWorkspace("")
if temperature == 0 {
temperature = 0.7
}
msgs := []Message{{Role: "user", Content: prompt}}
// Même préambule que la vraie génération : consigne personnelle + préambule
// agent + briefing machine (via InjectSkills), pour que l'IA ait le même
// contexte et les mêmes outils qu'en chat. On préfixe le tout d'une note de
// contexte (conscience du mode autonome + mémoire du passage précédent),
// fusionnée dans UN SEUL message système en tête — comme l'exigent les
// gabarits stricts (normalizeSystemMessages le garantit de toute façon).
final := msgs
sys := readSysPrompt()
note := taskContextNote(taskName, lastReport)
switch {
case sys != "" && note != "":
final = append([]Message{{Role: "system", Content: sys + "\n\n" + note}}, msgs...)
case sys != "":
final = append([]Message{{Role: "system", Content: sys}}, msgs...)
case note != "":
final = append([]Message{{Role: "system", Content: note}}, msgs...)
}
var content strings.Builder
_, err := runChat(ctx, InjectSkills(final, caps), temperature, caps, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
return true
})
// Les messages d'outils ne sont pas conservés : la tâche est éphémère, seul
// son compte-rendu survit.
return strings.TrimSpace(content.String()), err
}
// taskWorkspaceDir donne (et crée) le dossier de travail d'une tâche, sous la
// racine du dossier de travail — jamais dans le dossier d'une discussion, dont
// il partagerait le sort à la suppression.
func taskWorkspaceDir(taskID string) string {
root := agentWorkspace()
if taskID == "" {
return root
}
dir := filepath.Join(root, "tasks", taskID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return root
}
return dir
}
// taskContextNote construit la note de contexte injectée en tête du fil d'une
// tâche : d'abord la conscience du mode (l'IA tourne seule, sans utilisateur en
// face), puis — s'il existe — le compte-rendu du passage précédent, pour la
// continuité.
func taskContextNote(taskName, lastReport string) string {
var b strings.Builder
b.WriteString("[Tâche planifiée")
if taskName != "" {
b.WriteString(" « " + taskName + " »")
}
b.WriteString("] Tu es exécuté automatiquement en arrière-plan, sans utilisateur " +
"présent pour te répondre. Mène la tâche à son terme de façon autonome, puis " +
"termine par un compte-rendu clair et court de ce que tu as fait ou trouvé.")
if r := strings.TrimSpace(lastReport); r != "" {
if len(r) > reportMax {
r = r[:reportMax] + "…"
}
b.WriteString("\n\nCompte-rendu de ta dernière exécution (pour la continuité — " +
"appuie-toi dessus, ne le répète pas tel quel) :\n" + r)
}
return b.String()
}
// reportMax borne la taille du compte-rendu conservé : le texte final peut être
// long, on n'en garde qu'un aperçu pour l'interface et pour la relance suivante.
const reportMax = 4000
// runTask exécute une tâche et met à jour son état persisté (dernier passage,
// prochain passage, succès/échec, compte-rendu).
func runTask(t Task) {
start := time.Now()
// Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement
// du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai
// échec de la tâche : la faire tourner sur le mauvais modèle serait pire.
if err := ensureTaskPreset(t.Preset); err != nil {
recordTaskEnd(t.ID, start, "", fmt.Errorf("changement de preset : %w", err))
return
}
report, err := conv.RunAutonomous(context.Background(), t.ID, t.Name, t.Prompt, t.LastReport, taskCaps(t), 0)
// Occupé, ou modèle pas encore prêt : ce n'est pas un échec de la tâche, juste
// un mauvais moment. On ne touche pas à son état (NextRun reste dans le passé)
// pour qu'elle soit réessayée au tick suivant.
if err == ErrBusy || err == errModelLoading {
return
}
recordTaskEnd(t.ID, start, report, err)
}
// taskCaps dérive les capacités d'une tâche : on part du mode agent de la machine
// (qui débloque les outils), puis on applique les réglages propres à la tâche pour
// la mémoire et le web. Le web reste borné à ce que la machine offre réellement
// (serveur Crawl4AI configuré et joignable) : une tâche ne peut pas l'inventer.
//
// Le mode code reste OFF : c'est un mode de discussion (rôles, critères,
// vérification, puces dans le fil) qui n'a pas de sens sans personne en face.
func taskCaps(t Task) Caps {
// Mem posé EXPLICITEMENT à MemOff : le zéro de MemMode est la chaîne vide,
// que EnabledTools ne reconnaît pas comme « coupée » (il compare à MemOff) —
// une tâche sans agent se serait donc vu offrir les outils mem_*.
c := Caps{Agent: agentEnabled(), Mem: MemOff}
if !c.Agent {
return c // agent coupé : aucun outil, mémoire et web n'ont plus de sens
}
if t.NoWeb {
c.Internet = false
} else {
c.Internet = internetEnabled() && crawlReachable()
}
if t.NoMem {
c.Mem = MemOff
} else if m := memMode(); m == MemOff {
// La tâche veut la mémoire mais la machine l'a coupée globalement : on donne
// au moins les outils à la demande, sans l'injection proactive.
c.Mem = MemOnDemand
} else {
c.Mem = m
}
return c
}
// ensureTaskPreset bascule le moteur sur le preset d'id `id` s'il n'est pas déjà
// actif, puis attend que le modèle ait rechargé. Vide = rien à faire.
func ensureTaskPreset(id string) error {
if id == "" {
return nil
}
list, err := ListPresets()
if err != nil {
return err
}
var target *Preset
for i := range list {
if list[i].ID == id {
target = &list[i]
break
}
}
if target == nil {
return fmt.Errorf("preset introuvable : %s", id)
}
if target.Active {
return nil
}
if err := SwitchToPreset(target.Path); err != nil {
return err
}
// Le moteur redémarre : on attend qu'il réponde à nouveau (charger un gros
// modèle prend un moment).
deadline := time.Now().Add(4 * time.Minute)
for time.Now().Before(deadline) {
if healthCheck() {
return nil
}
time.Sleep(3 * time.Second)
}
return fmt.Errorf("le modèle n'a pas fini de charger après le changement de preset")
}
// recordTaskEnd met à jour l'état persisté d'une tâche après une exécution :
// durée, dernier passage, prochain passage, succès/échec, compte-rendu. Relit la
// tâche juste avant d'écrire pour ne pas écraser une modification concurrente
// (bascule ou édition depuis l'interface pendant l'exécution).
func recordTaskEnd(id string, start time.Time, report string, err error) {
now := time.Now()
cur, ok := getTask(id)
if !ok {
return // supprimée entre-temps : rien à écrire
}
cur.LastDurMs = now.Sub(start).Milliseconds()
cur.LastRun = now.UnixMilli()
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
if errors.Is(err, context.Canceled) {
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
// « interrompue » sans allumer l'indicateur rouge d'erreur.
cur.LastOK = true
cur.LastError = ""
cur.LastReport = "(interrompue manuellement)"
} else if err != nil {
cur.LastOK = false
cur.LastError = err.Error()
} else {
cur.LastOK = true
cur.LastError = ""
if len(report) > reportMax {
report = report[:reportMax] + "…"
}
cur.LastReport = report
}
_ = saveTask(cur)
}
+72
View File
@@ -0,0 +1,72 @@
package loki
// tasks_sched.go — la boucle de planification. Une seule goroutine, un tick par
// minute, dans le process qui possède la conversation et l'UI (ajean-ui /
// `ajean link`). Voir StartTaskScheduler, câblée dans newWebMux (web_server.go).
import (
"sync"
"time"
)
var taskSchedOnce sync.Once
// StartTaskScheduler démarre la boucle de planification (idempotent : sûr à
// appeler à chaque construction du mux, ne lance qu'une goroutine).
func StartTaskScheduler() {
taskSchedOnce.Do(func() { go taskSchedLoop() })
}
// taskSchedLoop réveille les tâches dues, une par tick (elles sont de toute façon
// sérialisées par le gate de génération). Le tick d'une minute suffit : c'est la
// résolution de la planification (voir tasks_cron.go).
func taskSchedLoop() {
// Premier passage immédiat pour amorcer les NextRun manquants (après un
// redémarrage du process, ou pour une tâche fraîchement créée sans NextRun).
primeNextRuns()
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
tickTasks(time.Now())
}
}
// primeNextRuns renseigne NextRun de toute tâche activée qui n'en a pas (0),
// relativement à maintenant — sinon une tâche à NextRun=0 serait considérée « due »
// en permanence, ou jamais planifiée selon le sens de la comparaison.
func primeNextRuns() {
now := time.Now()
for _, t := range listTasks() {
if t.Enabled && t.NextRun == 0 {
t.NextRun = computeNextRun(t.Schedule, t.TZ, now)
_ = saveTask(t)
}
}
}
// tickTasks lance la première tâche due (si l'interrupteur maître n'est pas en
// pause). Une seule par tick : lancer plusieurs inférences en parallèle est de
// toute façon impossible (gate de génération), et étaler leur départ évite qu'une
// rafale de tâches monopolise le modèle d'un coup.
func tickTasks(now time.Time) {
if tasksPaused() {
return
}
nowMs := now.UnixMilli()
for _, t := range listTasks() {
if !t.Enabled {
continue
}
if t.NextRun == 0 {
// Priming raté (schedule invalide) ou tâche neuve : on tente de le poser,
// sans la déclencher ce tour-ci.
t.NextRun = computeNextRun(t.Schedule, t.TZ, now)
_ = saveTask(t)
continue
}
if t.NextRun <= nowMs {
runTask(t) // met à jour LastRun/NextRun/… et persiste
return
}
}
}
+322
View File
@@ -0,0 +1,322 @@
package loki
import (
"context"
"encoding/json"
"errors"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
)
// Aller-retour complet en base : ce qui est enregistré se relit à l'identique,
// la liste est triée par nom (affichage stable) et la suppression efface bien.
func TestTasksCRUD(t *testing.T) {
testHome(t)
if got := listTasks(); len(got) != 0 {
t.Fatalf("base neuve : %d tâches, attendu 0", len(got))
}
a := Task{ID: newTaskID(), Name: "zèbre", Prompt: "p", Schedule: "@every 2h", Enabled: true}
b := Task{ID: newTaskID(), Name: "abeille", Prompt: "p", Schedule: "0 9 * * 1-5"}
if err := saveTask(a); err != nil {
t.Fatal(err)
}
if err := saveTask(b); err != nil {
t.Fatal(err)
}
list := listTasks()
if len(list) != 2 || list[0].Name != "abeille" || list[1].Name != "zèbre" {
t.Fatalf("liste non triée par nom : %+v", list)
}
got, ok := getTask(a.ID)
if !ok || got.Prompt != "p" || got.Schedule != "@every 2h" || !got.Enabled {
t.Fatalf("relecture incorrecte : %+v (ok=%v)", got, ok)
}
if err := deleteTask(a.ID); err != nil {
t.Fatal(err)
}
if _, ok := getTask(a.ID); ok {
t.Error("tâche supprimée toujours présente")
}
if len(listTasks()) != 1 {
t.Error("la suppression n'a pas retiré la tâche de la liste")
}
}
// L'interrupteur maître est un FREIN : base neuve = tâches actives, sans
// dépendre d'une écriture préalable.
func TestTasksPauseDefautActif(t *testing.T) {
testHome(t)
if tasksPaused() {
t.Error("base neuve : les tâches devraient être actives")
}
if err := setTasksPaused(true); err != nil {
t.Fatal(err)
}
if !tasksPaused() {
t.Error("pause non enregistrée")
}
_ = setTasksPaused(false)
if tasksPaused() {
t.Error("reprise non enregistrée")
}
}
// Le dossier de travail d'une tâche lui appartient : jamais celui d'une
// discussion (qui disparaît avec elle), et stable d'un passage à l'autre — une
// tâche qui tient un fichier de suivi doit le retrouver.
func TestTaskWorkspaceIsole(t *testing.T) {
testHome(t)
dir := taskWorkspaceDir("abc123")
if dir == agentWorkspace() || dir == convWorkspace() {
t.Fatalf("la tâche partage le dossier d'une discussion : %s", dir)
}
if filepath.Base(dir) != "abc123" || filepath.Base(filepath.Dir(dir)) != "tasks" {
t.Errorf("emplacement inattendu : %s", dir)
}
if again := taskWorkspaceDir("abc123"); again != dir {
t.Errorf("dossier instable entre deux passages : %s puis %s", dir, again)
}
// La bascule ne doit toucher QUE les outils : ce que voit l'utilisateur
// (panneau Fichiers, dépôts) reste sur sa discussion.
setTaskWorkspace(dir)
defer setTaskWorkspace("")
if agentCwd() != dir {
t.Errorf("agentCwd = %s, attendu %s", agentCwd(), dir)
}
if convWorkspace() == dir {
t.Error("convWorkspace suit la tâche : le panneau Fichiers changerait de dossier en cours de route")
}
}
// Les accès d'une tâche : l'agent commande tout, la tâche ne peut que RETIRER
// (no_mem/no_web), et le mode code reste hors sujet sans personne en face.
func TestTaskCaps(t *testing.T) {
testHome(t)
if c := taskCaps(Task{}); c.Agent || c.Internet || c.Mem != MemOff {
t.Errorf("agent coupé : aucun outil attendu, obtenu %+v", c)
}
if err := setAgentEnabled(true); err != nil {
t.Fatal(err)
}
c := taskCaps(Task{})
if !c.Agent {
t.Fatal("mode agent actif : la tâche doit avoir les outils")
}
if c.Code {
t.Error("le mode code n'a pas de sens pour une tâche autonome")
}
// Par défaut, la tâche suit le mode mémoire de la machine.
if c.Mem != memMode() {
t.Errorf("mémoire = %q, attendu celle de la machine (%q)", c.Mem, memMode())
}
// Machine mémoire COUPÉE mais tâche qui la demande : elle garde au moins les
// outils à la demande, sans l'injection proactive.
if err := setMemMode(MemOff); err != nil {
t.Fatal(err)
}
if got := taskCaps(Task{}).Mem; got != MemOnDemand {
t.Errorf("mémoire = %q, attendu %q", got, MemOnDemand)
}
if c := taskCaps(Task{NoMem: true, NoWeb: true}); c.Mem != MemOff || c.Internet {
t.Errorf("refus explicite non respecté : %+v", c)
}
// Web : borné à ce que la machine offre réellement — une tâche ne peut pas
// l'inventer (aucun serveur Crawl4AI joignable ici).
if c := taskCaps(Task{}); c.Internet {
t.Error("accès web accordé alors que la machine ne l'offre pas")
}
}
// Fin d'exécution : succès, échec et interruption volontaire n'écrivent pas la
// même chose — et une interruption n'est PAS un échec (pas d'indicateur rouge).
func TestRecordTaskEnd(t *testing.T) {
testHome(t)
base := Task{ID: "t1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true}
if err := saveTask(base); err != nil {
t.Fatal(err)
}
start := time.Now().Add(-2 * time.Second)
recordTaskEnd("t1", start, strings.Repeat("x", reportMax+50), nil)
got, _ := getTask("t1")
if !got.LastOK || got.LastError != "" {
t.Errorf("succès mal enregistré : %+v", got)
}
if len(got.LastReport) != reportMax+len("…") {
t.Errorf("compte-rendu non borné : %d caractères", len(got.LastReport))
}
if got.LastRun == 0 || got.NextRun <= got.LastRun {
t.Errorf("horodatages incohérents : last=%d next=%d", got.LastRun, got.NextRun)
}
if got.LastDurMs < 1000 {
t.Errorf("durée non mesurée : %d ms", got.LastDurMs)
}
recordTaskEnd("t1", start, "", errors.New("boum"))
got, _ = getTask("t1")
if got.LastOK || got.LastError != "boum" {
t.Errorf("échec mal enregistré : %+v", got)
}
recordTaskEnd("t1", start, "", context.Canceled)
got, _ = getTask("t1")
if !got.LastOK || got.LastError != "" || !strings.Contains(got.LastReport, "interrompue") {
t.Errorf("interruption prise pour un échec : %+v", got)
}
// Tâche supprimée entre-temps : on n'en ressuscite pas une.
recordTaskEnd("disparue", start, "r", nil)
if _, ok := getTask("disparue"); ok {
t.Error("recordTaskEnd a recréé une tâche supprimée")
}
}
// Le tic ne déclenche rien quand l'interrupteur maître est en pause, et pose le
// NextRun manquant sans lancer la tâche pour autant.
func TestTickTasks(t *testing.T) {
testHome(t)
now := time.Now()
due := Task{ID: "due", Name: "due", Prompt: "p", Schedule: "@every 1h", Enabled: true,
NextRun: now.Add(-time.Minute).UnixMilli()}
if err := saveTask(due); err != nil {
t.Fatal(err)
}
_ = setTasksPaused(true)
tickTasks(now)
if got, _ := getTask("due"); got.LastRun != 0 {
t.Error("une tâche s'est exécutée alors que tout est suspendu")
}
_ = setTasksPaused(false)
// NextRun absent : le tic le pose (relatif à maintenant) sans exécuter.
neuve := Task{ID: "neuve", Name: "neuve", Prompt: "p", Schedule: "@every 2h", Enabled: true}
if err := saveTask(neuve); err != nil {
t.Fatal(err)
}
primeNextRuns()
got, _ := getTask("neuve")
if got.NextRun == 0 || got.LastRun != 0 {
t.Errorf("amorçage incorrect : next=%d last=%d", got.NextRun, got.LastRun)
}
if d := time.UnixMilli(got.NextRun).Sub(now); d < 110*time.Minute || d > 130*time.Minute {
t.Errorf("prochain passage à %v, attendu ~2 h", d)
}
// Tâche désactivée : jamais amorcée, jamais lancée.
off := Task{ID: "off", Name: "off", Prompt: "p", Schedule: "@every 1h"}
if err := saveTask(off); err != nil {
t.Fatal(err)
}
primeNextRuns()
if got, _ := getTask("off"); got.NextRun != 0 {
t.Error("une tâche désactivée a été planifiée")
}
}
// Une tâche épinglée sur un preset qui n'existe plus échoue PROPREMENT : elle
// est marquée en erreur (et surtout, elle ne tourne pas sur le mauvais modèle).
func TestRunTaskPresetIntrouvable(t *testing.T) {
testHome(t)
tk := Task{ID: "p1", Name: "n", Prompt: "p", Schedule: "@every 1h", Enabled: true, Preset: "fantome"}
if err := saveTask(tk); err != nil {
t.Fatal(err)
}
runTask(tk)
got, _ := getTask("p1")
if got.LastOK || !strings.Contains(got.LastError, "preset") {
t.Errorf("échec de bascule mal signalé : %+v", got)
}
}
// La note de contexte dit à l'IA qu'elle tourne seule, et lui redonne son
// compte-rendu précédent — borné, pour ne pas manger le contexte.
func TestTaskContextNote(t *testing.T) {
n := taskContextNote("Veille mails", "")
if !strings.Contains(n, "Veille mails") || !strings.Contains(n, "sans utilisateur") {
t.Errorf("note incomplète : %q", n)
}
if strings.Contains(n, "dernière exécution") {
t.Error("premier passage : il n'y a pas de compte-rendu à rappeler")
}
long := taskContextNote("t", strings.Repeat("y", reportMax+100))
if !strings.Contains(long, "dernière exécution") {
t.Error("le compte-rendu précédent doit être réinjecté")
}
if len(long) > reportMax+600 {
t.Errorf("note non bornée : %d caractères", len(long))
}
}
// L'API refuse une tâche bancale AVANT de l'enregistrer : sans ça une fréquence
// illisible donnerait une tâche qui ne se déclenche jamais, sans rien dire.
func TestHandleTaskSaveValide(t *testing.T) {
testHome(t)
post := func(body string) (int, map[string]any) {
rr := httptest.NewRecorder()
handleTaskSave(rr, httptest.NewRequest("POST", "/api/tasks/save", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(rr.Body.Bytes(), &out)
return rr.Code, out
}
if code, out := post(`{"name":"","prompt":"p","schedule":"@every 1h"}`); code != 400 {
t.Errorf("nom vide accepté : %d %v", code, out)
}
if code, out := post(`{"name":"n","prompt":"","schedule":"@every 1h"}`); code != 400 {
t.Errorf("consigne vide acceptée : %d %v", code, out)
}
if code, out := post(`{"name":"n","prompt":"p","schedule":"tous les jours"}`); code != 400 {
t.Errorf("fréquence illisible acceptée : %d %v", code, out)
}
if len(listTasks()) != 0 {
t.Fatal("une tâche refusée a quand même été enregistrée")
}
code, out := post(`{"name":"n","prompt":"p","schedule":"0 9 * * 1-5","tz":"Europe/Paris","enabled":true}`)
if code != 200 || out["ok"] != true {
t.Fatalf("création refusée : %d %v", code, out)
}
id, _ := out["id"].(string)
got, ok := getTask(id)
if !ok || got.TZ != "Europe/Paris" || got.NextRun == 0 {
t.Fatalf("tâche mal créée : %+v", got)
}
// Édition : l'historique d'exécution SURVIT (on ne repart pas de zéro parce
// qu'on a corrigé une faute dans la consigne).
got.LastRun, got.LastReport, got.LastOK = 42, "vu hier", true
if err := saveTask(got); err != nil {
t.Fatal(err)
}
if code, out := post(`{"id":"` + id + `","name":"n2","prompt":"p2","schedule":"0 9 * * 1-5","enabled":true}`); code != 200 {
t.Fatalf("édition refusée : %d %v", code, out)
}
after, _ := getTask(id)
if after.Name != "n2" || after.Prompt != "p2" {
t.Errorf("édition non appliquée : %+v", after)
}
if after.LastRun != 42 || after.LastReport != "vu hier" {
t.Errorf("historique perdu à l'édition : %+v", after)
}
}
// Un message envoyé pendant qu'une tâche tourne doit dire LAQUELLE occupe le
// modèle : « génération en cours » sur un fil vide et immobile n'explique rien.
func TestBusyReasonNommeLaTache(t *testing.T) {
testHome(t)
if err := conv.busyReason(); err != ErrBusy {
t.Errorf("hors tâche : %v, attendu %v", err, ErrBusy)
}
conv.mu.Lock()
conv.runningTaskName = "Veille mails"
conv.mu.Unlock()
defer func() {
conv.mu.Lock()
conv.runningTaskName = ""
conv.mu.Unlock()
}()
if err := conv.busyReason(); err == nil || !strings.Contains(err.Error(), "Veille mails") {
t.Errorf("la tâche n'est pas nommée : %v", err)
}
}
+2485 -310
View File
File diff suppressed because it is too large. Load diff
+454 -153
View File
@@ -62,8 +62,8 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div id="conv-list" class="slist"></div>
</details>
<!-- Avertissement de lancement (App Translocation macOS) — masqué par défaut. -->
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:8px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
<div id="app-warn" class="muted" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--warn);border:1px solid color-mix(in srgb,var(--warn) 45%,transparent);background:color-mix(in srgb,var(--warn) 12%,transparent)"></div>
<div id="model-err" style="display:none;font-size:11px;line-height:1.5;margin:0 0 10px;padding:8px 10px;border-radius:4px;color:var(--err);border:1px solid color-mix(in srgb,var(--err) 45%,transparent);background:color-mix(in srgb,var(--err) 12%,transparent)"></div>
<!-- Journal du moteur : replié par défaut, ouvert au clic sur la pastille d'état
OU par son propre chevron — c'est une section comme les autres, sinon on ne
trouvait pas comment la refermer.
@@ -75,9 +75,194 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
latérale par défaut, c'est les discussions et la machine. Les sections
ouvertes par programme (journal du moteur, installation llama.cpp)
passent par openDetails(), qui déplie aussi les parents. -->
<details id="side-settings"><summary><span class="stitle">Réglages</span></summary>
<div class="sidegroup">
<details id="svc-log-box" ontoggle="if(this.open){loadSvcLog();showPaths()}"><summary><span class="stitle">Journal du moteur</span></summary>
</div><!-- /#side-scroll -->
<!-- Réglages : tout vit dans une modale (nav de sections à gauche, panneau à
droite) — voir js/22-settings-modal.js ; les ids historiques des sections
(svc-log-box, lc-details…) sont conservés sur les panneaux. Le bouton est
ANCRÉ en pied de barre, juste au-dessus du moniteur machine : toujours au
même endroit, quel que soit le nombre de discussions. -->
<button id="side-settings-btn" onclick="openSettings()" aria-haspopup="dialog">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.35a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.7 1.7 0 0 0 .34-1.87 1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.65 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.7 1.7 0 0 0 1.87.34H9a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09c0 .68.4 1.3 1.03 1.56a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.7 1.7 0 0 0-.34 1.87v.06c.26.63.88 1.03 1.56 1.03H21a2 2 0 1 1 0 4h-.09a1.7 1.7 0 0 0-1.56 1.03z"/></svg>
<span>Paramètres</span>
</button>
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
jauges fines — visible en permanence sans jamais disputer la place au
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
bloc entier se replie d'un clic. -->
<div id="side-perf">
<div class="perf-head"><span>Performance</span></div>
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
</div>
</div>
<div class="main" style="position:relative">
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
<header id="topbar">
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
</button>
<div id="topbar-title">Nouvelle discussion</div>
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</header>
<div id="chat"></div>
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
le replay terminé, sinon il clignoterait au chargement de chaque page).
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
pour le dessin des lettres. -->
<div id="chat-empty" aria-hidden="true">
<div id="ce-logo"></div>
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
pas de civilité ni d'accord qui obligerait à deviner. -->
<div id="ce-title">On commence par quoi ?</div>
</div>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
<div id="composer">
<div id="ctxmeta">
<!-- Mode de la discussion : Chat (assistant généraliste) ou Code (agent
de code : critères, vérification, LSP). Dans le pied de carte, pas
dans la barre de saisie déjà chargée — ça se règle par discussion,
pas par message. -->
<div id="mode-seg" role="group" aria-label="Mode de la discussion">
<button type="button" data-mode="chat" class="on" onclick="setMode('chat')">Chat</button>
<button type="button" data-mode="code" onclick="setMode('code')">Code</button>
</div>
<div id="ctxtrack"><div id="ctx-fill"></div></div>
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
<span id="ctx-text" class="muted">0 / 0</span>
<button id="ctx-compact" onclick="compactContext()">compacter</button>
</span>
</div>
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
<!-- Intensité du raisonnement : réglable ici parce que ça se décide au
moment d'écrire le message, pas dans un éditeur de preset. -->
<select id="think-effort" title="Intensité du raisonnement" aria-label="Intensité du raisonnement" onchange="setThinkEffort(this.value)">
<option value="">auto</option>
<option value="none">aucune</option>
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
<!-- Dictée vocale : enregistre au micro, transcrit en local (whisper.cpp
côté serveur) et pose le texte dans le champ. Voir js/23-dictate.js.
Le navigateur n'autorise le micro qu'en HTTPS (ou localhost). -->
<button id="mic" onclick="dictateToggle()" title="dicter au micro" aria-label="dicter au micro">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="3" width="6" height="12" rx="3"/><path d="M5 11a7 7 0 0 0 14 0"/><path d="M12 18v3"/></svg>
</button>
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
</button>
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
ce qui sort. -->
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
</button>
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
reste porté par aria-label et l'infobulle — l'action garde son nom
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
</button>
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
</button>
</div>
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
</div>
</div>
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
chargement de page pour un panneau fermé serait payé par tout le monde et
utile à personne. -->
<div id="files-backdrop" onclick="toggleFiles()"></div>
<aside id="files" aria-label="Fichiers de cette discussion">
<div class="files-head">
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
complet reste porté par l'aria-label de la section. -->
<span class="stitle">Fichiers</span>
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
captures), il faut pouvoir y descendre et en remonter. -->
<div id="files-crumb" class="files-crumb"></div>
<div id="files-list" class="files-list"></div>
<div id="files-foot" class="files-foot muted"></div>
</aside>
<div id="toast"></div>
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:4px;width:100%;max-width:480px;display:flex;flex-direction:column">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong>Benchmark</strong>
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
<button onclick="closeBenchModal()">fermer</button>
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
</div>
</div>
</div>
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
mêmes pour les deux formats : le format ne décide que du contenant. -->
<!-- Réglages : modale à deux volets — nav de sections à gauche, LE panneau
choisi à droite. Chaque section d'antan (details de la barre latérale) est
devenue un panneau .set-pane ; les ids historiques (svc-log-box,
lc-details…) sont conservés — openDetails() les résout vers
openSettings(panneau), donc pastille d'état et jobs moteur rouvrent la
modale au bon endroit. -->
<div id="settings-modal" class="modal-ov set-ov" style="display:none" onclick="if(event.target===this)closeSettings()">
<div class="modal-card set-card">
<div class="modal-head">
<strong>Paramètres</strong>
<button class="modal-x" onclick="closeSettings()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div class="set-shell">
<nav class="set-nav" id="set-nav" aria-label="sections des réglages">
<div class="set-nav-h">Intelligence artificielle</div>
<button data-pane="config" onclick="settingsShow('config')">Config active</button>
<button data-pane="presets" onclick="settingsShow('presets')">Presets</button>
<button data-pane="agent" onclick="settingsShow('agent')"><span>Mode agent</span><span id="agent-badge" class="sbadge"></span></button>
<button data-pane="tasks" onclick="settingsShow('tasks')"><span>Tâches planifiées</span><span id="tasks-badge" class="sbadge"></span></button>
<button data-pane="sysprompt" onclick="settingsShow('sysprompt')">System prompt</button>
<div class="set-nav-h">Moteur</div>
<button data-pane="engine" onclick="settingsShow('engine')">Moteur llama.cpp</button>
<button data-pane="engine-log" onclick="settingsShow('engine-log')">Journal du moteur</button>
<button data-pane="dictee" onclick="settingsShow('dictee')">Dictée</button>
<div class="set-nav-h">Application</div>
<button data-pane="identity" onclick="settingsShow('identity')">Identité</button>
<button data-pane="appearance" onclick="settingsShow('appearance')">Apparence</button>
<button data-pane="openai" onclick="settingsShow('openai')">Accès OpenAI</button>
<button data-pane="actions" onclick="settingsShow('actions')">Actions</button>
</nav>
<div class="set-body" id="set-body">
<section class="set-pane" data-pane="engine-log" id="svc-log-box">
<div class="set-pane-h"><span>Journal du moteur</span></div>
<div class="row" style="justify-content:flex-end;align-items:center">
<button onclick="loadSvcLog()" title="rafraîchir" aria-label="rafraîchir le journal"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
</div>
@@ -88,14 +273,17 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="io-cap">Emplacements</span>
</div>
<div id="paths-msg" class="muted" style="font-size:10px;line-height:1.5"></div>
</details>
<details open id="cfg-details"><summary><span class="stitle">Config active</span></summary>
</section>
<section class="set-pane" data-pane="config" id="cfg-details">
<div class="set-pane-h"><span>Config active</span></div>
<div id="cfg" class="slist">…</div>
</details>
<details><summary><span class="stitle">Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></summary>
</section>
<section class="set-pane" data-pane="presets">
<div class="set-pane-h"><span>Presets</span><button class="preset-add" onclick="event.preventDefault();event.stopPropagation();openPreset('')" title="ajouter" aria-label="ajouter"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5.5v13"/><path d="M5.5 12h13"/></svg></button></div>
<div class="slist"><div id="presets" class="rowlist"></div></div>
</details>
<details><summary><span class="stitle">Mode agent</span><span id="agent-badge" class="sbadge"></span></summary>
</section>
<section class="set-pane" data-pane="agent">
<div class="set-pane-h"><span>Mode agent</span></div>
<div class="slist"><label class="switchrow"><span class="switch"><input type="checkbox" id="agent-toggle" onchange="toggleAgent()"><span class="slider"></span></span> activer le mode agent<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">⚠ active l'<b>accès machine</b> (shell complet via <b>bash</b> sur le serveur) — l'IA peut inspecter, lancer des scripts, écrire/lire/éditer des fichiers (timeout 30s, max 300s). Mémoire et accès internet ci-dessous ne fonctionnent que si le mode agent est activé.</span></span></label></div>
<!-- Sous-réglages du mode agent. MÊME gabarit que « Machine » / « Apparence » :
étiquette de groupe nue, puis UNE carte de lignes (.slist). Le groupe
@@ -181,13 +369,33 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<label class="switchrow"><span class="switch"><input type="checkbox" id="compact-toggle" onchange="toggleCompact()"><span class="slider"></span></span> compactage automatique du contexte<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">activé par défaut : quand la conversation approche de la limite de contexte, les vieux tours sont résumés automatiquement (façon Hermes) au lieu de t'imposer un « vider ». Discussions quasi illimitées sans perdre le fil.</span></span></label>
</div>
</div>
</details>
<details><summary><span class="stitle">System prompt</span></summary>
</section>
<section class="set-pane" data-pane="tasks" id="tasks-details">
<div class="set-pane-h"><span>Tâches planifiées</span></div>
<!-- Tâches planifiées : l'IA exécute une consigne toute seule sur une
fréquence réglable (voir tasks.go). Chaque tâche tourne ISOLÉE de la
conversation — elle livre son résultat via les outils de l'IA (mail via
MCP, shell…). D'où l'avertissement quand le mode agent est off : sans
lui, aucune tâche ne peut agir. -->
<div class="slist">
<label class="switchrow"><span class="switch"><input type="checkbox" id="tasks-pause-toggle" onchange="toggleTasksPause()"><span class="slider"></span></span> suspendre toutes les tâches<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Interrupteur maître : coche pour que plus aucune tâche ne se déclenche. Les tâches gardent leur réglage et repartent quand tu décoches.</span></span></label>
</div>
<div id="tasks-agent-warn" class="muted" style="display:none;font-size:12px;line-height:1.5;margin:8px 2px">Le <b>mode agent</b> est désactivé : les tâches s'exécuteront mais l'IA n'aura <b>aucun outil</b> pour agir (envoyer un mail, lire des fichiers…). Active le mode agent ci-dessus.</div>
<div class="slist">
<div class="rowbody">
<div id="tasks-list" class="mcp-list"><div class="muted" style="font-size:12px">(aucune tâche — ex. « toutes les 2 h, relève mes mails et prépare des brouillons de réponse »)</div></div>
<button class="addbtn" style="margin-top:6px" onclick="openTask('')">+ ajouter une tâche</button>
</div>
</div>
</section>
<section class="set-pane" data-pane="sysprompt">
<div class="set-pane-h"><span>System prompt</span></div>
<div class="slist"><div class="rowbody">
<textarea id="sysprompt" placeholder="(optionnel) ex: tu es un assistant concis en français" oninput="saveSys()" class="sctl"></textarea>
</div></div>
</details>
<details id="lc-details"><summary><span class="stitle">Moteur</span></summary>
</section>
<section class="set-pane" data-pane="engine" id="lc-details">
<div class="set-pane-h"><span>Moteur</span></div>
<!-- Une seule carte : état, choix du mode et journal d'installation sont le
même sujet. Les trois modes sont des LIGNES de cette carte (elles
étaient des cartes dans une carte). -->
@@ -203,7 +411,10 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div class="muted" style="margin-top:4px"><code id="lc-provided-bin" style="word-break:break-all"></code></div>
<div id="eng-latest" style="margin-top:6px"></div>
<div class="row" style="margin:8px 0 0">
<button onclick="engCheck()">vérifier la version</button>
<!-- « vérifier » ne fait QUE regarder (aucun téléchargement, aucune
confirmation) ; « mettre à jour » télécharge et bascule. Les deux
gestes n'engagent pas pareil, chacun son bouton. -->
<button onclick="engCheck()">vérifier les mises à jour</button>
<button onclick="engUpdate()">mettre à jour le moteur</button>
<button id="eng-revert" style="display:none" onclick="engUse('image')">revenir au moteur de l'image</button>
</div>
@@ -250,18 +461,71 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<button onclick="act('stop')">arrêter</button>
</div>
</div></div>
</details>
</div><!-- /sidegroup -->
</section>
<!-- Dictée vocale. Réglages de SERVEUR : le modèle chargé et le GPU occupé
sont des propriétés de la machine, identiques pour tous les onglets.
Voir js/24-dictate-settings.js et internal/loki/dictate_*.go. -->
<section class="set-pane" data-pane="dictee">
<div class="set-pane-h"><span>Dictée</span></div>
<div class="subhead">Matériel<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">whisper tourne dans un processus séparé, allumé au premier clic sur le micro et éteint après <b>dix minutes</b> sans dictée — il ne garde pas la VRAM occupée pour rien. Le GPU choisi est transmis par <code>CUDA_VISIBLE_DEVICES</code>.</span></span></div>
<div class="slist">
<label class="switchrow"><span>processeur ou GPU</span>
<select id="dic-device" class="sctl"></select>
</label>
</div>
<div class="subhead">Modèle<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les modèles ne sont <b>pas</b> dans l'image : ils sont téléchargés à la demande dans <code>/data/whisper/</code> et survivent aux recréations du conteneur. Plus le modèle est gros, mieux il comprend — et plus il occupe de mémoire.</span></span></div>
<div class="slist">
<label class="switchrow"><span>modèle</span>
<select id="dic-model" class="sctl"></select>
</label>
<div class="rowbody">
<button id="dic-dl" class="btn" onclick="dictateDownload()">Télécharger ce modèle</button>
<span id="dic-dl-state" class="dim"></span>
</div>
</div>
<div class="subhead">Reconnaissance</div>
<div class="slist">
<label class="switchrow"><span>langue</span>
<select id="dic-lang" class="sctl">
<option value="fr">français</option>
<option value="en">anglais</option>
<option value="de">allemand</option>
<option value="es">espagnol</option>
<option value="it">italien</option>
<option value="auto">détection automatique</option>
</select>
</label>
<label class="switchrow"><span>réactivité<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Longueur des tranches transcrites au fil de la dictée. Court = le texte arrive plus tôt, avec moins de contexte donc plus d'erreurs. Long = mieux reconnu, mais plus d'attente.</span></span></span>
<select id="dic-react" class="sctl">
<option value="court">court</option>
<option value="moyen">moyen</option>
<option value="long">long</option>
</select>
</label>
<div class="rowbody">
<button class="btn primary" onclick="dictateSave()">Enregistrer</button>
<span id="dic-save-state" class="dim"></span>
</div>
</div>
<div class="subhead">Vérifier</div>
<div class="slist">
<div class="rowbody">
<button id="dic-test" class="btn" onclick="dictateTestMic()">Tester le micro (3 s)</button>
</div>
<div id="dic-test-out" class="dim"></div>
<div id="dic-state" class="dim"></div>
</div>
</section>
<!-- ── PARAMÈTRES DE L'APPLICATION ────────────────────────────────────
Séparés des réglages d'IA (Machine, Presets, Mode agent, System
prompt, Moteur), qui restent au-dessus : ce qui suit ne concerne que
l'application elle-même. -->
<div class="side-sep">Paramètres</div>
<!-- sidegroup : plusieurs sections dans UNE carte. Une carte par section
empilait 11 arrondis et autant de gouttières : la barre latérale ne
tenait plus dans un écran, alors que ces lignes sont des titres repliés. -->
<div class="sidegroup">
<details id="ident-details"><summary><span class="stitle">Identité</span></summary>
<section class="set-pane" data-pane="identity" id="ident-details">
<div class="set-pane-h"><span>Identité</span></div>
<div class="slist"><div class="rowbody">
<div class="pe-row">
<span class="pe-row-l">Ton prénom<span class="pe-sub">affiché sur tes messages</span></span>
@@ -281,8 +545,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<div id="p-avatar-ai" class="avpick"></div>
</div>
</div></div>
</details>
<details><summary><span class="stitle">Apparence</span></summary>
</section>
<section class="set-pane" data-pane="appearance">
<div class="set-pane-h"><span>Apparence</span></div>
<div class="slist">
<label class="switchrow rev"><span>mode sombre</span>
<span class="switch"><input type="checkbox" id="theme-dark" onchange="toggleThemeDark()"><span class="slider"></span></span>
@@ -295,8 +560,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-fold-tools" onchange="toggleView('fold-tools')"><span class="slider"></span></span> garder les bulles repliées<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Les bulles de raisonnement et d'outil arrivent <b>déjà fermées</b> au lieu de s'ouvrir puis se refermer. Un clic sur leur étiquette les déplie.</span></span></label>
<label class="switchrow"><span class="switch"><input type="checkbox" id="view-hide-stats" onchange="toggleView('hide-stats')"><span class="slider"></span></span> masquer la vitesse de génération<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Sous chaque réponse : <b>prefill</b> (lecture du contexte) et <b>decode</b> (écriture), en tokens et en tokens/seconde. Coche pour ne plus les afficher.</span></span></label>
</div>
</details>
<details><summary><span class="stitle">Accès OpenAI</span></summary>
</section>
<section class="set-pane" data-pane="openai">
<div class="set-pane-h"><span>Accès OpenAI</span></div>
<div class="subhead">Endpoint compatible OpenAI<span class="help" tabindex="0" onclick="event.preventDefault();event.stopPropagation();this.classList.toggle('open')">?<span class="tip">Branchez n'importe quel client compatible OpenAI (OpenCode, Continue, Cursor, Open WebUI…) sur cette adresse — le modèle s'y appelle <code>loki</code>. L'endpoint est servi par Loki <b>sur le port de cette interface</b> : aucun autre port à ouvrir. Si une clé est définie, le client doit envoyer <code>Authorization: Bearer &lt;clé&gt;</code>.</span></span></div>
<div class="slist">
<div class="rowbody">
@@ -332,8 +598,9 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<button onclick="apiKeyAction('clear')">retirer</button>
</div>
</div></div>
</details>
<details id="upd-details"><summary><span class="stitle">Actions</span></summary>
</section>
<section class="set-pane" data-pane="actions" id="upd-details">
<div class="set-pane-h"><span>Actions</span></div>
<div class="slist"><div class="rowbody">
<!-- Ligne 1 : mise à jour + export du fil. Depuis la 0.8 la conversation vit
dans loki.db (bbolt, verrouillé tant que le service tourne) : sans le
@@ -349,131 +616,11 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<button onclick="resetChat()" title="vider la discussion"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 7h16"/><path d="M9.5 7V4.5h5V7"/><path d="M6.5 7l1 12.5h9l1-12.5"/></svg><span>vider la discussion</span></button>
</div>
</div></div>
</details>
</div><!-- /sidegroup -->
</details><!-- /#side-settings -->
</div><!-- /#side-scroll -->
<!-- Moniteur machine : charge GPU, VRAM et mémoire vive. Posé en pied, en
jauges fines — visible en permanence sans jamais disputer la place au
fil. Le détail chiffré (Gio, température) vit sous chaque jauge, et le
bloc entier se replie d'un clic. -->
<div id="side-perf">
<div class="perf-head"><span>Performance</span></div>
<div class="perf-body"><div id="vram">…</div><div id="ram-details"><div id="ram"></div></div></div>
</section>
</div><!-- /.set-body -->
</div><!-- /.set-shell -->
</div>
</div>
<div class="main" style="position:relative">
<!-- Barre d'en-tête : de quoi on parle, et avec quel modèle. Le changement de
modèle demandait auparavant d'ouvrir la barre latérale et de descendre
jusqu'aux presets — trois clics pour un geste qu'on répète. -->
<header id="topbar">
<button id="sidetoggle" class="iconbtn" onclick="toggleSideCollapse()" title="afficher/masquer la barre latérale" aria-label="afficher ou masquer la barre latérale">
<svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></svg>
</button>
<div id="topbar-title">Nouvelle discussion</div>
<select id="model-switch" onchange="onModelSwitch(this)" title="modèle actif — changer relance le moteur" aria-label="modèle actif"></select>
<button id="themebtn" class="iconbtn" onclick="toggleThemeQuick()" title="thème clair / sombre" aria-label="basculer le thème">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
</button>
</header>
<div id="chat"></div>
<!-- Voile de chargement du fil : sans lui, une connexion lente donne un chat
VIDE indiscernable d'une conversation vide. Masqué au signal {caught_up}. -->
<div id="chat-loading"><span class="cl-dots"><span></span><span></span><span></span></span><span id="chat-loading-text">chargement de la conversation…</span></div>
<!-- Accueil : affiché UNIQUEMENT quand le fil est réellement vide (et une fois
le replay terminé, sinon il clignoterait au chargement de chaque page).
Le logo est cloné depuis #brand au démarrage — une seule source de vérité
pour le dessin des lettres. -->
<div id="chat-empty" aria-hidden="true">
<div id="ce-logo"></div>
<!-- Une seule ligne, NEUTRE en genre : l'UI est utilisée par tout le monde,
pas de civilité ni d'accord qui obligerait à deviner. -->
<div id="ce-title">On commence par quoi ?</div>
</div>
<button id="scrollbtn" onclick="jumpBottom()" title="aller en bas" aria-label="aller en bas"><svg viewBox="0 0 24 24" width="17" height="17" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14"/><path d="M6 13l6 6 6-6"/></svg></button>
<!-- Voile de dépôt : révélé pendant qu'on survole la fenêtre avec un fichier. -->
<div id="dropveil" aria-hidden="true"><div>Déposer pour joindre au message</div></div>
<div id="composer">
<div id="ctxmeta">
<div id="ctxtrack"><div id="ctx-fill"></div></div>
<span id="status-preset" class="muted" style="font-size:10px;letter-spacing:.03em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
<!-- Pied de carte, à droite : le décompte de contexte, compacter, puis le
trombone — tous à la même échelle, c'est une seule ligne d'outils. -->
<span style="display:flex;align-items:center;gap:8px;margin-left:auto">
<span id="ctx-text" class="muted">0 / 0</span>
<button id="ctx-compact" onclick="compactContext()">compacter</button>
</span>
</div>
<!-- Pièces jointes en attente : remplie par addFiles(), vidée à l'envoi.
Petites bulles POSÉES AU-DESSUS de la carte, dans la bande de dégradé
qui la sépare du fil — la carte ne bouge donc pas d'un pixel. -->
<div id="attach-list"></div>
<div id="inputbar">
<textarea id="input" rows="1" placeholder="Message Loki…" onkeydown="onKey(event)" oninput="autoGrow(this)" onpaste="onPaste(event)"></textarea>
<input type="file" id="attach-input" multiple style="display:none" onchange="onAttachPick(event)">
<!-- Joindre un fichier. Icône dessinée en SVG : pas de dépendance à une
police d'emoji, dont le rendu varie énormément d'un système à l'autre. -->
<button id="attach" onclick="document.getElementById('attach-input').click()" title="joindre un fichier" aria-label="joindre un fichier">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 16V4"/><path d="m7.5 8.5 4.5-4.5 4.5 4.5"/><path d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"/></svg>
</button>
<!-- Fichiers de la discussion : ouvre le panneau de droite. Même gabarit
que le dépôt — ce sont les deux faces du même sujet, ce qui entre et
ce qui sort. -->
<button id="files-btn" onclick="toggleFiles()" title="Fichiers de cette discussion" aria-label="Fichiers de cette discussion" aria-expanded="false">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
</button>
<!-- Envoi : pastille ronde à flèche, comme la maquette. Le mot « Envoyer »
reste porté par aria-label et l'infobulle — l'action garde son nom
pour un lecteur d'écran, elle ne l'affiche simplement plus. -->
<button id="send" onclick="send()" title="Envoyer" aria-label="Envoyer">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><path d="M3.4 20.4 21 12 3.4 3.6l.02 6.53L15.5 12 3.42 13.87z"/></svg>
</button>
<button id="stop" onclick="stopGen()" title="Arrêter la génération" aria-label="Arrêter la génération">
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" stroke="none" aria-hidden="true"><rect x="5" y="5" width="14" height="14" rx="3"/></svg>
</button>
</div>
<div id="sendhint" class="muted">Entrée pour envoyer · Maj+Entrée = nouvelle ligne</div>
</div>
</div>
<!-- Fichiers : ce qui appartient à la DISCUSSION ouverte — pièces jointes
déposées, captures, ce que l'agent y a écrit. Panneau de DROITE, symétrique
des réglages à gauche, ouvert et fermé par le bouton dossier du pied de
carte. Il ne se remplit qu'à l'ouverture (loadFiles) — un ReadDir à chaque
chargement de page pour un panneau fermé serait payé par tout le monde et
utile à personne. -->
<div id="files-backdrop" onclick="toggleFiles()"></div>
<aside id="files" aria-label="Fichiers de cette discussion">
<div class="files-head">
<!-- Titre court : le panneau est déjà nommé par son contexte, et le libellé
complet reste porté par l'aria-label de la section. -->
<span class="stitle">Fichiers</span>
<button id="files-refresh" onclick="loadFiles()" title="rafraîchir" aria-label="rafraîchir"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 3.5V9H15"/></svg></button>
<button id="files-close" onclick="toggleFiles()" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<!-- Fil d'Ariane : le dossier de la discussion a des sous-dossiers (uploads,
captures), il faut pouvoir y descendre et en remonter. -->
<div id="files-crumb" class="files-crumb"></div>
<div id="files-list" class="files-list"></div>
<div id="files-foot" class="files-foot muted"></div>
</aside>
<div id="toast"></div>
<div id="bench-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:30;align-items:center;justify-content:center;padding:20px" onclick="if(event.target===this)closeBenchModal()">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:8px;width:100%;max-width:480px;display:flex;flex-direction:column">
<div style="padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
<strong>Benchmark</strong>
<button onclick="closeBenchModal()" style="margin:0" title="fermer" aria-label="Fermer"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6.5 6.5l11 11"/><path d="M17.5 6.5l-11 11"/></svg></button>
</div>
<div id="bench-body" style="padding:18px 20px;display:flex;flex-direction:column;gap:12px"></div>
<div style="padding:12px 16px;border-top:1px solid var(--border);display:flex;justify-content:flex-end;gap:6px">
<button onclick="closeBenchModal()">fermer</button>
<button id="bench-rerun" onclick="runBenchUI()" style="border-color:var(--accent);color:var(--accent)">relancer</button>
</div>
</div>
</div>
<!-- Fenêtre d'export. Un fil relu pour retrouver une réponse n'a que faire des
mille lignes de raisonnement qui l'ont produite : on choisit ce qu'on
emporte plutôt que de tout prendre ou rien. Les options de CONTENU sont les
mêmes pour les deux formats : le format ne décide que du contenant. -->
<div id="export-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeExportModal()">
<div class="modal-card" style="max-width:460px">
<div class="modal-head">
@@ -653,6 +800,106 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div>
</div>
<div id="task-modal" class="modal-ov" style="display:none" onclick="if(event.target===this)closeTask()">
<div class="modal-card">
<div class="modal-head">
<strong id="task-modal-title">Tâche planifiée</strong>
<button class="modal-x" onclick="closeTask()" aria-label="Fermer">×</button>
</div>
<div class="pe-body">
<div id="task-result-block" class="pe-group" style="display:none">
<div class="pe-gh">Dernier résultat <span id="task-result-when" class="pe-sub" style="display:inline;margin:0"></span></div>
<div id="task-result" class="task-report"></div>
</div>
<div class="pe-group">
<div class="pe-gh">Nom</div>
<div class="pe-list">
<div class="pe-row stack plain"><input id="task-name" class="pe-inp" placeholder="Nom de la tâche"></div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Consigne</div>
<textarea id="task-prompt" class="pe-inp task-consigne" spellcheck="false" placeholder="Ce que l'IA doit faire, et comment livrer le résultat.&#10;&#10;Tu peux détailler librement : contexte, étapes, format du compte-rendu attendu…"></textarea>
</div>
<div class="pe-group">
<div class="pe-gh">Preset</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Modèle utilisé</span>
<span class="pe-row-c"><span class="pe-selc"><select id="task-preset"></select></span></span>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Fréquence</div>
<div class="pe-seg">
<label><input type="radio" name="task-freq-mode" value="interval" onchange="taskFreqUI('interval')"><span>Intervalle</span></label>
<label><input type="radio" name="task-freq-mode" value="cron" onchange="taskFreqUI('cron')"><span>Horaire précis</span></label>
</div>
<div class="pe-list" style="margin-top:8px">
<div id="task-interval-fields" class="pe-row">
<span class="pe-row-l">Toutes les</span>
<span class="pe-row-c">
<input id="task-interval-n" class="pe-val" type="number" min="1" value="2" style="width:54px">
<span class="pe-selc"><select id="task-interval-unit" onchange="taskUnitUI()">
<option value="m">minutes</option>
<option value="h" selected>heures</option>
<option value="d">jours</option>
</select></span>
</span>
</div>
<div id="task-time-row" class="pe-row" style="display:none">
<span class="pe-row-l">À</span>
<span class="pe-row-c"><input id="task-time" class="pe-val" type="time" value="09:00" style="width:110px"></span>
</div>
<div id="task-cron-fields" class="pe-row stack" style="display:none">
<input id="task-cron" class="pe-inp" placeholder="0 9 * * 1-5" spellcheck="false" style="font-family:var(--mono)">
<div class="pe-note"><code>minute heure jour mois jour-semaine</code></div>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-gh">Accès</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Accès à la mémoire</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-mem" checked><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Accès au web</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-web" checked><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div class="pe-group">
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Tâche active</span>
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="task-enabled" checked><span class="pe-sl"></span></label></span>
</div>
</div>
</div>
<div id="task-modal-status" class="pe-seg-note" style="padding:0 6px"></div>
</div>
<div class="pe-foot">
<div class="foot-l">
<button id="task-del" class="pe-del" onclick="deleteTaskUI()" title="supprimer" aria-label="supprimer" style="display:none"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a1 1 0 011-1h6a1 1 0 011 1v2"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/></svg></button>
</div>
<div class="foot-r">
<button id="task-run" class="pe-btn" onclick="runTaskNow()" style="display:none">tester maintenant</button>
<button class="pe-btn" onclick="closeTask()">annuler</button>
<button class="pe-btn primary" onclick="saveTask()">enregistrer</button>
</div>
</div>
</div>
</div>
<div id="modal" class="modal-ov" style="display:none">
<div class="modal-card">
<div class="modal-head">
@@ -727,6 +974,23 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
<div class="pe-note">Ces dossiers servent aussi de destination de téléchargement.</div>
</div>
<!-- Jeton Hugging Face. Replié comme « Dossiers de modèles » : on n'y
touche qu'une fois, le jour où un dépôt à accès restreint refuse
le téléchargement. Il vaut aussi pour la recherche, d'où sa place
dans ce groupe plutôt qu'à côté de la clé API du serveur, qui ne
parle pas du tout de la même chose. -->
<div id="m-hft-toggle" class="pe-row" style="cursor:pointer" onclick="toggleHFToken()">
<span class="pe-row-l">Jeton Hugging Face<span class="pe-sub">dépôts à accès restreint (« gated ») ou privés</span></span>
<span class="pe-row-c"><span id="m-hft-state" class="pe-sub"></span><span id="m-hft-caret" class="pe-caret"></span></span>
</div>
<div id="m-hft-body" class="pe-row stack" style="display:none">
<div class="pe-inpbtn">
<input id="m-hft-input" class="pe-inp" type="password" autocomplete="off" spellcheck="false" placeholder="hf_…" onkeydown="if(event.key==='Enter'){event.preventDefault();saveHFToken();}">
<button id="m-hft-save" onclick="saveHFToken()" title="Vérifier et enregistrer" aria-label="Enregistrer">✓</button>
</div>
<div id="m-hft-note" class="pe-note"></div>
<button id="m-hft-clear" class="pe-link" onclick="clearHFToken()" style="display:none">Retirer le jeton</button>
</div>
</div>
</div>
@@ -739,7 +1003,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<label id="be-lab-image" style="display:none"><input type="radio" name="m-be" value="image" onchange="onBackendMode('image')"><span>Image</span><span class="be-note" id="be-image-note"></span></label>
<label id="be-lab-fast"><input type="radio" name="m-be" value="fast" onchange="onBackendMode('fast')"><span>Précompilé</span><span class="be-note" id="be-fast-note"></span></label>
<label id="be-lab-opt"><input type="radio" name="m-be" value="opt" onchange="onBackendMode('opt')"><span>Compilé</span><span class="be-note" id="be-opt-note"></span></label>
<label><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
<label id="be-lab-custom"><input type="radio" name="m-be" value="custom" onchange="onBackendMode('custom')"><span>Personnalisé</span></label>
</div>
<div id="m-backend-custom" class="pe-list" style="display:none;margin-top:8px">
<div class="pe-row">
@@ -776,7 +1040,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-c"><input id="s-ctx" class="pe-val" type="number" min="0" step="1024" placeholder="32768" oninput="cfgWriteKey('CTX', this.value)"><span class="pe-unit">tok</span></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 = tout sur le GPU · auto = ce qui tient en VRAM</span></span>
<span class="pe-row-l">Couches sur GPU<span class="pe-sub">999 ou auto = ce qui tient en VRAM · all = tout, quitte à saturer</span></span>
<span class="pe-row-c"><input id="s-ngl" class="pe-val" type="text" inputmode="numeric" placeholder="999" oninput="cfgWriteKey('NGL', this.value)"></span>
</div>
<div class="pe-row">
@@ -842,7 +1106,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<span class="pe-row-c"><label class="pe-switch"><input type="checkbox" id="s-reasoning" onchange="cfgWriteKey('REASONING', this.checked?'on':'off')"><span class="pe-sl"></span></label></span>
</div>
<div class="pe-row" id="s-reasoning-effort-row">
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">envoyée au gabarit du modèle — auto = ne rien imposer</span></span>
<span class="pe-row-l">Intensité du raisonnement<span class="pe-sub" id="s-reasoning-effort-sub">défaut de ce preset — ajustable à la volée depuis la barre de saisie</span></span>
<span class="pe-row-c">
<select id="s-reasoning-effort" class="pe-val" onchange="cfgWriteKey('REASONING_EFFORT', this.value)">
<option value="">auto</option>
@@ -850,6 +1114,7 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
<option value="low">basse</option>
<option value="medium">moyenne</option>
<option value="high">haute</option>
<option value="xhigh">maximale</option>
</select>
</span>
</div>
@@ -868,6 +1133,42 @@ document.documentElement.setAttribute('data-side',localStorage.getItem('loki-sid
</div>
</div>
<!-- Échantillonnage (repris de l'amont AJEAN v0.9.5/v0.9.6) : ces valeurs
partent dans CHAQUE requête de complétion, pas sur la ligne de commande
du moteur — c'est le seul endroit où elles prennent effet. Un champ vide
n'envoie rien, llama-server garde son défaut. Les valeurs recommandées
dépendent du modèle (Qwen3.8 en réflexion : temp 1.0, top_p 0.95,
top_k 20, min_p 0). -->
<div id="m-sampling-row" class="pe-group" style="display:none">
<div class="pe-gh">Échantillonnage</div>
<div class="pe-list">
<div class="pe-row">
<span class="pe-row-l">Température<span class="pe-sub">vide = défaut du modèle</span></span>
<span class="pe-row-c"><input id="s-temp" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('TEMP', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">top_p<span class="pe-sub">noyau de probabilité</span></span>
<span class="pe-row-c"><input id="s-topp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('TOP_P', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">top_k<span class="pe-sub">0 = désactivé</span></span>
<span class="pe-row-c"><input id="s-topk" class="pe-val" type="number" min="0" step="1" placeholder="—" oninput="cfgWriteKey('TOP_K', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">min_p<span class="pe-sub">seuil de probabilité</span></span>
<span class="pe-row-c"><input id="s-minp" class="pe-val" type="number" min="0" max="1" step="0.01" placeholder="—" oninput="cfgWriteKey('MIN_P', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Pénalité de présence<span class="pe-sub">pousse vers de nouveaux mots (0 = off)</span></span>
<span class="pe-row-c"><input id="s-presp" class="pe-val" type="number" min="0" max="2" step="0.1" placeholder="—" oninput="cfgWriteKey('PRESENCE_PENALTY', this.value)"></span>
</div>
<div class="pe-row">
<span class="pe-row-l">Pénalité de répétition<span class="pe-sub">anti-répétition (1 = neutre)</span></span>
<span class="pe-row-c"><input id="s-reppen" class="pe-val" type="number" min="0" step="0.05" placeholder="—" oninput="cfgWriteKey('REPEAT_PENALTY', this.value)"></span>
</div>
</div>
</div>
<div id="m-raw" class="pe-group">
<div class="pe-gh" id="m-raw-head">Configuration</div>
<div class="pe-list">
+3
View File
@@ -26,6 +26,9 @@ const ICONS = {
plus: '<path d="M12 5.5v13"/><path d="M5.5 12h13"/>',
chevron: '<path d="M9.5 5.5 16 12l-6.5 6.5"/>',
search: '<circle cx="11" cy="11" r="6.5"/><path d="M20 20l-4.4-4.4"/>',
// Anneau ouvert : c'est la ROTATION (CSS) qui en fait un indicateur d'activité,
// le dessin reste au même trait que le reste du jeu.
spinner: '<path d="M12 3.5a8.5 8.5 0 1 1-8.5 8.5"/>',
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2"/><path d="M12 19.3v2.2"/><path d="M4.2 4.2l1.6 1.6"/><path d="M18.2 18.2l1.6 1.6"/><path d="M2.5 12h2.2"/><path d="M19.3 12h2.2"/><path d="M4.2 19.8l1.6-1.6"/><path d="M18.2 5.8l1.6-1.6"/>',
};
+6 -1
View File
@@ -9,7 +9,12 @@ async function loadStatus(){
let cls='err', txt='arrêté';
if(s.active && s.health){ cls='ok'; txt='prêt'; }
else if(s.load_error){ cls='err'; txt='erreur'; }
else if(s.active){ cls='loading'; txt='chargement…'; }
else if(s.active){
cls='loading'; txt='chargement…';
// Pourcentage estimé (octets du modèle déjà lus) : voir « ça avance »
// plutôt qu'un « chargement… » figé qui ressemble à un plantage.
if(typeof s.load_pct==='number' && s.load_pct>=0) txt='chargement '+s.load_pct+' %';
}
el.className='statuspill '+cls;
el.innerHTML='<span class="dot"></span>'+txt;
MODEL_READY = !!(s.active && s.health);
+1 -1
View File
@@ -451,7 +451,7 @@ async function loadAll(){
// MANQUANTE lève ici une ReferenceError qui est avalée en silence. Toute
// suppression de module doit donc retirer son appel de cette ligne, et le test
// navigateur écoute `pageerror` pour ne pas s'en apercevoir trop tard.
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp()]);
await Promise.allSettled([loadStatus(),loadVram(),loadRam(),loadCfg(),loadPresets(),loadModelSwitchFiles(),loadConversations(),loadIdentity(),loadAgent(),loadInternet(),loadMCP(),loadApiKey(),loadPrefs(),loadLlamacpp(),loadThinkEffort(),loadTasks()]);
releaseHeights(); // tout est en place : on rend la main et on mesure pour la prochaine fois
}
async function act(a){ toast(a+'…'); await jpost('/api/'+a); setTimeout(loadAll,1500); }
+144 -6
View File
@@ -101,6 +101,7 @@ async function openItem(kind, key){
// Model picker is preset-only: it edits the MODEL= line of the preset.
const modelRow = document.getElementById('m-model-row');
const settingsRow = document.getElementById('m-settings-row');
const samplingRow = document.getElementById('m-sampling-row');
const rawHead = document.getElementById('m-raw-head');
const rawToggle = document.getElementById('m-raw-toggle');
const rawBody = document.getElementById('m-raw-body');
@@ -108,6 +109,7 @@ async function openItem(kind, key){
if(kind === 'preset'){
modelRow.style.display = 'flex';
settingsRow.style.display = 'flex';
if(samplingRow) samplingRow.style.display = 'flex';
document.getElementById('m-hf-url').value = '';
resetDlUI();
resetHfUI();
@@ -119,9 +121,14 @@ async function openItem(kind, key){
// Dossiers de modèles : replié par défaut (réglage rare), chargé à l'ouverture.
document.getElementById('m-dir-path').value = '';
setModelDirsOpen(false);
// Jeton Hugging Face : même traitement, mais son ÉTAT est lu tout de suite —
// c'est lui qui s'affiche sur la ligne repliée (« aucun », « enregistré »).
setHFTokenOpen(false);
loadHFToken();
} else {
modelRow.style.display = 'none';
settingsRow.style.display = 'none';
if(samplingRow) samplingRow.style.display = 'none';
// Page mémoire : le contenu EST le champ principal — affiché en clair.
rawHead.textContent = 'Contenu';
rawToggle.style.display = 'none';
@@ -258,6 +265,70 @@ function setModelDirsOpen(open){
function toggleModelDirs(){
setModelDirsOpen(document.getElementById('m-dirs-body').style.display === 'none');
}
// ---- Jeton Hugging Face ----------------------------------------------------
// Un dépôt « gated » se lit sans jeton mais ne se télécharge pas. Le jeton se
// posait uniquement par la variable d'environnement HF_TOKEN : en conteneur, ça
// voulait dire éditer un docker-compose et recréer le conteneur pour répondre à
// un 401 découvert ici. Il se règle donc à l'endroit où le problème apparaît.
function setHFTokenOpen(open){
const b = document.getElementById('m-hft-body');
const c = document.getElementById('m-hft-caret');
if(!b) return;
b.style.display = open ? '' : 'none';
if(c) c.classList.toggle('open', open);
}
function toggleHFToken(){
setHFTokenOpen(document.getElementById('m-hft-body').style.display === 'none');
}
async function loadHFToken(){
let d = {};
try{ d = await jget('/api/hf/token'); }catch(_){ return; }
renderHFToken(d);
}
// Le jeton n'est jamais renvoyé en clair par le serveur : on n'affiche que sa
// forme masquée et sa provenance. « env » se signale à part — l'interface ne
// peut pas effacer une variable du conteneur, promettre le contraire par un
// bouton « Retirer » serait mentir.
function renderHFToken(d){
const st = document.getElementById('m-hft-state');
const note = document.getElementById('m-hft-note');
const clear = document.getElementById('m-hft-clear');
const inp = document.getElementById('m-hft-input');
if(!st || !note || !clear || !inp) return;
inp.value = '';
const src = (d && d.source) || '';
st.textContent = src ? (d.masked || 'enregistré') : 'aucun';
clear.style.display = src === 'config' ? '' : 'none';
if(src === 'env'){
note.textContent = 'Jeton fourni par la variable d’environnement HF_TOKEN. En enregistrer un ici le remplacera.';
} else if(src === 'config'){
note.textContent = 'Jeton enregistré' + (d.account ? ' — compte ' + d.account : '')
+ '. Un dépôt à accès restreint exige EN PLUS d’avoir accepté ses conditions sur huggingface.co avec ce compte.';
} else {
note.textContent = 'Sans jeton, les dépôts à accès restreint répondent 401 au téléchargement. Crée un jeton en lecture sur huggingface.co/settings/tokens, accepte les conditions du dépôt, puis colle-le ici.';
}
}
// Vérifié côté serveur AVANT d'être enregistré : un jeton mal collé accepté en
// silence rendrait exactement le 401 qu'on cherche à expliquer.
async function saveHFToken(){
const inp = document.getElementById('m-hft-input');
const tok = (inp.value || '').trim();
if(!tok){ toast('colle un jeton (hf_…)'); return; }
toast('vérification du jeton…');
const r = await jpost('/api/hf/token', {token: tok});
if(!r || !r.ok){ toast('jeton refusé : ' + ((r && r.error) || '')); return; }
renderHFToken(r);
toast(r.account ? 'jeton enregistré — compte ' + r.account : 'jeton enregistré');
}
async function clearHFToken(){
if(!await askConfirm('Les dépôts à accès restreint répondront de nouveau 401 au téléchargement.',
{title:'Retirer le jeton Hugging Face ?', okText:'Retirer'})) return;
const r = await jpost('/api/hf/token', {token: ''});
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || '')); return; }
renderHFToken(r);
toast('jeton retiré');
}
async function populateModelDirs(){
const box = document.getElementById('m-dirs-list');
if(!box) return;
@@ -273,7 +344,9 @@ async function populateModelDirs(){
const info = document.createElement('span');
info.className = 'muted';
const n = x.count >= 0 ? (x.count + ' modèle' + (x.count>1?'s':'')) : 'illisible';
const free = x.free >= 0 ? ', ' + fmtSize(x.free) + ' libres' : '';
// « ~ » quand le système de fichiers approxime (FUSE/shfs d'Unraid,
// overlay, réseau) : le chiffre est indicatif, il ne bloque plus rien.
const free = x.free >= 0 ? ', ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : '';
info.textContent = '(' + n + free + (x.home ? ', dossier loki' : '') + ')';
row.append(p, info);
if(!x.home){
@@ -399,6 +472,9 @@ async function populateBackend(){
document.getElementById('be-lab-image').style.display = beImagePath ? '' : 'none';
document.getElementById('be-lab-fast').style.display = beImagePath ? 'none' : '';
document.getElementById('be-lab-opt').style.display = beImagePath ? 'none' : '';
// En conteneur, « Personnalisé » ne mène nulle part (rien à compiler, et le
// moteur se met à jour par la carte Moteur des réglages) : on le retire.
document.getElementById('be-lab-custom').style.display = beImagePath ? 'none' : '';
document.getElementById('be-image-note').textContent = beImagePath ? 'llama.cpp officiel' : '';
// Menu « backend détecté » du mode personnalisé : tout ce qu'on trouve dans
// le dossier backends de loki (l'utilisateur peut y déposer son propre build).
@@ -413,8 +489,11 @@ async function populateBackend(){
const cur = currentBinInTextarea();
let mode = 'custom';
// Un preset sans BIN hérite du moteur global : en conteneur c'est celui de
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ».
// l'image, qu'on affiche comme tel plutôt qu'en « personnalisé ». Et comme
// l'option Personnalisé n'y est plus proposée, TOUT BIN non reconnu retombe
// aussi sur l'image (un vieux preset pointant un chemin mort redevient sain).
if(beImagePath && (sameBinPath(cur, beImagePath) || !cur)) mode = 'image';
else if(beImagePath) mode = 'image';
else if(sameBinPath(cur, beFastPath) || underDir(cur, beFastDir)) mode = 'fast';
else if(sameBinPath(cur, beOptPath)) mode = 'opt';
const radio = document.querySelector('input[name=m-be][value='+mode+']');
@@ -729,6 +808,14 @@ function populateSettings(){
set('s-ubatch', cfgReadKey('UBATCH'));
set('s-tbatch', cfgReadKey('THREADS_BATCH'));
set('s-kv', cfgReadKey('KV_TYPE'));
// Échantillonnage : envoyé PAR REQUÊTE (applySampling côté serveur), donc pas
// besoin de redémarrer le moteur pour qu'un changement prenne effet.
set('s-temp', cfgReadKey('TEMP'));
set('s-topp', cfgReadKey('TOP_P'));
set('s-topk', cfgReadKey('TOP_K'));
set('s-minp', cfgReadKey('MIN_P'));
set('s-presp', cfgReadKey('PRESENCE_PENALTY'));
set('s-reppen', cfgReadKey('REPEAT_PENALTY'));
set('s-moe', eaGetValued('--n-cpu-moe'));
set('s-spec-n', eaGetValued('--spec-draft-n-max'));
setSpecType(eaGetValued('--spec-type'));
@@ -755,9 +842,12 @@ function populateSettings(){
eff.value = (cfgReadKey('REASONING_EFFORT')||'').toLowerCase();
const effSub = document.getElementById('s-reasoning-effort-sub');
if(effSub){
// Deux emplacements, deux portées : ici c'est le défaut ENREGISTRÉ avec le
// preset (donc rattaché au modèle) ; la liste de la barre de saisie le
// dévie pour la suite de la session, jusqu'au prochain preset appliqué.
effSub.textContent = eff.value
? 'envoyée au gabarit du modèle — sans effet si son gabarit l\'ignore'
: 'envoyée au gabarit du modèle — auto = ne rien imposer';
? 'défaut de ce preset — sans effet si le gabarit du modèle l\'ignore'
: 'défaut de ce preset — ajustable à la volée depuis la barre de saisie';
}
}
chk('s-flash', eaHasFlag('--flash-attn') && !/^off$/i.test(eaGetValued('--flash-attn')));
@@ -893,7 +983,7 @@ async function populateDlDirs(){
for(const x of dlDirList){
const o = document.createElement('option');
o.value = x.path;
o.textContent = x.path + (x.free >= 0 ? ' — ' + fmtSize(x.free) + ' libres' : '');
o.textContent = x.path + (x.free >= 0 ? ' — ' + (x.free_exact === false ? '~' : '') + fmtSize(x.free) + ' libres' : '');
sel.append(o);
}
if(prev && dlDirList.some(x => samePath(x.path, prev))) sel.value = prev;
@@ -931,7 +1021,9 @@ async function startDownloadURL(url){
// p.size couvre TOUTES les tranches : un modèle découpé annonce ses 45 Go, pas
// les 15 Go du fichier dont le lien a été collé.
const nparts = p.parts > 1 ? ' en '+p.parts+' fichiers' : '';
e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '+fmtSize(p.free)+' libres';
e.prog.textContent = fmtSize(p.size)+nparts+' à télécharger — '
+ (p.free_exact === false ? '~' : '') + fmtSize(p.free) + ' libres'
+ (p.free_exact === false ? ' (estimation : partage réseau ou agrégé)' : '');
const r = await jpost('/api/models/download', {url, dir});
if(!r.ok){
e.prog.innerHTML = '<span style="color:var(--err)">erreur : '+(r.error||'')+'</span>';
@@ -1069,11 +1161,29 @@ async function hfSearch(){
const m = document.createElement('span'); m.className = 'hf-meta';
m.textContent = fmtCount(rep.downloads)+' ↓';
row.append(n, m);
// Dépôt verrouillé : il se LIT sans rien, mais chaque .gguf répond 401 au
// transfert. La pastille le dit dès la liste — sinon on choisit un quant,
// on lance 15 Go et l'erreur tombe à la fin de la sonde.
if(rep.gated){
const g = document.createElement('span');
g.className = 'hf-fit gated';
g.textContent = 'accès restreint';
g.title = hfGatedHint(r.hf_token);
row.appendChild(g);
}
list.appendChild(row);
}
o.appendChild(list);
}
// Le geste à faire n'est pas le même selon qu'un jeton est configuré ou non :
// sans jeton il en faut un, avec jeton c'est l'accès au dépôt qui manque.
function hfGatedHint(hasToken){
return hasToken
? 'Dépôt à accès restreint : le jeton enregistré sera utilisé. Si le téléchargement échoue en 401, accepte les conditions du dépôt sur huggingface.co avec le compte de ce jeton.'
: 'Dépôt à accès restreint : accepte ses conditions sur huggingface.co, puis enregistre un jeton dans « Jeton Hugging Face » plus bas. Sans jeton, le téléchargement échouera en 401.';
}
// Millions/milliers abrégés : un dépôt à 1 945 635 téléchargements dit surtout
// « celui-là est le plus utilisé », pas son compte exact.
function fmtCount(n){
@@ -1101,8 +1211,36 @@ async function hfPickRepo(repo){
back.onclick = hfSearch;
const title = document.createElement('span'); title.className = 'hf-name'; title.textContent = repo;
head.append(back, title);
if(r.gated){
const g = document.createElement('span');
g.className = 'hf-fit gated'; g.textContent = 'accès restreint';
head.appendChild(g);
}
o.appendChild(head);
// Avertissement en toutes lettres : la pastille seule ne dit pas quoi faire,
// et c'est ici que le téléchargement se déclenche.
if(r.gated){
const warn = document.createElement('div');
warn.className = 'pe-note';
warn.style.color = r.hf_token ? 'var(--warn)' : 'var(--err)';
warn.textContent = hfGatedHint(r.hf_token);
o.appendChild(warn);
// Le réglage est plus bas dans la même fenêtre : y mener directement évite
// de le chercher, et c'est le seul geste qui débloque ce dépôt.
if(!r.hf_token){
const go = document.createElement('button');
go.className = 'pe-link'; go.textContent = 'Renseigner un jeton Hugging Face';
go.onclick = ()=>{
setHFTokenOpen(true);
const inp = document.getElementById('m-hft-input');
document.getElementById('m-hft-toggle').scrollIntoView({block:'center', behavior:'smooth'});
if(inp) inp.focus();
};
o.appendChild(go);
}
}
// Projecteur vision : proposé UNIQUEMENT s'il vient de ce dépôt. Un mmproj
// encode dans l'espace latent de SON modèle ; en prendre un ailleurs donne un
// moteur qui démarre et ne voit rien. Quand le dépôt n'en publie pas, on le
+72 -6
View File
@@ -56,9 +56,13 @@ document.addEventListener('DOMContentLoaded', ()=>{
});
});
// Badge de rôle du mode code (builder / verifier) : posé par 09-stream.js à
// réception d'un delta {role}, repris par addMsg sur les bulles suivantes.
let ROLE_BADGE='';
function addMsg(role, text){
const el=document.createElement('div');
el.className='msg '+role;
if(ROLE_BADGE && (role==='assistant'||role==='reasoning'||role==='tool')) el.dataset.agentRole=ROLE_BADGE;
const collapsible = (role==='reasoning' || role==='tool');
// .body must be a real block so <p>/<pre>/<ul> margins behave properly.
if(collapsible){
@@ -72,6 +76,11 @@ function addMsg(role, text){
if((role==='user'||role==='assistant') && typeof paintLabel==='function'){
paintLabel(el.querySelector('.label'), role);
}
// Badge visible sur la bulle assistant pendant une passe de rôle.
if(ROLE_BADGE && role==='assistant'){
const b=document.createElement('span'); b.className='rolebadge'; b.textContent=ROLE_BADGE==='verifier'?'vérification':ROLE_BADGE;
el.querySelector('.label').appendChild(b);
}
}
el.querySelector('.body').textContent=text;
// Bulles techniques (raisonnement, outils, mémoire) : elles se posent CÔTE À
@@ -153,13 +162,21 @@ function setLabelCounts(el, add, del){
if(del) cnt.appendChild(Object.assign(document.createElement('span'),{className:'d',textContent:'-'+del}));
lab.appendChild(cnt);
}
// Ligne de mesures sous une réponse (prefill / decode). Les étiquettes VOUS/Loki
// sont masquées dans cette mise en page, donc les chiffres qu'on y écrivait
// avaient disparu : ils ont leur propre ligne, discrète, sous le texte. Masquée
// par la préférence d'affichage « masquer la vitesse de génération » (CSS).
function setStats(el, text){
// Ligne de mesures sous une réponse. Les étiquettes VOUS/Loki sont masquées dans
// cette mise en page, donc les chiffres qu'on y écrivait avaient disparu : ils
// ont leur propre ligne, discrète, sous le texte.
//
// Deux informations de nature différente y cohabitent, d'où deux éléments :
// .worktime — le TEMPS DE TRAVAIL du tour (de la question à la fin de la
// réponse, outils et raisonnement compris) ;
// .spd — les mesures de vitesse (prefill / decode).
// Seule la seconde est escamotée par « masquer la vitesse de génération » : la
// durée n'est pas une mesure de moteur, c'est ce que la réponse a coûté en
// temps. La ligne entière disparaît si elle ne porte plus que de la vitesse.
function setStats(el, work, speed){
if(!el) return;
let s = el.querySelector(':scope > .statline');
if(!work && !speed){ if(s) s.textContent=''; return; }
if(!s){
s=document.createElement('div'); s.className='statline';
// Apparition en fondu, à la PREMIÈRE pose seulement : la ligne arrive une fois
@@ -169,7 +186,18 @@ function setStats(el, text){
if(!(typeof REPLAYING!=='undefined' && REPLAYING)) s.classList.add('statline-in');
el.appendChild(s);
}
s.textContent = text;
// Les deux travées sont RÉUTILISÉES d'un rafraîchissement à l'autre : la ligne
// est repeinte à chaque token reçu, y recréer des éléments ferait travailler la
// mise en page des dizaines de fois par seconde pour rien.
let w = s.querySelector(':scope > .worktime');
let p = s.querySelector(':scope > .spd');
if(!w){ w=document.createElement('span'); w.className='worktime'; s.appendChild(w); }
if(!p){ p=document.createElement('span'); p.className='spd'; s.appendChild(p); }
w.textContent = work || '';
// Le séparateur voyage AVEC la vitesse : masquer celle-ci ne doit pas laisser
// un « · » orphelin en bout de durée.
p.textContent = speed ? ((work ? ' \u00b7 ' : '') + speed) : '';
s.classList.toggle('has-work', !!work);
}
function bodyOf(el){ return el.querySelector('.body'); }
// Render markdown into a message body in place; safe because md() escapes HTML.
@@ -191,6 +219,13 @@ function hydrateImages(root){
img.dataset.hydrated = '1';
const src = img.getAttribute('src');
img.classList.add('chatimg');
// Nom lisible pour la loupe : la fin du chemin visé, pas l'URL d'API.
let name = img.getAttribute('alt') || '';
if(!name){
const m = /[?&]path=([^&]*)/.exec(src);
if(m){ try{ name = decodeURIComponent(m[1]).split('/').pop(); }catch(_){ name = m[1]; } }
}
bindZoom(img, name);
const cached = IMG_CACHE.get(src);
if(cached){ img.src = cached; return; }
try{
@@ -223,6 +258,17 @@ function renderToolMsg(el, tu){
web_open: {lbl:'page web', head:'ouverture'},
web_read: {lbl:'page web', head:'lecture'},
web_grep: {lbl:'page web', head:'recherche'},
// Outils du mode code : chacun son étiquette, pas le fallback mémoire.
read: {lbl:'lecture', head:'lecture'},
grep: {lbl:'recherche', head:'grep'},
glob: {lbl:'fichiers', head:'glob'},
criteria: {lbl:'critères', head:'critères'},
ask: {lbl:'question', head:'question'},
bash_bg: {lbl:'terminal', head:'commande (fond)'},
bash_tail: {lbl:'terminal', head:'suivi de job'},
git_status: {lbl:'git', head:'git status'},
git_diff: {lbl:'git', head:'git diff'},
git_clone: {lbl:'git', head:'git clone'},
};
// Outils MCP (nom mcp__<serveur>__<outil>) : en-tête = nom du serveur, libellé lisible,
// pas le fallback mémoire. On extrait serveur et outil du nom namespacé.
@@ -280,6 +326,21 @@ function renderToolMsg(el, tu){
// la ligne en cours (max-height côté CSS l'empêche de pousser le fil).
pre.scrollTop = pre.scrollHeight;
}
// Capture d'écran : l'image est rendue ICI, dans la bulle de l'outil. Elle ne
// dépend donc plus de la bonne volonté du modèle à recopier la ligne markdown
// — il l'oubliait, et l'utilisateur ne voyait jamais ce qu'il avait demandé.
if(tu.image){
const wrap=document.createElement('div'); wrap.className='tool-shot';
const img=document.createElement('img');
// src (et non data-src) : hydrateImages sélectionne sur src, puis le
// remplace par un blob obtenu avec la clé de pilotage.
img.setAttribute('src', '/api/chat/image?path='+encodeURIComponent(tu.image));
img.alt = tu.image.split('/').pop();
img.loading = 'lazy';
wrap.appendChild(img);
body.appendChild(wrap);
hydrateImages(wrap);
}
// Diff d'une écriture (fichier ou page de mémoire) : lignes ajoutées en vert,
// retirées en rouge, contexte en gris — comme un diff de terminal.
if(tu.diff && tu.diff.length){
@@ -307,6 +368,11 @@ function renderToolMsg(el, tu){
body.appendChild(wait);
}
addCopyButtons(body); scrollMaybe();
// Carte à hauteur bornée : tant que l'outil TRAVAILLE, on suit le bas (la
// ligne en cours d'écriture, la commande qui se tape). Terminé, on remonte en
// tête — c'est le résumé qu'on relit, pas la dernière ligne.
const bw = el.querySelector('.bodywrap');
if(bw){ bw.scrollTop = tu.done ? 0 : bw.scrollHeight; }
}
// Inject a "copier" button into every <pre> code block (idempotent).
function addCopyButtons(root){
+304 -18
View File
@@ -51,8 +51,107 @@ function confirmPending(text){
let REPLAYING=true;
// État de rendu du tour courant, délimité par les événements user / turn_done.
let T=null;
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0 }; }
function newTurn(){ T={ reasonEl:null, contentEl:null, pendingToolEl:null, typingEl:null, fullContent:'', fullReason:'', turnCollapsibles:[], serverStats:null, reasonTok:0, contentTok:0, reasonFirstTs:0, reasonLastTs:0, contentFirstTs:0, contentLastTs:0, startTs:0, doneTs:0, speedText:'', model:'' }; }
newTurn();
// --- Temps de travail du tour ------------------------------------------------
// « Combien de temps l'IA a-t-elle travaillé ? » se lit sous la réponse, de la
// question envoyée à la fin du tour — raisonnement, appels d'outils et attentes
// compris. La mesure est prise sur les HORODATAGES SERVEUR (l'événement `user`
// puis `turn_done`) : elle est donc exacte en direct comme au rejeu du journal,
// où tout arrive d'un bloc côté client.
//
// Pendant le tour il n'y a pas encore de `turn_done` : le compteur avance à la
// seconde. Il ne peut pas se baser sur l'horloge du navigateur telle quelle (un
// téléphone n'est pas à la même heure que la machine), on garde donc l'ÉCART
// entre les deux, réévalué à chaque événement reçu en direct.
let TS_SKEW = 0; // horloge client − horloge serveur (ms)
function nowServer(){ return Date.now() - TS_SKEW; }
let WORK_TIMER = null;
function startWorkTimer(){ if(WORK_TIMER || REPLAYING) return; WORK_TIMER=setInterval(tickWork, 1000); }
function stopWorkTimer(){ if(WORK_TIMER){ clearInterval(WORK_TIMER); WORK_TIMER=null; } }
function tickWork(){ if(T.contentEl) paintStats(T.contentEl); paintTypingClock(); paintTurnClock(); }
// Chrono du TOUR COMPLET, affiché au-dessus de la carte de saisie : le temps
// entre l'envoi du message et le moment où l'on peut reparler — outils, passes
// de vérification et compaction compris. Les durées portées par les cartes ne
// disent que la génération de texte ; celle-ci répond à « ça a pris combien de
// temps, en tout ? ».
function paintTurnClock(){
const composer = document.getElementById('composer');
if(!composer || !T.startTs) return;
let el = document.getElementById('turn-clock');
if(!el){
el = document.createElement('div');
el.id = 'turn-clock';
el.className = 'composer-banner';
composer.insertBefore(el, composer.firstChild);
}
const ms = (T.doneTs || nowServer()) - T.startTs;
if(!(ms > 0)){ el.remove(); return; }
// Vitesse moyenne de la CONVERSATION à côté du temps du tour : le débit
// typique de ce modèle sur ce fil, toutes réponses confondues.
const avg = (CONV_MS > 1000 && CONV_TOK > 20)
? ' · moy ' + (CONV_TOK / (CONV_MS/1000)).toFixed(1) + ' tok/s' : '';
el.textContent = (T.doneTs ? 'tour terminé en ' : 'en cours — ') + fmtDur(ms) + avg;
el.classList.toggle('done', !!T.doneTs);
}
// --- Vitesse moyenne de la conversation --------------------------------------
// Alimentée par les événements `stats` (journalisés, donc rejoués au
// chargement : la moyenne survit au refresh). gen_tokens / gen_ms sont
// CUMULATIFS au sein d'une complétion et retombent à la suivante : on n'ajoute
// que le delta, et une valeur qui recule signale une nouvelle complétion.
let CONV_TOK = 0, CONV_MS = 0, _cgTok = 0, _cgMs = 0;
function feedConvSpeed(s){
const gt = s.gen_tokens || 0, gms = s.gen_ms || 0;
if(!gt) return;
if(gt < _cgTok){ _cgTok = 0; _cgMs = 0; }
CONV_TOK += gt - _cgTok;
CONV_MS += Math.max(0, gms - _cgMs);
_cgTok = gt; _cgMs = gms;
}
function resetConvSpeed(){ CONV_TOK = 0; CONV_MS = 0; _cgTok = 0; _cgMs = 0; }
// Le compteur se montre AUSSI sur l'indicateur « … » : pendant un appel d'outil
// ou un long prefill il n'y a encore aucune réponse sous laquelle écrire, et
// c'est précisément le moment où l'on se demande si ça avance.
function paintTypingClock(){
const el = T.typingEl;
if(!el || el.classList.contains('compacting')) return; // le compactage a son propre libellé
if(!T.startTs) return;
const ms = nowServer() - T.startTs;
if(!(ms > 1500)) return; // pas de compteur pour une réponse immédiate
let c = el.querySelector('.wclock');
if(!c){ c=document.createElement('span'); c.className='wclock'; el.appendChild(c); }
c.textContent = fmtDur(ms);
}
// Durée lisible : dixièmes sous 10 s (un tour court se juge à la fraction),
// secondes rondes ensuite, puis minutes et heures. Le séparateur décimal reste
// le POINT — la durée voisine des « 21.5 tok/s » sur la même ligne, une virgule
// y ferait deux conventions à trois mots d'écart.
function fmtDur(ms){
if(!(ms > 0)) return '';
if(ms < 10000) return (ms/1000).toFixed(1) + ' s';
const t = Math.round(ms/1000);
if(t < 60) return t + ' s';
const m = Math.floor(t/60), sec = t % 60;
if(m < 60) return m + ' min ' + String(sec).padStart(2, '0') + ' s';
return Math.floor(m/60) + ' h ' + String(m % 60).padStart(2, '0') + ' min';
}
// Libellé de durée du tour courant, vide tant qu'il n'y a rien à montrer (tour
// non commencé, ou horloges trop désaccordées pour que l'écart ait un sens).
function workLabel(){
if(!T.startTs) return '';
const ms = (T.doneTs || nowServer()) - T.startTs;
if(!(ms > 300)) return '';
return 'travail ' + fmtDur(ms);
}
// Repeint la ligne de mesures de la réponse : durée d'abord, vitesse ensuite.
// `speed` non fourni = on garde le dernier libellé de vitesse connu (c'est le
// cas du tic de seconde, qui ne fait avancer que la durée).
function paintStats(el, speed){
if(speed !== undefined) T.speedText = speed;
setStats(el, workLabel(), T.speedText);
}
const simpleMode=()=>document.documentElement.getAttribute('data-display')==='simple';
function removeTyping(){ if(T.typingEl){ T.typingEl.remove(); T.typingEl=null; } }
// Compactage : on ÉTIQUETTE l'indicateur de frappe déjà à l'écran au lieu
@@ -111,9 +210,23 @@ function renderStats(el, s){
// Réponse de l'assistant : ligne de mesures dédiée sous le texte (son étiquette
// est masquée dans cette mise en page). Bulle repliable : l'étiquette EST le
// bouton de repli, on y écrit comme avant.
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(parts).join(' · '));
else setStats(el, parts.join(' · '));
if(el.classList.contains('collapsible')) setLabel(el, ['reasoning'].concat(workLabel()||[], parts).join(' · '));
else paintStats(el, parts.join(' · '));
}
// --- Compteurs de bulle -----------------------------------------------------
// Ils sont PAR BULLE, jamais par tour. Un tour d'agent en ouvre une nouvelle
// après CHAQUE appel d'outil (le flux repasse T.contentEl à null) ; les
// compteurs, eux, n'étaient jamais remis à zéro. Chaque bulle affichait donc le
// CUMUL de tout le tour, divisé par le temps écoulé depuis le tout premier
// token — exécution des outils, pages web et prefill compris.
//
// Sur un tour agentique d'une heure, ça donnait une vitesse qui décroissait
// mécaniquement de 17 tok/s à 0,9 : le moteur n'avait pas ralenti, le compteur
// mesurait « tokens générés ÷ durée totale du tour ». La remise à zéro est faite
// à la CRÉATION de la bulle : tout chemin qui en ouvre une neuve est couvert,
// aujourd'hui comme demain.
function resetContentStats(){ T.contentTok=0; T.contentFirstTs=0; T.contentLastTs=0; T.speedText=''; }
function resetReasonStats(){ T.reasonTok=0; T.reasonFirstTs=0; T.reasonLastTs=0; }
// Label d'une bulle : nombre de tokens + vitesse. La vitesse est calculée à
// partir des HORODATAGES SERVEUR (firstTs→lastTs) : le temps réel de génération,
// donc correct aussi bien en direct qu'au replay (où les deltas arrivent d'un
@@ -122,18 +235,34 @@ function labelTokens(el, role, n, firstTs, lastTs){
if(!el) return;
const secs=(lastTs-firstTs)/1000;
const m = (secs>0.05 && n>1) ? n+' tok · '+(n/secs).toFixed(1)+' tok/s' : n+' tok';
// Bulle de raisonnement : sa propre durée de génération, comme le « réfléchi
// pendant… » d'un fil de discussion — elle reste lisible une fois repliée.
const d = (secs>0.05 && n>1) ? fmtDur(lastTs-firstTs) : '';
// Bulle technique : son étiquette EST le bouton de repli, les mesures y vont.
// Réponse de l'assistant : son étiquette porte l'avatar et le nom, les mesures
// vont dans la ligne dédiée — comme le font déjà les stats de fin de tour
// (applyStats). Sans ce partage, le libellé « Loki » était remplacé en direct
// par « assistant · 75 tok · 21.5 tok/s », et ne revenait qu'au rechargement.
if(el.classList.contains('collapsible')) setLabel(el, role+' · '+m);
else setStats(el, m);
if(el.classList.contains('collapsible')) setLabel(el, [role].concat(d||[], m).join(' · '));
else paintStats(el, m);
}
// Badge du modèle sur une bulle de réponse : le nom voyage avec la borne de
// tour (T.model), on le dépose dans dataset.model pour qu'applyIdentity — qui
// repeint les libellés de zéro — puisse le reposer (voir paintLabel).
function tagModel(el){
if(!el || !T.model) return;
el.dataset.model = T.model;
const l = el.querySelector('.label');
if(l && !l.querySelector('.modelbadge') && typeof modelBadge==='function') l.appendChild(modelBadge(T.model));
}
// Pendant le replay on met à jour l'état `busy` mais on NE touche PAS aux boutons
// (sinon user→stop puis turn_done→send à chaque tour rejoué = flottement visible).
// L'état final est appliqué une seule fois au caught_up via syncSendBtn().
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn(); }
function setBusy(on){ busy=on; if(!REPLAYING) syncSendBtn();
// La liste des discussions montre laquelle travaille : elle doit suivre l'état
// MÊME pendant le rejeu (une page rechargée en cours de génération doit voir
// l'anneau tourner sans attendre le premier événement en direct).
if(typeof convSyncBusy==='function') convSyncBusy(); }
function syncSendBtn(){
const sb=document.getElementById('send');
// ⚠️ L'état passe par un ATTRIBUT, jamais par un style inline. Ces deux
@@ -156,15 +285,130 @@ function syncSendBtn(){
hint.classList.toggle('waiting', !ready);
}
}
// ─── Rendu du bloc en cours : cadencé, puis lissé ────────────────────────────
// Repris de l'amont AJEAN (v0.9.5 issue #24, puis v0.10.5), adapté.
//
// Re-parser le Markdown du bloc ENTIER à chaque token est en O(n²) : sur un long
// raisonnement (des milliers de tokens) l'interface se met à ramer, les tokens
// semblent arriver au ralenti — alors que le moteur, lui, débite toujours autant
// — et un simple rafraîchissement « répare » tout, puisqu'il rend le bloc une
// seule fois. On coalesce donc : le texte s'accumule (concaténation, quasi
// gratuite) et on ne re-rend qu'à intervalle borné. Le rendu final exact est
// garanti par flushRender(), appelé à chaque frontière de bloc (outil, fin de
// tour, erreur, caught_up).
let renderTimer=null, renderPending=null, lastRenderMs=0; // {el, text}
function scheduleRender(el, text, final){
// Changement de bloc en cours de route : on rend d'abord l'ancien à sa dernière
// valeur, sinon son ultime bout de texte serait perdu.
if(renderPending && renderPending.el!==el) flushRender();
// `final` voyage AVEC le rendu en attente : le timer déjà armé (qui passe
// final=false) peut être celui qui consommera ce rendu — sans le drapeau sur
// le pending, le rendu de FIN de bloc repassait par le chemin « queue seule »
// et le début du raisonnement n'était jamais posé.
renderPending={el, text, final:!!final};
if(renderTimer) return;
// Cadence ADAPTATIVE : on vise à ne pas passer plus d'~1/6 du temps à re-parser
// le Markdown. Tant que le bloc est petit, un rendu coûte 1-2 ms → plancher
// 16 ms ≈ 60 img/s, l'apparition reste fluide token par token. Quand le bloc
// devient énorme, le rendu coûte cher et on espace tout seul jusqu'à 500 ms :
// le O(n²) est cassé sans jamais figer l'interface.
const delay=Math.min(500, Math.max(16, lastRenderMs*6));
renderTimer=setTimeout(()=>flushRender(false), delay);
}
// Au-delà de cette taille, un raisonnement EN COURS n'est re-parsé que sur sa
// fin : re-parser le bloc entier à chaque tick est en O(n²) et finissait par
// figer l'affichage (le texte semblait s'arrêter alors que le moteur débitait).
// La carte étant bornée en hauteur et collée en bas, on ne perd rien à l'écran ;
// le texte COMPLET est posé au rendu final (frontière de bloc → final=true).
const REASON_TAIL=6000;
function flushRender(final){
if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; }
const p=renderPending; renderPending=null;
if(!p) return;
let text=p.text;
if(final===false && !p.final && text.length>REASON_TAIL+800 && p.el.closest('.msg.reasoning')){
let cut=text.length-REASON_TAIL;
const nl=text.indexOf('\n', cut); if(nl>-1 && nl<cut+400) cut=nl+1;
text='*… (début replié pendant la génération — le texte complet est posé à la fin)*\n\n'+text.slice(cut);
}
// Carte bornée (raisonnement/outil) : si on était collé en bas, on y reste —
// la carte suit la génération. Un défilement manuel vers le haut est respecté.
// ⚠️ p.el est la BULLE (.msg), pas .body : le bodywrap est dedans, pas au-dessus.
const bw=p.el.querySelector ? p.el.querySelector(':scope > .bodywrap') : null;
const scrollable=!!bw;
const stick=scrollable && bw.scrollTop+bw.clientHeight>=bw.scrollHeight-60;
const t0=performance.now();
renderBody(p.el, text);
lastRenderMs=performance.now()-t0;
if(scrollable && stick) bw.scrollTop=bw.scrollHeight;
}
// Annule le rendu en attente SANS le poser : le bloc visé disparaît (fil vidé,
// raisonnement jeté), le rendre ensuite écrirait dans un élément détaché.
function cancelRender(){ if(renderTimer){ clearTimeout(renderTimer); renderTimer=null; } renderPending=null; }
// Lissage d'apparition (« machine à écrire »). Le décodage spéculatif (MTP) rend
// les tokens PAR RAFALES — plusieurs acceptés d'un coup, puis une pause : le
// texte grandit par paquets et saute à l'écran. On découple donc l'ARRIVÉE (les
// rafales du moteur) de l'AFFICHAGE : `target` = tout ce qui est reçu, `shown`
// avance à cadence régulière (requestAnimationFrame), et on n'affiche que le
// préfixe révélé. Le rendu passe TOUJOURS par scheduleRender pour conserver le
// garde-fou anti-O(n²) ci-dessus.
// UNIQUEMENT EN DIRECT : au rejeu tout est posé d'un bloc, sinon relire un fil
// deviendrait une lente réécriture caractère par caractère.
let smooth=null, smoothLast=0; // {el, target, shown, raf}
function smoothReset(){ if(smooth&&smooth.raf) cancelAnimationFrame(smooth.raf); smooth=null; smoothLast=0; }
// Solde le bloc courant : on affiche TOUT immédiatement. Appelé à chaque
// frontière (fin de tour, outil, changement de bloc) — sinon l'ultime bout de
// texte resterait en retard derrière le curseur de révélation.
function smoothSnap(){ if(!smooth) return; const el=smooth.el, target=smooth.target; smoothReset(); scheduleRender(el, target, true); }
// Débit BASÉ SUR LE TEMPS (indépendant du taux de rafraîchissement). La vitesse
// de révélation est proportionnelle au RETARD accumulé, avec une constante de
// temps TAU : le curseur traîne volontairement ~TAU derrière l'arrivée, ce qui
// donne un écoulement CONTINU malgré les rafales, et se vide en douceur quand la
// génération s'arrête. Un plancher garantit un progrès même sur un retard
// minuscule, sans jamais figer.
const SMOOTH_TAU=260; // ms — plus grand = plus lisse mais traîne davantage
function smoothStep(ts){
if(!smooth) return;
if(!smoothLast) smoothLast=ts;
const dt=Math.min(120, ts-smoothLast); smoothLast=ts;
const remaining=smooth.target.length - smooth.shown;
if(remaining<=0){ smooth.raf=null; smoothLast=0; return; }
let adv=remaining*dt/SMOOTH_TAU; // vitesse ∝ retard
if(adv<0.4) adv=0.4; // progrès minimal
smooth.shown=Math.min(smooth.target.length, smooth.shown+Math.ceil(adv));
scheduleRender(smooth.el, smooth.target.slice(0, smooth.shown));
smooth.raf=requestAnimationFrame(smoothStep);
}
function smoothFeed(el, target){
if(smooth && smooth.el!==el) smoothSnap(); // changement de bloc : solder l'ancien
if(!smooth) smooth={el, target, shown:0, raf:null};
smooth.target=target;
if(!smooth.raf) smooth.raf=requestAnimationFrame(smoothStep);
}
// Rend un bloc en streaming : lissé en direct, instantané au rejeu.
function feedBlock(el, full){ if(REPLAYING) scheduleRender(el, full); else smoothFeed(el, full); }
// Solde tout ce qui est en vol : le bloc est terminé, la suite (stats, repli,
// bulle d'outil) doit voir le texte complet.
function settleBlocks(){ smoothSnap(); flushRender(); }
// Traite UN événement du flux — même sémantique que l'ancien switch inline, mais
// piloté par le serveur et rejouable à l'identique.
function handleDelta(d){
if(typeof d.seq==='number' && d.seq>lastSeq) lastSeq=d.seq;
// Recalage de l'horloge : un événement reçu en DIRECT vient d'être émis, son
// `ts` serveur et l'heure locale désignent donc le même instant. Surtout pas
// pendant le rejeu, où les `ts` sont vieux de plusieurs heures.
if(!REPLAYING && typeof d.ts==='number' && d.ts>0) TS_SKEW = Date.now() - d.ts;
if(d.caught_up){
settleBlocks(); // rendre le dernier bloc rejoué à sa valeur exacte
// Fin du replay initial : on saute en bas puis on révèle (une seule fois — pas
// sur les reconnexions, pour ne pas te ramener en bas si tu lisais plus haut).
setChatLoading(null);
if(REPLAYING){ REPLAYING=false; jumpBottom(); syncSendBtn(); const c=chatEl(); c.style.transition='opacity .15s'; c.style.opacity='1'; }
// Le chrono ne démarre pas pendant le rejeu (aucun tic n'aurait de sens sur
// des tours déjà finis) : si le dernier tour rejoué est ENCORE en cours, il
// faut le lancer maintenant, sinon la durée resterait figée à l'écran.
if(busy && T.startTs && !T.doneTs) startWorkTimer();
// Fil vide : aucune bulle n'a été rejouée, donc aucune mutation ne viendra
// déclencher la synchro — c'est ici qu'on décide d'afficher l'accueil.
syncChatEmpty();
@@ -173,21 +417,45 @@ function handleDelta(d){
// serveur) : on nettoie l'écran et on resynchronise la liste, car la bascule
// a pu être déclenchée depuis un autre appareil. Les fichiers suivent : ils
// appartiennent à la discussion, le panneau doit changer avec elle.
if(d.reset!==undefined){ PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); return; }
if(d.reset!==undefined){ smoothReset(); cancelRender(); stopWorkTimer(); resetConvSpeed(); const tc=document.getElementById('turn-clock'); if(tc) tc.remove(); PENDING=null; document.getElementById('chat').innerHTML=''; newTurn(); setCtxUsed(0); lastSeq=0; setBusy(false); if(typeof loadConversations==='function') loadConversations(); if(typeof filesOnConvChange==='function') filesOnConvChange(); if(typeof modeOnConvChange==='function') modeOnConvChange(); return; }
// --- Mode code (20-mode.js) -----------------------------------------------
if(d.mode!==undefined){ if(typeof applyModeDelta==='function') applyModeDelta(d.mode); return; }
if(d.code_hint){ if(typeof showCodeHint==='function') showCodeHint(); return; }
if(d.criteria!==undefined){ if(typeof renderCriteria==='function') renderCriteria(d.criteria); return; }
// Bascule de rôle (mode code) : les blocs en cours appartiennent au rôle
// précédent — on les solde avant de les oublier, sinon leur dernier fragment
// resterait en vol et ne s'afficherait jamais.
if(d.role!==undefined){ settleBlocks(); ROLE_BADGE = d.role || ''; T.contentEl=null; T.reasonEl=null; return; }
if(d.ask){ if(typeof renderAskCard==='function') renderAskCard(d.ask); return; }
if(d.verify_done){ if(typeof onVerifyDone==='function') onVerifyDone(d.verify_done); return; }
if(d.user!==undefined){
newTurn();
// Modèle du tour : posé par le serveur sur la borne de tour (journalisée),
// donc présent au rejeu comme en direct. Repris par tagModel sur chaque
// bulle de réponse du tour.
T.model = d.model || '';
let el=PENDING;
if(!confirmPending(d.user)) el=addMsg('user', d.user);
// Pièces jointes du tour : rendues DANS la bulle. La bulle en attente en
// porte déjà (posées à l'envoi), on ne les ajoute donc qu'au replay/à une
// bulle neuve — sinon elles apparaîtraient en double.
if(d.files && !hasMsgFiles(el)) addMsgFiles(el, d.files);
setBusy(true); T.typingEl=addTyping(); return; }
// Départ du chronomètre : l'horodatage SERVEUR du message, pas l'heure
// locale — c'est ce qui rend la durée juste au rejeu comme en direct.
T.startTs = d.ts || 0; T.doneTs = 0;
setBusy(true); startWorkTimer(); paintTurnClock(); T.typingEl=addTyping(); return; }
// Fin de tour : la discussion vient d'être enregistrée côté serveur — son
// titre (déduit du 1er message) et son compteur d'échanges ont changé. Pas au
// replay, qui rejoue tous les tours passés d'un bloc.
if(d.turn_done){ removeTyping(); collapseAll(T.turnCollapsibles); if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats); setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
if(d.error){ removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.turn_done){ settleBlocks(); removeTyping(); collapseAll(T.turnCollapsibles); stopWorkTimer();
// La durée se fige ICI : au-delà, plus rien ne bouge, la ligne doit montrer
// le temps réellement passé et non continuer d'avancer.
T.doneTs = d.ts || T.doneTs || 0;
if(T.serverStats) renderStats(T.contentEl||T.reasonEl, T.serverStats);
else if(T.contentEl) paintStats(T.contentEl); // sans mesures serveur, la durée reste
paintTurnClock(); // fige le total au-dessus de la carte
setBusy(false); if(!REPLAYING && typeof loadConversations==='function') loadConversations(); return; }
if(d.error){ settleBlocks(); removeTyping(); T.contentEl=null; T.reasonEl=null; const eb=addMsg('assistant',''); eb.classList.add('errmsg'); renderBody(eb, d.error); return; }
if(d.compacting!==undefined){ setCompacting(d.compacting); return; }
if(d.compacted){ setCompacting(false); addCompactMark(); return; }
// Pas de toast au REPLAY : le journal est rejoué à chaque chargement de page,
@@ -197,29 +465,47 @@ function handleDelta(d){
// une trace du fil et DOIT être rejouée.
if(d.compact_noop){ setCompacting(false); if(!REPLAYING) toast('rien à compacter (contexte déjà minimal)'); return; }
if(d.ctx_used!==undefined){ setCtxUsed(d.ctx_used); return; }
if(d.stats){ T.serverStats=d.stats;
if(d.stats){ T.serverStats=d.stats; feedConvSpeed(d.stats);
// prompt_tokens_total = comptage exact du moteur (include_usage). Absent sur
// certains llama-server récents : le serveur publie alors un `ctx_used`
// estimé en fin de tour, traité plus bas — on ne remet donc PAS la jauge à
// zéro ici, on la laisse simplement inchangée.
if(d.stats.prompt_tokens_total){ setCtxUsed((d.stats.prompt_tokens_total||0)+(d.stats.gen_tokens||0)); }
if(T.contentEl||T.reasonEl) renderStats(T.contentEl||T.reasonEl, d.stats); return; }
if(d.tool_used){
settleBlocks(); // le bloc texte précédent (raisonnement/contenu) est terminé
killTyping('tool'); T.contentEl=null; T.reasonEl=null; const tu=d.tool_used;
// Une carte reste OUVERTE tant que SA section travaille (hauteur bornée à
// 280 px, défilement qui suit l'écriture), et se replie DÈS qu'elle est
// finie : l'outil à son résultat (tu.done, plus bas), le raisonnement quand
// le bloc suivant démarre (collapseAll à la création de la carte suivante).
if(!T.pendingToolEl){ collapseAll(T.turnCollapsibles); T.pendingToolEl=addMsg('tool',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.pendingToolEl); T.turnCollapsibles.push(T.pendingToolEl); }
renderToolMsg(T.pendingToolEl, tu);
// Outils masqués : on garde l'indicateur même quand l'appel est terminé (le
// tour continue, et rien d'autre n'est visible). Sinon, comportement inchangé.
if(!tu.done || viewOn('hide-tools')) showTyping('tool');
if(tu.done){ T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
// Résultat arrivé : la carte est finie, elle se replie tout de suite (en
// direct seulement — au rejeu elle est déjà née repliée). SAUF une capture
// d'écran : l'image EST le résultat, la replier la cacherait sitôt prise.
if(tu.done){
if(!REPLAYING && T.pendingToolEl && !tu.image) collapseBody(T.pendingToolEl);
// Carte à image : retirée de la liste du tour, sinon le repli de fin de
// tour (collapseAll) la fermerait quand même.
if(tu.image && T.pendingToolEl){ const i=T.turnCollapsibles.indexOf(T.pendingToolEl); if(i>=0) T.turnCollapsibles.splice(i,1); }
T.pendingToolEl=null; if(tu.name==='mem_add'||tu.name==='mem_edit') loadMem(); }
return; }
if(d.drop_reasoning){
smoothReset(); cancelRender(); // le bloc raisonnement disparaît : rien à rendre
if(T.reasonEl){ const i=T.turnCollapsibles.indexOf(T.reasonEl); if(i>=0) T.turnCollapsibles.splice(i,1); T.reasonEl.remove(); T.reasonEl=null; T.fullReason=''; }
return; }
if(d.reasoning_content){
killTyping('reasoning');
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; T.turnCollapsibles.push(T.reasonEl); }
if(!T.reasonEl){ collapseAll(T.turnCollapsibles); T.reasonEl=addMsg('reasoning',''); if(REPLAYING||viewOn('fold-tools')) collapseInstant(T.reasonEl); T.fullReason=''; resetReasonStats(); T.turnCollapsibles.push(T.reasonEl); }
// d.replace : le serveur renvoie le bloc ENTIER alors qu'on en affichait déjà
// le début (voir decorateEvent/coalesceReplay côté serveur) → on repart de zéro
// au lieu de concaténer, sinon le texte apparaît en double.
if(d.replace){ T.fullReason=''; T.reasonTok=0; T.reasonFirstTs=0; }
showTyping('reasoning'); T.fullReason+=d.reasoning_content; renderBody(T.reasonEl, T.fullReason);
if(d.replace){ smoothSnap(); T.fullReason=''; resetReasonStats(); }
showTyping('reasoning'); T.fullReason+=d.reasoning_content; feedBlock(T.reasonEl, T.fullReason);
// d.toks/d.ts0 présents quand l'événement est coalescé (replay) : plusieurs
// tokens d'un coup. Sinon (direct), 1 token, ts0=ts.
if(!T.reasonFirstTs) T.reasonFirstTs=d.ts0||d.ts||0; T.reasonLastTs=d.ts||T.reasonLastTs; T.reasonTok+=(d.toks||1);
@@ -227,9 +513,9 @@ function handleDelta(d){
return; }
if(d.content){
removeTyping();
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); T.fullContent=''; }
if(d.replace){ T.fullContent=''; T.contentTok=0; T.contentFirstTs=0; }
T.fullContent+=d.content; renderBody(T.contentEl, T.fullContent);
if(!T.contentEl){ collapseAll(T.turnCollapsibles); T.contentEl=addMsg('assistant',''); tagModel(T.contentEl); T.fullContent=''; resetContentStats(); }
if(d.replace){ smoothSnap(); T.fullContent=''; resetContentStats(); }
T.fullContent+=d.content; feedBlock(T.contentEl, T.fullContent);
if(!T.contentFirstTs) T.contentFirstTs=d.ts0||d.ts||0; T.contentLastTs=d.ts||T.contentLastTs; T.contentTok+=(d.toks||1);
labelTokens(T.contentEl, 'assistant', T.contentTok, T.contentFirstTs, T.contentLastTs);
return; }
+42
View File
@@ -0,0 +1,42 @@
// ─── Intensité du raisonnement (barre de saisie) ─────────────────────────────
// Le même réglage existe dans l'éditeur de preset, mais il y est figé avec le
// preset : le changer demande d'ouvrir les réglages, éditer, enregistrer. Ici il
// s'applique au message suivant, sans redémarrer le moteur — `reasoning_effort`
// voyage dans le corps de la requête, pas dans la ligne de commande.
//
// La valeur part dans la config vivante. Appliquer un preset la réécrit : le
// réglage reste donc attaché au modèle, cette liste ne fait que le dévier à la
// volée.
const THINK_EFFORT_LABELS = {
'': 'auto',
none: 'aucune',
low: 'basse',
medium: 'moyenne',
high: 'haute',
xhigh: 'maximale',
};
async function loadThinkEffort(){
try{ applyThinkEffort(await jget('/api/reasoning-effort')); }
catch(e){ /* endpoint indisponible : la liste garde sa valeur par défaut */ }
}
function applyThinkEffort(r){
const sel = document.getElementById('think-effort');
if(!sel || !r || !r.ok) return;
sel.value = r.value || '';
// Raisonnement coupé au niveau du preset : le niveau n'a plus de prise. On
// grise plutôt que de masquer, pour que le réglage reste trouvable.
sel.disabled = !r.on;
sel.title = r.on
? 'Intensité du raisonnement — envoyée au gabarit du modèle'
: 'Raisonnement désactivé pour ce modèle (réglages → Raisonnement)';
}
async function setThinkEffort(value){
const r = await jpost('/api/reasoning-effort', {value});
if(!r || !r.ok){ toast((r && r.error) || 'réglage refusé'); return; }
applyThinkEffort(r);
toast('raisonnement : '+(THINK_EFFORT_LABELS[r.value||''] || r.value));
}
+50 -4
View File
@@ -26,12 +26,28 @@ function fmtSize(n){
if(n >= 1024) return Math.round(n/1024)+' Ko';
return n+' o';
}
// Un nom de fichier ne dit pas ce qu'on a joint : pour une image, on montre
// l'image. Reconnaissance à l'extension — le serveur, lui, renifle le CONTENU
// (handleChatImage) et répond 415 si ça n'en est pas une, donc au pire on affiche
// une vignette cassée qui retombe sur son texte de repli.
const IMG_EXT = /\.(png|jpe?g|gif|webp|bmp|svg|avif|ico|heic|heif|tiff?)$/i;
function isImageName(name){ return IMG_EXT.test(String(name||'')); }
// Pastille de fichier, partagée par le composeur et les bulles du fil : même
// objet visuel des deux côtés, seul le contexte (CSS) change.
//
// opts.thumb = URL d'aperçu : la pastille porte alors une vignette à gauche du
// nom. Le composeur s'en sert avec un objectURL local (rien n'est encore parti
// vers le serveur à ce stade).
function fileChip(name, size, opts){
opts=opts||{};
const chip=document.createElement('div');
chip.className='chip-file'+(opts.cls?' '+opts.cls:'');
if(opts.thumb){
const t=document.createElement('img');
t.className='cf-thumb'; t.src=opts.thumb; t.alt='';
chip.appendChild(t);
chip.classList.add('has-thumb');
}
const n=document.createElement('span');
n.className='cf-name'; n.textContent=name; n.title=opts.title||name;
const s=document.createElement('span');
@@ -49,12 +65,31 @@ function fileChip(name, size, opts){
// Fichiers joints à un message DÉJÀ envoyé : posés AU-DESSUS de la bulle, dans
// leur propre rangée. Les mettre dedans étirait la bulle vers le haut et donnait
// un bloc bâtard, moitié texte moitié pastilles.
// Vignette d'une image DÉJÀ déposée : on la redemande au serveur par son chemin
// de travail. hydrateImages fait le fetch authentifié et pose le blob: — une
// balise <img> ne peut pas porter la clé de pilotage.
function msgImgTile(f){
const box=document.createElement('div');
box.className='msg-img';
const img=document.createElement('img');
img.src='/api/chat/image?path='+encodeURIComponent(f.path);
img.alt=f.name||''; img.title=f.name||'';
box.appendChild(img);
return box;
}
function addMsgFiles(el, files){
if(!el || !files || !files.length) return;
const box=document.createElement('div');
box.className='msg-files';
for(const f of files) box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
for(const f of files){
// Image : la vignette REMPLACE la pastille texte. Deux objets pour un seul
// fichier (une vignette + son nom en pastille) doublerait la rangée sans rien
// apprendre — le nom vit dans l'infobulle et dans la loupe.
if(f.path && isImageName(f.name)) box.appendChild(msgImgTile(f));
else box.appendChild(fileChip(f.name, f.size, {title:f.path||f.name}));
}
el.parentNode.insertBefore(box, el);
hydrateImages(box);
// Envoi sans un mot : la bulle serait un rectangle vide sous les pastilles.
markEmptyMsg(el);
return box;
@@ -202,11 +237,18 @@ function renderAttach(){
sizeText: a.state==='err' ? 'échec'
: (a.state==='up' && a.size>ATTACH_CHUNK) ? Math.round((a.sent||0)*100/a.size)+' %'
: fmtSize(a.size),
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); renderAttach(); }
thumb: a.thumb,
onRemove: ()=>{ ATTACH=ATTACH.filter(o=>o.id!==a.id); dropThumb(a); renderAttach(); }
}));
}
}
function clearAttach(){ ATTACH=[]; renderAttach(); }
// L'aperçu du composeur vit sur un objectURL local : il meurt avec sa pastille,
// sinon chaque fichier retiré laisse son image en mémoire jusqu'au rechargement.
// (La bulle envoyée, elle, repart du serveur — elle ne dépend pas de cet URL.)
function dropThumb(a){
if(a && a.thumb){ URL.revokeObjectURL(a.thumb); a.thumb=null; }
}
function clearAttach(){ for(const a of ATTACH) dropThumb(a); ATTACH=[]; renderAttach(); }
// Ce qui part avec le message, pour l'afficher dans sa bulle.
function attachSent(){ return ATTACH.filter(a=>a.state==='ok').map(a=>({name:a.name,size:a.size,path:a.path})); }
@@ -277,7 +319,11 @@ function addFiles(files){
for(const f of files||[]){
if(f.size>ATTACH_MAX){ toast('« '+f.name+' » fait '+fmtSize(f.size)+' — maximum '+fmtSize(ATTACH_MAX)); continue; }
if(!f.size){ toast('« '+f.name+' » est vide'); continue; }
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued'});
// Aperçu tout de suite, depuis le fichier local : rien n'est encore parti
// vers le serveur (le dépôt n'a lieu qu'à l'envoi), donc c'est la seule
// source d'image disponible à ce stade.
const thumb = isImageName(f.name) ? URL.createObjectURL(f) : null;
ATTACH.push({id:++ATTACH_SEQ, name:f.name||'fichier', size:f.size, file:f, path:null, state:'queued', thumb:thumb});
}
renderAttach();
}
+55
View File
@@ -86,6 +86,16 @@ function renderFoot(foot, r){
n.textContent = r.count + (r.count > 1 ? ' fichiers · ' : ' fichier · ') + fmtSize(r.total);
foot.appendChild(n);
foot.title = r.root || '';
// Tout emporter d'un coup : la discussion entière (dossiers et sous-dossiers
// compris) en une archive .zip. Absent quand il n'y a rien à archiver.
if(r.count > 0){
const z = document.createElement('button');
z.className = 'files-scope';
z.textContent = '.zip';
z.title = 'tout télécharger en une archive .zip';
z.onclick = ()=>downloadFilesZip(z);
foot.appendChild(z);
}
if(FILES_SCOPE !== 'legacy' && !r.legacy) return;
const b = document.createElement('button');
b.className = 'files-scope';
@@ -199,6 +209,51 @@ async function removeFile(e){
loadFiles();
}
// Télécharge TOUT le contenu affiché (discussion ou hors-discussion) en .zip.
// Même danse que downloadWorkspaceFile (14-attach.js) : la fiche meta d'abord —
// c'est elle qui CONSTRUIT l'archive côté serveur — puis le brut en direct, ou
// les tranches base64 derrière le tunnel chiffré.
async function downloadFilesZip(btn){
if(btn){ btn.disabled = true; btn.textContent = '…'; }
try{
const m = await jfetch('/api/chat/files/zip?meta=1&scope='+encodeURIComponent(FILES_SCOPE));
const meta = await m.json().catch(()=>({}));
if(!m.ok || !meta.ok){ toast('archive impossible : '+(meta.error||('HTTP '+m.status))); return; }
let blob;
if(meta.e2e){
blob = await fetchZipB64(meta.size||0);
} else {
const r = await jfetch('/api/chat/files/zip?scope='+encodeURIComponent(FILES_SCOPE));
if(!r.ok){ let msg='HTTP '+r.status; try{ msg=(await r.json()).error||msg; }catch(_){} toast('archive impossible : '+msg); return; }
blob = await r.blob();
}
const u = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = u; a.download = meta.name || 'loki-fichiers.zip';
document.body.appendChild(a); a.click(); a.remove();
setTimeout(()=>URL.revokeObjectURL(u), 10000);
toast(meta.files + ' fichier'+(meta.files>1?'s':'')+' archivé'+(meta.files>1?'s':''));
}catch(e){ toast('archive impossible : '+((e&&e.message)||'erreur')); }
finally{ if(btn){ btn.disabled = false; btn.textContent = '.zip'; } }
}
// Tranches base64 du zip (tunnel chiffré) — cousin de fetchFileB64.
async function fetchZipB64(size){
const parts = [];
let off = 0;
do{
const r = await jfetch('/api/chat/files/zip?b64=1&len='+DL_CHUNK+'&offset='+off+'&scope='+encodeURIComponent(FILES_SCOPE));
const j = await r.json();
if(!r.ok || !j.ok) throw new Error(j.error||('HTTP '+r.status));
const bytes = b64ToBytes(j.data||'');
parts.push(bytes);
if(!bytes.length && !j.eof) throw new Error('transfert interrompu');
off = (j.offset||0) + bytes.length;
if(j.eof) break;
} while(off < size);
return new Blob(parts, {type:'application/zip'});
}
// Changement de discussion (bascule, « vider »), signalé par le flux SSE :
// le panneau montre les fichiers de la discussion OUVERTE, il doit donc être
// redessiné. On revient aussi à sa racine — le sous-dossier affiché était celui
@@ -14,12 +14,25 @@
// titre, dates, nombre d'échanges — jamais les messages.
let CONVS = [], CONV_ACTIVE = '';
// Vrai quand le SERVEUR dit qu'un tour tourne sur la discussion active. Sert au
// premier affichage : le flux SSE n'a pas encore rejoué son journal, mais la
// ligne doit déjà s'animer si un agent travaille (page rechargée en cours de
// route, ou ouverte depuis un autre appareil).
let CONV_BUSY = false;
async function loadConversations(){
let r;
try{ r = await jget('/api/conversations'); }catch(_){ return; }
CONVS = r.conversations || [];
// Ordre de CRÉATION, la plus récente en tête : une discussion garde sa place
// dans la liste quoi qu'il arrive — écrire dans un vieux fil ne le fait pas
// remonter (le serveur, lui, trie par activité). Départage par l'ordre du
// fichier d'index pour les créations dans la même seconde.
const ord = new Map(CONVS.map((c, i) => [c.id, i]));
CONVS.sort((a, b) => (b.created || 0) - (a.created || 0)
|| ord.get(b.id) - ord.get(a.id));
CONV_ACTIVE = r.active || '';
CONV_BUSY = !!r.busy;
renderConversations();
}
@@ -37,11 +50,16 @@ function renderConversations(){
e.textContent = q ? 'aucune discussion ne correspond' : '(aucune)';
cont.appendChild(e);
syncTopbarTitle();
convSyncBusy();
return;
}
for(const c of list){
const row = document.createElement('div');
row.className = 'preset' + (c.id === CONV_ACTIVE ? ' active' : '');
// L'identifiant est porté par la ligne : convSyncBusy retrouve ainsi CELLE
// qui travaille sans redessiner la liste (un redessin relancerait
// l'animation de l'anneau à chaque événement du flux).
row.dataset.conv = c.id;
row.onclick = () => convSwitch(c.id);
const info = document.createElement('div'); info.className = 'preset-info';
@@ -74,6 +92,57 @@ function renderConversations(){
cont.appendChild(row);
}
syncTopbarTitle();
convSyncBusy();
}
// --- Discussion au travail : anneau qui tourne ------------------------------
// Une seule génération tourne à la fois, et c'est forcément sur la discussion
// OUVERTE (le serveur n'a qu'un fil en mémoire) : l'indicateur va donc sur la
// ligne active. Deux sources le renseignent — le flux SSE (`busy`, instantané)
// et la liste (`CONV_BUSY`, utile avant que le rejeu ait rattrapé). Une fois le
// rattrapage fini, l'état local fait foi : il est plus frais que la liste, qui
// n'est rechargée qu'en fin de tour.
function convBusyNow(){
const live = (typeof busy !== 'undefined') && busy;
if(typeof REPLAYING !== 'undefined' && !REPLAYING) return !!live;
return CONV_BUSY || !!live;
}
// Pose ou retire l'anneau dans un conteneur, SANS toucher au reste de son
// contenu : réécrire l'hôte remettrait l'animation à zéro.
function convSpinToggle(host, on, before){
if(!host) return;
let sp = host.querySelector(':scope > .conv-spin');
if(on && !sp){
sp = document.createElement('span');
sp.className = 'conv-spin';
sp.title = 'l\u2019agent travaille sur cette discussion\u2026';
sp.setAttribute('role', 'img');
sp.setAttribute('aria-label', 'génération en cours');
sp.appendChild(icon('spinner', 14));
if(before) host.insertBefore(sp, before); else host.appendChild(sp);
} else if(!on && sp){
sp.remove();
}
}
// Rafraîchit l'indicateur partout où il se montre : la ligne de la discussion
// concernée, et l'en-tête (la barre latérale est escamotée sur téléphone — sans
// ça, rien ne dirait que ça travaille).
function convSyncBusy(){
const on = convBusyNow();
const cont = document.getElementById('conv-list');
if(cont){
for(const row of cont.querySelectorAll('.preset')){
const mine = on && row.dataset.conv === CONV_ACTIVE;
row.classList.toggle('working', mine);
convSpinToggle(row, mine, row.firstChild);
}
}
// Dans l'en-tête, l'anneau est un FRÈRE du titre : celui-ci tronque son texte
// à l'ellipse, un enfant y serait rogné avec lui sur un titre long.
const tb = document.getElementById('topbar-title');
convSpinToggle(tb && tb.parentNode, on, tb);
}
// Titre de l'en-tête : celui de la discussion ouverte. Il dit de quoi on parle
+15
View File
@@ -25,6 +25,21 @@ function paintLabel(label, role){
const who = document.createElement('span'); who.className = 'who';
who.textContent = role === 'user' ? (IDENT.name || 'toi') : 'Loki';
label.appendChild(av); label.appendChild(who);
// Badge du modèle : quelle IA a produit CETTE réponse. Le nom vit dans
// dataset.model (posé par tagModel, 09-stream.js) — c'est ce qui le fait
// survivre au repeint des libellés lors d'un changement d'identité.
if(role === 'assistant' && label.parentElement && label.parentElement.dataset.model){
label.appendChild(modelBadge(label.parentElement.dataset.model));
}
}
// modelBadge fabrique la pastille du nom de modèle (partagée paintLabel/tagModel).
function modelBadge(name){
const b = document.createElement('span');
b.className = 'modelbadge';
b.textContent = name;
b.title = 'modèle : ' + name;
return b;
}
async function loadIdentity(){
+66 -10
View File
@@ -38,6 +38,12 @@ function openDetails(el){
if(typeof el === 'string') el = document.getElementById(el);
while(el){
if(el.tagName === 'DETAILS') el.open = true;
// La cible vit dans un panneau de la modale de réglages : ouvrir la modale
// sur ce panneau. C'est ce qui garde tous les appelants historiques
// (pastille d'état, jobs moteur) valides depuis la refonte en modale.
if(el.classList && el.classList.contains('set-pane') && typeof openSettings === 'function'){
openSettings(el.dataset.pane);
}
el = el.parentElement;
}
}
@@ -46,7 +52,14 @@ function openDetails(el){
// à cliquer la ligne correspondante dans les réglages (switchTo demande
// confirmation puis relance le moteur).
function onModelSwitch(sel){
const n = parseInt(sel.value, 10);
const v = sel.value;
// Entrée du groupe « Modèles » : charger CE .gguf, sans preset — la config
// active garde son contexte et son échantillonnage, seul MODEL change.
if(v && v.startsWith('m:')){
switchModelFile(v.slice(2), sel.options[sel.selectedIndex].textContent);
return;
}
const n = parseInt(v, 10);
if(!n) return;
const name = sel.options[sel.selectedIndex].textContent;
// La liste est repeinte par loadPresets() une fois la bascule faite (ou
@@ -55,6 +68,22 @@ function onModelSwitch(sel){
switchTo(n, name);
}
// Bascule directe vers un fichier de modèle (POST /api/models/use) : confirme,
// écrit MODEL, le service redémarre derrière. Annulé ou en échec, la sélection
// revient sur le modèle réellement chargé.
async function switchModelFile(value, name){
if(!await askConfirm('Charger « ' + name + ' » et redémarrer le moteur ?', {title:'Changer de modèle', okText:'Charger'})){
paintModelSwitch();
return;
}
toast('changement de modèle…');
let r = null;
try{ r = await jpost('/api/models/use', {value}); }catch(_){}
if(!r || !r.ok){ toast('erreur : ' + ((r && r.error) || 'réseau')); paintModelSwitch(); return; }
toast('modèle en cours de chargement…');
loadAll();
}
// Le sélecteur affiche LE MODÈLE CHARGÉ, pas seulement le preset actif.
//
// Les deux ne coïncident pas toujours : un preset n'est « actif » que si son
@@ -67,12 +96,22 @@ function onModelSwitch(sel){
// Deux sources, deux moments : loadPresets() donne la liste, loadCfg() donne le
// modèle. Chacune dépose ce qu'elle sait et redemande le dessin — elles partent
// en parallèle (loadAll), aucun ordre n'est garanti.
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '';
let MS_PRESETS = [], MS_ACTIVE = null, MS_MODEL = '', MS_FILES = [];
function renderModelSwitch(presets, active){
MS_PRESETS = presets || []; MS_ACTIVE = active || null;
paintModelSwitch();
}
// Les fichiers .gguf découverts sur le disque : le sélecteur doit permettre de
// CHARGER un modèle, pas seulement de rappeler un preset — sans preset créé,
// il n'offrait aucun choix. Chargé par loadAll(), rafraîchi avec le reste.
async function loadModelSwitchFiles(){
try{
const r = await jget('/api/models');
MS_FILES = Array.isArray(r) ? r : [];
}catch(_){ return; }
paintModelSwitch();
}
// Appelé par loadCfg avec la valeur MODEL de la configuration active.
function setLoadedModel(name){
MS_MODEL = (name || '').trim();
@@ -99,14 +138,31 @@ function paintModelSwitch(){
o.selected = true;
sel.appendChild(o);
}
MS_PRESETS.forEach((p, i) => {
const o = document.createElement('option');
o.value = String(i + 1);
o.textContent = p.name;
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
sel.appendChild(o);
});
sel.disabled = !MS_PRESETS.length;
if(MS_PRESETS.length){
const g = document.createElement('optgroup'); g.label = 'Presets';
MS_PRESETS.forEach((p, i) => {
const o = document.createElement('option');
o.value = String(i + 1);
o.textContent = p.name;
if(MS_ACTIVE && p.id === MS_ACTIVE.id) o.selected = true;
g.appendChild(o);
});
sel.appendChild(g);
}
// Modèles du disque : en choisir un le CHARGE (voir switchModelFile). Un
// preset règle tout (contexte, sampling…) ; un modèle ne change que MODEL.
if(MS_FILES.length){
const g = document.createElement('optgroup'); g.label = 'Modèles (.gguf)';
for(const m of MS_FILES){
const o = document.createElement('option');
o.value = 'm:' + m.value;
o.textContent = modelLabel(m.name);
if(m.missing && m.missing.length) o.disabled = true; // tranches manquantes
g.appendChild(o);
}
sel.appendChild(g);
}
sel.disabled = !MS_PRESETS.length && !MS_FILES.length;
sel.title = MS_MODEL
? 'modèle chargé : ' + MS_MODEL + ' — changer de preset relance le moteur'
: 'aucun modèle configuré';
+58
View File
@@ -0,0 +1,58 @@
// ===== Loupe d'image =======================================================
// Les images du fil sont PLAFONNÉES (CSS .chatimg / .msg-img) : sans plafond,
// une capture sortait à sa taille native et mangeait tout l'écran, ce qui rendait
// la conversation illisible. Le plafond ne se paie donc pas en information : un
// clic rouvre l'image ici, aussi grande que la fenêtre le permet.
//
// On réutilise la source DÉJÀ chargée par la vignette (blob: hydraté par
// hydrateImages, ou objectURL local d'une pièce jointe en attente) : rien n'est
// re-téléchargé, et ça marche derrière la clé de pilotage — qu'une balise <img>
// ne sait pas porter, d'où le blob: en premier lieu.
function lightboxEl(){
let el=document.getElementById('lightbox');
if(el) return el;
el=document.createElement('div');
el.id='lightbox';
el.innerHTML='<button type="button" class="lb-x" title="fermer" aria-label="fermer">×</button>'
+'<img alt=""><div class="lb-name"></div>';
// Clic sur le FOND (ou sur le ×) = fermer ; clic sur l'image = ne rien faire,
// sinon on referme dès qu'on veut la regarder de près.
el.onclick=e=>{ if(e.target===el || e.target.classList.contains('lb-x')) closeLightbox(); };
document.body.appendChild(el);
return el;
}
// Capture (3e argument à true) : sans elle, Échap serait d'abord vu par les
// écouteurs des modales déjà en place et la loupe ne se fermerait pas.
function _lbKey(e){
if(e.key==='Escape'){ e.preventDefault(); e.stopPropagation(); closeLightbox(); }
}
function openLightbox(src, name){
if(!src) return;
const el=lightboxEl(), img=el.querySelector('img');
img.src=src;
img.alt=name||'';
el.querySelector('.lb-name').textContent=name||'';
showModal(el);
document.addEventListener('keydown', _lbKey, true);
}
function closeLightbox(){
const el=document.getElementById('lightbox'); if(!el) return;
document.removeEventListener('keydown', _lbKey, true);
hideModal(el);
// La source n'est lâchée qu'APRÈS la transition : la vider tout de suite
// laissait un cadre vide clignoter pendant la fermeture.
setTimeout(()=>{
if(!el.classList.contains('show')) el.querySelector('img').removeAttribute('src');
}, 200);
}
// Rend une image cliquable (idempotent : renderBody reconstruit le DOM à chaque
// delta du streaming, et hydrateImages nous rappelle sur les mêmes éléments).
//
// On lit currentSrc/src AU CLIC et non à la liaison : l'hydratation remplace la
// source par un blob: après coup, et figer l'URL /api/chat/image ici rouvrirait
// une image que le navigateur n'a pas le droit de charger seul.
function bindZoom(img, name){
if(!img || img.dataset.zoom) return;
img.dataset.zoom='1';
img.addEventListener('click', ()=>openLightbox(img.currentSrc||img.src, name||img.alt||''));
}
+156
View File
@@ -0,0 +1,156 @@
// 20-mode.js — le mode Chat/Code : sélecteur du composeur, puce de suggestion,
// panneau des critères d'acceptation, carte de question (outil ask).
// La bascule est PAR DISCUSSION et vit côté serveur (/api/chat/mode) : tous
// les appareils voient le même mode, comme le fil lui-même.
let CURRENT_MODE = 'chat';
function modeSeg(){ return document.getElementById('mode-seg'); }
function paintMode(){
const seg = modeSeg();
if(!seg) return;
seg.querySelector('[data-mode="chat"]').classList.toggle('on', CURRENT_MODE!=='code');
seg.querySelector('[data-mode="code"]').classList.toggle('on', CURRENT_MODE==='code');
// Le panneau des critères n'a de sens qu'en mode code.
const box = document.getElementById('criteria-box');
if(box && CURRENT_MODE!=='code') box.style.display='none';
}
// Bascule demandée depuis CE navigateur.
async function setMode(mode){
if(mode===CURRENT_MODE) return;
try{
const r = await jfetch('/api/chat/mode', {method:'POST', body: JSON.stringify({mode})});
const j = await r.json();
if(j.ok){ CURRENT_MODE = j.mode; paintMode(); hideCodeHint(); }
}catch(e){ toast('bascule impossible : '+e); }
}
// Bascule reçue du serveur (autre appareil, ou écho de la nôtre).
function applyModeDelta(mode){
CURRENT_MODE = mode==='code' ? 'code' : 'chat';
paintMode();
if(CURRENT_MODE==='code') hideCodeHint();
}
// Recharge le mode + critères au chargement et à chaque bascule de discussion.
async function modeOnConvChange(){
try{
const r = await jfetch('/api/chat/mode');
const j = await r.json();
if(!j.ok) return;
CURRENT_MODE = j.mode==='code' ? 'code' : 'chat';
paintMode();
renderCriteria(j.criteria||[]);
hideCodeHint();
}catch(e){}
}
// --- Puce « passer en mode Code ? » -----------------------------------------
// Émise par le serveur (delta code_hint) quand un message de mode chat
// ressemble à une tâche de code. Ignorable, jamais bloquante.
// Insère un bandeau DANS le composeur, au-dessus de la barre de saisie.
// #composer est en position:absolute : un élément posé AVANT lui dans le DOM
// reste dans le flux normal de la page et se retrouve donc CACHÉ dessous. Ses
// enfants, eux, suivent sa colonne (--col) et son empilement.
function composerBanner(id){
const composer = document.getElementById('composer');
if(!composer) return null;
let el = document.getElementById(id);
if(!el){
el = document.createElement('div');
el.id = id;
el.className = 'composer-banner';
composer.insertBefore(el, composer.firstChild);
}
return el;
}
function showCodeHint(){
if(CURRENT_MODE==='code' || document.getElementById('code-hint')) return;
const chip = composerBanner('code-hint');
if(!chip) return;
chip.innerHTML = 'ça ressemble à une tâche de code — <button type="button" id="code-hint-yes">passer en mode Code</button><button type="button" id="code-hint-no" title="ignorer" aria-label="ignorer">✕</button>';
chip.querySelector('#code-hint-yes').onclick = ()=>{ setMode('code'); };
chip.querySelector('#code-hint-no').onclick = hideCodeHint;
}
function hideCodeHint(){
const chip = document.getElementById('code-hint');
if(chip) chip.remove();
}
// --- Panneau des critères ----------------------------------------------------
// Rendu par les deltas {criteria:[...]} (outil criteria, passe de vérif, ou
// édition manuelle). Repliable ; en-tête = compteur passés / total.
function renderCriteria(list){
let box = document.getElementById('criteria-box');
if(!list || !list.length){ if(box) box.remove(); return; }
if(CURRENT_MODE!=='code'){ if(box) box.remove(); return; }
const fresh = !box;
box = composerBanner('criteria-box');
if(!box) return;
if(fresh){
box.innerHTML = '<div id="criteria-head"></div><ul id="criteria-list"></ul>';
box.querySelector('#criteria-head').onclick = ()=> box.classList.toggle('folded');
// Repliée par défaut : cinq critères dépliés mangeaient la moitié de
// l'écran au-dessus de la saisie. L'en-tête porte déjà le compteur.
box.classList.add('folded');
}
box.style.display='';
const passed = list.filter(c=>c.status==='passed').length;
box.querySelector('#criteria-head').textContent =
'critères ' + passed + '/' + list.length + (passed===list.length ? ' ✓' : '');
box.classList.toggle('allpass', passed===list.length);
const ul = box.querySelector('#criteria-list');
ul.innerHTML='';
for(const c of list){
const li = document.createElement('li');
li.className = 'crit-'+(c.status||'pending');
const mark = c.status==='passed' ? '✓' : (c.status==='failed' ? '✗' : '○');
li.textContent = mark+' '+c.text + (c.note ? ' — '+c.note : '');
ul.appendChild(li);
}
}
function onVerifyDone(status){
if(status==='passed') toast('vérification : tous les critères passent ✓');
}
// --- Carte de question (outil ask) ------------------------------------------
// Le modèle a posé une question structurée et clos son tour : la carte propose
// ses options en boutons ; cliquer envoie la réponse comme un message normal.
function renderAskCard(ask){
if(!ask || !ask.question) return;
removeTyping();
const el = addMsg('assistant', '');
el.classList.add('askcard');
const body = el.querySelector('.body');
body.textContent = ask.question;
if(ask.options && ask.options.length){
const row = document.createElement('div');
row.className = 'askopts';
for(const opt of ask.options){
const b = document.createElement('button');
b.type = 'button';
b.textContent = opt;
b.onclick = ()=>{
// La réponse part comme un message utilisateur ordinaire.
const input = document.getElementById('input');
input.value = opt;
send();
row.querySelectorAll('button').forEach(x=>x.disabled=true);
};
row.appendChild(b);
}
body.appendChild(row);
}
scrollMaybe();
}
// Init : état du mode au chargement de la page.
window.addEventListener('DOMContentLoaded', modeOnConvChange);
+345
View File
@@ -0,0 +1,345 @@
// ─── Tâches planifiées ───────────────────────────────────────────────────────
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté.
//
// L'IA exécute une consigne toute seule sur une fréquence réglable (intervalle
// simple ou cron). Chaque tâche tourne ISOLÉE des discussions : elle livre son
// résultat via les propres outils de l'IA (mail via MCP, shell…), dans son
// dossier de travail à elle. L'interrupteur maître (« suspendre toutes les
// tâches ») permet de tout figer d'un coup pour discuter tranquille. Réutilise le
// gabarit visuel des serveurs MCP (mcp-row) et celui de l'éditeur de preset (pe-*).
let tasksList = [];
let taskEditing = null; // id en cours d'édition ('' = nouvelle, null = fermé)
let TASK_RUNNING = ''; // id de la tâche en cours d'exécution ('' = aucune)
let TASK_PRESETS = []; // presets disponibles (pour le sélecteur de la modale)
let TASK_MEM_ON = true; // état mémoire global (défaut d'une nouvelle tâche)
let TASK_WEB_ON = true; // état web global (défaut d'une nouvelle tâche)
let tasksPollTimer = null;
async function loadTasks(){ renderTasks(await jget('/api/tasks')); }
// Tant qu'une tâche tourne, on rafraîchit la liste toutes les 2 s (l'exécution est
// isolée du fil : sans sondage, on ne verrait ni son démarrage ni sa fin). On
// arrête le sondage dès qu'aucune tâche ne tourne — pas de poll permanent.
function ensureTasksPoll(){
if(TASK_RUNNING && !tasksPollTimer){
tasksPollTimer = setInterval(loadTasks, 2000);
} else if(!TASK_RUNNING && tasksPollTimer){
clearInterval(tasksPollTimer); tasksPollTimer = null;
}
}
function renderTasks(r){
tasksList = (r && r.tasks) || [];
const paused = !!(r && r.paused);
const agentOn = !!(r && r.agent);
TASK_PRESETS = (r && r.presets) || [];
TASK_MEM_ON = !r || r.mem_on !== false;
TASK_WEB_ON = !r || r.web_on !== false;
TASK_RUNNING = (r && r.running_id) || '';
ensureTasksPoll();
const pc = document.getElementById('tasks-pause-toggle');
if(pc) pc.checked = paused;
// Avertissement : sans mode agent, une tâche n'a aucun outil pour agir.
const warn = document.getElementById('tasks-agent-warn');
if(warn) warn.style.display = (tasksList.length && !agentOn) ? '' : 'none';
// Pastille : « en cours » l'emporte, sinon le nombre de tâches actives.
const nActive = tasksList.filter(t=>t.enabled).length;
if(TASK_RUNNING) setBadge('tasks-badge', 'warn', 'en cours');
else setBadge('tasks-badge', nActive ? true : null, nActive ? nActive+' active'+(nActive>1?'s':'') : '');
const list = document.getElementById('tasks-list');
list.textContent = '';
if(!tasksList.length){
list.innerHTML = '<div class="muted" style="font-size:12px">(aucune tâche)</div>';
return;
}
tasksList.forEach(t=>{
const running = (t.id === TASK_RUNNING);
const row = document.createElement('div');
row.className = 'mcp-row'+(t.enabled?'':' off')+(running?' task-running':'');
const dot = document.createElement('span');
dot.className = 'mcp-dot';
if(running){
// Point animé pendant l'exécution.
dot.classList.add('mcp-dot-ok','task-dot-run');
} else if(!t.enabled){ dot.classList.add('mcp-dot-off'); }
else if(t.last_run && !t.last_ok){ dot.classList.add('mcp-dot-err'); }
else if(t.last_run && t.last_ok){ dot.classList.add('mcp-dot-ok'); }
else { dot.classList.add('mcp-dot-off'); }
dot.title = running ? 'en cours d’exécution'
: (!t.enabled ? 'désactivée'
: (t.last_run ? (t.last_ok ? 'dernier passage : ok' : 'dernier passage : échec') : 'jamais exécutée'));
const info = document.createElement('div');
info.className = 'mcp-info';
const nm = document.createElement('div'); nm.className = 'mcp-name';
nm.textContent = t.name; nm.title = t.name;
const meta = document.createElement('div'); meta.className = 'mcp-meta';
if(running){
const rn = document.createElement('span'); rn.className = 'task-run-badge';
rn.textContent = 'en cours…'; meta.appendChild(rn);
} else {
const fr = document.createElement('span'); fr.className = 'mcp-tag';
fr.textContent = scheduleLabel(t.schedule); meta.appendChild(fr);
if(t.preset){
const pr = document.createElement('span'); pr.className = 'mcp-tag mcp-tag-preset';
const pn = (TASK_PRESETS.find(p=>p.id===t.preset)||{}).name || t.preset;
pr.textContent = pn; pr.title = 'preset : '+pn; meta.appendChild(pr);
}
if(t.enabled && t.next_run){
const nx = document.createElement('span'); nx.className = 'mcp-tools';
nx.textContent = '→ '+taskWhen(t.next_run);
nx.title = 'prochaine exécution : '+taskWhen(t.next_run);
meta.appendChild(nx);
}
if(t.last_run && !t.last_ok && t.last_error){
const er = document.createElement('span'); er.className = 'mcp-err';
er.textContent = 'échec'; er.title = t.last_error;
meta.appendChild(er);
}
}
info.appendChild(nm); info.appendChild(meta);
row.appendChild(dot); row.appendChild(info);
if(running){
// Bouton arrêter : passe par /api/chat/stop (conv.Stop annule le contexte de
// la tâche, cf. RunAutonomous).
const st = document.createElement('button');
st.className = 'task-stop-btn'; st.textContent = 'arrêter';
st.onclick = (e)=>{ e.stopPropagation(); stopRunningTask(); };
row.appendChild(st);
} else {
const sw = document.createElement('label'); sw.className = 'switch mcp-switch';
sw.onclick = (e)=>e.stopPropagation();
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!t.enabled;
cb.onchange = ()=>toggleTask(t.id, cb.checked);
const sl = document.createElement('span'); sl.className = 'slider';
sw.appendChild(cb); sw.appendChild(sl);
const edit = document.createElement('button');
edit.className = 'mcp-edit'; edit.title = 'éditer'; edit.textContent = '✎';
edit.onclick = (e)=>{ e.stopPropagation(); openTask(t.id); };
row.appendChild(sw); row.appendChild(edit);
}
row.onclick = ()=> running ? null : openTask(t.id);
list.appendChild(row);
});
}
async function stopRunningTask(){
await jfetch('/api/chat/stop',{method:'POST'}).catch(()=>{});
toast('arrêt de la tâche…');
setTimeout(loadTasks, 800);
}
// taskWhen met en forme un epoch ms dans le FUSEAU du navigateur (jj/mm HH:MM) —
// le serveur envoie l'horodatage brut, sinon il serait figé en UTC (décalage).
// Nom distinct du fmtWhen du panneau Fichiers, qui prend des SECONDES et rend
// autre chose : deux vues, deux formats, aucune raison de les confondre.
function taskWhen(ms){
if(!ms) return '';
const d = new Date(ms);
const p = n => String(n).padStart(2,'0');
return p(d.getDate())+'/'+p(d.getMonth()+1)+' '+p(d.getHours())+':'+p(d.getMinutes());
}
// (fmtDur vit dans 09-stream.js : même unité, même rendu — on ne le redouble pas.)
// scheduleLabel rend une fréquence lisible pour la pastille de la liste.
function scheduleLabel(s){
s = (s||'').trim();
const m = /^@every\s+(.+)$/i.exec(s);
if(m){
const d = m[1].trim();
const mm = /^(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?$/.exec(d);
if(mm){
let n = parseInt(mm[1], 10), u = mm[2]; const tm = mm[3];
// Anciennes tâches « jours » stockées en heures (multiple de 24).
if(u === 'h' && n % 24 === 0 && n >= 24){ n = n/24; u = 'd'; }
const ul = {m:'min', h:'h', d:'jour'}[u] || u;
let lbl = 'toutes les '+n+' '+ul+((u==='d'&&n>1)?'s':'');
if(u==='d' && tm) lbl += ' à '+tm;
return lbl;
}
return 'toutes les '+d;
}
return 'cron : '+s;
}
async function toggleTask(id, on){
await jpost('/api/tasks/toggle', {id, on});
await loadTasks();
toast(on ? 'tâche activée' : 'tâche désactivée');
}
async function toggleTasksPause(){
const on = document.getElementById('tasks-pause-toggle').checked;
await jpost('/api/tasks/pause', {on});
toast(on ? 'tâches suspendues' : 'tâches réactivées');
loadTasks();
}
// Ouvre la modale d'ajout (id='') ou d'édition (id existant).
function openTask(id){
taskEditing = id || '';
const t = id ? tasksList.find(x=>x.id===id) : null;
document.getElementById('task-modal-title').textContent = t ? 'Éditer « '+t.name+' »' : 'Nouvelle tâche';
document.getElementById('task-name').value = t ? t.name : '';
document.getElementById('task-prompt').value = t ? t.prompt : '';
document.getElementById('task-enabled').checked = t ? !!t.enabled : true;
// Accès mémoire/web : sur une tâche existante on lit son réglage (no_mem/no_web,
// stockés en négation) ; sur une nouvelle on suit l'état global de la machine.
document.getElementById('task-mem').checked = t ? !t.no_mem : TASK_MEM_ON;
document.getElementById('task-web').checked = t ? !t.no_web : TASK_WEB_ON;
fillPresetSelect(t ? (t.preset||'') : '');
// Décompose le schedule en intervalle (par défaut) ou cron. Forme intervalle :
// « @every N(m|h|d)[@HH:MM] » (l'heure n'existe que pour les jours).
const sched = t ? (t.schedule||'') : '@every 2h';
const m = /^@every\s+(\d+)(m|h|d)(?:@(\d{1,2}:\d{2}))?\s*$/i.exec(sched);
document.getElementById('task-time').value = '09:00';
if(m || !t){
setTaskFreqMode('interval');
document.getElementById('task-interval-n').value = m ? m[1] : '2';
document.getElementById('task-interval-unit').value = m ? m[2] : 'h';
if(m && m[3]){ const tm = m[3]; document.getElementById('task-time').value = tm.length===4 ? '0'+tm : tm; }
document.getElementById('task-cron').value = '';
} else {
setTaskFreqMode('cron');
document.getElementById('task-cron').value = sched;
}
taskUnitUI();
document.getElementById('task-modal-status').textContent = '';
renderTaskResult(t);
document.getElementById('task-del').style.display = t ? '' : 'none';
document.getElementById('task-run').style.display = t ? '' : 'none';
showModal('task-modal');
}
// renderTaskResult remplit le panneau « Dernier résultat » avec le compte-rendu
// COMPLET (défilable), l'état (ok / échec / en cours) et l'horodatage. Masqué pour
// une tâche neuve ou jamais exécutée.
function renderTaskResult(t){
const block = document.getElementById('task-result-block');
const box = document.getElementById('task-result');
const when = document.getElementById('task-result-when');
box.className = 'task-report';
if(!t || (!t.last_run && t.id !== TASK_RUNNING)){ block.style.display = 'none'; return; }
block.style.display = '';
if(t.id === TASK_RUNNING){
when.textContent = '(en cours)';
box.classList.add('report-run');
box.textContent = 'exécution en cours…';
return;
}
const parts = [];
if(t.last_run) parts.push(new Date(t.last_run).toLocaleString());
if(t.last_dur_ms) parts.push('en '+fmtDur(t.last_dur_ms));
when.textContent = parts.length ? '· '+parts.join(' · ') : '';
if(!t.last_ok){
box.classList.add('report-err');
box.textContent = 'Échec : '+(t.last_error||'erreur inconnue');
return;
}
const rep = (t.last_report||'').trim();
if(rep){ box.classList.add('md-report'); box.innerHTML = md(rep); }
else { box.classList.add('report-empty'); box.textContent = '(terminée sans texte de compte-rendu — l\'action a été faite via les outils)'; }
}
function closeTask(){ hideModal('task-modal'); taskEditing = null; }
// fillPresetSelect peuple le sélecteur de preset. Première option = « preset
// actif » (vide) : la tâche utilise le modèle en cours au moment de l'exécution.
function fillPresetSelect(selected){
const sel = document.getElementById('task-preset');
sel.textContent = '';
const opt0 = document.createElement('option'); opt0.value = ''; opt0.textContent = 'preset actif';
sel.appendChild(opt0);
TASK_PRESETS.forEach(p=>{
const o = document.createElement('option'); o.value = p.id;
o.textContent = p.name + (p.active ? ' (actif)' : '');
sel.appendChild(o);
});
sel.value = selected || '';
}
function setTaskFreqMode(mode){
const r = document.querySelector('input[name="task-freq-mode"][value="'+mode+'"]');
if(r) r.checked = true;
taskFreqUI(mode);
}
function taskFreqUI(mode){
document.getElementById('task-interval-fields').style.display = mode==='interval' ? '' : 'none';
document.getElementById('task-cron-fields').style.display = mode==='cron' ? '' : 'none';
taskUnitUI();
}
// taskUnitUI : la ligne « À (heure) » n'apparaît que pour un intervalle en JOURS
// (« tous les N jours à HH:MM »). Sans intérêt pour des minutes/heures.
function taskUnitUI(){
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value;
const unit = document.getElementById('task-interval-unit').value;
document.getElementById('task-time-row').style.display = (mode==='interval' && unit==='d') ? '' : 'none';
}
// buildSchedule sérialise l'état du formulaire de fréquence en chaîne serveur.
function buildSchedule(){
const mode = (document.querySelector('input[name="task-freq-mode"]:checked')||{}).value || 'interval';
if(mode==='cron') return document.getElementById('task-cron').value.trim();
const n = parseInt(document.getElementById('task-interval-n').value, 10);
const u = document.getElementById('task-interval-unit').value;
if(!n || n < 1) return '';
// Jours : on ancre à une heure de la journée (« @every Nd@HH:MM »).
if(u === 'd'){
const tm = document.getElementById('task-time').value || '09:00';
return '@every '+n+'d@'+tm;
}
return '@every '+n+u;
}
async function saveTask(){
const name = document.getElementById('task-name').value.trim();
const prompt = document.getElementById('task-prompt').value.trim();
const schedule = buildSchedule();
const enabled = document.getElementById('task-enabled').checked;
const preset = document.getElementById('task-preset').value;
const tz = (Intl.DateTimeFormat().resolvedOptions().timeZone) || '';
const no_mem = !document.getElementById('task-mem').checked;
const no_web = !document.getElementById('task-web').checked;
const st = document.getElementById('task-modal-status');
if(!name || !prompt){ st.textContent = 'nom et consigne obligatoires'; st.style.color = 'var(--err)'; return; }
if(!schedule){ st.textContent = 'fréquence invalide'; st.style.color = 'var(--err)'; return; }
const r = await jpost('/api/tasks/save', {id: taskEditing||'', name, prompt, schedule, enabled, preset, tz, no_mem, no_web});
if(!r || !r.ok){ st.textContent = (r && r.error) || 'échec'; st.style.color = 'var(--err)'; return; }
closeTask();
await loadTasks();
toast('tâche enregistrée');
}
async function deleteTaskUI(){
if(!taskEditing) return;
const t = tasksList.find(x=>x.id===taskEditing);
if(!await askConfirm('Supprimer la tâche « '+(t?t.name:'')+' » ?', {title:'Supprimer', okText:'Supprimer', danger:true})) return;
await jpost('/api/tasks/delete', {id: taskEditing});
closeTask();
await loadTasks();
toast('tâche supprimée');
}
async function runTaskNow(){
if(!taskEditing) return;
const st = document.getElementById('task-modal-status');
const r = await jpost('/api/tasks/run', {id: taskEditing});
if(r && r.ok){
st.textContent = 'lancée, suis son avancement dans la liste';
st.style.color = '';
closeTask();
// Rafraîchit tout de suite : loadTasks détecte running_id et lance le sondage
// qui suivra l'exécution jusqu'à la fin.
setTimeout(loadTasks, 400);
} else {
st.textContent = (r && r.error) || 'impossible de lancer maintenant';
st.style.color = 'var(--err)';
}
}
@@ -0,0 +1,38 @@
// ─── Réglages en modale ──────────────────────────────────────────────────────
// Toutes les sections de réglages vivent dans une modale à deux volets : la nav
// des sections à gauche, LE panneau choisi à droite. La barre latérale ne garde
// que les discussions et le moniteur machine — visuellement plus simple, et
// chaque section dispose enfin de toute la largeur.
//
// Les ids historiques des sections (svc-log-box, lc-details, tasks-details…)
// sont posés sur les .set-pane : openDetails() (18-shell.js) les résout vers le
// bon panneau, si bien que tous les chemins existants — pastille d'état du
// moteur, pastille d'installation, jobs llama.cpp — rouvrent la modale au bon
// endroit sans changer une ligne de leurs appelants.
let SET_LAST = 'config';
// Panneaux à contenu paresseux : chargés à l'affichage — l'équivalent de
// l'ancien ontoggle des <details>.
const SET_HOOKS = {
'engine-log': () => { loadSvcLog(); showPaths(); },
'dictee': () => { loadDictateSettings(); },
};
function openSettings(pane){
showModal('settings-modal');
settingsShow(pane || SET_LAST);
}
function closeSettings(){ hideModal('settings-modal'); }
function settingsShow(pane){
SET_LAST = pane;
document.querySelectorAll('#set-nav [data-pane]').forEach(b => b.classList.toggle('active', b.dataset.pane === pane));
document.querySelectorAll('#set-body .set-pane').forEach(s => s.classList.toggle('active', s.dataset.pane === pane));
const body = document.getElementById('set-body');
if(body) body.scrollTop = 0;
const h = SET_HOOKS[pane]; if(h) h();
}
// Échap ferme la modale — capture:false, donc les modales à confirmation
// (ask-modal, qui écoute en capture) gardent la main quand elles sont ouvertes.
document.addEventListener('keydown', e => {
if(e.key !== 'Escape') return;
const m = document.getElementById('settings-modal');
if(m && m.classList.contains('show')) closeSettings();
});
+92
View File
@@ -0,0 +1,92 @@
// ─── Dictée vocale ───────────────────────────────────────────────────────────
// Bouton micro de la carte de saisie : un clic enregistre, un second arrête et
// transcrit. La capture est encodée en WAV 16 kHz mono ICI, côté navigateur —
// whisper.cpp ne lit que du PCM, et encoder au bon format évite d'embarquer
// ffmpeg dans l'image. La transcription est locale (POST /api/transcribe →
// whisper-cli). Le texte s'ajoute au champ sans écraser ce qui s'y trouve.
//
// ⚠️ Le micro n'est autorisé par le NAVIGATEUR qu'en contexte sécurisé (HTTPS
// ou localhost) : servi en http://IP:8090, getUserMedia n'existe pas — on
// l'explique plutôt que d'échouer en silence.
let MIC = null; // {ctx, stream, src, node, chunks, rate, timer}
async function dictateToggle(){
if(MIC){ dictateStop(); return; }
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
toast('micro bloqué par le navigateur : il faut HTTPS (ou localhost)');
return;
}
let stream;
try{
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
}catch(e){ toast('accès au micro refusé'); return; }
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
// ScriptProcessor : déprécié mais universel, et amplement suffisant pour
// copier des blocs de PCM (AudioWorklet demanderait un module séparé,
// impossible dans un fichier unique servi inline).
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [];
node.onaudioprocess = e => { chunks.push(new Float32Array(e.inputBuffer.getChannelData(0))); };
src.connect(node); node.connect(ctx.destination);
// Garde-fou : une dictée oubliée ne tourne pas des heures.
MIC = {ctx, stream, src, node, chunks, rate:ctx.sampleRate, timer:setTimeout(dictateStop, 90000)};
document.getElementById('mic').classList.add('rec');
}
async function dictateStop(){
const m = MIC; if(!m) return; MIC = null;
clearTimeout(m.timer);
try{ m.src.disconnect(); m.node.disconnect(); }catch(_){}
m.stream.getTracks().forEach(t => t.stop());
try{ m.ctx.close(); }catch(_){}
const btn = document.getElementById('mic');
btn.classList.remove('rec'); btn.classList.add('busy'); btn.disabled = true;
try{
const wav = encodeWav16k(m.chunks, m.rate);
if(wav.byteLength < 16000){ toast('rien entendu'); return; } // < 0,5 s
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body:wav});
const j = await r.json().catch(()=>({}));
if(r.status === 503 && j.downloading){
// Le serveur relance le téléchargement à chaque tentative ; s'il a déjà
// échoué (conteneur sans accès réseau, Hugging Face injoignable), il nous
// renvoie la raison. La cacher derrière « 0 % » laissait l'utilisateur
// cliquer indéfiniment sur un micro qui n'écrira jamais rien.
toast(j.error ? 'modèle de dictée : échec du téléchargement — ' + j.error
: 'modèle de dictée en téléchargement (' + (j.pct||0) + ' %) — suivi dans Paramètres → Dictée');
return;
}
if(!r.ok){ toast(j.error || 'échec de la transcription'); return; }
const text = (j.text||'').trim();
if(!text){ toast('rien compris — réessaie'); return; }
const ta = document.getElementById('input');
ta.value = ta.value.trim() ? ta.value.replace(/\s+$/,'') + ' ' + text : text;
autoGrow(ta); ta.focus();
}catch(_){ toast('erreur réseau'); }
finally{ btn.classList.remove('busy'); btn.disabled = false; }
}
// Ré-échantillonne le PCM capturé (44,1/48 kHz selon la machine) vers du WAV
// 16 kHz mono 16 bits — le format d'entrée de whisper.cpp. Décimation simple :
// pour de la voix ré-échantillonnée VERS LE BAS, largement suffisant.
function encodeWav16k(chunks, fromRate){
const toRate = 16000;
let n = 0; for(const c of chunks) n += c.length;
const all = new Float32Array(n); let o = 0;
for(const c of chunks){ all.set(c, o); o += c.length; }
const ratio = fromRate / toRate, outN = Math.floor(n / ratio);
const pcm = new Int16Array(outN);
for(let i = 0; i < outN; i++){
const s = Math.max(-1, Math.min(1, all[Math.floor(i*ratio)] || 0));
pcm[i] = s < 0 ? s*0x8000 : s*0x7fff;
}
const buf = new ArrayBuffer(44 + pcm.length*2), v = new DataView(buf);
const wr = (off, s) => { for(let i = 0; i < s.length; i++) v.setUint8(off+i, s.charCodeAt(i)); };
wr(0,'RIFF'); v.setUint32(4, 36 + pcm.length*2, true); wr(8,'WAVEfmt ');
v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, toRate, true); v.setUint32(28, toRate*2, true);
v.setUint16(32, 2, true); v.setUint16(34, 16, true);
wr(36,'data'); v.setUint32(40, pcm.length*2, true);
new Int16Array(buf, 44).set(pcm);
return buf;
}
@@ -0,0 +1,154 @@
// ─── Réglages de la dictée ───────────────────────────────────────────────────
// Panneau Paramètres → Dictée : matériel, modèle, langue, réactivité, plus un
// test du micro. Ce sont des réglages de SERVEUR (le modèle chargé et le GPU
// occupé appartiennent à la machine), donc rien n'est gardé en localStorage :
// tout passe par /api/dictate/*.
let DIC_CFG = null;
async function loadDictateSettings(){
const [cfg, modeles, gpus] = await Promise.all([
jfetch('/api/dictate/config').then(r => r.json()).catch(()=>({})),
jfetch('/api/dictate/models').then(r => r.json()).catch(()=>[]),
jfetch('/api/vram').then(r => r.json()).catch(()=>[]),
]);
DIC_CFG = cfg || {};
// Matériel : « CPU seul » d'abord — c'est le choix qui marche partout, y
// compris sur une image bâtie sans CUDA.
const dev = document.getElementById('dic-device');
dev.innerHTML = '';
dev.appendChild(new Option('processeur seul', 'cpu'));
(gpus||[]).forEach((g, i) => {
const libre = Math.max(0, (g.total|0) - (g.used|0));
dev.appendChild(new Option(`GPU ${i} — ${g.name} (${libre} Mo libres sur ${g.total|0})`, String(i)));
});
dev.value = cfg.device || 'cpu';
const mod = document.getElementById('dic-model');
mod.innerHTML = '';
(modeles||[]).forEach(m => {
const mo = Math.round(m.octets / 1e6);
// Dire ce qui est déjà là : sans ça, choisir un modèle absent ressemble à
// un réglage instantané alors que c'est un téléchargement de 600 Mo.
const etat = m.present ? 'déjà téléchargé' : mo + ' Mo à télécharger';
mod.appendChild(new Option(`${m.nom} — ${etat}, ~${m.vram_mo} Mo de mémoire`, m.id));
});
mod.value = cfg.model || '';
document.getElementById('dic-lang').value = cfg.lang || 'fr';
document.getElementById('dic-react').value = cfg.reactivity || 'moyen';
refreshDictateState();
}
async function dictateSave(){
const cfg = {
model: document.getElementById('dic-model').value,
lang: document.getElementById('dic-lang').value,
device: document.getElementById('dic-device').value,
reactivity: document.getElementById('dic-react').value,
};
const el = document.getElementById('dic-save-state');
el.textContent = 'enregistrement…';
const r = await jfetch('/api/dictate/config', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(cfg)});
const j = await r.json().catch(()=>({}));
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
DIC_CFG = cfg;
// Le serveur de dictée a été arrêté côté Loki : le prochain clic sur le
// micro le relancera avec ces réglages. On le dit, sinon la première dictée
// suivante paraît anormalement lente sans raison visible.
el.textContent = 'enregistré — la prochaine dictée rechargera le modèle';
refreshDictateState();
}
async function dictateDownload(){
const id = document.getElementById('dic-model').value;
const el = document.getElementById('dic-dl-state');
el.textContent = 'démarrage…';
const r = await jfetch('/api/dictate/models/download', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({id})});
const j = await r.json().catch(()=>({}));
if(!r.ok){ el.textContent = j.error || 'échec'; return; }
suivreTelechargement();
}
// Sondage tant que le téléchargement tourne. Un modèle de 1 Go prend plusieurs
// minutes : sans progression affichée, l'utilisateur ne sait pas s'il se passe
// quelque chose.
async function suivreTelechargement(){
const el = document.getElementById('dic-dl-state');
const r = await jfetch('/api/dictate/models/download');
const j = await r.json().catch(()=>({}));
if(j.error){ el.textContent = 'échec : ' + j.error; return; }
if(j.running){
el.textContent = `téléchargement de ${j.id} — ${j.pct||0} %`;
setTimeout(suivreTelechargement, 1000);
return;
}
el.textContent = 'téléchargé';
loadDictateSettings();
}
async function refreshDictateState(){
const el = document.getElementById('dic-state');
if(!el) return;
const j = await jfetch('/api/dictate/state').then(r => r.json()).catch(()=>null);
if(!j){ el.textContent = ''; return; }
const bouts = [];
bouts.push(j.actif ? `moteur allumé depuis ${j.depuis_s||0} s` : 'moteur éteint (il démarre au premier clic sur le micro)');
bouts.push(j.present ? `modèle ${j.modele} présent` : `modèle ${j.modele} ABSENT — à télécharger`);
// note : le serveur nous dit quand il n'a pas pu respecter le matériel
// demandé (image sans CUDA, par exemple). Le taire ferait croire que le GPU
// choisi est utilisé.
if(j.note) bouts.push('⚠ ' + j.note);
if(j.dl && j.dl.running) bouts.push(`téléchargement ${j.dl.id} ${j.dl.pct||0} %`);
el.textContent = bouts.join(' · ');
}
// Test du micro : capte 3 s, mesure le niveau réel et transcrit, SANS toucher
// au champ de saisie. C'est le diagnostic qu'il fallait bricoler à la main
// quand la dictée ne rendait rien — niveau nul, micro muet, serveur en panne
// se ressemblent tous de l'extérieur.
async function dictateTestMic(){
const out = document.getElementById('dic-test-out');
const btn = document.getElementById('dic-test');
if(!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia)){
out.textContent = 'micro bloqué par le navigateur : il faut HTTPS (ou localhost)';
return;
}
btn.disabled = true;
out.textContent = 'écoute… parle maintenant (3 s)';
let stream;
try{
stream = await navigator.mediaDevices.getUserMedia({audio:{channelCount:1, echoCancellation:true, noiseSuppression:true}});
}catch(e){ out.textContent = 'accès au micro refusé'; btn.disabled = false; return; }
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const src = ctx.createMediaStreamSource(stream);
const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [];
node.onaudioprocess = e => chunks.push(new Float32Array(e.inputBuffer.getChannelData(0)));
src.connect(node); node.connect(ctx.destination);
await new Promise(r => setTimeout(r, 3000));
try{ src.disconnect(); node.disconnect(); }catch(_){}
stream.getTracks().forEach(t => t.stop());
const rate = ctx.sampleRate;
try{ ctx.close(); }catch(_){}
let n = 0; for(const c of chunks) n += c.length;
let somme = 0, crete = 0;
for(const c of chunks) for(let i = 0; i < c.length; i++){
const v = c[i]; somme += v*v;
const a = Math.abs(v); if(a > crete) crete = a;
}
const rms = Math.sqrt(somme / (n||1));
const niveau = rms < 0.002 ? 'QUASI MUET' : rms < 0.01 ? 'faible' : 'correct';
out.textContent = `niveau ${niveau} (rms ${rms.toFixed(4)}, crête ${crete.toFixed(3)}) — transcription…`;
const wav = encodeWav16k(chunks, rate);
const r = await jfetch('/api/transcribe', {method:'POST', headers:{'Content-Type':'audio/wav'}, body: wav});
const j = await r.json().catch(()=>({}));
const tete = `niveau ${niveau} (rms ${rms.toFixed(4)}) — `;
if(!r.ok) out.textContent = tete + (j.error || 'échec de la transcription');
else if(!(j.text||'').trim()) out.textContent = tete + 'rien compris' + (rms < 0.002 ? ' (le micro n’a rien capté : vérifie l’entrée choisie par le système)' : '');
else out.textContent = tete + '« ' + j.text.trim() + ' »';
btn.disabled = false;
refreshDictateState();
}
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff. Show more