Compare commits

...
Author SHA1 Message Date
Claude 96ef1ac95b Fusion de main : la suppression des modèles y a atterri, le sélecteur reste
main a livré entre-temps « un .gguf gardé se réutilise et s'efface »
(9bdaf00), qui couvre la suppression des modèles depuis l'éditeur avec un
index des références plus complet que celui de cette branche :
buildModelRefIndex lit aussi le --mmproj resté dans EXTRA_ARGS et le
construit en une passe, et deleteModelFile porte lui-même le refus « en
service ». Cette branche cède donc tout ce qui doublonnait.

Abandonné (repris par main) :
- modelSizeOnDisk, modelIsLoaded, modelPresetRefs, presetsUsingModel →
  modelFamilySize, modelUsers, buildModelRefIndex ;
- la liste « Modèles installés » (#m-mdl-*) et son CSS → celle de main
  (#m-inst-*), qui sélectionne aussi un modèle déjà téléchargé ;
- deletePresetModel → main supprime le .gguf partagé mais NOMME les autres
  presets concernés, plutôt que de conserver le fichier ;
- backend_models_delete_test.go → backend_models_refs_test.go couvre les
  mêmes cas, y compris les octets libérés d'une famille de tranches.

Conservé, c'est ce qui reste propre à cette branche : le sélecteur de
l'en-tête ne liste QUE des presets. Charger un .gguf n'écrivait que MODEL=,
laissant en place le contexte et l'échantillonnage du modèle précédent — un
27B par-dessus les réglages d'un 4B mourait en OOM sans explication.
/api/models/use disparaît avec le groupe qu'elle servait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
2026-09-12 22:26:34 +00:00
Claude b5904907e8 Reprise réseau, presets externes, see_image
Trois apports repérés chez AJEAN (v0.13.8) et OpenFox (2.0.118), portés et
adaptés à Loki.

## Reprise réseau du tour (llm_retry_net.go)

La requête de complétion partait une fois : un Do() qui échoue ou un statut
d'erreur tuait le tour. Les messages d'erreur le disaient eux-mêmes —
« réessaie dans quelques secondes » — autrement dit on demandait à
l'utilisateur de refaire à la main ce que le code pouvait faire seul. Une
tâche planifiée tombée pendant un redémarrage du moteur échouait pour de
bon, sans personne pour recliquer.

Trois reprises consécutives, 0,8 → 1,6 → 3,2 s, plafonnées, interruptibles
par un /stop. La règle de sûreté ne souffre pas d'exception : on ne rejoue
que TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ, sinon la moitié de la réponse
déjà chez l'utilisateur serait dupliquée. Le compteur repart à zéro dès
qu'une réponse arrive.

500 n'est pas un statut de reprise : c'est ce que llama.cpp rend pour un
appel d'outil malformé ou un prompt trop long, deux échecs déterministes
que les filets sémantiques traitent déjà. Restent les codes qui disent
« pas maintenant » : 429, 502, 503, 504.

## Presets externes (backend_external.go, web_external.go)

Un preset avec EXTERNAL=1 route le chat vers une API OpenAI-compatible
distante (OpenAI, Groq, OpenRouter, un vLLM sur une autre machine) au lieu
du llama-server local. C'est un preset COMME UN AUTRE : même liste, même
bascule, même prompt système par preset. La différence ne vit qu'à deux
endroits — l'inférence (resolveChatEndpoint) et la bascule, qui arrête le
moteur local au lieu de le redémarrer.

La clé du serveur local ne part jamais chez un tiers : chaque endpoint
porte la sienne. La clé du preset n'est jamais renvoyée en clair à
l'interface, et un champ vide ne l'efface pas — il faut y avoir touché.
Une fenêtre dédiée plutôt que l'éditeur habituel : un modèle distant n'a
ni quantification, ni couches GPU, ni moteur. Un bouton teste la connexion
avant d'enregistrer, et rend le message de l'API plutôt que le JSON brut.

Le résumé de compactage part au même endroit que le chat : le laisser
taper le moteur local aurait cassé toute compaction sur un preset externe.

## see_image (chat_vision_tool.go)

Loki savait voir une pièce jointe et une capture qu'il venait de prendre,
mais pas un fichier qui dort sur le disque : « regarde ~/photos/bug.png »
n'avait aucune réponse, `read` rendant des octets binaires. L'outil charge
l'image et la réinjecte dans un message utilisateur multimodal — même
chemin que les pièces jointes.

Même règle ÉPHÉMÈRE que les captures (et non celle de l'amont, qui persiste
l'image) : l'image va dans le tour en cours, pas dans l'historique. Un
base64 persisté repartirait à chaque tour et finirait par dépasser la
fenêtre pour de bon.

Le marqueur de perte à la compaction existait déjà mais n'offrait qu'un
recours, « reprends la capture » — ce qui enverrait photographier une page
web alors que l'image perdue est un PNG du disque. Formulation généralisée.

## Au passage

toolCallLabel est extrait de runChat. Cette table nom d'outil → argument a
une double fonction — libellé affiché ET argument principal — donc un outil
absent s'exécute sur une chaîne vide : see_image répondait « chemin de
fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne bronche.
Une table pareille se teste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 21:05:19 +00:00
Claude d1626781f9 Chargement de la conversation : compter les reprises, pas les millisecondes
TestLoadConversationAbsenceLegitimeResteRapide vérifie qu'une absence
légitime — première installation, rien d'enregistré — ne déclenche aucune
reprise. Il le mesurait au chronomètre : « moins de 250 ms », la durée
d'une attente de reprise.

Le temps de mur ne dit pas ça. Il mêle le coût du PREMIER essai (créer et
ouvrir la base bbolt, fsync compris) à l'attente qu'on cherche à exclure.
Sur un runner CI chargé, cet essai unique a pris 435 ms : le test a échoué
alors qu'aucune reprise n'avait eu lieu, et le même commit passait une
minute plus tôt sur un autre runner.

LoadConversation compte désormais ses reprises (loadConvRetries), et le
test lit ce compteur. Ce qu'il affirme est enfin ce qu'il mesure, et il ne
dépend plus de la charge de la machine.

Le test de la base illisible vérifie le compteur dans l'autre sens (quatre
reprises attendues) : sans ça, un compteur bloqué à zéro rendrait la
première assertion vraie sans rien prouver.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 20:25:48 +00:00
Claude 9bdaf00ca4 Modèles : un .gguf gardé se réutilise et s'efface
Un modèle téléchargé depuis l'éditeur d'un preset survit à la suppression
de ce preset (c'est voulu : on le réutilise ailleurs). Il devenait pourtant
inatteignable — impossible de refaire un preset dessus, impossible de
l'effacer.

Trois causes, trois correctifs.

1. « Le modèle existe déjà » n'est plus une erreur. La recherche Hugging
   Face proposait le quant, le clic lançait la sonde, et le téléchargement
   répondait en rouge « le modèle existe déjà » — fin du parcours. La sonde
   constate maintenant la présence du fichier (sans même sortir sur le
   réseau) et renvoie la valeur à écrire dans MODEL= : l'interface le
   SÉLECTIONNE, et la file continue (le projecteur vision, par exemple).
   Les quants déjà présents portent une pastille « déjà installé » dans la
   liste du dépôt, avant le clic.

2. Le sélecteur de modèle reconnaissait mal ce qu'il avait sous les yeux.
   /api/models comparait le dossier de chaque .gguf à LokiHome() alors que
   les téléchargements atterrissent dans $LOKI_HOME/models : la comparaison
   ne pouvait jamais être vraie. Conséquences : l'étiquette « dossier loki »
   ne s'affichait nulle part, et un preset écrit MODEL=modele.gguf
   s'affichait « introuvable ; ajoute son dossier ci-dessous » — le fichier
   étant juste à côté. Un nom simple est désormais résolu comme le fait le
   moteur : dossier de loki d'abord, puis les autres.

3. Une liste « Modèles installés », dans le groupe Modèle de l'éditeur.
   La route /api/models/delete existait depuis longtemps ; aucun bouton ne
   l'appelait. Le seul moment où un .gguf pouvait disparaître, c'était en
   cochant « supprimer aussi le fichier » à la suppression de son preset.
   La liste montre taille, dossier, tranches manquantes, et qui s'en sert :
   le modèle en service ne s'efface pas (le moteur l'a ouvert, la place ne
   serait même pas rendue), celui que des presets nomment prévient en les
   nommant puis obéit. La suppression dit ce qu'elle a libéré.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0129sffVC43rezAXUMQuzUog
2026-09-12 20:20:33 +00:00
Claude 06f0e74c77 Modèles : les supprimer depuis l'interface, et ne charger que des presets
Un .gguf ne pouvait s'effacer qu'en cochant « supprimer aussi le fichier »
au moment de supprimer son preset. Tout ce qui avait été téléchargé sans
preset — une quantization essayée puis abandonnée, un projecteur du mauvais
dépôt — restait donc sur le disque pour des dizaines de gigaoctets, sans
aucun moyen de partir depuis Loki.

L'éditeur de preset porte désormais une ligne « Modèles installés » (repliée,
sous le sélecteur de fichier) : chaque .gguf des dossiers déclarés avec sa
taille, ses tranches, qui le réclame, et un bouton « supprimer ».

Rien n'est réversible ici, d'où deux garde-fous côté serveur, partagés par
/api/models/delete ET par la case de l'éditeur de preset :

  - le modèle CHARGÉ est intouchable — l'effacer sous le moteur ne rend pas
    l'espace tant qu'il tient le descripteur, et le démarrage suivant meurt
    sur un fichier introuvable ;
  - un modèle réclamé par des presets ne part qu'avec `force`, le refus 409
    nommant les presets pour que la seconde confirmation dise ce qu'on casse.
    Supprimer un preset ne peut donc plus emporter le modèle de son voisin.

La suppression emporte toute la famille de tranches et annonce la place
réellement libérée. /api/models marque au passage le modèle chargé et les
presets qui référencent chaque fichier.

Le sélecteur de l'en-tête ne liste plus que des presets. Il proposait aussi
les .gguf du disque, chargés en n'écrivant que MODEL= : le contexte, le NGL
et l'échantillonnage du modèle PRÉCÉDENT restaient en place, et un 27B chargé
par-dessus les réglages d'un 4B partait sur 100k de contexte pour mourir en
OOM sans que rien ne dise pourquoi. Charger un modèle, c'est appliquer TOUS
ses réglages : ça s'appelle un preset, et c'est le seul chemin. /api/models/use
disparaît avec le groupe qu'il servait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoKfYRvVHTf8GyKhgY5WxT
2026-09-09 08:53:36 +00:00
MichaelandClaude Opus 5 54468b3336 Dictée : Parakeet remplace whisper.cpp
Parakeet TDT 0.6B v3 (NVIDIA), servi par sherpa-onnx. La v3 et non la v2 :
c'est la seule des deux qui parle français — la v2 est anglais seul.

CE QUI DISPARAÎT DU DOCKERFILE
Deux étapes de compilation, dont une CUDA de ~200 fichiers nvcc qui a été tuée
par l'OOM du runner plus d'une fois, et avec elles tout l'appareillage de
garde-fous qu'elles réclamaient (GGML_NATIVE=OFF contre le SIGILL en
production, bornage des architectures CUDA, deux binaires CPU/CUDA à choisir à
l'exécution). À la place : le téléchargement d'un binaire statique de 35 Mo,
version épinglée et empreinte SHA-256 vérifiée — le binaire s'exécute sur la
machine de l'utilisateur, une release remplacée en amont ne doit pas passer en
silence.

CE QUI CHANGE DANS LE CODE
La forme est la même — un processus local supervisé, éteint après dix minutes
d'inactivité. Le dialogue, lui, change : whisper-server exposait du HTTP
multipart, sherpa-onnx n'expose qu'un WebSocket dont le protocole tient en deux
entiers et des flottants. D'où un client WebSocket et une conversion WAV →
float32 côté serveur. Le parcours des blocs du WAV n'est pas du zèle : l'offset
44 codé en dur transforme un bloc LIST intercalé en craquement au début de
chaque phrase.

DEUX RÉGLAGES DISPARAISSENT, ET C'EST LE MOTEUR QUI L'IMPOSE
La LANGUE : Parakeet la détecte lui-même, il n'a aucun drapeau pour la forcer.
Le réglage n'aurait servi qu'à mentir. À surveiller : whisper avait précisément
écarté la détection automatique parce qu'elle se trompait sur des tranches
courtes.
Le GPU : le build livré est le statique CPU. Annoncer un sélecteur de carte
sans pouvoir l'honorer serait pire que de ne rien annoncer — et un modèle de
0,6 B en int8 sur des tranches de quelques secondes n'en a pas besoin, la carte
reste au moteur de chat.

MIGRATION
Un réglage enregistré du temps de whisper retombe sur le défaut au lieu de
casser la dictée. Les modèles ggml de /data/whisper/ ne servent plus à rien
mais ne sont PAS effacés : ce sont des données que personne n'a demandé de
perdre. Ils sont à supprimer à la main.

Le paquet UI regénéré ici couvre aussi les sources du commit précédent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:57 +02:00
MichaelandClaude Opus 5 17f8fa8f34 Mémoire longue (recall), projets et trackers
Trois apports repris d'AJEAN 0.12.9 → 0.13.5, adaptés au fork.

MÉMOIRE LONGUE DE LA CONVERSATION (chat_recall.go)
Le compactage résumait, donc perdait. Chaque gros bloc du torse est désormais
ARCHIVÉ verbatim sous un identifiant court (r7…) dans bbolt AVANT d'être
résumé ; le résumé cite l'id, et le modèle le rappelle avec recall(id) ou le
retrouve par mots-clés avec recall_search. Le contexte reste plat, l'archive
grossit sur disque. En mode code, un fichier lu ou un diff produit tôt dans la
session n'est plus perdu au compactage suivant.
Au passage : garde-fou anti-résumé-dégénéré, budget de résumé indexé sur la
fenêtre, queue ramenée à 20 % (compacter plus large ne coûte plus de perte), et
fin de l'épinglage du 1er message user — le modèle répondait à l'ancienne
demande au lieu de continuer la tâche en cours.

PROJETS (projects.go)
Un projet cloisonne une mémoire, ses discussions et ses trackers. La couture
est memoryDir(), qui pointe sur le projet actif : tout le code mémoire en
hérite sans le savoir. Migration automatique au premier démarrage (memory/*.md
→ memory/generale/, discussions et tâches orphelines rattachées). Une tâche
planifiée vise un projet et l'exécution le force, pour qu'une veille n'écrive
pas dans la mémoire du chantier affiché à l'écran.

TRACKERS (tracker.go)
3e type de mémoire : les données datées qui s'accumulent. On ne les lit jamais
en entier — consultation par niveaux (vue d'ensemble → année → mois →
événements), et la dernière valeur de chaque tracker est donnée d'emblée au
modèle, qui répond sans appeler l'outil.

Aussi : index MEMORY.md tenu par le CODE et injecté en tête de conversation
avec la description du projet (le modèle ne peut plus le désynchroniser) ;
prompt système rattaché au PRESET et non plus global — stocké en base, pas
dans le .env, qui ne saurait pas porter un texte multiligne.

Deux correctifs du lot précédent voyagent ici, faute de pouvoir séparer les
fichiers : msgText signale la présence d'une image au compactage, et le
garde-fou « pensé sans agir » passe à deux relances (nudgeCount/maxNudges).

Le paquet UI est regénéré dans le commit suivant, qui touche les mêmes sources.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:34:33 +02:00
MichaelandClaude Opus 5 c45ba615fa Démarrage, captures, postes distants : correctifs portés d'AJEAN
- Au démarrage, une lecture ratée de la base (verrou bbolt transitoire pendant
  le chevauchement des process au redémarrage du conteneur) était confondue
  avec une conversation absente. Pire que l'amont chez nous : getStr rend ""
  dans les deux cas, donc convEnsureActive forgeait un NOUVEL identifiant et
  l'écrasait — le fil en cours devenait orphelin, en silence. On sonde
  désormais la base avec son erreur AVANT toute écriture, on réessaie quatre
  fois, et un échec durable est journalisé sans que rien ne soit touché.

- Une capture d'écran disparaissait sans laisser de trace : stripImageParts
  aplatissait le message en ne gardant que sa légende. Le marqueur
  imageLostMarker rend la perte VISIBLE, pour que le modèle sache reprendre
  une capture au lieu de la redécrire de mémoire.

- maxLogEvents 20000 → 200000 : un seul tour à très long raisonnement
  tronquait déjà le journal de rejeu, et l'utilisateur perdait le début de sa
  conversation à l'écran.

- Keepalive WebSocket des postes distants (ping toutes les 25 s, des DEUX
  côtés). Sans lui, un poste au repos était coupé au bout de ~60-100 s par les
  intermédiaires qui ferment les canaux inactifs, puis reconnecté après
  backoff — les déconnexions à répétition sur tous les postes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 22:33:55 +02:00
MichaelandClaude Fable 5 2d4f5378c0 Presets : modifier le preset en service l'applique aussitôt
CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du chat
qui affiche toujours 32768. Seul le fichier du preset changeait :
config.env gardait l'ancienne valeur, le moteur tournait avec, et plus
aucun preset n'était détecté actif (empreinte différente). Il fallait
penser à rebasculer dessus à la main.

SavePresetApplying décide AVANT l'écriture si le preset édité est celui
en service (empreinte de l'ancien fichier == configuration courante) ;
si oui, la nouvelle version est installée comme une bascule
(applyPresetFile : réglages machine et moteur préservés) et le service
redémarre en arrière-plan. Un preset inactif ou nouveau reste un simple
fichier. L'UI le dit et rafraîchit l'état — la jauge de contexte suit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFhcmBMUXdK6UesdGUgFPH
2026-08-30 17:13:19 +02:00
MichaelandClaude Fable 5 9d2ff33943 Fichiers : le panneau se redessine seul
Il ne se remplissait qu'à l'ouverture ; tout ce que l'agent écrivait
ensuite (rapports, captures, scripts) et chaque pièce jointe déposée
restaient invisibles tant qu'on ne cliquait pas « rafraîchir ».

filesOnActivity, regroupé à 400 ms : appelé à chaque résultat d'outil,
en fin de tour et après un dépôt. Rien pendant le rejeu du journal ni
panneau fermé — l'ouverture recharge de toute façon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFhcmBMUXdK6UesdGUgFPH
2026-08-30 17:13:19 +02:00
MichaelandClaude Fable 5 475d523e4a Journal : outils coalescés, curseur de replay borné, export allégé
Trois maux d'une conversation agentique longue :

- Chaque appel d'outil s'écrivait en de nombreux événements (annonce,
  frappe du corps, arguments) jusqu'au done=true qui porte déjà l'état
  final. Le journal enflait jusqu'à maxLogEvents et tronquait les plus
  VIEUX événements : les premiers messages disparaissaient à l'affichage.
  Seul le done=true est conservé au compactage.
- Un événement non-outil (stats, raisonnement) glissé entre l'annonce et
  le résultat faisait émettre l'outil DEUX fois au replay et à l'export
  Markdown. L'annonce reste en attente jusqu'au done.
- Ouvrir une session plus ancienne avec le curseur de la précédente
  (Seq plus élevés) sautait tout : conversation vide. Curseur au-delà du
  dernier Seq → on repart du début.
- L'export JSON embarquait les images en base64 (fichier énorme) : les
  pièces jointes sont réduites à leur descriptif.

Repris de l'amont AJEAN v0.12.7, avec ses tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 3edd356f34 Mémoire : recherche par couverture multi-mots et rareté des termes
Un mot fréquent noyait les bonnes pages : la recherche ne comptait que
les occurrences. Elle pondère maintenant chaque terme par sa rareté dans
la mémoire et favorise les pages qui couvrent plusieurs mots de la
requête.

Repris de l'amont AJEAN v0.11.7, avec ses tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 dfdee26e4c GPU : --list-devices qui plante en OOM faisait disparaître la 2e carte
Moteur qui tourne, carte déjà pleine : l'énumération des cartes plante
en « CUDA error: out of memory » et ne rend qu'une liste tronquée — le
groupe Cartes graphiques de l'éditeur se cachait (moins de deux cartes)
et le tensor-split était perdu après un simple rechargement de l'UI,
qui vide le cache mémoire.

La dernière énumération réussie est persistée dans $LOKI_HOME/devices.json
et servie en repli (stale) quand le moteur sort en erreur. Identité, ordre
et mémoire totale sont des faits matériels stables ; seule la mémoire
libre y est périmée, sans importance pour répartir.

Repris de l'amont AJEAN v0.10.8 et v0.11.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 cfe70f2b70 Presets : un switch Raisonnement laissé sur off ne l'écrivait jamais
onchange ne part que sur une interaction : créer un preset et laisser
l'interrupteur décoché n'écrivait pas REASONING=, et la clé absente
laisse le moteur suivre le gabarit du modèle — il raisonnait malgré le
switch affiché sur off. À l'enregistrement, l'état de l'interrupteur
est désormais matérialisé : off explicite, ou on si aucune valeur active
plus précise (auto/deepseek) n'est déjà là.

Repris de l'amont AJEAN v0.12.1 (issue #46).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:47:10 +02:00
MichaelandClaude Fable 5 b4f4135101 Presets : la section « Moteur » ne servait plus à rien
Depuis 6f6a321 le moteur est un réglage de machine : le preset ne
l'impose plus, la bascule garde le courant. En conteneur l'éditeur ne
proposait de toute façon qu'une seule case, « Image », que personne ne
pouvait changer — et le BIN figé dans le preset servait encore à lister
les cartes graphiques : un vieux preset interrogeait le moteur de
l'image au lieu du moteur mis à jour.

Le groupe disparaît de l'éditeur ; la liste des GPU interroge le moteur
courant (config_bin), mémorisé au préchauffage. CSS mort retiré.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7fwdmmVbvLHznF9v1npzN
2026-08-30 15:44:55 +02:00
MichaelandClaude Fable 5 6f6a3218ad Presets : changer de modèle ramenait le moteur de l'image
Le moteur mis à jour depuis l'interface (Réglages → Moteur, BIN →
/data/engine/server-cuda-b10680/llama-server) ne tenait pas : à la
bascule de preset suivante — changer de modèle, tâche planifiée —
/app/llama-server revenait, et le modèle qui chargeait cinq minutes
plus tôt mourait sur « unknown model architecture: 'qwen4exp' ». Le
journal alternait les deux moteurs sans qu'aucun réglage visible ait
bougé, et le panneau Moteur affichait « fourni par l'image » juste
après un « ✓ moteur mis à jour ».

Cause : chaque preset créé depuis l'UI embarque BIN (newPresetSeedKeys),
donc le chemin de l'image de l'époque, et applyPresetFile remplace TOUTE
la configuration par le preset — BIN n'était pas dans preservedKeys.

Le moteur est un réglage de machine : le courant est conservé à la
bascule, sauf si le preset désigne un backend personnalisé (compilé pour
un modèle précis — là, c'est un vrai choix par modèle, il gagne). Et un
nouveau preset ne fige plus le moteur de l'image ni un moteur téléchargé.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-30 08:27:42 +02:00
MichaelandClaude Fable 5 78c258ab45 Entrypoint : le moteur mis à jour ne survivait pas à un redémarrage
docker-entrypoint.sh reposait BIN=/app/llama-server à chaque démarrage
du conteneur, sans regarder ce qu'il y avait avant. L'intention était
saine — une mise à jour d'image ne doit pas laisser un BIN obsolète —
mais elle écrasait aussi le moteur téléchargé depuis l'interface
(« mettre à jour le moteur », /data/engine/<version>/), pourtant choisi
par l'utilisateur et toujours présent sur le volume.

Symptôme vécu : Qwen3.8-Flash-Next chargeait avec server-cuda-b10680 ;
au redémarrage suivant, retour silencieux au moteur de l'image et
« unknown model architecture: 'qwen4exp' », sans qu'aucun réglage ait
bougé. Le journal alternait /app et /data/engine sans raison visible.

On ne garde BIN que s'il désigne un moteur installé sous
$LOKI_HOME/engine/ et encore exécutable ; sinon, comportement d'avant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-30 08:02:13 +02:00
MichaelandClaude Fable 5 33de4a1144 VRAM : le bilan mentait, un GET coupait le moteur, la dictée bloquait
Relecture du bouton « Libérer la VRAM » (009b585) — six défauts, tous
sur la même feature :

- gpuUsedSettled comptait une lecture ratée de nvidia-smi comme « 0 Mo »
  (pilote en réinitialisation juste après l'arrêt) : freed = before, et
  l'interface annonçait 14 Gio rendus alors que rien n'avait bougé. Elle
  rendait aussi la main au premier palier, AVANT que le pilote ait réagi
  (taskkill et Process.Kill reviennent avant le ménage CUDA) : « 0 Mo »
  après un déchargement qui marchait. gpuSettle saute les lectures
  ratées, n'accepte un palier qu'une fois la baisse observée, et est
  testable (sampler injecté).
- /api/vram/unload et /reload acceptaient GET : sans clé de pilotage,
  une balise <img> sur une page tierce suffisait à couper le moteur.
  405 + Allow: POST.
- whisperShutdown prend wsrvMu, que whisperEnsure garde jusqu'à deux
  minutes pendant un chargement de modèle : le geste dépassait le délai
  du navigateur, moteur pourtant déjà arrêté. whisperShutdownVite tue le
  processus en train de démarrer (poignée atomique hors verrou) au lieu
  d'attendre derrière lui.
- Sous systemd, une unité en crash-loop répond « activating », pas
  « active » : le stop était sauté et systemd relançait llama-server
  toutes les trois secondes pendant que l'UI disait « déjà arrêté ».
  engineNeedsStop élargit aux états transitoires.
- Un moteur planté au chargement était présenté comme « modèle
  déchargé — recharge-le » : LOAD_ERROR distingue les deux, le conseil
  renvoie vers l'erreur affichée dans le moniteur.
- Deux clics concurrents (moniteur + réglages) lançaient un stop au
  milieu d'un start ; VRAM_BUSY fait verrou, et le bouton se repeint
  depuis l'état renvoyé par le serveur, pas depuis l'ancien poll.
  Quelques Mo de bruit entre deux lectures ne font plus « VRAM libérée :
  0.0 Gio ».

Au passage, le 404 d'un téléchargement nomme le fichier manquant : sur
un dépôt qui publie six fragments sur sept (table PLE livrée à part),
« la révision a pu être réécrite » envoyait chercher au mauvais endroit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01961iDyM6pwn2dE2SW23gYX
2026-08-29 17:53:14 +02:00
MichaelandClaude Opus 5 009b585e75 VRAM : un bouton pour décharger le modèle et rendre la carte
Le modèle occupe la mémoire vidéo tant que le moteur tourne : une autre
application qui réclame la carte (jeu, encodage, autre serveur d'inférence)
ne trouvait plus rien à prendre. Le geste existait — « arrêter » le service,
au fond des réglages — mais son nom ne disait pas qu'il libérait la VRAM, et
il laissait tourner le serveur de dictée, qui garde la carte lui aussi.

- POST /api/vram/unload : arrête le moteur ET la dictée, attend que le pilote
  rende la mémoire (une lecture immédiate rapporte « 0 Mo libérés » après un
  déchargement pourtant réussi) et renvoie le bilan chiffré. Refuse pendant une
  génération, sauf {force:true} — la couper perdrait la réponse en cours.
- POST /api/vram/reload : relance le moteur, préflight compris (BIN/MODEL
  absents = la vraie raison tout de suite, pas un « chargement… » sans fin).
- Bouton sur les jauges du moniteur, là où l'on regarde la VRAM ; il devient
  « Recharger le modèle » dès que le moteur est arrêté, d'après /api/status et
  non d'un drapeau local (un second onglet afficherait sinon un bouton qui ment).
- Même commande dans Réglages → Moteur → Service du moteur.
- Carte de saisie : « Modèle déchargé — Recharger le modèle » au lieu de
  « Le modèle charge », qui promettait un chargement qui ne viendrait jamais.

La lecture nvidia-smi de /api/vram passe dans web_vram.go (gpuStats), partagée
avec le bilan du déchargement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8n9tDn5sZudAcUT8U6uGg
2026-08-26 20:18:09 +00:00
Claude 895aa67366 Presets : le bouton « + » ne s'affichait jamais
.preset-add est en display:none par défaut, et n'était révélé que par
« details[open]>summary>.preset-add ». Les panneaux de réglages ont
migré vers <section class="set-pane"><div class="set-pane-h">, donc ce
sélecteur ne matchait plus : le « + » des Presets restait invisible et
il n'y avait plus aucun moyen d'en créer un depuis l'interface.

Le même bouton dans « Discussions » est resté visible, lui, parce qu'il
vit encore dans un <details open><summary> — d'où un défaut qui ne
touchait que les presets.

Règle ajoutée pour le nouveau conteneur, puis UI réassemblée
(tools/assemble-ui).

Vérifié dans l'application lancée : display passe de none à block, et le
« + » apparaît dans l'en-tête du volet Presets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
2026-08-23 12:56:17 +00:00
LogiFlow 08eaf10a34 Merge pull request #23 from R0m1k3/fix/arg-scope-dockerfile
Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
2026-08-22 04:16:09 +02:00
MichaelandClaude Fable 5 e177d54e39 Image : l'ARG des drapeaux whisper était hors de portée — cmake tournait à nu
Le build de 51 minutes a échoué au lien final : « libcuda.so.1 not found »,
références cuMem* non résolues, et une libggml-cuda.so PARTAGÉE alors que
BUILD_SHARED_LIBS=OFF est censé être passé. Ce dernier détail était le vrai
indice : les drapeaux n'atteignaient pas cmake du tout.

ARG WHISPER_CMAKE_FLAGS="…" était déclaré entre deux étapes. Règle Docker :
un ARG posé après un FROM appartient à l'étape où il apparaît ; les « ARG »
nus des étapes whisperbuild-* héritent, eux, du scope GLOBAL — où rien
n'était défini. Valeur vide, cmake sans aucun drapeau : ggml en bibliothèques
partagées (échec de lien contre les stubs du pilote), et surtout
-march=native — le SIGILL de la PR #18 revenu en silence sur les deux
binaires. Les tests du Dockerfile n'y voyaient rien : ils vérifiaient le
texte, pas les règles de portée de Docker.

- L'ARG remonte avant le premier FROM, à côté de LLAMACPP_IMAGE.
- Chaque étape vérifie désormais SON binaire : un ldd qui montre libggml ou
  libwhisper en dynamique fait échouer le build sur-le-champ, au lieu de
  laisser partir un binaire qui ne trouvera pas ses .so dans l'image finale.
- TestDockerfileArgFlagsGlobal verrouille la position de l'ARG, ancré en
  début de ligne — une première version se laissait berner par une
  occurrence en commentaire, sa contre-épreuve l'a montré.

libcuda.so.1 reste une dépendance dynamique normale du binaire CUDA : c'est
le pilote, injecté à l'exécution par le NVIDIA Container Toolkit, et
l'édition de liens la résout via les stubs du toolkit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-22 04:12:52 +02:00
LogiFlow 66b53c8255 Merge pull request #22 from R0m1k3/fix/workflow-yaml-double-cle
Workflow : clé YAML dupliquée, plus aucun build ne partait
2026-08-21 23:07:17 +02:00
MichaelandClaude Fable 5 3991e68fc9 Workflow : clé YAML dupliquée, plus aucun build ne partait
Depuis la fusion de la PR #21, chaque push sur main échouait en moins d'une
minute avec zéro job lancé : le YAML du workflow était devenu invalide.

La PR #21 ajoutait cache-from/cache-to après build-args, alors que le bloc
with: les portait DÉJÀ en fin de liste. Une clé dupliquée dans un mapping
YAML fait rejeter le workflow avant même son démarrage — d'où des échecs
immédiats, sans le moindre journal.

Rectification au passage : le cache n'était pas absent, il était déjà actif.
Les trente-sept minutes venaient de la nouveauté de l'étape CUDA (cache
froid) et de l'absence de borne d'architectures, corrigée elle pour de bon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 22:36:37 +02:00
LogiFlow cc218ccfd7 Merge pull request #21 from R0m1k3/fix/cuda-blackwell-et-cache
Image : CUDA 12.8 pour Blackwell, architectures bornées, cache des couches
2026-08-21 22:33:06 +02:00
MichaelandClaude Opus 5 d8ed86bbba Image : CUDA 12.8 pour Blackwell, architectures bornées, cache des couches
Le build ne mourait plus, mais tournait encore après trente-sept minutes. Et
il aurait produit un binaire inutilisable sur la moitié du matériel visé.

Les GPU de la machine cible sont une RTX 5060 Ti (Blackwell, sm_120) et une
RTX 3060 (Ampere, sm_86). L'étape de compilation était épinglée sur
nvidia/cuda:12.4.1 : ce nvcc-là ne CONNAÎT pas Blackwell. Il refuse
l'architecture, et le binaire ne tournerait au mieux que par recompilation PTX
au chargement. 12.8.1 sur Ubuntu 24.04 est désormais utilisé — exactement ce
avec quoi llama.cpp bâtit l'image amont qui fournit libcudart.

Pour la durée, deux causes distinctes :

- Sans borne, ggml compile pour TOUTES les architectures qu'il connaît, de
  Maxwell à Blackwell. CMAKE_CUDA_ARCHITECTURES ramène le travail à quatre
  (Turing → Blackwell), et l'ARG CUDA_ARCHS permet d'élargir sans toucher au
  fichier pour un GPU plus ancien.
- Surtout : ce binaire ne dépend d'AUCUN fichier du dépôt, et il était pourtant
  recompilé à chaque push. Le cache de couches GitHub Actions le réutilise tant
  que ses lignes du Dockerfile ne bougent pas ; seuls le code Go et
  l'assemblage de l'image se refont.

Un test vérifie le plancher CUDA et la présence de la borne d'architectures :
ces deux fautes ne se voient pas à la lecture et coûtent quarante minutes à
découvrir.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 22:25:53 +02:00
LogiFlow 748328db25 Merge pull request #20 from R0m1k3/fix/build-cuda-oom
Image : le build CUDA était tué par l'OOM, pas en échec de compilation
2026-08-21 21:44:47 +02:00
MichaelandClaude Opus 5 8e88a73d91 Image : le build CUDA était tué par l'OOM, pas en échec de compilation
Le premier build de l'image avec whisper CUDA a échoué après huit minutes
sans écrire une seule ligne d'erreur : le journal s'arrête à 92 % de la
compilation, puis « Complete job ». Un compilateur qui échoue dit pourquoi ;
un compilateur tué ne dit rien.

Ces 92 % étaient atteints en TREIZE secondes. Ce ne sont pas des compilations
terminées, ce sont des nvcc lancés simultanément.

« cmake --build -j » sans nombre autorise chez Make un parallélisme illimité.
ggml-cuda compte environ 200 fichiers d'instanciation de gabarits et chaque
nvcc réclame 1 à 2 Go : le runner (16 Go) mourait d'un OOM. L'étape CPU y
survivait — peu de fichiers, compilation légère — ce qui a rendu le piège
invisible jusqu'à l'arrivée de CUDA.

- -j"$(nproc)" sur les deux étapes, et un test le vérifie désormais : cette
  faute est indétectable à la lecture et ne se manifeste qu'en CI.
- Le runner libère dotnet, android et ghc avant de bâtir. L'empilement
  nvidia/cuda-devel + runtime CUDA + Chromium approche les 14 Go disponibles ;
  cette part-là reste une hypothèse, mais elle coûte une minute à écarter.
- whisper-server CUDA est strippé : les symboles de débogage d'un binaire
  ggml-cuda pèsent plusieurs centaines de mégaoctets dans l'image finale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
2026-08-21 21:35:42 +02:00
LogiFlow f4969de9f7 Merge pull request #19 from R0m1k3/feat/dictee-temps-reel
Dictée : whisper-server supervisé, modèle et GPU au choix
2026-08-21 21:22:18 +02:00
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
MichaelandClaude Opus 5 847aad217b MCP : catalogue embarqué de serveurs connus
Ajouter un serveur MCP demandait d'écrire soi-même `npx -y @scope/paquet …`
dans la modale, en devinant le nom du paquet. Le catalogue liste une vingtaine
de serveurs connus, commande déjà renseignée, classés par catégorie.

- internal/loki/mcp_catalog.json est EMBARQUÉ dans le binaire (go:embed), pas
  interrogé sur le réseau : Loki tourne hors ligne, et rien de ce qui s'exécute
  sur la machine ne vient d'un annuaire distant. Pour proposer un serveur de
  plus : éditer le fichier et recompiler.
- Choisir une entrée n'installe rien. Ça préremplit la modale d'ajout existante
  et l'utilisateur relit la commande avant d'enregistrer — un serveur stdio
  exécute un process arbitraire, au même titre que l'outil bash du mode agent.
  Aucune ligne de mcp_client.go n'est touchée : le catalogue s'arrête à
  l'affichage, tout le reste passe par l'API MCP déjà en place.
- Une entrée dont le runtime manque (npx ou uvx absent) le signale dans la
  liste, plutôt que de laisser l'utilisateur découvrir l'échec au démarrage du
  serveur.
- Les entrées qui réclament une clé d'API la rappellent avant l'enregistrement,
  au lieu d'enregistrer un serveur qui ne peut pas se connecter.
- Les entrées Python publiées avant le SDK MCP 2.0 sont épinglées avec
  `uvx --with "mcp<2"` : sans cela elles plantent sur ImportError: McpError.
- Le Dockerfile gagne uv/uvx (~35 Mo). Sans lui, 9 des 21 entrées s'affichent
  sans pouvoir démarrer dans le conteneur.

Intensité du raisonnement (REASONING_EFFORT)

L'interrupteur Raisonnement était binaire. llama-server accepte
`reasoning_effort` dans /v1/chat/completions : `none` coupe le raisonnement,
toute autre valeur est passée au gabarit jinja du modèle.

- Nouvelle clé de preset REASONING_EFFORT — donc réglée par modèle, comme le
  reste du preset. Vide = on n'envoie rien et le gabarit garde son comportement.
- Pas de repli à prévoir : un gabarit qui ne lit pas la valeur l'ignore sans
  erreur. En pratique seuls gpt-oss et apparentés changent de comportement, et
  le sous-titre du réglage le dit — promettre un effet universel serait faux.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 10:06:14 +02:00
Claude e602adbbee Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-17 06:24:37 +00:00
Claude a1047b4e08 Boutons : leur rendre leur cadre
La refonte les voulait « sans contour » : trait transparent et aplat pris
sur --row-bg. Or --row-bg est exactement le fond des blocs qui les
contiennent (.slist, .rowbody). Un bouton posé dessus n'avait donc ni
trait ni écart de valeur : « démarrer / redémarrer / arrêter » et
« mettre à jour le moteur » se lisaient comme du texte, et rien ne disait
qu'on pouvait cliquer.

On revient au cadre d'avant la refonte — trait de 1 px et aplat un ton
au-dessus du bloc — pour que le bouton se détache quel que soit ce qu'il
y a derrière. La micro-interaction au survol reste : elle était bonne,
il lui manquait seulement quelque chose à animer, et le survol change
maintenant aussi la couleur du trait.

Les boutons-icônes de la saisie et de l'en-tête (envoyer, arrêter,
joindre, ☰) ne bougent pas : ils posent border:0 plus bas dans la
feuille et gardent leur allure nue, qui est la bonne pour un
pictogramme seul.

Vérifié en clair et en sombre, dans la barre latérale, les boîtes de
dialogue et les modales.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-17 06:24:35 +00:00
Claude 1dd1d32020 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 22:56:36 +00:00
Claude ee60ec8480 Réglages : rendre au congé la place de tourner
Les chevrons des sections débordaient dans l'arrondi de la carte, et le
rayon paraissait beaucoup trop fort. La cause n'était pas le rayon : la
carte des réglages avait perdu sa gouttière intérieure.

Le gabarit de carte (commit précédent) a déplacé le retrait vers
#side-scroll pour aligner Discussions, Réglages et Performance sur la
même largeur — mais il a mis le rembourrage de .sidegroup à zéro au lieu
de ne déplacer que le retrait EXTÉRIEUR. Les lignes allaient donc d'un
bord à l'autre : sur la première et la dernière, le chevron tombait dans
le congé de 20 px et semblait en sortir, et les filets de séparation
venaient buter dans la courbe.

On reprend le retrait d'avant la refonte — 16 px, exactement la valeur
qui fonctionnait — et tout rentre dans l'ordre sans toucher au rayon.

Vérifié en clair, en sombre et en largeur téléphone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 22:55:25 +00:00
Claude b378311873 Moteur : mettre à jour llama.cpp sans reconstruire l'image
llama.cpp publie plusieurs versions par jour ; l'image de Loki ne se
reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à
la date du dernier build, et le rattraper imposait un rebuild complet de
2,6 Go pour un composant qui en pèse 170 Mo. Le panneau « Moteur » ne le
disait même pas : il annonçait « rien à mettre à jour ici ».

Réglages → Moteur affiche maintenant la version qui tourne (bXXXXX et son
commit, lus dans la bannière du binaire — jusqu'ici invisibles ailleurs
que dans le journal du moteur) et la met à jour en un clic.

Où le moteur est pris. Pas dans les releases GitHub de llama.cpp : elles
ne contiennent AUCUN binaire CUDA pour Linux, et le mode « précompilé »
retomberait sur Vulkan, donc sur une régression pour une carte NVIDIA. La
seule distribution CUDA/Linux officielle et précompilée est l'image de
conteneur — celle-là même dont l'image de Loki hérite. On lit son
manifeste OCI et on ne télécharge que les couches qui portent /app, en
descendant du sommet : le runtime CUDA (2 Go) et la base système sont
déjà là. S'arrêter au binaire ne suffit pas — llama.cpp le range dans une
couche et ses .so dans la précédente — d'où une règle d'arrêt sur
« binaire + libggml-base + libllama », et un garde-fou de taille qui
interdit de descendre jusqu'au runtime.

Le moteur atterrit dans /data/engine/<version>/, donc sur le volume de
données : il survit à un docker compose pull. La variante (CUDA, Vulkan,
SYCL, MUSA, CPU) est déduite des backends ggml posés à côté du moteur
courant — le conteneur ne sait pas de quelle image il vient, et faire
retenir « server-cuda » à l'utilisateur serait un piège.

Le risque, et ce qui le couvre. La mise à jour apporte llama.cpp, pas le
runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
CUDA plus récent ne chargerait pas son backend GPU. Le symptôme serait
silencieux — tout marche, mais sur le processeur. Le nouveau moteur est
donc lancé à blanc avant toute bascule ; il est refusé s'il ne démarre
pas, ET s'il ne voit plus aucune carte alors que le moteur courant en
voyait. Dans les deux cas le moteur courant n'est pas touché, et celui de
l'image reste intact : « revenir au moteur de l'image » y ramène en un
clic, sans réseau.

Vérifié de bout en bout contre le vrai ghcr.io (166 Mo, 7 s, toutes les
bibliothèques et leurs liens de version présents) et, pour les chemins
d'échec, contre un faux registre.

LOKI_OCI_REGISTRY permet de viser un miroir quand ghcr.io n'est pas
joignable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 20:43:47 +00:00
Claude 54504cd1d5 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 20:00:38 +00:00
Claude e1d0e8f04a Discussions et moniteur : le gabarit de carte des réglages
La maquette posait ces deux blocs à plat, pleine largeur — et c'est ce que
j'avais suivi. Dans l'application réelle, ils voisinent avec les sections de
réglages, qui sont des CARTES : deux traitements pour deux listes qui se
touchent, et la barre latérale se lisait comme deux morceaux collés. Ils
reprennent donc le gabarit dominant de la colonne (.sidegroup) : fond de
panneau, rayon de 20, ombre discrète, lignes séparées d'un filet et rognées par
overflow plutôt qu'arrondies une à une.

La gouttière latérale revient (le plein format n'a plus lieu d'être), le titre
« Performance » devient une étiquette de groupe posée AU-DESSUS de sa carte —
comme « Paramètres » au-dessus de la sienne — et la sélection reste un aplat
plein, un cadre ferait un second contour à l'intérieur du premier.

Corrigé au passage : la remise à zéro de marge sur la dernière jauge. Elle
datait d'un temps où il n'y avait qu'un conteneur ; depuis que les cartes
graphiques et la mémoire vive sont deux blocs distincts, la dernière jauge de
CHAQUE bloc perdait sa marge et « Mémoire vive » venait se coller à la VRAM.
C'est le rembourrage bas de la carte qui absorbe désormais la dernière marge.

Vérifié dans les deux thèmes, avec deux cartes graphiques et trois discussions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 20:00:26 +00:00
Claude af83257c76 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 19:45:54 +00:00
Claude b518e98b43 Accès OpenAI : servi par Loki, par domaine ou par IP
L'endpoint compatible OpenAI n'était pas servi par Loki : le panneau annonçait
l'adresse de llama-server lui-même, http://<ip>:8080/v1. Dans le déploiement de
référence de ce fork, cette adresse ne peut joindre personne — le port 8080
n'est pas publié par le conteneur, l'entrypoint sème HOST=127.0.0.1, et l'IP
annoncée est celle du bridge Docker. L'autre voie proposée, « exposer en public
(ajean.link) », exigeait un jeton de relais que ce fork ne permet plus
d'obtenir : l'interrupteur ne pouvait que renvoyer vers un panneau supprimé.

Désormais, Loki sert /v1/* SUR SON PROPRE PORT et relaie vers le moteur. L'API
est donc joignable partout où l'interface l'est — IP du réseau local, nom de
domaine, reverse proxy — sans publier de second port ni ouvrir le moteur.

Serveur
- mountOAI (llm_oai.go) monte /v1/ sur le mux, et RIEN d'autre : ni /metrics,
  ni /props, ni /slots, qui divulgueraient le modèle chargé et l'état des slots.
  Le filtre interne d'oaiHandler reste en seconde barrière.
- requireCompletionKey (web_auth.go) garde cette surface avec la clé des
  COMPLÉTIONS, pas celle de pilotage : un client OpenAI n'a qu'un en-tête
  Authorization, et on veut pouvoir lui donner l'accès au modèle sans le droit
  de redémarrer la machine. Erreurs au format d'OpenAI (body.error.message), que
  les SDK savent présenter. Le préflight CORS passe sans clé — il n'en porte
  jamais, et le refuser casserait tout client tiers de navigateur.
- effectiveAPIKeyErr (backend_config.go) devient la source unique de la clé
  exigée : base d'abord, config.env en repli, exactement comme le moteur. Sans
  ce miroir, un API_KEY résiduel donnait un endpoint « ouvert » côté Loki et un
  401 côté moteur, sans rien pour l'expliquer. Lecture ratée = refus, jamais
  ouverture (même raisonnement que readWebKeyErr).
- oaiHandler passe à ReverseProxy.Rewrite : le port du moteur est relu à chaque
  requête au lieu d'être figé à la construction — il visait l'ancien port dès
  qu'on changeait PORT, jusqu'au redémarrage de Loki.
- withLocalAuth (relay_link.go) n'injecte plus la clé de pilotage sur /v1 : elle
  aurait été refusée par la garde, et surtout relayée au moteur. Le trafic du
  tunnel est marqué (en-tête effacé avant d'être posé, sinon un client le forge)
  et la surface y reste fermée tant que oai_public est faux — la promesse du
  tunnel est tenue.

Adresse affichée
- web_public_url.go : normalisation d'une adresse publique saisie à la main
  (schéma ajouté, /v1 recopié toléré, chemin refusé), origine de la requête via
  Host + X-Forwarded-Proto, et la règle de priorité entre les deux.
- Le calcul quitte le navigateur pour le serveur : c'est la concaténation côté
  client qui produisait l'adresse fantôme.

Interface
- Le panneau perd l'interrupteur ajean.link et l'interrupteur d'écoute LAN — ce
  dernier n'a plus d'objet, et deux interrupteurs pour « rendre l'IA joignable »
  était la confusion à lever. La route /api/network et `loki network` restent
  pour qui veut exposer le moteur en direct.
- Il gagne un champ « adresse publique » (facultatif, pour le reverse proxy) et
  un avertissement rouge tant qu'aucune clé n'est définie — l'endpoint est
  maintenant ouvert PARTOUT où l'interface l'est, ça ne se dit pas à voix basse.
  Le démarrage de `loki web` le crie aussi.

Vérifié bout en bout sur le serveur réel : liste des modèles à travers Loki avec
la clé (200), sans la clé (401), et complétion en streaming dont les tokens
arrivent espacés de 120 ms — le flux traverse bien le double proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 19:45:43 +00:00
Claude 1c031015de Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 19:33:32 +00:00
Claude 9c7f5283cf Flèche d'envoi : la centrer pour de bon
La flèche était décalée de 8 px vers la gauche dans son pavé — mesuré, pas
supposé : centre du bouton 1339, centre du dessin 1331.

La cause n'était pas dans la feuille de style mais dans le JS. syncSendBtn
montrait et cachait les deux boutons avec `style.display = 'inline-block'` ; ce
style INLINE l'emportait sur le `display:flex` de la règle commune. Le bouton
gardait donc sa boîte de 34 px, mais son icône de 18 px se collait au bord
gauche, `justify-content` n'ayant plus rien à centrer. Invisible tant que
l'icône était un glyphe de texte (qui remplit sa ligne), flagrant depuis qu'elle
est un SVG de taille fixe.

L'état « génération en cours » passe désormais par un attribut sur <html>
(data-busy) et c'est la feuille qui décide lequel des deux boutons s'affiche —
comme pour le thème, la barre latérale et le panneau Fichiers. Le style inline
`display:none` du bouton d'arrêt disparaît aussi du gabarit.

Vérifié au pixel dans les deux états : écart nul entre le centre du bouton et
celui du dessin, pour les trois commandes de la barre (dépôt, fichiers, envoi)
comme pour le bouton d'arrêt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 19:33:22 +00:00
Claude c03224fcd6 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 19:16:52 +00:00
Claude fef8cca2c4 Un seul jeu d'icônes pour toute l'interface
Les deux icônes de la carte de saisie venaient de la maquette ; tout le reste
était encore des GLYPHES DE TEXTE — ↻ × ☰ ⌫ 🗑 ✎ ↓ ▸ ▣ ·. C'est le système
d'exploitation qui les dessinait : le même bouton sortait fin sur macOS, gras et
bleuté sur Windows (émojis en couleur), et absent d'une machine sans police
d'émojis. Aucune cohérence possible, et rien à régler depuis la feuille de style.

Elles passent en SVG, tracées comme celles de la maquette : boîte de 24, trait de
1,8, extrémités arrondies, couleur héritée. Un seul fichier les décrit
(03-icons.js) — menu, fermer, rafraîchir, crayon, corbeille, téléchargement,
flèche, dossier, fichier, image, plus, chevron.

Touchées : le ☰ du téléphone, les croix de modale, les deux rafraîchissements,
le « + » des sections, la flèche de retour en bas, le téléchargement Hugging
Face, « vider la discussion », les lignes du panneau Fichiers (dossier, image,
fichier, télécharger, supprimer), renommer/supprimer une discussion, éditer un
serveur MCP.

Deux pièges rencontrés, notés dans la feuille :

- Une règle de centrage qui pose `display:inline-flex` sur ces boutons les rend
  VISIBLES en permanence : plusieurs se masquent justement par `display:none`
  (le ☰ hors téléphone, le « + » d'une section fermée, la flèche de retour en
  bas). Le ☰ s'est ainsi retrouvé par-dessus l'en-tête sur grand écran. Le
  centrage passe donc par le rembourrage pour ceux-là.
- Les titres de panneau (.stitle) et l'intertitre « Paramètres » gardaient le
  monospace en capitales de la charte précédente ; ils suivent maintenant le
  seul style de titre de la maquette, celui de « Performance ».

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 19:16:43 +00:00
Claude c3a1885987 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 19:03:17 +00:00
Claude 82136d6e0d Palettes officielles Stitch, géométrie et moniteur relevés
Le projet Stitch porte deux planches que je n'avais pas vues — « Official
Palette for Loki AI v1 » et « Official Dark Mode Palette for Loki AI ». Leurs
couleurs NOMMÉES font désormais foi, et elles corrigent le relevé précédent, que
la compression JPEG des captures avait décalé :

  clair   Main BG #F5F6F8 · Sidebar BG #ECECEC · Primary Accent #95A899
          Typography #333333
  sombre  Deep Navy #0F172A · Slate Surface #1E293B · Sage Accent #84A98C

Le thème sombre retrouve donc exactement les deux teintes du cahier des charges,
que mon relevé sur capture avait fait dériver vers #1E2834/#253141. Les surfaces
non nommées (carte de réponse, carte de saisie, piste de jauge, bulle sombre)
restent relevées sur les écrans de la maquette, en pleine résolution cette fois.

Géométrie, elle aussi mesurée sur la maquette :

- Bulles et carte de réponse : rayon 10 px (contre 16/18 et un coin rentré), la
  carte sans cadre — c'est l'ombre qui la détache.
- Carte de saisie : rayon 14 px et CADRE SAUGE, le seul contour coloré de
  l'écran. Il retombait au gris commun, une règle de coque plus bas dans la
  feuille reprenant la main sur la couleur.
- Liste des discussions : lignes pleine largeur séparées d'un filet, bande plate
  pour l'active — la maquette n'a ni pastille arrondie ni gouttière.
- Moniteur machine : titre en casse normale (le monospace capitales de la charte
  précédente y criait), libellé + pourcentage à la même échelle, jauge de 7 px
  entièrement arrondie. La sous-ligne chiffrée passe en infobulle et le bouton
  de repli disparaît : la maquette n'en a pas.
- Une jauge de VRAM par carte, en plus de la charge : sur une machine à une
  seule carte on retombe sur les trois jauges de la maquette (GPU, VRAM, RAM),
  et pour du LLM c'est la VRAM qui décide si un modèle tient.
- Échelle typographique des planches : corps 16 px dans le fil et la saisie,
  secondaire 14 px medium dans la barre latérale.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 19:03:08 +00:00
Claude f9ff17a614 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 18:50:21 +00:00
Claude 18edc9d3ed Palette relevée sur la maquette v1 (claire et sombre)
Vérification faite contre la source : le projet Stitch « Loki AI Chat Modern
Redesign v1 », écrans clair et sombre. Les couleurs sont désormais MESURÉES sur
la maquette, plus approchées à l'œil — et elles posent une hiérarchie de
surfaces que l'intuition inverse :

                     maquette clair   maquette sombre
  barre latérale     #E4E8E9          #0F1829     ← la plus sombre des trois
  fil                #EFF3F4          #1E2834
  carte de réponse   #E5E9EA          #253141     ← s'écarte du fil
  carte de saisie    #EFF3F6          #1F2734     ← la plus claire
  bulle utilisateur  #7EA58A          #334257
  ligne active       #D8DCDF          #22293B
  piste de jauge     #D5D9DC          #1E2738

Ce qui change concrètement :

- La barre latérale était la surface la plus CLAIRE (#FAFBFA) ; elle est la plus
  sombre. Idem pour la carte de réponse, qui était quasi blanche alors que la
  maquette la fait descendre sous le fil. En sombre elle monte au-dessus : dans
  les deux cas la carte se détache en s'éloignant du fond, jamais en allant vers
  le blanc.
- La bulle de l'utilisateur ne suit plus l'accent. Elle a son rôle propre
  (--user-bg) : sauge en clair, ARDOISE en sombre — la maquette réserve le vert
  aux jauges dès que le fond est sombre, ce que le vert unique ignorait.
- --accent redevient une couleur d'ENCRE (liens, icônes, sélection), lisible sur
  les deux fonds ; --accent-fill porte les aplats. Sans cette séparation, la
  sauge claire de la maquette (#7EA58A) donnait des icônes à 2:1 de contraste.
- La sélection dans la liste des discussions est un aplat neutre (#D8DCDF) et
  non une teinte verte : la maquette n'y met pas de second accent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 18:50:12 +00:00
Claude cd1792df41 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 18:37:05 +00:00
Claude 040c32a6d6 Blocs de code : un fond qui se détache de la carte de réponse
Depuis que la réponse de l'IA est une carte, le bloc de code avait exactement
son ton (#F4F6F5 dans une bulle #F6F8F7) : l'encadré disparaissait dans la
bulle, et un long prompt collé dans une réponse ne se distinguait plus du texte
qui l'entoure. --code-bg est désormais nettement plus soutenu que --bubble, dans
les deux variantes, avec un filet net. Le code EN LIGNE suit la même règle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 18:36:56 +00:00
Claude 41910b01ec Merge remote-tracking branch 'origin/claude/discussion-file-linking-nzgd0d' into claude/discussion-file-linking-nzgd0d 2026-08-16 18:35:25 +00:00
Claude 45f1ec4941 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 18:35:14 +00:00
Claude 6efd2a362e Barre de saisie cohérente et étiquettes d'identité stables
Deux retours, deux vrais défauts.

Boutons de la saisie
  Ils avaient trois générations de règles empilées — pilule large « Envoyer »,
  aplat noir, aplat sauge — si bien que chaque bouton tirait sa taille, sa forme
  et son alignement d'une couche différente : rond pour l'envoi et l'arrêt,
  carré de 15 px pour l'icône stop contre 16 pour ses voisines, et un
  align-self:center sur deux boutons seulement (les deux autres tombaient au
  bas de la pilule). Une seule règle les décrit désormais : 34x34, coins doux
  de 10 px — jamais un rond, la maquette n'en a pas —, icône de 18 px centrée,
  fond seulement au survol. Seule la couleur les distingue : atténué pour
  joindre et fichiers, sauge pour envoyer, rouille pour arrêter.

Étiquettes des bulles
  Une bulle changeait de nom en cours de route. Envoyée en direct, elle
  s'appelait « USER » (confirmPending réécrivait l'étiquette en dur) ; rejouée
  au chargement, elle portait l'avatar et le prénom. Même chose côté réponse :
  labelTokens remplaçait « Loki » par « assistant · 75 tok · 21.5 tok/s »
  pendant la génération, et le nom ne revenait qu'au rechargement de la page.
  L'étiquette porte maintenant l'identité DANS TOUS LES CAS ; les mesures de
  génération vont dans la ligne dédiée, à l'endroit exact où les stats de fin
  de tour les écrivent déjà. L'envoi en cours se lit à la bulle grisée et à son
  infobulle, plus à un libellé qui écrase le prénom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 18:35:04 +00:00
Claude 30be0a5909 Merge branch 'claude/discussion-file-linking-nzgd0d' 2026-08-16 18:19:11 +00:00
LogiFlow da4dcbce57 Merge pull request #9 from R0m1k3/main
Merge pull request #8 from R0m1k3/claude/discussion-file-linking-nzgd0d
2026-08-16 20:18:27 +02:00
Claude a44f3c763a Merge remote-tracking branch 'origin/claude/discussion-file-linking-nzgd0d' into claude/discussion-file-linking-nzgd0d 2026-08-16 18:15:39 +00:00
Claude 276a8287cb Refonte : modèle chargé, attribution, ombres, flèche, favicon
Retours sur la refonte, cinq corrections.

Modèle affiché
  Le sélecteur de l'en-tête ne montrait que le PRESET actif — or un preset n'est
  « actif » que si son empreinte correspond exactement à la configuration. Une
  configuration écrite à la main, héritée de l'image ou modifiée d'un cran depuis
  le dernier preset n'en désigne aucun, et l'en-tête restait muet alors qu'un
  modèle tournait. Il retombe désormais sur le nom du fichier de la
  configuration active (loadCfg le lui donne), et l'infobulle porte le chemin
  complet.

Attribution
  « fork de AJEAN » revient sous le nom de l'app, dans l'en-tête de la barre
  latérale, au lieu du pied de la zone défilante.

Ombres
  Les bulles du fil et la carte de saisie portent une vraie ombre douce
  (--shadow-card), comme la maquette — le filet de 1 px ne les décollait pas du
  fond.

Bouton d'envoi
  Chevron sauge sur fond transparent, à la place de la pastille verte pleine :
  c'est le dessin de la maquette, et il suit alors la même charte que ses
  voisins (dépôt, fichiers).

Favicon
  Carré sauge (#5E7F5A) au lieu du carré noir, aux quatre endroits qui le
  dessinent : favicon SVG, icône d'accueil iOS, icône du .exe (icon.ico) et PNG
  macOS — tous rendus depuis sys_brand_icon.go, seule source du dessin. La
  variante « template » de macOS reste noire : le système ne lit que son alpha.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 18:15:24 +00:00
LogiFlow ce23dccf03 Merge pull request #8 from R0m1k3/claude/discussion-file-linking-nzgd0d
Claude/discussion file linking nzgd0d
2026-08-16 19:48:37 +02:00
LogiFlow 5522adfd9e Merge pull request #7 from R0m1k3/main
Merge pull request #6 from R0m1k3/claude/discussion-file-linking-nzgd0d
2026-08-16 19:48:18 +02:00
Claude f99e5b59b8 Refonte de l'interface : direction « Sober Tech »
Reprise du design d'après la maquette fournie, sans changer d'architecture :
l'UI reste du HTML/CSS/JS assemblé dans le binaire (voir plus bas).

Charte
- Palette ardoise + sauge en remplacement des bruns « Terre ». Deux variantes :
  claire par défaut (celle de la maquette) et « Deep Dark » (fond #0F172A,
  cartes #1E293B), à un clic depuis l'en-tête. Une variable --on-accent porte ce
  qui s'écrit sur les aplats de sauge, pour que le contraste tienne dans les
  deux variantes.
- Typographie Inter (interface) + JetBrains Mono (code, chiffres, chemins), en
  sous-ensemble latin, embarquées comme l'étaient Bricolage et IBM Plex Mono —
  toujours aucune requête vers un service de polices.
- Boutons sans contour, fond au survol, léger enfoncement au clic.
- Coque à plat : plus de cartes flottantes séparées par une gouttière, la barre
  latérale est collée au bord et le fil occupe tout le reste.

Coque
- Barre d'en-tête : titre de la discussion + sélecteur de modèle. Changer de
  preset demandait d'ouvrir la barre latérale et de descendre jusqu'aux
  presets ; c'est désormais une liste déroulante, toujours visible.
- Barre latérale en trois zones — marque, contenu défilant, moniteur machine —
  et escamotable d'un clic sur grand écran. Les discussions y passent au
  premier plan, avec une recherche textuelle (filtrage côté client : la liste
  est déjà chargée, sans les messages) ; les réglages descendent sous un repli
  unique, d'où openDetails(), qui déplie aussi les parents.
- Moniteur machine en pied de colonne : une jauge par ressource (charge GPU,
  puis VRAM et température en détail, mémoire vive), sauge jusqu'à 75 %, ambre
  puis rouille quand la machine sature. Il remplace la section « Machine », qui
  disait la même chose en plus verbeux et en plus loin.
- L'option « barre latérale escamotable » disparaît d'Apparence : elle faisait
  de la barre un tiroir posé SUR la conversation, là où le bouton d'en-tête
  l'escamote pour de bon. Deux mécanismes concurrents pour la même intention.

Fil et saisie
- La réponse de l'IA redevient une carte, la question un aplat de sauge : sur un
  fond de fil coloré, du texte nu flottait sans ancrage.
- Saisie en pilule, ses outils (joindre, fichiers, envoyer) dans la barre
  elle-même. L'envoi est une pastille ronde à flèche ; le mot « Envoyer » reste
  porté par aria-label et l'infobulle.

Corrections croisées
- L'heure d'une discussion prenait la moitié de la ligne (flex:1 hérité de
  `.preset>span`), le titre était tronqué au premier mot.
- Une liste déroulante posée directement dans une ligne de modale débordait
  sous son intitulé quand sa valeur était longue (chemin de destination).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 16:17:59 +00:00
Claude 7310c84129 Bouton d'envoi : « Envoyer » au lieu de « send »
Le reste de l'interface est en français ; seul ce bouton était resté en anglais.
L'identifiant #send et la fonction send() ne changent pas — c'est le libellé
affiché qui est traduit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 15:49:19 +00:00
LogiFlow b9d2e88e64 Merge pull request #6 from R0m1k3/claude/discussion-file-linking-nzgd0d
Un dossier de fichiers par discussion
2026-08-16 17:48:19 +02:00
Claude 001d633750 Un dossier de fichiers par discussion
Les fichiers du chat vivaient dans un pot commun : on changeait de discussion
et on revoyait les mêmes pièces jointes, et supprimer une discussion laissait
derrière elle tout ce qu'on y avait déposé ou fait écrire à l'agent (seules ses
captures, déjà rangées par identifiant, partaient avec).

Chaque discussion a désormais son dossier, <workspace>/discussions/<id>/ :

- les dépôts (uploads/), les captures (captures/) et ce que l'agent écrit y
  atterrissent ; le shell et les chemins relatifs du modèle y sont résolus ;
- le panneau Fichiers s'ouvre sur ce dossier et n'en sort pas, et se redessine
  quand la discussion change (bascule ou vidage, signalés par le flux SSE) ;
- supprimer une discussion — ou la vider — emporte ses fichiers. Les deux gestes
  le disent maintenant avant de demander confirmation ; « clear chat » en
  demandait aucune.

La racine du dossier de travail reste la borne de sécurité : les liens des
anciens messages (uploads/x.pdf, captures/<id>/y.jpg) continuent d'ouvrir leur
fichier par un chemin de repli. Au démarrage, une migration range les captures
dans le dossier de leur discussion et rend chaque dépôt à la discussion qui le
mentionne dans son journal ; ce que personne ne réclame reste à la racine,
atteignable par le bouton « hors discussion » du panneau, qui disparaît une fois
le ménage fait.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LPyFxVHNAN9u5pVzSYMwjd
2026-08-16 15:33:55 +00:00
LogiFlow d42c334b9a Merge pull request #5 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Claude/ajean loki container fork aep9r2
2026-08-16 14:37:02 +02:00
LogiFlow 59ebbf6f8d Merge pull request #4 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Chercher et installer un modèle depuis Loki
2026-08-16 13:04:13 +02:00
LogiFlow 948aa5c54b Merge pull request #3 from R0m1k3/claude/ajean-loki-container-fork-aep9r2
Le fil ne passe plus sous la carte de saisie
2026-08-16 11:14:28 +02:00
185 changed files with 29989 additions and 1734 deletions

No files matched your search

+14
View File
@@ -15,6 +15,17 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
# L'image runtime CUDA de llama.cpp (~4 Go) et Chromium/Playwright
# (~1 Go) tiennent large dans les ~14 Go libres d'un runner neuf, mais
# ces trois répertoires ne servent à rien ici et rendent une dizaine de
# gigaoctets en moins d'une minute. Gardé depuis l'époque où l'image
# compilait whisper.cpp avec CUDA : la marge reste bonne à prendre.
- name: Libérer de l'espace disque
run: |
df -h /
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc
df -h /
- name: Setup Buildx
uses: docker/setup-buildx-action@v3
@@ -45,5 +56,8 @@ jobs:
LOKI_VERSION=${{ github.sha }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Le cache de couches réutilise l'étape asrfetch (téléchargement du
# binaire de dictée) tant que ses lignes du Dockerfile ne bougent
# pas : seul le code Go et l'assemblage de l'image se refont.
cache-from: type=gha
cache-to: type=gha,mode=max
+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/
+65
View File
@@ -14,6 +14,17 @@
# Déclaré avant le premier FROM : requis pour être utilisable dans un FROM.
ARG LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda
# ⚠️ AVANT le premier FROM, obligatoirement. Un ARG posé entre deux étapes
# appartient à l'étape où il apparaît ; les « ARG SHERPA_* » nus de l'étape
# asrfetch héritent du scope GLOBAL — c'est-à-dire d'ici. Placés plus bas, ils
# hériteraient d'une valeur VIDE, et l'étape téléchargerait une URL tronquée.
# Un test (TestDockerfileArgFlagsGlobal) verrouille cette position.
#
# La version est ÉPINGLÉE, et l'empreinte avec : le binaire de dictée est
# exécuté sur la machine de l'utilisateur, il ne se prend pas « au dernier ».
ARG SHERPA_ONNX_VERSION=v1.13.7
ARG SHERPA_ONNX_SHA256=2aa3965b37aa235e56aa3ebbb94942c20a23960059a006b4442b4bf893967983
# ── Étape 1 : binaire Go loki ───────────────────────────────────────────
FROM golang:1.25 AS gobuild
WORKDIR /src
@@ -23,6 +34,45 @@ 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 : serveur de dictée (Parakeet via sherpa-onnx) ──────────
# On TÉLÉCHARGE un binaire préconstruit, on ne compile plus rien. C'est le
# changement de fond par rapport à whisper.cpp, qu'il remplace : deux étapes de
# compilation (dont une CUDA de ~200 fichiers nvcc, tuée par l'OOM du runner
# plus d'une fois) laissent place à une extraction de 35 Mo.
#
# Le build « static-no-tts » lie sherpa-onnx ET onnxruntime en statique : rien à
# fournir côté image en dehors de la libc et de libstdc++, déjà présentes. Il
# n'existe qu'en variante CPU — c'est assumé : un modèle de 0,6 B en int8 sur
# des tranches de quelques secondes n'a pas besoin de la carte, qui reste au
# moteur de chat. La variante CUDA existe mais tirerait onnxruntime-gpu et
# cuDNN dans l'image, pour un gain sans intérêt à cette taille de tranche.
#
# L'empreinte est vérifiée : le binaire s'exécute sur la machine de
# l'utilisateur, une release remplacée en amont ne doit pas passer en silence.
FROM debian:12-slim AS asrfetch
ARG SHERPA_ONNX_VERSION
ARG SHERPA_ONNX_SHA256
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates bzip2 \
&& rm -rf /var/lib/apt/lists/*
RUN set -eu; \
nom="sherpa-onnx-${SHERPA_ONNX_VERSION}-linux-x64-static-no-tts"; \
curl -fsSL -o /tmp/sherpa.tar.bz2 \
"https://github.com/k2-fsa/sherpa-onnx/releases/download/${SHERPA_ONNX_VERSION}/${nom}.tar.bz2"; \
echo "${SHERPA_ONNX_SHA256} /tmp/sherpa.tar.bz2" | sha256sum -c -; \
mkdir -p /out; \
tar -xjf /tmp/sherpa.tar.bz2 -C /out --strip-components=2 \
"${nom}/bin/sherpa-onnx-offline-websocket-server"; \
chmod +x /out/sherpa-onnx-offline-websocket-server; \
/out/sherpa-onnx-offline-websocket-server --help >/dev/null 2>&1 || true
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
FROM ${LLAMACPP_IMAGE} AS runtime
@@ -43,6 +93,16 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
# Serveurs LSP du mode code (lsp.go) : TypeScript/JavaScript et Python via
# npm, Go via gopls compilé à l'étape 1. Un serveur absent désactive juste son
# langage — mais autant livrer l'image complète.
RUN npm install -g --no-audit --no-fund typescript typescript-language-server pyright && npm cache clean --force
# uv/uvx : lanceur des serveurs MCP écrits en Python (mcp-server-git, -fetch,
# -time, sqlite, docker…). Sans lui, une bonne partie du catalogue embarqué
# s'affiche mais ne peut pas démarrer. Deux binaires statiques, ~35 Mo.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Playwright + Chromium : l'outil web_screenshot capture une page RENDUE
# (JavaScript exécuté), ce que le moteur web intégré ne sait pas faire.
# Coût assumé : ~500 Mo à 1 Go d'image. Mettre PLAYWRIGHT=0 pour bâtir une image
@@ -66,6 +126,10 @@ RUN if [ "$PLAYWRIGHT" = "1" ]; then \
fi
COPY --from=gobuild /out/loki /usr/local/bin/loki
COPY --from=gobuild /out/gopls /usr/local/bin/gopls
# Dictée vocale (POST /api/transcribe). Le modèle (~500 Mo) n'est PAS dans
# l'image : téléchargé à la demande dans /data/asr/.
COPY --from=asrfetch /out/sherpa-onnx-offline-websocket-server /usr/local/bin/sherpa-onnx-offline-websocket-server
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /data /models
@@ -74,6 +138,7 @@ ENV LOKI_CONTAINER=1 \
LOKI_HOME=/data \
LOKI_MODEL_DIRS=/models \
LOKI_ENGINE_BIN=/app/llama-server \
LOKI_ASR_SERVER=/usr/local/bin/sherpa-onnx-offline-websocket-server \
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
+218 -18
View File
@@ -22,7 +22,7 @@ seule image.
│ ▲ modèles .gguf │
│ chromium (Playwright) — captures de pages web │
│ /data (config, bbolt, presets, mémoire, │
│ workspace + captures, modèles installés) │
│ workspace par discussion, modèles) │
│ /models (GGUF déposés à la main) │
└────────────────────────────────────────────────────┘
```
@@ -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`…).
@@ -122,8 +138,8 @@ et modèles compris.
| `/data/presets/` | un `.env` par preset (modèle, contexte, NGL, vision…) |
| `/data/models/` | modèles téléchargés depuis l'interface |
| `/data/memory/` | pages de mémoire persistante (`.md`) |
| `/data/workspace/` | dossier de travail de l'agent |
| `/data/workspace/captures/<id>/` | captures d'écran, un dossier par discussion |
| `/data/workspace/` | racine du dossier de travail de l'agent |
| `/data/workspace/discussions/<id>/` | fichiers d'UNE discussion : dépôts, captures, ce que l'agent y écrit |
| `/data/loki-engine.log` | journal de `llama-server` (aussi via `loki logs`) |
| `/models` | GGUF déposés à la main depuis l'hôte (volume séparé, `LOKI_MODEL_DIRS`) |
@@ -138,6 +154,16 @@ Sur Unraid, garde le **même** chemin hôte d'un lancement à l'autre : `/mnt/us
emplacements différents dès que le partage n'est pas en cache-only ou que le
*mover* est passé. Le compose fourni utilise `/mnt/user/appdata/loki/…`.
**Tu perds modèles, discussions et fichiers à chaque redémarrage ?** C'est le
signe que `/data` n'est **pas monté** : le conteneur écrit alors dans sa couche
éphémère, détruite à chaque recréation (mise à jour d'image, `compose down`,
redémarrage de l'array). Vérifie avec la commande `docker inspect` ci-dessus :
il doit y avoir une ligne `… → /data` **et** une `… → /models`. S'il n'y en a
pas, ton conteneur a été lancé sans mapping (template Docker Unraid incomplet,
`docker run` sans `-v`) — recrée-le avec les volumes du compose fourni. Depuis
cette version, Loki le détecte au démarrage : bandeau rouge dans l'UI et
avertissement en tête du journal du conteneur.
Autre piège au redémarrage du serveur : Docker relance les conteneurs
`restart: unless-stopped` **avant** que le plugin Nvidia Driver ait chargé ses
modules. Le journal montre alors des `ERROR: init … result=11` et le moteur
@@ -154,16 +180,88 @@ Héritées d'AJEAN :
- **Accès internet** : recherche + lecture de pages, moteur Go intégré ou
[Crawl4AI](https://github.com/unclecode/crawl4ai) pour les pages JS.
- **Agent** : shell, fichiers, workspace (`agent on`).
- **Serveurs MCP** : Node.js est inclus dans l'image pour les serveurs `npx`.
- **Serveurs MCP** : Node.js (`npx`) et uv (`uvx`) sont inclus dans l'image, pour
les serveurs écrits en JavaScript comme en Python. Au **premier** lancement,
`npx`/`uvx` téléchargent le paquet du serveur — Loki attend jusqu'à 3 minutes
ce coup-là (au lieu d'échouer sur « context deadline exceeded ») ; les
lancements suivants partent du cache en quelques secondes.
- **Tâches planifiées** : une consigne que l'IA exécute **toute seule**, sur une
fréquence réglable (« toutes les 2 h », « tous les jours à 9 h », ou une
expression cron). Chaque tâche tourne **isolée des discussions** — elle n'écrit
pas dans le fil, travaille dans son propre dossier (`workspace/tasks/<id>/`) et
livre son résultat par les outils de l'IA (mail via MCP, shell, fichiers…) ; le
compte-rendu du dernier passage est visible dans sa fiche, et **réinjecté** au
passage suivant pour la continuité. Réglages par tâche : preset (donc modèle) à
activer avant l'exécution, accès mémoire et web. Un **interrupteur maître**
suspend tout d'un coup. Sans **mode agent**, une tâche s'exécute mais n'a aucun
outil pour agir — l'interface le dit. Une seule inférence tourne à la fois : une
tâche attend son tour, et le bouton stop du chat l'interrompt.
- **Presets** de configuration par modèle, bench, auto-détection GPU.
- **API OpenAI-compatible** exposable (`network on`, protégée par clé).
- **Échantillonnage réglable par preset** : température, `top_p`, `top_k`,
`min_p`, pénalités de présence et de répétition, dans l'éditeur de preset. Ces
valeurs partent dans **chaque requête** au moteur, pas sur sa ligne de commande :
les changer ne demande donc aucun redémarrage. Un champ laissé vide n'envoie
rien et llama-server garde son défaut — utile parce que le défaut de llama.cpp
(top_k 40, min_p 0.05) est rarement celui que recommande le modèle (Qwen3.8 en
réflexion veut top_k 20, min_p 0, temp 1.0, top_p 0.95).
- **API OpenAI-compatible** exposable, protégée par clé (voir ci-dessous — ce
fork la sert autrement que l'amont).
Ajoutées par ce fork :
- **Mode Code** : chaque discussion a un sélecteur **Chat | Code** dans le pied
de la carte de saisie. En mode Code, Loki devient un agent de code : outils
`read`/`grep`/`glob` (lecture bornée, numéros de ligne — et un fichier doit
avoir été LU avant d'être modifié), outils git (`git_status`, `git_diff`,
`git_clone` — le clone atterrit dans le dossier de la discussion), jobs
d'arrière-plan (`bash_bg`/`bash_tail` pour un serveur de dev ou un build
long), et **critères d'acceptation** : le modèle pose le contrat (2-6
critères testables, éditables dans le panneau au-dessus de la saisie), puis
une **passe de vérification indépendante** — même modèle, contexte isolé,
seule habilitée à marquer un critère « passé » — contrôle le diff et relance
la correction jusqu'à ce que tout passe (2 corrections max). Les fichiers
modifiés passent au **LSP** (gopls, typescript-language-server, pyright —
inclus dans l'image) : les erreurs de compilation reviennent dans le résultat
de l'outil, sans lancer de build. Sécurité : commandes catastrophiques
refusées (rm -rf /, mkfs, reboot…), chemins bornés au dossier de la
discussion en mode Code. En mode Chat, un message qui ressemble à une tâche
de code fait apparaître une puce « passer en mode Code ? » — suggestion,
jamais bascule automatique. Conception reprise
d'[OpenFox](https://github.com/co-l/openfox) (MIT), réécrite en Go — voir
`NOTICE.md`.
- **Catalogue MCP** : le panneau *Serveurs MCP* offre un bouton **catalogue** —
une vingtaine de serveurs connus (filesystem, git, fetch, memory, sqlite,
playwright, context7, github…) avec leur commande déjà renseignée, classés par
catégorie. Choisir une entrée **n'installe rien** : ça remplit le formulaire
d'ajout, tu relis la commande — qui s'exécutera sur cette machine — puis tu
enregistres. Le catalogue est un JSON **embarqué dans le binaire**
(`internal/loki/mcp_catalog.json`), donc aucun appel à un annuaire distant :
pour en proposer d'autres, édite ce fichier et recompile. Une entrée dont le
runtime manque (`npx`/`uvx` absent) le signale au lieu d'échouer plus tard, et
celles qui réclament une clé d'API la rappellent avant l'enregistrement.
- **Intensité du raisonnement** : un niveau — auto / aucune / basse / moyenne /
haute / maximale — envoyé à `llama-server` comme `reasoning_effort`. Réglable
**dans la barre de saisie**, parce que ça se décide en écrivant le message : le
changement s'applique au message suivant, sans redémarrer le moteur. L'éditeur
de preset garde le même réglage comme **défaut du modèle** ; appliquer un
preset reprend donc la main sur le choix fait à la volée. `none` coupe le
raisonnement ; les autres valeurs sont passées au gabarit jinja du modèle, ce
qui ne change le comportement que des modèles qui les lisent (gpt-oss et
apparentés) — ailleurs c'est ignoré sans erreur, et l'interface le dit plutôt
que de promettre un effet. Aucun gabarit ne les connaît toutes (gpt-oss :
basse/moyenne/haute ; Qwen3.8 : basse/moyenne/maximale) et certains **refusent**
celles qu'ils ne connaissent pas, avec une erreur 500 qui tuait le tour : loki
lit alors les niveaux annoncés par le refus, **repli sur le plus proche** (haute
→ maximale) et rejoue le message sans rien perdre de l'historique — une fois,
puis la traduction est retenue pour ce modèle. La liste est grisée quand le
raisonnement est coupé pour ce modèle.
- **Discussions multiples** : historique complet dans la barre latérale, titre
repris du premier message (renommable), suppression. Supprimer une discussion
efface aussi ses captures d'écran (`workspace/captures/<id>/`), pour que le
disque ne se remplisse pas en silence.
repris du premier message (renommable), suppression. **Chaque discussion a son
dossier de fichiers** (`workspace/discussions/<id>/`) : les pièces jointes
déposées, les captures et ce que l'agent écrit y atterrissent, le shell et les
chemins relatifs du modèle y sont résolus. Changer de discussion change donc
les fichiers ; supprimer (ou vider) une discussion emporte les siens, pour que
le disque ne se remplisse pas en silence.
- **Recherche Hugging Face** intégrée avec verdict mémoire et installation liée
du projecteur vision (voir [Installer un modèle](#installer-un-modèle)).
- **Captures de pages web** : l'agent dispose de l'outil `web_screenshot`
@@ -174,16 +272,78 @@ Ajoutées par ce fork :
lui promettre des yeux qu'il n'a pas — il peut toujours prendre la capture et
la montrer, sans prétendre la décrire. L'image relayée au moteur reste
éphémère : la persister gonflait le contexte jusqu'à le faire déborder.
- **Panneau Fichiers** (bouton dossier du pied de carte) : ce que l'agent a
écrit dans son dossier de travail, avec navigation dans les sous-dossiers,
téléchargement et suppression. Un dossier affiche la taille de **tout** son
contenu — c'est ce qu'on libère en le supprimant. Le pied donne l'occupation
disque totale de l'agent. Les chemins sont bornés au dossier de travail, liens
symboliques résolus des deux côtés : rien du reste du disque n'est atteignable.
- **Panneau Fichiers** (bouton dossier de la barre de saisie) : les fichiers de la
discussion ouverte — dépôts, captures, ce que l'agent y a écrit — avec
navigation dans les sous-dossiers, téléchargement et suppression. Un dossier
affiche la taille de **tout** son contenu, c'est ce qu'on libère en le
supprimant, et le pied donne l'occupation disque de la discussion. Les chemins
sont bornés à son dossier, liens symboliques résolus des deux côtés : ni le
reste du disque ni les autres discussions ne sont atteignables. Les fichiers
d'avant ce rangement que la migration n'a pas su rattacher restent joignables
par le bouton **hors discussion**, qui disparaît une fois le ménage fait.
- **Interface « Sober Tech »** : ardoise et sauge, typographie Inter (interface)
et JetBrains Mono (code, chiffres, chemins) — embarquées dans le binaire, donc
aucune requête vers un service de polices. Deux variantes : claire par défaut,
**Deep Dark** (fond `#0F172A`, cartes `#1E293B`) d'un clic depuis l'en-tête.
L'en-tête porte le titre de la discussion et le **sélecteur de modèle** (le
changement de preset ne demande plus d'ouvrir les réglages) ; la barre
latérale s'escamote pour rendre toute la largeur au fil ; les discussions s'y
cherchent au clavier et les jauges **GPU / VRAM / mémoire vive** restent
visibles en pied de colonne.
- **Libérer la VRAM d'un clic** : sur les jauges du moniteur, un bouton décharge
le modèle et arrête le moteur (ainsi que le serveur de dictée, qui occupe la
carte lui aussi) pour rendre la mémoire vidéo à une autre application — jeu,
encodage, autre serveur d'inférence. Le bilan est annoncé en Gio réellement
rendus, et le même bouton devient **Recharger le modèle** pour reprendre la
main. Routes : `POST /api/vram/unload` et `POST /api/vram/reload`.
- **API OpenAI servie par Loki** : `/v1/*` est exposé **sur le port de
l'interface** (8090) et relayé vers llama-server, au lieu d'annoncer l'adresse
du moteur. Conséquence directe : l'API est joignable partout où l'interface
l'est — par l'IP du réseau local comme par un nom de domaine — sans publier de
second port ni ouvrir le moteur. L'amont annonçait `http://<ip>:8080/v1`, une
adresse injoignable en conteneur (le port 8080 n'y est pas publié, et l'IP
détectée est celle du bridge Docker).
- Authentification par la **clé API** du panneau (`Authorization: Bearer …`),
vérifiée par Loki **et** par le moteur. Sans clé, l'endpoint est ouvert et
l'interface le dit en rouge.
- **Adresse publique** : un champ où saisir son domaine, pour le cas du
reverse proxy où Loki ne voit qu'un appel interne. Laissé vide, l'adresse
affichée suit celle du navigateur.
- **TLS** : mettre un reverse proxy devant (Caddy, Nginx, Traefik). Loki
honore `X-Forwarded-Proto` pour annoncer une adresse en `https`.
- L'ancienne exposition publique via le relais de l'amont
(`<machine>.oai.ajean.link`) est retirée de l'interface : elle exigeait un
jeton de relais que ce fork ne permet plus d'obtenir, l'interrupteur ne
pouvait donc qu'échouer.
- **Budget d'appels d'outils** : un tour d'agent n'a aucun plafond — couper une
recherche légitime est pire que la laisser durer — mais au-delà de 24 appels
sur un même tour, Loki rappelle au modèle combien il en a déjà faits et lui
demande de conclure. Le rappel revient tous les 24 appels, en durcissant le
ton ; il ne coupe jamais le tour, c'est de la pression, pas une barrière.
Sans lui, un petit modèle qui tourne en rond n'avait rien en face de lui sauf
le bouton stop (vu en production : 50 appels, 55 minutes, à relire cinq fois
les mêmes fichiers). Réglable par `AGENT_BUDGET` dans `config.env` —
`AGENT_BUDGET=off` le désactive complètement.
- **Identité** : ton prénom et un avatar emoji pour toi et pour Loki, affichés
dans le fil.
- **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 :
@@ -206,12 +366,14 @@ Retirées par ce fork :
|---|---|---|
| Installation | binaire + `sudo ajean install` (systemd) | `docker compose up` |
| Moteur llama.cpp | compilé sur la machine (`ajean llamacpp install`) | image officielle llama.cpp (`server-cuda`), précompilée |
| Panneau « Moteur » | propose d'installer/compiler | annonce le moteur fourni par l'image, sans proposer d'install |
| Panneau « Moteur » | propose d'installer/compiler | affiche la version qui tourne et la met à jour en un clic (sans rebuild) |
| Supervision moteur | systemd / launchd / PID (Windows) | fichier PID (`LOKI_CONTAINER=1`) |
| 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` |
Le reste — mémoire, outils, protocole, moteur d'inférence — est celui d'AJEAN.
@@ -238,6 +400,44 @@ docker build --build-arg LLAMACPP_IMAGE=ghcr.io/ggml-org/llama.cpp:server-cuda-b
Aucune compilation de llama.cpp n'a lieu : le moteur est maintenu et
précompilé par l'équipe amont (toutes architectures GPU courantes).
## Mettre à jour llama.cpp sans reconstruire l'image
llama.cpp publie plusieurs versions par jour ; l'image de Loki, elle, ne se
reconstruit qu'à une mise à jour de Loki. Le moteur y était donc figé à la date
du dernier build, et le rattraper imposait un rebuild complet (2,6 Go) pour un
composant de 170 Mo.
**Réglages → Moteur** affiche désormais la version de llama.cpp qui tourne
(`b10450`, avec son commit) et deux boutons :
- **vérifier la version** — interroge le registre et dit s'il existe plus récent ;
- **mettre à jour le moteur** — télécharge `llama-server` et ses bibliothèques
depuis l'image officielle, `ghcr.io/ggml-org/llama.cpp:server-cuda`.
Ce qui est téléchargé n'est **pas l'image** : un manifeste OCI liste ses couches,
et seules celles qui portent `/app` sont récupérées (~170 Mo). Le runtime CUDA
(2 Go) et la base système sont déjà dans l'image de Loki. Le moteur atterrit dans
`/data/engine/<version>/`, donc sur le volume de données : il survit à un
`docker compose pull`.
La variante est déduite du moteur en place (CUDA, Vulkan, SYCL, MUSA ou CPU) :
une installation Vulkan ne se verra jamais proposer une image CUDA.
**Le garde-fou.** La mise à jour apporte llama.cpp, pas le runtime CUDA, qui
reste celui de l'image. Un llama.cpp compilé pour un CUDA plus récent ne
chargerait pas son backend GPU ici — et le symptôme serait silencieux : tout
fonctionne, mais sur le processeur. Le nouveau moteur est donc lancé à blanc
avant toute bascule ; s'il ne démarre pas, ou s'il ne voit plus aucune carte
alors que le moteur courant en voyait, la mise à jour est refusée et le moteur
courant n'est pas touché. Le moteur livré par l'image reste par ailleurs intact :
**revenir au moteur de l'image** y ramène en un clic, sans réseau.
Quand cette limite est atteinte pour de bon (CUDA majeur trop ancien), la
solution reste le rebuild avec un `LLAMACPP_IMAGE` récent, ci-dessus.
Derrière un miroir de registre ou un réseau qui n'atteint pas ghcr.io :
`LOKI_OCI_REGISTRY=https://mon-miroir.interne` dans l'environnement du conteneur.
## Licence
MIT — © les contributeurs d'AJEAN (« Jean contributors ») pour le code amont,
+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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

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": {
+28 -1
View File
@@ -4,12 +4,39 @@
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.
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
#
# SAUF si l'utilisateur a mis le moteur à jour depuis l'interface : celui-là
# vit sous $LOKI_HOME/engine/<version>/ (volume de données, il survit au
# conteneur) et c'est lui qu'il a choisi. Le reposer sur /app à chaque
# redémarrage ramenait silencieusement l'ancien moteur — un modèle qui
# chargeait la veille ne chargeait plus le lendemain, « unknown model
# architecture », sans que rien n'ait été touché. On ne le garde que s'il
# existe encore : un dossier supprimé à la main ne doit pas laisser BIN dans
# le vide.
cur=$(loki config get BIN)
case "$cur" in
"$LOKI_HOME"/engine/*/llama-server) [ -x "$cur" ] || cur="" ;;
*) cur="" ;;
esac
if [ -n "$cur" ]; then
echo "[entrypoint] moteur mis à jour conservé : $cur"
else
loki config set "BIN=${LOKI_ENGINE_BIN:-/app/llama-server}"
fi
# Les autres clés ne sont semées QUE si absentes : ce que l'utilisateur règle
# ensuite dans l'UI (modèle, contexte…) est conservé d'un redémarrage à l'autre.
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 {
+89 -3
View File
@@ -12,10 +12,41 @@ import (
"strings"
)
// readAPIKeyErr renvoie la clé Bearer des complétions en distinguant « aucune
// clé » d'une LECTURE RATÉE — même nuance que readWebKeyErr, et pour la même
// raison : sans clé, l'endpoint est OUVERT. Confondre les deux, c'est ouvrir
// l'accès aux complétions parce que la base était momentanément verrouillée par
// une commande CLI. requireCompletionKey refuse donc plutôt que d'ouvrir.
func readAPIKeyErr() (string, error) {
b, err := getBytesErr(bkState, "api_key")
return string(b), err
}
// readAPIKey renvoie la clé Bearer de llama-server, ou "" si aucune n'est
// définie. Elle est rangée hors de la configuration pour survivre aux
// changements de preset, qui remplacent la configuration en bloc.
func readAPIKey() string { return getStr(bkState, "api_key") }
// définie (ou illisible). Elle est rangée hors de la configuration pour survivre
// aux changements de preset, qui remplacent la configuration en bloc. Réservé à
// l'affichage et aux appels internes ; toute décision d'accès passe par
// effectiveAPIKeyErr.
func readAPIKey() string { k, _ := readAPIKeyErr(); return k }
// effectiveAPIKeyErr renvoie la clé que llama-server exige RÉELLEMENT : celle
// rangée en base, sinon la clé résiduelle de config.env (repli rétro-compatible,
// cf. backend_serve.go).
//
// Une seule source pour les deux côtés du relais. Quand la garde de Loki ne
// regardait que la clé en base, un API_KEY oublié dans config.env donnait un
// endpoint « ouvert » côté Loki et un 401 côté moteur — le client voyait un
// refus sans jamais comprendre quelle clé fournir.
func effectiveAPIKeyErr() (string, error) {
k, err := readAPIKeyErr()
if err != nil {
return "", err
}
if k != "" {
return k, nil
}
return strings.TrimSpace(ReadConfig()["API_KEY"]), nil
}
// authHeader sets the Authorization: Bearer header on req when an API key is
// configured, so Loki's own internal calls (chat/web/bench/test) authenticate
@@ -203,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.
+650
View File
@@ -0,0 +1,650 @@
// backend_engine_oci.go — mise à jour du moteur llama.cpp SANS reconstruire
// l'image et sans compiler : on va chercher le moteur là où l'équipe llama.cpp
// le publie déjà tout compilé — dans son image de conteneur officielle — et on
// en extrait le seul dossier qui nous intéresse, /app.
//
// Pourquoi ce détour plutôt que les releases GitHub (backend_prebuilt.go) :
// llama.cpp ne publie AUCUN binaire CUDA pour Linux dans ses releases. La seule
// distribution CUDA/Linux officielle et précompilée, ce sont ces images
// (ghcr.io/ggml-org/llama.cpp:server-cuda-b#####). C'est aussi exactement ce
// dont l'image de Loki hérite : mettre à jour par ce chemin, c'est obtenir le
// même moteur qu'une reconstruction complète de l'image, en ~170 Mo au lieu de
// 2,6 Go et sans passer par un rebuild.
//
// Ce qu'on télécharge : PAS l'image. Un manifeste OCI liste ses couches ; le
// runtime CUDA (2 Go) et les paquets système sont déjà présents dans l'image de
// Loki, donc inutiles. Seules les couches du haut portent /app (llama-server et
// ses .so, ~170 Mo). On les prend en partant du sommet et on s'arrête dès qu'on
// tient un moteur complet.
//
// Ce que ça NE remplace PAS : le runtime CUDA, lui, vient de l'image. Un
// llama.cpp compilé pour un CUDA majeur plus récent que celui de l'image ne
// démarrera pas — d'où le test à blanc (--list-devices) avant toute bascule, et
// la possibilité de revenir au moteur d'origine en un clic.
package loki
import (
"archive/tar"
"compress/gzip"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"time"
)
// ociBase est une variable et non une constante pour que les tests puissent la
// pointer sur un faux registre : tout ce qui suit se joue dans le dialogue avec
// le registre, et le vérifier en frappant ghcr.io rendrait la CI dépendante du
// réseau.
// LOKI_OCI_REGISTRY le remplace : c'est la porte de sortie pour un réseau qui
// n'atteint pas ghcr.io directement — miroir d'entreprise, cache local — sans
// quoi la mise à jour du moteur y serait tout simplement impossible.
var ociBase = "https://ghcr.io"
func init() {
if v := strings.TrimRight(strings.TrimSpace(os.Getenv("LOKI_OCI_REGISTRY")), "/"); v != "" {
if !strings.Contains(v, "://") {
v = "https://" + v
}
ociBase = v
}
}
// ociHost : l'hôte seul, tel que l'attend le paramètre `service` du jeton.
func ociHost() string {
h := strings.TrimPrefix(strings.TrimPrefix(ociBase, "https://"), "http://")
return strings.TrimSuffix(h, "/")
}
const (
ociEngineRepo = "ggml-org/llama.cpp"
// Au-delà de cette taille, une couche n'est plus une couche « applicative » :
// c'est le runtime CUDA (2 Go) ou une base système, qu'on ne veut à aucun
// prix télécharger. On descend les couches du sommet vers la base ; croiser
// une couche de cette taille signifie qu'on a dépassé /app.
ociLayerCap = 600 << 20
)
// ociLayer : une couche du manifeste (on n'a besoin que de ça).
type ociLayer struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
}
// ociDoc couvre à la fois un index multi-architecture et un manifeste d'image :
// les deux arrivent sur la même URL, distingués par le champ qui est rempli.
type ociDoc struct {
MediaType string `json:"mediaType"`
Manifests []struct {
Digest string `json:"digest"`
Platform struct {
Architecture string `json:"architecture"`
OS string `json:"os"`
} `json:"platform"`
} `json:"manifests"`
Layers []ociLayer `json:"layers"`
Annotations map[string]string `json:"annotations"`
}
// ociAccept : tous les types de manifeste qu'on sait lire. Sans cet en-tête, le
// registre répond en schéma 1 (obsolète) et la lecture échoue sans expliquer.
const ociAccept = "application/vnd.oci.image.index.v1+json," +
"application/vnd.docker.distribution.manifest.list.v2+json," +
"application/vnd.oci.image.manifest.v1+json," +
"application/vnd.docker.distribution.manifest.v2+json"
// ociToken obtient un jeton de lecture ANONYME. Les images publiques de ghcr.io
// sont lisibles sans compte, mais le registre exige quand même un jeton porteur.
func ociToken(repo string) (string, error) {
u := fmt.Sprintf("%s/token?scope=repository:%s:pull&service=%s", ociBase, repo, ociHost())
resp, err := (&http.Client{Timeout: 30 * time.Second}).Get(u)
if err != nil {
return "", fmt.Errorf("registre %s injoignable : %w", ociHost(), err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("%s/token : HTTP %d", ociHost(), resp.StatusCode)
}
var out struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.Token == "" {
return "", fmt.Errorf("jeton de lecture illisible")
}
return out.Token, nil
}
// ociFetch fait un GET authentifié sur le registre.
func ociFetch(url, token, accept string) (*http.Response, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if accept != "" {
req.Header.Set("Accept", accept)
}
return (&http.Client{Timeout: 0}).Do(req)
}
// ociArch traduit GOARCH vers le nom de plateforme OCI.
func ociArch() string {
if runtime.GOARCH == "arm64" {
return "arm64"
}
return "amd64"
}
// ociResolve lit le manifeste d'un tag et renvoie les couches de l'image pour
// CETTE architecture, plus le numéro de build annoncé par l'image
// (org.opencontainers.image.version, ex. « b10450 »).
func ociResolve(repo, tag string) (layers []ociLayer, version string, err error) {
token, err := ociToken(repo)
if err != nil {
return nil, "", err
}
get := func(ref string) (*ociDoc, error) {
resp, err := ociFetch(fmt.Sprintf("%s/v2/%s/manifests/%s", ociBase, repo, ref), token, ociAccept)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, fmt.Errorf("version inconnue dans le registre : %s", ref)
}
if resp.StatusCode != 200 {
return nil, fmt.Errorf("manifeste %s : HTTP %d", ref, resp.StatusCode)
}
var d ociDoc
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
return nil, fmt.Errorf("manifeste %s illisible : %w", ref, err)
}
return &d, nil
}
doc, err := get(tag)
if err != nil {
return nil, "", err
}
version = doc.Annotations["org.opencontainers.image.version"]
if len(doc.Manifests) > 0 {
want := ociArch()
digest := ""
for _, m := range doc.Manifests {
if m.Platform.OS == "linux" && m.Platform.Architecture == want {
digest = m.Digest
break
}
}
if digest == "" {
return nil, "", fmt.Errorf("aucune image linux/%s pour %s", want, tag)
}
if doc, err = get(digest); err != nil {
return nil, "", err
}
if v := doc.Annotations["org.opencontainers.image.version"]; v != "" {
version = v
}
}
if len(doc.Layers) == 0 {
return nil, "", fmt.Errorf("manifeste %s sans couche", tag)
}
return doc.Layers, version, nil
}
// ---------------------------------------------------------------------------
// Variante, version et emplacement du moteur
// ---------------------------------------------------------------------------
// engineOCISupported : ce mécanisme n'a de sens que là où ces images tournent.
// Les images llama.cpp sont linux/amd64 et linux/arm64, rien d'autre.
func engineOCISupported() bool {
return runtime.GOOS == "linux" && (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64")
}
// engineVariant déduit la variante d'image correspondant au moteur COURANT, en
// regardant les backends ggml posés à côté du binaire. C'est la seule source
// fiable : le conteneur ne sait pas de quelle image il a été bâti, et demander
// à l'utilisateur de retenir « server-cuda » serait un piège à erreur.
//
// Renvoie un préfixe de tag : "server-cuda", "server-vulkan", "server-intel",
// "server-musa", ou "server" (CPU) par défaut.
func engineVariant() string {
dir := ""
if bin := currentEngineBin(); bin != "" {
dir = filepath.Dir(bin)
}
if dir == "" {
return "server"
}
has := func(prefix string) bool {
m, _ := filepath.Glob(filepath.Join(dir, prefix+"*"))
return len(m) > 0
}
switch {
case has("libggml-cuda.so"):
return "server-cuda"
case has("libggml-musa.so"):
return "server-musa"
case has("libggml-sycl.so"):
return "server-intel"
case has("libggml-vulkan.so"):
return "server-vulkan"
}
return "server"
}
// currentEngineBin : le binaire réellement utilisé (BIN de la configuration,
// avec le repli sur le moteur de l'image quand BIN n'est pas encore posé).
func currentEngineBin() string {
bin := prebuiltResolveBin(ReadConfig()["BIN"])
if bin != "" && isFile(bin) {
return bin
}
return providedEngineBin()
}
// La bannière de version de llama.cpp a changé de forme plusieurs fois, et un
// binaire ancien reste parfaitement utilisable : on lit les trois formes.
//
// version: 0.1.0-dev (build 10450, commit ece963f41) ← actuelle
// version: 10450 (ece963f) ← précédente
// build: 4589 (a1b2c3d) ← plus ancienne
//
// C'est le NUMÉRO DE BUILD qui compte : c'est lui qui figure dans les tags
// d'image (server-cuda-b10450) et donc la seule chose comparable entre ce qui
// tourne et ce qui est publié.
var reEngineBuildLong = regexp.MustCompile(`build\s+(\d+),\s*commit\s+([0-9a-fA-F]+)`)
var reEngineBuildShort = regexp.MustCompile(`(?m)^\s*(?:version|build)\s*:\s*(\d+)\s*\(([0-9a-fA-F]+)\)`)
func parseEngineBuild(out string) (build int, commit string) {
for _, re := range []*regexp.Regexp{reEngineBuildLong, reEngineBuildShort} {
if m := re.FindStringSubmatch(out); m != nil {
n, _ := strconv.Atoi(m[1])
return n, m[2]
}
}
return 0, ""
}
// engineBuildOf interroge un llama-server pour son numéro de build. Renvoie
// (0, "") si le binaire ne répond pas — un moteur cassé ne doit pas empêcher
// d'en installer un autre.
func engineBuildOf(bin string) (int, string) {
if bin == "" || !isFile(bin) {
return 0, ""
}
cmd := hideCmd(exec.Command(bin, "--version"))
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, _ := cmd.CombinedOutput() // --version sort parfois en code non nul
return parseEngineBuild(string(out))
}
// engineDir : racine des moteurs installés par ce mécanisme, sous LOKI_HOME —
// c'est-à-dire sur le volume /data en conteneur, donc conservés d'un
// redémarrage (et d'une mise à jour d'image) à l'autre.
func engineDir() string { return filepath.Join(LokiHome(), "engine") }
// engineOwns dit si un chemin désigne un moteur installé ici.
func engineOwns(p string) bool {
if p == "" {
return false
}
norm := func(s string) string { return strings.ToLower(filepath.ToSlash(filepath.Clean(s))) }
return strings.HasPrefix(norm(p), norm(engineDir())+"/")
}
// engineInstalled liste les moteurs présents sous engineDir(), du plus ancien
// au plus récent (ordre des numéros de build).
func engineInstalled() []string {
entries, err := os.ReadDir(engineDir())
if err != nil {
return nil
}
var tags []string
for _, e := range entries {
if e.IsDir() && isFile(filepath.Join(engineDir(), e.Name(), "llama-server")) {
tags = append(tags, e.Name())
}
}
sort.Slice(tags, func(i, j int) bool { return engineTagBuild(tags[i]) < engineTagBuild(tags[j]) })
return tags
}
var reTagBuild = regexp.MustCompile(`-b(\d+)$`)
// engineTagBuild extrait le numéro de build d'un tag (« server-cuda-b10450 » →
// 10450), 0 si le tag n'en porte pas (tag mouvant « server-cuda »).
func engineTagBuild(tag string) int {
m := reTagBuild.FindStringSubmatch(tag)
if m == nil {
return 0
}
n, _ := strconv.Atoi(m[1])
return n
}
// engineTagOf : le tag d'un moteur téléchargé ici (« server-cuda-b10450 »), ""
// pour tout autre chemin.
func engineTagOf(bin string) string {
if !engineOwns(bin) {
return ""
}
return filepath.Base(filepath.Dir(bin))
}
// engineBinFor : chemin du llama-server d'une version installée.
func engineBinFor(tag string) string {
p := filepath.Join(engineDir(), filepath.Base(tag), "llama-server")
if isFile(p) {
return p
}
return ""
}
// enginePrune supprime les versions installées autres que celles à garder.
// Chaque version pèse ~450 Mo une fois décompressée : en laisser traîner cinq
// remplit le volume /data sans rien apporter.
func enginePrune(keep map[string]bool, logf func(string)) {
for _, tag := range engineInstalled() {
if keep[tag] {
continue
}
if err := os.RemoveAll(filepath.Join(engineDir(), tag)); err == nil && logf != nil {
logf("ancienne version supprimée : " + tag)
}
}
}
// ---------------------------------------------------------------------------
// Installation
// ---------------------------------------------------------------------------
// engineOCIInstall télécharge le moteur du tag demandé et l'installe dans
// engineDir()/<tag>. Renvoie le chemin du llama-server installé.
//
// Réentrant : une version déjà installée est renvoyée telle quelle, et une
// installation interrompue laisse un dossier « .part » qui est repris de zéro.
func engineOCIInstall(tag string, logf, phasef func(string)) (string, error) {
tag = filepath.Base(strings.TrimSpace(tag)) // jamais de séparateur dans un nom de dossier
if tag == "" || tag == "." {
return "", fmt.Errorf("version du moteur non précisée")
}
if bin := engineBinFor(tag); bin != "" {
logf("déjà installé : " + tag)
return bin, nil
}
phasef("lecture du manifeste " + tag + "…")
layers, version, err := ociResolve(ociEngineRepo, tag)
if err != nil {
return "", err
}
if version != "" {
logf(fmt.Sprintf("image %s:%s — llama.cpp %s, %d couches", ociEngineRepo, tag, version, len(layers)))
}
dest := filepath.Join(engineDir(), tag)
tmp := dest + ".part"
if err := os.RemoveAll(tmp); err != nil {
return "", err
}
if err := os.MkdirAll(tmp, 0o755); err != nil {
return "", err
}
defer os.RemoveAll(tmp) // no-op après le rename final
token, err := ociToken(ociEngineRepo)
if err != nil {
return "", err
}
// On descend du SOMMET vers la base : /app est ajouté en dernier dans le
// Dockerfile amont, et les couches du bas sont le runtime CUDA et le système,
// déjà présents ici. Une couche du haut peut masquer un fichier d'une couche
// plus basse : le premier extrait gagne, comme le ferait un runtime OCI.
seen := map[string]bool{}
var links []archiveLink
for i := len(layers) - 1; i >= 0; i-- {
l := layers[i]
if l.Size > ociLayerCap {
logf(fmt.Sprintf("couche %d ignorée (%d Mo) : c'est le runtime de l'image, pas le moteur", i, l.Size/1_000_000))
break
}
part := filepath.Join(tmp, "layer.tgz")
if l.Size > 4<<20 {
phasef(fmt.Sprintf("téléchargement du moteur (%d Mo)…", l.Size/1_000_000))
}
if err := ociDownloadBlob(ociEngineRepo, l.Digest, token, part, l.Size, logf); err != nil {
return "", fmt.Errorf("téléchargement de la couche %d : %w", i, err)
}
n, err := extractAppLayer(part, tmp, seen, &links)
_ = os.Remove(part)
if err != nil {
return "", fmt.Errorf("extraction de la couche %d : %w", i, err)
}
if n > 0 {
logf(fmt.Sprintf("couche %d : %d fichiers du moteur", i, n))
}
if engineSetComplete(seen) {
break
}
}
if err := applyLinks(links); err != nil {
return "", err
}
if !seen["llama-server"] {
return "", fmt.Errorf("llama-server absent de l'image %s — variante inattendue ?", tag)
}
if err := os.RemoveAll(dest); err != nil {
return "", err
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", err
}
if err := os.Rename(tmp, dest); err != nil {
return "", err
}
bin := filepath.Join(dest, "llama-server")
if err := os.Chmod(bin, 0o755); err != nil {
return "", err
}
logf("moteur installé : " + bin)
return bin, nil
}
// engineSetComplete : a-t-on de quoi lancer le moteur ? Le binaire seul ne
// suffit pas — depuis que llama.cpp éclate son code en bibliothèques, l'exécutable
// tient dans une couche et ses .so dans la précédente. S'arrêter au binaire
// donnait un moteur qui meurt sur « libllama.so: cannot open shared object ».
func engineSetComplete(seen map[string]bool) bool {
if !seen["llama-server"] {
return false
}
base, llama := false, false
for name := range seen {
switch {
case strings.HasPrefix(name, "libggml-base.so"):
base = true
case strings.HasPrefix(name, "libllama.so"):
llama = true
}
}
return base && llama
}
// ociDownloadBlob télécharge une couche vers dest, avec la progression du
// téléchargement dans le journal du job.
func ociDownloadBlob(repo, digest, token, dest string, size int64, logf func(string)) error {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/v2/%s/blobs/%s", ociBase, repo, digest), nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
return downloadRequest(req, dest, size, logf)
}
// extractAppLayer extrait les entrées « app/… » d'une couche (tar.gz) à PLAT
// dans dir, en ignorant celles déjà vues (une couche supérieure fait foi) et
// les marqueurs de suppression OCI (.wh.*). Renvoie le nombre de fichiers
// extraits. Les liens sont accumulés dans links : leur cible n'est pas
// forcément déjà extraite au moment où on croise l'entrée.
func extractAppLayer(archive, dir string, seen map[string]bool, links *[]archiveLink) (int, error) {
f, err := os.Open(archive)
if err != nil {
return 0, err
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
return 0, err
}
defer gz.Close()
count := 0
tr := tar.NewReader(gz)
for {
h, err := tr.Next()
if err == io.EOF {
return count, nil
}
if err != nil {
return count, err
}
name, ok := appEntryName(h.Name)
if !ok || seen[name] {
continue
}
p := filepath.Join(dir, name)
switch h.Typeflag {
case tar.TypeReg:
w, err := os.Create(p)
if err != nil {
return count, err
}
if _, err := io.Copy(w, tr); err != nil {
w.Close()
return count, err
}
w.Close()
_ = os.Chmod(p, os.FileMode(h.Mode)&0o777)
seen[name] = true
count++
case tar.TypeSymlink, tar.TypeLink:
// Indispensable : les .so sont livrés sous leur nom versionné
// (libllama.so.0.1.0) plus deux liens (libllama.so.0, libllama.so) —
// c'est le nom court que l'éditeur de liens cherche au lancement.
target, ok := appEntryName(h.Linkname)
if !ok {
target = filepath.Base(h.Linkname) // lien relatif au sein de /app
}
*links = append(*links, archiveLink{path: p, target: target})
seen[name] = true
count++
}
}
}
// appEntryName ramène « app/libllama.so » à « libllama.so ». Renvoie ok=false
// pour tout ce qui n'est pas un fichier direct de /app : autres dossiers,
// sous-dossiers, et marqueurs de suppression OCI.
func appEntryName(raw string) (string, bool) {
p := strings.TrimPrefix(filepath.ToSlash(strings.TrimSpace(raw)), "./")
rest, ok := strings.CutPrefix(p, "app/")
if !ok || rest == "" || strings.Contains(rest, "/") {
return "", false
}
if strings.HasPrefix(rest, ".wh.") {
return "", false
}
return rest, true
}
// ---------------------------------------------------------------------------
// Vérification et bascule
// ---------------------------------------------------------------------------
// engineOCILatest renvoie le dernier build publié pour la variante (numéro et
// tag figé correspondant). On lit le tag MOUVANT (« server-cuda ») et son
// annotation de version plutôt que de lister les 1000 tags du dépôt : une seule
// requête, et c'est l'amont qui dit ce qu'est « la dernière ».
func engineOCILatest(variant string) (build int, tag string, err error) {
_, version, err := ociResolve(ociEngineRepo, variant)
if err != nil {
return 0, "", err
}
if version == "" {
// Pas d'annotation : on retombe sur le tag mouvant lui-même, qui pointe
// bien sur la dernière image — on ne saura juste pas dire son numéro.
return 0, variant, nil
}
// L'annotation vaut « b10450 » : le tag figé est « server-cuda-b10450 ».
n, _ := strconv.Atoi(strings.TrimPrefix(version, "b"))
return n, variant + "-" + version, nil
}
// engineSmokeTest lance à blanc le moteur fraîchement installé — c'est là que
// se joue le seul vrai risque de ce mécanisme. La mise à jour apporte llama.cpp,
// PAS le runtime CUDA, qui reste celui de l'image : un llama.cpp compilé pour un
// CUDA plus récent ne chargera pas son backend GPU ici.
//
// Deux échecs distincts, et le second est le sournois :
//
// 1. le binaire ne démarre pas du tout (bibliothèque manquante) — bruyant ;
// 2. il démarre mais ne voit plus AUCUNE carte, alors que le moteur courant en
// voyait. Rien ne casse : le modèle se charge, tourne sur le processeur, et
// on ne s'en aperçoit qu'au débit. C'est le pire résultat possible, donc on
// le traite comme un échec de mise à jour.
//
// Ne voir aucune carte n'est un échec que si le moteur courant en voyait :
// sur une machine sans GPU, « aucune carte » est le résultat normal.
func engineSmokeTest(bin, ancien string) error {
devs, err := engineListDevices(bin)
if err != nil {
return err
}
if len(devs) > 0 {
return nil
}
if ancien == "" || ancien == bin {
return nil
}
avant, err := engineListDevices(ancien)
if err != nil || len(avant) == 0 {
return nil // le moteur courant n'en voyait pas plus : rien à reprocher au nouveau
}
return fmt.Errorf("le moteur téléchargé ne voit aucune carte graphique, alors que le moteur actuel en voit %d — "+
"il tournerait sur le processeur, beaucoup plus lentement", len(avant))
}
// engineListDevices lance `llama-server --list-devices` sur un binaire donné.
func engineListDevices(bin string) ([]map[string]any, error) {
cmd := hideCmd(exec.Command(bin, "--list-devices"))
cmd.Env = libraryPathEnv(filepath.Dir(bin))
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("le moteur ne démarre pas ici : %v\n%s", err, tailLines(string(out), 12))
}
return parseListDevices(string(out)), nil
}
// tailLines garde les n dernières lignes non vides d'une sortie.
func tailLines(s string, n int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return strings.Join(lines, "\n")
}
@@ -0,0 +1,53 @@
package loki
// Vérification contre le VRAI registre. Ignoré par défaut (la CI ne doit pas
// dépendre du réseau ni télécharger 170 Mo) :
//
// LOKI_NET_TEST=1 go test ./internal/loki -run TestMoteurReelDepuisGhcr -v
//
// Ce test existe parce que le faux registre ne peut pas garantir une chose : que
// la DISPOSITION de l'image amont est toujours celle qu'on suppose. Si llama.cpp
// range un jour son moteur autrement — sous-dossier, couche gigantesque, autre
// jeu de bibliothèques — c'est ici qu'on le verra, et pas chez l'utilisateur.
import (
"os"
"path/filepath"
"testing"
)
func TestMoteurReelDepuisGhcr(t *testing.T) {
if os.Getenv("LOKI_NET_TEST") == "" {
t.Skip("test réseau — LOKI_NET_TEST=1 pour l'exécuter")
}
if os.Getenv("LOKI_HOME") == "" {
t.Setenv("LOKI_HOME", t.TempDir())
}
build, tag, err := engineOCILatest("server-cuda")
if err != nil {
t.Fatal(err)
}
t.Logf("dernier build publié : %d → %s", build, tag)
if build == 0 {
t.Error("numéro de build absent de l'annotation de l'image")
}
bin, err := engineOCIInstall(tag, func(s string) { t.Log(s) }, func(s string) { t.Log("▶ " + s) })
if err != nil {
t.Fatal(err)
}
dir := filepath.Dir(bin)
// Ce que l'éditeur de liens réclamera au lancement. Un seul manquant et le
// moteur meurt sur « cannot open shared object file ».
for _, n := range []string{"libllama.so", "libggml.so", "libggml-base.so", "libggml-cuda.so", "libmtmd.so"} {
if _, err := os.Stat(filepath.Join(dir, n)); err != nil {
t.Errorf("%s : %v", n, err)
}
}
// Et le binaire doit savoir dire sa version : c'est ce qui pilote la
// comparaison « installé vs publié » de tout le panneau.
if got, commit := engineBuildOf(bin); got != build {
t.Errorf("le moteur installé annonce le build %d (%s), attendu %d", got, commit, build)
}
}
+394
View File
@@ -0,0 +1,394 @@
package loki
// Mise à jour du moteur depuis l'image officielle llama.cpp.
//
// Ce qui se joue ici n'est pas « est-ce qu'on télécharge » mais « est-ce qu'on
// télécharge le MINIMUM et est-ce que ce qu'on pose est LANÇABLE ». Deux pièges
// concrets, tous deux invisibles sans test :
//
// - l'image officielle range llama-server dans une couche et ses .so dans la
// précédente ; s'arrêter au binaire donne un moteur qui meurt sur
// « libllama.so: cannot open shared object file » ;
// - la couche du dessous est le runtime CUDA (2 Go). La descendre par erreur,
// c'est transformer une mise à jour de 170 Mo en un téléchargement de 2 Go.
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// --- Faux registre OCI ------------------------------------------------------
// fauxCouche fabrique une couche tar.gz. Les entrées sont décrites par leur nom
// complet dans l'archive ; une valeur préfixée par « -> » crée un lien.
func fauxCouche(t *testing.T, entrees map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
tw := tar.NewWriter(gz)
for nom, contenu := range entrees {
if cible, ok := strings.CutPrefix(contenu, "-> "); ok {
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeSymlink, Linkname: cible, Mode: 0o777}); err != nil {
t.Fatal(err)
}
continue
}
if err := tw.WriteHeader(&tar.Header{Name: nom, Typeflag: tar.TypeReg, Size: int64(len(contenu)), Mode: 0o755}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write([]byte(contenu)); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := gz.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// fauxRegistre sert un index multi-architecture, le manifeste amd64 et les
// blobs. `tailles` permet d'annoncer une taille différente de la taille réelle
// (c'est ce que lit le garde-fou des grosses couches). Renvoie le compteur des
// blobs réellement téléchargés.
func fauxRegistre(t *testing.T, version string, couches [][]byte, tailles []int64) (servis map[string]int) {
t.Helper()
servis = map[string]int{}
digests := make([]string, len(couches))
blobs := map[string][]byte{}
for i, c := range couches {
sum := sha256.Sum256(c)
digests[i] = "sha256:" + hex.EncodeToString(sum[:])
blobs[digests[i]] = c
}
manifeste := map[string]any{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"layers": func() []map[string]any {
var out []map[string]any
for i := range couches {
taille := int64(len(couches[i]))
if tailles != nil && tailles[i] > 0 {
taille = tailles[i]
}
out = append(out, map[string]any{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": digests[i], "size": taille,
})
}
return out
}(),
}
manBytes, _ := json.Marshal(manifeste)
manSum := sha256.Sum256(manBytes)
manDigest := "sha256:" + hex.EncodeToString(manSum[:])
index := map[string]any{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": []map[string]any{
// L'architecture qui ne nous concerne pas vient EN PREMIER : choisir
// « le premier manifeste » au lieu de filtrer sur la plateforme
// installerait un moteur d'une autre architecture.
{"digest": "sha256:mauvaise", "platform": map[string]string{"os": "linux", "architecture": "ppc64le"}},
{"digest": manDigest, "platform": map[string]string{"os": "linux", "architecture": ociArch()}},
},
"annotations": map[string]string{"org.opencontainers.image.version": version},
}
indexBytes, _ := json.Marshal(index)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasPrefix(r.URL.Path, "/token"):
_, _ = w.Write([]byte(`{"token":"jeton-de-test"}`))
case strings.Contains(r.URL.Path, "/manifests/"):
if r.Header.Get("Authorization") != "Bearer jeton-de-test" {
w.WriteHeader(401)
return
}
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
if ref == manDigest {
_, _ = w.Write(manBytes)
return
}
_, _ = w.Write(indexBytes)
case strings.Contains(r.URL.Path, "/blobs/"):
ref := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
b, ok := blobs[ref]
if !ok {
w.WriteHeader(404)
return
}
servis[ref]++
_, _ = w.Write(b)
default:
w.WriteHeader(404)
}
}))
t.Cleanup(srv.Close)
ancien := ociBase
ociBase = srv.URL
t.Cleanup(func() { ociBase = ancien })
return servis
}
// --- Tests ------------------------------------------------------------------
// Le cas réel : trois couches utiles au-dessus d'un runtime de 2 Go. On doit
// prendre le binaire ET les bibliothèques, résoudre les liens de version, et
// ne JAMAIS descendre jusqu'au runtime.
func TestInstallationMoteurDepuisImage(t *testing.T) {
testHome(t)
couches := [][]byte{
fauxCouche(t, map[string]string{"usr/local/cuda/lib64/libcudart.so": "runtime CUDA"}), // 2 Go annoncés
fauxCouche(t, map[string]string{
"app/libggml-base.so": "-> libggml-base.so.0",
"app/libggml-base.so.0": "-> libggml-base.so.0.20.0",
"app/libggml-base.so.0.20.0": "binaire ELF",
"app/libllama.so": "-> libllama.so.0",
"app/libllama.so.0": "-> libllama.so.0.1.0",
"app/libllama.so.0.1.0": "binaire ELF",
"app/libggml-cuda.so": "binaire ELF",
}),
fauxCouche(t, map[string]string{"app/llama-server": "#!/bin/true\n", "app/llama": "#!/bin/true\n"}),
fauxCouche(t, map[string]string{"etc/passwd": "sans rapport"}),
}
servis := fauxRegistre(t, "b10450", couches, []int64{2 << 30, 0, 0, 0})
bin, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {})
if err != nil {
t.Fatal(err)
}
if veut := filepath.Join(engineDir(), "server-cuda-b10450", "llama-server"); bin != veut {
t.Errorf("binaire installé en %s, attendu %s", bin, veut)
}
// La couche du runtime CUDA ne doit pas avoir été demandée : c'est tout
// l'intérêt (170 Mo au lieu de 2,6 Go).
sum := sha256.Sum256(couches[0])
if n := servis["sha256:"+hex.EncodeToString(sum[:])]; n != 0 {
t.Errorf("la couche du runtime CUDA a été téléchargée %d fois", n)
}
dir := filepath.Dir(bin)
for _, nom := range []string{"llama-server", "llama", "libggml-cuda.so", "libggml-base.so.0.20.0", "libllama.so.0.1.0"} {
if !isFile(filepath.Join(dir, nom)) {
t.Errorf("%s manquant", nom)
}
}
// Les noms courts sont ceux que l'éditeur de liens cherche au lancement :
// sans eux le moteur ne démarre pas, alors que tout « a l'air » installé.
for _, lien := range []string{"libggml-base.so", "libggml-base.so.0", "libllama.so", "libllama.so.0"} {
p := filepath.Join(dir, lien)
if _, err := os.Stat(p); err != nil { // Stat suit le lien : vérifie aussi qu'il ne pend pas
t.Errorf("%s : %v", lien, err)
}
}
// /app à plat, et rien d'autre que /app.
if isDir(filepath.Join(dir, "etc")) || isDir(filepath.Join(dir, "usr")) {
t.Error("des fichiers hors /app ont été extraits")
}
// 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)
}
// Réinstaller la même version ne redemande RIEN au registre : le job de mise
// à jour repasse par là à chaque vérification.
total := func() int {
n := 0
for _, v := range servis {
n += v
}
return n
}
avant := total()
if _, err := engineOCIInstall("server-cuda-b10450", func(string) {}, func(string) {}); err != nil {
t.Fatal(err)
}
if total() != avant {
t.Errorf("une version déjà installée a été re-téléchargée (%d → %d couches)", avant, total())
}
// Et la version se retrouve dans la liste des moteurs installés.
if got := engineInstalled(); len(got) != 1 || got[0] != "server-cuda-b10450" {
t.Errorf("versions installées = %v", got)
}
if engineTagBuild("server-cuda-b10450") != 10450 {
t.Error("numéro de build mal extrait du tag")
}
if !engineOwns(bin) {
t.Error("le binaire installé n'est pas reconnu comme géré par Loki")
}
}
// Une variante dont l'image ne contient pas de llama-server (mauvais tag, image
// « full » sans serveur) doit échouer CLAIREMENT, et ne rien laisser derrière
// elle qui ressemblerait à un moteur installé.
func TestInstallationRefuseUneImageSansMoteur(t *testing.T) {
testHome(t)
fauxRegistre(t, "b10450", [][]byte{
fauxCouche(t, map[string]string{"app/README": "pas de moteur ici"}),
}, nil)
if _, err := engineOCIInstall("server-b1", func(string) {}, func(string) {}); err == nil {
t.Fatal("installation acceptée sans llama-server")
}
if got := engineInstalled(); len(got) != 0 {
t.Errorf("versions installées après échec = %v", got)
}
}
// engineOCILatest lit le numéro de build dans l'annotation de l'image, et en
// déduit le tag figé — c'est lui qu'on installe, pour que « mis à jour vers »
// désigne une version précise et reproductible plutôt qu'un tag mouvant.
func TestDerniereVersionPubliee(t *testing.T) {
fauxRegistre(t, "b10450", [][]byte{fauxCouche(t, map[string]string{"app/llama-server": "x"})}, nil)
build, tag, err := engineOCILatest("server-cuda")
if err != nil {
t.Fatal(err)
}
if build != 10450 || tag != "server-cuda-b10450" {
t.Errorf("build=%d tag=%q", build, tag)
}
}
// La bannière du moteur est la seule source de vérité sur la version qui tourne
// vraiment — le tag de l'image, lui, ment dès qu'on a basculé de moteur.
func TestLectureVersionMoteur(t *testing.T) {
for _, c := range []struct {
nom, sortie string
build int
commit string
}{
// Relevée sur le binaire réel de l'image server-cuda-b10450.
{"forme actuelle", "version: 0.1.0-dev (build 10450, commit ece963f41)\nbuilt with GNU 14.2.0 for Linux x86_64\n", 10450, "ece963f41"},
{"forme précédente", "version: 10450 (ece963f)\nbuilt with cc\n", 10450, "ece963f"},
{"forme ancienne", "build: 4589 (a1b2c3d)\n", 4589, "a1b2c3d"},
{"bannière de démarrage", "ggml_cuda_init: found 1 device\nversion: 9001 (deadbee)\n", 9001, "deadbee"},
{"rien d'exploitable", "usage: llama-server [options]\n", 0, ""},
} {
t.Run(c.nom, func(t *testing.T) {
build, commit := parseEngineBuild(c.sortie)
if build != c.build || commit != c.commit {
t.Errorf("= (%d, %q), attendu (%d, %q)", build, commit, c.build, c.commit)
}
})
}
}
// Le filtre des entrées d'archive : on ne veut QUE les fichiers directs de /app.
// Un sous-dossier ou un marqueur de suppression OCI recopié à plat produirait
// des fichiers parasites à côté du binaire.
func TestFiltreDesEntreesApp(t *testing.T) {
for _, c := range []struct {
entree, veut string
ok bool
}{
{"app/llama-server", "llama-server", true},
{"./app/libllama.so", "libllama.so", true},
{"app/", "", false},
{"app/tools/quantize", "", false},
{"app/.wh.libggml-cuda.so", "", false},
{"usr/local/cuda/lib64/libcudart.so", "", false},
{"apparence/piege", "", false},
} {
got, ok := appEntryName(c.entree)
if ok != c.ok || got != c.veut {
t.Errorf("%q → (%q, %v), attendu (%q, %v)", c.entree, got, ok, c.veut, c.ok)
}
}
}
// Le binaire seul ne fait pas un moteur : c'est la règle d'arrêt de la descente
// des couches.
func TestMoteurCompletExigeLesBibliotheques(t *testing.T) {
vu := func(noms ...string) map[string]bool {
m := map[string]bool{}
for _, n := range noms {
m[n] = true
}
return m
}
if engineSetComplete(vu("llama-server")) {
t.Error("binaire seul accepté comme moteur complet")
}
if engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0")) {
t.Error("moteur sans libllama accepté")
}
if !engineSetComplete(vu("llama-server", "libggml-base.so.0.20.0", "libllama.so.0.1.0")) {
t.Error("moteur complet refusé")
}
}
// La variante se déduit des backends posés à côté du binaire : c'est ce qui
// évite de proposer une image CUDA à une installation Vulkan (et l'inverse).
func TestVarianteDeduiteDesBackends(t *testing.T) {
for _, c := range []struct{ backend, veut string }{
{"libggml-cuda.so", "server-cuda"},
{"libggml-vulkan.so", "server-vulkan"},
{"libggml-sycl.so", "server-intel"},
{"libggml-musa.so", "server-musa"},
{"", "server"},
} {
t.Run(c.veut, func(t *testing.T) {
home := testHome(t)
dir := filepath.Join(home, "faux-moteur")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "llama-server")
if err := os.WriteFile(bin, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if c.backend != "" {
if err := os.WriteFile(filepath.Join(dir, c.backend), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
if err := SetConfigKey("BIN", bin); err != nil {
t.Fatal(err)
}
if got := engineVariant(); got != c.veut {
t.Errorf("variante = %q, attendu %q", got, c.veut)
}
})
}
}
// Le ménage garde la version demandée et efface les autres : sans lui, chaque
// mise à jour laisse ~450 Mo sur le volume de données.
func TestMenageDesAnciennesVersions(t *testing.T) {
testHome(t)
for _, tag := range []string{"server-cuda-b1", "server-cuda-b2", "server-cuda-b3"} {
d := filepath.Join(engineDir(), tag)
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(d, "llama-server"), []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
}
enginePrune(map[string]bool{"server-cuda-b3": true}, nil)
got := engineInstalled()
if fmt.Sprint(got) != "[server-cuda-b3]" {
t.Errorf("après ménage : %v", got)
}
}
+119
View File
@@ -0,0 +1,119 @@
package loki
// backend_external.go — presets « externes » : au lieu de lancer un llama-server
// local, un tel preset route le chat vers une API OpenAI-compatible distante
// (OpenAI, Groq, OpenRouter, un autre Loki, un vLLM sur une autre machine…).
// Un preset externe se reconnaît à sa clé EXTERNAL=1 ; il porte l'URL, le nom du
// modèle et la clé d'accès.
//
// Choix d'archi, repris d'AJEAN : l'externe est un PRESET COMME UN AUTRE, un
// fichier .env dans presetsDir. Toute la mécanique existante s'applique sans
// rien changer — liste, nom d'affichage, empreinte d'activité, bascule, prompt
// système par preset, réglages d'échantillonnage. La différence ne vit qu'à
// DEUX endroits : au moment de l'inférence (resolveChatEndpoint) et au moment
// de la bascule (aucun moteur à redémarrer).
//
// Ce qui ne s'applique pas, et c'est voulu : un preset externe n'a ni MODEL, ni
// NGL, ni moteur. La machine distante décide de tout ça. CTX, en revanche,
// reste utile — c'est lui qui pilote la jauge de contexte et le seuil de
// compaction côté Loki.
import (
"fmt"
"strings"
)
const (
extKeyFlag = "EXTERNAL" // marqueur : "1" = preset externe
extKeyURL = "EXTERNAL_URL" // base ou URL complète des complétions
extKeyModel = "EXTERNAL_MODEL" // nom du modèle envoyé dans le payload
extKeyToken = "EXTERNAL_KEY" // clé API (Bearer), peut être vide
)
// chatEndpoint : où partent les appels /v1/chat/completions de ce tour.
type chatEndpoint struct {
URL string // URL complète des complétions
Model string // nom de modèle envoyé dans le payload
Key string // Bearer ("" = aucun)
External bool // true = API distante (pas le llama-server local)
}
// isExternalConfig indique si une configuration décrit un endpoint externe.
func isExternalConfig(cfg map[string]string) bool {
return strings.TrimSpace(cfg[extKeyFlag]) == "1"
}
// externalActive : le preset actif est-il un endpoint externe ?
func externalActive() bool { return isExternalConfig(ReadConfig()) }
// completionsURL normalise l'URL saisie en une URL de complétions complète.
// Accepte les trois formes qu'on colle en pratique :
//
// - une URL déjà complète (…/chat/completions) — laissée telle quelle ;
// - une base OpenAI (…/v1, …/openai/v1, …/api/v1) — on ajoute /chat/completions ;
// - autre chose — on suppose une racine et on ajoute /v1/chat/completions.
//
// Exiger la forme exacte serait une source d'échecs muets : la page d'un
// fournisseur donne tantôt « https://api.groq.com/openai/v1 », tantôt l'URL
// complète, et se tromper ne produit qu'un 404 illisible.
func completionsURL(raw string) string {
u := strings.TrimRight(strings.TrimSpace(raw), "/")
if u == "" {
return ""
}
if strings.HasSuffix(u, "/chat/completions") {
return u
}
if strings.HasSuffix(u, "/v1") {
return u + "/chat/completions"
}
return u + "/v1/chat/completions"
}
// resolveChatEndpoint décide où envoyer les complétions : l'API externe si le
// preset actif en est un, sinon le llama-server local.
func resolveChatEndpoint() chatEndpoint {
cfg := ReadConfig()
if isExternalConfig(cfg) {
return chatEndpoint{
URL: completionsURL(cfg[extKeyURL]),
Model: strings.TrimSpace(cfg[extKeyModel]),
Key: strings.TrimSpace(cfg[extKeyToken]),
External: true,
}
}
return chatEndpoint{
URL: fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort()),
Model: "loki",
Key: readAPIKey(),
}
}
// auth pose l'en-tête d'autorisation de CET endpoint. Indispensable de le tenir
// ici plutôt que d'appeler authHeader : celui-ci envoie la clé du serveur LOCAL,
// et l'expédier à api.openai.com serait fuiter un secret chez un tiers en même
// temps qu'un 401 garanti.
func (e chatEndpoint) auth(set func(k, v string)) {
if e.Key != "" {
set("Authorization", "Bearer "+e.Key)
}
}
// externalPresetContent construit le corps .env d'un preset externe (hors ligne
// « # NAME= », ajoutée par SavePreset). Une clé vide n'est pas écrite. `ctx` va
// dans la clé CTX standard — elle pilote la jauge de contexte et le seuil de
// compaction, exactement comme pour un preset local.
func externalPresetContent(url, model, key, ctx string) string {
m := map[string]string{
extKeyFlag: "1",
extKeyURL: strings.TrimSpace(url),
extKeyModel: strings.TrimSpace(model),
}
if k := strings.TrimSpace(key); k != "" {
m[extKeyToken] = k
}
if c := strings.TrimSpace(ctx); c != "" {
m["CTX"] = c
}
return formatEnv(m)
}
+196
View File
@@ -0,0 +1,196 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// Les trois formes d'URL qu'on colle en pratique. Exiger la forme exacte serait
// une source d'échecs muets : la page d'un fournisseur donne tantôt la base,
// tantôt l'URL complète, et se tromper ne produit qu'un 404 illisible.
func TestNormalisationDeLURLDeComplétions(t *testing.T) {
cas := map[string]string{
"https://api.openai.com/v1": "https://api.openai.com/v1/chat/completions",
"https://api.groq.com/openai/v1": "https://api.groq.com/openai/v1/chat/completions",
"https://api.openai.com/v1/chat/completions": "https://api.openai.com/v1/chat/completions",
"https://api.openai.com/v1/": "https://api.openai.com/v1/chat/completions",
"http://192.168.1.20:8080": "http://192.168.1.20:8080/v1/chat/completions",
"": "",
}
for in, want := range cas {
if got := completionsURL(in); got != want {
t.Errorf("completionsURL(%q) = %q, attendu %q", in, got, want)
}
}
}
// Un preset externe actif détourne les complétions ; sans lui, on reste sur le
// llama-server local avec la clé LOCALE.
func TestEndpointSuitLePresetActif(t *testing.T) {
testHome(t)
if err := SetConfigKey("PORT", "9999"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.External {
t.Fatal("aucun preset externe : l'endpoint doit rester local")
}
if ep.URL != "http://localhost:9999/v1/chat/completions" {
t.Fatalf("URL locale = %q", ep.URL)
}
if ep.Model != "loki" {
t.Fatalf("modèle local = %q, attendu loki", ep.Model)
}
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.groq.com/openai/v1", "llama-3.3-70b", "sk-secret", "65536"))); err != nil {
t.Fatal(err)
}
ep = resolveChatEndpoint()
if !ep.External {
t.Fatal("EXTERNAL=1 non reconnu")
}
if ep.URL != "https://api.groq.com/openai/v1/chat/completions" {
t.Fatalf("URL externe = %q", ep.URL)
}
if ep.Model != "llama-3.3-70b" || ep.Key != "sk-secret" {
t.Fatalf("modèle/clé externes = %q / %q", ep.Model, ep.Key)
}
// CTX passe par la clé standard : c'est elle qui pilote la jauge de contexte
// et le seuil de compaction, exactement comme pour un preset local.
if got := ReadConfig()["CTX"]; got != "65536" {
t.Fatalf("CTX = %q, attendu 65536", got)
}
}
// La clé du serveur LOCAL ne doit jamais partir chez un tiers : ce serait fuiter
// un secret en même temps qu'un 401 garanti.
func TestLaCleLocaleNePartJamaisChezUnTiers(t *testing.T) {
testHome(t)
if err := WriteConfig(parseEnv(externalPresetContent(
"https://api.openai.com/v1", "gpt-4o-mini", "", ""))); err != nil {
t.Fatal(err)
}
// Une clé d'API locale existe et n'a rien à faire dans la requête sortante.
if err := SetConfigKey("API_KEY", "sk-loki-locale"); err != nil {
t.Fatal(err)
}
ep := resolveChatEndpoint()
if ep.Key != "" {
t.Fatalf("clé envoyée à l'API externe = %q — le preset n'en déclare aucune", ep.Key)
}
got := map[string]string{}
ep.auth(func(k, v string) { got[k] = v })
if len(got) != 0 {
t.Fatalf("en-tête posé sans clé de preset : %v", got)
}
}
// Un preset externe se reconnaît, un preset local ne se prend pas pour un externe.
func TestReconnaissanceDunPresetExterne(t *testing.T) {
if !isExternalConfig(map[string]string{extKeyFlag: "1"}) {
t.Error("EXTERNAL=1 non reconnu")
}
for _, v := range []string{"", "0", "off", "false"} {
if isExternalConfig(map[string]string{extKeyFlag: v}) {
t.Errorf("EXTERNAL=%q pris pour un preset externe", v)
}
}
if isExternalConfig(parseEnv("MODEL=\"m.gguf\"\nCTX=4096\n")) {
t.Error("un preset local est pris pour un externe")
}
}
// La route de test rend un message LISIBLE plutôt que le JSON brut : c'est tout
// l'intérêt de tester avant d'enregistrer.
func TestRouteDeTestRemonteLErreurDeLAPI(t *testing.T) {
testHome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer sk-bonne" {
w.WriteHeader(401)
_, _ = w.Write([]byte(`{"error":{"message":"Incorrect API key provided"}}`))
return
}
sendJSON(w, 200, map[string]any{"choices": []any{}})
}))
t.Cleanup(srv.Close)
post := func(body string) map[string]any {
w := httptest.NewRecorder()
handlePresetExternalTest(w, httptest.NewRequest("POST", "/api/preset/external/test", strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return out
}
out := post(`{"url":"` + srv.URL + `","model":"m","key":"sk-mauvaise","keyTouched":true}`)
if out["ok"] == true {
t.Fatal("clé refusée annoncée comme valide")
}
if msg, _ := out["error"].(string); msg != "Incorrect API key provided" {
t.Fatalf("message d'erreur = %q, attendu celui de l'API et non le JSON brut", msg)
}
if out = post(`{"url":"` + srv.URL + `","model":"m","key":"sk-bonne","keyTouched":true}`); out["ok"] != true {
t.Fatalf("clé valide refusée : %v", out)
}
}
// Rééditer un preset sans toucher au champ clé ne doit PAS l'effacer : la modale
// ne la reçoit jamais en clair, donc un champ vide n'est pas une demande
// d'effacement.
func TestEditionSansToucherALaCleLaConserve(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Groq", externalPresetContent("https://api.groq.com/openai/v1", "llama", "sk-gardee", ""))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Groq","url":"https://api.groq.com/openai/v1","model":"llama-3.3","key":""}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, err := ReadPreset(id)
if err != nil {
t.Fatal(err)
}
cfg := parseEnv(content)
if cfg[extKeyToken] != "sk-gardee" {
t.Fatalf("clé = %q — effacée par une édition qui n'y touchait pas", cfg[extKeyToken])
}
if cfg[extKeyModel] != "llama-3.3" {
t.Fatalf("modèle non mis à jour : %q", cfg[extKeyModel])
}
// Et la clé ne ressort jamais en clair par la route de lecture.
w = httptest.NewRecorder()
handlePresetExternal(w, httptest.NewRequest("GET", "/api/preset/external?id="+id, nil))
if strings.Contains(w.Body.String(), "sk-gardee") {
t.Fatalf("la clé est renvoyée en clair : %s", w.Body.String())
}
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
if out["hasKey"] != true {
t.Fatal("hasKey doit signaler la présence d'une clé")
}
}
// Un preset externe demande explicitement d'écraser la clé par une vide.
func TestEffacementExpliciteDeLaCle(t *testing.T) {
testHome(t)
id, err := SavePreset("", "Local distant", externalPresetContent("http://192.168.1.20:8080", "qwen", "sk-a-jeter", ""))
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handlePresetExternalSave(w, httptest.NewRequest("POST", "/api/preset/external/save",
strings.NewReader(`{"id":"`+id+`","name":"Local distant","url":"http://192.168.1.20:8080","model":"qwen","key":"","keyTouched":true}`)))
if w.Code != 200 {
t.Fatalf("code %d : %s", w.Code, w.Body.String())
}
content, _ := ReadPreset(id)
if k := parseEnv(content)[extKeyToken]; k != "" {
t.Fatalf("clé = %q, attendu effacée (keyTouched avec champ vide)", k)
}
}
+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") != ""})
}
+244 -15
View File
@@ -70,6 +70,79 @@ func reasoningActive(v string) bool {
return true
}
// ReasoningEfforts liste les valeurs proposées par l'UI pour REASONING_EFFORT.
// "" (auto) n'y figure pas : c'est l'absence de réglage.
//
// Aucun gabarit ne les connaît toutes — gpt-oss lit low/medium/high, Qwen3.8 lit
// low/medium/xhigh — et certains REFUSENT celles qu'ils ne connaissent pas (voir
// llm_effort.go, qui traduit alors vers le niveau accepté le plus proche). La
// liste reste donc l'union des niveaux courants plutôt que le plus petit
// dénominateur commun, sinon le maximum d'un modèle est hors d'atteinte.
var ReasoningEfforts = []string{"none", "low", "medium", "high", "xhigh"}
// reasoningEffortValue normalise REASONING_EFFORT en une valeur envoyable telle
// quelle à llama-server, ou "" pour ne rien envoyer du tout.
//
// llama-server accepte `reasoning_effort` dans /v1/chat/completions : la valeur
// `none` coupe le raisonnement, toute autre valeur est simplement passée au
// gabarit jinja du modèle. Un gabarit qui ne la lit pas l'ignore sans erreur ;
// un gabarit qui la VALIDE, lui, peut lever une exception jinja et faire
// répondre 500 à llama-server (Qwen3.8 sur « high »). Le repli n'est pas ici —
// on ne sait pas d'avance ce que le gabarit accepte — mais dans llm_effort.go,
// qui apprend la réponse du refus lui-même et rejoue le tour.
func reasoningEffortValue(v string) string {
switch s := strings.ToLower(strings.TrimSpace(v)); s {
case "", "auto", "default":
return "" // rien envoyé : le gabarit garde son comportement
case "off", "false", "0", "no", "disable", "disabled", "none":
return "none"
case "low", "medium", "high":
return s
default:
// Valeur inconnue (éditée à la main) : on ne l'invente pas, on la laisse
// passer au gabarit, qui l'ignorera s'il ne la connaît pas.
return s
}
}
// reasoningTemplateKwargs construit les `chat_template_kwargs` à joindre à une
// requête de chat pour que « aucune » coupe VRAIMENT le raisonnement.
//
// Pourquoi ce doublon avec `reasoning_effort` : ce champ n'agit que sur les
// gabarits qui le LISENT (gpt-oss et apparentés). Un modèle hybride à la Qwen3
// ne connaît que `enable_thinking` — on lui envoie `reasoning_effort: none`, son
// gabarit l'ignore, et il continue de réfléchir alors que l'interface affiche
// « aucune ». Les llama-server récents traduisent eux-mêmes `none` en
// `enable_thinking=false`, mais pas les binaires plus anciens, qui laissent
// simplement tomber le champ.
//
// D'où la règle : quand le raisonnement doit être coupé, on le dit dans la
// langue que TOUS les gabarits comprennent. `chat_template_kwargs` est passé tel
// quel au gabarit jinja ; une clé qu'il n'utilise pas est ignorée sans erreur.
// Même procédé que la compaction (chat_compact.go), qui coupe déjà la réflexion
// de cette façon.
//
// off = REASONING explicitement à off. Clé absente ≠ off : sans consigne on ne
// touche à rien, le gabarit garde son défaut.
// Renvoie nil quand il n'y a rien à imposer.
func reasoningTemplateKwargs(off bool, effort string) map[string]any {
if off || effort == "none" {
return map[string]any{"enable_thinking": false}
}
if effort != "" {
// Niveau explicite : les vieux binaires ne relaient pas le champ de haut
// niveau au gabarit, celui-ci passe toujours.
return map[string]any{"reasoning_effort": effort}
}
return nil
}
// reasoningExplicitlyOff dit si REASONING interdit le raisonnement. Une clé
// absente ou vide n'est PAS une interdiction : c'est l'absence de consigne.
func reasoningExplicitlyOff(v string) bool {
return strings.TrimSpace(v) != "" && !reasoningActive(v)
}
// detectQuant returns the quantization tag for a preset: an explicit QUANT= line
// (manual override, with or without a leading '#') wins; otherwise it is
// auto-detected from the MODEL= filename. Returns "" when unknown.
@@ -128,38 +201,70 @@ func modelFromPresetContent(content string) string {
// n'effacer que la première laissait des dizaines de Go de fichiers que plus
// rien ne référence, et que rien ne sait plus supprimer depuis l'interface (les
// tranches suivantes n'y apparaissent pas).
func deleteModelFile(name string) error {
//
// Renvoie le nombre d'octets réellement libérés — l'interface le dit, et
// « 41,2 Go libérés » est la seule confirmation qui vaille quelque chose après
// un ménage de disque.
//
// Un modèle EN SERVICE n'est jamais effacé : le fichier reste mappé par
// llama-server (la place n'est donc même pas rendue) et le prochain démarrage
// meurt sur un .gguf absent, sans que rien n'ait prévenu.
func deleteModelFile(name string) (int64, error) {
p, err := resolveModelPath(name)
if err != nil {
return err
return 0, err
}
if _, active := modelUsers(name); active {
return 0, fmt.Errorf("%s", modelInServiceMsg)
}
freed := modelFamilySize(p)
if err := os.Remove(p); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
return 0, fmt.Errorf("modèle introuvable: %s", filepath.Base(p))
}
return err
return 0, err
}
dir := filepath.Dir(p)
for _, n := range shardFamily(filepath.Base(p)) {
_ = os.Remove(filepath.Join(dir, n)) // déjà supprimée ou absente = rien à faire
}
return nil
return freed, nil
}
// handleModelDelete deletes a single .gguf from LOKI_HOME.
// handleModelDelete efface un .gguf d'un des dossiers de modèles déclarés.
//
// Deux garde-fous, et un seul est un refus définitif :
//
// - modèle en service → refus net (deleteModelFile), il faut basculer d'abord ;
// - modèle nommé par un ou plusieurs presets → 409 avec la LISTE des presets
// concernés. L'interface la montre, l'utilisateur tranche, et renvoie la
// même requête avec force:true. Refuser tout court transformerait le
// nettoyage en impasse — c'est exactement le travers qu'on corrige ici.
func handleModelDelete(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Name string `json:"name"`
Force bool `json:"force"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
if err := deleteModelFile(req.Name); err != nil {
users, active := modelUsers(req.Name)
if active {
sendJSON(w, 409, map[string]any{"ok": false, "error": modelInServiceMsg, "active": true})
return
}
if len(users) > 0 && !req.Force {
sendJSON(w, 409, map[string]any{"ok": false, "used": users,
"error": "modèle utilisé par : " + strings.Join(users, ", ")})
return
}
freed, err := deleteModelFile(req.Name)
if err != nil {
sendJSON(w, 400, map[string]any{"ok": false, "error": err.Error()})
return
}
sendJSON(w, 200, map[string]any{"ok": true})
sendJSON(w, 200, map[string]any{"ok": true, "freed": freed, "used": users})
}
// ---- Hugging Face downloads -------------------------------------------------
@@ -309,7 +414,16 @@ func handleModelDownload(w http.ResponseWriter, r *http.Request) {
}
if len(todoURLs) == 0 {
dlMu.Unlock()
sendJSON(w, 409, map[string]any{"ok": false, "error": "le modèle existe déjà: " + name})
// Rien à faire : toutes les tranches sont là. Ce n'était pas une erreur,
// c'était le résultat voulu — le modèle est installé. On répond ok avec la
// valeur à écrire dans MODEL=, l'interface le sélectionne et enchaîne la
// file (le projecteur vision, par exemple) au lieu de s'arrêter en rouge.
value, _, _ := modelPresence(name, req.Dir)
if value == "" {
value = modelPickerValue(filepath.Dir(dests[0]), name)
}
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "exists": true,
"value": value, "parts": 0})
return
}
ctx, cancel := context.WithCancel(context.Background())
@@ -337,6 +451,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))
}
@@ -382,6 +503,21 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
// puis échouer au deux tiers du transfert.
urls, names := shardURLSet(dlURL, name)
name = names[0]
// Déjà sur le disque : on ne sonde même pas Hugging Face. C'est le cas qui
// mettait l'interface dans une impasse — un modèle gardé après la suppression
// de son preset ne pouvait plus être re-choisi qu'en le retéléchargeant, et
// le téléchargement refusait d'écraser. Il n'y a rien à télécharger : il y a
// un modèle à SÉLECTIONNER, et l'UI a besoin de sa valeur MODEL= pour ça.
// Cherché dans TOUS les dossiers déclarés, pas seulement la destination
// choisie : re-télécharger 40 Go sur le SSD parce que le fichier est sur le
// disque externe déjà déclaré n'aide personne — le sélecteur, lui, le trouve
// où qu'il soit.
if value, path, ok := modelPresence(name, ""); ok {
sendJSON(w, 200, map[string]any{"ok": true, "filename": name, "dir": filepath.Dir(path),
"exists": true, "value": value, "size": modelFamilySize(path),
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
var total int64
@@ -393,7 +529,8 @@ func handleModelDownloadProbe(w http.ResponseWriter, r *http.Request) {
}
total += n
}
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total, "free": diskFree(dir), "enough": true, "parts": len(urls)}
out := map[string]any{"ok": true, "filename": name, "dir": dir, "size": total,
"free": diskFree(dir), "free_exact": diskFreeReliable(dir), "enough": true, "parts": len(urls)}
if err := checkDiskSpace(dir, total); err != nil {
out["enough"] = false
out["error"] = err.Error()
@@ -408,8 +545,12 @@ func dlRequest(ctx context.Context, dlURL, rng string) (*http.Request, error) {
if err != nil {
return nil, err
}
// HF gated/private repos may need a token; reuse the same key store if set.
if k := os.Getenv("HF_TOKEN"); k != "" {
// Dépôt gated/privé : le jeton (réglage de l'interface, sinon HF_TOKEN) est
// ce qui débloque le transfert. Il n'accompagne QUE les liens Hugging Face :
// un lien collé vers un autre hébergeur n'a aucune raison de recevoir un
// secret, et le CDN vers lequel HF redirige est signé, donc n'en a pas besoin
// non plus (Go retire l'en-tête au changement de domaine).
if k := hfToken(); k != "" && isHFHost(req.URL.Host) {
req.Header.Set("Authorization", "Bearer "+k)
}
req.Header.Set("User-Agent", "loki/"+Version)
@@ -433,6 +574,93 @@ 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":
// Nommer le fichier : sur un modèle en fragments, c'est souvent UN
// fragment qui manque — certains dépôts n'en publient que six sur
// sept et livrent la table PLE à part, à lier soi-même — et sans
// le nom, on cherche une révision réécrite qui n'existe pas.
return fmt.Errorf("fichier %s absent du dépôt (HTTP 404) — dépôt réorganisé depuis la copie du lien, ou fragment jamais publié (table PLE livrée à part ?)", dlFileLabel(dlURL))
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)
}
// dlFileLabel : « nom.gguf » entre guillemets pour un message, ou vide si le
// lien n'a pas de nom de fichier lisible.
func dlFileLabel(dlURL string) string {
u, err := url.Parse(dlURL)
if err != nil {
return ""
}
name := path.Base(u.Path)
if name == "" || name == "/" || name == "." {
return ""
}
return "« " + name + " »"
}
// dlAccessReason nomme la raison du refus. repo vide = source hors Hugging Face
// (lien direct vers un autre hébergeur) : on ne parle alors pas de conditions à
// accepter, qui n'existent que là-bas.
func dlAccessReason(code, repo string) string {
switch code {
case "GatedRepo":
if repo == "" {
return "dépôt à accès restreint : ses conditions doivent être acceptées sur huggingface.co"
}
return "dépôt à accès restreint : accepte ses conditions sur huggingface.co/" + repo
case "RepoNotFound":
if repo == "" {
return "dépôt privé ou inexistant"
}
return "dépôt privé ou inexistant : " + repo
}
if repo != "" {
return "accès refusé par Hugging Face sur " + repo
}
return "accès refusé par la source"
}
// dlTokenHint complète la raison par l'état du jeton. Deux situations opposées
// se cachent derrière le même 401 : pas de jeton du tout, ou un jeton qui n'a
// pas accès à CE dépôt — et le geste à faire n'est pas le même.
func dlTokenHint() string {
if !hfTokenSet() {
return " — puis renseigne le jeton Hugging Face (réglage « Jeton Hugging Face », ou variable d'environnement HF_TOKEN)"
}
return " — le jeton Hugging Face enregistré n'y donne pas accès (expiré, ou conditions non acceptées avec ce compte)"
}
// dlProbe asks the server for the first byte to learn the total size and
// whether ranges are supported (206 + Content-Range).
func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err error) {
@@ -456,7 +684,7 @@ func dlProbe(ctx context.Context, dlURL string) (total int64, ranged bool, err e
// Server ignored the Range: single stream, ContentLength is the size.
return resp.ContentLength, false, nil
default:
return 0, false, fmt.Errorf("HTTP %d depuis la source", resp.StatusCode)
return 0, false, dlSourceError(resp, dlURL)
}
}
@@ -682,8 +910,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.
+96
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
@@ -135,3 +136,98 @@ 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{"« m.gguf » absent du dépôt", "404"},
},
{
// Dépôt qui publie six fragments sur sept (table PLE livrée à part) :
// le message doit NOMMER le fragment manquant, sinon on cherche une
// révision réécrite qui n'existe pas.
name: "fragment absent", resp: resp(404, "EntryNotFound"),
url: "https://huggingface.co/x/y-GGUF/resolve/main/Q2_K/y-Q2_K-00007-of-00007.gguf",
want: []string{"« y-Q2_K-00007-of-00007.gguf » absent du dépôt", "fragment jamais publié"},
},
{
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)
}
}
+164
View File
@@ -0,0 +1,164 @@
package loki
// backend_models_refs.go — QUI référence un .gguf, et peut-on l'effacer ?
//
// Vécu qui a produit ce fichier : un modèle téléchargé depuis l'éditeur d'un
// preset, le preset supprimé (sans cocher « supprimer aussi le .gguf »), et
// 40 Go de fichier plus rien pour les reprendre — la route /api/models/delete
// existait mais AUCUN bouton ne l'appelait. Supprimer un modèle depuis
// l'interface demande deux choses : savoir qui s'en sert (le moteur en service,
// d'autres presets), et le dire AVANT d'effacer.
import (
"os"
"path/filepath"
"strings"
)
// modelRefIndex — l'index « ce fichier est référencé par… », construit en une
// seule passe. Demander « qui utilise ce modèle ? » fichier par fichier
// relisait tous les presets à chaque ligne de la liste des modèles.
//
// Les clés sont des chemins normalisés (normDir) : un preset peut nommer son
// modèle en chemin absolu et un autre en simple nom de fichier, c'est le même
// fichier sur le disque.
type modelRefIndex struct {
active map[string]bool // référencé par config.env — donc par le moteur qui tourne
presets map[string][]string // chemin -> noms d'affichage des presets
}
// modelRefKey normalise une valeur MODEL=/MMPROJ= en clé d'index, ou "" si elle
// ne désigne pas un .gguf. resolveModelPath accepte le nom simple comme le
// chemin absolu et les résout dans les dossiers déclarés — c'est exactement la
// règle qu'appliquera le moteur.
func modelRefKey(v string) string {
v = strings.TrimSpace(v)
if v == "" {
return ""
}
p, err := resolveServeModelPath(v)
if err != nil {
// Introuvable : on garde quand même une clé comparable, sinon deux
// presets qui nomment le même fichier absent paraîtraient distincts.
p = filepath.Join(modelsDir(), baseName(v))
}
return normDir(p)
}
// modelKeysOf liste les .gguf référencés par une configuration : le modèle, le
// projecteur vision, et un --mmproj posé à la main dans EXTRA_ARGS (les presets
// d'avant la clé MMPROJ l'écrivaient là).
func modelKeysOf(env map[string]string) []string {
vals := []string{env["MODEL"], env["MMPROJ"]}
args := splitArgs(env["EXTRA_ARGS"])
for i, a := range args {
if a == "--mmproj" && i+1 < len(args) {
vals = append(vals, args[i+1])
}
}
out := []string{}
for _, v := range vals {
if k := modelRefKey(v); k != "" {
out = append(out, k)
}
}
return out
}
// buildModelRefIndex lit la configuration active et tous les presets.
func buildModelRefIndex() modelRefIndex {
ix := modelRefIndex{active: map[string]bool{}, presets: map[string][]string{}}
for _, k := range modelKeysOf(ReadConfig()) {
ix.active[k] = true
}
list, err := ListPresets()
if err != nil {
return ix
}
for _, p := range list {
content, err := ReadPreset(p.ID)
if err != nil {
continue
}
seen := map[string]bool{}
for _, k := range modelKeysOf(parseEnv(content)) {
if seen[k] {
continue // MODEL et MMPROJ identiques : un seul « utilisé par »
}
seen[k] = true
ix.presets[k] = append(ix.presets[k], p.Name)
}
}
return ix
}
// lookup renvoie les presets qui référencent path et s'il est en service.
func (ix modelRefIndex) lookup(path string) (presets []string, active bool) {
k := normDir(path)
return ix.presets[k], ix.active[k]
}
// modelUsers répond pour UN modèle (nom de fichier ou chemin).
func modelUsers(name string) (presets []string, active bool) {
k := modelRefKey(name)
if k == "" {
return nil, false
}
return buildModelRefIndex().lookup(k)
}
// modelInServiceErr est le refus opposé à la suppression d'un modèle que le
// moteur a ouvert. Effacer le .gguf sous un llama-server qui tourne ne libère
// même pas la place (le fichier reste mappé) et rend le prochain démarrage
// impossible sans rien dire.
const modelInServiceMsg = "modèle en service — bascule sur un autre preset avant de le supprimer"
// modelFamilySize additionne ce que la suppression libérera réellement
// (toutes les tranches présentes).
func modelFamilySize(p string) int64 {
return shardFamilySize(filepath.Dir(p), filepath.Base(p))
}
// modelPresence dit si un modèle est DÉJÀ sur le disque, et sous quelle valeur
// l'écrire dans MODEL=. dir restreint la recherche à ce dossier (celui visé par
// un téléchargement) ; vide = tous les dossiers déclarés.
//
// C'est ce qui transforme « le modèle existe déjà » — un cul-de-sac rouge au
// bout d'un clic sur un quant — en « déjà là, je le sélectionne ».
func modelPresence(name, dir string) (value, path string, ok bool) {
base := baseName(strings.TrimSpace(name))
if base == "" || !strings.HasSuffix(strings.ToLower(base), ".gguf") {
return "", "", false
}
dirs := modelDirs()
if strings.TrimSpace(dir) != "" {
if d, err := resolveDownloadDir(dir); err == nil {
dirs = []string{d}
}
}
for _, d := range dirs {
p := filepath.Join(d, base)
if st, err := os.Stat(p); err != nil || st.IsDir() {
continue
}
// Famille incomplète = modèle inutilisable : on ne prétend pas qu'il est
// là, l'appelant doit pouvoir relancer le téléchargement des tranches
// manquantes (handleModelDownload les reprend une par une).
if len(shardFamilyMissing(d, base)) > 0 {
continue
}
return modelPickerValue(d, base), p, true
}
return "", "", false
}
// modelPickerValue est la valeur à écrire dans MODEL= pour un fichier donné :
// le simple nom quand il vit dans le dossier de téléchargement de Loki (le
// preset reste alors portable d'une machine à l'autre), le chemin complet
// ailleurs. Même règle que la liste /api/models.
func modelPickerValue(dir, base string) string {
if normDir(dir) == normDir(modelsDir()) {
return base
}
return filepath.Join(dir, base)
}
+246
View File
@@ -0,0 +1,246 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
// writeModel pose un .gguf factice dans dir et renvoie son chemin.
func writeModel(t *testing.T, dir, name string, size int) string {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
p := filepath.Join(dir, name)
if err := os.WriteFile(p, make([]byte, size), 0o644); err != nil {
t.Fatal(err)
}
return p
}
// Le scénario complet du rapport : un modèle téléchargé depuis un preset, le
// preset supprimé sans son .gguf, et plus rien pour reprendre le fichier.
// Après ce correctif il doit être A) retrouvé par le sélecteur, B) effaçable.
func TestModeleOrphelinResteUtilisableEtEffaçable(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "modele.gguf", 1024)
// A) Présent sur le disque, sous la valeur qu'on écrira dans MODEL=.
value, path, ok := modelPresence("modele.gguf", "")
if !ok {
t.Fatal("modèle présent non détecté — le téléchargement resterait un cul-de-sac")
}
if value != "modele.gguf" {
t.Fatalf("valeur MODEL= = %q, attendu le simple nom de fichier", value)
}
if path != filepath.Join(modelsDir(), "modele.gguf") {
t.Fatalf("chemin = %q", path)
}
// B) Plus personne ne le référence : il s'efface sans force.
users, active := modelUsers("modele.gguf")
if active || len(users) != 0 {
t.Fatalf("modèle orphelin annoncé utilisé : actif=%v presets=%v", active, users)
}
freed, err := deleteModelFile("modele.gguf")
if err != nil {
t.Fatalf("suppression refusée : %v", err)
}
if freed != 1024 {
t.Fatalf("octets libérés = %d, attendu 1024", freed)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatal("le fichier est toujours là")
}
}
// Le modèle que le moteur a ouvert ne s'efface pas : la place ne serait même pas
// rendue, et le prochain démarrage mourrait sur un .gguf absent.
func TestSuppressionRefuseeSurLeModeleEnService(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "servi.gguf", 8)
if err := WriteConfig(map[string]string{"MODEL": "servi.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("servi.gguf"); !active {
t.Fatal("modèle de la configuration active non reconnu")
}
if _, err := deleteModelFile("servi.gguf"); err == nil {
t.Fatal("le modèle en service a été supprimé")
}
if _, err := os.Stat(filepath.Join(modelsDir(), "servi.gguf")); err != nil {
t.Fatal("fichier supprimé malgré le refus")
}
}
// Un modèle nommé par des presets n'est pas bloqué : il est SIGNALÉ, et
// force:true tranche. Refuser tout court recréerait l'impasse qu'on corrige.
func TestSuppressionSignaleLesPresetsPuisObeitAForce(t *testing.T) {
testHome(t)
p := writeModel(t, modelsDir(), "partage.gguf", 16)
if _, err := SavePreset("", "Rapide", "MODEL=\"partage.gguf\"\nCTX=4096\n"); err != nil {
t.Fatal(err)
}
if _, err := SavePreset("", "Long", "MODEL=\"partage.gguf\"\nCTX=131072\n"); err != nil {
t.Fatal(err)
}
users, active := modelUsers("partage.gguf")
if active {
t.Fatal("aucune configuration active ne devrait référencer ce modèle")
}
if len(users) != 2 {
t.Fatalf("presets référents = %v, attendu les deux", users)
}
post := func(body string) (int, map[string]any) {
r := httptest.NewRequest("POST", "/api/models/delete", strings.NewReader(body))
w := httptest.NewRecorder()
handleModelDelete(w, r)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
code, out := post(`{"name":"partage.gguf"}`)
if code != 409 {
t.Fatalf("code = %d, attendu 409 (presets référents)", code)
}
if len(out["used"].([]any)) != 2 {
t.Fatalf("la réponse doit NOMMER les presets concernés : %v", out)
}
if _, err := os.Stat(p); err != nil {
t.Fatal("fichier supprimé alors que la confirmation n'a pas eu lieu")
}
if code, out = post(`{"name":"partage.gguf","force":true}`); code != 200 {
t.Fatalf("code = %d avec force:true — %v", code, out)
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatal("force:true n'a pas supprimé le fichier")
}
}
// MMPROJ et un --mmproj resté dans EXTRA_ARGS comptent aussi : effacer le
// projecteur du modèle en service casse la vision sans rien dire.
func TestProjecteurCompteCommeReference(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "mmproj-F16.gguf", 4)
writeModel(t, modelsDir(), "vieux-mmproj.gguf", 4)
if err := WriteConfig(map[string]string{"MODEL": "m.gguf", "MMPROJ": "mmproj-F16.gguf"}); err != nil {
t.Fatal(err)
}
if _, active := modelUsers("mmproj-F16.gguf"); !active {
t.Fatal("MMPROJ= non compté comme référence")
}
if _, err := SavePreset("", "Vision", `MODEL="m.gguf"`+"\n"+`EXTRA_ARGS="--mmproj vieux-mmproj.gguf --flash-attn"`+"\n"); err != nil {
t.Fatal(err)
}
users, _ := modelUsers("vieux-mmproj.gguf")
if len(users) != 1 || users[0] != "Vision" {
t.Fatalf("--mmproj d'EXTRA_ARGS non compté : %v", users)
}
}
// Une famille de tranches incomplète n'est pas « déjà là » : il reste à
// télécharger, et prétendre le contraire donnerait un moteur qui meurt sur un
// tenseur introuvable.
func TestFamilleIncompleteNestPasPresente(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "gros-00001-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); ok {
t.Fatal("famille incomplète annoncée présente")
}
writeModel(t, modelsDir(), "gros-00002-of-00002.gguf", 4)
if _, _, ok := modelPresence("gros-00001-of-00002.gguf", ""); !ok {
t.Fatal("famille complète non détectée")
}
}
// Un modèle rangé dans un dossier déclaré ailleurs (disque externe) est trouvé
// lui aussi, et sous son chemin complet — un simple nom ne le désignerait pas.
func TestPresenceDansUnDossierDeclare(t *testing.T) {
home := testHome(t)
ext := filepath.Join(home, "externe")
writeModel(t, ext, "ailleurs.gguf", 32)
if _, _, ok := modelPresence("ailleurs.gguf", ""); ok {
t.Fatal("dossier non déclaré : le modèle ne doit pas être visible")
}
if err := saveExtraModelDirs([]string{ext}); err != nil {
t.Fatal(err)
}
value, _, ok := modelPresence("ailleurs.gguf", "")
if !ok {
t.Fatal("modèle d'un dossier déclaré non détecté")
}
if value != filepath.Join(ext, "ailleurs.gguf") {
t.Fatalf("valeur MODEL= = %q, attendu le chemin complet", value)
}
}
// /api/models doit marquer « dossier loki » le dossier de téléchargement et en
// donner le SIMPLE NOM : c'est ce que resolveModelPath retrouve, et c'est ce qui
// permet au sélecteur de reconnaître un MODEL=modele.gguf écrit à la main.
func TestListeModelesNommeLeDossierDeLoki(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "local.gguf", 64)
if err := WriteConfig(map[string]string{"MODEL": "local.gguf"}); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest("GET", "/api/models", nil))
var out []map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatal(err)
}
if len(out) != 1 {
t.Fatalf("liste = %v", out)
}
if out[0]["home"] != true {
t.Fatal("le dossier de téléchargement n'est pas marqué « dossier loki »")
}
if out[0]["value"] != "local.gguf" {
t.Fatalf("value = %v, attendu le simple nom de fichier", out[0]["value"])
}
if out[0]["active"] != true {
t.Fatal("le modèle en service doit être signalé comme tel")
}
}
// Télécharger un modèle DÉJÀ sur le disque n'est pas une erreur : c'est le
// résultat voulu. Les deux routes doivent répondre « ok, exists » avec la valeur
// à écrire dans MODEL= — sinon l'interface s'arrête en rouge sur « le modèle
// existe déjà » et il devient impossible de refaire un preset dessus.
func TestTelechargerUnModelePresentLeSelectionne(t *testing.T) {
testHome(t)
writeModel(t, modelsDir(), "deja.gguf", 2048)
const url = "https://huggingface.co/auteur/depot/resolve/main/deja.gguf"
call := func(h func(w http.ResponseWriter, r *http.Request), path string) (int, map[string]any) {
body := `{"url":"` + url + `","dir":""}`
w := httptest.NewRecorder()
h(w, httptest.NewRequest("POST", path, strings.NewReader(body)))
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
// La sonde ne doit même pas sortir sur le réseau : elle répond depuis le disque.
code, out := call(handleModelDownloadProbe, "/api/models/download/probe")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("sonde : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("sonde : value = %v, attendu le nom à mettre dans MODEL=", out["value"])
}
code, out = call(handleModelDownload, "/api/models/download")
if code != 200 || out["ok"] != true || out["exists"] != true {
t.Fatalf("téléchargement : code=%d out=%v", code, out)
}
if out["value"] != "deja.gguf" {
t.Fatalf("téléchargement : value = %v", out["value"])
}
}
+5 -1
View File
@@ -106,9 +106,13 @@ func TestDeleteModelFileEmporteLesTranches(t *testing.T) {
t.Fatal(err)
}
}
if err := deleteModelFile("m-00001-of-00002.gguf"); err != nil {
freed, err := deleteModelFile("m-00001-of-00002.gguf")
if err != nil {
t.Fatal(err)
}
if freed != 2 {
t.Fatalf("octets libérés = %d, attendu 2 (les deux tranches d'un octet)", freed)
}
for _, n := range names[:2] {
if _, err := os.Stat(filepath.Join(dir, n)); !os.IsNotExist(err) {
t.Fatalf("tranche %s non supprimée", n)
+11 -1
View File
@@ -440,8 +440,18 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) {
// downloadWithProgress télécharge url vers dest en journalisant la progression
// par tranches de ~25 Mo.
func downloadWithProgress(url, dest string, total int64, logf func(string)) error {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return err
}
return downloadRequest(req, dest, total, logf)
}
// downloadRequest est la même chose pour une requête déjà construite — un
// registre OCI exige un en-tête d'autorisation, même en lecture anonyme.
func downloadRequest(req *http.Request, dest string, total int64, logf func(string)) error {
client := &http.Client{Timeout: 0}
resp, err := client.Get(url)
resp, err := client.Do(req)
if err != nil {
return err
}
+91 -3
View File
@@ -225,9 +225,38 @@ func applyPresetFile(target string) error {
}
next[k] = v
}
// Le moteur suit la machine, pas le preset (voir presetImposesEngine).
if cur["BIN"] != "" && !presetImposesEngine(next["BIN"]) {
next["BIN"] = cur["BIN"]
}
return WriteConfig(next)
}
// presetImposesEngine : le BIN d'un preset a-t-il son mot à dire ?
//
// Le moteur est un réglage de MACHINE. Un preset créé avant une mise à jour du
// moteur (Réglages → Moteur, qui installe sous $LOKI_HOME/engine/) embarque
// encore le chemin de l'image, /app/llama-server ; l'appliquer — changer de
// modèle, une tâche planifiée — ramenait silencieusement ce vieux moteur, et
// un modèle qui chargeait la veille mourait sur « unknown model architecture »
// sans qu'aucun réglage visible ait bougé. Le moteur de l'image, un moteur
// téléchargé par Loki ou un précompilé ne sont donc jamais imposés par un
// preset : le courant reste.
//
// Un backend PERSONNALISÉ (compilé pour un modèle précis, backend_llamacpp.go :
// « un backend custom se choisit par modèle »), lui, est un vrai choix par
// modèle : le preset gagne.
func presetImposesEngine(p string) bool {
p = strings.TrimSpace(p)
if p == "" || engineOwns(p) || prebuiltOwns(p) {
return false
}
if img := strings.TrimSpace(os.Getenv("LOKI_ENGINE_BIN")); img != "" && samePath(p, img) {
return false
}
return true
}
// SwitchToPreset installe le preset et redémarre le service. Les réglages
// « appareil » (preservedKeys) sont conservés à travers la bascule.
func SwitchToPreset(target string) error {
@@ -235,6 +264,17 @@ func SwitchToPreset(target string) error {
return err
}
fmt.Printf("%s configuration <- %s\n", green("[ok]"), filepath.Base(target))
// Preset externe (backend_external.go) : rien à redémarrer, il n'a pas de
// MODEL et llama-server partirait en boucle de crash. On arrête au contraire
// le moteur encore en vie — le chat part vers l'API distante, garder le
// modèle en VRAM ne sert plus à rien.
if isExternalConfig(ReadConfig()) {
fmt.Println(dim("[info] preset externe — arrêt du moteur local"))
if !serviceIsActive() {
return nil
}
return serviceAction("stop")
}
fmt.Println(dim("[info] redémarrage du service..."))
return serviceAction("restart")
}
@@ -287,6 +327,10 @@ func cmdSwitch(args []string) error {
// mélangeaient donc aux options cochées pour le nouveau, et il fallait penser à
// tout nettoyer à la main. On repart d'une base vide : les valeurs non
// renseignées sont les défauts documentés (CTX 32768, NGL 999, BATCH 2048…).
//
// BIN n'est repris que s'il désigne un backend personnalisé (presetImposesEngine) :
// figer /app/llama-server dans chaque preset, c'est ce qui faisait perdre le
// moteur mis à jour à la première bascule.
var newPresetSeedKeys = []string{"BIN", "HOST", "PORT"}
// newPresetSeed renvoie la configuration de départ d'un preset créé depuis l'UI.
@@ -294,9 +338,11 @@ func newPresetSeed() map[string]string {
cur := ReadConfig()
seed := map[string]string{}
for _, k := range newPresetSeedKeys {
if v := cur[k]; v != "" {
seed[k] = v
v := cur[k]
if v == "" || (k == "BIN" && !presetImposesEngine(v)) {
continue
}
seed[k] = v
}
return seed
}
@@ -331,6 +377,41 @@ func SavePreset(id, name, content string) (string, error) {
return id, os.WriteFile(p, []byte(content), 0o644)
}
// SavePresetApplying enregistre un preset et, si c'était celui EN SERVICE,
// installe aussitôt la nouvelle version comme configuration active (mêmes
// règles qu'une bascule : applyPresetFile). Renvoie applied=true dans ce cas —
// l'appelant redémarre alors le moteur.
//
// Vécu : CTX passé à 100000 dans l'éditeur, « enregistré »… et la carte du
// chat qui affiche toujours 32768. Seul le fichier du preset avait changé ;
// config.env gardait l'ancienne valeur, le moteur tournait avec, et plus aucun
// preset n'était détecté actif (empreinte différente). Éditer le preset en
// service, c'est vouloir ces réglages-là tout de suite. Un preset inactif ou
// nouveau, lui, n'est qu'un fichier : rien ne bouge.
//
// « Actif » se décide AVANT l'écriture : c'est l'ancienne version du fichier qui
// porte l'empreinte de la configuration courante.
func SavePresetApplying(id, name, content string) (newID string, applied bool, err error) {
wasActive := false
if id != "" {
if old, rerr := ReadPreset(id); rerr == nil {
wasActive = presetFingerprint([]byte(old)) == configFingerprint(ReadConfig())
}
}
newID, err = SavePreset(id, name, content)
if err != nil || !wasActive {
return newID, false, err
}
p, err := safePresetPath(newID)
if err != nil {
return newID, false, err
}
if err := applyPresetFile(p); err != nil {
return newID, false, err
}
return newID, true, nil
}
// DeletePreset removes a preset by id; refuses if it is the active config.
func DeletePreset(id string) error {
p, err := safePresetPath(id)
@@ -344,7 +425,14 @@ func DeletePreset(id string) error {
if presetFingerprint(target) == configFingerprint(ReadConfig()) {
return fmt.Errorf("preset actif, switche d'abord")
}
return os.Remove(p)
if err := os.Remove(p); err != nil {
return err
}
// Le prompt système du preset vit en base, pas dans son .env (web_sysprompt.go) :
// sans ce ménage, il resterait orphelin et ressusciterait sur un preset recréé
// sous le même id.
_ = putStr(bkState, sysPromptKeyPrefix+id, "")
return nil
}
// ReadPreset returns the contents of a preset by id (filename).
+109
View File
@@ -68,6 +68,64 @@ func TestSwitchToPresetGardeLesReglagesMachine(t *testing.T) {
}
}
// Le moteur suit la machine. Vécu : moteur mis à jour depuis l'interface
// (BIN → /data/engine/server-cuda-b10680), Qwen3.8-Flash-Next charge ; on
// change de modèle — le preset, créé avant, porte BIN=/app/llama-server — et
// le vieux moteur revient : « unknown model architecture: 'qwen4exp' ». Seul
// un backend personnalisé, choix par modèle, a le droit d'imposer son BIN.
func TestSwitchToPresetGardeLeMoteur(t *testing.T) {
home := testHome(t)
t.Setenv("LOKI_ENGINE_BIN", "/app/llama-server")
write := func(p, body string) {
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
engine := filepath.Join(home, "engine", "server-cuda-b10680", "llama-server")
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
// Preset d'avant la mise à jour : il porte le moteur de l'image.
image := filepath.Join(home, "image.env")
write(image, "MODEL=nouveau.gguf\nBIN=/app/llama-server\n")
if err := applyPresetFile(image); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["BIN"]; got != engine {
t.Errorf("BIN = %q après bascule, attendu le moteur mis à jour %q", got, engine)
}
// Preset muet sur le moteur : idem.
muet := filepath.Join(home, "muet.env")
write(muet, "MODEL=autre.gguf\n")
if err := applyPresetFile(muet); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["BIN"]; got != engine {
t.Errorf("BIN = %q après preset muet, attendu %q conservé", got, engine)
}
// Backend personnalisé : le preset gagne.
custom := filepath.Join(home, "custom.env")
write(custom, "MODEL=exotique.gguf\nBIN=/opt/mon-fork/llama-server\n")
if err := applyPresetFile(custom); err != nil {
t.Fatal(err)
}
if got := ReadConfig()["BIN"]; got != "/opt/mon-fork/llama-server" {
t.Errorf("BIN = %q, attendu le backend personnalisé du preset", got)
}
// Un nouveau preset ne fige pas ce backend-là… si, justement : c'est un
// choix par modèle. Mais il ne fige ni l'image ni un moteur téléchargé.
if seed := newPresetSeed(); seed["BIN"] != "/opt/mon-fork/llama-server" {
t.Errorf("seed BIN = %q, attendu le backend personnalisé", seed["BIN"])
}
setConfig(t, "MODEL=ancien.gguf\nBIN="+engine+"\n")
if seed := newPresetSeed(); seed["BIN"] != "" {
t.Errorf("seed BIN = %q, un moteur téléchargé ne doit pas être figé dans un preset", seed["BIN"])
}
setConfig(t, "MODEL=ancien.gguf\nBIN=/app/llama-server\n")
if seed := newPresetSeed(); seed["BIN"] != "" {
t.Errorf("seed BIN = %q, le moteur de l'image ne doit pas être figé dans un preset", seed["BIN"])
}
}
// Un preset qui définit LUI-MÊME la sélection de cartes doit gagner sur celle
// de la machine : « FABLE 2 GPU » impose CUDA_VISIBLE_DEVICES=1,0 pour que son
// --tensor-split ait deux cartes. Écraser ça par une sélection mono-GPU faisait
@@ -102,3 +160,54 @@ func TestSwitchToPresetLaisseLePresetImposerSesGPU(t *testing.T) {
t.Errorf("WEB_ENGINE = %q, attendu go (réglage machine, jamais dicté par un preset)", cfg["WEB_ENGINE"])
}
}
// Modifier le preset ACTIF doit changer la configuration courante. Vécu : CTX
// passé à 100000 dans l'éditeur, « enregistré » — et la carte du chat qui
// affiche toujours 32768, parce que seul le fichier du preset avait bougé ;
// config.env, lui, gardait l'ancienne valeur et plus aucun preset n'était
// détecté actif. Un preset qui n'est pas celui en service, ou un preset
// nouveau, ne touche pas à la configuration.
func TestModifierLePresetActifLApplique(t *testing.T) {
testHome(t)
dir := presetsDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(dir, name+".env"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=32768\n")
write("autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=4096\n")
setConfig(t, "MODEL=foo.gguf\nCTX=32768\nMEM_MODE=always\n")
// Le preset en service : la config suit, les réglages machine restent.
id, applied, err := SavePresetApplying("actif", "Actif", "# NAME=Actif\nMODEL=foo.gguf\nCTX=100000\n")
if err != nil {
t.Fatal(err)
}
if id != "actif" || !applied {
t.Fatalf("id=%q applied=%v, attendu actif/true", id, applied)
}
cfg := ReadConfig()
if cfg["CTX"] != "100000" || cfg["MEM_MODE"] != "always" {
t.Fatalf("configuration non suivie : %v", cfg)
}
// Un autre preset : le fichier change, la config non.
if _, applied, err = SavePresetApplying("autre", "Autre", "# NAME=Autre\nMODEL=bar.gguf\nCTX=8192\n"); err != nil {
t.Fatal(err)
}
if applied || ReadConfig()["CTX"] != "100000" {
t.Fatalf("un preset inactif a touché la configuration (applied=%v, CTX=%q)", applied, ReadConfig()["CTX"])
}
// Un preset nouveau : jamais appliqué à la volée.
if _, applied, err = SavePresetApplying("", "Neuf", "MODEL=baz.gguf\n"); err != nil {
t.Fatal(err)
}
if applied || ReadConfig()["MODEL"] != "foo.gguf" {
t.Fatalf("un preset nouveau a touché la configuration (applied=%v)", applied)
}
}
+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)
}
}
+199 -18
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)
@@ -176,14 +359,12 @@ func cmdServe(args []string) error {
// exige alors l'en-tête "Authorization: Bearer <clé>". La clé est lue depuis
// $LOKI_HOME/.api_key en priorité (elle survit ainsi aux changements de preset
// qui réécrivent config.env), avec config.env comme repli rétro-compatible.
if k := readAPIKey(); k != "" {
llmArgs = append(llmArgs, "--api-key", k)
} else if k := cfg["API_KEY"]; k != "" {
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)
}
})
}
}
+231 -25
View File
@@ -34,7 +34,10 @@ const (
// Budget de la queue : fraction de la fenêtre gardée intacte (tours récents).
// Plus la queue est petite, plus on compacte de torse d'un coup → le contexte
// retombe bas et met longtemps à re-déclencher (au lieu de compacter souvent).
compactTailFrac = 0.25
// Descendu de 0.25 à 0.20 avec l'archivage recall : compacter plus large ne
// coûte plus de perte (les gros blocs restent rappelables), donc autant faire
// des compactions franches et rares plutôt que fréquentes et molles.
compactTailFrac = 0.20
// Un résultat d'outil du torse plus long que ça est remplacé par un marqueur
// dans le torse DÉGRAISSÉ (repli si le résumé échoue).
compactToolPruneLen = 200
@@ -77,11 +80,54 @@ func ctxWindow() int {
return 32768
}
// compactSummaryBudget renvoie le budget en tokens du résumé, adapté à la
// fenêtre. Assez pour PORTER LE FIL (la demande, les faits déjà trouvés, les
// décisions, l'état d'avancement) sans jamais le perdre — c'était la plainte de
// fond : à 700 tokens fixes, tout le passé d'une longue conversation était
// écrasé en un demi-paragraphe et le modèle « perdait le fil ». Le résumé reste
// de toute façon minuscule devant le torse compacté (souvent des dizaines de
// milliers de tokens), donc la réduction demeure massive. Bornes : 700 (petites
// fenêtres) à 1600 (grandes), ~4 % de la fenêtre entre les deux.
func compactSummaryBudget() int {
n := ctxWindow() * 4 / 100
if n < 700 {
n = 700
}
if n > 1600 {
n = 1600
}
return n
}
// imageLostMarker remplace la partie `image_url` d'un message multimodal dans
// msgText. Toute la chaîne de compaction passe par msgText — estimation de
// contexte, choix des messages à résumer, transcript donné au résumeur — et
// msgText n'extrayait QUE les parties `text`. Un message qui porte une capture
// (screenshotImageMessage) ne pesait donc que sa légende (« Capture … »), et le
// résumeur ne savait même pas qu'une image avait été montrée : au compactage
// suivant l'image disparaissait SANS AUCUNE trace. Observé en amont comme cause
// probable de boucles de raisonnement — le modèle a bien regardé l'image, ne
// peut plus s'y référer, et redérive à l'aveugle en texte.
//
// Ce marqueur ne rend pas l'image récupérable (le résumé est du texte), il rend
// sa PERTE visible : le résumeur peut la mentionner, et le modèle sait qu'il
// doit REVOIR l'image plutôt que deviner.
//
// Formulation volontairement générale depuis l'arrivée de see_image : une image
// peut désormais venir d'une capture (web_screenshot), d'une pièce jointe ou
// d'un fichier du disque. Dire « reprends la capture » enverrait le modèle
// photographier une page web alors que l'image perdue était un PNG sur le
// disque — le geste juste dépend de la provenance, que la légende conservée
// juste avant ce marqueur indique.
const imageLostMarker = " [image — not kept in the conversation past this point; look at it again (see_image on the file, or a new screenshot) if you still need to see it]"
// msgText extrait le texte d'un message (Content est `any`, en pratique string
// ou nil quand l'assistant n'a que des tool_calls). Un message multimodal
// (userMessageContent : parties texte + image quand la vision est active) porte
// un tableau de parties ; on en recolle les segments `text` pour que l'estimation
// de contexte et le transcript de compaction ne partent pas d'un texte vide.
// de contexte et le transcript de compaction ne partent pas d'un texte vide, et
// on marque la présence d'une partie `image_url` (voir imageLostMarker) — sans
// quoi elle reste invisible à toute la chaîne de compaction.
func msgText(m Message) string {
switch v := m.Content.(type) {
case string:
@@ -89,20 +135,30 @@ func msgText(m Message) string {
case []map[string]any:
var b strings.Builder
for _, part := range v {
if part["type"] == "text" {
switch part["type"] {
case "text":
if s, ok := part["text"].(string); ok {
b.WriteString(s)
}
case "image_url":
b.WriteString(imageLostMarker)
}
}
return b.String()
case []any: // même contenu relu depuis le JSON persisté (map générique)
var b strings.Builder
for _, p := range v {
if part, ok := p.(map[string]any); ok && part["type"] == "text" {
part, ok := p.(map[string]any)
if !ok {
continue
}
switch part["type"] {
case "text":
if s, ok := part["text"].(string); ok {
b.WriteString(s)
}
case "image_url":
b.WriteString(imageLostMarker)
}
}
return b.String()
@@ -181,8 +237,13 @@ func MaybeCompact(ctx context.Context, msgs []Message, caps Caps, knownTokens in
// l'historique compacté et true s'il a effectivement changé.
// compactBounds calcule les frontières head/tail pour un historique donné et un
// budget de queue (en tokens). Fonction pure (pas d'IO) → testable :
// - head : nb de messages protégés en tête = messages système + 1er message
// utilisateur (il ancre l'objectif). Un message user est une frontière sûre.
// - head : nb de messages protégés en tête = messages système, et RIEN d'autre.
// Le tout premier message utilisateur y était épinglé pour « ancrer
// l'objectif » ; c'était contre-productif. Après compaction, le modèle voyait
// deux demandes explicites — celle du DÉBUT de la conversation, épinglée, et
// celle réinjectée par `pending` — et répondait volontiers à l'ancienne, en
// abandonnant la tâche en cours. La demande courante est déjà garantie par
// `pending` ; le premier message, lui, part au résumé comme le reste.
// - tailStart : index de début de la queue protégée. On remonte depuis la fin
// jusqu'à remplir le budget, puis on recule jusqu'à une frontière SÛRE :
// un message `user`, ou un `assistant` (qui, s'il porte des tool_calls, part
@@ -204,9 +265,6 @@ func compactBounds(msgs []Message, tailBudget int) (head, tailStart int) {
for head < len(msgs) && msgs[head].Role == "system" {
head++
}
if head < len(msgs) && msgs[head].Role == "user" {
head++
}
tailStart = len(msgs)
acc := 0
for i := len(msgs) - 1; i >= head; i-- {
@@ -238,13 +296,46 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
torso := msgs[head:tailStart]
// 3. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
// 3. Archivage des gros blocs (mémoire longue). AVANT de compacter, chaque bloc
// du torse assez long est enregistré VERBATIM sous un id (r7…) : le résumé et
// le repli le remplacent par une référence à cet id, que le modèle pourra
// rappeler avec recall(id). C'est ce qui rend le compactage sûr même
// agressif — rien n'est perdu, seulement déplacé hors du contexte.
// On n'archive QUE si le modèle a réellement ses outils (mode agent) : sans
// eux il ne pourrait pas rappeler, autant garder un résumé propre sans ids.
// Un échec d'écriture (base absente, tests) laisse archived[i] nil → repli
// transparent sur le comportement classique (troncature/marqueur sans id).
archived := make([]*recallEntry, len(torso))
var index []recallEntry
if caps.Agent && compactEnabled() {
names := torsoToolNames(torso)
for i, m := range torso {
if !recallEligible(m) {
continue
}
content := msgText(m)
label := recallLabel(m.Role, names[m.ToolCallID], content)
id, aerr := archiveRecallBlock(label, m.Role, content)
if aerr != nil {
continue
}
archived[i] = &recallEntry{id: id, label: label}
index = append(index, recallEntry{id: id, label: label})
}
}
// 4. Dégraissage sans IA : les vieux résultats d'outils longs deviennent un
// marqueur. On travaille sur une copie pour ne pas muter l'historique amont.
// ⚠️ Ce torse dégraissé ne sert QUE de repli si le résumé échoue — surtout
// PAS d'entrée au résumeur, cf. juste en dessous.
// PAS d'entrée au résumeur, cf. juste en dessous. Un bloc archivé pointe vers
// son id (récupérable par recall) au lieu d'être effacé aveuglément.
pruned := make([]Message, len(torso))
for i, m := range torso {
pruned[i] = m
if e := archived[i]; e != nil {
pruned[i].Content = recallMarker(e.id, e.label)
continue
}
if m.Role == "tool" {
if t := msgText(m); len(t) > compactToolPruneLen {
pruned[i].Content = compactPrunedMarker
@@ -252,7 +343,7 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
}
}
// 4. Résumé du torse par le modèle local (un seul appel).
// 5. Résumé du torse par le modèle local (un seul appel).
//
// Le résumé se fait sur le torse ORIGINAL, pas sur le dégraissé. C'était LE
// bug de fond : on effaçait tous les résultats d'outils PUIS on demandait un
@@ -262,12 +353,26 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
// que des outils avaient tourné. À chaque compactage, l'IA repartait donc
// d'une recherche vide et recommençait à zéro : elle ne s'arrêtait jamais.
//
// Les résultats d'outils sont seulement RACCOURCIS (leur début, qui porte
// l'essentiel : titre, en-tête, premières lignes) pour que la transcription
// reste bornée. Les faits survivent, le volume reste maîtrisé.
// Un bloc archivé est réduit à sa TÊTE (assez pour le résumer fidèlement).
// On ne montre PAS l'id recall au résumeur : sa seule tâche est d'écrire de
// la prose. Lui exposer « recall:rN » l'incite à répondre juste par l'id
// (résumé dégénéré) ; l'index des ids est de toute façon ajouté séparément
// et de façon déterministe par compactSummaryUserMsg.
// Les résultats d'outils non archivés sont seulement RACCOURCIS (leur début,
// qui porte l'essentiel : titre, en-tête, premières lignes) pour que la
// transcription reste bornée. Les faits survivent, le volume reste maîtrisé.
forSummary := make([]Message, len(torso))
for i, m := range torso {
forSummary[i] = m
if archived[i] != nil {
r := []rune(msgText(m))
headTxt := string(r)
if len(r) > recallSummaryHeadLen {
headTxt = string(r[:recallSummaryHeadLen]) + "…"
}
forSummary[i].Content = headTxt + "\n[…reste de ce bloc omis ici]"
continue
}
if m.Role == "tool" {
if r := []rune(msgText(m)); len(r) > compactToolSummaryLen {
forSummary[i].Content = string(r[:compactToolSummaryLen]) + "\n[…suite coupée]"
@@ -276,15 +381,20 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
}
summary, err := summarizeTranscript(ctx, renderTranscript(forSummary))
var mid []Message
if err != nil || strings.TrimSpace(summary) == "" {
if err != nil || summaryLooksEmpty(summary) {
// Résumé raté (erreur, vide, ou juste une référence recall) → on garde le
// torse dégraissé, qui porte au moins les têtes de blocs et les marqueurs
// recall : bien plus informatif qu'un résumé dégénéré.
mid = pruned
} else {
// Le résumé est injecté comme un tour utilisateur→assistant (jamais un
// message `system` au milieu : certains gabarits, ex. Qwen, exigent que le
// system soit uniquement en tête — cf. mémoire qwen36-chat-template-fix).
// Le message porte aussi la CONSCIENCE de la compaction et l'index des ids
// rappelables (voir compactSummaryUserMsg).
mid = []Message{
{Role: "user", Content: compactSummaryPrefix + " The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n" + summary},
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, without redoing work that is already done."},
{Role: "user", Content: compactSummaryUserMsg(summary, index)},
{Role: "assistant", Content: "Understood. I'll resume from exactly where I left off, using the findings above, and call recall(id) if I need the full content of an archived block, without redoing work that is already done."},
}
}
@@ -327,6 +437,94 @@ func compactMessages(ctx context.Context, msgs []Message, caps Caps) ([]Message,
return out, true
}
// summaryLooksEmpty détecte un résumé raté : trop court, ou constitué seulement
// de références « recall:rN » (un petit modèle, mal aiguillé, répond parfois par
// un id au lieu d'écrire de la vraie prose). Dans ce cas on préfère le repli
// dégraissé. Seuil à 40 caractères de VRAI texte (hors jetons recall).
func summaryLooksEmpty(s string) bool {
s = strings.TrimSpace(s)
if len([]rune(s)) < 40 {
return true
}
var real strings.Builder
for _, f := range strings.Fields(s) {
low := strings.ToLower(strings.Trim(f, ".,;:()[]-•*"))
if strings.HasPrefix(low, "recall:r") || strings.HasPrefix(low, "recall(") {
continue
}
real.WriteString(f)
real.WriteString(" ")
}
return len([]rune(strings.TrimSpace(real.String()))) < 40
}
// recallEntry associe un id d'archive à son libellé court, pour l'index injecté
// dans le message de compaction.
type recallEntry struct{ id, label string }
// recallEligible dit si un message du torse mérite d'être archivé sous un id :
// un bloc assez gros (le résumé ne le porterait pas fidèlement) d'un rôle réel,
// et qui n'est pas déjà le résumé d'une compaction précédente (on ne ré-archive
// pas un résumé).
func recallEligible(m Message) bool {
switch m.Role {
case "tool", "user", "assistant":
default:
return false
}
t := msgText(m)
if len([]rune(t)) <= recallArchiveMinLen {
return false
}
return !strings.HasPrefix(t, compactSummaryPrefix)
}
// torsoToolNames associe chaque ToolCallID du torse au nom de l'outil qui l'a
// produit (l'assistant porte les tool_calls, le résultat ne porte que l'id),
// pour donner un libellé lisible aux blocs archivés (« web_read: … »).
func torsoToolNames(torso []Message) map[string]string {
names := map[string]string{}
for _, m := range torso {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
names[tc.ID] = tc.Function.Name
}
}
}
return names
}
// recallMarker remplace un bloc archivé dans le torse dégraissé (repli). Il cite
// l'id récupérable et interdit explicitement de relancer l'outil pour le refaire.
func recallMarker(id, label string) string {
return fmt.Sprintf("[Block archived to save context — full content retrievable with recall(\"%s\") (%s). Do NOT re-run a tool to fetch it back.]", id, label)
}
// compactSummaryUserMsg construit le message `user` synthétique qui porte le
// résumé. Il rend le modèle CONSCIENT que la conversation a été compactée (il le
// sait aussi bien que nous), et lui donne l'index des blocs rappelables. L'index
// ne liste QUE les ids créés à cette passe : sa taille reste bornée, une
// conversation infinie ne gonfle jamais cette section (les vieux blocs restent
// joignables par recall_search).
func compactSummaryUserMsg(summary string, index []recallEntry) string {
var b strings.Builder
b.WriteString(compactSummaryPrefix)
if len(index) > 0 {
b.WriteString(" The earlier turns of this conversation were summarized to save context, but nothing is lost: any block referenced below (or in the summary) as recall:rN can be brought back verbatim with the recall(id) tool, and recall_search(\"keywords\") finds older archived blocks not listed here. Here is the summary:\n\n")
} else {
b.WriteString(" The earlier turns of this conversation were summarized to save context. Here is the summary:\n\n")
}
b.WriteString(summary)
if len(index) > 0 {
b.WriteString("\n\nArchived blocks you can bring back with recall(id):\n")
for _, e := range index {
fmt.Fprintf(&b, "- %s — %s\n", e.id, e.label)
}
b.WriteString("Only recall a block when you actually need its full content.")
}
return b.String()
}
// renderTranscript sérialise le torse en texte lisible pour le résumeur.
func renderTranscript(msgs []Message) string {
var b strings.Builder
@@ -383,11 +581,16 @@ Summarize densely and faithfully, keeping ONLY the essentials:
- Sources already consulted (URLs opened, files read, commands run) — so they are not consulted a second time
- Decisions made and established facts
- STATE OF PROGRESS: what is already answered, what is still missing, and the next concrete step
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Aim for 300 words MAX — this is a compression summary, not a report.
Strict rules: no preamble or conclusion, no verbatim or long quotes, no throwaway detail. Use short bullet points. Be as concise as you can WHILE keeping every fact, decision and still-open task: a detail you drop here is lost for good, so when in doubt keep it. This is a dense compression summary, not a report. Always write ACTUAL prose sentences/bullets — never answer with just an id or a reference.
Write the summary in the SAME language as the conversation.`
// Le résumé part au MÊME endroit que le chat : sur un preset externe, il
// s'appuie sur l'API distante (backend_external.go). Le laisser taper le
// llama-server local ferait échouer toute compaction dès qu'aucun moteur
// local ne tourne — et la compaction, c'est ce qui empêche le fil de mourir.
ep := resolveChatEndpoint()
payload := map[string]any{
"model": "loki",
"model": ep.Model,
"messages": []Message{
{Role: "system", Content: sys},
{Role: "user", Content: transcript},
@@ -396,20 +599,19 @@ Write the summary in the SAME language as the conversation.`
"temperature": 0.2,
// Borne dure : sans ça, un modèle bavard (surtout à reasoning) produit un
// résumé énorme et lent, donc peu de réduction → re-compaction à chaque tour.
"max_tokens": 700,
"max_tokens": compactSummaryBudget(),
// Pas de réflexion pour un résumé : plus rapide, plus dense, et évite qu'un
// modèle hybride gaspille tout le budget en <think> (résumé vide). llama.cpp
// passe ces kwargs au gabarit Jinja (--jinja).
"chat_template_kwargs": map[string]any{"enable_thinking": false},
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
authHeader(req)
ep.auth(req.Header.Set)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", friendlyLLMError(err)
@@ -417,7 +619,11 @@ Write the summary in the SAME language as the conversation.`
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
return "", fmt.Errorf("résumé: llama-server %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
who := "llama-server"
if ep.External {
who = "API externe"
}
return "", fmt.Errorf("résumé: %s %d: %s", who, resp.StatusCode, strings.TrimSpace(string(b)))
}
var out summarizeResp
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+10 -3
View File
@@ -20,10 +20,17 @@ func TestCompactBoundsProtectsHead(t *testing.T) {
um("q2"), am("r2"),
um("q3"), am("r3"),
}
// budget minuscule → queue = juste le dernier tour, tête = system + 1er user.
// budget minuscule → queue = juste le dernier tour, tête = les seuls systèmes.
// Le 1er message user N'EST PLUS épinglé : il l'était pour « ancrer
// l'objectif », mais après compaction le modèle voyait deux demandes (celle du
// début, épinglée, et la courante réinjectée par `pending`) et répondait
// volontiers à l'ancienne. Voir compactBounds et TestCompactKeepsPendingRequest.
head, tail := compactBounds(msgs, 1)
if head != 2 { // system + premier user
t.Fatalf("head = %d, attendu 2", head)
if head != 1 { // le message système, et lui seul
t.Fatalf("head = %d, attendu 1 (systèmes uniquement)", head)
}
if msgs[head].Role != "user" {
t.Fatalf("le 1er message user doit être compactable, il commence le torse ; rôle obtenu %q", msgs[head].Role)
}
// Frontière sûre = user ou assistant (jamais un `tool`, qui serait orphelin).
if r := msgs[tail].Role; r != "user" && r != "assistant" {
+234 -16
View File
@@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
)
@@ -28,8 +30,12 @@ import (
// qu'une poignée d'événements au lieu d'un par token → 20000 couvre des
// centaines de tours. La marge sert surtout à absorber UN tour en cours (streamé
// token par token) avant sa coalescence : un très gros tour (long raisonnement +
// réponse) ne doit pas se faire tronquer le début avant d'être compacté.
const maxLogEvents = 20000
// réponse) ne doit pas se faire tronquer le début avant d'être compacté. 20000
// n'y suffisait pas — un seul tour à très long raisonnement tronquait déjà le
// journal de rejeu, et l'utilisateur perdait le DÉBUT de sa conversation à
// l'écran. Le journal ne pèse que de la mémoire d'affichage, jamais du contexte
// modèle : la marge est bon marché.
const maxLogEvents = 200000
// LogEvent = un événement d'affichage rejouable (un delta SSE + son numéro de
// séquence monotone + un horodatage serveur en ms). Le TS permet au client de
@@ -56,6 +62,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 {
@@ -68,12 +80,66 @@ var conv = func() *Conversation {
// modèle. En clair : cette machine déchiffre déjà pour lancer le modèle, le
// relais reste aveugle — la persister ici ne change rien à la posture E2E.
// loadConvAttempts / loadConvRetryWait : au redémarrage du conteneur, l'ancien
// process peut encore tenir le verrou bbolt quelques centaines de ms pendant que
// le nouveau démarre. Quatre essais espacés de 250 ms couvrent ce chevauchement
// sans retarder perceptiblement un démarrage normal (où le premier essai passe).
const (
loadConvAttempts = 4
loadConvRetryWait = 250 * time.Millisecond
)
// loadConvRetries compte les ATTENTES de reprise du dernier LoadConversation
// (0 = le premier essai a suffi). Il n'existe que pour être observable.
//
// Le test de non-régression veut vérifier qu'une absence légitime — première
// installation, rien d'enregistré — ne déclenche AUCUNE reprise. Il le mesurait
// au chronomètre (« moins de 250 ms »), ce qui confond « aucune reprise » avec
// « une seule tentative, mais lente » : sur un runner CI chargé, créer et
// ouvrir la base bbolt a pris 435 ms et le test a échoué alors qu'aucune reprise
// n'avait eu lieu. On compte donc les reprises au lieu de les déduire d'un
// temps de mur, qui ne dépend pas que de nous.
var loadConvRetries atomic.Int32
// LoadConversation recharge l'état persisté au démarrage du process. Sans état
// enregistré (première fois) on part d'une conversation vide.
//
// Une lecture RATÉE n'est PAS une absence, et les confondre coûte cher ici :
// getStr rend "" aussi bien pour « clé absente » que pour « base inaccessible »,
// donc sur un verrou transitoire convEnsureActive croyait la discussion active
// inexistante, forgeait un NOUVEL identifiant et l'écrasait — le fil en cours
// devenait orphelin, en silence. On sonde donc la base avec son erreur AVANT
// toute écriture, on réessaie, et en cas d'échec durable on le dit et on ne
// touche à rien.
func LoadConversation() {
// convEnsureActive reprend au passage le fil unique des versions
// précédentes (clé « conversation ») comme première discussion.
b := getBytes(bkChat, convKey(convEnsureActive()))
var b []byte
var err error
loadConvRetries.Store(0)
retry := func() {
loadConvRetries.Add(1)
time.Sleep(loadConvRetryWait)
}
for attempt := 0; attempt < loadConvAttempts; attempt++ {
// Sonde : lire la clé de la discussion active en gardant l'erreur. Tant
// qu'elle échoue, convEnsureActive ne doit surtout pas être appelée.
if _, err = getBytesErr(bkChat, ckActive); err != nil {
retry()
continue
}
// convEnsureActive reprend au passage le fil unique des versions
// précédentes (clé « conversation ») comme première discussion.
if b, err = getBytesErr(bkChat, convKey(convEnsureActive())); err == nil {
break
}
retry()
}
if err != nil {
// Toujours en échec : on le DIT au lieu de repartir à vide en silence, et
// on abandonne le chargement sans rien écrire — un serveur qui refuse de
// démarrer serait pire, et l'historique sur disque reste intact.
fmt.Fprintf(os.Stderr, "[conv] base illisible au démarrage (%v) — aucune discussion chargée ; rien n'a été écrasé, l'historique est intact\n", err)
return
}
if len(b) == 0 {
return
}
@@ -210,6 +276,21 @@ func (c *Conversation) compactLogLocked() {
bufKey, toks, ts0, seq0 = "", 0, 0, 0
}
for _, ev := range c.Log {
// Outils : un appel s'écrit en de nombreux événements (annonce done=false,
// frappe du corps, streaming des arguments) jusqu'au done=true, qui porte
// déjà l'état FINAL (résultat + diff). Les intermédiaires ne servent qu'à
// l'affichage EN DIRECT ; les garder dans le journal le faisait enfler sur
// une conversation agentique (gros write, appels en chaîne) jusqu'à
// maxLogEvents → troncature des plus VIEUX événements, donc perte des
// premiers messages à l'affichage. On ne conserve donc que le done=true
// (même politique que coalesceReplay au replay/export).
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
flush()
if done, _ := tu["done"].(bool); done {
out = append(out, ev)
}
continue
}
key := textKey(ev.Delta)
if key == "" {
flush()
@@ -286,12 +367,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 +432,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 +451,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 +475,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,
@@ -391,15 +539,25 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa
// Prompt système personnalisé (UI → /api/sysprompt, fichier côté serveur).
// Injecté seulement dans la vue envoyée au modèle, jamais persisté dans
// c.Messages : modifiable à chaud, effet dès le tour suivant.
final := msgs
// Contexte du projet actif (description, index mémoire, index des trackers),
// même traitement : injecté dans la vue envoyée, jamais persisté — voir
// projectSystemMessages.
final := append(projectSystemMessages(), msgs...)
if sp := readSysPrompt(); sp != "" {
final = append([]Message{{Role: "system", Content: sp}}, msgs...)
final = append([]Message{{Role: "system", Content: sp}}, final...)
}
// newBase : vue modèle publiée par une compaction survenue PENDANT le tour.
// 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 +577,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 +600,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 +608,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 +641,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
@@ -557,9 +748,11 @@ func (c *Conversation) Stop() {
// le bump d'epoch réduit de toute façon au silence.
func (c *Conversation) Reset() {
c.Stop()
// Vider la discussion efface aussi ses captures : les messages qui les
// affichaient disparaissent, les fichiers n'auraient plus aucun lecteur.
dropConvCaptures(convEnsureActive())
// Vider la discussion efface aussi ses fichiers (dépôts, captures, ce que
// l'agent y a écrit) : les messages qui les mentionnaient disparaissent, plus
// rien ne les rattacherait à quoi que ce soit. L'UI le dit avant de demander
// confirmation.
dropConvFiles(convEnsureActive())
c.mu.Lock()
c.Messages = nil
c.Log = nil
@@ -662,11 +855,15 @@ func coalesceReplay(events []LogEvent, from int) []map[string]any {
continue
}
_, isTool := ev.Delta["tool_used"].(map[string]any)
// Tout événement NON-outil clôt une éventuelle bulle d'outil en attente, pour
// préserver l'ordre (l'outil non terminé s'affiche avant ce qui le suit).
if !isTool {
flushTool()
}
// ⚠️ On ne « flushe » PAS l'annonce d'outil en attente sur un événement
// non-outil. Un appel d'outil s'écrit dans le journal en DEUX temps : une
// annonce (done=false, juste « je vais lancer X ») puis le résultat
// (done=true). Si un événement non-outil (stats, un bout de raisonnement)
// se glisse ENTRE les deux, flusher ici émettait l'annonce PUIS le done →
// le même outil apparaissait DEUX fois (à l'export Markdown et au replay).
// On garde donc l'annonce en attente : le done la remplace (émis une seule
// fois), et si aucun done n'arrive jamais (outil interrompu par un stop),
// le flushTool final l'émet une seule fois, en fin de fil.
// Delta texte ? (une seule clé content ou reasoning_content, valeur string)
key := ""
if s, ok := ev.Delta["content"].(string); ok {
@@ -737,6 +934,17 @@ func (c *Conversation) Subscribe(ctx context.Context, from int, emit func(map[st
snapshot := append([]LogEvent(nil), c.Log...)
epoch := c.epoch
c.mu.Unlock()
// ⚠️ Garde-fou anti-« premiers messages manquants au changement de session ».
// Le client se réabonne avec from=lastSeq (le dernier Seq qu'il a vu). Les Seq
// ne sont PAS globaux : ouvrir une session PLUS ANCIENNE charge un journal dont
// les Seq sont plus bas. Si le client garde un from élevé (hérité de la session
// qu'il quittait) et se reconnecte, coalesceReplay saute tout (Seq <= from) →
// la conversation ouverte apparaît tronquée, voire vide. En session normale, le
// client ne peut jamais avoir un from SUPÉRIEUR au dernier Seq du journal ; s'il
// l'est, son curseur vient d'une autre session → on repart du début.
if n := len(snapshot); n > 0 && from > snapshot[n-1].Seq {
from = 0
}
last := from
for _, ev := range coalesceReplay(snapshot, from) {
if ctx.Err() != nil {
@@ -752,6 +960,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
@@ -0,0 +1,155 @@
package loki
import (
"os"
"path/filepath"
"sync"
"testing"
"time"
bolt "go.etcd.io/bbolt"
)
// resetConvForTest vide l'état en RAM pour forcer un VRAI rechargement depuis la
// base, comme au démarrage d'un process qui ne connaît encore rien. `conv` est un
// global du paquet : sans ça, un test verrait l'état laissé par le précédent.
func resetConvForTest() {
conv.mu.Lock()
conv.Messages, conv.Log, conv.Seq, conv.CtxUsed = nil, nil, 0, 0
conv.mu.Unlock()
}
// TestLoadConversationReessayeSousContention : la panne d'origine est un verrou
// bbolt transitoire au redémarrage du conteneur (l'ancien process tient encore
// le fichier pendant que le nouveau démarre), confondu avec « rien à charger ».
//
// Réserve honnête : ce test ne reproduit PAS la panne à coup sûr. withDB ouvre
// déjà la base avec Options.Timeout de 5 s, qui absorbe seul une contention de
// 400 ms — ce test passe donc aussi sur le code d'AVANT le correctif. Il est
// gardé comme filet de non-régression sur le nouveau chemin (getBytesErr + boucle
// de tentatives) : ce chemin ne doit ni perdre la conversation, ni se bloquer.
func TestLoadConversationReessayeSousContention(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
// 1. Écrit une conversation par l'API normale (elle crée aussi la clé active).
resetConvForTest()
conv.mu.Lock()
conv.Messages = []Message{{Role: "user", Content: "bonjour"}}
conv.mu.Unlock()
conv.persist()
activeID := getStr(bkChat, ckActive)
if activeID == "" {
t.Fatal("persist n'a pas créé de discussion active — le montage du test est faux")
}
// 2. État en RAM vidé : le rechargement doit tout reprendre depuis la base.
resetConvForTest()
// 3. Verrou exclusif tenu une fenêtre courte dans une goroutine séparée : la
// base est momentanément indisponible, exactement comme au chevauchement.
db, err := bolt.Open(dbPath(), 0o600, &bolt.Options{Timeout: 2 * time.Second})
if err != nil {
t.Fatalf("impossible de prendre le verrou pour le test : %v", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(400 * time.Millisecond)
db.Close()
}()
LoadConversation()
wg.Wait()
conv.mu.Lock()
got := len(conv.Messages)
conv.mu.Unlock()
if got != 1 {
t.Fatalf("conversation perdue malgré une contention temporaire : %d message(s), attendu 1", got)
}
if id := getStr(bkChat, ckActive); id != activeID {
t.Fatalf("la discussion active a changé (%q → %q) : le fil d'origine est orphelin", activeID, id)
}
}
// TestLoadConversationBaseIllisibleNeForgePasDeDiscussion garde l'invariant qui
// justifie le correctif : convEnsureActive ÉCRIT (elle forge un identifiant et
// l'enregistre sous bkChat/active). Elle ne doit donc jamais être appelée sur la
// foi d'une lecture ratée — sinon un verrou durable suffisait à mettre le fil en
// cours de côté au profit d'une discussion neuve, en silence.
//
// Réserve honnête, là encore : avec une base illisible l'écriture échoue elle
// aussi, donc le disque finit identique dans les deux versions. Ce que ce test
// verrouille, c'est le CONTRAT — sortir sans rien écrire, et sans faire attendre
// indéfiniment — pas la reproduction du dégât.
func TestLoadConversationBaseIllisibleNeForgePasDeDiscussion(t *testing.T) {
home := t.TempDir()
t.Setenv("LOKI_HOME", home)
// Fichier de base invalide : bolt.Open échoue tout de suite (pas d'attente de
// verrou), ce qui exerce le chemin d'erreur sans allonger le test.
path := filepath.Join(home, "loki.db")
if err := os.WriteFile(path, []byte("ceci n'est pas une base bbolt"), 0o600); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
resetConvForTest()
start := time.Now()
LoadConversation()
elapsed := time.Since(start)
// Budget : loadConvAttempts tentatives espacées de loadConvRetryWait, plus une
// marge. Au-delà, la boucle de reprise retarderait le démarrage du serveur.
if max := time.Duration(loadConvAttempts)*loadConvRetryWait + 2*time.Second; elapsed > max {
t.Fatalf("démarrage retardé de %v par une base illisible (budget %v)", elapsed, max)
}
// Contrepartie de l'assertion « aucune reprise » du test d'absence légitime :
// un compteur bloqué à zéro la rendrait vraie sans rien prouver. Ici les
// reprises ont bel et bien lieu, et on le vérifie.
if n := loadConvRetries.Load(); int(n) != loadConvAttempts {
t.Fatalf("%d reprise(s) comptée(s) sur une base illisible, attendu %d", n, loadConvAttempts)
}
after, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(before) != string(after) {
t.Fatal("la base a été modifiée alors qu'elle n'a même pas pu être lue")
}
conv.mu.Lock()
n := len(conv.Messages)
conv.mu.Unlock()
if n != 0 {
t.Fatalf("%d message(s) chargés depuis une base illisible", n)
}
}
// TestLoadConversationAbsenceLegitimeResteRapide : le correctif ne doit pas avoir
// transformé le cas NORMAL (base saine, rien d'enregistré encore — premier
// démarrage) en attente inutile. Une seule tentative doit suffire.
func TestLoadConversationAbsenceLegitimeResteRapide(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
resetConvForTest()
LoadConversation()
// Mesuré sur le COMPTEUR de reprises, pas au chronomètre : une absence
// légitime doit sortir au premier essai. Le temps de mur ne disait pas ça —
// il mêlait le coût de l'essai lui-même (création de la base bbolt, fsync)
// à l'attente de reprise, et un runner CI chargé l'a fait échouer à 435 ms
// alors qu'aucune reprise n'avait été déclenchée.
if n := loadConvRetries.Load(); n != 0 {
t.Fatalf("absence légitime traitée comme une erreur : %d reprise(s) déclenchée(s), aucune ne devait l'être", n)
}
conv.mu.Lock()
n := len(conv.Messages)
conv.mu.Unlock()
if n != 0 {
t.Fatalf("%d message(s) chargés alors que rien n'avait été enregistré", n)
}
}
+84
View File
@@ -225,3 +225,87 @@ func TestLiveSelectionSurJournalTronque(t *testing.T) {
c.appendDelta(c.epoch, map[string]any{"content": "suite"})
waitSeq(t, got, 503)
}
// --- Régressions v0.12.7 : journal d'affichage (outils, replay, changement de session) ---
// compactLogLocked doit coalescer les événements tool_used d'un même appel en un
// seul (le done=true, qui porte l'état final) : les intermédiaires (annonce, frappe
// du corps) ne servent qu'à l'affichage en direct et faisaient enfler le journal
// jusqu'à la troncature des premiers messages.
func TestCompactLogCoalesceTools(t *testing.T) {
c := newTestConv()
c.Log = []LogEvent{
{Seq: 1, Delta: map[string]any{"user": "édite"}},
{Seq: 2, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f"}}},
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "typing": true, "body": "a"}}},
{Seq: 4, Delta: map[string]any{"tool_used": map[string]any{"name": "edit", "label": "f", "result": "ok", "done": true}}},
{Seq: 5, Delta: map[string]any{"content": "fini"}},
}
c.mu.Lock()
c.compactLogLocked()
c.mu.Unlock()
nTool := 0
for _, ev := range c.Log {
if tu, ok := ev.Delta["tool_used"].(map[string]any); ok {
nTool++
if done, _ := tu["done"].(bool); !done {
t.Error("un tool_used non terminé a survécu à la compaction")
}
}
}
if nTool != 1 {
t.Fatalf("compactLogLocked garde %d tool_used au lieu de 1", nTool)
}
}
// coalesceReplay ne doit émettre un outil qu'UNE fois même si un événement non-outil
// (stats, un bout de raisonnement) tombe entre l'annonce (done=false) et le done=true.
func TestCoalesceReplayNoDoubleTool(t *testing.T) {
events := []LogEvent{
{Seq: 1, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls"}}},
{Seq: 2, Delta: map[string]any{"stats": map[string]any{"gen_tokens": 3}}},
{Seq: 3, Delta: map[string]any{"tool_used": map[string]any{"name": "bash", "label": "ls", "result": "x", "done": true}}},
}
n := 0
for _, ev := range coalesceReplay(events, 0) {
if _, ok := ev["tool_used"]; ok {
n++
}
}
if n != 1 {
t.Fatalf("outil émis %d fois au replay (attendu 1)", n)
}
}
// Un abonné qui arrive avec un `from` SUPÉRIEUR au dernier Seq du journal (curseur
// hérité d'une autre session, plus récente) doit obtenir le fil COMPLET, pas rien :
// sans ce garde-fou, ouvrir une session plus ancienne la montrait tronquée/vide.
func TestSubscribeStaleFromCrossSession(t *testing.T) {
c := newTestConv()
c.appendDelta(c.epoch, map[string]any{"user": "premier message"})
c.appendDelta(c.epoch, map[string]any{"content": "réponse"})
// Le journal va jusqu'à Seq 2 ; on s'abonne avec from=9999 (curseur d'une autre session).
ctx, cancel := context.WithCancel(context.Background())
var got []map[string]any
done := make(chan struct{})
go func() {
c.Subscribe(ctx, 9999, func(ev map[string]any) bool {
got = append(got, ev)
if ev["caught_up"] != nil {
cancel()
}
return true
})
close(done)
}()
<-done
seen := ""
for _, ev := range got {
if u, ok := ev["user"].(string); ok {
seen += u
}
}
if !strings.Contains(seen, "premier message") {
t.Fatalf("le premier message n'a pas été rejoué avec un from périmé (reçu: %q)", seen)
}
}
+304
View File
@@ -0,0 +1,304 @@
package loki
// chat_convfiles.go — les fichiers rangés PAR DISCUSSION.
//
// Avant, tout ce qui passait par le chat — pièces jointes déposées, rapports
// écrits par l'agent, captures de pages — atterrissait dans UN seul dossier de
// travail partagé par toutes les discussions. Changer de discussion ne changeait
// rien au panneau Fichiers (on revoyait les mêmes), et supprimer une discussion
// laissait ses fichiers derrière elle — seules ses captures, déjà rangées par
// identifiant, partaient avec.
//
// Désormais chaque discussion a son dossier :
//
// <workspace>/discussions/<id>/ ← dossier courant de l'agent
// <workspace>/discussions/<id>/uploads/ ← pièces jointes de CETTE discussion
// <workspace>/discussions/<id>/captures/ ← captures de CETTE discussion
//
// Supprimer une discussion (ou la vider) emporte tout le dossier, et le panneau
// Fichiers n'ouvre que celui de la discussion active.
//
// La racine du dossier de travail reste la BORNE de sécurité : les liens écrits
// dans les anciens messages (`uploads/x.pdf`, `captures/<id>/y.jpg`) pointent
// encore vers elle et doivent continuer de marcher — c'est le rôle de
// workspaceFile, qui cherche d'abord dans la discussion puis retombe sur les
// emplacements d'avant.
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"unicode"
)
const (
// convFilesRoot : le sous-dossier qui contient un dossier par discussion.
// Nommé, plutôt que d'éparpiller les identifiants à la racine, pour que le
// dossier de travail reste lisible quand on l'ouvre depuis l'hôte.
convFilesRoot = "discussions"
// uploadsSub : les dépôts de l'utilisateur, à l'intérieur de la discussion.
uploadsSub = "uploads"
)
// safeConvID refuse tout identifiant qui ne soit pas un simple nom : c'est lui
// qui devient un segment de chemin, et il arrive parfois du client (paramètre de
// requête, ancien lien de message). Renvoie "" si l'identifiant est douteux.
func safeConvID(id string) string {
id = strings.TrimSpace(id)
if id == "" || len(id) > 64 {
return ""
}
for _, r := range id {
if r == '-' || r == '_' || unicode.IsDigit(r) || (r < unicode.MaxASCII && unicode.IsLetter(r)) {
continue
}
return ""
}
return id
}
// convDirFor renvoie le dossier (absolu) d'une discussion, sans le créer. Un
// identifiant vide ou douteux retombe sur la racine du dossier de travail :
// mieux vaut écrire au mauvais endroit que refuser d'écrire.
func convDirFor(convID string) string {
id := safeConvID(convID)
if id == "" {
return agentWorkspace()
}
return filepath.Join(agentWorkspace(), convFilesRoot, id)
}
// convWorkspace est le dossier de travail COURANT : celui de la discussion
// active, créé au besoin. C'est là que résolvent les chemins relatifs du modèle
// (resolveAgentPath), que démarre son shell, et que se déposent les fichiers.
//
// Si la création échoue (disque plein, droits), on retombe sur la racine du
// dossier de travail : l'agent doit continuer d'écrire quelque part.
func convWorkspace() string {
dir := convDirFor(convEnsureActive())
if dir == agentWorkspace() {
return dir
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return agentWorkspace()
}
return dir
}
// dropConvFiles efface TOUT le dossier d'une discussion (dépôts, captures,
// fichiers écrits par l'agent). Appelé quand la discussion est supprimée ou
// vidée : plus un message ne les mentionne, personne ne les retrouverait.
// Best-effort — un échec ne doit rien interrompre.
func dropConvFiles(convID string) {
dir := convDirFor(convID)
if dir == agentWorkspace() {
return // identifiant douteux : surtout pas la racine
}
_ = os.RemoveAll(dir)
}
// relWithin dit si `abs` se trouve DANS `root` et, si oui, renvoie son chemin
// relatif en séparateurs '/'.
//
// EvalSymlinks des deux côtés : sans ça, un lien posé dans le dossier passerait
// le test de préfixe tout en pointant ailleurs sur le disque.
func relWithin(root, abs string) (string, bool) {
if root == "" {
return "", false
}
if r, err := filepath.EvalSymlinks(root); err == nil {
root = r
}
if a, err := filepath.EvalSymlinks(abs); err == nil {
abs = a
}
rel, err := filepath.Rel(root, abs)
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", false
}
return filepath.ToSlash(rel), true
}
// workspaceFile résout un chemin RELATIF venu d'un client ou d'un message
// (`uploads/rapport.pdf`, `captures/xxx.jpg`) en fichier du disque.
//
// Ordre de recherche :
// 1. le dossier de la discussion active — le cas normal ;
// 2. les emplacements d'AVANT le rangement par discussion, pour que les liens
// des anciens messages continuent d'ouvrir leur fichier.
//
// ok=false signale une sortie du périmètre (« .. »), pas une absence : un
// fichier introuvable revient avec le chemin attendu, pour que l'appelant
// réponde « introuvable » et non « hors du dossier de travail ».
func workspaceFile(rel string) (string, bool) {
rel = strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(rel), "\\", "/"), "/")
if rel == "" {
return "", false
}
base := convWorkspace()
p := filepath.Join(base, filepath.FromSlash(rel))
if _, ok := relWithin(base, p); !ok {
return "", false
}
if _, err := os.Stat(p); err == nil {
return p, true
}
if legacy, ok := legacyWorkspaceFile(rel); ok {
return legacy, true
}
return p, true
}
// legacyWorkspaceFile retrouve un fichier désigné par un chemin d'avant le
// rangement par discussion. Deux formes existaient :
//
// captures/<id>/x.jpg → discussions/<id>/captures/x.jpg (déplacé par la migration)
// uploads/x.pdf → resté à la racine si aucune discussion ne le réclamait
func legacyWorkspaceFile(rel string) (string, bool) {
if parts := strings.SplitN(rel, "/", 3); len(parts) == 3 && parts[0] == captureDir && safeConvID(parts[1]) != "" {
p := filepath.Join(convDirFor(parts[1]), captureDir, filepath.FromSlash(parts[2]))
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, true
}
}
p := filepath.Join(agentWorkspace(), filepath.FromSlash(rel))
if _, ok := workspaceRel(p); !ok {
return "", false
}
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p, true
}
return "", false
}
// filesRoot donne la racine du panneau Fichiers pour un `scope` de requête :
// la discussion active par défaut, la racine du dossier de travail pour
// `scope=legacy` — celle où dorment les fichiers d'avant le rangement par
// discussion, que la migration n'a pas su rattacher.
func filesRoot(scope string) (root string, legacy bool) {
if strings.TrimSpace(scope) == "legacy" {
return agentWorkspace(), true
}
return convWorkspace(), false
}
// legacyCount compte ce qui traîne à la racine du dossier de travail, hors
// dossier des discussions. L'UI n'affiche le raccourci « hors discussion » que
// si ce compte est non nul — il tombe à zéro une fois le ménage fait, et le
// bouton disparaît de lui-même.
func legacyCount() int {
ents, err := os.ReadDir(agentWorkspace())
if err != nil {
return 0
}
n := 0
for _, e := range ents {
if e.Name() == convFilesRoot || strings.HasPrefix(e.Name(), ".") {
continue
}
n++
}
return n
}
var convFilesMigrateOnce sync.Once
// migrateConvFiles range les fichiers des versions précédentes dans le dossier
// de leur discussion. Une seule fois par process, au démarrage du serveur web.
func migrateConvFiles() { convFilesMigrateOnce.Do(migrateConvFilesNow) }
func migrateConvFilesNow() {
root := agentWorkspace()
if root == "" {
return
}
// 1. Les captures étaient DÉJÀ par discussion : captures/<id> → discussions/<id>/captures.
// Un simple renommage, donc instantané quel que soit le nombre d'images.
oldCaps := filepath.Join(root, captureDir)
if ents, err := os.ReadDir(oldCaps); err == nil {
for _, e := range ents {
id := safeConvID(e.Name())
if !e.IsDir() || id == "" {
continue
}
dst := filepath.Join(convDirFor(id), captureDir)
if _, err := os.Stat(dst); err == nil {
continue // déjà rangé (migration interrompue puis reprise)
}
if os.MkdirAll(filepath.Dir(dst), 0o755) != nil {
continue
}
_ = os.Rename(filepath.Join(oldCaps, e.Name()), dst)
}
_ = os.Remove(oldCaps) // ne part que s'il est vide
}
// 2. Les dépôts, eux, étaient dans un pot commun. On rend chaque fichier à la
// discussion qui le mentionne — l'historique garde le nom des pièces
// jointes de chaque tour. Ce qui n'est réclamé par personne reste à la
// racine : toujours téléchargeable (legacyWorkspaceFile) et visible via
// « hors discussion », plutôt que rattaché au hasard.
migrateLegacyUploads(filepath.Join(root, uploadsSub))
}
func migrateLegacyUploads(src string) {
ents, err := os.ReadDir(src)
if err != nil {
return
}
left := map[string]bool{}
for _, e := range ents {
if !e.IsDir() {
left[e.Name()] = true
}
}
for _, m := range convIndex() {
if len(left) == 0 {
break
}
for _, name := range convAttachNames(m.ID) {
if !left[name] {
continue
}
dst := filepath.Join(convDirFor(m.ID), uploadsSub)
if os.MkdirAll(dst, 0o755) != nil {
continue
}
if os.Rename(filepath.Join(src, name), filepath.Join(dst, name)) == nil {
delete(left, name)
}
}
}
_ = os.Remove(src) // ne part que s'il est vide
}
// convAttachNames relit les pièces jointes annoncées dans le journal d'affichage
// d'une discussion (delta `files`, cf. StartTurn). C'est la seule trace qui
// relie un fichier déposé à sa discussion.
func convAttachNames(id string) []string {
b := getBytes(bkChat, convKey(id))
if len(b) == 0 {
return nil
}
var st struct {
Log []struct {
Delta struct {
Files []attachInfo `json:"files"`
} `json:"delta"`
} `json:"log"`
}
if json.Unmarshal(b, &st) != nil {
return nil
}
var out []string
for _, ev := range st.Log {
for _, f := range ev.Delta.Files {
if n := safeUploadName(f.Name); n != "" {
out = append(out, n)
}
}
}
return out
}
+243
View File
@@ -0,0 +1,243 @@
package loki
import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// Le cœur de la demande : deux discussions ne partagent pas leurs fichiers.
// Avant, uploads/ était commun — on retrouvait les mêmes pièces jointes partout.
func TestFichiersIsolesParDiscussion(t *testing.T) {
withWorkspace(t)
a := convEnsureActive()
dirA, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dirA, "note-a.txt"), "aaa")
b := convNew() // b devient active
if a == b {
t.Fatal("convNew n'a pas créé de nouvelle discussion")
}
dirB, err := uploadsDir()
if err != nil {
t.Fatal(err)
}
if dirA == dirB {
t.Fatalf("les deux discussions déposent au même endroit : %s", dirA)
}
writeFile(t, filepath.Join(dirB, "note-b.txt"), "bbb")
// La discussion ouverte ne voit QUE ses propres dépôts.
names := listedNames(t, "uploads")
if names["note-a.txt"] || !names["note-b.txt"] {
t.Fatalf("la liste de la discussion b montre %v", names)
}
// Et le message ne peut pas joindre le fichier de l'autre discussion.
if got := attachFiles([]string{"uploads/note-a.txt"}); len(got) != 0 {
t.Fatalf("pièce jointe d'une autre discussion retenue : %+v", got)
}
if got := attachFiles([]string{"uploads/note-b.txt"}); len(got) != 1 {
t.Fatalf("pièce jointe de la discussion courante refusée : %+v", got)
}
// Retour sur a : ses fichiers sont là, ceux de b ont disparu de la vue.
if err := convSwitch(a); err != nil {
t.Fatal(err)
}
names = listedNames(t, "uploads")
if !names["note-a.txt"] || names["note-b.txt"] {
t.Fatalf("la liste de la discussion a montre %v", names)
}
}
// Supprimer une discussion emporte TOUS ses fichiers, pas seulement ses
// captures — et surtout pas ceux des autres.
func TestConvDeleteSupprimeLesFichiers(t *testing.T) {
ws := withWorkspace(t)
a := convEnsureActive()
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
writeFile(t, filepath.Join(ws, "rapport.md"), "rapport")
b := convNew()
autre := convWorkspace()
writeFile(t, filepath.Join(autre, "garde-moi.txt"), "ok")
if err := convDelete(a); err != nil {
t.Fatalf("suppression : %v", err)
}
if _, err := os.Stat(ws); !os.IsNotExist(err) {
t.Fatalf("le dossier de la discussion supprimée est toujours là (%v)", err)
}
if _, err := os.Stat(filepath.Join(autre, "garde-moi.txt")); err != nil {
t.Fatalf("les fichiers d'une AUTRE discussion ont été supprimés : %v", err)
}
if convEnsureActive() != b {
t.Fatalf("discussion active = %q, attendu %q", convEnsureActive(), b)
}
}
// Vider une discussion (« clear chat ») efface aussi ses fichiers : les messages
// qui les mentionnaient partent avec.
func TestResetSupprimeLesFichiersDeLaDiscussion(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "uploads", "joint.pdf"), "pdf")
conv.Reset()
if _, err := os.Stat(filepath.Join(ws, "uploads", "joint.pdf")); !os.IsNotExist(err) {
t.Fatalf("le fichier de la discussion vidée a survécu (%v)", err)
}
}
// Un identifiant de discussion douteux ne doit JAMAIS devenir un segment de
// chemin : dropConvFiles effacerait alors la racine du dossier de travail.
func TestDropConvFilesRefuseUnIdDouteux(t *testing.T) {
ws := withWorkspace(t)
writeFile(t, filepath.Join(ws, "a.txt"), "a")
racine := agentWorkspace()
for _, bad := range []string{"", ".", "..", "../..", "c1/../..", "/etc", `..\..`} {
dropConvFiles(bad)
if _, err := os.Stat(racine); err != nil {
t.Fatalf("id %q a détruit le dossier de travail : %v", bad, err)
}
}
if _, err := os.Stat(filepath.Join(ws, "a.txt")); err != nil {
t.Fatalf("fichier de la discussion détruit : %v", err)
}
}
// Les liens des anciens messages doivent continuer d'ouvrir leur fichier : ils
// pointent vers uploads/ et captures/<id>/ à la RACINE du dossier de travail.
func TestTelechargementDesCheminsDAvant(t *testing.T) {
withWorkspace(t)
racine := agentWorkspace()
id := convEnsureActive()
writeFile(t, filepath.Join(racine, "uploads", "ancien.txt"), "avant")
// Capture d'avant, déplacée par la migration là où elle est désormais rangée.
writeFile(t, filepath.Join(convDirFor(id), captureDir, "vue.jpg"), "jpeg")
for path, want := range map[string]string{
"uploads/ancien.txt": "avant",
captureDir + "/" + id + "/vue.jpg": "jpeg",
captureDir + "/vue.jpg": "jpeg", // le chemin d'aujourd'hui
} {
rec := httptest.NewRecorder()
handleChatFile(rec, httptest.NewRequest("GET", "/api/chat/file?path="+url.QueryEscape(path), nil))
if rec.Code != 200 || rec.Body.String() != want {
t.Errorf("path=%q : code %d, corps %q", path, rec.Code, rec.Body.String())
}
}
}
// La migration range les fichiers des versions précédentes : les captures par
// leur identifiant, les dépôts par la discussion qui les mentionne. Ce que
// personne ne réclame reste à la racine, atteignable par « hors discussion ».
func TestMigrationRangeLesFichiersDAvant(t *testing.T) {
withWorkspace(t)
racine := agentWorkspace()
a := convEnsureActive()
// Un tour avec pièce jointe : c'est cette trace, dans le journal de la
// discussion, qui rattache le fichier à elle.
state, _ := json.Marshal(map[string]any{
"log": []map[string]any{{"seq": 1, "delta": map[string]any{
"user": "regarde",
"files": []attachInfo{{Name: "joint.pdf", Path: "uploads/joint.pdf", Size: 3}},
}}},
})
if err := putBytes(bkChat, convKey(a), state); err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(racine, "uploads", "joint.pdf"), "pdf")
writeFile(t, filepath.Join(racine, "uploads", "orphelin.bin"), "??")
writeFile(t, filepath.Join(racine, captureDir, a, "vue.jpg"), "jpeg")
// La migration ne joue qu'une fois par process : on rearme le verrou pour
// pouvoir l'observer ici.
convFilesMigrateOnce = sync.Once{}
migrateConvFiles()
if _, err := os.Stat(filepath.Join(convDirFor(a), uploadsSub, "joint.pdf")); err != nil {
t.Errorf("le dépôt n'a pas rejoint sa discussion : %v", err)
}
if _, err := os.Stat(filepath.Join(convDirFor(a), captureDir, "vue.jpg")); err != nil {
t.Errorf("la capture n'a pas rejoint sa discussion : %v", err)
}
// Le fichier que personne ne réclame reste lisible plutôt que rattaché au hasard.
if _, err := os.Stat(filepath.Join(racine, uploadsSub, "orphelin.bin")); err != nil {
t.Errorf("le dépôt orphelin a été perdu : %v", err)
}
if legacyCount() == 0 {
t.Error("le reliquat hors discussion n'est pas signalé à l'UI")
}
}
// La vue « hors discussion » ne doit pas devenir une porte dérobée vers les
// fichiers des autres discussions.
func TestScopeLegacyNeDonnePasLesDiscussions(t *testing.T) {
ws := withWorkspace(t)
racine := agentWorkspace()
writeFile(t, filepath.Join(ws, "secret.txt"), "x")
writeFile(t, filepath.Join(racine, "ancien.txt"), "y")
out := listScope(t, "", "legacy")
if out["ok"] != true {
t.Fatalf("liste refusée : %v", out)
}
for _, e := range out["entries"].([]any) {
if e.(map[string]any)["name"] == convFilesRoot {
t.Fatalf("le dossier des discussions est listé : %v", out)
}
}
// Et il ne se supprime pas d'un clic : ce serait toutes les discussions.
rec := httptest.NewRecorder()
handleChatFileDelete(rec, httptest.NewRequest("POST", "/api/chat/file/delete",
strings.NewReader(`{"path":`+jsonQuote(convFilesRoot)+`,"scope":"legacy"}`)))
if rec.Code == http.StatusOK {
t.Errorf("suppression du dossier des discussions acceptée : %s", rec.Body.String())
}
if _, err := os.Stat(filepath.Join(ws, "secret.txt")); err != nil {
t.Fatalf("fichier d'une discussion supprimé : %v", err)
}
}
// listedNames renvoie les noms listés dans un dossier du panneau.
func listedNames(t *testing.T, dir string) map[string]bool {
t.Helper()
out := listFiles(t, dir)
names := map[string]bool{}
if out["ok"] != true {
return names
}
for _, e := range out["entries"].([]any) {
names[e.(map[string]any)["name"].(string)] = true
}
return names
}
func listScope(t *testing.T, dir, scope string) map[string]any {
t.Helper()
rec := httptest.NewRecorder()
handleChatFiles(rec, httptest.NewRequest("GET",
"/api/chat/files?dir="+url.QueryEscape(dir)+"&scope="+url.QueryEscape(scope), nil))
var out map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("réponse illisible (%d) : %s", rec.Code, rec.Body.String())
}
out["_code"] = rec.Code
return out
}
+67 -1
View File
@@ -103,12 +103,78 @@ func (c *Conversation) ExportJSON(o exportOpts) ([]byte, error) {
Version: Version,
ExportedAt: time.Now().Format(time.RFC3339),
CtxUsed: ctxUsed,
Messages: msgs,
Messages: stripInlineImages(msgs),
Log: filterLog(log, o),
}
return json.MarshalIndent(p, "", " ")
}
// stripInlineImages remplace les images en base64 (data URI) portées par les
// messages multimodaux (vision : pièces jointes images + outil see_image) par un
// court marqueur. Sans ça, l'export JSON d'une conversation avec images embarquait
// plusieurs Mo de base64 par image — un fichier énorme et illisible. Le Markdown,
// lui, n'était pas concerné : il ne cite que les NOMS de fichiers. Le journal
// d'affichage non plus (il ne porte pas les images). On ne touche donc qu'ici, et
// sur une COPIE : les messages appartiennent à la conversation vivante.
func stripInlineImages(msgs []Message) []Message {
out := make([]Message, len(msgs))
for i, m := range msgs {
out[i] = m
out[i].Content = stripInlineImagesValue(m.Content)
}
return out
}
func stripInlineImagesValue(v any) any {
// Une partie image (format OpenAI) : {"type":"image_url","image_url":{"url":"data:…"}}.
// On copie la partie et on élide l'URL data: en gardant l'en-tête (type MIME) lisible.
elide := func(part map[string]any) map[string]any {
cp := make(map[string]any, len(part))
for k, val := range part {
cp[k] = val
}
if iu, ok := cp["image_url"].(map[string]any); ok {
c2 := make(map[string]any, len(iu))
for k, val := range iu {
c2[k] = val
}
if url, ok := c2["url"].(string); ok && strings.HasPrefix(url, "data:") {
c2["url"] = elideDataURI(url)
}
cp["image_url"] = c2
}
return cp
}
switch parts := v.(type) {
case []map[string]any: // message fraîchement produit
out := make([]map[string]any, len(parts))
for i, p := range parts {
out[i] = elide(p)
}
return out
case []any: // message relu depuis la base (JSON) : []any de maps
out := make([]any, len(parts))
for i, p := range parts {
if mp, ok := p.(map[string]any); ok {
out[i] = elide(mp)
} else {
out[i] = p
}
}
return out
}
return v // simple chaîne (cas courant) : rien à faire
}
// elideDataURI garde l'en-tête « data:<mime>;base64, » (lisible) et remplace le
// corps base64 par sa taille, pour que l'export reste informatif sans le poids.
func elideDataURI(url string) string {
if i := strings.Index(url, ","); i >= 0 {
return url[:i+1] + fmt.Sprintf("<élidé — %s>", humanBytes(int64(len(url)-i-1)))
}
return "<image élidée>"
}
// filterLog applique au journal les MÊMES cases que le Markdown. Les deux
// formats avaient auparavant des options distinctes (le JSON proposait un
// obscur « journal d'affichage » à la place des trois autres) : personne ne
+32
View File
@@ -258,3 +258,35 @@ func TestCmdExportEcritLeFichier(t *testing.T) {
t.Fatal("option inconnue acceptée")
}
}
// L'export JSON d'un message multimodal (vision) doit ÉLIDER le base64 des images :
// sinon le fichier pesait plusieurs Mo par image. Le Markdown, lui, n'a jamais porté
// les images (juste les noms de fichiers).
func TestExportJSONElideImages(t *testing.T) {
t.Cleanup(func() { conv.Reset() })
big := strings.Repeat("A", 300000)
conv.mu.Lock()
conv.Messages = []Message{
{Role: "user", Content: []map[string]any{
{"type": "text", "text": "regarde"},
{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64," + big}},
}},
{Role: "assistant", Content: "vu"},
}
conv.Log = []LogEvent{{Seq: 1, TS: 1, Delta: map[string]any{"user": "regarde"}}}
conv.Seq = 1
conv.mu.Unlock()
b, err := conv.ExportJSON(defaultExportOpts())
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), big) {
t.Error("le base64 brut d'une image est encore présent dans le JSON")
}
if len(b) > 10000 {
t.Errorf("JSON toujours énorme malgré l'élision : %d octets", len(b))
}
if !strings.Contains(string(b), "élidé") {
t.Error("marqueur d'élision absent du JSON")
}
}
+104 -17
View File
@@ -3,6 +3,7 @@ package loki
import (
"errors"
"fmt"
"math"
"os"
"path/filepath"
"regexp"
@@ -98,36 +99,93 @@ func MemList() []MemPage {
// MemSearch cherche les termes de la requête dans le nom + le contenu de chaque
// page, renvoie une liste classée {fichier, titre, extrait}. Comme un moteur de
// recherche : à compléter par mem_read sur la page la plus pertinente.
//
// Classement en deux temps, pour ne PAS laisser une grosse page bourrée d'un mot
// fréquent écraser une petite page qui matche VRAIMENT la requête :
// 1. Couverture d'abord : les pages qui contiennent le PLUS de termes distincts
// de la requête passent devant (chercher « copine Nathan » doit remonter la
// page qui a les deux mots, pas celle qui répète « Nathan » 40 fois).
// 2. À couverture égale, score pondéré par la RARETÉ du terme (IDF) : un mot
// rare et discriminant (« copine ») pèse bien plus qu'un mot omniprésent
// (« Nathan »). La fréquence est amortie (log) pour que le bourrage ne gagne
// pas, et un match dans le NOM ou le TITRE est fortement bonifié.
func MemSearch(query string, limit int) []MemHit {
if limit <= 0 || limit > 30 {
limit = 8
}
terms := strings.Fields(strings.ToLower(query))
type scored struct {
hit MemHit
score int
terms := uniqueTerms(strings.ToLower(query))
type doc struct {
p MemPage
name string // minuscules
title string // minuscules
content string // brut (pour l'extrait)
hay string // minuscules : nom + contenu
}
var ranked []scored
docs := make([]doc, 0)
df := make(map[string]int) // nb de pages contenant chaque terme
for _, p := range MemList() {
b, _ := os.ReadFile(filepath.Join(memoryDir(), p.Name))
content := string(b)
hay := strings.ToLower(p.Name + "\n" + content)
score := 0
d := doc{
p: p,
name: strings.ToLower(p.Name),
title: strings.ToLower(p.Title),
content: content,
hay: strings.ToLower(p.Name + "\n" + content),
}
docs = append(docs, d)
for _, t := range terms {
score += strings.Count(hay, t)
if strings.Contains(strings.ToLower(p.Name), t) {
score += 3 // bonus si le terme est dans le nom de la page
if strings.Contains(d.hay, t) {
df[t]++
}
}
if score == 0 && query != "" {
}
n := len(docs)
type scored struct {
hit MemHit
matched int // termes distincts trouvés (clé de tri primaire)
score float64 // pertinence pondérée (clé secondaire)
}
var ranked []scored
for _, d := range docs {
matched := 0
score := 0.0
for _, t := range terms {
cnt := strings.Count(d.hay, t)
if cnt == 0 {
continue
}
matched++
// IDF : rare = discriminant. +1 pour qu'un terme fréquent compte encore.
idf := math.Log(float64(n+1)/float64(df[t]+1)) + 1
// Fréquence amortie : 10 occurrences ne valent pas 10x une occurrence.
tf := 1.0 + math.Log(float64(cnt))
field := 1.0
if strings.Contains(d.name, t) {
field += 4 // le terme est dans le NOM de la page
}
if strings.Contains(d.title, t) {
field += 2 // ... ou dans son TITRE
}
score += idf * tf * field
}
if matched == 0 && len(terms) > 0 {
continue
}
ranked = append(ranked, scored{
hit: MemHit{File: p.Name, Title: p.Title, Snippet: snippetAround(content, terms)},
score: score,
hit: MemHit{File: d.p.Name, Title: d.p.Title, Snippet: snippetAround(d.content, terms)},
matched: matched,
score: score,
})
}
sort.SliceStable(ranked, func(i, j int) bool { return ranked[i].score > ranked[j].score })
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].matched != ranked[j].matched {
return ranked[i].matched > ranked[j].matched // couverture d'abord
}
return ranked[i].score > ranked[j].score
})
out := []MemHit{}
for i, r := range ranked {
if i >= limit {
@@ -138,6 +196,19 @@ func MemSearch(query string, limit int) []MemHit {
return out
}
// uniqueTerms découpe une requête en mots distincts (dédupliqués, ordre gardé).
func uniqueTerms(q string) []string {
seen := map[string]bool{}
var out []string
for _, t := range strings.Fields(q) {
if !seen[t] {
seen[t] = true
out = append(out, t)
}
}
return out
}
// snippetAround renvoie un court extrait autour de la 1re occurrence d'un terme.
func snippetAround(content string, terms []string) string {
flat := strings.Join(strings.Fields(content), " ")
@@ -211,7 +282,13 @@ func MemAdd(name, content string) error {
return err
}
body := strings.TrimRight(content, "\n") + "\n"
return os.WriteFile(p, []byte(body), 0o644)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
return err
}
// L'index MEMORY.md est tenu par le CODE (mem_index.go) : une page créée y
// apparaît toujours, même si le modèle oublie de l'y écrire.
memIndexAdd(name)
return nil
}
// MemEdit remplace oldText par newText dans une page. oldText doit apparaître
@@ -284,7 +361,13 @@ func MemSave(name, old, content string) error {
return err
}
body := strings.TrimRight(content, "\n") + "\n"
return os.WriteFile(p, []byte(body), 0o644)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
return err
}
// Renommage depuis l'éditeur web : l'index suit (ancienne ligne retirée,
// nouvelle ajoutée). Une simple sauvegarde n'ajoute que si la ligne manquait.
memIndexRename(old, name)
return nil
}
// MemDelete supprime une page mémoire.
@@ -296,7 +379,11 @@ func MemDelete(name string) error {
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("introuvable")
}
return os.Remove(p)
if err := os.Remove(p); err != nil {
return err
}
memIndexRemove(name)
return nil
}
// MemMode gouverne l'accès de l'IA à sa mémoire persistante, indépendamment du
+50
View File
@@ -0,0 +1,50 @@
package loki
import (
"strings"
"testing"
)
// Reproduit le bug rapporté : une petite page dont le NOM et le TITRE contiennent
// « copine » ne remontait pas pour la requête « copine Nathan », noyée par de
// grosses pages qui répètent « Nathan » des dizaines de fois. Le classement doit
// mettre en tête la page qui matche le PLUS de termes distincts (couverture),
// puis pondérer par la rareté.
func TestMemSearchCoverageBeatsFrequency(t *testing.T) {
testHome(t)
// Deux pages « bruyantes » bourrées de « Nathan », sans « copine ».
if err := MemAdd("business-nathan.md", "# Business Nathan\n"+strings.Repeat("Nathan veut un business. ", 40)); err != nil {
t.Fatal(err)
}
if err := MemAdd("guide-ajean.md", "# Guide AJEAN\n"+strings.Repeat("projet de Nathan. ", 30)); err != nil {
t.Fatal(err)
}
// La petite page cible : matche les DEUX termes de la requête.
if err := MemAdd("profil-copine-nathan.md", "# Profil : Copine de Nathan\n- Prénom : Bao\n"); err != nil {
t.Fatal(err)
}
hits := MemSearch("copine Nathan", 8)
if len(hits) == 0 {
t.Fatal("aucun résultat pour « copine Nathan »")
}
if hits[0].File != "profil-copine-nathan.md" {
var names []string
for _, h := range hits {
names = append(names, h.File)
}
t.Fatalf("la page copine devrait être 1re (matche les 2 termes), obtenu ordre : %v", names)
}
}
// Une requête mono-terme rare doit privilégier un match dans le nom/titre.
func TestMemSearchNameTitleBoost(t *testing.T) {
testHome(t)
_ = MemAdd("notes-diverses.md", "# Notes\nUn jour Bao est passée dire bonjour, puis repartie.\n")
_ = MemAdd("profil-copine.md", "# Profil : Copine\n- Prénom : Bao\n")
hits := MemSearch("copine", 8)
if len(hits) == 0 || hits[0].File != "profil-copine.md" {
t.Fatalf("la page dont le nom/titre porte « copine » devrait être 1re, obtenu : %+v", hits)
}
}
+13 -1
View File
@@ -17,7 +17,19 @@ import (
//
// Approximation 1 tok ≈ 4 caractères : suffisante pour une alerte, et sans
// dépendance à un tokenizer.
const promptCharBudget = 7500 // ~1875 tokens, tout allumé
//
// Le budget n'a été relevé que pour de VRAIS outils nouveaux, jamais pour des
// lignes de prompt — et chaque palier dit lequel :
//
// 7500 → 7900 : recall + recall_search (chat_recall.go), ~720 car. Le compactage
// cesse d'être destructif : le modèle ne relance plus les lectures
// et recherches déjà faites. Il récupère bien plus qu'il ne coûte.
// 7900 → 8600 : tracker (tracker.go), ~620 car. Un seul schéma pour les quatre
// actions — quatre outils auraient coûté quatre fois ça.
//
// Descriptions déjà réduites au strict nécessaire dans les deux cas. La consigne
// d'origine tient pour la suite : ce budget ne se relève pas pour du prompt.
const promptCharBudget = 8600 // ~2150 tokens, tout allumé
func TestSystemPromptStaysLean(t *testing.T) {
caps := Caps{Agent: true, Internet: true, Mem: MemAlways}
+315
View File
@@ -0,0 +1,315 @@
package loki
import (
"encoding/binary"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
bolt "go.etcd.io/bbolt"
)
// chat_recall.go — mémoire longue de la conversation.
//
// Le compactage résume les vieux tours pour libérer la fenêtre, mais un résumé
// perd forcément du détail (un gros code, une longue page web, un diff). Plutôt
// que de le perdre pour de bon, on ARCHIVE chaque gros bloc AVANT de le
// compacter, sous un identifiant court et stable (r7, r42…). Le résumé cite l'id
// à la place du bloc (« code du jeu fourni → recall:r7 »), et le modèle peut le
// rappeler VERBATIM quand il en a besoin :
//
// recall(id) → ramène le bloc exact, tel qu'il était.
// recall_search(query) → retrouve un bloc (et son id) par mots-clés, même très
// ancien et disparu du résumé courant.
//
// L'archive est GLOBALE (un compteur monotone via bbolt NextSequence), pas liée
// à une discussion : un id ne pointe jamais que vers un seul bloc, pour toujours.
// Elle vit sur disque (bucket bkRecall) donc survit aux redémarrages et au
// multi-appareils. Le contexte, lui, ne porte que les ids ENCORE cités par le
// résumé courant : sa taille reste plate même sur une conversation infinie,
// pendant que l'archive grossit tranquillement sur disque (bon marché).
//
// En mode code, c'est ce qui rend une longue session tenable : un fichier lu ou
// un diff produit tôt dans la session n'est plus perdu au compactage suivant.
const (
// Un bloc du torse plus long que ça (en caractères) est archivé et remplacé
// par son id. En dessous, le résumé le porte très bien tout seul : pas la peine
// de créer un id pour trois lignes.
recallArchiveMinLen = 800
// Longueur de l'extrait de tête laissé au résumeur pour un bloc archivé : de
// quoi le résumer fidèlement, le reste étant récupérable par recall.
recallSummaryHeadLen = 400
// Nombre de résultats par défaut / max pour recall_search.
recallSearchDefault = 5
recallSearchMax = 20
)
// recallBlock est un bloc archivé, adressable par son id.
type recallBlock struct {
ID string `json:"id"` // "r<seq>"
Seq uint64 `json:"seq"` // séquence brute (tri, clé)
Label string `json:"label"` // libellé court lisible (rôle + première ligne)
Role string `json:"role"` // rôle d'origine (tool/user/assistant)
Content string `json:"content"` // contenu VERBATIM
Created int64 `json:"created"` // unix, pour un éventuel ménage futur
}
// recallKey encode une séquence en clé bbolt triable (big-endian).
func recallKey(seq uint64) []byte {
k := make([]byte, 8)
binary.BigEndian.PutUint64(k, seq)
return k
}
// recallIDToSeq lit "r<n>" → n. Tolère un id sans préfixe et les espaces : le
// modèle écrit aussi bien recall("r7") que recall("7") ou recall("R7 ").
func recallIDToSeq(id string) (uint64, bool) {
s := strings.TrimSpace(id)
s = strings.TrimPrefix(s, "r")
s = strings.TrimPrefix(s, "R")
n, err := strconv.ParseUint(strings.TrimSpace(s), 10, 64)
if err != nil {
return 0, false
}
return n, true
}
// archiveRecallBlock enregistre un bloc et renvoie son id ("r<seq>"). L'id est
// alloué par NextSequence dans la transaction d'écriture : monotone, sans
// collision, même si deux compactages se croisent. En cas d'échec d'écriture
// (base indisponible, ex. tests sans LOKI_HOME) on renvoie une erreur et
// l'appelant se rabat sur le compactage classique (troncature sans id).
func archiveRecallBlock(label, role, content string) (string, error) {
var id string
err := withDB(func(d *bolt.DB) error {
return d.Update(func(tx *bolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(bkRecall))
if err != nil {
return err
}
seq, err := b.NextSequence()
if err != nil {
return err
}
blk := recallBlock{
ID: "r" + strconv.FormatUint(seq, 10),
Seq: seq,
Label: label,
Role: role,
Content: content,
Created: time.Now().Unix(),
}
id = blk.ID
raw, err := json.Marshal(blk)
if err != nil {
return err
}
return b.Put(recallKey(seq), raw)
})
})
if err != nil {
return "", err
}
cacheBust(bkRecall)
return id, nil
}
// recallGet ramène un bloc par son id. false si l'id est mal formé ou absent.
func recallGet(id string) (recallBlock, bool) {
seq, ok := recallIDToSeq(id)
if !ok {
return recallBlock{}, false
}
var blk recallBlock
found := false
_ = view(bkRecall, func(b *bolt.Bucket) error {
v := b.Get(recallKey(seq))
if v == nil {
return nil
}
found = json.Unmarshal(v, &blk) == nil
return nil
})
return blk, found
}
// recallSearch cherche dans toute l'archive par recouvrement de mots-clés.
// Score = nombre de termes distincts de la requête présents dans (libellé +
// contenu), le libellé comptant double (un mot du titre est plus signifiant).
// Simple, lexical, zéro dépendance — l'issue de secours pour un bloc sorti du
// résumé courant. À égalité, le plus RÉCENT d'abord (seq décroissante).
func recallSearch(query string, limit int) []recallBlock {
terms := recallTokens(query)
if len(terms) == 0 {
return nil
}
if limit <= 0 {
limit = recallSearchDefault
}
if limit > recallSearchMax {
limit = recallSearchMax
}
type scored struct {
blk recallBlock
score int
}
var hits []scored
_ = view(bkRecall, func(b *bolt.Bucket) error {
return b.ForEach(func(_, v []byte) error {
var blk recallBlock
if json.Unmarshal(v, &blk) != nil {
return nil
}
label := strings.ToLower(blk.Label)
body := strings.ToLower(blk.Content)
score := 0
for t := range terms {
if strings.Contains(label, t) {
score += 2
}
if strings.Contains(body, t) {
score++
}
}
if score > 0 {
hits = append(hits, scored{blk, score})
}
return nil
})
})
sort.Slice(hits, func(i, j int) bool {
if hits[i].score != hits[j].score {
return hits[i].score > hits[j].score
}
return hits[i].blk.Seq > hits[j].blk.Seq
})
if len(hits) > limit {
hits = hits[:limit]
}
out := make([]recallBlock, len(hits))
for i, h := range hits {
out[i] = h.blk
}
return out
}
// recallTokens éclate une chaîne en un ensemble de mots-clés minuscules (>= 2
// caractères), en coupant sur tout ce qui n'est ni lettre ni chiffre.
func recallTokens(s string) map[string]struct{} {
out := map[string]struct{}{}
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ')
}) {
if len([]rune(w)) >= 2 {
out[w] = struct{}{}
}
}
return out
}
// recallLabel fabrique un libellé court et lisible pour un bloc, à partir de son
// rôle (préfixé du nom d'outil quand on le connaît) et de sa première ligne.
func recallLabel(role, toolName, content string) string {
head := ""
for _, line := range strings.Split(content, "\n") {
if s := strings.TrimSpace(line); s != "" {
head = s
break
}
}
if r := []rune(head); len(r) > 70 {
head = strings.TrimSpace(string(r[:70])) + "…"
}
tag := role
if role == "tool" && toolName != "" {
tag = toolName
}
if head == "" {
return tag
}
return tag + ": " + head
}
// toolRecall ramène un bloc archivé pour le modèle (outil recall).
func toolRecall(args map[string]any) string {
id, _ := args["id"].(string)
if strings.TrimSpace(id) == "" {
return "[erreur] id manquant"
}
blk, ok := recallGet(id)
if !ok {
return fmt.Sprintf("[erreur] bloc %q introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés", id)
}
return fmt.Sprintf("[%s — %s, rappelé verbatim]\n%s", blk.ID, blk.Label, blk.Content)
}
// toolRecallSearch retrouve des blocs par mots-clés (outil recall_search).
func toolRecallSearch(args map[string]any) string {
q, _ := args["query"].(string)
if strings.TrimSpace(q) == "" {
return "[erreur] query manquante"
}
lim := 0
if v, ok := args["limit"].(float64); ok {
lim = int(v)
}
hits := recallSearch(q, lim)
if len(hits) == 0 {
return "[aucun bloc archivé ne correspond]"
}
var b strings.Builder
b.WriteString("Blocs trouvés (rappelle le bon avec recall(id)) :\n")
for _, h := range hits {
fmt.Fprintf(&b, "- %s — %s\n %s\n", h.ID, h.Label, recallSnippet(h.Content, 160))
}
return strings.TrimRight(b.String(), "\n")
}
// recallSnippet renvoie un extrait d'une ligne, borné, pour l'aperçu de recherche.
func recallSnippet(s string, n int) string {
s = strings.Join(strings.Fields(s), " ")
if r := []rune(s); len(r) > n {
return strings.TrimSpace(string(r[:n])) + "…"
}
return s
}
// recallTool / recallSearchTool : schémas annoncés au modèle en mode agent.
func recallTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "recall",
Description: "Bring back verbatim an earlier block archived when this conversation was compacted, from the id shown as recall:rN in the summary.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "Block id, e.g. \"r7\"."},
},
"required": []string{"id"},
},
},
}
}
func recallSearchTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "recall_search",
Description: "Find an archived block and its recall id by keywords, when it is not listed in the current summary. Then call recall(id).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{"type": "string", "description": "Keywords describing the block."},
"limit": map[string]any{"type": "integer", "description": fmt.Sprintf("Default %d, max %d", recallSearchDefault, recallSearchMax)},
},
"required": []string{"query"},
},
},
}
}
+223
View File
@@ -0,0 +1,223 @@
package loki
import (
"strings"
"testing"
)
// bigBlock fabrique un contenu franchement au-dessus de recallArchiveMinLen,
// pour qu'il soit archivé plutôt que simplement porté par le résumé.
func bigBlock(marker string) string {
return marker + "\n" + strings.Repeat("ligne de contenu volumineux à conserver verbatim\n", 40)
}
// Un bloc archivé doit revenir MOT POUR MOT : c'est toute la promesse de recall.
// Un résumé approximatif suffisait déjà avant ; ce qu'on ajoute ici, c'est
// l'exactitude — un gros code ou un diff ne survit pas à une reformulation.
func TestRecallRendVerbatim(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
content := bigBlock("MARQUEUR-UNIQUE-42")
id, err := archiveRecallBlock("web_read: page", "tool", content)
if err != nil {
t.Fatalf("archivage impossible : %v", err)
}
if !strings.HasPrefix(id, "r") {
t.Fatalf("id mal formé : %q", id)
}
blk, ok := recallGet(id)
if !ok {
t.Fatalf("bloc %q introuvable juste après son archivage", id)
}
if blk.Content != content {
t.Fatal("le contenu rappelé diffère de l'original : l'archive n'est pas verbatim")
}
// Le passage par l'outil (ce que voit le modèle) doit aussi porter le contenu.
if got := toolRecall(map[string]any{"id": id}); !strings.Contains(got, "MARQUEUR-UNIQUE-42") {
t.Fatalf("l'outil recall ne rend pas le bloc : %q", got)
}
}
// Les ids doivent être uniques et monotones : deux compactages qui se croisent ne
// doivent jamais écraser le bloc de l'autre. NextSequence le garantit côté bbolt ;
// ce test verrouille qu'on s'en sert bien.
func TestRecallIDsUniquesEtMonotones(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
vus := map[string]bool{}
var last uint64
for i := 0; i < 5; i++ {
id, err := archiveRecallBlock("bloc", "tool", bigBlock("contenu"))
if err != nil {
t.Fatalf("archivage %d : %v", i, err)
}
if vus[id] {
t.Fatalf("id %q réutilisé : un bloc en écrase un autre", id)
}
vus[id] = true
seq, ok := recallIDToSeq(id)
if !ok {
t.Fatalf("id illisible : %q", id)
}
if i > 0 && seq <= last {
t.Fatalf("séquence non monotone : %d après %d", seq, last)
}
last = seq
}
}
// recallIDToSeq doit tolérer ce que le modèle écrit réellement : avec ou sans
// préfixe, majuscule, espaces. Un id refusé pour un espace en trop enverrait le
// modèle croire que son bloc a disparu.
func TestRecallIDToSeqTolerant(t *testing.T) {
for _, in := range []string{"r7", "R7", " r7 ", "7"} {
if seq, ok := recallIDToSeq(in); !ok || seq != 7 {
t.Fatalf("%q → (%d, %v), attendu (7, true)", in, seq, ok)
}
}
for _, in := range []string{"", "rr", "abc", "r-1"} {
if _, ok := recallIDToSeq(in); ok {
t.Fatalf("%q accepté alors qu'il est invalide", in)
}
}
}
// recall_search est l'issue de secours quand l'id n'est plus dans le résumé
// courant : le bloc doit rester trouvable par mots-clés, et l'outil doit rendre
// son id pour que le modèle puisse enchaîner sur recall(id).
func TestRecallSearchRetrouveParMotsCles(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
if _, err := archiveRecallBlock("web_read: recette", "tool", bigBlock("gratin dauphinois")); err != nil {
t.Fatal(err)
}
wanted, err := archiveRecallBlock("web_read: horaires", "tool", bigBlock("le train de 14h12 part quai 3"))
if err != nil {
t.Fatal(err)
}
hits := recallSearch("train horaires", 0)
if len(hits) == 0 {
t.Fatal("aucun résultat pour des mots-clés pourtant présents")
}
if hits[0].ID != wanted {
t.Fatalf("meilleur résultat %q, attendu %q (le libellé doit compter double)", hits[0].ID, wanted)
}
out := toolRecallSearch(map[string]any{"query": "train horaires"})
if !strings.Contains(out, wanted) {
t.Fatalf("l'outil recall_search ne rend pas l'id à rappeler : %q", out)
}
if n := len(recallSearch("motabsolumentabsent", 0)); n != 0 {
t.Fatalf("%d bloc(s) remontent pour un terme absent", n)
}
}
// Le cœur du lot : un gros bloc du torse doit être ARCHIVÉ avant compaction, et
// l'historique compacté doit porter son id. Sans llama-server en test, le résumé
// échoue et on tombe sur le repli dégraissé — c'est justement le chemin où
// l'ancien code effaçait le contenu sans laisser d'adresse.
func TestCompactArchiveLesGrosBlocs(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
page := func(n int) Message {
return tm(bigBlock("page numéro " + string(rune('a'+n))))
}
msgs := []Message{um("cherche les horaires du train pour Lyon")}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), page(i))
}
// caps.Agent : sans le mode agent, le modèle n'aurait pas l'outil recall et on
// n'archive délibérément rien.
out, changed := compactMessages(t.Context(), msgs, Caps{Agent: true})
if !changed {
t.Fatal("aucune compaction alors que le torse est plein de grosses pages")
}
joined := ""
for _, m := range out {
joined += msgText(m) + "\n"
}
if !strings.Contains(joined, "recall(") {
t.Fatalf("l'historique compacté ne cite aucun bloc rappelable :\n%s", joined)
}
if strings.Contains(joined, compactPrunedMarker) {
t.Fatal("un gros bloc a été effacé sans adresse de rappel alors qu'il était archivable")
}
// Et le contenu doit être réellement récupérable, pas seulement cité.
hits := recallSearch("page numéro", 0)
if len(hits) == 0 {
t.Fatal("les blocs cités dans l'historique ne sont pas dans l'archive")
}
if !strings.Contains(hits[0].Content, "ligne de contenu volumineux") {
t.Fatal("le bloc archivé ne contient pas le texte d'origine")
}
}
// Sans mode agent, le modèle n'a pas l'outil recall : archiver produirait des ids
// qu'il ne peut pas utiliser, et un résumé pollué de références mortes.
func TestCompactNArchivePasSansAgent(t *testing.T) {
t.Setenv("LOKI_HOME", t.TempDir())
msgs := []Message{um("question")}
for i := 0; i < 10; i++ {
msgs = append(msgs, atc("web_read"), tm(bigBlock("page")))
}
out, _ := compactMessages(t.Context(), msgs, Caps{})
for _, m := range out {
if strings.Contains(msgText(m), "recall(") {
t.Fatal("des références recall sont proposées alors que l'outil n'est pas fourni")
}
}
if len(recallSearch("page", 0)) != 0 {
t.Fatal("des blocs ont été archivés hors mode agent")
}
}
// recallEligible décide ce qui mérite un id. Deux pièges : les petits blocs (le
// résumé les porte très bien) et le résumé d'une compaction PRÉCÉDENTE, qu'on
// ré-archiverait à chaque passe en faisant enfler l'archive pour rien.
func TestRecallEligible(t *testing.T) {
gros := strings.Repeat("x", recallArchiveMinLen+1)
if !recallEligible(tm(gros)) {
t.Fatal("un gros résultat d'outil doit être archivé")
}
if recallEligible(tm("court")) {
t.Fatal("un petit bloc ne mérite pas un id : le résumé le porte")
}
if recallEligible(Message{Role: "system", Content: gros}) {
t.Fatal("un message système n'a rien à faire dans l'archive")
}
if recallEligible(um(compactSummaryPrefix + " " + gros)) {
t.Fatal("le résumé d'une compaction précédente serait ré-archivé à chaque passe")
}
}
// Garde-fou anti-résumé-dégénéré : un petit modèle mal aiguillé répond parfois
// par un simple « recall:r7 » au lieu d'écrire de la prose. Accepter ça, c'est
// remplacer tout le passé de la conversation par une référence vide.
func TestSummaryLooksEmpty(t *testing.T) {
vrai := "L'utilisateur cherche les horaires du train pour Lyon ; le train de 14h12 part quai 3, reste à confirmer le tarif."
if summaryLooksEmpty(vrai) {
t.Fatalf("un vrai résumé est rejeté : %q", vrai)
}
for _, degenere := range []string{"", " ", "recall:r7", "recall:r7 recall:r8 recall:r9", "voir recall(r7)."} {
if !summaryLooksEmpty(degenere) {
t.Fatalf("résumé dégénéré accepté : %q", degenere)
}
}
}
// compactSummaryUserMsg porte l'index des ids rappelables. Il doit rester borné
// à la passe courante : sinon une conversation infinie ferait enfler cette
// section jusqu'à annuler le bénéfice de la compaction.
func TestCompactSummaryUserMsg(t *testing.T) {
sans := compactSummaryUserMsg("résumé quelconque", nil)
if !strings.HasPrefix(sans, compactSummaryPrefix) {
t.Fatal("le message de compaction doit être reconnaissable à son préfixe")
}
if strings.Contains(sans, "recall(") {
t.Fatal("recall est proposé alors qu'aucun bloc n'a été archivé")
}
avec := compactSummaryUserMsg("résumé quelconque", []recallEntry{{id: "r7", label: "web_read: horaires"}})
if !strings.Contains(avec, "r7") || !strings.Contains(avec, "web_read: horaires") {
t.Fatalf("l'index des blocs rappelables est absent :\n%s", avec)
}
}
+59 -20
View File
@@ -20,6 +20,7 @@ import (
"encoding/json"
"fmt"
"net/http"
neturl "net/url"
"os"
"os/exec"
"path/filepath"
@@ -30,28 +31,24 @@ import (
"time"
)
// captureDir : sous-dossier du workspace où atterrissent les captures. Elles
// sont rangées PAR DISCUSSION (captures/<id>/…) pour que supprimer une
// discussion supprime aussi ses images — sinon elles s'accumulaient sur le
// disque sans qu'aucun écran ne les mentionne plus.
// captureDir : sous-dossier où atterrissent les captures, À L'INTÉRIEUR du
// dossier de la discussion (discussions/<id>/captures/…) — comme les dépôts.
// Supprimer une discussion emporte donc ses images, qui sinon s'accumulaient
// sur le disque sans qu'aucun écran ne les mentionne plus.
const captureDir = "captures"
// captureDirFor renvoie le dossier de captures d'une discussion (chemin absolu)
// et son préfixe relatif, celui qui sert dans les URLs d'affichage.
// et son préfixe relatif, celui qui sert dans les URLs d'affichage. Le relatif
// part du dossier de la DISCUSSION : /api/chat/image résout d'abord là
// (workspaceFile), et les liens des anciens messages — en captures/<id>/… —
// continuent d'ouvrir leur image par le chemin de repli.
func captureDirFor(convID string) (abs, rel string) {
rel = captureDir + "/" + convID
return filepath.Join(agentWorkspace(), captureDir, convID), rel
return filepath.Join(convDirFor(convID), captureDir), captureDir
}
// dropConvCaptures supprime les captures d'une discussion. Appelé quand on la
// supprime ou qu'on la vide. Best-effort : un échec ne doit rien interrompre.
func dropConvCaptures(convID string) {
if convID == "" {
return
}
abs, _ := captureDirFor(convID)
_ = os.RemoveAll(abs)
}
// Supprimer les captures d'une discussion n'a plus de fonction à soi : elles
// vivent dans son dossier, et dropConvFiles (chat_convfiles.go) l'emporte en
// entier — dépôts et fichiers écrits par l'agent compris.
// screenshotTimeout : une page lente ne doit pas bloquer le tour. Playwright a
// son propre délai interne, celui-ci est le garde-fou externe.
@@ -109,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
@@ -124,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
}
@@ -147,6 +154,12 @@ func engineSeesImages() bool {
// où un base64 de plusieurs dizaines de milliers de tokens était rejoué à
// chaque tour jusqu'à dépasser définitivement le contexte ;
// - garantir l'invariant à l'avenir, quel que soit le chemin d'écriture.
//
// Le texte restant reçoit imageLostMarker : sans lui, une conversation rouverte
// ne garde que la légende (« Capture … », « Image demandée (x.png) : ») et le
// modèle n'a plus aucun indice qu'il A VU une image — il la redécrit de mémoire
// au lieu d'y revenir. Même raison que dans msgText, autre bout de la chaîne.
// Vaut pour toutes les provenances : capture, pièce jointe, see_image.
func stripImageParts(msgs []Message) []Message {
for i, m := range msgs {
parts, ok := m.Content.([]any)
@@ -169,7 +182,7 @@ func stripImageParts(msgs []Message) []Message {
}
}
if dropped {
msgs[i].Content = strings.Join(texts, "\n")
msgs[i].Content = strings.Join(texts, "\n") + imageLostMarker
}
}
return msgs
@@ -188,7 +201,10 @@ func screenshotImageMessage(relPath string) (Message, bool) {
if !visionEnabled() || !engineSeesImages() {
return Message{}, false
}
abs := filepath.Join(agentWorkspace(), filepath.FromSlash(relPath))
abs, ok := workspaceFile(relPath)
if !ok {
return Message{}, false
}
mime := imageMime(abs)
if mime == "" {
return Message{}, false
@@ -233,7 +249,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)."
@@ -246,6 +262,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.
@@ -388,3 +411,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")
}
+37 -3
View File
@@ -139,7 +139,9 @@ func TestScreenshotSuitLEtatDeLaVision(t *testing.T) {
// Un base64 d'image persisté dans l'historique est rejoué à chaque tour : vu en
// production, 55 000 tokens de requête pour 32 768 de contexte — plus aucun
// tour ne passait. stripImageParts guérit les conversations existantes en
// retirant les parties image et en aplatissant le texte restant.
// retirant les parties image et en aplatissant le texte restant — en laissant
// imageLostMarker, pour que le modèle sache qu'une image A ÉTÉ montrée et qu'il
// doit en reprendre une plutôt que la redécrire de mémoire.
func TestStripImagePartsGueritLHistorique(t *testing.T) {
msgs := []Message{
{Role: "user", Content: "bonjour"}, // simple chaîne : intouchée
@@ -153,8 +155,40 @@ func TestStripImagePartsGueritLHistorique(t *testing.T) {
t.Fatalf("message texte modifié : %#v", out[0].Content)
}
got, ok := out[1].Content.(string)
if !ok || got != "Voici la capture demandée." {
t.Fatalf("le message multimodal doit être aplati en texte sans l'image, obtenu %#v", out[1].Content)
if !ok || got != "Voici la capture demandée."+imageLostMarker {
t.Fatalf("le message multimodal doit être aplati en texte, sans l'image mais avec le marqueur de perte, obtenu %#v", out[1].Content)
}
if strings.Contains(got, "base64") {
t.Fatalf("le base64 de l'image ne doit plus apparaître : %q", got)
}
}
// msgText est le point de passage de TOUTE la chaîne de compaction (estimation
// du contexte, transcript donné au résumeur). S'il n'extrait que les parties
// `text`, un message qui porte une capture ne pèse que sa légende et le résumeur
// ignore jusqu'à l'existence de l'image : elle disparaît sans laisser de trace.
func TestMsgTextSignaleLaPresenceDUneImage(t *testing.T) {
// Message VIVANT tel que construit par screenshotImageMessage ([]map[string]any).
live := Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": "Capture demandée :"},
{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
}}
// Même message RELU depuis le JSON persisté ([]any de map génériques).
reloaded := Message{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "Capture demandée :"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/jpeg;base64,AAAA"}},
}}
for name, m := range map[string]Message{"vivant": live, "relu": reloaded} {
got := msgText(m)
if !strings.Contains(got, "Capture demandée :") {
t.Fatalf("%s : le texte de la légende doit être conservé, obtenu %q", name, got)
}
if !strings.Contains(got, imageLostMarker) {
t.Fatalf("%s : la présence de l'image doit être signalée, obtenu %q", name, got)
}
if strings.Contains(got, "base64") {
t.Fatalf("%s : le base64 ne doit jamais entrer dans le transcript : %q", name, got)
}
}
}
+92 -8
View File
@@ -11,6 +11,10 @@ package loki
// bkChat/active → identifiant de la discussion ouverte
// bkChat/conv:<id> → l'état complet d'une discussion (JSON de Conversation)
//
// Les FICHIERS suivent le même découpage, sur le disque cette fois :
// <workspace>/discussions/<id>/ (chat_convfiles.go). Supprimer une discussion
// supprime donc aussi ses fichiers.
//
// Basculer = enregistrer la discussion courante, charger l'autre en mémoire et
// incrémenter l'epoch : les abonnés SSE reçoivent alors {reset:true} et
// rejouent le nouveau fil depuis zéro. C'est le mécanisme déjà utilisé par
@@ -49,10 +53,52 @@ type convMeta struct {
Created int64 `json:"created"`
Updated int64 `json:"updated"`
Turns int `json:"turns"`
// Project = slug du projet auquel la discussion appartient (projects.go). Vide
// sur les discussions d'avant les projets : ensureDefaultProject les rattache à
// « Générale » au premier démarrage, et convIndexForProject traite un champ vide
// comme le projet par défaut pour que rien ne disparaisse entre-temps.
Project string `json:"project,omitempty"`
}
func convKey(id string) string { return "conv:" + id }
// convProjectOf renvoie le projet d'une discussion, en traitant l'absence comme
// le projet par défaut : une discussion sans projet ne doit jamais devenir
// invisible.
func convProjectOf(m convMeta) string {
if s := strings.TrimSpace(m.Project); s != "" {
return s
}
return defaultProjectSlug
}
// convIndexForProject filtre l'index sur un projet.
func convIndexForProject(slug string) []convMeta {
var out []convMeta
for _, m := range convIndex() {
if convProjectOf(m) == slug {
out = append(out, m)
}
}
return out
}
// tagOrphanConversations rattache au projet donné les discussions qui n'en ont
// pas encore (migration, une seule fois : ensuite il n'y a plus d'orphelines).
func tagOrphanConversations(slug string) {
idx := convIndex()
changed := false
for i := range idx {
if strings.TrimSpace(idx[i].Project) == "" {
idx[i].Project = slug
changed = true
}
}
if changed {
convIndexSave(idx)
}
}
func convIndex() []convMeta {
var idx []convMeta
if b := getBytes(bkChat, ckIndex); len(b) > 0 {
@@ -86,7 +132,7 @@ func convEnsureActive() string {
}
_ = putStr(bkChat, ckActive, id)
now := time.Now().Unix()
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
return id
}
@@ -112,7 +158,7 @@ func convTouchMeta(id string, title string, turns int) {
convIndexSave(idx)
return
}
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns}))
convIndexSave(append(idx, convMeta{ID: id, Title: title, Created: now, Updated: now, Turns: turns, Project: activeProjectSlug()}))
}
// convSummary lit le premier message utilisateur pour en faire un titre. Sans
@@ -138,8 +184,40 @@ func convSummary(msgs []Message) string {
return ""
}
// ConvList renvoie les discussions et l'identifiant de l'active (pour l'UI).
func ConvList() ([]convMeta, string) { return convIndex(), convEnsureActive() }
// ConvList renvoie les discussions DU PROJET ACTIF et l'identifiant de l'active
// (pour l'UI). Le cloisonnement est ici : une discussion d'un autre projet n'a
// aucune raison d'apparaître dans la barre latérale.
func ConvList() ([]convMeta, string) {
return convIndexForProject(activeProjectSlug()), convEnsureActive()
}
// projectSwitch bascule sur un autre projet ET ouvre une de ses discussions : la
// plus récemment modifiée, ou une neuve s'il n'en a aucune. Sans ça, changer de
// projet laisserait à l'écran une discussion qui n'appartient plus à la liste
// affichée — et la mémoire (memoryDir) aurait changé sous ses pieds.
func projectSwitch(slug string) error {
if !projectExists(slug) {
return fmt.Errorf("projet introuvable")
}
convOpMu.Lock()
defer convOpMu.Unlock()
if slug == activeProjectSlug() {
return nil
}
conv.persist() // fige la discussion du projet qu'on quitte
if err := setActiveProject(slug); err != nil {
return err
}
list := convIndexForProject(slug)
if len(list) == 0 {
convCreate() // convIndex trie par date de modification : voir convIndex
return nil
}
// convIndex rend la plus récemment modifiée en tête.
_ = putStr(bkChat, ckActive, list[0].ID)
conv.loadFrom(getBytes(bkChat, convKey(list[0].ID)))
return nil
}
// convSwitch bascule sur une autre discussion : la courante est enregistrée,
// la cible chargée en mémoire, et l'epoch incrémenté pour que tous les clients
@@ -175,7 +253,7 @@ func convSwitch(id string) error {
func convCreate() string {
id := newConvID()
now := time.Now().Unix()
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now}))
convIndexSave(append(convIndex(), convMeta{ID: id, Created: now, Updated: now, Project: activeProjectSlug()}))
_ = putStr(bkChat, ckActive, id)
conv.loadFrom(nil)
return id
@@ -228,9 +306,15 @@ func convDelete(id string) error {
}
convIndexSave(next)
_ = putBytes(bkChat, convKey(id), nil)
// Les captures de cette discussion n'ont plus rien qui les référence : les
// garder occuperait le disque pour toujours.
dropConvCaptures(id)
// 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.
dropConvFiles(id)
if id != getStr(bkChat, ckActive) {
return nil
}
+47 -10
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 := agentWorkspace()
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()
}
@@ -185,16 +201,17 @@ func runShell(parent context.Context, command string, timeoutSec int) string {
ctx, cancel := context.WithTimeout(parent, time.Duration(timeoutSec)*time.Second)
defer cancel()
cmd := newShellCmd(ctx, command)
// Le shell démarre dans le workspace, pas dans le dossier d'où loki a été
// lancé : un `> notes.txt` du modèle ne doit pas atterrir sur le Bureau.
// Le shell démarre dans le dossier de la discussion ouverte, pas dans le
// dossier d'où loki a été lancé : un `> notes.txt` du modèle ne doit pas
// atterrir sur le Bureau, ni dans une AUTRE discussion.
//
// Le dossier est résolu UNE fois par process (agentWorkspace), donc s'il
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même le
// La racine est résolue UNE fois par process (agentWorkspace), donc si elle
// disparaît ensuite — l'utilisateur fait le ménage, ou le modèle lui-même la
// supprime — toutes les commandes suivantes échouaient sur un « chdir : no
// 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 := agentWorkspace(); ws != "" {
if ws := agentCwd(); ws != "" {
if err := os.MkdirAll(ws, 0o755); err == nil {
cmd.Dir = ws
}
@@ -257,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()
@@ -291,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"
@@ -308,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 {
+117
View File
@@ -0,0 +1,117 @@
package loki
// chat_vision_tool.go — l'outil see_image : le modèle charge lui-même une image
// du disque dans sa VISION, sans que l'utilisateur ait à la joindre au message.
//
// Loki savait déjà voir deux choses : une pièce jointe (web_upload.go) et une
// capture d'écran qu'il venait de prendre (chat_screenshot.go). Pas un fichier
// qui dort sur le disque. « Regarde la capture dans ~/photos/bug.png » n'avait
// donc aucune réponse : `read` rend des octets binaires, et le modèle finissait
// par décrire ce qu'il croyait deviner du nom de fichier.
//
// Mécanique : le résultat de l'outil reste un simple texte (un accusé). L'image,
// elle, est réinjectée juste après dans un message utilisateur multimodal
// (image_url) — le SEUL format que llama-server comprenne une fois --mmproj
// chargé, et exactement le chemin déjà emprunté par les pièces jointes et les
// captures.
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
)
// maxVisionBytes borne la taille d'une image chargée dans la vision. Au-delà,
// le base64 gonfle le contexte pour rien : le moteur redimensionne de toute
// façon avant l'encodeur d'images.
const maxVisionBytes = 12 << 20 // 12 Mio
// toolSeeImage lit un fichier image et renvoie (accusé texte, partie image_url).
// La partie image vaut nil en cas d'erreur : l'appelant n'injecte alors rien.
//
// Chaque refus dit CE QUI manque plutôt que « impossible » : sans projecteur, la
// réponse n'est pas la même que sur un poste distant ou un fichier trop lourd,
// et un modèle qui reçoit un motif clair peut corriger son geste lui-même.
func toolSeeImage(path string) (string, map[string]any) {
if !visionEnabled() {
return "[erreur] la vision n'est pas active sur ce preset (aucun projecteur MMPROJ configuré) — impossible de voir une image", nil
}
// Cible = un poste distant : le fichier est LÀ-BAS, pas lisible d'ici.
if agentTargetSlug() != "" {
return "[erreur] voir une image n'est pas possible sur un poste distant (le fichier est sur l'autre machine)", nil
}
if path == "" {
return "[erreur] chemin de fichier manquant", nil
}
abs := resolveAgentPath(path)
mime := imageMime(abs)
if mime == "" {
return "[erreur] format non reconnu comme image (attendu : png, jpg, gif, webp, bmp) : " + path, nil
}
st, err := os.Stat(abs)
if err != nil {
return "[erreur] fichier introuvable : " + path, nil
}
if st.IsDir() {
return "[erreur] c'est un dossier, pas une image : " + path, nil
}
if st.Size() > maxVisionBytes {
return fmt.Sprintf("[erreur] image trop lourde (%s, max %s) : %s",
humanBytes(st.Size()), humanBytes(maxVisionBytes), path), nil
}
// Sonde du moteur EN DERNIER, juste avant le travail coûteux. Elle fait un
// appel réseau (/props, mis en cache 10 s) : la poser d'entrée, c'était payer
// un aller-retour pour répondre « chemin manquant », et surtout masquer les
// motifs précis ci-dessus derrière un « le moteur ne voit pas » qui n'apprend
// rien quand le vrai problème est une faute de frappe dans le chemin.
//
// Elle reste indispensable : un projecteur déclaré ne garantit pas que le
// moteur en service sache traiter une image (mauvais couple modèle/mmproj,
// moteur trop ancien). Autant le dire que d'envoyer 12 Mio de base64 que
// personne ne regardera.
if !engineSeesImages() {
return "[erreur] le moteur en service ne traite pas les images (projecteur non chargé ?) — impossible de voir " + path, nil
}
b, err := os.ReadFile(abs)
if err != nil {
return "[erreur] lecture impossible : " + err.Error(), nil
}
if len(b) == 0 {
return "[erreur] fichier vide : " + path, nil
}
return "[ok] image chargée : " + filepath.Base(abs), map[string]any{
"type": "image_url",
"image_url": map[string]any{
"url": "data:" + mime + ";base64," + base64.StdEncoding.EncodeToString(b),
},
}
}
// seeImageMessage emballe la partie image dans le message utilisateur qui la
// porte jusqu'au modèle. La légende nomme le fichier : après compaction il ne
// restera qu'elle et imageLostMarker, et « Image demandée : » tout court ne
// dirait pas LAQUELLE rouvrir.
func seeImageMessage(label string, img map[string]any) Message {
return Message{Role: "user", Content: []map[string]any{
{"type": "text", "text": "Image demandée (" + label + ") :"},
img,
}}
}
// seeImageTool — schéma envoyé au modèle. Description tenue au plus court : les
// schémas partent dans CHAQUE requête et le préambule a un budget
// (TestSystemPromptStaysLean).
func seeImageTool() Tool {
return Tool{Type: "function", Function: ToolFunction{
Name: "see_image",
Description: "Ouvre un fichier image du disque pour le VOIR (png, jpg, gif, webp, bmp).",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"file": map[string]any{"type": "string", "description": "Chemin de l'image (relatif au dossier de travail, ou absolu)"},
},
"required": []string{"file"},
},
}}
}
+186
View File
@@ -0,0 +1,186 @@
package loki
import (
"encoding/base64"
"os"
"path/filepath"
"strings"
"testing"
)
// pngPixel : un PNG valide d'un pixel, assez pour qu'imageMime le reconnaisse.
var pngPixel, _ = base64.StdEncoding.DecodeString(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==")
// visionOn arme la vision (clé MMPROJ) et renvoie le dossier de travail.
func visionOn(t *testing.T) string {
t.Helper()
home := testHome(t)
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
t.Fatal(err)
}
return home
}
// Sans projecteur, l'outil DIT ce qui manque au lieu d'échouer vaguement — et
// il ne renvoie surtout aucune image.
func TestSeeImageSansVisionRefuseEtExplique(t *testing.T) {
testHome(t)
txt, img := toolSeeImage("photo.png")
if img != nil {
t.Fatal("une image a été chargée alors que la vision est inactive")
}
if !strings.Contains(txt, "MMPROJ") {
t.Fatalf("le refus ne dit pas ce qui manque : %q", txt)
}
}
// Les refus qui ne dépendent pas du moteur : format, absence, dossier, poids.
// Chacun doit nommer son motif — un modèle qui reçoit « impossible » ne peut
// pas corriger son geste, alors qu'un « ce n'est pas une image » se rattrape.
func TestSeeImageMotifsDeRefus(t *testing.T) {
home := visionOn(t)
write := func(name string, b []byte) string {
p := filepath.Join(home, name)
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
return p
}
write("notes.txt", []byte("bonjour"))
if err := os.MkdirAll(filepath.Join(home, "dossier.png"), 0o755); err != nil {
t.Fatal(err)
}
gros := write("gros.png", append(append([]byte(nil), pngPixel...), make([]byte, maxVisionBytes)...))
cas := []struct{ path, attendu string }{
{"", "chemin de fichier manquant"},
{filepath.Join(home, "notes.txt"), "format non reconnu"},
{filepath.Join(home, "absente.png"), "introuvable"},
{filepath.Join(home, "dossier.png"), "dossier"},
{gros, "trop lourde"},
}
for _, c := range cas {
txt, img := toolSeeImage(c.path)
if img != nil {
t.Errorf("%q : une image a été chargée malgré le refus", c.path)
}
if !strings.Contains(txt, c.attendu) {
t.Errorf("%q : motif = %q, attendu contenir %q", c.path, txt, c.attendu)
}
}
}
// Le message porteur nomme le fichier : après compactage il ne restera que sa
// légende et imageLostMarker, et « Image demandée : » tout court ne dirait pas
// LAQUELLE rouvrir.
func TestMessagePorteurNommeLeFichier(t *testing.T) {
img := map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,AAAA"}}
m := seeImageMessage("captures/bug.png", img)
if m.Role != "user" {
t.Fatalf("rôle = %q, attendu user (un message tool ne porte que du texte)", m.Role)
}
parts, ok := m.Content.([]map[string]any)
if !ok || len(parts) != 2 {
t.Fatalf("contenu multimodal attendu, obtenu %#v", m.Content)
}
txt, _ := parts[0]["text"].(string)
if !strings.Contains(txt, "captures/bug.png") {
t.Fatalf("la légende ne nomme pas le fichier : %q", txt)
}
if parts[1]["type"] != "image_url" {
t.Fatalf("seconde partie = %v, attendu image_url", parts[1]["type"])
}
// Et cette légende survit au compactage, suivie du marqueur de perte.
got := msgText(m)
if !strings.Contains(got, "captures/bug.png") || !strings.Contains(got, imageLostMarker) {
t.Fatalf("après compaction : %q", got)
}
}
// Le marqueur ne doit plus parler QUE de captures d'écran : une image perdue
// peut venir du disque, et « reprends la capture » enverrait photographier une
// page web à la place.
func TestMarqueurDePerteNestPlusSpecifiqueAuxCaptures(t *testing.T) {
if strings.Contains(imageLostMarker, "take the screenshot again") {
t.Fatal("le marqueur impose encore la capture d'écran comme seul recours")
}
if !strings.Contains(imageLostMarker, "see_image") {
t.Fatal("le marqueur ne mentionne pas see_image : le modèle ne saura pas rouvrir un fichier")
}
}
// L'outil n'est proposé QUE si la vision est active : l'annoncer sans projecteur
// ferait promettre au modèle de regarder, puis se contredire.
func TestOutilProposeSeulementAvecLaVision(t *testing.T) {
testHome(t)
has := func() bool {
for _, tl := range EnabledTools(Caps{Agent: true}) {
if tl.Function.Name == "see_image" {
return true
}
}
return false
}
if has() {
t.Fatal("see_image proposé sans projecteur configuré")
}
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
t.Fatal(err)
}
if !has() {
t.Fatal("see_image absent alors que la vision est active")
}
// Hors mode agent, jamais : l'outil lit un fichier du disque.
if err := SetConfigKey("MMPROJ", "mmproj-F16.gguf"); err != nil {
t.Fatal(err)
}
for _, tl := range EnabledTools(Caps{}) {
if tl.Function.Name == "see_image" {
t.Fatal("see_image proposé hors mode agent")
}
}
}
// Le chemin doit remonter du JSON d'appel jusqu'à l'outil. Le libellé d'un appel
// est dérivé par une table nom d'outil → argument ; see_image n'y figurait pas,
// donc il recevait une chaîne vide et répondait invariablement « chemin de
// fichier manquant » — l'outil aurait été inutilisable sans rien casser ailleurs.
func TestLeCheminDeLImageRemonteJusquALOutil(t *testing.T) {
if got := toolCallLabel("see_image", map[string]any{"file": "captures/bug.png"}); got != "captures/bug.png" {
t.Fatalf("libellé de see_image = %q, attendu le chemin du fichier", got)
}
}
// Contre-épreuve de l'extraction : les libellés des autres outils n'ont pas
// bougé. Déplacer une table de 40 lignes hors d'une fonction de 700, c'est
// exactement le genre de geste qui casse un cas au passage sans bruit.
func TestLibellesDesAutresOutilsInchanges(t *testing.T) {
cas := []struct{ tool, key, val, want string }{
{"bash", "command", "ls -la", "ls -la"},
{"read", "file", "main.go", "main.go"},
{"web_search", "query", "météo", "météo"},
{"grep", "pattern", "TODO", "TODO"},
{"recall", "id", "b12", "b12"},
{"git_clone", "url", "https://x/y", "https://x/y"},
{"outil_inconnu", "file", "x", ""},
}
for _, c := range cas {
if got := toolCallLabel(c.tool, map[string]any{c.key: c.val}); got != c.want {
t.Errorf("toolCallLabel(%q) = %q, attendu %q", c.tool, got, c.want)
}
}
if got := toolCallLabel("web_grep", map[string]any{"url": "https://x", "pattern": "p"}); got != "p @ https://x" {
t.Errorf("web_grep : %q", got)
}
if got := toolCallLabel("tracker", map[string]any{"action": "add", "name": "poids"}); got != "add poids" {
t.Errorf("tracker : %q", got)
}
// Argument absent ou du mauvais type : chaîne vide, jamais de panique.
if got := toolCallLabel("read", map[string]any{"file": 42}); got != "" {
t.Errorf("argument non-chaîne : %q", got)
}
if got := toolCallLabel("bash", nil); got != "" {
t.Errorf("arguments nil : %q", got)
}
}
+34 -2
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
)
// Dossier de travail du mode agent.
@@ -17,6 +18,12 @@ import (
// chemins relatifs y sont résolus, et le shell y démarre. Les chemins ABSOLUS
// restent honorés tels quels — quand l'utilisateur demande d'écrire dans un
// dossier précis, ça doit marcher.
//
// 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 — 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"
@@ -68,8 +75,33 @@ 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 workspace.
// "~/x" → dans le home de l'utilisateur ; relatif → dans le dossier de la
// discussion ouverte.
func resolveAgentPath(p string) string {
p = strings.TrimSpace(p)
if p == "" {
@@ -83,5 +115,5 @@ func resolveAgentPath(p string) string {
if filepath.IsAbs(p) {
return p
}
return filepath.Join(agentWorkspace(), filepath.FromSlash(p))
return filepath.Join(agentCwd(), filepath.FromSlash(p))
}
+11 -5
View File
@@ -6,14 +6,19 @@ import (
"testing"
)
// Un chemin relatif du modèle doit atterrir dans le workspace, jamais dans le
// répertoire courant du processus (Bureau, C:\ProgramData\loki\bin…).
// Un chemin relatif du modèle doit atterrir dans le dossier de la DISCUSSION
// ouverte, jamais dans le répertoire courant du processus (Bureau,
// C:\ProgramData\loki\bin…) ni dans un dossier commun à toutes les discussions.
func TestResolveAgentPathRelative(t *testing.T) {
ws := withWorkspace(t)
got := resolveAgentPath("meteo.json")
want := filepath.Join(agentWorkspace(), "meteo.json")
want := filepath.Join(ws, "meteo.json")
if got != want {
t.Fatalf("resolveAgentPath = %q, attendu %q", got, want)
}
if !strings.Contains(got, convFilesRoot) {
t.Fatalf("le fichier n'est pas rangé par discussion : %q", got)
}
}
// Un chemin absolu demandé explicitement reste intouché.
@@ -25,9 +30,10 @@ func TestResolveAgentPathAbsolute(t *testing.T) {
}
func TestResolveAgentPathSubdir(t *testing.T) {
ws := withWorkspace(t)
got := resolveAgentPath("notes/2026/a.txt")
if !strings.HasPrefix(got, agentWorkspace()) {
t.Fatalf("%q hors du workspace %q", got, agentWorkspace())
if !strings.HasPrefix(got, ws) {
t.Fatalf("%q hors du dossier de la discussion %q", got, ws)
}
if strings.Contains(got, "/") && filepath.Separator != '/' {
t.Fatalf("séparateurs non normalisés : %q", got)
+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)"
}
+74
View File
@@ -0,0 +1,74 @@
// Réglages de la dictée vocale — modèle et 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é
// est une propriété de la machine, identique pour tous les onglets ouverts.
package loki
import (
"fmt"
"time"
)
const dictateCfgKey = "dictate_cfg"
// DictateCfg : ce qui reste réglable depuis le passage à Parakeet.
//
// Deux champs ont disparu, et c'est le moteur qui l'impose, pas un choix de
// simplification :
// - la LANGUE : Parakeet v3 détecte la langue lui-même et n'a aucun drapeau
// pour la forcer. Le réglage n'aurait servi qu'à mentir.
// - le GPU : le binaire sherpa-onnx livré dans l'image est le build statique
// CPU. Annoncer un sélecteur de carte sans pouvoir l'honorer serait pire que
// de ne rien annoncer. Un modèle 0,6 B en int8 sur des tranches de quelques
// secondes n'a pas besoin de la carte, qui reste au moteur de chat.
type DictateCfg struct {
Model string `json:"model"`
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. C'est
// aussi ce qui rattrape les réglages enregistrés du temps de whisper, dont le
// Model ne veut plus rien dire.
func (c DictateCfg) withDefauts() DictateCfg {
if _, connu := asrCatalogue[c.Model]; !connu {
// v3 : le seul des deux qui parle français.
c.Model = "parakeet-tdt-0.6b-v3"
}
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 {
// 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é. On valide AVANT withDefauts, qui corrigerait
// justement la faute qu'on veut signaler.
if _, ok := asrCatalogue[c.Model]; !ok {
return fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
}
return putJSON(bkState, dictateCfgKey, c.withDefauts())
}
// 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
}
}
+78
View File
@@ -0,0 +1,78 @@
package loki
import (
"testing"
)
// Les défauts sont appliqués à la LECTURE : un réglage enregistré par une
// version antérieure, sans le champ, doit être comblé plutôt que de produire un
// modèle vide passé en argument au serveur.
func TestDefautsCombles(t *testing.T) {
testHome(t)
c := DictateCfg{}.withDefauts()
if _, ok := asrCatalogue[c.Model]; !ok {
t.Errorf("modèle par défaut %q hors catalogue", c.Model)
}
if c.Reactivity == "" {
t.Error("réactivité par défaut vide")
}
}
// Le passage de whisper à Parakeet laisse des réglages enregistrés dont le
// Model ne veut plus rien dire (« large-v3-turbo-q5_0 »). Ils doivent retomber
// sur le défaut, sans quoi la dictée serait morte après mise à jour, avec pour
// seul indice « modèle de dictée inconnu » au premier clic sur le micro.
func TestModeleWhisperHeriteRetombeSurLeDefaut(t *testing.T) {
testHome(t)
c := DictateCfg{Model: "large-v3-turbo-q5_0", Reactivity: "court"}.withDefauts()
if _, ok := asrCatalogue[c.Model]; !ok {
t.Fatalf("modèle hérité %q non corrigé", c.Model)
}
if c.Reactivity != "court" {
t.Errorf("réactivité = %q, elle devait être conservée", c.Reactivity)
}
}
// Valider à l'ÉCRITURE : 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 TestSaveRefuseModeleInconnu(t *testing.T) {
testHome(t)
if err := dictateCfgSave(DictateCfg{Model: "nawak", Reactivity: "moyen"}); err == nil {
t.Error("un modèle hors catalogue a été accepté")
}
}
func TestSaveLoadAllerRetour(t *testing.T) {
testHome(t)
want := DictateCfg{Model: "parakeet-tdt-0.6b-v2", Reactivity: "long"}
if err := dictateCfgSave(want); err != nil {
t.Fatal(err)
}
if got := dictateCfgLoad(); got != want {
t.Errorf("relu %+v, attendu %+v", got, want)
}
}
// Les bornes de découpage pilotent la latence perçue : plus la réserve est
// courte, plus le texte arrive vite, au prix d'un contexte plus maigre pour le
// modèle. L'ordre entre les trois réglages est ce qui doit tenir.
func TestChunkBounds(t *testing.T) {
court, _ := DictateCfg{Reactivity: "court"}.chunkBounds()
moyen, _ := DictateCfg{Reactivity: "moyen"}.chunkBounds()
long, longMax := DictateCfg{Reactivity: "long"}.chunkBounds()
if !(court < moyen && moyen < long) {
t.Errorf("réserves non croissantes : court=%v moyen=%v long=%v", court, moyen, long)
}
if longMax <= long {
t.Error("la coupure forcée doit être plus grande que la réserve minimale")
}
// Une valeur inconnue doit retomber sur le réglage moyen, pas sur zéro : une
// réserve nulle couperait à chaque échantillon.
inconnu, _ := DictateCfg{Reactivity: "nawak"}.chunkBounds()
if inconnu != moyen {
t.Errorf("réactivité inconnue = %v, attendu le réglage moyen %v", inconnu, moyen)
}
if inconnu <= 0 {
t.Error("réserve nulle : la dictée couperait à chaque échantillon")
}
}
+222
View File
@@ -0,0 +1,222 @@
// Catalogue des modèles de dictée — Parakeet (NVIDIA), servi par sherpa-onnx.
//
// Aucun n'est embarqué dans l'image : ils pèsent près de 500 Mo compressés et
// vivent dans <LOKI_HOME>/asr/, donc dans le volume /data — ils survivent aux
// recréations du conteneur.
//
// Différence de forme avec whisper.cpp, qu'ils remplacent : un modèle n'est plus
// UN fichier .bin mais un DOSSIER (encodeur, décodeur, joiner, table de jetons),
// livré en .tar.bz2. Le téléchargement décompresse donc au passage — d'où
// asrExtract plutôt qu'une simple copie de flux.
package loki
import (
"archive/tar"
"compress/bzip2"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
// Les archives sherpa-onnx officielles (conversions ONNX des modèles NeMo).
const asrModelBase = "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/"
type asrModel struct {
Nom string // libellé affiché
Dossier string // nom du dossier, identique en local et dans l'archive
Octets int64 // taille du TÉLÉCHARGEMENT (archive compressée), pour prévenir
Langues string // ce que le modèle sait transcrire — c'est LE critère de choix
}
// asrFichiers : les quatre fichiers qu'un modèle doit avoir pour être utilisable.
// Sert au contrôle de présence : un dossier à moitié extrait (téléchargement
// interrompu) ne doit pas passer pour un modèle installé.
var asrFichiers = []string{"encoder.int8.onnx", "decoder.int8.onnx", "joiner.int8.onnx", "tokens.txt"}
// 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 asrCatalogue = map[string]asrModel{
"parakeet-tdt-0.6b-v3": {
Nom: "Parakeet v3 — recommandé",
Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8",
Octets: 487_170_055,
Langues: "25 langues européennes, français inclus — détection automatique",
},
"parakeet-tdt-0.6b-v2": {
Nom: "Parakeet v2 — anglais seul",
Dossier: "sherpa-onnx-nemo-parakeet-tdt-0.6b-v2-int8",
Octets: 482_468_385,
Langues: "anglais uniquement",
},
}
// asrRoot : la racine des modèles de dictée. Volontairement distincte de
// l'ancien dossier `whisper/` : les .bin ggml qui y dorment ne servent plus à
// rien, mais les effacer tout seuls serait détruire des données que
// l'utilisateur n'a pas demandé de perdre. Ils sont à supprimer à la main.
func asrRoot() string { return filepath.Join(LokiHome(), "asr") }
// asrModelDirFor : dossier local d'un modèle. 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 asrModelDirFor(id string) string {
m, ok := asrCatalogue[id]
if !ok {
return ""
}
return filepath.Join(asrRoot(), m.Dossier)
}
func asrModelURLFor(id string) string {
m, ok := asrCatalogue[id]
if !ok {
return ""
}
return asrModelBase + m.Dossier + ".tar.bz2"
}
// asrModelPresent : le modèle est installé ET complet. On vérifie les quatre
// fichiers plutôt que l'existence du dossier : une extraction coupée en deux
// laisse un dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur
// tomberait au premier clic sur le micro plutôt qu'ici.
func asrModelPresent(id string) bool {
dir := asrModelDirFor(id)
if dir == "" {
return false
}
for _, f := range asrFichiers {
st, err := os.Stat(filepath.Join(dir, f))
if err != nil || st.Size() == 0 {
return false
}
}
return true
}
// asrModelFile : chemin d'un des fichiers du modèle, pour les arguments passés
// au serveur. Vide si le modèle est inconnu.
func asrModelFile(id, fichier string) string {
dir := asrModelDirFor(id)
if dir == "" {
return ""
}
return filepath.Join(dir, fichier)
}
// asrCatalogueTrie : le catalogue pour l'UI, du plus léger au plus lourd —
// l'ordre dans lequel se pose la question du compromis.
func asrCatalogueTrie() []map[string]any {
out := make([]map[string]any, 0, len(asrCatalogue))
for id, m := range asrCatalogue {
out = append(out, map[string]any{
"id": id, "nom": m.Nom, "octets": m.Octets,
"langues": m.Langues, "present": asrModelPresent(id),
})
}
sort.Slice(out, func(i, j int) bool {
return out[i]["octets"].(int64) < out[j]["octets"].(int64)
})
return out
}
// asrExtract décompresse une archive .tar.bz2 sous `dest`, en ne gardant que le
// contenu du dossier de premier niveau (l'archive amont l'embarque déjà).
//
// Deux précautions qui ne sont pas du zèle :
// - un chemin d'entrée qui remonte (« ../ ») ou absolu est REFUSÉ : une archive
// téléchargée est une entrée non fiable, et une entrée piégée écrirait
// n'importe où sur le disque du serveur ;
// - on extrait dans un dossier temporaire renommé à la fin, pour qu'une
// extraction interrompue ne laisse jamais un modèle à moitié installé sous
// son nom définitif.
func asrExtract(r io.Reader, dest string) error {
tmp := dest + ".part"
if err := os.RemoveAll(tmp); err != nil {
return err
}
if err := os.MkdirAll(tmp, 0o755); err != nil {
return err
}
tr := tar.NewReader(bzip2.NewReader(r))
for {
h, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
_ = os.RemoveAll(tmp)
return err
}
rel, err := asrSafeRel(h.Name)
if err != nil {
_ = os.RemoveAll(tmp)
return err
}
if rel == "" {
continue // le dossier racine de l'archive lui-même
}
cible := filepath.Join(tmp, rel)
switch h.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(cible, 0o755); err != nil {
_ = os.RemoveAll(tmp)
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(cible), 0o755); err != nil {
_ = os.RemoveAll(tmp)
return err
}
f, err := os.Create(cible)
if err != nil {
_ = os.RemoveAll(tmp)
return err
}
// #nosec G110 — l'archive vient d'une release GitHub épinglée par son
// nom, pas d'une source arbitraire ; et la borne utile ici est le
// disque, pas un plafond deviné.
if _, err := io.Copy(f, tr); err != nil {
_ = f.Close()
_ = os.RemoveAll(tmp)
return err
}
if err := f.Close(); err != nil {
_ = os.RemoveAll(tmp)
return err
}
default:
// Liens, périphériques, FIFO : une archive de modèle n'en a aucun
// besoin, et les suivre serait un autre moyen d'écrire hors du dossier.
continue
}
}
if err := os.RemoveAll(dest); err != nil {
_ = os.RemoveAll(tmp)
return err
}
return os.Rename(tmp, dest)
}
// asrSafeRel retire le dossier de premier niveau d'un chemin d'archive et refuse
// tout ce qui sortirait du dossier de destination. Renvoie "" pour l'entrée du
// dossier racine elle-même.
func asrSafeRel(name string) (string, error) {
n := strings.ReplaceAll(strings.TrimSpace(name), `\`, "/")
if n == "" || strings.HasPrefix(n, "/") || filepath.IsAbs(n) {
return "", fmt.Errorf("entrée d'archive au chemin absolu : %q", name)
}
parts := strings.Split(strings.Trim(n, "/"), "/")
for _, p := range parts {
if p == ".." {
return "", fmt.Errorf("entrée d'archive qui remonte hors du dossier : %q", name)
}
}
if len(parts) <= 1 {
return "", nil // le dossier racine de l'archive
}
return filepath.Join(parts[1:]...), nil
}
+158
View File
@@ -0,0 +1,158 @@
package loki
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
)
// Le catalogue nourrit une liste déroulante ET des chemins de fichiers : une
// entrée bancale ne se verrait qu'au premier clic sur le micro.
func TestCatalogueCoherent(t *testing.T) {
for id, m := range asrCatalogue {
if m.Nom == "" || m.Dossier == "" || m.Langues == "" {
t.Errorf("%s : entrée incomplète %+v", id, m)
}
if m.Octets <= 0 {
t.Errorf("%s : taille annoncée %d — la confirmation de téléchargement en a besoin", id, m.Octets)
}
if !strings.HasPrefix(asrModelURLFor(id), "https://") {
t.Errorf("%s : URL non https (%s)", id, asrModelURLFor(id))
}
}
// Le défaut doit exister, et être celui qui parle français.
def := DictateCfg{}.withDefauts().Model
if _, ok := asrCatalogue[def]; !ok {
t.Fatalf("le modèle par défaut %q n'est pas au catalogue", def)
}
if !strings.Contains(asrCatalogue[def].Langues, "français") {
t.Errorf("le modèle par défaut (%s) ne parle pas français : %q", def, asrCatalogue[def].Langues)
}
}
// Un identifiant hors catalogue ne doit JAMAIS produire de chemin : il vient
// d'une requête HTTP, et bâtir un chemin dessus ouvrirait le disque.
func TestCheminVideSiInconnu(t *testing.T) {
testHome(t)
if p := asrModelDirFor("../../etc"); p != "" {
t.Errorf("chemin bâti sur un identifiant inconnu : %q", p)
}
if u := asrModelURLFor("nawak"); u != "" {
t.Errorf("URL bâtie sur un identifiant inconnu : %q", u)
}
if f := asrModelFile("nawak", "encoder.int8.onnx"); f != "" {
t.Errorf("chemin de fichier bâti sur un identifiant inconnu : %q", f)
}
}
// Un modèle n'est « présent » que COMPLET : une extraction coupée laisse un
// dossier bien réel dont sherpa-onnx ne saura rien faire, et l'erreur tomberait
// au premier clic sur le micro plutôt qu'ici.
func TestPresentExigeTousLesFichiers(t *testing.T) {
testHome(t)
const id = "parakeet-tdt-0.6b-v3"
dir := asrModelDirFor(id)
if asrModelPresent(id) {
t.Fatal("modèle annoncé présent alors que rien n'est installé")
}
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
for i, f := range asrFichiers {
if asrModelPresent(id) {
t.Fatalf("modèle annoncé présent avec seulement %d fichier(s) sur %d", i, len(asrFichiers))
}
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
if !asrModelPresent(id) {
t.Error("modèle complet non reconnu")
}
// Un fichier vide (disque plein en fin d'extraction) ne compte pas.
if err := os.WriteFile(filepath.Join(dir, asrFichiers[0]), nil, 0o644); err != nil {
t.Fatal(err)
}
if asrModelPresent(id) {
t.Error("un fichier vide passe pour un modèle installé")
}
}
func TestCatalogueTriParTaille(t *testing.T) {
testHome(t)
l := asrCatalogueTrie()
if len(l) != len(asrCatalogue) {
t.Fatalf("%d entrées, attendu %d", len(l), len(asrCatalogue))
}
for i := 1; i < len(l); i++ {
if l[i-1]["octets"].(int64) > l[i]["octets"].(int64) {
t.Error("catalogue non trié du plus léger au plus lourd")
}
}
for _, e := range l {
for _, clef := range []string{"id", "nom", "octets", "langues", "present"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
}
}
}
}
// ─── Extraction d'archive ────────────────────────────────────────────────────
// asrSafeRel est la seule chose entre une archive téléchargée et le disque du
// serveur. Une entrée qui remonte ou qui est absolue doit être REFUSÉE, pas
// nettoyée en silence.
func TestSafeRelRefuseLesEvasions(t *testing.T) {
for _, mauvais := range []string{
"/etc/passwd",
"modele/../../etc/passwd",
"../dehors.txt",
`modele\..\..\dehors.txt`,
} {
if _, err := asrSafeRel(mauvais); err == nil {
t.Errorf("entrée d'archive acceptée alors qu'elle sort du dossier : %q", mauvais)
}
}
// Le dossier de premier niveau est retiré, le reste conservé.
got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/encoder.int8.onnx")
if err != nil {
t.Fatal(err)
}
if got != "encoder.int8.onnx" {
t.Errorf("chemin = %q, le dossier racine de l'archive doit être retiré", got)
}
// L'entrée du dossier racine lui-même ne produit rien.
if got, err := asrSafeRel("sherpa-onnx-nemo-parakeet/"); err != nil || got != "" {
t.Errorf("racine de l'archive : (%q, %v), attendu (\"\", nil)", got, err)
}
}
// Une extraction ratée ne doit jamais laisser un modèle à moitié installé sous
// son nom définitif : asrModelPresent le croirait bon et le serveur mourrait au
// démarrage. Elle ne doit pas non plus détruire un modèle déjà en place — le
// remplacement n'a lieu qu'une fois l'extraction terminée.
func TestExtractNeLaissePasDeDossierPartiel(t *testing.T) {
testHome(t)
dest := filepath.Join(t.TempDir(), "modele")
if err := os.MkdirAll(dest, 0o755); err != nil {
t.Fatal(err)
}
temoin := filepath.Join(dest, "deja-la.txt")
if err := os.WriteFile(temoin, []byte("modèle précédent"), 0o644); err != nil {
t.Fatal(err)
}
// Un flux qui n'est pas du bzip2 : la lecture échoue dès la première entrée.
if err := asrExtract(bytes.NewReader([]byte("ceci n'est pas une archive bzip2")), dest); err == nil {
t.Fatal("un flux illisible doit produire une erreur")
}
if _, err := os.Stat(dest + ".part"); err == nil {
t.Error("le dossier temporaire n'a pas été nettoyé après l'échec")
}
if _, err := os.Stat(temoin); err != nil {
t.Error("le modèle précédent a été détruit par une extraction qui a échoué")
}
}
+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":"parakeet-tdt-0.6b-v2","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.Model != "parakeet-tdt-0.6b-v2" || 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","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) < 2 {
t.Errorf("%d modèles renvoyés, au moins 2 attendus", len(l))
}
}
// Un identifiant inconnu ne doit pas lancer de téléchargement : asrDownload
// 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 le serveur :
// 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)
}
}
+455
View File
@@ -0,0 +1,455 @@
// Supervision du serveur de transcription : le modèle est chargé UNE fois et
// reste en mémoire, au lieu d'être relu depuis le disque à chaque phrase dictée.
//
// Le moteur est sherpa-onnx (Parakeet, NVIDIA), qui a remplacé whisper.cpp. Ce
// qui n'a PAS changé : un processus local supervisé, arrêté après un temps
// d'inactivité, redémarré quand les réglages bougent. Ce qui a changé : le
// dialogue. whisper-server exposait du HTTP multipart ; sherpa-onnx n'expose
// qu'un WebSocket, dont le protocole tient en deux entiers et des flottants
// (voir asrInferSur).
package loki
import (
"bytes"
"context"
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"math"
"net"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coder/websocket"
)
// asrServerBin : le binaire du serveur de transcription. Un seul, contrairement
// aux deux binaires whisper d'avant (CPU et CUDA) : le build sherpa-onnx livré
// est le statique CPU, il n'a donc aucune dépendance CUDA à satisfaire et
// démarre sur n'importe quelle base d'image.
func asrServerBin() string {
for _, p := range []string{os.Getenv("LOKI_ASR_SERVER"), "/usr/local/bin/sherpa-onnx-offline-websocket-server"} {
if p == "" {
continue
}
if _, err := os.Stat(p); err == nil {
return p
}
}
if p, err := exec.LookPath("sherpa-onnx-offline-websocket-server"); err == nil {
return p
}
return ""
}
// asrServerArgs. Les arguments de sherpa-onnx s'écrivent --clef=valeur : la
// forme séparée par une espace n'est PAS reconnue et le serveur démarre alors
// sans modèle.
func asrServerArgs(c DictateCfg, port int) ([]string, error) {
if asrModelDirFor(c.Model) == "" {
return nil, fmt.Errorf("modèle de dictée inconnu : %s", c.Model)
}
return []string{
"--encoder=" + asrModelFile(c.Model, "encoder.int8.onnx"),
"--decoder=" + asrModelFile(c.Model, "decoder.int8.onnx"),
"--joiner=" + asrModelFile(c.Model, "joiner.int8.onnx"),
"--tokens=" + asrModelFile(c.Model, "tokens.txt"),
// nemo_transducer : sans ce type, les sorties du modèle sont mal
// interprétées et la transcription est du bruit, pas une erreur.
"--model-type=nemo_transducer",
"--port=" + strconv.Itoa(port),
// Le serveur n'a AUCUNE authentification. Il n'écoute donc que la boucle
// locale : sur un conteneur au réseau partagé, l'exposer offrirait la
// transcription à tout le réseau.
"--host=127.0.0.1",
}, nil
}
// asrServerEnv masque tous les GPU, en REMPLAÇANT toute valeur déjà présente.
// Une variable dupliquée laisse le gagnant dépendre de la libc. Le binaire livré
// est CPU de toute façon ; la variable est là pour que ça reste vrai si l'image
// venait à embarquer un build GPU sans qu'on repense à la dictée.
func asrServerEnv(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)
}
}
return append(out, clef)
}
// 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 le serveur 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
}
// asrMarqueur reconnaît ce qui n'est pas de la parole dictée : le vide, et les
// annotations entre crochets ou parenthèses. Parakeet en produit beaucoup moins
// que whisper, mais le filtre ne coûte rien et un « (silence) » collé dans le
// champ de saisie se remarque tout de suite.
func asrMarqueur(s string) bool {
s = strings.TrimSpace(s)
if s == "" {
return true
}
return (strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) ||
(strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")"))
}
// ─── Protocole ───────────────────────────────────────────────────────────────
// asrEnteteOctets : l'en-tête du serveur sherpa-onnx, deux entiers 32 bits en
// petit-boutien — la fréquence d'échantillonnage, puis le NOMBRE D'OCTETS
// d'audio annoncé. Le serveur décode dès qu'il a reçu exactement ce nombre : un
// compte faux, et il attend indéfiniment de quoi finir.
const asrEnteteOctets = 8
// asrTrameMax borne la taille d'une trame WebSocket envoyée. Le serveur
// reconstitue l'utterance à partir du compte annoncé, donc le découpage est
// libre — mais pas la TAILLE : une pile WebSocket refuse par défaut les messages
// au-delà de quelques dizaines de kilooctets et ferme la connexion au lieu de
// répondre. 10 Ko est la valeur qu'emploie le client de référence de
// sherpa-onnx ; on s'aligne dessus plutôt que de parier sur une limite.
const asrTrameMax = 10240
// asrInferSur prend l'adresse du serveur en paramètre : c'est ce qui rend
// l'appel testable sans lancer de processus.
//
// Le protocole, en entier : on ouvre une connexion, on envoie UNE trame binaire
// contenant l'en-tête suivi des échantillons en float32 petit-boutien, et on lit
// UNE trame texte qui porte le résultat en JSON.
func asrInferSur(ctx context.Context, adresse string, wav []byte) (string, error) {
taux, ech, err := wavVersFloat32(wav)
if err != nil {
return "", err
}
if len(ech) == 0 {
return "", nil // rien à transcrire : ce n'est pas une erreur
}
c, _, err := websocket.Dial(ctx, adresse, nil)
if err != nil {
return "", fmt.Errorf("connexion au serveur de dictée : %w", err)
}
defer func() { _ = c.CloseNow() }()
// Une transcription peut légitimement dépasser la taille par défaut acceptée
// en lecture ; on ne borne pas ce que le serveur nous renvoie.
c.SetReadLimit(-1)
corps := make([]byte, asrEnteteOctets+4*len(ech))
binary.LittleEndian.PutUint32(corps[0:4], uint32(taux))
binary.LittleEndian.PutUint32(corps[4:8], uint32(4*len(ech)))
for i, v := range ech {
binary.LittleEndian.PutUint32(corps[asrEnteteOctets+4*i:], math.Float32bits(v))
}
for off := 0; off < len(corps); off += asrTrameMax {
fin := off + asrTrameMax
if fin > len(corps) {
fin = len(corps)
}
if err := c.Write(ctx, websocket.MessageBinary, corps[off:fin]); err != nil {
return "", fmt.Errorf("envoi de l'audio : %w", err)
}
}
_, brut, err := c.Read(ctx)
if err != nil {
return "", fmt.Errorf("lecture du résultat : %w", err)
}
var j struct {
Text string `json:"text"`
}
if err := json.Unmarshal(brut, &j); err != nil {
return "", fmt.Errorf("réponse illisible du serveur de dictée : %w", err)
}
if asrMarqueur(j.Text) {
return "", nil
}
return strings.TrimSpace(j.Text), nil
}
// wavVersFloat32 lit le WAV produit par le navigateur (16 kHz mono) et rend ses
// échantillons normalisés dans [-1, 1], ce qu'attend sherpa-onnx.
//
// On parcourt les blocs plutôt que de sauter à un offset fixe : un WAV n'a pas
// d'en-tête de taille garantie (« LIST », « fact » et consorts s'intercalent
// avant « data »), et l'offset 44 codé en dur transformerait ces octets en
// craquement au début de chaque phrase.
func wavVersFloat32(wav []byte) (taux int, ech []float32, err error) {
if len(wav) < 12 || string(wav[0:4]) != "RIFF" || string(wav[8:12]) != "WAVE" {
return 0, nil, errors.New("audio illisible : ce n'est pas un WAV")
}
var canaux, bits, format int
pos := 12
for pos+8 <= len(wav) {
id := string(wav[pos : pos+4])
taille := int(binary.LittleEndian.Uint32(wav[pos+4 : pos+8]))
corps := pos + 8
if taille < 0 || corps+taille > len(wav) {
// Bloc annoncé plus grand que le fichier : on prend ce qui reste
// plutôt que d'échouer — un enregistrement coupé net reste utile.
taille = len(wav) - corps
}
switch id {
case "fmt ":
if taille < 16 {
return 0, nil, errors.New("audio illisible : bloc fmt tronqué")
}
format = int(binary.LittleEndian.Uint16(wav[corps : corps+2]))
canaux = int(binary.LittleEndian.Uint16(wav[corps+2 : corps+4]))
taux = int(binary.LittleEndian.Uint32(wav[corps+4 : corps+8]))
bits = int(binary.LittleEndian.Uint16(wav[corps+14 : corps+16]))
case "data":
if canaux <= 0 || taux <= 0 {
return 0, nil, errors.New("audio illisible : bloc data avant fmt")
}
ech, err = pcmVersFloat32(wav[corps:corps+taille], format, bits, canaux)
if err != nil {
return 0, nil, err
}
return taux, ech, nil
}
// Les blocs sont alignés sur un nombre pair d'octets.
pos = corps + taille + taille%2
}
return 0, nil, errors.New("audio illisible : aucun bloc data")
}
// pcmVersFloat32 convertit les échantillons bruts. Les canaux sont MOYENNÉS
// plutôt que de garder le premier : un navigateur qui livrerait du stéréo par
// erreur donnerait sinon un canal muet une fois sur deux.
func pcmVersFloat32(data []byte, format, bits, canaux int) ([]float32, error) {
const pcmEntier, pcmFlottant = 1, 3
switch {
case format == pcmEntier && bits == 16:
n := len(data) / 2 / canaux
out := make([]float32, n)
for i := 0; i < n; i++ {
somme := 0.0
for c := 0; c < canaux; c++ {
v := int16(binary.LittleEndian.Uint16(data[2*(i*canaux+c):]))
somme += float64(v) / 32768
}
out[i] = float32(somme / float64(canaux))
}
return out, nil
case format == pcmFlottant && bits == 32:
n := len(data) / 4 / canaux
out := make([]float32, n)
for i := 0; i < n; i++ {
somme := 0.0
for c := 0; c < canaux; c++ {
somme += float64(math.Float32frombits(binary.LittleEndian.Uint32(data[4*(i*canaux+c):])))
}
out[i] = float32(somme / float64(canaux))
}
return out, nil
}
return nil, fmt.Errorf("format audio non géré (format %d, %d bits) — attendu PCM 16 bits", format, bits)
}
// ─── Supervision ─────────────────────────────────────────────────────────────
// asrIdle : au-delà, le serveur s'éteint et rend sa mémoire. Une dictée n'est
// pas un service permanent ; garder un modèle chargé toute la journée pour
// quelques phrases prive la machine de mémoire vive.
const asrIdle = 10 * time.Minute
// asrReady : plafond d'attente au démarrage. Un modèle de 600 Mo lu depuis un
// disque lent prend du temps ; échouer trop tôt afficherait une panne là où il
// n'y a qu'une lenteur.
const asrReady = 120 * time.Second
var wsrvMu sync.Mutex
// wsrvDemarrage : le processus en cours de démarrage, visible SANS le verrou.
// asrStartLocked garde wsrvMu pendant tout le chargement du modèle ; c'est la
// seule poignée qu'un autre appelant (asrShutdownVite) a sur lui pour
// l'interrompre. Nil hors démarrage.
var wsrvDemarrage atomic.Pointer[exec.Cmd]
var wsrv struct {
cmd *exec.Cmd
port int
cfg DictateCfg
log *bytes.Buffer
timer *time.Timer
depuis time.Time
}
// asrEnsure 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 effectif sans
// redémarrer Loki.
func asrEnsure() (int, error) {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
if wsrv.cmd != nil && wsrv.cfg == cfg && wsrv.cmd.ProcessState == nil {
asrTouchLocked()
return wsrv.port, nil
}
asrStopLocked()
return asrStartLocked(cfg)
}
func asrStartLocked(cfg DictateCfg) (int, error) {
if !asrModelPresent(cfg.Model) {
return 0, fmt.Errorf("modèle de dictée absent : %s (à télécharger dans Paramètres → Dictée)", cfg.Model)
}
bin := asrServerBin()
if bin == "" {
return 0, fmt.Errorf("serveur de dictée introuvable (image à reconstruire)")
}
port, err := portLibre()
if err != nil {
return 0, fmt.Errorf("aucun port libre pour le serveur de dictée : %w", err)
}
args, err := asrServerArgs(cfg, port)
if err != nil {
return 0, err
}
log := &bytes.Buffer{}
cmd := exec.Command(bin, args...)
cmd.Env = asrServerEnv(os.Environ())
cmd.Stdout, cmd.Stderr = log, log
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("démarrage du serveur de dictée : %w", err)
}
wsrv.cmd, wsrv.port, wsrv.cfg = cmd, port, cfg
wsrv.log, wsrv.depuis = log, time.Now()
wsrvDemarrage.Store(cmd)
err = asrAttendPret(cmd, port, log)
wsrvDemarrage.Store(nil)
if err != nil {
asrStopLocked()
return 0, err
}
asrTouchLocked()
return port, nil
}
// asrAttendPret sonde le serveur jusqu'à ce qu'il accepte une connexion. Le
// serveur sherpa-onnx construit son moteur AVANT d'ouvrir sa socket : le simple
// fait qu'elle réponde signifie donc que le modèle est chargé — inutile de lui
// envoyer un audio de test.
//
// 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.
func asrAttendPret(cmd *exec.Cmd, port int, log *bytes.Buffer) error {
fin := time.Now().Add(asrReady)
adr := "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 := asrExitReason(err)
if l := lastLine(log.String()); l != "" {
msg += " — dernière sortie : " + l
}
return fmt.Errorf("le serveur de dictée s'est arrêté au démarrage : %s", msg)
case <-time.After(200 * time.Millisecond):
}
conn, err := net.DialTimeout("tcp", adr, 2*time.Second)
if err == nil {
_ = conn.Close()
return nil
}
}
return fmt.Errorf("le serveur de dictée n'a pas répondu en %s", asrReady)
}
func asrTouchLocked() {
if wsrv.timer != nil {
wsrv.timer.Stop()
}
wsrv.timer = time.AfterFunc(asrIdle, asrShutdown)
}
func asrStopLocked() {
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 = DictateCfg{}
}
// asrShutdown 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 asrShutdown() {
wsrvMu.Lock()
defer wsrvMu.Unlock()
asrStopLocked()
}
// asrShutdownVite arrête le serveur sans jamais attendre derrière un démarrage.
// asrShutdown prend wsrvMu, et asrEnsure le garde pendant tout le chargement du
// modèle — jusqu'à asrReady (deux minutes) depuis un disque lent. Appelé par un
// geste de l'interface, ce blocage ferait abandonner le navigateur alors que le
// moteur, lui, est déjà arrêté.
//
// Si le verrou est pris, on tue le processus en train de démarrer : sa mort fait
// sortir asrAttendPret, le verrou se libère, et on finit le ménage par la voie
// normale. La dictée qui attendait reçoit une erreur de démarrage — c'est
// l'utilisateur qui vient de demander la place, pas une panne.
func asrShutdownVite() {
for i := 0; i < 20; i++ {
if wsrvMu.TryLock() {
asrStopLocked()
wsrvMu.Unlock()
return
}
if cmd := wsrvDemarrage.Load(); cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
time.Sleep(100 * time.Millisecond)
}
}
// asrInfer garantit le serveur puis lui soumet le WAV.
func asrInfer(ctx context.Context, wav []byte) (string, error) {
port, err := asrEnsure()
if err != nil {
return "", err
}
return asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(port), wav)
}
func asrEtat() map[string]any {
cfg := dictateCfgLoad()
wsrvMu.Lock()
defer wsrvMu.Unlock()
etat := map[string]any{
"actif": wsrv.cmd != nil,
"modele": cfg.Model,
"present": asrModelPresent(cfg.Model),
// Le moteur livré est le build statique CPU (voir asrServerBin) : le dire
// vaut mieux que laisser chercher un réglage de carte qui n'existe pas.
"device": "cpu",
}
if wsrv.cmd != nil {
etat["depuis_s"] = int(time.Since(wsrv.depuis).Seconds())
}
return etat
}
+397
View File
@@ -0,0 +1,397 @@
package loki
import (
"context"
"encoding/binary"
"encoding/json"
"math"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/coder/websocket"
)
// wavTest fabrique un WAV 16 kHz mono PCM 16 bits, comme celui que le
// navigateur envoie.
func wavTest(ech []int16, taux int) []byte {
data := make([]byte, 2*len(ech))
for i, v := range ech {
binary.LittleEndian.PutUint16(data[2*i:], uint16(v))
}
b := make([]byte, 0, 44+len(data))
ajout32 := func(v uint32) {
var x [4]byte
binary.LittleEndian.PutUint32(x[:], v)
b = append(b, x[:]...)
}
ajout16 := func(v uint16) {
var x [2]byte
binary.LittleEndian.PutUint16(x[:], v)
b = append(b, x[:]...)
}
b = append(b, "RIFF"...)
ajout32(uint32(36 + len(data)))
b = append(b, "WAVE"...)
b = append(b, "fmt "...)
ajout32(16)
ajout16(1) // PCM entier
ajout16(1) // mono
ajout32(uint32(taux))
ajout32(uint32(taux * 2))
ajout16(2) // alignement de bloc
ajout16(16) // bits par échantillon
b = append(b, "data"...)
ajout32(uint32(len(data)))
return append(b, data...)
}
func TestServerArgsFormeCleValeur(t *testing.T) {
testHome(t)
args, err := asrServerArgs(DictateCfg{Model: "parakeet-tdt-0.6b-v3"}, 4242)
if err != nil {
t.Fatal(err)
}
joint := strings.Join(args, " ")
// sherpa-onnx ne reconnaît QUE --clef=valeur. La forme séparée par une
// espace le laisse démarrer sans modèle, sans se plaindre.
for _, a := range args {
if !strings.HasPrefix(a, "--") || !strings.Contains(a, "=") {
t.Errorf("argument %q : sherpa-onnx exige la forme --clef=valeur", a)
}
}
for _, attendu := range []string{"--model-type=nemo_transducer", "--host=127.0.0.1", "--port=4242"} {
if !strings.Contains(joint, attendu) {
t.Errorf("argument manquant : %s\nobtenu : %s", attendu, joint)
}
}
// Les quatre fichiers du modèle, sinon le serveur démarre puis meurt.
for _, f := range asrFichiers {
if !strings.Contains(joint, f) {
t.Errorf("le fichier %s du modèle n'est pas passé au serveur", f)
}
}
}
func TestServerArgsRefuseModeleInconnu(t *testing.T) {
testHome(t)
if _, err := asrServerArgs(DictateCfg{Model: "nawak"}, 1234); err == nil {
t.Error("un modèle hors catalogue doit être refusé avant le lancement")
}
}
// Le serveur ne doit jamais prendre une carte : le binaire livré est le build
// CPU, et la VRAM appartient au moteur de chat.
func TestServerEnvMasqueLesGPU(t *testing.T) {
got := asrServerEnv([]string{"PATH=/bin", "CUDA_VISIBLE_DEVICES=0,1", "HOME=/root"})
n := 0
for _, v := range got {
if strings.HasPrefix(v, "CUDA_VISIBLE_DEVICES=") {
n++
if v != "CUDA_VISIBLE_DEVICES=" {
t.Errorf("CUDA_VISIBLE_DEVICES = %q, attendu vide", v)
}
}
}
// Une variable en double laisse le gagnant dépendre de la libc.
if n != 1 {
t.Errorf("%d occurrences de CUDA_VISIBLE_DEVICES, attendu exactement 1", n)
}
if !contientTout(got, "PATH=/bin", "HOME=/root") {
t.Error("le reste de l'environnement doit être conservé")
}
}
func contientTout(l []string, vals ...string) bool {
for _, v := range vals {
trouve := false
for _, x := range l {
if x == v {
trouve = true
break
}
}
if !trouve {
return false
}
}
return true
}
func TestPortLibre(t *testing.T) {
p, err := portLibre()
if err != nil {
t.Fatal(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()
}
// ─── Conversion WAV → float32 ────────────────────────────────────────────────
// L'offset 44 codé en dur est le piège classique : un WAV n'a pas d'en-tête de
// taille garantie, et des blocs (LIST, fact…) s'intercalent avant `data`. Les
// prendre pour de l'audio produit un craquement au début de chaque phrase.
func TestWavIgnoreLesBlocsIntercales(t *testing.T) {
base := wavTest([]int16{0, 16384, -16384, 32767}, 16000)
// Insère un bloc « LIST » de 6 octets juste après l'en-tête RIFF/WAVE.
extra := append([]byte("LIST"), 6, 0, 0, 0, 'I', 'N', 'F', 'O', 'x', 'y')
avec := append(append(append([]byte{}, base[:12]...), extra...), base[12:]...)
binary.LittleEndian.PutUint32(avec[4:8], uint32(len(avec)-8))
taux, ech, err := wavVersFloat32(avec)
if err != nil {
t.Fatalf("wavVersFloat32 : %v", err)
}
if taux != 16000 {
t.Errorf("taux = %d, attendu 16000", taux)
}
if len(ech) != 4 {
t.Fatalf("%d échantillons, attendu 4 — un bloc intercalé a été pris pour de l'audio", len(ech))
}
if ech[0] != 0 || math.Abs(float64(ech[1])-0.5) > 0.001 {
t.Errorf("échantillons mal normalisés : %v", ech)
}
}
func TestWavRefuseCeQuiNEnEstPas(t *testing.T) {
for nom, in := range map[string][]byte{
"vide": {},
"pas un WAV": []byte("ceci n'est pas un fichier audio du tout"),
"sans data": append([]byte("RIFF\x00\x00\x00\x00WAVE"), "fmt "...),
} {
if _, _, err := wavVersFloat32(in); err == nil {
t.Errorf("%s : accepté alors qu'il est illisible", nom)
}
}
}
// Le stéréo doit être MOYENNÉ, pas réduit au premier canal : un navigateur qui
// livrerait deux canaux donnerait sinon un silence une fois sur deux.
func TestPcmStereoMoyenne(t *testing.T) {
data := make([]byte, 8) // deux trames stéréo
binary.LittleEndian.PutUint16(data[0:], uint16(int16(0)))
binary.LittleEndian.PutUint16(data[2:], uint16(int16(32767)))
binary.LittleEndian.PutUint16(data[4:], uint16(0x8000)) // -32768
binary.LittleEndian.PutUint16(data[6:], uint16(int16(32767)))
ech, err := pcmVersFloat32(data, 1, 16, 2)
if err != nil {
t.Fatal(err)
}
if len(ech) != 2 {
t.Fatalf("%d échantillons, attendu 2", len(ech))
}
if math.Abs(float64(ech[0])-0.5) > 0.01 {
t.Errorf("premier échantillon = %v, attendu la moyenne des deux canaux (~0,5)", ech[0])
}
if math.Abs(float64(ech[1])) > 0.01 {
t.Errorf("second échantillon = %v, attendu ~0 (canaux opposés)", ech[1])
}
}
func TestPcmFormatNonGere(t *testing.T) {
if _, err := pcmVersFloat32(make([]byte, 8), 1, 24, 1); err == nil {
t.Error("un PCM 24 bits doit être refusé explicitement, pas décodé de travers")
}
}
// ─── Protocole WebSocket ─────────────────────────────────────────────────────
// serveurASRFactice rejoue le protocole de sherpa-onnx : il lit l'en-tête et
// les échantillons, puis répond en JSON. Il vérifie au passage que le client
// annonce le BON nombre d'octets — un compte faux et le vrai serveur attend
// indéfiniment de quoi finir, sans jamais répondre.
func serveurASRFactice(t *testing.T, texte string) (adresse string, arret func(), recu *struct {
Taux int
N int
}) {
t.Helper()
recu = &struct {
Taux int
N int
}{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
defer func() { _ = c.CloseNow() }()
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
// il ne borne pas la taille des trames recues, ce test non plus.
c.SetReadLimit(-1)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
var buf []byte
for {
_, b, err := c.Read(ctx)
if err != nil {
return
}
buf = append(buf, b...)
if len(buf) < asrEnteteOctets {
continue
}
recu.Taux = int(binary.LittleEndian.Uint32(buf[0:4]))
attendu := int(binary.LittleEndian.Uint32(buf[4:8]))
if len(buf)-asrEnteteOctets < attendu {
continue
}
recu.N = attendu / 4
_ = c.Write(ctx, websocket.MessageText, []byte(`{"text":"`+texte+`"}`))
return
}
}))
return "ws" + strings.TrimPrefix(srv.URL, "http"), srv.Close, recu
}
func TestInferEnvoieLeBonEnTete(t *testing.T) {
adresse, arret, recu := serveurASRFactice(t, " bonjour ceci est un test ")
defer arret()
wav := wavTest(make([]int16, 800), 16000)
txt, err := asrInferSur(context.Background(), adresse, wav)
if err != nil {
t.Fatalf("asrInferSur : %v", err)
}
if txt != "bonjour ceci est un test" {
t.Errorf("texte = %q, les espaces de bord doivent être retirés", txt)
}
if recu.Taux != 16000 {
t.Errorf("taux annoncé = %d, attendu 16000", recu.Taux)
}
if recu.N != 800 {
t.Errorf("%d échantillons annoncés, attendu 800 — un compte faux fige le serveur", recu.N)
}
}
// Un audio plus gros qu'une trame doit arriver ENTIER : le serveur recolle les
// morceaux à partir du compte annoncé, encore faut-il tout envoyer.
func TestInferDecoupeLesGrosEnvois(t *testing.T) {
adresse, arret, recu := serveurASRFactice(t, "ok")
defer arret()
n := asrTrameMax // > une trame une fois converti en float32
if _, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, n), 16000)); err != nil {
t.Fatalf("asrInferSur : %v", err)
}
if recu.N != n {
t.Errorf("%d échantillons reçus, attendu %d", recu.N, n)
}
}
// Sur du silence, le moteur peut rendre une annotation plutôt qu'un texte. La
// laisser passer la collerait telle quelle dans le champ de saisie.
func TestInferFiltreLesMarqueurs(t *testing.T) {
for _, marqueur := range []string{"[BLANK_AUDIO]", "(silence)", "[SOUND]", " [ Silence ] ", " "} {
adresse, arret, _ := serveurASRFactice(t, marqueur)
txt, err := asrInferSur(context.Background(), adresse, wavTest(make([]int16, 100), 16000))
arret()
if err != nil {
t.Fatalf("asrInferSur(%q) : %v", marqueur, err)
}
if txt != "" {
t.Errorf("marqueur %q rendu comme texte %q, attendu vide", marqueur, txt)
}
}
}
func TestInferServeurInjoignable(t *testing.T) {
p, err := portLibre()
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, err = asrInferSur(ctx, "ws://127.0.0.1:"+strconv.Itoa(p), wavTest(make([]int16, 100), 16000))
if err == nil {
t.Error("un serveur injoignable doit remonter une erreur")
}
}
func TestInferReponseIllisible(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
if err != nil {
return
}
defer func() { _ = c.CloseNow() }()
// Le vrai serveur reconstitue l'utterance a partir du compte annonce :
// il ne borne pas la taille des trames recues, ce test non plus.
c.SetReadLimit(-1)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if _, _, err := c.Read(ctx); err != nil {
return
}
_ = c.Write(ctx, websocket.MessageText, []byte("ceci n'est pas du JSON"))
}))
defer srv.Close()
_, err := asrInferSur(context.Background(), "ws"+strings.TrimPrefix(srv.URL, "http"), wavTest(make([]int16, 100), 16000))
if err == nil {
t.Error("une réponse illisible doit remonter une erreur")
}
}
// Un audio vide n'est pas une panne : on rend une transcription vide sans même
// ouvrir de connexion.
func TestInferAudioVide(t *testing.T) {
txt, err := asrInferSur(context.Background(), "ws://127.0.0.1:1", wavTest(nil, 16000))
if err != nil {
t.Fatalf("un WAV sans échantillon ne doit pas être une erreur : %v", err)
}
if txt != "" {
t.Errorf("texte = %q, attendu vide", txt)
}
}
// ─── Supervision ─────────────────────────────────────────────────────────────
// Un modèle absent doit être annoncé comme tel, pas produire un lancement du
// serveur voué à mourir sur un fichier introuvable.
func TestEnsureModeleAbsent(t *testing.T) {
testHome(t)
if err := dictateCfgSave(DictateCfg{Model: "parakeet-tdt-0.6b-v3", Reactivity: "moyen"}); err != nil {
t.Fatal(err)
}
_, err := asrEnsure()
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 := asrEtat()
if e["actif"] != false {
t.Errorf("actif = %v, attendu false sans serveur lancé", e["actif"])
}
for _, clef := range []string{"modele", "present", "device"} {
if _, ok := e[clef]; !ok {
t.Errorf("clé %q manquante — l'UI en a besoin", clef)
}
}
// Le binaire livré est CPU : l'état ne doit pas laisser croire à une carte.
if e["device"] != "cpu" {
t.Errorf("device = %v, attendu \"cpu\"", e["device"])
}
}
func TestEtatSerialisable(t *testing.T) {
testHome(t)
if _, err := json.Marshal(asrEtat()); err != nil {
t.Fatalf("l'état doit être sérialisable pour /api/dictate/state : %v", err)
}
}
+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)
}
}
}
+395 -55
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,28 +269,105 @@ 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())
// Mémoire longue de la conversation (chat_recall.go) : le compactage
// n'archive des blocs QUE quand le mode agent est actif — annoncer recall
// sans lui donnerait un outil qui ne peut rien trouver.
tools = append(tools, recallTool(), recallSearchTool())
}
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 {
tools = append(tools, memSearchTool(), memReadTool(), memAddTool(), memEditTool())
// Trackers : 3e type de mémoire (données datées). Même axe que les pages —
// un seul outil pour les quatre actions, cf. trackerTool.
tools = append(tools, trackerTool())
}
// Outils web : seulement si le mode agent ET l'accès internet sont actifs.
// caps.Internet intègre déjà la joignabilité (globalCaps / override web_server.go),
// donc prompt et outils restent cohérents — pas de web_search halluciné.
if caps.Agent && caps.Internet {
tools = append(tools, webSearchTool(), webOpenTool(), webReadTool(), webGrepTool())
// 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.
// Voir une image du DISQUE : seulement quand la vision est réellement active
// (projecteur MMPROJ déclaré). Sinon l'outil ne saurait que renvoyer une
// erreur, et l'annoncer ferait croire au modèle qu'il a des yeux qu'il n'a
// pas — il promettrait alors de regarder, puis se contredirait.
if caps.Agent && visionEnabled() {
tools = append(tools, seeImageTool())
}
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 +390,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 +410,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 +432,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.
@@ -565,6 +665,55 @@ func isNetTimeout(err error) bool {
return errors.As(err, &ne) && ne.Timeout()
}
// toolCallLabel dérive le libellé HUMAIN d'un appel d'outil : la commande, le
// chemin, la requête… Il est annoncé à l'interface AVANT l'exécution — sans ça,
// une commande shell lente laisse l'écran figé sans rien dire — et il sert
// ensuite d'argument principal à plusieurs outils.
//
// Cette double fonction est un piège : un outil absent de cette table reçoit un
// libellé VIDE, et si son exécution lit ce libellé (see_image, read, bash…), il
// s'exécute sur une chaîne vide. Vécu à l'ajout de see_image : l'outil répondait
// « chemin de fichier manquant » quoi qu'on lui passe, sans que rien d'autre ne
// bronche. D'où l'extraction : une table pareille se teste.
func toolCallLabel(name string, args map[string]any) string {
label := ""
switch name {
case "mem_search", "web_search", "recall_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write", "read", "git_diff", "see_image":
label, _ = args["file"].(string)
case "recall":
label, _ = args["id"].(string)
case "tracker":
// Libellé lisible : « nom » ou « action nom », pas un dump d'arguments.
label = strings.TrimSpace(str(args["action"]) + " " + str(args["name"]))
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":
u, _ := args["url"].(string)
p, _ := args["pattern"].(string)
label = p + " @ " + u
default:
// Outils MCP : libellé = un aperçu compact des arguments.
if isMCPTool(name) {
label = mcpArgLabel(args)
}
}
return label
}
func runChat(ctx context.Context, messages []Message, temperature float64, caps Caps, cb ChatCallback) ([]Message, error) {
var extra []Message
tools := EnabledTools(caps)
@@ -574,7 +723,23 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// reasoning is enabled we split that out ourselves so the UI's reasoning
// bubble works regardless of backend. The ik_llama.cpp fork already sends
// reasoning_content, in which case we leave content untouched.
reasoningOn := reasoningActive(ReadConfig()["REASONING"])
chatCfg := ReadConfig()
reasoningOn := reasoningActive(chatCfg["REASONING"])
// Intensité du raisonnement, passée telle quelle au gabarit du modèle. Vide
// = on n'envoie rien. Réglage par preset, donc de fait par modèle.
effortWanted := reasoningEffortValue(chatCfg["REASONING_EFFORT"])
// …sauf si le gabarit de CE modèle a déjà refusé ce niveau (llm_effort.go) :
// on part alors directement sur la traduction apprise, sans repayer le 500.
reasoningEffort := effortResolve(effortWanted)
// Le raisonnement est-il interdit pour ce tour ? « aucune » compte comme une
// interdiction, y compris quand le repli ci-dessus a retiré le niveau : c'est
// `enable_thinking` qui porte alors la consigne, seul.
thinkOff := reasoningExplicitlyOff(chatCfg["REASONING"]) || effortWanted == "none"
// Ce que le gabarit du modèle doit savoir, dans SA langue : `reasoning_effort`
// ne parle qu'aux gabarits qui le lisent, `enable_thinking` parle aux modèles
// hybrides (Qwen3 & co). Sans ça, « aucune » n'avait aucun effet sur eux : le
// modèle réfléchissait pendant que l'interface annonçait le contraire.
reasoningKwargs := reasoningTemplateKwargs(thinkOff, reasoningEffort)
// When llama.cpp fails to parse a model-generated tool call (HTTP 500), we
// retry the same turn once with tools removed so the model answers in plain
// text from the tool results already gathered, instead of dying mid-chat.
@@ -583,6 +748,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{}
@@ -592,17 +760,45 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
repeatCount := map[string]int{}
// Garde-fou « pensé sans agir » : certains modèles à reasoning planifient un
// appel d'outil dans leur <think> puis émettent le token de fin SANS l'émettre
// (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é.
// (ni réponse, ni tool_call). On relance alors le tour avec un nudge explicite
// au lieu d'afficher « pas de réponse ». Le premier suffit la plupart du temps ;
// un second, plus direct, rattrape le cas où le modèle re-décrit le même plan
// en boucle sans jamais l'exécuter — une simple reformulation de « agis » ne
// suffit alors plus. Borné à maxNudges : un modèle vraiment bloqué doit finir
// par rendre la main plutôt que faire attendre.
const maxNudges = 2
nudgeCount := 0
// Garde-fou « appel d'outil écrit en texte » (code_retry.go) : une seule
// relance par tour, comme le nudge.
patternRetried := false
// Reprises RÉSEAU consécutives (llm_retry_net.go). Remis à zéro dès qu'une
// réponse arrive : une boucle d'outils longue retrouve son budget à chaque
// itération réussie, un moteur durablement mort finit par rendre la main.
netRetries := 0
// Destination des complétions : llama-server local, ou une API OpenAI-compatible
// externe si le preset actif en est un (backend_external.go). Résolu UNE FOIS
// par tour — une bascule de preset en plein tour est rare, et se rejoue de
// toute façon au message suivant.
ep := resolveChatEndpoint()
// 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": ep.Model,
// Normalisé juste avant l'envoi : un seul système, en tête. Les gabarits
// stricts (Qwen3.x) refusent un système ailleurs qu'en position 0.
"messages": normalizeSystemMessages(messages),
"stream": true,
"temperature": temperature,
// include_usage → chunk final avec `usage.prompt_tokens` = taille TOTALE
@@ -611,6 +807,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
@@ -620,15 +827,28 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
payload["parallel_tool_calls"] = false
}
body, _ := json.Marshal(payload)
url := fmt.Sprintf("http://localhost:%d/v1/chat/completions", LLMPort())
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, "POST", ep.URL, bytes.NewReader(body))
if err != nil {
return extra, err
}
req.Header.Set("Content-Type", "application/json")
authHeader(req)
// ⚠️ Surtout pas authHeader : celui-ci pose la clé du serveur LOCAL, et
// l'envoyer à api.openai.com serait fuiter un secret chez un tiers en même
// temps qu'un 401 garanti. Chaque endpoint porte la sienne.
ep.auth(req.Header.Set)
resp, err := http.DefaultClient.Do(req)
if err != nil {
// Rien n'est encore parti à l'écran : le tour est rejouable tel quel.
// C'est le cas du moteur qui redémarre (bascule de preset, rechargement
// de modèle) — quelques secondes de connexion refusée qui faisaient
// échouer pour de bon une tâche planifiée tombée pile là.
if netRetries < llmNetRetries && llmRetryableErr(ctx, err) {
logLLMRetry(netRetries+1, friendlyLLMError(err).Error())
if werr := llmNetBackoff(ctx, netRetries); werr == nil {
netRetries++
continue
}
}
err = friendlyLLMError(err)
cb(StreamEvent{Err: err})
return extra, err
@@ -644,6 +864,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.
@@ -674,10 +910,30 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
messages = steerSystem(messages, "N'appelle plus d'outil. Réponds maintenant directement en français à partir des informations déjà obtenues.")
continue
}
err := fmt.Errorf("llama-server a renvoyé %d : %s", resp.StatusCode, msg)
// Dernier recours, APRÈS les filets sémantiques ci-dessus : un statut
// qui dit « pas maintenant » (passerelle, service indisponible, trop de
// requêtes) et non « ta requête est fautive ». Le corps n'a pas été
// diffusé, le tour est rejouable.
if netRetries < llmNetRetries && llmRetryableStatus(resp.StatusCode) && ctx.Err() == nil {
logLLMRetry(netRetries+1, fmt.Sprintf("le moteur a renvoyé %d", resp.StatusCode))
if werr := llmNetBackoff(ctx, netRetries); werr == nil {
netRetries++
continue
}
}
// Nommer la BONNE machine : « llama-server a renvoyé 401 » sur un preset
// externe envoie chercher la panne du mauvais côté.
who := "llama-server"
if ep.External {
who = "l'API externe"
}
err := fmt.Errorf("%s a renvoyé %d : %s", who, resp.StatusCode, msg)
cb(StreamEvent{Err: err})
return extra, err
}
// Une réponse est arrivée : le budget de reprise réseau repart à neuf pour
// la suite de la boucle d'outils.
netRetries = 0
toolCalls := map[int]*ToolCall{}
assistantContent := strings.Builder{}
finishReason := ""
@@ -773,12 +1029,24 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
if cur := toolCalls[0]; cur != nil {
key := "command"
switch cur.Function.Name {
case "mem_search", "web_search":
case "mem_search", "web_search", "recall_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 "recall":
key = "id"
case "web_open", "web_read", "web_grep":
key = "url"
case "grep", "glob":
key = "pattern"
case "ask":
key = "question"
case "criteria":
key = "action"
case "bash_tail":
key = "id"
case "git_clone":
key = "url"
}
p := previewArg(cur.Function.Arguments, key)
// Corps en cours de frappe pour les outils d'écriture : on le diffuse
@@ -939,37 +1207,22 @@ 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
// announce the call BEFORE running it — otherwise the UI shows
// nothing while a slow shell command runs and looks frozen.
label := ""
switch tc.Function.Name {
case "mem_search", "web_search":
label, _ = args["query"].(string)
case "mem_read", "mem_add", "mem_edit", "edit", "write":
label, _ = args["file"].(string)
case "bash":
label, _ = args["command"].(string)
case "web_open", "web_read":
label, _ = args["url"].(string)
case "web_grep":
u, _ := args["url"].(string)
p, _ := args["pattern"].(string)
label = p + " @ " + u
default:
// Outils MCP : libellé = un aperçu compact des arguments.
if isMCPTool(tc.Function.Name) {
label = mcpArgLabel(args)
}
}
// Libellé humain, annoncé AVANT l'exécution (voir toolCallLabel) : sans
// ça l'interface reste muette pendant une commande lente. Il sert
// aussi d'argument principal à plusieurs outils.
label := toolCallLabel(tc.Function.Name, args)
cb(StreamEvent{ToolUsed: &ToolUsedEvent{Name: tc.Function.Name, Label: label}})
result := ""
// diff : rempli par les outils d'écriture (edit / mémoire) pour que
// l'UI montre les lignes ajoutées et retirées.
var diff []DiffLine
// visionImg : partie image_url rendue par see_image, réinjectée après
// le résultat de l'outil (un message `tool` ne porte que du texte).
var visionImg map[string]any
// Appel rigoureusement identique déjà exécuté dans ce tour : on ne le
// rejoue pas. Les petits modèles réémettent volontiers deux fois la
// même écriture ; la rejouer produisait une fausse erreur (« old
@@ -985,6 +1238,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
continue
}
switch tc.Function.Name {
case "see_image":
result, visionImg = toolSeeImage(label)
case "recall":
result = toolRecall(args)
case "recall_search":
result = toolRecallSearch(args)
case "tracker":
result = toolTracker(args)
case "mem_search":
lim := 0
if v, ok := args["limit"].(float64); ok {
@@ -1038,10 +1299,14 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
// Cible = un poste distant : on écrit LÀ-BAS. Pas de diff (on
// n'a pas l'ancien contenu du fichier distant).
result = nodeCall(tgt, nodeCapWrite, map[string]any{"path": label, "content": content})
} else if msg := codeWriteGuard(caps, label, true); msg != "" {
result = msg
} else {
result = fileWrite(label, content)
if !strings.HasPrefix(result, "[erreur]") {
diff = addedDiff(content)
trackerNoteWrite(resolveAgentPath(label))
result += lspDiagBlock(resolveAgentPath(label), caps)
}
}
case "edit":
@@ -1049,13 +1314,41 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
newText, _ := args["new"].(string)
if tgt := agentTargetSlug(); tgt != "" {
result = nodeEditRemote(tgt, label, oldText, newText)
} else if msg := codeWriteGuard(caps, label, false); msg != "" {
result = msg
} else {
result = fileEdit(label, oldText, newText)
// Diff seulement si l'édition a réussi (sinon le fichier n'a pas bougé).
if !strings.HasPrefix(result, "[erreur]") {
diff = lineDiff(oldText, newText)
trackerNoteWrite(resolveAgentPath(label))
result += lspDiagBlock(resolveAgentPath(label), caps)
}
}
case "read":
result = toolRead(args, caps.Code)
case "grep":
result = toolGrep(args, caps.Code)
case "glob":
result = toolGlob(args, caps.Code)
case "ask":
result = toolAsk(args)
if !strings.HasPrefix(result, "[erreur]") {
q, _ := args["question"].(string)
cb(StreamEvent{Ask: &AskEvent{Question: q, Options: askOptions(args)}})
}
case "bash_bg":
result = toolBashBg(args)
case "bash_tail":
result = toolBashTail(args)
case "git_status":
result = toolGitStatus(ctx)
case "git_diff":
result = toolGitDiff(ctx, args)
case "git_clone":
result = toolGitClone(ctx, args)
case "criteria":
result = toolCriteria(args, caps.Role == "verifier")
case "bash":
to := 0
switch v := args["timeout"].(type) {
@@ -1064,6 +1357,12 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case int:
to = v
}
// Politique de sécurité : les commandes catastrophiques sont
// refusées AVANT toute exécution, cible distante comprise.
if reason := dangerousCommand(label); reason != "" {
result = refusedCommandResult(reason)
break
}
if tgt := agentTargetSlug(); tgt != "" {
// Cible = un poste distant : la commande s'exécute LÀ-BAS via le
// canal du poste (fail-closed : nodeCall renvoie une erreur si le
@@ -1081,7 +1380,7 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
case "web_grep":
result = capWebOutput(toolWebGrep(args))
case "web_screenshot":
result = toolWebScreenshot(args)
result = toolWebScreenshot(args, caps)
default:
if isMCPTool(tc.Function.Name) {
result = mcpCall(tc.Function.Name, args)
@@ -1092,7 +1391,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)
@@ -1116,6 +1421,15 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
}
}
}
// see_image a réussi : même relais, et surtout même règle ÉPHÉMÈRE
// que la capture — l'image va dans `messages` (le tour en cours)
// mais PAS dans `extra` (l'historique persistant). Un base64
// persisté repartirait à CHAQUE tour suivant et finirait par
// dépasser la fenêtre pour de bon. Le modèle regarde l'image
// maintenant ; ce qu'il en dit, lui, reste.
if visionImg != nil {
messages = append(messages, seeImageMessage(label, visionImg))
}
}
// Compaction EN COURS DE TOUR. Le seuil n'était testé qu'AU DÉBUT du tour :
// une boucle d'outils peut à elle seule remplir la fenêtre (résultats
@@ -1139,23 +1453,40 @@ 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.
if strings.TrimSpace(assistantContent.String()) == "" {
// Filet de sécurité (le vrai fix est le prompt court, voir baseSystemPrompt) :
// si un modèle « pense sans agir » malgré tout, on le relance UNE fois avec
// une consigne impérative au lieu d'afficher « pas de réponse ».
if len(tools) > 0 && !disableTools && !nudged {
nudged = true
// si un modèle « pense sans agir » malgré tout, on le relance avec une
// consigne impérative au lieu d'afficher « pas de réponse ».
if len(tools) > 0 && !disableTools && nudgeCount < maxNudges {
nudgeCount++
// Le raisonnement de ce tour avorté ne mène à rien : on demande à
// l'UI de l'effacer avant de relancer, pour ne pas afficher deux
// blocs de réflexion successifs.
cb(StreamEvent{DropReasoning: true})
messages = append(messages, Message{
Role: "user",
Content: "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act.",
})
nudge := "You reasoned but did not call a tool or answer. Act NOW: call the appropriate tool directly (e.g. mem_search/mem_read/bash), or give your final answer if you already have the info. Don't explain, act."
if nudgeCount > 1 {
// Le premier nudge n'a pas suffi : le modèle re-décrit le même
// plan sans l'exécuter. Second nudge plus impératif, où on lui
// interdit explicitement de re-raisonner.
nudge = "You are stuck re-describing the same plan without executing it. Stop reasoning. In your NEXT message, either call ONE tool right now, or write your final answer in plain text using only what you already know — no more planning, no more thinking, act or answer this instant."
}
messages = append(messages, Message{Role: "user", Content: nudge})
continue
}
cb(StreamEvent{Content: "_(le modèle n'a pas produit de réponse — finish: " + finishReason + ")_"})
@@ -1173,7 +1504,16 @@ func runChat(ctx context.Context, messages []Message, temperature float64, caps
var healthClient = &http.Client{Timeout: 3 * time.Second}
// healthCheck pings llama.cpp's /health endpoint.
//
// Preset externe : il n'y a pas de llama-server local à sonder. On répond
// « prêt » sans latence — la vraie joignabilité de l'API distante se révèle à
// l'appel de complétion, avec un message d'erreur explicite si elle échoue.
// Sans ce court-circuit, la saisie resterait bloquée sur un moteur éteint que
// personne n'allumera jamais.
func healthCheck() bool {
if externalActive() {
return true
}
resp, err := healthClient.Get(fmt.Sprintf("http://localhost:%d/health", LLMPort()))
if err != nil {
return false
+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)
}
}
+67 -14
View File
@@ -40,15 +40,67 @@ import (
// oaiSuffix est le domaine sous lequel on autorise l'émission de certificats.
const oaiSuffix = ".oai.ajean.link"
// viaTunnelHeader marque une requête arrivée par le tunnel du relais (posé par
// withLocalAuth, qui l'efface d'abord — un client ne peut donc pas le forger).
// Il sert à garder la promesse du tunnel : hors du front TLS dédié, la surface
// des complétions y reste fermée tant que oaiPublicEnabled() est faux.
const viaTunnelHeader = "X-Loki-Via"
// sendOAIError répond au format d'erreur d'OpenAI. Les SDK officiels lisent
// body.error.message ; un {"error":"…"} plat leur fait afficher un message vide,
// et certains clients (LiteLLM, LangChain) échouent carrément au décodage.
func sendOAIError(w http.ResponseWriter, status int, msg, typ, code string) {
sendJSON(w, status, map[string]any{"error": map[string]any{
"message": msg, "type": typ, "param": nil, "code": code,
}})
}
// mountOAI branche la surface compatible OpenAI sur le mux de Loki, protégée par
// la clé des complétions (web_auth.go).
//
// C'est ce qui rend l'API joignable par le nom de domaine ou l'IP de
// L'INTERFACE, sans publier de second port : le moteur peut rester sur la boucle
// locale, et un reverse proxy devant Loki suffit pour le TLS. L'adresse annoncée
// auparavant (celle du moteur, port 8080) n'était joignable dans aucun
// déploiement en conteneur, où ce port n'est pas publié.
//
// ⚠️ On ne monte QUE /v1/. Surtout pas /health, /props, /metrics ni /slots — que
// oaiHandler autorise pour le tunnel : ils divulgueraient le modèle chargé, la
// taille de contexte et l'état des slots, sans authentification quand aucune clé
// n'est définie. Le filtre interne d'oaiHandler reste en seconde barrière.
func mountOAI(mux *http.ServeMux) {
mux.Handle("/v1/", requireCompletionKey(oaiHandler()))
}
// oaiHandler construit le reverse-proxy vers llama-server, restreint à la surface
// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le SaaS envoie
// la vraie clé (.api_key), que llama-server valide lui-même (--api-key).
// compatible OpenAI. On NE touche PAS à l'en-tête Authorization : le client
// envoie la vraie clé, que llama-server valide lui-même (--api-key). Elle est
// donc vérifiée deux fois — par Loki puis par le moteur — et c'est voulu : le
// moteur reste protégé même si on l'expose un jour en direct.
func oaiHandler() http.Handler {
llama := &url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())}
lp := httputil.NewSingleHostReverseProxy(llama)
lp.FlushInterval = -1 // streaming SSE des complétions
lp.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) {
http.Error(w, "llama-server injoignable: "+e.Error(), http.StatusBadGateway)
lp := &httputil.ReverseProxy{
// FlushInterval négatif = on vide le tampon à chaque écriture : c'est ce
// qui fait arriver les tokens un par un chez le client au lieu d'un bloc
// en fin de génération.
// ⚠️ Ne JAMAIS envelopper le ResponseWriter sur ce chemin (compteur,
// journalisation…) : un wrapper qui n'implémente pas http.Flusher
// retransforme le flux en réponse bufferisée, sans autre symptôme qu'une
// attente inexplicable.
FlushInterval: -1,
// Rewrite (et non Director) : le port du moteur est relu À CHAQUE
// REQUÊTE. Capturé à la construction, il figeait l'ancienne valeur dès
// qu'on changeait PORT depuis l'interface, et le proxy visait dans le
// vide jusqu'au redémarrage de Loki. Rewrite n'hérite pas non plus des
// X-Forwarded-* entrants : un client ne peut pas les forger vers le
// moteur.
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(&url.URL{Scheme: "http", Host: fmt.Sprintf("127.0.0.1:%d", LLMPort())})
pr.Out.Host = pr.In.Host
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, e error) {
sendOAIError(w, http.StatusBadGateway,
"moteur injoignable : "+e.Error(), "api_error", "upstream_unavailable")
},
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p := r.URL.Path
@@ -56,7 +108,8 @@ func oaiHandler() http.Handler {
lp.ServeHTTP(w, r)
return
}
http.Error(w, "not found (endpoint OpenAI: /v1/*)", http.StatusNotFound)
sendOAIError(w, http.StatusNotFound,
"chemin inconnu — l'endpoint compatible OpenAI est /v1/*", "invalid_request_error", "not_found")
})
}
@@ -72,14 +125,14 @@ func runOAIFront(rawLn net.Listener, tlsCfg *tls.Config) error {
return srv.Serve(tls.NewListener(rawLn, tlsCfg))
}
// oaiPublicEnabled indique si l'accès OpenAI public est activé pour cette
// machine. Piloté par l'UI et lu en direct → activable/coupable sans redémarrer
// le service de lien.
// oaiPublicEnabled indique si le front TLS du tunnel doit servir la surface
// OpenAI. Drapeau désormais en LECTURE SEULE : plus aucune interface ne
// l'allume, depuis que l'endpoint est servi par Loki sur son propre port (voir
// mountOAI). Il ne subsiste que pour les installations migrées depuis AJEAN dont
// le tunnel sert encore ce front — et pour son défaut, qui est le refus :
// demuxTunnelStream ferme le flux quand il est absent.
func oaiPublicEnabled() bool { return getBool(bkState, "oai_public") }
// setOAIPublic active (on) ou coupe (off) l'accès OpenAI public.
func setOAIPublic(on bool) error { return putBool(bkState, "oai_public", on) }
// oaiTLSConfig renvoie une config TLS qui, à la demande, obtient/renouvelle via
// Let's Encrypt (TLS-ALPN-01) le certificat de tout nom en *.oai.ajean.link, et
// répond elle-même aux challenges ACME. La clé privée est stockée dans
+251
View File
@@ -0,0 +1,251 @@
package loki
// Tests de la surface compatible OpenAI servie par Loki (mountOAI).
//
// Elle est joignable partout où l'interface l'est : c'est tout l'intérêt, et
// c'est aussi ce qui rend sa garde d'accès critique. Ces tests verrouillent les
// quatre promesses : la clé est exigée quand elle existe, l'amont n'est jamais
// appelé quand elle manque, l'en-tête du client arrive INTACT au moteur (qui la
// revalide), et rien d'autre que /v1 n'est monté.
import (
"bufio"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
// fauxMoteur monte un llama-server de comédie et pointe la configuration
// dessus : le proxy vise 127.0.0.1:LLMPort(), c'est donc tout ce qu'il faut.
// Renvoie le serveur et un pointeur sur les en-têtes de la dernière requête
// reçue (nil tant qu'il n'a rien reçu).
func fauxMoteur(t *testing.T, h http.HandlerFunc) (*httptest.Server, func() http.Header) {
t.Helper()
var vues http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
vues = r.Header.Clone()
if h != nil {
h(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"loki"}]}`))
}))
t.Cleanup(srv.Close)
port := srv.URL[strings.LastIndexByte(srv.URL, ':')+1:]
if _, err := strconv.Atoi(port); err != nil {
t.Fatalf("port du faux moteur illisible : %q", srv.URL)
}
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
return srv, func() http.Header { return vues }
}
// appelOAI joue une requête à travers la garde + le proxy, sans monter tout le
// serveur web (newWebMux a des effets de bord : MCP, migrations, conversation).
func appelOAI(t *testing.T, méthode, chemin, clé string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(méthode, chemin, nil)
if clé != "" {
req.Header.Set("Authorization", "Bearer "+clé)
}
rec := httptest.NewRecorder()
requireCompletionKey(oaiHandler()).ServeHTTP(rec, req)
return rec
}
// erreurOAI décode le corps au format d'erreur d'OpenAI — celui que les SDK
// savent présenter à l'utilisateur.
func erreurOAI(t *testing.T, rec *httptest.ResponseRecorder) (message, code string) {
t.Helper()
var body struct {
Error struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("corps illisible (%d) : %s", rec.Code, rec.Body.String())
}
return body.Error.Message, body.Error.Code
}
// Sans clé configurée, l'endpoint est ouvert — comme llama-server sans
// --api-key. Le panneau et le démarrage le disent en rouge ; ici on vérifie
// seulement que le comportement est bien celui-là, et pas un refus silencieux.
func TestOAISansCleLaissePasser(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
rec := appelOAI(t, "GET", "/v1/models", "")
if rec.Code != 200 {
t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"loki"`) {
t.Errorf("réponse du moteur non relayée : %s", rec.Body.String())
}
if vues() == nil {
t.Error("le moteur n'a pas été appelé")
}
}
// La bonne clé passe, ET l'en-tête arrive INTACT au moteur : c'est le contrat.
// llama-server la valide lui aussi (--api-key), il reste donc protégé même si
// on l'expose un jour en direct.
func TestOAIBonneCleEtEnTeteRelayee(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
if err := writeAPIKey("sk-loki-test"); err != nil {
t.Fatal(err)
}
rec := appelOAI(t, "GET", "/v1/models", "sk-loki-test")
if rec.Code != 200 {
t.Fatalf("code %d, corps %s", rec.Code, rec.Body.String())
}
if got := vues().Get("Authorization"); got != "Bearer sk-loki-test" {
t.Errorf("en-tête transmis au moteur = %q, attendu la clé du client", got)
}
}
// Mauvaise clé, et clé absente : 401 au format OpenAI, et le moteur n'est JAMAIS
// appelé — une garde qui laisse passer la requête avant de refuser ne protège
// rien.
func TestOAICleRefusee(t *testing.T) {
for _, cas := range []struct{ nom, envoyée string }{
{"mauvaise clé", "sk-loki-pasbonne"},
{"aucune clé", ""},
} {
t.Run(cas.nom, func(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
if err := writeAPIKey("sk-loki-test"); err != nil {
t.Fatal(err)
}
rec := appelOAI(t, "POST", "/v1/chat/completions", cas.envoyée)
if rec.Code != 401 {
t.Fatalf("code %d, attendu 401 — corps %s", rec.Code, rec.Body.String())
}
if rec.Header().Get("WWW-Authenticate") == "" {
t.Error("en-tête WWW-Authenticate absent")
}
msg, code := erreurOAI(t, rec)
if code != "invalid_api_key" || msg == "" {
t.Errorf("erreur = {code:%q, message:%q}", code, msg)
}
if vues() != nil {
t.Error("le moteur a été appelé malgré le refus")
}
})
}
}
// La clé résiduelle de config.env fait foi quand la base n'en a pas : c'est
// celle que le moteur exige (backend_serve.go). Sans ce miroir, Loki laissait
// passer et llama refusait — un 401 que rien n'expliquait.
func TestOAICleResiduelleDeConfig(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
if err := SetConfigKey("API_KEY", "sk-ancienne"); err != nil {
t.Fatal(err)
}
if rec := appelOAI(t, "GET", "/v1/models", ""); rec.Code != 401 {
t.Fatalf("sans clé : code %d, attendu 401", rec.Code)
}
if vues() != nil {
t.Fatal("le moteur a été appelé malgré le refus")
}
if rec := appelOAI(t, "GET", "/v1/models", "sk-ancienne"); rec.Code != 200 {
t.Fatalf("avec la clé de config.env : code %d, attendu 200", rec.Code)
}
}
// Un préflight CORS ne porte jamais d'Authorization. Le refuser casserait tout
// client tiers qui tourne dans un navigateur.
func TestOAIPreflightPasseSansCle(t *testing.T) {
testHome(t)
_, vues := fauxMoteur(t, nil)
if err := writeAPIKey("sk-loki-test"); err != nil {
t.Fatal(err)
}
if rec := appelOAI(t, "OPTIONS", "/v1/chat/completions", ""); rec.Code == 401 {
t.Fatalf("préflight refusé : %s", rec.Body.String())
}
if vues() == nil {
t.Error("le préflight n'a pas atteint le moteur, qui répond ses en-têtes CORS")
}
}
// Seul /v1/ est monté. /metrics, /props et /slots divulgueraient le modèle
// chargé et l'état du moteur — sans clé, à qui joint l'adresse.
func TestOAIMonteSeulementV1(t *testing.T) {
testHome(t)
mux := http.NewServeMux()
mountOAI(mux)
témoin := false
mux.HandleFunc("/", func(http.ResponseWriter, *http.Request) { témoin = true })
for _, chemin := range []string{"/metrics", "/props", "/slots", "/api/ping", "/"} {
témoin = false
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", chemin, nil))
if !témoin {
t.Errorf("%s n'a pas été servi par le handler racine — il est passé à l'endpoint OpenAI", chemin)
}
}
// …et /v1/, lui, ne va PAS au handler racine.
témoin = false
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", "/v1/models", nil))
if témoin {
t.Error("/v1/models a été servi par le handler racine")
}
}
// Le streaming doit arriver en morceaux. Si FlushInterval régresse, le premier
// événement n'arrive qu'à la fin de la génération — invisible en test unitaire
// classique, d'où ce test qui bloque l'amont entre deux écritures.
func TestOAIStreamingArriveEnMorceaux(t *testing.T) {
testHome(t)
débloque := make(chan struct{})
fauxMoteur(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
fl, ok := w.(http.Flusher)
if !ok {
t.Error("le faux moteur ne sait pas vider son tampon")
return
}
_, _ = w.Write([]byte("data: un\n\n"))
fl.Flush()
<-débloque
_, _ = w.Write([]byte("data: deux\n\n"))
fl.Flush()
})
front := httptest.NewServer(requireCompletionKey(oaiHandler()))
defer front.Close()
defer close(débloque)
resp, err := http.Get(front.URL + "/v1/chat/completions")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
lu := make(chan string, 1)
go func() {
ligne, _ := bufio.NewReader(resp.Body).ReadString('\n')
lu <- ligne
}()
select {
case ligne := <-lu:
if !strings.Contains(ligne, "un") {
t.Fatalf("première ligne = %q", ligne)
}
case <-time.After(3 * time.Second):
t.Fatal("rien reçu avant la fin de la génération : le flux est bufferisé")
}
}
+124
View File
@@ -0,0 +1,124 @@
package loki
// llm_retry_net.go — réessayer un tour que RIEN n'a encore diffusé.
//
// Jusqu'ici, la requête de complétion partait une fois : un `Do()` qui échoue
// ou un statut d'erreur tuait le tour. Les messages d'erreur eux-mêmes le
// disaient — « réessaie dans quelques secondes », « réessaie dans un
// instant » — c'est-à-dire qu'on demandait à l'utilisateur de refaire à la main
// ce que le code pouvait faire seul.
//
// Le cas courant chez Loki n'a rien d'exotique : le moteur redémarre (bascule de
// preset, rechargement de modèle, redémarrage du conteneur) et la connexion est
// refusée pendant quelques secondes. Une tâche planifiée qui tombe pile là
// échouait pour de bon, sans personne pour cliquer.
//
// LA règle de sûreté, et elle ne souffre pas d'exception : on ne rejoue que
// TANT QU'AUCUN OCTET N'A ÉTÉ DIFFUSÉ. Une fois le flux commencé, la moitié de
// la réponse est déjà chez l'utilisateur ; la rejouer la dupliquerait. Les deux
// points de reprise (échec de `Do`, statut d'erreur avant lecture du corps)
// sont tous deux situés avant la première ligne SSE.
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"syscall"
"time"
)
const (
// llmNetRetries : tentatives CONSÉCUTIVES par tour. Le compteur repart à zéro
// dès qu'une réponse arrive, donc une boucle d'outils longue retrouve son
// budget à chaque itération réussie — mais un moteur durablement mort rend la
// main au lieu de tourner sans fin.
llmNetRetries = 3
// Attente de la 1re reprise, doublée ensuite (0,8 s → 1,6 s → 3,2 s). Un
// llama-server qui redémarre met quelques secondes à rouvrir son port : plus
// court ne sert à rien, plus long fait passer une panne franche pour un gel.
llmNetWait = 800 * time.Millisecond
llmNetMaxWait = 5 * time.Second
)
// llmRetryableErr : cette erreur de transport vaut-elle une nouvelle tentative ?
//
// Un ctx déjà fini n'en vaut JAMAIS une : c'est le /stop de l'utilisateur ou la
// limite de l'appelant, et réessayer serait passer outre. On le vérifie donc
// avant de regarder l'erreur elle-même — une échéance dépassée remonte sinon
// comme un simple timeout réseau, indiscernable d'une surcharge du moteur.
func llmRetryableErr(ctx context.Context, err error) bool {
if err == nil || ctx.Err() != nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return false
}
switch {
case errors.Is(err, syscall.ECONNREFUSED): // moteur en cours de démarrage
return true
case errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.EOF), errors.Is(err, io.ErrUnexpectedEOF):
return true
case isNetTimeout(err): // moteur surchargé ou en plein chargement du modèle
return true
}
return false
}
// llmRetryableStatus : ce code HTTP vaut-il une nouvelle tentative ?
//
// Volontairement étroit. 500 en est ABSENT : c'est le code que llama.cpp rend
// pour un appel d'outil malformé ou un prompt qui dépasse le contexte, deux
// échecs déterministes que les filets au-dessus traitent déjà (retrait des
// outils, compaction en vol). Le rejouer trois fois ne ferait que retarder
// l'erreur de plusieurs secondes. Restent les codes qui disent « pas
// maintenant » : passerelle, service indisponible, trop de requêtes.
func llmRetryableStatus(code int) bool {
switch code {
case http.StatusTooManyRequests, http.StatusBadGateway,
http.StatusServiceUnavailable, http.StatusGatewayTimeout:
return true
}
return false
}
// llmNetDelay : combien attendre avant la tentative n (0 = la première reprise).
// Doublement puis plafond. Séparé de l'attente pour être vérifiable sans dormir
// cinq secondes dans la suite de tests — et parce qu'un décalage de bits sur un
// n non borné déborderait en silence (0,8 s << 64 revient à zéro).
func llmNetDelay(n int) time.Duration {
if n < 0 {
n = 0
}
if n > 16 { // au-delà, le plafond s'applique de toute façon
n = 16
}
if d := llmNetWait << n; d < llmNetMaxWait {
return d
}
return llmNetMaxWait
}
// llmNetBackoff attend avant la tentative n (0 = la première reprise).
// Interruptible : un /stop pendant l'attente rend la main tout de suite au lieu
// de faire patienter l'utilisateur pour une requête qu'il vient d'annuler.
func llmNetBackoff(ctx context.Context, n int) error {
t := time.NewTimer(llmNetDelay(n))
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
return nil
}
}
// logLLMRetry trace la reprise. Elle n'est PAS envoyée à l'interface : le tour
// n'a encore rien produit, et faire clignoter une erreur qu'on est en train de
// rattraper inquiéterait pour rien. Le journal du conteneur, lui, doit la voir —
// c'est là qu'on cherche quand « ça a mis huit secondes ».
func logLLMRetry(attempt int, cause string) {
fmt.Fprintf(os.Stderr, "[llm] %s — nouvelle tentative %d/%d\n", cause, attempt, llmNetRetries)
}
+199
View File
@@ -0,0 +1,199 @@
package loki
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"syscall"
"testing"
"time"
)
// flakyServer répond `code` aux `fails` premières requêtes, puis sert un flux
// SSE normal. Renvoie le port (à mettre dans PORT) et le compteur d'appels.
func flakyServer(t *testing.T, fails int, code int, body string) (string, *atomic.Int32) {
t.Helper()
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if int(calls.Add(1)) <= fails {
http.Error(w, "moteur occupé", code)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(200)
_, _ = w.Write([]byte(body))
w.(http.Flusher).Flush()
}))
t.Cleanup(srv.Close)
u, err := url.Parse(srv.URL)
if err != nil {
t.Fatal(err)
}
return u.Port(), &calls
}
// Le cas qui motive tout le fichier : le moteur redémarre, la connexion est
// refusée quelques secondes, et le tour mourait pour de bon — une tâche
// planifiée tombée pile là échouait sans personne pour recliquer.
func TestConnexionRefuseeEstRejouee(t *testing.T) {
testHome(t)
// Un port fermé : on ouvre puis on referme aussitôt pour en obtenir un dont on
// est sûr que personne n'écoute.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
if err := SetConfigKey("PORT", itoa(port)); err != nil {
t.Fatal(err)
}
start := time.Now()
_, err = runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true })
elapsed := time.Since(start)
if err == nil {
t.Fatal("moteur injoignable : aucune erreur remontée")
}
// 3 reprises espacées de 0,8 / 1,6 / 3,2 s : le tour ne peut pas avoir rendu
// la main tout de suite. Sans ce délai, aucune reprise n'a eu lieu.
if min := llmNetWait + 2*llmNetWait; elapsed < min {
t.Fatalf("rendu la main en %v : aucune reprise n'a eu lieu (attendu au moins %v)", elapsed, min)
}
}
// Un statut « pas maintenant » (503) est rejoué, et le tour aboutit.
func TestStatutTransitoireEstRejoueEtAboutit(t *testing.T) {
testHome(t)
port, calls := flakyServer(t, 2, http.StatusServiceUnavailable,
sseChunk("ça a fini par passer")+"data: [DONE]\n\n")
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
var content strings.Builder
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
return true
})
if err != nil {
t.Fatalf("503 transitoire non rattrapé : %v", err)
}
if n := calls.Load(); n != 3 {
t.Fatalf("%d appel(s) au moteur, attendu 3 (deux échecs puis la bonne)", n)
}
if !strings.Contains(content.String(), "ça a fini par passer") {
t.Fatalf("réponse perdue : %q", content.String())
}
}
// Contre-épreuve : un 500 n'est PAS un statut de reprise RÉSEAU. C'est ce que
// llama.cpp rend pour un appel d'outil malformé ou un prompt trop long — deux
// échecs déterministes ; attendre puis rejouer à l'identique ne ferait que
// retarder l'erreur de plusieurs secondes.
//
// Le tour peut malgré tout repartir UNE fois : le filet de compaction en vol,
// antérieur à ce fichier, retente le tour avec un historique résumé (le 500 est
// souvent un dépassement de contexte). C'est un rejeu SÉMANTIQUE — il change la
// requête et n'attend pas. La preuve qu'aucune reprise réseau n'a eu lieu est
// donc le CHRONO : trois reprises coûteraient au bas mot 0,8 + 1,6 s d'attente.
func TestErreur500NestPasRejoueeParLeReseau(t *testing.T) {
testHome(t)
port, calls := flakyServer(t, 99, http.StatusInternalServerError, "")
if err := SetConfigKey("PORT", port); err != nil {
t.Fatal(err)
}
start := time.Now()
_, err := runChat(context.Background(), []Message{{Role: "user", Content: "bonjour"}}, 0.7, Caps{}, func(ev StreamEvent) bool { return true })
elapsed := time.Since(start)
if err == nil {
t.Fatal("500 : aucune erreur remontée")
}
if elapsed >= llmNetWait {
t.Fatalf("rendu la main en %v : une attente de reprise réseau a eu lieu sur un 500", elapsed)
}
// Borne haute large : ce qui compte est qu'on ne soit pas allé au bout du
// budget réseau (1 + 3 = 4 appels).
if n := calls.Load(); n > 2 {
t.Fatalf("%d appels au moteur sur un 500 : le budget de reprise réseau a été consommé", n)
}
}
// Un /stop pendant le tour ne doit surtout pas être rattrapé : réessayer, c'est
// passer outre l'annulation de l'utilisateur.
func TestAnnulationNestJamaisRejouee(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if llmRetryableErr(ctx, syscall.ECONNREFUSED) {
t.Fatal("ctx annulé : la reprise doit être refusée")
}
if llmRetryableErr(context.Background(), context.Canceled) {
t.Fatal("context.Canceled : la reprise doit être refusée")
}
if llmRetryableErr(context.Background(), context.DeadlineExceeded) {
t.Fatal("échéance dépassée : c'est la limite de l'appelant, pas une panne réseau")
}
// L'attente elle-même rend la main tout de suite sur un ctx fini.
if err := llmNetBackoff(ctx, 2); err == nil {
t.Fatal("l'attente doit être interrompue par le ctx")
}
}
// Les erreurs de transport qui méritent une reprise, et celles qui n'en méritent
// pas. Une faute de frappe dans l'URL ne se répare pas en attendant.
func TestErreursDeTransportRejouables(t *testing.T) {
ctx := context.Background()
for _, e := range []error{syscall.ECONNREFUSED, syscall.ECONNRESET, io.EOF, io.ErrUnexpectedEOF} {
if !llmRetryableErr(ctx, e) {
t.Errorf("%v devrait être rejouable (moteur qui redémarre ou connexion perdue)", e)
}
}
if llmRetryableErr(ctx, errors.New("unsupported protocol scheme")) {
t.Error("une erreur de configuration ne se répare pas en réessayant")
}
if llmRetryableErr(ctx, nil) {
t.Error("pas d'erreur, pas de reprise")
}
}
// Les codes de reprise, et ceux qui n'en sont pas.
func TestStatutsRejouables(t *testing.T) {
for _, c := range []int{http.StatusTooManyRequests, http.StatusBadGateway,
http.StatusServiceUnavailable, http.StatusGatewayTimeout} {
if !llmRetryableStatus(c) {
t.Errorf("%d dit « pas maintenant » : rejouable", c)
}
}
for _, c := range []int{200, 400, 401, 404, 500} {
if llmRetryableStatus(c) {
t.Errorf("%d ne doit pas déclencher de reprise", c)
}
}
}
// L'attente double à chaque tentative, se plafonne, et ne déborde pas : un
// décalage de bits sur un n non borné repasserait à zéro en silence — la reprise
// deviendrait une rafale.
func TestAttenteCroissantePuisPlafonnee(t *testing.T) {
if got := llmNetDelay(0); got != llmNetWait {
t.Errorf("1re attente = %v, attendu %v", got, llmNetWait)
}
if got := llmNetDelay(1); got != 2*llmNetWait {
t.Errorf("2e attente = %v, attendu le double", got)
}
for _, n := range []int{10, 20, 64, 1000} {
if got := llmNetDelay(n); got != llmNetMaxWait {
t.Errorf("llmNetDelay(%d) = %v, attendu le plafond %v", n, got, llmNetMaxWait)
}
}
if got := llmNetDelay(-1); got != llmNetWait {
t.Errorf("llmNetDelay(-1) = %v, attendu la 1re attente", 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"]
}
]
+157
View File
@@ -0,0 +1,157 @@
package loki
// Catalogue MCP embarqué.
//
// Ajouter un serveur MCP demandait jusqu'ici d'écrire soi-même la commande et
// ses arguments (`npx -y @scope/paquet …`) dans la modale. Le catalogue fournit
// une liste de serveurs connus, déjà renseignés : un clic préremplit la modale,
// l'utilisateur relit la commande et enregistre.
//
// Le catalogue est un JSON EMBARQUÉ dans le binaire (go:embed), pas un appel à
// un annuaire distant : cohérent avec un Loki qui tourne hors ligne, et rien de
// ce qui s'exécute sur la machine ne vient du réseau. Pour proposer un serveur
// de plus : éditer mcp_catalog.json et recompiler.
//
// SÉCURITÉ : le catalogue ne fait qu'AFFICHER une commande. Il n'installe ni
// n'exécute rien tout seul — c'est l'enregistrement du serveur par
// l'utilisateur, via la modale existante, qui décide. Même niveau de confiance
// que l'outil `bash` du mode agent (voir l'en-tête de mcp_config.go).
import (
_ "embed"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
)
//go:embed mcp_catalog.json
var mcpCatalogRaw []byte
// MCPCatalogEntry décrit un serveur proposé par le catalogue. Les champs de
// transport reprennent ceux de MCPServerConfig : l'UI peut les recopier tels
// quels dans la modale d'ajout.
type MCPCatalogEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
Homepage string `json:"homepage,omitempty"`
// Runtime : binaire nécessaire pour lancer le serveur ("node" → npx,
// "python" → uvx). Sert à signaler une entrée non installable ici.
Runtime string `json:"runtime,omitempty"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env map[string]string `json:"env,omitempty"`
// Secrets : variables d'environnement que l'utilisateur DOIT renseigner
// (jetons, clés d'API). L'UI les met en évidence ; elles sont volontairement
// vides dans le catalogue.
Secrets []string `json:"secrets,omitempty"`
// Renseignés à la lecture, pas dans le fichier.
Installed bool `json:"installed"`
RuntimeOK bool `json:"runtimeOk"`
}
type mcpCatalogFile struct {
Version int `json:"version"`
Servers []MCPCatalogEntry `json:"servers"`
}
var (
mcpCatalogOnce sync.Once
mcpCatalogParsed []MCPCatalogEntry
mcpCatalogParseEr error
)
// mcpCatalogEntries décode le JSON embarqué une seule fois. Le contenu est figé
// dans le binaire : inutile de le relire à chaque appel.
func mcpCatalogEntries() ([]MCPCatalogEntry, error) {
mcpCatalogOnce.Do(func() {
var f mcpCatalogFile
if err := json.Unmarshal(mcpCatalogRaw, &f); err != nil {
mcpCatalogParseEr = fmt.Errorf("catalogue MCP illisible : %w", err)
return
}
mcpCatalogParsed = f.Servers
})
return mcpCatalogParsed, mcpCatalogParseEr
}
// mcpRuntimeAvailable indique si le binaire qui lance ce type de serveur est
// présent. L'image conteneur embarque Node ; uv/uvx peut manquer.
func mcpRuntimeAvailable(runtime string) bool {
switch runtime {
case "node":
return lookPathOK("npx")
case "python":
return lookPathOK("uvx")
default:
return true
}
}
func lookPathOK(bin string) bool {
_, err := exec.LookPath(bin)
return err == nil
}
// MCPCatalog renvoie les entrées du catalogue, chemins résolus et marquées
// « déjà installée » (un serveur configuré porte le même nom que l'entrée).
func MCPCatalog() ([]MCPCatalogEntry, error) {
entries, err := mcpCatalogEntries()
if err != nil {
return nil, err
}
configured, err := LoadMCPConfig()
if err != nil {
// Une config MCP illisible ne doit pas vider le catalogue : on affiche
// les entrées sans savoir lesquelles sont déjà là.
configured = map[string]MCPServerConfig{}
}
out := make([]MCPCatalogEntry, 0, len(entries))
for _, e := range entries {
e.Args = expandCatalogSlice(e.Args)
e.Env = expandCatalogMap(e.Env)
_, e.Installed = configured[e.ID]
e.RuntimeOK = mcpRuntimeAvailable(e.Runtime)
out = append(out, e)
}
return out, nil
}
// expandCatalogToken remplace les jetons de chemin du catalogue. Seul
// {workspace} existe : c'est le dossier de travail de l'agent, le seul chemin
// qu'un serveur du catalogue a une raison légitime de recevoir par défaut.
func expandCatalogToken(s string) string {
if !strings.Contains(s, "{workspace}") {
return s
}
return strings.ReplaceAll(s, "{workspace}", workspaceDir())
}
func expandCatalogSlice(in []string) []string {
if len(in) == 0 {
return in
}
out := make([]string, len(in))
for i, s := range in {
out[i] = expandCatalogToken(s)
}
return out
}
func expandCatalogMap(in map[string]string) map[string]string {
if len(in) == 0 {
return in
}
out := make(map[string]string, len(in))
for k, v := range in {
out[k] = expandCatalogToken(v)
}
return out
}
+262
View File
@@ -0,0 +1,262 @@
{
"version": 1,
"note": "Catalogue MCP embarqué (hors ligne, aucun annuaire distant). Éditable à la main : ajoute une entrée pour la proposer dans l'interface, puis relance le build. Le jeton {workspace} est remplacé par le dossier de travail de l'agent. Les entrées « uvx --with mcp<2 … » visent des serveurs publiés avant le SDK MCP 2.0 : sans cet épinglage, ils plantent au démarrage (ImportError McpError).",
"servers": [
{
"id": "filesystem",
"name": "Filesystem",
"category": "Fichiers",
"description": "Lecture, écriture et recherche de fichiers, limitées aux dossiers passés en argument.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "{workspace}"],
"env": {},
"secrets": []
},
{
"id": "git",
"name": "Git",
"category": "Code",
"description": "Historique, diff et statut d'un dépôt Git ; commits et branches.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
"runtime": "python",
"command": "uvx",
"args": ["mcp-server-git", "--repository", "{workspace}"],
"env": {},
"secrets": []
},
{
"id": "fetch",
"name": "Fetch",
"category": "Web",
"description": "Télécharge une URL et la convertit en Markdown pour le modèle.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "mcp-server-fetch"],
"env": {},
"secrets": []
},
{
"id": "memory",
"name": "Memory",
"category": "Agent",
"description": "Mémoire sous forme de graphe de connaissances (entités, relations, observations).",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-memory"],
"env": { "MEMORY_FILE_PATH": "{workspace}/mcp-memory.json" },
"secrets": []
},
{
"id": "sequential-thinking",
"name": "Sequential Thinking",
"category": "Agent",
"description": "Structure le raisonnement en étapes révisables. Utile aux petits modèles locaux.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
"env": {},
"secrets": []
},
{
"id": "time",
"name": "Time",
"category": "Utilitaires",
"description": "Heure courante et conversions de fuseaux horaires.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/time",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "mcp-server-time"],
"env": {},
"secrets": []
},
{
"id": "sqlite",
"name": "SQLite",
"category": "Données",
"description": "Interroge et modifie une base SQLite locale.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/sqlite",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "mcp-server-sqlite", "--db-path", "{workspace}/agent.db"],
"env": {},
"secrets": []
},
{
"id": "everything",
"name": "Everything (test)",
"category": "Utilitaires",
"description": "Serveur de référence exposant toutes les primitives MCP. Sert à vérifier l'intégration.",
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/everything",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-everything"],
"env": {},
"secrets": []
},
{
"id": "duckduckgo",
"name": "DuckDuckGo",
"category": "Web",
"description": "Recherche web via DuckDuckGo, sans clé d'API.",
"homepage": "https://pypi.org/project/duckduckgo-mcp-server/",
"runtime": "python",
"command": "uvx",
"args": ["duckduckgo-mcp-server"],
"env": {},
"secrets": []
},
{
"id": "playwright",
"name": "Playwright",
"category": "Web",
"description": "Pilote un navigateur (navigation, clics, formulaires) via l'arbre d'accessibilité.",
"homepage": "https://github.com/microsoft/playwright-mcp",
"runtime": "node",
"command": "npx",
"args": ["-y", "@playwright/mcp@latest", "--headless"],
"env": {},
"secrets": []
},
{
"id": "puppeteer",
"name": "Puppeteer",
"category": "Web",
"description": "Automatisation d'un Chrome sans interface (alternative à Playwright).",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/puppeteer",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-puppeteer"],
"env": {},
"secrets": []
},
{
"id": "context7",
"name": "Context7",
"category": "Code",
"description": "Documentation à jour des bibliothèques, injectée directement dans le contexte.",
"homepage": "https://github.com/upstash/context7",
"runtime": "node",
"command": "npx",
"args": ["-y", "@upstash/context7-mcp"],
"env": {},
"secrets": []
},
{
"id": "github",
"name": "GitHub",
"category": "Code",
"description": "Dépôts, issues et pull requests via l'API GitHub. Nécessite un jeton personnel.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/github",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "" },
"secrets": ["GITHUB_PERSONAL_ACCESS_TOKEN"]
},
{
"id": "postgres",
"name": "PostgreSQL",
"category": "Données",
"description": "Requêtes SQL en lecture seule et inspection du schéma d'une base PostgreSQL.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/postgres",
"runtime": "node",
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgresql://utilisateur:motdepasse@hote:5432/base"
],
"env": {},
"secrets": []
},
{
"id": "docker",
"name": "Docker",
"category": "Infra",
"description": "Gère conteneurs et stacks compose. Nécessite l'accès au socket Docker.",
"homepage": "https://pypi.org/project/docker-mcp/",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "docker-mcp"],
"env": {},
"secrets": []
},
{
"id": "brave-search",
"name": "Brave Search",
"category": "Web",
"description": "Recherche web et locale via l'API Brave. Nécessite une clé d'API.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/brave-search",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": { "BRAVE_API_KEY": "" },
"secrets": ["BRAVE_API_KEY"]
},
{
"id": "firecrawl",
"name": "Firecrawl",
"category": "Web",
"description": "Scraping et crawl de sites avec extraction structurée. Nécessite une clé d'API.",
"homepage": "https://github.com/firecrawl/firecrawl-mcp-server",
"runtime": "node",
"command": "npx",
"args": ["-y", "firecrawl-mcp"],
"env": { "FIRECRAWL_API_KEY": "" },
"secrets": ["FIRECRAWL_API_KEY"]
},
{
"id": "notion",
"name": "Notion",
"category": "Productivité",
"description": "Lecture et écriture de pages et bases Notion. Nécessite un jeton d'intégration.",
"homepage": "https://github.com/makenotion/notion-mcp-server",
"runtime": "node",
"command": "npx",
"args": ["-y", "@notionhq/notion-mcp-server"],
"env": { "NOTION_TOKEN": "" },
"secrets": ["NOTION_TOKEN"]
},
{
"id": "slack",
"name": "Slack",
"category": "Productivité",
"description": "Lecture des canaux et envoi de messages Slack. Nécessite un jeton de bot.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/slack",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": { "SLACK_BOT_TOKEN": "", "SLACK_TEAM_ID": "" },
"secrets": ["SLACK_BOT_TOKEN"]
},
{
"id": "google-maps",
"name": "Google Maps",
"category": "Utilitaires",
"description": "Géocodage, itinéraires et recherche de lieux. Nécessite une clé d'API.",
"homepage": "https://github.com/modelcontextprotocol/servers-archived/tree/main/src/google-maps",
"runtime": "node",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-google-maps"],
"env": { "GOOGLE_MAPS_API_KEY": "" },
"secrets": ["GOOGLE_MAPS_API_KEY"]
},
{
"id": "sentry",
"name": "Sentry",
"category": "Infra",
"description": "Récupère les issues et traces d'erreur d'un projet Sentry.",
"homepage": "https://pypi.org/project/mcp-server-sentry/",
"runtime": "python",
"command": "uvx",
"args": ["--with", "mcp<2", "mcp-server-sentry", "--auth-token", "REMPLACE_MOI"],
"env": {},
"secrets": []
}
]
}
+118
View File
@@ -0,0 +1,118 @@
package loki
import (
"strings"
"testing"
)
// Le catalogue est embarqué : s'il ne décode pas, le binaire livré est cassé et
// le panneau MCP part vide. Ce test attrape une virgule en trop au build.
func TestMCPCatalogDecodes(t *testing.T) {
entries, err := mcpCatalogEntries()
if err != nil {
t.Fatalf("catalogue illisible : %v", err)
}
if len(entries) == 0 {
t.Fatal("catalogue vide")
}
seen := map[string]bool{}
for _, e := range entries {
if e.ID == "" || e.Name == "" || e.Command == "" {
t.Errorf("entrée incomplète : %+v", e)
}
if seen[e.ID] {
t.Errorf("id dupliqué : %s", e.ID)
}
seen[e.ID] = true
if e.Description == "" {
t.Errorf("%s : description manquante", e.ID)
}
// Un secret annoncé doit exister comme variable d'environnement, sinon
// l'UI met en avant un champ que l'utilisateur ne peut pas remplir.
for _, s := range e.Secrets {
if _, ok := e.Env[s]; !ok {
t.Errorf("%s : secret %q absent de env", e.ID, s)
}
}
}
}
// Les jetons {workspace} doivent disparaître : un serveur lancé avec un chemin
// littéral « {workspace} » démarre puis échoue à la première lecture.
func TestMCPCatalogExpandsWorkspaceToken(t *testing.T) {
entries, err := MCPCatalog()
if err != nil {
t.Fatalf("MCPCatalog : %v", err)
}
for _, e := range entries {
for _, a := range e.Args {
if strings.Contains(a, "{workspace}") {
t.Errorf("%s : jeton non résolu dans args : %s", e.ID, a)
}
}
for k, v := range e.Env {
if strings.Contains(v, "{workspace}") {
t.Errorf("%s : jeton non résolu dans env[%s] : %s", e.ID, k, v)
}
}
}
}
func TestReasoningEffortValue(t *testing.T) {
cases := map[string]string{
"": "",
" ": "",
"auto": "",
"default": "",
"off": "none",
"none": "none",
"false": "none",
"0": "none",
"low": "low",
"MEDIUM": "medium",
" High ": "high",
}
for in, want := range cases {
if got := reasoningEffortValue(in); got != want {
t.Errorf("reasoningEffortValue(%q) = %q, attendu %q", in, got, want)
}
}
}
func TestReasoningExplicitlyOff(t *testing.T) {
// Clé absente ou vide = pas de consigne, surtout pas une interdiction.
for _, v := range []string{"", " ", "on", "auto", "deepseek"} {
if reasoningExplicitlyOff(v) {
t.Errorf("reasoningExplicitlyOff(%q) = true, attendu false", v)
}
}
for _, v := range []string{"off", "none", "0", "false", " Disabled "} {
if !reasoningExplicitlyOff(v) {
t.Errorf("reasoningExplicitlyOff(%q) = false, attendu true", v)
}
}
}
func TestReasoningTemplateKwargs(t *testing.T) {
// « aucune » doit parler aux gabarits hybrides, pas seulement à ceux qui
// lisent reasoning_effort : sinon le modèle réfléchit quand même.
if got := reasoningTemplateKwargs(false, "none"); got == nil || got["enable_thinking"] != false {
t.Errorf("effort none : %v, attendu enable_thinking=false", got)
}
if got := reasoningTemplateKwargs(true, ""); got == nil || got["enable_thinking"] != false {
t.Errorf("REASONING=off : %v, attendu enable_thinking=false", got)
}
// Un niveau explicite voyage aussi dans les kwargs : les binaires anciens ne
// relaient pas le champ de haut niveau au gabarit.
if got := reasoningTemplateKwargs(false, "high"); got == nil || got["reasoning_effort"] != "high" {
t.Errorf("effort high : %v, attendu reasoning_effort=high", got)
}
if _, ok := reasoningTemplateKwargs(false, "high")["enable_thinking"]; ok {
t.Error("un niveau explicite ne doit pas couper le raisonnement")
}
// Auto = aucune consigne : on n'écrit rien dans la requête.
if got := reasoningTemplateKwargs(false, ""); got != nil {
t.Errorf("auto : %v, attendu nil", got)
}
}
+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)
}
+234
View File
@@ -0,0 +1,234 @@
package loki
// mem_index.go — maintien AUTOMATIQUE de l'index MEMORY.md du projet, par le CODE
// (pas par le modèle). À chaque création / suppression / renommage d'une page, on
// ajoute ou retire sa ligne `- [titre](fichier.md)` dans MEMORY.md. L'index
// reflète donc TOUJOURS l'état réel des pages, quel que soit le modèle — un petit
// modèle qui « oublie » de tenir son index ne peut plus le désynchroniser. Le
// modèle garde une seule liberté : enrichir l'accroche après le titre, qu'on ne
// touche jamais.
//
// L'index est ensuite INJECTÉ comme un message système au début de la
// conversation (et après un compactage) plutôt que recopié dans le prompt à
// chaque tour : le modèle sait d'emblée quelles pages existent, sans payer la
// liste à chaque échange ni avoir à lancer une recherche pour la découvrir.
//
// Best-effort partout : si l'index est illisible, on n'échoue pas — il sera
// re-synchronisé au prochain passage (reconcileMemIndex).
import (
"os"
"path/filepath"
"strings"
)
const memIndexFile = "MEMORY.md"
// memIndexPrefix marque le message d'index injecté dans l'historique, pour le
// détecter et éviter les doublons.
const memIndexPrefix = "Project memory index"
// memIndexMessage construit le message système d'index à injecter UNE FOIS au
// début de la conversation (et après un compactage). ok=false hors mode mémoire
// proactif ou si l'index est vide. On n'injecte que l'INDEX (titres + accroches),
// jamais le contenu des pages — l'IA fait mem_read pour lire une page.
func memIndexMessage() (Message, bool) {
if memMode() != MemAlways {
return Message{}, false
}
idx := strings.TrimSpace(MemContent(memIndexFile))
// Un index qui n'a que son en-tête ne dit rien au modèle et coûte du contexte
// à chaque tour : on attend au moins une VRAIE ligne de page. Le test est fait
// sur le début de ligne, parce que l'en-tête cite le format `- [titre](…)` en
// exemple — une simple recherche du motif le prendrait pour une page.
if idx == "" || !hasIndexedPage(idx) {
return Message{}, false
}
content := memIndexPrefix + " for project \"" + projectName(activeProjectSlug()) +
"\" — the pages you can open with mem_read (only titles/hooks here, not their content). Auto-maintained.\n\n" + idx
return Message{Role: "system", Content: content}, true
}
// projectContextPrefix marque le message de contexte projet injecté (description),
// pour le détecter et éviter les doublons.
const projectContextPrefix = "Project context"
// projectContextMessage construit le message système décrivant le projet actif à
// partir de sa description, à injecter UNE FOIS au début de la conversation (et
// après un compactage). ok=false si aucune description : l'IA sait alors juste
// dans quel projet elle est via l'index mémoire, sans laïus. Indépendant du mode
// mémoire — une description reste utile même mémoire coupée.
func projectContextMessage() (Message, bool) {
slug := activeProjectSlug()
desc := strings.TrimSpace(projectDesc(slug))
if desc == "" {
return Message{}, false
}
content := projectContextPrefix + " — you are working within the project \"" +
projectName(slug) + "\". What this project is about (set by the user):\n\n" + desc
return Message{Role: "system", Content: content}, true
}
// projectSystemMessages renvoie, dans l'ordre, tout ce qui situe l'IA dans son
// projet : description du projet, index mémoire, index des trackers.
//
// Ces messages sont injectés dans la vue ENVOYÉE au modèle, jamais persistés dans
// c.Messages — même traitement que le prompt système personnalisé (voir
// Conversation.generate). Deux conséquences voulues :
// - l'index est TOUJOURS à jour : une page créée au milieu d'une conversation y
// apparaît au tour suivant, sans attendre un compactage ;
// - le compactage ne peut pas les avaler, puisqu'ils ne font pas partie de
// l'historique qu'il résume — donc rien à réinjecter après coup.
//
// Le prix est une invalidation du cache de prompt quand l'index change, c'est-à-
// dire quand une page est créée ou supprimée : exactement les moments où le
// modèle DOIT voir la nouvelle liste.
func projectSystemMessages() []Message {
var out []Message
if m, ok := projectContextMessage(); ok {
out = append(out, m)
}
if m, ok := memIndexMessage(); ok {
out = append(out, m)
}
if m, ok := trackerIndexMessage(); ok {
out = append(out, m)
}
return out
}
// hasIndexedPage dit si l'index contient au moins une ligne de page réelle
// (« - [titre](fichier.md) » en début de ligne).
func hasIndexedPage(idx string) bool {
for _, line := range strings.Split(idx, "\n") {
l := strings.TrimSpace(line)
if strings.HasPrefix(l, "- [") && strings.Contains(l, "](") {
return true
}
}
return false
}
// isIndexFile indique si `name` est le fichier d'index (à ne jamais indexer
// lui-même : il se listerait en boucle).
func isIndexFile(name string) bool {
return strings.EqualFold(strings.TrimSpace(name), memIndexFile)
}
// indexLineFor construit la ligne d'index d'une page : `- [titre](fichier.md)`.
// Le titre vient de la 1re ligne de la page, à défaut le nom du fichier.
func indexLineFor(name string) string {
title := name
if t := titleOf(MemContent(name)); t != "" {
title = t
}
return "- [" + title + "](" + name + ")"
}
// indexRefFor est le motif qui identifie la ligne d'une page dans l'index :
// `](fichier.md)`. Le nom complet ET la parenthèse fermante évitent qu'un nom en
// matche un autre dont il serait le suffixe (`notes.md` vs `mes-notes.md`).
func indexRefFor(name string) string { return "](" + name + ")" }
// writeIndex écrit le contenu de l'index. Passe par le disque directement plutôt
// que par MemSave : MemSave normalise et pourrait un jour se mettre à indexer,
// ce qui bouclerait.
func writeIndex(body string) {
dir := memoryDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return
}
_ = os.WriteFile(filepath.Join(dir, memIndexFile), []byte(body), 0o644)
}
// memIndexAdd ajoute la ligne d'une page à MEMORY.md si elle n'y est pas déjà. Si
// une ligne pour ce fichier existe (l'IA a pu y écrire une accroche), on la LAISSE
// telle quelle : l'index appartient au code pour sa structure, au modèle pour sa
// prose.
func memIndexAdd(name string) {
fn, err := memFileName(name)
if err != nil || isIndexFile(fn) {
return
}
ensureIndexSeed()
content := MemContent(memIndexFile)
ref := indexRefFor(fn)
for _, line := range strings.Split(content, "\n") {
if strings.Contains(line, ref) {
return // déjà indexée
}
}
writeIndex(strings.TrimRight(content, "\n") + "\n" + indexLineFor(fn) + "\n")
}
// memIndexRemove retire la (les) ligne(s) d'une page de MEMORY.md.
func memIndexRemove(name string) {
fn, err := memFileName(name)
if err != nil || isIndexFile(fn) {
return
}
content := MemContent(memIndexFile)
if content == "" {
return
}
ref := indexRefFor(fn)
lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
changed := false
for _, line := range lines {
if strings.Contains(line, ref) {
changed = true
continue
}
out = append(out, line)
}
if !changed {
return
}
writeIndex(strings.TrimRight(strings.Join(out, "\n"), "\n") + "\n")
}
// memIndexRename retire l'ancienne ligne et ajoute la nouvelle.
func memIndexRename(oldName, newName string) {
if oldName != "" {
memIndexRemove(oldName)
}
memIndexAdd(newName)
}
// reconcileMemIndex complète l'index du projet ACTIF : pour chaque page existante
// non référencée dans MEMORY.md, il ajoute sa ligne. ADDITIF — il ne touche ni aux
// lignes ni aux accroches déjà présentes. Indispensable après la migration vers
// les projets : les pages déplacées à la main sur le disque n'ont jamais été
// indexées, puisque l'index n'est alimenté que par la création d'une page.
func reconcileMemIndex() {
pages := MemList()
if len(pages) == 0 {
return
}
ensureIndexSeed()
content := MemContent(memIndexFile)
var add []string
for _, p := range pages {
if isIndexFile(p.Name) {
continue
}
if strings.Contains(content, indexRefFor(p.Name)) {
continue // déjà indexée
}
add = append(add, indexLineFor(p.Name))
}
if len(add) == 0 {
return
}
writeIndex(strings.TrimRight(content, "\n") + "\n" + strings.Join(add, "\n") + "\n")
}
// ensureIndexSeed crée MEMORY.md (avec son en-tête) s'il n'existe pas encore, pour
// que l'ajout d'une première ligne ait un fichier où s'écrire.
func ensureIndexSeed() {
if strings.TrimSpace(MemContent(memIndexFile)) != "" {
return
}
writeIndex(memorySeed(projectName(activeProjectSlug())))
}
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more